@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,29 @@
1
+ // Child process used by test/activity-stream.test.mjs to prove that two
2
+ // processes appending to the same board produce unique, contiguous seq values.
3
+ // Usage: node activity-append-child.mjs <memoryRoot> <sessionId> <count> <tag>
4
+
5
+ import crypto from 'node:crypto';
6
+
7
+ import { createActivityStore } from '../../src/activity/store.js';
8
+
9
+ const [memoryRoot, sessionId, countText, tag] = process.argv.slice(2);
10
+ const count = Number(countText);
11
+ if (!memoryRoot || !sessionId || !Number.isInteger(count) || count < 1) {
12
+ console.error(JSON.stringify({ ok: false, error: 'bad-args' }));
13
+ process.exit(2);
14
+ }
15
+ const store = createActivityStore({ memoryRoot });
16
+ for (let index = 0; index < count; index += 1) {
17
+ const result = store.append({
18
+ sessionId,
19
+ kind: 'doing',
20
+ // Deliberately dissimilar texts: this test is about append/lock, not dedupe.
21
+ text: `${tag} 独立任务 ${index} ${crypto.randomUUID()}`,
22
+ key: `${tag}|task-${index}`,
23
+ });
24
+ if (!result.ok) {
25
+ console.error(JSON.stringify({ ok: false, index, code: result.code }));
26
+ process.exit(3);
27
+ }
28
+ }
29
+ console.log(JSON.stringify({ ok: true, count }));
@@ -198,7 +198,7 @@ export async function runLiveSuite({ runCase, runAsyncCase, runSymlinkCase, asse
198
198
  });
199
199
 
200
200
  // L1 — round-trip, exact field set, patch semantics, goal truncation.
