From 05ef9e94fc7a456d6e0446295b8b0564eb1748df Mon Sep 17 00:00:00 2001 From: dev-driver Date: Tue, 27 Oct 2026 08:19:22 +0000 Subject: [PATCH 1/5] test(daemon): a wake recorded unroutable is re-sent once the seat's binding exists A row created before its seat's pane was bound records an unroutable wake. Once the binding exists, the stuck sweep should send that wake rather than report it. Tests only; the fix follows. Co-Authored-By: Claude Opus 5.5 --- .../daemon/test/queue-stuck-sweep.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/daemon/test/queue-stuck-sweep.test.ts b/packages/daemon/test/queue-stuck-sweep.test.ts index e15c51cc1..aad61341b 100644 --- a/packages/daemon/test/queue-stuck-sweep.test.ts +++ b/packages/daemon/test/queue-stuck-sweep.test.ts @@ -387,6 +387,29 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o expect((await findingsFor(row.qitemId))[0]!.destinationSession).toBe("sender@r"); }); + // #2151: a row created before its seat's pane was bound records an unroutable wake. Once the binding exists, the sweep + // sends that wake instead of reporting it. + it("re-sends a wake recorded unroutable once the seat's terminal binding exists, and raises no finding for it", async () => { + // Real topology, so the repository's pre-topology transport shortcut doesn't apply. + db.prepare("INSERT INTO rigs(id,name) VALUES ('fixture-rig','r')").run(); + db.prepare("INSERT INTO nodes(id,rig_id,logical_id,runtime,profile) VALUES ('late-node','fixture-rig','late','codex','none')").run(); + const sent: string[] = []; + repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => ({ ok: true, entities: [] }), + transport: { send: async (destination) => { sent.push(destination); return { ok: true, verified: true }; } }, + }); + const row = await repo.create({ sourceSession: "sender@r", destinationSession: "late@r", body: "work" }); + expect(row.lastNudgeResult).toMatch(/^unroutable:/); + expect(sent).toEqual([]); + // The seat's pane comes up: its tmux binding is written. + db.prepare("INSERT INTO bindings(id,node_id,tmux_session) VALUES ('late-binding','late-node','late@r')").run(); + await runSweep(); + expect(sent).toEqual(["late@r"]); + expect(repo.getById(row.qitemId)!.lastNudgeResult).not.toMatch(/^unroutable:/); + expect(await findingsFor(row.qitemId)).toHaveLength(0); + }); + // With no live recovery seat, the finding is still created (it's owed work) to the missing session, and says so. it("a dead seat's own row with no operator agent or seat stays with it, and its why says no live recovery seat is available", async () => { const row = await repo.create({ sourceSession: "worker@r", destinationSession: "worker@r", body: "work" }); From 5e03eeece3821f0db259d2033ae5482e24545035 Mon Sep 17 00:00:00 2001 From: dev-driver Date: Tue, 27 Oct 2026 08:20:23 +0000 Subject: [PATCH 2/5] fix(daemon): re-send a wake recorded unroutable once the seat's binding exists A row created before its seat's pane was bound records an unroutable wake, and the stuck sweep reported it as an undelivered wake that nothing retried. Now, when that destination has a terminal binding, the sweep sends the wake through the ordinary path instead of reporting it. The next tick reads the new result, so a failed send is still reported. A row with a live retry ladder is left to the ladder. An address that still has no binding is reported as before. Co-Authored-By: Claude Opus 5.5 --- packages/daemon/src/domain/queue-stuck-sweep.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/daemon/src/domain/queue-stuck-sweep.ts b/packages/daemon/src/domain/queue-stuck-sweep.ts index 248252ffa..204feb086 100644 --- a/packages/daemon/src/domain/queue-stuck-sweep.ts +++ b/packages/daemon/src/domain/queue-stuck-sweep.ts @@ -637,6 +637,15 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise Date: Tue, 27 Oct 2026 09:08:01 +0000 Subject: [PATCH 3/5] test(daemon): the sweep's re-send settles the row's wake intent With durable wake intents attached, the re-send must go through the row's own intent: it reads delivered afterwards and a later startup drain sends nothing. An intent already sending or delivered is not sent again. Tests only; the change follows. Co-Authored-By: Claude Opus 5.5 --- .../daemon/test/queue-stuck-sweep.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/daemon/test/queue-stuck-sweep.test.ts b/packages/daemon/test/queue-stuck-sweep.test.ts index aad61341b..2385c7acb 100644 --- a/packages/daemon/test/queue-stuck-sweep.test.ts +++ b/packages/daemon/test/queue-stuck-sweep.test.ts @@ -23,6 +23,7 @@ import { migrate } from "../src/db/migrate.js"; import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; import { QueueRepository, deriveCrossHostSuccessorId, type QueueItem } from "../src/domain/queue-repository.js"; import { EventBus } from "../src/domain/event-bus.js"; +import { OutboxHandler } from "../src/domain/outbox-handler.js"; import { SettingsStore } from "../src/domain/user-settings/settings-store.js"; import { archiveAgedTerminalTransitions } from "../src/domain/queue-retention.js"; import type { HumanFragment } from "../src/domain/gateway/human-registry.js"; @@ -410,6 +411,45 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o expect(await findingsFor(row.qitemId)).toHaveLength(0); }); + // With the durable wake intents in place (the production path), the re-send goes through the row's own intent: it's + // claimed and settled, so a later startup drain doesn't wake the seat a second time. + async function lateSeatWithOutbox() { + db.prepare("INSERT INTO rigs(id,name) VALUES ('fixture-rig','r')").run(); + db.prepare("INSERT INTO nodes(id,rig_id,logical_id,runtime,profile) VALUES ('late-node','fixture-rig','late','codex','none')").run(); + const sent: string[] = []; + repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => ({ ok: true, entities: [] }), + transport: { send: async (destination) => { sent.push(destination); return { ok: true, verified: true }; } }, + }); + const outbox = new OutboxHandler(db); + repo.attachOutbox(outbox); + const row = await repo.create({ sourceSession: "sender@r", destinationSession: "late@r", body: "work" }); + expect(row.lastNudgeResult).toMatch(/^unroutable:/); + expect(outbox.getById(`wake-intent-${row.qitemId}`)?.deliveryState).toBe("failed"); + db.prepare("INSERT INTO bindings(id,node_id,tmux_session) VALUES ('late-binding','late-node','late@r')").run(); + return { sent, outbox, row }; + } + + it("re-sends through the row's wake intent: the intent reads delivered, and a later drain sends nothing", async () => { + const { sent, outbox, row } = await lateSeatWithOutbox(); + await runSweep(); + expect(sent).toEqual(["late@r"]); + expect(outbox.getById(`wake-intent-${row.qitemId}`)?.deliveryState).toBe("delivered"); + expect(repo.getById(row.qitemId)!.lastNudgeResult).not.toMatch(/^unroutable:/); + expect(await findingsFor(row.qitemId)).toHaveLength(0); + await repo.drainPendingWakeIntents(); + expect(sent).toEqual(["late@r"]); + }); + + it.each(["sending", "delivered"])("doesn't send again when the row's wake intent is already %s", async (state) => { + const { sent, outbox, row } = await lateSeatWithOutbox(); + db.prepare("UPDATE outbox_entries SET delivery_state = ? WHERE outbox_id = ?").run(state, `wake-intent-${row.qitemId}`); + await runSweep(); + expect(sent).toEqual([]); + expect(outbox.getById(`wake-intent-${row.qitemId}`)?.deliveryState).toBe(state); + }); + // With no live recovery seat, the finding is still created (it's owed work) to the missing session, and says so. it("a dead seat's own row with no operator agent or seat stays with it, and its why says no live recovery seat is available", async () => { const row = await repo.create({ sourceSession: "worker@r", destinationSession: "worker@r", body: "work" }); From f2bcb59734ec30221e8277bb0645309d1845cc38 Mon Sep 17 00:00:00 2001 From: dev-driver Date: Tue, 27 Oct 2026 09:08:35 +0000 Subject: [PATCH 4/5] fix(daemon): the sweep's re-send delivers the row's own wake intent Round 1: with durable wake intents, maybeNudge sent the wake and recorded the row's result but left the intent failed, so the next startup drain could wake the seat again. deliverWakeForSuccessor claims and settles the row's intent (and falls back to maybeNudge with no intent store), so an intent in flight or already settled is not sent twice. Co-Authored-By: Claude Opus 5.5 --- packages/daemon/src/domain/queue-stuck-sweep.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/daemon/src/domain/queue-stuck-sweep.ts b/packages/daemon/src/domain/queue-stuck-sweep.ts index 204feb086..676d51310 100644 --- a/packages/daemon/src/domain/queue-stuck-sweep.ts +++ b/packages/daemon/src/domain/queue-stuck-sweep.ts @@ -637,13 +637,14 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise Date: Tue, 27 Oct 2026 10:02:13 +0000 Subject: [PATCH 5/5] test(daemon): read the late seat's row back after its create wake settles With an outbox attached, a create's wake runs after its commit, so the row create returns predates it and carries no result yet. The fixture now waits for the wake intent to settle (failed, unroutable) and reads the row back. When the row's wake intent is already sending or delivered, the row still raises the sweep's finding. Those cases route the finding to an orchestrator, so the seat's terminal sees only the row's own wake, and check that the row records no new wake attempt. Co-Authored-By: Claude Opus 5.5 --- packages/daemon/test/queue-stuck-sweep.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/daemon/test/queue-stuck-sweep.test.ts b/packages/daemon/test/queue-stuck-sweep.test.ts index 2385c7acb..5281927a1 100644 --- a/packages/daemon/test/queue-stuck-sweep.test.ts +++ b/packages/daemon/test/queue-stuck-sweep.test.ts @@ -424,9 +424,13 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o }); const outbox = new OutboxHandler(db); repo.attachOutbox(outbox); - const row = await repo.create({ sourceSession: "sender@r", destinationSession: "late@r", body: "work" }); + const created = await repo.create({ sourceSession: "sender@r", destinationSession: "late@r", body: "work" }); + // With an outbox, the create's wake runs after its commit: read the row back once that wake has settled. + await vi.waitFor(() => expect(outbox.getById(`wake-intent-${created.qitemId}`)?.deliveryState).not.toMatch(/^(pending|sending)$/)); + const row = repo.getById(created.qitemId)!; expect(row.lastNudgeResult).toMatch(/^unroutable:/); expect(outbox.getById(`wake-intent-${row.qitemId}`)?.deliveryState).toBe("failed"); + expect(sent).toEqual([]); db.prepare("INSERT INTO bindings(id,node_id,tmux_session) VALUES ('late-binding','late-node','late@r')").run(); return { sent, outbox, row }; } @@ -442,12 +446,14 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o expect(sent).toEqual(["late@r"]); }); + // The row still raises the sweep's finding here; it goes to the orchestrator, so only the row's own wake could reach the seat. it.each(["sending", "delivered"])("doesn't send again when the row's wake intent is already %s", async (state) => { const { sent, outbox, row } = await lateSeatWithOutbox(); db.prepare("UPDATE outbox_entries SET delivery_state = ? WHERE outbox_id = ?").run(state, `wake-intent-${row.qitemId}`); - await runSweep(); - expect(sent).toEqual([]); + await runSweep({ resolveOrchestrator: () => "orch@r" }); + expect(sent).not.toContain("late@r"); expect(outbox.getById(`wake-intent-${row.qitemId}`)?.deliveryState).toBe(state); + expect(repo.getById(row.qitemId)!.lastNudgeAttempt).toBe(row.lastNudgeAttempt); }); // With no live recovery seat, the finding is still created (it's owed work) to the missing session, and says so.