dsh-live-compact 1.1.4 → 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/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
- rotateShadow,
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";
@@ -57,6 +55,100 @@ const CAPTURE_EVENT_BATCH = 128;
57
55
  const CAPTURE_RESULT_BATCH = 1;
58
56
  const RECOVER_RAW_BATCH = 1;
59
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
+ }
60
152
 
61
153
  function eventContent(session, seq) {
62
154
  const event = session.eventAt(seq);
@@ -184,80 +276,41 @@ export function saveShadowImpl(
184
276
  rotationIO = {},
185
277
  ) {
186
278
  const shadow = shadowDirectory();
187
- ensureShadowDir(shadow, access);
188
-
189
279
  const file = shadowFilePath(shadowFile, warnKey);
190
- const dir = sessionDir(shadow, warnKey);
191
- ensureShadowDir(dir, access);
192
-
193
- return withShadowRoot(shadow, () => {
194
- const owner = join(dir, "owner");
195
- const ownerExisted = existsSync(owner);
196
- let ownerWritten = false;
197
-
198
- try {
199
- writeShadowOwner(dir, access, shadow);
200
- ownerWritten = true;
201
-
202
- const body = shadowFileBody(warnKey, shadowBody(messages));
203
- const incoming = Buffer.byteLength(body);
204
- const cap = shadowMaxBytes();
205
-
206
- const rotation = rotateShadow({
207
- ...rotationIO,
208
- dir: shadow,
209
- incomingBytes: incoming,
210
- protectPath: file,
211
- protectPaths,
212
- sessionKey: warnKey,
213
- cap,
214
- });
215
-
216
- if (!rotation.ok) {
217
- if (rotation.reason) {
218
- const incomplete = rotation.reason === "scan-incomplete";
219
- const error = Object.assign(new Error(incomplete
220
- ? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
221
- : "spine: shadow capacity measurement is invalid; backup refused; leaving the span"), {
222
- code: incomplete ? "ESHADOWSCAN" : "ESHADOWMETER",
223
- });
224
- if (required) throw error;
225
- this.ctx.logger?.warn?.(error.message);
226
- return;
227
- }
228
- this.warnShadowFull(warnKey, shadow, rotation.used, cap);
229
- if (required) {
230
- throw new Error(
231
- "spine: shadow backup is full; leaving the span",
232
- );
233
- }
234
- return;
235
- }
236
-
237
- if (rotation.deleted > 0) {
238
- this.ctx.logger?.info?.(
239
- `spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
240
- `腾出 ${rotation.freed} 字节,` +
241
- `用量 ${rotation.used + incoming}/${cap}`,
242
- );
243
- }
244
-
245
- if (rotation.oversize) {
246
- this.warnShadowOversize(warnKey, cap);
247
- }
248
-
249
- writeShadowFile(file, body, access, { root: shadow });
250
-
251
- if (!existsSync(file)) {
252
- throw new Error("spine: shadow backup was not written");
253
- }
254
- } catch (error) {
255
- if (ownerWritten && !ownerExisted) {
256
- discardShadowFiles([owner], error);
257
- }
258
- throw error;
259
- }
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,
260
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;
261
314
  }
262
315
 
263
316
  export { saveShadowImpl as saveShadow };
@@ -304,9 +357,11 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
304
357
  type: event?.type === "tool/result" ? "tool" : "other",
305
358
  toolCalls,
306
359
  longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
360
+ checkpoint: isCheckpoint(session, nodes[index]),
307
361
  });
308
362
  }
309
- const rounds = keptToolRounds(visible, settings.keepRounds);
363
+ const floorVisible = visible.reduce((found, item, index) => item.checkpoint ? index : found, -1);
364
+ const rounds = keptToolRounds(visible, settings.keepRounds, floorVisible);
310
365
  const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
311
366
  const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
312
367
  const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
@@ -365,7 +420,6 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
365
420
  }
366
421
  return pieces;
367
422
  };
368
- const spanText = (spanStart, spanEnd) => spanPieces(spanStart, spanEnd).map((piece) => piece.text).join("\n");
369
423
  const liveText = (spanStart, spanEnd) => {
370
424
  const bits = [];
371
425
  for (let index = 0; index < nodes.length; index += 1) {
@@ -437,9 +491,11 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
437
491
  const start = picked.start;
438
492
  end = picked.end;
439
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;
440
496
  let card = picked.pointerOnly
441
- ? { ok: true, text: pointerOnlyText(spanPieces(start, end)), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
442
- : buildCard(spanText(start, end), liveText(start, end), spanPieces(start, end), extras);
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);
443
499
  if (card.ok !== true) return null;
444
500
  card = attachBackupHint(card, freshShadowFile("cut", key));
445
501
  if (!card.text.includes("不是备份文件路径")) card = { ...card, text: `${card.text}\n序号是本会话日志里的事件号,不是备份文件路径。` };
@@ -473,6 +529,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
473
529
  captureRetryStats = new Map();
474
530
  retryableCaptureSeqs = new Map();
475
531
  retryableCaptureSeeded = new Set();
532
+ captureRecoveryHolds = new Map();
476
533
  pruneGuarded = false;
477
534
 
478
535
  constructor(ctx, config, resolvedConfig) {
@@ -585,7 +642,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
585
642
  return planMessages(messages);
586
643
  }
587
644
 
588
- scheduleCaptureCatchup(agent, events, settings) {
645
+ scheduleCaptureCatchup(agent, events, settings, options = {}) {
589
646
  this.pendingCatchups ||= new Map();
590
647
  this.pendingCatchupJobs ||= new Set();
591
648
  this.pendingCuts ||= new Set();
@@ -596,6 +653,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
596
653
  task.session = agent.session;
597
654
  task.events = events;
598
655
  task.settings = settings;
656
+ task.freezeSnapshot ||= options.freezeSnapshot === true;
599
657
  return task;
600
658
  }
601
659
  let resolve;
@@ -606,16 +664,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
606
664
  session: agent.session,
607
665
  events,
608
666
  settings,
609
- phase: "events",
667
+ phase: "previsible",
610
668
  eventIndex: 0,
611
669
  recoverIndex: 0,
612
- visibleIds: null,
670
+ visibleIds: [...new Set(this.surfaceToolIds(agent.session))],
613
671
  scheduled: false,
614
672
  retryRequested: false,
615
673
  captureRetryAttempts: 0,
616
674
  progressed: false,
617
675
  eventTail: Number.isInteger(events?.at?.(-1)?.seq) ? events.at(-1).seq : -1,
618
676
  surfaceTail: agent.session.surface?.nodes?.at?.(-1) ?? -1,
677
+ freezeSnapshot: options.freezeSnapshot === true,
619
678
  resolve,
620
679
  promise,
621
680
  };
@@ -641,13 +700,13 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
641
700
  scheduleImmediate(() => {
642
701
  task.scheduled = false;
643
702
  try {
644
- const latest = task.session.snapshotEvents?.();
703
+ const latest = task.freezeSnapshot ? null : task.session.snapshotEvents?.();
645
704
  const latestTail = Number.isInteger(latest?.at?.(-1)?.seq) ? latest.at(-1).seq : -1;
646
705
  const latestSurfaceTail = task.session.surface?.nodes?.at?.(-1) ?? -1;
647
706
  if (Array.isArray(latest) && (latestTail !== task.eventTail || latest.length !== task.events.length)) {
648
707
  task.events = latest;
649
708
  task.eventTail = latestTail;
650
- if (task.phase === "visible") {
709
+ if (task.phase === "previsible" || task.phase === "visible") {
651
710
  task.phase = "events";
652
711
  task.visibleIds = null;
653
712
  task.recoverIndex = 0;
@@ -655,11 +714,22 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
655
714
  }
656
715
  if (latestSurfaceTail !== task.surfaceTail) {
657
716
  task.surfaceTail = latestSurfaceTail;
658
- if (task.phase === "visible") {
717
+ if (task.phase === "previsible" || task.phase === "visible") {
659
718
  task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
660
719
  task.recoverIndex = 0;
661
720
  }
662
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
+ }
663
733
  if (task.phase === "events") {
664
734
  const result = this.captureNewBatch(task.session, task.events, task.settings, {
665
735
  eventLimit: CAPTURE_EVENT_BATCH,
@@ -739,12 +809,16 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
739
809
  const signal = AbortSignal.timeout(30000);
740
810
  const ev = events || agent.session.snapshotEvents();
741
811
  const deferCapture = options.deferCapture === true;
742
- const catchup = deferCapture
743
- ? this.scheduleCaptureCatchup(agent, ev, settings)
744
- : { finished: true, progressed: false, retryRequested: false };
745
- if (!deferCapture) {
746
- await this.flushPendingCaptures();
747
- this.captureNew(agent.session, ev, settings);
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
+ }
748
822
  }
749
823
  const jobs = collectJobs(ev);
750
824
  const calls = indexToolCalls(ev);
@@ -805,7 +879,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
805
879
  try {
806
880
  state.localOnly = true;
807
881
  state.approvedCard = chunk.card;
808
- if (deferCapture) await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
882
+ await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
809
883
  await this.commitSelectedSpan(agent, chunk, op);
810
884
  measurement = this.ctx.tokenMeter.measure(agent.session);
811
885
  const after = measurement.totalTokens;
@@ -914,7 +988,28 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
914
988
  const event = session.eventAt(seq);
915
989
  if (event?.type === "tool/result") {
916
990
  const id = event.data?.message?.toolCallId;
917
- const raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
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
+ }
918
1013
  if (!id || !verifiedRaw(raw).ok) {
919
1014
  throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
920
1015
  }
@@ -1137,8 +1232,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1137
1232
  ),
1138
1233
  access,
1139
1234
  });
1140
- this.rawSeen.set(mapKey, record);
1141
- return record;
1235
+ const cached = record?.ok ? { ...record, aclVerified: true } : record;
1236
+ this.rawSeen.set(mapKey, cached);
1237
+ return cached;
1142
1238
  } catch (error) {
1143
1239
  if (
1144
1240
  error?.code === "ESHADOWACCESS" ||
@@ -1216,6 +1312,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1216
1312
  const cursor = this.captureCursorStart(session, events, settings);
1217
1313
  let next = cursor;
1218
1314
  let retry = null;
1315
+ let retryIndex = null;
1219
1316
  let processed = 0;
1220
1317
  let captures = 0;
1221
1318
  let progressed = false;
@@ -1245,7 +1342,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1245
1342
  captures += 1;
1246
1343
  }
1247
1344
  this.noteCaptureResult(session, event, record);
1248
- if (record?.retryable && retry === null) retry = seq;
1345
+ if (record?.retryable && retry === null) {
1346
+ retry = seq;
1347
+ retryIndex = index;
1348
+ }
1249
1349
  }
1250
1350
  }
1251
1351
  next = seq + 1;
@@ -1266,7 +1366,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1266
1366
  progressed,
1267
1367
  retryable: retry !== null,
1268
1368
  done: exhausted && retry === null && next > lastSeq,
1269
- nextIndex: index,
1369
+ nextIndex: retryIndex ?? index,
1270
1370
  };
1271
1371
  }
1272
1372
 
@@ -1353,14 +1453,34 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1353
1453
 
1354
1454
  recoverVisibleRaw(session, access = {}) {
1355
1455
  const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
1356
- for (const id of new Set(this.surfaceToolIds(session))) {
1456
+ const ids = [...new Set(this.surfaceToolIds(session))];
1457
+ if (ids.length && existsSync(dir)) ensureShadowDir(dir, access);
1458
+ for (const id of ids) {
1357
1459
  const mapKey = this.rawKey(session, id);
1358
1460
  if (this.rawSeen.has(mapKey)) continue;
1359
1461
  try {
1360
- this.rawSeen.set(mapKey, readRawToolResult({ dir, toolCallId: id, access }));
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
+ }
1361
1475
  } catch (error) {
1362
1476
  noteShadowWarning(this.ctx?.logger, error);
1363
- this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
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));
1364
1484
  }
1365
1485
  }
1366
1486
  }
@@ -1368,6 +1488,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1368
1488
  recoverVisibleRawBatch(session, access = {}, startIndex = 0, limit = RECOVER_RAW_BATCH, visibleIds = null) {
1369
1489
  const ids = visibleIds || [...new Set(this.surfaceToolIds(session))];
1370
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
+ }
1371
1499
  let index = Math.max(0, startIndex);
1372
1500
  let processed = 0;
1373
1501
  let progressed = false;
@@ -1376,12 +1504,31 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1376
1504
  const mapKey = this.rawKey(session, id);
1377
1505
  if (this.rawSeen.has(mapKey)) continue;
1378
1506
  try {
1379
- const record = readRawToolResult({ dir, toolCallId: id, access });
1380
- this.rawSeen.set(mapKey, record);
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
+ }
1381
1521
  progressed ||= Boolean(record?.ok);
1382
1522
  } catch (error) {
1383
1523
  noteShadowWarning(this.ctx?.logger, error);
1384
- this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
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));
1385
1532
  }
1386
1533
  processed += 1;
1387
1534
  }
@@ -1389,7 +1536,20 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1389
1536
  }
1390
1537
 
1391
1538
  captureSession(session) {
1392
- return this.captureNew(session, session?.snapshotEvents?.() || []);
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}`;
1393
1553
  }
1394
1554
 
1395
1555
  surfaceToolIds(session) {
@@ -1406,14 +1566,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1406
1566
  rawVerifiedSet(session) {
1407
1567
  const ids = new Set();
1408
1568
  const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
1409
- for (const id of this.surfaceToolIds(session)) {
1569
+ const visibleIds = this.surfaceToolIds(session);
1570
+ const recoveredNeedsAcl = visibleIds.filter((id) => {
1410
1571
  const cached = this.rawSeen.get(this.rawKey(session, id));
1411
- if (!cached?.ok) continue;
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) {
1576
+ const cached = this.rawSeen.get(this.rawKey(session, id));
1577
+ if (!cached?.ok || cached.aclVerified !== true && cached.recovered !== true) continue;
1412
1578
  try {
1413
1579
  // Recheck both files immediately before prune, retaining the first
1414
1580
  // trusted hash rather than blessing a replacement raw/meta pair.
1415
- const stored = readRawToolResult({ dir, toolCallId: id });
1416
- if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) ids.add(id);
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
+ }
1417
1587
  } catch (error) {
1418
1588
  noteShadowWarning(this.ctx?.logger, error);
1419
1589
  }
@@ -1479,6 +1649,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1479
1649
  warnKey = DEFAULT_KEY,
1480
1650
  protectPaths = [],
1481
1651
  access = {},
1652
+ rotationIO = {},
1482
1653
  ) {
1483
1654
  return saveShadowImpl.call(
1484
1655
  this,
@@ -1488,9 +1659,68 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1488
1659
  warnKey,
1489
1660
  protectPaths,
1490
1661
  access,
1662
+ rotationIO,
1491
1663
  );
1492
1664
  }
1493
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
+
1494
1724
  async summarize(input, agent, signal) {
1495
1725
  const decision = takeLocalCut(input?.liveCompact);
1496
1726
  const state = this.stateFor(agent);
@@ -1500,14 +1730,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1500
1730
  const card = input?.liveCompact?.card;
1501
1731
  if (card?.needsBackup && !card.shadowFile) throw new Error("spine: omit refused; backup path missing");
1502
1732
  const restored = this.restoreRawMessages(input.messages, state.key);
1503
- this.saveShadow(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
1733
+ await this.saveShadowAsync(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
1504
1734
  if (card?.needsBackup && !existsSync(card.shadowFile)) throw new Error("spine: shadow backup was not written");
1505
1735
  const text = backupClaimOk(restored) ? decision.text : withoutFullBackupClaim(decision.text, card?.shadowFile);
1506
1736
  const block = { type: "text", text };
1507
1737
  return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
1508
1738
  }
1509
1739
  try {
1510
- this.saveShadow(
1740
+ await this.saveShadowAsync(
1511
1741
  this.restoreRawMessages(input.messages, state.key),
1512
1742
  false,
1513
1743
  freshShadowFile("official", state.key),
@@ -1522,7 +1752,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1522
1752
  }
1523
1753
  }
1524
1754
  const joined = input.messages.map(messageText).join("\n\n");
1525
- if (process.env.SPINE_THUNDER !== "1") return super.summarize(input, agent, signal);
1755
+ if (process.env.SPINE_THUNDER !== "1") return this.summarizeOfficial(input, agent, signal);
1526
1756
  const next = joined;
1527
1757
  const mode = decideThunder({
1528
1758
  previous: state.lastPrefix || "",
@@ -1530,7 +1760,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1530
1760
  modelKind: process.env.SPINE_THUNDER_MODEL === "27b" ? "27b" : "flash",
1531
1761
  prefixTokens: state.lastPrefixTokens || 0,
1532
1762
  });
1533
- if (mode === "attach") return super.summarize(input, agent, signal);
1763
+ if (mode === "attach") return this.summarizeOfficial(input, agent, signal);
1534
1764
  const users = input.messages.filter((message) => (message?.role ?? message?.message?.role) === "user");
1535
1765
  const taskLine = messageText(users.at(-1) || input.messages.at(-1)).slice(0, 500);
1536
1766
  const trunk = buildCard(joined, "", input.messages.map((message) => ({
@@ -1539,9 +1769,25 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1539
1769
  })));
1540
1770
  const facts = [...new Set([...(trunk.hardLines || []), ...(trunk.missing || [])])];
1541
1771
  const slim = buildSendLess({ taskLine, uncovered: trunk.uncovered, facts });
1542
- return super.summarize({
1772
+ return this.summarizeOfficial({
1543
1773
  ...input,
1544
1774
  messages: [{ role: "user", content: [{ type: "text", text: slim }] }],
1545
1775
  }, agent, signal);
1546
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
+ }
1547
1793
  }