@lcv-ideas-software/cross-review 4.0.0
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 +2568 -0
- package/LICENSE +201 -0
- package/NOTICE +26 -0
- package/README.md +208 -0
- package/SECURITY.md +52 -0
- package/dist/scripts/api-streaming-smoke.d.ts +1 -0
- package/dist/scripts/api-streaming-smoke.js +78 -0
- package/dist/scripts/api-streaming-smoke.js.map +1 -0
- package/dist/scripts/runtime-default-smoke.d.ts +1 -0
- package/dist/scripts/runtime-default-smoke.js +88 -0
- package/dist/scripts/runtime-default-smoke.js.map +1 -0
- package/dist/scripts/runtime-smoke.d.ts +1 -0
- package/dist/scripts/runtime-smoke.js +148 -0
- package/dist/scripts/runtime-smoke.js.map +1 -0
- package/dist/scripts/smoke.d.ts +1 -0
- package/dist/scripts/smoke.js +6156 -0
- package/dist/scripts/smoke.js.map +1 -0
- package/dist/src/core/cache-manifest.d.ts +22 -0
- package/dist/src/core/cache-manifest.js +133 -0
- package/dist/src/core/cache-manifest.js.map +1 -0
- package/dist/src/core/caller-tokens.d.ts +32 -0
- package/dist/src/core/caller-tokens.js +240 -0
- package/dist/src/core/caller-tokens.js.map +1 -0
- package/dist/src/core/config.d.ts +9 -0
- package/dist/src/core/config.js +643 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/convergence.d.ts +5 -0
- package/dist/src/core/convergence.js +186 -0
- package/dist/src/core/convergence.js.map +1 -0
- package/dist/src/core/cost.d.ts +59 -0
- package/dist/src/core/cost.js +359 -0
- package/dist/src/core/cost.js.map +1 -0
- package/dist/src/core/file-config.d.ts +316 -0
- package/dist/src/core/file-config.js +490 -0
- package/dist/src/core/file-config.js.map +1 -0
- package/dist/src/core/orchestrator.d.ts +199 -0
- package/dist/src/core/orchestrator.js +3430 -0
- package/dist/src/core/orchestrator.js.map +1 -0
- package/dist/src/core/prompt-parts.d.ts +58 -0
- package/dist/src/core/prompt-parts.js +122 -0
- package/dist/src/core/prompt-parts.js.map +1 -0
- package/dist/src/core/relator-lottery.d.ts +23 -0
- package/dist/src/core/relator-lottery.js +112 -0
- package/dist/src/core/relator-lottery.js.map +1 -0
- package/dist/src/core/reports.d.ts +2 -0
- package/dist/src/core/reports.js +82 -0
- package/dist/src/core/reports.js.map +1 -0
- package/dist/src/core/session-store.d.ts +149 -0
- package/dist/src/core/session-store.js +1923 -0
- package/dist/src/core/session-store.js.map +1 -0
- package/dist/src/core/status.d.ts +61 -0
- package/dist/src/core/status.js +249 -0
- package/dist/src/core/status.js.map +1 -0
- package/dist/src/core/timeouts.d.ts +2 -0
- package/dist/src/core/timeouts.js +3 -0
- package/dist/src/core/timeouts.js.map +1 -0
- package/dist/src/core/types.d.ts +604 -0
- package/dist/src/core/types.js +36 -0
- package/dist/src/core/types.js.map +1 -0
- package/dist/src/dashboard/server.d.ts +2 -0
- package/dist/src/dashboard/server.js +339 -0
- package/dist/src/dashboard/server.js.map +1 -0
- package/dist/src/mcp/server.d.ts +54 -0
- package/dist/src/mcp/server.js +1584 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/observability/logger.d.ts +9 -0
- package/dist/src/observability/logger.js +24 -0
- package/dist/src/observability/logger.js.map +1 -0
- package/dist/src/peers/anthropic.d.ts +14 -0
- package/dist/src/peers/anthropic.js +290 -0
- package/dist/src/peers/anthropic.js.map +1 -0
- package/dist/src/peers/base.d.ts +72 -0
- package/dist/src/peers/base.js +416 -0
- package/dist/src/peers/base.js.map +1 -0
- package/dist/src/peers/deepseek.d.ts +12 -0
- package/dist/src/peers/deepseek.js +246 -0
- package/dist/src/peers/deepseek.js.map +1 -0
- package/dist/src/peers/errors.d.ts +2 -0
- package/dist/src/peers/errors.js +185 -0
- package/dist/src/peers/errors.js.map +1 -0
- package/dist/src/peers/gemini.d.ts +13 -0
- package/dist/src/peers/gemini.js +215 -0
- package/dist/src/peers/gemini.js.map +1 -0
- package/dist/src/peers/grok.d.ts +17 -0
- package/dist/src/peers/grok.js +346 -0
- package/dist/src/peers/grok.js.map +1 -0
- package/dist/src/peers/model-selection.d.ts +4 -0
- package/dist/src/peers/model-selection.js +260 -0
- package/dist/src/peers/model-selection.js.map +1 -0
- package/dist/src/peers/openai.d.ts +14 -0
- package/dist/src/peers/openai.js +299 -0
- package/dist/src/peers/openai.js.map +1 -0
- package/dist/src/peers/perplexity.d.ts +18 -0
- package/dist/src/peers/perplexity.js +375 -0
- package/dist/src/peers/perplexity.js.map +1 -0
- package/dist/src/peers/registry.d.ts +3 -0
- package/dist/src/peers/registry.js +77 -0
- package/dist/src/peers/registry.js.map +1 -0
- package/dist/src/peers/retry.d.ts +2 -0
- package/dist/src/peers/retry.js +36 -0
- package/dist/src/peers/retry.js.map +1 -0
- package/dist/src/peers/stub.d.ts +13 -0
- package/dist/src/peers/stub.js +344 -0
- package/dist/src/peers/stub.js.map +1 -0
- package/dist/src/peers/text.d.ts +18 -0
- package/dist/src/peers/text.js +39 -0
- package/dist/src/peers/text.js.map +1 -0
- package/dist/src/security/redact.d.ts +2 -0
- package/dist/src/security/redact.js +128 -0
- package/dist/src/security/redact.js.map +1 -0
- package/docs/api-keys.md +34 -0
- package/docs/architecture.md +118 -0
- package/docs/caching.md +135 -0
- package/docs/costs.md +40 -0
- package/docs/evidence-preflight.md +88 -0
- package/docs/github-security-baseline.md +32 -0
- package/docs/model-selection.md +105 -0
- package/docs/reports/cross-review-v2-api-capability-smoke-2026-04-30.md +354 -0
- package/docs/reports/cross-review-v2-format-recovery-findings-2026-04-28.md +223 -0
- package/docs/reports/cross-review-v2-official-provider-docs-refresh-2026-05-05.md +60 -0
- package/docs/reports/cross-review-v2-token-streaming-smoke-2026-04-30.md +119 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1923 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { PEERS } from "./types.js";
|
|
5
|
+
import { mergeCost, mergeUsage } from "./cost.js";
|
|
6
|
+
import { redact } from "../security/redact.js";
|
|
7
|
+
export const SWEEP_MIN_IDLE_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
function now() {
|
|
9
|
+
return new Date().toISOString();
|
|
10
|
+
}
|
|
11
|
+
// v2.4.0 / audit closure (P1.3): atomicWriteFile retry on Windows.
|
|
12
|
+
// `fs.renameSync` in Win32 fails with EPERM/EACCES/EBUSY when the
|
|
13
|
+
// destination is briefly held by another handle (AV scan, indexing,
|
|
14
|
+
// concurrent reader). Pre-v2.4.0 the rename threw and left the .tmp
|
|
15
|
+
// orphaned in the session directory. Now we (a) try rename, (b) on
|
|
16
|
+
// transient EPERM/EACCES/EBUSY/EEXIST retry up to 5 times with short
|
|
17
|
+
// backoff, (c) on terminal failure clean up the tmp file ourselves so
|
|
18
|
+
// the session directory does not accumulate `*.tmp` artifacts, (d)
|
|
19
|
+
// re-throw the last error so the caller still observes the failure.
|
|
20
|
+
// Mirrors the v1.6.7 P1.2 fix.
|
|
21
|
+
const ATOMIC_WRITE_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY", "EEXIST"]);
|
|
22
|
+
const ATOMIC_WRITE_MAX_ATTEMPTS = 5;
|
|
23
|
+
const TMP_NONCE_BYTES = 2;
|
|
24
|
+
function writeJson(file, data) {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
const nonce = crypto.randomBytes(TMP_NONCE_BYTES).toString("hex");
|
|
27
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${nonce}.tmp`;
|
|
28
|
+
fs.writeFileSync(tmp, redact(`${JSON.stringify(data, null, 2)}\n`), "utf8");
|
|
29
|
+
let lastErr = null;
|
|
30
|
+
for (let attempt = 0; attempt < ATOMIC_WRITE_MAX_ATTEMPTS; attempt += 1) {
|
|
31
|
+
try {
|
|
32
|
+
fs.renameSync(tmp, file);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
lastErr = err;
|
|
37
|
+
const code = err.code;
|
|
38
|
+
if (!code || !ATOMIC_WRITE_RETRY_CODES.has(code))
|
|
39
|
+
break;
|
|
40
|
+
const wait = 10 * 2 ** attempt; // 10, 20, 40, 80, 160 ms
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
while (Date.now() - start < wait) {
|
|
43
|
+
/* spin — sync write path, brief by design */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Terminal failure path: best-effort tmp cleanup so callers don't see
|
|
48
|
+
// the orphan accumulate even when the write itself failed.
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(tmp);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
throw lastErr;
|
|
56
|
+
}
|
|
57
|
+
// v2.4.0 / audit closure (P1.3 companion): boot sweep of orphan .tmp files.
|
|
58
|
+
// Crashes inside writeJson (between writeFileSync and renameSync) leave
|
|
59
|
+
// files matching `<basename>.<pid>.<ts>.<nonce>.tmp` in the session
|
|
60
|
+
// directory. They are never read but should not accumulate. Walk every
|
|
61
|
+
// session dir at boot, drop files matching the .tmp pattern whose holder
|
|
62
|
+
// pid is dead OR whose timestamp is older than 1h. Idempotent +
|
|
63
|
+
// best-effort.
|
|
64
|
+
const TMP_FILE_PATTERN = /\.(\d+)\.(\d+)\.[0-9a-f]+\.tmp$/;
|
|
65
|
+
const TMP_STALE_AFTER_MS = 60 * 60 * 1000; // 1h
|
|
66
|
+
function readJson(file) {
|
|
67
|
+
// v2.4.0 / audit closure: contextualize JSON.parse failures so callers see
|
|
68
|
+
// which file is malformed rather than a bare SyntaxError. Read errors
|
|
69
|
+
// still propagate naturally (ENOENT, EACCES) so caller can branch.
|
|
70
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(raw);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
throw new Error(`failed to parse JSON at ${file}: ${message}`, { cause: err });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function safeFilePart(value) {
|
|
80
|
+
const normalized = value
|
|
81
|
+
.normalize("NFKD")
|
|
82
|
+
.replace(/[^\w.-]+/g, "-")
|
|
83
|
+
.replace(/^-+|-+$/g, "")
|
|
84
|
+
.slice(0, 80);
|
|
85
|
+
return normalized || "evidence";
|
|
86
|
+
}
|
|
87
|
+
function timestampFilePart() {
|
|
88
|
+
return now().replace(/[:.]/g, "-");
|
|
89
|
+
}
|
|
90
|
+
export class SessionStore {
|
|
91
|
+
config;
|
|
92
|
+
// v2.4.0 / audit closure (P3.13): in-memory monotonic seq counter per
|
|
93
|
+
// session. Pre-v2.4.0 appendEvent recomputed seq by reading the events
|
|
94
|
+
// file, splitting on newlines and counting non-empty lines — that race
|
|
95
|
+
// remained even inside withSessionLock because two emit calls within
|
|
96
|
+
// the same process could compute identical seqs if the OS write returned
|
|
97
|
+
// before the next read. The cache below is initialized on first use
|
|
98
|
+
// (lazy) by reading the existing file ONCE and is incremented strictly
|
|
99
|
+
// monotonically thereafter. Restart re-initializes from disk, so seq
|
|
100
|
+
// remains correct across process boundaries.
|
|
101
|
+
seqCache = new Map();
|
|
102
|
+
constructor(config) {
|
|
103
|
+
this.config = config;
|
|
104
|
+
fs.mkdirSync(this.sessionsDir(), { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
sessionsDir() {
|
|
107
|
+
return path.join(this.config.data_dir, "sessions");
|
|
108
|
+
}
|
|
109
|
+
sessionDir(sessionId) {
|
|
110
|
+
this.assertSessionId(sessionId);
|
|
111
|
+
const sessionsRoot = fs.realpathSync(this.sessionsDir());
|
|
112
|
+
const candidate = path.resolve(sessionsRoot, sessionId);
|
|
113
|
+
const containedCandidate = fs.existsSync(candidate) ? fs.realpathSync(candidate) : candidate;
|
|
114
|
+
if (!this.isPathContained(sessionsRoot, containedCandidate)) {
|
|
115
|
+
throw new Error(`session path escapes data directory: ${sessionId}`);
|
|
116
|
+
}
|
|
117
|
+
return containedCandidate;
|
|
118
|
+
}
|
|
119
|
+
metaPath(sessionId) {
|
|
120
|
+
return path.join(this.sessionDir(sessionId), "meta.json");
|
|
121
|
+
}
|
|
122
|
+
eventsPath(sessionId) {
|
|
123
|
+
return path.join(this.sessionDir(sessionId), "events.ndjson");
|
|
124
|
+
}
|
|
125
|
+
assertSessionId(sessionId) {
|
|
126
|
+
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(sessionId)) {
|
|
127
|
+
throw new Error(`invalid session_id: ${sessionId}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
isPathContained(parent, target) {
|
|
131
|
+
const relative = path.relative(parent, target);
|
|
132
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
133
|
+
}
|
|
134
|
+
processAlive(pid) {
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
sleepSync(ms) {
|
|
144
|
+
const buffer = new SharedArrayBuffer(4);
|
|
145
|
+
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
146
|
+
}
|
|
147
|
+
totalsFor(meta) {
|
|
148
|
+
const peerResults = meta.rounds.flatMap((round) => round.peers);
|
|
149
|
+
const generations = meta.generation_files ?? [];
|
|
150
|
+
return {
|
|
151
|
+
usage: mergeUsage([
|
|
152
|
+
...peerResults.map((peer) => peer.usage),
|
|
153
|
+
...generations.map((generation) => generation.usage),
|
|
154
|
+
]),
|
|
155
|
+
cost: mergeCost([
|
|
156
|
+
...peerResults.map((peer) => peer.cost),
|
|
157
|
+
...generations.map((generation) => generation.cost),
|
|
158
|
+
]),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
withSessionLock(sessionId, fn) {
|
|
162
|
+
const dir = this.sessionDir(sessionId);
|
|
163
|
+
const lockPath = path.join(dir, ".lock");
|
|
164
|
+
const timeoutAt = Date.now() + 30_000;
|
|
165
|
+
while (true) {
|
|
166
|
+
try {
|
|
167
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
168
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquired_at: now() }));
|
|
169
|
+
fs.closeSync(fd);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code !== "EEXIST")
|
|
174
|
+
throw error;
|
|
175
|
+
try {
|
|
176
|
+
const lock = readJson(lockPath);
|
|
177
|
+
const age = lock.acquired_at ? Date.now() - Date.parse(lock.acquired_at) : Infinity;
|
|
178
|
+
if (!lock.pid || age > 120_000 || !this.processAlive(lock.pid)) {
|
|
179
|
+
fs.rmSync(lockPath, { force: true });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
fs.rmSync(lockPath, { force: true });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (Date.now() >= timeoutAt) {
|
|
188
|
+
throw new Error(`timed out waiting for session lock: ${sessionId}`, { cause: error });
|
|
189
|
+
}
|
|
190
|
+
this.sleepSync(100);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
return fn();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
fs.rmSync(lockPath, { force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
init(task, caller, snapshot, reviewFocus) {
|
|
201
|
+
const session_id = crypto.randomUUID();
|
|
202
|
+
// v2.22.0 (B.P3): snapshot the cost ceiling at session_init time so
|
|
203
|
+
// budget pressure analysis is decoupled from later env-var mutation.
|
|
204
|
+
// null when the operator runs without a session-level cost cap.
|
|
205
|
+
const ceiling = this.config.budget.max_session_cost_usd;
|
|
206
|
+
const meta = {
|
|
207
|
+
session_id,
|
|
208
|
+
version: this.config.version,
|
|
209
|
+
created_at: now(),
|
|
210
|
+
updated_at: now(),
|
|
211
|
+
task,
|
|
212
|
+
...(reviewFocus ? { review_focus: reviewFocus } : {}),
|
|
213
|
+
caller,
|
|
214
|
+
capability_snapshot: snapshot,
|
|
215
|
+
convergence_health: {
|
|
216
|
+
state: "idle",
|
|
217
|
+
last_event_at: now(),
|
|
218
|
+
detail: "Session initialized.",
|
|
219
|
+
},
|
|
220
|
+
rounds: [],
|
|
221
|
+
totals: {
|
|
222
|
+
usage: {},
|
|
223
|
+
cost: { currency: "USD", estimated: false, source: "unknown-rate" },
|
|
224
|
+
},
|
|
225
|
+
cost_ceiling_usd: typeof ceiling === "number" && ceiling > 0 ? ceiling : null,
|
|
226
|
+
costs_per_round: [],
|
|
227
|
+
budget_warning_emitted: false,
|
|
228
|
+
};
|
|
229
|
+
fs.mkdirSync(path.join(this.sessionDir(session_id), "agent-runs"), { recursive: true });
|
|
230
|
+
writeJson(this.metaPath(session_id), meta);
|
|
231
|
+
fs.writeFileSync(path.join(this.sessionDir(session_id), "task.md"), task, "utf8");
|
|
232
|
+
if (reviewFocus) {
|
|
233
|
+
fs.writeFileSync(path.join(this.sessionDir(session_id), "review-focus.md"), reviewFocus, "utf8");
|
|
234
|
+
}
|
|
235
|
+
return meta;
|
|
236
|
+
}
|
|
237
|
+
// v2.4.0 / cross-review R5 (codex blocker): refuse to overwrite an
|
|
238
|
+
// existing in_flight when starting a new round. Pre-R5 markInFlight
|
|
239
|
+
// unconditionally clobbered `meta.in_flight`, so a second concurrent
|
|
240
|
+
// ask_peers on the same session would silently steamroll the first
|
|
241
|
+
// round's state — and the format-recovery quota counter would race
|
|
242
|
+
// because both calls could read the same `recoveriesAlready` baseline.
|
|
243
|
+
// R5 throws when in_flight is already populated; the boot-time
|
|
244
|
+
// `clearStaleInFlight` sweep clears any orphan in_flight from a
|
|
245
|
+
// crashed prior host so legitimate operators are not blocked.
|
|
246
|
+
markInFlight(sessionId, params) {
|
|
247
|
+
return this.withSessionLock(sessionId, () => {
|
|
248
|
+
const meta = this.read(sessionId);
|
|
249
|
+
if (meta.in_flight) {
|
|
250
|
+
throw new Error(`session ${sessionId} already has an in-flight round (round=${meta.in_flight.round}, started_at=${meta.in_flight.started_at}); refusing to start a concurrent round. Wait for the round to complete, cancel it via session_cancel_job, or recover it via session_recover_interrupted.`);
|
|
251
|
+
}
|
|
252
|
+
meta.in_flight = {
|
|
253
|
+
round: params.round,
|
|
254
|
+
peers: params.peers,
|
|
255
|
+
started_at: params.started_at,
|
|
256
|
+
status: "running",
|
|
257
|
+
};
|
|
258
|
+
meta.convergence_scope = params.scope;
|
|
259
|
+
meta.convergence_health = {
|
|
260
|
+
state: "running",
|
|
261
|
+
last_event_at: now(),
|
|
262
|
+
detail: `Round ${params.round} is running.`,
|
|
263
|
+
};
|
|
264
|
+
meta.updated_at = now();
|
|
265
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
266
|
+
return meta;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
read(sessionId) {
|
|
270
|
+
return readJson(this.metaPath(sessionId));
|
|
271
|
+
}
|
|
272
|
+
// v2.4.0 / audit closure (P3.13) — refined after cross-review R2 (codex
|
|
273
|
+
// caught a durability gap in the initial implementation).
|
|
274
|
+
//
|
|
275
|
+
// Pre-R2: the cache was incremented BEFORE appendFileSync. If the
|
|
276
|
+
// append failed (ENOSPC, EACCES, write-error mid-call) the cache held
|
|
277
|
+
// an already-handed-out seq number that nothing on disk consumed —
|
|
278
|
+
// and a subsequent successful append would reuse the same disk byte
|
|
279
|
+
// for a different event, while the cache produced seq+1. After
|
|
280
|
+
// process restart the cache rebuild re-counted lines and produced a
|
|
281
|
+
// duplicate seq.
|
|
282
|
+
//
|
|
283
|
+
// R2 (codex): the cache is updated ONLY after the appendFileSync
|
|
284
|
+
// returns. If append throws, the cache is unchanged so the next call
|
|
285
|
+
// reuses the same intended seq (no gap, no duplicate). On restart
|
|
286
|
+
// the cache rebuild reflects on-disk reality. The lazy load uses
|
|
287
|
+
// line count of the existing file as a reasonable approximation of
|
|
288
|
+
// the durable max-seq.
|
|
289
|
+
peekNextSeq(sessionId, file) {
|
|
290
|
+
let cached = this.seqCache.get(sessionId);
|
|
291
|
+
if (cached === undefined) {
|
|
292
|
+
try {
|
|
293
|
+
cached = fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).length;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (error.code !== "ENOENT")
|
|
297
|
+
throw error;
|
|
298
|
+
cached = 0;
|
|
299
|
+
}
|
|
300
|
+
this.seqCache.set(sessionId, cached);
|
|
301
|
+
}
|
|
302
|
+
return cached + 1;
|
|
303
|
+
}
|
|
304
|
+
commitSeq(sessionId, committed) {
|
|
305
|
+
this.seqCache.set(sessionId, committed);
|
|
306
|
+
}
|
|
307
|
+
appendEvent(event) {
|
|
308
|
+
const sessionId = event.session_id;
|
|
309
|
+
if (!sessionId)
|
|
310
|
+
return;
|
|
311
|
+
try {
|
|
312
|
+
this.withSessionLock(sessionId, () => {
|
|
313
|
+
const file = this.eventsPath(sessionId);
|
|
314
|
+
const seq = this.peekNextSeq(sessionId, file);
|
|
315
|
+
fs.appendFileSync(file, `${JSON.stringify({ ...event, seq, ts: event.ts ?? now() })}\n`, "utf8");
|
|
316
|
+
// Only commit the cache AFTER the durable append succeeded.
|
|
317
|
+
// If appendFileSync threw above, the cache still reflects the
|
|
318
|
+
// last persisted seq and the next call reuses this seq number.
|
|
319
|
+
this.commitSeq(sessionId, seq);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Event persistence must never break provider calls or MCP responses.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
readEvents(sessionId, sinceSeq = 0) {
|
|
327
|
+
const file = this.eventsPath(sessionId);
|
|
328
|
+
if (!fs.existsSync(file))
|
|
329
|
+
return [];
|
|
330
|
+
return fs
|
|
331
|
+
.readFileSync(file, "utf8")
|
|
332
|
+
.split(/\r?\n/)
|
|
333
|
+
.filter(Boolean)
|
|
334
|
+
.map((line, index) => ({ seq: index + 1, ...JSON.parse(line) }))
|
|
335
|
+
.filter((event) => event.seq > sinceSeq);
|
|
336
|
+
}
|
|
337
|
+
// v2.27.0: corrupted meta.json files are silently skipped + quarantined to
|
|
338
|
+
// `<session_dir>/meta.json.bad` so subsequent startup sweeps do not re-throw.
|
|
339
|
+
// Empirically demonstrated by 3 sessions corrupted by the v2.25.1 redact
|
|
340
|
+
// escape-boundary bug (77c47284, be47a5b0, 7edf63e3) that caused parse
|
|
341
|
+
// errors on every Claude Code reload until manually deleted 2026-05-12.
|
|
342
|
+
list() {
|
|
343
|
+
if (!fs.existsSync(this.sessionsDir()))
|
|
344
|
+
return [];
|
|
345
|
+
const entries = fs.readdirSync(this.sessionsDir(), { withFileTypes: true });
|
|
346
|
+
const metas = [];
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
if (!entry.isDirectory())
|
|
349
|
+
continue;
|
|
350
|
+
const sessionDir = path.join(this.sessionsDir(), entry.name);
|
|
351
|
+
const file = path.join(sessionDir, "meta.json");
|
|
352
|
+
if (!fs.existsSync(file))
|
|
353
|
+
continue;
|
|
354
|
+
try {
|
|
355
|
+
metas.push(readJson(file));
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
359
|
+
const quarantine = path.join(sessionDir, "meta.json.bad");
|
|
360
|
+
try {
|
|
361
|
+
if (!fs.existsSync(quarantine)) {
|
|
362
|
+
fs.renameSync(file, quarantine);
|
|
363
|
+
console.error(`[cross-review] quarantined corrupted meta.json at ${file} -> ${quarantine} (${message})`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
/* best-effort */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return metas.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
372
|
+
}
|
|
373
|
+
// v2.27.0: prune finalized sessions older than `maxAgeDays` days. Default
|
|
374
|
+
// 60 days (configurable via CROSS_REVIEW_PRUNE_AFTER_DAYS env var or
|
|
375
|
+
// explicit arg). Only removes sessions whose outcome is terminal (converged
|
|
376
|
+
// | aborted | max-rounds) AND whose updated_at is older than the cutoff.
|
|
377
|
+
// In-flight or untyped-outcome sessions are never pruned. Idempotent +
|
|
378
|
+
// best-effort. Empirically motivated by 534 sessions accumulated on disk
|
|
379
|
+
// by 2026-05-12 inflating cold-start sweep cost.
|
|
380
|
+
pruneOldSessions(maxAgeDays) {
|
|
381
|
+
const envDays = Number.parseFloat(process.env.CROSS_REVIEW_PRUNE_AFTER_DAYS ?? "");
|
|
382
|
+
const days = maxAgeDays != null && maxAgeDays > 0
|
|
383
|
+
? maxAgeDays
|
|
384
|
+
: Number.isFinite(envDays) && envDays > 0
|
|
385
|
+
? envDays
|
|
386
|
+
: 60;
|
|
387
|
+
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
388
|
+
let scanned = 0;
|
|
389
|
+
let pruned = 0;
|
|
390
|
+
for (const session of this.list()) {
|
|
391
|
+
scanned += 1;
|
|
392
|
+
if (!session.outcome)
|
|
393
|
+
continue;
|
|
394
|
+
const lastTouched = Date.parse(session.updated_at);
|
|
395
|
+
if (!Number.isFinite(lastTouched) || lastTouched >= cutoffMs)
|
|
396
|
+
continue;
|
|
397
|
+
const dir = this.sessionDir(session.session_id);
|
|
398
|
+
try {
|
|
399
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
400
|
+
pruned += 1;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* best-effort */
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { scanned, pruned };
|
|
407
|
+
}
|
|
408
|
+
savePrompt(sessionId, round, prompt) {
|
|
409
|
+
const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-prompt.md`);
|
|
410
|
+
fs.writeFileSync(file, redact(prompt), "utf8");
|
|
411
|
+
return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
412
|
+
}
|
|
413
|
+
saveDraft(sessionId, round, draft) {
|
|
414
|
+
const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-draft.md`);
|
|
415
|
+
fs.writeFileSync(file, redact(draft), "utf8");
|
|
416
|
+
return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
417
|
+
}
|
|
418
|
+
saveGeneration(sessionId, round, result, label = "generation") {
|
|
419
|
+
const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-${result.peer}-${label}.json`);
|
|
420
|
+
writeJson(file, { ...result, text: redact(result.text) });
|
|
421
|
+
const relativePath = path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
422
|
+
this.withSessionLock(sessionId, () => {
|
|
423
|
+
const meta = this.read(sessionId);
|
|
424
|
+
const artifact = {
|
|
425
|
+
ts: now(),
|
|
426
|
+
round,
|
|
427
|
+
label,
|
|
428
|
+
peer: result.peer,
|
|
429
|
+
path: relativePath,
|
|
430
|
+
usage: result.usage,
|
|
431
|
+
cost: result.cost,
|
|
432
|
+
latency_ms: result.latency_ms,
|
|
433
|
+
};
|
|
434
|
+
meta.generation_files = [...(meta.generation_files ?? []), artifact];
|
|
435
|
+
meta.totals = this.totalsFor(meta);
|
|
436
|
+
meta.updated_at = now();
|
|
437
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
438
|
+
});
|
|
439
|
+
return relativePath;
|
|
440
|
+
}
|
|
441
|
+
saveFinal(sessionId, text) {
|
|
442
|
+
const file = path.join(this.sessionDir(sessionId), "final.md");
|
|
443
|
+
fs.writeFileSync(file, redact(text), "utf8");
|
|
444
|
+
return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
445
|
+
}
|
|
446
|
+
saveReport(sessionId, text) {
|
|
447
|
+
const file = path.join(this.sessionDir(sessionId), "session-report.md");
|
|
448
|
+
fs.writeFileSync(file, redact(text), "utf8");
|
|
449
|
+
return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
450
|
+
}
|
|
451
|
+
savePeerResult(sessionId, round, result, label = "response") {
|
|
452
|
+
const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-${result.peer}-${label}.json`);
|
|
453
|
+
writeJson(file, { ...result, text: redact(result.text) });
|
|
454
|
+
return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
455
|
+
}
|
|
456
|
+
savePeerFailure(sessionId, round, failure) {
|
|
457
|
+
const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-${failure.peer}-failure.json`);
|
|
458
|
+
writeJson(file, { ...failure, message: redact(failure.message) });
|
|
459
|
+
return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
|
|
460
|
+
}
|
|
461
|
+
appendRound(sessionId, params) {
|
|
462
|
+
return this.withSessionLock(sessionId, () => {
|
|
463
|
+
const meta = this.read(sessionId);
|
|
464
|
+
// v3.2.0 (Codex bug report 2026-05-12): refuse to append a round
|
|
465
|
+
// to a finalized session. Otherwise the per-round
|
|
466
|
+
// `convergence_health` write below would clobber the converged
|
|
467
|
+
// health set by `finalize()`, producing the contradictory
|
|
468
|
+
// `outcome=converged / health=blocked` state observed in session
|
|
469
|
+
// 41244a1c (R6 ran after a `session_finalize` call corrupted the
|
|
470
|
+
// meta — but the orchestrator path can also produce this if any
|
|
471
|
+
// post-finalize round mutator slips through).
|
|
472
|
+
if (meta.outcome) {
|
|
473
|
+
const err = new Error(`session_already_finalized: cannot append round to session ${sessionId} (outcome="${meta.outcome}")`);
|
|
474
|
+
err.code = "session_already_finalized";
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
const round = {
|
|
478
|
+
round: meta.rounds.length + 1,
|
|
479
|
+
started_at: params.started_at,
|
|
480
|
+
completed_at: now(),
|
|
481
|
+
caller_status: params.caller_status,
|
|
482
|
+
draft_file: params.draft_file,
|
|
483
|
+
prompt_file: params.prompt_file,
|
|
484
|
+
peers: params.peers,
|
|
485
|
+
rejected: params.rejected,
|
|
486
|
+
convergence: params.convergence,
|
|
487
|
+
};
|
|
488
|
+
meta.rounds.push(round);
|
|
489
|
+
meta.failed_attempts = [
|
|
490
|
+
...(meta.failed_attempts ?? []),
|
|
491
|
+
...params.rejected.map((failure) => ({ ...failure, round: round.round })),
|
|
492
|
+
];
|
|
493
|
+
delete meta.in_flight;
|
|
494
|
+
meta.convergence_scope = params.convergence_scope;
|
|
495
|
+
meta.convergence_health = {
|
|
496
|
+
state: params.convergence.converged ? "converged" : "blocked",
|
|
497
|
+
last_event_at: now(),
|
|
498
|
+
detail: params.convergence.reason,
|
|
499
|
+
};
|
|
500
|
+
meta.updated_at = now();
|
|
501
|
+
meta.totals = this.totalsFor(meta);
|
|
502
|
+
// v2.22.0 (B.P3): append per-round cost. Sum of peer.cost.total_cost
|
|
503
|
+
// across this round's peers. Coerced to 0 when adapters didn't
|
|
504
|
+
// surface a cost (stub paths, error rounds). Read AFTER totalsFor
|
|
505
|
+
// so the new round's peer costs are already counted by the merger,
|
|
506
|
+
// but we recompute the round-local sum independently to avoid
|
|
507
|
+
// diff-based drift if a peer's cost changed in a retry loop.
|
|
508
|
+
const roundCost = params.peers.reduce((sum, peer) => sum + (peer.cost?.total_cost ?? 0), 0);
|
|
509
|
+
meta.costs_per_round = [...(meta.costs_per_round ?? []), roundCost];
|
|
510
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
511
|
+
return round;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
// v2.22.0 (B.P3): one-shot guard for `session.budget_warning` emit
|
|
515
|
+
// idempotency. Persisted in meta.json so the warning fires at most
|
|
516
|
+
// once per session even across host restarts.
|
|
517
|
+
markBudgetWarningEmitted(sessionId) {
|
|
518
|
+
return this.withSessionLock(sessionId, () => {
|
|
519
|
+
const meta = this.read(sessionId);
|
|
520
|
+
meta.budget_warning_emitted = true;
|
|
521
|
+
meta.updated_at = now();
|
|
522
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
523
|
+
return meta;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
// v2.25.0 (circular mode): atomically replace meta.circular_state. The
|
|
527
|
+
// orchestrator's circular loop calls this every round so resumed
|
|
528
|
+
// sessions can pick up the rotation cursor and consecutive-no-change
|
|
529
|
+
// count from disk without re-deriving them by walking events.
|
|
530
|
+
setCircularState(sessionId, state) {
|
|
531
|
+
return this.withSessionLock(sessionId, () => {
|
|
532
|
+
const meta = this.read(sessionId);
|
|
533
|
+
meta.circular_state = state;
|
|
534
|
+
meta.updated_at = now();
|
|
535
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
536
|
+
return meta;
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
// v3.5.0 (CRV2-1 + CRV2-6, Codex operational report): persist
|
|
540
|
+
// requested-vs-effective budget + max_rounds traceability once at the
|
|
541
|
+
// start of a run. Pre-v3.5.0 the durable record only had
|
|
542
|
+
// `cost_ceiling_usd` (always the effective value) and nothing for
|
|
543
|
+
// max_rounds — so retroactive analysis could not tell whether a
|
|
544
|
+
// ceiling came from a per-call arg or a config default, nor what
|
|
545
|
+
// max_rounds the caller actually requested. This fills that gap with
|
|
546
|
+
// pure-additive metadata; `cost_ceiling_usd` is kept in sync with
|
|
547
|
+
// `effective_cost_ceiling_usd` for back-compat with v3.4.x readers.
|
|
548
|
+
setSessionTraceability(sessionId, traceability) {
|
|
549
|
+
return this.withSessionLock(sessionId, () => {
|
|
550
|
+
const meta = this.read(sessionId);
|
|
551
|
+
meta.requested_max_rounds = traceability.requested_max_rounds;
|
|
552
|
+
meta.effective_max_rounds = traceability.effective_max_rounds;
|
|
553
|
+
meta.requested_max_cost_usd = traceability.requested_max_cost_usd;
|
|
554
|
+
meta.effective_cost_ceiling_usd = traceability.effective_cost_ceiling_usd;
|
|
555
|
+
meta.cost_ceiling_source = traceability.cost_ceiling_source;
|
|
556
|
+
// Keep the legacy field in sync so v3.4.x dashboard/readers that
|
|
557
|
+
// only know `cost_ceiling_usd` still see the effective ceiling.
|
|
558
|
+
meta.cost_ceiling_usd = traceability.effective_cost_ceiling_usd;
|
|
559
|
+
meta.updated_at = now();
|
|
560
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
561
|
+
return meta;
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// v3.2.0 (Codex bug report 2026-05-12): public guard for orchestrator
|
|
565
|
+
// entry points. Throws when the session has already been finalized so
|
|
566
|
+
// round-starting tools fail fast instead of appending rounds onto a
|
|
567
|
+
// closed session (which would re-derive `convergence_health` from the
|
|
568
|
+
// post-final round's `convergence.converged` and leave the meta in the
|
|
569
|
+
// contradictory `outcome=converged / health=blocked` state observed in
|
|
570
|
+
// session 41244a1c). Error code is structured for upstream callers.
|
|
571
|
+
assertNotFinalized(sessionId) {
|
|
572
|
+
const meta = this.read(sessionId);
|
|
573
|
+
if (meta.outcome) {
|
|
574
|
+
const err = new Error(`session_already_finalized: session ${sessionId} is finalized with outcome="${meta.outcome}"; cannot start new rounds`);
|
|
575
|
+
err.code = "session_already_finalized";
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
finalize(sessionId, outcome, reason) {
|
|
580
|
+
return this.withSessionLock(sessionId, () => {
|
|
581
|
+
const meta = this.read(sessionId);
|
|
582
|
+
// v3.2.0 (Codex bug report 2026-05-12): when the caller asserts
|
|
583
|
+
// outcome="converged", the latest round (if any) MUST have
|
|
584
|
+
// `convergence.converged === true`. Otherwise we would persist the
|
|
585
|
+
// contradictory `outcome=converged / health=blocked` state observed
|
|
586
|
+
// in session 41244a1c (R6 had perplexity:unparseable_after_recovery
|
|
587
|
+
// → convergence.converged=false, but session_finalize was invoked
|
|
588
|
+
// with outcome="converged"/"unanimous_ready" anyway). Refuse with a
|
|
589
|
+
// structured error so the operator/caller fixes the mismatch
|
|
590
|
+
// upstream instead of corrupting the meta.
|
|
591
|
+
if (outcome === "converged" && meta.rounds.length > 0) {
|
|
592
|
+
const latest = meta.rounds[meta.rounds.length - 1];
|
|
593
|
+
if (latest.convergence?.converged !== true) {
|
|
594
|
+
const err = new Error(`session_finalize_outcome_mismatch: cannot finalize as "converged" — latest round (round=${latest.round}) has convergence.converged=${latest.convergence?.converged ?? "undefined"}, reason="${latest.convergence?.reason ?? "n/a"}"`);
|
|
595
|
+
err.code = "session_finalize_outcome_mismatch";
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
meta.outcome = outcome;
|
|
600
|
+
if (reason)
|
|
601
|
+
meta.outcome_reason = reason;
|
|
602
|
+
delete meta.in_flight;
|
|
603
|
+
meta.convergence_health = {
|
|
604
|
+
state: outcome === "converged" ? "converged" : outcome === "max-rounds" ? "blocked" : "stale",
|
|
605
|
+
last_event_at: now(),
|
|
606
|
+
detail: reason ?? outcome,
|
|
607
|
+
};
|
|
608
|
+
meta.updated_at = now();
|
|
609
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
610
|
+
return meta;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
requestCancellation(sessionId, reason = "operator_requested", jobId) {
|
|
614
|
+
return this.withSessionLock(sessionId, () => {
|
|
615
|
+
const meta = this.read(sessionId);
|
|
616
|
+
meta.control = {
|
|
617
|
+
status: "cancel_requested",
|
|
618
|
+
reason,
|
|
619
|
+
job_id: jobId,
|
|
620
|
+
requested_at: now(),
|
|
621
|
+
updated_at: now(),
|
|
622
|
+
};
|
|
623
|
+
meta.convergence_health = {
|
|
624
|
+
state: meta.outcome === "converged" ? "converged" : "blocked",
|
|
625
|
+
last_event_at: now(),
|
|
626
|
+
detail: `Cancellation requested: ${reason}`,
|
|
627
|
+
};
|
|
628
|
+
meta.updated_at = now();
|
|
629
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
630
|
+
return meta;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
markCancelled(sessionId, reason = "cancelled") {
|
|
634
|
+
return this.withSessionLock(sessionId, () => {
|
|
635
|
+
const meta = this.read(sessionId);
|
|
636
|
+
meta.outcome = "aborted";
|
|
637
|
+
meta.outcome_reason = reason;
|
|
638
|
+
delete meta.in_flight;
|
|
639
|
+
meta.control = {
|
|
640
|
+
status: "cancelled",
|
|
641
|
+
reason,
|
|
642
|
+
job_id: meta.control?.job_id,
|
|
643
|
+
requested_at: meta.control?.requested_at,
|
|
644
|
+
updated_at: now(),
|
|
645
|
+
};
|
|
646
|
+
meta.convergence_health = {
|
|
647
|
+
state: "stale",
|
|
648
|
+
last_event_at: now(),
|
|
649
|
+
detail: reason,
|
|
650
|
+
};
|
|
651
|
+
meta.updated_at = now();
|
|
652
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
653
|
+
return meta;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
isCancellationRequested(sessionId) {
|
|
657
|
+
const meta = this.read(sessionId);
|
|
658
|
+
return meta.control?.status === "cancel_requested";
|
|
659
|
+
}
|
|
660
|
+
appendFallbackEvent(sessionId, event) {
|
|
661
|
+
return this.withSessionLock(sessionId, () => {
|
|
662
|
+
const meta = this.read(sessionId);
|
|
663
|
+
meta.fallback_events = [...(meta.fallback_events ?? []), event];
|
|
664
|
+
meta.updated_at = now();
|
|
665
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
666
|
+
return meta;
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
// v2.7.0 Evidence Broker: aggregate NEEDS_EVIDENCE asks from a round
|
|
670
|
+
// into the session-level checklist. Each (peer, ask) pair is
|
|
671
|
+
// deduplicated by sha256(peer + ":" + ask) so the same ask repeated
|
|
672
|
+
// across rounds increments `round_count` instead of producing
|
|
673
|
+
// duplicate entries. Returns the updated checklist (or empty array
|
|
674
|
+
// if nothing was added/updated).
|
|
675
|
+
appendEvidenceChecklistItems(sessionId, round, incoming) {
|
|
676
|
+
if (!incoming.length)
|
|
677
|
+
return [];
|
|
678
|
+
return this.withSessionLock(sessionId, () => {
|
|
679
|
+
const meta = this.read(sessionId);
|
|
680
|
+
const existing = meta.evidence_checklist ?? [];
|
|
681
|
+
const byId = new Map(existing.map((item) => [item.id, item]));
|
|
682
|
+
const ts = now();
|
|
683
|
+
for (const { peer, ask } of incoming) {
|
|
684
|
+
const trimmed = ask.trim();
|
|
685
|
+
if (!trimmed)
|
|
686
|
+
continue;
|
|
687
|
+
const id = crypto
|
|
688
|
+
.createHash("sha256")
|
|
689
|
+
.update(`${peer}:${trimmed}`)
|
|
690
|
+
.digest("hex")
|
|
691
|
+
.slice(0, 16);
|
|
692
|
+
const existing = byId.get(id);
|
|
693
|
+
if (existing) {
|
|
694
|
+
// Same ask resurfaced. Bump last_round/last_seen_at and
|
|
695
|
+
// round_count only when the round number is strictly newer
|
|
696
|
+
// (avoid double-counting if the same caller_request appears
|
|
697
|
+
// multiple times within the same round across peers — though
|
|
698
|
+
// we already iterate per-peer, so this is defensive).
|
|
699
|
+
if (round > existing.last_round) {
|
|
700
|
+
existing.last_round = round;
|
|
701
|
+
existing.last_seen_at = ts;
|
|
702
|
+
existing.round_count += 1;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
byId.set(id, {
|
|
707
|
+
id,
|
|
708
|
+
peer,
|
|
709
|
+
first_round: round,
|
|
710
|
+
last_round: round,
|
|
711
|
+
round_count: 1,
|
|
712
|
+
ask: trimmed,
|
|
713
|
+
first_seen_at: ts,
|
|
714
|
+
last_seen_at: ts,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const updated = Array.from(byId.values()).sort((a, b) => {
|
|
719
|
+
if (a.first_round !== b.first_round)
|
|
720
|
+
return a.first_round - b.first_round;
|
|
721
|
+
if (a.peer !== b.peer)
|
|
722
|
+
return a.peer.localeCompare(b.peer);
|
|
723
|
+
return a.ask.localeCompare(b.ask);
|
|
724
|
+
});
|
|
725
|
+
meta.evidence_checklist = updated;
|
|
726
|
+
meta.updated_at = ts;
|
|
727
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
728
|
+
return updated;
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
// v2.8.0: terminal statuses owned by the operator. The runtime never
|
|
732
|
+
// auto-mutates items in these states — it only surfaces them via the
|
|
733
|
+
// peer_resurfaced_terminal collection so the orchestrator can emit a
|
|
734
|
+
// visibility event. Held as a Set because the runtime checks membership
|
|
735
|
+
// on every item every round; a Set lookup avoids any risk of someone
|
|
736
|
+
// later writing the buggy `(status === "satisfied" || "deferred" ||
|
|
737
|
+
// "rejected")` truthy-OR form by accident.
|
|
738
|
+
static TERMINAL_STATUSES = new Set(["satisfied", "deferred", "rejected"]);
|
|
739
|
+
// v2.8.0: resurfacing-inference for the evidence checklist. Runs AFTER
|
|
740
|
+
// appendEvidenceChecklistItems for a given round and applies two rules
|
|
741
|
+
// atomically under the session lock:
|
|
742
|
+
// 1. Items in `open` whose `last_round < currentRound` were not
|
|
743
|
+
// brought back by any peer this round → promote to `addressed`
|
|
744
|
+
// and stamp `addressed_at_round`.
|
|
745
|
+
// 2. Items in `addressed` whose `last_round === currentRound` were
|
|
746
|
+
// resurfaced this round (aggregation already bumped last_round
|
|
747
|
+
// and round_count) → revert to `open` and clear addressed_at_round.
|
|
748
|
+
// Terminal operator statuses (satisfied/deferred/rejected) are NEVER
|
|
749
|
+
// touched here. The peer_resurfaced_terminal information is surfaced
|
|
750
|
+
// by the orchestrator via a separate event so operators see when peers
|
|
751
|
+
// keep asking for items they explicitly closed; the status itself is
|
|
752
|
+
// operator-owned.
|
|
753
|
+
runEvidenceChecklistAddressDetection(sessionId, currentRound) {
|
|
754
|
+
return this.withSessionLock(sessionId, () => {
|
|
755
|
+
const meta = this.read(sessionId);
|
|
756
|
+
const checklist = meta.evidence_checklist ?? [];
|
|
757
|
+
if (!checklist.length) {
|
|
758
|
+
return { not_resurfaced: [], reopened: [], peer_resurfaced_terminal: [] };
|
|
759
|
+
}
|
|
760
|
+
const notResurfaced = [];
|
|
761
|
+
const reopened = [];
|
|
762
|
+
const peerResurfacedTerminal = [];
|
|
763
|
+
const history = meta.evidence_status_history ?? [];
|
|
764
|
+
const ts = now();
|
|
765
|
+
for (const item of checklist) {
|
|
766
|
+
const status = item.status ?? "open";
|
|
767
|
+
if (status === "open" && item.last_round < currentRound) {
|
|
768
|
+
// v3.5.0 (CRV2-2): an `open` item the peer did not resurface
|
|
769
|
+
// becomes `not_resurfaced`, NOT `addressed`. "The peer did not
|
|
770
|
+
// re-ask" is not proof the evidence was satisfied — only the
|
|
771
|
+
// judge autowire (verified-satisfied) or explicit operator
|
|
772
|
+
// action may move an item to a confirmed state. This keeps the
|
|
773
|
+
// audit trail honest. `not_resurfaced` is still not `open`, so
|
|
774
|
+
// it does not hard-block the `=== "open"` convergence gate;
|
|
775
|
+
// the inference is recorded, not enforced.
|
|
776
|
+
item.status = "not_resurfaced";
|
|
777
|
+
item.addressed_at_round = currentRound;
|
|
778
|
+
// v2.9.0: tag the inference path so the dashboard and audit
|
|
779
|
+
// trail can distinguish runtime resurfacing from runtime judge
|
|
780
|
+
// promotions. Operator-set terminal statuses do not populate
|
|
781
|
+
// this field; setEvidenceChecklistItemStatus clears it.
|
|
782
|
+
item.address_method = "resurfacing";
|
|
783
|
+
delete item.judge_rationale;
|
|
784
|
+
notResurfaced.push(item);
|
|
785
|
+
history.push({
|
|
786
|
+
ts,
|
|
787
|
+
item_id: item.id,
|
|
788
|
+
from: "open",
|
|
789
|
+
to: "not_resurfaced",
|
|
790
|
+
by: "runtime",
|
|
791
|
+
round: currentRound,
|
|
792
|
+
note: `auto: peer did not resurface ask in round ${currentRound} (not proof of satisfaction)`,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
else if ((status === "not_resurfaced" || status === "addressed") &&
|
|
796
|
+
item.last_round === currentRound) {
|
|
797
|
+
// v3.5.0 (CRV2-2): a peer resurfacing an item reverts it to
|
|
798
|
+
// `open` regardless of whether the prior state was the soft
|
|
799
|
+
// `not_resurfaced` inference or a judge/operator `addressed` —
|
|
800
|
+
// the peer's renewed ask wins over either inference path.
|
|
801
|
+
const from = status;
|
|
802
|
+
item.status = "open";
|
|
803
|
+
delete item.addressed_at_round;
|
|
804
|
+
delete item.address_method;
|
|
805
|
+
delete item.judge_rationale;
|
|
806
|
+
reopened.push(item);
|
|
807
|
+
history.push({
|
|
808
|
+
ts,
|
|
809
|
+
item_id: item.id,
|
|
810
|
+
from,
|
|
811
|
+
to: "open",
|
|
812
|
+
by: "runtime",
|
|
813
|
+
round: currentRound,
|
|
814
|
+
note: `auto: peer resurfaced ask in round ${currentRound}`,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
else if (SessionStore.TERMINAL_STATUSES.has(status) && item.last_round === currentRound) {
|
|
818
|
+
// Operator closed it but the peer brought it back this round.
|
|
819
|
+
// Status stays terminal (operator-owned); we surface it for
|
|
820
|
+
// the orchestrator to emit a visibility event.
|
|
821
|
+
peerResurfacedTerminal.push(item);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (notResurfaced.length || reopened.length) {
|
|
825
|
+
meta.evidence_status_history = history;
|
|
826
|
+
meta.updated_at = ts;
|
|
827
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
not_resurfaced: notResurfaced,
|
|
831
|
+
reopened,
|
|
832
|
+
peer_resurfaced_terminal: peerResurfacedTerminal,
|
|
833
|
+
};
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
// v2.8.0: operator workflow mutator for the evidence checklist. Used by
|
|
837
|
+
// the session_evidence_checklist_update MCP tool. Allowed transitions
|
|
838
|
+
// (operator): open → satisfied | deferred | rejected | open;
|
|
839
|
+
// addressed | not_resurfaced → satisfied | deferred | rejected | open.
|
|
840
|
+
// Terminal-state items can also be moved BACK to "open" by the operator
|
|
841
|
+
// (retract a deferral/rejection); that re-arms the runtime
|
|
842
|
+
// auto-promotion logic. Operator CANNOT move items to "addressed" or
|
|
843
|
+
// "not_resurfaced" — both are runtime-managed (judge promotion and
|
|
844
|
+
// resurfacing inference respectively). Returns the mutated item and the
|
|
845
|
+
// appended history entry.
|
|
846
|
+
setEvidenceChecklistItemStatus(sessionId, itemId, status, options = {}) {
|
|
847
|
+
return this.withSessionLock(sessionId, () => {
|
|
848
|
+
const meta = this.read(sessionId);
|
|
849
|
+
const checklist = meta.evidence_checklist ?? [];
|
|
850
|
+
const item = checklist.find((entry) => entry.id === itemId);
|
|
851
|
+
if (!item) {
|
|
852
|
+
throw new Error(`evidence_checklist_item_not_found: ${itemId}`);
|
|
853
|
+
}
|
|
854
|
+
const from = item.status ?? "open";
|
|
855
|
+
if (from === status) {
|
|
856
|
+
// No-op: already at the requested status. We still record a
|
|
857
|
+
// history entry so the audit trail captures the operator's
|
|
858
|
+
// explicit intent.
|
|
859
|
+
}
|
|
860
|
+
const ts = now();
|
|
861
|
+
const entry = {
|
|
862
|
+
ts,
|
|
863
|
+
item_id: itemId,
|
|
864
|
+
from,
|
|
865
|
+
to: status,
|
|
866
|
+
by: options.by ?? "operator",
|
|
867
|
+
note: options.note,
|
|
868
|
+
};
|
|
869
|
+
item.status = status;
|
|
870
|
+
// The signature excludes "addressed" so any operator-driven status
|
|
871
|
+
// change clears the runtime-managed stamps (v2.8.0 addressed_at_round
|
|
872
|
+
// + v2.9.0 address_method + judge_rationale).
|
|
873
|
+
delete item.addressed_at_round;
|
|
874
|
+
delete item.address_method;
|
|
875
|
+
delete item.judge_rationale;
|
|
876
|
+
const history = meta.evidence_status_history ?? [];
|
|
877
|
+
history.push(entry);
|
|
878
|
+
meta.evidence_status_history = history;
|
|
879
|
+
meta.evidence_checklist = checklist;
|
|
880
|
+
meta.updated_at = ts;
|
|
881
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
882
|
+
return { item, history_entry: entry };
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
// v2.9.0: runtime-judge promotion path. Promotes an `open` item to
|
|
886
|
+
// `addressed` ONLY — never touches terminal operator statuses, never
|
|
887
|
+
// moves anything other than open. Atomic under the session lock.
|
|
888
|
+
// Returns null when the item is not currently `open` (already
|
|
889
|
+
// addressed, terminal, or missing) so the caller can skip emit.
|
|
890
|
+
markEvidenceItemAddressedByJudge(sessionId, itemId, params) {
|
|
891
|
+
return this.withSessionLock(sessionId, () => {
|
|
892
|
+
const meta = this.read(sessionId);
|
|
893
|
+
const checklist = meta.evidence_checklist ?? [];
|
|
894
|
+
const item = checklist.find((entry) => entry.id === itemId);
|
|
895
|
+
if (!item)
|
|
896
|
+
return null;
|
|
897
|
+
const status = item.status ?? "open";
|
|
898
|
+
// Single allowed transition: open → addressed (judge). Terminal
|
|
899
|
+
// statuses (satisfied/deferred/rejected) and already-addressed
|
|
900
|
+
// items are NOT auto-mutated here.
|
|
901
|
+
if (status !== "open")
|
|
902
|
+
return null;
|
|
903
|
+
const ts = now();
|
|
904
|
+
const rationale = params.rationale.trim().slice(0, 800);
|
|
905
|
+
item.status = "addressed";
|
|
906
|
+
item.addressed_at_round = params.round;
|
|
907
|
+
item.address_method = "judge";
|
|
908
|
+
item.judge_rationale = rationale;
|
|
909
|
+
const entry = {
|
|
910
|
+
ts,
|
|
911
|
+
item_id: itemId,
|
|
912
|
+
from: "open",
|
|
913
|
+
to: "addressed",
|
|
914
|
+
by: "runtime",
|
|
915
|
+
round: params.round,
|
|
916
|
+
note: `judge[${params.judge_peer}]: ${rationale}`,
|
|
917
|
+
};
|
|
918
|
+
const history = meta.evidence_status_history ?? [];
|
|
919
|
+
history.push(entry);
|
|
920
|
+
meta.evidence_status_history = history;
|
|
921
|
+
meta.evidence_checklist = checklist;
|
|
922
|
+
meta.updated_at = ts;
|
|
923
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
924
|
+
return { item, history_entry: entry };
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
recoverInterruptedSessions(activeSessionIds = new Set()) {
|
|
928
|
+
const recovered = [];
|
|
929
|
+
for (const session of this.list()) {
|
|
930
|
+
if (session.outcome || activeSessionIds.has(session.session_id) || !session.in_flight)
|
|
931
|
+
continue;
|
|
932
|
+
const updated = this.withSessionLock(session.session_id, () => {
|
|
933
|
+
const current = this.read(session.session_id);
|
|
934
|
+
if (current.outcome || activeSessionIds.has(current.session_id) || !current.in_flight) {
|
|
935
|
+
return current;
|
|
936
|
+
}
|
|
937
|
+
const round = current.in_flight.round;
|
|
938
|
+
delete current.in_flight;
|
|
939
|
+
current.control = {
|
|
940
|
+
status: "recovered_after_restart",
|
|
941
|
+
reason: `Round ${round} was interrupted before completion and can be resumed manually.`,
|
|
942
|
+
updated_at: now(),
|
|
943
|
+
};
|
|
944
|
+
current.convergence_health = {
|
|
945
|
+
state: "stale",
|
|
946
|
+
last_event_at: now(),
|
|
947
|
+
detail: `Recovered interrupted round ${round} after MCP restart. Start a new round to continue from saved session context.`,
|
|
948
|
+
};
|
|
949
|
+
current.updated_at = now();
|
|
950
|
+
writeJson(this.metaPath(current.session_id), current);
|
|
951
|
+
return current;
|
|
952
|
+
});
|
|
953
|
+
recovered.push(updated);
|
|
954
|
+
}
|
|
955
|
+
return recovered;
|
|
956
|
+
}
|
|
957
|
+
// v2.12.0: walk session events.ndjson and aggregate
|
|
958
|
+
// `session.evidence_judge_pass.shadow_decision` events into a peer-keyed
|
|
959
|
+
// rollup. Operator observability: how many shadow decisions exist, what
|
|
960
|
+
// the would_promote rate looks like per judge_peer, what confidence
|
|
961
|
+
// distribution the judge returns. Walks the event log per session
|
|
962
|
+
// (O(events) per call); acceptable for v2.12 because the corpus is
|
|
963
|
+
// bounded (≤ a few hundred sessions historically) and the dashboard
|
|
964
|
+
// refreshes on demand.
|
|
965
|
+
aggregateShadowJudgments(sessionId) {
|
|
966
|
+
const sessions = sessionId ? [this.read(sessionId)] : this.list();
|
|
967
|
+
const byPeer = {};
|
|
968
|
+
let decisionsTotal = 0;
|
|
969
|
+
let wouldPromoteTotal = 0;
|
|
970
|
+
const peerKnown = PEERS;
|
|
971
|
+
for (const session of sessions) {
|
|
972
|
+
const events = this.readEvents(session.session_id);
|
|
973
|
+
for (const event of events) {
|
|
974
|
+
if (event.type !== "session.evidence_judge_pass.shadow_decision")
|
|
975
|
+
continue;
|
|
976
|
+
const data = (event.data ?? {});
|
|
977
|
+
const judgePeer = data.judge_peer;
|
|
978
|
+
if (!judgePeer || !peerKnown.includes(judgePeer))
|
|
979
|
+
continue;
|
|
980
|
+
let entry = byPeer[judgePeer];
|
|
981
|
+
if (!entry) {
|
|
982
|
+
entry = {
|
|
983
|
+
judge_peer: judgePeer,
|
|
984
|
+
decisions_total: 0,
|
|
985
|
+
would_promote: 0,
|
|
986
|
+
would_skip_satisfied_unverified: 0,
|
|
987
|
+
would_skip_not_satisfied: 0,
|
|
988
|
+
by_confidence: {},
|
|
989
|
+
first_seen_at: null,
|
|
990
|
+
last_seen_at: null,
|
|
991
|
+
};
|
|
992
|
+
byPeer[judgePeer] = entry;
|
|
993
|
+
}
|
|
994
|
+
entry.decisions_total += 1;
|
|
995
|
+
decisionsTotal += 1;
|
|
996
|
+
if (data.would_promote === true) {
|
|
997
|
+
entry.would_promote += 1;
|
|
998
|
+
wouldPromoteTotal += 1;
|
|
999
|
+
}
|
|
1000
|
+
else if (data.satisfied === true) {
|
|
1001
|
+
entry.would_skip_satisfied_unverified += 1;
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
entry.would_skip_not_satisfied += 1;
|
|
1005
|
+
}
|
|
1006
|
+
if (data.confidence === "verified" ||
|
|
1007
|
+
data.confidence === "inferred" ||
|
|
1008
|
+
data.confidence === "unknown") {
|
|
1009
|
+
entry.by_confidence[data.confidence] = (entry.by_confidence[data.confidence] ?? 0) + 1;
|
|
1010
|
+
}
|
|
1011
|
+
const ts = event.ts ?? null;
|
|
1012
|
+
if (ts) {
|
|
1013
|
+
if (!entry.first_seen_at || ts < entry.first_seen_at)
|
|
1014
|
+
entry.first_seen_at = ts;
|
|
1015
|
+
if (!entry.last_seen_at || ts > entry.last_seen_at)
|
|
1016
|
+
entry.last_seen_at = ts;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
decisions_total: decisionsTotal,
|
|
1022
|
+
would_promote_total: wouldPromoteTotal,
|
|
1023
|
+
by_judge_peer: byPeer,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
metrics(sessionId) {
|
|
1027
|
+
const sessions = sessionId ? [this.read(sessionId)] : this.list();
|
|
1028
|
+
const peerResults = {};
|
|
1029
|
+
const peerFailures = {};
|
|
1030
|
+
const decisionQuality = {};
|
|
1031
|
+
const peerLatencies = [];
|
|
1032
|
+
const generationLatencies = [];
|
|
1033
|
+
let moderationRecoveries = 0;
|
|
1034
|
+
let fallbackEvents = 0;
|
|
1035
|
+
const perPeer = {};
|
|
1036
|
+
const accumulator = (peer) => {
|
|
1037
|
+
let entry = perPeer[peer];
|
|
1038
|
+
if (!entry) {
|
|
1039
|
+
entry = {
|
|
1040
|
+
results_total: 0,
|
|
1041
|
+
ready_count: 0,
|
|
1042
|
+
not_ready_count: 0,
|
|
1043
|
+
needs_evidence_count: 0,
|
|
1044
|
+
unresolved_count: 0,
|
|
1045
|
+
cost_sum: 0,
|
|
1046
|
+
cost_count: 0,
|
|
1047
|
+
parser_warnings_total: 0,
|
|
1048
|
+
rejected_total: 0,
|
|
1049
|
+
failures_by_class: {},
|
|
1050
|
+
};
|
|
1051
|
+
perPeer[peer] = entry;
|
|
1052
|
+
}
|
|
1053
|
+
return entry;
|
|
1054
|
+
};
|
|
1055
|
+
for (const session of sessions) {
|
|
1056
|
+
fallbackEvents += session.fallback_events?.length ?? 0;
|
|
1057
|
+
for (const round of session.rounds) {
|
|
1058
|
+
for (const peer of round.peers) {
|
|
1059
|
+
peerResults[peer.peer] = (peerResults[peer.peer] ?? 0) + 1;
|
|
1060
|
+
const quality = peer.decision_quality ?? "failed";
|
|
1061
|
+
decisionQuality[quality] = (decisionQuality[quality] ?? 0) + 1;
|
|
1062
|
+
if (Number.isFinite(peer.latency_ms))
|
|
1063
|
+
peerLatencies.push(peer.latency_ms);
|
|
1064
|
+
if (peer.parser_warnings.some((warning) => warning.includes("moderation_safe_retry"))) {
|
|
1065
|
+
moderationRecoveries += 1;
|
|
1066
|
+
}
|
|
1067
|
+
const acc = accumulator(peer.peer);
|
|
1068
|
+
acc.results_total += 1;
|
|
1069
|
+
if (peer.status === "READY")
|
|
1070
|
+
acc.ready_count += 1;
|
|
1071
|
+
else if (peer.status === "NOT_READY")
|
|
1072
|
+
acc.not_ready_count += 1;
|
|
1073
|
+
else if (peer.status === "NEEDS_EVIDENCE")
|
|
1074
|
+
acc.needs_evidence_count += 1;
|
|
1075
|
+
else
|
|
1076
|
+
acc.unresolved_count += 1;
|
|
1077
|
+
if (peer.cost?.total_cost != null &&
|
|
1078
|
+
Number.isFinite(peer.cost.total_cost) &&
|
|
1079
|
+
peer.cost.source !== "stub") {
|
|
1080
|
+
acc.cost_sum += peer.cost.total_cost;
|
|
1081
|
+
acc.cost_count += 1;
|
|
1082
|
+
}
|
|
1083
|
+
acc.parser_warnings_total += peer.parser_warnings.length;
|
|
1084
|
+
}
|
|
1085
|
+
for (const failure of round.rejected) {
|
|
1086
|
+
peerFailures[failure.failure_class] = (peerFailures[failure.failure_class] ?? 0) + 1;
|
|
1087
|
+
const acc = accumulator(failure.peer);
|
|
1088
|
+
acc.rejected_total += 1;
|
|
1089
|
+
acc.failures_by_class[failure.failure_class] =
|
|
1090
|
+
(acc.failures_by_class[failure.failure_class] ?? 0) + 1;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
for (const generation of session.generation_files ?? []) {
|
|
1094
|
+
if (generation.latency_ms != null && Number.isFinite(generation.latency_ms)) {
|
|
1095
|
+
generationLatencies.push(generation.latency_ms);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const average = (values) => values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
|
1100
|
+
const perPeerHealth = {};
|
|
1101
|
+
for (const [peer, acc] of Object.entries(perPeer)) {
|
|
1102
|
+
const total = acc.results_total;
|
|
1103
|
+
perPeerHealth[peer] = {
|
|
1104
|
+
peer,
|
|
1105
|
+
results_total: total,
|
|
1106
|
+
ready_count: acc.ready_count,
|
|
1107
|
+
not_ready_count: acc.not_ready_count,
|
|
1108
|
+
needs_evidence_count: acc.needs_evidence_count,
|
|
1109
|
+
unresolved_count: acc.unresolved_count,
|
|
1110
|
+
ready_rate: total > 0 ? acc.ready_count / total : 0,
|
|
1111
|
+
needs_evidence_rate: total > 0 ? acc.needs_evidence_count / total : 0,
|
|
1112
|
+
avg_cost_usd: acc.cost_count > 0 ? acc.cost_sum / acc.cost_count : null,
|
|
1113
|
+
total_cost_usd: acc.cost_count > 0 ? acc.cost_sum : null,
|
|
1114
|
+
parser_warnings_total: acc.parser_warnings_total,
|
|
1115
|
+
rejected_total: acc.rejected_total,
|
|
1116
|
+
failures_by_class: acc.failures_by_class,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
return {
|
|
1120
|
+
generated_at: now(),
|
|
1121
|
+
scope: sessionId ? "session" : "all",
|
|
1122
|
+
session_id: sessionId,
|
|
1123
|
+
sessions: {
|
|
1124
|
+
total: sessions.length,
|
|
1125
|
+
converged: sessions.filter((session) => session.outcome === "converged").length,
|
|
1126
|
+
aborted: sessions.filter((session) => session.outcome === "aborted").length,
|
|
1127
|
+
max_rounds: sessions.filter((session) => session.outcome === "max-rounds").length,
|
|
1128
|
+
unfinished: sessions.filter((session) => !session.outcome).length,
|
|
1129
|
+
},
|
|
1130
|
+
rounds: sessions.reduce((sum, session) => sum + session.rounds.length, 0),
|
|
1131
|
+
peer_results: peerResults,
|
|
1132
|
+
peer_failures: peerFailures,
|
|
1133
|
+
decision_quality: decisionQuality,
|
|
1134
|
+
moderation_recoveries: moderationRecoveries,
|
|
1135
|
+
fallback_events: fallbackEvents,
|
|
1136
|
+
total_usage: mergeUsage(sessions.map((session) => session.totals.usage)),
|
|
1137
|
+
total_cost: mergeCost(sessions.map((session) => session.totals.cost)),
|
|
1138
|
+
latency_ms: {
|
|
1139
|
+
peer_average: average(peerLatencies),
|
|
1140
|
+
generation_average: average(generationLatencies),
|
|
1141
|
+
},
|
|
1142
|
+
per_peer_health: perPeerHealth,
|
|
1143
|
+
// v2.12.0: shadow_decision rollup. See aggregateShadowJudgments().
|
|
1144
|
+
shadow_judgment: this.aggregateShadowJudgments(sessionId),
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
// v2.16.0: read-only operational doctor. This is intentionally a
|
|
1148
|
+
// reporting surface, not a cleanup tool: it never finalizes, rewrites
|
|
1149
|
+
// or deletes sessions. Operators use it after audits to see which
|
|
1150
|
+
// sessions need human action and which records are legacy metadata
|
|
1151
|
+
// artifacts (for example caller==lead_peer before the petitioner/
|
|
1152
|
+
// relator split).
|
|
1153
|
+
//
|
|
1154
|
+
// v2.22.0 (A.P2): `includeLegacy` toggles per-session enumeration of
|
|
1155
|
+
// `findings.self_lead_metadata`. Default false because pre-v2.16.0
|
|
1156
|
+
// sessions carry the legacy self-lead artifact at a 38% hit rate
|
|
1157
|
+
// (178/467 in the May 2026 audit corpus); enumerating them every call
|
|
1158
|
+
// floods the response. `totals.self_lead_metadata` count remains
|
|
1159
|
+
// visible regardless. Pass `includeLegacy=true` to enumerate.
|
|
1160
|
+
//
|
|
1161
|
+
// v2.22.0 (B.P2): `findings.open_evidence_sessions[i]` entries gain
|
|
1162
|
+
// `item_types` (open items grouped by surfacing peer) and
|
|
1163
|
+
// `chronic_blockers` (item ids with `round_count >= 3`) so operators
|
|
1164
|
+
// can see which evidence asks are systemic vs cauda ruidosa.
|
|
1165
|
+
sessionDoctor(limit = 20, includeLegacy = false, repair = false) {
|
|
1166
|
+
const cappedLimit = Math.max(1, Math.min(100, Math.trunc(limit) || 20));
|
|
1167
|
+
// v3.6.0 (C): opt-in repair pass BEFORE the read-only audit. Fixes
|
|
1168
|
+
// the contradictory `outcome="converged" + health.state="blocked"`
|
|
1169
|
+
// state left on disk by pre-v3.2.0 sessions (v3.2.0 fixed the cause
|
|
1170
|
+
// via the finalize/appendRound invariants; old corrupt metas
|
|
1171
|
+
// persist). Only that specific contradiction is touched, only when
|
|
1172
|
+
// the operator explicitly passes `repair: true`. Recomputes
|
|
1173
|
+
// `convergence_health` from the latest round's `convergence.converged`.
|
|
1174
|
+
const repaired = [];
|
|
1175
|
+
if (repair) {
|
|
1176
|
+
for (const session of this.list()) {
|
|
1177
|
+
if (session.outcome === "converged" && session.convergence_health?.state === "blocked") {
|
|
1178
|
+
const latest = session.rounds.at(-1);
|
|
1179
|
+
const latestConverged = latest?.convergence?.converged === true;
|
|
1180
|
+
// Only repair when the latest round actually converged — i.e.
|
|
1181
|
+
// the `outcome="converged"` finalize was legitimate and only
|
|
1182
|
+
// the health field is the stale lie. If the latest round did
|
|
1183
|
+
// NOT converge, the contradiction is deeper and we leave it
|
|
1184
|
+
// for manual operator inspection rather than guessing.
|
|
1185
|
+
if (latestConverged) {
|
|
1186
|
+
const fromState = session.convergence_health?.state;
|
|
1187
|
+
const fixed = this.withSessionLock(session.session_id, () => {
|
|
1188
|
+
const meta = this.read(session.session_id);
|
|
1189
|
+
if (meta.outcome === "converged" &&
|
|
1190
|
+
meta.convergence_health?.state === "blocked" &&
|
|
1191
|
+
meta.rounds.at(-1)?.convergence?.converged === true) {
|
|
1192
|
+
meta.convergence_health = {
|
|
1193
|
+
state: "converged",
|
|
1194
|
+
last_event_at: now(),
|
|
1195
|
+
detail: `v3.6.0 doctor repair: recomputed health from latest round (was "blocked" with outcome="converged" — pre-v3.2.0 corruption artifact)`,
|
|
1196
|
+
};
|
|
1197
|
+
meta.updated_at = now();
|
|
1198
|
+
writeJson(this.metaPath(session.session_id), meta);
|
|
1199
|
+
return true;
|
|
1200
|
+
}
|
|
1201
|
+
return false;
|
|
1202
|
+
});
|
|
1203
|
+
if (fixed) {
|
|
1204
|
+
repaired.push({
|
|
1205
|
+
session_id: session.session_id,
|
|
1206
|
+
from_health_state: fromState,
|
|
1207
|
+
to_health_state: "converged",
|
|
1208
|
+
reason: "outcome=converged but health=blocked; latest round has convergence.converged=true — recomputed health",
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const sessions = this.list();
|
|
1216
|
+
const openSessions = [];
|
|
1217
|
+
const staleSessions = [];
|
|
1218
|
+
const blockedSessions = [];
|
|
1219
|
+
const maxRoundsSessions = [];
|
|
1220
|
+
const selfLeadMetadata = [];
|
|
1221
|
+
const openEvidenceSessions = [];
|
|
1222
|
+
const grokProviderErrorSessions = [];
|
|
1223
|
+
const eventReadErrorSessions = [];
|
|
1224
|
+
let eventsTotal = 0;
|
|
1225
|
+
let tokenDeltaEvents = 0;
|
|
1226
|
+
let tokenCompletedEvents = 0;
|
|
1227
|
+
const pushLimited = (target, entry) => {
|
|
1228
|
+
if (target.length < cappedLimit)
|
|
1229
|
+
target.push(entry);
|
|
1230
|
+
};
|
|
1231
|
+
for (const session of sessions) {
|
|
1232
|
+
const scope = session.convergence_scope;
|
|
1233
|
+
const petitioner = scope?.petitioner ?? scope?.caller ?? session.caller;
|
|
1234
|
+
const leadPeer = scope?.lead_peer;
|
|
1235
|
+
const evidenceList = session.evidence_checklist ?? [];
|
|
1236
|
+
const openEvidenceItemsList = evidenceList.filter((item) => (item.status ?? "open") === "open");
|
|
1237
|
+
const openEvidenceItems = openEvidenceItemsList.length;
|
|
1238
|
+
const grokProviderErrors = (session.failed_attempts ?? []).filter((failure) => failure.peer === "grok" && failure.failure_class === "provider_error").length;
|
|
1239
|
+
const entry = {
|
|
1240
|
+
session_id: session.session_id,
|
|
1241
|
+
version: session.version,
|
|
1242
|
+
caller: session.caller,
|
|
1243
|
+
petitioner,
|
|
1244
|
+
lead_peer: leadPeer,
|
|
1245
|
+
outcome: session.outcome,
|
|
1246
|
+
outcome_reason: session.outcome_reason,
|
|
1247
|
+
health_state: session.convergence_health?.state,
|
|
1248
|
+
health_detail: session.convergence_health?.detail,
|
|
1249
|
+
rounds: session.rounds.length,
|
|
1250
|
+
updated_at: session.updated_at,
|
|
1251
|
+
...(openEvidenceItems > 0 ? { open_evidence_items: openEvidenceItems } : {}),
|
|
1252
|
+
...(grokProviderErrors > 0 ? { grok_provider_errors: grokProviderErrors } : {}),
|
|
1253
|
+
};
|
|
1254
|
+
// v2.22.0 (B.P2): drill-down for open-evidence entries. Aggregate
|
|
1255
|
+
// open items by peer + flag chronic blockers (round_count >= 3).
|
|
1256
|
+
if (openEvidenceItems > 0) {
|
|
1257
|
+
const itemTypes = {};
|
|
1258
|
+
const chronicBlockers = [];
|
|
1259
|
+
for (const item of openEvidenceItemsList) {
|
|
1260
|
+
itemTypes[item.peer] = (itemTypes[item.peer] ?? 0) + 1;
|
|
1261
|
+
if (item.round_count >= 3) {
|
|
1262
|
+
chronicBlockers.push(item.id);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
entry.item_types = itemTypes;
|
|
1266
|
+
entry.chronic_blockers = chronicBlockers;
|
|
1267
|
+
}
|
|
1268
|
+
// v3.7.5 (A1, logs+sessions study 2026-05-15): terminal outcomes
|
|
1269
|
+
// are NEVER stale or blocked — they are DONE. Pre-v3.7.5 the
|
|
1270
|
+
// doctor classified solely on `convergence_health.state` which
|
|
1271
|
+
// markCancelled writes as "stale" on `outcome="aborted"`. Result:
|
|
1272
|
+
// 22 cancelled sessions of 244 (9%) were flagged as needing
|
|
1273
|
+
// attention when they were terminal. Likewise the v3.6.0 repair
|
|
1274
|
+
// path was the symmetric symptom for `outcome="converged" +
|
|
1275
|
+
// state="blocked"`. The classification fix keeps backward compat
|
|
1276
|
+
// with the 244 existing sessions on disk (no migration) and only
|
|
1277
|
+
// recognizes the truth at the consumer layer: if the session has
|
|
1278
|
+
// a terminal outcome, do not flag it as stale or blocked.
|
|
1279
|
+
const isTerminal = session.outcome != null;
|
|
1280
|
+
if (!session.outcome)
|
|
1281
|
+
pushLimited(openSessions, entry);
|
|
1282
|
+
if (!isTerminal && session.convergence_health?.state === "stale")
|
|
1283
|
+
pushLimited(staleSessions, entry);
|
|
1284
|
+
if (!isTerminal && session.convergence_health?.state === "blocked")
|
|
1285
|
+
pushLimited(blockedSessions, entry);
|
|
1286
|
+
if (session.outcome === "max-rounds")
|
|
1287
|
+
pushLimited(maxRoundsSessions, entry);
|
|
1288
|
+
if (petitioner && leadPeer && petitioner === leadPeer)
|
|
1289
|
+
pushLimited(selfLeadMetadata, entry);
|
|
1290
|
+
if (openEvidenceItems > 0)
|
|
1291
|
+
pushLimited(openEvidenceSessions, entry);
|
|
1292
|
+
if (grokProviderErrors > 0)
|
|
1293
|
+
pushLimited(grokProviderErrorSessions, entry);
|
|
1294
|
+
let sessionEvents = [];
|
|
1295
|
+
try {
|
|
1296
|
+
sessionEvents = this.readEvents(session.session_id);
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
entry.event_read_error = redact(error instanceof Error ? error.message : String(error));
|
|
1300
|
+
pushLimited(eventReadErrorSessions, entry);
|
|
1301
|
+
}
|
|
1302
|
+
for (const event of sessionEvents) {
|
|
1303
|
+
eventsTotal += 1;
|
|
1304
|
+
if (event.type === "peer.token.delta")
|
|
1305
|
+
tokenDeltaEvents += 1;
|
|
1306
|
+
if (event.type === "peer.token.completed")
|
|
1307
|
+
tokenCompletedEvents += 1;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// v2.22.0 (A.P2): compute the headline self_lead_metadata count
|
|
1311
|
+
// BEFORE deciding whether to suppress the per-session array, so
|
|
1312
|
+
// `totals.self_lead_metadata` always reflects reality even when the
|
|
1313
|
+
// findings array is empty.
|
|
1314
|
+
const selfLeadCount = sessions.filter((session) => {
|
|
1315
|
+
const scope = session.convergence_scope;
|
|
1316
|
+
const petitioner = scope?.petitioner ?? scope?.caller ?? session.caller;
|
|
1317
|
+
return Boolean(petitioner && scope?.lead_peer && petitioner === scope.lead_peer);
|
|
1318
|
+
}).length;
|
|
1319
|
+
const recommendations = [];
|
|
1320
|
+
if (openSessions.length > 0) {
|
|
1321
|
+
recommendations.push("Review open_sessions first; finalize, contest, cancel or explicitly continue each live case.");
|
|
1322
|
+
}
|
|
1323
|
+
if (selfLeadCount > 0) {
|
|
1324
|
+
// Recommendation fires off the headline count, not the in-array
|
|
1325
|
+
// count, so operators are still nudged when the array is hidden.
|
|
1326
|
+
const baseAdvice = "Treat self_lead_metadata as legacy/protocol-drift evidence; do not rewrite historical records automatically.";
|
|
1327
|
+
if (!includeLegacy) {
|
|
1328
|
+
recommendations.push(`${baseAdvice} ${selfLeadCount} legacy sessions hidden by default — pass include_legacy=true to enumerate.`);
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
recommendations.push(baseAdvice);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (openEvidenceSessions.length > 0) {
|
|
1335
|
+
recommendations.push("Address or explicitly terminal-mark open evidence checklist items before expecting convergence.");
|
|
1336
|
+
}
|
|
1337
|
+
if (grokProviderErrorSessions.length > 0) {
|
|
1338
|
+
recommendations.push("Run a Grok-specific smoke/probe for sessions with grok provider errors before relying on Grok in release gates.");
|
|
1339
|
+
}
|
|
1340
|
+
if (eventReadErrorSessions.length > 0) {
|
|
1341
|
+
recommendations.push("Inspect event_read_error_sessions manually; malformed events.ndjson records were skipped for doctor aggregation but not modified.");
|
|
1342
|
+
}
|
|
1343
|
+
if (eventsTotal > 0 && tokenDeltaEvents / eventsTotal > 0.5) {
|
|
1344
|
+
recommendations.push("Token delta events dominate this corpus; increase CROSS_REVIEW_TOKEN_DELTA_CHARS_THRESHOLD or disable token streaming for low-noise audits.");
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
generated_at: now(),
|
|
1348
|
+
scope: "all",
|
|
1349
|
+
limit: cappedLimit,
|
|
1350
|
+
totals: {
|
|
1351
|
+
sessions: sessions.length,
|
|
1352
|
+
open: sessions.filter((session) => !session.outcome).length,
|
|
1353
|
+
stale: sessions.filter((session) => session.convergence_health?.state === "stale").length,
|
|
1354
|
+
blocked: sessions.filter((session) => session.convergence_health?.state === "blocked")
|
|
1355
|
+
.length,
|
|
1356
|
+
max_rounds: sessions.filter((session) => session.outcome === "max-rounds").length,
|
|
1357
|
+
self_lead_metadata: selfLeadCount,
|
|
1358
|
+
open_evidence_sessions: sessions.filter((session) => (session.evidence_checklist ?? []).some((item) => (item.status ?? "open") === "open")).length,
|
|
1359
|
+
grok_provider_error_sessions: sessions.filter((session) => (session.failed_attempts ?? []).some((failure) => failure.peer === "grok" && failure.failure_class === "provider_error")).length,
|
|
1360
|
+
event_read_error_sessions: eventReadErrorSessions.length,
|
|
1361
|
+
},
|
|
1362
|
+
findings: {
|
|
1363
|
+
open_sessions: openSessions,
|
|
1364
|
+
stale_sessions: staleSessions,
|
|
1365
|
+
blocked_sessions: blockedSessions,
|
|
1366
|
+
max_rounds_sessions: maxRoundsSessions,
|
|
1367
|
+
// v2.22.0 (A.P2): suppress per-session enumeration unless
|
|
1368
|
+
// operator passes include_legacy=true. Headline count remains
|
|
1369
|
+
// in `totals.self_lead_metadata`.
|
|
1370
|
+
self_lead_metadata: includeLegacy ? selfLeadMetadata : [],
|
|
1371
|
+
open_evidence_sessions: openEvidenceSessions,
|
|
1372
|
+
grok_provider_error_sessions: grokProviderErrorSessions,
|
|
1373
|
+
event_read_error_sessions: eventReadErrorSessions,
|
|
1374
|
+
},
|
|
1375
|
+
event_noise: {
|
|
1376
|
+
events_total: eventsTotal,
|
|
1377
|
+
token_delta_events: tokenDeltaEvents,
|
|
1378
|
+
token_completed_events: tokenCompletedEvents,
|
|
1379
|
+
token_delta_ratio: eventsTotal > 0 ? tokenDeltaEvents / eventsTotal : null,
|
|
1380
|
+
},
|
|
1381
|
+
recommendations,
|
|
1382
|
+
// v3.6.0 (C): only present when repair was requested; lists the
|
|
1383
|
+
// converged+blocked contradictions that were recomputed.
|
|
1384
|
+
...(repair ? { repaired } : {}),
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
// v2.14.0 (item 1): compute precision/recall/F1 for the shadow judge
|
|
1388
|
+
// against empirical ground truth (whether peers raised the same ask
|
|
1389
|
+
// in a subsequent round). Walks events.ndjson per session, finds each
|
|
1390
|
+
// `session.evidence_judge_pass.shadow_decision` event, looks up the
|
|
1391
|
+
// matching item in `meta.evidence_checklist` by id, and classifies
|
|
1392
|
+
// based on (would_promote x ask_resurfaced). Returns per-peer rollup.
|
|
1393
|
+
computeJudgmentPrecisionReport(opts) {
|
|
1394
|
+
const sessions = opts?.session_id ? [this.read(opts.session_id)] : this.list();
|
|
1395
|
+
const peerKnown = PEERS;
|
|
1396
|
+
const byPeer = {};
|
|
1397
|
+
let totalDecisions = 0;
|
|
1398
|
+
let totalWithGroundTruth = 0;
|
|
1399
|
+
let totalSkippedNoGT = 0;
|
|
1400
|
+
const acc = (peer) => {
|
|
1401
|
+
let entry = byPeer[peer];
|
|
1402
|
+
if (!entry) {
|
|
1403
|
+
entry = {
|
|
1404
|
+
judge_peer: peer,
|
|
1405
|
+
decisions_total: 0,
|
|
1406
|
+
decisions_with_ground_truth: 0,
|
|
1407
|
+
decisions_skipped_no_ground_truth: 0,
|
|
1408
|
+
true_positive: 0,
|
|
1409
|
+
false_positive: 0,
|
|
1410
|
+
true_negative: 0,
|
|
1411
|
+
false_negative: 0,
|
|
1412
|
+
precision: null,
|
|
1413
|
+
recall: null,
|
|
1414
|
+
f1: null,
|
|
1415
|
+
by_confidence: {},
|
|
1416
|
+
};
|
|
1417
|
+
byPeer[peer] = entry;
|
|
1418
|
+
}
|
|
1419
|
+
return entry;
|
|
1420
|
+
};
|
|
1421
|
+
for (const session of sessions) {
|
|
1422
|
+
const events = this.readEvents(session.session_id);
|
|
1423
|
+
const checklist = session.evidence_checklist ?? [];
|
|
1424
|
+
const itemById = new Map();
|
|
1425
|
+
for (const item of checklist)
|
|
1426
|
+
itemById.set(item.id, item);
|
|
1427
|
+
const maxRound = session.rounds.length;
|
|
1428
|
+
for (const event of events) {
|
|
1429
|
+
if (event.type !== "session.evidence_judge_pass.shadow_decision")
|
|
1430
|
+
continue;
|
|
1431
|
+
const data = (event.data ?? {});
|
|
1432
|
+
const judgePeer = data.judge_peer;
|
|
1433
|
+
if (!judgePeer || !peerKnown.includes(judgePeer))
|
|
1434
|
+
continue;
|
|
1435
|
+
if (opts?.peer && judgePeer !== opts.peer)
|
|
1436
|
+
continue;
|
|
1437
|
+
if (opts?.since && event.ts && event.ts < opts.since)
|
|
1438
|
+
continue;
|
|
1439
|
+
const itemId = data.item_id;
|
|
1440
|
+
if (!itemId)
|
|
1441
|
+
continue;
|
|
1442
|
+
const item = itemById.get(itemId);
|
|
1443
|
+
if (!item)
|
|
1444
|
+
continue;
|
|
1445
|
+
const judgeRound = event.round ?? item.last_round;
|
|
1446
|
+
const peerStats = acc(judgePeer);
|
|
1447
|
+
peerStats.decisions_total += 1;
|
|
1448
|
+
totalDecisions += 1;
|
|
1449
|
+
// Ground truth: did the ask resurface AFTER the judge ran?
|
|
1450
|
+
// last_round > judgeRound → resurfaced. last_round === judgeRound
|
|
1451
|
+
// AND maxRound > judgeRound → not resurfaced (we have evidence
|
|
1452
|
+
// peers had a chance to ask again and didn't). last_round ===
|
|
1453
|
+
// judgeRound AND maxRound === judgeRound → no ground truth.
|
|
1454
|
+
const resurfaced = item.last_round > judgeRound;
|
|
1455
|
+
const peersHadChance = maxRound > judgeRound;
|
|
1456
|
+
if (!resurfaced && !peersHadChance) {
|
|
1457
|
+
peerStats.decisions_skipped_no_ground_truth += 1;
|
|
1458
|
+
totalSkippedNoGT += 1;
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
peerStats.decisions_with_ground_truth += 1;
|
|
1462
|
+
totalWithGroundTruth += 1;
|
|
1463
|
+
const wouldPromote = data.would_promote === true;
|
|
1464
|
+
let bucket;
|
|
1465
|
+
if (wouldPromote && !resurfaced)
|
|
1466
|
+
bucket = "tp";
|
|
1467
|
+
else if (wouldPromote && resurfaced)
|
|
1468
|
+
bucket = "fp";
|
|
1469
|
+
else if (!wouldPromote && resurfaced)
|
|
1470
|
+
bucket = "tn";
|
|
1471
|
+
else
|
|
1472
|
+
bucket = "fn";
|
|
1473
|
+
if (bucket === "tp")
|
|
1474
|
+
peerStats.true_positive += 1;
|
|
1475
|
+
else if (bucket === "fp")
|
|
1476
|
+
peerStats.false_positive += 1;
|
|
1477
|
+
else if (bucket === "tn")
|
|
1478
|
+
peerStats.true_negative += 1;
|
|
1479
|
+
else
|
|
1480
|
+
peerStats.false_negative += 1;
|
|
1481
|
+
if (data.confidence) {
|
|
1482
|
+
let bc = peerStats.by_confidence[data.confidence];
|
|
1483
|
+
if (!bc) {
|
|
1484
|
+
bc = { tp: 0, fp: 0, tn: 0, fn: 0 };
|
|
1485
|
+
peerStats.by_confidence[data.confidence] = bc;
|
|
1486
|
+
}
|
|
1487
|
+
bc[bucket] += 1;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
// Compute precision/recall/f1 per peer.
|
|
1492
|
+
for (const peer of Object.keys(byPeer)) {
|
|
1493
|
+
const stats = byPeer[peer];
|
|
1494
|
+
if (!stats)
|
|
1495
|
+
continue;
|
|
1496
|
+
const tp = stats.true_positive;
|
|
1497
|
+
const fp = stats.false_positive;
|
|
1498
|
+
const fn = stats.false_negative;
|
|
1499
|
+
stats.precision = tp + fp > 0 ? tp / (tp + fp) : null;
|
|
1500
|
+
stats.recall = tp + fn > 0 ? tp / (tp + fn) : null;
|
|
1501
|
+
stats.f1 =
|
|
1502
|
+
stats.precision != null && stats.recall != null && stats.precision + stats.recall > 0
|
|
1503
|
+
? (2 * stats.precision * stats.recall) / (stats.precision + stats.recall)
|
|
1504
|
+
: null;
|
|
1505
|
+
}
|
|
1506
|
+
return {
|
|
1507
|
+
generated_at: now(),
|
|
1508
|
+
peer_filter: opts?.peer,
|
|
1509
|
+
since_filter: opts?.since,
|
|
1510
|
+
session_filter: opts?.session_id,
|
|
1511
|
+
decisions_total: totalDecisions,
|
|
1512
|
+
decisions_with_ground_truth: totalWithGroundTruth,
|
|
1513
|
+
decisions_skipped_no_ground_truth: totalSkippedNoGT,
|
|
1514
|
+
by_judge_peer: byPeer,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
// v2.14.0 (path-A structural fix): resolve `meta.evidence_files[]`
|
|
1518
|
+
// entries into in-memory contents for inlining into peer prompts.
|
|
1519
|
+
// Reads each attachment from disk, applies a per-file cap (60% of the
|
|
1520
|
+
// total cap to leave room for at least 1 other attachment + headers),
|
|
1521
|
+
// accumulates into a total-cap, and returns whatever fits. Order
|
|
1522
|
+
// preserved (oldest attachment first). Files that cannot be read
|
|
1523
|
+
// (deleted, permission denied) are skipped silently — the caller
|
|
1524
|
+
// sees only the metadata that survived. This closes the recurring
|
|
1525
|
+
// "meta-channel limit" pattern (v2.5.0, v2.13.0) where codex demanded
|
|
1526
|
+
// evidence the MCP `caller → server` 200KB channel could not carry:
|
|
1527
|
+
// the file content already lives in `data_dir/sessions/<id>/evidence/`
|
|
1528
|
+
// by the time we inline, so the only constraint is the peer model's
|
|
1529
|
+
// context window — much larger than the MCP boundary.
|
|
1530
|
+
readEvidenceAttachments(sessionId, totalCapChars) {
|
|
1531
|
+
if (!Number.isFinite(totalCapChars) || totalCapChars <= 0)
|
|
1532
|
+
return [];
|
|
1533
|
+
const meta = this.read(sessionId);
|
|
1534
|
+
const files = meta.evidence_files ?? [];
|
|
1535
|
+
if (!files.length)
|
|
1536
|
+
return [];
|
|
1537
|
+
const perFileCap = Math.max(2_000, Math.floor(totalCapChars * 0.6));
|
|
1538
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
1539
|
+
const result = [];
|
|
1540
|
+
let used = 0;
|
|
1541
|
+
for (const file of files) {
|
|
1542
|
+
const absolutePath = path.resolve(sessionDir, file.path);
|
|
1543
|
+
if (!this.isPathContained(sessionDir, absolutePath))
|
|
1544
|
+
continue;
|
|
1545
|
+
let raw;
|
|
1546
|
+
try {
|
|
1547
|
+
raw = fs.readFileSync(absolutePath, "utf8");
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
const remaining = totalCapChars - used;
|
|
1553
|
+
if (remaining <= 0)
|
|
1554
|
+
break;
|
|
1555
|
+
const cap = Math.min(perFileCap, remaining);
|
|
1556
|
+
const truncated = raw.length > cap;
|
|
1557
|
+
const slice = truncated ? raw.slice(0, cap) : raw;
|
|
1558
|
+
result.push({
|
|
1559
|
+
label: file.label,
|
|
1560
|
+
relative_path: file.path,
|
|
1561
|
+
content: slice,
|
|
1562
|
+
bytes: raw.length,
|
|
1563
|
+
truncated,
|
|
1564
|
+
content_type: file.content_type,
|
|
1565
|
+
});
|
|
1566
|
+
used += slice.length;
|
|
1567
|
+
}
|
|
1568
|
+
return result;
|
|
1569
|
+
}
|
|
1570
|
+
// v2.14.0 (item 4): contest a final verdict. Stamps the contested
|
|
1571
|
+
// session's meta with the contestation record AND initializes a new
|
|
1572
|
+
// session that references back. Validates the original session is
|
|
1573
|
+
// in a final state (converged | aborted | max-rounds). Per the
|
|
1574
|
+
// tribunal-colegiado memory, this is the canonical "caller NOT_READY
|
|
1575
|
+
// → novo ciclo deliberativo dentro dos mesmos autos" surface — the
|
|
1576
|
+
// original session is preserved (append-only); a new session opens
|
|
1577
|
+
// for re-deliberation with a fresh task + initial_draft and a
|
|
1578
|
+
// structural reference back to the contested session.
|
|
1579
|
+
contestVerdict(params) {
|
|
1580
|
+
const original = this.read(params.session_id);
|
|
1581
|
+
if (!original.outcome) {
|
|
1582
|
+
throw new Error(`cannot_contest_in_flight_session: session ${params.session_id} has no outcome yet (still in flight). Wait for it to converge or finalize before contesting.`);
|
|
1583
|
+
}
|
|
1584
|
+
if (original.contestation) {
|
|
1585
|
+
throw new Error(`session_already_contested: session ${params.session_id} was already contested at ${original.contestation.contested_at} (new_session_id=${original.contestation.new_session_id}).`);
|
|
1586
|
+
}
|
|
1587
|
+
const newCaller = params.new_caller ?? "operator";
|
|
1588
|
+
const newSession = this.init(params.new_task, newCaller, [], undefined);
|
|
1589
|
+
// Cross-link new session → original.
|
|
1590
|
+
this.withSessionLock(newSession.session_id, () => {
|
|
1591
|
+
const m = this.read(newSession.session_id);
|
|
1592
|
+
m.contests_session_id = params.session_id;
|
|
1593
|
+
m.updated_at = now();
|
|
1594
|
+
writeJson(this.metaPath(newSession.session_id), m);
|
|
1595
|
+
return m;
|
|
1596
|
+
});
|
|
1597
|
+
// Stamp original with contestation record.
|
|
1598
|
+
const contestedMeta = this.withSessionLock(params.session_id, () => {
|
|
1599
|
+
const m = this.read(params.session_id);
|
|
1600
|
+
m.contestation = {
|
|
1601
|
+
contested_at: now(),
|
|
1602
|
+
reason: params.reason,
|
|
1603
|
+
original_outcome: m.outcome ?? null,
|
|
1604
|
+
new_session_id: newSession.session_id,
|
|
1605
|
+
};
|
|
1606
|
+
m.updated_at = now();
|
|
1607
|
+
writeJson(this.metaPath(params.session_id), m);
|
|
1608
|
+
return m;
|
|
1609
|
+
});
|
|
1610
|
+
return { contested_meta: contestedMeta, new_session_id: newSession.session_id };
|
|
1611
|
+
}
|
|
1612
|
+
attachEvidence(sessionId, params) {
|
|
1613
|
+
const extension = safeFilePart(params.extension ?? "txt").replace(/\./g, "") || "txt";
|
|
1614
|
+
const label = safeFilePart(params.label);
|
|
1615
|
+
const relativePath = `evidence/${timestampFilePart()}-${label}.${extension}`;
|
|
1616
|
+
const file = path.join(this.sessionDir(sessionId), relativePath);
|
|
1617
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1618
|
+
fs.writeFileSync(file, redact(params.content), "utf8");
|
|
1619
|
+
const meta = this.withSessionLock(sessionId, () => {
|
|
1620
|
+
const current = this.read(sessionId);
|
|
1621
|
+
current.evidence_files = [
|
|
1622
|
+
...(current.evidence_files ?? []),
|
|
1623
|
+
{
|
|
1624
|
+
ts: now(),
|
|
1625
|
+
label: params.label,
|
|
1626
|
+
path: relativePath.replace(/\\/g, "/"),
|
|
1627
|
+
content_type: params.content_type,
|
|
1628
|
+
},
|
|
1629
|
+
];
|
|
1630
|
+
current.updated_at = now();
|
|
1631
|
+
writeJson(this.metaPath(sessionId), current);
|
|
1632
|
+
return current;
|
|
1633
|
+
});
|
|
1634
|
+
return { path: relativePath.replace(/\\/g, "/"), meta };
|
|
1635
|
+
}
|
|
1636
|
+
escalateToOperator(sessionId, params) {
|
|
1637
|
+
return this.withSessionLock(sessionId, () => {
|
|
1638
|
+
const meta = this.read(sessionId);
|
|
1639
|
+
meta.operator_escalations = [
|
|
1640
|
+
...(meta.operator_escalations ?? []),
|
|
1641
|
+
{ ts: now(), reason: params.reason, severity: params.severity },
|
|
1642
|
+
];
|
|
1643
|
+
meta.convergence_health = {
|
|
1644
|
+
state: meta.outcome === "converged" ? "converged" : "blocked",
|
|
1645
|
+
last_event_at: now(),
|
|
1646
|
+
detail: `Operator escalation requested: ${params.reason}`,
|
|
1647
|
+
};
|
|
1648
|
+
meta.updated_at = now();
|
|
1649
|
+
writeJson(this.metaPath(sessionId), meta);
|
|
1650
|
+
return meta;
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
sweepIdle(idleMs, outcome = "aborted", reason = "stale") {
|
|
1654
|
+
const effectiveIdleMs = Math.max(idleMs, SWEEP_MIN_IDLE_MS);
|
|
1655
|
+
const nowMs = Date.now();
|
|
1656
|
+
const swept = [];
|
|
1657
|
+
for (const session of this.list()) {
|
|
1658
|
+
if (session.outcome)
|
|
1659
|
+
continue;
|
|
1660
|
+
const updatedAt = Date.parse(session.updated_at);
|
|
1661
|
+
const idleFor = Number.isFinite(updatedAt) ? nowMs - updatedAt : Infinity;
|
|
1662
|
+
if (idleFor < effectiveIdleMs)
|
|
1663
|
+
continue;
|
|
1664
|
+
const finalized = this.withSessionLock(session.session_id, () => {
|
|
1665
|
+
const current = this.read(session.session_id);
|
|
1666
|
+
current.outcome = outcome;
|
|
1667
|
+
current.outcome_reason = reason;
|
|
1668
|
+
delete current.in_flight;
|
|
1669
|
+
current.convergence_health = {
|
|
1670
|
+
state: "stale",
|
|
1671
|
+
last_event_at: now(),
|
|
1672
|
+
detail: reason,
|
|
1673
|
+
idle_ms: idleFor,
|
|
1674
|
+
};
|
|
1675
|
+
current.updated_at = now();
|
|
1676
|
+
writeJson(this.metaPath(session.session_id), current);
|
|
1677
|
+
return current;
|
|
1678
|
+
});
|
|
1679
|
+
swept.push(finalized);
|
|
1680
|
+
}
|
|
1681
|
+
return swept;
|
|
1682
|
+
}
|
|
1683
|
+
// v2.4.0 / audit closure (P1.3 companion): boot sweep of orphan .tmp
|
|
1684
|
+
// files. Crashes inside writeJson (between writeFileSync and renameSync)
|
|
1685
|
+
// leave files matching `<basename>.<pid>.<ts>.<nonce>.tmp` in the session
|
|
1686
|
+
// directory. Walk every session dir at boot, drop files matching the
|
|
1687
|
+
// .tmp pattern whose holder pid is dead OR whose timestamp is older than
|
|
1688
|
+
// 1h. Idempotent + best-effort. Returns counts for telemetry.
|
|
1689
|
+
// v3.7.5 (B1, logs+sessions study 2026-05-15): prune the
|
|
1690
|
+
// `<data_dir>/corrupt_sessions/` quarantine directory. Created
|
|
1691
|
+
// historically when meta.json corruption was severe enough to move
|
|
1692
|
+
// the whole session dir (one such case from the 2026-05-08 v2.25.1
|
|
1693
|
+
// redact escape-boundary bug remains on disk). Pre-v3.7.5 there was
|
|
1694
|
+
// no automated cleanup — the entries accumulated forever even after
|
|
1695
|
+
// root-cause fixes shipped. This method scans the directory and
|
|
1696
|
+
// removes subdirectories whose mtime is older than `minAgeMs`,
|
|
1697
|
+
// leaving fresher cases for forensic inspection. Read-only when the
|
|
1698
|
+
// dir does not exist. Errors per-entry are swallowed and surface as
|
|
1699
|
+
// `kept` so a single permission failure doesn't abort the sweep.
|
|
1700
|
+
pruneCorruptSessions(minAgeMs) {
|
|
1701
|
+
const corruptDir = path.join(this.config.data_dir, "corrupt_sessions");
|
|
1702
|
+
if (!fs.existsSync(corruptDir))
|
|
1703
|
+
return { scanned: 0, removed: 0, kept: 0 };
|
|
1704
|
+
let entries;
|
|
1705
|
+
try {
|
|
1706
|
+
entries = fs.readdirSync(corruptDir, { withFileTypes: true });
|
|
1707
|
+
}
|
|
1708
|
+
catch {
|
|
1709
|
+
return { scanned: 0, removed: 0, kept: 0 };
|
|
1710
|
+
}
|
|
1711
|
+
const cutoff = Date.now() - Math.max(0, minAgeMs);
|
|
1712
|
+
let scanned = 0;
|
|
1713
|
+
let removed = 0;
|
|
1714
|
+
let kept = 0;
|
|
1715
|
+
for (const ent of entries) {
|
|
1716
|
+
if (!ent.isDirectory())
|
|
1717
|
+
continue;
|
|
1718
|
+
scanned += 1;
|
|
1719
|
+
const entryPath = path.join(corruptDir, ent.name);
|
|
1720
|
+
let mtimeMs;
|
|
1721
|
+
try {
|
|
1722
|
+
mtimeMs = fs.statSync(entryPath).mtimeMs;
|
|
1723
|
+
}
|
|
1724
|
+
catch {
|
|
1725
|
+
kept += 1;
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
if (mtimeMs > cutoff) {
|
|
1729
|
+
kept += 1;
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
try {
|
|
1733
|
+
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
1734
|
+
removed += 1;
|
|
1735
|
+
}
|
|
1736
|
+
catch {
|
|
1737
|
+
kept += 1;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return { scanned, removed, kept };
|
|
1741
|
+
}
|
|
1742
|
+
sweepOrphanTmpFiles() {
|
|
1743
|
+
let scanned = 0;
|
|
1744
|
+
let removed = 0;
|
|
1745
|
+
const root = this.sessionsDir();
|
|
1746
|
+
if (!fs.existsSync(root))
|
|
1747
|
+
return { scanned, removed };
|
|
1748
|
+
let entries;
|
|
1749
|
+
try {
|
|
1750
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1751
|
+
}
|
|
1752
|
+
catch {
|
|
1753
|
+
return { scanned, removed };
|
|
1754
|
+
}
|
|
1755
|
+
for (const ent of entries) {
|
|
1756
|
+
if (!ent.isDirectory())
|
|
1757
|
+
continue;
|
|
1758
|
+
const sessionPath = path.join(root, ent.name);
|
|
1759
|
+
let files;
|
|
1760
|
+
try {
|
|
1761
|
+
files = fs.readdirSync(sessionPath);
|
|
1762
|
+
}
|
|
1763
|
+
catch {
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
for (const f of files) {
|
|
1767
|
+
const m = TMP_FILE_PATTERN.exec(f);
|
|
1768
|
+
if (!m)
|
|
1769
|
+
continue;
|
|
1770
|
+
scanned += 1;
|
|
1771
|
+
const tmpPid = Number.parseInt(m[1] ?? "", 10);
|
|
1772
|
+
const tmpTs = Number.parseInt(m[2] ?? "", 10);
|
|
1773
|
+
const tmpAge = Date.now() - tmpTs;
|
|
1774
|
+
const holderAlive = Number.isInteger(tmpPid) ? this.processAlive(tmpPid) : false;
|
|
1775
|
+
if (!holderAlive || tmpAge > TMP_STALE_AFTER_MS) {
|
|
1776
|
+
try {
|
|
1777
|
+
fs.unlinkSync(path.join(sessionPath, f));
|
|
1778
|
+
removed += 1;
|
|
1779
|
+
}
|
|
1780
|
+
catch {
|
|
1781
|
+
/* ignore */
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return { scanned, removed };
|
|
1787
|
+
}
|
|
1788
|
+
// v2.4.0 / audit closure (P3.11): clear stale meta.in_flight at boot.
|
|
1789
|
+
// `markInFlight` sets meta.in_flight before each round and clearInFlight
|
|
1790
|
+
// is supposed to clear it on resolve/reject. If the host crashes
|
|
1791
|
+
// mid-spawn, in_flight stays set forever — confusing audit consumers
|
|
1792
|
+
// and `recoverInterruptedSessions` consumers that read it as "round in
|
|
1793
|
+
// progress". sweepIdle clears in_flight only after 24h idle (footgun
|
|
1794
|
+
// floor). This companion sweep covers the common host-crash case where
|
|
1795
|
+
// we want to reconcile in_flight as soon as the new boot starts, not
|
|
1796
|
+
// after a day. Conditions to clear:
|
|
1797
|
+
// - holder pid (lock holder, if any) is dead, OR
|
|
1798
|
+
// - in_flight.started_at is older than HEARTBEAT_STALE_AFTER_MS.
|
|
1799
|
+
// Sessions still actively running on a live PID are skipped. Idempotent
|
|
1800
|
+
// + best-effort. Returns counts for telemetry.
|
|
1801
|
+
clearStaleInFlight() {
|
|
1802
|
+
const HEARTBEAT_STALE_AFTER_MS = 30 * 60 * 1000; // 30 minutes
|
|
1803
|
+
let scanned = 0;
|
|
1804
|
+
let cleared = 0;
|
|
1805
|
+
for (const session of this.list()) {
|
|
1806
|
+
if (!session.in_flight)
|
|
1807
|
+
continue;
|
|
1808
|
+
scanned += 1;
|
|
1809
|
+
const startedIso = session.in_flight.started_at;
|
|
1810
|
+
const startedAge = startedIso ? Date.now() - Date.parse(startedIso) : Infinity;
|
|
1811
|
+
// Best-effort liveness probe via the active lock holder pid (if any).
|
|
1812
|
+
let holderAlive = true;
|
|
1813
|
+
const lockPath = path.join(this.sessionDir(session.session_id), ".lock");
|
|
1814
|
+
if (fs.existsSync(lockPath)) {
|
|
1815
|
+
try {
|
|
1816
|
+
const lock = readJson(lockPath);
|
|
1817
|
+
if (Number.isInteger(lock.pid)) {
|
|
1818
|
+
holderAlive = this.processAlive(lock.pid);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
// malformed lock — assume dead so the lock sweep cleans it up.
|
|
1823
|
+
holderAlive = false;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
// No active lock — heartbeat staleness is the only signal.
|
|
1828
|
+
holderAlive = !Number.isFinite(startedAge) ? false : startedAge <= HEARTBEAT_STALE_AFTER_MS;
|
|
1829
|
+
}
|
|
1830
|
+
if (!holderAlive || startedAge > HEARTBEAT_STALE_AFTER_MS) {
|
|
1831
|
+
try {
|
|
1832
|
+
this.withSessionLock(session.session_id, () => {
|
|
1833
|
+
const current = this.read(session.session_id);
|
|
1834
|
+
if (!current.in_flight)
|
|
1835
|
+
return;
|
|
1836
|
+
delete current.in_flight;
|
|
1837
|
+
current.updated_at = now();
|
|
1838
|
+
writeJson(this.metaPath(session.session_id), current);
|
|
1839
|
+
cleared += 1;
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
/* best-effort */
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return { scanned, cleared };
|
|
1848
|
+
}
|
|
1849
|
+
// v2.5.0: abort sessions that were never finalized.
|
|
1850
|
+
//
|
|
1851
|
+
// Empirical analysis of 253 historical sessions surfaced 22 in-progress
|
|
1852
|
+
// orphans where every peer had reached READY but the caller never
|
|
1853
|
+
// invoked `session_finalize`. Those sessions stayed at `outcome:
|
|
1854
|
+
// undefined` indefinitely, polluting `session_list` and stealing rows
|
|
1855
|
+
// from `session_recover_interrupted` consumers that interpret a missing
|
|
1856
|
+
// outcome as "still running".
|
|
1857
|
+
//
|
|
1858
|
+
// The session-start contract (orchestrator.ts > sessionContractDirectives
|
|
1859
|
+
// rule 4) now codifies the caller's finalize obligation; this boot
|
|
1860
|
+
// sweep cleans up the cases where the caller exited without honoring
|
|
1861
|
+
// that contract. It is a companion to `clearStaleInFlight`, with a
|
|
1862
|
+
// longer threshold because the failure mode is "host died after a
|
|
1863
|
+
// session ran", not "host died mid-round".
|
|
1864
|
+
//
|
|
1865
|
+
// Conditions to abort:
|
|
1866
|
+
// - meta.outcome is undefined (not finalized);
|
|
1867
|
+
// - meta.in_flight is absent (i.e. the in-flight sweep already ran or
|
|
1868
|
+
// the session was never marked in-flight); a still-in-flight session
|
|
1869
|
+
// is the inFlight sweep's job, not ours;
|
|
1870
|
+
// - no active lock holder, OR the session is past the staleness
|
|
1871
|
+
// threshold (default 24h via CROSS_REVIEW_STALE_HOURS).
|
|
1872
|
+
//
|
|
1873
|
+
// Idempotent + best-effort. Returns counts for telemetry.
|
|
1874
|
+
abortStaleSessions(staleHours) {
|
|
1875
|
+
const envHours = Number.parseFloat(process.env.CROSS_REVIEW_STALE_HOURS ?? "");
|
|
1876
|
+
const hours = staleHours != null && staleHours > 0
|
|
1877
|
+
? staleHours
|
|
1878
|
+
: Number.isFinite(envHours) && envHours > 0
|
|
1879
|
+
? envHours
|
|
1880
|
+
: 24;
|
|
1881
|
+
const staleThresholdMs = hours * 60 * 60 * 1000;
|
|
1882
|
+
let scanned = 0;
|
|
1883
|
+
let aborted = 0;
|
|
1884
|
+
for (const session of this.list()) {
|
|
1885
|
+
// Already finalized? Skip.
|
|
1886
|
+
if (session.outcome)
|
|
1887
|
+
continue;
|
|
1888
|
+
// Currently in-flight? Don't race the in-flight sweep — let it
|
|
1889
|
+
// either clear in_flight (next pass aborts) or leave it in place
|
|
1890
|
+
// (legitimate running session, must not be touched).
|
|
1891
|
+
if (session.in_flight)
|
|
1892
|
+
continue;
|
|
1893
|
+
scanned += 1;
|
|
1894
|
+
// Live lock holder => assume still running, skip.
|
|
1895
|
+
const lockPath = path.join(this.sessionDir(session.session_id), ".lock");
|
|
1896
|
+
if (fs.existsSync(lockPath)) {
|
|
1897
|
+
try {
|
|
1898
|
+
const lock = readJson(lockPath);
|
|
1899
|
+
if (Number.isInteger(lock.pid) && this.processAlive(lock.pid)) {
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
catch {
|
|
1904
|
+
/* malformed lock — fall through to staleness check */
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
const lastTouched = Date.parse(session.updated_at);
|
|
1908
|
+
if (!Number.isFinite(lastTouched))
|
|
1909
|
+
continue;
|
|
1910
|
+
if (Date.now() - lastTouched < staleThresholdMs)
|
|
1911
|
+
continue;
|
|
1912
|
+
try {
|
|
1913
|
+
this.finalize(session.session_id, "aborted", `stale_no_finalize_${hours}h`);
|
|
1914
|
+
aborted += 1;
|
|
1915
|
+
}
|
|
1916
|
+
catch {
|
|
1917
|
+
/* best-effort */
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return { scanned, aborted };
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
//# sourceMappingURL=session-store.js.map
|