@staix/agent-hub 0.12.33 → 0.12.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +9 -3
- package/docs/agent-notes/budget.md +7 -3
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +80 -16
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +7 -4
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +35 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23567 -16606
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +82 -12
- 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 +34 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +41 -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-native.ts +2 -2
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +100 -10
- package/src/adapters/registry.ts +127 -11
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +8 -7
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +41 -102
- package/src/cli/main.ts +126 -290
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +26 -126
- package/src/cli/recovery-guidance.ts +43 -22
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +81 -22
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +17 -7
- package/src/cli/upgrade-runtime.ts +126 -147
- package/src/cli/upgrade.ts +44 -27
- package/src/hub/budget.ts +59 -31
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +19 -9
- package/src/hub/config-apply.ts +22 -15
- package/src/hub/config-reload.ts +18 -3
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +15 -39
- package/src/hub/daemon.ts +506 -341
- package/src/hub/execution-budget.ts +3 -2
- package/src/hub/hub-tools.ts +19 -8
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +14 -17
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +9 -9
- package/src/hub/routing.ts +94 -19
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +39 -10
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +13 -8
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +60 -27
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/hub/workflow.ts +30 -52
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +5 -3
- package/src/ui/index.html +4 -3
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/AGENTS.block.md +2 -1
- package/templates/routing.toml +2 -2
|
@@ -1,10 +1,9 @@
|
|
|
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 {
|
|
2
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
3
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
5
4
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
6
|
-
import { cpSync, existsSync, mkdirSync,
|
|
7
|
-
import {
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
8
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
8
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
|
|
10
9
|
import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
|
|
@@ -13,7 +12,7 @@ import { hubHome } from "../hub/project.ts";
|
|
|
13
12
|
import { packageDigest, preserveRuntimeDependencies, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
|
|
14
13
|
import { inspectSavedTerminal, inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
15
14
|
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 {
|
|
15
|
+
import { SHARED_CHANNEL_VERSION_ARGV, nativeRecordedSessionState } from "../adapters/definitions/native-recovery.ts";
|
|
17
16
|
import { refreshManager } from "../hub/manager.ts";
|
|
18
17
|
import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
|
|
19
18
|
import { readRecoveryContinuity, retireRecoveryContinuity, writeRecoveryContinuity } from "../hub/recovery-continuity.ts";
|
|
@@ -34,50 +33,30 @@ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalB
|
|
|
34
33
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
35
34
|
const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
|
|
36
35
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
* dir, creating the project directory only with the first persisted turn; the slug replaces
|
|
40
|
-
* every non-alphanumeric character of the project root with '-'.
|
|
41
|
-
*/
|
|
42
|
-
function claudeTranscriptState(binding: TerminalBinding): "found" | "missing" | "unknown" {
|
|
43
|
-
return recordedFileState(checkedSessionPath(binding, "transcript"));
|
|
44
|
-
}
|
|
45
|
-
function claudeTranscriptExists(binding: TerminalBinding): boolean { return claudeTranscriptState(binding) === "found"; }
|
|
36
|
+
/** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
|
|
37
|
+
const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
|
|
46
38
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const unresumable = (binding: TerminalBinding) => codexTranscript(binding) === "unknown"
|
|
59
|
-
? `codex: the session store ${codexSessions(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
60
|
-
: `codex: thread ${binding.sessionId} has no resumable transcript under ${codexSessions(binding)}`;
|
|
39
|
+
const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
|
|
40
|
+
const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
|
|
41
|
+
return state === "unknown" ? "unknown" : state === false ? "missing" : "found";
|
|
42
|
+
};
|
|
43
|
+
const sessionPath = (binding: TerminalBinding) => projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptPath?.(binding);
|
|
44
|
+
export const observedSession = (peer: string, value: RecoveryPeer | undefined, projectRoot?: string) => value?.[(projectRoot ? projectDefinition(peer, projectRoot) : definitionOf(peer))?.session.idField ?? "sessionId"];
|
|
45
|
+
const nativeZeroTurn = (stateDir: string, binding: TerminalBinding, phase: "plan" | "attach" | "verify") => projectDefinition(binding.peer, binding.projectRoot)?.session.zeroTurn?.({ stateDir, binding, phase }) === true;
|
|
46
|
+
const unresumable = (binding: TerminalBinding) => sessionState(binding) === "unknown"
|
|
47
|
+
? `codex: the session store ${sessionPath(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
48
|
+
: `codex: thread ${binding.sessionId} has no resumable transcript under ${sessionPath(binding)}`;
|
|
61
49
|
|
|
62
50
|
const closeUnsettled = (binding: TerminalBinding, seen: "listed" | "unreadable") => seen === "listed"
|
|
63
51
|
? `${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`
|
|
64
52
|
: `${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`;
|
|
65
53
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
function codexZeroTurn(stateDir: string, thread: string): boolean {
|
|
73
|
-
let current: string | undefined, started = false, turned = false;
|
|
74
|
-
for (const e of readEvents(join(stateDir, "events.jsonl"))) {
|
|
75
|
-
if (e.type === "native_thread" && e.peer === "codex") {
|
|
76
|
-
current = e.thread;
|
|
77
|
-
if (e.thread === thread && e.fresh) started = true;
|
|
78
|
-
} else if (e.type === "turn_start" && e.peer === "codex" && current === thread) turned = true;
|
|
79
|
-
}
|
|
80
|
-
return started && !turned;
|
|
54
|
+
/** Preserve legacy source-text capability probes; only fixed definition data supplies paths and export names. */
|
|
55
|
+
function legacyTargetLists(root: string, probe: { file: string; exportName: string }, kind: string): boolean {
|
|
56
|
+
const file = join(root, probe.file);
|
|
57
|
+
const values = existsSync(file) ? new RegExp(`export const ${probe.exportName}\\s*=\\s*\\[([^\\]]*)\\]`).exec(readFileSync(file, "utf8"))?.[1] : undefined;
|
|
58
|
+
const name = kind.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
59
|
+
return !!values && new RegExp(`["']${name}["']`).test(values);
|
|
81
60
|
}
|
|
82
61
|
|
|
83
62
|
function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
|
|
@@ -89,11 +68,11 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
|
|
|
89
68
|
|
|
90
69
|
/** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
|
|
91
70
|
export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
71
|
+
const adapter = projectDefinition(binding.peer, binding.projectRoot);
|
|
72
|
+
const nativeTail = adapter?.session.resumeArgv?.(binding, fresh);
|
|
73
|
+
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(binding.peer !== adapter.kind ? ["--as", binding.peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
74
|
+
if (!tail) throw new Error(`${binding.peer}: no recorded terminal resume adapter`);
|
|
75
|
+
return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
|
|
97
76
|
}
|
|
98
77
|
|
|
99
78
|
const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
|
|
@@ -135,9 +114,9 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
135
114
|
// The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
|
|
136
115
|
if (now.state === "busy") cut = "; its turn was cut: a delivery it had not settled is held as needs_review, and its queue stays held until ahub queue resolve";
|
|
137
116
|
if (binding) {
|
|
138
|
-
if ((binding.peer
|
|
117
|
+
if (observedSession(binding.peer, now, binding.projectRoot) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
|
|
139
118
|
// The same question the plan asked (`makeUpgradePlan`), for this one peer.
|
|
140
|
-
const session = binding.peer
|
|
119
|
+
const session = projectDefinition(binding.peer, binding.projectRoot)?.session.crashIdentity ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
|
|
141
120
|
const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
|
|
142
121
|
current = found.byPeer[binding.peer];
|
|
143
122
|
if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
|
|
@@ -162,7 +141,20 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
162
141
|
}
|
|
163
142
|
}
|
|
164
143
|
|
|
165
|
-
|
|
144
|
+
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
145
|
+
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
146
|
+
const declared = Object.keys(readInstanceConfig(projectRoot).instances ?? {});
|
|
147
|
+
if (!declared.length) return undefined;
|
|
148
|
+
if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
|
|
149
|
+
if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
|
|
150
|
+
const peers = Object.entries(observedPeers);
|
|
151
|
+
if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
|
|
152
|
+
!peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
|
|
153
|
+
}
|
|
154
|
+
return "source lacks peer_instances support for this project's declared instances; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end any extra native sessions and temporarily remove the instance declarations, upgrade the source, then declare them again; legacy peer identities were not reinterpreted";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
|
|
166
158
|
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
167
159
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
168
160
|
// caller reads that as not inspected, never as stopped.
|
|
@@ -208,6 +200,8 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
208
200
|
}
|
|
209
201
|
}
|
|
210
202
|
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
203
|
+
const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
|
|
204
|
+
if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
|
|
211
205
|
let response: any;
|
|
212
206
|
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
|
|
213
207
|
catch (error) {
|
|
@@ -236,7 +230,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
236
230
|
sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), projects: [], blockers: [] };
|
|
237
231
|
if (kind === "upgrade") {
|
|
238
232
|
try {
|
|
239
|
-
if ((await run([
|
|
233
|
+
if ((await run([...SHARED_CHANNEL_VERSION_ARGV], { timeoutMs: 10_000 })).code !== 0) body.blockers.push("shared Claude plugin installer is unavailable");
|
|
240
234
|
} catch { body.blockers.push("shared Claude plugin installer is unavailable"); }
|
|
241
235
|
}
|
|
242
236
|
const managerFile = join(hubHome(), "manager/status.json");
|
|
@@ -255,21 +249,19 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
255
249
|
const blockers = [...source.blockers];
|
|
256
250
|
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");
|
|
257
251
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
258
|
-
const sessions:
|
|
252
|
+
const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
|
|
259
253
|
const reconnectOnly: string[] = [];
|
|
260
254
|
for (const peer of source.peers) {
|
|
261
255
|
if (peer.state === "offline") continue;
|
|
262
|
-
const adapter =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
if (peer.id === "claude") {
|
|
256
|
+
const adapter = projectDefinition(peer.id, project.root);
|
|
257
|
+
const refusal = adapter?.session.recoveryRefusal?.(peer);
|
|
258
|
+
if (refusal) { blockers.push(refusal); continue; }
|
|
259
|
+
if (adapter?.transport === "claude-channel") {
|
|
268
260
|
// #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
|
|
269
261
|
// that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
|
|
270
262
|
// ponytail: the channel's hello carries no launch id, so a plain `claude` that took the peer id from a live
|
|
271
263
|
// managed session reads as managed; send the launch id at hello (a protocol bump) if that case shows up.
|
|
272
|
-
const recorded = await launcherOf("
|
|
264
|
+
const recorded = await launcherOf(peer.id as TerminalBinding["peer"], project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
|
|
273
265
|
if (recorded?.state === "unknown") {
|
|
274
266
|
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
275
267
|
delete peer.sessionId;
|
|
@@ -279,7 +271,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
279
271
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
280
272
|
if (!launcher) {
|
|
281
273
|
delete peer.sessionId;
|
|
282
|
-
if (source.protocol === PROTOCOL) reconnectOnly.push(
|
|
274
|
+
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
283
275
|
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`);
|
|
284
276
|
continue;
|
|
285
277
|
}
|
|
@@ -291,28 +283,27 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
291
283
|
continue;
|
|
292
284
|
}
|
|
293
285
|
}
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
286
|
+
if (adapter?.session.launchRecord && (!adapter.session.restoreArgs || peer.args?.mode === "tui")) {
|
|
287
|
+
const session = observedSession(peer.id, peer, project.root);
|
|
288
|
+
if (!session) blockers.push(adapter.session.crashIdentity
|
|
289
|
+
? `${peer.id}: original session ID is unknown; manual-required; next action: reconnect the original ${adapter.displayName} session and make a new recovery plan`
|
|
290
|
+
: `${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
|
|
291
|
+
else sessions[peer.id as TerminalBinding["peer"]] = adapter.session.crashIdentity
|
|
292
|
+
? { sessionId: session, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args?.backend ? { backend: peer.args.backend } : {}), ...(peer.args?.model ? { model: peer.args.model } : {}) } : session;
|
|
293
|
+
} else if (adapter?.session.resumeById) {
|
|
294
|
+
const session = adapter.session.crashIdentity?.(peer).sessionId;
|
|
301
295
|
if (!session) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
|
|
302
|
-
} else if (
|
|
303
|
-
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");
|
|
304
|
-
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 } : {}) };
|
|
305
|
-
} else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
296
|
+
} else if (!adapter?.session.freshOnRestore && !adapter?.session.restoreArgs) blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
306
297
|
}
|
|
307
298
|
const terminals = await inspectTerminals(project.root, sessions, {
|
|
308
299
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
309
300
|
});
|
|
310
301
|
blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
|
|
311
302
|
const freshStart: string[] = [];
|
|
312
|
-
for (const binding of terminals.bindings) if (binding.peer === "
|
|
313
|
-
const transcript =
|
|
303
|
+
for (const binding of terminals.bindings) if (projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout") {
|
|
304
|
+
const transcript = sessionState(binding);
|
|
314
305
|
if (transcript === "found") continue;
|
|
315
|
-
if (transcript === "missing" &&
|
|
306
|
+
if (transcript === "missing" && nativeZeroTurn(project.stateDir, binding, "plan")) { freshStart.push(binding.peer); continue; }
|
|
316
307
|
blockers.push(transcript === "unknown"
|
|
317
308
|
? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
|
|
318
309
|
: `${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`);
|
|
@@ -362,7 +353,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
362
353
|
delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
|
|
363
354
|
const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
|
|
364
355
|
for (const binding of bindings ?? []) {
|
|
365
|
-
|
|
356
|
+
// Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
|
|
357
|
+
const key = projectDefinition(binding.peer, binding.projectRoot)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
|
|
366
358
|
if (binding.launch.env[key]) value[key] = binding.launch.env[key];
|
|
367
359
|
}
|
|
368
360
|
}
|
|
@@ -394,8 +386,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
394
386
|
const observed = await inspectRecovery(planned.project);
|
|
395
387
|
if (observed.instanceId !== progress.instanceId) throw nativeFailure(planned, saved.peer, "target_unavailable", `${saved.peer}: daemon instance changed during terminal revalidation`, { handle: saved.handle, targetState: observed.state });
|
|
396
388
|
const peer = observed.peers.find((item) => item.id === saved.peer);
|
|
397
|
-
const session = saved.peer
|
|
398
|
-
if (session !== saved.sessionId || saved.peer
|
|
389
|
+
const session = observedSession(saved.peer, peer, saved.projectRoot);
|
|
390
|
+
if (session !== saved.sessionId || projectDefinition(saved.peer, saved.projectRoot)?.session.crashIdentity && progress.reattachments?.[saved.peer] && saved.sessionFile && peer?.sessionFile !== saved.sessionFile) throw nativeFailure(planned, saved.peer, "session_changed", `${saved.peer}: daemon session changed during terminal revalidation; manual-required`, { handle: saved.handle, expectedSession: saved.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
|
|
399
391
|
return current;
|
|
400
392
|
};
|
|
401
393
|
/** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
|
|
@@ -420,7 +412,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
420
412
|
return { state: "unknown", why: `the target hub reads as ${target.state === "running" ? `instance ${target.instanceId}, not ${progress.instanceId}` : target.state}`, ...(settled ? {} : { step: "wait until the target answers" }) };
|
|
421
413
|
}
|
|
422
414
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
423
|
-
const session = peer
|
|
415
|
+
const session = observedSession(peer, attached, planned.project.root);
|
|
424
416
|
if (session) return { state: "live", session };
|
|
425
417
|
let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
|
|
426
418
|
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
@@ -516,11 +508,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
516
508
|
if (live.state !== "running") throw nativeFailure(planned, binding.peer, "target_unavailable", `${binding.peer}: target hub reads as ${live.state} while awaiting attachment; saved terminal retained`, { handle: binding.handle, targetState: live.state });
|
|
517
509
|
if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw nativeFailure(planned, binding.peer, "target_unavailable", `${binding.peer}: target identity changed while awaiting attachment`, { handle: binding.handle, targetState: live.state });
|
|
518
510
|
const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
|
|
519
|
-
const session = binding.peer
|
|
511
|
+
const session = observedSession(binding.peer, peer, binding.projectRoot);
|
|
520
512
|
const changed = !!session && expectedSession !== null && session !== expectedSession;
|
|
521
513
|
// #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
|
|
522
514
|
// at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
|
|
523
|
-
const zeroTurn = changed && !progress.reattachments?.[binding.peer] && !progress.repairedSessions?.[binding.peer] &&
|
|
515
|
+
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");
|
|
524
516
|
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! });
|
|
525
517
|
if (now() > deadline) break;
|
|
526
518
|
const launcher = await launcherOf(binding.peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
|
|
@@ -546,7 +538,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
546
538
|
const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
|
|
547
539
|
session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
|
|
548
540
|
if (decision.action === "settle" && session) {
|
|
549
|
-
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, {
|
|
541
|
+
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
|
|
550
542
|
return session;
|
|
551
543
|
}
|
|
552
544
|
if (decision.action === "fail" || decision.action === "create") throw new AttachmentLauncherGone(decision);
|
|
@@ -569,7 +561,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
569
561
|
}
|
|
570
562
|
} catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
|
|
571
563
|
const peer = live.peers.find((p) => p.id === binding.peer);
|
|
572
|
-
const session = peer?.state === "offline" ? undefined : binding.peer
|
|
564
|
+
const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer, binding.projectRoot);
|
|
573
565
|
(progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
|
|
574
566
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
575
567
|
lastHubConnectAttempt: "unknown" };
|
|
@@ -590,7 +582,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
590
582
|
return {
|
|
591
583
|
...(liveTestPeer === undefined ? {} : { liveTestPeer }),
|
|
592
584
|
now, sleep, inspect: inspectRecovery,
|
|
593
|
-
recordedSessionState: async (binding, kind) =>
|
|
585
|
+
recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
|
|
594
586
|
stage: async (op) => {
|
|
595
587
|
if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
|
|
596
588
|
const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
|
|
@@ -601,10 +593,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
601
593
|
}
|
|
602
594
|
// Reuse capability proof only after verifying the cached root and digest still identify these exact bytes.
|
|
603
595
|
const cached = op.targetDigest === target.digest && op.targetRoot === target.root;
|
|
604
|
-
|
|
605
|
-
//
|
|
596
|
+
// Once every source committed, compatibility cannot be re-applied using mutable project declarations.
|
|
597
|
+
// Package identity/hash and live-test authority above still bind these retained bytes.
|
|
598
|
+
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) {
|
|
599
|
+
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
600
|
+
}
|
|
601
|
+
if (cached && op.targetFeatures !== undefined) {
|
|
602
|
+
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
603
|
+
return { ...target, targetFeatures: [...op.targetFeatures] };
|
|
604
|
+
}
|
|
606
605
|
// A prepared commitSent receipt remains uncertain and must still obtain proof before a possible commit.
|
|
607
|
-
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) return target;
|
|
608
606
|
// Legacy uncommitted receipts acquire verified feature evidence before source compatibility checks.
|
|
609
607
|
const probe = await run([process.execPath, "-e", `import * as target from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(JSON.stringify({protocol: target.PROTOCOL, features: target.DAEMON_FEATURES ?? []}))`]);
|
|
610
608
|
let metadata: { protocol?: unknown; features?: unknown } | undefined;
|
|
@@ -613,18 +611,14 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
613
611
|
if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
|
|
614
612
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
615
613
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
614
|
+
if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
616
615
|
const verified = { ...target, targetFeatures };
|
|
617
616
|
// Existing peer/waiver compatibility checks remain properties of the already verified bytes.
|
|
618
617
|
if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
if (!
|
|
623
|
-
}
|
|
624
|
-
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
|
|
625
|
-
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
626
|
-
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
627
|
-
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");
|
|
618
|
+
for (const adapter of ADAPTERS) {
|
|
619
|
+
const probe = adapter.session.legacyTargetSupport;
|
|
620
|
+
if (!probe || !op.plan.projects.some(p => p.source.peers.some(peer => peer.id === adapter.kind && peer.state !== "offline"))) continue;
|
|
621
|
+
if (!legacyTargetLists(target.root, probe, adapter.kind)) throw new FinalRefusal(probe.refusal);
|
|
628
622
|
}
|
|
629
623
|
// Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
|
|
630
624
|
if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
|
|
@@ -664,7 +658,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
664
658
|
progress.terminals[key] = true; save(); continue;
|
|
665
659
|
}
|
|
666
660
|
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
667
|
-
const transcript = binding.peer === "
|
|
661
|
+
const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
|
|
668
662
|
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
669
663
|
if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
|
|
670
664
|
throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
|
|
@@ -697,36 +691,23 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
697
691
|
const live = await inspectRecovery(planned.project);
|
|
698
692
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
699
693
|
if (group === "native" && !onlyPeer) {
|
|
700
|
-
for (const peer of planned.source.peers.filter(
|
|
694
|
+
for (const peer of planned.source.peers.filter(p => projectDefinition(p.id, planned.project.root)?.session.restoreArgs && p.state !== "offline" && !(projectDefinition(p.id, planned.project.root)?.session.launchRecord && p.args?.mode === "tui"))) {
|
|
701
695
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
702
696
|
if (current && current.state !== "offline") continue;
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
if (
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
|
|
712
|
-
}
|
|
713
|
-
// #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
|
|
714
|
-
// one is filtered out above), so a snapshot that recorded no mode still restores it headless.
|
|
715
|
-
if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
|
|
716
|
-
if (definitionOf(peer.id)?.session.restoreOnStart && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
717
|
-
if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
718
|
-
if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
|
|
719
|
-
if (peer.id === "pi") {
|
|
720
|
-
const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
|
|
721
|
-
if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
|
|
722
|
-
else if (peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
723
|
-
}
|
|
697
|
+
const adapter = projectDefinition(peer.id, planned.project.root)!;
|
|
698
|
+
const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
|
|
699
|
+
const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
|
|
700
|
+
if (refusal) throw new Error(refusal);
|
|
701
|
+
const captured = adapter.session.restoreArgs!(peer);
|
|
702
|
+
for (const [key, value] of Object.entries(captured)) args.push(`--${key}`, value);
|
|
703
|
+
const legacy = adapter.session.legacyHeadlessArgv;
|
|
704
|
+
if (legacy && legacyTargetLists(op.targetRoot!, legacy, adapter.kind)) args.push(...legacy.argv);
|
|
724
705
|
await command(op, args, planned.project);
|
|
725
706
|
}
|
|
726
707
|
}
|
|
727
708
|
let claudeGroup = false;
|
|
728
709
|
for (const plannedBinding of planned.terminals as TerminalBinding[]) {
|
|
729
|
-
if ((plannedBinding.peer === "claude") !== (claudeGroup = group
|
|
710
|
+
if ((projectDefinition(plannedBinding.peer, plannedBinding.projectRoot)?.transport === "claude-channel") !== (claudeGroup = group !== "native") || onlyPeer && plannedBinding.peer !== onlyPeer) continue;
|
|
730
711
|
const key = `restored:${plannedBinding.peer}`;
|
|
731
712
|
try {
|
|
732
713
|
// #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
|
|
@@ -743,17 +724,17 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
743
724
|
// A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
|
|
744
725
|
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
745
726
|
const chosen = !recordedOnly && !!progress.fresh?.[original.peer];
|
|
746
|
-
let read: ReturnType<typeof
|
|
747
|
-
const transcript = () => original.peer !== "
|
|
727
|
+
let read: ReturnType<typeof sessionState> | undefined;
|
|
728
|
+
const transcript = () => projectDefinition(original.peer, original.projectRoot)?.session.missingEvidence !== "rollout" || (chosen && !accepted) ? "found" : (read ??= sessionState(original));
|
|
748
729
|
// An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
|
|
749
730
|
// rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
|
|
750
731
|
const fresh = () => recordedOnly ? false : accepted
|
|
751
|
-
? (original.peer === "
|
|
732
|
+
? (projectDefinition(original.peer, original.projectRoot)?.session.missingEvidence === "rollout" ? (chosen || !!planned.freshStart?.includes(original.peer)) && transcript() === "missing" : nativeZeroTurn(planned.project.stateDir, original, "attach"))
|
|
752
733
|
: chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
|
|
753
734
|
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
754
735
|
// peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
|
|
755
736
|
// The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
|
|
756
|
-
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
|
|
737
|
+
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : projectDefinition(original.peer, original.projectRoot)?.transport === "claude-channel" ? "zero-turn" : "fresh-start";
|
|
757
738
|
const notRestored = (why: string) => new Error(fresh()
|
|
758
739
|
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
759
740
|
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
@@ -769,7 +750,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
769
750
|
const decide = (receipt: RestoreReceipt, evidence: Awaited<ReturnType<typeof peerEvidence>>) => restoreTransition(receipt, {
|
|
770
751
|
target: "running", launcher: evidence.state === "unknown" ? "unknown" : evidence.state === "gone" ? "gone" : evidence.terminal ? "live" : "older-instance",
|
|
771
752
|
session: evidence.state === "live" && evidence.session ? evidence.session === original.sessionId ? "planned" : "other" : "none",
|
|
772
|
-
allowOther: fresh() || (!recordedOnly &&
|
|
753
|
+
allowOther: fresh() || (!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach")),
|
|
773
754
|
binding: evidence.state === "live" && evidence.terminal ? { ...original, ...evidence.terminal } : original,
|
|
774
755
|
});
|
|
775
756
|
const receipt = decodeRestoreReceipt(progress.terminals[key]);
|
|
@@ -790,7 +771,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
790
771
|
// #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
|
|
791
772
|
// succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
|
|
792
773
|
// the operator or the plan accepted, counts as restored.
|
|
793
|
-
if (attached !== original.sessionId && !fresh() && !(!recordedOnly &&
|
|
774
|
+
if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
|
|
794
775
|
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`);
|
|
795
776
|
}
|
|
796
777
|
// Validate the proposed transition before committing continuity/waiver effects or a done receipt.
|
|
@@ -854,7 +835,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
854
835
|
// The planned session already attached is the restoration (the table's row for it), not a reason to block.
|
|
855
836
|
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
856
837
|
if (evidence.state === "live" && evidence.session === original.sessionId) {
|
|
857
|
-
if (repair) await settle(evidence.session);
|
|
838
|
+
if (repair) await settle(evidence.session!); // equality with the recorded string session id above proves this value
|
|
858
839
|
else { progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, original, false) } satisfies RestoreReceipt; save(); }
|
|
859
840
|
continue;
|
|
860
841
|
}
|
|
@@ -862,7 +843,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
862
843
|
if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
|
|
863
844
|
if (transcript() === "missing" && !fresh()) {
|
|
864
845
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
865
|
-
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind:
|
|
846
|
+
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind: projectDefinition(original.peer, original.projectRoot)!.session.missingEvidence!, checkedPath: sessionPath(original) });
|
|
866
847
|
throw notRestored(unresumable(original).slice("codex: ".length));
|
|
867
848
|
}
|
|
868
849
|
if (fresh()) {
|
|
@@ -918,14 +899,14 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
918
899
|
// A bare pending/failed row can hide an uncertain create. Only a durable exact chosen binding
|
|
919
900
|
// can become the terminal that an explicit fresh choice must close before another create.
|
|
920
901
|
const binding = chosenReceipt?.state === "waiting" ? chosenReceipt.binding : undefined;
|
|
921
|
-
if (binding &&
|
|
902
|
+
if (binding && sessionState(binding) === "missing" && (await peerEvidence(planned, progress, plannedBinding.peer)).state === "gone") {
|
|
922
903
|
// Retain the exact chosen terminal so an explicit later fresh choice closes it before creating again.
|
|
923
904
|
progress.terminals[`retired:${plannedBinding.peer}`] = binding;
|
|
924
905
|
delete progress.terminals[`closedRetired:${plannedBinding.peer}`];
|
|
925
906
|
const marker = progress.reattachments?.[plannedBinding.peer];
|
|
926
907
|
if (marker) marker.binding = binding;
|
|
927
908
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
928
|
-
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind:
|
|
909
|
+
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind: projectDefinition(plannedBinding.peer, plannedBinding.projectRoot)!.session.missingEvidence!, checkedPath: sessionPath(binding) });
|
|
929
910
|
}
|
|
930
911
|
}
|
|
931
912
|
if (error instanceof ReattachmentRequired || isDatabaseBusy(error)) throw error;
|
|
@@ -934,7 +915,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
934
915
|
throw error;
|
|
935
916
|
}
|
|
936
917
|
}
|
|
937
|
-
if (group
|
|
918
|
+
if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
|
|
938
919
|
// #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
|
|
939
920
|
const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
|
|
940
921
|
while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
|
|
@@ -946,7 +927,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
946
927
|
installPlugin: async (op) => {
|
|
947
928
|
const configured = new Set<string>();
|
|
948
929
|
for (const planned of op.plan.projects) {
|
|
949
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
|
|
930
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
950
931
|
if (!binding) continue;
|
|
951
932
|
const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
|
|
952
933
|
if (configured.has(profile)) continue;
|
|
@@ -974,7 +955,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
974
955
|
abandonRestartSnapshot(project.stateDir, op.id);
|
|
975
956
|
const planned = op.plan.projects?.find(item => item.project.id === project.id);
|
|
976
957
|
const progress = op.projects?.find(item => item.id === project.id);
|
|
977
|
-
const source = planned?.source.peers.find(peer => peer.id
|
|
958
|
+
const source = planned?.source.peers.find(peer => definitionOf(peer.id)?.start.autoStart && peer.state !== "offline");
|
|
978
959
|
if (source && progress) {
|
|
979
960
|
// abandonRestartSnapshot is deliberately a no-op for another operation or an unreadable snapshot.
|
|
980
961
|
// A continuity bookmark needs positive archival evidence, including when disposal resumes after a crash.
|
|
@@ -983,13 +964,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
983
964
|
try { archived = JSON.parse(readFileSync(archive, "utf8")); } catch { return { continuity: "not-archived" }; }
|
|
984
965
|
if (!archived || typeof archived !== "object" || (archived as { operationId?: unknown }).operationId !== op.id) return { continuity: "not-archived" };
|
|
985
966
|
try {
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
const backend = binding?.backend ?? source.args?.backend;
|
|
967
|
+
const continuity = projectDefinition(source.id, project.root)?.session.continuityBookmark;
|
|
968
|
+
if (!continuity) return;
|
|
969
|
+
const binding = planned ? resolveAcceptedBinding(planned, progress, source.id as TerminalBinding["peer"]) : undefined;
|
|
990
970
|
writeRecoveryContinuity(project.stateDir, { schema: 1, operationId: op.id, projectId: project.id, projectRoot: project.root, at: now(),
|
|
991
|
-
|
|
992
|
-
...(backend === "auto" || backend === "dgx" || backend === "mlx" ? { backend } : {}), model: binding?.model ?? source.args?.model } });
|
|
971
|
+
...continuity(source, binding) });
|
|
993
972
|
} catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
|
|
994
973
|
}
|
|
995
974
|
},
|
|
@@ -1012,21 +991,21 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
1012
991
|
// A #225 restart clears the old target's repair fence, never its accepted conversation.
|
|
1013
992
|
const retained = retired?.state === "done" && accepted?.state === "done" && retired.binding.peer === old.id && retired.binding.projectRoot === planned.project.root && retired.binding.sessionId === accepted.binding.sessionId;
|
|
1014
993
|
if ((recordedOnly || retained) && accepted?.state === "done") {
|
|
1015
|
-
const session = old.id
|
|
994
|
+
const session = observedSession(old.id, peer, planned.project.root);
|
|
1016
995
|
if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
|
|
1017
|
-
old.id
|
|
996
|
+
projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.session.crashIdentity && (recordedOnly || old.sessionFile) && accepted.binding.sessionFile && peer.sessionFile !== accepted.binding.sessionFile) throw nativeFailure(planned, old.id, "session_changed", `${old.id}: reattached a different recorded conversation`, { handle: accepted.binding.handle, expectedSession: accepted.binding.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
|
|
1018
997
|
continue;
|
|
1019
998
|
}
|
|
1020
999
|
if (!recordedOnly && (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id))) continue; // #215: a new session was accepted (recorded)
|
|
1021
|
-
if (old.id === "
|
|
1022
|
-
if (old.id === "claude" && peer.sessionId !== old.sessionId) {
|
|
1000
|
+
if (projectDefinition(old.id, planned.project.root)?.session.idField === "threadId" && peer.threadId !== old.threadId) throw nativeFailure(planned, old.id, "session_changed", "Codex resumed a different conversation", { ...(old.threadId ? { expectedSession: old.threadId } : {}), ...(peer.threadId ? { observedSession: peer.threadId } : {}) });
|
|
1001
|
+
if (projectDefinition(old.id, planned.project.root)?.transport === "claude-channel" && peer.sessionId !== old.sessionId) {
|
|
1023
1002
|
// #64: the restore gate already accepted a fresh session when the original never
|
|
1024
1003
|
// persisted a transcript. Re-derive persistence from the binding exactly like that
|
|
1025
1004
|
// gate did, so a first turn landing after the plan was made keeps the check strict.
|
|
1026
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
|
|
1027
|
-
if (recordedOnly || !binding || binding.sessionId !== old.sessionId ||
|
|
1005
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
1006
|
+
if (recordedOnly || !binding || binding.sessionId !== old.sessionId || !nativeZeroTurn(planned.project.stateDir, binding, "verify")) throw nativeFailure(planned, old.id, "session_changed", "Claude resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
|
|
1028
1007
|
}
|
|
1029
|
-
if (old.id
|
|
1008
|
+
if (projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.session.crashIdentity && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw nativeFailure(planned, old.id, "session_changed", "Pi resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
|
|
1030
1009
|
}
|
|
1031
1010
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
1032
1011
|
const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
|