@staix/agent-hub 0.12.20 → 0.12.21

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 (60) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +6 -5
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/bus.md +5 -2
  5. package/docs/agent-notes/daemon.md +8 -2
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +3 -1
  8. package/docs/agent-notes/tests.md +3 -2
  9. package/docs/bench.md +143 -0
  10. package/docs/events.md +2 -1
  11. package/docs/operations.md +271 -46
  12. package/docs/quickstart.md +12 -10
  13. package/docs/research.md +92 -0
  14. package/docs/security.md +45 -10
  15. package/docs/smoke.md +79 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
  18. package/docs/verified.json +28 -17
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +3 -2
  22. package/src/adapters/acp.ts +67 -6
  23. package/src/adapters/codex-appserver.ts +67 -2
  24. package/src/adapters/local-worker.ts +4 -3
  25. package/src/adapters/pi.ts +58 -4
  26. package/src/cli/bench.ts +287 -0
  27. package/src/cli/console-state.ts +226 -28
  28. package/src/cli/console.ts +16 -4
  29. package/src/cli/facts-hook.ts +50 -8
  30. package/src/cli/help.ts +10 -0
  31. package/src/cli/identity.ts +10 -4
  32. package/src/cli/launch.ts +96 -17
  33. package/src/cli/main.ts +159 -18
  34. package/src/cli/preview.ts +13 -3
  35. package/src/cli/status-lines.ts +2 -1
  36. package/src/cli/terminal-recovery.ts +83 -31
  37. package/src/cli/upgrade-runtime.ts +67 -18
  38. package/src/cli/upgrade.ts +81 -9
  39. package/src/hub/bench.ts +211 -0
  40. package/src/hub/bus.ts +19 -4
  41. package/src/hub/conductor.ts +22 -21
  42. package/src/hub/config-trust.ts +1 -0
  43. package/src/hub/control-client.ts +4 -1
  44. package/src/hub/daemon.ts +346 -40
  45. package/src/hub/delivery-journal.ts +6 -6
  46. package/src/hub/events.ts +2 -1
  47. package/src/hub/lifecycle.ts +8 -13
  48. package/src/hub/manager.ts +16 -14
  49. package/src/hub/permission-mode.ts +44 -0
  50. package/src/hub/recovery-store.ts +21 -9
  51. package/src/hub/registry.ts +36 -22
  52. package/src/hub/research.ts +255 -0
  53. package/src/hub/tasks.ts +70 -43
  54. package/src/hub/tool-refusal.ts +7 -0
  55. package/src/hub/ui.ts +4 -1
  56. package/src/local/tools.ts +14 -10
  57. package/src/pi/process-signature.ts +23 -0
  58. package/src/pi/tool-receipts.ts +7 -1
  59. package/src/ui/index.html +83 -6
  60. package/src/ui/task-progress.ts +33 -0
@@ -2,7 +2,7 @@ import { isAbsolute, join, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
- import { processSignature } from "../pi/process-signature.ts";
5
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
6
6
  import { atomicPrivateJSON } from "../hub/recovery-store.ts";
7
7
 
8
8
  /** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
@@ -130,6 +130,12 @@ export interface TerminalRecoveryOptions {
130
130
  instanceId?: string;
131
131
  /** Injectable process-start identity used to reject stale or reused launcher PIDs. */
132
132
  processIdentity?: ProcessIdentity;
133
+ /**
134
+ * #228: the identity as releases up to 0.12.12 computed it (unpinned `ps`, the reader's TZ and locale). It defaults
135
+ * to that read only when `processIdentity` is the default, so an injected identity is never second-guessed by a
136
+ * real `ps` read of the same pid.
137
+ */
138
+ legacyIdentity?: ProcessIdentity;
133
139
  }
134
140
 
135
141
  export interface RecordedTerminalLaunch {
@@ -233,6 +239,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
233
239
  stateDir: config.stateDir ?? "",
234
240
  instanceId: config.instanceId ?? "",
235
241
  processIdentity: config.processIdentity ?? defaultProcessIdentity,
242
+ legacyIdentity: config.legacyIdentity ?? (config.processIdentity ? () => undefined : unpinnedProcessIdentity),
236
243
  };
237
244
  }
238
245
 
@@ -272,22 +279,40 @@ export function recordPath(stateDir: string): string {
272
279
  return join(stateDir, RECORD_FILE);
273
280
  }
274
281
 
