@staix/agent-hub 0.12.19 → 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 +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/cli/upgrade.ts
CHANGED
|
@@ -2,9 +2,9 @@ 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
|
-
import { acquireRecoveryLock, claimRunner, readOperation, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
7
|
+
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
8
8
|
|
|
9
9
|
export interface RecoveryPeer {
|
|
10
10
|
id: string;
|
|
@@ -22,12 +22,18 @@ export interface Inspection {
|
|
|
22
22
|
peers: RecoveryPeer[];
|
|
23
23
|
recovery?: { operationId?: string; phase?: string; ready?: boolean };
|
|
24
24
|
blockers: string[];
|
|
25
|
+
/** #215: for a stopped runtime, the operation whose committed restart snapshot its state dir holds (unreleased). */
|
|
26
|
+
snapshot?: string;
|
|
25
27
|
}
|
|
26
28
|
export interface PlannedProject {
|
|
27
29
|
project: Project;
|
|
28
30
|
source: Inspection;
|
|
29
31
|
terminals: unknown[];
|
|
30
32
|
blockers: string[];
|
|
33
|
+
/** Unmanaged sessions (#206): their plugin reattaches to the new daemon; no terminal is closed or relaunched and no session id is compared. */
|
|
34
|
+
reconnectOnly?: string[];
|
|
35
|
+
/** #215: Codex threads with no rollout and no turn the hub recorded; they restart as a new session, nothing to lose. */
|
|
36
|
+
freshStart?: string[];
|
|
31
37
|
}
|
|
32
38
|
export interface UpgradePlan {
|
|
33
39
|
schema: 1;
|
|
@@ -46,9 +52,22 @@ export interface ProjectProgress {
|
|
|
46
52
|
instanceId?: string;
|
|
47
53
|
terminals: Record<string, unknown>;
|
|
48
54
|
releaseRequested?: boolean;
|
|
55
|
+
/** #215: peers the operator moved to a fresh session, with the session or thread id whose continuity is lost. */
|
|
56
|
+
fresh?: Record<string, { lost: string; reason: string; at: number }>;
|
|
57
|
+
/** #215: set before the commit request is sent; from then on the source may have committed (abort is not offered). */
|
|
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 }[];
|
|
49
64
|
}
|
|
50
65
|
export interface RecoveryOperation {
|
|
51
|
-
|
|
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;
|
|
52
71
|
id: string;
|
|
53
72
|
plan: UpgradePlan;
|
|
54
73
|
createdAt: number;
|
|
@@ -62,6 +81,188 @@ export interface RecoveryOperation {
|
|
|
62
81
|
pluginInstalled?: boolean;
|
|
63
82
|
globalInstalled?: boolean;
|
|
64
83
|
error?: string;
|
|
84
|
+
/** #215: every human disposition, in order. Operator reasons only; never task or message text. */
|
|
85
|
+
audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
|
|
86
|
+
disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string> };
|
|
87
|
+
/** #215: a refusal no resume can get past was recorded (FinalRefusal); `next` no longer offers resume. */
|
|
88
|
+
final?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** #215: a refusal that holds for every resume of the operation (its staged target or preserved source is fixed). */
|
|
92
|
+
export class FinalRefusal extends Error {}
|
|
93
|
+
|
|
94
|
+
export { recoveryCommand };
|
|
95
|
+
const STOP = "--stop-and-archive --reason <text>";
|
|
96
|
+
/**
|
|
97
|
+
* Whether the staged target reads recovery waivers: a fresh session cannot be released without them. The one waiver
|
|
98
|
+
* check (status, dispose, staging and restore all use it).
|
|
99
|
+
* ponytail: a text sniff of the target's restart.ts; a rename or re-export reads as "no waivers" (the safe side). Upgrade
|
|
100
|
+
* path: a capability list in package.json read here and by coordinatorCurrent.
|
|
101
|
+
*/
|
|
102
|
+
export function targetReadsWaivers(op: { targetRoot?: string }): boolean {
|
|
103
|
+
try { return !!op.targetRoot && readFileSync(join(op.targetRoot, "src/hub/restart.ts"), "utf8").includes("export function readRecoveryWaivers"); } catch { return false; }
|
|
104
|
+
}
|
|
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
|
+
|
|
137
|
+
/** Effects anywhere in the operation: a project past `prepared` or any terminal receipt. Abort needs none. */
|
|
138
|
+
export const hasEffects = (op: RecoveryOperation) => op.projects.some((p) => !["pending", "prepared"].includes(p.phase) || Object.keys(p.terminals).length > 0);
|
|
139
|
+
/**
|
|
140
|
+
* #215: why abort would refuse, or undefined when it can cancel. Abort and `next` both ask this, so `next` never
|
|
141
|
+
* offers an abort that refuses. `live` holds inspections of the operation's projects. A #215 coordinator writes
|
|
142
|
+
* `commitSent` before every commit request, so its receipt alone rules a commit in or out; an older one does not,
|
|
143
|
+
* and a prepared source of its operation that is not running (or was not inspected) may have committed.
|
|
144
|
+
*/
|
|
145
|
+
export function abortRefusal(op: RecoveryOperation, live: Record<string, Inspection | undefined> = {}): string | undefined {
|
|
146
|
+
if (op.disposition) return "a stop-and-archive of this operation is partway";
|
|
147
|
+
if (hasEffects(op)) return "operation has stopped runtimes or uncertain terminal effects";
|
|
148
|
+
const recorded = !op.sourceRoot || coordinatorCurrent(op.sourceRoot);
|
|
149
|
+
for (let i = 0; i < op.projects.length; i++) {
|
|
150
|
+
const progress = op.projects[i]!, planned = op.plan.projects[i]!, state = live[progress.id];
|
|
151
|
+
if (progress.commitSent) return `${progress.id}: its commit request may have been sent`;
|
|
152
|
+
if (state?.recovery?.operationId === op.id && state.recovery.phase !== "released" &&
|
|
153
|
+
(state.instanceId !== planned.source.instanceId || !["preparing", "prepared"].includes(state.recovery.phase ?? ""))) return `${progress.id}: recovery has progressed`;
|
|
154
|
+
// A #215 coordinator's prepared source that stopped was never asked to commit; one that cannot be read (unavailable,
|
|
155
|
+
// stopping, not inspected) may still hold this operation's hold. An older coordinator records no sent commit.
|
|
156
|
+
if (progress.phase === "prepared" && state?.state !== "running" && (!recorded || state?.state !== "stopped")) {
|
|
157
|
+
return recorded ? `${progress.id}: the source reads as ${state?.state ?? "not inspected"}, so whether this operation's hold still stands is uncertain`
|
|
158
|
+
: `${progress.id}: the source is ${state?.state ?? "not inspected"} and may have committed (this operation's coordinator does not record a sent commit)`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* #215: why resume can never get past what is live, or undefined. The runner's checks, mirrored (the invariant test in
|
|
166
|
+
* test/upgrade.test.ts holds the two together); `next` offers resume and --fresh-session only when this is undefined,
|
|
167
|
+
* and dispose accepts --fresh-session only then. Unavailable, starting or stopping is waited for.
|
|
168
|
+
*/
|
|
169
|
+
export function resumeBlocked(op: RecoveryOperation, live: Record<string, Inspection | undefined> = {}): string | undefined {
|
|
170
|
+
if (op.final) return "staging refused this operation's fixed target or preserved source";
|
|
171
|
+
const old = !!op.sourceRoot && !coordinatorCurrent(op.sourceRoot);
|
|
172
|
+
for (let i = 0; i < op.projects.length; i++) {
|
|
173
|
+
const p = op.projects[i]!, planned = op.plan.projects[i], state = live[p.id];
|
|
174
|
+
if (!state || !planned || p.phase === "verified") continue;
|
|
175
|
+
if (state.state === "missing") return `${p.id}: the project directory is missing`;
|
|
176
|
+
if (state.state === "incompatible") return `${p.id}: its hub speaks another control protocol`;
|
|
177
|
+
const source = p.phase === "pending" || p.phase === "prepared";
|
|
178
|
+
if (state.state === "running") {
|
|
179
|
+
const expected = source ? state.instanceId === planned.source.instanceId
|
|
180
|
+
: state.recovery?.operationId === op.id && (!p.instanceId || state.instanceId === p.instanceId) && state.version === op.plan.version;
|
|
181
|
+
if (!expected) return `${p.id}: its hub is not the one this operation expects`;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (state.state !== "stopped") continue;
|
|
185
|
+
if (p.phase === "pending") return `${p.id}: the source stopped before it was prepared`;
|
|
186
|
+
if (p.phase === "prepared" && !p.commitSent && !old) return `${p.id}: the source stopped while prepared, with no commit request`;
|
|
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`;
|
|
190
|
+
if (state.snapshot !== op.id) return `${p.id}: its hub stopped without this operation's restart snapshot`;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* #215: why stop-and-archive would refuse, or undefined when it can act. Dispose and `next` both ask this, so `next`
|
|
197
|
+
* never offers a stop-and-archive that refuses. Every unverified project must read as running, stopped or missing:
|
|
198
|
+
* any other state (unavailable, starting, stopping, incompatible, not inspected) hides whether this operation owns it.
|
|
199
|
+
*/
|
|
200
|
+
export function disposeRefusal(op: RecoveryOperation, live: Record<string, Inspection | undefined> = {}): { project: string; reads: string } | undefined {
|
|
201
|
+
const unsettled = op.projects.find((p) => p.phase !== "verified" && !["running", "stopped", "missing"].includes(live[p.id]?.state ?? ""));
|
|
202
|
+
return unsettled ? { project: unsettled.id, reads: live[unsettled.id]?.state ?? "not inspected" } : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* #215: what the person can do now, in the receipt table's order: resume (it also launches a failed peer again),
|
|
207
|
+
* abort where it can succeed, a fresh session for a failed Codex or Claude restoration, and stop-and-archive last.
|
|
208
|
+
* `status` and every error that names choices use this one list.
|
|
209
|
+
*/
|
|
210
|
+
export function nextActions(receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): string[] {
|
|
211
|
+
// A receipt read without its project lists (never written so by a coordinator) still gets the general choices.
|
|
212
|
+
const op: RecoveryOperation = receipt.projects ? receipt : { ...receipt, projects: [], plan: { ...receipt.plan, projects: receipt.plan?.projects ?? [] } };
|
|
213
|
+
if (op.phase === "completed" || op.phase === "cancelled") return [];
|
|
214
|
+
// claimRunner refuses an owner it cannot read, so resume, abort and dispose are all refused until the record reads.
|
|
215
|
+
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)`];
|
|
216
|
+
if (runner) return [`wait: runner ${runner} is working; ${recoveryCommand(op, "status")}`];
|
|
217
|
+
const unsettled = disposeRefusal(op, live);
|
|
218
|
+
const stop = unsettled ? `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` : recoveryCommand(op, "dispose", STOP);
|
|
219
|
+
if (op.disposition) return [unsettled ? stop : `rerun ${stop}`];
|
|
220
|
+
const failed = [...new Set(op.projects.flatMap((p) => failedPeers(op, p, live)))];
|
|
221
|
+
const old = !!op.sourceRoot && !coordinatorCurrent(op.sourceRoot);
|
|
222
|
+
const abortable = !abortRefusal(op, live);
|
|
223
|
+
const stuck = resumeBlocked(op, live);
|
|
224
|
+
return [
|
|
225
|
+
...(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)` : ""}`]),
|
|
226
|
+
...(abortable ? [recoveryCommand(op, "abort")] : []),
|
|
227
|
+
...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`)) : []),
|
|
228
|
+
stop,
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* "next actions: ..." from nextActions, with the inspections the caller holds. Every error that lists choices ends with
|
|
234
|
+
* it, after only its own peer-specific step ("end that codex session", "close Orca terminal <h>"), so no error offers
|
|
235
|
+
* an abort that abort refuses or leaves out one that would succeed.
|
|
236
|
+
*/
|
|
237
|
+
export const nextActionsText = (op: RecoveryOperation, live: Record<string, Inspection | undefined> = {}): string => `next actions: ${nextActions(op, undefined, live).join(" | ")}`;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* #215: what `next` reads, the same for status, abort and the runner's errors: every project not yet verified, its
|
|
241
|
+
* source or its target. An inspection that throws reads as not inspected, never as stopped.
|
|
242
|
+
*/
|
|
243
|
+
export async function liveProjects(op: RecoveryOperation, inspect: (project: Project) => Promise<Inspection>): Promise<Record<string, Inspection | undefined>> {
|
|
244
|
+
if (op.phase === "completed" || op.phase === "cancelled") return {};
|
|
245
|
+
const open = op.plan.projects.filter((_, i) => op.projects[i] && op.projects[i].phase !== "verified");
|
|
246
|
+
return Object.fromEntries(await Promise.all(open.map(async (p) => [p.project.id, await inspect(p.project).catch(() => undefined)] as const)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Peers of a project whose restoration failed and for which a fresh session is a real choice: not Pi (a restored hub
|
|
251
|
+
* resumes its recorded session), not one already chosen, and not a planned fresh start (nothing to lose; resume
|
|
252
|
+
* launches it new again).
|
|
253
|
+
*/
|
|
254
|
+
function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
|
|
255
|
+
const planned = op.plan.projects.find((p) => p.project.id === progress.id);
|
|
256
|
+
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && value === "failed").map(([key]) => key.slice("restored:".length))
|
|
257
|
+
.filter((peer) => peer !== "pi" && !progress.fresh?.[peer] && !planned?.freshStart?.includes(peer) && !plannedAttached(op, progress, peer, live));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** #215: the inspection shows the planned session attached for `peer`, so resume records it as restored: nothing is lost. */
|
|
261
|
+
function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer: string, live: Record<string, Inspection | undefined>): boolean {
|
|
262
|
+
const state = live[progress.id];
|
|
263
|
+
const binding = (op.plan.projects.find((p) => p.project.id === progress.id)?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
|
|
264
|
+
const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
|
|
265
|
+
return !!binding && (peer === "codex" ? current?.threadId : current?.sessionId) === binding.sessionId;
|
|
65
266
|
}
|
|
66
267
|
|
|
67
268
|
/** Registry reads for planning must not create a registry or run migrations. */
|
|
@@ -70,7 +271,9 @@ export function registeredProjects(home = hubHome()): Project[] {
|
|
|
70
271
|
if (!existsSync(path)) return [];
|
|
71
272
|
const db = new Database(path, { readonly: true });
|
|
72
273
|
try {
|
|
73
|
-
|
|
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);
|
|
74
277
|
} finally { db.close(); }
|
|
75
278
|
}
|
|
76
279
|
|
|
@@ -95,8 +298,21 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
|
|
|
95
298
|
return op;
|
|
96
299
|
}
|
|
97
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
|
+
|
|
98
314
|
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
99
|
-
if (op.schema
|
|
315
|
+
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
100
316
|
const { fingerprint, ...reviewed } = op.plan;
|
|
101
317
|
if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
|
|
102
318
|
op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
|
|
@@ -116,6 +332,8 @@ export interface RecoveryDriver {
|
|
|
116
332
|
refreshManager?(op: RecoveryOperation): Promise<void>;
|
|
117
333
|
release(project: Project, operation: string, instance: string): Promise<void>;
|
|
118
334
|
verify(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation): Promise<void>;
|
|
335
|
+
/** #215 stop-and-archive: stop this operation's unreleased target (when given) and archive its committed snapshot. */
|
|
336
|
+
stopAndArchive(project: Project, op: RecoveryOperation, targetInstance?: string): Promise<void>;
|
|
119
337
|
sleep(ms: number): Promise<void>;
|
|
120
338
|
now(): number;
|
|
121
339
|
}
|
|
@@ -139,25 +357,72 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
139
357
|
} catch (error) { releaseRunner(); throw error; }
|
|
140
358
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
141
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`;
|
|
142
363
|
const identity = (observed: Inspection, planned: PlannedProject, progress: ProjectProgress) => {
|
|
143
|
-
if (observed.state
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (observed.
|
|
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"}`
|
|
366
|
+
: `${planned.project.id}: the target is stopped${observed.snapshot === id ? "" : " and this operation's restart snapshot is gone, so nothing can start it"}`);
|
|
367
|
+
if (observed.state === "missing") throw new Error(`${planned.project.id}: the project directory is missing`);
|
|
368
|
+
if (observed.state !== "running") throw new Error(`${planned.project.id}: the target reads as ${observed.state}; wait until it answers`);
|
|
369
|
+
if (progress.instanceId && observed.instanceId !== progress.instanceId) throw new Error(`${planned.project.id}: daemon instance changed; refusing to act on its replacement`);
|
|
370
|
+
if (observed.recovery?.operationId !== id) throw new Error(`${planned.project.id}: daemon is not owned by this recovery operation`);
|
|
371
|
+
if (observed.version !== op.plan.version) throw new Error(`${planned.project.id}: target daemon version mismatch`);
|
|
147
372
|
};
|
|
148
|
-
const sourceRoster = (live: Inspection, planned: PlannedProject) => {
|
|
373
|
+
const sourceRoster = (live: Inspection, planned: PlannedProject, progress: ProjectProgress, again = false) => {
|
|
374
|
+
// #215: a peer whose terminal this operation already closed must stay detached after a re-preparation, and a
|
|
375
|
+
// planned peer that detached since may pass it, as the daemon's readiness lets it (#21): closeTerminals then
|
|
376
|
+
// records its close only once Orca no longer lists the terminal.
|
|
377
|
+
const closed = (peer: string) => progress.terminals[`closed:${peer}`] === true;
|
|
149
378
|
const expected = planned.source.peers.filter((p) => p.state !== "offline");
|
|
150
379
|
const active = live.peers.filter((p) => p.state !== "offline");
|
|
151
|
-
|
|
380
|
+
const extra = active.find((p) => !expected.some((peer) => peer.id === p.id));
|
|
381
|
+
const changed = extra ?? expected.find((peer) => {
|
|
152
382
|
const current = active.find((p) => p.id === peer.id);
|
|
153
|
-
|
|
154
|
-
|
|
383
|
+
if (closed(peer.id)) return !!current;
|
|
384
|
+
if (!current) return !again;
|
|
385
|
+
return !planned.reconnectOnly?.includes(peer.id) && (current.threadId !== peer.threadId || current.sessionId !== peer.sessionId);
|
|
386
|
+
});
|
|
387
|
+
if (!changed) return;
|
|
388
|
+
// A session that is not in the plan, or whose terminal this operation closed, has no original to restore.
|
|
389
|
+
const fix = extra || closed(changed.id) ? `end that ${changed.id} session` : `restore ${changed.id}'s original session`;
|
|
390
|
+
if (hasEffects(op)) throw new Error(`${planned.project.id}: ${changed.id} changed while this operation has recorded effects, so a new plan cannot replace it; ${fix} before resuming`);
|
|
391
|
+
// The lock this operation holds refuses a new upgrade until the operation is cancelled or ended.
|
|
392
|
+
throw new Error(`${planned.project.id}: source conversation or active peer membership changed (${changed.id}); ${fix} before resuming, or end this operation: a new plan can be made once it is cancelled or ended`);
|
|
393
|
+
};
|
|
394
|
+
// Prepare, or after an expired lease re-prepare, the planned source and wait until it is quiet.
|
|
395
|
+
const prepareSource = async (planned: PlannedProject, progress: ProjectProgress, again = false) => {
|
|
396
|
+
const project = planned.project, instance = planned.source.instanceId!;
|
|
397
|
+
await driver.prepare(project, id, instance);
|
|
398
|
+
const deadline = driver.now() + idleTimeoutMs;
|
|
399
|
+
for (;;) {
|
|
400
|
+
const live = await driver.inspect(project);
|
|
401
|
+
if (live.instanceId !== instance) throw new Error(`${project.id}: source daemon changed during preparation`);
|
|
402
|
+
if (live.recovery?.operationId !== id) {
|
|
403
|
+
const other = live.recovery?.phase !== "released" ? live.recovery?.operationId : undefined;
|
|
404
|
+
throw new Error(other ? `${project.id}: the source is held by another recovery operation ${other}; wait until that operation ends or its hold lapses`
|
|
405
|
+
: `${project.id}: the preparation expired before the source was quiet (resume prepares it again)`);
|
|
406
|
+
}
|
|
407
|
+
if (live.recovery.ready) return sourceRoster(live, planned, progress, again);
|
|
408
|
+
if (driver.now() >= deadline) {
|
|
409
|
+
await driver.abort(project, id, instance);
|
|
410
|
+
throw new Error(`${project.id}: active turns, approvals or completion checks did not finish; source runtime left running`);
|
|
411
|
+
}
|
|
412
|
+
await driver.sleep(250);
|
|
413
|
+
}
|
|
155
414
|
};
|
|
156
415
|
try {
|
|
416
|
+
if (op.disposition) {
|
|
417
|
+
// The stop-and-archive in progress keeps its own cause; resume never runs an operation being abandoned.
|
|
418
|
+
op.error ??= `a stop-and-archive of this operation is partway; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
419
|
+
op.phase = "blocked"; save();
|
|
420
|
+
return op;
|
|
421
|
+
}
|
|
157
422
|
op.phase = "running"; delete op.error; save();
|
|
158
423
|
step("stage");
|
|
159
424
|
const target = await driver.stage(op);
|
|
160
|
-
if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new
|
|
425
|
+
if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
|
|
161
426
|
op.targetRoot = target.root; op.targetDigest = target.digest; save();
|
|
162
427
|
|
|
163
428
|
// Check every untouched source before stopping even the first project.
|
|
@@ -165,47 +430,67 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
165
430
|
const progress = op.projects[i]!, planned = op.plan.projects[i]!;
|
|
166
431
|
if (progress.phase !== "pending") continue;
|
|
167
432
|
const live = await driver.inspect(planned.project);
|
|
433
|
+
if (!["running", "stopped", "missing"].includes(live.state)) throw new Error(`${planned.project.id}: the source reads as ${live.state}; wait until it answers`);
|
|
168
434
|
if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
|
|
169
|
-
throw new Error(`${planned.project.id}: source runtime changed
|
|
435
|
+
throw new Error(`${planned.project.id}: source runtime changed`);
|
|
170
436
|
}
|
|
171
|
-
|
|
437
|
+
const other = live.recovery?.phase !== "released" ? live.recovery?.operationId : undefined;
|
|
438
|
+
if (other && other !== id) throw new Error(`${planned.project.id}: the source is held by another recovery operation ${other}; wait until that operation ends or its hold lapses`);
|
|
439
|
+
sourceRoster(live, planned, progress);
|
|
172
440
|
}
|
|
173
441
|
for (let i = 0; i < op.projects.length; i++) {
|
|
174
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
|
+
}
|
|
175
452
|
if (progress.phase === "verified" || progress.phase === "peers-restored") continue;
|
|
176
453
|
if (progress.phase === "pending") {
|
|
177
454
|
step(`prepare:${project.id}`);
|
|
178
|
-
await
|
|
179
|
-
const deadline = driver.now() + idleTimeoutMs;
|
|
180
|
-
for (;;) {
|
|
181
|
-
const live = await driver.inspect(project);
|
|
182
|
-
if (live.instanceId !== planned.source.instanceId) throw new Error("source daemon changed during preparation");
|
|
183
|
-
if (live.recovery?.operationId !== id) throw new Error("preparation expired or belongs to another operation");
|
|
184
|
-
if (live.recovery.ready) { sourceRoster(live, planned); break; }
|
|
185
|
-
if (driver.now() >= deadline) {
|
|
186
|
-
await driver.abort(project, id, planned.source.instanceId!);
|
|
187
|
-
throw new Error(`${project.id}: active turns, approvals or completion checks did not finish; source runtime left running`);
|
|
188
|
-
}
|
|
189
|
-
await driver.sleep(250);
|
|
190
|
-
}
|
|
455
|
+
await prepareSource(planned, progress);
|
|
191
456
|
progress.phase = "prepared"; save();
|
|
192
457
|
}
|
|
193
458
|
if (progress.phase === "prepared") {
|
|
194
459
|
step(`commit:${project.id}`);
|
|
195
460
|
let live = await driver.inspect(project);
|
|
196
461
|
if (live.state === "running" && live.instanceId === planned.source.instanceId) {
|
|
197
|
-
if (live.recovery?.operationId !== id || !live.recovery.ready)
|
|
462
|
+
if (live.recovery?.operationId !== id || !live.recovery.ready) {
|
|
463
|
+
const other = live.recovery?.operationId;
|
|
464
|
+
if (other && other !== id && live.recovery?.phase !== "released") throw new Error(`${project.id}: the source is held by another recovery operation ${other}; nothing was prepared, closed or stopped, and ending this one leaves that hold alone; wait until that operation ends or its hold lapses`);
|
|
465
|
+
// #215: the hold lapsed (an expired lease) after this operation may have recorded terminal effects.
|
|
466
|
+
// Re-prepare the same verified source; the receipts stay, so no terminal is closed twice.
|
|
467
|
+
step(`reprepare:${project.id}`);
|
|
468
|
+
await prepareSource(planned, progress, true);
|
|
469
|
+
step(`commit:${project.id}`);
|
|
470
|
+
} else {
|
|
471
|
+
// #215: a hold of ours can predate a roster change, as after a resume whose roster check failed: check again.
|
|
472
|
+
sourceRoster(live, planned, progress, true);
|
|
473
|
+
}
|
|
198
474
|
await driver.closeTerminals(planned, progress, op, save);
|
|
475
|
+
// Durable, unlike `step`: from here the source may have committed, which status and abort must not forget.
|
|
476
|
+
progress.commitSent = true; save();
|
|
199
477
|
await driver.commit(project, id, planned.source.instanceId!);
|
|
200
478
|
const deadline = driver.now() + 30_000;
|
|
201
479
|
do {
|
|
202
480
|
live = await driver.inspect(project);
|
|
203
481
|
if (live.state === "stopped") break;
|
|
204
|
-
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(
|
|
482
|
+
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
205
483
|
await driver.sleep(100);
|
|
206
484
|
} while (driver.now() < deadline);
|
|
485
|
+
} else if (live.state === "running") {
|
|
486
|
+
throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
|
|
487
|
+
} else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
|
|
488
|
+
// #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
|
|
489
|
+
// prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
|
|
490
|
+
throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
|
|
207
491
|
}
|
|
208
|
-
if (live.state
|
|
492
|
+
if (live.state === "missing") throw new Error(`${project.id}: the project directory is missing`);
|
|
493
|
+
if (live.state !== "stopped") throw new Error(`${project.id}: old shutdown is not verified (the source reads as ${live.state}); not starting a second daemon; check ${recoveryCommand(op, "status")} once it answers`);
|
|
209
494
|
progress.phase = "stopped"; save();
|
|
210
495
|
}
|
|
211
496
|
if (progress.phase === "stopped") {
|
|
@@ -253,14 +538,27 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
253
538
|
}
|
|
254
539
|
releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
|
|
255
540
|
} catch (error) {
|
|
256
|
-
|
|
541
|
+
// #215: every runner error ends with the choices status shows, read from every open source, never only this one.
|
|
542
|
+
if (error instanceof FinalRefusal) op.final = true;
|
|
543
|
+
const next = nextActionsText(op, await liveProjects(op, driver.inspect));
|
|
544
|
+
op.phase = "blocked"; op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
|
|
257
545
|
} finally { releaseRunner(); }
|
|
258
546
|
return op;
|
|
259
547
|
}
|
|
260
548
|
|
|
261
|
-
|
|
549
|
+
/** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
|
|
550
|
+
export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
|
|
551
|
+
const effect = (value: unknown) => value === "pending" || value === "failed" ? value : "done";
|
|
552
|
+
const next = nextActions(op, runnerPid, live);
|
|
262
553
|
return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
|
|
263
|
-
|
|
554
|
+
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
555
|
+
...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
|
|
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)])) } : {}),
|
|
557
|
+
...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
|
|
558
|
+
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
559
|
+
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
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` } : {}),
|
|
561
|
+
...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
264
562
|
}
|
|
265
563
|
|
|
266
564
|
/** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
|
|
@@ -278,21 +576,120 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
278
576
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
279
577
|
return;
|
|
280
578
|
}
|
|
281
|
-
if (op
|
|
282
|
-
throw new Error("operation has stopped runtimes or uncertain terminal effects; resume it instead");
|
|
283
|
-
}
|
|
579
|
+
if (abandoned(op)) writeOperation(id, op, home);
|
|
284
580
|
const { fingerprint, ...body } = op.plan;
|
|
285
581
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
582
|
+
const live = await liveProjects(op, driver.inspect);
|
|
583
|
+
const refusal = abortRefusal(op, live);
|
|
584
|
+
if (refusal) throw new Error(`abort refused: ${refusal}; ${nextActionsText(op, live)}`);
|
|
585
|
+
// Only this operation's own uncommitted holds are aborted; a lapsed hold, a replacement daemon or another
|
|
586
|
+
// operation's hold is left alone, and a source that is not running had no commit sent (abortRefusal).
|
|
286
587
|
for (const planned of op.plan.projects) {
|
|
287
|
-
const
|
|
288
|
-
if (
|
|
289
|
-
if (live.instanceId !== planned.source.instanceId || !["preparing", "prepared"].includes(live.recovery.phase ?? "")) throw new Error("recovery has progressed; resume it instead");
|
|
290
|
-
await driver.abort(planned.project, id, planned.source.instanceId!);
|
|
291
|
-
} else if (op.projects.find((p) => p.id === planned.project.id)?.phase === "prepared") {
|
|
292
|
-
throw new Error("prepared source outcome is uncertain; resume it instead");
|
|
293
|
-
}
|
|
588
|
+
const state = live[planned.project.id];
|
|
589
|
+
if (state?.recovery?.operationId === id && state.recovery.phase !== "released") await driver.abort(planned.project, id, planned.source.instanceId!);
|
|
294
590
|
}
|
|
295
591
|
op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
|
|
296
592
|
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
297
593
|
} finally { releaseRunner(); }
|
|
298
594
|
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* #215: the human-only ways out of an operation that cannot restore a native session. `fresh` records the lost
|
|
598
|
+
* conversation and leaves the operation to resume; `stop-and-archive` abandons it, and releases the lock only after
|
|
599
|
+
* every runtime this operation held or started is verified released or stopped.
|
|
600
|
+
*/
|
|
601
|
+
export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
|
|
602
|
+
let op = readOperation<RecoveryOperation>(id, home);
|
|
603
|
+
validateReceipt(id, op);
|
|
604
|
+
// A finished operation owns no lock: never take one only to refuse.
|
|
605
|
+
if (op.phase === "completed" || op.phase === "cancelled") throw new Error(`operation is already ${op.phase}`);
|
|
606
|
+
acquireRecoveryLock(id, home);
|
|
607
|
+
const releaseRunner = claimRunner(id, home);
|
|
608
|
+
try {
|
|
609
|
+
op = readOperation<RecoveryOperation>(id, home);
|
|
610
|
+
validateReceipt(id, op);
|
|
611
|
+
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
612
|
+
// Another runner finished it while this one waited: leave no lock behind, as resume and abort do.
|
|
613
|
+
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
614
|
+
throw new Error(`operation is already ${op.phase}`);
|
|
615
|
+
}
|
|
616
|
+
if (abandoned(op)) writeOperation(id, op, home);
|
|
617
|
+
const at = driver.now();
|
|
618
|
+
// Read once, as status and the runner do: every choice and refusal below is decided on it.
|
|
619
|
+
const live = await liveProjects(op, driver.inspect);
|
|
620
|
+
const next = nextActionsText(op, live);
|
|
621
|
+
if ("fresh" in choice) {
|
|
622
|
+
// `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
|
|
623
|
+
if (choice.fresh === "pi") throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${next}`);
|
|
624
|
+
if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
|
|
625
|
+
if (!targetReadsWaivers(op)) throw new Error(`--fresh-session is not available: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session; ${next}`);
|
|
626
|
+
// The new session is launched by resume, so it helps only where resume can get past what is live.
|
|
627
|
+
const stuck = resumeBlocked(op, live);
|
|
628
|
+
if (stuck) throw new Error(`--fresh-session cannot help: ${stuck}, so resume could never launch the new session; ${next}`);
|
|
629
|
+
const peer = choice.fresh;
|
|
630
|
+
// Per project: a planned fresh start has nothing to lose, so only the other failed projects take the choice.
|
|
631
|
+
const failed = op.projects.filter((p) => failedPeers(op, p, live).includes(peer));
|
|
632
|
+
if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
|
|
633
|
+
throw new Error(`${peer}: its plan already restarts it as a new session (no rollout and no turn, nothing to lose), so there is no conversation to record as lost; ${next}`);
|
|
634
|
+
}
|
|
635
|
+
if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && plannedAttached(op, p, peer, live))) {
|
|
636
|
+
throw new Error(`${peer}: its planned session is attached again, so nothing was lost; resume records it as restored; ${next}`);
|
|
637
|
+
}
|
|
638
|
+
if (!failed.length) throw new Error(`${peer}: no failed restoration of it is open to a fresh session (none failed, or one is already chosen); --fresh-session applies only then (${recoveryCommand(op, "status")})`);
|
|
639
|
+
for (const progress of failed) {
|
|
640
|
+
const binding = (op.plan.projects.find((p) => p.project.id === progress.id)!.terminals as { peer: string; sessionId: string }[]).find((t) => t.peer === peer)!;
|
|
641
|
+
(progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at };
|
|
642
|
+
}
|
|
643
|
+
(op.audit ??= []).push({ at, action: "fresh-session", reason, peer, projects: failed.map((p) => p.id) });
|
|
644
|
+
op.updatedAt = at; writeOperation(id, op, home);
|
|
645
|
+
return op;
|
|
646
|
+
}
|
|
647
|
+
// Every project is inspected before acting on any, as the runner does before its first stop.
|
|
648
|
+
const unsettled = disposeRefusal(op, live);
|
|
649
|
+
if (unsettled) throw new Error(`${unsettled.project}: its hub reads as ${unsettled.reads}, so whether this operation owns it cannot be verified; nothing was stopped or released; ${next}`);
|
|
650
|
+
const acts: { planned: PlannedProject; live?: Inspection; act: string }[] = [];
|
|
651
|
+
for (let i = 0; i < op.projects.length; i++) {
|
|
652
|
+
const progress = op.projects[i]!, planned = op.plan.projects[i]!;
|
|
653
|
+
const state = progress.phase === "verified" ? undefined : live[planned.project.id]!;
|
|
654
|
+
const ours = state?.state === "running" && state.recovery?.operationId === id && state.recovery.phase !== "released";
|
|
655
|
+
// A target is known by its operation fence, not only by the receipt: `up` may have failed, or the first
|
|
656
|
+
// identity read thrown, after the target started and before its instance was recorded.
|
|
657
|
+
const target = ours && state.instanceId !== planned.source.instanceId && state.recovery?.phase === "restored";
|
|
658
|
+
const act = !state ? "released earlier; left running"
|
|
659
|
+
: state.state === "stopped" ? "stopped"
|
|
660
|
+
: state.state === "missing" ? "project directory is missing; nothing was stopped or archived (ahub doctor --orphans lists a hub left running there)"
|
|
661
|
+
: ours && state.instanceId === planned.source.instanceId && ["pending", "prepared"].includes(progress.phase) ? "source hold released; source left running"
|
|
662
|
+
: target ? "target stopped"
|
|
663
|
+
: state.instanceId === planned.source.instanceId && ["pending", "prepared"].includes(progress.phase)
|
|
664
|
+
? `source left running (${state.recovery?.operationId && state.recovery.phase !== "released" ? `held by another operation, ${state.recovery.operationId}` : progress.phase === "prepared" ? "its hold had lapsed" : "never prepared"})`
|
|
665
|
+
: `left running: instance ${state.instanceId ?? "unknown"} is not held by this operation`;
|
|
666
|
+
acts.push({ planned, ...(state ? { live: state } : {}), act });
|
|
667
|
+
}
|
|
668
|
+
// Each outcome is recorded as it happens, so a disposition that stops partway stays true and resume refuses it.
|
|
669
|
+
const disposition = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
|
|
670
|
+
abandoned(op); // #227: in the same write as the disposition
|
|
671
|
+
(op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
|
|
672
|
+
// Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
|
|
673
|
+
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
674
|
+
try {
|
|
675
|
+
for (const { planned, live, act } of acts) {
|
|
676
|
+
if (act.startsWith("source hold")) await driver.abort(planned.project, id, planned.source.instanceId!);
|
|
677
|
+
else if (act === "stopped" || act === "target stopped") {
|
|
678
|
+
await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
|
|
679
|
+
if ((await driver.inspect(planned.project)).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
|
|
680
|
+
}
|
|
681
|
+
disposition.projects[planned.project.id] ??= act;
|
|
682
|
+
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
683
|
+
}
|
|
684
|
+
} catch (error) {
|
|
685
|
+
op.phase = "blocked"; op.updatedAt = driver.now();
|
|
686
|
+
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
687
|
+
writeOperation(id, op, home);
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
delete op.error;
|
|
691
|
+
op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
|
|
692
|
+
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
693
|
+
return op;
|
|
694
|
+
} finally { releaseRunner(); }
|
|
695
|
+
}
|
package/src/hub/ask.ts
CHANGED
|
@@ -87,7 +87,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
87
87
|
const tasks = d.board.list().sort((a, b) => hit(b.title) - hit(a.title) || b.updated - a.updated).slice(0, MAX_TASKS);
|
|
88
88
|
// An ordinary task's last note is model-written (a summary, a review note): one that matches a PII pattern is shown
|
|
89
89
|
// only on campus, like a PII task (#69).
|
|
90
|
-
const notePii = (t: (typeof tasks)[number]) => !d.isPii(t) && !!t.history.at(-1)?.note && (d.isPiiText?.(t.history.at(-1)!.note!) ?? false);
|
|
90
|
+
const notePii = (t: (typeof tasks)[number]) => !d.isPii(t) && !!t.history.at(-1)?.note && (!!t.history.at(-1)!.withheld || (d.isPiiText?.(t.history.at(-1)!.note!) ?? false));
|
|
91
91
|
const anyPii = tasks.some((t) => d.isPii(t) || notePii(t));
|
|
92
92
|
const showPii = anyPii ? await onCampus() : false;
|
|
93
93
|
let piiShown = false;
|