dsh-live-compact 1.1.3 → 1.1.4

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.
Files changed (3) hide show
  1. package/README.md +13 -7
  2. package/index.js +334 -21
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -14,13 +14,13 @@
14
14
  headless:
15
15
 
16
16
  ```sh
17
- dsh plugin --profile headless add dsh-live-compact@1.1.3
17
+ dsh plugin --profile headless add dsh-live-compact@1.1.4
18
18
  ```
19
19
 
20
20
  网页端:
21
21
 
22
22
  ```sh
23
- dsh plugin --profile web add dsh-live-compact@1.1.3
23
+ dsh plugin --profile web add dsh-live-compact@1.1.4
24
24
  ```
25
25
 
26
26
  想先装到本机看版本:
@@ -30,6 +30,12 @@ npm install -g dsh-live-compact
30
30
  dsh-live-compact --version
31
31
  ```
32
32
 
33
+ ## 1.1.4
34
+
35
+ - 修复 #34:空闲维护的大段 catch-up 改为分批让出事件循环;Windows 阴影文件短暂忙时按 8 次上限和退避自动重试,永久失败保持 fail-closed,不阻塞轮次结束返回。
36
+ - 游标和 retryable 结果按会话缓存,避免长历史重复扫描;工具结果、raw/meta 字节与 SHA256 在重启恢复后继续校验。
37
+ - 本地门禁 `npm run test:gate` 通过 508/508;Windows 2022 x64 与 Windows 11 ARM 的标准用户长会话验收均通过,含 29 轮、进程重启边界和 15,001 事件/356 个工具结果的双阶段捕获恢复。
38
+
33
39
  ## 1.1.3
34
40
 
35
41
  - 修复 #34:工具结果事件不再同步执行 shadow/ACL 文件操作,改为事件循环后排队并按 toolCallId 去重;维护开始前会等待已排队捕获,`captureNew` 仍负责补抓和重试。
@@ -307,8 +313,8 @@ GLM 5.3 Flash 用了同一个插件、同一套题。完整跑完的 7 组为 23
307
313
  从 npm 装。headless 和网页是两个 profile,要各装一次。桌面端是另一个 profile,普通 `dsh plugin --profile desktop` 会被拒绝。
308
314
 
309
315
  ```sh
310
- dsh plugin --profile headless add dsh-live-compact@1.1.3
311
- dsh plugin --profile web add dsh-live-compact@1.1.3
316
+ dsh plugin --profile headless add dsh-live-compact@1.1.4
317
+ dsh plugin --profile web add dsh-live-compact@1.1.4
312
318
  ```
313
319
 
314
320
  想先装到本机看版本:
@@ -321,9 +327,9 @@ dsh-live-compact --version
321
327
  上不了 npm 时的镜像安装命令:
322
328
 
323
329
  ```sh
324
- dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.3.tgz
325
- dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.3.tgz
326
- npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.3.tgz"
330
+ dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.4.tgz
331
+ dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.4.tgz
332
+ npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.4.tgz"
327
333
  dsh-live-compact --version
