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 @@ -6,9 +6,10 @@ title: "Backlog-row-ready-to-grind notifier — background service that proactiv
tier: factory-infrastructure
effort: M
created: 2026-05-13
last_updated: 2026-05-13
last_updated: 2026-05-14
depends_on: [B-0400]
composes_with: [B-0402, B-0440, B-0442]
children: [B-0500, B-0501, B-0502, B-0460]
tags: [multi-agent, background-service, bus, mechanization, infinite-backlog, work-assignment]
type: feature
---
Expand Down Expand Up @@ -141,11 +142,14 @@ form a two-layer defense against the Standing-by pattern.

## Pre-start checklist

- [ ] Prior-art search: existing audit scripts in `tools/hygiene/`
(check for backlog-readiness-scan overlap)
- [ ] Dependency proof: B-0400 bus protocol slice ready
- [ ] Verify readiness-detection heuristics handle edge cases
(forked work, multi-agent claims, partial completion)
- [x] Prior-art search: existing audit scripts in `tools/hygiene/`
(check for backlog-readiness-scan overlap) — no overlap found; `backlog-ready-notifier.ts`
is distinct from the hygiene audit scripts
- [x] Dependency proof: B-0400 bus protocol slice ready — B-0400 status: closed (2026-05-13)
- [x] Verify readiness-detection heuristics handle edge cases
(forked work, multi-agent claims, partial completion) — handled in existing tests;
`isAgentQueueEmpty` conservative-busy on adapter failure covers edge cases
- [x] Decomposition: child rows created (B-0500, B-0501, B-0502, B-0460) — 2026-05-14

## Substrate-honest caveats

Expand All @@ -155,13 +159,19 @@ form a two-layer defense against the Standing-by pattern.
- Agent autonomy must be preserved — service publishes, agent decides
- Per razor-discipline: claim is design-level

## Decomposition into implementation slices (TBD)
## Decomposition into implementation slices

When picked up for implementation:
Using the canonical per-service slice ordering from `tools/bg/README.md`:

- Slice 1: backlog row parsing + readiness detection
- Slice 2: agent queue-state detection (commits + PRs)
- Slice 3: assignment payload computation
- Slice 4: bus integration
- Slice 5: assignment history tracking
- Slice 6: tests + documentation
| Slice | Description | Status | Child row |
|-------|-------------|--------|-----------|
| 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 |
| 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 |
| 6 | launchd plist + `docs/AUTONOMOUS-LOOP.md` wiring | ❌ open | B-0502 |

Slices 1, 2, 4 are live in `tools/bg/backlog-ready-notifier.ts` (per README "1+2+4 live").
B-0460 depends on B-0449 (subscriber library design pass); B-0500/B-0501/B-0502 are independent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
id: B-0460
priority: P1
status: open
title: "B-0441 slice 5.2 — work-assignment subscriber handler (agent-side claim-and-act)"
tier: factory-infrastructure
effort: S
created: 2026-05-14
last_updated: 2026-05-14
parent: B-0441
depends_on: [B-0449]
composes_with: [B-0441, B-0449, B-0459, B-0461]
tags: [multi-agent, background-service, bus, subscriber-pattern, anti-idle, work-assignment]
type: feature
---

# B-0441 slice 5.2 — `work-assignment` subscriber handler

## Origin

B-0449 (bg-services subscriber-agent architecture design pass) explicitly reserved this ID:

> "Once Option C is adopted (or alternative chosen), three follow-up rows track
> per-topic handler implementation:
>
> - B-0459 — `infinite-backlog-nudge` handler (slice 5.1)
> - **B-0460 — `work-assignment` handler (slice 5.2)**
> - B-0461 — `missed-substrate-cascade` handler (slice 5.3)"

This row is the implementation of that design. B-0459 (slice 5.1) is the sibling for
the `infinite-backlog-nudge` topic; B-0461 (slice 5.3) is the sibling for
`missed-substrate-cascade`.

