dsh-live-compact 1.1.0 → 1.1.2
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/README.md +87 -35
- package/adapter.js +16 -0
- package/client.js +145 -0
- package/cooldown.js +21 -5
- package/cordis.patch.yml +5 -9
- package/gate.js +73 -46
- package/index.js +351 -126
- package/package.json +22 -6
- package/plan.js +116 -27
- package/raw.js +52 -15
- package/settings.js +136 -0
- package/shadow.js +802 -81
- package/shell.js +38 -28
package/index.js
CHANGED
|
@@ -8,48 +8,48 @@
|
|
|
8
8
|
* 小切在轮次结束后做,不调模型。最近 3 个已结束的工具轮留在原处。
|
|
9
9
|
* 切法和 1.0.6 一样:不越过用户消息。不够 1000 token 就跳过,不回头凑。
|
|
10
10
|
* 超过 65 万停掉小切,交给官方大压缩。
|
|
11
|
-
* The small cut runs after the turn, with no model call. It does not cross a user
|
|
11
|
+
* The small cut runs after the turn, with no model call. It does not cross a genuine user
|
|
12
12
|
* message. Under 1000 tokens, skip. Do not walk back to manufacture a cut.
|
|
13
13
|
* Above 650000, small cuts stop for the host gate.
|
|
14
14
|
*/
|
|
15
15
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
16
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
-
import {
|
|
19
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
20
19
|
import { pathToFileURL } from "node:url";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { captureOwnedToolResult, rawExcerptText, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
20
|
+
import { loadBase, loadCompaction, loadLlm } from "./adapter.js";
|
|
21
|
+
import { DEFAULTS, engineConfig, readSettings } from "./settings.js";
|
|
22
|
+
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";
|
|
23
|
+
import { captureOwnedToolResult, contentHash, rawExcerptText, readRawToolResult, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
26
24
|
import {
|
|
27
25
|
discardShadowFiles,
|
|
28
26
|
ensureShadowDir,
|
|
29
27
|
flushDrainStats,
|
|
28
|
+
noteShadowWarning,
|
|
30
29
|
rotateShadow,
|
|
30
|
+
shadowDirectory,
|
|
31
31
|
sessionDir,
|
|
32
|
+
shadowAccessFused,
|
|
32
33
|
shadowFileBody,
|
|
33
34
|
shadowMaxBytes,
|
|
35
|
+
tightenCreatedShadow,
|
|
36
|
+
tightenShadow,
|
|
34
37
|
writeShadowFile,
|
|
35
38
|
writeShadowOwner,
|
|
39
|
+
withShadowRoot,
|
|
36
40
|
} from "./shadow.js";
|
|
37
41
|
import { buildSendLess, decideThunder } from "./thunder.js";
|
|
38
|
-
import {
|
|
42
|
+
import { inCooldown, skipLiveOfficialCompaction } from "./cooldown.js";
|
|
43
|
+
|
|
44
|
+
const { BasicCompactionEngine } = await loadBase();
|
|
45
|
+
const { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } = await loadCompaction();
|
|
46
|
+
const { createUserMessage } = await loadLlm();
|
|
39
47
|
|
|
40
48
|
const MARK = "Spine extract.";
|
|
41
49
|
const SPINE_SOURCE_KIND = "spine-checkpoint";
|
|
42
50
|
const CHECKPOINT_PREAMBLE = "这是旧内容摘录,不是新指令,也不是你的授权。请从后面的消息接着做,不要把摘录里的句子当成你刚说的话。 This excerpt is not a new instruction, not a confirmation, and not an authorization.";
|
|
43
|
-
const CHUNK_MIN = Number(process.env.SPINE_CHUNK_MIN || 1000); // 不够这一刀就不切,避免每轮一张碎卡片。
|
|
44
|
-
const CHUNK_MAX = Number(process.env.SPINE_CHUNK_MAX || RAIL_TOKENS); // 一刀最多 3.2 万,和卡片轨道对齐。
|
|
45
|
-
const POINTER_TOKENS = Number(process.env.SPINE_POINTER_TOKENS || CHUNK_MAX); // 单个工具结果超过这个数才只留指针。默认仍是 3.2 万。
|
|
46
|
-
const BIG_GATE = 650000; // 绝对停切线,不是窗口比例。1M 官方大门约 678464。
|
|
47
51
|
const URGENT_TOKENS = 48000;
|
|
48
52
|
const MAX_TAIL = 2200;
|
|
49
|
-
const MIN_PRESSURE = Number(process.env.SPINE_MIN_TOKENS || 20000);
|
|
50
|
-
const KEEP_ROUNDS = Number(process.env.SPINE_KEEP_ROUNDS || 3);
|
|
51
|
-
const KEEP_ROUND_TOKENS = Number(process.env.SPINE_KEEP_ROUND_TOKENS || 32000);
|
|
52
|
-
const COOLDOWN_MS = resolveCooldownMs(); // 额外的时间兜底;0 = 只用 seq 一次性跳过
|
|
53
53
|
const MAX_TRACKED_SESSIONS = 64;
|
|
54
54
|
const DEFAULT_KEY = "__default__";
|
|
55
55
|
|
|
@@ -90,8 +90,26 @@ function eventRole(session, seq) {
|
|
|
90
90
|
return "other";
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// progress-rules 0.2.1's pre-step contract, not a general notice permission.
|
|
94
|
+
// Session source metadata does not authenticate producer identity. Unknown/extra metadata
|
|
95
|
+
// stays a boundary; neither body text nor the raw role is used as a substitute.
|
|
96
|
+
function classifyUserEvent(session, seq) {
|
|
97
|
+
const event = session.eventAt(seq);
|
|
98
|
+
const data = event?.data;
|
|
99
|
+
const source = data?.source;
|
|
100
|
+
const content = data?.content;
|
|
101
|
+
const pluginNotice = event?.type === "user/message" && !isCheckpoint(session, seq)
|
|
102
|
+
&& data?.role === "user"
|
|
103
|
+
&& typeof data.id === "string" && data.id.length > 0
|
|
104
|
+
&& !Object.hasOwn(event, "source")
|
|
105
|
+
&& Object.keys(data).every((key) => ["id", "role", "content", "source"].includes(key))
|
|
106
|
+
&& source?.kind === "progress-rules" && source.form === "notice"
|
|
107
|
+
&& source.summary === "过程纪律:要求正文通报"
|
|
108
|
+
&& Object.keys(source).length === 3
|
|
109
|
+
&& Array.isArray(content) && content.length === 1
|
|
110
|
+
&& content[0]?.type === "text" && typeof content[0].text === "string"
|
|
111
|
+
&& Object.keys(content[0]).length === 2;
|
|
112
|
+
return { pluginNotice, isGenuineUserBoundary: event?.type === "user/message" && !pluginNotice };
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
function cutOk(fn, session, seq) {
|
|
@@ -102,10 +120,11 @@ function cutOk(fn, session, seq) {
|
|
|
102
120
|
}
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
function restartCaptureCursor(events, keepRounds = DEFAULTS.keepRounds) {
|
|
124
|
+
const results = (events || []).filter((event) => event?.type === "tool/result");
|
|
125
|
+
if (results.length <= keepRounds) return 0;
|
|
126
|
+
const boundary = results[results.length - keepRounds - 1];
|
|
127
|
+
return Number.isInteger(boundary?.seq) ? boundary.seq + 1 : 0;
|
|
109
128
|
}
|
|
110
129
|
|
|
111
130
|
function sessionKey(agent) {
|
|
@@ -157,6 +176,7 @@ export function saveShadowImpl(
|
|
|
157
176
|
warnKey = DEFAULT_KEY,
|
|
158
177
|
protectPaths = [],
|
|
159
178
|
access = {},
|
|
179
|
+
rotationIO = {},
|
|
160
180
|
) {
|
|
161
181
|
const shadow = shadowDirectory();
|
|
162
182
|
ensureShadowDir(shadow, access);
|
|
@@ -165,75 +185,91 @@ export function saveShadowImpl(
|
|
|
165
185
|
const dir = sessionDir(shadow, warnKey);
|
|
166
186
|
ensureShadowDir(dir, access);
|
|
167
187
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
188
|
+
return withShadowRoot(shadow, () => {
|
|
189
|
+
const owner = join(dir, "owner");
|
|
190
|
+
const ownerExisted = existsSync(owner);
|
|
191
|
+
let ownerWritten = false;
|
|
171
192
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
try {
|
|
194
|
+
writeShadowOwner(dir, access, shadow);
|
|
195
|
+
ownerWritten = true;
|
|
175
196
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
197
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
198
|
+
const incoming = Buffer.byteLength(body);
|
|
199
|
+
const cap = shadowMaxBytes();
|
|
179
200
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
const rotation = rotateShadow({
|
|
202
|
+
...rotationIO,
|
|
203
|
+
dir: shadow,
|
|
204
|
+
incomingBytes: incoming,
|
|
205
|
+
protectPath: file,
|
|
206
|
+
protectPaths,
|
|
207
|
+
sessionKey: warnKey,
|
|
208
|
+
cap,
|
|
209
|
+
});
|
|
188
210
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
if (!rotation.ok) {
|
|
212
|
+
if (rotation.reason) {
|
|
213
|
+
const incomplete = rotation.reason === "scan-incomplete";
|
|
214
|
+
const error = Object.assign(new Error(incomplete
|
|
215
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
216
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span"), {
|
|
217
|
+
code: incomplete ? "ESHADOWSCAN" : "ESHADOWMETER",
|
|
218
|
+
});
|
|
219
|
+
if (required) throw error;
|
|
220
|
+
this.ctx.logger?.warn?.(error.message);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
224
|
+
if (required) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"spine: shadow backup is full; leaving the span",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
195
230
|
}
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
231
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
232
|
+
if (rotation.deleted > 0) {
|
|
233
|
+
this.ctx.logger?.info?.(
|
|
234
|
+
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
235
|
+
`腾出 ${rotation.freed} 字节,` +
|
|
236
|
+
`用量 ${rotation.used + incoming}/${cap}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
206
239
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
240
|
+
if (rotation.oversize) {
|
|
241
|
+
this.warnShadowOversize(warnKey, cap);
|
|
242
|
+
}
|
|
210
243
|
|
|
211
|
-
|
|
244
|
+
writeShadowFile(file, body, access, { root: shadow });
|
|
212
245
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
246
|
+
if (!existsSync(file)) {
|
|
247
|
+
throw new Error("spine: shadow backup was not written");
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (ownerWritten && !ownerExisted) {
|
|
251
|
+
discardShadowFiles([owner], error);
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
219
254
|
}
|
|
220
|
-
|
|
221
|
-
}
|
|
255
|
+
});
|
|
222
256
|
}
|
|
223
257
|
|
|
224
258
|
export { saveShadowImpl as saveShadow };
|
|
225
259
|
|
|
226
260
|
// 从后往前选一刀,和 1.0.6 一样。不越过用户消息。工具对必须成对。
|
|
227
|
-
// 最近
|
|
228
|
-
// Pick one span from the tail, as in 1.0.6. Do not cross a user message.
|
|
229
|
-
// Tool pairs stay balanced. The newest
|
|
230
|
-
// Under
|
|
261
|
+
// 最近 settings.keepRounds 个已结束的工具轮留在原处。不够 settings.chunkMin 就返回 null。
|
|
262
|
+
// Pick one span from the tail, as in 1.0.6. Do not cross a genuine user message.
|
|
263
|
+
// Tool pairs stay balanced. The newest settings.keepRounds finished rounds stay verbatim.
|
|
264
|
+
// Under settings.chunkMin, return null.
|
|
231
265
|
function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}) {
|
|
266
|
+
const settings = context.settings ?? readSettings();
|
|
232
267
|
const decision = jobCutDecision(context.jobs || []);
|
|
233
268
|
if (!decision.cut) return null;
|
|
234
269
|
const nodes = session.surface.nodes;
|
|
235
270
|
const priced = measurement.nodes;
|
|
236
271
|
if (nodes.length === 0 || nodes.length !== priced.length) return null;
|
|
272
|
+
const classifications = nodes.map((seq) => classifyUserEvent(session, seq));
|
|
237
273
|
const first = session.eventAt(nodes[0])?.type === "system/message" ? 1 : 0;
|
|
238
274
|
let end = priced.length - 1;
|
|
239
275
|
let floorCard = -1;
|
|
@@ -256,9 +292,17 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
256
292
|
const toolCalls = event?.type === "assistant/message"
|
|
257
293
|
? (event.data?.message?.content || []).filter((block) => block.type === "tool-call").length
|
|
258
294
|
: 0;
|
|
259
|
-
|
|
295
|
+
const toolCallId = event?.data?.message?.toolCallId || "";
|
|
296
|
+
const call = toolCallId ? context.calls?.get?.(toolCallId) : null;
|
|
297
|
+
visible.push({
|
|
298
|
+
index,
|
|
299
|
+
type: event?.type === "tool/result" ? "tool" : "other",
|
|
300
|
+
toolCalls,
|
|
301
|
+
longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
|
|
302
|
+
});
|
|
260
303
|
}
|
|
261
|
-
const rounds = keptToolRounds(visible,
|
|
304
|
+
const rounds = keptToolRounds(visible, settings.keepRounds);
|
|
305
|
+
const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
|
|
262
306
|
const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
|
|
263
307
|
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
264
308
|
if (span && cutEnd === null) return null;
|
|
@@ -266,7 +310,7 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
266
310
|
const keptEnd = visible[span.end].index;
|
|
267
311
|
for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
|
|
268
312
|
end = cutEnd;
|
|
269
|
-
while (end >= first &&
|
|
313
|
+
while (end >= first && classifications[end].isGenuineUserBoundary && !(floorCard >= 0 && end - 1 <= floorCard)) end -= 1;
|
|
270
314
|
if (floorCard >= 0 && end <= floorCard) return null;
|
|
271
315
|
if (isCheckpoint(session, nodes[end])) return null;
|
|
272
316
|
while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) {
|
|
@@ -275,7 +319,10 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
275
319
|
}
|
|
276
320
|
if (floorCard >= 0 && end <= floorCard) return null;
|
|
277
321
|
}
|
|
278
|
-
|
|
322
|
+
const longSpan = newestLong && visible[newestLong.start].index > floorCard
|
|
323
|
+
? { start: visible[newestLong.start].index, end: visible[newestLong.end].index }
|
|
324
|
+
: null;
|
|
325
|
+
if (!longSpan && (end < first || isCheckpoint(session, nodes[end]))) return null;
|
|
279
326
|
const spanPieces = (spanStart, spanEnd) => {
|
|
280
327
|
const pieces = [];
|
|
281
328
|
let pendingCommand = "";
|
|
@@ -296,6 +343,8 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
296
343
|
const text = excerpt.text;
|
|
297
344
|
pieces.push({
|
|
298
345
|
role: eventRole(session, nodes[index]),
|
|
346
|
+
source: event?.data?.source,
|
|
347
|
+
...classifications[index],
|
|
299
348
|
text,
|
|
300
349
|
isError: Boolean(event?.data?.message?.isError || event?.data?.isError),
|
|
301
350
|
command: call?.command || (event?.type === "tool/result" ? pendingCommand : ""),
|
|
@@ -329,22 +378,36 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
329
378
|
: event?.type === "system/message" ? "system" : "other";
|
|
330
379
|
return {
|
|
331
380
|
type,
|
|
381
|
+
...classifications[index],
|
|
332
382
|
tokens: priced[index].tokens,
|
|
333
383
|
checkpoint: isCheckpoint(session, seq),
|
|
334
384
|
balancedBefore: cutOk(toolPairingBalancedBefore, session, seq),
|
|
335
385
|
balancedAfter: cutOk(toolPairingBalancedAfter, session, seq),
|
|
336
386
|
};
|
|
337
387
|
});
|
|
338
|
-
const
|
|
388
|
+
const cutOptions = {
|
|
339
389
|
engine: context.engine || "cloud",
|
|
340
|
-
chunkMin:
|
|
341
|
-
chunkMax:
|
|
342
|
-
pointerMax:
|
|
390
|
+
chunkMin: settings.chunkMin,
|
|
391
|
+
chunkMax: settings.chunkMax,
|
|
392
|
+
pointerMax: settings.pointerTokens,
|
|
343
393
|
maxEnd: end,
|
|
344
394
|
totalTokens: context.totalTokens,
|
|
345
395
|
drain: context.drain === true,
|
|
346
396
|
urgentTokens: URGENT_TOKENS,
|
|
347
|
-
}
|
|
397
|
+
};
|
|
398
|
+
let usedLongCut = false;
|
|
399
|
+
let picked = null;
|
|
400
|
+
if (longSpan) {
|
|
401
|
+
const longPicked = cutLongReadRound(items, longSpan.start, longSpan.end, cutOptions);
|
|
402
|
+
if (longPicked && longPicked.start >= longSpan.start && longPicked.end <= longSpan.end) {
|
|
403
|
+
picked = longPicked;
|
|
404
|
+
usedLongCut = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!picked) {
|
|
408
|
+
if (end < first || isCheckpoint(session, nodes[end])) return null;
|
|
409
|
+
picked = selectCutIndexes(items, cutOptions);
|
|
410
|
+
}
|
|
348
411
|
if (!picked) return null;
|
|
349
412
|
if (picked.pointerOnly) {
|
|
350
413
|
const result = session.eventAt(nodes[picked.end]);
|
|
@@ -360,10 +423,12 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
360
423
|
}
|
|
361
424
|
}
|
|
362
425
|
if (pairStart < 0) return null;
|
|
426
|
+
if (usedLongCut && pairStart < longSpan.start) return null;
|
|
363
427
|
if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return null;
|
|
364
428
|
if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return null;
|
|
365
429
|
picked.start = pairStart;
|
|
366
430
|
}
|
|
431
|
+
if (usedLongCut && (picked.start < longSpan.start || picked.end > longSpan.end)) return null;
|
|
367
432
|
const start = picked.start;
|
|
368
433
|
end = picked.end;
|
|
369
434
|
const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
|
|
@@ -381,7 +446,8 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
381
446
|
return { start: nodes[start], end: nodes[end], tokens, after, keptTokens, card, prefix };
|
|
382
447
|
}
|
|
383
448
|
|
|
384
|
-
export
|
|
449
|
+
export const BIG_GATE = DEFAULTS.bigGate;
|
|
450
|
+
export { isCheckpoint, planMessages, spineCheckpointSource };
|
|
385
451
|
|
|
386
452
|
// 只换「切哪一段、怎么换成卡片」。官方大门仍由宿主算。
|
|
387
453
|
// Changes which span is cut and how it becomes a card. The host still owns the stock gate.
|
|
@@ -395,19 +461,25 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
395
461
|
captureCursor = new Map();
|
|
396
462
|
pruneGuarded = false;
|
|
397
463
|
|
|
398
|
-
constructor(ctx, config) {
|
|
464
|
+
constructor(ctx, config, resolvedConfig) {
|
|
399
465
|
if (process.env.SPINE_ENGINE_FAIL === "construct") throw new Error("spine: constructor refused");
|
|
400
|
-
super(ctx, config);
|
|
466
|
+
super(ctx, resolvedConfig ?? engineConfig(config));
|
|
467
|
+
this.persistCapture = true;
|
|
401
468
|
if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
|
|
469
|
+
this.liveConfig = config;
|
|
402
470
|
try {
|
|
403
471
|
const pendingCuts = new Set();
|
|
404
472
|
ctx.effect(() => async () => {
|
|
405
473
|
await Promise.allSettled(pendingCuts);
|
|
406
474
|
});
|
|
407
475
|
ctx.on("session/event", (session, event) => {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
476
|
+
try {
|
|
477
|
+
this.noteEvent(session, event);
|
|
478
|
+
if (event?.type !== "tool/result") return;
|
|
479
|
+
this.captureOne(session, event);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
noteShadowWarning(ctx.logger, error);
|
|
482
|
+
}
|
|
411
483
|
});
|
|
412
484
|
ctx.on("agent/status", ({ agent, status }) => {
|
|
413
485
|
if (status !== "idle") return;
|
|
@@ -417,10 +489,12 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
417
489
|
const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
|
|
418
490
|
if (!done) return;
|
|
419
491
|
state.warming = true;
|
|
420
|
-
const job = this.finishTurn(agent, events)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
492
|
+
const job = this.finishTurn(agent, events)
|
|
493
|
+
.catch((error) => noteShadowWarning(this.ctx?.logger, error))
|
|
494
|
+
.finally(() => {
|
|
495
|
+
state.warming = false;
|
|
496
|
+
pendingCuts.delete(job);
|
|
497
|
+
});
|
|
424
498
|
pendingCuts.add(job);
|
|
425
499
|
});
|
|
426
500
|
} catch (error) {
|
|
@@ -472,26 +546,25 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
472
546
|
}
|
|
473
547
|
|
|
474
548
|
async finishTurn(agent, events) {
|
|
549
|
+
const settings = readSettings(this.liveConfig);
|
|
475
550
|
const state = this.stateFor(agent);
|
|
476
551
|
const signal = AbortSignal.timeout(30000);
|
|
477
552
|
const ev = events || agent.session.snapshotEvents();
|
|
478
|
-
this.captureNew(agent.session, ev);
|
|
553
|
+
this.captureNew(agent.session, ev, settings);
|
|
479
554
|
const jobs = collectJobs(ev);
|
|
480
555
|
const calls = indexToolCalls(ev);
|
|
481
556
|
try {
|
|
482
557
|
await agent.runMaintenance(async (agentSignal) => {
|
|
483
558
|
const op = AbortSignal.any([agentSignal, signal]);
|
|
484
559
|
let measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
485
|
-
if (measurement.totalTokens <
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
state.cooldownSeenSeq = officialSeq;
|
|
491
|
-
this.ctx.logger?.info?.(`spine cooldown: official compaction ${officialSeq} seen, skip this pass`);
|
|
560
|
+
if (measurement.totalTokens < settings.minTokens || measurement.totalTokens >= settings.bigGate) return;
|
|
561
|
+
// Restored cards establish a baseline; a live card still skips once,
|
|
562
|
+
// even when maintenance or engine construction is delayed.
|
|
563
|
+
if (skipLiveOfficialCompaction(agent.session)) {
|
|
564
|
+
this.ctx.logger?.info?.("spine cooldown: live official compaction seen, skip this pass");
|
|
492
565
|
return;
|
|
493
566
|
}
|
|
494
|
-
if (inCooldown(agent.session, { windowMs:
|
|
567
|
+
if (inCooldown(agent.session, { windowMs: settings.cooldownMs })) return;
|
|
495
568
|
this.ensurePruneGuard();
|
|
496
569
|
const resolved = this.engineFor(agent);
|
|
497
570
|
if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
|
|
@@ -506,13 +579,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
506
579
|
const base = state.turnBase ?? last;
|
|
507
580
|
const growth = Math.max(0, last - base);
|
|
508
581
|
state.maxTurnGrowth = Math.max(state.maxTurnGrowth || 0, growth);
|
|
509
|
-
const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth >
|
|
582
|
+
const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth > settings.keepRoundTokens;
|
|
510
583
|
const drain = cloud && (projected || last >= URGENT_TOKENS);
|
|
511
584
|
const drainNotes = [];
|
|
512
585
|
for (let pass = 0; pass < 8; pass += 1) {
|
|
513
586
|
const measured = measurement;
|
|
514
|
-
if (drain && measured.totalTokens <=
|
|
587
|
+
if (drain && measured.totalTokens <= settings.keepRoundTokens) break;
|
|
515
588
|
const chunk = selectNewestChunk(agent.session, measured, state.key, {
|
|
589
|
+
settings,
|
|
516
590
|
engine: resolved.engine,
|
|
517
591
|
jobs,
|
|
518
592
|
calls,
|
|
@@ -521,7 +595,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
521
595
|
drain,
|
|
522
596
|
});
|
|
523
597
|
if (chunk === null) {
|
|
524
|
-
if (drain && measured.totalTokens >
|
|
598
|
+
if (drain && measured.totalTokens > settings.keepRoundTokens) {
|
|
525
599
|
this.ctx.logger?.warn?.(`spine drain stopped at ${measured.totalTokens}; no legal span`);
|
|
526
600
|
} else {
|
|
527
601
|
this.ctx.logger?.debug?.("spine cut skipped");
|
|
@@ -581,15 +655,40 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
581
655
|
async commitSelectedSpan(agent, chunk, signal) {
|
|
582
656
|
signal?.throwIfAborted();
|
|
583
657
|
const session = agent.session;
|
|
584
|
-
const nodes = session.surface.nodes;
|
|
585
|
-
const startIdx = nodes.indexOf(chunk.start);
|
|
586
|
-
const endIdx = nodes.indexOf(chunk.end);
|
|
587
|
-
if (startIdx < 0 || endIdx < startIdx) throw new Error("spine: selected span is not on the surface");
|
|
588
658
|
if (chunk.card?.ok !== true || !chunk.card.text) throw new Error("spine: approved card missing; leaving the span");
|
|
589
|
-
const
|
|
659
|
+
const assertCurrentSpan = (expected) => {
|
|
660
|
+
const nodes = session.surface.nodes;
|
|
661
|
+
const start = nodes.indexOf(chunk.start);
|
|
662
|
+
const end = nodes.indexOf(chunk.end);
|
|
663
|
+
if (start < 0 || end < start) throw new Error("spine: selected span is not on the surface");
|
|
664
|
+
const selected = nodes.slice(start, end + 1);
|
|
665
|
+
if (expected && (selected.length !== expected.length || selected.some((seq, i) => seq !== expected[i]))) {
|
|
666
|
+
throw new Error("spine: selected span changed before commit");
|
|
667
|
+
}
|
|
668
|
+
const items = nodes.map(seq => ({
|
|
669
|
+
type: session.eventAt(seq)?.type === "user/message" ? "user" : "other",
|
|
670
|
+
...classifyUserEvent(session, seq),
|
|
671
|
+
checkpoint: isCheckpoint(session, seq),
|
|
672
|
+
}));
|
|
673
|
+
const floor = items.findLastIndex(item => item.checkpoint);
|
|
674
|
+
if (start <= floor || !pointerSpanAllowed(items, start, end, this.engineFor(agent).engine)) {
|
|
675
|
+
throw new Error("spine: selected span violates checkpoint or user boundary");
|
|
676
|
+
}
|
|
677
|
+
if (!cutOk(toolPairingBalancedBefore, session, chunk.start) || !cutOk(toolPairingBalancedAfter, session, chunk.end)) {
|
|
678
|
+
throw new Error("spine: selected span violates tool pairing");
|
|
679
|
+
}
|
|
680
|
+
return selected;
|
|
681
|
+
};
|
|
682
|
+
const shadowedSeqs = assertCurrentSpan();
|
|
590
683
|
const messages = [];
|
|
591
684
|
for (const seq of shadowedSeqs) {
|
|
592
685
|
const event = session.eventAt(seq);
|
|
686
|
+
if (event?.type === "tool/result") {
|
|
687
|
+
const id = event.data?.message?.toolCallId;
|
|
688
|
+
if (!id || !verifiedRaw(this.rawSeen.get(this.rawKey(session, id))).ok) {
|
|
689
|
+
throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
593
692
|
messages.push({
|
|
594
693
|
role: eventRole(session, seq),
|
|
595
694
|
content: eventContent(session, seq),
|
|
@@ -597,6 +696,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
597
696
|
});
|
|
598
697
|
}
|
|
599
698
|
const summarized = await this.summarize({ messages, liveCompact: { card: chunk.card } }, agent, signal);
|
|
699
|
+
signal?.throwIfAborted();
|
|
700
|
+
assertCurrentSpan(shadowedSeqs);
|
|
600
701
|
const compactionId = CompactionId(randomUUID());
|
|
601
702
|
const checkpointMessage = createUserMessage({
|
|
602
703
|
content: [
|
|
@@ -662,10 +763,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
662
763
|
return super.compactIfNeeded(agent, trigger, signal);
|
|
663
764
|
}
|
|
664
765
|
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// Headers often carry only the provider. After the catalog and official DeepSeek, read the live profile URL.
|
|
668
|
-
// No URL still means local, so an unconfigured session is not rewritten as cloud.
|
|
766
|
+
// Preserve explicit URL precedence. Only the exact account provider gets
|
|
767
|
+
// its root settings / launch environment sources and a no-address fallback.
|
|
669
768
|
engineFor(agent) {
|
|
670
769
|
const header = agent?.session?.requestHeader?.()?.config || {};
|
|
671
770
|
const context = agent?.session?.requestContext?.() || {};
|
|
@@ -676,7 +775,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
676
775
|
headerURL: header.baseURL || context.baseURL || "",
|
|
677
776
|
listed,
|
|
678
777
|
layers: this.providerLayers(),
|
|
778
|
+
directory: this.configurableProviders(provider),
|
|
679
779
|
patchText: this.profilePatchText(),
|
|
780
|
+
launchURL: this.accountLaunchURL(provider),
|
|
680
781
|
catalog: this.catalogProviders(),
|
|
681
782
|
});
|
|
682
783
|
return resolveEngine({ provider, baseURL });
|
|
@@ -705,12 +806,34 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
705
806
|
providerLayers() {
|
|
706
807
|
try {
|
|
707
808
|
const rows = this.hostService("configEditor")?.configuration?.() || [];
|
|
708
|
-
return rows.map((row) => ({ override: row?.override, inherited: row?.inherited }));
|
|
809
|
+
return rows.map((row) => ({ settingsNs: row?.entry?.options?.id, override: row?.override, inherited: row?.inherited }));
|
|
709
810
|
} catch {
|
|
710
811
|
return [];
|
|
711
812
|
}
|
|
712
813
|
}
|
|
713
814
|
|
|
815
|
+
configurableProviders(provider) {
|
|
816
|
+
if (provider !== "deepseek-account") return [];
|
|
817
|
+
try {
|
|
818
|
+
const llm = this.ctx.llm || this.hostService("llm");
|
|
819
|
+
return llm?.listConfigurableProviders?.() || [];
|
|
820
|
+
} catch {
|
|
821
|
+
return [];
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
accountLaunchURL(provider) {
|
|
826
|
+
if (provider !== "deepseek-account") return "";
|
|
827
|
+
try {
|
|
828
|
+
const environment = this.hostService("launchEnvironment");
|
|
829
|
+
// Match launchEnvironmentOf: process env applies only without a snapshot.
|
|
830
|
+
return environment ? environment.get("DEEPSEEK_BASE_URL")?.value ?? "" : process.env.DEEPSEEK_BASE_URL ?? "";
|
|
831
|
+
} catch {
|
|
832
|
+
// Unreadable is not evidence of absence. Keep the decision conservative.
|
|
833
|
+
return " ";
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
714
837
|
catalogProviders() {
|
|
715
838
|
const anchors = [import.meta.url];
|
|
716
839
|
try {
|
|
@@ -758,6 +881,12 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
758
881
|
const key = session?.id || DEFAULT_KEY;
|
|
759
882
|
const mapKey = this.rawKey(session, id);
|
|
760
883
|
if (this.rawSeen.get(mapKey)?.ok) return this.rawSeen.get(mapKey);
|
|
884
|
+
const platform = access.platform ?? process.platform;
|
|
885
|
+
if (!access.runner && platform === "win32" && shadowAccessFused()) {
|
|
886
|
+
const skipped = { ok: false, reason: "unwritable", toolCallId: id };
|
|
887
|
+
this.rawSeen.set(mapKey, skipped);
|
|
888
|
+
return skipped;
|
|
889
|
+
}
|
|
761
890
|
const call = this.callIndex.get(session?.id || DEFAULT_KEY)?.get(id) || null;
|
|
762
891
|
try {
|
|
763
892
|
const shadow = shadowDirectory();
|
|
@@ -766,6 +895,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
766
895
|
const dir = sessionDir(shadow, key);
|
|
767
896
|
const record = captureOwnedToolResult({
|
|
768
897
|
dir,
|
|
898
|
+
root: shadow,
|
|
769
899
|
toolCallId: id,
|
|
770
900
|
text: messageText(event.data?.message),
|
|
771
901
|
turn: event.data?.turn,
|
|
@@ -784,11 +914,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
784
914
|
error?.code === "ESHADOWACCESS" ||
|
|
785
915
|
error?.code === "ESHADOWCLEANUP"
|
|
786
916
|
) {
|
|
787
|
-
|
|
917
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
918
|
+
const refused = { ok: false, reason: "unwritable", toolCallId: id };
|
|
919
|
+
this.rawSeen.set(mapKey, refused);
|
|
920
|
+
return refused;
|
|
788
921
|
}
|
|
789
922
|
const message = error instanceof Error ? error.message : String(error);
|
|
790
923
|
this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
|
|
791
|
-
const record = { ok: false, reason: "unwritable", toolCallId: id };
|
|
924
|
+
const record = { ok: false, reason: "unwritable", toolCallId: id, retryable: error?.code === "ESHADOWBUSY" };
|
|
792
925
|
this.rawSeen.set(mapKey, record);
|
|
793
926
|
return record;
|
|
794
927
|
}
|
|
@@ -808,11 +941,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
808
941
|
}
|
|
809
942
|
}
|
|
810
943
|
|
|
811
|
-
captureNew(session, events) {
|
|
944
|
+
captureNew(session, events, settings = readSettings(this.liveConfig)) {
|
|
945
|
+
if (!Array.isArray(events)) events = [];
|
|
812
946
|
const key = session?.id || DEFAULT_KEY;
|
|
813
|
-
let cursor = this.captureCursor.get(key)
|
|
814
|
-
if (
|
|
947
|
+
let cursor = this.captureCursor.has(key) ? this.captureCursor.get(key) : this.readCaptureCursor(key);
|
|
948
|
+
if (cursor == null) cursor = restartCaptureCursor(events, settings.keepRounds);
|
|
949
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0 || events.length < cursor) cursor = restartCaptureCursor(events, settings.keepRounds);
|
|
950
|
+
// A live event callback may have failed before the recent-three restart
|
|
951
|
+
// boundary. Keep those visible originals eligible without replaying history.
|
|
952
|
+
for (const seq of session?.surface?.nodes || []) {
|
|
953
|
+
const event = session.eventAt?.(seq);
|
|
954
|
+
const id = event?.type === "tool/result" && event.data?.message?.toolCallId;
|
|
955
|
+
if (id && this.rawSeen.get(this.rawKey(session, id))?.retryable) cursor = Math.min(cursor, seq);
|
|
956
|
+
}
|
|
815
957
|
let next = cursor;
|
|
958
|
+
let retry = null;
|
|
816
959
|
const seen = new Set();
|
|
817
960
|
for (const event of events) {
|
|
818
961
|
const seq = Number.isInteger(event?.seq) ? event.seq : next;
|
|
@@ -822,15 +965,85 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
822
965
|
const id = event.data?.message?.toolCallId;
|
|
823
966
|
if (id && !seen.has(id)) {
|
|
824
967
|
seen.add(id);
|
|
825
|
-
this.
|
|
968
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
969
|
+
const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
|
|
970
|
+
if (record?.retryable && retry === null) retry = seq;
|
|
826
971
|
}
|
|
827
972
|
}
|
|
828
973
|
next = seq + 1;
|
|
829
974
|
}
|
|
975
|
+
// Scanning later events is useful, but is not proof of a successful backup.
|
|
976
|
+
// Their completed records stay cached while the first busy result is retried.
|
|
977
|
+
next = retry ?? next;
|
|
830
978
|
this.captureCursor.set(key, next);
|
|
979
|
+
this.writeCaptureCursor(key, next);
|
|
980
|
+
this.recoverVisibleRaw(session);
|
|
831
981
|
return seen;
|
|
832
982
|
}
|
|
833
983
|
|
|
984
|
+
captureCursorFile(key) {
|
|
985
|
+
// Include the full key hash: sanitized directory names can alias each other.
|
|
986
|
+
return join(sessionDir(shadowDirectory(), key), `spine-capture-${contentHash(key)}.json`);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
readCaptureCursor(key, access = {}) {
|
|
990
|
+
if (this.persistCapture !== true) return null;
|
|
991
|
+
try {
|
|
992
|
+
const current = this.captureCursorFile(key);
|
|
993
|
+
const legacy = !existsSync(current);
|
|
994
|
+
const file = legacy ? join(shadowDirectory(), "spine-capture.json") : current;
|
|
995
|
+
if (!existsSync(file)) return null;
|
|
996
|
+
tightenShadow(shadowDirectory(), "dir", access);
|
|
997
|
+
if (!legacy) tightenShadow(sessionDir(shadowDirectory(), key), "dir", access);
|
|
998
|
+
tightenShadow(file, "file", access);
|
|
999
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1000
|
+
const cursor = legacy ? parsed?.sessions?.[key] : parsed?.key === key && parsed?.v === 1 ? parsed.cursor : null;
|
|
1001
|
+
return Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
|
|
1002
|
+
} catch {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
writeCaptureCursor(key, cursor, access = {}) {
|
|
1008
|
+
if (this.persistCapture !== true || !Number.isSafeInteger(cursor) || cursor < 0) return;
|
|
1009
|
+
let tmp;
|
|
1010
|
+
let fd;
|
|
1011
|
+
try {
|
|
1012
|
+
ensureShadowDir(shadowDirectory(), access);
|
|
1013
|
+
const file = this.captureCursorFile(key);
|
|
1014
|
+
// Cursor metadata is outside the payload cap and needs no rotation lock.
|
|
1015
|
+
// Secure the parent first so Windows temps inherit its private ACL.
|
|
1016
|
+
ensureShadowDir(dirname(file), access);
|
|
1017
|
+
const candidate = `${file}.${process.pid}-${randomBytes(8).toString("hex")}.tmp`;
|
|
1018
|
+
fd = openSync(candidate, "wx", 0o600);
|
|
1019
|
+
tmp = candidate;
|
|
1020
|
+
tightenCreatedShadow(tmp, fd, access, { remember: false });
|
|
1021
|
+
writeFileSync(fd, JSON.stringify({ v: 1, key, cursor }));
|
|
1022
|
+
closeSync(fd);
|
|
1023
|
+
fd = undefined;
|
|
1024
|
+
renameSync(tmp, file);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1027
|
+
} finally {
|
|
1028
|
+
if (fd !== undefined) { try { closeSync(fd); } catch {} }
|
|
1029
|
+
if (tmp) { try { unlinkSync(tmp); } catch {} }
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
recoverVisibleRaw(session, access = {}) {
|
|
1034
|
+
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1035
|
+
for (const id of new Set(this.surfaceToolIds(session))) {
|
|
1036
|
+
const mapKey = this.rawKey(session, id);
|
|
1037
|
+
if (this.rawSeen.has(mapKey)) continue;
|
|
1038
|
+
try {
|
|
1039
|
+
this.rawSeen.set(mapKey, readRawToolResult({ dir, toolCallId: id, access }));
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1042
|
+
this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
834
1047
|
captureSession(session) {
|
|
835
1048
|
return this.captureNew(session, session?.snapshotEvents?.() || []);
|
|
836
1049
|
}
|
|
@@ -847,11 +1060,19 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
847
1060
|
}
|
|
848
1061
|
|
|
849
1062
|
rawVerifiedSet(session) {
|
|
850
|
-
const prefix = `${session?.id || DEFAULT_KEY}\0`;
|
|
851
1063
|
const ids = new Set();
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1064
|
+
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1065
|
+
for (const id of this.surfaceToolIds(session)) {
|
|
1066
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1067
|
+
if (!cached?.ok) continue;
|
|
1068
|
+
try {
|
|
1069
|
+
// Recheck both files immediately before prune, retaining the first
|
|
1070
|
+
// trusted hash rather than blessing a replacement raw/meta pair.
|
|
1071
|
+
const stored = readRawToolResult({ dir, toolCallId: id });
|
|
1072
|
+
if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) ids.add(id);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1075
|
+
}
|
|
855
1076
|
}
|
|
856
1077
|
return ids;
|
|
857
1078
|
}
|
|
@@ -873,8 +1094,12 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
873
1094
|
const original = pruner.pruneSession.bind(pruner);
|
|
874
1095
|
function guarded(session) {
|
|
875
1096
|
const engine = pruner.__spineEngine;
|
|
876
|
-
|
|
877
|
-
|
|
1097
|
+
try {
|
|
1098
|
+
engine.captureSession(session);
|
|
1099
|
+
return runPruneAfterCapture(engine.surfaceToolIds(session), engine.rawVerifiedSet(session), () => original(session));
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
engine.ctx.logger?.warn?.(`spine: prune skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
1102
|
+
}
|
|
878
1103
|
}
|
|
879
1104
|
guarded.__spineGuarded = true;
|
|
880
1105
|
pruner.pruneSession = guarded;
|
|
@@ -949,7 +1174,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
949
1174
|
error?.code === "ESHADOWACCESS" ||
|
|
950
1175
|
error?.code === "ESHADOWCLEANUP"
|
|
951
1176
|
) {
|
|
952
|
-
|
|
1177
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
953
1178
|
}
|
|
954
1179
|
}
|
|
955
1180
|
const joined = input.messages.map(messageText).join("\n\n");
|