328
334
  ```
329
335
 
package/index.js CHANGED
@@ -53,6 +53,10 @@ const URGENT_TOKENS = 48000;
53
53
  const MAX_TAIL = 2200;
54
54
  const MAX_TRACKED_SESSIONS = 64;
55
55
  const DEFAULT_KEY = "__default__";
56
+ const CAPTURE_EVENT_BATCH = 128;
57
+ const CAPTURE_RESULT_BATCH = 1;
58
+ const RECOVER_RAW_BATCH = 1;
59
+ const CAPTURE_RETRY_LIMIT = 8;
56
60
 
57
61
  function eventContent(session, seq) {
58
62
  const event = session.eventAt(seq);
@@ -461,7 +465,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
461
465
  callIndex = new Map();
462
466
  captureCursor = new Map();
463
467
  pendingCaptures = new Set();
468
+ pendingCaptureJobs = new Map();
464
469
  pendingCaptureKeys = new Set();
470
+ pendingCatchups = new Map();
471
+ pendingCatchupJobs = new Set();
472
+ pendingCuts = new Set();
473
+ captureRetryStats = new Map();
474
+ retryableCaptureSeqs = new Map();
475
+ retryableCaptureSeeded = new Set();
465
476
  pruneGuarded = false;
466
477
 
467
478
  constructor(ctx, config, resolvedConfig) {
@@ -471,9 +482,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
471
482
  if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
472
483
  this.liveConfig = config;
473
484
  try {
474
- const pendingCuts = new Set();
475
485
  ctx.effect(() => async () => {
476
- await Promise.allSettled([...pendingCuts, ...this.pendingCaptures]);
486
+ await Promise.allSettled([...this.pendingCuts, ...this.pendingCaptures, ...this.pendingCatchupJobs]);
477
487
  });
478
488
  ctx.on("session/event", (session, event) => {
479
489
  try {
@@ -491,17 +501,22 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
491
501
  scheduleImmediate(() => {
492
502
  try {
493
503
  const current = this.rawSeen.get(key);
494
- if (!current || current.retryable) this.captureOne(session, event);
504
+ if (!current || current.retryable) {
505
+ const record = this.captureOne(session, event);
506
+ this.noteCaptureResult(session, event, record);
507
+ }
495
508
  } catch (error) {
496
509
  noteShadowWarning(ctx.logger, error);
497
510
  } finally {
498
511
  this.pendingCaptureKeys.delete(key);
499
512
  this.pendingCaptures.delete(job);
513
+ if (this.pendingCaptureJobs.get(key) === job) this.pendingCaptureJobs.delete(key);
500
514
  resolve();
501
515
  }
502
516
  });
503
517
  });
504
518
  this.pendingCaptures.add(job);
519
+ this.pendingCaptureJobs.set(key, job);
505
520
  } catch (error) {
506
521
  noteShadowWarning(ctx.logger, error);
507
522
  }
@@ -514,13 +529,13 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
514
529
  const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
515
530
  if (!done) return;
516
531
  state.warming = true;
517
- const job = this.finishTurn(agent, events)
532
+ const job = this.finishTurn(agent, events, { deferCapture: true })
518
533
  .catch((error) => noteShadowWarning(this.ctx?.logger, error))
519
534
  .finally(() => {
520
535
  state.warming = false;
521
- pendingCuts.delete(job);
536
+ this.pendingCuts.delete(job);
522
537
  });
523
- pendingCuts.add(job);
538
+ this.pendingCuts.add(job);
524
539
  });
525
540
  } catch (error) {
526
541
  const message = error instanceof Error ? error.message : String(error);
@@ -570,13 +585,167 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
570
585
  return planMessages(messages);
571
586
  }
572
587
 
573
- async finishTurn(agent, events) {
588
+ scheduleCaptureCatchup(agent, events, settings) {
589
+ this.pendingCatchups ||= new Map();
590
+ this.pendingCatchupJobs ||= new Set();
591
+ this.pendingCuts ||= new Set();
592
+ const key = sessionKey(agent);
593
+ let task = this.pendingCatchups.get(key);
594
+ if (task) {
595
+ task.agent = agent;
596
+ task.session = agent.session;
597
+ task.events = events;
598
+ task.settings = settings;
599
+ return task;
600
+ }
601
+ let resolve;
602
+ const promise = new Promise((done) => { resolve = done; });
603
+ task = {
604
+ key,
605
+ agent,
606
+ session: agent.session,
607
+ events,
608
+ settings,
609
+ phase: "events",
610
+ eventIndex: 0,
611
+ recoverIndex: 0,
612
+ visibleIds: null,
613
+ scheduled: false,
614
+ retryRequested: false,
615
+ captureRetryAttempts: 0,
616
+ progressed: false,
617
+ eventTail: Number.isInteger(events?.at?.(-1)?.seq) ? events.at(-1).seq : -1,
618
+ surfaceTail: agent.session.surface?.nodes?.at?.(-1) ?? -1,
619
+ resolve,
620
+ promise,
621
+ };
622
+ this.pendingCatchups.set(key, task);
623
+ this.pendingCatchupJobs.add(promise);
624
+ this.queueCaptureCatchup(task);
625
+ return task;
626
+ }
627
+
628
+ queueCaptureRetry(task) {
629
+ if (!task || task.finished || task.scheduled) return;
630
+ task.scheduled = true;
631
+ const delay = Math.min(250, 10 * (2 ** Math.max(0, task.captureRetryAttempts - 1)));
632
+ setTimeout(() => {
633
+ task.scheduled = false;
634
+ if (!task.finished) this.queueCaptureCatchup(task);
635
+ }, delay);
636
+ }
637
+
638
+ queueCaptureCatchup(task) {
639
+ if (!task || task.scheduled) return;
640
+ task.scheduled = true;
641
+ scheduleImmediate(() => {
642
+ task.scheduled = false;
643
+ try {
644
+ const latest = task.session.snapshotEvents?.();
645
+ const latestTail = Number.isInteger(latest?.at?.(-1)?.seq) ? latest.at(-1).seq : -1;
646
+ const latestSurfaceTail = task.session.surface?.nodes?.at?.(-1) ?? -1;
647
+ if (Array.isArray(latest) && (latestTail !== task.eventTail || latest.length !== task.events.length)) {
648
+ task.events = latest;
649
+ task.eventTail = latestTail;
650
+ if (task.phase === "visible") {
651
+ task.phase = "events";
652
+ task.visibleIds = null;
653
+ task.recoverIndex = 0;
654
+ }
655
+ }
656
+ if (latestSurfaceTail !== task.surfaceTail) {
657
+ task.surfaceTail = latestSurfaceTail;
658
+ if (task.phase === "visible") {
659
+ task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
660
+ task.recoverIndex = 0;
661
+ }
662
+ }
663
+ if (task.phase === "events") {
664
+ const result = this.captureNewBatch(task.session, task.events, task.settings, {
665
+ eventLimit: CAPTURE_EVENT_BATCH,
666
+ resultLimit: CAPTURE_RESULT_BATCH,
667
+ startIndex: task.eventIndex,
668
+ });
669
+ task.eventIndex = result.nextIndex;
670
+ task.progressed ||= result.progressed;
671
+ if (result.retryable) {
672
+ task.captureRetryAttempts += 1;
673
+ const previousMax = this.captureRetryStats?.get(task.key) || 0;
674
+ if (task.captureRetryAttempts > previousMax) this.captureRetryStats.set(task.key, task.captureRetryAttempts);
675
+ if (task.captureRetryAttempts >= CAPTURE_RETRY_LIMIT) {
676
+ this.finishCaptureCatchup(task);
677
+ return;
678
+ }
679
+ this.queueCaptureRetry(task);
680
+ return;
681
+ }
682
+ if (result.progressed) task.captureRetryAttempts = 0;
683
+ if (!result.done) {
684
+ this.queueCaptureCatchup(task);
685
+ return;
686
+ }
687
+ task.phase = "visible";
688
+ task.recoverIndex = 0;
689
+ task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
690
+ }
691
+ const recovered = this.recoverVisibleRawBatch(task.session, {}, task.recoverIndex, RECOVER_RAW_BATCH, task.visibleIds);
692
+ task.progressed ||= recovered.progressed;
693
+ task.recoverIndex = recovered.nextIndex;
694
+ if (!recovered.done) {
695
+ this.queueCaptureCatchup(task);
696
+ return;
697
+ }
698
+ this.finishCaptureCatchup(task);
699
+ } catch (error) {
700
+ noteShadowWarning(this.ctx?.logger, error);
701
+ this.finishCaptureCatchup(task);
702
+ }
703
+ });
704
+ }
705
+
706
+ finishCaptureCatchup(task) {
707
+ if (!task || task.finished) return;
708
+ task.finished = true;
709
+ this.pendingCatchups.delete(task.key);
710
+ this.pendingCatchupJobs.delete(task.promise);
711
+ task.resolve();
712
+ if (task.retryRequested && task.progressed) this.scheduleMaintenanceRetry(task.agent);
713
+ }
714
+
715
+ scheduleMaintenanceRetry(agent) {
716
+ const state = this.stateFor(agent);
717
+ if (state.retryScheduled) return;
718
+ state.retryScheduled = true;
719
+ scheduleImmediate(() => {
720
+ state.retryScheduled = false;
721
+ if (state.warming) return;
722
+ const events = agent.session.snapshotEvents();
723
+ const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
724
+ if (!done) return;
725
+ state.warming = true;
726
+ const job = this.finishTurn(agent, events, { deferCapture: true })
727
+ .catch((error) => noteShadowWarning(this.ctx?.logger, error))
728
+ .finally(() => {
729
+ state.warming = false;
730
+ this.pendingCuts.delete(job);
731
+ });
732
+ this.pendingCuts.add(job);
733
+ });
734
+ }
735
+
736
+ async finishTurn(agent, events, options = {}) {
574
737
  const settings = readSettings(this.liveConfig);
575
738
  const state = this.stateFor(agent);
576
739
  const signal = AbortSignal.timeout(30000);
577
740
  const ev = events || agent.session.snapshotEvents();
578
- await this.flushPendingCaptures();
579
- this.captureNew(agent.session, ev, settings);
741
+ 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);
748
+ }
580
749
  const jobs = collectJobs(ev);
581
750
  const calls = indexToolCalls(ev);
582
751
  try {
@@ -636,6 +805,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
636
805
  try {
637
806
  state.localOnly = true;
638
807
  state.approvedCard = chunk.card;
808
+ if (deferCapture) await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
639
809
  await this.commitSelectedSpan(agent, chunk, op);
640
810
  measurement = this.ctx.tokenMeter.measure(agent.session);
641
811
  const after = measurement.totalTokens;
@@ -656,6 +826,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
656
826
  this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved}`);
