@staix/agent-hub 0.12.30 → 0.12.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -7
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +4 -0
  6. package/docs/agent-notes/daemon.md +5 -2
  7. package/docs/agent-notes/local-worker.md +2 -0
  8. package/docs/agent-notes/tasks.md +5 -0
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +129 -22
  12. package/docs/quickstart.md +17 -2
  13. package/docs/security.md +30 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  16. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  17. package/docs/verification/2026-10-17-418-probes.md +21 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +66 -19
  22. package/src/adapters/claude-channel.ts +29 -11
  23. package/src/adapters/local-worker.ts +2 -2
  24. package/src/adapters/pi.ts +5 -0
  25. package/src/cli/attention.ts +5 -1
  26. package/src/cli/console-state.ts +56 -12
  27. package/src/cli/console.ts +19 -6
  28. package/src/cli/identity.ts +3 -2
  29. package/src/cli/jcode-mcp.ts +1 -1
  30. package/src/cli/main.ts +8 -1
  31. package/src/cli/output.ts +13 -2
  32. package/src/cli/upgrade-interactive.ts +2 -1
  33. package/src/cli/upgrade-restore-state.ts +133 -0
  34. package/src/cli/upgrade-runtime.ts +162 -58
  35. package/src/cli/upgrade.ts +49 -24
  36. package/src/hub/board.ts +82 -24
  37. package/src/hub/bus.ts +78 -29
  38. package/src/hub/check-runner.ts +514 -0
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/control-client.ts +5 -1
  41. package/src/hub/daemon.ts +400 -67
  42. package/src/hub/delivery-hints.ts +30 -0
  43. package/src/hub/delivery-journal.ts +185 -12
  44. package/src/hub/delivery-superseded.ts +52 -0
  45. package/src/hub/envelope.ts +1 -1
  46. package/src/hub/host-executable.ts +11 -0
  47. package/src/hub/hub-tools.ts +9 -1
  48. package/src/hub/peer-catalog.ts +3 -0
  49. package/src/hub/routing.ts +35 -16
  50. package/src/hub/settings.ts +2 -1
  51. package/src/hub/spec-read.ts +120 -0
  52. package/src/hub/supervision.ts +21 -6
  53. package/src/hub/task-sweep.ts +7 -6
  54. package/src/hub/tasks.ts +173 -34
  55. package/src/memory/capture.ts +2 -1
  56. package/src/ui/index.html +12 -9
  57. package/src/ui/needs.ts +7 -5
  58. package/src/ui/office-model.ts +4 -1
  59. package/src/ui/task-board.ts +1 -1
  60. package/src/ui/task-progress.ts +6 -2
  61. package/templates/AGENTS.block.md +1 -1
  62. package/templates/profiles/general.json +1 -1
  63. package/templates/profiles/research.json +1 -1
  64. package/templates/profiles/writing.json +1 -1
@@ -15,9 +15,16 @@ import { readEvents } from "../hub/events.ts";
15
15
  import { refreshManager } from "../hub/manager.ts";
16
16
  import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
17
17
  import { recoveryLock } from "../hub/recovery-store.ts";
18
+ import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type RestoreEvidence, type RestoreTransition } from "./upgrade-restore-state.ts";
18
19
 
19
20
  /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
20
- const RECONNECT_WAIT_MS = 90_000;
21
+ const UNMANAGED_RECONNECT_WAIT_MS = 90_000;
22
+ /** Managed TUI startup can include resume and MCP handshakes; match createTerminal's ten-minute readiness ceiling. */
23
+ const RECONNECT_WAIT_MS = 600_000;
24
+ class AttachmentLauncherGone extends Error {
25
+ constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
26
+ }
27
+ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalBinding => ({ ...binding, sessionId });
21
28
 
22
29
  export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
23
30
  const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
@@ -151,7 +158,7 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
151
158
  }
152
159
  }
153
160
 
154
- export async function inspectRecovery(project: Project): Promise<Inspection> {
161
+ export async function inspectRecovery(project: Project, targetFeatures?: readonly string[]): Promise<Inspection> {
155
162
  // #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
156
163
  // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
157
164
  // caller reads that as not inspected, never as stopped.
@@ -189,9 +196,9 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
189
196
  }
190
197
  if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
191
198
  let response: any;
192
- try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
199
+ try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
193
200
  catch (error) {
194
- if (isDatabaseBusy(error)) throw error;
201
+ if (isDatabaseBusy(error) || targetFeatures !== undefined) throw error;
195
202
  // A stop can complete between authenticated status and the second read. Never infer stopped
196
203
  // from a connection failure: the coordinator will inspect the ownership manifest again.
197
204
  return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
@@ -352,10 +359,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
352
359
  if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
353
360
  return result;
354
361
  };
355
- const control = (project: Project, op: string, id: string, instance: string) => {
362
+ const control = (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
356
363
  const sourceProtocol = readControl(project.stateDir)?.protocol;
357
364
  const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
358
- return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance }, protocol).then(() => {});
365
+ return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, protocol).then(() => {});
359
366
  };
