@jjh32456/dsh-longlive 1.6.7 → 1.6.8

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 (41) hide show
  1. package/CHANGELOG.md +40 -8
  2. package/QUICKSTART.md +10 -10
  3. package/README.en.md +4 -3
  4. package/README.md +10 -4
  5. package/SPEC.md +2 -2
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +3 -2
  8. package/docs/CONFIG.md +33 -6
  9. package/docs/DEV.md +3 -3
  10. package/docs/FAQ.md +4 -4
  11. package/docs/FEATURES.md +36 -5
  12. package/docs/OPERATIONS.md +1 -1
  13. package/docs/QUICKSTART.md +3 -3
  14. package/docs/README.en.md +3 -2
  15. package/docs/TROUBLESHOOTING.md +3 -3
  16. package/package.json +13 -8
  17. package/src/activity/index.js +649 -0
  18. package/src/activity/model.js +341 -0
  19. package/src/activity/store.js +625 -0
  20. package/src/config.js +29 -2
  21. package/src/features.js +26 -0
  22. package/src/heartbeat.js +156 -23
  23. package/src/identity/handover.js +52 -1
  24. package/src/live.js +22 -2
  25. package/src/recall/privacy.mjs +105 -16
  26. package/src/router/service/board.mjs +95 -15
  27. package/src/router/service/hint.mjs +14 -0
  28. package/src/router/service/service.mjs +27 -11
  29. package/test/activity-realtime.test.mjs +959 -0
  30. package/test/activity-stream.test.mjs +512 -0
  31. package/test/autobind.test.mjs +25 -11
  32. package/test/config-schema.test.mjs +6 -0
  33. package/test/feature-flags.test.mjs +17 -0
  34. package/test/fixtures/activity-append-child.mjs +29 -0
  35. package/test/live.test.mjs +6 -3
  36. package/test/packaging.test.mjs +7 -5
  37. package/test/router-background/router.test.mjs +44 -12
  38. package/test/run-rc.mjs +16 -0
  39. package/test/safety-defaults.test.mjs +5 -3
  40. package/test/state-truth.test.mjs +937 -0
  41. package/test/version-consistency.test.mjs +286 -0
