@staix/agent-hub 0.12.32 → 0.12.34
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 +16 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +7 -5
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +48 -14
- package/docs/quickstart.md +5 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +20 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +33 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/reset.ts +13 -1
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-presentation.ts +11 -1
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +107 -146
- package/src/cli/upgrade.ts +10 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/bus.ts +133 -14
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +244 -241
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/restart.ts +12 -1
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +14 -11
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/routing.toml +2 -2
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { checkedSessionPath, recordedFileState } from "./recovery-session-file.ts";
|
|
2
1
|
import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
3
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
4
|
-
import { qwenSessionId } from "../hub/crash.ts";
|
|
2
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
5
3
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
6
|
-
import { cpSync, existsSync, mkdirSync,
|
|
7
|
-
import {
|
|
4
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
8
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
7
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
|
|
10
8
|
import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
|
|
@@ -13,12 +11,13 @@ import { hubHome } from "../hub/project.ts";
|
|
|
13
11
|
import { packageDigest, preserveRuntimeDependencies, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
|
|
14
12
|
import { inspectSavedTerminal, inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
15
13
|
import { ReattachmentRequired, MissingRecordedSession, FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
|
|
16
|
-
import {
|
|
14
|
+
import { SHARED_CHANNEL_VERSION_ARGV, nativeRecordedSessionState } from "../adapters/definitions/native-recovery.ts";
|
|
17
15
|
import { refreshManager } from "../hub/manager.ts";
|
|
18
16
|
import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
|
|
19
17
|
import { readRecoveryContinuity, retireRecoveryContinuity, writeRecoveryContinuity } from "../hub/recovery-continuity.ts";
|
|
20
18
|
import { recoveryLock } from "../hub/recovery-store.ts";
|
|
21
19
|
import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type RestoreEvidence, type RestoreTransition } from "./upgrade-restore-state.ts";
|
|
20
|
+
import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
|
|
22
21
|
import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
|
|
23
22
|
|
|
24
23
|
/** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
|
|
@@ -33,50 +32,27 @@ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalB
|
|
|
33
32
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
34
33
|
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");
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* #215: `codex resume <id>` reads the thread's rollout from the store its launcher uses (the captured CODEX_HOME, else
|
|
48
|
-
* the default), so an app-server thread id alone proves nothing.
|
|
49
|
-
* ponytail: a `.jsonl` under sessions/ whose name carries the thread id is the evidence; ask Codex itself once it offers
|
|
50
|
-
* a supported resumability query, or when its store layout changes.
|
|
51
|
-
*/
|
|
52
|
-
const codexSessions = (binding: TerminalBinding) => checkedSessionPath(binding, "rollout");
|
|
53
|
-
function codexTranscript(binding: TerminalBinding): "found" | "missing" | "unknown" {
|
|
54
|
-
try { return readdirSync(codexSessions(binding), { recursive: true }).some((name) => String(name).endsWith(".jsonl") && basename(String(name)).includes(binding.sessionId)) ? "found" : "missing"; }
|
|
55
|
-
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; } // no store has no rollout; an unreadable one says nothing
|
|
56
|
-
}
|
|
57
|
-
const unresumable = (binding: TerminalBinding) => codexTranscript(binding) === "unknown"
|
|
58
|
-
? `codex: the session store ${codexSessions(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
59
|
-
: `codex: thread ${binding.sessionId} has no resumable transcript under ${codexSessions(binding)}`;
|
|
35
|
+
const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
|
|
36
|
+
const state = definitionOf(binding.peer)?.session.transcriptExists?.(binding);
|
|
37
|
+
return state === "unknown" ? "unknown" : state === false ? "missing" : "found";
|
|
38
|
+
};
|
|
39
|
+
const sessionPath = (binding: TerminalBinding) => definitionOf(binding.peer)?.session.transcriptPath?.(binding);
|
|
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") => definitionOf(binding.peer)?.session.zeroTurn?.({ stateDir, binding, phase }) === true;
|
|
42
|
+
const unresumable = (binding: TerminalBinding) => sessionState(binding) === "unknown"
|
|
43
|
+
? `codex: the session store ${sessionPath(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
44
|
+
: `codex: thread ${binding.sessionId} has no resumable transcript under ${sessionPath(binding)}`;
|
|
60
45
|
|
|
61
46
|
const closeUnsettled = (binding: TerminalBinding, seen: "listed" | "unreadable") => seen === "listed"
|
|
62
47
|
? `${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`
|
|
63
48
|
: `${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`;
|
|
64
49
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
function codexZeroTurn(stateDir: string, thread: string): boolean {
|
|
72
|
-
let current: string | undefined, started = false, turned = false;
|
|
73
|
-
for (const e of readEvents(join(stateDir, "events.jsonl"))) {
|
|
74
|
-
if (e.type === "native_thread" && e.peer === "codex") {
|
|
75
|
-
current = e.thread;
|
|
76
|
-
if (e.thread === thread && e.fresh) started = true;
|
|
77
|
-
} else if (e.type === "turn_start" && e.peer === "codex" && current === thread) turned = true;
|
|
78
|
-
}
|
|
79
|
-
return started && !turned;
|
|
50
|
+
/** Preserve legacy source-text capability probes; only fixed definition data supplies paths and export names. */
|
|
51
|
+
function legacyTargetLists(root: string, probe: { file: string; exportName: string }, kind: string): boolean {
|
|
52
|
+
const file = join(root, probe.file);
|
|
53
|
+
const values = existsSync(file) ? new RegExp(`export const ${probe.exportName}\\s*=\\s*\\[([^\\]]*)\\]`).exec(readFileSync(file, "utf8"))?.[1] : undefined;
|
|
54
|
+
const name = kind.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
return !!values && new RegExp(`["']${name}["']`).test(values);
|
|
80
56
|
}
|
|
81
57
|
|
|
82
58
|
function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
|
|
@@ -88,11 +64,9 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
|
|
|
88
64
|
|
|
89
65
|
/** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
|
|
90
66
|
export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
return [process.execPath, entrypoint, "--project", projectRoot,
|
|
94
|
-
...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
|
|
95
|
-
...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
|
|
67
|
+
const tail = definitionOf(binding.peer)?.session.resumeArgv?.(binding, fresh);
|
|
68
|
+
if (!tail) throw new Error(`${binding.peer}: no recorded terminal resume adapter`);
|
|
69
|
+
return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
|
|
96
70
|
}
|
|
97
71
|
|
|
98
72
|
const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
|
|
@@ -134,9 +108,9 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
134
108
|
// The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
|
|
135
109
|
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";
|
|
136
110
|
if (binding) {
|
|
137
|
-
if ((binding.peer
|
|
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}`);
|
|
138
112
|
// The same question the plan asked (`makeUpgradePlan`), for this one peer.
|
|
139
|
-
const session = binding.peer
|
|
113
|
+
const session = definitionOf(binding.peer)?.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;
|
|
140
114
|
const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
|
|
141
115
|
current = found.byPeer[binding.peer];
|
|
142
116
|
if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
|
|
@@ -172,12 +146,21 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
172
146
|
const sourceProtocol = control?.protocol;
|
|
173
147
|
const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
|
|
174
148
|
// A stopped runtime reports whose unreleased restart snapshot it holds: only that one can start it again (#215).
|
|
175
|
-
|
|
149
|
+
let snapshot: string | undefined, snapshotError: Inspection["snapshotError"];
|
|
150
|
+
if (base.state === "stopped") {
|
|
151
|
+
try { snapshot = readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id })?.operationId; }
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!(error instanceof RecoveryDeferredHubError)) throw error;
|
|
154
|
+
// Corruption blocks restore, but cannot erase independently confirmed shutdown or the person's archive exit.
|
|
155
|
+
// Keep diagnostics static: the rejected snapshot may contain forged envelope fields.
|
|
156
|
+
snapshotError = { code: "recovery_deferred_hub", message: "restart snapshot deferred hub notices are invalid; restore is blocked" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
176
159
|
// A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
|
|
177
160
|
// "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
|
|
178
161
|
if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) return {
|
|
179
|
-
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],
|
|
180
|
-
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
|
|
162
|
+
state: base.state, peers: [], blockers: base.state === "stopped" ? snapshotError ? [snapshotError.message] : [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
|
|
163
|
+
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
|
|
181
164
|
};
|
|
182
165
|
let status = base.status;
|
|
183
166
|
if (!status && control && sourceProtocol === PROTOCOL) return {
|
|
@@ -226,7 +209,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
226
209
|
sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), projects: [], blockers: [] };
|
|
227
210
|
if (kind === "upgrade") {
|
|
228
211
|
try {
|
|
229
|
-
if ((await run([
|
|
212
|
+
if ((await run([...SHARED_CHANNEL_VERSION_ARGV], { timeoutMs: 10_000 })).code !== 0) body.blockers.push("shared Claude plugin installer is unavailable");
|
|
230
213
|
} catch { body.blockers.push("shared Claude plugin installer is unavailable"); }
|
|
231
214
|
}
|
|
232
215
|
const managerFile = join(hubHome(), "manager/status.json");
|
|
@@ -245,21 +228,19 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
245
228
|
const blockers = [...source.blockers];
|
|
246
229
|
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");
|
|
247
230
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
248
|
-
const sessions:
|
|
231
|
+
const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
|
|
249
232
|
const reconnectOnly: string[] = [];
|
|
250
233
|
for (const peer of source.peers) {
|
|
251
234
|
if (peer.state === "offline") continue;
|
|
252
235
|
const adapter = definitionOf(peer.id);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
if (peer.id === "claude") {
|
|
236
|
+
const refusal = adapter?.session.recoveryRefusal?.(peer);
|
|
237
|
+
if (refusal) { blockers.push(refusal); continue; }
|
|
238
|
+
if (adapter?.transport === "claude-channel") {
|
|
258
239
|
// #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
|
|
259
240
|
// that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
|
|
260
241
|
// ponytail: the channel's hello carries no launch id, so a plain `claude` that took the peer id from a live
|
|
261
242
|
// managed session reads as managed; send the launch id at hello (a protocol bump) if that case shows up.
|
|
262
|
-
const recorded = await launcherOf("
|
|
243
|
+
const recorded = await launcherOf(peer.id as TerminalBinding["peer"], project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
|
|
263
244
|
if (recorded?.state === "unknown") {
|
|
264
245
|
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
265
246
|
delete peer.sessionId;
|
|
@@ -269,7 +250,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
269
250
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
270
251
|
if (!launcher) {
|
|
271
252
|
delete peer.sessionId;
|
|
272
|
-
if (source.protocol === PROTOCOL) reconnectOnly.push(
|
|
253
|
+
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
273
254
|
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`);
|
|
274
255
|
continue;
|
|
275
256
|
}
|
|
@@ -281,28 +262,27 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
281
262
|
continue;
|
|
282
263
|
}
|
|
283
264
|
}
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
265
|
+
if (adapter?.session.launchRecord && (!adapter.session.restoreArgs || peer.args?.mode === "tui")) {
|
|
266
|
+
const session = observedSession(peer.id, peer);
|
|
267
|
+
if (!session) blockers.push(adapter.session.crashIdentity
|
|
268
|
+
? `${peer.id}: original session ID is unknown; manual-required; next action: reconnect the original ${adapter.displayName} session and make a new recovery plan`
|
|
269
|
+
: `${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
|
|
270
|
+
else sessions[peer.id as TerminalBinding["peer"]] = adapter.session.crashIdentity
|
|
271
|
+
? { sessionId: session, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args?.backend ? { backend: peer.args.backend } : {}), ...(peer.args?.model ? { model: peer.args.model } : {}) } : session;
|
|
272
|
+
} else if (adapter?.session.resumeById) {
|
|
273
|
+
const session = adapter.session.crashIdentity?.(peer).sessionId;
|
|
291
274
|
if (!session) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
|
|
292
|
-
} else if (
|
|
293
|
-
if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required; next action: reconnect the original Pi session and make a new recovery plan");
|
|
294
|
-
else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
|
|
295
|
-
} else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
275
|
+
} else if (!adapter?.session.freshOnRestore && !adapter?.session.restoreArgs) blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
296
276
|
}
|
|
297
277
|
const terminals = await inspectTerminals(project.root, sessions, {
|
|
298
278
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
299
279
|
});
|
|
300
280
|
blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
|
|
301
281
|
const freshStart: string[] = [];
|
|
302
|
-
for (const binding of terminals.bindings) if (binding.peer === "
|
|
303
|
-
const transcript =
|
|
282
|
+
for (const binding of terminals.bindings) if (definitionOf(binding.peer)?.session.missingEvidence === "rollout") {
|
|
283
|
+
const transcript = sessionState(binding);
|
|
304
284
|
if (transcript === "found") continue;
|
|
305
|
-
if (transcript === "missing" &&
|
|
285
|
+
if (transcript === "missing" && nativeZeroTurn(project.stateDir, binding, "plan")) { freshStart.push(binding.peer); continue; }
|
|
306
286
|
blockers.push(transcript === "unknown"
|
|
307
287
|
? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
|
|
308
288
|
: `${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`);
|
|
@@ -352,7 +332,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
352
332
|
delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
|
|
353
333
|
const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
|
|
354
334
|
for (const binding of bindings ?? []) {
|
|
355
|
-
|
|
335
|
+
// Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
|
|
336
|
+
const key = definitionOf(binding.peer)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
|
|
356
337
|
if (binding.launch.env[key]) value[key] = binding.launch.env[key];
|
|
357
338
|
}
|
|
358
339
|
}
|
|
@@ -384,8 +365,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
384
365
|
const observed = await inspectRecovery(planned.project);
|
|
385
366
|
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 });
|
|
386
367
|
const peer = observed.peers.find((item) => item.id === saved.peer);
|
|
387
|
-
const session = saved.peer
|
|
388
|
-
if (session !== saved.sessionId || saved.peer
|
|
368
|
+
const session = observedSession(saved.peer, peer);
|
|
369
|
+
if (session !== saved.sessionId || definitionOf(saved.peer)?.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 }) });
|
|
389
370
|
return current;
|
|
390
371
|
};
|
|
391
372
|
/** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
|
|
@@ -410,7 +391,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
410
391
|
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" }) };
|
|
411
392
|
}
|
|
412
393
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
413
|
-
const session = peer
|
|
394
|
+
const session = observedSession(peer, attached);
|
|
414
395
|
if (session) return { state: "live", session };
|
|
415
396
|
let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
|
|
416
397
|
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
@@ -506,11 +487,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
506
487
|
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 });
|
|
507
488
|
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 });
|
|
508
489
|
const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
|
|
509
|
-
const session = binding.peer
|
|
490
|
+
const session = observedSession(binding.peer, peer);
|
|
510
491
|
const changed = !!session && expectedSession !== null && session !== expectedSession;
|
|
511
492
|
// #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
|
|
512
493
|
// at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
|
|
513
|
-
const zeroTurn = changed && !progress.reattachments?.[binding.peer] && !progress.repairedSessions?.[binding.peer] &&
|
|
494
|
+
const zeroTurn = changed && !progress.reattachments?.[binding.peer] && !progress.repairedSessions?.[binding.peer] && (planned.terminals as TerminalBinding[]).some((p) => p.peer === binding.peer && p.sessionId === binding.sessionId) && nativeZeroTurn(planned.project.stateDir, binding, "attach");
|
|
514
495
|
if (changed && !zeroTurn) throw nativeFailure(planned, binding.peer, "session_changed", `${binding.peer}: a different conversation attached while awaiting ${binding.sessionId}`, { handle: binding.handle, expectedSession: expectedSession!, observedSession: session! });
|
|
515
496
|
if (now() > deadline) break;
|
|
516
497
|
const launcher = await launcherOf(binding.peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
|
|
@@ -536,7 +517,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
536
517
|
const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
|
|
537
518
|
session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
|
|
538
519
|
if (decision.action === "settle" && session) {
|
|
539
|
-
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, {
|
|
520
|
+
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
|
|
540
521
|
return session;
|
|
541
522
|
}
|
|
542
523
|
if (decision.action === "fail" || decision.action === "create") throw new AttachmentLauncherGone(decision);
|
|
@@ -559,7 +540,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
559
540
|
}
|
|
560
541
|
} catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
|
|
561
542
|
const peer = live.peers.find((p) => p.id === binding.peer);
|
|
562
|
-
const session = peer?.state === "offline" ? undefined : binding.peer
|
|
543
|
+
const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer);
|
|
563
544
|
(progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
|
|
564
545
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
565
546
|
lastHubConnectAttempt: "unknown" };
|
|
@@ -580,7 +561,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
580
561
|
return {
|
|
581
562
|
...(liveTestPeer === undefined ? {} : { liveTestPeer }),
|
|
582
563
|
now, sleep, inspect: inspectRecovery,
|
|
583
|
-
recordedSessionState: async (binding, kind) =>
|
|
564
|
+
recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
|
|
584
565
|
stage: async (op) => {
|
|
585
566
|
if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
|
|
586
567
|
const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
|
|
@@ -606,15 +587,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
606
587
|
const verified = { ...target, targetFeatures };
|
|
607
588
|
// Existing peer/waiver compatibility checks remain properties of the already verified bytes.
|
|
608
589
|
if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
if (!
|
|
613
|
-
}
|
|
614
|
-
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
|
|
615
|
-
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
616
|
-
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
617
|
-
if (!peers || !/["']jcode["']/.test(peers)) throw new FinalRefusal("target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged");
|
|
590
|
+
for (const adapter of ADAPTERS) {
|
|
591
|
+
const probe = adapter.session.legacyTargetSupport;
|
|
592
|
+
if (!probe || !op.plan.projects.some(p => p.source.peers.some(peer => peer.id === adapter.kind && peer.state !== "offline"))) continue;
|
|
593
|
+
if (!legacyTargetLists(target.root, probe, adapter.kind)) throw new FinalRefusal(probe.refusal);
|
|
618
594
|
}
|
|
619
595
|
// Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
|
|
620
596
|
if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
|
|
@@ -654,7 +630,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
654
630
|
progress.terminals[key] = true; save(); continue;
|
|
655
631
|
}
|
|
656
632
|
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
657
|
-
const transcript = binding.peer === "
|
|
633
|
+
const transcript = definitionOf(binding.peer)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
|
|
658
634
|
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
659
635
|
if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
|
|
660
636
|
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`);
|
|
@@ -666,8 +642,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
666
642
|
throw new Error(`source preparation expired or changed while waiting for the terminal; no terminal was closed`);
|
|
667
643
|
}
|
|
668
644
|
// Refresh the same prepared lease immediately before the terminal effect. Preserve
|
|
669
|
-
// the original peer roster
|
|
670
|
-
await control(planned.project, "prepare", op.id, planned.source.instanceId
|
|
645
|
+
// the original peer roster and verified target features, including after a resume.
|
|
646
|
+
await control(planned.project, "prepare", op.id, planned.source.instanceId!, op.targetFeatures);
|
|
671
647
|
progress.terminals[key] = "pending"; save();
|
|
672
648
|
const result = await closeTerminal(binding, 0, terminalOptions(run));
|
|
673
649
|
if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
|
|
@@ -687,36 +663,23 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
687
663
|
const live = await inspectRecovery(planned.project);
|
|
688
664
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
689
665
|
if (group === "native" && !onlyPeer) {
|
|
690
|
-
for (const peer of planned.source.peers.filter(
|
|
666
|
+
for (const peer of planned.source.peers.filter(p => definitionOf(p.id)?.session.restoreArgs && p.state !== "offline" && !(definitionOf(p.id)?.session.launchRecord && p.args?.mode === "tui"))) {
|
|
691
667
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
692
668
|
if (current && current.state !== "offline") continue;
|
|
669
|
+
const adapter = definitionOf(peer.id)!;
|
|
693
670
|
const args = [peer.id];
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
|
|
701
|
-
if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
|
|
702
|
-
}
|
|
703
|
-
// #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
|
|
704
|
-
// one is filtered out above), so a snapshot that recorded no mode still restores it headless.
|
|
705
|
-
if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
|
|
706
|
-
if (definitionOf(peer.id)?.session.restoreOnStart && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
707
|
-
if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
708
|
-
if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
|
|
709
|
-
if (peer.id === "pi") {
|
|
710
|
-
const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
|
|
711
|
-
if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
|
|
712
|
-
else if (peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
713
|
-
}
|
|
671
|
+
const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
|
|
672
|
+
if (refusal) throw new Error(refusal);
|
|
673
|
+
const captured = adapter.session.restoreArgs!(peer);
|
|
674
|
+
for (const [key, value] of Object.entries(captured)) args.push(`--${key}`, value);
|
|
675
|
+
const legacy = adapter.session.legacyHeadlessArgv;
|
|
676
|
+
if (legacy && legacyTargetLists(op.targetRoot!, legacy, adapter.kind)) args.push(...legacy.argv);
|
|
714
677
|
await command(op, args, planned.project);
|
|
715
678
|
}
|
|
716
679
|
}
|
|
717
680
|
let claudeGroup = false;
|
|
718
681
|
for (const plannedBinding of planned.terminals as TerminalBinding[]) {
|
|
719
|
-
if ((plannedBinding.peer === "claude") !== (claudeGroup = group
|
|
682
|
+
if ((definitionOf(plannedBinding.peer)?.transport === "claude-channel") !== (claudeGroup = group !== "native") || onlyPeer && plannedBinding.peer !== onlyPeer) continue;
|
|
720
683
|
const key = `restored:${plannedBinding.peer}`;
|
|
721
684
|
try {
|
|
722
685
|
// #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
|
|
@@ -733,17 +696,17 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
733
696
|
// A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
|
|
734
697
|
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
735
698
|
const chosen = !recordedOnly && !!progress.fresh?.[original.peer];
|
|
736
|
-
let read: ReturnType<typeof
|
|
737
|
-
const transcript = () => original.peer !== "
|
|
699
|
+
let read: ReturnType<typeof sessionState> | undefined;
|
|
700
|
+
const transcript = () => definitionOf(original.peer)?.session.missingEvidence !== "rollout" || (chosen && !accepted) ? "found" : (read ??= sessionState(original));
|
|
738
701
|
// An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
|
|
739
702
|
// rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
|
|
740
703
|
const fresh = () => recordedOnly ? false : accepted
|
|
741
|
-
? (original.peer === "
|
|
704
|
+
? (definitionOf(original.peer)?.session.missingEvidence === "rollout" ? (chosen || !!planned.freshStart?.includes(original.peer)) && transcript() === "missing" : nativeZeroTurn(planned.project.stateDir, original, "attach"))
|
|
742
705
|
: chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
|
|
743
706
|
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
744
707
|
// peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
|
|
745
708
|
// The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
|
|
746
|
-
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
|
|
709
|
+
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : definitionOf(original.peer)?.transport === "claude-channel" ? "zero-turn" : "fresh-start";
|
|
747
710
|
const notRestored = (why: string) => new Error(fresh()
|
|
748
711
|
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
749
712
|
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
@@ -759,7 +722,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
759
722
|
const decide = (receipt: RestoreReceipt, evidence: Awaited<ReturnType<typeof peerEvidence>>) => restoreTransition(receipt, {
|
|
760
723
|
target: "running", launcher: evidence.state === "unknown" ? "unknown" : evidence.state === "gone" ? "gone" : evidence.terminal ? "live" : "older-instance",
|
|
761
724
|
session: evidence.state === "live" && evidence.session ? evidence.session === original.sessionId ? "planned" : "other" : "none",
|
|
762
|
-
allowOther: fresh() || (!recordedOnly &&
|
|
725
|
+
allowOther: fresh() || (!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach")),
|
|
763
726
|
binding: evidence.state === "live" && evidence.terminal ? { ...original, ...evidence.terminal } : original,
|
|
764
727
|
});
|
|
765
728
|
const receipt = decodeRestoreReceipt(progress.terminals[key]);
|
|
@@ -780,7 +743,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
780
743
|
// #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
|
|
781
744
|
// succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
|
|
782
745
|
// the operator or the plan accepted, counts as restored.
|
|
783
|
-
if (attached !== original.sessionId && !fresh() && !(!recordedOnly &&
|
|
746
|
+
if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
|
|
784
747
|
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`);
|
|
785
748
|
}
|
|
786
749
|
// Validate the proposed transition before committing continuity/waiver effects or a done receipt.
|
|
@@ -852,7 +815,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
852
815
|
if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
|
|
853
816
|
if (transcript() === "missing" && !fresh()) {
|
|
854
817
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
855
|
-
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind:
|
|
818
|
+
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind: definitionOf(original.peer)!.session.missingEvidence!, checkedPath: sessionPath(original) });
|
|
856
819
|
throw notRestored(unresumable(original).slice("codex: ".length));
|
|
857
820
|
}
|
|
858
821
|
if (fresh()) {
|
|
@@ -908,14 +871,14 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
908
871
|
// A bare pending/failed row can hide an uncertain create. Only a durable exact chosen binding
|
|
909
872
|
// can become the terminal that an explicit fresh choice must close before another create.
|
|
910
873
|
const binding = chosenReceipt?.state === "waiting" ? chosenReceipt.binding : undefined;
|
|
911
|
-
if (binding &&
|
|
874
|
+
if (binding && sessionState(binding) === "missing" && (await peerEvidence(planned, progress, plannedBinding.peer)).state === "gone") {
|
|
912
875
|
// Retain the exact chosen terminal so an explicit later fresh choice closes it before creating again.
|
|
913
876
|
progress.terminals[`retired:${plannedBinding.peer}`] = binding;
|
|
914
877
|
delete progress.terminals[`closedRetired:${plannedBinding.peer}`];
|
|
915
878
|
const marker = progress.reattachments?.[plannedBinding.peer];
|
|
916
879
|
if (marker) marker.binding = binding;
|
|
917
880
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
918
|
-
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind:
|
|
881
|
+
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind: definitionOf(plannedBinding.peer)!.session.missingEvidence!, checkedPath: sessionPath(binding) });
|
|
919
882
|
}
|
|
920
883
|
}
|
|
921
884
|
if (error instanceof ReattachmentRequired || isDatabaseBusy(error)) throw error;
|
|
@@ -924,7 +887,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
924
887
|
throw error;
|
|
925
888
|
}
|
|
926
889
|
}
|
|
927
|
-
if (group
|
|
890
|
+
if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
|
|
928
891
|
// #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
|
|
929
892
|
const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
|
|
930
893
|
while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
|
|
@@ -936,7 +899,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
936
899
|
installPlugin: async (op) => {
|
|
937
900
|
const configured = new Set<string>();
|
|
938
901
|
for (const planned of op.plan.projects) {
|
|
939
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
|
|
902
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => definitionOf(t.peer)?.transport === "claude-channel");
|
|
940
903
|
if (!binding) continue;
|
|
941
904
|
const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
|
|
942
905
|
if (configured.has(profile)) continue;
|
|
@@ -964,7 +927,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
964
927
|
abandonRestartSnapshot(project.stateDir, op.id);
|
|
965
928
|
const planned = op.plan.projects?.find(item => item.project.id === project.id);
|
|
966
929
|
const progress = op.projects?.find(item => item.id === project.id);
|
|
967
|
-
const source = planned?.source.peers.find(peer => peer.id
|
|
930
|
+
const source = planned?.source.peers.find(peer => definitionOf(peer.id)?.start.autoStart && peer.state !== "offline");
|
|
968
931
|
if (source && progress) {
|
|
969
932
|
// abandonRestartSnapshot is deliberately a no-op for another operation or an unreadable snapshot.
|
|
970
933
|
// A continuity bookmark needs positive archival evidence, including when disposal resumes after a crash.
|
|
@@ -973,13 +936,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
973
936
|
try { archived = JSON.parse(readFileSync(archive, "utf8")); } catch { return { continuity: "not-archived" }; }
|
|
974
937
|
if (!archived || typeof archived !== "object" || (archived as { operationId?: unknown }).operationId !== op.id) return { continuity: "not-archived" };
|
|
975
938
|
try {
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
const backend = binding?.backend ?? source.args?.backend;
|
|
939
|
+
const continuity = definitionOf(source.id)?.session.continuityBookmark;
|
|
940
|
+
if (!continuity) return;
|
|
941
|
+
const binding = planned ? resolveAcceptedBinding(planned, progress, source.id as TerminalBinding["peer"]) : undefined;
|
|
980
942
|
writeRecoveryContinuity(project.stateDir, { schema: 1, operationId: op.id, projectId: project.id, projectRoot: project.root, at: now(),
|
|
981
|
-
|
|
982
|
-
...(backend === "auto" || backend === "dgx" || backend === "mlx" ? { backend } : {}), model: binding?.model ?? source.args?.model } });
|
|
943
|
+
...continuity(source, binding) });
|
|
983
944
|
} catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
|
|
984
945
|
}
|
|
985
946
|
},
|
|
@@ -1002,21 +963,21 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
1002
963
|
// A #225 restart clears the old target's repair fence, never its accepted conversation.
|
|
1003
964
|
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;
|
|
1004
965
|
if ((recordedOnly || retained) && accepted?.state === "done") {
|
|
1005
|
-
const session = old.id
|
|
966
|
+
const session = observedSession(old.id, peer);
|
|
1006
967
|
if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
|
|
1007
|
-
old.id
|
|
968
|
+
definitionOf(old.id)?.session.launchRecord && definitionOf(old.id)?.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 }) });
|
|
1008
969
|
continue;
|
|
1009
970
|
}
|
|
1010
971
|
if (!recordedOnly && (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id))) continue; // #215: a new session was accepted (recorded)
|
|
1011
|
-
if (old.id === "
|
|
1012
|
-
if (old.id === "claude" && peer.sessionId !== old.sessionId) {
|
|
972
|
+
if (definitionOf(old.id)?.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 } : {}) });
|
|
973
|
+
if (definitionOf(old.id)?.transport === "claude-channel" && peer.sessionId !== old.sessionId) {
|
|
1013
974
|
// #64: the restore gate already accepted a fresh session when the original never
|
|
1014
975
|
// persisted a transcript. Re-derive persistence from the binding exactly like that
|
|
1015
976
|
// gate did, so a first turn landing after the plan was made keeps the check strict.
|
|
1016
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
|
|
1017
|
-
if (recordedOnly || !binding || binding.sessionId !== old.sessionId ||
|
|
977
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => definitionOf(t.peer)?.transport === "claude-channel");
|
|
978
|
+
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 } : {}) });
|
|
1018
979
|
}
|
|
1019
|
-
if (old.id
|
|
980
|
+
if (definitionOf(old.id)?.session.launchRecord && definitionOf(old.id)?.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 } : {}) });
|
|
1020
981
|
}
|
|
1021
982
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
1022
983
|
const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
|