@staix/agent-hub 0.12.34 → 0.12.35
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.
- package/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/budget.md +3 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +5 -1
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +67 -13
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +21 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15438 -14863
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +3 -0
- package/src/adapters/registry.ts +24 -8
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +29 -4
- package/src/cli/main.ts +101 -28
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/terminal-recovery.ts +68 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +67 -39
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +296 -125
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +17 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +82 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +53 -23
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { shellQuote } from "./shell-quote.ts";
|
|
2
2
|
export { shellQuote } from "./shell-quote.ts";
|
|
3
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
3
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
4
|
+
import { readInstanceConfig, readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
6
|
+
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
4
7
|
import { isAbsolute, join, resolve } from "node:path";
|
|
5
8
|
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
@@ -28,7 +31,7 @@ export type CommandRunner = (argv: readonly string[]) => Promise<CommandResult>;
|
|
|
28
31
|
/** Stable process identity hook. It returns a digest, never raw process metadata. */
|
|
29
32
|
export type ProcessIdentity = (pid: number) => string | undefined | Promise<string | undefined>;
|
|
30
33
|
|
|
31
|
-
export type TerminalPeer =
|
|
34
|
+
export type TerminalPeer = string;
|
|
32
35
|
export interface SessionRef { sessionId: string; sessionFile?: string; backend?: string; model?: string; }
|
|
33
36
|
|
|
34
37
|
export interface LaunchMetadata {
|
|
@@ -48,6 +51,10 @@ export interface TerminalBinding {
|
|
|
48
51
|
incarnationId: string;
|
|
49
52
|
worktreeId: string;
|
|
50
53
|
projectRoot: string;
|
|
54
|
+
/** Authenticated declared-extra execution root; canonical projectRoot remains the authority. */
|
|
55
|
+
executionCwd?: string;
|
|
56
|
+
/** Verified Orca grouping root, only canonical project or the authorized execution worktree. */
|
|
57
|
+
terminalRoot?: string;
|
|
51
58
|
sessionId: string;
|
|
52
59
|
sessionFile?: string;
|
|
53
60
|
backend?: string;
|
|
@@ -129,6 +136,8 @@ export interface TerminalCreateResult {
|
|
|
129
136
|
|
|
130
137
|
export interface TerminalRecoveryOptions {
|
|
131
138
|
runner?: CommandRunner;
|
|
139
|
+
/** Exact execution root returned by authenticated extra startup; checked against the current declaration. */
|
|
140
|
+
executionCwd?: string;
|
|
132
141
|
/** Defaults to `orca`; callers in a managed session may pin another executable. */
|
|
133
142
|
orcaExecutable?: string;
|
|
134
143
|
/** Absolute path to this package's JS entrypoint. */
|
|
@@ -150,6 +159,8 @@ export interface TerminalRecoveryOptions {
|
|
|
150
159
|
export interface RecordedTerminalLaunch {
|
|
151
160
|
peer: TerminalPeer;
|
|
152
161
|
projectRoot: string;
|
|
162
|
+
executionCwd?: string;
|
|
163
|
+
terminalRoot?: string;
|
|
153
164
|
stateDir: string;
|
|
154
165
|
instanceId: string;
|
|
155
166
|
launcherPid: number;
|
|
@@ -243,6 +254,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
|
|
|
243
254
|
runner: config.runner ?? defaultRunner(orcaExecutable),
|
|
244
255
|
orcaExecutable,
|
|
245
256
|
packageEntrypoint: config.packageEntrypoint ?? defaultEntrypoint,
|
|
257
|
+
executionCwd: config.executionCwd ?? "",
|
|
246
258
|
stateDir: config.stateDir ?? "",
|
|
247
259
|
instanceId: config.instanceId ?? "",
|
|
248
260
|
processIdentity: config.processIdentity ?? defaultProcessIdentity,
|
|
@@ -312,9 +324,30 @@ export function recordPath(stateDir: string): string {
|
|
|
312
324
|
return join(stateDir, RECORD_FILE);
|
|
313
325
|
}
|
|
314
326
|
|
|
327
|
+
/** Extra terminal ownership is supported only for the native proxy transport; other I2 extras stay headless. */
|
|
328
|
+
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
329
|
+
const builtin = definitionOf(peer);
|
|
330
|
+
if (builtin?.session.launchRecord) return projectRoot;
|
|
331
|
+
const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
|
|
332
|
+
const definition = definitionOf(peer, declarations);
|
|
333
|
+
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
334
|
+
if (definition.transport !== "codex-appserver" || !declarations?.[peer]) throw new Error(`${peer}: I2 extra instance has no managed terminal support`);
|
|
335
|
+
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
|
|
336
|
+
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
337
|
+
return expected;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function savedTerminalRoot(binding: Pick<TerminalBinding, "peer" | "projectRoot" | "executionCwd" | "terminalRoot">): string {
|
|
341
|
+
const expected = executionRoot(binding.peer, binding.projectRoot, binding.executionCwd ?? binding.projectRoot);
|
|
342
|
+
if (definitionOf(binding.peer)) return binding.projectRoot;
|
|
343
|
+
const root = binding.terminalRoot ?? binding.projectRoot;
|
|
344
|
+
if (root !== binding.projectRoot && root !== expected) throw new Error(`${binding.peer}: recorded Orca grouping root is not authorized`);
|
|
345
|
+
return root;
|
|
346
|
+
}
|
|
347
|
+
|
|
315
348
|
function validRecord(item: unknown): item is RecordedTerminalLaunch {
|
|
316
349
|
const row = object(item);
|
|
317
|
-
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;
|
|
350
|
+
return typeof row.peer === "string" && typeof row.projectRoot === "string" && (row.executionCwd === undefined || typeof row.executionCwd === "string") && (row.terminalRoot === undefined || typeof row.terminalRoot === "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;
|
|
318
351
|
}
|
|
319
352
|
|
|
320
353
|
/**
|
|
@@ -410,13 +443,15 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
410
443
|
const handle = process.env.ORCA_TERMINAL_HANDLE;
|
|
411
444
|
const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
|
|
412
445
|
if (!handle || !worktreeIdFromEnv) return undefined;
|
|
446
|
+
if (!definitionOf(peer) && !config.executionCwd) throw new Error(`${peer}: recording an extra terminal requires its authenticated startup execution root`);
|
|
447
|
+
const expectedRoot = executionRoot(peer, projectRoot, config.executionCwd || undefined);
|
|
413
448
|
const value = await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]);
|
|
414
449
|
const terminal = terminalObject(value);
|
|
415
450
|
const actualRoot = nestedString(terminal, ["worktreePath", "projectRoot"]);
|
|
416
451
|
const actualHandle = nestedString(terminal, ["handle"]);
|
|
417
452
|
const actualWorktree = nestedString(terminal, ["worktreeId"]);
|
|
418
453
|
const incarnationId = nestedString(terminal, ["incarnationId"]);
|
|
419
|
-
if (actualHandle !== handle || !actualRoot || !rootMatches(actualRoot, projectRoot) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
|
|
454
|
+
if (actualHandle !== handle || !actualRoot || !(rootMatches(actualRoot, projectRoot) || !definitionOf(peer) && rootMatches(actualRoot, expectedRoot)) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
|
|
420
455
|
throw new OrcaCommandError(blocker("project-root-mismatch", "Orca launch terminal failed canonical root/worktree readback", peer, handle));
|
|
421
456
|
}
|
|
422
457
|
const launcherSignature = await config.processIdentity(process.pid);
|
|
@@ -424,7 +459,7 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
424
459
|
const launchId = randomUUID();
|
|
425
460
|
process.env.AGENTHUB_LAUNCH_ID = launchId;
|
|
426
461
|
process.env.AGENTHUB_INSTANCE_ID = instanceId;
|
|
427
|
-
const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
462
|
+
const row: RecordedTerminalLaunch = { peer, projectRoot, ...(!definitionOf(peer) ? { executionCwd: expectedRoot, terminalRoot: actualRoot } : {}), stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
428
463
|
const existing = launchRecords(stateDir);
|
|
429
464
|
// #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
|
|
430
465
|
// then read those launchers as gone.
|
|
@@ -470,12 +505,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
|
|
|
470
505
|
return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
|
|
471
506
|
}
|
|
472
507
|
|
|
473
|
-
function identityFrom(terminal: Record<string, unknown
|
|
508
|
+
function identityFrom(terminal: Record<string, unknown>, instances?: KindMap): TerminalPeer | undefined {
|
|
474
509
|
const wait = object(terminal.agentWait);
|
|
475
510
|
const session = object(terminal.session);
|
|
476
511
|
const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
|
|
477
512
|
for (const candidate of candidates) {
|
|
478
|
-
if (typeof candidate === "string" && definitionOf(candidate)?.session.launchRecord) return candidate as TerminalPeer;
|
|
513
|
+
if (typeof candidate === "string" && definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
|
|
479
514
|
}
|
|
480
515
|
return undefined;
|
|
481
516
|
}
|
|
@@ -513,7 +548,9 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
|
|
|
513
548
|
const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
|
|
514
549
|
incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
|
|
515
550
|
...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
516
|
-
const
|
|
551
|
+
const adapter = definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
|
|
552
|
+
const nativeTail = adapter?.session.resumeArgv?.(binding, false);
|
|
553
|
+
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
517
554
|
if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
|
|
518
555
|
const argv = [...launch.argv, ...tail];
|
|
519
556
|
const command = [...prefix, ...argv.map(shellQuote)].join(" ");
|
|
@@ -538,7 +575,10 @@ function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBin
|
|
|
538
575
|
const root = nestedString(actual, ["worktreePath", "projectRoot"]);
|
|
539
576
|
const worktree = nestedString(actual, ["worktreeId"]);
|
|
540
577
|
if (handle !== binding.handle || incarnation !== binding.incarnationId || worktree !== binding.worktreeId) return blocker("stale-terminal", "captured terminal identity changed; refusing to mutate it", binding.peer, binding.handle);
|
|
541
|
-
|
|
578
|
+
let expectedRoot: string;
|
|
579
|
+
try { expectedRoot = savedTerminalRoot(binding); }
|
|
580
|
+
catch { return blocker("project-root-mismatch", "declared terminal execution root changed or is unavailable; refusing to mutate it", binding.peer, binding.handle); }
|
|
581
|
+
if (!rootMatches(root, expectedRoot)) return blocker("project-root-mismatch", "terminal belongs to a different project root; refusing to mutate it", binding.peer, binding.handle);
|
|
542
582
|
return undefined;
|
|
543
583
|
}
|
|
544
584
|
|
|
@@ -560,6 +600,7 @@ export async function inspectSavedTerminal(binding: TerminalBinding, options?: C
|
|
|
560
600
|
const completeIdentity = (value: Record<string, unknown>) =>
|
|
561
601
|
[nestedString(value, ["handle"]), nestedString(value, ["incarnationId"]), nestedString(value, ["worktreeId"]), nestedString(value, ["worktreePath", "projectRoot"])].every(Boolean);
|
|
562
602
|
try {
|
|
603
|
+
savedTerminalRoot(binding);
|
|
563
604
|
const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]));
|
|
564
605
|
if (!completeIdentity(shown)) return unknown();
|
|
565
606
|
const issue = verifyBindingData(shown, binding);
|
|
@@ -603,6 +644,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
603
644
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
604
645
|
const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
|
|
605
646
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
647
|
+
const instances = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot).instances : undefined;
|
|
606
648
|
// #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
|
|
607
649
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
608
650
|
const rows = launchRecords(config.stateDir);
|
|
@@ -619,6 +661,15 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
619
661
|
|
|
620
662
|
for (const [peer, ref] of requested) {
|
|
621
663
|
const sessionId = ref.sessionId;
|
|
664
|
+
const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
665
|
+
if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
|
|
666
|
+
const ownRecord = records.find(item => item.peer === peer);
|
|
667
|
+
let expectedRoot: string, expectedTerminalRoot: string;
|
|
668
|
+
try {
|
|
669
|
+
expectedRoot = executionRoot(peer, projectRoot, ownRecord?.executionCwd ?? projectRoot);
|
|
670
|
+
expectedTerminalRoot = savedTerminalRoot({ peer, projectRoot, executionCwd: ownRecord?.executionCwd, terminalRoot: ownRecord?.terminalRoot });
|
|
671
|
+
}
|
|
672
|
+
catch { blockers.push(blocker("ownership-unknown", `${peer}: declared execution worktree changed or is unverified`, peer)); continue; }
|
|
622
673
|
// #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
|
|
623
674
|
const unread = unknown.find((item) => item.peer === peer);
|
|
624
675
|
if (unread) {
|
|
@@ -629,7 +680,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
629
680
|
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));
|
|
630
681
|
continue;
|
|
631
682
|
}
|
|
632
|
-
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot,
|
|
683
|
+
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot));
|
|
633
684
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
634
685
|
for (const listedTerminal of candidates) {
|
|
635
686
|
const handle = nestedString(listedTerminal, ["handle"]);
|
|
@@ -642,16 +693,16 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
642
693
|
continue;
|
|
643
694
|
}
|
|
644
695
|
// A show readback is required; list can be stale between recovery steps.
|
|
645
|
-
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot,
|
|
646
|
-
const identity = identityFrom(terminal);
|
|
696
|
+
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
|
|
697
|
+
const identity = identityFrom(terminal, instances);
|
|
647
698
|
const terminalSession = sessionFrom(terminal);
|
|
648
699
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
649
700
|
// A recorded launcher proves ownership; the daemon's supplied session id is the
|
|
650
701
|
// authoritative session id when Orca omits it from terminal metadata.
|
|
651
|
-
if ((identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
702
|
+
if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
652
703
|
}
|
|
653
704
|
if (!peerCandidates.length) {
|
|
654
|
-
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
705
|
+
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
655
706
|
blockers.push(ownershipUnknown
|
|
656
707
|
? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
|
|
657
708
|
: blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
|
|
@@ -676,9 +727,9 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
676
727
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
677
728
|
// Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
|
|
678
729
|
const { args: _launchArgs, ...observedTerminal } = terminal;
|
|
679
|
-
const sessionFile = definitionOf(peer)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
730
|
+
const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
680
731
|
const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
|
|
681
|
-
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
732
|
+
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, ...(extra ? { executionCwd: expectedRoot, terminalRoot: expectedTerminalRoot } : {}), sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
682
733
|
bindings.push(binding);
|
|
683
734
|
byPeer[peer] = binding;
|
|
684
735
|
}
|
|
@@ -750,6 +801,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
750
801
|
const config = normalizeOptions(options);
|
|
751
802
|
let made: string | undefined; // a later failure must still report the terminal it created
|
|
752
803
|
try {
|
|
804
|
+
savedTerminalRoot(binding);
|
|
753
805
|
const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
|
|
754
806
|
// The coordinator journals a pending creation before this call and reconciles
|
|
755
807
|
// it on resume. Other agents in the worktree do not imply a prior create.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
2
3
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
3
4
|
|
|
4
5
|
export type RestoreReceipt =
|
|
@@ -23,10 +24,16 @@ export type RestoreReceiptStatus = "unstarted" | "pending" | "waiting:pending" |
|
|
|
23
24
|
function malformed(): never { throw new Error("invalid restore receipt"); }
|
|
24
25
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
25
26
|
const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
26
|
-
function terminalBinding(value: unknown): TerminalBinding {
|
|
27
|
-
if (!object(value) || typeof value.peer !== "string"
|
|
27
|
+
function terminalBinding(value: unknown, historical = false): TerminalBinding {
|
|
28
|
+
if (!object(value) || typeof value.peer !== "string") malformed();
|
|
28
29
|
for (const key of ["handle", "incarnationId", "worktreeId", "projectRoot", "sessionId"]) if (!text(value[key])) malformed();
|
|
29
|
-
|
|
30
|
+
if (historical) {
|
|
31
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(value.peer)) malformed();
|
|
32
|
+
} else try {
|
|
33
|
+
const adapter = definitionOf(value.peer) ?? definitionOf(value.peer, readInstanceConfig(value.projectRoot as string).instances);
|
|
34
|
+
if (!adapter?.session.launchRecord) malformed();
|
|
35
|
+
} catch { malformed(); }
|
|
36
|
+
for (const key of ["sessionFile", "backend", "model", "executionCwd", "terminalRoot"]) if (value[key] !== undefined && typeof value[key] !== "string") malformed();
|
|
30
37
|
const launch = value.launch;
|
|
31
38
|
const validLaunch = (v: unknown) => object(v) && text(v.packageEntrypoint) && text(v.command)
|
|
32
39
|
&& Array.isArray(v.argv) && v.argv.every(arg => typeof arg === "string")
|
|
@@ -36,7 +43,9 @@ function terminalBinding(value: unknown): TerminalBinding {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** Decode legacy representations only here. Absence is unstarted, never pending or failed. */
|
|
39
|
-
export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined {
|
|
46
|
+
export function decodeRestoreReceipt(value: unknown, options: { authority?: "current" | "historical" } = {}): RestoreReceipt | undefined {
|
|
47
|
+
// Historical decoding proves receipt structure only, never current permission to launch or replace a peer.
|
|
48
|
+
const historical = options.authority === "historical";
|
|
40
49
|
if (value === undefined) return undefined;
|
|
41
50
|
if (value === "pending" || value === "failed") return { state: value };
|
|
42
51
|
if (!object(value)) malformed();
|
|
@@ -47,14 +56,14 @@ export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined
|
|
|
47
56
|
case "pending": case "failed": return { state: value.state };
|
|
48
57
|
case "waiting": {
|
|
49
58
|
if (value.origin !== "pending" && value.origin !== "failed") malformed();
|
|
50
|
-
return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding) };
|
|
59
|
+
return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding, historical) };
|
|
51
60
|
}
|
|
52
|
-
case "done": return { state: "done", binding: terminalBinding(value.binding) };
|
|
61
|
+
case "done": return { state: "done", binding: terminalBinding(value.binding, historical) };
|
|
53
62
|
default: return malformed();
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
if (Object.hasOwn(value, "attachmentReceipt")) malformed();
|
|
57
|
-
return { state: "done", binding: terminalBinding(value) };
|
|
66
|
+
return { state: "done", binding: terminalBinding(value, historical) };
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
2
2
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
3
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
3
4
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
4
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
5
6
|
import { join, resolve } from "node:path";
|
|
@@ -32,13 +33,16 @@ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalB
|
|
|
32
33
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
33
34
|
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");
|
|
34
35
|
|
|
36
|
+
/** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
|
|
37
|
+
const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
|
|
38
|
+
|
|
35
39
|
const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
|
|
36
|
-
const state =
|
|
40
|
+
const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
|
|
37
41
|
return state === "unknown" ? "unknown" : state === false ? "missing" : "found";
|
|
38
42
|
};
|
|
39
|
-
const sessionPath = (binding: TerminalBinding) =>
|
|
40
|
-
export const observedSession = (peer: string, value: RecoveryPeer | undefined) => value?.[definitionOf(peer)?.session.idField ?? "sessionId"];
|
|
41
|
-
const nativeZeroTurn = (stateDir: string, binding: TerminalBinding, phase: "plan" | "attach" | "verify") =>
|
|
43
|
+
const sessionPath = (binding: TerminalBinding) => projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptPath?.(binding);
|
|
44
|
+
export const observedSession = (peer: string, value: RecoveryPeer | undefined, projectRoot?: string) => value?.[(projectRoot ? projectDefinition(peer, projectRoot) : definitionOf(peer))?.session.idField ?? "sessionId"];
|
|
45
|
+
const nativeZeroTurn = (stateDir: string, binding: TerminalBinding, phase: "plan" | "attach" | "verify") => projectDefinition(binding.peer, binding.projectRoot)?.session.zeroTurn?.({ stateDir, binding, phase }) === true;
|
|
42
46
|
const unresumable = (binding: TerminalBinding) => sessionState(binding) === "unknown"
|
|
43
47
|
? `codex: the session store ${sessionPath(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
44
48
|
: `codex: thread ${binding.sessionId} has no resumable transcript under ${sessionPath(binding)}`;
|
|
@@ -64,7 +68,9 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
|
|
|
64
68
|
|
|
65
69
|
/** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
|
|
66
70
|
export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
|
|
67
|
-
const
|
|
71
|
+
const adapter = projectDefinition(binding.peer, binding.projectRoot);
|
|
72
|
+
const nativeTail = adapter?.session.resumeArgv?.(binding, fresh);
|
|
73
|
+
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(binding.peer !== adapter.kind ? ["--as", binding.peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
68
74
|
if (!tail) throw new Error(`${binding.peer}: no recorded terminal resume adapter`);
|
|
69
75
|
return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
|
|
70
76
|
}
|
|
@@ -108,9 +114,9 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
108
114
|
// The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
|
|
109
115
|
if (now.state === "busy") cut = "; its turn was cut: a delivery it had not settled is held as needs_review, and its queue stays held until ahub queue resolve";
|
|
110
116
|
if (binding) {
|
|
111
|
-
if (observedSession(binding.peer, now) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
|
|
117
|
+
if (observedSession(binding.peer, now, binding.projectRoot) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
|
|
112
118
|
// The same question the plan asked (`makeUpgradePlan`), for this one peer.
|
|
113
|
-
const session =
|
|
119
|
+
const session = projectDefinition(binding.peer, binding.projectRoot)?.session.crashIdentity ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
|
|
114
120
|
const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
|
|
115
121
|
current = found.byPeer[binding.peer];
|
|
116
122
|
if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
|
|
@@ -135,7 +141,20 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
135
141
|
}
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
|
|
144
|
+
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
145
|
+
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
146
|
+
const declared = Object.keys(readInstanceConfig(projectRoot).instances ?? {});
|
|
147
|
+
if (!declared.length) return undefined;
|
|
148
|
+
if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
|
|
149
|
+
if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
|
|
150
|
+
const peers = Object.entries(observedPeers);
|
|
151
|
+
if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
|
|
152
|
+
!peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
|
|
153
|
+
}
|
|
154
|
+
return "source lacks peer_instances support for this project's declared instances; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end any extra native sessions and temporarily remove the instance declarations, upgrade the source, then declare them again; legacy peer identities were not reinterpreted";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
|
|
139
158
|
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
140
159
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
141
160
|
// caller reads that as not inspected, never as stopped.
|
|
@@ -181,6 +200,8 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
181
200
|
}
|
|
182
201
|
}
|
|
183
202
|
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
203
|
+
const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
|
|
204
|
+
if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
|
|
184
205
|
let response: any;
|
|
185
206
|
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
|
|
186
207
|
catch (error) {
|
|
@@ -232,7 +253,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
232
253
|
const reconnectOnly: string[] = [];
|
|
233
254
|
for (const peer of source.peers) {
|
|
234
255
|
if (peer.state === "offline") continue;
|
|
235
|
-
const adapter =
|
|
256
|
+
const adapter = projectDefinition(peer.id, project.root);
|
|
236
257
|
const refusal = adapter?.session.recoveryRefusal?.(peer);
|
|
237
258
|
if (refusal) { blockers.push(refusal); continue; }
|
|
238
259
|
if (adapter?.transport === "claude-channel") {
|
|
@@ -263,7 +284,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
263
284
|
}
|
|
264
285
|
}
|
|
265
286
|
if (adapter?.session.launchRecord && (!adapter.session.restoreArgs || peer.args?.mode === "tui")) {
|
|
266
|
-
const session = observedSession(peer.id, peer);
|
|
287
|
+
const session = observedSession(peer.id, peer, project.root);
|
|
267
288
|
if (!session) blockers.push(adapter.session.crashIdentity
|
|
268
289
|
? `${peer.id}: original session ID is unknown; manual-required; next action: reconnect the original ${adapter.displayName} session and make a new recovery plan`
|
|
269
290
|
: `${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
|
|
@@ -279,7 +300,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
279
300
|
});
|
|
280
301
|
blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
|
|
281
302
|
const freshStart: string[] = [];
|
|
282
|
-
for (const binding of terminals.bindings) if (
|
|
303
|
+
for (const binding of terminals.bindings) if (projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout") {
|
|
283
304
|
const transcript = sessionState(binding);
|
|
284
305
|
if (transcript === "found") continue;
|
|
285
306
|
if (transcript === "missing" && nativeZeroTurn(project.stateDir, binding, "plan")) { freshStart.push(binding.peer); continue; }
|
|
@@ -333,7 +354,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
333
354
|
const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
|
|
334
355
|
for (const binding of bindings ?? []) {
|
|
335
356
|
// Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
|
|
336
|
-
const key =
|
|
357
|
+
const key = projectDefinition(binding.peer, binding.projectRoot)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
|
|
337
358
|
if (binding.launch.env[key]) value[key] = binding.launch.env[key];
|
|
338
359
|
}
|
|
339
360
|
}
|
|
@@ -365,8 +386,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
365
386
|
const observed = await inspectRecovery(planned.project);
|
|
366
387
|
if (observed.instanceId !== progress.instanceId) throw nativeFailure(planned, saved.peer, "target_unavailable", `${saved.peer}: daemon instance changed during terminal revalidation`, { handle: saved.handle, targetState: observed.state });
|
|
367
388
|
const peer = observed.peers.find((item) => item.id === saved.peer);
|
|
368
|
-
const session = observedSession(saved.peer, peer);
|
|
369
|
-
if (session !== saved.sessionId ||
|
|
389
|
+
const session = observedSession(saved.peer, peer, saved.projectRoot);
|
|
390
|
+
if (session !== saved.sessionId || projectDefinition(saved.peer, saved.projectRoot)?.session.crashIdentity && progress.reattachments?.[saved.peer] && saved.sessionFile && peer?.sessionFile !== saved.sessionFile) throw nativeFailure(planned, saved.peer, "session_changed", `${saved.peer}: daemon session changed during terminal revalidation; manual-required`, { handle: saved.handle, expectedSession: saved.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
|
|
370
391
|
return current;
|
|
371
392
|
};
|
|
372
393
|
/** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
|
|
@@ -391,7 +412,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
391
412
|
return { state: "unknown", why: `the target hub reads as ${target.state === "running" ? `instance ${target.instanceId}, not ${progress.instanceId}` : target.state}`, ...(settled ? {} : { step: "wait until the target answers" }) };
|
|
392
413
|
}
|
|
393
414
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
394
|
-
const session = observedSession(peer, attached);
|
|
415
|
+
const session = observedSession(peer, attached, planned.project.root);
|
|
395
416
|
if (session) return { state: "live", session };
|
|
396
417
|
let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
|
|
397
418
|
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
@@ -487,7 +508,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
487
508
|
if (live.state !== "running") throw nativeFailure(planned, binding.peer, "target_unavailable", `${binding.peer}: target hub reads as ${live.state} while awaiting attachment; saved terminal retained`, { handle: binding.handle, targetState: live.state });
|
|
488
509
|
if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw nativeFailure(planned, binding.peer, "target_unavailable", `${binding.peer}: target identity changed while awaiting attachment`, { handle: binding.handle, targetState: live.state });
|
|
489
510
|
const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
|
|
490
|
-
const session = observedSession(binding.peer, peer);
|
|
511
|
+
const session = observedSession(binding.peer, peer, binding.projectRoot);
|
|
491
512
|
const changed = !!session && expectedSession !== null && session !== expectedSession;
|
|
492
513
|
// #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
|
|
493
514
|
// at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
|
|
@@ -540,7 +561,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
540
561
|
}
|
|
541
562
|
} catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
|
|
542
563
|
const peer = live.peers.find((p) => p.id === binding.peer);
|
|
543
|
-
const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer);
|
|
564
|
+
const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer, binding.projectRoot);
|
|
544
565
|
(progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
|
|
545
566
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
546
567
|
lastHubConnectAttempt: "unknown" };
|
|
@@ -572,10 +593,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
572
593
|
}
|
|
573
594
|
// Reuse capability proof only after verifying the cached root and digest still identify these exact bytes.
|
|
574
595
|
const cached = op.targetDigest === target.digest && op.targetRoot === target.root;
|
|
575
|
-
|
|
576
|
-
//
|
|
596
|
+
// Once every source committed, compatibility cannot be re-applied using mutable project declarations.
|
|
597
|
+
// Package identity/hash and live-test authority above still bind these retained bytes.
|
|
598
|
+
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) {
|
|
599
|
+
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
600
|
+
}
|
|
601
|
+
if (cached && op.targetFeatures !== undefined) {
|
|
602
|
+
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
603
|
+
return { ...target, targetFeatures: [...op.targetFeatures] };
|
|
604
|
+
}
|
|
577
605
|
// A prepared commitSent receipt remains uncertain and must still obtain proof before a possible commit.
|
|
578
|
-
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) return target;
|
|
579
606
|
// Legacy uncommitted receipts acquire verified feature evidence before source compatibility checks.
|
|
580
607
|
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 ?? []}))`]);
|
|
581
608
|
let metadata: { protocol?: unknown; features?: unknown } | undefined;
|
|
@@ -584,6 +611,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
584
611
|
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");
|
|
585
612
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
586
613
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
614
|
+
if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
587
615
|
const verified = { ...target, targetFeatures };
|
|
588
616
|
// Existing peer/waiver compatibility checks remain properties of the already verified bytes.
|
|
589
617
|
if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
|
|
@@ -630,7 +658,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
630
658
|
progress.terminals[key] = true; save(); continue;
|
|
631
659
|
}
|
|
632
660
|
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
633
|
-
const transcript =
|
|
661
|
+
const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
|
|
634
662
|
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
635
663
|
if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
|
|
636
664
|
throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
|
|
@@ -663,11 +691,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
663
691
|
const live = await inspectRecovery(planned.project);
|
|
664
692
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
665
693
|
if (group === "native" && !onlyPeer) {
|
|
666
|
-
for (const peer of planned.source.peers.filter(p =>
|
|
694
|
+
for (const peer of planned.source.peers.filter(p => projectDefinition(p.id, planned.project.root)?.session.restoreArgs && p.state !== "offline" && !(projectDefinition(p.id, planned.project.root)?.session.launchRecord && p.args?.mode === "tui"))) {
|
|
667
695
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
668
696
|
if (current && current.state !== "offline") continue;
|
|
669
|
-
const adapter =
|
|
670
|
-
const args = [peer.id];
|
|
697
|
+
const adapter = projectDefinition(peer.id, planned.project.root)!;
|
|
698
|
+
const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
|
|
671
699
|
const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
|
|
672
700
|
if (refusal) throw new Error(refusal);
|
|
673
701
|
const captured = adapter.session.restoreArgs!(peer);
|
|
@@ -679,7 +707,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
679
707
|
}
|
|
680
708
|
let claudeGroup = false;
|
|
681
709
|
for (const plannedBinding of planned.terminals as TerminalBinding[]) {
|
|
682
|
-
if ((
|
|
710
|
+
if ((projectDefinition(plannedBinding.peer, plannedBinding.projectRoot)?.transport === "claude-channel") !== (claudeGroup = group !== "native") || onlyPeer && plannedBinding.peer !== onlyPeer) continue;
|
|
683
711
|
const key = `restored:${plannedBinding.peer}`;
|
|
684
712
|
try {
|
|
685
713
|
// #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
|
|
@@ -697,16 +725,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
697
725
|
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
698
726
|
const chosen = !recordedOnly && !!progress.fresh?.[original.peer];
|
|
699
727
|
let read: ReturnType<typeof sessionState> | undefined;
|
|
700
|
-
const transcript = () =>
|
|
728
|
+
const transcript = () => projectDefinition(original.peer, original.projectRoot)?.session.missingEvidence !== "rollout" || (chosen && !accepted) ? "found" : (read ??= sessionState(original));
|
|
701
729
|
// An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
|
|
702
730
|
// rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
|
|
703
731
|
const fresh = () => recordedOnly ? false : accepted
|
|
704
|
-
? (
|
|
732
|
+
? (projectDefinition(original.peer, original.projectRoot)?.session.missingEvidence === "rollout" ? (chosen || !!planned.freshStart?.includes(original.peer)) && transcript() === "missing" : nativeZeroTurn(planned.project.stateDir, original, "attach"))
|
|
705
733
|
: chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
|
|
706
734
|
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
707
735
|
// peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
|
|
708
736
|
// The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
|
|
709
|
-
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" :
|
|
737
|
+
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : projectDefinition(original.peer, original.projectRoot)?.transport === "claude-channel" ? "zero-turn" : "fresh-start";
|
|
710
738
|
const notRestored = (why: string) => new Error(fresh()
|
|
711
739
|
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
712
740
|
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
@@ -807,7 +835,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
807
835
|
// The planned session already attached is the restoration (the table's row for it), not a reason to block.
|
|
808
836
|
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
809
837
|
if (evidence.state === "live" && evidence.session === original.sessionId) {
|
|
810
|
-
if (repair) await settle(evidence.session);
|
|
838
|
+
if (repair) await settle(evidence.session!); // equality with the recorded string session id above proves this value
|
|
811
839
|
else { progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, original, false) } satisfies RestoreReceipt; save(); }
|
|
812
840
|
continue;
|
|
813
841
|
}
|
|
@@ -815,7 +843,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
815
843
|
if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
|
|
816
844
|
if (transcript() === "missing" && !fresh()) {
|
|
817
845
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
818
|
-
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind:
|
|
846
|
+
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind: projectDefinition(original.peer, original.projectRoot)!.session.missingEvidence!, checkedPath: sessionPath(original) });
|
|
819
847
|
throw notRestored(unresumable(original).slice("codex: ".length));
|
|
820
848
|
}
|
|
821
849
|
if (fresh()) {
|
|
@@ -878,7 +906,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
878
906
|
const marker = progress.reattachments?.[plannedBinding.peer];
|
|
879
907
|
if (marker) marker.binding = binding;
|
|
880
908
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
881
|
-
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind:
|
|
909
|
+
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind: projectDefinition(plannedBinding.peer, plannedBinding.projectRoot)!.session.missingEvidence!, checkedPath: sessionPath(binding) });
|
|
882
910
|
}
|
|
883
911
|
}
|
|
884
912
|
if (error instanceof ReattachmentRequired || isDatabaseBusy(error)) throw error;
|
|
@@ -899,7 +927,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
899
927
|
installPlugin: async (op) => {
|
|
900
928
|
const configured = new Set<string>();
|
|
901
929
|
for (const planned of op.plan.projects) {
|
|
902
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) =>
|
|
930
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
903
931
|
if (!binding) continue;
|
|
904
932
|
const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
|
|
905
933
|
if (configured.has(profile)) continue;
|
|
@@ -936,7 +964,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
936
964
|
try { archived = JSON.parse(readFileSync(archive, "utf8")); } catch { return { continuity: "not-archived" }; }
|
|
937
965
|
if (!archived || typeof archived !== "object" || (archived as { operationId?: unknown }).operationId !== op.id) return { continuity: "not-archived" };
|
|
938
966
|
try {
|
|
939
|
-
const continuity =
|
|
967
|
+
const continuity = projectDefinition(source.id, project.root)?.session.continuityBookmark;
|
|
940
968
|
if (!continuity) return;
|
|
941
969
|
const binding = planned ? resolveAcceptedBinding(planned, progress, source.id as TerminalBinding["peer"]) : undefined;
|
|
942
970
|
writeRecoveryContinuity(project.stateDir, { schema: 1, operationId: op.id, projectId: project.id, projectRoot: project.root, at: now(),
|
|
@@ -963,21 +991,21 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
963
991
|
// A #225 restart clears the old target's repair fence, never its accepted conversation.
|
|
964
992
|
const retained = retired?.state === "done" && accepted?.state === "done" && retired.binding.peer === old.id && retired.binding.projectRoot === planned.project.root && retired.binding.sessionId === accepted.binding.sessionId;
|
|
965
993
|
if ((recordedOnly || retained) && accepted?.state === "done") {
|
|
966
|
-
const session = observedSession(old.id, peer);
|
|
994
|
+
const session = observedSession(old.id, peer, planned.project.root);
|
|
967
995
|
if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
|
|
968
|
-
|
|
996
|
+
projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.session.crashIdentity && (recordedOnly || old.sessionFile) && accepted.binding.sessionFile && peer.sessionFile !== accepted.binding.sessionFile) throw nativeFailure(planned, old.id, "session_changed", `${old.id}: reattached a different recorded conversation`, { handle: accepted.binding.handle, expectedSession: accepted.binding.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
|
|
969
997
|
continue;
|
|
970
998
|
}
|
|
971
999
|
if (!recordedOnly && (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id))) continue; // #215: a new session was accepted (recorded)
|
|
972
|
-
if (
|
|
973
|
-
if (
|
|
1000
|
+
if (projectDefinition(old.id, planned.project.root)?.session.idField === "threadId" && peer.threadId !== old.threadId) throw nativeFailure(planned, old.id, "session_changed", "Codex resumed a different conversation", { ...(old.threadId ? { expectedSession: old.threadId } : {}), ...(peer.threadId ? { observedSession: peer.threadId } : {}) });
|
|
1001
|
+
if (projectDefinition(old.id, planned.project.root)?.transport === "claude-channel" && peer.sessionId !== old.sessionId) {
|
|
974
1002
|
// #64: the restore gate already accepted a fresh session when the original never
|
|
975
1003
|
// persisted a transcript. Re-derive persistence from the binding exactly like that
|
|
976
1004
|
// gate did, so a first turn landing after the plan was made keeps the check strict.
|
|
977
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) =>
|
|
1005
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
978
1006
|
if (recordedOnly || !binding || binding.sessionId !== old.sessionId || !nativeZeroTurn(planned.project.stateDir, binding, "verify")) throw nativeFailure(planned, old.id, "session_changed", "Claude resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
|
|
979
1007
|
}
|
|
980
|
-
if (
|
|
1008
|
+
if (projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.session.crashIdentity && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw nativeFailure(planned, old.id, "session_changed", "Pi resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
|
|
981
1009
|
}
|
|
982
1010
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
983
1011
|
const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
|