@@ -0,0 +1,937 @@
1
+ // suite: P2 状态诚实(R3 目标新鲜度 / R4 状态诚实 / R6.2 开机卡年龄)
2
+ //
3
+ // 这一套只测「共享板 / status / 开机卡不说谎」这四件事,全部在 fixture
4
+ // memoryRoot 里跑,不读真实记忆根、不装插件、不联网:
5
+ // - R3: live 行的 goalUpdatedAt 只由「写 goal」推进,心跳 tick 不得改它;
6
+ // board.focus.updatedAt = 目标写入时间;目标 >30 分钟未更新时
7
+ // focus 项带 stale:true(board.stale 同步),旧行不编造目标时间。
8
+ // - R4.1: board.pulse.running 按 effectiveState 统计,不再数行自报 state。
9
+ // - R4.2: status 投影取 board 同字段(service.mjs 不再硬编码 running:0);
10
+ // buildStatus({board}) 透传 board.pulse.running。
11
+ // - R4.3: live 行在 turn/start → running+turn,turn/end → idle/waiting/
12
+ // blocked+turn;心跳 tick 只续 heartbeat、不动 state/turn/last_action。
13
+ // - R6.2: 开机卡逐字含「状态卡写于 X 分钟前」,X 由 STATE.md mtime 与 now
14
+ // 计算;拿不到 mtime 时不编造。
15
+
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import test from 'node:test';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ import {
24
+ LIVE_RECORD_FIELDS,
25
+ liveTouch,
26
+ liveUpdate,
27
+ } from '../src/live.js';
28
+ import {
29
+ createAutoHeartbeat,
30
+ stateForTurnEnd,
31
+ stateForTurnStart,
32
+ turnBoundaryPlan,
33
+ } from '../src/heartbeat.js';
34
+ import { createBoardBuilder, FOCUS_STALE_MS, renderFocusLine } from '../src/router/service/board.mjs';
35
+ import { renderHint } from '../src/router/service/hint.mjs';
36
+ import { buildStatus } from '../src/router/service/status.mjs';
37
+ import { createRouterService } from '../src/router/service/service.mjs';
38
+ import { MockLlm } from '../src/router/background/mock-llm.mjs';
39
+ import { buildOpenCard, collectOpenCard } from '../src/identity/handover.js';
40
+
41
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
42
+ const ROOT = path.resolve(HERE, '..');
43
+ const MIN = 60 * 1000;
44
+ const BASE_MS = Date.parse('2026-11-06T04:00:00.000Z');
45
+
46
+ function makeRoot(label) {
47
+ return fs.mkdtempSync(path.join(os.tmpdir(), `longlive-state-truth-${label}-`));
48
+ }
49
+
50
+ function clean(root) {
51
+ try {
52
+ fs.rmSync(root, { recursive: true, force: true });
53
+ } catch {
54
+ /* temp dir */
55
+ }
56
+ }
57
+
58
+ function iso(ms) {
59
+ return new Date(ms).toISOString();
60
+ }
61
+
62
+ function rowPath(root, sessionId) {
63
+ return path.join(root, '.longlive', 'live', `${sessionId}.json`);
64
+ }
65
+
66
+ function readRow(root, sessionId) {
67
+ return JSON.parse(fs.readFileSync(rowPath(root, sessionId), 'utf8'));
68
+ }
69
+
70
+ function writeRow(root, sessionId, record) {
71
+ fs.mkdirSync(path.dirname(rowPath(root, sessionId)), { recursive: true });
72
+ fs.writeFileSync(rowPath(root, sessionId), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
73
+ }
74
+
75
+ function buildBoard(root, nowMs, args = {}) {
76
+ const builder = createBoardBuilder({
77
+ memoryRoot: root,
78
+ config: {},
79
+ now: () => new Date(nowMs),
80
+ paths: {},
81
+ seq: 0,
82
+ });
83
+ const snapshot = builder.build(args);
84
+ return snapshot.board;
85
+ }
86
+
87
+ function writeBoard(root, nowMs) {
88
+ const boardFile = path.join(root, 'vault', 'board.json');
89
+ const builder = createBoardBuilder({
90
+ memoryRoot: root,
91
+ config: {},
92
+ now: () => new Date(nowMs),
93
+ paths: { boardFile },
94
+ seq: 0,
95
+ });
96
+ const snapshot = builder.build({});
97
+ const written = builder.write(snapshot);
98
+ return { written, board: written.board, boardFile };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // R3 — 目标新鲜度
103
+ // ---------------------------------------------------------------------------
104
+
105
+ test('R3: goalUpdatedAt 只由写 goal 推进;心跳 touch 不动 goal/目标时间', () => {
106
+ const root = makeRoot('goal-clock');
107
+ const sessionId = 'session-r3-clock';
108
+ try {
109
+ const first = liveUpdate({
110
+ sessionId,
111
+ state: 'running',
112
+ turn: 3,
113
+ goal: '把 libtv S08-S25 渲完并合成',
114
+ last_action: { kind: 'tool', detail: 'pwsh libtv render S08-S10' },
115
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
116
+ assert.equal(first.ok, true);
117
+ assert.equal(first.record.goalUpdatedAt, iso(BASE_MS), 'row carries the goal clock');
118
+ assert.ok(LIVE_RECORD_FIELDS.includes('goalUpdatedAt'), 'goalUpdatedAt 是文档化行字段');
119
+
120
+ // 心跳只续 liveness:goalUpdatedAt / goal / state / turn 全部原样。
121
+ liveUpdate({ sessionId, state: 'running', turn: 3 }, { memoryRoot: root, now: new Date(BASE_MS + 5 * MIN) });
122
+ const heartbeatOnly = readRow(root, sessionId);
123
+ assert.equal(heartbeatOnly.heartbeatAt, iso(BASE_MS + 5 * MIN), 'heartbeatAt 前进');
124
+ assert.equal(heartbeatOnly.goalUpdatedAt, iso(BASE_MS), 'goalUpdatedAt 不跟随 heartbeat');
125
+ assert.equal(heartbeatOnly.goal, '把 libtv S08-S25 渲完并合成', 'goal 原样保留');
126
+
127
+ // 真正重写 goal 才推进目标时间。
128
+ liveUpdate({
129
+ sessionId,
130
+ state: 'running',
131
+ turn: 4,
132
+ goal: '改渲 S10-S12',
133
+ }, { memoryRoot: root, now: new Date(BASE_MS + 7 * MIN) });
134
+ const rewritten = readRow(root, sessionId);
135
+ assert.equal(rewritten.goalUpdatedAt, iso(BASE_MS + 7 * MIN), '写 goal 才推进目标时间');
136
+ assert.equal(rewritten.goal, '改渲 S10-S12');
137
+ } finally {
138
+ clean(root);
139
+ }
140
+ });
141
+
142
+ test('R3: liveTouch(lastAction:false) 保留 goal 时钟,只动 heartbeat/updatedAt', () => {
143
+ const root = makeRoot('goal-touch');
144
+ const sessionId = 'session-r3-touch';
145
+ try {
146
+ // liveTouch 是自动心跳的写入口(不新建行),这里直接钉它的保留语义。
147
+ liveUpdate({
148
+ sessionId,
149
+ state: 'running',
150
+ turn: 2,
151
+ goal: '写 P2 状态诚实',
152
+ last_action: { kind: 'tool', detail: 'edit board.mjs' },
153
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
154
+ const before = readRow(root, sessionId);
155
+ const touched = liveTouch(sessionId, {
156
+ memoryRoot: root,
157
+ now: new Date(BASE_MS + 5 * MIN),
158
+ lastAction: false,
159
+ });
160
+ assert.equal(touched.ok, true);
161
+ const after = readRow(root, sessionId);
162
+ assert.equal(after.heartbeatAt, iso(BASE_MS + 5 * MIN));
163
+ assert.equal(after.goalUpdatedAt, before.goalUpdatedAt, '心跳不得改目标写入时间');
164
+ assert.deepEqual(after.last_action, before.last_action, 'lastAction:false 保留 last_action');
165
+ assert.equal(after.state, 'running');
166
+ assert.equal(after.turn, 2);
167
+ } finally {
168
+ clean(root);
169
+ }
170
+ });
171
+
172
+ test('R3: focus.updatedAt = 目标写入时间,不是 heartbeatAt', () => {
173
+ const root = makeRoot('focus-time');
174
+ const sessionId = 'session-r3-focus';
175
+ try {
176
+ liveUpdate({
177
+ sessionId,
178
+ state: 'running',
179
+ turn: 3,
180
+ goal: '把 libtv S08-S25 渲完并合成',
181
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
182
+ // 心跳刷新到 5 分钟后;目标仍在 0 分钟处。
183
+ liveUpdate({ sessionId, state: 'running', turn: 3 }, { memoryRoot: root, now: new Date(BASE_MS + 5 * MIN) });
184
+
185
+ const board = buildBoard(root, BASE_MS + 5 * MIN);
186
+ const focus = board.focus.find((item) => item.sessionId === sessionId);
187
+ assert.ok(focus, 'focus 里应有该会话');
188
+ assert.equal(focus.updatedAt, iso(BASE_MS), 'updatedAt 命中目标写入时间');
189
+ assert.notEqual(focus.updatedAt, iso(BASE_MS + 5 * MIN), 'updatedAt 不是心跳时间');
190
+ assert.equal(focus.stale, undefined, '新鲜目标不带 stale 键(持久化形状不变)');
191
+ assert.equal(board.stale, false);
192
+ } finally {
193
+ clean(root);
194
+ }
195
+ });
196
+
197
+ test('R3: 目标 31 分钟未更新 → focus.stale:true + board.stale + 渲染行可见', () => {
198
+ const root = makeRoot('focus-stale');
199
+ const sessionId = 'session-r3-stale';
200
+ try {
201
+ liveUpdate({
202
+ sessionId,
203
+ state: 'running',
204
+ turn: 3,
205
+ goal: '把 libtv S08-S25 渲完并合成',
206
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
207
+ liveUpdate({ sessionId, state: 'running', turn: 3 }, { memoryRoot: root, now: new Date(BASE_MS + 31 * MIN) });
208
+
209
+ const board = buildBoard(root, BASE_MS + 31 * MIN);
210
+ const focus = board.focus.find((item) => item.sessionId === sessionId);
211
+ assert.ok(focus);
212
+ assert.equal(focus.stale, true, '目标 31 分钟没更新必须标 stale');
213
+ assert.equal(focus.updatedAt, iso(BASE_MS), 'stale 但 updatedAt 仍是目标时间');
214
+ assert.equal(board.stale, true, 'board 级聚合同步');
215
+ assert.match(renderFocusLine(focus), /stale|过期|不新鲜/i, '渲染行里 stale 可见');
216
+ assert.ok(FOCUS_STALE_MS === 30 * MIN, '阈值固定 30 分钟');
217
+ } finally {
218
+ clean(root);
219
+ }
220
+ });
221
+
222
+ test('R3: 29 分钟未更新不标 stale(30 分钟阈值边界)', () => {
223
+ const root = makeRoot('focus-fresh29');
224
+ const sessionId = 'session-r3-fresh';
225
+ try {
226
+ liveUpdate({
227
+ sessionId,
228
+ state: 'running',
229
+ goal: '跑 361 用例',
230
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
231
+ liveUpdate({ sessionId, state: 'running' }, { memoryRoot: root, now: new Date(BASE_MS + 29 * MIN) });
232
+ const board = buildBoard(root, BASE_MS + 29 * MIN);
233
+ const focus = board.focus.find((item) => item.sessionId === sessionId);
234
+ assert.equal(focus.stale, undefined);
235
+ assert.equal(board.stale, false);
236
+ } finally {
237
+ clean(root);
238
+ }
239
+ });
240
+
241
+ test('R3: 旧行(无 goalUpdatedAt)不编造目标时间,也不谎报 stale', () => {
242
+ const root = makeRoot('focus-legacy');
243
+ const sessionId = 'session-r3-legacy';
244
+ try {
245
+ liveUpdate({
246
+ sessionId,
247
+ state: 'running',
248
+ goal: '旧版本写的目标',
249
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
250
+ // 模拟 1.6.7 写的行:没有 goalUpdatedAt 字段。
251
+ const legacy = readRow(root, sessionId);
252
+ delete legacy.goalUpdatedAt;
253
+ writeRow(root, sessionId, legacy);
254
+
255
+ const board = buildBoard(root, BASE_MS + 2 * MIN);
256
+ const focus = board.focus.find((item) => item.sessionId === sessionId);
257
+ assert.ok(focus);
258
+ assert.equal(focus.updatedAt, null, '拿不到目标时间就 null,不拿心跳冒充');
259
+ assert.equal(focus.stale, undefined, '没有目标时间就不判 stale');
260
+ } finally {
261
+ clean(root);
262
+ }
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // R4.1 — running 计数按 effectiveState
267
+ // ---------------------------------------------------------------------------
268
+
269
+ test('R4.1: pulse.running 只数 effectiveState=running(34 死 + 2 活 → 2)', () => {
270
+ const root = makeRoot('pulse-running');
271
+ try {
272
+ const boardTime = BASE_MS + 10 * MIN;
273
+ // 34 条早已死掉、但自报 running 的行:心跳停在 10 分钟前 → effectiveState=dead。
274
+ for (let index = 0; index < 34; index += 1) {
275
+ liveUpdate({
276
+ sessionId: `session-dead${String(index).padStart(2, '0')}`,
277
+ state: 'running',
278
+ turn: 1,
279
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
280
+ }
281
+ // 1 条 idle 活行:心跳 100s 前 → 回落 declared state=idle(算 active)。
282
+ liveUpdate({ sessionId: 'session-live0003', state: 'idle' }, { memoryRoot: root, now: new Date(boardTime - 100_000) });
283
+ // 2 条真正在跑的行:心跳 10s 前 → effectiveState=running。
284
+ liveUpdate({ sessionId: 'session-live0001', state: 'running', turn: 4 }, { memoryRoot: root, now: new Date(boardTime - 10_000) });
285
+ liveUpdate({ sessionId: 'session-live0002', state: 'running', turn: 2 }, { memoryRoot: root, now: new Date(boardTime - 10_000) });
286
+
287
+ const board = buildBoard(root, boardTime);
288
+ assert.equal(board.pulse.running, 2, 'pulse.running 必须是有效活跃的 2,不是自报 36');
289
+ assert.equal(board.counts.running, 2, 'counts.running 同口径');
290
+ assert.equal(board.pulse.active, 3, '1 idle + 2 running 仍是 3 个活跃会话');
291
+ assert.notEqual(board.pulse.running, 34);
292
+ assert.notEqual(board.pulse.running, 36);
293
+ } finally {
294
+ clean(root);
295
+ }
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // R4.2 — status 投影取 board 同字段
300
+ // ---------------------------------------------------------------------------
301
+
302
+ test('R4.2: buildStatus({board}) 透传 board.pulse.running(不是另一个数)', () => {
303
+ const root = makeRoot('status-projection');
304
+ try {
305
+ const boardTime = BASE_MS + 10 * MIN;
306
+ liveUpdate({ sessionId: 'session-live0001', state: 'running', turn: 4 }, { memoryRoot: root, now: new Date(boardTime - 10_000) });
307
+ const board = buildBoard(root, boardTime);
308
+ assert.equal(board.pulse.running, 1);
309
+
310
+ const snapshot = buildStatus({ board, now: iso(boardTime) });
311
+ assert.equal(snapshot.board.pulse.running, board.pulse.running, 'status.json 与 board.json 同字段同值');
312
+ assert.equal(snapshot.board.pulse.running, 1);
313
+ } finally {
314
+ clean(root);
315
+ }
316
+ });
317
+
318
+ test('R4.2: service.mjs 的 status 投影不再把 pulse.running 硬编码为 0', () => {
319
+ const source = fs.readFileSync(path.join(ROOT, 'src', 'router', 'service', 'service.mjs'), 'utf8');
320
+ assert.equal(source.includes('running: 0'), false, '不得残留硬编码 running: 0');
321
+ assert.ok(source.includes('running: board.pulse?.running'), 'running 取 board 同字段');
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // R4.3 — live 状态机(turn 边界)
326
+ // ---------------------------------------------------------------------------
327
+
328
+ test('R4.3: 纯函数 stateForTurnStart / stateForTurnEnd / turnBoundaryPlan', () => {
329
+ assert.equal(stateForTurnStart(), 'running');
330
+ assert.equal(stateForTurnEnd('waiting'), 'waiting', 'waiting 是给用户的承诺,保留');
331
+ assert.equal(stateForTurnEnd('blocked'), 'blocked', 'blocked 保留');
332
+ assert.equal(stateForTurnEnd('running'), 'idle');
333
+ assert.equal(stateForTurnEnd(undefined), 'idle');
334
+ assert.deepEqual(turnBoundaryPlan('turn/start', { turn: 5 }), {
335
+ state: 'running', turn: 5, kind: 'turn', detail: 'turn/start #5',
336
+ });
337
+ assert.deepEqual(turnBoundaryPlan('turn/end', { turn: 5, priorState: 'blocked' }), {
338
+ state: 'blocked', turn: 5, kind: 'turn', detail: 'turn/end #5',
339
+ });
340
+ assert.equal(turnBoundaryPlan('turn/end', { turn: 0, priorState: 'running' }).state, 'idle');
341
+ });
342
+
343
+ function fakeHostCtx() {
344
+ const hooks = [];
345
+ return {
346
+ hooks,
347
+ ctx: {
348
+ on(name, fn) {
349
+ hooks.push({ name, fn });
350
+ return () => {};
351
+ },
352
+ },
353
+ };
354
+ }
355
+
356
+ test('R4.3: turn/start → state=running+turn;turn/end → idle+turn(heartbeatMs:0 也工作)', () => {
357
+ const root = makeRoot('turn-machine');
358
+ const sessionId = 'session-turn0001';
359
+ try {
360
+ let nowMs = BASE_MS;
361
+ // 先有一行(带 goal),模拟会话已用过 longlive 工具被绑定。
362
+ liveUpdate({
363
+ sessionId,
364
+ state: 'running',
365
+ turn: 4,
366
+ goal: '写 state-truth 测试',
367
+ last_action: { kind: 'tool', detail: 'edit test.mjs' },
368
+ }, { memoryRoot: root, now: new Date(nowMs) });
369
+ const goalClock = readRow(root, sessionId).goalUpdatedAt;
370
+
371
+ const host = fakeHostCtx();
372
+ const beat = createAutoHeartbeat({
373
+ ctx: host.ctx,
374
+ memoryRoot: root,
375
+ heartbeatMs: 0, // 定时心跳关闭;turn 状态机必须仍然工作
376
+ now: () => new Date(nowMs),
377
+ resolve: () => ({ id: sessionId }),
378
+ });
379
+ assert.equal(beat.bind(sessionId), true);
380
+ beat.start();
381
+ const hook = host.hooks.find((item) => item.name === 'session/event');
382
+ assert.ok(hook, 'start() 必须注册 session/event');
383
+
384
+ nowMs = BASE_MS + 1000;
385
+ hook.fn({ id: sessionId }, { type: 'turn/start', data: { turn: 5 } });
386
+ const started = readRow(root, sessionId);
387
+ assert.equal(started.state, 'running', 'turn/start → running');
388
+ assert.equal(started.turn, 5, 'turn/start 落 turn 号');
389
+ assert.equal(started.last_action.detail, 'turn/start #5');
390
+ assert.equal(started.goalUpdatedAt, goalClock, 'turn/start 不冒充 goal 写入');
391
+
392
+ nowMs = BASE_MS + 2000;
393
+ hook.fn({ id: sessionId }, { type: 'turn/end', data: { turn: 5 } });
394
+ const ended = readRow(root, sessionId);
395
+ assert.equal(ended.state, 'idle', 'turn/end → idle');
396
+ assert.equal(ended.turn, 5, 'turn/end 落 turn 号');
397
+ assert.equal(ended.last_action.detail, 'turn/end #5');
398
+ beat.stop();
399
+ } finally {
400
+ clean(root);
401
+ }
402
+ });
403
+
404
+ test('R4.3: turn/end 保留 waiting / blocked 承诺(不抹成 idle)', () => {
405
+ const root = makeRoot('turn-promise');
406
+ const sessionId = 'session-turn0002';
407
+ try {
408
+ let nowMs = BASE_MS + 60_000;
409
+ const host = fakeHostCtx();
410
+ const beat = createAutoHeartbeat({
411
+ ctx: host.ctx,
412
+ memoryRoot: root,
413
+ heartbeatMs: 0,
414
+ now: () => new Date(nowMs),
415
+ resolve: () => ({ id: sessionId }),
416
+ });
417
+ beat.bind(sessionId);
418
+ beat.start();
419
+ const hook = host.hooks.find((item) => item.name === 'session/event');
420
+
421
+ liveUpdate({
422
+ sessionId,
423
+ state: 'waiting',
424
+ turn: 2,
425
+ goal: '等用户确认',
426
+ }, { memoryRoot: root, now: new Date(nowMs) });
427
+ hook.fn({ id: sessionId }, { type: 'turn/end', data: { turn: 2 } });
428
+ assert.equal(readRow(root, sessionId).state, 'waiting', 'waiting 保留');
429
+ assert.equal(readRow(root, sessionId).turn, 2);
430
+
431
+ liveUpdate({
432
+ sessionId,
433
+ state: 'blocked',
434
+ turn: 3,
435
+ goal: '等用户确认',
436
+ blocked_on: '等待用户确认',
437
+ }, { memoryRoot: root, now: new Date(nowMs) });
438
+ hook.fn({ id: sessionId }, { type: 'turn/end', data: { turn: 3 } });
439
+ const blocked = readRow(root, sessionId);
440
+ assert.equal(blocked.state, 'blocked', 'blocked 保留');
441
+ assert.equal(blocked.turn, 3);
442
+ assert.equal(blocked.blocked_on, '等待用户确认', 'blocked_on 不静默清空');
443
+ beat.stop();
444
+ } finally {
445
+ clean(root);
446
+ }
447
+ });
448
+
449
+ test('R4.3: 心跳 tick 只续 heartbeatAt,不动 state/turn/last_action/目标时间', () => {
450
+ const root = makeRoot('tick-only');
451
+ const sessionId = 'session-tick0001';
452
+ try {
453
+ let nowMs = BASE_MS;
454
+ liveUpdate({
455
+ sessionId,
456
+ state: 'blocked',
457
+ turn: 7,
458
+ goal: '等用户确认',
459
+ blocked_on: '等待用户确认',
460
+ last_action: { kind: 'tool', detail: 'session_live_list' },
461
+ }, { memoryRoot: root, now: new Date(nowMs) });
462
+ const before = readRow(root, sessionId);
463
+
464
+ const host = fakeHostCtx();
465
+ let tickFn = null;
466
+ const beat = createAutoHeartbeat({
467
+ ctx: host.ctx,
468
+ memoryRoot: root,
469
+ heartbeatMs: 15_000,
470
+ now: () => new Date(nowMs),
471
+ setInterval: (fn) => { tickFn = fn; return { unref() {} }; },
472
+ clearInterval: () => {},
473
+ });
474
+ beat.bind(sessionId);
475
+ beat.start();
476
+ assert.equal(beat.isEnabled(), true);
477
+ assert.ok(typeof tickFn === 'function', '启用时应有兜底定时器');
478
+
479
+ nowMs = BASE_MS + 15_000;
480
+ tickFn();
481
+ const after = readRow(root, sessionId);
482
+ assert.equal(after.heartbeatAt, iso(BASE_MS + 15_000), 'tick 续 heartbeat');
483
+ assert.equal(after.state, before.state, 'tick 不动 state');
484
+ assert.equal(after.turn, before.turn, 'tick 不动 turn');
485
+ assert.deepEqual(after.last_action, before.last_action, 'tick 不覆盖 last_action');
486
+ assert.equal(after.goalUpdatedAt, before.goalUpdatedAt, 'tick 不动目标时间');
487
+ beat.stop();
488
+ } finally {
489
+ clean(root);
490
+ }
491
+ });
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // R6.2 — 开机卡年龄
495
+ // ---------------------------------------------------------------------------
496
+
497
+ function writeState(root, ageMinutes) {
498
+ const stateFile = path.join(root, 'STATE.md');
499
+ fs.writeFileSync(stateFile, [
500
+ '# 状态卡',
501
+ '',
502
+ '## 我在哪',
503
+ '正在给活动流写验收脚本。',
504
+ '',
505
+ '## 在等什么',
506
+ '等候选树写完、等基线结果。',
507
+ '',
508
+ '## 最近三件',
509
+ '1. 读 PLAN',
510
+ '2. 写 acceptance.mjs',
511
+ '3. 跑 1.6.7 基线',
512
+ '',
513
+ ].join('\n'), 'utf8');
514
+ fs.mkdirSync(path.join(root, 'handover'), { recursive: true });
515
+ fs.writeFileSync(path.join(root, 'handover', '2026-11-06-演示.md'), '# 交接卡\n\n- 目标:活动流验收\n- 下一步:跑 acceptance.mjs\n', 'utf8');
516
+ const old = new Date(Date.now() - ageMinutes * MIN);
517
+ fs.utimesSync(stateFile, old, old);
518
+ return stateFile;
519
+ }
520
+
521
+ test('R6.2: collectOpenCard 逐字含「状态卡写于 42 分钟前」(来自 STATE.md mtime)', () => {
522
+ const root = makeRoot('opencard');
523
+ try {
524
+ writeState(root, 42);
525
+ const now = new Date();
526
+ const card = collectOpenCard({ memoryRoot: root, now, maxChars: 1200 });
527
+ const text = String(card.text ?? '');
528
+ const match = /状态卡写于\s*(\d+)\s*分钟前/.exec(text);
529
+ assert.ok(match, `开机卡必须含「状态卡写于 X 分钟前」,实际头:"${text.split('\n')[0]}"`);
530
+ const minutes = Number(match[1]);
531
+ assert.ok(minutes >= 41 && minutes <= 43, `X 应接近 42,实际 ${minutes}`);
532
+ assert.equal(card.stateAgeMinutes, minutes, '结构化字段与文案同值');
533
+ } finally {
534
+ clean(root);
535
+ }
536
+ });
537
+
538
+ test('R6.2: buildOpenCard 纯函数路径(stateAgeMs)也输出同一句', () => {
539
+ const now = new Date();
540
+ const card = buildOpenCard({
541
+ stateText: '# 卡\n\n## 我在哪\n- 在写测试\n',
542
+ handover: { name: '2026-11-06-x.md', text: '# X\n\n## 一句话\n测试卡。\n' },
543
+ now,
544
+ stateAgeMs: 42 * MIN,
545
+ maxChars: 1200,
546
+ });
547
+ assert.match(card.text, /状态卡写于 42 分钟前/);
548
+ assert.equal(card.stateAgeMinutes, 42);
549
+ });
550
+
551
+ test('R6.2: 拿不到 STATE.md mtime 时不编造年龄句', () => {
552
+ const now = new Date();
553
+ const plain = buildOpenCard({
554
+ stateText: '# 卡\n\n## 我在哪\n- 在写测试\n',
555
+ handover: null,
556
+ now,
557
+ });
558
+ assert.doesNotMatch(plain.text, /状态卡写于/);
559
+ assert.equal(plain.stateAgeMinutes, null);
560
+
561
+ const root = makeRoot('opencard-missing');
562
+ try {
563
+ const card = collectOpenCard({ memoryRoot: root, now });
564
+ assert.equal(card.stateAgeMinutes, null);
565
+ assert.doesNotMatch(card.text, /状态卡写于/);
566
+ assert.ok(card.warnings.some((code) => code.startsWith('STATE_READ_FAILED')), '缺 STATE.md 要告警');
567
+ } finally {
568
+ clean(root);
569
+ }
570
+ });
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // 兼容:board.json 的 focus 形状只有在 stale 时才多一个字段
574
+ // ---------------------------------------------------------------------------
575
+
576
+ test('board 兼容:新鲜 focus 仍是 6 字段;stale 时持久化 JSON 里多 stale:true', () => {
577
+ const root = makeRoot('board-shape');
578
+ const sessionId = 'session-shape01';
579
+ try {
580
+ liveUpdate({
581
+ sessionId,
582
+ state: 'running',
583
+ goal: '写 P2 测试',
584
+ }, { memoryRoot: root, now: new Date(BASE_MS) });
585
+ const fresh = writeBoard(root, BASE_MS + MIN);
586
+ assert.equal(fresh.written.ok, true);
587
+ const freshFocus = fresh.board.focus[0];
588
+ assert.deepEqual(Object.keys(freshFocus).sort(), [
589
+ 'goal', 'lastAction', 'lastConclusion', 'queuedCount', 'sessionId', 'updatedAt',
590
+ ], '新鲜行不得多出字段');
591
+
592
+ // 心跳保持新鲜(否则行会进入 dead 被移出 focus),目标仍是 31 分钟前。
593
+ liveUpdate({ sessionId, state: 'running' }, { memoryRoot: root, now: new Date(BASE_MS + 31 * MIN) });
594
+ const stale = writeBoard(root, BASE_MS + 31 * MIN);
595
+ assert.equal(stale.board.focus[0].stale, true);
596
+ const persisted = JSON.parse(fs.readFileSync(stale.boardFile, 'utf8'));
597
+ assert.equal(persisted.focus[0].stale, true, 'stale 标记必须落进 board.json 供面板看');
598
+ assert.equal(persisted.focus[0].updatedAt, iso(BASE_MS), '落盘的 updatedAt 仍是目标时间');
599
+ } finally {
600
+ clean(root);
601
+ }
602
+ });
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // R3.4 — stale 的用户可见出口:共享提示
606
+ // ---------------------------------------------------------------------------
607
+
608
+ test('R3.4: 共享提示渲染 stale 与目标写入时间;新鲜目标不出现 stale', () => {
609
+ const root = makeRoot('hint-stale');
610
+ const sessionId = 'session-r34-hint';
611
+ const other = 'session-r34-other';
612
+ try {
613
+ liveUpdate({ sessionId, state: 'running', goal: '31 分钟没动的目标' }, { memoryRoot: root, now: new Date(BASE_MS) });
614
+ // 心跳保持新鲜(否则行会 dead 而离开 focus),目标仍是 31 分钟前。
615
+ liveUpdate({ sessionId, state: 'running' }, { memoryRoot: root, now: new Date(BASE_MS + 31 * MIN) });
616
+ const staleBoard = buildBoard(root, BASE_MS + 31 * MIN);
617
+ const staleFocus = staleBoard.focus.find((item) => item.sessionId === sessionId);
618
+ assert.equal(staleFocus.stale, true, 'board 里必须已经标 stale');
619
+ const staleHint = renderHint(staleBoard, {
620
+ now: new Date(BASE_MS + 31 * MIN), maxTokens: 700, selfSessionId: other,
621
+ });
622
+ assert.match(staleHint.text, /stale/i, `共享提示必须能看到 stale:${staleHint.text}`);
623
+ assert.match(staleHint.text, /31 分钟前写入/, '提示里保留目标写入时间(相对分钟)');
624
+ assert.ok(staleHint.tokens <= 700, '提示仍在 token 预算内');
625
+
626
+ const freshRoot = makeRoot('hint-fresh');
627
+ try {
628
+ liveUpdate({ sessionId, state: 'running', goal: '刚写的目标' }, { memoryRoot: freshRoot, now: new Date(BASE_MS) });
629
+ const freshBoard = buildBoard(freshRoot, BASE_MS + MIN);
630
+ const freshHint = renderHint(freshBoard, { now: new Date(BASE_MS + MIN), maxTokens: 700, selfSessionId: other });
631
+ assert.doesNotMatch(freshHint.text, /stale/i, '新鲜目标不得出现 stale');
632
+ } finally {
633
+ clean(freshRoot);
634
+ }
635
+ } finally {
636
+ clean(root);
637
+ }
638
+ });
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // R4.4 — RouterService 监听真机两参数 (session, event)
642
+ // ---------------------------------------------------------------------------
643
+
644
+ function fakeRouterHost() {
645
+ const listeners = new Map();
646
+ const ctx = {
647
+ logger: () => ({ warn() {}, info() {}, debug() {}, error() {} }),
648
+ on(name, handler) {
649
+ const list = listeners.get(name) ?? [];
650
+ list.push(handler);
651
+ listeners.set(name, list);
652
+ return () => {
653
+ const index = list.indexOf(handler);
654
+ if (index >= 0) list.splice(index, 1);
655
+ };
656
+ },
657
+ get() { return undefined; },
658
+ };
659
+ return {
660
+ ctx,
661
+ fire(name, ...args) {
662
+ for (const handler of [...(listeners.get(name) ?? [])]) handler(...args);
663
+ },
664
+ handlerCount: (name) => (listeners.get(name) ?? []).length,
665
+ };
666
+ }
667
+
668
+ async function startRouterService({ memoryRoot, now }) {
669
+ const host = fakeRouterHost();
670
+ const vaultDir = path.join(memoryRoot, 'vault');
671
+ const service = createRouterService(host.ctx, {
672
+ enabled: true,
673
+ provider: 'mock',
674
+ model: 'mock-1',
675
+ memoryRoot,
676
+ vaultDir,
677
+ tickMs: 60000,
678
+ sharedBoard: true,
679
+ maxTokensPerRun: 50000,
680
+ maxTokensPerDay: 150000,
681
+ callMaxTokens: 256,
682
+ maxAttempts: 1,
683
+ maxConsecutiveErrors: 3,
684
+ disposeTimeoutMs: 500,
685
+ }, {
686
+ llm: new MockLlm(),
687
+ memoryRoot,
688
+ installTimers: false,
689
+ installListener: true,
690
+ now,
691
+ stability: { enabled: false, waitMs: 0, retries: 0 },
692
+ });
693
+ const started = await service.start();
694
+ assert.notEqual(started?.ok, false, `router service failed to start: ${JSON.stringify(started)}`);
695
+ return {
696
+ host,
697
+ service,
698
+ vaultDir,
699
+ async close() {
700
+ try { await service.stop('test'); } catch { /* already stopped */ }
701
+ try { service.dispose(); } catch { /* ignore */ }
702
+ },
703
+ };
704
+ }
705
+
706
+ test('R4.4: RouterService 的 session/event 兼容真机两参数 (session, event)', async () => {
707
+ const root = makeRoot('r44');
708
+ const memoryRoot = path.join(root, 'memory');
709
+ fs.mkdirSync(memoryRoot, { recursive: true });
710
+ const sessionId = 'session-r44-router';
711
+ let nowMs = BASE_MS;
712
+ const env = await startRouterService({ memoryRoot, now: () => new Date(nowMs) });
713
+ try {
714
+ const session = { id: sessionId, header: { id: sessionId, origin: 'main' } };
715
+ env.host.fire('session/created', session);
716
+ nowMs += 1000;
717
+ // 真机契约:两个位置参数 (session, event) —— 1.6.8 之前这里被当成单参数
718
+ // 信封,event 拿到的是 session 对象,turn/end 被整条忽略。
719
+ env.host.fire('session/event', session, {
720
+ type: 'turn/end',
721
+ data: { turn: 3, reason: { kind: 'completed' } },
722
+ });
723
+ let row = readRow(memoryRoot, sessionId);
724
+ assert.equal(row.turn, 3, 'two-arg turn/end 必须被接收并落 turn 号');
725
+ assert.equal(row.state, 'idle');
726
+ await env.service.tick({ tier: 'reflect', reason: 'test' });
727
+ const status = JSON.parse(fs.readFileSync(path.join(env.vaultDir, 'status.json'), 'utf8'));
728
+ assert.equal(status.live.turnEndEventVia, 'turn/end', 'status 必须记录 turn/end 这条通道');
729
+
730
+ // 旧的一参数信封(repo 既有测试形状)仍要兼容。
731
+ env.host.fire('session/event', {
732
+ type: 'turn/end',
733
+ session: { sessionId },
734
+ turn: 4,
735
+ data: { reason: { kind: 'completed' } },
736
+ });
737
+ row = readRow(memoryRoot, sessionId);
738
+ assert.equal(row.turn, 4, 'legacy one-arg turn/end 仍兼容');
739
+ } finally {
740
+ await env.close();
741
+ clean(root);
742
+ }
743
+ });
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // R4.5 — 未绑定会话(只跑原生工具)的 turn 边界也维护 live 行
747
+ // ---------------------------------------------------------------------------
748
+
749
+ test('R4.5: 未绑定会话的 turn 边界维护 live 行:首见建行、遵守 128 行上限、不绑定', () => {
750
+ const root = makeRoot('r45');
751
+ const native = 'session-r45-native';
752
+ const firstSeen = 'session-r45-first';
753
+ const capped = 'session-r45-capped';
754
+ const subagent = 'session-r45-sub';
755
+ try {
756
+ let nowMs = BASE_MS;
757
+ const hooks = [];
758
+ const host = {
759
+ listeners: hooks,
760
+ ctx: { on(name, fn) { hooks.push({ name, fn }); return () => {}; } },
761
+ emitSession(session, event) {
762
+ for (const hook of [...hooks]) if (hook.name === 'session/event') hook.fn(session, event);
763
+ },
764
+ };
765
+ // maxRows=2:A、B 两行之后,第三条(C)不得再创建。
766
+ const hb = createAutoHeartbeat({
767
+ ctx: host.ctx,
768
+ memoryRoot: root,
769
+ heartbeatMs: 0,
770
+ maxRows: 2,
771
+ now: () => new Date(nowMs),
772
+ });
773
+ hb.start();
774
+ try {
775
+ // A:行已存在(Router session/created 写的),但从未调用 longlive 工具 → 未绑定。
776
+ liveUpdate({ sessionId: native, state: 'idle' }, { memoryRoot: root, now: new Date(nowMs) });
777
+ host.emitSession({ id: native, header: { id: native, origin: 'main' } }, { type: 'turn/start', data: { turn: 1 } });
778
+ let row = readRow(root, native);
779
+ assert.equal(row.state, 'running', '未绑定会话 turn/start 必须切 running');
780
+ assert.equal(row.turn, 1);
781
+ assert.deepEqual(hb.boundIds(), [], '维护事件会话不等于绑定(tick 不刷未绑定行)');
782
+
783
+ nowMs += 1000;
784
+ host.emitSession({ id: native, header: { id: native, origin: 'main' } }, { type: 'turn/end', data: { turn: 1 } });
785
+ row = readRow(root, native);
786
+ assert.equal(row.state, 'idle');
787
+ assert.equal(row.turn, 1);
788
+
789
+ // B:行不存在 → 首个 turn/start 先建行。
790
+ nowMs += 1000;
791
+ host.emitSession({ id: firstSeen, header: { id: firstSeen, origin: 'main' } }, { type: 'turn/start', data: { turn: 2 } });
792
+ assert.equal(readRow(root, firstSeen).state, 'running', '首见会话必须在 turn 边界建行');
793
+
794
+ // C:已到 maxRows=2 → 不得创建第 3 行。
795
+ nowMs += 1000;
796
+ host.emitSession({ id: capped, header: { id: capped, origin: 'main' } }, { type: 'turn/start', data: { turn: 1 } });
797
+ assert.equal(fs.existsSync(rowPath(root, capped)), false, '到达行上限时不得创建新行');
798
+
799
+ // subagent:不计入 live 注册表。
800
+ host.emitSession({ id: subagent, header: { id: subagent, origin: 'subagent' } }, { type: 'turn/start', data: { turn: 1 } });
801
+ assert.equal(fs.existsSync(rowPath(root, subagent)), false, 'subagent 不建 live 行');
802
+ } finally {
803
+ hb.stop();
804
+ }
805
+ } finally {
806
+ clean(root);
807
+ }
808
+ });
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // R4.6 / R4.7 — pulse 口径统一(subagent 单独计数;blocked 同口径同源)
812
+ // ---------------------------------------------------------------------------
813
+
814
+ test('R4.6: subagent live 行不进 pulse.running/active/focus,只在 counts.subagents 计数', () => {
815
+ const root = makeRoot('r46');
816
+ const main = 'session-r46-main';
817
+ const sub = 'abc12345-0000-4000-8000-000000000000';
818
+ try {
819
+ liveUpdate({ sessionId: main, state: 'running', goal: '主会话' }, { memoryRoot: root, now: new Date(BASE_MS) });
820
+ liveUpdate({ sessionId: sub, state: 'running' }, { memoryRoot: root, now: new Date(BASE_MS) });
821
+ const board = buildBoard(root, BASE_MS, {
822
+ metadata: [
823
+ { sessionId: main, origin: 'main' },
824
+ { sessionId: sub, origin: 'subagent' },
825
+ ],
826
+ });
827
+ assert.equal(board.pulse.running, 1, 'subagent 行不得抬高 pulse.running');
828
+ assert.equal(board.pulse.active, 1, 'subagent 行不得抬高 pulse.active');
829
+ assert.equal(board.pulse.running, board.pulse.active, 'running 与 active 必须同口径');
830
+ assert.deepEqual(board.focus.map((item) => item.sessionId), [main], 'subagent 不进 focus');
831
+ assert.equal(board.counts.subagents, 1, 'subagent 单独计数');
832
+ } finally {
833
+ clean(root);
834
+ }
835
+ });
836
+
837
+ test('R4.6 边界:metadata 不可用时识别不出 origin 的行 fail-safe 进 unknown,不抬高 running/active', () => {
838
+ const root = makeRoot('r46-nometa');
839
+ const main = 'session-r46-nometa-main';
840
+ const sub = 'abc12345-0000-4000-8000-000000000000';
841
+ try {
842
+ liveUpdate({ sessionId: main, state: 'running', goal: '主会话' }, { memoryRoot: root, now: new Date(BASE_MS) });
843
+ liveUpdate({ sessionId: sub, state: 'running' }, { memoryRoot: root, now: new Date(BASE_MS) });
844
+ // host ctx.get('sessions') 不可用 → RouterService 传 metadata=null
845
+ const board = buildBoard(root, BASE_MS, { metadata: null });
846
+ assert.equal(board.pulse.running, 1, 'metadata 缺失时 bare-UUID 行不得重蹈 running 虚高');
847
+ assert.equal(board.pulse.active, 1, 'metadata 缺失时 active 不得虚高');
848
+ assert.equal(board.pulse.running, board.pulse.active);
849
+ assert.deepEqual(board.focus.map((item) => item.sessionId), [main], '识别不出的行不得进 focus');
850
+ assert.equal(board.pulse.unknownSessions, 1, '识别不出 origin 的行归 unknownSessions(不冒充 running)');
851
+ assert.equal(board.counts.subagents, 0, '无 metadata 时无法确证 subagent,但不影响不计数');
852
+
853
+ // fail-safe 不能把普通主会话也藏掉:非 UUID 形状的 id 仍按 main 处理。
854
+ const other = 'sess-r46-legacy';
855
+ liveUpdate({ sessionId: other, state: 'idle' }, { memoryRoot: root, now: new Date(BASE_MS) });
856
+ const board2 = buildBoard(root, BASE_MS, { metadata: null });
857
+ assert.equal(board2.pulse.active, 2, 'sess-* 形状的行仍算主会话(只 fail-safe 裸 UUID)');
858
+ assert.equal(board2.pulse.running, 1, 'idle 行不抬高 running');
859
+ } finally {
860
+ clean(root);
861
+ }
862
+ });
863
+
864
+ test('R4.7: 心跳新鲜的活 blocked 行计入(1),死行自报 blocked 不计,running 口径不回归', () => {
865
+ const root = makeRoot('r47-dead');
866
+ const deadAt = new Date(BASE_MS - 10 * MIN).toISOString();
867
+ try {
868
+ // 30 条 10 分钟前的心跳行,自报 blocked 但 effectiveState=dead。
869
+ for (let index = 0; index < 30; index += 1) {
870
+ writeRow(root, `session-r47-dead${String(index).padStart(4, '0')}`, {
871
+ schemaVersion: 1,
872
+ sessionId: `session-r47-dead${String(index).padStart(4, '0')}`,
873
+ pid: 999999,
874
+ host: os.hostname(),
875
+ startedAt: deadAt,
876
+ updatedAt: deadAt,
877
+ heartbeatAt: deadAt,
878
+ ttlMs: 60000,
879
+ state: 'blocked',
880
+ turn: 1,
881
+ goal: '',
882
+ goalUpdatedAt: '',
883
+ blocked_on: '等用户',
884
+ queued: [],
885
+ last_action: null,
886
+ });
887
+ }
888
+ // 活的 blocked 行(心跳新鲜 → effectiveState=running)必须计入 blocked=1。
889
+ liveUpdate({ sessionId: 'session-r47-liveblock', state: 'blocked', blocked_on: '等确认' }, { memoryRoot: root, now: new Date(BASE_MS) });
890
+ liveUpdate({ sessionId: 'session-r47-fresh', state: 'running' }, { memoryRoot: root, now: new Date(BASE_MS) });
891
+ const board = buildBoard(root, BASE_MS);
892
+ assert.equal(board.pulse.blocked, 1, '活 blocked 行计入 1;30 条死行自报 blocked 不计');
893
+ assert.equal(board.pulse.running, 1, 'blocked 行不得同时算 running(口径不回归)');
894
+ assert.equal(board.counts.blocked, board.pulse.blocked, 'counts 与 pulse 同源');
895
+ assert.equal(board.pulse.active, 2, '2 条有效行(blocked + running)');
896
+ } finally {
897
+ clean(root);
898
+ }
899
+ });
900
+
901
+ test('R4.7 口径窗口:blocked 行在 (ttl,2ttl] 仍按声明状态计入,>2ttl 变 stale 后不计', () => {
902
+ const root = makeRoot('r47-ttl');
903
+ try {
904
+ liveUpdate({ sessionId: 'session-r47-ttl', state: 'blocked', blocked_on: '等确认' }, { memoryRoot: root, now: new Date(BASE_MS) });
905
+ const inWindow = buildBoard(root, BASE_MS + 90 * 1000); // 1.5 × ttl
906
+ assert.equal(inWindow.pulse.blocked, 1, '声明态窗口内 blocked 计 1');
907
+ const afterStale = buildBoard(root, BASE_MS + 130 * 1000); // > 2 × ttl
908
+ assert.equal(afterStale.pulse.blocked, 0, 'stale 后不再计入');
909
+ } finally {
910
+ clean(root);
911
+ }
912
+ });
913
+
914
+ test('R4.7: board.json 与 status.json 的 pulse.blocked 同源同值(Router 自身状态不覆盖)', async () => {
915
+ const root = makeRoot('r47-proj');
916
+ const memoryRoot = path.join(root, 'memory');
917
+ fs.mkdirSync(memoryRoot, { recursive: true });
918
+ const nowMs = BASE_MS;
919
+ try {
920
+ liveUpdate({ sessionId: 'session-r47-blk1', state: 'blocked', blocked_on: '等确认' }, { memoryRoot, now: new Date(nowMs) });
921
+ liveUpdate({ sessionId: 'session-r47-blk2', state: 'blocked', blocked_on: '等确认' }, { memoryRoot, now: new Date(nowMs) });
922
+ const env = await startRouterService({ memoryRoot, now: () => new Date(nowMs) });
923
+ try {
924
+ await env.service.tick({ tier: 'full', reason: 'test' });
925
+ const board = JSON.parse(fs.readFileSync(path.join(env.vaultDir, 'board.json'), 'utf8'));
926
+ const status = JSON.parse(fs.readFileSync(path.join(env.vaultDir, 'status.json'), 'utf8'));
927
+ assert.equal(board.pulse.blocked, 2, '2 条活 blocked 行都要计入(不是恒 0)');
928
+ assert.equal(status.board.pulse.blocked, board.pulse.blocked,
929
+ `status.blocked=${status.board.pulse.blocked} 必须等于 board.blocked=${board.pulse.blocked}`);
930
+ assert.equal(status.board.pulse.running, board.pulse.running, 'running 也必须同源');
931
+ } finally {
932
+ await env.close();
933
+ }
934
+ } finally {
935
+ clean(root);
936
+ }
937
+ });