dsh-live-compact 1.1.2 → 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 +30 -14
  2. package/index.js +361 -17
  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.2
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.2
23
+ dsh plugin --profile web add dsh-live-compact@1.1.4
24
24
  ```
25
25
 
26
26
  想先装到本机看版本:
@@ -30,6 +30,20 @@ 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
+
39
+ ## 1.1.3
40
+
41
+ - 修复 #34:工具结果事件不再同步执行 shadow/ACL 文件操作,改为事件循环后排队并按 toolCallId 去重;维护开始前会等待已排队捕获,`captureNew` 仍负责补抓和重试。
42
+ - 这条路径避免在模型收到工具结果前被同步磁盘操作卡住;权限失败仍然只拒绝备份,不让宿主 fatal 退出。
43
+ - Windows hosted 验收:Windows 11 ARM、Windows 2022 x64 的 Node 22/24 core,以及三套 DSH 0.2.0/0.2.1 普通用户 runtime 均通过;官方 Desktop 普通用户安装、启用、设置保存和重启读取在 Windows 11 ARM 与 Windows 2022 均通过。
44
+ - 同一候选又做了 29 轮持久会话回放:Windows 2022 用时 14 分 05 秒,Windows 11 ARM 用时 17 分 08 秒,含第 21 轮进程重启边界和 58 次工具结果捕获。原报告的 93M/第 29 轮冻结没有在这组受控回放中重现;Node 24 首次双进程 cursor 测试的 299/300 竞态重跑通过,作为后续观察项保留。
45
+ - Config、验证/头脑风暴模式和自动主题分类继续后置,不改变 1.1.2 的小切默认参数。
46
+
33
47
  ## 1.1.2
34
48
 
35
49
  - 常用压缩参数可以在 DSH 插件设置里改,保留原有环境变量用法。
@@ -43,7 +57,7 @@ dsh-live-compact --version
43
57
 
44
58
  ### Windows 备份权限
45
59
 
46
- Windows 双用户实测(Actions run `42367713007`,Windows 11 Enterprise ARM / Server 2022 x64)确认:1.1.1 的 `/inheritance:r /grant:r` 会留下其他主体的显式 ACE,第二个普通用户仍能读取备份。1.1.2 改为完整替换 DACL。本次运行时代码修改后,最终候选仍须重新完成原生 Windows 与 Desktop 验收,结果随最终候选记录。
60
+ Windows 双用户实测(Actions run `42367713007`,Windows 11 Enterprise ARM / Server 2022 x64)确认:1.1.1 的 `/inheritance:r /grant:r` 会留下其他主体的显式 ACE,第二个普通用户仍能读取备份。1.1.2 改为完整替换 DACL。1.1.3 候选又通过了 hosted Windows core/runtime、官方 Desktop 普通用户安装与设置保存/重启,以及 29 轮持久会话回放;原报告的 93M/第 29 轮冻结未在受控回放中重现,慢存储、超大历史和共享目录压力仍属于后续观察范围。
47
61
 
48
62
  权限写入改为固定的 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`(Windows PowerShell 5.1 / .NET Framework),使用 `-NoProfile -NonInteractive -EncodedCommand`。脚本固定内嵌在已打包的 `shadow.js`,路径、SID、文件类型通过子进程环境传递,不拼进脚本。当前用户 SID 仍由固定 System32 `whoami.exe` 每进程获取一次,脚本再对照自身 Windows 身份。新建受保护的 DACL,只允许当前用户、SYSTEM、Administrators 完全控制;目录 ACE 带 OI/CI,直接修正的文件 ACE 不带继承标志。只修改 DACL,不提权,不改 owner、group 或 SACL,不使用临时宽松 ACL。写后读取实际 DACL,核验保护位、完整 ACE 数量、SID、Allow、权限及继承标志,收到确认才缓存。工具失败、超时、SID 非法或读回不符都会熔断本进程后续备份,不回退到 chmod。
