@staix/agent-hub 0.12.20 → 0.12.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/bus.md +5 -2
- package/docs/agent-notes/daemon.md +8 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +2 -1
- package/docs/operations.md +271 -46
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +45 -10
- package/docs/smoke.md +79 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
- package/docs/verified.json +28 -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 +67 -6
- package/src/adapters/codex-appserver.ts +67 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +58 -4
- 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 +10 -0
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +96 -17
- package/src/cli/main.ts +159 -18
- package/src/cli/preview.ts +13 -3
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-runtime.ts +67 -18
- package/src/cli/upgrade.ts +81 -9
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +19 -4
- 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 +346 -40
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +2 -1
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +21 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +70 -43
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/tools.ts +14 -10
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +83 -6
- package/src/ui/task-progress.ts +33 -0
|
@@ -2,7 +2,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { processSignature } from "../pi/process-signature.ts";
|
|
5
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
6
6
|
import { atomicPrivateJSON } from "../hub/recovery-store.ts";
|
|
7
7
|
|
|
8
8
|
/** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
|
|
@@ -130,6 +130,12 @@ export interface TerminalRecoveryOptions {
|
|
|
130
130
|
instanceId?: string;
|
|
131
131
|
/** Injectable process-start identity used to reject stale or reused launcher PIDs. */
|
|
132
132
|
processIdentity?: ProcessIdentity;
|
|
133
|
+
/**
|
|
134
|
+
* #228: the identity as releases up to 0.12.12 computed it (unpinned `ps`, the reader's TZ and locale). It defaults
|
|
135
|
+
* to that read only when `processIdentity` is the default, so an injected identity is never second-guessed by a
|
|
136
|
+
* real `ps` read of the same pid.
|
|
137
|
+
*/
|
|
138
|
+
legacyIdentity?: ProcessIdentity;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
export interface RecordedTerminalLaunch {
|
|
@@ -233,6 +239,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
|
|
|
233
239
|
stateDir: config.stateDir ?? "",
|
|
234
240
|
instanceId: config.instanceId ?? "",
|
|
235
241
|
processIdentity: config.processIdentity ?? defaultProcessIdentity,
|
|
242
|
+
legacyIdentity: config.legacyIdentity ?? (config.processIdentity ? () => undefined : unpinnedProcessIdentity),
|
|
236
243
|
};
|
|
237
244
|
}
|
|
238
245
|
|
|
@@ -272,22 +279,40 @@ export function recordPath(stateDir: string): string {
|
|
|
272
279
|
return join(stateDir, RECORD_FILE);
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
function validRecord(item: unknown): item is RecordedTerminalLaunch {
|
|
283
|
+
const row = object(item);
|
|
284
|
+
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none".
|
|
289
|
+
* #228: a row that fails validation is kept apart (`invalid`), never dropped: whatever it would answer is unknown, and
|
|
290
|
+
* `all` is the file as read, so a writer puts every such row back unchanged.
|
|
291
|
+
*/
|
|
292
|
+
type LaunchRecords = { rows: RecordedTerminalLaunch[]; invalid: unknown[]; all: unknown[] };
|
|
293
|
+
function launchRecords(stateDir: string): LaunchRecords | "unreadable" {
|
|
294
|
+
const none = { rows: [], invalid: [], all: [] };
|
|
295
|
+
if (!stateDir || !existsSync(recordPath(stateDir))) return none;
|
|
278
296
|
try {
|
|
279
297
|
const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
|
|
280
298
|
if (!Array.isArray(value)) return "unreadable";
|
|
281
|
-
return value.filter((item)
|
|
282
|
-
const row = object(item);
|
|
283
|
-
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
284
|
-
});
|
|
299
|
+
return { rows: value.filter(validRecord), invalid: value.filter((item) => !validRecord(item)), all: value };
|
|
285
300
|
} catch (error) {
|
|
286
|
-
return (error as NodeJS.ErrnoException).code === "ENOENT" ?
|
|
301
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? none : "unreadable";
|
|
287
302
|
}
|
|
288
303
|
}
|
|
289
304
|
|
|
290
|
-
|
|
305
|
+
/**
|
|
306
|
+
* #228: whether a row that cannot be evaluated may answer a question about this peer, project and instance: it does
|
|
307
|
+
* unless one of its fields is a readable string that names another one (a row for another peer or an older instance).
|
|
308
|
+
*/
|
|
309
|
+
function concerns(raw: unknown, query: { peer?: string; projectRoot: string; instanceId: string }): boolean {
|
|
310
|
+
const row = object(raw);
|
|
311
|
+
const other = (value: unknown, wanted?: string) => wanted !== undefined && typeof value === "string" && value !== wanted;
|
|
312
|
+
return !other(row.peer, query.peer) && !other(row.projectRoot, query.projectRoot) && !other(row.instanceId, query.instanceId);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function writeLaunchRecords(stateDir: string, rows: unknown[]): void {
|
|
291
316
|
mkdirSync(stateDir, { recursive: true });
|
|
292
317
|
atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
|
|
293
318
|
}
|
|
@@ -297,30 +322,50 @@ function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): v
|
|
|
297
322
|
* process holds it now; unknown when the pid exists but its identity cannot be read. Unknown is never gone.
|
|
298
323
|
*/
|
|
299
324
|
export type LauncherState = "live" | "gone" | "unknown";
|
|
300
|
-
async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity): Promise<LauncherState> {
|
|
301
|
-
let
|
|
302
|
-
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
325
|
+
async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<LauncherState> {
|
|
326
|
+
let current: string | undefined;
|
|
327
|
+
try { current = await identity(row.launcherPid) || undefined; } catch { /* unreadable: decided by the pid probe */ }
|
|
328
|
+
// #228: a launcher recorded by 0.12.12 or older hashed `ps` in its own TZ and locale (#177 pinned it in 0.12.13). Read
|
|
329
|
+
// the same unpinned way in this reader's environment, a match is that launcher; anything else stays gone (pid reuse).
|
|
330
|
+
if (current && current !== row.launcherSignature) {
|
|
331
|
+
let old: string | undefined;
|
|
332
|
+
try { old = await legacy(row.launcherPid) || undefined; } catch { /* no legacy reading: the pinned one decides */ }
|
|
333
|
+
if (old === row.launcherSignature) return "live";
|
|
334
|
+
}
|
|
335
|
+
return processLiveness(row.launcherPid, row.launcherSignature, () => current);
|
|
306
336
|
}
|
|
307
337
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
338
|
+
/** The launcher signature as releases up to 0.12.12 computed it: `ps` in the caller's own TZ and locale, unpinned. */
|
|
339
|
+
function unpinnedProcessIdentity(pid: number): string | undefined {
|
|
340
|
+
try {
|
|
341
|
+
const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
|
|
342
|
+
const text = result.exitCode === 0 ? result.stdout.toString().trim() : "";
|
|
343
|
+
return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined;
|
|
344
|
+
} catch { return undefined; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* This instance's launch records whose launcher is live, those whose launcher cannot be identified (never gone), and
|
|
349
|
+
* the rows that cannot be evaluated and may be this project's and instance's (#228: unknown for their peer).
|
|
350
|
+
*/
|
|
351
|
+
async function recordsByState(records: LaunchRecords, projectRoot: string, instanceId: string, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[]; invalid: unknown[] }> {
|
|
352
|
+
if (!instanceId) return { live: [], unknown: [], invalid: [] };
|
|
353
|
+
const candidates = records.rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
|
|
354
|
+
const states = await Promise.all(candidates.map((item) => launcherState(item, identity, legacy)));
|
|
355
|
+
return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown"),
|
|
356
|
+
invalid: records.invalid.filter((raw) => concerns(raw, { projectRoot, instanceId })) };
|
|
314
357
|
}
|
|
315
358
|
|
|
316
359
|
/** #215: the launcher recorded for `peer` on this instance (one row per peer and instance) and its state; managed means live. */
|
|
317
|
-
export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState } | undefined> {
|
|
360
|
+
export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState; invalidRow?: true } | undefined> {
|
|
318
361
|
const config = normalizeOptions(options);
|
|
319
362
|
if (!config.stateDir || !config.instanceId) return undefined;
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
363
|
+
const records = launchRecords(config.stateDir);
|
|
364
|
+
if (records === "unreadable") return { state: "unknown" };
|
|
365
|
+
// #228: a row that cannot be evaluated and may be this launcher's is unknown, never "no launcher".
|
|
366
|
+
if (records.invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) return { state: "unknown", invalidRow: true };
|
|
367
|
+
const record = records.rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
|
|
368
|
+
return record ? { record, state: await launcherState(record, config.processIdentity, config.legacyIdentity) } : undefined;
|
|
324
369
|
}
|
|
325
370
|
|
|
326
371
|
/**
|
|
@@ -353,7 +398,8 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
353
398
|
if (existing === "unreadable") {
|
|
354
399
|
throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
|
|
355
400
|
}
|
|
356
|
-
|
|
401
|
+
// #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
|
|
402
|
+
const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
|
|
357
403
|
rows.push(row);
|
|
358
404
|
writeLaunchRecords(stateDir, rows);
|
|
359
405
|
return row;
|
|
@@ -482,7 +528,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
482
528
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
483
529
|
const rows = launchRecords(config.stateDir);
|
|
484
530
|
if (rows === "unreadable") return { bindings, byPeer, blockers: [blocker("command-error", `${recordPath(config.stateDir)} cannot be read, so no terminal can be bound to its launch; inspect or move it aside, then plan or resume again`)], manualRequired: true };
|
|
485
|
-
const { live: records, unknown } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity);
|
|
531
|
+
const { live: records, unknown, invalid } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity, config.legacyIdentity);
|
|
486
532
|
|
|
487
533
|
let listed: Record<string, unknown>[];
|
|
488
534
|
try {
|
|
@@ -500,6 +546,10 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
500
546
|
blockers.push(blocker("ownership-unknown", `the ${peer} launcher recorded for terminal ${unread.handle} (pid ${unread.launcherPid}) cannot be identified, so no terminal can be bound to its launch; wait until it exits or end it, then plan or resume again`, peer, unread.handle));
|
|
501
547
|
continue;
|
|
502
548
|
}
|
|
549
|
+
if (invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) {
|
|
550
|
+
blockers.push(blocker("ownership-unknown", `${recordPath(config.stateDir)} holds a launch record that cannot be evaluated and may be this ${peer} launcher's, so no terminal can be bound to its launch; next action: inspect that row and fix it, or end that launcher and remove its row, then plan or resume again`, peer));
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
503
553
|
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
|
|
504
554
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
505
555
|
for (const listedTerminal of candidates) {
|
|
@@ -639,8 +689,10 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
639
689
|
const launches = async () => {
|
|
640
690
|
const rows = launchRecords(config.stateDir);
|
|
641
691
|
if (rows === "unreadable") return { live: undefined, gone: false, mayRun: true }; // unknown, never gone
|
|
642
|
-
|
|
643
|
-
|
|
692
|
+
// #228: a row that cannot be evaluated and may be this launch's is unknown too.
|
|
693
|
+
if (rows.invalid.some((raw) => concerns(raw, { peer: binding.peer, projectRoot: binding.projectRoot, instanceId: config.instanceId }))) return { live: undefined, gone: false, mayRun: true };
|
|
694
|
+
const possible = rows.rows.filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
|
|
695
|
+
const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity, config.legacyIdentity) })));
|
|
644
696
|
return { live: states.find((entry) => entry.state === "live")?.item, gone: states.length > 0 && states.every((entry) => entry.state === "gone"), mayRun: states.some((entry) => entry.state !== "gone") };
|
|
645
697
|
};
|
|
646
698
|
// #215: Orca types the command into a login shell that outlives it, so the terminal never exits with the launcher.
|
|
@@ -92,7 +92,11 @@ async function rpc(project: Project, message: Record<string, unknown>, protocol
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
95
|
-
|
|
95
|
+
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
96
|
+
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
97
|
+
// caller reads that as not inspected, never as stopped.
|
|
98
|
+
const claim = registeredProjects().find((p) => p.id === project.id);
|
|
99
|
+
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
|
|
96
100
|
const control = readControl(project.stateDir);
|
|
97
101
|
const sourceProtocol = control?.protocol;
|
|
98
102
|
const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
|
|
@@ -175,7 +179,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
175
179
|
if (recorded?.state === "unknown") {
|
|
176
180
|
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
177
181
|
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`);
|
|
182
|
+
blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
|
|
179
183
|
continue;
|
|
180
184
|
}
|
|
181
185
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
@@ -305,7 +309,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
305
309
|
* expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
|
|
306
310
|
*/
|
|
307
311
|
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 }> => {
|
|
312
|
+
Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
|
|
309
313
|
const target = await inspectRecovery(planned.project);
|
|
310
314
|
if (target.state !== "running" || target.instanceId !== progress.instanceId) {
|
|
311
315
|
// 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 +319,44 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
315
319
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
316
320
|
const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
|
|
317
321
|
if (session) return { state: "live", session };
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
322
|
+
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
323
|
+
// launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
|
|
324
|
+
for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
|
|
325
|
+
if (!instanceId) continue;
|
|
326
|
+
const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
|
|
327
|
+
if (launch?.state === "live") return { state: "live", handle: launch.record!.handle, ...(attached ? { attached: true as const } : {}) };
|
|
328
|
+
if (launch?.state === "unknown") {
|
|
329
|
+
return launch.record ? { state: "unknown", why: `its launcher in terminal ${launch.record.handle} cannot be read`, step: `wait until it attaches, or end it and close terminal ${launch.record.handle}` }
|
|
330
|
+
: launch.invalidRow ? { state: "unknown", why: "a launch record that may be its launcher's cannot be evaluated", step: `inspect ${recordPath(planned.project.stateDir)} and fix that row, or end that launcher and remove its row` }
|
|
331
|
+
: { state: "unknown", why: "the launcher records cannot be read", step: `inspect ${recordPath(planned.project.stateDir)} and move it aside (sessions it recorded then count as unmanaged)` };
|
|
332
|
+
}
|
|
323
333
|
}
|
|
334
|
+
// #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
|
|
335
|
+
// not reported its id yet) is a session of unknown identity, never "nothing attached".
|
|
336
|
+
if (attached) return { state: "unknown", why: `a ${peer} session is attached to the target without a session id`, step: `wait until it reports one, or end that ${peer} session` };
|
|
324
337
|
return { state: "gone" };
|
|
325
338
|
};
|
|
339
|
+
/**
|
|
340
|
+
* #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
|
|
341
|
+
* (`closedRetired:<peer>` `pending` until Orca no longer lists it; an unreadable or truncated inventory is unknown and
|
|
342
|
+
* changes nothing) or found absent, before its peer is relaunched against the new instance.
|
|
343
|
+
*/
|
|
344
|
+
const closeRetired = async (progress: ProjectProgress, peer: string, save: () => void): Promise<void> => {
|
|
345
|
+
const retired = progress.terminals[`retired:${peer}`] as TerminalBinding | undefined;
|
|
346
|
+
const key = `closedRetired:${peer}`;
|
|
347
|
+
if (!retired || typeof retired !== "object" || progress.terminals[key] === true) return;
|
|
348
|
+
const seen = await listed(retired);
|
|
349
|
+
if (progress.terminals[key] === "pending" || seen !== "listed") {
|
|
350
|
+
if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
|
|
351
|
+
progress.terminals[key] = true; save(); return;
|
|
352
|
+
}
|
|
353
|
+
const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
|
|
354
|
+
if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
|
|
355
|
+
progress.terminals[key] = "pending"; save();
|
|
356
|
+
const result = await closeTerminal(retired, 0, terminalOptions(run));
|
|
357
|
+
if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
|
|
358
|
+
progress.terminals[key] = true; save();
|
|
359
|
+
};
|
|
326
360
|
const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
|
|
327
361
|
for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
|
|
328
362
|
const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
|
|
@@ -423,17 +457,30 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
423
457
|
await command(op, args, planned.project);
|
|
424
458
|
}
|
|
425
459
|
}
|
|
426
|
-
for (const
|
|
427
|
-
if ((
|
|
428
|
-
const key = `restored:${
|
|
460
|
+
for (const plannedBinding of planned.terminals as TerminalBinding[]) {
|
|
461
|
+
if ((plannedBinding.peer === "claude") !== (group === "claude")) continue;
|
|
462
|
+
const key = `restored:${plannedBinding.peer}`;
|
|
463
|
+
// #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
|
|
464
|
+
// instead of the planned one (fresh choice, fresh start, zero-turn Claude) is relaunched by that id, which the
|
|
465
|
+
// operation-fenced waiver already covers.
|
|
466
|
+
await closeRetired(progress, plannedBinding.peer, save);
|
|
467
|
+
const retired = progress.terminals[`retired:${plannedBinding.peer}`] as TerminalBinding | undefined;
|
|
468
|
+
const accepted = retired && typeof retired === "object" && retired.sessionId !== plannedBinding.sessionId ? retired.sessionId : undefined;
|
|
469
|
+
const original = accepted ? { ...plannedBinding, sessionId: accepted } : plannedBinding;
|
|
429
470
|
// A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
|
|
430
471
|
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
431
472
|
const chosen = !!progress.fresh?.[original.peer];
|
|
432
473
|
let read: ReturnType<typeof codexTranscript> | undefined;
|
|
433
|
-
const transcript = () => original.peer !== "codex" || chosen ? "found" : (read ??= codexTranscript(original));
|
|
434
|
-
|
|
474
|
+
const transcript = () => original.peer !== "codex" || (chosen && !accepted) ? "found" : (read ??= codexTranscript(original));
|
|
475
|
+
// An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
|
|
476
|
+
// rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
|
|
477
|
+
const fresh = () => accepted
|
|
478
|
+
? (original.peer === "codex" ? (chosen || !!planned.freshStart?.includes("codex")) && transcript() === "missing" : original.peer === "claude" && !claudeTranscriptExists(original))
|
|
479
|
+
: chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
|
|
435
480
|
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
436
481
|
// peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
|
|
482
|
+
// The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
|
|
483
|
+
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
|
|
437
484
|
const notRestored = (why: string) => new Error(fresh()
|
|
438
485
|
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
439
486
|
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
@@ -442,7 +489,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
442
489
|
? `${original.peer}: whether a ${original.peer} session or launcher is live cannot be told (${evidence.why}); nothing was recorded or created${evidence.step ? `; ${evidence.step} first` : ""}`
|
|
443
490
|
: evidence.session
|
|
444
491
|
? `${original.peer}: session ${evidence.session} is attached instead of ${original.sessionId}; no terminal was created; end that ${original.peer} session and close its terminal first`
|
|
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`);
|
|
492
|
+
: `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session ${evidence.attached ? "with an id " : ""}attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
|
|
446
493
|
const receipt = progress.terminals[key];
|
|
447
494
|
if (receipt && receipt !== "pending" && receipt !== "failed") {
|
|
448
495
|
progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
|
|
@@ -456,14 +503,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
456
503
|
if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
|
|
457
504
|
throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${attached} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
|
|
458
505
|
}
|
|
459
|
-
// The
|
|
460
|
-
|
|
506
|
+
// The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
|
|
507
|
+
// never with a session accepted after a restart (#225), whose choice must stay on record.
|
|
508
|
+
if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
|
|
461
509
|
delete progress.fresh[original.peer];
|
|
462
510
|
if (!Object.keys(progress.fresh).length) delete progress.fresh;
|
|
463
511
|
}
|
|
464
512
|
// Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
|
|
465
513
|
// a launch writes the waiver first, but this must not rely on that.
|
|
466
|
-
|
|
514
|
+
// The planned session needs no waiver: the target's snapshot already expects it.
|
|
515
|
+
if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
467
516
|
progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
|
|
468
517
|
};
|
|
469
518
|
// #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
|
|
@@ -494,7 +543,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
494
543
|
if (fresh()) {
|
|
495
544
|
if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
|
|
496
545
|
// The restored daemon must accept the new session instead of the recorded one.
|
|
497
|
-
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]:
|
|
546
|
+
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
498
547
|
}
|
|
499
548
|
const entrypoint = join(op.targetRoot!, "src/cli/main.js");
|
|
500
549
|
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
|
|
|
@@ -56,9 +56,18 @@ export interface ProjectProgress {
|
|
|
56
56
|
fresh?: Record<string, { lost: string; reason: string; at: number }>;
|
|
57
57
|
/** #215: set before the commit request is sent; from then on the source may have committed (abort is not offered). */
|
|
58
58
|
commitSent?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* #225: each restart of a target that stopped after it started, recorded before the new one starts: the dead
|
|
61
|
+
* instance and when. Status and audit read it; restore reads the launch records of every instance listed here.
|
|
62
|
+
*/
|
|
63
|
+
restarts?: { instanceId: string; at: number }[];
|
|
59
64
|
}
|
|
60
65
|
export interface RecoveryOperation {
|
|
61
|
-
|
|
66
|
+
/**
|
|
67
|
+
* #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
|
|
68
|
+
* abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
|
|
69
|
+
*/
|
|
70
|
+
schema: 1 | 2;
|
|
62
71
|
id: string;
|
|
63
72
|
plan: UpgradePlan;
|
|
64
73
|
createdAt: number;
|
|
@@ -94,6 +103,37 @@ export function targetReadsWaivers(op: { targetRoot?: string }): boolean {
|
|
|
94
103
|
try { return !!op.targetRoot && readFileSync(join(op.targetRoot, "src/hub/restart.ts"), "utf8").includes("export function readRecoveryWaivers"); } catch { return false; }
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
/**
|
|
107
|
+
* #225: whether an operation's coordinator restarts a target that stopped after it started. Resume runs the operation's
|
|
108
|
+
* own runner, so an older one keeps refusing and `next` must not offer resume for that row.
|
|
109
|
+
* ponytail: a text sniff of its upgrade.ts, like targetReadsWaivers and coordinatorCurrent; a rename reads as "cannot
|
|
110
|
+
* restart" (the safe side). Upgrade path: the capability list those two name.
|
|
111
|
+
*/
|
|
112
|
+
export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
|
|
113
|
+
if (!op.sourceRoot) return true;
|
|
114
|
+
try { return readFileSync(join(op.sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export function recordRestart"); } catch { return false; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
|
|
119
|
+
* the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
|
|
120
|
+
* (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
|
|
121
|
+
* revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
|
|
122
|
+
* The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
|
|
123
|
+
*/
|
|
124
|
+
export function recordRestart(progress: ProjectProgress, at: number): void {
|
|
125
|
+
(progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
|
|
126
|
+
for (const [key, value] of Object.entries(progress.terminals)) {
|
|
127
|
+
if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
|
|
128
|
+
const peer = key.slice("restored:".length);
|
|
129
|
+
progress.terminals[`retired:${peer}`] = value;
|
|
130
|
+
delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
|
|
131
|
+
delete progress.terminals[key];
|
|
132
|
+
}
|
|
133
|
+
delete progress.instanceId;
|
|
134
|
+
progress.phase = "stopped";
|
|
135
|
+
}
|
|
136
|
+
|
|
97
137
|
/** Effects anywhere in the operation: a project past `prepared` or any terminal receipt. Abort needs none. */
|
|
98
138
|
export const hasEffects = (op: RecoveryOperation) => op.projects.some((p) => !["pending", "prepared"].includes(p.phase) || Object.keys(p.terminals).length > 0);
|
|
99
139
|
/**
|
|
@@ -144,9 +184,9 @@ export function resumeBlocked(op: RecoveryOperation, live: Record<string, Inspec
|
|
|
144
184
|
if (state.state !== "stopped") continue;
|
|
145
185
|
if (p.phase === "pending") return `${p.id}: the source stopped before it was prepared`;
|
|
146
186
|
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;
|
|
187
|
+
// #225: a target that stopped after it started is restarted from this operation's snapshot (the line below), by a
|
|
188
|
+
// coordinator that can; an older one's runner refuses it.
|
|
189
|
+
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
190
|
if (state.snapshot !== op.id) return `${p.id}: its hub stopped without this operation's restart snapshot`;
|
|
151
191
|
}
|
|
152
192
|
return undefined;
|
|
@@ -231,7 +271,9 @@ export function registeredProjects(home = hubHome()): Project[] {
|
|
|
231
271
|
if (!existsSync(path)) return [];
|
|
232
272
|
const db = new Database(path, { readonly: true });
|
|
233
273
|
try {
|
|
234
|
-
|
|
274
|
+
// #226: a registry no current release opened yet has no signature column, and this read never migrates.
|
|
275
|
+
const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
|
|
276
|
+
return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
235
277
|
} finally { db.close(); }
|
|
236
278
|
}
|
|
237
279
|
|
|
@@ -256,8 +298,21 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
|
|
|
256
298
|
return op;
|
|
257
299
|
}
|
|
258
300
|
|
|
301
|
+
/**
|
|
302
|
+
* #227: a coordinator from before #215 never reads `disposition`, and an older global `ahub recovery resume` runs it.
|
|
303
|
+
* Every older validateReceipt refuses a schema other than 1 before the lock and the runner claim, so schema 2 keeps it
|
|
304
|
+
* from advancing an operation being abandoned. A #215 coordinator's runner refuses the disposition itself and keeps
|
|
305
|
+
* schema 1, so a 0.12.20 CLI can still rerun its dispose. True when the receipt changed (a disposition recorded at 1
|
|
306
|
+
* by 0.12.20 is moved to 2 by the next command of this release that holds the runner claim).
|
|
307
|
+
*/
|
|
308
|
+
function abandoned(op: RecoveryOperation): boolean {
|
|
309
|
+
if (!op.disposition || op.schema !== 1 || !op.sourceRoot || coordinatorCurrent(op.sourceRoot)) return false;
|
|
310
|
+
op.schema = 2;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
259
314
|
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
260
|
-
if (op.schema
|
|
315
|
+
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
261
316
|
const { fingerprint, ...reviewed } = op.plan;
|
|
262
317
|
if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
|
|
263
318
|
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 +357,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
302
357
|
} catch (error) { releaseRunner(); throw error; }
|
|
303
358
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
304
359
|
const step = (value: string) => { op.step = value; save(); };
|
|
360
|
+
// #225: one restart per project per resume, so a target that keeps dying never spins unattended.
|
|
361
|
+
const restarted = new Set<string>();
|
|
362
|
+
const stoppedAgain = (progress: ProjectProgress) => `the target stopped again after restart ${progress.restarts?.length ?? 0} in this resume`;
|
|
305
363
|
const identity = (observed: Inspection, planned: PlannedProject, progress: ProjectProgress) => {
|
|
306
|
-
if (observed.state === "stopped") throw new Error(progress.
|
|
364
|
+
if (observed.state === "stopped") throw new Error(restarted.has(progress.id) ? `${planned.project.id}: ${stoppedAgain(progress)}; read its hub.log before resuming again`
|
|
365
|
+
: 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
366
|
: `${planned.project.id}: the target is stopped${observed.snapshot === id ? "" : " and this operation's restart snapshot is gone, so nothing can start it"}`);
|
|
308
367
|
if (observed.state === "missing") throw new Error(`${planned.project.id}: the project directory is missing`);
|
|
309
368
|
if (observed.state !== "running") throw new Error(`${planned.project.id}: the target reads as ${observed.state}; wait until it answers`);
|
|
@@ -381,6 +440,15 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
381
440
|
}
|
|
382
441
|
for (let i = 0; i < op.projects.length; i++) {
|
|
383
442
|
const progress = op.projects[i]!, planned = op.plan.projects[i]!, project = planned.project;
|
|
443
|
+
if (progress.phase === "started" || progress.phase === "peers-restored") {
|
|
444
|
+
// #225: a target that stopped after it started, with this operation's snapshot, starts again from it.
|
|
445
|
+
const live = await driver.inspect(project);
|
|
446
|
+
if (live.state === "stopped") {
|
|
447
|
+
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`);
|
|
448
|
+
step(`restart:${project.id}`);
|
|
449
|
+
recordRestart(progress, driver.now()); restarted.add(progress.id); save();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
384
452
|
if (progress.phase === "verified" || progress.phase === "peers-restored") continue;
|
|
385
453
|
if (progress.phase === "pending") {
|
|
386
454
|
step(`prepare:${project.id}`);
|
|
@@ -486,7 +554,8 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
|
|
|
486
554
|
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
487
555
|
...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
|
|
488
556
|
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])) } : {})
|
|
557
|
+
...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
|
|
558
|
+
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
490
559
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
491
560
|
...(!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
561
|
...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
@@ -507,6 +576,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
507
576
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
508
577
|
return;
|
|
509
578
|
}
|
|
579
|
+
if (abandoned(op)) writeOperation(id, op, home);
|
|
510
580
|
const { fingerprint, ...body } = op.plan;
|
|
511
581
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
512
582
|
const live = await liveProjects(op, driver.inspect);
|
|
@@ -543,6 +613,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
543
613
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
544
614
|
throw new Error(`operation is already ${op.phase}`);
|
|
545
615
|
}
|
|
616
|
+
if (abandoned(op)) writeOperation(id, op, home);
|
|
546
617
|
const at = driver.now();
|
|
547
618
|
// Read once, as status and the runner do: every choice and refusal below is decided on it.
|
|
548
619
|
const live = await liveProjects(op, driver.inspect);
|
|
@@ -596,6 +667,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
596
667
|
}
|
|
597
668
|
// Each outcome is recorded as it happens, so a disposition that stops partway stays true and resume refuses it.
|
|
598
669
|
const disposition = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
|
|
670
|
+
abandoned(op); // #227: in the same write as the disposition
|
|
599
671
|
(op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
|
|
600
672
|
// Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
|
|
601
673
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|