Public: anyone can read this; the crew can change it.

Sentinel: queue:agent was degraded 18 minutes today: The agent queue has waited over 30 minutes: 14 queued, 2 of 2 slots running. #11522

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11518-sentinel-queue-agent-was-degraded-18-min → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/11518-sentinel-queue-agent-was-degraded-18-min was merged into main as 9e8c29c.

Review

Sign in to review this change.
2 files changed +68 −9
src/crew/dispatch.ts +11 −9
@@ -1591,7 +1591,11 @@ export function pump(
15911591 noted.add(shut.id);
15921592 appendRunLog(app.config.dataDir, shut.id, "queued, not started: its repository's valve is shut (/flow)");
15931593 }
1594 for (const [at, queued] of gated.order.entries()) {
1594 // One week read per repository per pass: past the slots the loop still
1595 // reads every queued run, to say which the pace or a limit holds (#11518).
1596 const weeks = new Map<string, ReturnType<typeof weekFor>>();
1597 const floor = paceFloor(app.db);
1598 for (const queued of gated.order) {
15951599 // One run per issue at a time: this one waits for its sibling, not for a slot.
15961600 // A sibling that hangs is still caught: the running run goes stale on its
15971601 // heartbeat or past NO_PROGRESS_MS and vitals names it under `stuck:`.
@@ -1613,13 +1617,6 @@ export function pump(
16131617 }
16141618 continue;
16151619 }
1616 if (slots <= 0) {
1617 // Out of slots: nothing more starts, but the runs behind this one that
1618 // wait on a running sibling are still held by that, not by the slots.
1619 for (const rest of gated.order.slice(at + 1))
1620 if (rest.issueId !== null && runningIssues.has(rest.issueId)) held.set(rest.id, "issue");
1621 break;
1622 }
16231620 // The week's pace holds every path that starts a run in an autonomous
16241621 // repository: assigned, review, feedback and check runs as much as picks (#8873).
16251622 if (home !== undefined && home.autonomy === "autonomous") {
@@ -1627,7 +1624,8 @@ export function pump(
16271624 const priorityGoing =
16281625 (priorityIn.get(home.id) ?? 0) +
16291626 active.filter((r) => r.state === "running" && r.repoId === home.id && isPriorityRun(app, r, priorityMemo)).length;
1630 const paced = weekHold(weekFor(app, home, new Date()), home.autonomySlots, going - priorityGoing, isPriorityRun(app, fresh, priorityMemo) ? fresh.reason : "pick", priorityGoing, paceFloor(app.db));
1627 if (!weeks.has(home.id)) weeks.set(home.id, weekFor(app, home, new Date()));
1628 const paced = weekHold(weeks.get(home.id)!, home.autonomySlots, going - priorityGoing, isPriorityRun(app, fresh, priorityMemo) ? fresh.reason : "pick", priorityGoing, floor);
16311629 if (paced !== null) {
16321630 held.set(fresh.id, "pace");
16331631 if (!noted.has(fresh.id)) {
@@ -1654,6 +1652,10 @@ export function pump(
16541652 }
16551653 continue;
16561654 }
1655 // Out of slots: nothing more starts. The runs past this point are still
1656 // read, so those a policy above holds are named for vitals, and only the
1657 // rest count as waiting for a slot (#11518).
1658 if (slots <= 0) continue;

Nit: the slots <= 0 check now runs after the pace and limit checks. Passes with a long queue will therefore append the pace and limit notes to the run log of every held run behind the slots, once each (they are deduplicated through noted). That is intended. Consider a test that asserts a pace-held run past the slots is named pace, since only the limit path is covered.

16571659 started.push(fresh.id);
16581660 goingIn.set(fresh.repoId, (goingIn.get(fresh.repoId) ?? 0) + 1);
16591661 if (isPriorityRun(app, fresh, priorityMemo)) priorityIn.set(fresh.repoId, (priorityIn.get(fresh.repoId) ?? 0) + 1);
test/vitals-held-agent.test.ts +57
@@ -11,6 +11,7 @@ import { createUser } from "../src/core/users.ts";
1111 import { startCrew } from "../src/crew/dispatch.ts";
1212 import { turnValve } from "../src/flow/valve_store.ts";
1313 import { serve } from "../src/http/server.ts";
14 import { setQuota } from "../src/settings/quotas.ts";
1415 import { HELD_FRESH_MS, heldRuns, noteHeld } from "../src/runs/held.ts";
1516 import { QUEUE_WAIT_MAX_MS, vitals } from "../src/kernel/vitals.ts";
1617
@@ -188,3 +189,59 @@ test("with the real dispatcher, a run queued behind its issue's running run is h
188189 servers.stop();
189190 }
190191 });
192
193 test("with the real dispatcher and every slot taken, a run an account limit holds is still named, not counted as waiting for a slot (#11518)", async () => {
194 app.db.query("DELETE FROM run").run();
195 const servers = await serve(app);
196 const fake = join(dataDir, "fake-claude-3");
197 writeFileSync(fake, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi\nexit 1\n`);
198 chmodSync(fake, 0o755);
199 app.db.query("DELETE FROM flow_valve WHERE repo_id = ?1").run(repoId);
200 // Another account's repository, with no limit: its run waits for the slot.
201 const other = await createUser(app.db, {
202 origin: "seed",
203 trusted: true,
204 username: "slotwaiter",
205 email: "[email protected]",
206 password: "a-good-passphrase",
207 });
208 if (!other.ok) throw new Error(String(other.error));
209 const otherRepo = crypto.randomUUID();
210 app.db
211 .query("INSERT INTO repo (id, owner_id, name, visibility, created_at) VALUES (?1, ?2, 'waits', 'private', ?3)")
212 .run(otherRepo, other.value.id, new Date().toISOString());
213 // The holder's account may run one at a time, and one runs: it takes the only slot.
214 setQuota(app.db, "runs", 1, ownerId);
215 const running = createRun(app.db, { repoId, kind: "agent" });
216 app.db
217 .query("UPDATE run SET state = 'running', started_at = ?1, heartbeat_at = ?1 WHERE id = ?2")
218 .run(new Date().toISOString(), running.id);
219 const ahead = createRun(app.db, { repoId: otherRepo, kind: "agent" });
220 const limited = createRun(app.db, { repoId, kind: "agent" });
221 const earlier = new Date(Date.now() - 3 * QUEUE_WAIT_MAX_MS).toISOString();
222 const hourAgo = new Date(Date.now() - 2 * QUEUE_WAIT_MAX_MS).toISOString();
223 app.db.query("UPDATE run SET created_at = ?1 WHERE id = ?2").run(earlier, ahead.id);
224 app.db.query("UPDATE run SET created_at = ?1 WHERE id = ?2").run(hourAgo, limited.id);
225 const crew = await startCrew(app, {
226 forgeUrl: servers.url,
227 localUrl: servers.url,
228 binary: fake,
229 concurrency: 1,
230 budgetUsd: 1,
231 runTimeoutMs: 60_000,
232 tickMs: 600_000,
233 });
234 try {
235 // The pass stops starting at `ahead` (no slot), and still reads `limited` past it.
236 expect(heldRuns(app.db).get(ahead.id)).toBeUndefined();
237 expect(heldRuns(app.db).get(limited.id)).toBe("limit");
238 await crew.stop();
239 const sentence = agentQueue(Date.now())?.sentence ?? "";
240 expect(sentence).toContain("1 queued, 1 of 1 slots running");
241 expect(sentence).toContain("held on purpose: 1 by an account limit.");
242 } finally {
243 setQuota(app.db, "runs", null, ownerId);
244 await crew.stop();
245 servers.stop();
246 }
247 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs