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", () => {