From af4aff14fe932ea4fd7629c3c3bec98aab2cc439 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:19:07 -0800 Subject: [PATCH] fix(cli,daemon): name pre-0.7.9 handovers and unconfirmed broadcast recipients (#2572) rig seat stop, clean and in-place restart still refuse a session recorded as adopted. When the seat's last completed handover was a fresh, fork or rebuild handover, the refusal now says that a successor from before OpenRig 0.7.9 keeps the adopted origin until its next launched handover or managed launch. It no longer suggests rig unclaim, which would end OpenRig's management of the seat. rig seat stop's help says the same. rig broadcast prints UNCONFIRMED instead of FAILED for a recipient whose text or Enter may have reached it (input attempted or written), and points to rig capture before any resend. No refusal, error code or setting is added or removed. --- packages/cli/src/commands/broadcast.ts | 5 ++- packages/cli/src/commands/seat.ts | 4 +- packages/cli/test/broadcast.test.ts | 29 ++++++++++++++ .../src/domain/seat-lifecycle-service.ts | 29 +++++++++++--- .../test/seat-lifecycle-service.test.ts | 40 +++++++++++++++++++ .../daemon/test/seat-restart-in-place.test.ts | 15 +++++++ 6 files changed, 115 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/broadcast.ts b/packages/cli/src/commands/broadcast.ts index e89e918ea..481dc2ffb 100644 --- a/packages/cli/src/commands/broadcast.ts +++ b/packages/cli/src/commands/broadcast.ts @@ -222,10 +222,13 @@ async function runCrossHostBroadcast( * local route does, so per-target honesty prints identically. A partial * fan-out sets a non-zero exit either way. */ function renderBroadcastResults(data: Record): void { - const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; managedCompaction?: { notice?: unknown } }>) ?? []; + const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; inputPhase?: string; sent?: boolean; managedCompaction?: { notice?: unknown } }>) ?? []; for (const r of results) { if (r.ok) { console.log(`${r.sessionName}: sent`); + } else if (r.inputPhase === "attempted" || r.inputPhase === "written" || r.sent === true) { + // The daemon records this recipient's receipt as indeterminate (#2507): its text or Enter may have reached it. + console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}UNCONFIRMED — ${r.error ?? "unknown error"}; the text may have reached it, so check with rig capture before resending`); } else { // A target that resolved to no sessions has no name to report. console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`); diff --git a/packages/cli/src/commands/seat.ts b/packages/cli/src/commands/seat.ts index e756f5e17..ed7052e69 100644 --- a/packages/cli/src/commands/seat.ts +++ b/packages/cli/src/commands/seat.ts @@ -1428,7 +1428,9 @@ Examples: .description("Stop exactly one LIVE managed seat (kills only that seat's tmux session; audited)") .addHelpText("after", ` Siblings are untouched. A dead seat is refused (use rig seat clean); an adopted -session is refused (use rig unclaim). Stopping ends that agent's session and any +session is refused (use rig unclaim). A seat handed over (fresh, fork or rebuild) +before OpenRig 0.7.9 still reads adopted and is refused too, until its next launched +handover or managed launch; the refusal says so. Stopping ends that agent's session and any work in progress: check it first (rig capture ) and stop only a seat you own or were asked to stop. rig seat launch --fresh --reason starts a fresh occupant there; it does not resume the stopped conversation. diff --git a/packages/cli/test/broadcast.test.ts b/packages/cli/test/broadcast.test.ts index ba946aabe..739006525 100644 --- a/packages/cli/test/broadcast.test.ts +++ b/packages/cli/test/broadcast.test.ts @@ -70,6 +70,19 @@ describe("Broadcast CLI", () => { { ok: false, sessionName: "c@notice-rig", error: "send failed", managedCompaction: { stage: "compacting", observed: "before_send", notice: "c@notice-rig was being compacted: /compact was submitted." } }, ], })); + } else if (parsed.rig === "phase-rig") { + // #2507: a recipient whose input was attempted or written is recorded indeterminate, not failed. + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + total: 5, sent: 1, failed: 4, + results: [ + { ok: true, sessionName: "a@phase-rig", inputPhase: "written", sent: true }, + { ok: false, sessionName: "b@phase-rig", inputPhase: "not_reached", sent: false, error: "target_needs_input" }, + { ok: false, sessionName: "c@phase-rig", inputPhase: "attempted", sent: false, error: "paste lost" }, + { ok: false, sessionName: "d@phase-rig", inputPhase: "written", sent: false, error: "enter unconfirmed" }, + { ok: false, sessionName: "e@phase-rig", sent: true, error: "receipt lost" }, + ], + })); } else if (parsed.rig === "warn-rig") { // S2 (OPR.0.5.4.3): an unattributed broadcast's response carries the // sign-it notice as an additive warning; the renderer must surface it. @@ -156,6 +169,22 @@ describe("Broadcast CLI", () => { expect(exitCode).toBe(1); }); + it("broadcast prints UNCONFIRMED, not FAILED, for a recipient whose input was attempted or written; exit code unchanged", async () => { + const { stdout, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "broadcast", "--rig", "phase-rig", "hello"]); + }); + const check = "the text may have reached it, so check with rig capture before resending"; + expect(stdout).toEqual([ + "a@phase-rig: sent", + "b@phase-rig: FAILED — target_needs_input", + `c@phase-rig: UNCONFIRMED — paste lost; ${check}`, + `d@phase-rig: UNCONFIRMED — enter unconfirmed; ${check}`, + `e@phase-rig: UNCONFIRMED — receipt lost; ${check}`, + "1/5 delivered", + ]); + expect(exitCode).toBe(1); + }); + it("broadcast --rig prints per-target summary", async () => { const { logs } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "broadcast", "--rig", "my-rig", "hello"]); diff --git a/packages/daemon/src/domain/seat-lifecycle-service.ts b/packages/daemon/src/domain/seat-lifecycle-service.ts index 36c02934f..69c90c8af 100644 --- a/packages/daemon/src/domain/seat-lifecycle-service.ts +++ b/packages/daemon/src/domain/seat-lifecycle-service.ts @@ -1086,7 +1086,7 @@ export class SeatLifecycleService { return { ok: false, code: "no_session", message: `Seat "${input.seatRef}" has no session to stop (checked: latest sessions row for node ${seat.logicalId}).` }; } if (session.origin === "claimed") { - return { + return this.earlierHandoverRefusal(resolved.nodeId, session, "stop refuses to kill it") ?? { ok: false, code: "claimed_session", message: `Session "${session.session_name}" was adopted (origin=claimed), not launched by OpenRig — stop refuses to kill it.`, @@ -1136,7 +1136,7 @@ export class SeatLifecycleService { if ("code" in resolved) return { ok: false, code: "selection_changed", message: "The selected seat changed." }; const seat = this.describe(resolved); const session = this.latestSession(resolved.nodeId)!; - if (session.origin === "claimed") return { ok: false, code: "claimed_session", message: `Session "${session.session_name}" was adopted (origin=claimed), not launched by OpenRig — stop refuses to kill it.`, guidance: "Release an adopted session with: rig unclaim" }; + if (session.origin === "claimed") return this.earlierHandoverRefusal(resolved.nodeId, session, "stop refuses to kill it") ?? { ok: false, code: "claimed_session", message: `Session "${session.session_name}" was adopted (origin=claimed), not launched by OpenRig — stop refuses to kill it.`, guidance: "Release an adopted session with: rig unclaim" }; const probed = await this.probeLiveness(session.session_name, "stop refuses rather than kill blind"); if ("code" in probed) return probed; const afterProbe = selection(); @@ -1179,7 +1179,7 @@ export class SeatLifecycleService { const mutationTargets = nonTerminal; for (const row of mutationTargets) { if (row.origin === "claimed") { - return { + return this.earlierHandoverRefusal(resolved.nodeId, row, "clean refuses to touch adopted state") ?? { ok: false, code: "claimed_session", message: `Session "${row.session_name}" was adopted (origin=claimed) — clean refuses to touch adopted state.`, @@ -1871,8 +1871,9 @@ export class SeatLifecycleService { return { ok: false, code: "no_session", message: `Seat "${seatRef}" has no live managed session to restart (checked: latest sessions row).` }; } if (session.origin === "claimed") { - return { ok: false, code: "claimed_session", message: `Session "${session.session_name}" was adopted (origin=claimed), not launched by OpenRig; in-place restart refuses it.`, - guidance: "Release an adopted session with: rig unclaim" }; + return this.earlierHandoverRefusal(node.id, session, "in-place restart refuses it") + ?? { ok: false, code: "claimed_session", message: `Session "${session.session_name}" was adopted (origin=claimed), not launched by OpenRig; in-place restart refuses it.`, + guidance: "Release an adopted session with: rig unclaim" }; } const probed = await this.probeLiveness(session.session_name, "in-place restart refuses rather than act on an unknown session"); if ("code" in probed) return probed; @@ -2263,6 +2264,24 @@ export class SeatLifecycleService { return row ?? null; } + /** From 0.7.9 a fresh, fork or rebuild handover records its successor as launched (#2440); a successor from before + * keeps origin=claimed. Name that case rather than suggest `rig unclaim`, which would end OpenRig's management. */ + private earlierHandoverRefusal(nodeId: string, session: { id: string; session_name: string }, refuses: string): SeatRefusal | null { + if (this.latestSession(nodeId)?.id !== session.id) return null; + const handover = this.db.prepare( + "SELECT continuity_outcome, handover_at FROM nodes WHERE id = ? AND handover_result = 'complete' AND continuity_outcome IN ('fresh', 'forked', 'rebuilt')", + ).get(nodeId) as { continuity_outcome: string; handover_at: string | null } | undefined; + if (!handover) return null; + return { + ok: false, + code: "claimed_session", + message: `Session "${session.session_name}" is recorded as adopted (origin=claimed). The seat's last handover ` + + `(${handover.continuity_outcome}${handover.handover_at ? ` at ${handover.handover_at}` : ""}) is one OpenRig launches, but a ` + + `successor from before OpenRig 0.7.9 keeps the adopted origin — ${refuses}.`, + guidance: "It keeps that origin until its next launched handover or managed launch.", + }; + } + private settingTargetLabel(target: SeatSettingTarget): string { return target.nodeId === undefined ? target.seatRef : target.nodeId; } diff --git a/packages/daemon/test/seat-lifecycle-service.test.ts b/packages/daemon/test/seat-lifecycle-service.test.ts index 979f73e9e..1d626e0f1 100644 --- a/packages/daemon/test/seat-lifecycle-service.test.ts +++ b/packages/daemon/test/seat-lifecycle-service.test.ts @@ -398,6 +398,46 @@ describe("SeatLifecycleService", () => { expect(sessionRegistry.getBindingForNode(a.node.id)).not.toBeNull(); }); + // #2440 records a launched handover's successor as launched from 0.7.9; one handed over earlier keeps origin=claimed. + function recordHandover(nodeId: string, outcome: string | null) { + db.prepare("UPDATE nodes SET continuity_outcome = ?, handover_result = 'complete', handover_at = ? WHERE id = ?") + .run(outcome, "2026-11-01T10:00:00.000Z", nodeId); + } + + it.each(["fresh", "forked", "rebuilt"])("a seat handed over (%s) before 0.7.9 is refused as that case, never pointed at rig unclaim", async (outcome) => { + const seat = seatFixture("s5-rig", "dev.handed", { origin: "claimed" }); + recordHandover(seat.node.id, outcome); + for (const result of [ + await service.stopSeat({ seatRef: seat.sessionName, reason: "x" }), + await service.cleanSeat({ seatRef: seat.sessionName, reason: "x" }), + ]) { + expect(result).toMatchObject({ ok: false, code: "claimed_session" }); + if (result.ok) throw new Error("expected refusal"); + expect(result.message).toContain(`last handover (${outcome} at 2026-11-01T10:00:00.000Z)`); + expect(result.message).toContain("before OpenRig 0.7.9"); + expect(result.guidance).toBe("It keeps that origin until its next launched handover or managed launch."); + expect(result.guidance).not.toContain("rig unclaim"); + } + expect(tmux.killed).toEqual([]); + }); + + it("an adopted seat with no handover, or a discovered one, still points at rig unclaim", async () => { + const adopted = seatFixture("s5-rig", "dev.adopted", { origin: "claimed" }); + const discovered = seatFixture("s5-rig", "dev.discovered", { origin: "claimed" }); + recordHandover(discovered.node.id, null); + for (const seat of [adopted, discovered]) { + for (const result of [ + await service.stopSeat({ seatRef: seat.sessionName, reason: "x" }), + await service.cleanSeat({ seatRef: seat.sessionName, reason: "x" }), + ]) { + expect(result).toMatchObject({ ok: false, code: "claimed_session", guidance: "Release an adopted session with: rig unclaim" }); + if (result.ok) throw new Error("expected refusal"); + expect(result.message).toContain("was adopted (origin=claimed)"); + } + } + expect(tmux.killed).toEqual([]); + }); + it("P4: stop refuses a claimed (adopted) session, an indeterminate probe, and a no-session node", async () => { const claimed = seatFixture("s5-rig", "dev.adopted", { origin: "claimed" }); const claimedResult = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" }); diff --git a/packages/daemon/test/seat-restart-in-place.test.ts b/packages/daemon/test/seat-restart-in-place.test.ts index 0873f1cce..53de11075 100644 --- a/packages/daemon/test/seat-restart-in-place.test.ts +++ b/packages/daemon/test/seat-restart-in-place.test.ts @@ -558,6 +558,21 @@ describe("rig seat restart --keep-conversation: gates before any signal", () => w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id); expect(await w.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: false, code: "claimed_session" }); }); + + it("names a seat handed over before 0.7.9 instead of pointing at rig unclaim", async () => { + const w = world(); + w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id); + expect(await w.service.restartInPlace({ seatRef: SEAT, plan: true })) + .toMatchObject({ ok: false, code: "claimed_session", guidance: "Release an adopted session with: rig unclaim" }); + w.db.prepare("UPDATE nodes SET continuity_outcome = 'rebuilt', handover_result = 'complete', handover_at = ? WHERE id = ?") + .run("2026-11-01T10:00:00.000Z", w.node.id); + const result = await w.service.restartInPlace({ seatRef: SEAT, plan: true }); + expect(result).toMatchObject({ ok: false, code: "claimed_session", + guidance: "It keeps that origin until its next launched handover or managed launch." }); + if (result.ok) throw new Error("expected refusal"); + expect(result.message).toContain("last handover (rebuilt at 2026-11-01T10:00:00.000Z)"); + expect(result.message).toContain("in-place restart refuses it"); + }); }); describe("rig seat restart --keep-conversation: conflicting retained native-thread evidence", () => {