## Context: what B-0441's notifier produces

`tools/bg/backlog-ready-notifier.ts` (B-0441, slices 1+2+4 live) publishes:

```json
{
"topic": "work-assignment",
"to": "*",
"payload": {
"rowId": "B-NNNN",
"priority": "P1",
"rationale": "Ready-to-grind: B-NNNN is open with all deps satisfied. ..."
}
}
```

This slice implements the per-tick handler that reads and acts on that envelope.

## Acceptance criteria

- [ ] B-0449 has landed `tools/bus/subscribe.ts` exporting `subscribeOnce(topic, handler)`
(this row blocks until B-0449 is merged — see dependency chain)
- [ ] Per-tick handler for `work-assignment` topic (Option C architecture per B-0449):
- Reads each matching envelope from the bus dir (honors `ZETA_BUS_DIR`)
- Logs envelope content (topic, rowId, priority, rationale) to the current tick shard
- Marks envelope as consumed via `seen.json` per `subscribeOnce` contract
- **Action stub** (minimum for slice 5.2): reads `rowId` from payload and queues it
as speculative-work candidate for step 3 of the same tick
(per B-0449 Option C: subscriber wires into step 1 and queues work into step 3)
- Optional AC: invokes `bun tools/bus/claim.ts acquire --from <surface> --item <rowId>`
to claim the row proactively (only when the claim exits 0; skip on conflict)
- [ ] `docs/AUTONOMOUS-LOOP-PER-TICK.md` step 1 (refresh) updated to call
`subscribeOnce("work-assignment", workAssignmentHandler)` alongside the
`infinite-backlog-nudge` subscriber call added by B-0459
- [ ] Unit tests (DST-replayable with fake bus dir + injected envelopes):
- Work-assignment envelope present → logged, consumed, no error,
`rowId` surfaced as speculative-work candidate
- No envelope → no-op, no error
- Malformed envelope (missing `rowId`) → logged as warning, consumed, no throw
- Claim-acquire Optional AC: when claim exits 0 → `acquire` was called with correct
`--item` value
- [ ] `tools/bg/README.md` §"What's still pending" updated: slice 5.2 stub landed

## Scope (what is NOT in scope)

Per B-0449 Option C, the **stub** behavior is: log + consume + queue into step 3.
A "full" implementation that autonomously opens PRs or performs multi-step
implementation work is a later slice (slice 5.2+). The goal here is to close the loop:
agent tick reads work-assignment → registers it as "I should look at this" → backlog-
item-start-gate discipline applies from that point forward.

## Dependency chain

```
B-0400 (bus protocol — closed)
└─ B-0449 (subscribe-once library + step-1 wiring design)
└─ B-0459 (slice 5.1 — infinite-backlog-nudge handler)
└─ B-0460 (THIS ROW — work-assignment handler; slice 5.2)
```

B-0460 is a sibling of B-0459, not a sequential dependency. Both depend on B-0449;
either can land first once B-0449 merges.

## Composes with

- B-0400 (bus protocol — envelope schema + `ZETA_BUS_DIR` convention)
- B-0441 (backlog-ready-notifier — produces the work-assignment envelopes)
- B-0449 (subscribe-once library — transport this handler uses)
- B-0459 (slice 5.1 — sibling handler for `infinite-backlog-nudge`)
- B-0461 (slice 5.3 — sibling handler for `missed-substrate-cascade`)
- `docs/AUTONOMOUS-LOOP-PER-TICK.md` (step 1 is where the subscriber call lands)
- `.claude/rules/claim-acquire-before-worktree-work.md` (Optional AC uses claim.ts)

## Pre-start checklist (per backlog-item-start-gate)