657
827
  } catch (error) {
658
828
  const message = error instanceof Error ? error.message : String(error);
829
+ if (message.includes("raw backup missing before cut")) {
830
+ catchup.retryRequested = true;
831
+ if (catchup.finished && catchup.progressed) this.scheduleMaintenanceRetry(agent);
832
+ }
659
833
  this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
660
834
  break;
661
835
  }
@@ -678,9 +852,33 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
678
852
  }
679
853
  }
680
854
 
681
- async flushPendingCaptures() {
682
- if (!this.pendingCaptures?.size) return;
683
- await Promise.allSettled([...this.pendingCaptures]);
855
+ async flushPendingCaptures(session = null, ids = null) {
856
+ if (!session) {
857
+ if (!this.pendingCaptures?.size) return;
858
+ await Promise.allSettled([...this.pendingCaptures]);
859
+ return;
860
+ }
861
+ const key = session.id || DEFAULT_KEY;
862
+ const pendingIds = ids || this.surfaceToolIds(session);
863
+ const jobs = [...new Set(pendingIds)]
864
+ .map((id) => this.pendingCaptureJobs?.get?.(`${key}\0${id}`))
865
+ .filter(Boolean);
866
+ if (jobs.length) await Promise.allSettled(jobs);
867
+ }
868
+
869
+ chunkToolCallIds(session, chunk) {
870
+ const nodes = session?.surface?.nodes || [];
871
+ const start = nodes.indexOf(chunk?.start);
872
+ const end = nodes.indexOf(chunk?.end);
873
+ if (start < 0 || end < start) return [];
874
+ const ids = [];
875
+ for (const seq of nodes.slice(start, end + 1)) {
876
+ const event = session.eventAt?.(seq);
877
+ if (event?.type !== "tool/result") continue;
878
+ const id = event.data?.message?.toolCallId;
879
+ if (id) ids.push(id);
880
+ }
881
+ return ids;
684
882
  }
685
883
 
686
884
  async commitSelectedSpan(agent, chunk, signal) {
@@ -716,7 +914,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
716
914
  const event = session.eventAt(seq);
717
915
  if (event?.type === "tool/result") {
718
916
  const id = event.data?.message?.toolCallId;
719
- if (!id || !verifiedRaw(this.rawSeen.get(this.rawKey(session, id))).ok) {
917
+ const raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
918
+ if (!id || !verifiedRaw(raw).ok) {
720
919
  throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
721
920
  }
722
921
  }
@@ -972,19 +1171,109 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
972
1171
  }
973
1172
  }
974
1173
 
975
- captureNew(session, events, settings = readSettings(this.liveConfig)) {
976
- if (!Array.isArray(events)) events = [];
1174
+ noteCaptureResult(session, event, record) {
1175
+ const key = session?.id || DEFAULT_KEY;
1176
+ this.retryableCaptureSeqs ||= new Map();
1177
+ this.retryableCaptureSeeded ||= new Set();
1178
+ let seqs = this.retryableCaptureSeqs.get(key);
1179
+ if (!seqs) {
1180
+ seqs = new Set();
1181
+ this.retryableCaptureSeqs.set(key, seqs);
1182
+ }
1183
+ const seq = Number.isInteger(event?.seq) ? event.seq : null;
1184
+ if (seq === null) return;
1185
+ if (record?.retryable) seqs.add(seq);
1186
+ else seqs.delete(seq);
1187
+ }
1188
+
1189
+ captureCursorStart(session, events, settings) {
977
1190
  const key = session?.id || DEFAULT_KEY;
978
1191
  let cursor = this.captureCursor.has(key) ? this.captureCursor.get(key) : this.readCaptureCursor(key);
979
1192
  if (cursor == null) cursor = restartCaptureCursor(events, settings.keepRounds);
980
1193
  if (!Number.isSafeInteger(cursor) || cursor < 0 || events.length < cursor) cursor = restartCaptureCursor(events, settings.keepRounds);
981
- // A live event callback may have failed before the recent-three restart
982
- // boundary. Keep those visible originals eligible without replaying history.
983
- for (const seq of session?.surface?.nodes || []) {
984
- const event = session.eventAt?.(seq);
985
- const id = event?.type === "tool/result" && event.data?.message?.toolCallId;
986
- if (id && this.rawSeen.get(this.rawKey(session, id))?.retryable) cursor = Math.min(cursor, seq);
1194
+ this.retryableCaptureSeqs ||= new Map();
1195
+ this.retryableCaptureSeeded ||= new Set();
1196
+ let retryable = this.retryableCaptureSeqs.get(key);
1197
+ if (!retryable) {
1198
+ retryable = new Set();
1199
+ this.retryableCaptureSeqs.set(key, retryable);
1200
+ }
1201
+ if (!this.retryableCaptureSeeded.has(key)) {
1202
+ for (const seq of session?.surface?.nodes || []) {
1203
+ const event = session.eventAt?.(seq);
1204
+ const id = event?.type === "tool/result" && event.data?.message?.toolCallId;
1205
+ if (id && this.rawSeen.get(this.rawKey(session, id))?.retryable) retryable.add(seq);
1206
+ }
1207
+ this.retryableCaptureSeeded.add(key);
1208
+ }
1209
+ if (retryable.size) cursor = Math.min(cursor, Math.min(...retryable));
1210
+ return cursor;
1211
+ }
1212
+
1213
+ captureNewBatch(session, events, settings = readSettings(this.liveConfig), limits = {}) {
1214
+ if (!Array.isArray(events)) events = [];
1215
+ const key = session?.id || DEFAULT_KEY;
1216
+ const cursor = this.captureCursorStart(session, events, settings);
1217
+ let next = cursor;
1218
+ let retry = null;
1219
+ let processed = 0;
1220
+ let captures = 0;
1221
+ let progressed = false;
1222
+ let exhausted = true;
1223
+ const seen = new Set();
1224
+ const eventLimit = Math.max(1, limits.eventLimit || CAPTURE_EVENT_BATCH);
1225
+ const resultLimit = Math.max(1, limits.resultLimit || CAPTURE_RESULT_BATCH);
1226
+ let index = Math.max(0, Number.isInteger(limits.startIndex) ? limits.startIndex : 0);
1227
+ const indexedSeq = Number.isInteger(events[index]?.seq) ? events[index].seq : null;
1228
+ if (index > 0 && indexedSeq !== null && indexedSeq > cursor) {
1229
+ const rewind = events.findIndex((event) => Number.isInteger(event?.seq) && event.seq >= cursor);
1230
+ index = rewind >= 0 ? rewind : 0;
1231
+ }
1232
+ for (; index < events.length; index += 1) {
1233
+ const event = events[index];
1234
+ const seq = Number.isInteger(event?.seq) ? event.seq : next;
1235
+ if (seq < cursor) continue;
1236
+ this.noteEvent(session, event);
1237
+ if (event?.type === "tool/result") {
1238
+ const id = event.data?.message?.toolCallId;
1239
+ if (id && !seen.has(id)) {
1240
+ seen.add(id);
1241
+ const cached = this.rawSeen.get(this.rawKey(session, id));
1242
+ const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
1243
+ if (!cached || cached.retryable) {
1244
+ if (record?.ok) progressed = true;
1245
+ captures += 1;
1246
+ }
1247
+ this.noteCaptureResult(session, event, record);
1248
+ if (record?.retryable && retry === null) retry = seq;
1249
+ }
1250
+ }
1251
+ next = seq + 1;
1252
+ processed += 1;
1253
+ if (retry !== null || processed >= eventLimit || captures >= resultLimit) {
1254
+ exhausted = false;
1255
+ index += 1;
1256
+ break;
1257
+ }
987
1258
  }
1259
+ next = retry ?? next;
1260
+ this.captureCursor.set(key, next);
1261
+ this.writeCaptureCursor(key, next);
1262
+ const lastEvent = events.at(-1);
1263
+ const lastSeq = Number.isInteger(lastEvent?.seq) ? lastEvent.seq : events.length - 1;
1264
+ return {
1265
+ seen,
1266
+ progressed,
1267
+ retryable: retry !== null,
1268
+ done: exhausted && retry === null && next > lastSeq,
1269
+ nextIndex: index,
1270
+ };
1271
+ }
1272
+
1273
+ captureNew(session, events, settings = readSettings(this.liveConfig)) {
1274
+ if (!Array.isArray(events)) events = [];
1275
+ const key = session?.id || DEFAULT_KEY;
1276
+ const cursor = this.captureCursorStart(session, events, settings);
988
1277
  let next = cursor;
989
1278
  let retry = null;
990
1279
  const seen = new Set();
@@ -998,6 +1287,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
998
1287
  seen.add(id);
999
1288
  const cached = this.rawSeen.get(this.rawKey(session, id));
1000
1289
  const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
1290
+ this.noteCaptureResult(session, event, record);
1001
1291
  if (record?.retryable && retry === null) retry = seq;
1002
1292
  }
1003
1293
  }
@@ -1075,6 +1365,29 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1075
1365
  }
1076
1366
  }
