Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ are closed (status: closed in frontmatter)._
- [ ] **[B-0496](backlog/P1/B-0496-hamiltonian-viz-slice-2-live-github-api-2026-05-14.md)** Hamiltonian viz — slice-2: live GitHub API commit fetch → trajectory
- [ ] **[B-0497](backlog/P1/B-0497-b0440-slice-6-standing-by-detector-launchd-registration-2026-05-14.md)** B-0440 slice 6 — standing-by-detector launchd plist + AUTONOMOUS-LOOP.md wiring update
- [x] **[B-0500](backlog/P1/B-0500-b0441-slice-3-queue-state-guard-poll-once-wiring-2026-05-14.md)** B-0441 slice 3 — wire isAgentQueueEmpty guard into pollOnce
- [ ] **[B-0501](backlog/P1/B-0501-b0441-slice-5-assignment-history-dedup-cooldown-2026-05-14.md)** B-0441 slice 5 — assignment history dedup cooldown (avoid re-assigning same row within short window)
- [x] **[B-0501](backlog/P1/B-0501-b0441-slice-5-assignment-history-dedup-cooldown-2026-05-14.md)** B-0441 slice 5 — assignment history dedup cooldown (avoid re-assigning same row within short window)
- [ ] **[B-0502](backlog/P1/B-0502-b0441-slice-6-launchd-plist-autonomous-loop-docs-2026-05-14.md)** B-0441 slice 6 — launchd plist for backlog-ready-notifier + AUTONOMOUS-LOOP.md update
- [x] **[B-0503](backlog/P1/B-0503-b0442-slice5a-open-recovery-pr-core-function-2026-05-14.md)** B-0442 slice 5a — openRecoveryPR core function + RecoveryAdapters + DST tests
- [x] **[B-0504](backlog/P1/B-0504-b0442-slice5b-wire-auto-recover-into-pollonce-2026-05-14.md)** B-0442 slice 5b — wire --auto-recover into pollOnce + real RecoveryAdapters + config flags
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ provides a less-ambiguous concrete claim — eliminating the
decompositionSuggestion: <slice-breakdown> } }` (Slice 4, shipped)
- [x] Honors agent autonomy — assignment is suggestion, not directive
(per `.claude/rules/no-directives.md`) — by design; envelope is advisory
- [ ] Tracks assignment history to avoid re-assigning same row
within short window (Slice 5a, B-0501 open — `historyFile`/cooldown logic not yet in `tools/bg/backlog-ready-notifier.ts`)
- [x] Tracks assignment history to avoid re-assigning same row
within short window (Slice 5a, B-0501 shipped — `historyFile`/`cooldownMin` config + read/write adapters + cooldown-gate logic in `tools/bg/backlog-ready-notifier.ts`; 8 new tests cover skip-within-window / re-assign-after-window / first-assignment / multi-row-mix / pruning)
- [x] Tests cover the readiness-detection heuristics
(`tools/bg/backlog-ready-notifier.test.ts`)
- [x] Documented in `docs/AUTONOMOUS-LOOP.md`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
id: B-0501
priority: P1
status: open
status: closed
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
last_updated: 2026-05-20
parent: B-0441
depends_on: []
composes_with: [B-0441, B-0500, B-0502]
Expand All @@ -28,34 +28,44 @@ busy. This produces noisy bus output and makes the assignment signal less meanin

## Acceptance criteria

- [ ] `NotifierConfig` gains a `historyFile` field (default
- [x] `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
- [x] 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`:
- [x] 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
- [x] `PollResult` gains a `skippedDueToCooldown: string[]` field listing any `rowId`s
that were skipped because of cooldown
- [ ] Adapters interface gains:
- [x] 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):
- [x] 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

## Resolution

Shipped in this PR. 8 new tests (45 total) cover all acceptance criteria + bonus
coverage of `defaultHistoryFile()` honoring `ZETA_BUS_DIR` + new `--history-file`
and `--cooldown-min` CLI flags. REAL_ADAPTERS uses atomic-rename via
`renameSync(tmp, path)` after `writeFileSync(tmp, ...)`. Default config resolves
the history-file path at module-load time via `defaultHistoryFile()` honoring
`process.env.ZETA_BUS_DIR`. B-0441 parent acceptance criterion "Tracks assignment
history to avoid re-assigning same row within short window" is now satisfied.

## Design sketch

```typescript
Expand Down
174 changes: 172 additions & 2 deletions tools/bg/backlog-ready-notifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_CONFIG,
defaultHistoryFile,
parseArgs,
parseRow,
parsePositiveMinutes,
pollOnce,
runOnce,
type Adapters,
type AssignmentHistory,
type BacklogRow,
} from "./backlog-ready-notifier";
import type { AgentId, MessageEnvelope, SenderAgentId } from "../bus/types";
Expand All @@ -19,12 +21,18 @@ type FakeAssignmentCall = {
rationale: string;
};