275
- /** #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none". */
276
- function launchRecords(stateDir: string): RecordedTerminalLaunch[] | "unreadable" {
277
- if (!stateDir || !existsSync(recordPath(stateDir))) return [];
282
+ function validRecord(item: unknown): item is RecordedTerminalLaunch {
283
+ const row = object(item);
284
+ return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
285
+ }
286
+
287
+ /**
288
+ * #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none".
289
+ * #228: a row that fails validation is kept apart (`invalid`), never dropped: whatever it would answer is unknown, and
290
+ * `all` is the file as read, so a writer puts every such row back unchanged.
291
+ */
292
+ type LaunchRecords = { rows: RecordedTerminalLaunch[]; invalid: unknown[]; all: unknown[] };
293
+ function launchRecords(stateDir: string): LaunchRecords | "unreadable" {
294
+ const none = { rows: [], invalid: [], all: [] };
295
+ if (!stateDir || !existsSync(recordPath(stateDir))) return none;
278
296
  try {
279
297
  const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
280
298
  if (!Array.isArray(value)) return "unreadable";
281
- return value.filter((item): item is RecordedTerminalLaunch => {
282
- const row = object(item);
283
- return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
284
- });
299
+ return { rows: value.filter(validRecord), invalid: value.filter((item) => !validRecord(item)), all: value };
285
300
  } catch (error) {
286
- return (error as NodeJS.ErrnoException).code === "ENOENT" ? [] : "unreadable";
301
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? none : "unreadable";
287
302
  }
288
303
  }
289
304
 
290
- function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): void {
305
+ /**
306
+ * #228: whether a row that cannot be evaluated may answer a question about this peer, project and instance: it does
307
+ * unless one of its fields is a readable string that names another one (a row for another peer or an older instance).
308
+ */
309
+ function concerns(raw: unknown, query: { peer?: string; projectRoot: string; instanceId: string }): boolean {
310
+ const row = object(raw);
311
+ const other = (value: unknown, wanted?: string) => wanted !== undefined && typeof value === "string" && value !== wanted;
312
+ return !other(row.peer, query.peer) && !other(row.projectRoot, query.projectRoot) && !other(row.instanceId, query.instanceId);
313
+ }
314
+
315
+ function writeLaunchRecords(stateDir: string, rows: unknown[]): void {
291
316
  mkdirSync(stateDir, { recursive: true });
292
317
  atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
293
318
  }
@@ -297,30 +322,50 @@ function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): v
297
322
  * process holds it now; unknown when the pid exists but its identity cannot be read. Unknown is never gone.
298
323
  */
299
324
  export type LauncherState = "live" | "gone" | "unknown";