360
367
  const revalidateTerminal = async (planned: PlannedProject, progress: ProjectProgress, saved: TerminalBinding, exact: boolean): Promise<TerminalBinding> => {
361
368
  const options = { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId };
@@ -387,7 +394,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
387
394
  * expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
388
395
  */
389
396
  const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
390
- Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
397
+ Promise<{ state: "live"; session?: string; handle?: string; attached?: true; terminal?: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> } | { state: "gone"; terminal?: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> } | { state: "unknown"; why: string; step?: string; idless?: true }> => {
391
398
  const target = await inspectRecovery(planned.project);
392
399
  if (target.state !== "running" || target.instanceId !== progress.instanceId) {
393
400
  // Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
@@ -397,22 +404,31 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
397
404
  const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
398
405
  const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
399
406
  if (session) return { state: "live", session };
407
+ let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
400
408
  // #225: the launch records of every instance this operation started for the project, the current one first: a
401
409
  // launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
402
410
  for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
403
411
  if (!instanceId) continue;
404
412
  const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
405
- if (launch?.state === "live") return { state: "live", handle: launch.record!.handle, ...(attached ? { attached: true as const } : {}) };
413
+ if (launch?.state === "live") {
414
+ const record = launch.record!;
415
+ return { state: "live", handle: record.handle, ...(attached ? { attached: true as const } : {}),
416
+ ...(instanceId === progress.instanceId ? { terminal: { handle: record.handle, incarnationId: record.incarnationId, worktreeId: record.worktreeId } } : {}) };
417
+ }
406
418
  if (launch?.state === "unknown") {
407
419
  return launch.record ? { state: "unknown", why: `its launcher in terminal ${launch.record.handle} cannot be read`, step: `wait until it attaches, or end it and close terminal ${launch.record.handle}` }
408
420
  : launch.invalidRow ? { state: "unknown", why: "a launch record that may be its launcher's cannot be evaluated", step: `inspect ${recordPath(planned.project.stateDir)} and fix that row, or end that launcher and remove its row` }
409
421
  : { state: "unknown", why: "the launcher records cannot be read", step: `inspect ${recordPath(planned.project.stateDir)} and move it aside (sessions it recorded then count as unmanaged)` };
410
422
  }
423
+ if (instanceId === progress.instanceId && launch?.state === "gone" && launch.record) {
424
+ const record = launch.record;
425
+ endedTerminal = { handle: record.handle, incarnationId: record.incarnationId, worktreeId: record.worktreeId };
426
+ }
411
427
  }
412
428
  // #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
413
429
  // not reported its id yet) is a session of unknown identity, never "nothing attached".
414
- if (attached) return { state: "unknown", why: `a ${peer} session is attached to the target without a session id`, step: `wait until it reports one, or end that ${peer} session` };
415
- return { state: "gone" };
430
+ if (attached) return { state: "unknown", idless: true, why: `a ${peer} session is attached to the target without a session id`, step: `wait until it reports one, or end that ${peer} session` };
431
+ return { state: "gone", ...(endedTerminal ? { terminal: endedTerminal } : {}) };
416
432
  };
417
433
  /**
418
434
  * #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
@@ -436,13 +452,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
436
452
  progress.terminals[key] = true; save();
437
453
  };
438
454
  /** #414: an idle terminal is not an authenticated peer. Keep its receipt while waiting, including on resume. */
439
- const awaitAttachment = async (planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, binding: TerminalBinding, save: () => void, expectedSession: string | null = binding.sessionId): Promise<string> => {
455
+ const awaitAttachment = async (planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, waiting: Extract<RestoreReceipt, { state: "waiting" }>, save: () => void, expectedSession: string | null = waiting.binding.sessionId): Promise<string> => {
456
+ const binding = waiting.binding;
457
+ const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
440
458
  const deadline = now() + RECONNECT_WAIT_MS;
441
459
  let live: Inspection;
442
460
  for (;;) {
443
461
  live = await inspectRecovery(planned.project);
444
- if (live.state === "running" && (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id)) throw new Error(`${binding.peer}: target identity changed while awaiting attachment`);
445
- const peer = live.peers.find((p) => p.id === binding.peer);
462
+ if (live.state !== "running") throw new Error(`${binding.peer}: target hub reads as ${live.state} while awaiting attachment; saved terminal retained`);
463
+ if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error(`${binding.peer}: target identity changed while awaiting attachment`);
464
+ const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
446
465
  const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
447
466
  const changed = !!session && expectedSession !== null && session !== expectedSession;
448
467
  // #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
@@ -450,10 +469,38 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
450
469
  const zeroTurn = changed && binding.peer === "claude" && (planned.terminals as TerminalBinding[]).some((p) => p.peer === "claude" && p.sessionId === binding.sessionId) && !claudeTranscriptExists(binding);
451
470
  if (changed && !zeroTurn) throw new Error(`${binding.peer}: a different conversation attached while awaiting ${binding.sessionId}`);
452
471
  if (now() > deadline) break;
453
- if (live.state === "running" && peer && ["idle", "busy", "paused"].includes(peer.state) && session) {
472
+ const launcher = await launcherOf(binding.peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
473
+ const sameTerminal = launcher?.record && matchesTerminal(launcher.record);
474
+ let launchState: RestoreEvidence["launcher"] = !launcher ? "no-record-yet" : launcher.state === "unknown" || !launcher.record ? "unknown"
475
+ : sameTerminal ? launcher.state : launcher.state === "gone" ? "no-record-yet" : "replaced";
476
+ let olderHandle: string | undefined;
477
+ if ((launchState === "gone" || launchState === "no-record-yet") && !session) {
478
+ // An exact exit is not proof while an older launcher or an id-less session may still run.
479
+ const evidence = await peerEvidence(planned, progress, binding.peer);
480
+ if (evidence.state === "gone" && evidence.terminal && matchesTerminal(evidence.terminal)) launchState = "gone";
481
+ else if (evidence.state === "unknown") {
482
+ if (!(launchState === "no-record-yet" && evidence.idless)) launchState = "unknown";
483
+ } else if (evidence.state === "live" && !evidence.session) {
484
+ if (evidence.terminal) launchState = matchesTerminal(evidence.terminal) ? "live" : "replaced";
485
+ else { launchState = "older-instance"; olderHandle = evidence.handle; }
486
+ } else if (evidence.state === "live" && evidence.session) {
487
+ if (now() >= deadline) break;
488
+ await sleep(Math.min(1000, deadline - now()));
489
+ continue;
490
+ }
491
+ }
492
+ const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
493
+ session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
494
+ if (decision.action === "settle" && session) {
454
495
  if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { claude: "zero-turn" });
455
496
  return session;
456
497
  }
498
+ if (decision.action === "fail" || decision.action === "create") throw new AttachmentLauncherGone(decision);
499
+ if (decision.action === "block") {
500
+ if (launchState === "replaced") throw new Error(`${binding.peer}: saved terminal identity changed while awaiting attachment`);
501
+ if (launchState === "older-instance") throw new Error(`${binding.peer}: an older target's launcher in terminal ${olderHandle ?? "unknown"} may still run; saved terminal retained; no terminal was created`);
502
+ throw new Error(`${binding.peer}: the chosen restoration launcher's identity cannot be verified (${launchState}); saved terminal retained; no terminal was created`);
503
+ }
457
504
  if (now() >= deadline) break;
458
505
  await sleep(Math.min(1000, deadline - now()));
459
506
  }
@@ -468,7 +515,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
468
515
  }
469
516
  } catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
470
517
  const peer = live.peers.find((p) => p.id === binding.peer);
471
- const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
518
+ const session = peer?.state === "offline" ? undefined : binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
472
519
  (progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
473
520
  hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
474
521
  lastHubConnectAttempt: "unknown" };
@@ -485,15 +532,23 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
485
532
  if (liveTestPeer && (!existsSync(identityPath) || !readFileSync(identityPath, "utf8").includes("export const LIVE_TEST_SUPPORTED = true;"))) {
486
533
  throw new FinalRefusal("target does not support live-test delegation; choose a supporting target before retrying; runtimes unchanged");
487
534
  }
488
- // #215: the protocol and waiver checks are properties of these bytes. Once they passed (the runner recorded the
489
- // digest), a resume does not check again, so a transient probe failure cannot refuse an operation for good;
490
- // changed bytes are the runner's final "staged target changed".
491
- if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return target;
492
- const protocol = await run([process.execPath, "-e", `import { PROTOCOL } from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(PROTOCOL)`]);
493
- const read = protocol.code === 0 ? Number.parseInt(protocol.stdout.trim(), 10) : NaN;
494
- // A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
495
- if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
496
- if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
535
+ // Reuse capability proof only after verifying the cached root and digest still identify these exact bytes.
536
+ const cached = op.targetDigest === target.digest && op.targetRoot === target.root;
537
+ if (cached && op.targetFeatures !== undefined) return { ...target, targetFeatures: [...op.targetFeatures] };
538
+ // A legacy cached operation already past commit needs no capability proof to finish restoration (#215).
539
+ // A prepared commitSent receipt remains uncertain and must still obtain proof before a possible commit.
540
+ if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) return target;
541
+ // Legacy uncommitted receipts acquire verified feature evidence before source compatibility checks.
542
+ const probe = await run([process.execPath, "-e", `import * as target from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(JSON.stringify({protocol: target.PROTOCOL, features: target.DAEMON_FEATURES ?? []}))`]);
543
+ let metadata: { protocol?: unknown; features?: unknown } | undefined;
544
+ try { if (probe.code === 0) metadata = JSON.parse(probe.stdout); } catch { /* Unknown metadata refuses before effects. */ }
545
+ if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
546
+ if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
547
+ const targetFeatures: readonly string[] = [...metadata.features];
548
+ if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
549
+ const verified = { ...target, targetFeatures };
550
+ // Existing peer/waiver compatibility checks remain properties of the already verified bytes.
551
+ if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
497
552
  if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "qwen" && peer.state !== "offline"))) {
498
553
  const catalog = join(target.root, "src/hub/peer-catalog.ts");
499
554
  const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
@@ -508,11 +563,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
508
563
  if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
509
564
  throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
510
565
  }