type HistoryStore = {
read: AssignmentHistory | null;
written: AssignmentHistory[];
};

function fakeAdapters(
nowIso: string,
rows: BacklogRow[],
capturedCalls: FakeAssignmentCall[] = [],
gitLogStr: string = "",
ghPrListStr: string = "",
history: HistoryStore = { read: null, written: [] },
): Adapters {
return {
now: () => new Date(nowIso),
Expand All @@ -46,6 +54,11 @@ function fakeAdapters(
},
execGitLog: () => gitLogStr,
execGhPrList: () => ghPrListStr,
readHistoryFile: () => history.read,
writeHistoryFile: (_path, h) => {
history.written.push(h);
history.read = h;
},
};
}

Expand Down Expand Up @@ -445,15 +458,172 @@ title: only a title
// 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("assignment-history cooldown (slice 5a)", () => {
test("defaultHistoryFile honors ZETA_BUS_DIR env var when set", () => {
const before = process.env.ZETA_BUS_DIR;
try {
process.env.ZETA_BUS_DIR = "/var/zeta-test";
expect(defaultHistoryFile()).toBe("/var/zeta-test/assignment-history.json");
delete process.env.ZETA_BUS_DIR;
expect(defaultHistoryFile()).toBe("/tmp/zeta-bus/assignment-history.json");
Comment thread
AceHack marked this conversation as resolved.
} finally {
if (before === undefined) delete process.env.ZETA_BUS_DIR;
else process.env.ZETA_BUS_DIR = before;
}
});

test("row assigned at T=0; same row at T=15min (within 30min cooldown) → skipped", () => {
const captured: FakeAssignmentCall[] = [];
// Pre-populate history with B-9001 published at T=0.
const history: HistoryStore = {
read: { entries: [{ rowId: "B-9001", publishedAt: "2026-05-13T18:00:00.000Z" }] },
written: [],
};
// Poll at T+15min.
const adapters = fakeAdapters(
"2026-05-13T18:15:00.000Z",
[ROW_OPEN_NO_DEPS],
captured,
"",
"",
history,
);
const result = pollOnce({ ...DEFAULT_CONFIG, cooldownMin: 30 }, adapters);
expect(result.skippedDueToCooldown).toEqual(["B-9001"]);
expect(result.publishedEnvelopeIds).toHaveLength(0);
expect(captured).toHaveLength(0);
expect(result.note).toContain("skipped 1 due to cooldown");
});

test("row assigned at T=0; same row at T=35min (after 30min cooldown) → re-assigned", () => {
const captured: FakeAssignmentCall[] = [];
const history: HistoryStore = {
read: { entries: [{ rowId: "B-9001", publishedAt: "2026-05-13T18:00:00.000Z" }] },
written: [],
};
// Poll at T+35min — entry is expired (older than 30min cooldown).
const adapters = fakeAdapters(
"2026-05-13T18:35:00.000Z",
[ROW_OPEN_NO_DEPS],
captured,
"",
"",
history,
);
const result = pollOnce({ ...DEFAULT_CONFIG, cooldownMin: 30 }, adapters);
expect(result.skippedDueToCooldown).toEqual([]);
expect(result.publishedEnvelopeIds).toHaveLength(1);
expect(captured).toHaveLength(1);
// History rewritten: pruned the stale entry, appended fresh entry.
expect(history.written).toHaveLength(1);
expect(history.written[0]!.entries).toEqual([
{ rowId: "B-9001", publishedAt: "2026-05-13T18:35:00.000Z" },
]);
});

