@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.
- package/CHANGELOG.md +40 -8
- package/QUICKSTART.md +10 -10
- package/README.en.md +4 -3
- package/README.md +10 -4
- package/SPEC.md +2 -2
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CONFIG.md +33 -6
- package/docs/DEV.md +3 -3
- package/docs/FAQ.md +4 -4
- package/docs/FEATURES.md +36 -5
- package/docs/OPERATIONS.md +1 -1
- package/docs/QUICKSTART.md +3 -3
- package/docs/README.en.md +3 -2
- package/docs/TROUBLESHOOTING.md +3 -3
- package/package.json +13 -8
- package/src/activity/index.js +649 -0
- package/src/activity/model.js +341 -0
- package/src/activity/store.js +625 -0
- package/src/config.js +29 -2
- package/src/features.js +26 -0
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/live.js +22 -2
- package/src/recall/privacy.mjs +105 -16
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/service.mjs +27 -11
- package/test/activity-realtime.test.mjs +959 -0
- package/test/activity-stream.test.mjs +512 -0
- package/test/autobind.test.mjs +25 -11
- package/test/config-schema.test.mjs +6 -0
- package/test/feature-flags.test.mjs +17 -0
- package/test/fixtures/activity-append-child.mjs +29 -0
- package/test/live.test.mjs +6 -3
- package/test/packaging.test.mjs +7 -5
- package/test/router-background/router.test.mjs +44 -12
- package/test/run-rc.mjs +16 -0
- package/test/safety-defaults.test.mjs +5 -3
- package/test/state-truth.test.mjs +937 -0
- 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 }));
|
package/test/live.test.mjs
CHANGED
|
@@ -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', '写读往返:
|
|
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
|
|
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');
|
package/test/packaging.test.mjs
CHANGED
|
@@ -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(
|
|
144
|
-
assert.equal(PKG.version,
|
|
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.
|
|
148
|
-
assert.ok(en.includes(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
912
|
-
//
|
|
913
|
-
//
|
|
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 <=
|
|
916
|
-
`stop 必须在
|
|
932
|
+
stopElapsed <= CALL_TIMEOUT_MS,
|
|
933
|
+
`stop 必须在 callTimeout 预算(${CALL_TIMEOUT_MS}ms)内返回,实测 ${stopElapsed}ms`,
|
|
917
934
|
);
|
|
918
|
-
assert.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
981
|
+
const stopElapsed = Date.now() - started;
|
|
982
|
+
const tickRes = await withHangGuard(tickPromise, 10000, 'P08 tick');
|
|
957
983
|
|
|
958
|
-
assert.
|
|
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:
|
|
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)
|
|
151
|
-
|
|
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);
|