diff --git a/docs/backlog/P1/B-0441-backlog-row-ready-to-grind-notifier-background-service-2026-05-13.md b/docs/backlog/P1/B-0441-backlog-row-ready-to-grind-notifier-background-service-2026-05-13.md index c5be17039..db5ca8a95 100644 --- a/docs/backlog/P1/B-0441-backlog-row-ready-to-grind-notifier-background-service-2026-05-13.md +++ b/docs/backlog/P1/B-0441-backlog-row-ready-to-grind-notifier-background-service-2026-05-13.md @@ -167,7 +167,7 @@ Using the canonical per-service slice ordering from `tools/bg/README.md`: |-------|-------------|--------|-----------| | 1 | Skeleton + no-op poll loop | ✅ shipped | — | | 2 | Real detection signal #1 (backlog-row scan: status + deps satisfied) | ✅ shipped | — | -| 3 | Queue-state guard wiring (`isAgentQueueEmpty` into `pollOnce`) | ❌ open | B-0500 | +| 3 | Queue-state guard wiring (`isAgentQueueEmpty` into `pollOnce`) | ✅ shipped | B-0500 | | 4 | Bus-publish wiring (`work-assignment` topic) | ✅ shipped | — | | 5a | Assignment history dedup / cooldown (avoid re-assigning same row) | ❌ open | B-0501 | | 5.2 | Agent-side `work-assignment` subscriber handler (consume + act) | ❌ open | B-0460 | diff --git a/docs/backlog/P1/B-0500-b0441-slice-3-queue-state-guard-poll-once-wiring-2026-05-14.md b/docs/backlog/P1/B-0500-b0441-slice-3-queue-state-guard-poll-once-wiring-2026-05-14.md index 55a831e01..9f781ba00 100644 --- a/docs/backlog/P1/B-0500-b0441-slice-3-queue-state-guard-poll-once-wiring-2026-05-14.md +++ b/docs/backlog/P1/B-0500-b0441-slice-3-queue-state-guard-poll-once-wiring-2026-05-14.md @@ -1,7 +1,7 @@ --- id: B-0500 priority: P1 -status: open +status: closed title: "B-0441 slice 3 — wire isAgentQueueEmpty guard into pollOnce" tier: factory-infrastructure effort: XS @@ -34,7 +34,7 @@ work-assignment envelopes on every poll cycle. ## Acceptance criteria -- [ ] `pollOnce` consults `isAgentQueueEmpty(config.targetAgent, adapters)` before +- [x] `pollOnce` consults `isAgentQueueEmpty(config.targetAgent, adapters)` before publishing any work-assignment envelopes - When queue is NOT empty → skip publish; include `"queueBusy: true"` in the `PollResult` note field; return early (no envelopes published) @@ -42,14 +42,14 @@ work-assignment envelopes on every poll cycle. - Conservative default: adapter failures (`execGitLog → null`, `execGhPrList → null`) are treated as queue BUSY (do not trigger assignment) — matches the existing `isAgentQueueEmpty` behavior -- [ ] `NotifierConfig` gains a `targetAgent` field (default `"otto"`); `parseArgs` +- [x] `NotifierConfig` gains a `targetAgent` field (default `"otto"`); `parseArgs` wires `--target-agent ` flag (accepts any string; not restricted to `SENDER_IDS` because the agent patterns map is the actual lookup) -- [ ] `PollResult` gains a `queueBusy: boolean` field; `pollOnce` populates it -- [ ] Adapters interface unchanged (already includes `execGitLog` + `execGhPrList` +- [x] `PollResult` gains a `queueBusy: boolean` field; `pollOnce` populates it +- [x] Adapters interface unchanged (already includes `execGitLog` + `execGhPrList` + `agentPatterns` — exactly what `isAgentQueueEmpty` needs) -- [ ] Existing tests updated to pass `targetAgent` where `DEFAULT_CONFIG` is used -- [ ] New tests added: +- [x] Existing tests updated to pass `targetAgent` where `DEFAULT_CONFIG` is used +- [x] New tests added: - `pollOnce` with queue-busy adapters → `publishedEnvelopeIds` empty, `queueBusy: true`, no `publishAssignment` calls - `pollOnce` with queue-empty adapters AND ready rows → envelopes published, @@ -91,7 +91,7 @@ B-0441 (slices 1+2+4 shipped — backlog-ready-notifier.ts functional) ## Pre-start checklist (per backlog-item-start-gate) -- [ ] Verify `isAgentQueueEmpty` signature in `backlog-ready-notifier.ts` before writing -- [ ] Run `bun tools/bg/backlog-ready-notifier.test.ts` to confirm all existing tests pass +- [x] Verify `isAgentQueueEmpty` signature in `backlog-ready-notifier.ts` before writing +- [x] Run `bun tools/bg/backlog-ready-notifier.test.ts` to confirm all existing tests pass before adding new ones -- [ ] Verify `PollResult` type is exported (it is — used in test file) +- [x] Verify `PollResult` type is exported (it is — used in test file) diff --git a/tools/bg/backlog-ready-notifier.test.ts b/tools/bg/backlog-ready-notifier.test.ts index fb506a902..b072e6dc9 100644 --- a/tools/bg/backlog-ready-notifier.test.ts +++ b/tools/bg/backlog-ready-notifier.test.ts @@ -344,7 +344,7 @@ title: only a title }); test("runOnce returns a single result without daemon mode", () => { - const result = runOnce({ ...DEFAULT_CONFIG, backlogDir: "/nonexistent" }); + const result = runOnce({ ...DEFAULT_CONFIG, backlogDir: "/nonexistent" }, fakeAdapters("2026-05-13T18:00:00Z", [])); expect(result.pollAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // /nonexistent has no P*/ dirs so should report 0 rows expect(result.totalOpenRows).toBe(0); @@ -425,6 +425,33 @@ title: only a title expect(result.publishedEnvelopeIds).toHaveLength(3); expect(captured).toHaveLength(3); }); + + test("pollOnce with queue-busy adapters → queueBusy: true, no publish", () => { + const captured: FakeAssignmentCall[] = []; + // "testagent" has commit pattern "testagent". Set git log to contain it. + const adapters = fakeAdapters("2026-05-13T18:00:00Z", [ROW_OPEN_NO_DEPS], captured, "commit by testagent"); + const config = { ...DEFAULT_CONFIG, targetAgent: "testagent" }; + + const result = pollOnce(config, adapters); + + expect(result.queueBusy).toBe(true); + expect(result.publishedEnvelopeIds).toHaveLength(0); + expect(captured).toHaveLength(0); + expect(result.note).toContain("queue busy for testagent"); + }); + + test("pollOnce with queue-empty adapters AND ready rows → queueBusy: false, publishes", () => { + const captured: FakeAssignmentCall[] = []; + // clean git log and prs + const adapters = fakeAdapters("2026-05-13T18:00:00Z", [ROW_OPEN_NO_DEPS], captured, "", ""); + const config = { ...DEFAULT_CONFIG, targetAgent: "testagent" }; + + const result = pollOnce(config, adapters); + + expect(result.queueBusy).toBe(false); + expect(result.publishedEnvelopeIds).toHaveLength(1); + expect(captured).toHaveLength(1); + }); }); describe("parseArgs", () => { @@ -442,17 +469,19 @@ title: only a title expect(config.backlogDir).toBe("/custom"); }); - test("--no-publish + --agent + --to + --max-assignments", () => { + test("--no-publish + --agent + --to + --max-assignments + --target-agent", () => { const config = parseArgs([ "--no-publish", "--agent", "vera", "--to", "lior", "--max-assignments", "5", + "--target-agent", "riven", ]); expect(config.noPublish).toBe(true); expect(config.fromAgent).toBe("vera"); expect(config.toAgent).toBe("lior"); expect(config.maxAssignments).toBe(5); + expect(config.targetAgent).toBe("riven"); }); test("rejects unknown flags", () => { diff --git a/tools/bg/backlog-ready-notifier.ts b/tools/bg/backlog-ready-notifier.ts index d7b904289..3c1888a0b 100644 --- a/tools/bg/backlog-ready-notifier.ts +++ b/tools/bg/backlog-ready-notifier.ts @@ -32,6 +32,8 @@ export type NotifierConfig = { toAgent: AgentId; /** Max number of work-assignment envelopes to publish per poll */ maxAssignments: number; + /** Agent whose queue state is checked before assignment */ + targetAgent: string; }; export const DEFAULT_CONFIG: NotifierConfig = { @@ -42,6 +44,7 @@ export const DEFAULT_CONFIG: NotifierConfig = { fromAgent: "otto", toAgent: "*", maxAssignments: 3, + targetAgent: "otto", }; export type BacklogRow = { @@ -60,6 +63,7 @@ export type PollResult = { publishedEnvelopeIds: string[]; /** Structured publish-failure reason; null on success or skip. */ lastPublishError: string | null; + queueBusy: boolean; note: string; }; @@ -246,6 +250,8 @@ export function pollOnce( adapters: Adapters = REAL_ADAPTERS, ): PollResult { const pollAt = adapters.now(); + const busy = !isAgentQueueEmpty(config.targetAgent, adapters); + const allRows = adapters.scanBacklog(config.backlogDir); const openRows = allRows.filter(r => r.status === "open"); const idToStatus = new Map(allRows.map(r => [r.id, r.status])); @@ -261,6 +267,19 @@ export function pollOnce( r.dependsOn.every(dep => isDepSatisfied(idToStatus.get(dep))), ); + if (busy) { + return { + pollAt: pollAt.toISOString(), + totalOpenRows: openRows.length, + readyRowsFound: readyRows.length, + candidateIds: readyRows.slice(0, 10).map(r => r.id), + publishedEnvelopeIds: [], + lastPublishError: null, + queueBusy: true, + note: `queue busy for ${config.targetAgent} — skip publish`, + }; + } + const publishedEnvelopeIds: string[] = []; let lastPublishError: string | null = null; if (!config.noPublish && readyRows.length > 0) { @@ -305,6 +324,7 @@ export function pollOnce( candidateIds: readyRows.slice(0, 10).map(r => r.id), publishedEnvelopeIds, lastPublishError, + queueBusy: false, note: readyRows.length > 0 ? `${readyRows.length} of ${openRows.length} open rows are ready-to-grind; top candidates: ${readyRows.slice(0, 5).map(r => r.id).join(", ")}${publishNote}${danglingNote}` : `${openRows.length} open rows but none ready${danglingNote}`, @@ -312,15 +332,18 @@ export function pollOnce( } /** Run a single poll iteration and return its result. */ -export function runOnce(config: NotifierConfig = DEFAULT_CONFIG): PollResult { - const result = pollOnce(config); +export function runOnce( + config: NotifierConfig = DEFAULT_CONFIG, + adapters: Adapters = REAL_ADAPTERS, +): PollResult { + const result = pollOnce(config, adapters); console.log(JSON.stringify(result)); return result; } -export async function runDaemon(config: NotifierConfig = DEFAULT_CONFIG): Promise { +export async function runDaemon(config: NotifierConfig = DEFAULT_CONFIG, adapters: Adapters = REAL_ADAPTERS): Promise { while (true) { - runOnce(config); + runOnce(config, adapters); await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000)); } } @@ -363,6 +386,7 @@ const KNOWN_FLAGS = [ "--agent", "--to", "--max-assignments", + "--target-agent", ] as const; export function parseArgs(argv: string[]): NotifierConfig { @@ -386,6 +410,10 @@ export function parseArgs(argv: string[]): NotifierConfig { config.toAgent = parseAgentId(argv[++i]); } else if (arg === "--max-assignments") { config.maxAssignments = parsePositiveInt(argv[++i], "--max-assignments"); + } else if (arg === "--target-agent") { + const next = argv[++i]; + if (next === undefined) throw new Error("--target-agent requires a value"); + config.targetAgent = next; } else { throw new Error(`unknown flag: ${arg}; known flags: ${KNOWN_FLAGS.join(", ")}`); }