@staix/agent-hub 0.12.30 → 0.12.31
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 +21 -0
- package/README.md +21 -7
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -0
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +5 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +129 -22
- package/docs/quickstart.md +17 -2
- package/docs/security.md +30 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -19
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +56 -12
- package/src/cli/console.ts +19 -6
- package/src/cli/identity.ts +3 -2
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +8 -1
- package/src/cli/output.ts +13 -2
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +162 -58
- package/src/cli/upgrade.ts +49 -24
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +78 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +400 -67
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +173 -34
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -15,9 +15,16 @@ import { readEvents } from "../hub/events.ts";
|
|
|
15
15
|
import { refreshManager } from "../hub/manager.ts";
|
|
16
16
|
import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
|
|
17
17
|
import { recoveryLock } from "../hub/recovery-store.ts";
|
|
18
|
+
import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type RestoreEvidence, type RestoreTransition } from "./upgrade-restore-state.ts";
|
|
18
19
|
|
|
19
20
|
/** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
|
|
20
|
-
const
|
|
21
|
+
const UNMANAGED_RECONNECT_WAIT_MS = 90_000;
|
|
22
|
+
/** Managed TUI startup can include resume and MCP handshakes; match createTerminal's ten-minute readiness ceiling. */
|
|
23
|
+
const RECONNECT_WAIT_MS = 600_000;
|
|
24
|
+
class AttachmentLauncherGone extends Error {
|
|
25
|
+
constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
|
|
26
|
+
}
|
|
27
|
+
const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalBinding => ({ ...binding, sessionId });
|
|
21
28
|
|
|
22
29
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
23
30
|
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");
|
|
@@ -151,7 +158,7 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
161
|
+
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[]): Promise<Inspection> {
|
|
155
162
|
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
156
163
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
157
164
|
// caller reads that as not inspected, never as stopped.
|
|
@@ -189,9 +196,9 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
189
196
|
}
|
|
190
197
|
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
191
198
|
let response: any;
|
|
192
|
-
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
|
|
199
|
+
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
|
|
193
200
|
catch (error) {
|
|
194
|
-
if (isDatabaseBusy(error)) throw error;
|
|
201
|
+
if (isDatabaseBusy(error) || targetFeatures !== undefined) throw error;
|
|
195
202
|
// A stop can complete between authenticated status and the second read. Never infer stopped
|
|
196
203
|
// from a connection failure: the coordinator will inspect the ownership manifest again.
|
|
197
204
|
return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
|
|
@@ -352,10 +359,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
352
359
|
if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
|
|
353
360
|
return result;
|
|
354
361
|
};
|
|
355
|
-
const control = (project: Project, op: string, id: string, instance: string) => {
|
|
362
|
+
const control = (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
|
|
356
363
|
const sourceProtocol = readControl(project.stateDir)?.protocol;
|
|
357
364
|
const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
|
|
358
|
-
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance }, protocol).then(() => {});
|
|
365
|
+
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, protocol).then(() => {});
|
|
359
366
|
};
|
|
360
367
|
const revalidateTerminal = async (planned: PlannedProject, progress: ProjectProgress, saved: TerminalBinding, exact: boolean): Promise<TerminalBinding> => {
|
|
361
368
|
const options = { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId };
|
|
@@ -387,7 +394,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
387
394
|
* expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
|
|
388
395
|
*/
|
|
389
396
|
const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
|
|
390
|
-
Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
|
|
397
|
+
Promise<{ state: "live"; session?: string; handle?: string; attached?: true; terminal?: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> } | { state: "gone"; terminal?: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> } | { state: "unknown"; why: string; step?: string; idless?: true }> => {
|
|
391
398
|
const target = await inspectRecovery(planned.project);
|
|
392
399
|
if (target.state !== "running" || target.instanceId !== progress.instanceId) {
|
|
393
400
|
// Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
|
|
@@ -397,22 +404,31 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
397
404
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
398
405
|
const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
|
|
399
406
|
if (session) return { state: "live", session };
|
|
407
|
+
let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
|
|
400
408
|
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
401
409
|
// launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
|
|
402
410
|
for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
|
|
403
411
|
if (!instanceId) continue;
|
|
404
412
|
const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
|
|
405
|
-
if (launch?.state === "live")
|
|
413
|
+
if (launch?.state === "live") {
|
|
414
|
+
const record = launch.record!;
|
|
415
|
+
return { state: "live", handle: record.handle, ...(attached ? { attached: true as const } : {}),
|
|
416
|
+
...(instanceId === progress.instanceId ? { terminal: { handle: record.handle, incarnationId: record.incarnationId, worktreeId: record.worktreeId } } : {}) };
|
|
417
|
+
}
|
|
406
418
|
if (launch?.state === "unknown") {
|
|
407
419
|
return launch.record ? { state: "unknown", why: `its launcher in terminal ${launch.record.handle} cannot be read`, step: `wait until it attaches, or end it and close terminal ${launch.record.handle}` }
|
|
408
420
|
: launch.invalidRow ? { state: "unknown", why: "a launch record that may be its launcher's cannot be evaluated", step: `inspect ${recordPath(planned.project.stateDir)} and fix that row, or end that launcher and remove its row` }
|
|
409
421
|
: { state: "unknown", why: "the launcher records cannot be read", step: `inspect ${recordPath(planned.project.stateDir)} and move it aside (sessions it recorded then count as unmanaged)` };
|
|
410
422
|
}
|
|
423
|
+
if (instanceId === progress.instanceId && launch?.state === "gone" && launch.record) {
|
|
424
|
+
const record = launch.record;
|
|
425
|
+
endedTerminal = { handle: record.handle, incarnationId: record.incarnationId, worktreeId: record.worktreeId };
|
|
426
|
+
}
|
|
411
427
|
}
|
|
412
428
|
// #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
|
|
413
429
|
// not reported its id yet) is a session of unknown identity, never "nothing attached".
|
|
414
|
-
if (attached) return { state: "unknown", why: `a ${peer} session is attached to the target without a session id`, step: `wait until it reports one, or end that ${peer} session` };
|
|
415
|
-
return { state: "gone" };
|
|
430
|
+
if (attached) return { state: "unknown", idless: true, why: `a ${peer} session is attached to the target without a session id`, step: `wait until it reports one, or end that ${peer} session` };
|
|
431
|
+
return { state: "gone", ...(endedTerminal ? { terminal: endedTerminal } : {}) };
|
|
416
432
|
};
|
|
417
433
|
/**
|
|
418
434
|
* #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
|
|
@@ -436,13 +452,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
436
452
|
progress.terminals[key] = true; save();
|
|
437
453
|
};
|
|
438
454
|
/** #414: an idle terminal is not an authenticated peer. Keep its receipt while waiting, including on resume. */
|
|
439
|
-
const awaitAttachment = async (planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation,
|
|
455
|
+
const awaitAttachment = async (planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, waiting: Extract<RestoreReceipt, { state: "waiting" }>, save: () => void, expectedSession: string | null = waiting.binding.sessionId): Promise<string> => {
|
|
456
|
+
const binding = waiting.binding;
|
|
457
|
+
const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
|
|
440
458
|
const deadline = now() + RECONNECT_WAIT_MS;
|
|
441
459
|
let live: Inspection;
|
|
442
460
|
for (;;) {
|
|
443
461
|
live = await inspectRecovery(planned.project);
|
|
444
|
-
if (live.state
|
|
445
|
-
|
|
462
|
+
if (live.state !== "running") throw new Error(`${binding.peer}: target hub reads as ${live.state} while awaiting attachment; saved terminal retained`);
|
|
463
|
+
if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error(`${binding.peer}: target identity changed while awaiting attachment`);
|
|
464
|
+
const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
|
|
446
465
|
const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
|
|
447
466
|
const changed = !!session && expectedSession !== null && session !== expectedSession;
|
|
448
467
|
// #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
|
|
@@ -450,10 +469,38 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
450
469
|
const zeroTurn = changed && binding.peer === "claude" && (planned.terminals as TerminalBinding[]).some((p) => p.peer === "claude" && p.sessionId === binding.sessionId) && !claudeTranscriptExists(binding);
|
|
451
470
|
if (changed && !zeroTurn) throw new Error(`${binding.peer}: a different conversation attached while awaiting ${binding.sessionId}`);
|
|
452
471
|
if (now() > deadline) break;
|
|
453
|
-
|
|
472
|
+
const launcher = await launcherOf(binding.peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
|
|
473
|
+
const sameTerminal = launcher?.record && matchesTerminal(launcher.record);
|
|
474
|
+
let launchState: RestoreEvidence["launcher"] = !launcher ? "no-record-yet" : launcher.state === "unknown" || !launcher.record ? "unknown"
|
|
475
|
+
: sameTerminal ? launcher.state : launcher.state === "gone" ? "no-record-yet" : "replaced";
|
|
476
|
+
let olderHandle: string | undefined;
|
|
477
|
+
if ((launchState === "gone" || launchState === "no-record-yet") && !session) {
|
|
478
|
+
// An exact exit is not proof while an older launcher or an id-less session may still run.
|
|
479
|
+
const evidence = await peerEvidence(planned, progress, binding.peer);
|
|
480
|
+
if (evidence.state === "gone" && evidence.terminal && matchesTerminal(evidence.terminal)) launchState = "gone";
|
|
481
|
+
else if (evidence.state === "unknown") {
|
|
482
|
+
if (!(launchState === "no-record-yet" && evidence.idless)) launchState = "unknown";
|
|
483
|
+
} else if (evidence.state === "live" && !evidence.session) {
|
|
484
|
+
if (evidence.terminal) launchState = matchesTerminal(evidence.terminal) ? "live" : "replaced";
|
|
485
|
+
else { launchState = "older-instance"; olderHandle = evidence.handle; }
|
|
486
|
+
} else if (evidence.state === "live" && evidence.session) {
|
|
487
|
+
if (now() >= deadline) break;
|
|
488
|
+
await sleep(Math.min(1000, deadline - now()));
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
|
|
493
|
+
session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
|
|
494
|
+
if (decision.action === "settle" && session) {
|
|
454
495
|
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { claude: "zero-turn" });
|
|
455
496
|
return session;
|
|
456
497
|
}
|
|
498
|
+
if (decision.action === "fail" || decision.action === "create") throw new AttachmentLauncherGone(decision);
|
|
499
|
+
if (decision.action === "block") {
|
|
500
|
+
if (launchState === "replaced") throw new Error(`${binding.peer}: saved terminal identity changed while awaiting attachment`);
|
|
501
|
+
if (launchState === "older-instance") throw new Error(`${binding.peer}: an older target's launcher in terminal ${olderHandle ?? "unknown"} may still run; saved terminal retained; no terminal was created`);
|
|
502
|
+
throw new Error(`${binding.peer}: the chosen restoration launcher's identity cannot be verified (${launchState}); saved terminal retained; no terminal was created`);
|
|
503
|
+
}
|
|
457
504
|
if (now() >= deadline) break;
|
|
458
505
|
await sleep(Math.min(1000, deadline - now()));
|
|
459
506
|
}
|
|
@@ -468,7 +515,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
468
515
|
}
|
|
469
516
|
} catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
|
|
470
517
|
const peer = live.peers.find((p) => p.id === binding.peer);
|
|
471
|
-
const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
|
|
518
|
+
const session = peer?.state === "offline" ? undefined : binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
|
|
472
519
|
(progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
|
|
473
520
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
474
521
|
lastHubConnectAttempt: "unknown" };
|
|
@@ -485,15 +532,23 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
485
532
|
if (liveTestPeer && (!existsSync(identityPath) || !readFileSync(identityPath, "utf8").includes("export const LIVE_TEST_SUPPORTED = true;"))) {
|
|
486
533
|
throw new FinalRefusal("target does not support live-test delegation; choose a supporting target before retrying; runtimes unchanged");
|
|
487
534
|
}
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
535
|
+
// Reuse capability proof only after verifying the cached root and digest still identify these exact bytes.
|
|
536
|
+
const cached = op.targetDigest === target.digest && op.targetRoot === target.root;
|
|
537
|
+
if (cached && op.targetFeatures !== undefined) return { ...target, targetFeatures: [...op.targetFeatures] };
|
|
538
|
+
// A legacy cached operation already past commit needs no capability proof to finish restoration (#215).
|
|
539
|
+
// A prepared commitSent receipt remains uncertain and must still obtain proof before a possible commit.
|
|
540
|
+
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) return target;
|
|
541
|
+
// Legacy uncommitted receipts acquire verified feature evidence before source compatibility checks.
|
|
542
|
+
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 ?? []}))`]);
|
|
543
|
+
let metadata: { protocol?: unknown; features?: unknown } | undefined;
|
|
544
|
+
try { if (probe.code === 0) metadata = JSON.parse(probe.stdout); } catch { /* Unknown metadata refuses before effects. */ }
|
|
545
|
+
if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
546
|
+
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");
|
|
547
|
+
const targetFeatures: readonly string[] = [...metadata.features];
|
|
548
|
+
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
549
|
+
const verified = { ...target, targetFeatures };
|
|
550
|
+
// Existing peer/waiver compatibility checks remain properties of the already verified bytes.
|
|
551
|
+
if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
|
|
497
552
|
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "qwen" && peer.state !== "offline"))) {
|
|
498
553
|
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
499
554
|
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
@@ -508,11 +563,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
508
563
|
if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
|
|
509
564
|
throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
|
|
510
565
|
}
|
|
511
|
-
return
|
|
566
|
+
return verified;
|
|
512
567
|
},
|
|
513
|
-
prepare: (p, id, instance) => control(p, "prepare", id, instance),
|
|
568
|
+
prepare: (p, id, instance, features) => control(p, "prepare", id, instance, features),
|
|
514
569
|
abort: (p, id, instance) => control(p, "abort", id, instance),
|
|
515
|
-
commit: (p, id, instance) => control(p, "commit", id, instance),
|
|
570
|
+
commit: (p, id, instance, features) => control(p, "commit", id, instance, features),
|
|
516
571
|
release: (p, id, instance) => control(p, "release", id, instance),
|
|
517
572
|
closeTerminals: async (planned, progress, op, save) => {
|
|
518
573
|
for (const binding of planned.terminals as TerminalBinding[]) {
|
|
@@ -627,22 +682,35 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
627
682
|
: evidence.session
|
|
628
683
|
? `${original.peer}: session ${evidence.session} is attached instead of ${original.sessionId}; no terminal was created; end that ${original.peer} session and close its terminal first`
|
|
629
684
|
: `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session ${evidence.attached ? "with an id " : ""}attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
685
|
+
const decide = (receipt: RestoreReceipt, evidence: Awaited<ReturnType<typeof peerEvidence>>) => restoreTransition(receipt, {
|
|
686
|
+
target: "running", launcher: evidence.state === "unknown" ? "unknown" : evidence.state === "gone" ? "gone" : evidence.terminal ? "live" : "older-instance",
|
|
687
|
+
session: evidence.state === "live" && evidence.session ? evidence.session === original.sessionId ? "planned" : "other" : "none",
|
|
688
|
+
allowOther: fresh() || (original.peer === "claude" && !claudeTranscriptExists(original)),
|
|
689
|
+
binding: evidence.state === "live" && evidence.terminal ? { ...original, ...evidence.terminal } : original,
|
|
690
|
+
});
|
|
691
|
+
const receipt = decodeRestoreReceipt(progress.terminals[key]);
|
|
692
|
+
function awaitBound(waiting: Extract<RestoreReceipt, { state: "waiting" }> & { origin: "pending" }, expected: string | null): Promise<string>;
|
|
693
|
+
function awaitBound(waiting: Extract<RestoreReceipt, { state: "waiting" }>, expected: string | null): Promise<string | undefined>;
|
|
694
|
+
async function awaitBound(waiting: Extract<RestoreReceipt, { state: "waiting" }>, expected: string | null): Promise<string | undefined> {
|
|
695
|
+
try { return await awaitAttachment(planned, progress, op, waiting, save, expected); }
|
|
696
|
+
catch (error) {
|
|
697
|
+
if (!(error instanceof AttachmentLauncherGone)) throw error;
|
|
698
|
+
progress.terminals[key] = error.transition.next; save();
|
|
699
|
+
// The table decides whether this failed wait blocks or is eligible for a new launch.
|
|
700
|
+
if (error.transition.action === "create") return undefined;
|
|
701
|
+
throw notRestored(`its launcher no longer runs and no ${original.peer} session attached`);
|
|
702
|
+
}
|
|
637
703
|
}
|
|
638
704
|
// #215: the pending row of the receipt table, used wherever a pending launch meets an attached session.
|
|
639
|
-
const settle = async (attached: string) => {
|
|
705
|
+
const settle = async (attached: string, recovered?: TerminalBinding) => {
|
|
640
706
|
// #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
|
|
641
707
|
// succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
|
|
642
708
|
// the operator or the plan accepted, counts as restored.
|
|
643
709
|
if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
|
|
644
710
|
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`);
|
|
645
711
|
}
|
|
712
|
+
// Validate the proposed transition before committing continuity/waiver effects or a done receipt.
|
|
713
|
+
const verified = await revalidateTerminal(planned, progress, attachedBinding(recovered ?? original, attached), !!recovered);
|
|
646
714
|
// The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
|
|
647
715
|
// never with a session accepted after a restart (#225), whose choice must stay on record.
|
|
648
716
|
if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
|
|
@@ -653,31 +721,58 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
653
721
|
// a launch writes the waiver first, but this must not rely on that.
|
|
654
722
|
// The planned session needs no waiver: the target's snapshot already expects it.
|
|
655
723
|
if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
656
|
-
progress.terminals[key] =
|
|
724
|
+
progress.terminals[key] = { state: "done", binding: verified } satisfies RestoreReceipt; save();
|
|
657
725
|
};
|
|
658
|
-
|
|
659
|
-
|
|
726
|
+
if (receipt?.state === "done") {
|
|
727
|
+
// A completed restoration is never a waiting launch. A closed terminal blocks exact revalidation;
|
|
728
|
+
// it cannot erase continuity or create a fresh-session choice (PROBE-B).
|
|
729
|
+
const observed = await peerEvidence(planned, progress, original.peer);
|
|
730
|
+
const decision = restoreTransition(receipt, { target: "running", launcher: "no-record-yet", session: observed.state === "live" && observed.session === receipt.binding.sessionId ? "planned" : observed.state === "live" && observed.session ? "other" : "none", allowOther: false });
|
|
731
|
+
if (decision.action === "block") {
|
|
732
|
+
const condition = observed.state === "unknown" ? `cannot be verified (${observed.why})`
|
|
733
|
+
: observed.state === "live" && observed.session ? `session ${observed.session} is attached instead of ${receipt.binding.sessionId}`
|
|
734
|
+
: observed.state === "live" ? `is not attached; its launcher in terminal ${observed.handle ?? "unknown"} may still run` : "is not attached";
|
|
735
|
+
throw new Error(`${original.peer}: completed restoration ${condition}; saved done receipt retained; resume its recorded conversation`);
|
|
736
|
+
}
|
|
737
|
+
progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, receipt.binding, true) } satisfies RestoreReceipt; save();
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (receipt?.state === "waiting") {
|
|
741
|
+
const binding = receipt.binding;
|
|
742
|
+
const sessionId = await awaitBound(receipt, fresh() && binding.sessionId === original.sessionId ? null : binding.sessionId);
|
|
743
|
+
if (sessionId !== undefined) { await settle(sessionId, binding); continue; }
|
|
744
|
+
}
|
|
745
|
+
if (receipt?.state === "pending" || receipt?.state === "failed") {
|
|
660
746
|
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
661
|
-
|
|
662
|
-
if (evidence.state
|
|
663
|
-
if (
|
|
664
|
-
|
|
747
|
+
const decision = decide(receipt, evidence);
|
|
748
|
+
if (decision.action === "settle" && evidence.state === "live" && evidence.session) { await settle(evidence.session); continue; }
|
|
749
|
+
if (decision.action === "poll" && decision.next.state === "waiting") {
|
|
750
|
+
const waiting = decision.next;
|
|
751
|
+
progress.terminals[key] = waiting; save();
|
|
752
|
+
const session = await awaitBound(waiting, fresh() ? null : original.sessionId);
|
|
753
|
+
if (session !== undefined) { await settle(session, waiting.binding); continue; }
|
|
754
|
+
} else if (decision.action === "fail") {
|
|
755
|
+
progress.terminals[key] = decision.next; save();
|
|
665
756
|
throw notRestored(`its launcher no longer runs and no ${original.peer} session attached`);
|
|
757
|
+
} else if (decision.action === "block") {
|
|
758
|
+
if (evidence.state === "live" && evidence.session) {
|
|
759
|
+
throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${evidence.session} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
|
|
760
|
+
}
|
|
761
|
+
if (evidence.state !== "gone") throw blocked(evidence);
|
|
762
|
+
throw new Error(`${original.peer}: restoration evidence is not actionable`);
|
|
666
763
|
}
|
|
667
|
-
// failed, nothing live: launched again below; the receipt stays `failed` until `pending` replaces it, so a
|
|
668
|
-
// refusal on the way keeps the failed-restoration choices
|
|
669
764
|
}
|
|
670
765
|
// Never create beside a live launch, nor on evidence that could not be read: read now, not at restore start.
|
|
671
766
|
// The planned session already attached is the restoration (the table's row for it), not a reason to block.
|
|
672
767
|
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
673
768
|
if (evidence.state === "live" && evidence.session === original.sessionId) {
|
|
674
|
-
progress.terminals[key] = await revalidateTerminal(planned, progress, original, false); save();
|
|
769
|
+
progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, original, false) } satisfies RestoreReceipt; save();
|
|
675
770
|
continue;
|
|
676
771
|
}
|
|
677
772
|
if (evidence.state !== "gone") throw blocked(evidence);
|
|
678
773
|
if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
|
|
679
774
|
if (transcript() === "missing" && !fresh()) {
|
|
680
|
-
progress.terminals[key] = "failed"; save();
|
|
775
|
+
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
681
776
|
throw notRestored(unresumable(original).slice("codex: ".length));
|
|
682
777
|
}
|
|
683
778
|
if (fresh()) {
|
|
@@ -691,7 +786,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
691
786
|
const launch = { ...original.launch, packageEntrypoint: entrypoint, argv,
|
|
692
787
|
command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
|
|
693
788
|
const binding = { ...original, launch, launchMetadata: launch };
|
|
694
|
-
progress.terminals[key] = "pending"; save();
|
|
789
|
+
progress.terminals[key] = { state: "pending" } satisfies RestoreReceipt; save();
|
|
695
790
|
const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
|
|
696
791
|
const exited = restored.blockers.find((b) => b.code === "launcher-exited");
|
|
697
792
|
if (exited) {
|
|
@@ -699,25 +794,34 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
699
794
|
// nothing live makes it failed; an attached session is settled as the pending row settles it; anything else
|
|
700
795
|
// stays pending.
|
|
701
796
|
const after = await peerEvidence(planned, progress, original.peer);
|
|
702
|
-
|
|
797
|
+
const decision = decide({ state: "pending" }, after);
|
|
798
|
+
if (decision.action === "settle" && after.state === "live" && after.session) { await settle(after.session); continue; }
|
|
799
|
+
if (decision.action === "poll" && decision.next.state === "waiting") {
|
|
800
|
+
progress.terminals[key] = decision.next; save();
|
|
801
|
+
const session = await awaitBound(decision.next, fresh() ? null : original.sessionId);
|
|
802
|
+
if (session !== undefined) { await settle(session, decision.next.binding); continue; }
|
|
803
|
+
}
|
|
804
|
+
if (decision.action === "fail") {
|
|
805
|
+
progress.terminals[key] = decision.next; save();
|
|
806
|
+
throw notRestored(exited.message);
|
|
807
|
+
}
|
|
703
808
|
if (after.state !== "gone") throw blocked(after);
|
|
704
|
-
progress.terminals[key] = "failed"; save();
|
|
705
|
-
throw notRestored(exited.message);
|
|
706
809
|
}
|
|
707
810
|
if (restored.manualRequired || !restored.newBinding) {
|
|
708
811
|
const handle = restored.binding?.handle ?? restored.blockers[0]?.handle;
|
|
709
812
|
throw new Error(`${original.peer}: original session restoration needs manual verification${restored.blockers[0] ? ` (${restored.blockers[0].message})` : ""}; wait until it attaches, or end that launch and close its terminal${handle ? ` ${handle}` : ""} first`);
|
|
710
813
|
}
|
|
711
814
|
// The daemon's id is authoritative: a fresh session is recorded under the id it reports once attached.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
815
|
+
const awaiting = { state: "waiting", origin: "pending", binding: restored.newBinding } satisfies RestoreReceipt;
|
|
816
|
+
progress.terminals[key] = awaiting; save();
|
|
817
|
+
const sessionId = await awaitBound(awaiting, fresh() ? null : restored.newBinding.sessionId);
|
|
818
|
+
await settle(sessionId, awaiting.binding);
|
|
715
819
|
}
|
|
716
820
|
if (group === "claude") for (const id of planned.reconnectOnly ?? []) {
|
|
717
821
|
// #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
|
|
718
|
-
const deadline = now() +
|
|
822
|
+
const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
|
|
719
823
|
while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
|
|
720
|
-
if (now() >= deadline) throw new Error(`${id}: the unmanaged session did not reconnect within ${
|
|
824
|
+
if (now() >= deadline) throw new Error(`${id}: the unmanaged session did not reconnect within ${UNMANAGED_RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`);
|
|
721
825
|
await sleep(1000);
|
|
722
826
|
}
|
|
723
827
|
}
|
|
@@ -771,9 +875,9 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
771
875
|
if (old.id === "pi" && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw new Error("Pi resumed a different conversation");
|
|
772
876
|
}
|
|
773
877
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
774
|
-
const saved = progress.terminals[`restored:${original.peer}`];
|
|
775
|
-
if (
|
|
776
|
-
await revalidateTerminal(planned, progress, saved
|
|
878
|
+
const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
|
|
879
|
+
if (saved?.state !== "done") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
|
|
880
|
+
await revalidateTerminal(planned, progress, saved.binding, true);
|
|
777
881
|
}
|
|
778
882
|
const snapshot = await rpc(planned.project, { t: "ui_snapshot", after: 0 });
|
|
779
883
|
if (snapshot.ok === false) throw new Error("dashboard snapshot readback failed");
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
1
2
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
2
3
|
import { Database } from "bun:sqlite";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -87,6 +88,8 @@ export interface RecoveryOperation {
|
|
|
87
88
|
sourceRoot: string;
|
|
88
89
|
targetRoot?: string;
|
|
89
90
|
targetDigest?: string;
|
|
91
|
+
/** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
|
|
92
|
+
targetFeatures?: readonly string[];
|
|
90
93
|
projects: ProjectProgress[];
|
|
91
94
|
pluginInstalled?: boolean;
|
|
92
95
|
globalInstalled?: boolean;
|
|
@@ -127,7 +130,7 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
|
|
|
127
130
|
|
|
128
131
|
/**
|
|
129
132
|
* #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
|
|
130
|
-
* the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
|
|
133
|
+
* the time stay for status and audit. Each done or waiting `restored:<peer>` binding of the dead instance is retired
|
|
131
134
|
* (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
|
|
132
135
|
* revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
|
|
133
136
|
* The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
|
|
@@ -135,9 +138,11 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
|
|
|
135
138
|
export function recordRestart(progress: ProjectProgress, at: number): void {
|
|
136
139
|
(progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
|
|
137
140
|
for (const [key, value] of Object.entries(progress.terminals)) {
|
|
138
|
-
if (!key.startsWith("restored:")
|
|
141
|
+
if (!key.startsWith("restored:")) continue;
|
|
142
|
+
const receipt = decodeRestoreReceipt(value);
|
|
143
|
+
if (receipt?.state !== "done" && receipt?.state !== "waiting") continue;
|
|
139
144
|
const peer = key.slice("restored:".length);
|
|
140
|
-
progress.terminals[`retired:${peer}`] =
|
|
145
|
+
progress.terminals[`retired:${peer}`] = receipt.binding;
|
|
141
146
|
delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
|
|
142
147
|
delete progress.terminals[key];
|
|
143
148
|
}
|
|
@@ -280,7 +285,7 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
|
|
|
280
285
|
*/
|
|
281
286
|
function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
|
|
282
287
|
const planned = op.plan.projects.find((p) => p.project.id === progress.id);
|
|
283
|
-
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && value
|
|
288
|
+
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
|
|
284
289
|
.filter((peer) => peer !== "pi" && !progress.fresh?.[peer] && !planned?.freshStart?.includes(peer) && !plannedAttached(op, progress, peer, live));
|
|
285
290
|
}
|
|
286
291
|
|
|
@@ -341,17 +346,18 @@ function abandoned(op: RecoveryOperation): boolean {
|
|
|
341
346
|
|
|
342
347
|
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
343
348
|
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
349
|
+
if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
|
|
344
350
|
const { fingerprint, ...reviewed } = op.plan;
|
|
345
351
|
if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
|
|
346
352
|
op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
|
|
347
353
|
}
|
|
348
354
|
|
|
349
355
|
export interface RecoveryDriver {
|
|
350
|
-
stage(op: RecoveryOperation): Promise<{ root: string; digest: string }>;
|
|
351
|
-
inspect(project: Project): Promise<Inspection>;
|
|
352
|
-
prepare(project: Project, operation: string, instance: string): Promise<void>;
|
|
356
|
+
stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
|
|
357
|
+
inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
|
|
358
|
+
prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
353
359
|
abort(project: Project, operation: string, instance: string): Promise<void>;
|
|
354
|
-
commit(project: Project, operation: string, instance: string): Promise<void>;
|
|
360
|
+
commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
355
361
|
closeTerminals(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save: () => void): Promise<void>;
|
|
356
362
|
start(project: Project, op: RecoveryOperation): Promise<void>;
|
|
357
363
|
restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
|
|
@@ -383,6 +389,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
383
389
|
return op;
|
|
384
390
|
}
|
|
385
391
|
} catch (error) { releaseRunner(); throw error; }
|
|
392
|
+
let targetFeatures: readonly string[] = [];
|
|
386
393
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
387
394
|
const step = (value: string) => { op.step = value; save(); };
|
|
388
395
|
// #225: one restart per project per resume, so a target that keeps dying never spins unattended.
|
|
@@ -422,7 +429,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
422
429
|
// Prepare, or after an expired lease re-prepare, the planned source and wait until it is quiet.
|
|
423
430
|
const prepareSource = async (planned: PlannedProject, progress: ProjectProgress, again = false) => {
|
|
424
431
|
const project = planned.project, instance = planned.source.instanceId!;
|
|
425
|
-
await driver.prepare(project, id, instance);
|
|
432
|
+
await driver.prepare(project, id, instance, targetFeatures);
|
|
426
433
|
const deadline = driver.now() + idleTimeoutMs;
|
|
427
434
|
for (;;) {
|
|
428
435
|
const live = await driver.inspect(project);
|
|
@@ -452,13 +459,16 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
452
459
|
step("stage");
|
|
453
460
|
const target = await driver.stage(op);
|
|
454
461
|
if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
|
|
455
|
-
|
|
462
|
+
targetFeatures = target.targetFeatures ?? op.targetFeatures ?? [];
|
|
463
|
+
op.targetRoot = target.root; op.targetDigest = target.digest;
|
|
464
|
+
if (target.targetFeatures !== undefined) op.targetFeatures = [...target.targetFeatures];
|
|
465
|
+
save();
|
|
456
466
|
|
|
457
|
-
//
|
|
467
|
+
// Pending sources receive the verified target features before holds or terminal/runtime effects.
|
|
458
468
|
for (let i = 0; i < op.projects.length; i++) {
|
|
459
469
|
const progress = op.projects[i]!, planned = op.plan.projects[i]!;
|
|
460
470
|
if (progress.phase !== "pending") continue;
|
|
461
|
-
const live = await driver.inspect(planned.project);
|
|
471
|
+
const live = await driver.inspect(planned.project, targetFeatures);
|
|
462
472
|
if (!["running", "stopped", "missing"].includes(live.state)) throw new Error(`${planned.project.id}: the source reads as ${live.state}; wait until it answers`);
|
|
463
473
|
if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
|
|
464
474
|
throw new Error(`${planned.project.id}: source runtime changed`);
|
|
@@ -486,7 +496,14 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
486
496
|
}
|
|
487
497
|
if (progress.phase === "prepared") {
|
|
488
498
|
step(`commit:${project.id}`);
|
|
489
|
-
let live
|
|
499
|
+
let live: Inspection;
|
|
500
|
+
try { live = await driver.inspect(project); }
|
|
501
|
+
catch (error) {
|
|
502
|
+
if (!progress.commitSent || !isDatabaseBusy(error)) throw error;
|
|
503
|
+
live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
|
|
504
|
+
}
|
|
505
|
+
const resumedShutdown = progress.commitSent && live.state === "unavailable";
|
|
506
|
+
let awaitShutdown = resumedShutdown;
|
|
490
507
|
if (live.state === "running" && live.instanceId === planned.source.instanceId) {
|
|
491
508
|
if (live.recovery?.operationId !== id || !live.recovery.ready) {
|
|
492
509
|
const other = live.recovery?.operationId;
|
|
@@ -503,8 +520,22 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
503
520
|
await driver.closeTerminals(planned, progress, op, save);
|
|
504
521
|
// Durable, unlike `step`: from here the source may have committed, which status and abort must not forget.
|
|
505
522
|
progress.commitSent = true; save();
|
|
506
|
-
await driver.commit(project, id, planned.source.instanceId
|
|
523
|
+
await driver.commit(project, id, planned.source.instanceId!, targetFeatures);
|
|
524
|
+
awaitShutdown = true;
|
|
525
|
+
} else if (live.state === "running") {
|
|
526
|
+
throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
|
|
527
|
+
} else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
|
|
528
|
+
// #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
|
|
529
|
+
// prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
|
|
530
|
+
throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
|
|
531
|
+
}
|
|
532
|
+
if (awaitShutdown) {
|
|
507
533
|
const deadline = driver.now() + 30_000;
|
|
534
|
+
// An already-sent commit can be between its final reply and the source closing its control server.
|
|
535
|
+
if (resumedShutdown) {
|
|
536
|
+
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
537
|
+
await driver.sleep(100);
|
|
538
|
+
}
|
|
508
539
|
do {
|
|
509
540
|
try { live = await driver.inspect(project); }
|
|
510
541
|
catch (error) {
|
|
@@ -517,12 +548,6 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
517
548
|
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
518
549
|
await driver.sleep(100);
|
|
519
550
|
} while (driver.now() < deadline);
|
|
520
|
-
} else if (live.state === "running") {
|
|
521
|
-
throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
|
|
522
|
-
} else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
|
|
523
|
-
// #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
|
|
524
|
-
// prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
|
|
525
|
-
throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
|
|
526
551
|
}
|
|
527
552
|
if (live.state === "missing") throw new Error(`${project.id}: the project directory is missing`);
|
|
528
553
|
if (live.state !== "stopped") throw new Error(`${project.id}: old shutdown is not verified (the source reads as ${live.state}); not starting a second daemon; check ${recoveryCommand(op, "status")} once it answers`);
|
|
@@ -584,12 +609,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
584
609
|
|
|
585
610
|
/** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
|
|
586
611
|
export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
|
|
587
|
-
const effect = (value: unknown) => value === "pending" || value === "failed" ? value : "done";
|
|
612
|
+
const effect = (key: string, value: unknown) => key.startsWith("restored:") ? restoreReceiptStatus(value) : value === "pending" || value === "failed" ? value : "done";
|
|
588
613
|
const next = nextActions(op, runnerPid, live);
|
|
589
614
|
return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
|
|
590
615
|
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
591
616
|
...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
|
|
592
|
-
projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(value)])) } : {}),
|
|
617
|
+
projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(key, value)])) } : {}),
|
|
593
618
|
...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
|
|
594
619
|
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
595
620
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
@@ -669,10 +694,10 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
669
694
|
const peer = choice.fresh;
|
|
670
695
|
// Per project: a planned fresh start has nothing to lose, so only the other failed projects take the choice.
|
|
671
696
|
const failed = op.projects.filter((p) => failedPeers(op, p, live).includes(peer));
|
|
672
|
-
if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`]
|
|
697
|
+
if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
|
|
673
698
|
throw new Error(`${peer}: its plan already restarts it as a new session (no rollout and no turn, nothing to lose), so there is no conversation to record as lost; ${next}`);
|
|
674
699
|
}
|
|
675
|
-
if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`]
|
|
700
|
+
if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && plannedAttached(op, p, peer, live))) {
|
|
676
701
|
throw new Error(`${peer}: its planned session is attached again, so nothing was lost; resume records it as restored; ${next}`);
|
|
677
702
|
}
|
|
678
703
|
if (!failed.length) throw new Error(`${peer}: no failed restoration of it is open to a fresh session (none failed, or one is already chosen); --fresh-session applies only then (${recoveryCommand(op, "status")})`);
|