300
- async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity): Promise<LauncherState> {
301
- let signature: string | undefined;
302
- try { signature = await identity(row.launcherPid); } catch { /* unreadable: decided by the pid probe below */ }
303
- if (typeof signature === "string" && signature.length > 0) return signature === row.launcherSignature ? "live" : "gone";
304
- try { process.kill(row.launcherPid, 0); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return "gone"; }
305
- return "unknown";
325
+ async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<LauncherState> {
326
+ let current: string | undefined;
327
+ try { current = await identity(row.launcherPid) || undefined; } catch { /* unreadable: decided by the pid probe */ }
328
+ // #228: a launcher recorded by 0.12.12 or older hashed `ps` in its own TZ and locale (#177 pinned it in 0.12.13). Read
329
+ // the same unpinned way in this reader's environment, a match is that launcher; anything else stays gone (pid reuse).
330
+ if (current && current !== row.launcherSignature) {
331
+ let old: string | undefined;
332
+ try { old = await legacy(row.launcherPid) || undefined; } catch { /* no legacy reading: the pinned one decides */ }
333
+ if (old === row.launcherSignature) return "live";
334
+ }
335
+ return processLiveness(row.launcherPid, row.launcherSignature, () => current);
306
336
  }
307
337
 
308
- /** This instance's launch records whose launcher is live, and those whose launcher cannot be identified (never gone). */
309
- async function recordsByState(rows: RecordedTerminalLaunch[], projectRoot: string, instanceId: string, identity: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[] }> {
310
- if (!instanceId) return { live: [], unknown: [] };
311
- const candidates = rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
312
- const states = await Promise.all(candidates.map((item) => launcherState(item, identity)));
313
- return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown") };
338
+ /** The launcher signature as releases up to 0.12.12 computed it: `ps` in the caller's own TZ and locale, unpinned. */
339
+ function unpinnedProcessIdentity(pid: number): string | undefined {
340
+ try {
341
+ const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
342
+ const text = result.exitCode === 0 ? result.stdout.toString().trim() : "";
343
+ return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined;
344
+ } catch { return undefined; }
345
+ }
346
+
347
+ /**
348
+ * This instance's launch records whose launcher is live, those whose launcher cannot be identified (never gone), and
349
+ * the rows that cannot be evaluated and may be this project's and instance's (#228: unknown for their peer).
350
+ */
351
+ async function recordsByState(records: LaunchRecords, projectRoot: string, instanceId: string, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[]; invalid: unknown[] }> {
352
+ if (!instanceId) return { live: [], unknown: [], invalid: [] };
353
+ const candidates = records.rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
354
+ const states = await Promise.all(candidates.map((item) => launcherState(item, identity, legacy)));
355
+ return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown"),
356
+ invalid: records.invalid.filter((raw) => concerns(raw, { projectRoot, instanceId })) };
314
357
  }
315
358
 
316
359
  /** #215: the launcher recorded for `peer` on this instance (one row per peer and instance) and its state; managed means live. */
317
- export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState } | undefined> {
360
+ export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState; invalidRow?: true } | undefined> {
318
361
  const config = normalizeOptions(options);
319
362
  if (!config.stateDir || !config.instanceId) return undefined;
320
- const rows = launchRecords(config.stateDir);
321
- if (rows === "unreadable") return { state: "unknown" };
322
- const record = rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
323
- return record ? { record, state: await launcherState(record, config.processIdentity) } : undefined;
363
+ const records = launchRecords(config.stateDir);
364
+ if (records === "unreadable") return { state: "unknown" };
365
+ // #228: a row that cannot be evaluated and may be this launcher's is unknown, never "no launcher".
366
+ if (records.invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) return { state: "unknown", invalidRow: true };
367
+ const record = records.rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
368
+ return record ? { record, state: await launcherState(record, config.processIdentity, config.legacyIdentity) } : undefined;
324
369
  }
325
370
 
326
371
  /**
@@ -353,7 +398,8 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
353
398
  if (existing === "unreadable") {
354
399
  throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
355
400
  }
356
- const rows = existing.filter((item) => !(item.peer === peer && item.instanceId === instanceId));
401
+ // #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
402
+ const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
357
403
  rows.push(row);
358
404
  writeLaunchRecords(stateDir, rows);
359
405
  return row;
@@ -482,7 +528,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
482
528
  // CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
483
529
  const rows = launchRecords(config.stateDir);
484
530
  if (rows === "unreadable") return { bindings, byPeer, blockers: [blocker("command-error", `${recordPath(config.stateDir)} cannot be read, so no terminal can be bound to its launch; inspect or move it aside, then plan or resume again`)], manualRequired: true };
485
- const { live: records, unknown } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity);
531
+ const { live: records, unknown, invalid } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity, config.legacyIdentity);
486
532
 
487
533
  let listed: Record<string, unknown>[];
488
534
  try {
@@ -500,6 +546,10 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
500
546
  blockers.push(blocker("ownership-unknown", `the ${peer} launcher recorded for terminal ${unread.handle} (pid ${unread.launcherPid}) cannot be identified, so no terminal can be bound to its launch; wait until it exits or end it, then plan or resume again`, peer, unread.handle));
501
547
  continue;
502
548
  }
549
+ if (invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) {
550
+ blockers.push(blocker("ownership-unknown", `${recordPath(config.stateDir)} holds a launch record that cannot be evaluated and may be this ${peer} launcher's, so no terminal can be bound to its launch; next action: inspect that row and fix it, or end that launcher and remove its row, then plan or resume again`, peer));
551
+ continue;
552
+ }
503
553
  const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
504
554
  const peerCandidates: Record<string, unknown>[] = [];
505
555
  for (const listedTerminal of candidates) {
@@ -639,8 +689,10 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
639
689
  const launches = async () => {
640
690
  const rows = launchRecords(config.stateDir);
641
691
  if (rows === "unreadable") return { live: undefined, gone: false, mayRun: true }; // unknown, never gone
642
- const possible = rows.filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
643
- const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity) })));
692
+ // #228: a row that cannot be evaluated and may be this launch's is unknown too.
693
+ if (rows.invalid.some((raw) => concerns(raw, { peer: binding.peer, projectRoot: binding.projectRoot, instanceId: config.instanceId }))) return { live: undefined, gone: false, mayRun: true };
694
+ const possible = rows.rows.filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
695
+ const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity, config.legacyIdentity) })));
644
696
  return { live: states.find((entry) => entry.state === "live")?.item, gone: states.length > 0 && states.every((entry) => entry.state === "gone"), mayRun: states.some((entry) => entry.state !== "gone") };
645
697
  };
646
698
  // #215: Orca types the command into a login shell that outlives it, so the terminal never exits with the launcher.
@@ -92,7 +92,11 @@ async function rpc(project: Project, message: Record<string, unknown>, protocol
92
92
  }
93
93
 
94
94
  export async function inspectRecovery(project: Project): Promise<Inspection> {
95
- const base = await inspectProject(project);
95
+ // #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
96
+ // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
97
+ // caller reads that as not inspected, never as stopped.
98
+ const claim = registeredProjects().find((p) => p.id === project.id);
99
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
96
100
  const control = readControl(project.stateDir);
97
101
  const sourceProtocol = control?.protocol;
98
102
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
@@ -175,7 +179,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
175
179
  if (recorded?.state === "unknown") {
176
180
  // An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
177
181
  delete peer.sessionId;
178
- blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
182
+ blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
179
183
  continue;
180
184
  }
181
185
  const launcher = recorded?.state === "live" ? recorded.record! : undefined;
@@ -305,7 +309,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
305
309
  * expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
306
310
  */
