fix(queue): match the unroutable wake retry on the exact prefix (#2824)

The stuck sweep's wake retry (wakeToRetry) and its re-check after the send match "unroutable:" with an exact, case-sensitive startsWith again, as before #2649 moved the sweep's rules from SQL into TypeScript. The repository writes that prefix in lowercase, and findUndelivered and the finding route already match it exactly, so nothing changes on the real data path today; a row whose result matches only when case is ignored is no longer re-sent as an unbound-pane wake. The "failed:" and "qitem-" matches stay case-insensitive, the faithful port of SQLite's LIKE. Follow-up to #2649.
This commit is contained in:
billbenetti authored and GitHub committed 2026-11-09 03:36:58 -08:00
1 parent 603d0776fb
commit 7078c754d6
2 files changed
+28 -2

No files matched your search

@@ -751,7 +751,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// again, and one already in flight or settled isn't sent twice. The next tick reads the new result, so a failed send
// is reported then. A row with a live retry ladder is left to the ladder.
const wakeToRetry = (row: QueueItem, humanNotice: boolean): boolean => !humanNotice
&& startsWithCI(row.lastNudgeResult, "unroutable:")
&& !!row.lastNudgeResult?.startsWith("unroutable:")
&& !hasLiveLadder(deps.db, row.qitemId, now, suppressionMinutes)
&& deps.queueRepo.classifyDestinationOf(row.destinationSession).class === "pane-bound";
const isHumanNotice = (row: QueueItem): boolean => row.deliveryOutcome === "never-posted" || row.deliveryOutcome === "transport-failed";
@@ -1049,7 +1049,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
othersMayHaveRun();
// judged again on the row as the send (and anyone else during it) left it
row = undeliveredNow(row.qitemId);
if (!row || !startsWithCI(row.lastNudgeResult, "unroutable:")) continue;
if (!row?.lastNudgeResult?.startsWith("unroutable:")) continue;
}
const c = undeliveredCandidate(row, true);
if (c) candidates.push(c);
@@ -606,6 +606,32 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
expect(await findingsFor(row.qitemId)).toHaveLength(0);
});
// The repository records the prefix in lowercase and its undelivered listing matches it exactly; the sweep's retry
// matches it the same way, so a listed row whose result only resembles it is not taken for an unbound pane and re-sent.
it("does not re-send a wake whose result only matches 'unroutable:' ignoring case", async () => {
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:/);
const failedAt = new Date(Date.now() - 10 * 60_000);
setNudgeResult(row.qitemId, "Unroutable: no pane yet", failedAt);
// Offer the row to the sweep as undelivered, whatever the listing's own prefix filter says.
vi.spyOn(repo, "findUndelivered").mockImplementation(((opts?: { qitemId?: string }) =>
!opts?.qitemId || opts.qitemId === row.qitemId ? [repo.getById(row.qitemId)!] : []) as typeof repo.findUndelivered);
db.prepare("INSERT INTO bindings(id,node_id,tmux_session) VALUES ('late-binding','late-node','late@r')").run();
// The row's finding goes to the orchestrator, so only a wake retry could reach the seat.
await runSweep({ resolveOrchestrator: () => "orch@r" });
expect(sent).not.toContain("late@r");
expect(repo.getById(row.qitemId)!.lastNudgeResult).toBe("Unroutable: no pane yet");
expect(repo.getById(row.qitemId)!.lastNudgeAttempt).toBe(failedAt.toISOString());
});
// 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() {