201
- runCase('L1', '写读往返:14 字段齐全,patch 保留旧值,goal 截断并标注', () => {
201
+ runCase('L1', '写读往返:15 字段齐全,patch 保留旧值,goal 截断并标注', () => {
202
202
  const root = newRoot(TMP, 'l1');
203
203
  const sessionId = 's-l1';
204
204
  const longGoal = '一二三四五六七八九十'.repeat(9); // 90 code points
@@ -231,17 +231,19 @@ export async function runLiveSuite({ runCase, runAsyncCase, runSymlinkCase, asse
231
231
  // that drops a field pass, because the expectation would move with it.
232
232
  const DOCUMENTED_FIELDS = [
233
233
  'schemaVersion', 'sessionId', 'pid', 'host', 'startedAt', 'updatedAt', 'heartbeatAt',
234
- 'ttlMs', 'state', 'turn', 'goal', 'blocked_on', 'queued', 'last_action',
234
+ 'ttlMs', 'state', 'turn', 'goal', 'goalUpdatedAt', 'blocked_on', 'queued', 'last_action',
235
235
  ];
236
236
  assert(
237
237
  JSON.stringify(Object.keys(stored)) === JSON.stringify(DOCUMENTED_FIELDS),
238
- `stored row must be exactly the 14 documented fields in order, got ${JSON.stringify(Object.keys(stored))}`,
238
+ `stored row must be exactly the 15 documented fields in order, got ${JSON.stringify(Object.keys(stored))}`,
239
239
  );
240
240
  assert(stored.schemaVersion === LIVE_SCHEMA_VERSION, 'schemaVersion');
241
241
  assert(stored.pid === process.pid, 'pid must be the writing process');
242
242
  assert(stored.host === HOST, 'host must be this machine');
243
243
  assert(stored.startedAt === iso(NOW), 'startedAt = now');
244
244
  assert(stored.updatedAt === iso(NOW) && stored.heartbeatAt === iso(NOW), 'updatedAt/heartbeatAt = now');
245
+ // R3: the goal has its own clock; a heartbeat touch must never move it.
246
+ assert(stored.goalUpdatedAt === iso(NOW), 'goalUpdatedAt = the goal write time');
245
247
  assert(stored.state === 'blocked', 'state');
246
248
  assert(stored.turn === 7, 'turn');
247
249
  assert(stored.blocked_on === 'waiting for review', 'blocked_on while blocked');
@@ -262,6 +264,7 @@ export async function runLiveSuite({ runCase, runAsyncCase, runSymlinkCase, asse
262
264
  const patched = second.record;
263
265
  assert(patched.turn === 7, 'turn preserved by patch');
264
266
  assert(patched.goal === stored.goal, 'goal preserved by patch');
267
+ assert(patched.goalUpdatedAt === iso(NOW), 'goalUpdatedAt preserved by a goal-less patch');
265
268
  assert(JSON.stringify(patched.queued) === JSON.stringify(['a', 'b', 'c']), 'queued preserved');
266
269
  assert(patched.blocked_on === '', 'blocked_on emptied');
267
270
  assert(patched.startedAt === iso(NOW), 'startedAt preserved across updates');
@@ -11,6 +11,7 @@ import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { VERSION } from '../src/version.js';
14
15
 
15
16
  const HERE = path.dirname(fileURLToPath(import.meta.url));
16
17
  const ROOT = path.resolve(HERE, '..');
@@ -140,17 +141,18 @@ test('只带白名单安装后:没有 prototype,所有 src 相对 import 都
140
141
  assert.deepEqual(offenders, [], `存在跨出包目录/缺失的 import:\n${offenders.join('\n')}`);
141
142
  });
142
143
 
143
- test('R2-07 发布版本面:包版本 1.6.5,文档同步 D2/D6 能力集', () => {
144
- assert.equal(PKG.version, '1.6.5');
144
+ test(`R2-07 发布版本面:包版本 ${VERSION} + 活动流,文档同步 D2/D6 能力集`, () => {
145
+ assert.equal(PKG.version, VERSION);
145
146
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
146
147
  const en = read('docs/README.en.md');
147
- assert.ok(en.includes('@jjh32456/dsh-longlive-panel@0.2.5'), '面板版本未统一到 0.2.5(scoped 包名)');
148
- assert.ok(en.includes('jjh32456-dsh-longlive-1.6.5.tgz'), '英文文档示例 tgz 不是 scoped 的 1.6.5');
148
+ assert.ok(en.includes('@jjh32456/dsh-longlive-panel@0.2.6'), '面板版本未统一到 0.2.6(scoped 包名)');
149
+ assert.ok(en.includes(`jjh32456-dsh-longlive-${VERSION}.tgz`), `英文文档示例 tgz 不是 scoped 的 ${VERSION}`);
149
150
  assert.ok(en.includes('@jjh32456/dsh-longlive'), '英文文档缺少 scoped 包名');
150
151
  assert.equal(en.includes('dsh-longlive-panel@0.1.3'), false, '英文文档仍残留 0.1.3');
151
152
  assert.equal(en.includes('0.9.0-rc.1'), false, '英文文档仍残留 rc.1');
152
153
  const features = read('docs/FEATURES.md');
153
- assert.ok(features.startsWith('# 1.6.5'), 'FEATURES.md 标题不是 1.6.5');
154
+ assert.ok(features.startsWith(`# ${VERSION}`), `FEATURES.md 标题不是 # ${VERSION}`);
155
+ assert.ok(features.includes('sharedBrain.activity'), 'FEATURES.md 未记录活动流开关');
154
156
  assert.equal(features.includes('0.9.0-rc.1'), false, 'FEATURES.md 仍残留 rc.1');
155
157
  });
156
158
 
@@ -46,6 +46,20 @@ function makeCase({ tasks = [], queueText = null, memory = null, llm = null, rou
46
46
 
47
47
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
48
48
 
49
+ // 「挂住」防线,不依赖墙钟精度:只把无限等待变成显式失败,阈值远高于负载抖动
50
+ // (本套件单测观测到的最高耗时约 2.2s,这里取 10s)。正常路径会清掉定时器。
51
+ async function withHangGuard(promise, ms, label) {
52
+ let timer = null;
53
+ const guard = new Promise((_, reject) => {
54
+ timer = setTimeout(() => reject(new Error(`${label} 超过 ${ms}ms 仍未收敛(疑似 stop/tick 挂住回归)`)), ms);
55
+ });
56
+ try {
57
+ return await Promise.race([promise, guard]);
58
+ } finally {
59
+ if (timer) clearTimeout(timer);
60
+ }
61
+ }
62
+
49
63
  after(() => {
50
64
  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ }
51
65
  });
@@ -890,11 +904,12 @@ test('P10/P08:iterator.return() 永不 resolve 时 tick 仍能收敛,busy/
890
904
  }
891
905
  },
892
906
  };
907
+ const CALL_TIMEOUT_MS = 2000;
893
908
  const r = c.router({
894
909
  llm,
895
910
  maxTokens: 200,
896
911
  callMaxTokens: 50,
897
- callTimeoutMs: 5000,
912
+ callTimeoutMs: CALL_TIMEOUT_MS,
898
913
  stopTimeoutMs: 500,
899
914
  retry: { maxAttempts: 1 },
900
915
  });
@@ -905,22 +920,28 @@ test('P10/P08:iterator.return() 永不 resolve 时 tick 仍能收敛,busy/
905
920
  const started = Date.now();
906
921
  const stopRes = await r.stop();
907
922
  const stopElapsed = Date.now() - started;
908
- const tickRes = await tickPromise;
923
+ const tickRes = await withHangGuard(tickPromise, 10000, 'P10 tick');
909
924
  const settleElapsed = Date.now() - started;
910
925
 
911
- assert.equal(stopRes.timedOut, false, 'stop 不能因为 return() 挂住');
912
- // 只量 stop() 本身;旧写法把 tick 收敛也算了进去,CI 抖动时会偶发 551ms。
913
- // 断言保留,放宽一个明确的调度余量(stopTimeoutMs + 150ms)。
926
+ // 真回归防线:旧实现 await iterator.return() 无限期,stop/tick 会一直挂住。
927
+ // 这里只要求 stop 在单次调用预算(callTimeout,2s 级)内返回,并对 tick 收敛
928
+ // 给同样量级的上界;**不再**贴着 stopTimeoutMs(500ms)断言——负载下定时器
929
+ // 回调被调度挤压时 stop 合法地走 timedOut 分支(实测偶发 ~1.3s),那不是回归。
930
+ // 若 stop 真的挂住(回到 await return()),elapsed 会远超 CALL_TIMEOUT_MS。
914
931
  assert.ok(
915
- stopElapsed <= 500 + 150,
916
- `stop 必须在 stopTimeoutMs(500ms)附近收敛,允许 150ms 调度抖动(实测 ${stopElapsed}ms)`,
932
+ stopElapsed <= CALL_TIMEOUT_MS,
933
+ `stop 必须在 callTimeout 预算(${CALL_TIMEOUT_MS}ms)内返回,实测 ${stopElapsed}ms`,
917
934
  );
918
- assert.ok(settleElapsed < 2000, `tick 必须在 stop 后很快收敛(实测 ${settleElapsed}ms)`);
935
+ assert.equal(stopRes.state, 'stopped', 'stop 返回后状态必须停在 stopped');
936
+ assert.equal(r.state, 'stopped');
937
+ assert.ok(settleElapsed <= CALL_TIMEOUT_MS + 1000, `tick 必须在 stop 后收敛,实测 ${settleElapsed}ms`);
919
938
  assert.equal(tickRes.ok, true);
920
939
  assert.equal(r.busy, false, 'busy 必须释放');
921
940
  assert.equal(r.reservedTokens, 0);
922
941
  assert.equal(r.ledger.snapshot().reserved, 0, '预留必须释放,不能泄漏 50');
923
- assert.equal(returned, false, '生成器 finally 可能来不及跑,但 tick 不能等它');
942
+ // `returned` 只作诊断:生成器 finally 可能来得及跑、也可能被丢下;关键是
943
+ // stop/tick 不许等它——上面的时间上界就是这条防线,故不在此断言它的值。
944
+ void returned;
924
945
  await r.dispose();
925
946
  });
926
947
 
@@ -942,22 +963,33 @@ test('P08:正常 abort 时消费端走到 drain 并关闭迭代器(finally
942
963
  }
943
964
  },
944
965
  };
966
+ const CALL_TIMEOUT_MS = 2000;
945
967
  const r = c.router({
946
968
  llm,
947
969
  maxTokens: 200,
948
970
  callMaxTokens: 50,
949
- stopTimeoutMs: 500,
971
+ callTimeoutMs: CALL_TIMEOUT_MS,
972
+ // 2s 预算:正常 abort 的 drain 是确定性的,不该由 500ms 定时器抖动决定成败。
973
+ stopTimeoutMs: 2000,
950
974
  retry: { maxAttempts: 1 },
951
975
  });
952
976
  r.start();
953
977
  const tickPromise = r.tick();
954
978
  await sleep(40);
979
+ const started = Date.now();
955
980
  const stopRes = await r.stop();
956
- await tickPromise;
981
+ const stopElapsed = Date.now() - started;
982
+ const tickRes = await withHangGuard(tickPromise, 10000, 'P08 tick');
957
983
 
958
- assert.equal(stopRes.timedOut, false);
984
+ assert.ok(
985
+ stopElapsed <= CALL_TIMEOUT_MS,
986
+ `stop 必须在 callTimeout 预算(${CALL_TIMEOUT_MS}ms)内返回,实测 ${stopElapsed}ms`,
987
+ );
988
+ assert.equal(stopRes.state, 'stopped');
989
+ assert.equal(tickRes.ok, true, 'tick 必须收敛(不能被 abort 拖挂)');
959
990
  assert.equal(returned, true, 'abort 之后必须真正关闭生成器');
960
991
  assert.ok(pulled >= 1);
992
+ assert.equal(r.busy, false);
961
993
  assert.equal(r.ledger.snapshot().reserved, 0);
962
994
  await r.dispose();
963
995
  });
package/test/run-rc.mjs CHANGED
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { VERSION } from '../src/version.js';
17
18
 
18
19
  const HERE = path.dirname(fileURLToPath(import.meta.url));
19
20
  const ROOT = path.resolve(HERE, '..');
@@ -149,6 +150,21 @@ const suites = [
149
150
  args: ['--test', 'test/feature-flags.test.mjs'],
150
151
  parse: parseNodeTest,
151
152
  },
153
+ {
154
+ label: `${VERSION} shared-brain activity stream (node --test)`,
155
+ args: ['--test', 'test/activity-stream.test.mjs', 'test/activity-realtime.test.mjs'],
156
+ parse: parseNodeTest,
157
+ },
158
+ {
159
+ label: 'P2 state truth: focus/running/status/turn-state/open-card (node --test)',
160
+ args: ['--test', 'test/state-truth.test.mjs'],
161
+ parse: parseNodeTest,
162
+ },
163
+ {
164
+ label: `${VERSION} version consistency (node --test)`,
165
+ args: ['--test', 'test/version-consistency.test.mjs'],
166
+ parse: parseNodeTest,
167
+ },
152
168
  {
153
169
  label: 'host Config schema (node --test)',
154
170
  args: ['--test', 'test/config-schema.test.mjs'],
@@ -137,7 +137,7 @@ test('settings client: slot key is @jjh32456/dsh-longlive#session-longlive (row
137
137
  'client 仍残留未 scoped 的模块 id');
138
138
  });
139
139
 
140
- test('Config schema: 85 nodes (1.6.4 adds hotTruncate), safety defaults visible, descriptions in zh+en', { skip: !CONFIG_SCHEMA_AVAILABLE }, () => {
140
+ test('Config schema: 90 nodes (1.6.4 hotTruncate + sharedBrain.activity), safety defaults visible, descriptions in zh+en', { skip: !CONFIG_SCHEMA_AVAILABLE }, () => {
141
141
  function countNodes(schema) {
142
142
  const dict = schema?.dict;
143
143
  if (!dict || typeof dict !== 'object') return 1;
@@ -147,12 +147,14 @@ test('Config schema: 85 nodes (1.6.4 adds hotTruncate), safety defaults visible,
147
147
  }
148
148
  // The schema object itself is the root, not a field: subtract it so the count
149
149
  // matches the configured nodes. 1.6.0 had 80; 1.6.4 adds the hotTruncate
150
- // block (1 parent + 4 leaves), so the budget is 85.
151
- assert.equal(countNodes(Config) - 1, 85, 'Config schema 节点数必须保持 85');
150
+ // block (1 parent + 4 leaves) = 85; the rebased activity stream adds
151
+ // sharedBrain.activity (1 parent + 1 activity + 3 leaves) = 90.
152
+ assert.equal(countNodes(Config) - 1, 90, 'Config schema 节点数必须保持 90');
152
153
 
153
154
  const parsed = Config({});
154
155
  assert.equal(parsed.autoTruncate.enabled, false);
155
156
  assert.equal(parsed.autoTruncate.mode, 'dryrun');
157
+ assert.equal(parsed.sharedBrain.activity.enabled, false, 'activity stream is off by default');
156
158
  assert.match(Config.dict.autoTruncate.dict.enabled.meta.description.zh, /默认关闭/);
157
159
  assert.match(Config.dict.routerRuntime.meta.description.en, /default off/);
158
160
  assert.equal(Config.dict.memoryRoot.meta.disabled, true);