test("history file absent → first assignment proceeds normally and writes history", () => {
const captured: FakeAssignmentCall[] = [];
const history: HistoryStore = { read: null, written: [] };
const adapters = fakeAdapters(
"2026-05-13T18:00:00.000Z",
[ROW_OPEN_NO_DEPS],
captured,
"",
"",
history,
);
const result = pollOnce(DEFAULT_CONFIG, adapters);
expect(result.skippedDueToCooldown).toEqual([]);
expect(result.publishedEnvelopeIds).toHaveLength(1);
expect(history.written).toHaveLength(1);
expect(history.written[0]!.entries[0]).toMatchObject({
rowId: "B-9001",
publishedAt: "2026-05-13T18:00:00.000Z",
});
});

test("multiple rows in cooldown → only expired rows published; skippedDueToCooldown lists skipped IDs", () => {
const captured: FakeAssignmentCall[] = [];
// B-9001 published 15min ago (still in cooldown); B-9002 published 45min ago (expired).
const history: HistoryStore = {
read: {
entries: [
{ rowId: "B-9001", publishedAt: "2026-05-13T18:00:00.000Z" },
{ rowId: "B-9002", publishedAt: "2026-05-13T17:30:00.000Z" },
],
},
written: [],
};
const rowB9001: BacklogRow = { ...ROW_OPEN_NO_DEPS, id: "B-9001" };
const rowB9002: BacklogRow = { ...ROW_OPEN_NO_DEPS, id: "B-9002" };
const adapters = fakeAdapters(
"2026-05-13T18:15:00.000Z", // T+15min from B-9001; T+45min from B-9002
[rowB9001, rowB9002],
captured,
"",
"",
history,
);
const result = pollOnce({ ...DEFAULT_CONFIG, maxAssignments: 10, cooldownMin: 30 }, adapters);
expect(result.skippedDueToCooldown).toEqual(["B-9001"]);
expect(result.publishedEnvelopeIds).toHaveLength(1);
expect(captured.map(c => c.rowId)).toEqual(["B-9002"]);
});

test("history pruning: entries older than cooldownMin removed on write", () => {
const captured: FakeAssignmentCall[] = [];
// One ancient entry + one fresh entry from a different row that's about to be re-published-fresh.
const history: HistoryStore = {
read: {
entries: [
{ rowId: "B-OLD", publishedAt: "2026-05-13T17:00:00.000Z" }, // 60min old (expired)
{ rowId: "B-RECENT", publishedAt: "2026-05-13T17:50:00.000Z" }, // 10min old (kept)
],
},
written: [],
};
const rowNew: BacklogRow = { ...ROW_OPEN_NO_DEPS, id: "B-NEW" };
const adapters = fakeAdapters(
"2026-05-13T18:00:00.000Z",
[rowNew],
captured,
"",
"",
history,
);
const result = pollOnce({ ...DEFAULT_CONFIG, cooldownMin: 30 }, adapters);
expect(result.publishedEnvelopeIds).toHaveLength(1);
// Written history should NOT include B-OLD (pruned) but should keep B-RECENT and add B-NEW.
expect(history.written).toHaveLength(1);
const writtenIds = history.written[0]!.entries.map(e => e.rowId);
expect(writtenIds).not.toContain("B-OLD");
expect(writtenIds).toContain("B-RECENT");
expect(writtenIds).toContain("B-NEW");
});

test("--history-file and --cooldown-min flags parse correctly", () => {
const config = parseArgs([
"--history-file", "/custom/path/assignment-history.json",
"--cooldown-min", "60",
]);
expect(config.historyFile).toBe("/custom/path/assignment-history.json");
expect(config.cooldownMin).toBe(60);
});

test("--history-file rejects missing value", () => {
expect(() => parseArgs(["--history-file"])).toThrow(/requires a value/);
});
});

describe("parseArgs", () => {
test("default config when no args", () => {
expect(parseArgs([])).toEqual(DEFAULT_CONFIG);
Expand Down
Loading
Loading