From 50c3157c1807cf8b00a64d1186bb9fee5a7ceddc Mon Sep 17 00:00:00 2001 From: dev-driver Date: Tue, 27 Oct 2026 17:25:14 +0000 Subject: [PATCH 1/2] test(daemon): a stuck sweep finding for a terminal seat goes to a person The queue wake types nothing into a bound terminal seat, and nothing there can claim work, but the stuck sweep made its recovery finding the seat's own. Add the case: the finding goes to the person and says why. --- packages/daemon/test/queue-stuck-sweep.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/daemon/test/queue-stuck-sweep.test.ts b/packages/daemon/test/queue-stuck-sweep.test.ts index 5281927a1..56f0e3cda 100644 --- a/packages/daemon/test/queue-stuck-sweep.test.ts +++ b/packages/daemon/test/queue-stuck-sweep.test.ts @@ -498,6 +498,23 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o return row; } + // A bound terminal seat (a shell or a TUI, not an agent): the queue wake types nothing into it and nothing there can + // claim work, so its finding goes where an unroutable address's does, never to the seat itself. + it("work for a terminal seat is told to the person, not the seat, and says why", async () => { + seedLeadSeat(); + db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('node-log', 'rig-r', 'logger', 'terminal', '/tmp')").run(); + db.prepare("INSERT INTO sessions (id, node_id, session_name, status, created_at) VALUES ('sess-log', 'node-log', 'logger@r', 'running', '2026-07-02 12:00:00')").run(); + db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session, tmux_pane) VALUES ('bind-log', 'node-log', 'tmux', 'logger@r', '%2')").run(); + expect(repo.classifyDestinationOf("logger@r").class).toBe("pane-bound"); + const row = await repo.create({ sourceSession: "lead@r", destinationSession: "logger@r", body: "work" }); + setNudgeResult(row.qitemId, "failed:terminal-seat: 'logger@r' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it", new Date()); + await runSweep({ resolveOperatorSeat: () => "person@external", resolveRecoveryAgentSeat: () => "operator-admin@kernel" }); + const findings = await findingsFor(row.qitemId); + expect(findings).toHaveLength(1); + expect(findings[0]!.destinationSession).toBe("person@external"); + expect(findings[0]!.body).toContain("logger@r is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat. This finding goes to person@external"); + }); + it("work for an address with no wake route (a hosted member) is told to the person, and says why", async () => { const row = await mkUnroutableRow(); await runSweep({ resolveOperatorSeat: () => "person@external", resolveRecoveryAgentSeat: () => "operator-admin@kernel" }); From 16a52ef632ac4aedcf16b194c9cb02c82955af30 Mon Sep 17 00:00:00 2001 From: dev-driver Date: Wed, 28 Oct 2026 15:48:01 +0000 Subject: [PATCH 2/2] fix(daemon): route a terminal seat's stuck-sweep finding to a person The sweep already sends a finding for an unroutable address or an unclaimed hosted member to the operator person, the row's creator or the operator agent. A bound terminal seat now goes the same way, with "is a terminal seat; nothing there can claim this work", and a terminal seat is never chosen as that recipient. With no one to take it, the finding stays with the terminal seat and says so; a second test covers that case. The typing guard is unchanged. --- packages/daemon/src/domain/queue-stuck-sweep.ts | 16 +++++++++++----- packages/daemon/test/queue-stuck-sweep.test.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/daemon/src/domain/queue-stuck-sweep.ts b/packages/daemon/src/domain/queue-stuck-sweep.ts index 676d51310..550ca6edf 100644 --- a/packages/daemon/src/domain/queue-stuck-sweep.ts +++ b/packages/daemon/src/domain/queue-stuck-sweep.ts @@ -27,6 +27,7 @@ import { resolvePickupThresholdMinutes } from "./queue-pickup.js"; import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } from "./queue-owner.js"; import { nodeSessionConfirmedMissing } from "./node-inventory.js"; +import { isBoundTerminalSeat } from "./terminal-seat.js"; import type Database from "better-sqlite3"; import { deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js"; import { stalledPickupFinding } from "./queue-pickup.js"; @@ -865,21 +866,26 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise deps.queueRepo.classifyDestinationOf(seat).class; for (const c of candidates) { if (c.route !== c.row.destinationSession) continue; const destination = classOf(c.row.destinationSession); const noRoute = destination === "unroutable" && !!c.row.lastNudgeResult?.startsWith("unroutable:"); - if (!noRoute && !(destination === "hosted" && !c.row.claimedAt)) continue; + const terminalSeat = destination === "pane-bound" && isBoundTerminalSeat(deps.db, c.row.destinationSession); + if (!noRoute && !terminalSeat && !(destination === "hosted" && !c.row.claimedAt)) continue; const operatorSeat = (deps.resolveOperatorSeat ?? resolveOperatorSeat)(); const person = operatorSeat && classOf(operatorSeat) === "gateway-routable" ? operatorSeat : null; const choices = [person, c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()]; - const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable"); + const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable" + && !isBoundTerminalSeat(deps.db, seat)); c.why += noRoute ? `; ${c.route} has no wake route on this daemon (no terminal, not a person). If it's a hosted member, it sees this work only when it next checks, so ask it to; otherwise fix the address` - : `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`; + : terminalSeat + ? `; ${c.route} is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat` + : `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`; if (chosen < 0) { c.why += `. No person, creator or operator agent that can be reached is available, so this finding stays addressed to it`; continue; diff --git a/packages/daemon/test/queue-stuck-sweep.test.ts b/packages/daemon/test/queue-stuck-sweep.test.ts index 56f0e3cda..d646f333e 100644 --- a/packages/daemon/test/queue-stuck-sweep.test.ts +++ b/packages/daemon/test/queue-stuck-sweep.test.ts @@ -515,6 +515,23 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o expect(findings[0]!.body).toContain("logger@r is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat. This finding goes to person@external"); }); + // The creator is a terminal seat too, so it can't take the finding either; with no person or operator agent, it stays. + it("work for a terminal seat with no person, agent creator or operator agent keeps its finding, and says why", async () => { + seedLeadSeat(); + for (const [id, name, pane] of [["log", "logger", "%2"], ["mon", "monitor", "%3"]]) { + db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES (?, 'rig-r', ?, 'terminal', '/tmp')").run(`node-${id}`, name); + db.prepare("INSERT INTO sessions (id, node_id, session_name, status, created_at) VALUES (?, ?, ?, 'running', '2026-07-02 12:00:00')").run(`sess-${id}`, `node-${id}`, `${name}@r`); + db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session, tmux_pane) VALUES (?, ?, 'tmux', ?, ?)").run(`bind-${id}`, `node-${id}`, `${name}@r`, pane); + } + const row = await repo.create({ sourceSession: "monitor@r", destinationSession: "logger@r", body: "work" }); + setNudgeResult(row.qitemId, "failed:terminal-seat: 'logger@r' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it", new Date()); + await runSweep({ resolveOperatorSeat: () => null, resolveRecoveryAgentSeat: () => null }); + const findings = await findingsFor(row.qitemId); + expect(findings).toHaveLength(1); + expect(findings[0]!.destinationSession).toBe("logger@r"); + expect(findings[0]!.body).toContain("logger@r is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat. No person, creator or operator agent that can be reached is available, so this finding stays addressed to it"); + }); + it("work for an address with no wake route (a hosted member) is told to the person, and says why", async () => { const row = await mkUnroutableRow(); await runSweep({ resolveOperatorSeat: () => "person@external", resolveRecoveryAgentSeat: () => "operator-admin@kernel" });