@staix/agent-hub 0.12.20 → 0.12.22
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 +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- package/src/ui/task-progress.ts +33 -0
|
@@ -8,10 +8,11 @@ import type { Project } from "../hub/registry.ts";
|
|
|
8
8
|
import { hubHome } from "../hub/project.ts";
|
|
9
9
|
import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
|
|
10
10
|
import { inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
11
|
-
import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
|
|
11
|
+
import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
|
|
12
12
|
import { readEvents } from "../hub/events.ts";
|
|
13
13
|
import { refreshManager } from "../hub/manager.ts";
|
|
14
14
|
import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
|
|
15
|
+
import { recoveryLock } from "../hub/recovery-store.ts";
|
|
15
16
|
|
|
16
17
|
/** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
|
|
17
18
|
const RECONNECT_WAIT_MS = 90_000;
|
|
@@ -91,8 +92,63 @@ async function rpc(project: Project, message: Record<string, unknown>, protocol
|
|
|
91
92
|
} finally { client.close(); }
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* #272: end one attached agent before an upgrade, for the person who chose it on the plan screen. The plan is what was
|
|
97
|
+
* drawn, so all of it is read again and any difference ends nothing: no operation holds the lock, the hub runs as the
|
|
98
|
+
* instance the plan read with the peer attached, and for a TUI agent the attached session is the one the plan bound and
|
|
99
|
+
* binds again to a terminal with the plan's handle and incarnation. `closeTerminal` itself compares only the terminal's
|
|
100
|
+
* handle, incarnation, worktree and root, never who runs in it. A headless agent is the hub's own process, so the hub
|
|
101
|
+
* is asked to stop it, which a hub older than that request cannot do. `deps` are a test's fakes.
|
|
102
|
+
*/
|
|
103
|
+
export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer, deps: { run?: RunCommand; inspect?: (project: Project) => Promise<Inspection>; lock?: () => string | undefined } = {}): Promise<string> {
|
|
104
|
+
const { run = runCommand, inspect = inspectRecovery, lock = recoveryLock } = deps;
|
|
105
|
+
const name = `${planned.project.id}/${peer.id}`;
|
|
106
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === peer.id);
|
|
107
|
+
const refused = (why: string) => `${name}: not ended (${why})`;
|
|
108
|
+
let cut = "", current: TerminalBinding | undefined;
|
|
109
|
+
try {
|
|
110
|
+
const owner = lock();
|
|
111
|
+
if (owner) return refused(`recovery operation ${owner} holds the lock`);
|
|
112
|
+
const hub = await inspect(planned.project);
|
|
113
|
+
if (hub.state !== "running" || hub.instanceId !== planned.source.instanceId) return refused(`its hub reads as ${hub.state === "running" ? `instance ${hub.instanceId}, not the ${planned.source.instanceId} of the plan` : hub.state}`);
|
|
114
|
+
const now = hub.peers.find((p) => p.id === peer.id && p.state !== "offline");
|
|
115
|
+
if (!now) return refused("it is no longer attached to its hub");
|
|
116
|
+
// The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
|
|
117
|
+
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";
|
|
118
|
+
if (binding) {
|
|
119
|
+
if ((binding.peer === "codex" ? now.threadId : now.sessionId) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
|
|
120
|
+
// The same question the plan asked (`makeUpgradePlan`), for this one peer.
|
|
121
|
+
const session = binding.peer === "pi" ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
|
|
122
|
+
const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
|
|
123
|
+
current = found.byPeer[binding.peer];
|
|
124
|
+
if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
|
|
125
|
+
if (current.handle !== binding.handle || current.incarnationId !== binding.incarnationId) return refused(`its session is now bound to terminal ${current.handle} (${current.incarnationId}), not the plan's ${binding.handle} (${binding.incarnationId})`);
|
|
126
|
+
}
|
|
127
|
+
// The reads above take a second or more: an operation that took the lock meanwhile still stops this.
|
|
128
|
+
const taken = lock();
|
|
129
|
+
if (taken) return refused(`recovery operation ${taken} holds the lock`);
|
|
130
|
+
} catch (error) { return refused((error as Error).message); } // a lock or a registry that cannot be read throws
|
|
131
|
+
if (current) {
|
|
132
|
+
const result = await closeTerminal(current, 0, terminalOptions(run));
|
|
133
|
+
return result.closed ? `${name}: terminal ${current.handle} closed${cut}` : `${name}: its terminal was not closed (${result.blockers[0]?.message ?? "unknown reason"}); end it in that terminal`;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await rpc(planned.project, { t: "peer_stop", peer: peer.id }, planned.source.protocol ?? PROTOCOL);
|
|
137
|
+
return `${name}: stopped${cut}`;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const message = (error as Error).message;
|
|
140
|
+
return /does not know "peer_stop"/.test(message)
|
|
141
|
+
? `${name}: hub ${planned.source.version ?? "of this version"} cannot end a headless agent by itself; it stops with the old hub at the upgrade and the new hub starts it again (ahub stop ${peer.id} ends it then)`
|
|
142
|
+
: `${name}: not stopped (${message})`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
94
146
|
export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
95
|
-
|
|
147
|
+
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
148
|
+
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
149
|
+
// caller reads that as not inspected, never as stopped.
|
|
150
|
+
const claim = registeredProjects().find((p) => p.id === project.id);
|
|
151
|
+
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
|
|
96
152
|
const control = readControl(project.stateDir);
|
|
97
153
|
const sourceProtocol = control?.protocol;
|
|
98
154
|
const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
|
|
@@ -127,8 +183,10 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
127
183
|
peers: [], blockers: ["runtime changed during recovery inspection"] };
|
|
128
184
|
}
|
|
129
185
|
const peers = Object.values(response.recovery?.peers ?? {}) as any[];
|
|
186
|
+
// #272: the hub's own words for what it waits on; shown to the person, never decided on.
|
|
187
|
+
const waiting = (Array.isArray(response.recovery?.blockers) ? response.recovery.blockers : []).filter((b: unknown): b is string => typeof b === "string").slice(0, 20).map((b: string) => b.slice(0, 200));
|
|
130
188
|
return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
|
|
131
|
-
recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
|
|
189
|
+
recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready, ...(waiting.length ? { waiting } : {}) }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
|
|
132
190
|
...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
|
|
133
191
|
...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
|
|
134
192
|
...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
|
|
@@ -175,7 +233,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
175
233
|
if (recorded?.state === "unknown") {
|
|
176
234
|
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
177
235
|
delete peer.sessionId;
|
|
178
|
-
blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
|
|
236
|
+
blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
|
|
179
237
|
continue;
|
|
180
238
|
}
|
|
181
239
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
@@ -305,7 +363,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
305
363
|
* expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
|
|
306
364
|
*/
|
|
307
365
|
const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
|
|
308
|
-
Promise<{ state: "live"; session?: string; handle?: string } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
|
|
366
|
+
Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
|
|
309
367
|
const target = await inspectRecovery(planned.project);
|
|
310
368
|
if (target.state !== "running" || target.instanceId !== progress.instanceId) {
|
|
311
369
|
// Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
|
|
@@ -315,14 +373,44 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
315
373
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
316
374
|
const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
|
|
317
375
|
if (session) return { state: "live", session };
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
376
|
+
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
377
|
+
// launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
|
|
378
|
+
for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
|
|
379
|
+
if (!instanceId) continue;
|
|
380
|
+
const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
|
|
381
|
+
if (launch?.state === "live") return { state: "live", handle: launch.record!.handle, ...(attached ? { attached: true as const } : {}) };
|
|
382
|
+
if (launch?.state === "unknown") {
|
|
383
|
+
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}` }
|
|
384
|
+
: 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` }
|
|
385
|
+
: { 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)` };
|
|
386
|
+
}
|
|
323
387
|
}
|
|
388
|
+
// #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
|
|
389
|
+
// not reported its id yet) is a session of unknown identity, never "nothing attached".
|
|
390
|
+
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` };
|
|
324
391
|
return { state: "gone" };
|
|
325
392
|
};
|
|
393
|
+
/**
|
|
394
|
+
* #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
|
|
395
|
+
* (`closedRetired:<peer>` `pending` until Orca no longer lists it; an unreadable or truncated inventory is unknown and
|
|
396
|
+
* changes nothing) or found absent, before its peer is relaunched against the new instance.
|
|
397
|
+
*/
|
|
398
|
+
const closeRetired = async (progress: ProjectProgress, peer: string, save: () => void): Promise<void> => {
|
|
399
|
+
const retired = progress.terminals[`retired:${peer}`] as TerminalBinding | undefined;
|
|
400
|
+
const key = `closedRetired:${peer}`;
|
|
401
|
+
if (!retired || typeof retired !== "object" || progress.terminals[key] === true) return;
|
|
402
|
+
const seen = await listed(retired);
|
|
403
|
+
if (progress.terminals[key] === "pending" || seen !== "listed") {
|
|
404
|
+
if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
|
|
405
|
+
progress.terminals[key] = true; save(); return;
|
|
406
|
+
}
|
|
407
|
+
const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
|
|
408
|
+
if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
|
|
409
|
+
progress.terminals[key] = "pending"; save();
|
|
410
|
+
const result = await closeTerminal(retired, 0, terminalOptions(run));
|
|
411
|
+
if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
|
|
412
|
+
progress.terminals[key] = true; save();
|
|
413
|
+
};
|
|
326
414
|
const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
|
|
327
415
|
for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
|
|
328
416
|
const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
|
|
@@ -423,17 +511,30 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
423
511
|
await command(op, args, planned.project);
|
|
424
512
|
}
|
|
425
513
|
}
|
|
426
|
-
for (const
|
|
427
|
-
if ((
|
|
428
|
-
const key = `restored:${
|
|
514
|
+
for (const plannedBinding of planned.terminals as TerminalBinding[]) {
|
|
515
|
+
if ((plannedBinding.peer === "claude") !== (group === "claude")) continue;
|
|
516
|
+
const key = `restored:${plannedBinding.peer}`;
|
|
517
|
+
// #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
|
|
518
|
+
// instead of the planned one (fresh choice, fresh start, zero-turn Claude) is relaunched by that id, which the
|
|
519
|
+
// operation-fenced waiver already covers.
|
|
520
|
+
await closeRetired(progress, plannedBinding.peer, save);
|
|
521
|
+
const retired = progress.terminals[`retired:${plannedBinding.peer}`] as TerminalBinding | undefined;
|
|
522
|
+
const accepted = retired && typeof retired === "object" && retired.sessionId !== plannedBinding.sessionId ? retired.sessionId : undefined;
|
|
523
|
+
const original = accepted ? { ...plannedBinding, sessionId: accepted } : plannedBinding;
|
|
429
524
|
// A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
|
|
430
525
|
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
431
526
|
const chosen = !!progress.fresh?.[original.peer];
|
|
432
527
|
let read: ReturnType<typeof codexTranscript> | undefined;
|
|
433
|
-
const transcript = () => original.peer !== "codex" || chosen ? "found" : (read ??= codexTranscript(original));
|
|
434
|
-
|
|
528
|
+
const transcript = () => original.peer !== "codex" || (chosen && !accepted) ? "found" : (read ??= codexTranscript(original));
|
|
529
|
+
// An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
|
|
530
|
+
// rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
|
|
531
|
+
const fresh = () => accepted
|
|
532
|
+
? (original.peer === "codex" ? (chosen || !!planned.freshStart?.includes("codex")) && transcript() === "missing" : original.peer === "claude" && !claudeTranscriptExists(original))
|
|
533
|
+
: chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
|
|
435
534
|
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
436
535
|
// peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
|
|
536
|
+
// The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
|
|
537
|
+
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
|
|
437
538
|
const notRestored = (why: string) => new Error(fresh()
|
|
438
539
|
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
439
540
|
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
@@ -442,7 +543,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
442
543
|
? `${original.peer}: whether a ${original.peer} session or launcher is live cannot be told (${evidence.why}); nothing was recorded or created${evidence.step ? `; ${evidence.step} first` : ""}`
|
|
443
544
|
: evidence.session
|
|
444
545
|
? `${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`
|
|
445
|
-
: `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
|
|
546
|
+
: `${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`);
|
|
446
547
|
const receipt = progress.terminals[key];
|
|
447
548
|
if (receipt && receipt !== "pending" && receipt !== "failed") {
|
|
448
549
|
progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
|
|
@@ -456,14 +557,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
456
557
|
if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
|
|
457
558
|
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`);
|
|
458
559
|
}
|
|
459
|
-
// The
|
|
460
|
-
|
|
560
|
+
// The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
|
|
561
|
+
// never with a session accepted after a restart (#225), whose choice must stay on record.
|
|
562
|
+
if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
|
|
461
563
|
delete progress.fresh[original.peer];
|
|
462
564
|
if (!Object.keys(progress.fresh).length) delete progress.fresh;
|
|
463
565
|
}
|
|
464
566
|
// Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
|
|
465
567
|
// a launch writes the waiver first, but this must not rely on that.
|
|
466
|
-
|
|
568
|
+
// The planned session needs no waiver: the target's snapshot already expects it.
|
|
569
|
+
if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
467
570
|
progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
|
|
468
571
|
};
|
|
469
572
|
// #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
|
|
@@ -494,7 +597,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
494
597
|
if (fresh()) {
|
|
495
598
|
if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
|
|
496
599
|
// The restored daemon must accept the new session instead of the recorded one.
|
|
497
|
-
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]:
|
|
600
|
+
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
498
601
|
}
|
|
499
602
|
const entrypoint = join(op.targetRoot!, "src/cli/main.js");
|
|
500
603
|
const argv = restoredTerminalArgv(entrypoint, planned.project.root, original, fresh());
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import type
|
|
5
|
+
import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
6
6
|
import { hubHome } from "../hub/project.ts";
|
|
7
7
|
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
8
8
|
|
|
@@ -20,7 +20,8 @@ export interface Inspection {
|
|
|
20
20
|
version?: string;
|
|
21
21
|
protocol?: number;
|
|
22
22
|
peers: RecoveryPeer[];
|
|
23
|
-
|
|
23
|
+
/** `waiting` (#272): what the running hub says keeps it from being quiet (busy peers, pending approvals); display only. */
|
|
24
|
+
recovery?: { operationId?: string; phase?: string; ready?: boolean; waiting?: string[] };
|
|
24
25
|
blockers: string[];
|
|
25
26
|
/** #215: for a stopped runtime, the operation whose committed restart snapshot its state dir holds (unreleased). */
|
|
26
27
|
snapshot?: string;
|
|
@@ -56,9 +57,18 @@ export interface ProjectProgress {
|
|
|
56
57
|
fresh?: Record<string, { lost: string; reason: string; at: number }>;
|
|
57
58
|
/** #215: set before the commit request is sent; from then on the source may have committed (abort is not offered). */
|
|
58
59
|
commitSent?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* #225: each restart of a target that stopped after it started, recorded before the new one starts: the dead
|
|
62
|
+
* instance and when. Status and audit read it; restore reads the launch records of every instance listed here.
|
|
63
|
+
*/
|
|
64
|
+
restarts?: { instanceId: string; at: number }[];
|
|
59
65
|
}
|
|
60
66
|
export interface RecoveryOperation {
|
|
61
|
-
|
|
67
|
+
/**
|
|
68
|
+
* #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
|
|
69
|
+
* abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
|
|
70
|
+
*/
|
|
71
|
+
schema: 1 | 2;
|
|
62
72
|
id: string;
|
|
63
73
|
plan: UpgradePlan;
|
|
64
74
|
createdAt: number;
|
|
@@ -94,6 +104,37 @@ export function targetReadsWaivers(op: { targetRoot?: string }): boolean {
|
|
|
94
104
|
try { return !!op.targetRoot && readFileSync(join(op.targetRoot, "src/hub/restart.ts"), "utf8").includes("export function readRecoveryWaivers"); } catch { return false; }
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
/**
|
|
108
|
+
* #225: whether an operation's coordinator restarts a target that stopped after it started. Resume runs the operation's
|
|
109
|
+
* own runner, so an older one keeps refusing and `next` must not offer resume for that row.
|
|
110
|
+
* ponytail: a text sniff of its upgrade.ts, like targetReadsWaivers and coordinatorCurrent; a rename reads as "cannot
|
|
111
|
+
* restart" (the safe side). Upgrade path: the capability list those two name.
|
|
112
|
+
*/
|
|
113
|
+
export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
|
|
114
|
+
if (!op.sourceRoot) return true;
|
|
115
|
+
try { return readFileSync(join(op.sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export function recordRestart"); } catch { return false; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
|
|
120
|
+
* the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
|
|
121
|
+
* (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
|
|
122
|
+
* revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
|
|
123
|
+
* The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
|
|
124
|
+
*/
|
|
125
|
+
export function recordRestart(progress: ProjectProgress, at: number): void {
|
|
126
|
+
(progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
|
|
127
|
+
for (const [key, value] of Object.entries(progress.terminals)) {
|
|
128
|
+
if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
|
|
129
|
+
const peer = key.slice("restored:".length);
|
|
130
|
+
progress.terminals[`retired:${peer}`] = value;
|
|
131
|
+
delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
|
|
132
|
+
delete progress.terminals[key];
|
|
133
|
+
}
|
|
134
|
+
delete progress.instanceId;
|
|
135
|
+
progress.phase = "stopped";
|
|
136
|
+
}
|
|
137
|
+
|
|
97
138
|
/** Effects anywhere in the operation: a project past `prepared` or any terminal receipt. Abort needs none. */
|
|
98
139
|
export const hasEffects = (op: RecoveryOperation) => op.projects.some((p) => !["pending", "prepared"].includes(p.phase) || Object.keys(p.terminals).length > 0);
|
|
99
140
|
/**
|
|
@@ -144,9 +185,9 @@ export function resumeBlocked(op: RecoveryOperation, live: Record<string, Inspec
|
|
|
144
185
|
if (state.state !== "stopped") continue;
|
|
145
186
|
if (p.phase === "pending") return `${p.id}: the source stopped before it was prepared`;
|
|
146
187
|
if (p.phase === "prepared" && !p.commitSent && !old) return `${p.id}: the source stopped while prepared, with no commit request`;
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
if (p.phase === "started" || p.phase === "peers-restored") return `${p.id}: the target stopped after it started;
|
|
188
|
+
// #225: a target that stopped after it started is restarted from this operation's snapshot (the line below), by a
|
|
189
|
+
// coordinator that can; an older one's runner refuses it.
|
|
190
|
+
if ((p.phase === "started" || p.phase === "peers-restored") && !coordinatorRestarts(op)) return `${p.id}: the target stopped after it started; this operation's coordinator cannot restart it`;
|
|
150
191
|
if (state.snapshot !== op.id) return `${p.id}: its hub stopped without this operation's restart snapshot`;
|
|
151
192
|
}
|
|
152
193
|
return undefined;
|
|
@@ -162,32 +203,48 @@ export function disposeRefusal(op: RecoveryOperation, live: Record<string, Inspe
|
|
|
162
203
|
return unsettled ? { project: unsettled.id, reads: live[unsettled.id]?.state ?? "not inspected" } : undefined;
|
|
163
204
|
}
|
|
164
205
|
|
|
206
|
+
/** #272: one entry of `next`. `text` is the line status prints; a screen runs `kind` through `recoveryArgv`. */
|
|
207
|
+
export interface NextChoice {
|
|
208
|
+
kind: "resume" | "abort" | "fresh" | "stop" | "wait";
|
|
209
|
+
/** `fresh` only: the peer that starts as a new session. */
|
|
210
|
+
peer?: string;
|
|
211
|
+
text: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
165
214
|
/**
|
|
166
215
|
* #215: what the person can do now, in the receipt table's order: resume (it also launches a failed peer again),
|
|
167
216
|
* abort where it can succeed, a fresh session for a failed Codex or Claude restoration, and stop-and-archive last.
|
|
168
|
-
* `status
|
|
217
|
+
* `status`, every error that names choices and the interactive screen (#272) use this one list.
|
|
169
218
|
*/
|
|
170
|
-
export function
|
|
219
|
+
export function nextChoices(receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): NextChoice[] {
|
|
171
220
|
// A receipt read without its project lists (never written so by a coordinator) still gets the general choices.
|
|
172
221
|
const op: RecoveryOperation = receipt.projects ? receipt : { ...receipt, projects: [], plan: { ...receipt.plan, projects: receipt.plan?.projects ?? [] } };
|
|
173
222
|
if (op.phase === "completed" || op.phase === "cancelled") return [];
|
|
174
223
|
// claimRunner refuses an owner it cannot read, so resume, abort and dispose are all refused until the record reads.
|
|
175
|
-
if (runner === "unknown") return [`${recoveryCommand(op, "status")} again: whether a runner holds the operation could not be read (resume, abort and dispose are refused until it can)`];
|
|
176
|
-
if (runner) return [`wait: runner ${runner} is working; ${recoveryCommand(op, "status")}`];
|
|
224
|
+
if (runner === "unknown") return [{ kind: "wait", text: `${recoveryCommand(op, "status")} again: whether a runner holds the operation could not be read (resume, abort and dispose are refused until it can)` }];
|
|
225
|
+
if (runner) return [{ kind: "wait", text: `wait: runner ${runner} is working; ${recoveryCommand(op, "status")}` }];
|
|
177
226
|
const unsettled = disposeRefusal(op, live);
|
|
178
|
-
const stop = unsettled ? `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` : recoveryCommand(op, "dispose", STOP);
|
|
179
|
-
if (op.disposition) return [unsettled ? stop : `rerun ${stop}`];
|
|
227
|
+
const stop: NextChoice = unsettled ? { kind: "wait", text: `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` } : { kind: "stop", text: recoveryCommand(op, "dispose", STOP) };
|
|
228
|
+
if (op.disposition) return [unsettled ? stop : { kind: "stop", text: `rerun ${stop.text}` }];
|
|
180
229
|
const failed = [...new Set(op.projects.flatMap((p) => failedPeers(op, p, live)))];
|
|
181
230
|
const old = !!op.sourceRoot && !coordinatorCurrent(op.sourceRoot);
|
|
182
231
|
const abortable = !abortRefusal(op, live);
|
|
183
232
|
const stuck = resumeBlocked(op, live);
|
|
184
233
|
return [
|
|
185
|
-
...(stuck ? [] : [`${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}`]),
|
|
186
|
-
...(abortable ? [recoveryCommand(op, "abort")] : []),
|
|
187
|
-
...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`)) : []),
|
|
234
|
+
...(stuck ? [] : [{ kind: "resume" as const, text: `${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}` }]),
|
|
235
|
+
...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
|
|
236
|
+
...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => ({ kind: "fresh" as const, peer, text: recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`) })) : []),
|
|
188
237
|
stop,
|
|
189
238
|
];
|
|
190
239
|
}
|
|
240
|
+
export const nextActions = (receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): string[] => nextChoices(receipt, runner, live).map((c) => c.text);
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* #272: a runner that only waits for its source to get quiet can be stopped and the operation aborted: nothing was
|
|
244
|
+
* closed, stopped or asked to commit. Abort decides again on what is live, so a runner that moved on meanwhile is
|
|
245
|
+
* refused there, never here.
|
|
246
|
+
*/
|
|
247
|
+
export const cancellableWait = (op: RecoveryOperation): boolean => op.phase === "running" && op.step.startsWith("prepare:") && !op.disposition && !hasEffects(op) && !op.projects.some((p) => p.commitSent);
|
|
191
248
|
|
|
192
249
|
/**
|
|
193
250
|
* "next actions: ..." from nextActions, with the inspections the caller holds. Every error that lists choices ends with
|
|
@@ -231,7 +288,9 @@ export function registeredProjects(home = hubHome()): Project[] {
|
|
|
231
288
|
if (!existsSync(path)) return [];
|
|
232
289
|
const db = new Database(path, { readonly: true });
|
|
233
290
|
try {
|
|
234
|
-
|
|
291
|
+
// #226: a registry no current release opened yet has no signature column, and this read never migrates.
|
|
292
|
+
const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
|
|
293
|
+
return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
235
294
|
} finally { db.close(); }
|
|
236
295
|
}
|
|
237
296
|
|
|
@@ -256,8 +315,21 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
|
|
|
256
315
|
return op;
|
|
257
316
|
}
|
|
258
317
|
|
|
318
|
+
/**
|
|
319
|
+
* #227: a coordinator from before #215 never reads `disposition`, and an older global `ahub recovery resume` runs it.
|
|
320
|
+
* Every older validateReceipt refuses a schema other than 1 before the lock and the runner claim, so schema 2 keeps it
|
|
321
|
+
* from advancing an operation being abandoned. A #215 coordinator's runner refuses the disposition itself and keeps
|
|
322
|
+
* schema 1, so a 0.12.20 CLI can still rerun its dispose. True when the receipt changed (a disposition recorded at 1
|
|
323
|
+
* by 0.12.20 is moved to 2 by the next command of this release that holds the runner claim).
|
|
324
|
+
*/
|
|
325
|
+
function abandoned(op: RecoveryOperation): boolean {
|
|
326
|
+
if (!op.disposition || op.schema !== 1 || !op.sourceRoot || coordinatorCurrent(op.sourceRoot)) return false;
|
|
327
|
+
op.schema = 2;
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
259
331
|
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
260
|
-
if (op.schema
|
|
332
|
+
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
261
333
|
const { fingerprint, ...reviewed } = op.plan;
|
|
262
334
|
if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
|
|
263
335
|
op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
|
|
@@ -302,8 +374,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
302
374
|
} catch (error) { releaseRunner(); throw error; }
|
|
303
375
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
304
376
|
const step = (value: string) => { op.step = value; save(); };
|
|
377
|
+
// #225: one restart per project per resume, so a target that keeps dying never spins unattended.
|
|
378
|
+
const restarted = new Set<string>();
|
|
379
|
+
const stoppedAgain = (progress: ProjectProgress) => `the target stopped again after restart ${progress.restarts?.length ?? 0} in this resume`;
|
|
305
380
|
const identity = (observed: Inspection, planned: PlannedProject, progress: ProjectProgress) => {
|
|
306
|
-
if (observed.state === "stopped") throw new Error(progress.
|
|
381
|
+
if (observed.state === "stopped") throw new Error(restarted.has(progress.id) ? `${planned.project.id}: ${stoppedAgain(progress)}; read its hub.log before resuming again`
|
|
382
|
+
: progress.phase !== "stopped" ? `${planned.project.id}: the target stopped after it started (a crash or a reboot)${observed.snapshot === id ? "; resume restarts it from this operation's snapshot" : " and this operation's restart snapshot is gone, so nothing can start it"}`
|
|
307
383
|
: `${planned.project.id}: the target is stopped${observed.snapshot === id ? "" : " and this operation's restart snapshot is gone, so nothing can start it"}`);
|
|
308
384
|
if (observed.state === "missing") throw new Error(`${planned.project.id}: the project directory is missing`);
|
|
309
385
|
if (observed.state !== "running") throw new Error(`${planned.project.id}: the target reads as ${observed.state}; wait until it answers`);
|
|
@@ -381,6 +457,15 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
381
457
|
}
|
|
382
458
|
for (let i = 0; i < op.projects.length; i++) {
|
|
383
459
|
const progress = op.projects[i]!, planned = op.plan.projects[i]!, project = planned.project;
|
|
460
|
+
if (progress.phase === "started" || progress.phase === "peers-restored") {
|
|
461
|
+
// #225: a target that stopped after it started, with this operation's snapshot, starts again from it.
|
|
462
|
+
const live = await driver.inspect(project);
|
|
463
|
+
if (live.state === "stopped") {
|
|
464
|
+
if (live.snapshot !== id) throw new Error(`${project.id}: the target stopped after it started and this operation's restart snapshot is gone, so nothing can start it`);
|
|
465
|
+
step(`restart:${project.id}`);
|
|
466
|
+
recordRestart(progress, driver.now()); restarted.add(progress.id); save();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
384
469
|
if (progress.phase === "verified" || progress.phase === "peers-restored") continue;
|
|
385
470
|
if (progress.phase === "pending") {
|
|
386
471
|
step(`prepare:${project.id}`);
|
|
@@ -486,7 +571,8 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
|
|
|
486
571
|
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
487
572
|
...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
|
|
488
573
|
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)])) } : {}),
|
|
489
|
-
...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {})
|
|
574
|
+
...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
|
|
575
|
+
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
490
576
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
491
577
|
...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
|
|
492
578
|
...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
@@ -507,6 +593,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
507
593
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
508
594
|
return;
|
|
509
595
|
}
|
|
596
|
+
if (abandoned(op)) writeOperation(id, op, home);
|
|
510
597
|
const { fingerprint, ...body } = op.plan;
|
|
511
598
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
512
599
|
const live = await liveProjects(op, driver.inspect);
|
|
@@ -543,6 +630,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
543
630
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
544
631
|
throw new Error(`operation is already ${op.phase}`);
|
|
545
632
|
}
|
|
633
|
+
if (abandoned(op)) writeOperation(id, op, home);
|
|
546
634
|
const at = driver.now();
|
|
547
635
|
// Read once, as status and the runner do: every choice and refusal below is decided on it.
|
|
548
636
|
const live = await liveProjects(op, driver.inspect);
|
|
@@ -596,6 +684,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
596
684
|
}
|
|
597
685
|
// Each outcome is recorded as it happens, so a disposition that stops partway stays true and resume refuses it.
|
|
598
686
|
const disposition = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
|
|
687
|
+
abandoned(op); // #227: in the same write as the disposition
|
|
599
688
|
(op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
|
|
600
689
|
// Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
|
|
601
690
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|