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.
- package/README.md +30 -14
- package/index.js +361 -17
- 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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-

|
|
85
101
|
|
|
86
|
-

|
|
87
103
|
|
|
88
|
-

|
|
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.
|
|
301
|
-
dsh plugin --profile web add dsh-live-compact@1.1.
|
|
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
|
-
|
|
327
|
+
上不了 npm 时的镜像安装命令:
|
|
312
328
|
|
|
313
329
|
```sh
|
|
314
|
-
dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.
|
|
315
|
-
dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.
|
|
316
|
-
npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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.
|
|
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": {
|