dsh-log-contract 0.3.3 → 0.3.5
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 +7 -0
- package/bin/dsh-log-contract.mjs +6 -4
- package/lib/checks.js +135 -0
- package/lib/contracts.js +24 -0
- package/lib/index.js +2 -2
- package/lib/repair.js +73 -2
- package/lib/validate.js +11 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,13 @@ pnpm add -D dsh-log-contract # 或 npm install
|
|
|
38
38
|
pnpm dlx dsh-log-contract --help
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
> **你是 dsh-retrace 用户?** 无需单独安装——`dsh-retrace` 已把 `dsh-log-contract`
|
|
42
|
+
> 声明为依赖,装 retrace 时自动带好契约守护(体检/写前校验/修复原语全部随插件生效)。
|
|
43
|
+
> 本包独立发布,供愿意单独使用或二次开发的用户直接引入。
|
|
44
|
+
>
|
|
45
|
+
> **从 GitHub 下载了 ZIP?** 解压后 `cd dsh-log-contract && npm install && npm run build`,
|
|
46
|
+
> 然后 `node bin/dsh-log-contract.mjs check <session-log>` 即可使用(无需全局安装)。
|
|
47
|
+
|
|
41
48
|
依赖:Node ≥ 22(`node:zlib` 内置 zstd)、`@deepseek-ai/dsh-session`(peer,校验/解码复用官方实现,保证与 Harness 读路径同源)。
|
|
42
49
|
|
|
43
50
|
---
|
package/bin/dsh-log-contract.mjs
CHANGED
|
@@ -23,11 +23,12 @@ const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log cont
|
|
|
23
23
|
--json 输出机器可读 JSON 报告
|
|
24
24
|
--max-details N 每条违规最多列 N 个缺失 seq(默认 8,--json 忽略)
|
|
25
25
|
|
|
26
|
-
dsh-log-contract fix <session-log> [--remove-markers] [--neutralize] [--apply] [--backup-dir DIR] [--json]
|
|
26
|
+
dsh-log-contract fix <session-log> [--remove-markers] [--neutralize] [--clip-crossstep] [--apply] [--backup-dir DIR] [--json]
|
|
27
27
|
诊断 + 修复(2026-08 事故固化方案)。先做严格 seq 连续扫描 + 契约体检
|
|
28
28
|
(含 W1/W2 wire 级悬空 tool 检查),再按需修复:
|
|
29
29
|
--remove-markers 移除 retrace/message-editor marker 并全量重编号
|
|
30
30
|
(用于大范围遮蔽历史 / marker 漏盖 tool/result)
|
|
31
|
+
--clip-crossstep 裁剪 assistant/message 的跨 step sourceEventSeqs(token-meter 不再抛 belongs to another step)
|
|
31
32
|
--neutralize 原地中和 turn-null marker(type→retrace/marker +
|
|
32
33
|
ignorable:true,删 surfaceOp/sourceEventSeqs,seq/行数不变)
|
|
33
34
|
—— token-meter 不再刷屏,会话驻留也安全(2026-08-30 事故)
|
|
@@ -172,6 +173,7 @@ function cmdFix(args) {
|
|
|
172
173
|
const json = args.includes('--json');
|
|
173
174
|
const removeMarkers = args.includes('--remove-markers');
|
|
174
175
|
const neutralize = args.includes('--neutralize');
|
|
176
|
+
const clipCrossStep = args.includes('--clip-crossstep');
|
|
175
177
|
const dropFailedTurns = args.includes('--drop-failed-turns');
|
|
176
178
|
const trimIdx = args.indexOf('--trim-last');
|
|
177
179
|
const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
|
|
@@ -183,7 +185,7 @@ function cmdFix(args) {
|
|
|
183
185
|
const file = args.find((a) => !a.startsWith('-'));
|
|
184
186
|
if (!file) fail(USAGE);
|
|
185
187
|
|
|
186
|
-
const result = repairSession(file, { removeMarkers, neutralize, dropFailedTurns, trimLast, compactLast, apply, backupDir });
|
|
188
|
+
const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, compactLast, apply, backupDir });
|
|
187
189
|
if (json) {
|
|
188
190
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
189
191
|
process.exit(result.ok ? 0 : 1);
|
|
@@ -192,7 +194,7 @@ function cmdFix(args) {
|
|
|
192
194
|
process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
|
|
193
195
|
process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
|
|
194
196
|
if (result.applied) {
|
|
195
|
-
process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker
|
|
197
|
+
process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker,裁剪 ${result.clipped} 个跨 step 引用(seq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
|
|
196
198
|
process.stdout.write(` 备份:${result.backupPath}\n`);
|
|
197
199
|
process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
|
|
198
200
|
} else if (apply && !result.ok) {
|
|
@@ -200,7 +202,7 @@ function cmdFix(args) {
|
|
|
200
202
|
} else if (apply) {
|
|
201
203
|
process.stdout.write(' (--apply 且无问题——无内容可修)\n');
|
|
202
204
|
} else {
|
|
203
|
-
process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号、${result.neutralized} 个 turn-null marker
|
|
205
|
+
process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号、${result.neutralized} 个 turn-null marker 可中和、${result.clipped} 个跨 step 引用可裁剪;加 --apply 落盘,--remove-markers / --neutralize / --clip-crossstep / --drop-failed-turns / --trim-last N 启用于对应修复)\n`);
|
|
204
206
|
}
|
|
205
207
|
process.stdout.write('\n');
|
|
206
208
|
process.exit(result.ok ? 0 : 1);
|
package/lib/checks.js
CHANGED
|
@@ -122,6 +122,37 @@ export function isReplaceOp(op) {
|
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* S9 —— 文件物理序 seq 单调(2026-08-30 事故固化;交接书 L2)。
|
|
127
|
+
*
|
|
128
|
+
* 按**文件物理行序**(非 seq 排序)要求展开后的事件 seq 严格单调递增。
|
|
129
|
+
* 单进程 append 不可能写出非单调物理序(appendCore 断言 seq==cursor+i 且按
|
|
130
|
+
* id 串行化)——非单调 = 多写入者/旧光标回放交织的现场特征(526f1835 物理序
|
|
131
|
+
* 734056→733539→735470)。E2 只查「排序后连续」,排序会掩盖物理序倒退;
|
|
132
|
+
* S9 补「物理序单调」盲区。
|
|
133
|
+
*
|
|
134
|
+
* @param rows - loadSessionLog 的 rows(物理行序,每行含 decoded 数组)。
|
|
135
|
+
* @returns S9 违规列表(error 级)。
|
|
136
|
+
*/
|
|
137
|
+
export function physicalOrderViolations(rows) {
|
|
138
|
+
const out = [];
|
|
139
|
+
let prevSeq = -1;
|
|
140
|
+
let prevLineNo = null;
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
if (!Array.isArray(row.decoded) || row.decoded.length === 0) continue;
|
|
143
|
+
for (const event of row.decoded) {
|
|
144
|
+
if (typeof event.seq !== 'number' || !Number.isSafeInteger(event.seq) || event.seq < 0) continue; // E1 处理
|
|
145
|
+
if (event.seq < prevSeq) {
|
|
146
|
+
out.push(violation('S9', { seq: event.seq, lineNo: row.lineNo, eventType: event.type }, `文件物理序 seq 倒退:${prevSeq}(line ${prevLineNo})→ ${event.seq}(line ${row.lineNo})——非单调 = 多写入者/旧光标回放交织(单进程 append 不可能写出),会话加载会被拒`));
|
|
147
|
+
return out; // 首个倒退即现场特征,报一次足够(后续乱序都源自此)
|
|
148
|
+
}
|
|
149
|
+
prevSeq = event.seq;
|
|
150
|
+
prevLineNo = row.lineNo;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
125
156
|
/**
|
|
126
157
|
* 与官方同语义的 surface 增量重放,逐事件归因 S1–S7。
|
|
127
158
|
* @param {Array<{event:object, lineNo?:number}>} events 按日志顺序的事件(带 loc 包装)
|
|
@@ -395,6 +426,110 @@ export function tokenMeterViolations(events) {
|
|
|
395
426
|
return out;
|
|
396
427
|
}
|
|
397
428
|
|
|
429
|
+
/**
|
|
430
|
+
* T2 —— token-meter 的 sourceEventSeqs 引用必须同 turn/step(2026-08-30 第二类
|
|
431
|
+
* 刷屏事故固化)。
|
|
432
|
+
*
|
|
433
|
+
* 镜像官方 `_estimateProviderAssistant`(dsh-token-meter lib/index.js:634-650):
|
|
434
|
+
* `assistant/message` 的每个 `sourceEventSeqs` 若指向 `assistant/chunk`,其
|
|
435
|
+
* turn/step 必须与消息自身一致;跨 step 引用 → 官方抛
|
|
436
|
+
* `token meter: assistant/message at seq N source seq M belongs to another step`
|
|
437
|
+
* (lib/index.js:645)。
|
|
438
|
+
*
|
|
439
|
+
* 事故现场:DSH resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
|
|
440
|
+
* chunk 全部引用进新 assistant/message(526f1835 seq 936047 跨 step 7/8/9)→
|
|
441
|
+
* 离线 check(T1)全绿但实机 token-meter 崩溃 → 同样刷屏压垮 host。
|
|
442
|
+
*
|
|
443
|
+
* @param events - 行序事件流(`{event, lineNo}`)。
|
|
444
|
+
* @returns T2 违规列表(error 级,token-meter 实机必崩)。
|
|
445
|
+
*/
|
|
446
|
+
export function tokenMeterSourceViolations(events) {
|
|
447
|
+
const out = [];
|
|
448
|
+
const bySeq = new Map();
|
|
449
|
+
for (const { event, lineNo } of events) bySeq.set(event.seq, { event, lineNo });
|
|
450
|
+
for (const { event, lineNo } of events) {
|
|
451
|
+
if (event.type !== 'assistant/message' || !Array.isArray(event.sourceEventSeqs) || event.sourceEventSeqs.length === 0) continue;
|
|
452
|
+
const turn = event.data?.turn;
|
|
453
|
+
const step = event.data?.step;
|
|
454
|
+
if (turn == null || step == null) continue; // turn-null marker 由 T1 覆盖
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
for (const s of event.sourceEventSeqs) {
|
|
457
|
+
if (s >= event.seq) {
|
|
458
|
+
out.push(violation('T2', { seq: event.seq, lineNo, eventType: event.type }, `assistant/message at seq ${event.seq} source seq ${s} is not earlier——token meter 折叠会抛错(_estimateProviderAssistant)`));
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
if (seen.has(s)) {
|
|
462
|
+
out.push(violation('T2', { seq: event.seq, lineNo, eventType: event.type }, `assistant/message at seq ${event.seq} repeats source seq ${s}——token meter 折叠会抛错`));
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
seen.add(s);
|
|
466
|
+
const src = bySeq.get(s);
|
|
467
|
+
if (!src || src.event.type !== 'assistant/chunk') continue; // 非 chunk 引用官方跳过
|
|
468
|
+
if (src.event.data?.turn !== turn || src.event.data?.step !== step) {
|
|
469
|
+
out.push(violation('T2', { seq: event.seq, lineNo, eventType: event.type }, `assistant/message at seq ${event.seq} source seq ${s} belongs to another step(消息 turn ${turn}/step ${step},源 turn ${String(src.event.data?.turn)}/step ${String(src.event.data?.step)})——token meter 折叠会抛错,/compact 与压力测量永久失败(DSH resend 在 step 未关时跨 step 引用)`));
|
|
470
|
+
break; // 官方抛一次即停(consumedEvents 不前进),只报首条
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* I1 —— inbox seed 相对重放(交接书 L1;镜像 dsh-agent lib/types/inbox.js)。
|
|
479
|
+
*
|
|
480
|
+
* 复刻官方 Inbox:从 `header.seedLength` 起重放 `agent/inbox/spliced`,
|
|
481
|
+
* next-turn/next-step 双队列;每条 splice 校验
|
|
482
|
+
* `start + removedCount <= 队列长` 且不产生重复 message id。违反 =
|
|
483
|
+
* `resume failed: invalid persisted inbox splice at seq N`(fork 边界孤儿:
|
|
484
|
+
* fork 时"移除父待处理提示词"的 removedCount=1 在子会话 seed 相对空 inbox 上非法)。
|
|
485
|
+
*
|
|
486
|
+
* @param events - 行序事件流(`{event, lineNo}`)。
|
|
487
|
+
* @param header - 日志 header(取 seedLength)。
|
|
488
|
+
* @returns I1 违规列表(error 级,resume 会被拒)。
|
|
489
|
+
*/
|
|
490
|
+
export function inboxReplayViolations(events, header) {
|
|
491
|
+
const out = [];
|
|
492
|
+
const seedLength = header?.seedLength;
|
|
493
|
+
if (typeof seedLength !== 'number' || !Number.isSafeInteger(seedLength) || seedLength < 0) {
|
|
494
|
+
// 无 seedLength(非 fork 会话)→ Inbox 从 0 重放,语义等同全量;仍做队列校验
|
|
495
|
+
}
|
|
496
|
+
const state = { 'next-turn': [], 'next-step': [] };
|
|
497
|
+
const startSeq = seedLength ?? 0;
|
|
498
|
+
for (const { event, lineNo } of events) {
|
|
499
|
+
if (event.seq < startSeq) continue;
|
|
500
|
+
if (event.type !== 'agent/inbox/spliced') continue;
|
|
501
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
502
|
+
const splice = event.data;
|
|
503
|
+
if (!splice || typeof splice.target !== 'string' || !['next-turn', 'next-step'].includes(splice.target)) {
|
|
504
|
+
out.push(violation('I1', loc, `spliced 缺合法 target(next-turn/next-step):${JSON.stringify(splice)?.slice(0, 80)}`));
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const inbox = state[splice.target];
|
|
508
|
+
const removedCount = splice.removedCount ?? 0;
|
|
509
|
+
if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
|
|
510
|
+
|| !Number.isSafeInteger(removedCount) || removedCount < 0
|
|
511
|
+
|| splice.start + removedCount > inbox.length) {
|
|
512
|
+
out.push(violation('I1', loc, `invalid inbox splice @seq ${event.seq}:target=${splice.target} start=${splice.start} removedCount=${removedCount} 但队列长 ${inbox.length}(seedLength=${seedLength})——resume 会被拒(fork 边界孤儿 spliced 即此形态,removedCount 指向 seed 相对空 inbox)`));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const inserted = Array.isArray(splice.inserted) ? splice.inserted : [];
|
|
516
|
+
const candidate = inbox.slice(0, splice.start).concat(inserted, inbox.slice(splice.start + removedCount));
|
|
517
|
+
const ids = new Set();
|
|
518
|
+
const other = splice.target === 'next-turn' ? state['next-step'] : state['next-turn'];
|
|
519
|
+
for (const message of [...candidate, ...other]) {
|
|
520
|
+
const id = message?.id ?? message?.message?.id;
|
|
521
|
+
if (id === undefined) continue;
|
|
522
|
+
if (ids.has(id)) {
|
|
523
|
+
out.push(violation('I1', loc, `message "${id}" 已在待处理队列中(target=${splice.target})——resume 会被拒(重复 pending id)`));
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
ids.add(id);
|
|
527
|
+
}
|
|
528
|
+
inbox.splice(splice.start, removedCount, ...inserted);
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
398
533
|
/** 从事件推导 wire 消息(与 dsh-session deriveEventMessage 同语义)。 */
|
|
399
534
|
export function deriveWireMessage(event) {
|
|
400
535
|
if (event.type === 'user/message') {
|
package/lib/contracts.js
CHANGED
|
@@ -98,6 +98,22 @@ export const CONTRACT_RULES = [
|
|
|
98
98
|
source: '@deepseek-ai/dsh-session lib/index.js:398 (planSurfaceEvent "not contiguous");审计 S2/N6',
|
|
99
99
|
description: 'seq 必须从 0(或窗口 baseSeq)严格连续递增。缺口/倒退 = 违反单写入者假设(多实例共享存储并发写的痕迹),加载时直接 throw。',
|
|
100
100
|
},
|
|
101
|
+
{
|
|
102
|
+
id: 'S9',
|
|
103
|
+
title: '文件物理序 seq 单调(多写入者交织现场特征)',
|
|
104
|
+
layer: LAYER.PERSISTENCE,
|
|
105
|
+
severity: SEVERITY.ERROR,
|
|
106
|
+
source: '2026-08-28 实锤:526f1835 文件物理序 734056→733539→735470;单进程 appendCore 断言 seq==cursor+i 且按 id 串行化不可能写出',
|
|
107
|
+
description: '按文件物理行序要求展开后事件 seq 严格单调递增。E2 在排序后检查(loadSessionLog 会 sort),物理序倒退被掩盖;S9 在排序前按行序检查,非单调 = 多写入者/旧光标回放交织的直接现场证据,加载会被拒。',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'I1',
|
|
111
|
+
title: 'inbox seed 相对重放(fork 边界孤儿 spliced)',
|
|
112
|
+
layer: LAYER.ENGINE,
|
|
113
|
+
severity: SEVERITY.ERROR,
|
|
114
|
+
source: '@deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28 实锤:62c5b531/73ed35d8 fork 边界 removedCount=1 孤儿',
|
|
115
|
+
description: '从 header.seedLength 起重放 agent/inbox/spliced,next-turn/next-step 双队列;start+removedCount 不得超过队列长、不得产生重复 pending id。fork 时"移除父待处理提示词"的 splice 假设父会话 inbox,子会话 seed 相对空 inbox 上非法 → resume 被拒(invalid persisted inbox splice)。',
|
|
116
|
+
},
|
|
101
117
|
{
|
|
102
118
|
id: 'E3',
|
|
103
119
|
title: 'type 必须在已知词汇表内(或带 ignorable 标记)',
|
|
@@ -205,6 +221,14 @@ export const CONTRACT_RULES = [
|
|
|
205
221
|
source: '@deepseek-ai/dsh-token-meter lib/index.js:566-625 (_foldEvent)',
|
|
206
222
|
description: 'token meter 折叠要求 assistant/message 与 step/end 与打开的 step/start(turn/step 完全一致)匹配;违反即 /compact 与压力测量永久失败。retrace 的 turn-null 编辑/撤回 marker(空 assistant/message replace)命中此条——foldSurface 认可其合法性但 token meter 崩溃(M1 只约束 append 形态的盲区),压缩前需清理。',
|
|
207
223
|
},
|
|
224
|
+
{
|
|
225
|
+
id: 'T2',
|
|
226
|
+
title: 'token-meter 源引用:assistant/message 的 sourceEventSeqs 引用的 chunk 必须同 turn/step',
|
|
227
|
+
layer: LAYER.ENGINE,
|
|
228
|
+
severity: SEVERITY.ERROR,
|
|
229
|
+
source: '@deepseek-ai/dsh-token-meter lib/index.js:634-650 (_estimateProviderAssistant,:645 belongs to another step)',
|
|
230
|
+
description: 'token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30 实测 526f1835 seq 936047 跨 step 7/8/9)。T1 只查 step 配对不查源引用,此条补盲区;修复用 fix --clip-crossstep。',
|
|
231
|
+
},
|
|
208
232
|
{
|
|
209
233
|
id: 'P3',
|
|
210
234
|
title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
|
package/lib/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export { loadSessionLog, tailSeq } from './log-reader.js';
|
|
8
8
|
export { validateSessionLog } from './validate.js';
|
|
9
9
|
export { createPreWriter, preWriterFromLog } from './prewrite.js';
|
|
10
|
-
export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
|
|
10
|
+
export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, clipCrossStepSourcesText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
|
|
11
11
|
export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
|
|
12
|
-
export { tokenMeterViolations } from './checks.js';
|
|
12
|
+
export { tokenMeterViolations, tokenMeterSourceViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
|
|
13
13
|
export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
|
package/lib/repair.js
CHANGED
|
@@ -119,6 +119,63 @@ export function strictScanText(text) {
|
|
|
119
119
|
return { failures, count };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 裁剪 assistant/message 的跨 step sourceEventSeqs(2026-08-30 第二类事故)。
|
|
124
|
+
*
|
|
125
|
+
* 现象:DSH 的 resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
|
|
126
|
+
* assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(526f1835
|
|
127
|
+
* seq 936047:sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
|
|
128
|
+
* 要求每个 source chunk 与消息同 turn/step(dsh-token-meter lib/index.js:645,
|
|
129
|
+
* `belongs to another step`)→ 同样刷屏压垮 host。
|
|
130
|
+
*
|
|
131
|
+
* 修复:把 sourceEventSeqs 裁剪为只含「与消息同 turn/step 的 assistant/chunk」
|
|
132
|
+
* (丢弃跨 step 的 chunk 与边界事件)。token-meter 检查全过(所有 src 同 step),
|
|
133
|
+
* token 计量仍准(保留的就是本 step 实际输出)。不动 seq/行数/其他字段。
|
|
134
|
+
*
|
|
135
|
+
* @param {string} text - JSONL 全文(含 header 行)。
|
|
136
|
+
* @returns {{ text: string, clipped: number, seqs: Array<number> }}
|
|
137
|
+
*/
|
|
138
|
+
export function clipCrossStepSourcesText(text) {
|
|
139
|
+
const parts = text.split('\n');
|
|
140
|
+
// 用 decodeLine 展开每行,建立「展开后事件 seq → 事件」映射(chunk 行会展开为
|
|
141
|
+
// 多个 assistant/chunk,其 seq 是 seq0+偏移——必须用展开后的 seq 才能对上
|
|
142
|
+
// sourceEventSeqs 引用的值)。同时记录每个展开后事件属于哪一行,便于回写。
|
|
143
|
+
const bySeq = new Map(); // 展开后 seq → { event, lineIndex }
|
|
144
|
+
for (let i = 0; i < parts.length; i++) {
|
|
145
|
+
const raw = parts[i];
|
|
146
|
+
if (!raw.trim()) continue;
|
|
147
|
+
const decoded = decodeLine(raw);
|
|
148
|
+
if (decoded === null) continue;
|
|
149
|
+
for (const ev of decoded) {
|
|
150
|
+
if (typeof ev.seq === 'number') bySeq.set(ev.seq, { event: ev, lineIndex: i });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const clipped = [];
|
|
154
|
+
let clippedCount = 0;
|
|
155
|
+
for (const { event, lineIndex } of bySeq.values()) {
|
|
156
|
+
if (event.type !== 'assistant/message' || !Array.isArray(event.sourceEventSeqs) || event.sourceEventSeqs.length === 0) continue;
|
|
157
|
+
const { turn, step } = event.data ?? {};
|
|
158
|
+
if (turn == null || step == null) continue; // turn-null marker 由 neutralize 处理
|
|
159
|
+
let dirty = false;
|
|
160
|
+
const kept = event.sourceEventSeqs.filter((s) => {
|
|
161
|
+
const src = bySeq.get(s)?.event;
|
|
162
|
+
if (!src || src.type !== 'assistant/chunk') return true; // 保留非 chunk 引用(边界事件)
|
|
163
|
+
if (src.data?.turn === turn && src.data?.step === step) return true;
|
|
164
|
+
dirty = true;
|
|
165
|
+
return false;
|
|
166
|
+
});
|
|
167
|
+
if (dirty) {
|
|
168
|
+
// 回写:只改事件所属的行。若该行是 chunk 行(多个展开事件共享一行),
|
|
169
|
+
// 此事件必是独立 assistant/message 行,直接改那一行。
|
|
170
|
+
event.sourceEventSeqs = kept;
|
|
171
|
+
parts[lineIndex] = JSON.stringify(event);
|
|
172
|
+
clipped.push(event.seq);
|
|
173
|
+
clippedCount++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { text: parts.join('\n'), clipped: clippedCount, seqs: clipped };
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
/**
|
|
123
180
|
* 原地中和 turn-null marker(2026-08-30 事故升级:单 marker 会让 token-meter
|
|
124
181
|
* 监听器在每条事件上抛错刷屏、压垮 host 事件循环 → 全会话连锁锁定)。
|
|
@@ -599,11 +656,12 @@ export function rebuildZstdText(text) {
|
|
|
599
656
|
* @param {string} file - .jsonl 或 .jsonl.zstd 路径。
|
|
600
657
|
* @param {{
|
|
601
658
|
* removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number, compactLast?: number,
|
|
602
|
-
* neutralize?: boolean, apply?: boolean, backupDir?: string
|
|
659
|
+
* neutralize?: boolean, clipCrossStep?: boolean, apply?: boolean, backupDir?: string
|
|
603
660
|
* }} opts
|
|
604
661
|
* @returns {{
|
|
605
662
|
* file, ok, issues: Array<{kind:string, detail:string}>,
|
|
606
|
-
* removed, renumbered, neutralized, neutralizedSeqs: Array<number>,
|
|
663
|
+
* removed, renumbered, neutralized, neutralizedSeqs: Array<number>,
|
|
664
|
+
* clipped, clippedSeqs: Array<number>, backupPath, applied,
|
|
607
665
|
* check: { ok, summary }
|
|
608
666
|
* }}
|
|
609
667
|
*/
|
|
@@ -665,6 +723,17 @@ export function repairSession(file, opts = {}) {
|
|
|
665
723
|
plain = r.text;
|
|
666
724
|
}
|
|
667
725
|
}
|
|
726
|
+
let clipped = 0;
|
|
727
|
+
const clippedSeqs = [];
|
|
728
|
+
if (opts.clipCrossStep) {
|
|
729
|
+
const r = clipCrossStepSourcesText(plain);
|
|
730
|
+
clipped = r.clipped;
|
|
731
|
+
clippedSeqs.push(...r.seqs);
|
|
732
|
+
if (r.clipped > 0) {
|
|
733
|
+
issues.push({ kind: 'clip-crossstep', detail: `裁剪 ${r.clipped} 个 assistant/message 的跨 step sourceEventSeqs(保留同 step chunk,token-meter 不再抛 belongs to another step)` });
|
|
734
|
+
plain = r.text;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
668
737
|
if (typeof opts.trimLast === 'number') {
|
|
669
738
|
const r = trimLastMessagesText(plain, opts.trimLast);
|
|
670
739
|
applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
|
|
@@ -709,6 +778,8 @@ export function repairSession(file, opts = {}) {
|
|
|
709
778
|
renumbered,
|
|
710
779
|
neutralized,
|
|
711
780
|
neutralizedSeqs,
|
|
781
|
+
clipped,
|
|
782
|
+
clippedSeqs,
|
|
712
783
|
backupPath,
|
|
713
784
|
applied,
|
|
714
785
|
check,
|
package/lib/validate.js
CHANGED
|
@@ -13,11 +13,14 @@ import {
|
|
|
13
13
|
envelopeViolations,
|
|
14
14
|
engineViolations,
|
|
15
15
|
finalFold,
|
|
16
|
+
inboxReplayViolations,
|
|
16
17
|
isSafeInt,
|
|
18
|
+
physicalOrderViolations,
|
|
17
19
|
pluginViolations,
|
|
18
20
|
replaySurface,
|
|
19
21
|
violation,
|
|
20
22
|
tokenMeterViolations,
|
|
23
|
+
tokenMeterSourceViolations,
|
|
21
24
|
toolPairingViolations,
|
|
22
25
|
toolResultStructureViolations,
|
|
23
26
|
wireViolations,
|
|
@@ -99,14 +102,21 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
// ── S9 · 文件物理序 seq 单调(多写入者交织现场;E2 排序后检查看不到)──
|
|
106
|
+
violations.push(...physicalOrderViolations(rows));
|
|
107
|
+
|
|
102
108
|
// ── S · surface 增量重放(归因)+ 官方 foldSurface 终验 ────────────────
|
|
103
109
|
const replay = replaySurface(events);
|
|
104
110
|
violations.push(...replay.violations);
|
|
105
111
|
|
|
106
112
|
const folded = finalFold(events.map((e) => e.event));
|
|
107
113
|
|
|
108
|
-
// ── T · token meter 配对(事故根因 3
|
|
114
|
+
// ── T · token meter 配对(事故根因 3 + 2026-08-30 两类刷屏)──
|
|
109
115
|
violations.push(...tokenMeterViolations(events));
|
|
116
|
+
violations.push(...tokenMeterSourceViolations(events));
|
|
117
|
+
|
|
118
|
+
// ── I1 · inbox seed 相对重放(fork 边界孤儿;交接书 L1)──────────────
|
|
119
|
+
violations.push(...inboxReplayViolations(events, header));
|
|
110
120
|
|
|
111
121
|
// ── P3/P4 · 考古契约(工具配对 + 输出结构)──
|
|
112
122
|
violations.push(...toolPairingViolations(events));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-contract",
|
|
3
3
|
"description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"bin": {
|