49
63
 
@@ -75,17 +89,19 @@ Windows 双用户实测(Actions run `42367713007`,Windows 11 Enterprise ARM
75
89
 
76
90
  这轮连的是插件版。10 组同一套 31 轮填日志,组内串行,两个模型同时开。原版 DSH 压缩对比留到 1.1.2。Coder 390 27B 这轮没跑,后补。测评图在下面,也在 [v1.1.1](https://github.com/Tranquillitatis-Lab/dsh-live-compact/releases/tag/v1.1.1)。
77
91
 
92
+ 下面三张图沿用已完成的 1.1.1 实测数据;1.1.2 只更新运行时修复和门禁,图标题按当前发布版本标注。
93
+
78
94
  看图时,先看日志题答对多少,再看首步缓存和压缩节省。首步缓存取每轮第一个 `step_end`,把命中 token 加起来,再除以这些请求的总输入 token。压缩节省是 `spine-cuts.jsonl` 里 `saved` 的合计。压缩轮次耗时是发生压缩的各轮总耗时之和,压缩操作本身为毫秒级。任务墙钟是从第 1 轮开始到第 31 轮结束的实际用时。结束上下文取最后一轮的 `totalTokens`,包含此前保留下来的内容。
79
95
 
80
- DS V4.1 Flash 跑了 10 组,填日志 230/230,跳过 0。首步缓存 98.64%,压缩节省 65 180 token,总用时 36 分 18 秒。多数组结束在 3.5 万到 4.6 万 token。第 1 组读了 25 次全文,4 次压缩共节省 21 493 token,结束时仍有 282 871。第 2 组几乎只用 bash,只读了一次全文,未触发压缩,结束时是 37 781。
96
+ DS V4.1 Flash 跑了 10 组,填日志 230/230,跳过 0。首步缓存 98.64%,压缩节省 65 180 token,总墙钟 36 分 18 秒。多数组结束在 3.5 万到 4.6 万 token。第 1 组读了 25 次全文,4 次压缩共节省 21 493 token,结束时仍有 282 871。第 2 组几乎只用 bash,只读了一次全文,未触发压缩,结束时是 37 781。图中的“含切割轮次墙钟”是整轮模型、工具和网络等待,不是插件小切耗时;小切操作本身为毫秒级。
81
97
 
82
- GLM 5.3 Flash 用了同一个插件、同一套题。有 7 组填日志达到 23/23。第 5、6 组全程连接失败。第 7 组前段连接失败,完成 14/23。有用量记录的轮次,首步缓存是 98.97%。第 1、2 组主要用 grep,未触发压缩,结束时分别是 39 511 和 29 987 token。第 4、9 组反复读全文,结束时分别是 285 535 和 281 515,和 DS 第 1 组很像。可比的 7 组里,DS 每组都更快。
98
+ GLM 5.3 Flash 用了同一个插件、同一套题。完整跑完的 7 组为 23/23;第 5、6 组连接失败;第 7 组前段连接失败,完成 14/23。有用量记录的轮次,首步缓存是 98.97%。第 1、2 组主要用 grep,未触发压缩,结束时分别是 39 511 和 29 987 token。第 4、9 组反复读全文,结束时分别是 285 535 和 281 515。GLM 图只展示插件实测;没有对应 stock 配对数据的地方不推断原版差值。
83
99
 
84
- ![插件版对比](docs/slide-111-overview.jpg)
100
+ ![插件版对比](docs/slide-112-overview.jpg)
85
101
 
86
- ![DS V4.1 Flash](docs/slide-111-ds.jpg)
102
+ ![DS V4.1 Flash](docs/slide-112-ds.jpg)
87
103
 
88
- ![GLM 5.3 Flash](docs/slide-111-glm.jpg)
104
+ ![GLM 5.3 Flash](docs/slide-112-glm.jpg)
89
105
 
90
106
  ## 1.0.9
91
107
 
@@ -297,8 +313,8 @@ GLM 5.3 Flash 用了同一个插件、同一套题。有 7 组填日志达到 23
297
313
  从 npm 装。headless 和网页是两个 profile,要各装一次。桌面端是另一个 profile,普通 `dsh plugin --profile desktop` 会被拒绝。
298
314
 
299
315
  ```sh
300
- dsh plugin --profile headless add dsh-live-compact@1.1.2
301
- dsh plugin --profile web add dsh-live-compact@1.1.2
316
+ dsh plugin --profile headless add dsh-live-compact@1.1.4
317
+ dsh plugin --profile web add dsh-live-compact@1.1.4
302
318
  ```
303
319
 
304
320
  想先装到本机看版本:
@@ -308,12 +324,12 @@ npm install -g dsh-live-compact
308
324
  dsh-live-compact --version
309
325
  ```
310
326
 
311
- 旧版 1.1.1 的国内镜像安装命令:
327
+ 上不了 npm 时的镜像安装命令:
312
328
 
313
329
  ```sh
314
- dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz
315
- dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz
316
- npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.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"
317
333
  dsh-live-compact --version
318
334
  ```
319
335
 
package/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  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
+ import { setImmediate as scheduleImmediate } from "node:timers";
18
19
  import { dirname, join } from "node:path";
19
20
  import { pathToFileURL } from "node:url";
20
21
  import { loadBase, loadCompaction, loadLlm } from "./adapter.js";
@@ -52,6 +53,10 @@ const URGENT_TOKENS = 48000;
52
53
  const MAX_TAIL = 2200;
53
54
  const MAX_TRACKED_SESSIONS = 64;
54
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;
55
60
 
56
61
  function eventContent(session, seq) {
57
62
  const event = session.eventAt(seq);
@@ -459,6 +464,15 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
459
464
  rawSeen = new Map();
460
465
  callIndex = new Map();
461
466
  captureCursor = new Map();
467
+ pendingCaptures = new Set();
468
+ pendingCaptureJobs = new Map();
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();
462
476
  pruneGuarded = false;
463
477
 
464
478
  constructor(ctx, config, resolvedConfig) {
@@ -468,15 +482,41 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
468
482
  if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
469
483
  this.liveConfig = config;
470
484
  try {
471
- const pendingCuts = new Set();
472
485
  ctx.effect(() => async () => {
473
- await Promise.allSettled(pendingCuts);
486
+ await Promise.allSettled([...this.pendingCuts, ...this.pendingCaptures, ...this.pendingCatchupJobs]);
474
487
  });
475
488
  ctx.on("session/event", (session, event) => {
476
489
  try {
477
490
  this.noteEvent(session, event);
478
491
  if (event?.type !== "tool/result") return;
479
- this.captureOne(session, event);
492
+ const id = event.data?.message?.toolCallId;
493
+ if (!id) return;
494
+ const key = this.rawKey(session, id);
495
+ const cached = this.rawSeen.get(key);
496
+ if (cached && !cached.retryable) return;
497
+ if (this.pendingCaptureKeys.has(key)) return;
498
+ this.pendingCaptureKeys.add(key);
499
+ let job;
500
+ job = new Promise((resolve) => {
501
+ scheduleImmediate(() => {
502
+ try {
503
+ const current = this.rawSeen.get(key);
504
+ if (!current || current.retryable) {
505
+ const record = this.captureOne(session, event);
506
+ this.noteCaptureResult(session, event, record);
507
+ }
508
+ } catch (error) {
509
+ noteShadowWarning(ctx.logger, error);
510
+ } finally {
511
+ this.pendingCaptureKeys.delete(key);
512
+ this.pendingCaptures.delete(job);
513
+ if (this.pendingCaptureJobs.get(key) === job) this.pendingCaptureJobs.delete(key);
514
+ resolve();
515
+ }
516
+ });
517
+ });
518
+ this.pendingCaptures.add(job);
519
+ this.pendingCaptureJobs.set(key, job);
480
520
  } catch (error) {
481
521
  noteShadowWarning(ctx.logger, error);
482
522
  }
@@ -489,13 +529,13 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
489
529
  const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
490
530
  if (!done) return;
491
531
  state.warming = true;
492
- const job = this.finishTurn(agent, events)
532
+ const job = this.finishTurn(agent, events, { deferCapture: true })
493
533
  .catch((error) => noteShadowWarning(this.ctx?.logger, error))
494
534
  .finally(() => {
495
535
  state.warming = false;
496
- pendingCuts.delete(job);
536
+ this.pendingCuts.delete(job);
497
537
  });
498
- pendingCuts.add(job);
538
+ this.pendingCuts.add(job);
499
539
  });
500
540
  } catch (error) {
501
541
  const message = error instanceof Error ? error.message : String(error);
@@ -545,12 +585,167 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
545
585
  return planMessages(messages);
546
586
  }
547
587
 
548
- 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 = {}) {
549
737
  const settings = readSettings(this.liveConfig);
550
738
  const state = this.stateFor(agent);
551
739
  const signal = AbortSignal.timeout(30000);
552
740
  const ev = events || agent.session.snapshotEvents();
553
- 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
+ }
554
749
  const jobs = collectJobs(ev);
555
750
  const calls = indexToolCalls(ev);
556
751
  try {
@@ -610,6 +805,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
610
805
  try {
611
806
  state.localOnly = true;
612
807
  state.approvedCard = chunk.card;
808
+ if (deferCapture) await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
613
809
  await this.commitSelectedSpan(agent, chunk, op);
614
810
  measurement = this.ctx.tokenMeter.measure(agent.session);
615
811
  const after = measurement.totalTokens;
@@ -630,6 +826,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
630
826
  this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved}`);
631
827
  } catch (error) {
632
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
+ }
633
833
  this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
