dsh-live-compact 1.1.3 → 1.1.5
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 +260 -0
- package/README.md +72 -205
- package/index.js +680 -121
- package/package.json +5 -2
- package/plan.js +41 -11
- package/raw.js +6 -4
- package/shadow-save-worker.mjs +24 -0
- package/shadow.js +168 -17
- package/shell.js +4 -2
package/index.js
CHANGED
|
@@ -16,28 +16,26 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
16
16
|
import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
18
|
import { setImmediate as scheduleImmediate } from "node:timers";
|
|
19
|
+
import { Worker } from "node:worker_threads";
|
|
19
20
|
import { dirname, join } from "node:path";
|
|
20
21
|
import { pathToFileURL } from "node:url";
|
|
21
22
|
import { loadBase, loadCompaction, loadLlm } from "./adapter.js";
|
|
22
23
|
import { DEFAULTS, engineConfig, readSettings } from "./settings.js";
|
|
23
|
-
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, cutLongReadRound, finishedToolRounds, indexToolCalls, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, roundHasLongRead, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
|
|
24
|
-
import { captureOwnedToolResult, contentHash, rawExcerptText, readRawToolResult, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
24
|
+
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, cutLongReadRound, finishedToolRounds, indexToolCalls, isToolFailure, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, roundHasLongRead, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
|
|
25
|
+
import { captureOwnedToolResult, contentHash, rawExcerptText, rawName, readRawToolResult, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
25
26
|
import {
|
|
26
|
-
discardShadowFiles,
|
|
27
27
|
ensureShadowDir,
|
|
28
28
|
flushDrainStats,
|
|
29
29
|
noteShadowWarning,
|
|
30
|
-
|
|
30
|
+
saveShadowTransaction,
|
|
31
31
|
shadowDirectory,
|
|
32
|
-
sessionDir,
|
|
33
|
-
shadowAccessFused,
|
|
34
32
|
shadowFileBody,
|
|
35
33
|
shadowMaxBytes,
|
|
34
|
+
sessionDir,
|
|
35
|
+
shadowAccessFused,
|
|
36
|
+
shadowAccessFuseBuffer,
|
|
36
37
|
tightenCreatedShadow,
|
|
37
38
|
tightenShadow,
|
|
38
|
-
writeShadowFile,
|
|
39
|
-
writeShadowOwner,
|
|
40
|
-
withShadowRoot,
|
|
41
39
|
} from "./shadow.js";
|
|
42
40
|
import { buildSendLess, decideThunder } from "./thunder.js";
|
|
43
41
|
import { inCooldown, skipLiveOfficialCompaction } from "./cooldown.js";
|
|
@@ -53,6 +51,104 @@ const URGENT_TOKENS = 48000;
|
|
|
53
51
|
const MAX_TAIL = 2200;
|
|
54
52
|
const MAX_TRACKED_SESSIONS = 64;
|
|
55
53
|
const DEFAULT_KEY = "__default__";
|
|
54
|
+
const CAPTURE_EVENT_BATCH = 128;
|
|
55
|
+
const CAPTURE_RESULT_BATCH = 1;
|
|
56
|
+
const RECOVER_RAW_BATCH = 1;
|
|
57
|
+
const CAPTURE_RETRY_LIMIT = 8;
|
|
58
|
+
const RECOVERY_ACL_REAUTH_LIMIT = 32;
|
|
59
|
+
|
|
60
|
+
const shadowWorkers = new Map();
|
|
61
|
+
|
|
62
|
+
function shadowWorkerFor(root) {
|
|
63
|
+
const key = String(root);
|
|
64
|
+
let entry = shadowWorkers.get(key);
|
|
65
|
+
if (entry) return entry;
|
|
66
|
+
const worker = new Worker(new URL("./shadow-save-worker.mjs", import.meta.url), {
|
|
67
|
+
workerData: { spineWorker: 1, accessFuse: shadowAccessFuseBuffer() },
|
|
68
|
+
});
|
|
69
|
+
entry = { worker, nextId: 1, pending: new Map(), idleTimer: null, retired: false };
|
|
70
|
+
const removeEntry = () => {
|
|
71
|
+
if (shadowWorkers.get(key) === entry) shadowWorkers.delete(key);
|
|
72
|
+
};
|
|
73
|
+
const scheduleIdleExit = () => {
|
|
74
|
+
if (entry.retired || entry.pending.size || entry.idleTimer) return;
|
|
75
|
+
entry.idleTimer = setTimeout(() => {
|
|
76
|
+
entry.idleTimer = null;
|
|
77
|
+
if (entry.pending.size) return;
|
|
78
|
+
entry.retired = true;
|
|
79
|
+
removeEntry();
|
|
80
|
+
worker.terminate().catch(() => {});
|
|
81
|
+
}, 500);
|
|
82
|
+
entry.idleTimer.unref?.();
|
|
83
|
+
};
|
|
84
|
+
entry.scheduleIdleExit = scheduleIdleExit;
|
|
85
|
+
const fail = (error) => {
|
|
86
|
+
entry.retired = true;
|
|
87
|
+
if (entry.idleTimer) {
|
|
88
|
+
clearTimeout(entry.idleTimer);
|
|
89
|
+
entry.idleTimer = null;
|
|
90
|
+
}
|
|
91
|
+
for (const { reject } of entry.pending.values()) reject(error);
|
|
92
|
+
entry.pending.clear();
|
|
93
|
+
removeEntry();
|
|
94
|
+
worker.terminate().catch(() => {});
|
|
95
|
+
};
|
|
96
|
+
worker.on("message", (message) => {
|
|
97
|
+
const task = entry.pending.get(message?.id);
|
|
98
|
+
if (!task) return;
|
|
99
|
+
entry.pending.delete(message.id);
|
|
100
|
+
if (message?.ok) {
|
|
101
|
+
task.resolve(message.result);
|
|
102
|
+
scheduleIdleExit();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const error = new Error(message?.error?.message || "spine: shadow worker failed");
|
|
106
|
+
if (message?.error?.code) error.code = message.error.code;
|
|
107
|
+
task.reject(error);
|
|
108
|
+
scheduleIdleExit();
|
|
109
|
+
});
|
|
110
|
+
worker.on("error", (error) => fail(error));
|
|
111
|
+
worker.on("exit", (code) => {
|
|
112
|
+
if (entry.pending.size || (!entry.retired && code !== 0)) {
|
|
113
|
+
fail(Object.assign(new Error(`spine: shadow worker exited ${code}`), { code: "ESHADOWWORKER" }));
|
|
114
|
+
} else {
|
|
115
|
+
entry.retired = true;
|
|
116
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
117
|
+
entry.idleTimer = null;
|
|
118
|
+
removeEntry();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
shadowWorkers.set(key, entry);
|
|
122
|
+
return entry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runShadowWorker(payload) {
|
|
126
|
+
const entry = shadowWorkerFor(payload.shadow);
|
|
127
|
+
if (entry.idleTimer) {
|
|
128
|
+
clearTimeout(entry.idleTimer);
|
|
129
|
+
entry.idleTimer = null;
|
|
130
|
+
}
|
|
131
|
+
const id = entry.nextId++;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
entry.pending.set(id, { resolve, reject });
|
|
134
|
+
try {
|
|
135
|
+
entry.worker.postMessage({ id, payload });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
entry.pending.delete(id);
|
|
138
|
+
reject(error);
|
|
139
|
+
entry.scheduleIdleExit();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function recoveryRetryable(record, dir, toolCallId) {
|
|
145
|
+
if (record?.retryable === true || record?.reason === "unwritable") return true;
|
|
146
|
+
// A completely absent backup has never been captured and can be retried.
|
|
147
|
+
// If its metadata is still present, the body was damaged after capture and
|
|
148
|
+
// must remain fail-closed rather than being silently overwritten.
|
|
149
|
+
if (record?.reason === "missing-file") return !existsSync(join(dir, `${rawName(toolCallId)}.meta.json`));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
56
152
|
|
|
57
153
|
function eventContent(session, seq) {
|
|
58
154
|
const event = session.eventAt(seq);
|
|
@@ -180,80 +276,41 @@ export function saveShadowImpl(
|
|
|
180
276
|
rotationIO = {},
|
|
181
277
|
) {
|
|
182
278
|
const shadow = shadowDirectory();
|
|
183
|
-
ensureShadowDir(shadow, access);
|
|
184
|
-
|
|
185
279
|
const file = shadowFilePath(shadowFile, warnKey);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
199
|
-
const incoming = Buffer.byteLength(body);
|
|
200
|
-
const cap = shadowMaxBytes();
|
|
201
|
-
|
|
202
|
-
const rotation = rotateShadow({
|
|
203
|
-
...rotationIO,
|
|
204
|
-
dir: shadow,
|
|
205
|
-
incomingBytes: incoming,
|
|
206
|
-
protectPath: file,
|
|
207
|
-
protectPaths,
|
|
208
|
-
sessionKey: warnKey,
|
|
209
|
-
cap,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
if (!rotation.ok) {
|
|
213
|
-
if (rotation.reason) {
|
|
214
|
-
const incomplete = rotation.reason === "scan-incomplete";
|
|
215
|
-
const error = Object.assign(new Error(incomplete
|
|
216
|
-
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
217
|
-
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span"), {
|
|
218
|
-
code: incomplete ? "ESHADOWSCAN" : "ESHADOWMETER",
|
|
219
|
-
});
|
|
220
|
-
if (required) throw error;
|
|
221
|
-
this.ctx.logger?.warn?.(error.message);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
225
|
-
if (required) {
|
|
226
|
-
throw new Error(
|
|
227
|
-
"spine: shadow backup is full; leaving the span",
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (rotation.deleted > 0) {
|
|
234
|
-
this.ctx.logger?.info?.(
|
|
235
|
-
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
236
|
-
`腾出 ${rotation.freed} 字节,` +
|
|
237
|
-
`用量 ${rotation.used + incoming}/${cap}`,
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (rotation.oversize) {
|
|
242
|
-
this.warnShadowOversize(warnKey, cap);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
writeShadowFile(file, body, access, { root: shadow });
|
|
246
|
-
|
|
247
|
-
if (!existsSync(file)) {
|
|
248
|
-
throw new Error("spine: shadow backup was not written");
|
|
249
|
-
}
|
|
250
|
-
} catch (error) {
|
|
251
|
-
if (ownerWritten && !ownerExisted) {
|
|
252
|
-
discardShadowFiles([owner], error);
|
|
253
|
-
}
|
|
254
|
-
throw error;
|
|
255
|
-
}
|
|
280
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
281
|
+
const cap = shadowMaxBytes();
|
|
282
|
+
const result = saveShadowTransaction({
|
|
283
|
+
shadow,
|
|
284
|
+
file,
|
|
285
|
+
body,
|
|
286
|
+
required,
|
|
287
|
+
protectPaths,
|
|
288
|
+
sessionKey: warnKey,
|
|
289
|
+
cap,
|
|
290
|
+
access,
|
|
291
|
+
rotationIO,
|
|
256
292
|
});
|
|
293
|
+
if (!result?.ok) {
|
|
294
|
+
if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
|
|
295
|
+
const error = result.reason === "scan-incomplete"
|
|
296
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
297
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span";
|
|
298
|
+
this.ctx.logger?.warn?.(error);
|
|
299
|
+
} else if (result?.rotation) {
|
|
300
|
+
this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
const rotation = result.rotation;
|
|
305
|
+
if (rotation.deleted > 0) {
|
|
306
|
+
this.ctx.logger?.info?.(
|
|
307
|
+
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
308
|
+
`腾出 ${rotation.freed} 字节,` +
|
|
309
|
+
`用量 ${rotation.used + Buffer.byteLength(body)}/${cap}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (rotation.oversize) this.warnShadowOversize(warnKey, cap);
|
|
313
|
+
return result;
|
|
257
314
|
}
|
|
258
315
|
|
|
259
316
|
export { saveShadowImpl as saveShadow };
|
|
@@ -300,9 +357,11 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
300
357
|
type: event?.type === "tool/result" ? "tool" : "other",
|
|
301
358
|
toolCalls,
|
|
302
359
|
longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
|
|
360
|
+
checkpoint: isCheckpoint(session, nodes[index]),
|
|
303
361
|
});
|
|
304
362
|
}
|
|
305
|
-
const
|
|
363
|
+
const floorVisible = visible.reduce((found, item, index) => item.checkpoint ? index : found, -1);
|
|
364
|
+
const rounds = keptToolRounds(visible, settings.keepRounds, floorVisible);
|
|
306
365
|
const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
|
|
307
366
|
const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
|
|
308
367
|
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
@@ -361,7 +420,6 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
361
420
|
}
|
|
362
421
|
return pieces;
|
|
363
422
|
};
|
|
364
|
-
const spanText = (spanStart, spanEnd) => spanPieces(spanStart, spanEnd).map((piece) => piece.text).join("\n");
|
|
365
423
|
const liveText = (spanStart, spanEnd) => {
|
|
366
424
|
const bits = [];
|
|
367
425
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
@@ -433,9 +491,11 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
433
491
|
const start = picked.start;
|
|
434
492
|
end = picked.end;
|
|
435
493
|
const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
|
|
494
|
+
const pieces = spanPieces(start, end);
|
|
495
|
+
if (picked.pointerOnly && pieces.some((piece) => piece.role === "tool" && isToolFailure(piece.text, piece.isError))) return null;
|
|
436
496
|
let card = picked.pointerOnly
|
|
437
|
-
? { ok: true, text: pointerOnlyText(
|
|
438
|
-
: buildCard(
|
|
497
|
+
? { ok: true, text: pointerOnlyText(pieces), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
|
|
498
|
+
: buildCard(pieces.map((piece) => piece.text).join("\n"), liveText(start, end), pieces, extras);
|
|
439
499
|
if (card.ok !== true) return null;
|
|
440
500
|
card = attachBackupHint(card, freshShadowFile("cut", key));
|
|
441
501
|
if (!card.text.includes("不是备份文件路径")) card = { ...card, text: `${card.text}\n序号是本会话日志里的事件号,不是备份文件路径。` };
|
|
@@ -461,7 +521,15 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
461
521
|
callIndex = new Map();
|
|
462
522
|
captureCursor = new Map();
|
|
463
523
|
pendingCaptures = new Set();
|
|
524
|
+
pendingCaptureJobs = new Map();
|
|
464
525
|
pendingCaptureKeys = new Set();
|
|
526
|
+
pendingCatchups = new Map();
|
|
527
|
+
pendingCatchupJobs = new Set();
|
|
528
|
+
pendingCuts = new Set();
|
|
529
|
+
captureRetryStats = new Map();
|
|
530
|
+
retryableCaptureSeqs = new Map();
|
|
531
|
+
retryableCaptureSeeded = new Set();
|
|
532
|
+
captureRecoveryHolds = new Map();
|
|
465
533
|
pruneGuarded = false;
|
|
466
534
|
|
|
467
535
|
constructor(ctx, config, resolvedConfig) {
|
|
@@ -471,9 +539,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
471
539
|
if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
|
|
472
540
|
this.liveConfig = config;
|
|
473
541
|
try {
|
|
474
|
-
const pendingCuts = new Set();
|
|
475
542
|
ctx.effect(() => async () => {
|
|
476
|
-
await Promise.allSettled([...pendingCuts, ...this.pendingCaptures]);
|
|
543
|
+
await Promise.allSettled([...this.pendingCuts, ...this.pendingCaptures, ...this.pendingCatchupJobs]);
|
|
477
544
|
});
|
|
478
545
|
ctx.on("session/event", (session, event) => {
|
|
479
546
|
try {
|
|
@@ -491,17 +558,22 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
491
558
|
scheduleImmediate(() => {
|
|
492
559
|
try {
|
|
493
560
|
const current = this.rawSeen.get(key);
|
|
494
|
-
if (!current || current.retryable)
|
|
561
|
+
if (!current || current.retryable) {
|
|
562
|
+
const record = this.captureOne(session, event);
|
|
563
|
+
this.noteCaptureResult(session, event, record);
|
|
564
|
+
}
|
|
495
565
|
} catch (error) {
|
|
496
566
|
noteShadowWarning(ctx.logger, error);
|
|
497
567
|
} finally {
|
|
498
568
|
this.pendingCaptureKeys.delete(key);
|
|
499
569
|
this.pendingCaptures.delete(job);
|
|
570
|
+
if (this.pendingCaptureJobs.get(key) === job) this.pendingCaptureJobs.delete(key);
|
|
500
571
|
resolve();
|
|
501
572
|
}
|
|
502
573
|
});
|
|
503
574
|
});
|
|
504
575
|
this.pendingCaptures.add(job);
|
|
576
|
+
this.pendingCaptureJobs.set(key, job);
|
|
505
577
|
} catch (error) {
|
|
506
578
|
noteShadowWarning(ctx.logger, error);
|
|
507
579
|
}
|
|
@@ -514,13 +586,13 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
514
586
|
const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
|
|
515
587
|
if (!done) return;
|
|
516
588
|
state.warming = true;
|
|
517
|
-
const job = this.finishTurn(agent, events)
|
|
589
|
+
const job = this.finishTurn(agent, events, { deferCapture: true })
|
|
518
590
|
.catch((error) => noteShadowWarning(this.ctx?.logger, error))
|
|
519
591
|
.finally(() => {
|
|
520
592
|
state.warming = false;
|
|
521
|
-
pendingCuts.delete(job);
|
|
593
|
+
this.pendingCuts.delete(job);
|
|
522
594
|
});
|
|
523
|
-
pendingCuts.add(job);
|
|
595
|
+
this.pendingCuts.add(job);
|
|
524
596
|
});
|
|
525
597
|
} catch (error) {
|
|
526
598
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -570,13 +642,184 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
570
642
|
return planMessages(messages);
|
|
571
643
|
}
|
|
572
644
|
|
|
573
|
-
|
|
645
|
+
scheduleCaptureCatchup(agent, events, settings, options = {}) {
|
|
646
|
+
this.pendingCatchups ||= new Map();
|
|
647
|
+
this.pendingCatchupJobs ||= new Set();
|
|
648
|
+
this.pendingCuts ||= new Set();
|
|
649
|
+
const key = sessionKey(agent);
|
|
650
|
+
let task = this.pendingCatchups.get(key);
|
|
651
|
+
if (task) {
|
|
652
|
+
task.agent = agent;
|
|
653
|
+
task.session = agent.session;
|
|
654
|
+
task.events = events;
|
|
655
|
+
task.settings = settings;
|
|
656
|
+
task.freezeSnapshot ||= options.freezeSnapshot === true;
|
|
657
|
+
return task;
|
|
658
|
+
}
|
|
659
|
+
let resolve;
|
|
660
|
+
const promise = new Promise((done) => { resolve = done; });
|
|
661
|
+
task = {
|
|
662
|
+
key,
|
|
663
|
+
agent,
|
|
664
|
+
session: agent.session,
|
|
665
|
+
events,
|
|
666
|
+
settings,
|
|
667
|
+
phase: "previsible",
|
|
668
|
+
eventIndex: 0,
|
|
669
|
+
recoverIndex: 0,
|
|
670
|
+
visibleIds: [...new Set(this.surfaceToolIds(agent.session))],
|
|
671
|
+
scheduled: false,
|
|
672
|
+
retryRequested: false,
|
|
673
|
+
captureRetryAttempts: 0,
|
|
674
|
+
progressed: false,
|
|
675
|
+
eventTail: Number.isInteger(events?.at?.(-1)?.seq) ? events.at(-1).seq : -1,
|
|
676
|
+
surfaceTail: agent.session.surface?.nodes?.at?.(-1) ?? -1,
|
|
677
|
+
freezeSnapshot: options.freezeSnapshot === true,
|
|
678
|
+
resolve,
|
|
679
|
+
promise,
|
|
680
|
+
};
|
|
681
|
+
this.pendingCatchups.set(key, task);
|
|
682
|
+
this.pendingCatchupJobs.add(promise);
|
|
683
|
+
this.queueCaptureCatchup(task);
|
|
684
|
+
return task;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
queueCaptureRetry(task) {
|
|
688
|
+
if (!task || task.finished || task.scheduled) return;
|
|
689
|
+
task.scheduled = true;
|
|
690
|
+
const delay = Math.min(250, 10 * (2 ** Math.max(0, task.captureRetryAttempts - 1)));
|
|
691
|
+
setTimeout(() => {
|
|
692
|
+
task.scheduled = false;
|
|
693
|
+
if (!task.finished) this.queueCaptureCatchup(task);
|
|
694
|
+
}, delay);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
queueCaptureCatchup(task) {
|
|
698
|
+
if (!task || task.scheduled) return;
|
|
699
|
+
task.scheduled = true;
|
|
700
|
+
scheduleImmediate(() => {
|
|
701
|
+
task.scheduled = false;
|
|
702
|
+
try {
|
|
703
|
+
const latest = task.freezeSnapshot ? null : task.session.snapshotEvents?.();
|
|
704
|
+
const latestTail = Number.isInteger(latest?.at?.(-1)?.seq) ? latest.at(-1).seq : -1;
|
|
705
|
+
const latestSurfaceTail = task.session.surface?.nodes?.at?.(-1) ?? -1;
|
|
706
|
+
if (Array.isArray(latest) && (latestTail !== task.eventTail || latest.length !== task.events.length)) {
|
|
707
|
+
task.events = latest;
|
|
708
|
+
task.eventTail = latestTail;
|
|
709
|
+
if (task.phase === "previsible" || task.phase === "visible") {
|
|
710
|
+
task.phase = "events";
|
|
711
|
+
task.visibleIds = null;
|
|
712
|
+
task.recoverIndex = 0;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (latestSurfaceTail !== task.surfaceTail) {
|
|
716
|
+
task.surfaceTail = latestSurfaceTail;
|
|
717
|
+
if (task.phase === "previsible" || task.phase === "visible") {
|
|
718
|
+
task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
|
|
719
|
+
task.recoverIndex = 0;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (task.phase === "previsible") {
|
|
723
|
+
const recovered = this.recoverVisibleRawBatch(task.session, {}, task.recoverIndex, RECOVER_RAW_BATCH, task.visibleIds);
|
|
724
|
+
task.progressed ||= recovered.progressed;
|
|
725
|
+
task.recoverIndex = recovered.nextIndex;
|
|
726
|
+
if (!recovered.done) {
|
|
727
|
+
this.queueCaptureCatchup(task);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
task.phase = "events";
|
|
731
|
+
task.eventIndex = 0;
|
|
732
|
+
}
|
|
733
|
+
if (task.phase === "events") {
|
|
734
|
+
const result = this.captureNewBatch(task.session, task.events, task.settings, {
|
|
735
|
+
eventLimit: CAPTURE_EVENT_BATCH,
|
|
736
|
+
resultLimit: CAPTURE_RESULT_BATCH,
|
|
737
|
+
startIndex: task.eventIndex,
|
|
738
|
+
});
|
|
739
|
+
task.eventIndex = result.nextIndex;
|
|
740
|
+
task.progressed ||= result.progressed;
|
|
741
|
+
if (result.retryable) {
|
|
742
|
+
task.captureRetryAttempts += 1;
|
|
743
|
+
const previousMax = this.captureRetryStats?.get(task.key) || 0;
|
|
744
|
+
if (task.captureRetryAttempts > previousMax) this.captureRetryStats.set(task.key, task.captureRetryAttempts);
|
|
745
|
+
if (task.captureRetryAttempts >= CAPTURE_RETRY_LIMIT) {
|
|
746
|
+
this.finishCaptureCatchup(task);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
this.queueCaptureRetry(task);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (result.progressed) task.captureRetryAttempts = 0;
|
|
753
|
+
if (!result.done) {
|
|
754
|
+
this.queueCaptureCatchup(task);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
task.phase = "visible";
|
|
758
|
+
task.recoverIndex = 0;
|
|
759
|
+
task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
|
|
760
|
+
}
|
|
761
|
+
const recovered = this.recoverVisibleRawBatch(task.session, {}, task.recoverIndex, RECOVER_RAW_BATCH, task.visibleIds);
|
|
762
|
+
task.progressed ||= recovered.progressed;
|
|
763
|
+
task.recoverIndex = recovered.nextIndex;
|
|
764
|
+
if (!recovered.done) {
|
|
765
|
+
this.queueCaptureCatchup(task);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
this.finishCaptureCatchup(task);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
771
|
+
this.finishCaptureCatchup(task);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
finishCaptureCatchup(task) {
|
|
777
|
+
if (!task || task.finished) return;
|
|
778
|
+
task.finished = true;
|
|
779
|
+
this.pendingCatchups.delete(task.key);
|
|
780
|
+
this.pendingCatchupJobs.delete(task.promise);
|
|
781
|
+
task.resolve();
|
|
782
|
+
if (task.retryRequested && task.progressed) this.scheduleMaintenanceRetry(task.agent);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
scheduleMaintenanceRetry(agent) {
|
|
786
|
+
const state = this.stateFor(agent);
|
|
787
|
+
if (state.retryScheduled) return;
|
|
788
|
+
state.retryScheduled = true;
|
|
789
|
+
scheduleImmediate(() => {
|
|
790
|
+
state.retryScheduled = false;
|
|
791
|
+
if (state.warming) return;
|
|
792
|
+
const events = agent.session.snapshotEvents();
|
|
793
|
+
const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
|
|
794
|
+
if (!done) return;
|
|
795
|
+
state.warming = true;
|
|
796
|
+
const job = this.finishTurn(agent, events, { deferCapture: true })
|
|
797
|
+
.catch((error) => noteShadowWarning(this.ctx?.logger, error))
|
|
798
|
+
.finally(() => {
|
|
799
|
+
state.warming = false;
|
|
800
|
+
this.pendingCuts.delete(job);
|
|
801
|
+
});
|
|
802
|
+
this.pendingCuts.add(job);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async finishTurn(agent, events, options = {}) {
|
|
574
807
|
const settings = readSettings(this.liveConfig);
|
|
575
808
|
const state = this.stateFor(agent);
|
|
576
809
|
const signal = AbortSignal.timeout(30000);
|
|
577
810
|
const ev = events || agent.session.snapshotEvents();
|
|
578
|
-
|
|
579
|
-
|
|
811
|
+
const deferCapture = options.deferCapture === true;
|
|
812
|
+
let catchup = { finished: true, progressed: false, retryRequested: false };
|
|
813
|
+
if (deferCapture) {
|
|
814
|
+
catchup = this.scheduleCaptureCatchup(agent, ev, settings);
|
|
815
|
+
} else {
|
|
816
|
+
if (ev.length > CAPTURE_EVENT_BATCH) {
|
|
817
|
+
catchup = this.scheduleCaptureCatchup(agent, ev, settings, { freezeSnapshot: true });
|
|
818
|
+
} else {
|
|
819
|
+
await this.flushPendingCaptures();
|
|
820
|
+
this.captureNew(agent.session, ev, settings);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
580
823
|
const jobs = collectJobs(ev);
|
|
581
824
|
const calls = indexToolCalls(ev);
|
|
582
825
|
try {
|
|
@@ -636,6 +879,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
636
879
|
try {
|
|
637
880
|
state.localOnly = true;
|
|
638
881
|
state.approvedCard = chunk.card;
|
|
882
|
+
await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
|
|
639
883
|
await this.commitSelectedSpan(agent, chunk, op);
|
|
640
884
|
measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
641
885
|
const after = measurement.totalTokens;
|
|
@@ -656,6 +900,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
656
900
|
this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved}`);
|
|
657
901
|
} catch (error) {
|
|
658
902
|
const message = error instanceof Error ? error.message : String(error);
|
|
903
|
+
if (message.includes("raw backup missing before cut")) {
|
|
904
|
+
catchup.retryRequested = true;
|
|
905
|
+
if (catchup.finished && catchup.progressed) this.scheduleMaintenanceRetry(agent);
|
|
906
|
+
}
|
|
659
907
|
this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
|
|
660
908
|
break;
|
|
661
909
|
}
|
|
@@ -678,9 +926,33 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
678
926
|
}
|
|
679
927
|
}
|
|
680
928
|
|
|
681
|
-
async flushPendingCaptures() {
|
|
682
|
-
if (!
|
|
683
|
-
|
|
929
|
+
async flushPendingCaptures(session = null, ids = null) {
|
|
930
|
+
if (!session) {
|
|
931
|
+
if (!this.pendingCaptures?.size) return;
|
|
932
|
+
await Promise.allSettled([...this.pendingCaptures]);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const key = session.id || DEFAULT_KEY;
|
|
936
|
+
const pendingIds = ids || this.surfaceToolIds(session);
|
|
937
|
+
const jobs = [...new Set(pendingIds)]
|
|
938
|
+
.map((id) => this.pendingCaptureJobs?.get?.(`${key}\0${id}`))
|
|
939
|
+
.filter(Boolean);
|
|
940
|
+
if (jobs.length) await Promise.allSettled(jobs);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
chunkToolCallIds(session, chunk) {
|
|
944
|
+
const nodes = session?.surface?.nodes || [];
|
|
945
|
+
const start = nodes.indexOf(chunk?.start);
|
|
946
|
+
const end = nodes.indexOf(chunk?.end);
|
|
947
|
+
if (start < 0 || end < start) return [];
|
|
948
|
+
const ids = [];
|
|
949
|
+
for (const seq of nodes.slice(start, end + 1)) {
|
|
950
|
+
const event = session.eventAt?.(seq);
|
|
951
|
+
if (event?.type !== "tool/result") continue;
|
|
952
|
+
const id = event.data?.message?.toolCallId;
|
|
953
|
+
if (id) ids.push(id);
|
|
954
|
+
}
|
|
955
|
+
return ids;
|
|
684
956
|
}
|
|
685
957
|
|
|
686
958
|
async commitSelectedSpan(agent, chunk, signal) {
|
|
@@ -716,7 +988,29 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
716
988
|
const event = session.eventAt(seq);
|
|
717
989
|
if (event?.type === "tool/result") {
|
|
718
990
|
const id = event.data?.message?.toolCallId;
|
|
719
|
-
|
|
991
|
+
let raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
|
|
992
|
+
if (id && raw?.ok && raw.recovered && raw.aclVerified !== true) {
|
|
993
|
+
try {
|
|
994
|
+
const verified = readRawToolResult({
|
|
995
|
+
dir: sessionDir(shadowDirectory(), session.id || DEFAULT_KEY),
|
|
996
|
+
toolCallId: id,
|
|
997
|
+
tighten: true,
|
|
998
|
+
});
|
|
999
|
+
// ACL success cannot establish a new content identity. Keep the
|
|
1000
|
+
// first verified hash even if an external writer replaces both files.
|
|
1001
|
+
const sameBackup = verified?.ok && verified.path === raw.path &&
|
|
1002
|
+
verified.sha256 === raw.sha256 && verified.bytes === raw.bytes;
|
|
1003
|
+
raw = sameBackup
|
|
1004
|
+
? { ...verified, recovered: false, aclVerified: true }
|
|
1005
|
+
: { ...raw, ok: false, reason: verified?.reason || "hash-mismatch", aclVerified: false };
|
|
1006
|
+
this.rawSeen.set(this.rawKey(session, id), raw);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1009
|
+
raw = { ...raw, ok: false, reason: "unwritable", toolCallId: id, aclVerified: false };
|
|
1010
|
+
this.rawSeen.set(this.rawKey(session, id), raw);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (!id || !verifiedRaw(raw).ok) {
|
|
720
1014
|
throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
|
|
721
1015
|
}
|
|
722
1016
|
}
|
|
@@ -938,8 +1232,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
938
1232
|
),
|
|
939
1233
|
access,
|
|
940
1234
|
});
|
|
941
|
-
|
|
942
|
-
|
|
1235
|
+
const cached = record?.ok ? { ...record, aclVerified: true } : record;
|
|
1236
|
+
this.rawSeen.set(mapKey, cached);
|
|
1237
|
+
return cached;
|
|
943
1238
|
} catch (error) {
|
|
944
1239
|
if (
|
|
945
1240
|
error?.code === "ESHADOWACCESS" ||
|
|
@@ -972,19 +1267,113 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
972
1267
|
}
|
|
973
1268
|
}
|
|
974
1269
|
|
|
975
|
-
|
|
976
|
-
|
|
1270
|
+
noteCaptureResult(session, event, record) {
|
|
1271
|
+
const key = session?.id || DEFAULT_KEY;
|
|
1272
|
+
this.retryableCaptureSeqs ||= new Map();
|
|
1273
|
+
this.retryableCaptureSeeded ||= new Set();
|
|
1274
|
+
let seqs = this.retryableCaptureSeqs.get(key);
|
|
1275
|
+
if (!seqs) {
|
|
1276
|
+
seqs = new Set();
|
|
1277
|
+
this.retryableCaptureSeqs.set(key, seqs);
|
|
1278
|
+
}
|
|
1279
|
+
const seq = Number.isInteger(event?.seq) ? event.seq : null;
|
|
1280
|
+
if (seq === null) return;
|
|
1281
|
+
if (record?.retryable) seqs.add(seq);
|
|
1282
|
+
else seqs.delete(seq);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
captureCursorStart(session, events, settings) {
|
|
977
1286
|
const key = session?.id || DEFAULT_KEY;
|
|
978
1287
|
let cursor = this.captureCursor.has(key) ? this.captureCursor.get(key) : this.readCaptureCursor(key);
|
|
979
1288
|
if (cursor == null) cursor = restartCaptureCursor(events, settings.keepRounds);
|
|
980
1289
|
if (!Number.isSafeInteger(cursor) || cursor < 0 || events.length < cursor) cursor = restartCaptureCursor(events, settings.keepRounds);
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1290
|
+
this.retryableCaptureSeqs ||= new Map();
|
|
1291
|
+
this.retryableCaptureSeeded ||= new Set();
|
|
1292
|
+
let retryable = this.retryableCaptureSeqs.get(key);
|
|
1293
|
+
if (!retryable) {
|
|
1294
|
+
retryable = new Set();
|
|
1295
|
+
this.retryableCaptureSeqs.set(key, retryable);
|
|
1296
|
+
}
|
|
1297
|
+
if (!this.retryableCaptureSeeded.has(key)) {
|
|
1298
|
+
for (const seq of session?.surface?.nodes || []) {
|
|
1299
|
+
const event = session.eventAt?.(seq);
|
|
1300
|
+
const id = event?.type === "tool/result" && event.data?.message?.toolCallId;
|
|
1301
|
+
if (id && this.rawSeen.get(this.rawKey(session, id))?.retryable) retryable.add(seq);
|
|
1302
|
+
}
|
|
1303
|
+
this.retryableCaptureSeeded.add(key);
|
|
1304
|
+
}
|
|
1305
|
+
if (retryable.size) cursor = Math.min(cursor, Math.min(...retryable));
|
|
1306
|
+
return cursor;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
captureNewBatch(session, events, settings = readSettings(this.liveConfig), limits = {}) {
|
|
1310
|
+
if (!Array.isArray(events)) events = [];
|
|
1311
|
+
const key = session?.id || DEFAULT_KEY;
|
|
1312
|
+
const cursor = this.captureCursorStart(session, events, settings);
|
|
1313
|
+
let next = cursor;
|
|
1314
|
+
let retry = null;
|
|
1315
|
+
let retryIndex = null;
|
|
1316
|
+
let processed = 0;
|
|
1317
|
+
let captures = 0;
|
|
1318
|
+
let progressed = false;
|
|
1319
|
+
let exhausted = true;
|
|
1320
|
+
const seen = new Set();
|
|
1321
|
+
const eventLimit = Math.max(1, limits.eventLimit || CAPTURE_EVENT_BATCH);
|
|
1322
|
+
const resultLimit = Math.max(1, limits.resultLimit || CAPTURE_RESULT_BATCH);
|
|
1323
|
+
let index = Math.max(0, Number.isInteger(limits.startIndex) ? limits.startIndex : 0);
|
|
1324
|
+
const indexedSeq = Number.isInteger(events[index]?.seq) ? events[index].seq : null;
|
|
1325
|
+
if (index > 0 && indexedSeq !== null && indexedSeq > cursor) {
|
|
1326
|
+
const rewind = events.findIndex((event) => Number.isInteger(event?.seq) && event.seq >= cursor);
|
|
1327
|
+
index = rewind >= 0 ? rewind : 0;
|
|
987
1328
|
}
|
|
1329
|
+
for (; index < events.length; index += 1) {
|
|
1330
|
+
const event = events[index];
|
|
1331
|
+
const seq = Number.isInteger(event?.seq) ? event.seq : next;
|
|
1332
|
+
if (seq < cursor) continue;
|
|
1333
|
+
this.noteEvent(session, event);
|
|
1334
|
+
if (event?.type === "tool/result") {
|
|
1335
|
+
const id = event.data?.message?.toolCallId;
|
|
1336
|
+
if (id && !seen.has(id)) {
|
|
1337
|
+
seen.add(id);
|
|
1338
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1339
|
+
const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
|
|
1340
|
+
if (!cached || cached.retryable) {
|
|
1341
|
+
if (record?.ok) progressed = true;
|
|
1342
|
+
captures += 1;
|
|
1343
|
+
}
|
|
1344
|
+
this.noteCaptureResult(session, event, record);
|
|
1345
|
+
if (record?.retryable && retry === null) {
|
|
1346
|
+
retry = seq;
|
|
1347
|
+
retryIndex = index;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
next = seq + 1;
|
|
1352
|
+
processed += 1;
|
|
1353
|
+
if (retry !== null || processed >= eventLimit || captures >= resultLimit) {
|
|
1354
|
+
exhausted = false;
|
|
1355
|
+
index += 1;
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
next = retry ?? next;
|
|
1360
|
+
this.captureCursor.set(key, next);
|
|
1361
|
+
this.writeCaptureCursor(key, next);
|
|
1362
|
+
const lastEvent = events.at(-1);
|
|
1363
|
+
const lastSeq = Number.isInteger(lastEvent?.seq) ? lastEvent.seq : events.length - 1;
|
|
1364
|
+
return {
|
|
1365
|
+
seen,
|
|
1366
|
+
progressed,
|
|
1367
|
+
retryable: retry !== null,
|
|
1368
|
+
done: exhausted && retry === null && next > lastSeq,
|
|
1369
|
+
nextIndex: retryIndex ?? index,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
captureNew(session, events, settings = readSettings(this.liveConfig)) {
|
|
1374
|
+
if (!Array.isArray(events)) events = [];
|
|
1375
|
+
const key = session?.id || DEFAULT_KEY;
|
|
1376
|
+
const cursor = this.captureCursorStart(session, events, settings);
|
|
988
1377
|
let next = cursor;
|
|
989
1378
|
let retry = null;
|
|
990
1379
|
const seen = new Set();
|
|
@@ -998,6 +1387,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
998
1387
|
seen.add(id);
|
|
999
1388
|
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1000
1389
|
const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
|
|
1390
|
+
this.noteCaptureResult(session, event, record);
|
|
1001
1391
|
if (record?.retryable && retry === null) retry = seq;
|
|
1002
1392
|
}
|
|
1003
1393
|
}
|
|
@@ -1063,20 +1453,103 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1063
1453
|
|
|
1064
1454
|
recoverVisibleRaw(session, access = {}) {
|
|
1065
1455
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1066
|
-
|
|
1456
|
+
const ids = [...new Set(this.surfaceToolIds(session))];
|
|
1457
|
+
if (ids.length && existsSync(dir)) ensureShadowDir(dir, access);
|
|
1458
|
+
for (const id of ids) {
|
|
1067
1459
|
const mapKey = this.rawKey(session, id);
|
|
1068
1460
|
if (this.rawSeen.has(mapKey)) continue;
|
|
1069
1461
|
try {
|
|
1070
|
-
|
|
1462
|
+
const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
|
|
1463
|
+
const retryable = recoveryRetryable(record, dir, id);
|
|
1464
|
+
const cached = record?.ok
|
|
1465
|
+
? { ...record, recovered: true, aclVerified: false }
|
|
1466
|
+
: { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
|
|
1467
|
+
this.rawSeen.set(mapKey, cached);
|
|
1468
|
+
if (!cached.ok) {
|
|
1469
|
+
const event = (session?.surface?.nodes || [])
|
|
1470
|
+
.map((seq) => session.eventAt?.(seq))
|
|
1471
|
+
.find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
|
|
1472
|
+
if (event) this.noteCaptureResult(session, event, cached);
|
|
1473
|
+
if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1474
|
+
}
|
|
1071
1475
|
} catch (error) {
|
|
1072
1476
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1073
|
-
|
|
1477
|
+
const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
|
|
1478
|
+
this.rawSeen.set(mapKey, failed);
|
|
1479
|
+
const event = (session?.surface?.nodes || [])
|
|
1480
|
+
.map((seq) => session.eventAt?.(seq))
|
|
1481
|
+
.find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
|
|
1482
|
+
if (event) this.noteCaptureResult(session, event, failed);
|
|
1483
|
+
this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1074
1484
|
}
|
|
1075
1485
|
}
|
|
1076
1486
|
}
|
|
1077
1487
|
|
|
1488
|
+
recoverVisibleRawBatch(session, access = {}, startIndex = 0, limit = RECOVER_RAW_BATCH, visibleIds = null) {
|
|
1489
|
+
const ids = visibleIds || [...new Set(this.surfaceToolIds(session))];
|
|
1490
|
+
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1491
|
+
if (ids.length && startIndex === 0 && existsSync(dir)) {
|
|
1492
|
+
try {
|
|
1493
|
+
ensureShadowDir(dir, access);
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1496
|
+
return { nextIndex: ids.length, done: true, progressed: false };
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
let index = Math.max(0, startIndex);
|
|
1500
|
+
let processed = 0;
|
|
1501
|
+
let progressed = false;
|
|
1502
|
+
while (index < ids.length && processed < Math.max(1, limit)) {
|
|
1503
|
+
const id = ids[index++];
|
|
1504
|
+
const mapKey = this.rawKey(session, id);
|
|
1505
|
+
if (this.rawSeen.has(mapKey)) continue;
|
|
1506
|
+
try {
|
|
1507
|
+
const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
|
|
1508
|
+
const retryable = recoveryRetryable(record, dir, id);
|
|
1509
|
+
const cached = record?.ok
|
|
1510
|
+
? { ...record, recovered: true, aclVerified: false }
|
|
1511
|
+
: { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
|
|
1512
|
+
this.rawSeen.set(mapKey, cached);
|
|
1513
|
+
if (!cached.ok) {
|
|
1514
|
+
const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
|
|
1515
|
+
const candidate = session.eventAt?.(seq);
|
|
1516
|
+
return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
|
|
1517
|
+
}));
|
|
1518
|
+
if (event) this.noteCaptureResult(session, event, cached);
|
|
1519
|
+
if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1520
|
+
}
|
|
1521
|
+
progressed ||= Boolean(record?.ok);
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1524
|
+
const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
|
|
1525
|
+
this.rawSeen.set(mapKey, failed);
|
|
1526
|
+
const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
|
|
1527
|
+
const candidate = session.eventAt?.(seq);
|
|
1528
|
+
return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
|
|
1529
|
+
}));
|
|
1530
|
+
if (event) this.noteCaptureResult(session, event, failed);
|
|
1531
|
+
this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1532
|
+
}
|
|
1533
|
+
processed += 1;
|
|
1534
|
+
}
|
|
1535
|
+
return { nextIndex: index, done: index >= ids.length, progressed };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1078
1538
|
captureSession(session) {
|
|
1079
|
-
|
|
1539
|
+
const key = session?.id || DEFAULT_KEY;
|
|
1540
|
+
const events = session?.snapshotEvents?.() || [];
|
|
1541
|
+
const fingerprint = this.captureRecoveryFingerprint(session, events);
|
|
1542
|
+
if (this.captureRecoveryHolds.get(key) === fingerprint) {
|
|
1543
|
+
this.captureRecoveryHolds.delete(key);
|
|
1544
|
+
return new Set();
|
|
1545
|
+
}
|
|
1546
|
+
return this.captureNew(session, events);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
captureRecoveryFingerprint(session, events = null) {
|
|
1550
|
+
const snapshot = events || session?.snapshotEvents?.() || [];
|
|
1551
|
+
const nodes = session?.surface?.nodes || [];
|
|
1552
|
+
return `${snapshot.length}:${snapshot.at(-1)?.seq ?? -1}|${nodes.length}:${nodes.at(-1) ?? -1}`;
|
|
1080
1553
|
}
|
|
1081
1554
|
|
|
1082
1555
|
surfaceToolIds(session) {
|
|
@@ -1093,14 +1566,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1093
1566
|
rawVerifiedSet(session) {
|
|
1094
1567
|
const ids = new Set();
|
|
1095
1568
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1096
|
-
|
|
1569
|
+
const visibleIds = this.surfaceToolIds(session);
|
|
1570
|
+
const recoveredNeedsAcl = visibleIds.filter((id) => {
|
|
1571
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1572
|
+
return cached?.ok && cached.recovered === true && cached.aclVerified !== true;
|
|
1573
|
+
});
|
|
1574
|
+
if (recoveredNeedsAcl.length > RECOVERY_ACL_REAUTH_LIMIT) return ids;
|
|
1575
|
+
for (const id of visibleIds) {
|
|
1097
1576
|
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1098
|
-
if (!cached?.ok) continue;
|
|
1577
|
+
if (!cached?.ok || cached.aclVerified !== true && cached.recovered !== true) continue;
|
|
1099
1578
|
try {
|
|
1100
1579
|
// Recheck both files immediately before prune, retaining the first
|
|
1101
1580
|
// trusted hash rather than blessing a replacement raw/meta pair.
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1581
|
+
const needsAcl = cached.recovered === true && cached.aclVerified !== true;
|
|
1582
|
+
const stored = readRawToolResult({ dir, toolCallId: id, tighten: needsAcl });
|
|
1583
|
+
if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) {
|
|
1584
|
+
ids.add(id);
|
|
1585
|
+
if (needsAcl) this.rawSeen.set(this.rawKey(session, id), { ...stored, recovered: false, aclVerified: true });
|
|
1586
|
+
}
|
|
1104
1587
|
} catch (error) {
|
|
1105
1588
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1106
1589
|
}
|
|
@@ -1166,6 +1649,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1166
1649
|
warnKey = DEFAULT_KEY,
|
|
1167
1650
|
protectPaths = [],
|
|
1168
1651
|
access = {},
|
|
1652
|
+
rotationIO = {},
|
|
1169
1653
|
) {
|
|
1170
1654
|
return saveShadowImpl.call(
|
|
1171
1655
|
this,
|
|
@@ -1175,9 +1659,68 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1175
1659
|
warnKey,
|
|
1176
1660
|
protectPaths,
|
|
1177
1661
|
access,
|
|
1662
|
+
rotationIO,
|
|
1178
1663
|
);
|
|
1179
1664
|
}
|
|
1180
1665
|
|
|
1666
|
+
async saveShadowAsync(
|
|
1667
|
+
messages,
|
|
1668
|
+
required,
|
|
1669
|
+
shadowFile,
|
|
1670
|
+
warnKey = DEFAULT_KEY,
|
|
1671
|
+
protectPaths = [],
|
|
1672
|
+
access = {},
|
|
1673
|
+
rotationIO = {},
|
|
1674
|
+
) {
|
|
1675
|
+
if (!access.runner && (access.platform ?? process.platform) === "win32" && shadowAccessFused()) {
|
|
1676
|
+
const error = new Error("spine: shadow permission already failed; backup refused");
|
|
1677
|
+
error.code = "ESHADOWACCESS";
|
|
1678
|
+
throw error;
|
|
1679
|
+
}
|
|
1680
|
+
const injected = Object.values(access || {}).some((value) => typeof value === "function") ||
|
|
1681
|
+
Object.values(rotationIO || {}).some((value) => typeof value === "function");
|
|
1682
|
+
// Node's test runner can monkey-patch fs bindings in the parent isolate;
|
|
1683
|
+
// a worker cannot observe those injected failures. Keep test/fault-injection
|
|
1684
|
+
// paths synchronous while production uses the complete off-thread txn.
|
|
1685
|
+
if (injected || process.env.NODE_TEST_CONTEXT) {
|
|
1686
|
+
return this.saveShadow(messages, required, shadowFile, warnKey, protectPaths, access, rotationIO);
|
|
1687
|
+
}
|
|
1688
|
+
const shadow = shadowDirectory();
|
|
1689
|
+
const file = shadowFilePath(shadowFile, warnKey);
|
|
1690
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
1691
|
+
const cap = shadowMaxBytes();
|
|
1692
|
+
const result = await runShadowWorker({
|
|
1693
|
+
shadow,
|
|
1694
|
+
file,
|
|
1695
|
+
body,
|
|
1696
|
+
required,
|
|
1697
|
+
protectPaths,
|
|
1698
|
+
sessionKey: warnKey,
|
|
1699
|
+
cap,
|
|
1700
|
+
access,
|
|
1701
|
+
rotationIO,
|
|
1702
|
+
});
|
|
1703
|
+
if (!result?.ok) {
|
|
1704
|
+
if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
|
|
1705
|
+
this.ctx.logger?.warn?.(result.reason === "scan-incomplete"
|
|
1706
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
1707
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span");
|
|
1708
|
+
} else if (result?.rotation) {
|
|
1709
|
+
this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
|
|
1710
|
+
}
|
|
1711
|
+
return result;
|
|
1712
|
+
}
|
|
1713
|
+
if (result.rotation?.deleted > 0) {
|
|
1714
|
+
this.ctx.logger?.info?.(
|
|
1715
|
+
`spine: shadow 轮换 删除 ${result.rotation.deleted} 个文件,` +
|
|
1716
|
+
`腾出 ${result.rotation.freed} 字节,` +
|
|
1717
|
+
`用量 ${result.rotation.used + Buffer.byteLength(body)}/${cap}`,
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
if (result.rotation?.oversize) this.warnShadowOversize(warnKey, cap);
|
|
1721
|
+
return result;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1181
1724
|
async summarize(input, agent, signal) {
|
|
1182
1725
|
const decision = takeLocalCut(input?.liveCompact);
|
|
1183
1726
|
const state = this.stateFor(agent);
|
|
@@ -1187,14 +1730,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1187
1730
|
const card = input?.liveCompact?.card;
|
|
1188
1731
|
if (card?.needsBackup && !card.shadowFile) throw new Error("spine: omit refused; backup path missing");
|
|
1189
1732
|
const restored = this.restoreRawMessages(input.messages, state.key);
|
|
1190
|
-
this.
|
|
1733
|
+
await this.saveShadowAsync(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
|
|
1191
1734
|
if (card?.needsBackup && !existsSync(card.shadowFile)) throw new Error("spine: shadow backup was not written");
|
|
1192
1735
|
const text = backupClaimOk(restored) ? decision.text : withoutFullBackupClaim(decision.text, card?.shadowFile);
|
|
1193
1736
|
const block = { type: "text", text };
|
|
1194
1737
|
return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
|
|
1195
1738
|
}
|
|
1196
1739
|
try {
|
|
1197
|
-
this.
|
|
1740
|
+
await this.saveShadowAsync(
|
|
1198
1741
|
this.restoreRawMessages(input.messages, state.key),
|
|
1199
1742
|
false,
|
|
1200
1743
|
freshShadowFile("official", state.key),
|
|
@@ -1209,7 +1752,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1209
1752
|
}
|
|
1210
1753
|
}
|
|
1211
1754
|
const joined = input.messages.map(messageText).join("\n\n");
|
|
1212
|
-
if (process.env.SPINE_THUNDER !== "1") return
|
|
1755
|
+
if (process.env.SPINE_THUNDER !== "1") return this.summarizeOfficial(input, agent, signal);
|
|
1213
1756
|
const next = joined;
|
|
1214
1757
|
const mode = decideThunder({
|
|
1215
1758
|
previous: state.lastPrefix || "",
|
|
@@ -1217,7 +1760,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1217
1760
|
modelKind: process.env.SPINE_THUNDER_MODEL === "27b" ? "27b" : "flash",
|
|
1218
1761
|
prefixTokens: state.lastPrefixTokens || 0,
|
|
1219
1762
|
});
|
|
1220
|
-
if (mode === "attach") return
|
|
1763
|
+
if (mode === "attach") return this.summarizeOfficial(input, agent, signal);
|
|
1221
1764
|
const users = input.messages.filter((message) => (message?.role ?? message?.message?.role) === "user");
|
|
1222
1765
|
const taskLine = messageText(users.at(-1) || input.messages.at(-1)).slice(0, 500);
|
|
1223
1766
|
const trunk = buildCard(joined, "", input.messages.map((message) => ({
|
|
@@ -1226,9 +1769,25 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1226
1769
|
})));
|
|
1227
1770
|
const facts = [...new Set([...(trunk.hardLines || []), ...(trunk.missing || [])])];
|
|
1228
1771
|
const slim = buildSendLess({ taskLine, uncovered: trunk.uncovered, facts });
|
|
1229
|
-
return
|
|
1772
|
+
return this.summarizeOfficial({
|
|
1230
1773
|
...input,
|
|
1231
1774
|
messages: [{ role: "user", content: [{ type: "text", text: slim }] }],
|
|
1232
1775
|
}, agent, signal);
|
|
1233
1776
|
}
|
|
1777
|
+
|
|
1778
|
+
async summarizeOfficial(input, agent, signal) {
|
|
1779
|
+
const result = await super.summarize(input, agent, signal);
|
|
1780
|
+
const blocks = Array.isArray(result?.summary) ? result.summary : [];
|
|
1781
|
+
const usable = blocks.some((block) => {
|
|
1782
|
+
if (typeof block === "string") return block.trim().length > 0;
|
|
1783
|
+
if (!block || typeof block !== "object") return false;
|
|
1784
|
+
if (typeof block.text === "string" && block.text.trim()) return true;
|
|
1785
|
+
return Array.isArray(block.content) && block.content.some((part) => typeof part?.text === "string" && part.text.trim());
|
|
1786
|
+
});
|
|
1787
|
+
if (!usable) {
|
|
1788
|
+
this.ctx.logger?.warn?.("spine: official summarizer returned no usable summary; leaving the span untouched");
|
|
1789
|
+
throw new Error("spine: official summarizer returned no usable summary");
|
|
1790
|
+
}
|
|
1791
|
+
return result;
|
|
1792
|
+
}
|
|
1234
1793
|
}
|