dsh-log-contract 0.3.4 → 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/lib/checks.js +135 -0
- package/lib/contracts.js +24 -0
- package/lib/index.js +1 -1
- 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/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
|
@@ -9,5 +9,5 @@ export { validateSessionLog } from './validate.js';
|
|
|
9
9
|
export { createPreWriter, preWriterFromLog } from './prewrite.js';
|
|
10
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/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": {
|