307
311
  const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
308
- Promise<{ state: "live"; session?: string; handle?: string } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
312
+ Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
309
313
  const target = await inspectRecovery(planned.project);
310
314
  if (target.state !== "running" || target.instanceId !== progress.instanceId) {
311
315
  // Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
@@ -315,14 +319,44 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
315
319
  const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
316
320
  const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
317
321
  if (session) return { state: "live", session };
318
- const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
319
- if (launch?.state === "live") return { state: "live", handle: launch.record!.handle };
320
- if (launch?.state === "unknown") {
321
- 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}` }
322
- : { 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)` };
322
+ // #225: the launch records of every instance this operation started for the project, the current one first: a
323
+ // launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
324
+ for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
325
+ if (!instanceId) continue;
326
+ const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
327
+ if (launch?.state === "live") return { state: "live", handle: launch.record!.handle, ...(attached ? { attached: true as const } : {}) };
328
+ if (launch?.state === "unknown") {
329
+ 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}` }
330
+ : 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` }
331
+ : { 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)` };
332
+ }
323
333
  }
334
+ // #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
335
+ // not reported its id yet) is a session of unknown identity, never "nothing attached".
336
+ 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` };
324
337
  return { state: "gone" };
325
338
  };
339
+ /**
340
+ * #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
341
+ * (`closedRetired:<peer>` `pending` until Orca no longer lists it; an unreadable or truncated inventory is unknown and
342
+ * changes nothing) or found absent, before its peer is relaunched against the new instance.
343
+ */
344
+ const closeRetired = async (progress: ProjectProgress, peer: string, save: () => void): Promise<void> => {
345
+ const retired = progress.terminals[`retired:${peer}`] as TerminalBinding | undefined;
346
+ const key = `closedRetired:${peer}`;
347
+ if (!retired || typeof retired !== "object" || progress.terminals[key] === true) return;
348
+ const seen = await listed(retired);
349
+ if (progress.terminals[key] === "pending" || seen !== "listed") {
350
+ if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
351
+ progress.terminals[key] = true; save(); return;
352
+ }
353
+ const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
354
+ if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
355
+ progress.terminals[key] = "pending"; save();
356
+ const result = await closeTerminal(retired, 0, terminalOptions(run));
357
+ if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
358
+ progress.terminals[key] = true; save();
359
+ };
326
360
  const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
327
361
  for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
328
362
  const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
@@ -423,17 +457,30 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
423
457
  await command(op, args, planned.project);
424
458
  }
425
459
  }