634
834
  break;
635
835
  }
@@ -652,6 +852,35 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
652
852
  }
653
853
  }
654
854
 
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;
882
+ }
883
+
655
884
  async commitSelectedSpan(agent, chunk, signal) {
656
885
  signal?.throwIfAborted();
657
886
  const session = agent.session;
@@ -685,7 +914,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
685
914
  const event = session.eventAt(seq);
686
915
  if (event?.type === "tool/result") {
687
916
  const id = event.data?.message?.toolCallId;
688
- 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) {
689
919
  throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
690
920
  }
691
921
  }
@@ -941,19 +1171,109 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
941
1171
  }
942
1172
  }
943
1173
 
944
- captureNew(session, events, settings = readSettings(this.liveConfig)) {
945
- 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) {
946
1190
  const key = session?.id || DEFAULT_KEY;
947
1191
  let cursor = this.captureCursor.has(key) ? this.captureCursor.get(key) : this.readCaptureCursor(key);
948
1192
  if (cursor == null) cursor = restartCaptureCursor(events, settings.keepRounds);
949
1193
  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);
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);
956
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
+ }
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);
957
1277
  let next = cursor;
958
1278
  let retry = null;
959
1279
  const seen = new Set();
@@ -967,6 +1287,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
967
1287
  seen.add(id);
968
1288
  const cached = this.rawSeen.get(this.rawKey(session, id));
969
1289
  const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
1290
+ this.noteCaptureResult(session, event, record);
970
1291
  if (record?.retryable && retry === null) retry = seq;
971
1292
  }
972
1293
  }
@@ -1044,6 +1365,29 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
1044
1365
  }
1045
1366
  }
1046
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
+
1047
1391
  captureSession(session) {
1048
1392
  return this.captureNew(session, session?.snapshotEvents?.() || []);
1049
1393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live-compact",
3
- "version": "1.1.2",
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": {