Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -34,22 +34,22 @@ 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)
- When queue IS empty (or unknown agent) → proceed with current publish logic
- 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 <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,
Expand Down Expand Up @@ -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)
33 changes: 31 additions & 2 deletions tools/bg/backlog-ready-notifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
36 changes: 32 additions & 4 deletions tools/bg/backlog-ready-notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -42,6 +44,7 @@ export const DEFAULT_CONFIG: NotifierConfig = {
fromAgent: "otto",
toAgent: "*",
maxAssignments: 3,
targetAgent: "otto",
};

export type BacklogRow = {
Expand All @@ -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;
};

Expand Down Expand Up @@ -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]));
Expand All @@ -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) {
Expand Down Expand Up @@ -305,22 +324,26 @@ 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}`,
};
}

/** 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<never> {
export async function runDaemon(config: NotifierConfig = DEFAULT_CONFIG, adapters: Adapters = REAL_ADAPTERS): Promise<never> {
while (true) {
runOnce(config);
runOnce(config, adapters);
await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000));
}
}
Expand Down Expand Up @@ -363,6 +386,7 @@ const KNOWN_FLAGS = [
"--agent",
"--to",
"--max-assignments",
"--target-agent",
] as const;

export function parseArgs(argv: string[]): NotifierConfig {
Expand All @@ -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(", ")}`);
}
Expand Down
Loading