511
- return target;
566
+ return verified;
512
567
  },
513
- prepare: (p, id, instance) => control(p, "prepare", id, instance),
568
+ prepare: (p, id, instance, features) => control(p, "prepare", id, instance, features),
514
569
  abort: (p, id, instance) => control(p, "abort", id, instance),
515
- commit: (p, id, instance) => control(p, "commit", id, instance),
570
+ commit: (p, id, instance, features) => control(p, "commit", id, instance, features),
516
571
  release: (p, id, instance) => control(p, "release", id, instance),
517
572
  closeTerminals: async (planned, progress, op, save) => {
518
573
  for (const binding of planned.terminals as TerminalBinding[]) {
@@ -627,22 +682,35 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
627
682
  : evidence.session
628
683
  ? `${original.peer}: session ${evidence.session} is attached instead of ${original.sessionId}; no terminal was created; end that ${original.peer} session and close its terminal first`
629
684
  : `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session ${evidence.attached ? "with an id " : ""}attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
630
- const receipt = progress.terminals[key];
631
- if (receipt && receipt !== "pending" && receipt !== "failed") {
632
- const binding = receipt as TerminalBinding;
633
- const sessionId = await awaitAttachment(planned, progress, op, binding, save, fresh() && binding.sessionId === original.sessionId ? null : binding.sessionId);
634
- progress.terminals[key] = { ...binding, sessionId }; save();
635
- progress.terminals[key] = await revalidateTerminal(planned, progress, progress.terminals[key] as TerminalBinding, true); save();
636
- continue;
685
+ const decide = (receipt: RestoreReceipt, evidence: Awaited<ReturnType<typeof peerEvidence>>) => restoreTransition(receipt, {
686
+ target: "running", launcher: evidence.state === "unknown" ? "unknown" : evidence.state === "gone" ? "gone" : evidence.terminal ? "live" : "older-instance",
687
+ session: evidence.state === "live" && evidence.session ? evidence.session === original.sessionId ? "planned" : "other" : "none",
688
+ allowOther: fresh() || (original.peer === "claude" && !claudeTranscriptExists(original)),
689
+ binding: evidence.state === "live" && evidence.terminal ? { ...original, ...evidence.terminal } : original,
690
+ });
691
+ const receipt = decodeRestoreReceipt(progress.terminals[key]);
692
+ function awaitBound(waiting: Extract<RestoreReceipt, { state: "waiting" }> & { origin: "pending" }, expected: string | null): Promise<string>;
693
+ function awaitBound(waiting: Extract<RestoreReceipt, { state: "waiting" }>, expected: string | null): Promise<string | undefined>;
694
+ async function awaitBound(waiting: Extract<RestoreReceipt, { state: "waiting" }>, expected: string | null): Promise<string | undefined> {
695
+ try { return await awaitAttachment(planned, progress, op, waiting, save, expected); }
696
+ catch (error) {
697
+ if (!(error instanceof AttachmentLauncherGone)) throw error;
698
+ progress.terminals[key] = error.transition.next; save();
699
+ // The table decides whether this failed wait blocks or is eligible for a new launch.
700
+ if (error.transition.action === "create") return undefined;
701
+ throw notRestored(`its launcher no longer runs and no ${original.peer} session attached`);
702
+ }
637
703
  }
638
704
  // #215: the pending row of the receipt table, used wherever a pending launch meets an attached session.
639
- const settle = async (attached: string) => {
705
+ const settle = async (attached: string, recovered?: TerminalBinding) => {
640
706
  // #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
641
707
  // succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
642
708
  // the operator or the plan accepted, counts as restored.
643
709
  if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
644
710
  throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${attached} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
645
711
  }
712
+ // Validate the proposed transition before committing continuity/waiver effects or a done receipt.
713
+ const verified = await revalidateTerminal(planned, progress, attachedBinding(recovered ?? original, attached), !!recovered);
646
714
  // The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
647
715
  // never with a session accepted after a restart (#225), whose choice must stay on record.
648
716
  if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
@@ -653,31 +721,58 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
653
721
  // a launch writes the waiver first, but this must not rely on that.
654
722
  // The planned session needs no waiver: the target's snapshot already expects it.
655
723
  if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
656
- progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
724
+ progress.terminals[key] = { state: "done", binding: verified } satisfies RestoreReceipt; save();
657
725
  };
658
- // #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
659
- if (receipt === "pending" || receipt === "failed") {
726
+ if (receipt?.state === "done") {
727
+ // A completed restoration is never a waiting launch. A closed terminal blocks exact revalidation;
728
+ // it cannot erase continuity or create a fresh-session choice (PROBE-B).
729
+ const observed = await peerEvidence(planned, progress, original.peer);
730
+ const decision = restoreTransition(receipt, { target: "running", launcher: "no-record-yet", session: observed.state === "live" && observed.session === receipt.binding.sessionId ? "planned" : observed.state === "live" && observed.session ? "other" : "none", allowOther: false });
731
+ if (decision.action === "block") {
732
+ const condition = observed.state === "unknown" ? `cannot be verified (${observed.why})`
733
+ : observed.state === "live" && observed.session ? `session ${observed.session} is attached instead of ${receipt.binding.sessionId}`
734
+ : observed.state === "live" ? `is not attached; its launcher in terminal ${observed.handle ?? "unknown"} may still run` : "is not attached";
735
+ throw new Error(`${original.peer}: completed restoration ${condition}; saved done receipt retained; resume its recorded conversation`);
736
+ }
737
+ progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, receipt.binding, true) } satisfies RestoreReceipt; save();
738
+ continue;
739
+ }
740
+ if (receipt?.state === "waiting") {
741
+ const binding = receipt.binding;
742
+ const sessionId = await awaitBound(receipt, fresh() && binding.sessionId === original.sessionId ? null : binding.sessionId);
743
+ if (sessionId !== undefined) { await settle(sessionId, binding); continue; }
744
+ }
745
+ if (receipt?.state === "pending" || receipt?.state === "failed") {
660
746
  const evidence = await peerEvidence(planned, progress, original.peer);
661
- if (evidence.state === "live" && evidence.session) { await settle(evidence.session); continue; }
662
- if (evidence.state !== "gone") throw blocked(evidence);
663
- if (receipt === "pending") {
664
- progress.terminals[key] = "failed"; save();
747
+ const decision = decide(receipt, evidence);
748
+ if (decision.action === "settle" && evidence.state === "live" && evidence.session) { await settle(evidence.session); continue; }
749
+ if (decision.action === "poll" && decision.next.state === "waiting") {
750
+ const waiting = decision.next;
751
+ progress.terminals[key] = waiting; save();
752
+ const session = await awaitBound(waiting, fresh() ? null : original.sessionId);
753
+ if (session !== undefined) { await settle(session, waiting.binding); continue; }
754
+ } else if (decision.action === "fail") {
755
+ progress.terminals[key] = decision.next; save();
665
756
  throw notRestored(`its launcher no longer runs and no ${original.peer} session attached`);
757
+ } else if (decision.action === "block") {
758
+ if (evidence.state === "live" && evidence.session) {
759
+ throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${evidence.session} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
760
+ }
761
+ if (evidence.state !== "gone") throw blocked(evidence);
762
+ throw new Error(`${original.peer}: restoration evidence is not actionable`);
666
763
  }
667
- // failed, nothing live: launched again below; the receipt stays `failed` until `pending` replaces it, so a
668
- // refusal on the way keeps the failed-restoration choices
669
764
  }
670
765
  // Never create beside a live launch, nor on evidence that could not be read: read now, not at restore start.
671
766
  // The planned session already attached is the restoration (the table's row for it), not a reason to block.
672
767
  const evidence = await peerEvidence(planned, progress, original.peer);
673
768
  if (evidence.state === "live" && evidence.session === original.sessionId) {
674
- progress.terminals[key] = await revalidateTerminal(planned, progress, original, false); save();
769
+ progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, original, false) } satisfies RestoreReceipt; save();
675
770
  continue;
676
771
  }
677
772
  if (evidence.state !== "gone") throw blocked(evidence);
678
773
  if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
679
774
  if (transcript() === "missing" && !fresh()) {
680
- progress.terminals[key] = "failed"; save();
775
+ progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
681
776
  throw notRestored(unresumable(original).slice("codex: ".length));
682
777
  }
683
778
  if (fresh()) {
@@ -691,7 +786,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
691
786
  const launch = { ...original.launch, packageEntrypoint: entrypoint, argv,
692
787
  command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
693
788
  const binding = { ...original, launch, launchMetadata: launch };
694
- progress.terminals[key] = "pending"; save();
789
+ progress.terminals[key] = { state: "pending" } satisfies RestoreReceipt; save();
695
790
  const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
696
791
  const exited = restored.blockers.find((b) => b.code === "launcher-exited");
697
792
  if (exited) {
@@ -699,25 +794,34 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
699
794
  // nothing live makes it failed; an attached session is settled as the pending row settles it; anything else
700
795
  // stays pending.
701
796
  const after = await peerEvidence(planned, progress, original.peer);
702
- if (after.state === "live" && after.session) { await settle(after.session); continue; }
797
+ const decision = decide({ state: "pending" }, after);
798
+ if (decision.action === "settle" && after.state === "live" && after.session) { await settle(after.session); continue; }
799
+ if (decision.action === "poll" && decision.next.state === "waiting") {
800
+ progress.terminals[key] = decision.next; save();
801
+ const session = await awaitBound(decision.next, fresh() ? null : original.sessionId);
802
+ if (session !== undefined) { await settle(session, decision.next.binding); continue; }
803
+ }
804
+ if (decision.action === "fail") {
805
+ progress.terminals[key] = decision.next; save();
806
+ throw notRestored(exited.message);
807
+ }
703
808
  if (after.state !== "gone") throw blocked(after);
704
- progress.terminals[key] = "failed"; save();
705
- throw notRestored(exited.message);
706
809
  }
707
810
  if (restored.manualRequired || !restored.newBinding) {
708
811
  const handle = restored.binding?.handle ?? restored.blockers[0]?.handle;
709
812
  throw new Error(`${original.peer}: original session restoration needs manual verification${restored.blockers[0] ? ` (${restored.blockers[0].message})` : ""}; wait until it attaches, or end that launch and close its terminal${handle ? ` ${handle}` : ""} first`);
710
813
  }
711
814
  // The daemon's id is authoritative: a fresh session is recorded under the id it reports once attached.
712
- progress.terminals[key] = restored.newBinding; save();
713
- const sessionId = await awaitAttachment(planned, progress, op, restored.newBinding, save, fresh() ? null : restored.newBinding.sessionId);
714
- progress.terminals[key] = { ...restored.newBinding, sessionId }; save();
815
+ const awaiting = { state: "waiting", origin: "pending", binding: restored.newBinding } satisfies RestoreReceipt;
816
+ progress.terminals[key] = awaiting; save();
817
+ const sessionId = await awaitBound(awaiting, fresh() ? null : restored.newBinding.sessionId);
818
+ await settle(sessionId, awaiting.binding);
715
819
  }
716
820
  if (group === "claude") for (const id of planned.reconnectOnly ?? []) {
717
821
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
718
- const deadline = now() + RECONNECT_WAIT_MS;
822
+ const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
719
823
  while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
720
- if (now() >= deadline) throw new Error(`${id}: the unmanaged session did not reconnect within ${RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`);
824
+ if (now() >= deadline) throw new Error(`${id}: the unmanaged session did not reconnect within ${UNMANAGED_RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`);
721
825
  await sleep(1000);
722
826
  }
723
827
  }
@@ -771,9 +875,9 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
771
875
  if (old.id === "pi" && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw new Error("Pi resumed a different conversation");
772
876
  }
773
877
  for (const original of planned.terminals as TerminalBinding[]) {
774
- const saved = progress.terminals[`restored:${original.peer}`];
775
- if (!saved || typeof saved === "string") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
776
- await revalidateTerminal(planned, progress, saved as TerminalBinding, true);
878
+ const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
879
+ if (saved?.state !== "done") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
880
+ await revalidateTerminal(planned, progress, saved.binding, true);
777
881
  }
778
882
  const snapshot = await rpc(planned.project, { t: "ui_snapshot", after: 0 });
779
883
  if (snapshot.ok === false) throw new Error("dashboard snapshot readback failed");
@@ -1,3 +1,4 @@
1
+ import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
1
2
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
2
3
  import { Database } from "bun:sqlite";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -87,6 +88,8 @@ export interface RecoveryOperation {
87
88
  sourceRoot: string;
88
89
  targetRoot?: string;
89
90
  targetDigest?: string;
91
+ /** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
92
+ targetFeatures?: readonly string[];
90
93
  projects: ProjectProgress[];
91
94
  pluginInstalled?: boolean;
92
95
  globalInstalled?: boolean;
@@ -127,7 +130,7 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
127
130
 
128
131
  /**
129
132
  * #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
130
- * the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
133
+ * the time stay for status and audit. Each done or waiting `restored:<peer>` binding of the dead instance is retired
131
134
  * (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
132
135
  * revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
133
136
  * The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
@@ -135,9 +138,11 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
135
138
  export function recordRestart(progress: ProjectProgress, at: number): void {
136
139
  (progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
137
140
  for (const [key, value] of Object.entries(progress.terminals)) {
138
- if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
141
+ if (!key.startsWith("restored:")) continue;
142
+ const receipt = decodeRestoreReceipt(value);
143
+ if (receipt?.state !== "done" && receipt?.state !== "waiting") continue;
139
144
  const peer = key.slice("restored:".length);
140
- progress.terminals[`retired:${peer}`] = value;
145
+ progress.terminals[`retired:${peer}`] = receipt.binding;
141
146
  delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
142
147
  delete progress.terminals[key];
143
148
  }
@@ -280,7 +285,7 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
280
285
  */
281
286
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
282
287
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
283
- return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && value === "failed").map(([key]) => key.slice("restored:".length))
288
+ return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
284
289
  .filter((peer) => peer !== "pi" && !progress.fresh?.[peer] && !planned?.freshStart?.includes(peer) && !plannedAttached(op, progress, peer, live));
285
290
  }
286
291
 
@@ -341,17 +346,18 @@ function abandoned(op: RecoveryOperation): boolean {
341
346
 
342
347
  function validateReceipt(id: string, op: RecoveryOperation): void {
343
348
  if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
349
+ if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
344
350
  const { fingerprint, ...reviewed } = op.plan;
345
351
  if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
346
352
  op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
347
353
  }
348
354
 
349
355
  export interface RecoveryDriver {
350
- stage(op: RecoveryOperation): Promise<{ root: string; digest: string }>;
351
- inspect(project: Project): Promise<Inspection>;
352
- prepare(project: Project, operation: string, instance: string): Promise<void>;
356
+ stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
357
+ inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
358
+ prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
353
359
  abort(project: Project, operation: string, instance: string): Promise<void>;
354
- commit(project: Project, operation: string, instance: string): Promise<void>;
360
+ commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
355
361
  closeTerminals(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save: () => void): Promise<void>;
356
362
  start(project: Project, op: RecoveryOperation): Promise<void>;
357
363
  restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
@@ -383,6 +389,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
383
389
  return op;
384
390
  }
385
391
  } catch (error) { releaseRunner(); throw error; }
392
+ let targetFeatures: readonly string[] = [];
386
393
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
387
394
  const step = (value: string) => { op.step = value; save(); };
388
395
  // #225: one restart per project per resume, so a target that keeps dying never spins unattended.
@@ -422,7 +429,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
422
429
  // Prepare, or after an expired lease re-prepare, the planned source and wait until it is quiet.
423
430
  const prepareSource = async (planned: PlannedProject, progress: ProjectProgress, again = false) => {
424
431
  const project = planned.project, instance = planned.source.instanceId!;
425
- await driver.prepare(project, id, instance);
432
+ await driver.prepare(project, id, instance, targetFeatures);
426
433
  const deadline = driver.now() + idleTimeoutMs;
427
434
  for (;;) {
428
435
  const live = await driver.inspect(project);
@@ -452,13 +459,16 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
452
459
  step("stage");
453
460
  const target = await driver.stage(op);
454
461
  if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
455
- op.targetRoot = target.root; op.targetDigest = target.digest; save();
462
+ targetFeatures = target.targetFeatures ?? op.targetFeatures ?? [];
463
+ op.targetRoot = target.root; op.targetDigest = target.digest;
464
+ if (target.targetFeatures !== undefined) op.targetFeatures = [...target.targetFeatures];
465
+ save();
456
466
 
457
- // Check every untouched source before stopping even the first project.
467
+ // Pending sources receive the verified target features before holds or terminal/runtime effects.
458
468
  for (let i = 0; i < op.projects.length; i++) {
459
469
  const progress = op.projects[i]!, planned = op.plan.projects[i]!;
460
470
  if (progress.phase !== "pending") continue;
461
- const live = await driver.inspect(planned.project);
471
+ const live = await driver.inspect(planned.project, targetFeatures);
462
472
  if (!["running", "stopped", "missing"].includes(live.state)) throw new Error(`${planned.project.id}: the source reads as ${live.state}; wait until it answers`);
463
473
  if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
464
474
  throw new Error(`${planned.project.id}: source runtime changed`);
@@ -486,7 +496,14 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
486
496
  }
487
497
  if (progress.phase === "prepared") {
488
498
  step(`commit:${project.id}`);
489
- let live = await driver.inspect(project);
499
+ let live: Inspection;
500
+ try { live = await driver.inspect(project); }
501
+ catch (error) {
502
+ if (!progress.commitSent || !isDatabaseBusy(error)) throw error;
503
+ live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
504
+ }
505
+ const resumedShutdown = progress.commitSent && live.state === "unavailable";
506
+ let awaitShutdown = resumedShutdown;
490
507
  if (live.state === "running" && live.instanceId === planned.source.instanceId) {
491
508
  if (live.recovery?.operationId !== id || !live.recovery.ready) {
492
509
  const other = live.recovery?.operationId;
@@ -503,8 +520,22 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
503
520
  await driver.closeTerminals(planned, progress, op, save);
504
521
  // Durable, unlike `step`: from here the source may have committed, which status and abort must not forget.
505
522
  progress.commitSent = true; save();
506
- await driver.commit(project, id, planned.source.instanceId!);
523
+ await driver.commit(project, id, planned.source.instanceId!, targetFeatures);
524
+ awaitShutdown = true;
525
+ } else if (live.state === "running") {
526
+ throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
527
+ } else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
528
+ // #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
529
+ // prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
530
+ throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
531
+ }
532
+ if (awaitShutdown) {
507
533
  const deadline = driver.now() + 30_000;
534
+ // An already-sent commit can be between its final reply and the source closing its control server.
535
+ if (resumedShutdown) {
536
+ if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
537
+ await driver.sleep(100);
538
+ }
508
539
  do {
509
540
  try { live = await driver.inspect(project); }
510
541
  catch (error) {
@@ -517,12 +548,6 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
517
548
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
518
549
  await driver.sleep(100);
519
550
  } while (driver.now() < deadline);
520
- } else if (live.state === "running") {
521
- throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
522
- } else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
523
- // #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
524
- // prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
525
- throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
526
551
  }
527
552
  if (live.state === "missing") throw new Error(`${project.id}: the project directory is missing`);
528
553
  if (live.state !== "stopped") throw new Error(`${project.id}: old shutdown is not verified (the source reads as ${live.state}); not starting a second daemon; check ${recoveryCommand(op, "status")} once it answers`);
@@ -584,12 +609,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
584
609
 
585
610
  /** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
586
611
  export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
587
- const effect = (value: unknown) => value === "pending" || value === "failed" ? value : "done";
612
+ const effect = (key: string, value: unknown) => key.startsWith("restored:") ? restoreReceiptStatus(value) : value === "pending" || value === "failed" ? value : "done";
588
613
  const next = nextActions(op, runnerPid, live);
589
614
  return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
590
615
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
591
616
  ...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
592
- projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(value)])) } : {}),
617
+ projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(key, value)])) } : {}),
593
618
  ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
594
619
  ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
595
620
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
@@ -669,10 +694,10 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
669
694
  const peer = choice.fresh;
670
695
  // Per project: a planned fresh start has nothing to lose, so only the other failed projects take the choice.
671
696
  const failed = op.projects.filter((p) => failedPeers(op, p, live).includes(peer));
672
- if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
697
+ if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
673
698
  throw new Error(`${peer}: its plan already restarts it as a new session (no rollout and no turn, nothing to lose), so there is no conversation to record as lost; ${next}`);
674
699
  }
675
- if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && plannedAttached(op, p, peer, live))) {
700
+ if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && plannedAttached(op, p, peer, live))) {
676
701
  throw new Error(`${peer}: its planned session is attached again, so nothing was lost; resume records it as restored; ${next}`);
677
702
  }
678
703
  if (!failed.length) throw new Error(`${peer}: no failed restoration of it is open to a fresh session (none failed, or one is already chosen); --fresh-session applies only then (${recoveryCommand(op, "status")})`);