@staix/agent-hub 0.12.19 → 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.
- package/CHANGELOG.md +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
|
|
6
|
-
import { inspectProject } from "../hub/lifecycle.ts";
|
|
6
|
+
import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
|
|
7
7
|
import type { Project } from "../hub/registry.ts";
|
|
8
8
|
import { hubHome } from "../hub/project.ts";
|
|
9
9
|
import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
|
|
10
|
-
import { inspectTerminals, closeTerminal, createTerminal, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
11
|
-
import { planFingerprint, registeredProjects, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
|
|
10
|
+
import { inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
11
|
+
import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
|
|
12
|
+
import { readEvents } from "../hub/events.ts";
|
|
12
13
|
import { refreshManager } from "../hub/manager.ts";
|
|
14
|
+
import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
|
|
15
|
+
|
|
16
|
+
/** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
|
|
17
|
+
const RECONNECT_WAIT_MS = 90_000;
|
|
13
18
|
|
|
14
19
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
15
20
|
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");
|
|
@@ -25,6 +30,42 @@ function claudeTranscriptExists(binding: TerminalBinding): boolean {
|
|
|
25
30
|
return existsSync(join(config, "projects", slug, `${binding.sessionId}.jsonl`));
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
/**
|
|
34
|
+
* #215: `codex resume <id>` reads the thread's rollout from the store its launcher uses (the captured CODEX_HOME, else
|
|
35
|
+
* the default), so an app-server thread id alone proves nothing.
|
|
36
|
+
* ponytail: a `.jsonl` under sessions/ whose name carries the thread id is the evidence; ask Codex itself once it offers
|
|
37
|
+
* a supported resumability query, or when its store layout changes.
|
|
38
|
+
*/
|
|
39
|
+
const codexSessions = (binding: TerminalBinding) => join(binding.launch.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
|
|
40
|
+
function codexTranscript(binding: TerminalBinding): "found" | "missing" | "unknown" {
|
|
41
|
+
try { return readdirSync(codexSessions(binding), { recursive: true }).some((name) => String(name).endsWith(".jsonl") && basename(String(name)).includes(binding.sessionId)) ? "found" : "missing"; }
|
|
42
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; } // no store has no rollout; an unreadable one says nothing
|
|
43
|
+
}
|
|
44
|
+
const unresumable = (binding: TerminalBinding) => codexTranscript(binding) === "unknown"
|
|
45
|
+
? `codex: the session store ${codexSessions(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
46
|
+
: `codex: thread ${binding.sessionId} has no resumable transcript under ${codexSessions(binding)}`;
|
|
47
|
+
|
|
48
|
+
const closeUnsettled = (binding: TerminalBinding, seen: "listed" | "unreadable") => seen === "listed"
|
|
49
|
+
? `${binding.peer}: terminal close outcome needs manual reconciliation: Orca still lists terminal ${binding.handle}; close that terminal (the login shell it runs in) by hand in Orca first`
|
|
50
|
+
: `${binding.peer}: Orca's terminal list could not be read, so whether terminal ${binding.handle} is closed is unknown; check it in Orca (close it by hand, with the login shell it runs in, if it is still open) first`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* #215: Codex writes a thread's rollout with its first message, so a thread this hub saw Codex start (`native_thread`
|
|
54
|
+
* with `fresh`) on which no turn ran has nothing to lose. A codex turn_start belongs to the thread most recently
|
|
55
|
+
* adopted before it; detaching forgets nothing. A thread whose start the log does not show (resumed, older hub, pruned
|
|
56
|
+
* log) is unsure, and unsure counts as turned.
|
|
57
|
+
*/
|
|
58
|
+
function codexZeroTurn(stateDir: string, thread: string): boolean {
|
|
59
|
+
let current: string | undefined, started = false, turned = false;
|
|
60
|
+
for (const e of readEvents(join(stateDir, "events.jsonl"))) {
|
|
61
|
+
if (e.type === "native_thread" && e.peer === "codex") {
|
|
62
|
+
current = e.thread;
|
|
63
|
+
if (e.thread === thread && e.fresh) started = true;
|
|
64
|
+
} else if (e.type === "turn_start" && e.peer === "codex" && current === thread) turned = true;
|
|
65
|
+
}
|
|
66
|
+
return started && !turned;
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
|
|
29
70
|
return { orcaExecutable: orcaExecutable(), runner: async (args) => {
|
|
30
71
|
const result = await run([orcaExecutable(), ...args], { timeoutMs: 610_000 });
|
|
@@ -32,9 +73,10 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
|
|
|
32
73
|
} };
|
|
33
74
|
}
|
|
34
75
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (binding.peer === "
|
|
76
|
+
/** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
|
|
77
|
+
export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
|
|
78
|
+
if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", ...(fresh ? [] : ["resume", binding.sessionId])];
|
|
79
|
+
if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", ...(fresh ? [] : ["--resume", binding.sessionId])];
|
|
38
80
|
return [process.execPath, entrypoint, "--project", projectRoot, "pi", "--mode", "tui",
|
|
39
81
|
...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
|
|
40
82
|
...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
|
|
@@ -50,13 +92,21 @@ async function rpc(project: Project, message: Record<string, unknown>, protocol
|
|
|
50
92
|
}
|
|
51
93
|
|
|
52
94
|
export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
53
|
-
|
|
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 });
|
|
54
100
|
const control = readControl(project.stateDir);
|
|
55
101
|
const sourceProtocol = control?.protocol;
|
|
56
102
|
const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
|
|
57
|
-
|
|
103
|
+
// A stopped runtime reports whose unreleased restart snapshot it holds: only that one can start it again (#215).
|
|
104
|
+
const snapshot = base.state === "stopped" ? readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id })?.operationId : undefined;
|
|
105
|
+
// A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
|
|
106
|
+
// "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
|
|
107
|
+
if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) return {
|
|
58
108
|
state: base.state, peers: [], blockers: base.state === "stopped" ? [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
|
|
59
|
-
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}),
|
|
109
|
+
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
|
|
60
110
|
};
|
|
61
111
|
let status = base.status;
|
|
62
112
|
if (!status && legacySupported) {
|
|
@@ -114,11 +164,39 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
114
164
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
115
165
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
116
166
|
const blockers = [...source.blockers];
|
|
117
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14 or protocol-
|
|
167
|
+
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14, protocol-15 or protocol-16 source is required");
|
|
118
168
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
119
169
|
const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
|
|
170
|
+
const reconnectOnly: string[] = [];
|
|
120
171
|
for (const peer of source.peers) {
|
|
121
172
|
if (peer.state === "offline") continue;
|
|
173
|
+
if (peer.id === "claude") {
|
|
174
|
+
// #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
|
|
175
|
+
// that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
|
|
176
|
+
// ponytail: the channel's hello carries no launch id, so a plain `claude` that took the peer id from a live
|
|
177
|
+
// managed session reads as managed; send the launch id at hello (a protocol bump) if that case shows up.
|
|
178
|
+
const recorded = await launcherOf("claude", project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
|
|
179
|
+
if (recorded?.state === "unknown") {
|
|
180
|
+
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
181
|
+
delete peer.sessionId;
|
|
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`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
186
|
+
if (!launcher) {
|
|
187
|
+
delete peer.sessionId;
|
|
188
|
+
if (source.protocol === PROTOCOL) reconnectOnly.push("claude");
|
|
189
|
+
else blockers.push(`claude: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let record: { instanceId?: unknown; launchId?: unknown; sessionId?: unknown } | undefined;
|
|
193
|
+
try { record = JSON.parse(readFileSync(join(project.stateDir, "claude-session.json"), "utf8")); } catch { /* no record: the id check below reports it */ }
|
|
194
|
+
if (peer.sessionId && (record?.instanceId !== source.instanceId || record?.launchId !== launcher.launchId || record?.sessionId !== peer.sessionId)) {
|
|
195
|
+
delete peer.sessionId;
|
|
196
|
+
blockers.push(`claude: the recorded session was not written by the live launcher in terminal ${launcher.handle}; manual-required; next action: close any other Claude session in this project, send one message in that terminal, then make a new plan`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
122
200
|
if (peer.id === "codex" || peer.id === "claude") {
|
|
123
201
|
const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
|
|
124
202
|
if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
|
|
@@ -132,7 +210,16 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
132
210
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
133
211
|
});
|
|
134
212
|
blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
|
|
135
|
-
|
|
213
|
+
const freshStart: string[] = [];
|
|
214
|
+
for (const binding of terminals.bindings) if (binding.peer === "codex") {
|
|
215
|
+
const transcript = codexTranscript(binding);
|
|
216
|
+
if (transcript === "found") continue;
|
|
217
|
+
if (transcript === "missing" && codexZeroTurn(project.stateDir, binding.sessionId)) { freshStart.push("codex"); continue; }
|
|
218
|
+
blockers.push(transcript === "unknown"
|
|
219
|
+
? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
|
|
220
|
+
: `${unresumable(binding)}, and the hub cannot show that no turn ran on it, so its conversation may not come back after the restart; manual-required; next action: end that Codex session and close its Orca terminal ${binding.handle} (ahub codex starts a new one later), then make a new plan`);
|
|
221
|
+
}
|
|
222
|
+
body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(freshStart.length ? { freshStart } : {}) });
|
|
136
223
|
}
|
|
137
224
|
if (!body.projects.length) body.blockers.push("no running registered projects in scope");
|
|
138
225
|
return { ...body, fingerprint: planFingerprint(body) };
|
|
@@ -208,14 +295,96 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
208
295
|
if (session !== saved.sessionId) throw new Error(`${saved.peer}: daemon session changed during terminal revalidation; manual-required`);
|
|
209
296
|
return current;
|
|
210
297
|
};
|
|
298
|
+
/** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
|
|
299
|
+
const listed = async (binding: TerminalBinding): Promise<"listed" | "absent" | "unreadable"> => {
|
|
300
|
+
const result = await run([orcaExecutable(), "terminal", "list", "--json"]);
|
|
301
|
+
let inventory: any;
|
|
302
|
+
try { inventory = JSON.parse(result.stdout); } catch { return "unreadable"; }
|
|
303
|
+
const rows = inventory?.result?.terminals;
|
|
304
|
+
if (result.code !== 0 || inventory?.ok !== true || !Array.isArray(rows) || inventory.result?.truncated) return "unreadable";
|
|
305
|
+
return rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId) ? "listed" : "absent";
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* #215: what runs for a peer on the target, the one place restore reads it. Only a running target answering as the
|
|
309
|
+
* expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
|
|
310
|
+
*/
|
|
311
|
+
const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
|
|
312
|
+
Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
|
|
313
|
+
const target = await inspectRecovery(planned.project);
|
|
314
|
+
if (target.state !== "running" || target.instanceId !== progress.instanceId) {
|
|
315
|
+
// Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
|
|
316
|
+
const settled = ["running", "stopped", "missing"].includes(target.state);
|
|
317
|
+
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" }) };
|
|
318
|
+
}
|
|
319
|
+
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
320
|
+
const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
|
|
321
|
+
if (session) return { state: "live", session };
|
|
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
|
+
}
|
|
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` };
|
|
337
|
+
return { state: "gone" };
|
|
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
|
+
};
|
|
360
|
+
const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
|
|
361
|
+
for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
|
|
362
|
+
const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
|
|
363
|
+
const id = peer === "codex" ? current?.threadId : current?.sessionId;
|
|
364
|
+
if (id) return id;
|
|
365
|
+
if (now() >= deadline) throw new Error(`${peer}: the new session reported no id within ${RECONNECT_WAIT_MS / 1000} s; wait until it attaches first`);
|
|
366
|
+
await sleep(1000);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
211
369
|
return {
|
|
212
370
|
now, sleep, inspect: inspectRecovery,
|
|
213
371
|
stage: async (op) => {
|
|
214
|
-
if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new
|
|
372
|
+
if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
|
|
215
373
|
const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version) }
|
|
216
374
|
: await stageRelease(op.plan.version, op.plan.integrity!, run);
|
|
375
|
+
// #215: the protocol and waiver checks are properties of these bytes. Once they passed (the runner recorded the
|
|
376
|
+
// digest), a resume does not check again, so a transient probe failure cannot refuse an operation for good;
|
|
377
|
+
// changed bytes are the runner's final "staged target changed".
|
|
378
|
+
if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return target;
|
|
217
379
|
const protocol = await run([process.execPath, "-e", `import { PROTOCOL } from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(PROTOCOL)`]);
|
|
218
|
-
|
|
380
|
+
const read = protocol.code === 0 ? Number.parseInt(protocol.stdout.trim(), 10) : NaN;
|
|
381
|
+
// A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
|
|
382
|
+
if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
383
|
+
if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
384
|
+
// Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
|
|
385
|
+
if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
|
|
386
|
+
throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
|
|
387
|
+
}
|
|
219
388
|
return target;
|
|
220
389
|
},
|
|
221
390
|
prepare: (p, id, instance) => control(p, "prepare", id, instance),
|
|
@@ -227,35 +396,33 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
227
396
|
const key = `closed:${binding.peer}`;
|
|
228
397
|
if (progress.terminals[key] === true) continue;
|
|
229
398
|
if (progress.terminals[key] === "pending") {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
const rows = inventory.result?.terminals;
|
|
233
|
-
if (result.code !== 0 || inventory.ok !== true || !Array.isArray(rows) || inventory.result?.truncated ||
|
|
234
|
-
rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId)) {
|
|
235
|
-
throw new Error(`${binding.peer}: terminal close outcome needs manual reconciliation`);
|
|
236
|
-
}
|
|
399
|
+
const seen = await listed(binding);
|
|
400
|
+
if (seen !== "absent") throw new Error(closeUnsettled(binding, seen)); // the receipt stays pending
|
|
237
401
|
progress.terminals[key] = true; save(); continue;
|
|
238
402
|
}
|
|
239
403
|
// A peer whose TUI already exited left nothing to close: the source reports it
|
|
240
404
|
// detached, and inventory silence is the same proof the pending-reconcile path
|
|
241
405
|
// accepts. No mutation is issued for a terminal that no longer exists (#21).
|
|
242
|
-
const
|
|
406
|
+
const sourceNow = await inspectRecovery(planned.project);
|
|
407
|
+
const attached = sourceNow.peers.find((item) => item.id === binding.peer);
|
|
243
408
|
if (attached?.state === "offline") {
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
const rows = inventory.result?.terminals;
|
|
247
|
-
if (result.code !== 0 || inventory.ok !== true || !Array.isArray(rows) || inventory.result?.truncated ||
|
|
248
|
-
rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId)) {
|
|
249
|
-
throw new Error(`${binding.peer}: terminal close outcome needs manual reconciliation`);
|
|
250
|
-
}
|
|
409
|
+
const seen = await listed(binding);
|
|
410
|
+
if (seen !== "absent") throw new Error(closeUnsettled(binding, seen));
|
|
251
411
|
progress.terminals[key] = true; save(); continue;
|
|
252
412
|
}
|
|
413
|
+
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
414
|
+
const transcript = binding.peer === "codex" ? codexTranscript(binding) : "found";
|
|
415
|
+
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
416
|
+
if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
|
|
417
|
+
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`);
|
|
418
|
+
}
|
|
253
419
|
const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
|
|
254
|
-
if (!idle.satisfied) throw new Error(`${binding.peer}: terminal is not verified idle; source retained`);
|
|
420
|
+
if (!idle.satisfied) throw new Error(`${binding.peer}: terminal is not verified idle; source retained; finish or cancel its turn first`);
|
|
255
421
|
const source = await inspectRecovery(planned.project);
|
|
256
422
|
if (source.instanceId !== planned.source.instanceId || source.recovery?.operationId !== op.id || !source.recovery.ready) {
|
|
257
|
-
throw new Error(
|
|
258
|
-
}
|
|
423
|
+
throw new Error(`source preparation expired or changed while waiting for the terminal; no terminal was closed`);
|
|
424
|
+
}
|
|
425
|
+
// Refresh the same prepared lease immediately before the terminal effect. Preserve
|
|
259
426
|
// the original peer roster, including any terminal already closed in this operation.
|
|
260
427
|
await control(planned.project, "prepare", op.id, planned.source.instanceId!);
|
|
261
428
|
progress.terminals[key] = "pending"; save();
|
|
@@ -269,6 +436,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
269
436
|
if (checkpoint.projectId !== p.id || checkpoint.projectRoot !== p.root || checkpoint.operationId !== op.id) {
|
|
270
437
|
throw new Error("committed restart snapshot is missing or belongs to another operation");
|
|
271
438
|
}
|
|
439
|
+
const reconnect = op.plan.projects.find((planned) => planned.project.id === p.id)?.reconnectOnly ?? [];
|
|
440
|
+
if (reconnect.length) waiveRecoveryPeers(p.stateDir, op.id, Object.fromEntries(reconnect.map((peer) => [peer, "reconnect-only"])));
|
|
272
441
|
await command(op, ["up"], p);
|
|
273
442
|
},
|
|
274
443
|
restore: async (planned, progress, op, group, save) => {
|
|
@@ -288,42 +457,127 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
288
457
|
await command(op, args, planned.project);
|
|
289
458
|
}
|
|
290
459
|
}
|
|
291
|
-
for (const
|
|
292
|
-
if ((
|
|
293
|
-
const key = `restored:${
|
|
294
|
-
|
|
295
|
-
|
|
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;
|
|
470
|
+
// A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
|
|
471
|
+
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
472
|
+
const chosen = !!progress.fresh?.[original.peer];
|
|
473
|
+
let read: ReturnType<typeof codexTranscript> | undefined;
|
|
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");
|
|
480
|
+
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
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";
|
|
484
|
+
const notRestored = (why: string) => new Error(fresh()
|
|
485
|
+
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
486
|
+
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
487
|
+
// Anything live or unknown blocks a create and keeps the receipt: never decide on evidence that could not be read.
|
|
488
|
+
const blocked = (evidence: Exclude<Awaited<ReturnType<typeof peerEvidence>>, { state: "gone" }>) => new Error(evidence.state === "unknown"
|
|
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` : ""}`
|
|
490
|
+
: evidence.session
|
|
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`
|
|
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`);
|
|
493
|
+
const receipt = progress.terminals[key];
|
|
494
|
+
if (receipt && receipt !== "pending" && receipt !== "failed") {
|
|
495
|
+
progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
|
|
296
496
|
continue;
|
|
297
497
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (attached !== original.sessionId) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
expected = { ...original, sessionId: attached };
|
|
498
|
+
// #215: the pending row of the receipt table, used wherever a pending launch meets an attached session.
|
|
499
|
+
const settle = async (attached: string) => {
|
|
500
|
+
// #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
|
|
501
|
+
// succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
|
|
502
|
+
// the operator or the plan accepted, counts as restored.
|
|
503
|
+
if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
|
|
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`);
|
|
505
|
+
}
|
|
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]) {
|
|
509
|
+
delete progress.fresh[original.peer];
|
|
510
|
+
if (!Object.keys(progress.fresh).length) delete progress.fresh;
|
|
313
511
|
}
|
|
314
|
-
|
|
315
|
-
|
|
512
|
+
// Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
|
|
513
|
+
// a launch writes the waiver first, but this must not rely on that.
|
|
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() });
|
|
516
|
+
progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
|
|
517
|
+
};
|
|
518
|
+
// #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
|
|
519
|
+
if (receipt === "pending" || receipt === "failed") {
|
|
520
|
+
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
521
|
+
if (evidence.state === "live" && evidence.session) { await settle(evidence.session); continue; }
|
|
522
|
+
if (evidence.state !== "gone") throw blocked(evidence);
|
|
523
|
+
if (receipt === "pending") {
|
|
524
|
+
progress.terminals[key] = "failed"; save();
|
|
525
|
+
throw notRestored(`its launcher no longer runs and no ${original.peer} session attached`);
|
|
526
|
+
}
|
|
527
|
+
// failed, nothing live: launched again below; the receipt stays `failed` until `pending` replaces it, so a
|
|
528
|
+
// refusal on the way keeps the failed-restoration choices
|
|
529
|
+
}
|
|
530
|
+
// Never create beside a live launch, nor on evidence that could not be read: read now, not at restore start.
|
|
531
|
+
// The planned session already attached is the restoration (the table's row for it), not a reason to block.
|
|
532
|
+
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
533
|
+
if (evidence.state === "live" && evidence.session === original.sessionId) {
|
|
534
|
+
progress.terminals[key] = await revalidateTerminal(planned, progress, original, false); save();
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (evidence.state !== "gone") throw blocked(evidence);
|
|
538
|
+
if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
|
|
539
|
+
if (transcript() === "missing" && !fresh()) {
|
|
540
|
+
progress.terminals[key] = "failed"; save();
|
|
541
|
+
throw notRestored(unresumable(original).slice("codex: ".length));
|
|
542
|
+
}
|
|
543
|
+
if (fresh()) {
|
|
544
|
+
if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
|
|
545
|
+
// The restored daemon must accept the new session instead of the recorded one.
|
|
546
|
+
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
316
547
|
}
|
|
317
548
|
const entrypoint = join(op.targetRoot!, "src/cli/main.js");
|
|
318
|
-
const argv = restoredTerminalArgv(entrypoint, planned.project.root, original);
|
|
549
|
+
const argv = restoredTerminalArgv(entrypoint, planned.project.root, original, fresh());
|
|
319
550
|
const assignments = { ...original.launch.env, AGENTHUB_HOME: hubHome(), AGENTHUB_RECOVERY_OPERATION: op.id };
|
|
320
551
|
const launch = { ...original.launch, packageEntrypoint: entrypoint, argv,
|
|
321
552
|
command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
|
|
322
553
|
const binding = { ...original, launch, launchMetadata: launch };
|
|
323
554
|
progress.terminals[key] = "pending"; save();
|
|
324
|
-
const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
|
|
325
|
-
|
|
326
|
-
|
|
555
|
+
const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
|
|
556
|
+
const exited = restored.blockers.find((b) => b.code === "launcher-exited");
|
|
557
|
+
if (exited) {
|
|
558
|
+
// #215: the launcher's exit alone is not the peer's state (a target that died or flapped also ends it): only
|
|
559
|
+
// nothing live makes it failed; an attached session is settled as the pending row settles it; anything else
|
|
560
|
+
// stays pending.
|
|
561
|
+
const after = await peerEvidence(planned, progress, original.peer);
|
|
562
|
+
if (after.state === "live" && after.session) { await settle(after.session); continue; }
|
|
563
|
+
if (after.state !== "gone") throw blocked(after);
|
|
564
|
+
progress.terminals[key] = "failed"; save();
|
|
565
|
+
throw notRestored(exited.message);
|
|
566
|
+
}
|
|
567
|
+
if (restored.manualRequired || !restored.newBinding) {
|
|
568
|
+
const handle = restored.binding?.handle ?? restored.blockers[0]?.handle;
|
|
569
|
+
throw new Error(`${original.peer}: original session restoration needs manual verification${restored.blockers[0] ? ` (${restored.blockers[0].message})` : ""}; wait until it attaches, or end that launch and close its terminal${handle ? ` ${handle}` : ""} first`);
|
|
570
|
+
}
|
|
571
|
+
// The daemon's id is authoritative: a fresh session is recorded under the id it reports once attached.
|
|
572
|
+
progress.terminals[key] = fresh() ? { ...restored.newBinding, sessionId: await attachedId(planned, original.peer, op) } : restored.newBinding; save();
|
|
573
|
+
}
|
|
574
|
+
if (group === "claude") for (const id of planned.reconnectOnly ?? []) {
|
|
575
|
+
// #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
|
|
576
|
+
const deadline = now() + RECONNECT_WAIT_MS;
|
|
577
|
+
while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
|
|
578
|
+
if (now() >= deadline) throw new Error(`${id}: the unmanaged session did not reconnect within ${RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`);
|
|
579
|
+
await sleep(1000);
|
|
580
|
+
}
|
|
327
581
|
}
|
|
328
582
|
},
|
|
329
583
|
installPlugin: async (op) => {
|
|
@@ -344,6 +598,18 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
344
598
|
const readback = await run(["ahub", "--version"], { env: env(op) });
|
|
345
599
|
if (readback.code !== 0 || readback.stdout.trim() !== op.plan.version) throw new Error("global CLI version was not verified");
|
|
346
600
|
},
|
|
601
|
+
stopAndArchive: async (project, op, instance) => {
|
|
602
|
+
if (instance) {
|
|
603
|
+
// #215: a target an older coordinator started speaks its own protocol, which the lifecycle stop's inspection
|
|
604
|
+
// refuses. Ask it to stop at that protocol, fenced by its instance (the daemon refuses another), then wait as
|
|
605
|
+
// the lifecycle stop does until its manifest and registry claim are gone.
|
|
606
|
+
const sourceProtocol = readControl(project.stateDir)?.protocol;
|
|
607
|
+
const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
|
|
608
|
+
await rpc(project, { t: "kill", instanceId: instance }, protocol);
|
|
609
|
+
await awaitStopped(project, instance);
|
|
610
|
+
}
|
|
611
|
+
abandonRestartSnapshot(project.stateDir, op.id);
|
|
612
|
+
},
|
|
347
613
|
refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
|
|
348
614
|
verify: async (planned, progress, op) => {
|
|
349
615
|
const live = await inspectRecovery(planned.project);
|
|
@@ -351,6 +617,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
351
617
|
for (const old of planned.source.peers.filter((p) => p.state !== "offline")) {
|
|
352
618
|
const peer = live.peers.find((p) => p.id === old.id);
|
|
353
619
|
if (!peer || !["idle", "busy", "paused"].includes(peer.state)) throw new Error(`${old.id}: peer reattachment not verified`);
|
|
620
|
+
if (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id)) continue; // #215: a new session was accepted (recorded)
|
|
354
621
|
if (old.id === "codex" && peer.threadId !== old.threadId) throw new Error("Codex resumed a different conversation");
|
|
355
622
|
if (old.id === "claude" && peer.sessionId !== old.sessionId) {
|
|
356
623
|
// #64: the restore gate already accepted a fresh session when the original never
|
|
@@ -363,7 +630,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
363
630
|
}
|
|
364
631
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
365
632
|
const saved = progress.terminals[`restored:${original.peer}`];
|
|
366
|
-
if (!saved || saved === "
|
|
633
|
+
if (!saved || typeof saved === "string") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
|
|
367
634
|
await revalidateTerminal(planned, progress, saved as TerminalBinding, true);
|
|
368
635
|
}
|
|
369
636
|
const snapshot = await rpc(planned.project, { t: "ui_snapshot", after: 0 });
|