1077
1367
 
1368
+ recoverVisibleRawBatch(session, access = {}, startIndex = 0, limit = RECOVER_RAW_BATCH, visibleIds = null) {
1369
+ const ids = visibleIds || [...new Set(this.surfaceToolIds(session))];
1370
+ const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
1371
+ let index = Math.max(0, startIndex);
1372
+ let processed = 0;
1373
+ let progressed = false;
1374
+ while (index < ids.length && processed < Math.max(1, limit)) {
1375
+ const id = ids[index++];
1376
+ const mapKey = this.rawKey(session, id);
1377
+ if (this.rawSeen.has(mapKey)) continue;
1378
+ try {
1379
+ const record = readRawToolResult({ dir, toolCallId: id, access });
1380
+ this.rawSeen.set(mapKey, record);
1381
+ progressed ||= Boolean(record?.ok);
1382
+ } catch (error) {
1383
+ noteShadowWarning(this.ctx?.logger, error);
1384
+ this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
1385
+ }
1386
+ processed += 1;
1387
+ }
1388
+ return { nextIndex: index, done: index >= ids.length, progressed };
1389
+ }
1390
+
1078
1391
  captureSession(session) {
1079
1392
  return this.captureNew(session, session?.snapshotEvents?.() || []);
1080
1393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live-compact",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "In-place newest-turn compaction for DeepSeek Harness. Compress while the task runs and keep the prefix.",
5
5
  "license": "MIT",
6
6
  "author": "MerkyorLynn <268568828+MerkyorLynn@users.noreply.github.com>",
@@ -41,7 +41,7 @@
41
41
  "compaction"
42
42
  ],
