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/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";
@@ -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 dir = sessionDir(shadow, warnKey);
187
- ensureShadowDir(dir, access);
188
-
189
- return withShadowRoot(shadow, () => {
190
- const owner = join(dir, "owner");
191
- const ownerExisted = existsSync(owner);
192
- let ownerWritten = false;
193
-
194
- try {
195
- writeShadowOwner(dir, access, shadow);
196
- ownerWritten = true;
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 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);
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(spanPieces(start, end)), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
438
- : 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);
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) this.captureOne(session, event);
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
- async finishTurn(agent, events) {
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
- await this.flushPendingCaptures();
579
- this.captureNew(agent.session, ev, settings);
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 (!this.pendingCaptures?.size) return;
683
- await Promise.allSettled([...this.pendingCaptures]);
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
- if (!id || !verifiedRaw(this.rawSeen.get(this.rawKey(session, id))).ok) {
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
- this.rawSeen.set(mapKey, record);
942
- return record;
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
- captureNew(session, events, settings = readSettings(this.liveConfig)) {
976
- if (!Array.isArray(events)) events = [];
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
- // A live event callback may have failed before the recent-three restart
982
- // boundary. Keep those visible originals eligible without replaying history.
983
- for (const seq of session?.surface?.nodes || []) {
984
- const event = session.eventAt?.(seq);
985
- const id = event?.type === "tool/result" && event.data?.message?.toolCallId;
986
- if (id && this.rawSeen.get(this.rawKey(session, id))?.retryable) cursor = Math.min(cursor, seq);
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
- 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) {
1067
1459
  const mapKey = this.rawKey(session, id);
1068
1460
  if (this.rawSeen.has(mapKey)) continue;
1069
1461
  try {
1070
- 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
+ }
1071
1475
  } catch (error) {
1072
1476
  noteShadowWarning(this.ctx?.logger, error);
1073
- 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));
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
- 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}`;
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
- for (const id of this.surfaceToolIds(session)) {
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 stored = readRawToolResult({ dir, toolCallId: id });
1103
- 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
+ }
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.saveShadow(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
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.saveShadow(
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 super.summarize(input, agent, signal);
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 super.summarize(input, agent, signal);
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 super.summarize({
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
  }