426
- for (const original of planned.terminals as TerminalBinding[]) {
427
- if ((original.peer === "claude") !== (group === "claude")) continue;
428
- const key = `restored:${original.peer}`;
460
+ for (const plannedBinding of planned.terminals as TerminalBinding[]) {
461
+ if ((plannedBinding.peer === "claude") !== (group === "claude")) continue;
462
+ const key = `restored:${plannedBinding.peer}`;
463
+ // #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
464
+ // instead of the planned one (fresh choice, fresh start, zero-turn Claude) is relaunched by that id, which the
465
+ // operation-fenced waiver already covers.
466
+ await closeRetired(progress, plannedBinding.peer, save);
467
+ const retired = progress.terminals[`retired:${plannedBinding.peer}`] as TerminalBinding | undefined;
468
+ const accepted = retired && typeof retired === "object" && retired.sessionId !== plannedBinding.sessionId ? retired.sessionId : undefined;
469
+ const original = accepted ? { ...plannedBinding, sessionId: accepted } : plannedBinding;
429
470
  // A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
430
471
  // start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
431
472
  const chosen = !!progress.fresh?.[original.peer];
432
473
  let read: ReturnType<typeof codexTranscript> | undefined;
433
- const transcript = () => original.peer !== "codex" || chosen ? "found" : (read ??= codexTranscript(original));
434
- const fresh = () => chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
474
+ const transcript = () => original.peer !== "codex" || (chosen && !accepted) ? "found" : (read ??= codexTranscript(original));
475
+ // An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
476
+ // rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
477
+ const fresh = () => accepted
478
+ ? (original.peer === "codex" ? (chosen || !!planned.freshStart?.includes("codex")) && transcript() === "missing" : original.peer === "claude" && !claudeTranscriptExists(original))
479
+ : chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
435
480
  // The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
436
481
  // peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
482
+ // The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
483
+ const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
437
484
  const notRestored = (why: string) => new Error(fresh()
438
485
  ? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
439
486
  : `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
@@ -442,7 +489,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
442
489
  ? `${original.peer}: whether a ${original.peer} session or launcher is live cannot be told (${evidence.why}); nothing was recorded or created${evidence.step ? `; ${evidence.step} first` : ""}`
443
490
  : evidence.session
444
491
  ? `${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`
445
- : `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
492
+ : `${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`);
446
493
  const receipt = progress.terminals[key];
447
494
  if (receipt && receipt !== "pending" && receipt !== "failed") {
448
495
  progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
@@ -456,14 +503,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
456
503
  if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
457
504
  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`);
458
505
  }
459
- // The original came back after all: a fresh session chosen for it lost nothing.
460
- if (attached === original.sessionId && progress.fresh?.[original.peer]) {
506
+ // The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
507
+ // never with a session accepted after a restart (#225), whose choice must stay on record.
508
+ if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
461
509
  delete progress.fresh[original.peer];
462
510
  if (!Object.keys(progress.fresh).length) delete progress.fresh;
463
511
  }
464
512
  // Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
465
513
  // a launch writes the waiver first, but this must not rely on that.
466
- if (attached !== original.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: progress.fresh?.[original.peer] ? "fresh-session" : fresh() ? "fresh-start" : "zero-turn" });
514
+ // The planned session needs no waiver: the target's snapshot already expects it.
515
+ if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
467
516
  progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
468
517
  };
469
518
  // #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
@@ -494,7 +543,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
494
543
  if (fresh()) {
495
544
  if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
496
545
  // The restored daemon must accept the new session instead of the recorded one.
497
- waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: progress.fresh?.[original.peer] ? "fresh-session" : "fresh-start" });
546
+ waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
498
547
  }
499
548
  const entrypoint = join(op.targetRoot!, "src/cli/main.js");
500
549
  const argv = restoredTerminalArgv(entrypoint, planned.project.root, original, fresh());
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import type { Project } from "../hub/registry.ts";
5
+ import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
6
6
  import { hubHome } from "../hub/project.ts";
7
7
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
8
8
 
@@ -56,9 +56,18 @@ export interface ProjectProgress {
56
56
  fresh?: Record<string, { lost: string; reason: string; at: number }>;
57
57
  /** #215: set before the commit request is sent; from then on the source may have committed (abort is not offered). */
58
58
  commitSent?: boolean;
59
+ /**
60
+ * #225: each restart of a target that stopped after it started, recorded before the new one starts: the dead
61
+ * instance and when. Status and audit read it; restore reads the launch records of every instance listed here.
62
+ */
63
+ restarts?: { instanceId: string; at: number }[];
59
64
  }
60
65
  export interface RecoveryOperation {
61
- schema: 1;
66
+ /**
67
+ * #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
68
+ * abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
69
+ */
70
+ schema: 1 | 2;
62
71
  id: string;
63
72
  plan: UpgradePlan;
64
73
  createdAt: number;
@@ -94,6 +103,37 @@ export function targetReadsWaivers(op: { targetRoot?: string }): boolean {
94
103
  try { return !!op.targetRoot && readFileSync(join(op.targetRoot, "src/hub/restart.ts"), "utf8").includes("export function readRecoveryWaivers"); } catch { return false; }
95
104
  }
96
105
 
106
+ /**
107
+ * #225: whether an operation's coordinator restarts a target that stopped after it started. Resume runs the operation's
108
+ * own runner, so an older one keeps refusing and `next` must not offer resume for that row.
109
+ * ponytail: a text sniff of its upgrade.ts, like targetReadsWaivers and coordinatorCurrent; a rename reads as "cannot
110
+ * restart" (the safe side). Upgrade path: the capability list those two name.
111
+ */
112
+ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
113
+ if (!op.sourceRoot) return true;
114
+ try { return readFileSync(join(op.sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export function recordRestart"); } catch { return false; }
115
+ }
116
+
117
+ /**
118
+ * #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
119
+ * the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
120
+ * (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
121
+ * revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
122
+ * The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
123
+ */
124
+ export function recordRestart(progress: ProjectProgress, at: number): void {
125
+ (progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
126
+ for (const [key, value] of Object.entries(progress.terminals)) {
127
+ if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
128
+ const peer = key.slice("restored:".length);
129
+ progress.terminals[`retired:${peer}`] = value;
130
+ delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
131
+ delete progress.terminals[key];
132
+ }
133
+ delete progress.instanceId;
134
+ progress.phase = "stopped";
135
+ }
136
+
97
137
  /** Effects anywhere in the operation: a project past `prepared` or any terminal receipt. Abort needs none. */
98
138
  export const hasEffects = (op: RecoveryOperation) => op.projects.some((p) => !["pending", "prepared"].includes(p.phase) || Object.keys(p.terminals).length > 0);
99
139
  /**
@@ -144,9 +184,9 @@ export function resumeBlocked(op: RecoveryOperation, live: Record<string, Inspec
144
184
  if (state.state !== "stopped") continue;
145
185
  if (p.phase === "pending") return `${p.id}: the source stopped before it was prepared`;
146
186
  if (p.phase === "prepared" && !p.commitSent && !old) return `${p.id}: the source stopped while prepared, with no commit request`;
147
- // ponytail: a target that dies after it started is not restarted; that needs its peers relaunched against the new
148
- // instance (CodexPeer is created only by start, launcher records are keyed by instance). Follow-up issue.
149
- if (p.phase === "started" || p.phase === "peers-restored") return `${p.id}: the target stopped after it started; restarting it is not supported in this release`;
187
+ // #225: a target that stopped after it started is restarted from this operation's snapshot (the line below), by a
188
+ // coordinator that can; an older one's runner refuses it.
189
+ if ((p.phase === "started" || p.phase === "peers-restored") && !coordinatorRestarts(op)) return `${p.id}: the target stopped after it started; this operation's coordinator cannot restart it`;
150
190
  if (state.snapshot !== op.id) return `${p.id}: its hub stopped without this operation's restart snapshot`;
151
191
  }
152
192
  return undefined;
@@ -231,7 +271,9 @@ export function registeredProjects(home = hubHome()): Project[] {
231
271
  if (!existsSync(path)) return [];
232
272
  const db = new Database(path, { readonly: true });
233
273
  try {
234
- return db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects ORDER BY root").all() as Project[];
274
+ // #226: a registry no current release opened yet has no signature column, and this read never migrates.
275
+ const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
276
+ return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
235
277
  } finally { db.close(); }
236
278
  }
237
279
 
@@ -256,8 +298,21 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
256
298
  return op;
257
299
  }
258
300
 
301
+ /**
302
+ * #227: a coordinator from before #215 never reads `disposition`, and an older global `ahub recovery resume` runs it.
303
+ * Every older validateReceipt refuses a schema other than 1 before the lock and the runner claim, so schema 2 keeps it
304
+ * from advancing an operation being abandoned. A #215 coordinator's runner refuses the disposition itself and keeps
305
+ * schema 1, so a 0.12.20 CLI can still rerun its dispose. True when the receipt changed (a disposition recorded at 1
306
+ * by 0.12.20 is moved to 2 by the next command of this release that holds the runner claim).
307
+ */
308
+ function abandoned(op: RecoveryOperation): boolean {
309
+ if (!op.disposition || op.schema !== 1 || !op.sourceRoot || coordinatorCurrent(op.sourceRoot)) return false;
310
+ op.schema = 2;
311
+ return true;
312
+ }
313
+
259
314
  function validateReceipt(id: string, op: RecoveryOperation): void {
260
- if (op.schema !== 1 || op.id !== id) throw new Error("unsupported operation receipt");
315
+ if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
261
316
  const { fingerprint, ...reviewed } = op.plan;
262
317
  if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
263
318
  op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
@@ -302,8 +357,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
302
357
  } catch (error) { releaseRunner(); throw error; }
303
358
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
304
359
  const step = (value: string) => { op.step = value; save(); };
360
+ // #225: one restart per project per resume, so a target that keeps dying never spins unattended.
361
+ const restarted = new Set<string>();
362
+ const stoppedAgain = (progress: ProjectProgress) => `the target stopped again after restart ${progress.restarts?.length ?? 0} in this resume`;
305
363
  const identity = (observed: Inspection, planned: PlannedProject, progress: ProjectProgress) => {
306
- if (observed.state === "stopped") throw new Error(progress.phase !== "stopped" ? `${planned.project.id}: the target stopped after it started (a crash or a reboot); restarting it is not supported in this release`
364
+ if (observed.state === "stopped") throw new Error(restarted.has(progress.id) ? `${planned.project.id}: ${stoppedAgain(progress)}; read its hub.log before resuming again`
365
+ : progress.phase !== "stopped" ? `${planned.project.id}: the target stopped after it started (a crash or a reboot)${observed.snapshot === id ? "; resume restarts it from this operation's snapshot" : " and this operation's restart snapshot is gone, so nothing can start it"}`
307
366
  : `${planned.project.id}: the target is stopped${observed.snapshot === id ? "" : " and this operation's restart snapshot is gone, so nothing can start it"}`);
308
367
  if (observed.state === "missing") throw new Error(`${planned.project.id}: the project directory is missing`);
309
368
  if (observed.state !== "running") throw new Error(`${planned.project.id}: the target reads as ${observed.state}; wait until it answers`);
@@ -381,6 +440,15 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
381
440
  }
382
441
  for (let i = 0; i < op.projects.length; i++) {
383
442
  const progress = op.projects[i]!, planned = op.plan.projects[i]!, project = planned.project;
443
+ if (progress.phase === "started" || progress.phase === "peers-restored") {
444
+ // #225: a target that stopped after it started, with this operation's snapshot, starts again from it.
445
+ const live = await driver.inspect(project);
446
+ if (live.state === "stopped") {
447
+ if (live.snapshot !== id) throw new Error(`${project.id}: the target stopped after it started and this operation's restart snapshot is gone, so nothing can start it`);
448
+ step(`restart:${project.id}`);
449
+ recordRestart(progress, driver.now()); restarted.add(progress.id); save();
450
+ }
451
+ }
384
452
  if (progress.phase === "verified" || progress.phase === "peers-restored") continue;
385
453
  if (progress.phase === "pending") {
386
454
  step(`prepare:${project.id}`);
@@ -486,7 +554,8 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
486
554
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
487
555
  ...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
488
556
  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)])) } : {}),
489
- ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}) })),
557
+ ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
558
+ ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
490
559
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
491
560
  ...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
492
561
  ...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
@@ -507,6 +576,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
507
576
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
508
577
  return;
509
578
  }
579
+ if (abandoned(op)) writeOperation(id, op, home);
510
580
  const { fingerprint, ...body } = op.plan;
511
581
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
512
582
  const live = await liveProjects(op, driver.inspect);
@@ -543,6 +613,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
543
613
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
544
614
  throw new Error(`operation is already ${op.phase}`);
545
615
  }
616
+ if (abandoned(op)) writeOperation(id, op, home);
546
617
  const at = driver.now();
547
618
  // Read once, as status and the runner do: every choice and refusal below is decided on it.
548
619
  const live = await liveProjects(op, driver.inspect);
@@ -596,6 +667,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
596
667
  }
597
668
  // Each outcome is recorded as it happens, so a disposition that stops partway stays true and resume refuses it.
598
669
  const disposition = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
670
+ abandoned(op); // #227: in the same write as the disposition
599
671
  (op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
600
672
  // Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
601
673
  op.updatedAt = driver.now(); writeOperation(id, op, home);