43
43
  "scripts": {
44
- "test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node test/excerpt.mjs && node --test test/public-exports.test.mjs test/cooldown.test.mjs test/gate-config.test.mjs test/drain-stopline.mjs test/scan-once.mjs test/issue-19.mjs test/issue-20.mjs test/windows-acl.test.mjs test/issue-27.mjs test/issue-29.mjs test/issue-29-notice.mjs test/client-settings.test.mjs test/settings-integration.test.mjs test/runtime-lifecycle.mjs test/shadow-lifecycle.mjs test/finalize-over-32000.mjs",
44
+ "test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node test/excerpt.mjs && node --test test/public-exports.test.mjs test/cooldown.test.mjs test/gate-config.test.mjs test/drain-stopline.mjs test/scan-once.mjs test/issue-19.mjs test/issue-20.mjs test/issue-34.mjs test/windows-acl.test.mjs test/issue-27.mjs test/issue-29.mjs test/issue-29-notice.mjs test/client-settings.test.mjs test/settings-integration.test.mjs test/runtime-lifecycle.mjs test/shadow-lifecycle.mjs test/finalize-over-32000.mjs",
45
45
  "test:live-gate": "python3 -B -m unittest discover -s test/live-fill -p test_*.py"
46
46
  },
47
47
  "engines": {