dsh-live-compact 1.1.4 → 1.1.6

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,31 +16,30 @@ 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";
42
+ import { activeCompactionPeers, warnCompactionStandDown } from "./gate.js";
44
43
 
45
44
  const { BasicCompactionEngine } = await loadBase();
46
45
  const { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } = await loadCompaction();
@@ -57,6 +56,134 @@ const CAPTURE_EVENT_BATCH = 128;
57
56
  const CAPTURE_RESULT_BATCH = 1;
58
57
  const RECOVER_RAW_BATCH = 1;
59
58
  const CAPTURE_RETRY_LIMIT = 8;
59
+ const RECOVERY_ACL_REAUTH_LIMIT = 32;
60
+ const SHADOW_WORKER_TIMEOUT_MS = 30000;
61
+
62
+ const shadowWorkers = new Map();
63
+
64
+ function shadowWorkerFor(root) {
65
+ const key = String(root);
66
+ let entry = shadowWorkers.get(key);
67
+ if (entry) return entry;
68
+ const worker = new Worker(new URL("./shadow-save-worker.mjs", import.meta.url), {
69
+ workerData: { spineWorker: 1, accessFuse: shadowAccessFuseBuffer() },
70
+ });
71
+ entry = { worker, nextId: 1, pending: new Map(), idleTimer: null, retired: false };
72
+ const removeEntry = () => {
73
+ if (shadowWorkers.get(key) === entry) shadowWorkers.delete(key);
74
+ };
75
+ const scheduleIdleExit = () => {
76
+ if (entry.retired || entry.pending.size || entry.idleTimer) return;
77
+ entry.idleTimer = setTimeout(() => {
78
+ entry.idleTimer = null;
79
+ if (entry.pending.size) return;
80
+ entry.retired = true;
81
+ removeEntry();
82
+ worker.terminate().catch(() => {});
83
+ }, 500);
84
+ entry.idleTimer.unref?.();
85
+ };
86
+ entry.scheduleIdleExit = scheduleIdleExit;
87
+ const fail = (error) => {
88
+ entry.retired = true;
89
+ if (entry.idleTimer) {
90
+ clearTimeout(entry.idleTimer);
91
+ entry.idleTimer = null;
92
+ }
93
+ for (const { reject, timer } of entry.pending.values()) {
94
+ if (timer) clearTimeout(timer);
95
+ reject(error);
96
+ }
97
+ entry.pending.clear();
98
+ removeEntry();
99
+ worker.terminate().catch(() => {});
100
+ };
101
+ worker.on("message", (message) => {
102
+ const task = entry.pending.get(message?.id);
103
+ if (!task) return;
104
+ if (task.timer) clearTimeout(task.timer);
105
+ entry.pending.delete(message.id);
106
+ if (message?.ok) {
107
+ task.resolve(message.result);
108
+ scheduleIdleExit();
109
+ return;
110
+ }
111
+ const error = new Error(message?.error?.message || "spine: shadow worker failed");
112
+ if (message?.error?.code) error.code = message.error.code;
113
+ task.reject(error);
114
+ scheduleIdleExit();
115
+ });
116
+ worker.on("error", (error) => fail(error));
117
+ worker.on("exit", (code) => {
118
+ if (entry.pending.size || (!entry.retired && code !== 0)) {
119
+ fail(Object.assign(new Error(`spine: shadow worker exited ${code}`), { code: "ESHADOWWORKER" }));
120
+ } else {
121
+ entry.retired = true;
122
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
123
+ entry.idleTimer = null;
124
+ removeEntry();
125
+ }
126
+ });
127
+ shadowWorkers.set(key, entry);
128
+ return entry;
129
+ }
130
+
131
+ function runShadowWorker(payload) {
132
+ const entry = shadowWorkerFor(payload.shadow);
133
+ if (entry.idleTimer) {
134
+ clearTimeout(entry.idleTimer);
135
+ entry.idleTimer = null;
136
+ }
137
+ const id = entry.nextId++;
138
+ return new Promise((resolve, reject) => {
139
+ const timer = setTimeout(() => {
140
+ const error = Object.assign(new Error("spine: shadow worker timed out"), { code: "ESHADOWWORKER" });
141
+ failShadowWorker(entry, error);
142
+ }, SHADOW_WORKER_TIMEOUT_MS);
143
+ timer.unref?.();
144
+ entry.pending.set(id, { resolve, reject, timer });
145
+ try {
146
+ entry.worker.postMessage({ id, payload });
147
+ } catch (error) {
148
+ clearTimeout(timer);
149
+ entry.pending.delete(id);
150
+ reject(error);
151
+ entry.scheduleIdleExit();
152
+ }
153
+ });
154
+ }
155
+
156
+ function failShadowWorker(entry, error) {
157
+ if (!entry || entry.retired) return;
158
+ entry.retired = true;
159
+ if (entry.idleTimer) {
160
+ clearTimeout(entry.idleTimer);
161
+ entry.idleTimer = null;
162
+ }
163
+ for (const { reject, timer } of entry.pending.values()) {
164
+ if (timer) clearTimeout(timer);
165
+ reject(error);
166
+ }
167
+ entry.pending.clear();
168
+ for (const [key, value] of shadowWorkers) {
169
+ if (value === entry) shadowWorkers.delete(key);
170
+ }
171
+ entry.worker.terminate().catch(() => {});
172
+ }
173
+
174
+ function recoveryRetryable(record, dir, toolCallId) {
175
+ if (record?.retryable === true || record?.reason === "unwritable") return true;
176
+ // A completely absent backup has never been captured and can be retried.
177
+ // If its metadata is still present, the body was damaged after capture and
178
+ // must remain fail-closed rather than being silently overwritten.
179
+ if (record?.reason === "missing-file") return !existsSync(join(dir, `${rawName(toolCallId)}.meta.json`));
180
+ return false;
181
+ }
182
+
183
+ function captureErrorRetryable(error) {
184
+ const code = error?.code;
185
+ return code === "ESHADOWBUSY" || ["EAGAIN", "EBUSY", "EINTR", "EIO"].includes(code);
186
+ }
60
187
 