- [ ] Verify B-0449 is merged: `grep -q "^status: closed" docs/backlog/P1/B-0449-*.md`
- [ ] Verify `tools/bus/subscribe.ts` exists and exports `subscribeOnce`
- [ ] Read B-0459 implementation as the canonical sibling reference before writing
- [ ] Check `docs/AUTONOMOUS-LOOP-PER-TICK.md` step 1 current text to know exact
insertion point for the new `subscribeOnce("work-assignment", ...)` call
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
id: B-0500
priority: P1
status: open
title: "B-0441 slice 3 — wire isAgentQueueEmpty guard into pollOnce"
tier: factory-infrastructure
effort: XS
created: 2026-05-14
last_updated: 2026-05-14
parent: B-0441
depends_on: []
composes_with: [B-0441, B-0501, B-0502]
tags: [background-service, queue-state, bus, mechanization, anti-idle]
type: feature
---

# B-0441 slice 3 — queue-state guard wiring into `pollOnce`

## Origin

`tools/bg/backlog-ready-notifier.ts` already contains `isAgentQueueEmpty` (slices 1+2+4
landed; per `tools/bg/README.md`: "1+2+4 live"). However `pollOnce` publishes work-assignment
envelopes **unconditionally** whenever ready rows exist — the queue-state guard is never
consulted. The file header comment acknowledges this explicitly:

> "Queue-state detection (only assign when an agent's queue is empty) is slice 5;
> slice 4 publishes unconditionally when ready rows exist so the reactive loop is
> closed end-to-end."

The comment labels this as "slice 5" using B-0441's original design-sketch numbering;
the README's canonical per-service ordering calls it "slice 3" (second detection signal).
Either label — the gap is real: an agent actively grinding a branch will still receive
work-assignment envelopes on every poll cycle.

## Acceptance criteria

- [ ] `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`
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`
+ `agentPatterns` — exactly what `isAgentQueueEmpty` needs)
- [ ] Existing tests updated to pass `targetAgent` where `DEFAULT_CONFIG` is used
- [ ] 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,
`queueBusy: false`
- `--target-agent` parsed correctly by `parseArgs`

## Design sketch

```typescript
// In pollOnce, before the publish block:
const busy = !isAgentQueueEmpty(config.targetAgent, adapters);
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`,
};
}
// existing publish block continues...
```

## Why XS effort

The function `isAgentQueueEmpty` is already written and tested. This slice is wiring
one conditional check + adding one config field + one result field + a small set of
tests. Net change: ~30 lines of implementation + ~40 lines of tests.

## Dependency chain

```
B-0441 (slices 1+2+4 shipped — backlog-ready-notifier.ts functional)
└─ B-0500 (THIS ROW — wire queue-state guard; slice 3)
```

## 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
before adding new ones
- [ ] Verify `PollResult` type is exported (it is — used in test file)
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
id: B-0501
priority: P1
status: open
title: "B-0441 slice 5 — assignment history dedup cooldown (avoid re-assigning same row within short window)"
tier: factory-infrastructure
effort: S
created: 2026-05-14
last_updated: 2026-05-14
parent: B-0441
depends_on: []
composes_with: [B-0441, B-0500, B-0502]
tags: [background-service, bus, mechanization, anti-idle, history-tracking]
type: feature
---

# B-0441 slice 5 — assignment history dedup / cooldown

## Origin

B-0441 acceptance criterion:
> "Tracks assignment history to avoid re-assigning same row within short window"

The current `pollOnce` publishes the same top-N ready rows on every poll cycle with no
memory of prior assignments. An idle agent will receive the same `work-assignment` envelope
for `B-NNNN` every 10 minutes until either the agent claims it or the agent's queue becomes
busy. This produces noisy bus output and makes the assignment signal less meaningful.

## Acceptance criteria

