fix(compaction): show undeliverable managed restore stages (#1953)

## What a user gets

Managed Claude compaction shows the owed restore stage and its last delivery result in `rig compact --state` and seat status. Active-work idle waits show as waiting, without marking the stage held or raising a failure notice. Repeated actual failures create one queue item for the delegation owner or registered human fallback; changed reasons update it without another wake.

## How you verified it

Baseline RED exposed missing diagnostics; round-one RED reproduced false busy-stage failure counts and a notice callback running inside the real lifecycle lease. After correction, 85 focused daemon tests pass, including three busy-stage polls, unresolved/rejected callbacks, retry, restart, cancellation and notification deduplication. Daemon typecheck passes. Initial author checks also passed 210 daemon and 63 CLI tests. The stub runtime cannot perform native Claude compaction; these fixtures do not establish the original native stall's cause. Required CI will run on the new head.

## Anything you were unsure about

Notices start after the guarded operation settles and do not delay its drain. The human fallback is a plain ask: urgent-only subscribers are not paged; every-ask subscribers keep their usual notification. Cost: one small record write per unsuccessful poll and at most one diagnostic item per attempt (+124 net source lines, +149 net test lines; 1.09 added test lines per added source line). Existing owner-closed dispositions stand; an open notice is not automatically closed when the stage later progresses. No new reason codes, scheduler or delivery permission; idle, occupant, typing, retry and cancel behavior stays.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-24 18:44:10 -07:00
1 parent c061d70530
commit f8b3d03cd1
11 files changed
+292 -19

No files matched your search

+2
View File
@@ -28,6 +28,7 @@ interface SeatStatusResponse {
runtime: string | null;
/** Slice 16: an open runtime switch, or one recovery stopped short of finishing. */
runtimeSwitch?: { id: string; phase: string; from: string; to: string; committed: boolean; startedAt: string; attention: string | null; outcome: string | null };
compaction?: { stage: string; detail: string };
current_occupant: string | null;
typingGuard?: { desired: boolean; effective: boolean; pending: boolean; heldCount: number;
desiredMode?: string; effectiveMode?: string; desiredLevel?: string; effectiveLevel?: string; };
@@ -212,6 +213,7 @@ function printHuman(status: SeatStatusResponse): void {
else console.log("Hold pauses all automatic input, including writing lifecycle. Changing mode does not replay held messages.");
}
console.log(`Session: ${display(status.session_status, "unknown")}`);
if (status.compaction) console.log(`Managed compaction (${status.compaction.stage}): ${status.compaction.detail}`);
const reason = sessionReason(status.session_reason);
if (reason) console.log(`Reason: ${reason}`);
if (status.permissions?.account) {
+7
View File
@@ -94,6 +94,13 @@ const STATUS = {
};
describe("seat typing guard commands", () => {
it("prints the daemon's managed restore delivery failure", async () => {
const detail = "OpenRig owes its turn boundary. Last delivery: target_activity_unknown; no_activity_signal.";
const deps = makeDeps({ status: 200, data: { ...STATUS, compaction: { stage: "compacting", detail } } }, []);
const result = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "status", "worker@test"]).then(() => undefined));
expect(result.logs).toContain(`Managed compaction (compacting): ${detail}`);
});
it("keeps the legacy on prefix when status reports hold", async () => {
const paths: string[] = [];
const deps = makeDeps({ status: 200, data: { ...STATUS, typingGuard: { desired: true, effective: true, effectiveMode: "hold", pending: false, heldCount: 0 } } }, paths);
@@ -415,6 +415,25 @@ export function buildPostCompactTurnBoundaryPrompt(): string {
type PendingPostCompactStage = "turn_boundary" | "restore_prompt" | "compliance_prompt";
/** Safe delivery diagnostics: never retain pane contents or an exception message. */
export interface RestoreDeliveryAttempt {
stage: PendingPostCompactStage;
at: string;
ok: boolean;
reason: string;
outcome?: SendResult["outcome"];
activity?: Pick<NonNullable<SendResult["activity"]>, "state" | "reason" | "evidenceSource">;
errorClass?: string;
failures: number;
noticeErrorClass?: string;
}
export interface RestoreDeliveryFailure {
sessionName: string;
attemptId: string;
mapPath: string;
lastDelivery: RestoreDeliveryAttempt;
}
/** A managed compaction the enforcer has recorded for a seat: the stage, and a phrase that says only what was observed. */
export interface ObservedCompaction {
stage: "preparing" | "compacting" | "compacted" | "restoring";
@@ -493,6 +512,7 @@ interface CompactionRecord {
compactSending?: true;
/** The owed restore steps follow a /compact whose outcome is unknown, so each goes only into an empty input box. */
uncertainCompact?: true;
lastDelivery?: RestoreDeliveryAttempt;
updatedAt: number;
}
@@ -575,12 +595,14 @@ export class ClaudeCompactionEnforcer {
saturationBoundPercentage: number;
}) => Promise<void> | void;
private readonly onRestoreDeliveryFailure?: (failure: RestoreDeliveryFailure) => Promise<void>;
private readonly terminalPolicies?: TerminalPolicyStore;
constructor(
settingsStore: SettingsStore,
sessionTransport: SessionTransport,
opts?: {
onRestoreDeliveryFailure?: (failure: RestoreDeliveryFailure) => Promise<void>;
terminalPolicies?: TerminalPolicyStore;
dedupWindowMs?: number;
openrigHome?: string;
@@ -603,6 +625,7 @@ export class ClaudeCompactionEnforcer {
}) => Promise<void> | void;
},
) {
this.onRestoreDeliveryFailure = opts?.onRestoreDeliveryFailure;
this.terminalPolicies = opts?.terminalPolicies;
this.settingsStore = settingsStore;
this.sessionTransport = sessionTransport;
@@ -678,18 +701,22 @@ export class ClaudeCompactionEnforcer {
/** The restore step OpenRig owes this seat next, or null. After a /compact whose outcome is unknown (see
* `afterUncertainCompact`) it also gives what the input box last read (`not_checked` before the first check) and
* whether the step is held for it. */
getOwedRestore(sessionName: string): { stage: PendingPostCompactStage; held: boolean; inputBox?: string; detail: string } | null {
getOwedRestore(sessionName: string): { stage: PendingPostCompactStage; held: boolean; inputBox?: string; detail: string; lastDelivery?: RestoreDeliveryAttempt } | null {
const stage = this.pendingPostCompactRestore.get(sessionName);
if (!stage) return null;
const lastDelivery = this.records.get(sessionName)?.record.lastDelivery;
const failed = !!lastDelivery && lastDelivery.failures > 0;
const last = lastDelivery ? { lastDelivery } : {};
const failure = lastDelivery ? ` ${failed ? "Last delivery" : "waiting: seat busy"} at ${lastDelivery.at}: ${[lastDelivery.reason, lastDelivery.activity?.reason, lastDelivery.errorClass, lastDelivery.noticeErrorClass && `owner notice failed (${lastDelivery.noticeErrorClass})`].filter(Boolean).join("; ")}. OpenRig will retry on its existing polls.` : "";
const step = { turn_boundary: "post-compaction turn boundary", restore_prompt: "restore request", compliance_prompt: "post-restore audit" }[stage];
if (!this.afterUncertainCompact.has(sessionName)) {
return { stage, held: false, detail: `OpenRig owes this seat its ${step} next, sent on a poll once the pane is idle.` };
return { stage, held: failed, ...last, detail: `OpenRig owes this seat its ${step} next, sent on a poll once the pane is idle.${failure}` };
}
const inputBox = this.lastInputBox.get(sessionName) ?? "not_checked";
const held = inputBox !== "not_checked" && !INPUT_BOX_CLEAR.has(inputBox);
return { stage, held, inputBox, detail: held
return { stage, held: held || failed, inputBox, ...last, detail: (held
? `held: OpenRig owes this seat its restore steps after a /compact whose result was lost, and waits while its input box isn't empty (it reads ${inputBox}). The seat may have /compact text waiting, which any Enter would submit, so check the pane. A seat whose own status line keeps the box unreadable stays held.`
: `OpenRig owes this seat its ${step} next, after a /compact whose result was lost. Each step goes only into an empty input box, sent on a poll once the pane is idle (the box last read ${inputBox}).` };
: `OpenRig owes this seat its ${step} next, after a /compact whose result was lost. Each step goes only into an empty input box, sent on a poll once the pane is idle (the box last read ${inputBox}).`) + failure };
}
/** An open record from before a restart that was too old to resume, while no later attempt has replaced it. */
@@ -738,17 +765,66 @@ export class ClaudeCompactionEnforcer {
input: EnforcerInput,
run: () => Promise<EnforcerOutcome>,
): Promise<EnforcerOutcome> {
const stage = this.pendingPostCompactRestore.get(input.sessionName);
const record = this.records.get(input.sessionName);
const previous = record?.record.lastDelivery;
const runIfCurrent = (): Promise<EnforcerOutcome> => input.isObservationCurrent && !input.isObservationCurrent()
? Promise.resolve({ triggered: false, reason: "stale_observation" }) : run();
const guard = this.sessionTransport.deliveryGuard;
if (guard && input.runtime === "claude-code") {
try { return await guard.lifecycle([guard.target(input.sessionName).nodeId], runIfCurrent); }
catch (error) {
if (error instanceof DeliveryGuardError) return { triggered: false, reason: error.code === "typing_guard_enabled" ? "typing_guard_enabled" : "guard_target_unknown" };
throw error;
try {
const outcome = guard && input.runtime === "claude-code"
? await guard.lifecycle([guard.target(input.sessionName).nodeId], runIfCurrent) : await runIfCurrent();
// Refusals before send (policy, guard, observation) must be visible too. A send has already recorded its full result.
if (!outcome.triggered && record?.record.lastDelivery === previous) {
this.recordRestoreFailure(input.sessionName, stage, record, { ok: false, reason: outcome.reason });
}
return outcome;
} catch (error) {
this.recordRestoreFailure(input.sessionName, stage, record, {
ok: false, reason: error instanceof DeliveryGuardError ? error.code : "send_failed",
}, error instanceof Error ? error.name : typeof error);
if (error instanceof DeliveryGuardError) return { triggered: false, reason: error.code === "typing_guard_enabled" ? "typing_guard_enabled" : "guard_target_unknown" };
throw error;
} finally {
// The awaited lifecycle has settled: notice work must not inherit its active lease.
const lastDelivery = record?.record.lastDelivery;
if (record && lastDelivery && lastDelivery !== previous && lastDelivery.failures >= 2 && this.onRestoreDeliveryFailure) {
void Promise.resolve().then(() => {
if (this.records.get(input.sessionName) !== record || record.record.lastDelivery !== lastDelivery || this.pendingPostCompactRestore.get(input.sessionName) !== stage) return;
return this.onRestoreDeliveryFailure!({ sessionName: input.sessionName, attemptId: record.record.attemptId, mapPath: record.record.mapPath, lastDelivery });
}).catch(error => {
if (this.records.get(input.sessionName) === record && record.record.lastDelivery === lastDelivery && this.pendingPostCompactRestore.get(input.sessionName) === stage) {
lastDelivery.noticeErrorClass = error instanceof Error ? error.name : typeof error;
this.persist(input.sessionName, record.record.attemptId, { lastDelivery });
}
});
}
}
return runIfCurrent();
}
private recordRestoreFailure(
session: string, stage: PendingPostCompactStage | undefined,
record: { file: string; record: CompactionRecord } | undefined,
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity">, errorClass?: string,
): void {
if (!stage || !record || this.records.get(session) !== record || this.pendingPostCompactRestore.get(session) !== stage) return;
const prior = record.record.lastDelivery;
// Active work is the expected wait between restore steps, not an undeliverable stage.
const waiting = result.reason === "wait_for_idle_timeout" && result.activity?.state === "running";
const lastDelivery: RestoreDeliveryAttempt = {
stage, at: new Date(this.now()).toISOString(), ok: result.ok, reason: result.reason ?? "send_failed",
...(result.outcome ? { outcome: result.outcome } : {}),
...(result.activity ? { activity: { state: result.activity.state, reason: result.activity.reason, evidenceSource: result.activity.evidenceSource } } : {}),
...(errorClass ? { errorClass } : {}), failures: waiting ? 0 : (prior?.failures ?? 0) + 1,
};
this.persist(session, record.record.attemptId, { lastDelivery });
}
private async sendRestoreStage(session: string, text: string, options: Parameters<SessionTransport["send"]>[2]): Promise<SendResult> {
const stage = this.pendingPostCompactRestore.get(session), record = this.records.get(session);
const result = await this.sessionTransport.send(session, text, options);
if (!result.ok || result.outcome === "retained") this.recordRestoreFailure(session, stage, record, result);
return result;
}
/**
@@ -810,7 +886,7 @@ export class ClaudeCompactionEnforcer {
if (!INPUT_BOX_CLEAR.has(box)) return { triggered: false, reason: "input_box_not_empty" };
}
if (pendingStage === "turn_boundary") {
const boundary = await this.sessionTransport.send(
const boundary = await this.sendRestoreStage(
input.sessionName,
buildPostCompactTurnBoundaryPrompt(),
sendOpts,
@@ -828,7 +904,7 @@ export class ClaudeCompactionEnforcer {
// OPR.0.4.1.09: resolve the extra FOR THIS SEAT (per-seat preferred; the legacy
// global is refused if it declares a different seat) - never inject wrong-seat state.
const extra = resolvePostCompactExtra(input.sessionName, this.openrigHome, policy.messageFilePath);
const restore = await this.sessionTransport.send(
const restore = await this.sendRestoreStage(
input.sessionName,
buildPostCompactRestorePrompt({
sessionName: input.sessionName,
@@ -858,7 +934,7 @@ export class ClaudeCompactionEnforcer {
return { triggered: true };
}
if (pendingStage === "compliance_prompt") {
const compliance = await this.sessionTransport.send(
const compliance = await this.sendRestoreStage(
input.sessionName,
buildPostCompactCompliancePrompt(policy.postRestoreAuditInstruction),
sendOpts,
@@ -1396,7 +1472,7 @@ export class ClaudeCompactionEnforcer {
private persistStage(sessionName: string, phase: CompactionRecord["phase"]): void {
const current = this.records.get(sessionName);
if (current) this.persist(sessionName, current.record.attemptId, { phase });
if (current) this.persist(sessionName, current.record.attemptId, { phase, lastDelivery: undefined });
}
/**
@@ -0,0 +1,30 @@
import type Database from "better-sqlite3";
import type { RestoreDeliveryFailure } from "./claude-compaction-enforcer.js";
import type { QueueRepository } from "./queue-repository.js";
import { defaultResolveOrchestrator } from "./queue-owner.js";
import { workflowHumanDestination } from "./workflow-human-destination.js";
import { shellQuote } from "../adapters/shell-quote.js";
/** One existing diagnostic queue item per compaction attempt, also across restarts. */
export async function reportRestoreDeliveryFailure(db: Database.Database, queue: QueueRepository, failure: RestoreDeliveryFailure): Promise<void> {
const { sessionName, attemptId, lastDelivery } = failure;
const qitemId = `qitem-compaction-restore-${attemptId}`;
const reason = [lastDelivery.reason, lastDelivery.activity?.reason, lastDelivery.errorClass].filter(Boolean).join("; ");
const summary = `Managed restore for ${sessionName}: ${lastDelivery.stage} undeliverable (${reason})`;
const existing = queue.getById(qitemId);
if (existing) {
// An owner's closed disposition stands. Changed evidence updates the same row without another wake.
if (["pending", "in-progress", "blocked"].includes(existing.state) && existing.summary !== summary) {
queue.update({ qitemId, actorSession: "system:compaction", summary, transitionNote: summary });
}
return;
}
await queue.create({
qitemId, sourceSession: sessionName,
destinationSession: defaultResolveOrchestrator(db, sessionName) ?? workflowHumanDestination(),
summary, wakeTrigger: "openrig",
tags: ["compaction", "restore-delivery", `attempt:${attemptId}`],
evidenceRef: failure.mapPath,
body: `${summary}. OpenRig still owes this stage after repeated delivery failures. Please inspect the seat and decide whether intervention is needed. This does not prove native compaction finished. The existing poll retries with all delivery checks intact. Inspect rig compact ${shellQuote(sessionName)} --state --json and rig seat status ${shellQuote(sessionName)} before acting. Do not issue another /compact. Restore map: ${failure.mapPath}. The latest reason is in this item's summary and transition notes.`,
});
}
@@ -1,3 +1,4 @@
import type { ClaudeCompactionEnforcer, ObservedCompaction } from "./claude-compaction-enforcer.js";
import path from "node:path";
import { TerminalPolicyStore } from "./terminal-policy.js";
import type { RigRepository } from "./rig-repository.js";
@@ -12,6 +13,7 @@ import { RUNTIME_HOME_VAR, seatRuntimeHome, type HomeRuntime } from "./seat-runt
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
export interface SeatStatus {
compaction?: ObservedCompaction & { owedRestore: ReturnType<ClaudeCompactionEnforcer["getOwedRestore"]> };
seat_ref: string;
rig_id: string;
rig_name: string;
@@ -64,8 +66,10 @@ interface SeatMatch {
export class SeatStatusService {
private rigRepo: RigRepository;
private env: NodeJS.ProcessEnv;
private compactionEnforcer?: Pick<ClaudeCompactionEnforcer, "recoverSeat" | "observedCompaction" | "getOwedRestore">;
constructor(deps: { rigRepo: RigRepository; env?: NodeJS.ProcessEnv }) {
constructor(deps: { rigRepo: RigRepository; env?: NodeJS.ProcessEnv; compactionEnforcer?: SeatStatusService["compactionEnforcer"] }) {
this.compactionEnforcer = deps.compactionEnforcer;
this.rigRepo = deps.rigRepo;
this.env = deps.env ?? process.env;
}
@@ -124,6 +128,7 @@ export class SeatStatusService {
private projectStatus(seatRef: string, entry: NodeInventoryEntry): SeatStatus {
return {
...this.compactionStatus(entry),
seat_ref: seatRef,
rig_id: entry.rigId,
rig_name: entry.rigName,
@@ -148,6 +153,14 @@ export class SeatStatusService {
};
}
private compactionStatus(entry: NodeInventoryEntry): Pick<SeatStatus, "compaction"> {
const enforcer = this.compactionEnforcer, session = entry.canonicalSessionName;
if (!enforcer || entry.runtime !== "claude-code" || !session) return {};
enforcer.recoverSeat({ sessionName: session, cwd: entry.cwd, runtime: entry.runtime });
const observed = enforcer.observedCompaction(session);
return observed ? { compaction: { ...observed, owedRestore: enforcer.getOwedRestore(session) } } : {};
}
private runtimeHomeStatus(nodeId: string, runtime: string | null): Pick<SeatStatus, "runtime_home"> {
if (!runtime || !Object.hasOwn(RUNTIME_HOME_VAR, runtime)) return {};
const variable = RUNTIME_HOME_VAR[runtime as HomeRuntime];
+2 -1
View File
@@ -1,3 +1,4 @@
import type { ClaudeCompactionEnforcer } from "../domain/claude-compaction-enforcer.js";
import { OutboxHandler } from "../domain/outbox-handler.js";
import { Hono, type Context } from "hono";
import type { RigRepository } from "../domain/rig-repository.js";
@@ -99,7 +100,7 @@ seatRoutes.post("/retire-held-message/:seatRef/:id", async c => {
seatRoutes.get("/status/:seatRef", (c) => {
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const service = new SeatStatusService({ rigRepo });
const service = new SeatStatusService({ rigRepo, compactionEnforcer: c.get("compactionEnforcer" as never) as ClaudeCompactionEnforcer | undefined });
const result = service.getStatus(c.req.param("seatRef")!);
if (result.ok) {
+2
View File
@@ -1,3 +1,4 @@
import { reportRestoreDeliveryFailure } from "./domain/compaction-restore-notice.js";
import { ClaudeTokenUsage, claudeTokenUsageReader } from "./domain/provider/claude-token-usage.js";
import type { ClaudeTokenIdentity } from "./domain/provider/provider-types.js";
import { TerminalPolicyStore } from "./domain/terminal-policy.js";
@@ -2506,6 +2507,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// so a stage minted by a retired generation is refused for the successor. null = UNKNOWN → the
// gate is inert (never compares a stale generation as if live).
{
onRestoreDeliveryFailure: (failure) => reportRestoreDeliveryFailure(db, queueRepoInstance, failure),
terminalPolicies: new TerminalPolicyStore(db),
resolveOccupantGeneration: (sessionName) =>
sessionRegistry.currentOccupantGenerationForSession(sessionName),
@@ -203,7 +203,7 @@ describe("post-compact drain readiness", () => {
await monitor.pollOnce();
expect(tmux.sendText).toHaveBeenCalledTimes(index);
expect(tmux.sendKeys).toHaveBeenCalledTimes(index);
expect(enforcer.observedCompaction(sessionName)).toEqual(before);
expect(enforcer.observedCompaction(sessionName)?.stage).toBe(before?.stage);
// No new hook/sample authorizes this advance: the actual pane becomes idle.
pane = COMPACTED_PANE;
@@ -38,8 +38,8 @@ function fixture(){
(transport as any).capture=async()=>({ok:true,sessionName:seat,content:'',inputBox:{status:hooks.box??'empty'}});
const settings={resolveClaudeCompactionPolicy:()=>policy};
// A daemon process: a new one shares the instance home, the seat and the clock, and nothing held in memory.
const daemon=(instance=randomUUID())=>new ClaudeCompactionEnforcer(settings as any,transport,{openrigHome:home,daemonInstance:instance,now:()=>clock,sleep:async(ms)=>{clock+=ms;},resolveOccupantGeneration:()=>generation});
return{daemon,writes,policy,hooks,transport,home,advance:(ms:number)=>{clock+=ms;},generation:(g:string)=>{generation=g;}};
const daemon=(instance=randomUUID(),options:NonNullable<ConstructorParameters<typeof ClaudeCompactionEnforcer>[2]>={})=>new ClaudeCompactionEnforcer(settings as any,transport,{openrigHome:home,daemonInstance:instance,now:()=>clock,sleep:async(ms)=>{clock+=ms;},resolveOccupantGeneration:()=>generation,...options});
return{daemon,writes,policy,hooks,transport,home,now:()=>clock,advance:(ms:number)=>{clock+=ms;},generation:(g:string)=>{generation=g;}};
}
type F=ReturnType<typeof fixture>;
const compacts=(f:F)=>f.writes.filter(t=>t.startsWith('/compact'));
@@ -276,3 +276,68 @@ it("an enforcer in the same daemon process never treats that process's own recor
const second=f.daemon('one-process');await staleTick(second);
expect(boundaries(f)).toHaveLength(0);
});
it('shows the exact restore refusal, retries it, and clears the failure when the owed stage advances',async()=>{
const f=fixture(),notice=vi.fn();
const e=f.daemon(undefined,{onRestoreDeliveryFailure:notice});
await e.maybeAutoCompact(high);completeMap(e);await e.maybeAutoCompact(high);
const send=vi.spyOn(f.transport,'send');
send.mockResolvedValue({ok:false,sessionName:seat,sent:false,reason:'target_activity_unknown',activity:{state:'unknown',reason:'no_activity_signal',evidenceSource:'pane_heuristic',sampledAt:new Date().toISOString(),evidence:'PRIVATE PANE TEXT'}});
for(let i=0;i<3;i++){f.advance(30000);await staleTick(e);}
const state=await stateOf(e);
expect(state.owedRestore).toMatchObject({stage:'turn_boundary',lastDelivery:{ok:false,reason:'target_activity_unknown',activity:{state:'unknown',reason:'no_activity_signal',evidenceSource:'pane_heuristic'},failures:3}});
expect(state.owedRestore.lastDelivery.at).toBe(new Date(f.now()).toISOString());
expect(JSON.stringify(state)).not.toContain('PRIVATE PANE TEXT');
expect(e.observedCompaction(seat)?.detail).toContain('target_activity_unknown');
expect(notice).toHaveBeenCalledWith(expect.objectContaining({sessionName:seat,attemptId:state.preparation.attemptId}));
// The diagnostic survives a restart; the existing poll still owns its retry.
const restarted=f.daemon();expect(restarted.hasPendingPostCompactStage(seat,{runtime:'claude-code'})).toBe(true);
expect((await stateOf(restarted)).owedRestore.lastDelivery.reason).toBe('target_activity_unknown');
send.mockRestore();await staleTick(restarted);
expect((await stateOf(restarted)).owedRestore).toMatchObject({stage:'restore_prompt'});
expect((await stateOf(restarted)).owedRestore.lastDelivery).toBeUndefined();
expect(compacts(f)).toHaveLength(1);
});
it('shows a thrown restore error by class without exposing its message or ending the pending stage',async()=>{
const f=fixture(),e=await compactedBy(f);
vi.spyOn(f.transport,'send').mockRejectedValue(new TypeError('PRIVATE ERROR MESSAGE'));
await expect(staleTick(e)).rejects.toThrow(TypeError);
const state=await stateOf(e);
expect(state.owedRestore).toMatchObject({stage:'turn_boundary',lastDelivery:{ok:false,errorClass:'TypeError'}});
expect(JSON.stringify(state)).not.toContain('PRIVATE ERROR MESSAGE');
e.cancelPreparation(seat);expect(e.getOwedRestore(seat)).toBeNull();
});
it('a busy restore waits without holding the stage or notifying its owner',async()=>{
const f=fixture(),notice=vi.fn(async()=>{});
const e=f.daemon(undefined,{onRestoreDeliveryFailure:notice});
await e.maybeAutoCompact(high);completeMap(e);await e.maybeAutoCompact(high);
await staleTick(e);await staleTick(e);
expect(e.getOwedRestore(seat)?.stage).toBe('compliance_prompt');
vi.spyOn(Date,'now').mockImplementation(f.now);f.hooks.busy=true;
for(let i=0;i<3;i++){await staleTick(e);f.advance(20000);}
expect(e.getOwedRestore(seat)).toMatchObject({stage:'compliance_prompt',held:false,lastDelivery:{reason:'wait_for_idle_timeout',activity:{state:'running'},failures:0}});
expect(e.getOwedRestore(seat)?.detail).toContain('waiting: seat busy');
expect(notice).not.toHaveBeenCalled();
f.hooks.busy=false;await staleTick(e);
expect(e.getOwedRestore(seat)).toBeNull();expect(compacts(f)).toHaveLength(1);
});
it.each(['unresolved','rejected'])('owner notice starts after the lifecycle lease ends and a notice left %s does not hold the drain',async(mode)=>{
const f=fixture(),held:boolean[]=[];
const e=f.daemon(undefined,{onRestoreDeliveryFailure:async()=>{
held.push(f.transport.deliveryGuard!.ownsLifecycle('node-one'));
if(mode==='rejected')throw new RangeError('PRIVATE NOTICE ERROR');
await new Promise<void>(()=>{});
}});
await e.maybeAutoCompact(high);completeMap(e);await e.maybeAutoCompact(high);
vi.spyOn(f.transport,'send').mockResolvedValue({ok:false,sessionName:seat,sent:false,reason:'target_activity_unknown'});
await staleTick(e);await staleTick(e);
for(let i=0;i<10;i++)await Promise.resolve();
expect(held).toEqual([false]);
if(mode==='rejected')expect(e.getOwedRestore(seat)?.lastDelivery?.noticeErrorClass).toBe('RangeError');
expect(readFileSync(recordFile(e),'utf8')).not.toContain('PRIVATE NOTICE ERROR');
});
@@ -0,0 +1,66 @@
import * as humanSelection from "../src/domain/workflow-human-destination.js";
import type { HumanFragment } from "../src/domain/gateway/human-registry.js";
import { it, expect, vi } from "vitest";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { reportRestoreDeliveryFailure } from "../src/domain/compaction-restore-notice.js";
import type { RestoreDeliveryFailure } from "../src/domain/claude-compaction-enforcer.js";
it("notifies the seat's owner once, updates a changed refusal without another wake, and honors the owner's closure", async () => {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
try {
const rigs = new RigRepository(db), sessions = new SessionRegistry(db);
const rig = rigs.createRig("restore-notice");
const owner = rigs.addNode(rig.id, "owner", { runtime: "claude-code" });
const seat = rigs.addNode(rig.id, "writer", { runtime: "claude-code" });
sessions.registerSession(owner.id, "owner@restore-notice");
sessions.updateBinding(owner.id, { tmuxSession: "owner@restore-notice" });
sessions.registerSession(seat.id, "writer@restore-notice");
rigs.addEdge(rig.id, owner.id, seat.id, "delegates_to");
const send = vi.fn(async () => ({ ok: true, verified: true }));
const queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true, transport: { send } });
const failure: RestoreDeliveryFailure = { sessionName: "writer@restore-notice", attemptId: "attempt-one", mapPath: "/maps/attempt-one/RESTORE-MAP.md",
lastDelivery: { stage: "turn_boundary", at: "2026-01-01T00:00:00Z", ok: false, reason: "target_activity_unknown", failures: 2 } };
await reportRestoreDeliveryFailure(db, queue, failure);
await Promise.all([reportRestoreDeliveryFailure(db, queue, failure), reportRestoreDeliveryFailure(db, queue, failure)]);
const id = "qitem-compaction-restore-attempt-one";
expect(queue.getById(id)?.destinationSession).toBe("owner@restore-notice");
expect(send).toHaveBeenCalledTimes(1);
// A restarted reporter uses the same persisted item, not an in-memory dedup list.
const restarted = new QueueRepository(db, new EventBus(db), { validateRig: () => true, transport: { send } });
failure.lastDelivery = { ...failure.lastDelivery, reason: "typing_guard_enabled", failures: 4 };
await reportRestoreDeliveryFailure(db, restarted, failure);
await reportRestoreDeliveryFailure(db, restarted, failure);
expect(restarted.getById(id)?.summary).toContain("typing_guard_enabled");
expect(restarted.listTransitions(id).filter(t => t.transitionNote?.includes("summary updated"))).toHaveLength(1);
expect(send).toHaveBeenCalledTimes(1);
restarted.update({ qitemId: id, actorSession: "owner@restore-notice", state: "done", closureReason: "no-follow-on" });
failure.lastDelivery.reason = "target_needs_input";
await reportRestoreDeliveryFailure(db, restarted, failure);
expect(restarted.getById(id)?.state).toBe("done");
expect(send).toHaveBeenCalledTimes(1);
} finally { db.close(); }
});
it("a human fallback is a plain ask: urgent-only stays on Steering while every-ask subscribers see it", async () => {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const human = { entityId: "human-owner", class: "human", displayName: "Owner", address: "human-owner@external", connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary", handle: "U0" }], prefs: {},
subscriptions: [{ destination: { connector: "slack" }, filter: { preset: "urgent-only" } }] } as HumanFragment;
const selected = vi.spyOn(humanSelection, "workflowHumanDestination").mockReturnValue(human.address);
try {
const queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [human] }) });
const failure: RestoreDeliveryFailure = { sessionName: "writer@demo", attemptId: "human-plain", mapPath: "/maps/restore.md", lastDelivery: { stage: "restore_prompt", at: "2026-01-01T00:00:00Z", ok: false, reason: "typing_guard_enabled", failures: 2 } };
await reportRestoreDeliveryFailure(db, queue, failure);
expect(queue.listTransitions("qitem-compaction-restore-human-plain")[0]).toMatchObject({ ownerNotificationKind: "human-required", ownerNotificationLevel: "RECORD" });
human.subscriptions = [];
await reportRestoreDeliveryFailure(db, queue, { ...failure, attemptId: "human-every-ask" });
expect(queue.listTransitions("qitem-compaction-restore-human-every-ask")[0]).toMatchObject({ ownerNotificationKind: "human-required", ownerNotificationLevel: "ALERT" });
} finally { selected.mockRestore(); db.close(); }
});
@@ -24,6 +24,17 @@ describe("SeatStatusService", () => {
db.close();
});
it("shows an owed restore's delivery failure on the same seat status the operator reads", () => {
const rig = rigRepo.createRig("restore-status");
const node = rigRepo.addNode(rig.id, "writer", { runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, "writer@restore-status");
sessionRegistry.updateStatus(session.id, "running");
const owedRestore = { stage: "turn_boundary" as const, held: true, detail: "OpenRig owes the turn boundary. Last delivery: target_activity_unknown." };
const observer = { recoverSeat: () => {}, observedCompaction: () => ({ stage: "compacting" as const, detail: owedRestore.detail }), getOwedRestore: () => owedRestore };
const status = new SeatStatusService({ rigRepo, compactionEnforcer: observer }).getStatus(session.sessionName);
expect(status.ok && status.status.compaction).toEqual({ stage: "compacting", detail: owedRestore.detail, owedRestore });
});
it("reports a confirmed missing current session as detached while preserving its registration", async () => {
const { app, rigRepo: repo, sessionRegistry: registry } = createTestApp(db);
const rig = repo.createRig("missing-seat");