61
188
  function eventContent(session, seq) {
62
189
  const event = session.eventAt(seq);
@@ -78,7 +205,27 @@ function isCheckpoint(session, seq) {
78
205
  // 自己的卡片,以及已经写过的官方标记,都要停住。新卡片不再用官方标记。
79
206
  // Stop on our own cards and on older official markers. New cards do not use the official marker.
80
207
  if (kind === SPINE_SOURCE_KIND || kind === "compact-checkpoint") return true;
81
- return eventText(session, seq).includes(MARK);
208
+ // The legacy text marker is only meaningful on a user/message card and only
209
+ // at the beginning of its text. A tool result or an ordinary sentence that
210
+ // merely mentions the marker must not create a permanent cut boundary.
211
+ if (event?.type !== "user/message") return false;
212
+ const text = eventText(session, seq).trimStart();
213
+ return text === MARK || text.startsWith(`${MARK}\n`) || text.startsWith(`${MARK}\r\n`);
214
+ }
215
+
216
+ function latestTurnCompleted(events) {
217
+ let lastStart = -1;
218
+ let lastEnd = -1;
219
+ let reason;
220
+ for (let index = 0; index < (events || []).length; index += 1) {
221
+ const event = events[index];
222
+ if (event?.type === "turn/start") lastStart = index;
223
+ if (event?.type === "turn/end") {
224
+ lastEnd = index;
225
+ reason = event.data?.reason?.kind;
226
+ }
227
+ }
228
+ return lastEnd > lastStart && reason === "completed";
82
229
  }
83
230
 
84
231
  function spineCheckpointSource(compactionId) {
@@ -184,80 +331,41 @@ export function saveShadowImpl(
184
331
  rotationIO = {},
185
332
  ) {
186
333
  const shadow = shadowDirectory();
187
- ensureShadowDir(shadow, access);
188
-
189
334
  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
- }
335
+ const body = shadowFileBody(warnKey, shadowBody(messages));
336
+ const cap = shadowMaxBytes();
337
+ const result = saveShadowTransaction({
338
+ shadow,
339
+ file,
340
+ body,
341
+ required,
342
+ protectPaths,
343
+ sessionKey: warnKey,
344
+ cap,
345
+ access,
346
+ rotationIO,
260
347
  });
348
+ if (!result?.ok) {
349
+ if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
350
+ const error = result.reason === "scan-incomplete"
351
+ ? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
352
+ : "spine: shadow capacity measurement is invalid; backup refused; leaving the span";
353
+ this.ctx.logger?.warn?.(error);
354
+ } else if (result?.rotation) {
355
+ this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
356
+ }
357
+ return result;
358
+ }
359
+ const rotation = result.rotation;
360
+ if (rotation.deleted > 0) {
361
+ this.ctx.logger?.info?.(
362
+ `spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
363
+ `腾出 ${rotation.freed} 字节,` +
364
+ `用量 ${rotation.used + Buffer.byteLength(body)}/${cap}`,
365
+ );
366
+ }
367
+ if (rotation.oversize) this.warnShadowOversize(warnKey, cap);
368
+ return result;
261
369
  }
262
370
 
263
371
  export { saveShadowImpl as saveShadow };
@@ -304,16 +412,16 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
304
412
  type: event?.type === "tool/result" ? "tool" : "other",
305
413
  toolCalls,
306
414
  longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
415
+ checkpoint: isCheckpoint(session, nodes[index]),
307
416
  });
308
417
  }
309
- const rounds = keptToolRounds(visible, settings.keepRounds);
418
+ const floorVisible = visible.reduce((found, item, index) => item.checkpoint ? index : found, -1);
419
+ const rounds = keptToolRounds(visible, settings.keepRounds, floorVisible);
310
420
  const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
311
421
  const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
312
422
  const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
313
423
  if (span && cutEnd === null) return null;
314
424
  if (cutEnd !== null) {
315
- const keptEnd = visible[span.end].index;
316
- for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
317
425
  end = cutEnd;
318
426
  while (end >= first && classifications[end].isGenuineUserBoundary && !(floorCard >= 0 && end - 1 <= floorCard)) end -= 1;
319
427
  if (floorCard >= 0 && end <= floorCard) return null;
@@ -365,7 +473,6 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
365
473
  }
366
474
  return pieces;
367
475
  };
368
- const spanText = (spanStart, spanEnd) => spanPieces(spanStart, spanEnd).map((piece) => piece.text).join("\n");
369
476
  const liveText = (spanStart, spanEnd) => {
370
477
  const bits = [];
371
478
  for (let index = 0; index < nodes.length; index += 1) {
@@ -402,6 +509,9 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
402
509
  };
403
510
  let usedLongCut = false;
404
511
  let picked = null;
512
+ const fallbackWithoutLongRead = () => context.disableLongRead
513
+ ? null
514
+ : selectNewestChunk(session, measurement, key, { ...context, disableLongRead: true });
405
515
  if (longSpan) {
406
516
  const longPicked = cutLongReadRound(items, longSpan.start, longSpan.end, cutOptions);
407
517
  if (longPicked && longPicked.start >= longSpan.start && longPicked.end <= longSpan.end) {
@@ -427,25 +537,39 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
427
537
  }
428
538
  }
429
539
  }
430
- if (pairStart < 0) return null;
431
- if (usedLongCut && pairStart < longSpan.start) return null;
432
- if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return null;
433
- if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return null;
540
+ if (pairStart < 0) return usedLongCut ? fallbackWithoutLongRead() : null;
541
+ if (usedLongCut && pairStart < longSpan.start) return fallbackWithoutLongRead();
542
+ if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return usedLongCut ? fallbackWithoutLongRead() : null;
543
+ if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return usedLongCut ? fallbackWithoutLongRead() : null;
434
544
  picked.start = pairStart;
435
545
  }
436
- if (usedLongCut && (picked.start < longSpan.start || picked.end > longSpan.end)) return null;
546
+ if (usedLongCut && (picked.start < longSpan.start || picked.end > longSpan.end)) return fallbackWithoutLongRead();
437
547
  const start = picked.start;
438
548
  end = picked.end;
439
549
  const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
550
+ const pieces = spanPieces(start, end);
551
+ if (picked.pointerOnly && pieces.some((piece) => piece.role === "tool" && isToolFailure(piece.text, piece.isError))) {
552
+ return usedLongCut ? fallbackWithoutLongRead() : null;
553
+ }
440
554
  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);
443
- if (card.ok !== true) return null;
555
+ ? { ok: true, text: pointerOnlyText(pieces), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
556
+ : buildCard(pieces.map((piece) => piece.text).join("\n"), liveText(start, end), pieces, extras);
557
+ if (card.ok !== true) return usedLongCut ? fallbackWithoutLongRead() : null;
444
558
  card = attachBackupHint(card, freshShadowFile("cut", key));
445
559
  if (!card.text.includes("不是备份文件路径")) card = { ...card, text: `${card.text}\n序号是本会话日志里的事件号,不是备份文件路径。` };
446
560
  if (!cutOk(toolPairingBalancedAfter, session, nodes[end])) return null;
447
561
  let after = 0;
448
562
  for (let index = end + 1; index < priced.length; index += 1) after += priced[index].tokens;
563
+ // `keptTokens` must describe retained rounds to the right of the actual
564
+ // cut. A long-read cut can be newer than the ordinary rounds selected above;
565
+ // counting those older rounds made `tailBeyondKept` negative and disabled
566
+ // the tail guard.
567
+ for (const round of rounds) {
568
+ const roundStart = visible[round.start]?.index;
569
+ const roundEnd = visible[round.end]?.index;
570
+ if (!Number.isInteger(roundStart) || !Number.isInteger(roundEnd) || roundStart <= end) continue;
571
+ for (let index = roundStart; index <= roundEnd; index += 1) keptTokens += priced[index].tokens;
572
+ }
449
573
  let prefix = 0;
450
574
  for (let index = 0; index < start; index += 1) prefix += priced[index].tokens;
451
575
  return { start: nodes[start], end: nodes[end], tokens, after, keptTokens, card, prefix };
@@ -473,6 +597,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
473
597
  captureRetryStats = new Map();
474
598
  retryableCaptureSeqs = new Map();
475
599
  retryableCaptureSeeded = new Set();
600
+ captureRecoveryHolds = new Map();
476
601
  pruneGuarded = false;
477
602
 
478
603
  constructor(ctx, config, resolvedConfig) {
@@ -487,6 +612,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
487
612
  });
488
613
  ctx.on("session/event", (session, event) => {
489
614
  try {
615
+ if (this.standDown?.()) return;
490
616
  this.noteEvent(session, event);
491
617
  if (event?.type !== "tool/result") return;
492
618
  const id = event.data?.message?.toolCallId;
@@ -500,6 +626,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
500
626
  job = new Promise((resolve) => {
501
627
  scheduleImmediate(() => {
502
628
  try {
629
+ if (this.standDown?.()) return;
503
630
  const current = this.rawSeen.get(key);
504
631
  if (!current || current.retryable) {
505
632
  const record = this.captureOne(session, event);
@@ -523,10 +650,11 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
523
650
  });
524
651
  ctx.on("agent/status", ({ agent, status }) => {
525
652
  if (status !== "idle") return;
653
+ if (this.standDown?.()) return;
526
654
  const state = this.stateFor(agent);
527
655
  if (state.warming) return;
528
656
  const events = agent.session.snapshotEvents();
529
- const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
657
+ const done = latestTurnCompleted(events);
530
658
  if (!done) return;
531
659
  state.warming = true;
532
660
  const job = this.finishTurn(agent, events, { deferCapture: true })
@@ -543,6 +671,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
543
671
  }
544
672
  }
545
673
 
674
+ standDown() {
675
+ const peers = activeCompactionPeers(this.ctx);
676
+ if (peers.length) warnCompactionStandDown(this.ctx);
677
+ return peers.length > 0;
678
+ }
679
+
680
+ ownedSignal(signal) {
681
+ const controller = new AbortController();
682
+ const dispose = this.ctx.on("internal/status", () => {
683
+ if (this.standDown?.()) controller.abort();
684
+ }, { global: true });
685
+ if (this.standDown?.()) controller.abort();
686
+ return {
687
+ signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
688
+ dispose,
689
+ };
690
+ }
691
+
546
692
  stateFor(agent) {
547
693
  const key = sessionKey(agent);
548
694
  let state = this.sessionState.get(key);
@@ -585,7 +731,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
585
731
  return planMessages(messages);
586
732
  }
587
733
 
588
- scheduleCaptureCatchup(agent, events, settings) {
734
+ scheduleCaptureCatchup(agent, events, settings, options = {}) {
589
735
  this.pendingCatchups ||= new Map();
590
736
  this.pendingCatchupJobs ||= new Set();
591
737
  this.pendingCuts ||= new Set();
@@ -596,6 +742,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
596
742
  task.session = agent.session;
597
743
  task.events = events;
598
744
  task.settings = settings;
745
+ task.freezeSnapshot ||= options.freezeSnapshot === true;
599
746
  return task;
600
747
  }
601
748
  let resolve;
@@ -606,16 +753,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
606
753
  session: agent.session,
607
754
  events,
608
755
  settings,
609
- phase: "events",
756
+ phase: "previsible",
610
757
  eventIndex: 0,
611
758
  recoverIndex: 0,
612
- visibleIds: null,
759
+ visibleIds: [...new Set(this.surfaceToolIds(agent.session))],
613
760
  scheduled: false,
614
761
  retryRequested: false,
615
762
  captureRetryAttempts: 0,
616
763
  progressed: false,
617
764
  eventTail: Number.isInteger(events?.at?.(-1)?.seq) ? events.at(-1).seq : -1,
618
765
  surfaceTail: agent.session.surface?.nodes?.at?.(-1) ?? -1,
766
+ freezeSnapshot: options.freezeSnapshot === true,
619
767
  resolve,
620
768
  promise,
621
769
  };
@@ -641,13 +789,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
641
789
  scheduleImmediate(() => {
642
790
  task.scheduled = false;
643
791
  try {
644
- const latest = task.session.snapshotEvents?.();
792
+ if (this.standDown?.()) {
793
+ this.finishCaptureCatchup(task);
794
+ return;
795
+ }
796
+ const latest = task.freezeSnapshot ? null : task.session.snapshotEvents?.();
645
797
  const latestTail = Number.isInteger(latest?.at?.(-1)?.seq) ? latest.at(-1).seq : -1;
646
798
  const latestSurfaceTail = task.session.surface?.nodes?.at?.(-1) ?? -1;
647
799
  if (Array.isArray(latest) && (latestTail !== task.eventTail || latest.length !== task.events.length)) {
648
800
  task.events = latest;
649
801
  task.eventTail = latestTail;
650
- if (task.phase === "visible") {
802
+ if (task.phase === "previsible" || task.phase === "visible") {
651
803
  task.phase = "events";
652
804
  task.visibleIds = null;
653
805
  task.recoverIndex = 0;
@@ -655,11 +807,22 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
655
807
  }
656
808
  if (latestSurfaceTail !== task.surfaceTail) {
657
809
  task.surfaceTail = latestSurfaceTail;
658
- if (task.phase === "visible") {
810
+ if (task.phase === "previsible" || task.phase === "visible") {
659
811
  task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
660
812
  task.recoverIndex = 0;
661
813
  }
662
814
  }
815
+ if (task.phase === "previsible") {
816
+ const recovered = this.recoverVisibleRawBatch(task.session, {}, task.recoverIndex, RECOVER_RAW_BATCH, task.visibleIds);
817
+ task.progressed ||= recovered.progressed;
818
+ task.recoverIndex = recovered.nextIndex;
819
+ if (!recovered.done) {
820
+ this.queueCaptureCatchup(task);
821
+ return;
822
+ }
823
+ task.phase = "events";
824
+ task.eventIndex = 0;
825
+ }
663
826
  if (task.phase === "events") {
664
827
  const result = this.captureNewBatch(task.session, task.events, task.settings, {
665
828
  eventLimit: CAPTURE_EVENT_BATCH,
@@ -709,18 +872,20 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
709
872
  this.pendingCatchups.delete(task.key);
710
873
  this.pendingCatchupJobs.delete(task.promise);
711
874
  task.resolve();
712
- if (task.retryRequested && task.progressed) this.scheduleMaintenanceRetry(task.agent);
875
+ if (!this.standDown?.() && task.retryRequested && task.progressed) this.scheduleMaintenanceRetry(task.agent);
713
876
  }
714
877
 
715
878
  scheduleMaintenanceRetry(agent) {
879
+ if (this.standDown?.()) return;
716
880
  const state = this.stateFor(agent);
717
881
  if (state.retryScheduled) return;
718
882
  state.retryScheduled = true;
719
883
  scheduleImmediate(() => {
720
884
  state.retryScheduled = false;
885
+ if (this.standDown?.()) return;
721
886
  if (state.warming) return;
722
887
  const events = agent.session.snapshotEvents();
723
- const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
888
+ const done = latestTurnCompleted(events);
724
889
  if (!done) return;
725
890
  state.warming = true;
726
891
  const job = this.finishTurn(agent, events, { deferCapture: true })
@@ -734,32 +899,49 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
734
899
  }
735
900
 
736
901
  async finishTurn(agent, events, options = {}) {
902
+ if (this.standDown?.()) return null;
737
903
  const settings = readSettings(this.liveConfig);
738
904
  const state = this.stateFor(agent);
739
905
  const signal = AbortSignal.timeout(30000);
740
906
  const ev = events || agent.session.snapshotEvents();
741
907
  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);
908
+ let catchup = { finished: true, progressed: false, retryRequested: false };
909
+ if (deferCapture) {
910
+ catchup = this.scheduleCaptureCatchup(agent, ev, settings);
911
+ } else {
912
+ if (ev.length > CAPTURE_EVENT_BATCH) {
913
+ catchup = this.scheduleCaptureCatchup(agent, ev, settings, { freezeSnapshot: true });
914
+ } else {
915
+ await this.flushPendingCaptures();
916
+ this.captureNew(agent.session, ev, settings);
917
+ }
748
918
  }
749
919
  const jobs = collectJobs(ev);
750
920
  const calls = indexToolCalls(ev);
751
921
  try {
752
922
  await agent.runMaintenance(async (agentSignal) => {
923
+ if (this.standDown?.()) return;
753
924
  const op = AbortSignal.any([agentSignal, signal]);
754
925
  let measurement = this.ctx.tokenMeter.measure(agent.session);
755
- if (measurement.totalTokens < settings.minTokens || measurement.totalTokens >= settings.bigGate) return;
926
+ const settleTurnBaseline = () => {
927
+ state.turnBase = measurement.totalTokens;
928
+ state.maxTurnGrowth = 0;
929
+ };
930
+ if (measurement.totalTokens < settings.minTokens || measurement.totalTokens >= settings.bigGate) {
931
+ settleTurnBaseline();
932
+ return;
933
+ }
756
934
  // Restored cards establish a baseline; a live card still skips once,
757
935
  // even when maintenance or engine construction is delayed.
758
936
  if (skipLiveOfficialCompaction(agent.session)) {
759
937
  this.ctx.logger?.info?.("spine cooldown: live official compaction seen, skip this pass");
938
+ settleTurnBaseline();
939
+ return;
940
+ }
941
+ if (inCooldown(agent.session, { windowMs: settings.cooldownMs })) {
942
+ settleTurnBaseline();
760
943
  return;
761
944
  }
762
- if (inCooldown(agent.session, { windowMs: settings.cooldownMs })) return;
763
945
  this.ensurePruneGuard();
764
946
  const resolved = this.engineFor(agent);
765
947
  if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
@@ -805,8 +987,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
805
987
  try {
806
988
  state.localOnly = true;
807
989
  state.approvedCard = chunk.card;
808
- if (deferCapture) await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
809
- await this.commitSelectedSpan(agent, chunk, op);
990
+ await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
991
+ const committed = await this.commitSelectedSpan(agent, chunk, op);
992
+ if (committed === false) break;
810
993
  measurement = this.ctx.tokenMeter.measure(agent.session);
811
994
  const after = measurement.totalTokens;
812
995
  reduced = after < before;
@@ -841,6 +1024,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
841
1024
  }
842
1025
  if (drainNotes.length) flushDrainStats(shadowDirectory(), drainNotes, { sid: state.key, drain });
843
1026
  state.turnBase = measurement.totalTokens;
1027
+ state.maxTurnGrowth = 0;
844
1028
  await this.ctx.sessions?.flush?.(agent.session);
845
1029
  this.rememberPrefix(agent);
846
1030
  });
@@ -882,6 +1066,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
882
1066
  }
883
1067
 
884
1068
  async commitSelectedSpan(agent, chunk, signal) {
1069
+ if (this.standDown?.()) return false;
885
1070
  signal?.throwIfAborted();
886
1071
  const session = agent.session;
887
1072
  if (chunk.card?.ok !== true || !chunk.card.text) throw new Error("spine: approved card missing; leaving the span");
@@ -914,7 +1099,28 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
914
1099
  const event = session.eventAt(seq);
915
1100
  if (event?.type === "tool/result") {
916
1101
  const id = event.data?.message?.toolCallId;
917
- const raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
1102
+ let raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
1103
+ if (id && raw?.ok && raw.recovered && raw.aclVerified !== true) {
1104
+ try {
1105
+ const verified = readRawToolResult({
1106
+ dir: sessionDir(shadowDirectory(), session.id || DEFAULT_KEY),
1107
+ toolCallId: id,
1108
+ tighten: true,
1109
+ });
1110
+ // ACL success cannot establish a new content identity. Keep the
1111
+ // first verified hash even if an external writer replaces both files.
1112
+ const sameBackup = verified?.ok && verified.path === raw.path &&
1113
+ verified.sha256 === raw.sha256 && verified.bytes === raw.bytes;
1114
+ raw = sameBackup
1115
+ ? { ...verified, recovered: false, aclVerified: true }
1116
+ : { ...raw, ok: false, reason: verified?.reason || "hash-mismatch", aclVerified: false };
1117
+ this.rawSeen.set(this.rawKey(session, id), raw);
1118
+ } catch (error) {
1119
+ noteShadowWarning(this.ctx?.logger, error);
1120
+ raw = { ...raw, ok: false, reason: "unwritable", toolCallId: id, aclVerified: false };
1121
+ this.rawSeen.set(this.rawKey(session, id), raw);
1122
+ }
1123
+ }
918
1124
  if (!id || !verifiedRaw(raw).ok) {
919
1125
  throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
920
1126
  }
@@ -927,6 +1133,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
927
1133
  }
928
1134
  const summarized = await this.summarize({ messages, liveCompact: { card: chunk.card } }, agent, signal);
929
1135
  signal?.throwIfAborted();
1136
+ if (this.standDown?.()) return false;
930
1137
  assertCurrentSpan(shadowedSeqs);
931
1138
  const compactionId = CompactionId(randomUUID());
932
1139
  const checkpointMessage = createUserMessage({
@@ -986,11 +1193,27 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
986
1193
  }
987
1194
 
988
1195
  async compactIfNeeded(agent, trigger, signal) {
1196
+ if (this.standDown?.()) return null;
989
1197
  const state = this.stateFor(agent);
990
1198
  state.liveAgent = agent;
991
1199
  state.localOnly = false;
992
1200
  this.ensurePruneGuard();
993
- return super.compactIfNeeded(agent, trigger, signal);
1201
+ const owned = this.ownedSignal(signal);
1202
+ try {
1203
+ return await super.compactIfNeeded(agent, trigger, owned.signal);
1204
+ } finally {
1205
+ owned.dispose?.();
1206
+ }
1207
+ }
1208
+
1209
+ async compactNow(agent, signal, sourceCommandId) {
1210
+ if (this.standDown?.()) return null;
1211
+ const owned = this.ownedSignal(signal);
1212
+ try {
1213
+ return await super.compactNow(agent, owned.signal, sourceCommandId);
1214
+ } finally {
1215
+ owned.dispose?.();
1216
+ }
994
1217
  }
995
1218
 
996
1219
  // Preserve explicit URL precedence. Only the exact account provider gets
@@ -999,9 +1222,11 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
999
1222
  const header = agent?.session?.requestHeader?.()?.config || {};
1000
1223
  const context = agent?.session?.requestContext?.() || {};
1001
1224
  const provider = header.provider || context.provider || "";
1225
+ const model = header.model || context.model || "";
1002
1226
  const listed = this.listedProviders();
1003
1227
  const baseURL = resolveProviderBaseURL({
1004
1228
  provider,
1229
+ model,
1005
1230
  headerURL: header.baseURL || context.baseURL || "",
1006
1231
  listed,
1007
1232
  layers: this.providerLayers(),
@@ -1073,10 +1298,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1073
1298
  try {
1074
1299
  const loaded = createRequire(anchor)("@earendil-works/pi-ai/providers/all");
1075
1300
  const list = typeof loaded.builtinProviders === "function" ? loaded.builtinProviders() : [];
1076
- return list.map((entry) => ({
1077
- id: entry?.id || "",
1078
- baseURL: entry?.baseUrl || entry?.baseURL || "",
1079
- })).filter((entry) => entry.id && entry.baseURL);
1301
+ return list.map((entry) => {
1302
+ let models = [];
1303
+ try {
1304
+ const declared = typeof entry?.getModels === "function" ? entry.getModels() : entry?.models || [];
1305
+ models = (Array.isArray(declared) ? declared : []).map((model) => ({
1306
+ id: model?.id || "",
1307
+ baseURL: model?.baseUrl || model?.baseURL || "",
1308
+ })).filter((model) => model.id && model.baseURL);
1309
+ } catch {}
1310
+ return {
1311
+ id: entry?.id || "",
1312
+ baseURL: entry?.baseUrl || entry?.baseURL || "",
1313
+ models,
1314
+ };
1315
+ }).filter((entry) => entry.id && (entry.baseURL || entry.models.length));
1080
1316
  } catch {}
1081
1317
  }
1082
1318
  return [];
@@ -1106,6 +1342,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1106
1342
  }
1107
1343
 
1108
1344
  captureOne(session, event, access = {}) {
1345
+ if (this.standDown?.()) return null;
1109
1346
  const id = event?.data?.message?.toolCallId;
1110
1347
  if (!id) return null;
1111
1348
  const key = session?.id || DEFAULT_KEY;
@@ -1137,8 +1374,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1137
1374
  ),
1138
1375
  access,
1139
1376
  });
1140
- this.rawSeen.set(mapKey, record);
1141
- return record;
1377
+ const cached = record?.ok ? { ...record, aclVerified: true } : record;
1378
+ this.rawSeen.set(mapKey, cached);
1379
+ return cached;
1142
1380
  } catch (error) {
1143
1381
  if (
1144
1382
  error?.code === "ESHADOWACCESS" ||
@@ -1151,7 +1389,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1151
1389
  }
1152
1390
  const message = error instanceof Error ? error.message : String(error);
1153
1391
  this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
1154
- const record = { ok: false, reason: "unwritable", toolCallId: id, retryable: error?.code === "ESHADOWBUSY" };
1392
+ const record = { ok: false, reason: "unwritable", toolCallId: id, retryable: captureErrorRetryable(error) };
1155
1393
  this.rawSeen.set(mapKey, record);
1156
1394
  return record;
1157
1395
  }
@@ -1211,11 +1449,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1211
1449
  }
1212
1450
 
1213
1451
  captureNewBatch(session, events, settings = readSettings(this.liveConfig), limits = {}) {
1452
+ if (this.standDown?.()) {
1453
+ return {
1454
+ seen: new Set(),
1455
+ progressed: false,
1456
+ retryable: false,
1457
+ done: true,
1458
+ nextIndex: Number.isInteger(limits.startIndex) ? limits.startIndex : 0,
1459
+ };
1460
+ }
1214
1461
  if (!Array.isArray(events)) events = [];
1215
1462
  const key = session?.id || DEFAULT_KEY;
1216
1463
  const cursor = this.captureCursorStart(session, events, settings);
1217
1464
  let next = cursor;
1218
1465
  let retry = null;
1466
+ let retryIndex = null;
1219
1467
  let processed = 0;
1220
1468
  let captures = 0;
1221
1469
  let progressed = false;
@@ -1245,7 +1493,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1245
1493
  captures += 1;
1246
1494
  }
1247
1495
  this.noteCaptureResult(session, event, record);
1248
- if (record?.retryable && retry === null) retry = seq;
1496
+ if (record?.retryable && retry === null) {
1497
+ retry = seq;
1498
+ retryIndex = index;
1499
+ }
1249
1500
  }
1250
1501
  }
1251
1502
  next = seq + 1;
@@ -1266,11 +1517,12 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1266
1517
  progressed,
1267
1518
  retryable: retry !== null,
1268
1519
  done: exhausted && retry === null && next > lastSeq,
1269
- nextIndex: index,
1520
+ nextIndex: retryIndex ?? index,
1270
1521
  };
1271
1522
  }
1272
1523
 
1273
1524
  captureNew(session, events, settings = readSettings(this.liveConfig)) {
1525
+ if (this.standDown?.()) return new Set();
1274
1526
  if (!Array.isArray(events)) events = [];
1275
1527
  const key = session?.id || DEFAULT_KEY;
1276
1528
  const cursor = this.captureCursorStart(session, events, settings);
@@ -1326,6 +1578,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1326
1578
  }
1327
1579
 
1328
1580
  writeCaptureCursor(key, cursor, access = {}) {
1581
+ if (this.standDown?.()) return;
1329
1582
  if (this.persistCapture !== true || !Number.isSafeInteger(cursor) || cursor < 0) return;
1330
1583
  let tmp;
1331
1584
  let fd;
@@ -1352,22 +1605,52 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1352
1605
  }
1353
1606
 
1354
1607
  recoverVisibleRaw(session, access = {}) {
1608
+ if (this.standDown?.()) return;
1355
1609
  const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
1356
- for (const id of new Set(this.surfaceToolIds(session))) {
1610
+ const ids = [...new Set(this.surfaceToolIds(session))];
1611
+ if (ids.length && existsSync(dir)) ensureShadowDir(dir, access);
1612
+ for (const id of ids) {
1357
1613
  const mapKey = this.rawKey(session, id);
1358
1614
  if (this.rawSeen.has(mapKey)) continue;
1359
1615
  try {
1360
- this.rawSeen.set(mapKey, readRawToolResult({ dir, toolCallId: id, access }));
1616
+ const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
1617
+ const retryable = recoveryRetryable(record, dir, id);
1618
+ const cached = record?.ok
1619
+ ? { ...record, recovered: true, aclVerified: false }
1620
+ : { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
1621
+ this.rawSeen.set(mapKey, cached);
1622
+ if (!cached.ok) {
1623
+ const event = (session?.surface?.nodes || [])
1624
+ .map((seq) => session.eventAt?.(seq))
1625
+ .find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
1626
+ if (event) this.noteCaptureResult(session, event, cached);
1627
+ if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
1628
+ }
1361
1629
  } catch (error) {
1362
1630
  noteShadowWarning(this.ctx?.logger, error);
1363
- this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
1631
+ const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
1632
+ this.rawSeen.set(mapKey, failed);
1633
+ const event = (session?.surface?.nodes || [])
1634
+ .map((seq) => session.eventAt?.(seq))
1635
+ .find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
1636
+ if (event) this.noteCaptureResult(session, event, failed);
1637
+ this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
1364
1638
  }
1365
1639
  }
1366
1640
  }
1367
1641
 
1368
1642
  recoverVisibleRawBatch(session, access = {}, startIndex = 0, limit = RECOVER_RAW_BATCH, visibleIds = null) {
1643
+ if (this.standDown?.()) return { nextIndex: startIndex, done: true, progressed: false };
1369
1644
  const ids = visibleIds || [...new Set(this.surfaceToolIds(session))];
1370
1645
  const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
1646
+ if (ids.length && startIndex === 0 && existsSync(dir)) {
1647
+ try {
1648
+ ensureShadowDir(dir, access);
1649
+ } catch (error) {
1650
+ noteShadowWarning(this.ctx?.logger, error);
1651
+ return { nextIndex: ids.length, done: true, progressed: false };
1652
+ }
1653
+ }
1371
1654
  let index = Math.max(0, startIndex);
1372
1655
  let processed = 0;
1373
1656
  let progressed = false;
@@ -1376,12 +1659,31 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1376
1659
  const mapKey = this.rawKey(session, id);
1377
1660
  if (this.rawSeen.has(mapKey)) continue;
1378
1661
  try {
1379
- const record = readRawToolResult({ dir, toolCallId: id, access });
1380
- this.rawSeen.set(mapKey, record);
1662
+ const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
1663
+ const retryable = recoveryRetryable(record, dir, id);
1664
+ const cached = record?.ok
1665
+ ? { ...record, recovered: true, aclVerified: false }
1666
+ : { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
1667
+ this.rawSeen.set(mapKey, cached);
1668
+ if (!cached.ok) {
1669
+ const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
1670
+ const candidate = session.eventAt?.(seq);
1671
+ return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
1672
+ }));
1673
+ if (event) this.noteCaptureResult(session, event, cached);
1674
+ if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
1675
+ }
1381
1676
  progressed ||= Boolean(record?.ok);
1382
1677
  } catch (error) {
1383
1678
  noteShadowWarning(this.ctx?.logger, error);
1384
- this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
1679
+ const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
1680
+ this.rawSeen.set(mapKey, failed);
1681
+ const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
1682
+ const candidate = session.eventAt?.(seq);
1683
+ return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
1684
+ }));
1685
+ if (event) this.noteCaptureResult(session, event, failed);
1686
+ this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
1385
1687
  }
1386
1688
  processed += 1;
1387
1689
  }
@@ -1389,7 +1691,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1389
1691
  }
1390
1692
 
1391
1693
  captureSession(session) {
1392
- return this.captureNew(session, session?.snapshotEvents?.() || []);
1694
+ if (this.standDown?.()) return new Set();
1695
+ const key = session?.id || DEFAULT_KEY;
1696
+ const events = session?.snapshotEvents?.() || [];
1697
+ const fingerprint = this.captureRecoveryFingerprint(session, events);
1698
+ if (this.captureRecoveryHolds.get(key) === fingerprint) {
1699
+ this.captureRecoveryHolds.delete(key);
1700
+ return new Set();
1701
+ }
1702
+ return this.captureNew(session, events);
1703
+ }
1704
+
1705
+ captureRecoveryFingerprint(session, events = null) {
1706
+ const snapshot = events || session?.snapshotEvents?.() || [];
1707
+ const nodes = session?.surface?.nodes || [];
1708
+ return `${snapshot.length}:${snapshot.at(-1)?.seq ?? -1}|${nodes.length}:${nodes.at(-1) ?? -1}`;
1393
1709
  }
1394
1710
 
1395
1711
  surfaceToolIds(session) {
@@ -1406,14 +1722,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1406
1722
  rawVerifiedSet(session) {
1407
1723
  const ids = new Set();
1408
1724
  const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
1409
- for (const id of this.surfaceToolIds(session)) {
1725
+ const visibleIds = this.surfaceToolIds(session);
1726
+ const recoveredNeedsAcl = visibleIds.filter((id) => {
1410
1727
  const cached = this.rawSeen.get(this.rawKey(session, id));
1411
- if (!cached?.ok) continue;
1728
+ return cached?.ok && cached.recovered === true && cached.aclVerified !== true;
1729
+ });
1730
+ if (recoveredNeedsAcl.length > RECOVERY_ACL_REAUTH_LIMIT) return ids;
1731
+ for (const id of visibleIds) {
1732
+ const cached = this.rawSeen.get(this.rawKey(session, id));
1733
+ if (!cached?.ok || cached.aclVerified !== true && cached.recovered !== true) continue;
1412
1734
  try {
1413
1735
  // Recheck both files immediately before prune, retaining the first
1414
1736
  // 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);
1737
+ const needsAcl = cached.recovered === true && cached.aclVerified !== true;
1738
+ const stored = readRawToolResult({ dir, toolCallId: id, tighten: needsAcl });
1739
+ if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) {
1740
+ ids.add(id);
1741
+ if (needsAcl) this.rawSeen.set(this.rawKey(session, id), { ...stored, recovered: false, aclVerified: true });
1742
+ }
1417
1743
  } catch (error) {
1418
1744
  noteShadowWarning(this.ctx?.logger, error);
1419
1745
  }
@@ -1422,6 +1748,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1422
1748
  }
1423
1749
 
1424
1750
  ensurePruneGuard() {
1751
+ if (this.standDown?.()) return;
1425
1752
  if (this.pruneGuarded) return;
1426
1753
  let pruner;
1427
1754
  try {
@@ -1439,6 +1766,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1439
1766
  function guarded(session) {
1440
1767
  const engine = pruner.__spineEngine;
1441
1768
  try {
1769
+ if (engine.standDown?.()) return;
1442
1770
  engine.captureSession(session);
1443
1771
  return runPruneAfterCapture(engine.surfaceToolIds(session), engine.rawVerifiedSet(session), () => original(session));
1444
1772
  } catch (error) {
@@ -1479,7 +1807,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1479
1807
  warnKey = DEFAULT_KEY,
1480
1808
  protectPaths = [],
1481
1809
  access = {},
1810
+ rotationIO = {},
1482
1811
  ) {
1812
+ if (this.standDown?.()) return { ok: false, reason: "stand-down" };
1483
1813
  return saveShadowImpl.call(
1484
1814
  this,
1485
1815
  messages,
@@ -1488,9 +1818,69 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1488
1818
  warnKey,
1489
1819
  protectPaths,
1490
1820
  access,
1821
+ rotationIO,
1491
1822
  );
1492
1823
  }
1493
1824
 
1825
+ async saveShadowAsync(
1826
+ messages,
1827
+ required,
1828
+ shadowFile,
1829
+ warnKey = DEFAULT_KEY,
1830
+ protectPaths = [],
1831
+ access = {},
1832
+ rotationIO = {},
1833
+ ) {
1834
+ if (this.standDown?.()) return { ok: false, reason: "stand-down" };
1835
+ if (!access.runner && (access.platform ?? process.platform) === "win32" && shadowAccessFused()) {
1836
+ const error = new Error("spine: shadow permission already failed; backup refused");
1837
+ error.code = "ESHADOWACCESS";
1838
+ throw error;
1839
+ }
1840
+ const injected = Object.values(access || {}).some((value) => typeof value === "function") ||
1841
+ Object.values(rotationIO || {}).some((value) => typeof value === "function");
1842
+ // Node's test runner can monkey-patch fs bindings in the parent isolate;
1843
+ // a worker cannot observe those injected failures. Keep test/fault-injection
1844
+ // paths synchronous while production uses the complete off-thread txn.
1845
+ if (injected || process.env.NODE_TEST_CONTEXT) {
1846
+ return this.saveShadow(messages, required, shadowFile, warnKey, protectPaths, access, rotationIO);
1847
+ }
1848
+ const shadow = shadowDirectory();
1849
+ const file = shadowFilePath(shadowFile, warnKey);
1850
+ const body = shadowFileBody(warnKey, shadowBody(messages));
1851
+ const cap = shadowMaxBytes();
1852
+ const result = await runShadowWorker({
1853
+ shadow,
1854
+ file,
1855
+ body,
1856
+ required,
1857
+ protectPaths,
1858
+ sessionKey: warnKey,
1859
+ cap,
1860
+ access,
1861
+ rotationIO,
1862
+ });
1863
+ if (!result?.ok) {
1864
+ if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
1865
+ this.ctx.logger?.warn?.(result.reason === "scan-incomplete"
1866
+ ? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
1867
+ : "spine: shadow capacity measurement is invalid; backup refused; leaving the span");
1868
+ } else if (result?.rotation) {
1869
+ this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
1870
+ }
1871
+ return result;
1872
+ }
1873
+ if (result.rotation?.deleted > 0) {
1874
+ this.ctx.logger?.info?.(
1875
+ `spine: shadow 轮换 删除 ${result.rotation.deleted} 个文件,` +
1876
+ `腾出 ${result.rotation.freed} 字节,` +
1877
+ `用量 ${result.rotation.used + Buffer.byteLength(body)}/${cap}`,
1878
+ );
1879
+ }
1880
+ if (result.rotation?.oversize) this.warnShadowOversize(warnKey, cap);
1881
+ return result;
1882
+ }
1883
+
1494
1884
  async summarize(input, agent, signal) {
1495
1885
  const decision = takeLocalCut(input?.liveCompact);
1496
1886
  const state = this.stateFor(agent);
@@ -1500,14 +1890,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1500
1890
  const card = input?.liveCompact?.card;
1501
1891
  if (card?.needsBackup && !card.shadowFile) throw new Error("spine: omit refused; backup path missing");
1502
1892
  const restored = this.restoreRawMessages(input.messages, state.key);
1503
- this.saveShadow(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
1893
+ await this.saveShadowAsync(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
1504
1894
  if (card?.needsBackup && !existsSync(card.shadowFile)) throw new Error("spine: shadow backup was not written");
1505
1895
  const text = backupClaimOk(restored) ? decision.text : withoutFullBackupClaim(decision.text, card?.shadowFile);
1506
1896
  const block = { type: "text", text };
1507
1897
  return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
1508
1898
  }
1509
1899
  try {
1510
- this.saveShadow(
1900
+ await this.saveShadowAsync(
1511
1901
  this.restoreRawMessages(input.messages, state.key),
1512
1902
  false,
1513
1903
  freshShadowFile("official", state.key),
@@ -1522,7 +1912,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1522
1912
  }
1523
1913
  }
1524
1914
  const joined = input.messages.map(messageText).join("\n\n");
1525
- if (process.env.SPINE_THUNDER !== "1") return super.summarize(input, agent, signal);
1915
+ if (process.env.SPINE_THUNDER !== "1") return this.summarizeOfficial(input, agent, signal);
1526
1916
  const next = joined;
1527
1917
  const mode = decideThunder({
1528
1918
  previous: state.lastPrefix || "",
@@ -1530,18 +1920,34 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1530
1920
  modelKind: process.env.SPINE_THUNDER_MODEL === "27b" ? "27b" : "flash",
1531
1921
  prefixTokens: state.lastPrefixTokens || 0,
1532
1922
  });
1533
- if (mode === "attach") return super.summarize(input, agent, signal);
1923
+ if (mode === "attach") return this.summarizeOfficial(input, agent, signal);
1534
1924
  const users = input.messages.filter((message) => (message?.role ?? message?.message?.role) === "user");
1535
1925
  const taskLine = messageText(users.at(-1) || input.messages.at(-1)).slice(0, 500);
1536
1926
  const trunk = buildCard(joined, "", input.messages.map((message) => ({
1537
1927
  role: message?.role ?? message?.message?.role,
1538
1928
  text: messageText(message),
1539
1929
  })));
1540
- const facts = [...new Set([...(trunk.hardLines || []), ...(trunk.missing || [])])];
1930
+ const facts = [...(trunk.hardFacts || []), ...(trunk.missingFacts || [])];
1541
1931
  const slim = buildSendLess({ taskLine, uncovered: trunk.uncovered, facts });
1542
- return super.summarize({
1932
+ return this.summarizeOfficial({
1543
1933
  ...input,
1544
1934
  messages: [{ role: "user", content: [{ type: "text", text: slim }] }],
1545
1935
  }, agent, signal);
1546
1936
  }
1937
+
1938
+ async summarizeOfficial(input, agent, signal) {
1939
+ const result = await super.summarize(input, agent, signal);
1940
+ const blocks = Array.isArray(result?.summary) ? result.summary : [];
1941
+ const usable = blocks.some((block) => {
1942
+ if (typeof block === "string") return block.trim().length > 0;
1943
+ if (!block || typeof block !== "object") return false;
1944
+ if (typeof block.text === "string" && block.text.trim()) return true;
1945
+ return Array.isArray(block.content) && block.content.some((part) => typeof part?.text === "string" && part.text.trim());
1946
+ });
1947
+ if (!usable) {
1948
+ this.ctx.logger?.warn?.("spine: official summarizer returned no usable summary; leaving the span untouched");
1949
+ throw new Error("spine: official summarizer returned no usable summary");
1950
+ }
1951
+ return result;
1952
+ }
1547
1953
  }