- [ ] `NotifierConfig` gains a `historyFile` field (default
`"/tmp/zeta-bus/assignment-history.json"`; respects `ZETA_BUS_DIR` if set)
and a `cooldownMin` field (default `30`)
- [ ] Before publishing a work-assignment envelope for a given `rowId`, check the
history file:
- If `rowId` appears in the history with a timestamp within `cooldownMin` minutes
of `now()` → skip that row (do not publish)
- If absent or expired → publish and record `{ rowId, publishedAt: now().toISOString() }`
- [ ] After publishing, write the updated history back to `historyFile`:
- Prune entries older than `cooldownMin` before writing to bound file size
- Use atomic write (write to `<historyFile>.tmp` then rename) to survive concurrent
access from multiple notifier instances
- [ ] `PollResult` gains a `skippedDueToCooldown: string[]` field listing any `rowId`s
that were skipped because of cooldown
- [ ] Adapters interface gains:
- `readHistoryFile: (path: string) => AssignmentHistory | null`
(returns null when file absent or unreadable)
- `writeHistoryFile: (path: string, history: AssignmentHistory) => void`
- Tests inject fake implementations; production uses `REAL_ADAPTERS` with
`fs.readFileSync` / atomic-rename write
- [ ] Tests added (DST-replayable with injected adapters):
- Row assigned at T=0; same row at T=15min (within 30min cooldown) → skipped
- Row assigned at T=0; same row at T=35min (after 30min cooldown) → re-assigned
- History file absent → treated as empty; first assignment proceeds normally
- Multiple rows in cooldown → only expired rows published; `skippedDueToCooldown`
lists skipped IDs
- History pruning: entries older than `cooldownMin` removed on write

## Design sketch

```typescript
export type AssignmentHistoryEntry = {
rowId: string;
publishedAt: string; // ISO-8601
};

export type AssignmentHistory = {
entries: AssignmentHistoryEntry[];
};

// In pollOnce, before the publish loop:
const history = adapters.readHistoryFile(config.historyFile) ?? { entries: [] };
const cooldownMs = config.cooldownMin * 60_000;
const now = adapters.now();
const activeEntries = new Set(
history.entries
.filter(e => now.getTime() - new Date(e.publishedAt).getTime() < cooldownMs)
.map(e => e.rowId),
);

// Filter ready rows before publish:
const toPublish = toAssign.filter(r => !activeEntries.has(r.id));
const skippedDueToCooldown = toAssign.filter(r => activeEntries.has(r.id)).map(r => r.id);

// After publish loop, update history:
const newEntries: AssignmentHistoryEntry[] = [
...history.entries.filter(
e => now.getTime() - new Date(e.publishedAt).getTime() < cooldownMs
),
...publishedRowIds.map(id => ({ rowId: id, publishedAt: now.toISOString() })),
];
adapters.writeHistoryFile(config.historyFile, { entries: newEntries });
```

## Why separate from slice 3 (B-0500)

Slice 3 gates on queue-state (external signal: is the agent busy?).
Slice 5 gates on publication history (internal memory: did we just assign this?).
Both can be done independently; both modify different aspects of `pollOnce`'s output logic.
Separating them makes each diff reviewable in isolation.

## Atomic-write note

History file lives in `/tmp/zeta-bus/` (same directory as bus envelopes). Multiple
notifier instances running on different surfaces could race to update it. Atomic rename
(`tmp → final`) prevents partial writes. Concurrent notifiers may still read stale history
between their read and write — acceptable for a cooldown mechanism (a double-assignment
within the race window is a minor noise issue, not a correctness bug).

## Dependency chain

```
B-0441 (slices 1+2+4 shipped)
└─ B-0501 (THIS ROW — assignment history dedup; slice 5)
```

Does NOT depend on B-0500 (slice 3) — both slices modify `pollOnce` independently.
Coordinate merge order to avoid conflicts if both land in the same window.

## Pre-start checklist (per backlog-item-start-gate)

- [ ] Verify `/tmp/zeta-bus/` write permissions and atomic-rename behavior on macOS
(can use `bun tools/bus/claim.ts` as reference — it writes to the same directory)
- [ ] Run `bun tools/bg/backlog-ready-notifier.test.ts` to confirm all existing tests pass
- [ ] Check `tools/bus/bus.ts` for any existing atomic-write utility to reuse
Loading
Loading