@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/hub/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { canonicalPath, hubHome, projectRoot } from "./project.ts";
|
|
6
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
6
7
|
|
|
7
8
|
const BASE_PORT = 4600;
|
|
8
9
|
const STRIDE = 10;
|
|
@@ -19,22 +20,27 @@ export interface Project {
|
|
|
19
20
|
basePort: number;
|
|
20
21
|
instanceId: string | null;
|
|
21
22
|
pid: number | null;
|
|
23
|
+
/** #226: the claimant's processSignature; absent for a claim written by 0.12.20 or older (judged by its pid alone). */
|
|
24
|
+
pidSignature?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* #226: `pid_signature` holds "<instance id> <signature>". An older daemon's claim rewrites instance_id and pid but not
|
|
29
|
+
* this column, so a signature counts only for the instance that wrote it: under another instance the claim is unsigned.
|
|
30
|
+
*/
|
|
31
|
+
export const PROJECT_COLUMNS = "id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid";
|
|
32
|
+
export function projectFromRow(raw: Record<string, unknown>): Project {
|
|
33
|
+
const { pid_signature: signed, ...row } = raw;
|
|
34
|
+
const project = { ...row } as unknown as Project;
|
|
35
|
+
const prefix = `${project.instanceId} `;
|
|
36
|
+
if (project.instanceId && typeof signed === "string" && signed.startsWith(prefix) && signed.length > prefix.length) project.pidSignature = signed.slice(prefix.length);
|
|
37
|
+
return project;
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
const idFor = (root: string) => `p_${createHash("sha256").update(root).digest("hex").slice(0, 24)}`;
|
|
25
41
|
// Preserve the original identifier of missing legacy checkouts as a reservation.
|
|
26
42
|
const legacyRoot = (path: string) => { try { return realpathSync(path); } catch { return resolve(path); } };
|
|
27
43
|
const canonicalState = canonicalPath;
|
|
28
|
-
const alive = (pid: number): boolean | undefined => {
|
|
29
|
-
try {
|
|
30
|
-
process.kill(pid, 0);
|
|
31
|
-
return true;
|
|
32
|
-
} catch (error) {
|
|
33
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
34
|
-
if (code === "ESRCH") return false;
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
44
|
|
|
39
45
|
export class Registry {
|
|
40
46
|
readonly path: string;
|
|
@@ -56,6 +62,11 @@ export class Registry {
|
|
|
56
62
|
base_port INTEGER NOT NULL UNIQUE, instance_id TEXT, pid INTEGER, claimed_at INTEGER
|
|
57
63
|
)`);
|
|
58
64
|
this.db.run("CREATE TABLE IF NOT EXISTS registry_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
65
|
+
const columns = this.db.query("PRAGMA table_info(projects)").all() as { name: string }[];
|
|
66
|
+
if (!columns.some((c) => c.name === "pid_signature")) {
|
|
67
|
+
try { this.db.run("ALTER TABLE projects ADD COLUMN pid_signature TEXT"); }
|
|
68
|
+
catch (error) { if (!/duplicate column/i.test((error as Error).message)) throw error; } // a concurrent open added it
|
|
69
|
+
}
|
|
59
70
|
this.importLegacy();
|
|
60
71
|
}
|
|
61
72
|
|
|
@@ -70,7 +81,7 @@ export class Registry {
|
|
|
70
81
|
if (existing) {
|
|
71
82
|
if (existing.root !== canonicalRoot) throw new Error(`registry id collision for ${canonicalRoot}`);
|
|
72
83
|
if (explicitState && existing.stateDir !== state) {
|
|
73
|
-
if (existing.instanceId && (existing.pid === null ||
|
|
84
|
+
if (existing.instanceId && (existing.pid === null || processLiveness(existing.pid, existing.pidSignature) !== "gone")) throw new Error(`cannot relocate live or uncertain project ${id}`);
|
|
74
85
|
this.db.query("UPDATE projects SET state_dir = ? WHERE id = ?").run(state, id);
|
|
75
86
|
}
|
|
76
87
|
return;
|
|
@@ -91,7 +102,7 @@ export class Registry {
|
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
list(): Project[] {
|
|
94
|
-
return this.db.query(
|
|
105
|
+
return (this.db.query(`SELECT ${PROJECT_COLUMNS}, pid_signature FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
allocate(root: string): number {
|
|
@@ -110,17 +121,18 @@ export class Registry {
|
|
|
110
121
|
return base;
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
/** `signature`: the claimant's processSignature (#226); without one the claim is judged by its pid alone. */
|
|
125
|
+
claim(id: string, instanceId: string, pid: number, signature?: string): boolean {
|
|
114
126
|
let claimed = false;
|
|
115
127
|
const tx = this.db.transaction(() => {
|
|
116
128
|
const project = this.row(id);
|
|
117
129
|
if (!project) return;
|
|
118
130
|
if (project.instanceId && project.instanceId !== instanceId) {
|
|
119
131
|
if (project.pid === null) return;
|
|
120
|
-
|
|
121
|
-
if (state !== false) return;
|
|
132
|
+
if (processLiveness(project.pid, project.pidSignature) !== "gone") return;
|
|
122
133
|
}
|
|
123
|
-
this.db.query("UPDATE projects SET instance_id = ?, pid = ?, claimed_at = ? WHERE id = ?")
|
|
134
|
+
this.db.query("UPDATE projects SET instance_id = ?, pid = ?, pid_signature = ?, claimed_at = ? WHERE id = ?")
|
|
135
|
+
.run(instanceId, pid, signature ? `${instanceId} ${signature}` : null, Date.now(), id);
|
|
124
136
|
claimed = true;
|
|
125
137
|
});
|
|
126
138
|
runTx(tx as Tx);
|
|
@@ -128,7 +140,7 @@ export class Registry {
|
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
release(id: string, instanceId: string): void {
|
|
131
|
-
this.db.query("UPDATE projects SET instance_id = NULL, pid = NULL, claimed_at = NULL WHERE id = ? AND instance_id = ?").run(id, instanceId);
|
|
143
|
+
this.db.query("UPDATE projects SET instance_id = NULL, pid = NULL, pid_signature = NULL, claimed_at = NULL WHERE id = ? AND instance_id = ?").run(id, instanceId);
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
remove(id: string): void {
|
|
@@ -136,16 +148,17 @@ export class Registry {
|
|
|
136
148
|
const project = this.row(id);
|
|
137
149
|
if (!project) return;
|
|
138
150
|
if (project.instanceId) {
|
|
139
|
-
if (project.pid === null ||
|
|
151
|
+
if (project.pid === null || processLiveness(project.pid, project.pidSignature) !== "gone") throw new Error(`project ${id} has a live or uncertain claim`);
|
|
140
152
|
} else if (existsSync(join(project.stateDir, "status.json")) || existsSync(join(project.stateDir, "control-token"))) {
|
|
141
|
-
let pid: number | undefined;
|
|
153
|
+
let pid: number | undefined, signature: string | undefined;
|
|
142
154
|
try {
|
|
143
|
-
const status = JSON.parse(readFileSync(join(project.stateDir, "status.json"), "utf8")) as { pid?: number };
|
|
155
|
+
const status = JSON.parse(readFileSync(join(project.stateDir, "status.json"), "utf8")) as { pid?: number; pidSignature?: unknown };
|
|
144
156
|
pid = typeof status.pid === "number" ? status.pid : undefined;
|
|
157
|
+
signature = typeof status.pidSignature === "string" ? status.pidSignature : undefined;
|
|
145
158
|
} catch {
|
|
146
159
|
// A state directory without a readable owner marker is uncertain.
|
|
147
160
|
}
|
|
148
|
-
if (pid === undefined ||
|
|
161
|
+
if (pid === undefined || processLiveness(pid, signature) !== "gone") throw new Error(`project ${id} has a live or uncertain legacy state`);
|
|
149
162
|
}
|
|
150
163
|
this.db.query("DELETE FROM projects WHERE id = ?").run(id);
|
|
151
164
|
});
|
|
@@ -157,7 +170,8 @@ export class Registry {
|
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
private row(id: string): Project | null {
|
|
160
|
-
|
|
173
|
+
const raw = this.db.query(`SELECT ${PROJECT_COLUMNS}, pid_signature FROM projects WHERE id = ?`).get(id) as Record<string, unknown> | null;
|
|
174
|
+
return raw ? projectFromRow(raw) : null;
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
private nextBase(after = BASE_PORT - STRIDE): number {
|
package/src/hub/report.ts
CHANGED
|
@@ -13,6 +13,21 @@ export interface Report {
|
|
|
13
13
|
quota: { readings: number; hard: number };
|
|
14
14
|
conduct: Record<string, Record<string, number>>;
|
|
15
15
|
supervision: Record<string, { turns: number | null; tokens: number | null; measuredTokens: number | null; knownTokenTurns: number; unknownTokenTurns: number }>;
|
|
16
|
+
/** Per "peer route": model changes between consecutive decisions of a session, and the planner's verdicts (#197). */
|
|
17
|
+
routes: Record<string, RouteSwitches>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RouteSwitches {
|
|
21
|
+
decisions: number;
|
|
22
|
+
/** Pins the planner started; 0 while stay_switch is off, when session boundaries are not recorded. */
|
|
23
|
+
sessions: number;
|
|
24
|
+
/** Decisions made without a session key: each looks like a new session, so sessions are unknown. */
|
|
25
|
+
stateless: number;
|
|
26
|
+
switches: number;
|
|
27
|
+
toolLoopSwitches: number;
|
|
28
|
+
planned: number;
|
|
29
|
+
plannedInToolLoops: number;
|
|
30
|
+
modes: string[];
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export interface UsageCoverage {
|
|
@@ -85,7 +100,7 @@ function formatUsage(usage: Report["usage"]): string[] {
|
|
|
85
100
|
|
|
86
101
|
/** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
|
|
87
102
|
export function summarize(events: StampedEvent[]): Report {
|
|
88
|
-
const r: Report = { peers: {}, conduct: {}, supervision: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
103
|
+
const r: Report = { peers: {}, conduct: {}, supervision: {}, routes: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
89
104
|
const supervisor = (id: string) => (r.supervision[id] ??= { turns: null, tokens: null, measuredTokens: null, knownTokenTurns: 0, unknownTokenTurns: 0 });
|
|
90
105
|
const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
|
|
91
106
|
const claudeNativeStops = events.some(event => event.type === "native_turn_end" && event.peer === "claude" && !!event.id);
|
|
@@ -95,6 +110,7 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
95
110
|
const taskMessages = new Map<string, number>();
|
|
96
111
|
const seenUsage = new Set<string>();
|
|
97
112
|
const seenSupervision = new Set<string>();
|
|
113
|
+
const lastTier = new Map<string, string>();
|
|
98
114
|
for (const e of events) {
|
|
99
115
|
r.from ??= e.at;
|
|
100
116
|
r.to = e.at;
|
|
@@ -168,6 +184,26 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
168
184
|
r.quota.readings++;
|
|
169
185
|
if (e.hard) r.quota.hard++;
|
|
170
186
|
break;
|
|
187
|
+
case "route": {
|
|
188
|
+
// ponytail: consecutive decisions of one peer and route are taken as one session between pins; that holds while each
|
|
189
|
+
// peer runs one session at a time (Pi, and local outside PII turns). An opaque session ordinal is the upgrade path.
|
|
190
|
+
const key = `${e.peer} ${e.route}`;
|
|
191
|
+
const s = (r.routes[key] ??= { decisions: 0, sessions: 0, stateless: 0, switches: 0, toolLoopSwitches: 0, planned: 0, plannedInToolLoops: 0, modes: [] });
|
|
192
|
+
s.decisions++;
|
|
193
|
+
if (e.staySwitch && !s.modes.includes(e.staySwitch)) s.modes.push(e.staySwitch);
|
|
194
|
+
if (e.stateless) s.stateless++;
|
|
195
|
+
if (e.reason === "new_pin" && !e.stateless) s.sessions++;
|
|
196
|
+
else if (lastTier.has(key) && lastTier.get(key) !== e.tier) {
|
|
197
|
+
s.switches++;
|
|
198
|
+
if (e.turnType === "tool_result") s.toolLoopSwitches++;
|
|
199
|
+
}
|
|
200
|
+
lastTier.set(key, e.tier);
|
|
201
|
+
if (e.plan === "switch") {
|
|
202
|
+
s.planned++;
|
|
203
|
+
if (e.turnType === "tool_result") s.plannedInToolLoops++;
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
171
207
|
}
|
|
172
208
|
}
|
|
173
209
|
r.overlaps.pairs = pairs.size;
|
|
@@ -205,6 +241,12 @@ export function formatReport(r: Report): string[] {
|
|
|
205
241
|
const tasks = Object.entries(r.tasks).sort().map(([k, n]) => `${k} ${n}`).join(", ");
|
|
206
242
|
lines.push(`task events: ${tasks || "none"}`);
|
|
207
243
|
lines.push(`quota readings: ${r.quota.readings} (${r.quota.hard} hard limits)`);
|
|
244
|
+
for (const [key, s] of Object.entries(r.routes).sort(([a], [b]) => a.localeCompare(b))) {
|
|
245
|
+
const sessions = s.stateless ? `sessions unknown (${s.stateless} decision${s.stateless === 1 ? "" : "s"} without a session key)`
|
|
246
|
+
: s.sessions ? `${s.sessions} session${s.sessions === 1 ? "" : "s"}, ${(s.switches / s.sessions).toFixed(1)} model changes per session` : "sessions unknown (stay_switch off)";
|
|
247
|
+
const planner = s.modes.length ? `; planner (${s.modes.join(", ")}): ${s.planned} switches, ${s.plannedInToolLoops} inside tool loops` : "";
|
|
248
|
+
lines.push(`route ${key}: ${s.decisions} decisions, ${sessions}; ${s.switches} model change${s.switches === 1 ? "" : "s"}, ${s.toolLoopSwitches} inside tool loops${planner}`);
|
|
249
|
+
}
|
|
208
250
|
return lines;
|
|
209
251
|
}
|
|
210
252
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { StampedEvent } from "./events.ts";
|
|
5
|
+
import { hubHome } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
|
|
9
|
+
* live writer and `ahub research backfill` produce the same records. Never a title, detail, plan, note, path, message
|
|
10
|
+
* body or check output: the events carry none of those.
|
|
11
|
+
*/
|
|
12
|
+
export const RESEARCH_SCHEMA = "agent-hub.research/v1";
|
|
13
|
+
export const LABELS = ["ok", "regressed", "reverted", "incomplete", "wrong", "abandoned"] as const;
|
|
14
|
+
export type Label = (typeof LABELS)[number];
|
|
15
|
+
/** A person's later verdict that turns an approved task into a failure. */
|
|
16
|
+
const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplete", "wrong", "abandoned"]);
|
|
17
|
+
|
|
18
|
+
export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
|
|
19
|
+
export interface TaskRecord {
|
|
20
|
+
schema: typeof RESEARCH_SCHEMA; kind: "task"; project: string; task: number;
|
|
21
|
+
class: string | null; pii: boolean; outcome: "approved";
|
|
22
|
+
/** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
|
|
23
|
+
createdAt: string | null; startedAt: string | null; approvedAt: string;
|
|
24
|
+
/** From the first `in_progress` to the approval; null when the start is not in the events. */
|
|
25
|
+
wallMs: number | null;
|
|
26
|
+
/** Sum of the attributed turns' durations, the turns still open at the approval included. */
|
|
27
|
+
activeMs: number;
|
|
28
|
+
/** Owners in the order they held the task. */
|
|
29
|
+
owners: string[]; reviewer: string | null;
|
|
30
|
+
/** Times the task went to review, was sent back, passed or failed its check, was reported done. */
|
|
31
|
+
reviewRounds: number; changesRequested: number; checkPassed: number; checkFailed: number; dones: number;
|
|
32
|
+
/** Owner changes after the first owner, and the same counted by the board's move reason. */
|
|
33
|
+
reassignments: number; reassignedBy: Record<string, number>;
|
|
34
|
+
/** Approved on its first review with no failed check. */
|
|
35
|
+
firstPass: boolean;
|
|
36
|
+
stuck: number; overlaps: number; conflicts: number;
|
|
37
|
+
tests: { pass: number; fail: number };
|
|
38
|
+
/** Attributed tokens per peer and per #200 attribution rule; unattributed tokens are in no record. */
|
|
39
|
+
tokens: { total: number; byPeer: Record<string, number>; byAttribution: Record<string, number> };
|
|
40
|
+
usage: Record<string, PeerUsage>;
|
|
41
|
+
/** Provider-reported total tokens per served (else requested) model. */
|
|
42
|
+
usageByModel: Record<string, number>;
|
|
43
|
+
turns: number; filesChanged: number;
|
|
44
|
+
/** Model routes and served models seen for the task's attributed requests. */
|
|
45
|
+
models: string[];
|
|
46
|
+
writer: { version: string; source: "live" | "backfill" };
|
|
47
|
+
}
|
|
48
|
+
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
|
|
49
|
+
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
|
|
50
|
+
export type ResearchRecord = TaskRecord | LabelRecord;
|
|
51
|
+
|
|
52
|
+
/** The store name for a project: a hash of its id, never its path. */
|
|
53
|
+
export const projectKey = (projectId: string): string => createHash("sha256").update(projectId).digest("hex").slice(0, 16);
|
|
54
|
+
export const researchDir = (home = hubHome()): string => join(home, "research");
|
|
55
|
+
export const researchFile = (projectId: string, home = hubHome()): string => join(researchDir(home), `${projectKey(projectId)}.jsonl`);
|
|
56
|
+
/** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
|
|
57
|
+
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
|
|
58
|
+
|
|
59
|
+
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models"> & {
|
|
60
|
+
models: Set<string>; approvedAt: string | null; lastState: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
|
|
63
|
+
const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
|
|
64
|
+
const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
|
|
65
|
+
reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
|
|
66
|
+
tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
|
|
67
|
+
approvedAt: null, lastState: null });
|
|
68
|
+
const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
|
|
69
|
+
const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One record per approved task in `events`. Approval is final on the board, so everything attributed to the task
|
|
73
|
+
* counts, also after the approval: the turn that approved it ends later, and its usage arrives after that. The live
|
|
74
|
+
* writer therefore waits for those turns (daemon `recordResearch`); backfill reads to the end of the file.
|
|
75
|
+
*/
|
|
76
|
+
export function taskRecords(events: StampedEvent[], projectId: string, writer: TaskRecord["writer"]): TaskRecord[] {
|
|
77
|
+
const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
|
|
78
|
+
const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
|
|
79
|
+
const seenUsage = new Set<string>();
|
|
80
|
+
for (const e of events) {
|
|
81
|
+
if (e.type === "task") {
|
|
82
|
+
// Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
|
|
83
|
+
if (e.event === "proposed" && tasks.get(e.id)?.createdAt) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
|
|
84
|
+
const a = acc(e.id);
|
|
85
|
+
a.class = e.class; if (e.pii) a.pii = true;
|
|
86
|
+
if (e.owner && e.owner !== a.owners.at(-1)) {
|
|
87
|
+
if (a.owners.length) { a.reassignments++; bump(a.reassignedBy, e.reason ?? "none"); }
|
|
88
|
+
a.owners.push(e.owner);
|
|
89
|
+
}
|
|
90
|
+
if (e.reviewer) a.reviewer = e.reviewer;
|
|
91
|
+
if (e.event === "proposed") a.createdAt ??= e.at;
|
|
92
|
+
if (e.state === "in_progress") a.startedAt ??= e.at;
|
|
93
|
+
if (e.event === "done") a.dones++;
|
|
94
|
+
if (e.event === "check passed") a.checkPassed++;
|
|
95
|
+
if (e.event === "check failed") a.checkFailed++;
|
|
96
|
+
if (e.state === "in_review" && a.lastState !== "in_review") a.reviewRounds++;
|
|
97
|
+
if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
|
|
98
|
+
if (e.state === "approved") a.approvedAt ??= e.at;
|
|
99
|
+
a.lastState = e.state;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
|
|
103
|
+
if (task === undefined) continue;
|
|
104
|
+
const a = acc(task);
|
|
105
|
+
if ("pii" in e && e.pii === true) a.pii = true;
|
|
106
|
+
if (e.type === "tokens") { a.tokens.total += e.n; bump(a.tokens.byPeer, e.peer, e.n); bump(a.tokens.byAttribution, e.attribution ?? "unknown", e.n); }
|
|
107
|
+
else if (e.type === "turn_end") { a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0; }
|
|
108
|
+
else if (e.type === "usage") {
|
|
109
|
+
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
110
|
+
if (seenUsage.has(key)) continue;
|
|
111
|
+
seenUsage.add(key);
|
|
112
|
+
const u = a.usage[e.peer] ??= { input: null, output: null, cacheRead: null, total: null };
|
|
113
|
+
u.input = add(u.input, e.inputTokens); u.output = add(u.output, e.outputTokens); u.cacheRead = add(u.cacheRead, e.cacheReadTokens); u.total = add(u.total, e.totalTokens);
|
|
114
|
+
const model = e.servedModel ?? e.requestedModel;
|
|
115
|
+
if (model) a.models.add(String(model));
|
|
116
|
+
if (typeof e.totalTokens === "number" && Number.isFinite(e.totalTokens)) bump(a.usageByModel, String(model ?? "unknown"), e.totalTokens);
|
|
117
|
+
}
|
|
118
|
+
else if (e.type === "route") a.models.add(e.route);
|
|
119
|
+
else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
|
|
120
|
+
else if (e.type === "stuck") a.stuck++;
|
|
121
|
+
else if (e.type === "overlap") a.overlaps++;
|
|
122
|
+
else if (e.type === "conflict") a.conflicts++;
|
|
123
|
+
}
|
|
124
|
+
const out: TaskRecord[] = [];
|
|
125
|
+
for (const a of [...earlier, ...tasks.values()]) {
|
|
126
|
+
if (!a.approvedAt) continue;
|
|
127
|
+
const { approvedAt, lastState: _state, models, ...rest } = a;
|
|
128
|
+
const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(approvedAt);
|
|
129
|
+
out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome: "approved", approvedAt,
|
|
130
|
+
wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
|
|
131
|
+
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer });
|
|
132
|
+
}
|
|
133
|
+
return out.sort((x, y) => x.approvedAt.localeCompare(y.approvedAt) || x.task - y.task);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every record in one store file; a line that does not parse is skipped. */
|
|
137
|
+
export function readStore(file: string): ResearchRecord[] {
|
|
138
|
+
if (!existsSync(file)) return [];
|
|
139
|
+
return readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
140
|
+
if (!line.trim()) return [];
|
|
141
|
+
try { const r = JSON.parse(line); return r?.schema === RESEARCH_SCHEMA ? [r as ResearchRecord] : []; } catch { return []; }
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** This project's store, or every project's with `all`. */
|
|
145
|
+
export function readStores(projectId: string | undefined, home = hubHome()): ResearchRecord[] {
|
|
146
|
+
if (projectId) return readStore(researchFile(projectId, home));
|
|
147
|
+
const dir = researchDir(home);
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).sort().flatMap((name) => readStore(join(dir, name)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Append task records the store does not hold yet (same task, by `recordKey`), and labels; returns how many were written. */
|
|
153
|
+
export function appendRecords(projectId: string, records: ResearchRecord[], home = hubHome()): number {
|
|
154
|
+
if (!records.length) return 0;
|
|
155
|
+
const file = researchFile(projectId, home);
|
|
156
|
+
mkdirSync(researchDir(home), { recursive: true, mode: 0o700 });
|
|
157
|
+
chmodSync(researchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
|
|
158
|
+
const held = new Set(readStore(file).flatMap((r) => r.kind === "task" ? [recordKey(r)] : []));
|
|
159
|
+
const added = records.filter((r) => r.kind === "label" || !held.has(recordKey(r)));
|
|
160
|
+
if (!added.length) return 0;
|
|
161
|
+
appendFileSync(file, added.map((r) => JSON.stringify(r)).join("\n") + "\n", { mode: 0o600 });
|
|
162
|
+
chmodSync(file, 0o600);
|
|
163
|
+
return added.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The record a person's label is for. `created` (a record's `createdAt`, from `ahub research export`) names one exactly.
|
|
168
|
+
* Otherwise the current board's task with that id, found by its proposal in `events`, so a label never lands on an
|
|
169
|
+
* earlier task with the same id; an id this hub's events do not have (a task from before `ahub reset --all`) is the
|
|
170
|
+
* store's only record with that id. Throws why there is none, or which ones to choose from.
|
|
171
|
+
*/
|
|
172
|
+
export function labelTarget(records: ResearchRecord[], events: StampedEvent[], task: number, created?: string): TaskRecord {
|
|
173
|
+
// A record without a proposal time (its proposal was not in the events) cannot be told apart, so it takes no label.
|
|
174
|
+
const mine = records.filter((r): r is TaskRecord => r.kind === "task" && r.task === task && r.createdAt !== null);
|
|
175
|
+
if (created !== undefined) {
|
|
176
|
+
return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
|
|
177
|
+
}
|
|
178
|
+
const proposed = events.filter((e) => e.type === "task" && e.id === task && e.event === "proposed").at(-1);
|
|
179
|
+
if (proposed) {
|
|
180
|
+
return mine.find((r) => r.createdAt === proposed.at)
|
|
181
|
+
?? fail(`task #${task} has no research record yet: it is not approved, its record still waits for the turns that approved it, or it was approved while research was off (ahub research backfill builds that one)${mine.length ? `; an earlier task #${task} from before a reset is labelled with --created ${mine.map((r) => r.createdAt).join(" or ")}` : ""}`);
|
|
182
|
+
}
|
|
183
|
+
if (mine.length === 1) return mine[0]!;
|
|
184
|
+
if (!mine.length) throw new Error(records.some((r) => r.kind === "task" && r.task === task) ? `task #${task}'s research record has no proposal time (its proposal was not in the events), so it takes no label` : `no research record for task #${task}`);
|
|
185
|
+
throw new Error(`task #${task} is not on this hub's board and the store has ${mine.length} records with that id; name one with --created <time>: ${mine.map((r) => r.createdAt).join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
const fail = (message: string): never => { throw new Error(message); };
|
|
188
|
+
|
|
189
|
+
const pct = (n: number, d: number) => d ? n / d : null;
|
|
190
|
+
/** Linear interpolation between the closest ranks: the median of [100, 300] is 200. */
|
|
191
|
+
function quantile(values: number[], q: number): number | null {
|
|
192
|
+
if (!values.length) return null;
|
|
193
|
+
const sorted = [...values].sort((a, b) => a - b), at = q * (sorted.length - 1), low = Math.floor(at);
|
|
194
|
+
return sorted[low]! + (sorted[Math.min(low + 1, sorted.length - 1)]! - sorted[low]!) * (at - low);
|
|
195
|
+
}
|
|
196
|
+
export interface Measures {
|
|
197
|
+
tasks: number; succeeded: number; failedByLabel: number;
|
|
198
|
+
successRate: number | null; firstPassRate: number | null; reworkRate: number | null; checkFailureRate: number | null;
|
|
199
|
+
tokensPerTask: { median: number | null; p90: number | null }; wallMsPerTask: { median: number | null; p90: number | null };
|
|
200
|
+
}
|
|
201
|
+
function measures(records: TaskRecord[], labels: Map<string, Label>): Measures {
|
|
202
|
+
const failed = records.filter((r) => FAILED.has(labels.get(recordKey(r)) ?? "")).length;
|
|
203
|
+
return { tasks: records.length, succeeded: records.length - failed, failedByLabel: failed,
|
|
204
|
+
successRate: pct(records.length - failed, records.length), firstPassRate: pct(records.filter((r) => r.firstPass).length, records.length),
|
|
205
|
+
reworkRate: pct(records.reduce((n, r) => n + r.changesRequested, 0), records.length),
|
|
206
|
+
checkFailureRate: pct(records.reduce((n, r) => n + r.checkFailed, 0), records.reduce((n, r) => n + r.checkFailed + r.checkPassed, 0)),
|
|
207
|
+
tokensPerTask: { median: quantile(records.map((r) => r.tokens.total), 0.5), p90: quantile(records.map((r) => r.tokens.total), 0.9) },
|
|
208
|
+
wallMsPerTask: { median: quantile(records.flatMap((r) => r.wallMs ?? []), 0.5), p90: quantile(records.flatMap((r) => r.wallMs ?? []), 0.9) } };
|
|
209
|
+
}
|
|
210
|
+
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures> }
|
|
211
|
+
/**
|
|
212
|
+
* The derived measures, never stored as truth: each task counts once (its last record), and a person's latest label
|
|
213
|
+
* decides whether an approved task later failed. Records older than `since` (ms epoch, by approval) are left out.
|
|
214
|
+
*/
|
|
215
|
+
export function researchReport(records: ResearchRecord[], since = 0): ResearchReport {
|
|
216
|
+
const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
|
|
217
|
+
for (const r of records) {
|
|
218
|
+
const key = recordKey(r);
|
|
219
|
+
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
220
|
+
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
221
|
+
}
|
|
222
|
+
const all = [...latest.values()];
|
|
223
|
+
const group = (keys: (r: TaskRecord) => string[]) => {
|
|
224
|
+
// A Map: a peer or model may be named `constructor`, which a plain object answers with an inherited value.
|
|
225
|
+
const out = new Map<string, TaskRecord[]>();
|
|
226
|
+
for (const r of all) for (const k of keys(r)) out.set(k, [...(out.get(k) ?? []), r]);
|
|
227
|
+
return Object.fromEntries([...out].sort(([a], [b]) => a.localeCompare(b)).map(([k, rs]) => [k, measures(rs, labels)]));
|
|
228
|
+
};
|
|
229
|
+
return { overall: measures(all, labels), byClass: group((r) => [r.class ?? "unknown"]), byOwner: group((r) => r.owners.length ? [...new Set(r.owners)] : ["none"]),
|
|
230
|
+
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"]) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fmtPct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
|
|
234
|
+
const fmtNum = (v: number | null) => v === null ? "-" : String(Math.round(v));
|
|
235
|
+
export function formatResearch(r: ResearchReport): string[] {
|
|
236
|
+
const row = (name: string, m: Measures) => `${name.padEnd(24)} ${String(m.tasks).padStart(5)} ${fmtPct(m.successRate).padStart(7)} ${fmtPct(m.firstPassRate).padStart(10)} ${(m.reworkRate === null ? "-" : m.reworkRate.toFixed(2)).padStart(6)} ${fmtPct(m.checkFailureRate).padStart(11)} ${fmtNum(m.tokensPerTask.median).padStart(10)} ${fmtNum(m.tokensPerTask.p90).padStart(10)} ${(m.wallMsPerTask.median === null ? "-" : `${Math.round(m.wallMsPerTask.median / 60_000)}m`).padStart(8)}`;
|
|
237
|
+
const head = `${"".padEnd(24)} ${"tasks".padStart(5)} ${"success".padStart(7)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"check fails".padStart(11)} ${"tokens p50".padStart(10)} ${"tokens p90".padStart(10)} ${"wall p50".padStart(8)}`;
|
|
238
|
+
const section = (title: string, groups: Record<string, Measures>) => Object.keys(groups).length ? [``, title, head, ...Object.entries(groups).map(([k, m]) => row(k, m))] : [];
|
|
239
|
+
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject)];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
|
|
243
|
+
"reviewer", "reviewRounds", "changesRequested", "checkPassed", "checkFailed", "dones", "reassignments", "firstPass", "stuck", "overlaps", "conflicts",
|
|
244
|
+
"testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "writerVersion", "writerSource"] as const;
|
|
245
|
+
/** Task records as CSV with a header row, one row per record, each with the task's latest label; JSONL has every field. */
|
|
246
|
+
export function toCsv(records: ResearchRecord[]): string {
|
|
247
|
+
const labels = new Map<string, Label>();
|
|
248
|
+
for (const r of records) if (r.kind === "label") labels.set(recordKey(r), r.label);
|
|
249
|
+
const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
250
|
+
const rows = records.filter((r): r is TaskRecord => r.kind === "task").map((r) => [r.project, r.task, r.class, r.pii, r.outcome, r.createdAt, r.startedAt,
|
|
251
|
+
r.approvedAt, r.wallMs, r.activeMs, r.owners.join(" "), r.reviewer, r.reviewRounds, r.changesRequested, r.checkPassed, r.checkFailed, r.dones,
|
|
252
|
+
r.reassignments, r.firstPass, r.stuck, r.overlaps, r.conflicts, r.tests.pass, r.tests.fail, r.tokens.total, r.turns, r.filesChanged, r.models.join(" "),
|
|
253
|
+
labels.get(recordKey(r)) ?? "", r.writer.version, r.writer.source].map(cell).join(","));
|
|
254
|
+
return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
|
|
255
|
+
}
|
package/src/hub/restart.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { BusSnapshot } from "./bus.ts";
|
|
|
5
5
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
6
6
|
import { MAX_HOP } from "./envelope.ts";
|
|
7
7
|
import { validateJournalSnapshot } from "./delivery-journal.ts";
|
|
8
|
+
import { atomicPrivateJSON } from "./recovery-store.ts";
|
|
8
9
|
|
|
9
10
|
export const RESTART_SCHEMA_VERSION = 1;
|
|
10
11
|
|
|
@@ -96,6 +97,30 @@ export function removeRestartSnapshot(stateDir: string): void {
|
|
|
96
97
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Peers whose saved native session id the restored daemon does not require back, each with the coordinator's
|
|
102
|
+
* reason (#206 reconnect-only, #215 fresh session). Fenced by operation: a file left by another operation waives nothing.
|
|
103
|
+
*/
|
|
104
|
+
export function readRecoveryWaivers(stateDir: string, operationId: string): Record<string, string> {
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(readFileSync(join(stateDir, "recovery-waivers.json"), "utf8"));
|
|
107
|
+
if (value?.operationId !== operationId || !value.peers || typeof value.peers !== "object") return {};
|
|
108
|
+
return Object.fromEntries(Object.entries(value.peers).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].length > 0));
|
|
109
|
+
} catch { return {}; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function waiveRecoveryPeers(stateDir: string, operationId: string, peers: Record<string, string>): void {
|
|
113
|
+
atomicPrivateJSON(join(stateDir, "recovery-waivers.json"), { operationId, peers: { ...readRecoveryWaivers(stateDir, operationId), ...peers } });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** #215 stop-and-archive: keep an abandoned operation's committed snapshot for manual reconciliation; it is never replayed. */
|
|
117
|
+
export function abandonRestartSnapshot(stateDir: string, operationId: string): void {
|
|
118
|
+
try { if (JSON.parse(readFileSync(restartPath(stateDir), "utf8"))?.operationId !== operationId) return; } catch { return; }
|
|
119
|
+
const archived = join(stateDir, `restart.abandoned.${createHash("sha256").update(operationId).digest("hex")}.json`);
|
|
120
|
+
renameSync(restartPath(stateDir), archived);
|
|
121
|
+
chmodSync(archived, 0o600);
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
/** Preserve an uncertain release outcome for manual reconciliation without making it authoritative on startup. */
|
|
100
125
|
export function archiveRestartSnapshot(stateDir: string, operationId: string): void {
|
|
101
126
|
renameSync(restartPath(stateDir), releasedRestartPath(stateDir, operationId));
|