@jjh32456/dsh-longlive 1.6.8 → 1.8.0

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 (288) hide show
  1. package/CHANGELOG.md +69 -1539
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +33 -12
  4. package/README.en.md +17 -7
  5. package/README.md +29 -10
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +17 -12
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +143 -37
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +66 -29
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +3 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +42 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +129 -18
  41. package/src/activity/model.js +11 -1
  42. package/src/activity/store.js +22 -3
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +145 -48
  58. package/src/deps.js +6 -4
  59. package/src/features.js +158 -8
  60. package/src/identity/index.js +13 -5
  61. package/src/identity/inject-position.js +2 -2
  62. package/src/index.js +97 -7
  63. package/src/lifecycle/gap-clocks.js +328 -0
  64. package/src/lifecycle/index.js +269 -0
  65. package/src/memory/activity-capture.js +229 -0
  66. package/src/memory/anchors.js +194 -0
  67. package/src/memory/audit-records.js +222 -0
  68. package/src/memory/auto-cards.js +401 -0
  69. package/src/memory/capture.js +442 -0
  70. package/src/memory/card-anchors.js +203 -0
  71. package/src/memory/conflicts.js +106 -0
  72. package/src/memory/decay.js +311 -0
  73. package/src/memory/distill.js +471 -0
  74. package/src/memory/event-index.js +917 -0
  75. package/src/memory/evidence-chain.js +295 -0
  76. package/src/memory/evidence.js +145 -0
  77. package/src/memory/filter-runner.js +1 -1
  78. package/src/memory/filter.js +5 -1
  79. package/src/memory/forget-v2.js +410 -0
  80. package/src/memory/forget.js +71 -0
  81. package/src/memory/frames-activation.js +247 -0
  82. package/src/memory/fts-query.js +174 -0
  83. package/src/memory/governance.js +604 -0
  84. package/src/memory/index.js +1 -0
  85. package/src/memory/l2-gate.js +169 -0
  86. package/src/memory/lock.js +121 -0
  87. package/src/memory/migrate.js +139 -0
  88. package/src/memory/recall-v2.js +223 -0
  89. package/src/memory/recall.js +65 -2
  90. package/src/memory/records.js +425 -0
  91. package/src/memory/retention.js +128 -0
  92. package/src/memory/review.js +124 -0
  93. package/src/memory/trust.js +88 -0
  94. package/src/memory/version-chain.js +264 -0
  95. package/src/memory/wal.js +137 -0
  96. package/src/memory/working-frame.js +516 -0
  97. package/src/metrics/classify.mjs +5 -3
  98. package/src/metrics/index.mjs +21 -1
  99. package/src/metrics/prestep-latency.mjs +300 -0
  100. package/src/metrics/store.mjs +25 -0
  101. package/src/policy-profile.js +18 -3
  102. package/src/recall/anaphora.mjs +87 -0
  103. package/src/recall/cards.mjs +7 -0
  104. package/src/recall/embedding.mjs +8 -8
  105. package/src/recall/entities.mjs +112 -0
  106. package/src/recall/index.js +235 -19
  107. package/src/recall/inject-order.mjs +10 -8
  108. package/src/recall/privacy.mjs +1 -1
  109. package/src/recall/recall-core.mjs +116 -10
  110. package/src/recall/shared-visibility.js +302 -0
  111. package/src/recall/time-normalizer.mjs +196 -0
  112. package/src/role/activation.js +191 -0
  113. package/src/role/attention.js +318 -0
  114. package/src/role/base.js +36 -0
  115. package/src/role/channels.js +423 -0
  116. package/src/role/index.js +425 -0
  117. package/src/role/interfaces.js +115 -0
  118. package/src/role/personality.js +370 -0
  119. package/src/role/readers.js +151 -0
  120. package/src/role/relationships.js +477 -0
  121. package/src/role/store.js +202 -0
  122. package/src/role/writeprotect.js +373 -0
  123. package/src/router/aliases.json +176 -25
  124. package/src/router/background/agent-runner.mjs +1 -1
  125. package/src/router/background/anchor.mjs +1 -1
  126. package/src/router/background/audit.mjs +2 -2
  127. package/src/router/background/budget-ledger.mjs +2 -2
  128. package/src/router/background/host-sim.mjs +1 -1
  129. package/src/router/background/memory.mjs +4 -4
  130. package/src/router/background/queue.mjs +1 -1
  131. package/src/router/background/retry.mjs +1 -1
  132. package/src/router/background/router.mjs +1 -1
  133. package/src/router/background/stream.mjs +1 -1
  134. package/src/router/background/tool-policy.mjs +1 -1
  135. package/src/router/design-headings.json +5 -5
  136. package/src/router/face/config.mjs +1 -1
  137. package/src/router/face/identity.mjs +3 -3
  138. package/src/router/judge.mjs +8 -7
  139. package/src/router/policies/generic.json +21 -4
  140. package/src/router/route.js +109 -22
  141. package/src/router/router.mjs +10 -6
  142. package/src/router/routing-rules.json +260 -445
  143. package/src/router/run-sim.mjs +8 -8
  144. package/src/router/runtime.js +23 -12
  145. package/src/router/service/config.mjs +5 -5
  146. package/src/router/service/context.mjs +1 -1
  147. package/src/router/service/queue.mjs +1 -1
  148. package/src/router/shadow.mjs +4 -3
  149. package/src/surgery-recovery.js +2 -2
  150. package/src/tool-output.js +2 -2
  151. package/src/tools.js +39 -7
  152. package/src/truncate-visible.js +2 -2
  153. package/src/ui/actions.js +1312 -0
  154. package/src/ui/panel-api.js +367 -0
  155. package/src/ui/panel-auth.js +251 -0
  156. package/src/ui/panel-integrity.js +243 -0
  157. package/src/ui/routes-readonly.js +695 -0
  158. package/src/ui/routes.js +3 -0
  159. package/docs/DEV.md +0 -279
  160. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  161. package/docs/PRESENCE-BUS.md +0 -694
  162. package/docs/SURGERY.md +0 -186
  163. package/scripts/mark-core-cards.mjs +0 -289
  164. package/scripts/pack.mjs +0 -392
  165. package/scripts/router-proposals.mjs +0 -386
  166. package/src/router/fixtures/M1.json +0 -16
  167. package/src/router/fixtures/M2.json +0 -16
  168. package/src/router/fixtures/M3.json +0 -20
  169. package/src/router/fixtures/M4.json +0 -20
  170. package/src/router/fixtures/M5.json +0 -45
  171. package/src/router/fixtures/M6.json +0 -53
  172. package/src/router/fixtures/M7.json +0 -46
  173. package/src/router/fixtures/S1.json +0 -20
  174. package/src/router/fixtures/S2.json +0 -23
  175. package/src/router/fixtures/S3.json +0 -20
  176. package/src/router/fixtures/S4.json +0 -34
  177. package/src/router/fixtures/S5.json +0 -43
  178. package/src/router/fixtures/S6.json +0 -47
  179. package/src/router/fixtures/T1.json +0 -23
  180. package/src/router/fixtures/T2.json +0 -16
  181. package/src/router/fixtures/T3.json +0 -19
  182. package/src/router/fixtures/T4.json +0 -16
  183. package/src/router/fixtures/X1.json +0 -17
  184. package/src/router/fixtures/X2.json +0 -20
  185. package/src/router/gen-design-headings.mjs +0 -107
  186. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  187. package/test/activity-realtime.test.mjs +0 -959
  188. package/test/activity-stream.test.mjs +0 -512
  189. package/test/auto-recall.test.mjs +0 -474
  190. package/test/auto-truncate.test.mjs +0 -931
  191. package/test/autobind.test.mjs +0 -399
  192. package/test/bus.test.mjs +0 -1572
  193. package/test/cli-guard.test.mjs +0 -198
  194. package/test/conclusion.test.mjs +0 -408
  195. package/test/config-schema.test.mjs +0 -210
  196. package/test/d6-output.test.mjs +0 -194
  197. package/test/d6.test.mjs +0 -395
  198. package/test/deps.test.mjs +0 -342
  199. package/test/doctor-setup.test.mjs +0 -297
  200. package/test/embedding-recall.test.mjs +0 -347
  201. package/test/feature-flags.test.mjs +0 -585
  202. package/test/filter-shadow.test.mjs +0 -170
  203. package/test/fixtures/activity-append-child.mjs +0 -29
  204. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  205. package/test/fixtures/d6-header-tools.json +0 -1
  206. package/test/fixtures/lock-child.mjs +0 -67
  207. package/test/fixtures/pre-step-container.mjs +0 -100
  208. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  209. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  210. package/test/fixtures/profile-session-longlive-config.json +0 -20
  211. package/test/fixtures/proposal-cas-child.mjs +0 -66
  212. package/test/host-engine-status.test.mjs +0 -307
  213. package/test/hot-truncate.test.mjs +0 -1349
  214. package/test/identity-card-marker.test.mjs +0 -307
  215. package/test/identity-router/run.mjs +0 -721
  216. package/test/integration-pre-step.test.mjs +0 -553
  217. package/test/live.test.mjs +0 -1023
  218. package/test/maintenance-schedule.test.mjs +0 -538
  219. package/test/memory.test.mjs +0 -2828
  220. package/test/metrics.test.mjs +0 -478
  221. package/test/orchestrator.test.mjs +0 -266
  222. package/test/p1_probe.mjs +0 -97
  223. package/test/packaging.test.mjs +0 -184
  224. package/test/portability.test.mjs +0 -343
  225. package/test/rc4-regressions.test.mjs +0 -562
  226. package/test/rc5-regressions.test.mjs +0 -197
  227. package/test/rc6-chinese.test.mjs +0 -394
  228. package/test/router-1100/apply-off.test.mjs +0 -175
  229. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  230. package/test/router-1100/config-context.test.mjs +0 -211
  231. package/test/router-1100/helpers.mjs +0 -129
  232. package/test/router-1100/hint-envelope.test.mjs +0 -150
  233. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  234. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  235. package/test/router-1100/router-apply.test.mjs +0 -372
  236. package/test/router-1100/router-fixb.test.mjs +0 -602
  237. package/test/router-1100/router-proposals.test.mjs +0 -405
  238. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  239. package/test/router-1100/router-security-cr.test.mjs +0 -333
  240. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  241. package/test/router-1100/tools-contract.test.mjs +0 -164
  242. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  243. package/test/router-1100/v6-contract.test.mjs +0 -158
  244. package/test/router-1100/v6b-contract.test.mjs +0 -293
  245. package/test/router-1100/v6c-contract.test.mjs +0 -249
  246. package/test/router-1100/wal-rotation.test.mjs +0 -375
  247. package/test/router-1100/wp1-config.test.mjs +0 -170
  248. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  249. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  250. package/test/router-1100/wp1-status.test.mjs +0 -220
  251. package/test/router-1100/wp1-tools.test.mjs +0 -146
  252. package/test/router-1100/wp5-cli.test.mjs +0 -240
  253. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  254. package/test/router-1100/wp6-merge.test.mjs +0 -386
  255. package/test/router-1100/wp6-wake.test.mjs +0 -502
  256. package/test/router-background/audit.test.mjs +0 -37
  257. package/test/router-background/budget-ledger.test.mjs +0 -330
  258. package/test/router-background/cross-process.test.mjs +0 -222
  259. package/test/router-background/fixtures/cross-child.mjs +0 -85
  260. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  261. package/test/router-background/router.test.mjs +0 -1076
  262. package/test/router-background/stream-retry.test.mjs +0 -358
  263. package/test/router-background/tool-agent.test.mjs +0 -384
  264. package/test/router-face/bridge.test.mjs +0 -169
  265. package/test/router-face/face.test.mjs +0 -316
  266. package/test/router-failclosed.test.mjs +0 -466
  267. package/test/router-identity.test.mjs +0 -384
  268. package/test/router-l1-l2.contract.test.mjs +0 -1075
  269. package/test/router-memory-tools.test.mjs +0 -262
  270. package/test/router-policy.test.mjs +0 -225
  271. package/test/router-runtime.test.mjs +0 -438
  272. package/test/router-shadow.test.mjs +0 -287
  273. package/test/router.test.mjs +0 -903
  274. package/test/run-rc.mjs +0 -315
  275. package/test/run.mjs +0 -2469
  276. package/test/safety-defaults.test.mjs +0 -181
  277. package/test/seeded-load.test.mjs +0 -436
  278. package/test/session-original.test.mjs +0 -338
  279. package/test/state-truth.test.mjs +0 -937
  280. package/test/surgery-recovery.test.mjs +0 -936
  281. package/test/t7_drive.mjs +0 -151
  282. package/test/tool-output.test.mjs +0 -393
  283. package/test/tool-schema.test.mjs +0 -177
  284. package/test/tools_contract.test.mjs +0 -716
  285. package/test/truncate-visible.test.mjs +0 -1150
  286. package/test/ui-flags.test.mjs +0 -477
  287. package/test/v4-hardening.test.mjs +0 -427
  288. package/test/version-consistency.test.mjs +0 -286
@@ -1,330 +0,0 @@
1
- // test/budget-ledger.test.mjs — 跨实例/跨重启的原子预算账本
2
- //
3
- // 对应红队 P02(两实例各 75 / 预算 100 实花 150)、P03(重启只恢复
4
- // max 而不是 sum)、P06(负 usage 退款)与 MF-08(损坏不猜)。
5
-
6
- import test from 'node:test';
7
- import assert from 'node:assert/strict';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
- import {
12
- BudgetLedger,
13
- LEDGER_ERRORS,
14
- foldRecords,
15
- readLedger,
16
- } from '../../src/router/background/budget-ledger.mjs';
17
-
18
- const here = path.dirname(fileURLToPath(import.meta.url));
19
- const TMP = path.join(path.dirname(here), '.tmp');
20
- let caseNo = 0;
21
-
22
- function ledgerFile() {
23
- const dir = path.join(TMP, `ledger-${process.pid}-${caseNo++}`);
24
- fs.mkdirSync(dir, { recursive: true });
25
- return path.join(dir, 'budget.ledger.jsonl');
26
- }
27
-
28
- function makeLedger(file, overrides = {}) {
29
- return new BudgetLedger({ file, runId: 'run-test', maxTokens: 100, ...overrides });
30
- }
31
-
32
- test('reserve 全有或全无:100 预算下 75 可以,第二个 30 直接 E_BUDGET_EXCEEDED 且不写记录', () => {
33
- const file = ledgerFile();
34
- const ledger = makeLedger(file);
35
-
36
- const first = ledger.reserve({ taskId: 't1', tokens: 75, owner: 'run-a' });
37
- assert.equal(first.ok, true);
38
- assert.equal(first.remaining, 25);
39
-
40
- const linesBefore = readLedger(file).records.length;
41
- const second = ledger.reserve({ taskId: 't2', tokens: 30, owner: 'run-b' });
42
- assert.equal(second.ok, false);
43
- assert.equal(second.code, LEDGER_ERRORS.EXCEEDED);
44
- assert.equal(second.needed, 30);
45
- assert.equal(second.remaining, 25);
46
- assert.equal(readLedger(file).records.length, linesBefore, '拒绝时不能留下半条预留');
47
-
48
- const snapshot = ledger.snapshot();
49
- assert.equal(snapshot.used, 0);
50
- assert.equal(snapshot.reserved, 75);
51
- });
52
-
53
- test('settle 幂等:同一 reservation 重复结算只计一次', () => {
54
- const file = ledgerFile();
55
- const ledger = makeLedger(file);
56
- const reservation = ledger.reserve({ taskId: 't1', tokens: 75, owner: 'run-a' });
57
-
58
- const settled = ledger.settle({ id: reservation.id, tokens: 60 });
59
- assert.equal(settled.duplicate, false);
60
- assert.equal(settled.used, 60);
61
- assert.equal(settled.reserved, 0);
62
-
63
- const again = ledger.settle({ id: reservation.id, tokens: 60 });
64
- assert.equal(again.duplicate, true);
65
- assert.equal(again.used, 60);
66
- assert.equal(ledger.snapshot().used, 60);
67
- });
68
-
69
- test('release 只释放预留、不计费', () => {
70
- const file = ledgerFile();
71
- const ledger = makeLedger(file);
72
- const reservation = ledger.reserve({ tokens: 75, owner: 'run-a' });
73
-
74
- const released = ledger.release({ id: reservation.id });
75
- assert.equal(released.released, true);
76
- assert.equal(released.reserved, 0);
77
- assert.equal(ledger.snapshot().used, 0);
78
-
79
- const twice = ledger.release({ id: reservation.id });
80
- assert.equal(twice.released, false);
81
- });
82
-
83
- test('P02:两个实例共享账本,预留严格串行(75 + 60 时第二个必须失败)', () => {
84
- const file = ledgerFile();
85
- const a = makeLedger(file, { runId: 'run-a' });
86
- const b = makeLedger(file, { runId: 'run-b' });
87
-
88
- assert.equal(a.reserve({ tokens: 75, owner: 'run-a' }).ok, true);
89
- const blocked = b.reserve({ tokens: 60, owner: 'run-b' });
90
- assert.equal(blocked.ok, false);
91
- assert.equal(blocked.code, LEDGER_ERRORS.EXCEEDED);
92
- assert.equal(blocked.used, 0);
93
- assert.equal(blocked.remaining, 25);
94
- assert.equal(a.snapshot().reserved, 75, 'B 的失败不能影响 A 已拿到的预留');
95
- });
96
-
97
- test('P03:新实例从账本恢复全部 used + reserved(不是取 max)', () => {
98
- const file = ledgerFile();
99
- const first = makeLedger(file, { runId: 'run-1', maxTokens: 300 });
100
- const one = first.reserve({ tokens: 75, owner: 'run-1' });
101
- first.settle({ id: one.id, tokens: 75 });
102
- const two = first.reserve({ tokens: 75, owner: 'run-1' });
103
- first.settle({ id: two.id, tokens: 75 });
104
- const three = first.reserve({ tokens: 50, owner: 'run-1' });
105
-
106
- const restarted = makeLedger(file, { runId: 'run-2', maxTokens: 300 });
107
- const snapshot = restarted.snapshot();
108
- assert.equal(snapshot.used, 150, '两笔 75 必须相加');
109
- assert.equal(snapshot.reserved, 50);
110
- assert.equal(snapshot.records, 5);
111
-
112
- // 剩余 100:再要 101 必须失败,说明串起来的账是真的。
113
- assert.equal(restarted.reserve({ tokens: 101, owner: 'run-2' }).ok, false);
114
- assert.equal(restarted.reserve({ tokens: 100, owner: 'run-2' }).ok, true);
115
- assert.equal(three.ok, true);
116
- });
117
-
118
- test('崩溃遗留的 active 预留:releaseOwner 只清理自己的 owner', () => {
119
- const file = ledgerFile();
120
- const crashed = makeLedger(file, { runId: 'run-crashed', maxTokens: 300 });
121
- crashed.reserve({ tokens: 75, owner: 'run-crashed' });
122
-
123
- const fresh = makeLedger(file, { runId: 'run-fresh', maxTokens: 300 });
124
- const mine = fresh.reserve({ tokens: 50, owner: 'run-fresh' });
125
- assert.equal(mine.ok, true);
126
- assert.equal(fresh.snapshot().reserved, 125);
127
-
128
- const released = fresh.releaseOwner({ owner: 'run-fresh' });
129
- assert.equal(released.released, 1);
130
- assert.equal(fresh.snapshot().reserved, 75, '别人的预留不能被我顺手释放');
131
- assert.equal(fresh.snapshot().used, 0, '释放不等于计费');
132
- });
133
-
134
- test('adopt 只在空账本上生效,之后 reserve 以 adopted used 为起点', () => {
135
- const file = ledgerFile();
136
- const ledger = makeLedger(file);
137
- const adopted = ledger.adopt({ used: 80, reason: 'audit_bootstrap' });
138
- assert.equal(adopted.ok, true);
139
- assert.equal(adopted.used, 80);
140
-
141
- const second = ledger.adopt({ used: 10 });
142
- assert.equal(second.ok, false);
143
- assert.equal(second.code, 'E_BUDGET_ALREADY_INITIALISED');
144
-
145
- const blocked = ledger.reserve({ tokens: 30 });
146
- assert.equal(blocked.ok, false);
147
- assert.equal(blocked.remaining, 20);
148
- });
149
-
150
- test('账本损坏:完整坏行 ⇒ snapshot 报 badLines,reserve 拒绝猜剩余额度(E_BUDGET_CORRUPT)', () => {
151
- const file = ledgerFile();
152
- const ledger = makeLedger(file);
153
- ledger.reserve({ tokens: 20 });
154
- // 关键:坏行是「完整的一行」(带换行),不是可修的半行尾 —— 这种是篡改/损坏,不能猜。
155
- fs.appendFileSync(file, '{"v":1,"seq":99,"op":"settle"\n');
156
-
157
- const read = readLedger(file);
158
- assert.equal(read.badLines, 1);
159
- assert.equal(read.tailTruncated, false);
160
-
161
- const snapshot = ledger.snapshot();
162
- assert.equal(snapshot.badLines, 1);
163
-
164
- assert.throws(
165
- () => ledger.reserve({ tokens: 10 }),
166
- (error) => error.code === LEDGER_ERRORS.CORRUPT,
167
- );
168
- assert.ok(fs.readFileSync(file, 'utf8').includes('"op":"settle"'));
169
- });
170
-
171
- test('半行尾巴在下次写入前被修掉,不会永久毒化账本', () => {
172
- const file = ledgerFile();
173
- const ledger = makeLedger(file);
174
- ledger.reserve({ tokens: 20 });
175
- fs.appendFileSync(file, '{"v":1,"seq":2,"op":"res');
176
-
177
- const reservation = ledger.reserve({ tokens: 30 });
178
- assert.equal(reservation.ok, true, '修尾之后应能继续预留');
179
- const snapshot = ledger.snapshot();
180
- assert.equal(snapshot.badLines, 0);
181
- assert.equal(snapshot.reserved, 50);
182
- });
183
-
184
- test('锁:死 pid 的锁按 stale 处理;活 pid 的锁超时后返回 E_BUDGET_LOCK', () => {
185
- const file = ledgerFile();
186
- const ledger = makeLedger(file);
187
-
188
- fs.writeFileSync(ledger.lockPath, JSON.stringify({ pid: 999999, at: Date.now() }));
189
- assert.equal(ledger.reserve({ tokens: 10 }).ok, true);
190
- assert.equal(fs.existsSync(ledger.lockPath), false, 'stale 锁应被清掉');
191
-
192
- fs.writeFileSync(ledger.lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
193
- try {
194
- assert.throws(
195
- () => makeLedger(file, { lockTimeoutMs: 30, lockRetryMs: 2 }).reserve({ tokens: 10 }),
196
- (error) => error.code === LEDGER_ERRORS.LOCK,
197
- );
198
- } finally {
199
- fs.rmSync(ledger.lockPath, { force: true });
200
- }
201
- });
202
-
203
- test('foldRecords:未知 op / 非对象 / 负数 token 全算坏行', () => {
204
- const state = foldRecords([
205
- { op: 'reserve', id: 'a', tokens: 10 },
206
- { op: 'settle', id: 'a', tokens: 10 },
207
- { op: 'mystery' },
208
- { op: 'reserve', id: 'b', tokens: -5 },
209
- null,
210
- ]);
211
- assert.equal(state.used, 10);
212
- assert.equal(state.reserved, 0);
213
- assert.equal(state.badLines, 3);
214
- assert.equal(state.records, 5);
215
- });
216
-
217
- test('P0-1:settle 硬上限 —— 预留 75、实际 120 只记 75,多出的 45 拒绝(E_BUDGET_OVERSHOOT)', () => {
218
- const file = ledgerFile();
219
- const ledger = makeLedger(file);
220
- const reservation = ledger.reserve({ taskId: 't1', tokens: 75 });
221
- assert.equal(reservation.ok, true);
222
-
223
- const settled = ledger.settle({ id: reservation.id, tokens: 120 });
224
- assert.equal(settled.ok, false);
225
- assert.equal(settled.code, LEDGER_ERRORS.OVERSHOOT);
226
- assert.equal(settled.applied, 75);
227
- assert.equal(settled.rejected, 45);
228
- assert.equal(settled.used, 75);
229
- assert.equal(settled.reserved, 0);
230
-
231
- const snapshot = ledger.snapshot();
232
- assert.equal(snapshot.used, 75, '重试 60+60 在 75 预留下只能记 75,不能记 120');
233
- assert.equal(snapshot.reserved, 0);
234
- });
235
-
236
- test('P0-1:settle 硬上限 —— used 80 + 预留 20,实际 25 只记 20,used 封顶 100', () => {
237
- const file = ledgerFile();
238
- const ledger = makeLedger(file, { maxTokens: 100 });
239
- const first = ledger.reserve({ tokens: 80 });
240
- ledger.settle({ id: first.id, tokens: 80 });
241
-
242
- const second = ledger.reserve({ tokens: 20 });
243
- assert.equal(second.ok, true, '80 已用 + 20 预留正好 100');
244
-
245
- const settled = ledger.settle({ id: second.id, tokens: 25 });
246
- assert.equal(settled.ok, false);
247
- assert.equal(settled.code, LEDGER_ERRORS.OVERSHOOT);
248
- assert.equal(settled.applied, 20);
249
- assert.equal(settled.rejected, 5);
250
- assert.equal(settled.used, 100);
251
- });
252
-
253
- test('settle 未知 reservation:E_BUDGET_UNKNOWN_RESERVATION,且不写 settle 记录', () => {
254
- const file = ledgerFile();
255
- const ledger = makeLedger(file);
256
- ledger.reserve({ tokens: 10 });
257
- const before = readLedger(file).records.length;
258
-
259
- const settled = ledger.settle({ id: 'rsv-does-not-exist', tokens: 5 });
260
- assert.equal(settled.ok, false);
261
- assert.equal(settled.code, LEDGER_ERRORS.UNKNOWN_RESERVATION);
262
- assert.equal(readLedger(file).records.length, before);
263
- });
264
-
265
- test('P0-2:reclaimDead 回收 pid 不存在的预留,活 pid + 有效租约保留', () => {
266
- const file = ledgerFile();
267
- const dead = makeLedger(file, { pid: 999999, runId: 'run-dead' });
268
- assert.equal(dead.reserve({ tokens: 75, owner: 'run-dead', leaseMs: 60000 }).ok, true);
269
-
270
- const alive = makeLedger(file, { runId: 'run-alive' });
271
- assert.equal(alive.reserve({ tokens: 10, owner: 'run-alive', leaseMs: 60000 }).ok, true);
272
- assert.equal(alive.snapshot().reserved, 85);
273
-
274
- const reclaimed = alive.reclaimDead();
275
- assert.equal(reclaimed.released, 1);
276
- assert.equal(reclaimed.freedTokens, 75);
277
- assert.equal(reclaimed.items[0].reason, 'dead_pid');
278
- assert.equal(alive.snapshot().reserved, 10, '活 pid 的预留必须保留');
279
- });
280
-
281
- test('P0-2:reclaimDead 在租约过期时回收(即使 pid 还活着)', () => {
282
- const file = ledgerFile();
283
- const ledger = makeLedger(file, { pid: process.pid });
284
- assert.equal(ledger.reserve({ tokens: 25, leaseMs: 1000 }).ok, true);
285
-
286
- const reclaimed = ledger.reclaimDead({ now: Date.now() + 5000 });
287
- assert.equal(reclaimed.released, 1);
288
- assert.equal(reclaimed.items[0].reason, 'lease_expired');
289
- assert.equal(ledger.snapshot().reserved, 0);
290
- });
291
-
292
- test('P0-3:repairTail() 可在 start 前调用 —— 截掉半行、保留完好行', () => {
293
- const file = ledgerFile();
294
- const ledger = makeLedger(file);
295
- ledger.reserve({ tokens: 20 });
296
- fs.appendFileSync(file, '{"v":1,"seq":2,"op":"res');
297
- assert.equal(readLedger(file).tailTruncated, true);
298
-
299
- const repaired = ledger.repairTail();
300
- assert.equal(repaired.repaired, true);
301
-
302
- const read = readLedger(file);
303
- assert.equal(read.badLines, 0);
304
- assert.equal(read.tailTruncated, false);
305
- assert.equal(read.records.length, 1, '完好行必须保留');
306
- assert.equal(ledger.reserve({ tokens: 30 }).ok, true);
307
- });
308
-
309
- test('P0-4:账本 hash 链 —— 删掉中间一条整行 → chainBreaks>0,reserve 抛 E_LEDGER_TAMPERED', () => {
310
- const file = ledgerFile();
311
- const ledger = makeLedger(file, { maxTokens: 300 });
312
- ledger.reserve({ tokens: 20 });
313
- ledger.reserve({ tokens: 30 });
314
- ledger.reserve({ tokens: 10 });
315
-
316
- const read = readLedger(file);
317
- assert.equal(read.signed, true);
318
- assert.equal(read.chainBreaks.length, 0);
319
-
320
- const lines = fs.readFileSync(file, 'utf8').trimEnd().split('\n');
321
- assert.equal(lines.length, 3);
322
- fs.writeFileSync(file, `${lines[0]}\n${lines[2]}\n`); // 删中间整行
323
-
324
- const tampered = readLedger(file);
325
- assert.ok(tampered.chainBreaks.length > 0, '删中间行必须暴露 seq gap / prev_hash 断裂');
326
- assert.throws(
327
- () => ledger.reserve({ tokens: 1 }),
328
- (error) => error.code === LEDGER_ERRORS.TAMPERED,
329
- );
330
- });
@@ -1,222 +0,0 @@
1
- // test/cross-process.test.mjs — 真跨进程预算回归(红队 P0-2 / 验收项 75+75→75、60+60→60)
2
- //
3
- // 每个子进程有独立的 queue/audit,只共享 budget.ledger.jsonl —— 这是红队的实测形状。
4
- // 另外用 SIGKILL 真杀一个在 reserve 之后挂起的子进程,验证重启不会把死预留算成 25。
5
-
6
- import test, { after } from 'node:test';
7
- import assert from 'node:assert/strict';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
- import { spawn } from 'node:child_process';
11
- import { fileURLToPath } from 'node:url';
12
- import { Router } from '../../src/router/background/router.mjs';
13
- import { MockLlm } from '../../src/router/background/mock-llm.mjs';
14
- import { BudgetLedger } from '../../src/router/background/budget-ledger.mjs';
15
-
16
- const here = path.dirname(fileURLToPath(import.meta.url));
17
- const CHILD = path.join(here, 'fixtures', 'cross-child.mjs');
18
- const TMP = path.join(path.dirname(here), '.tmp-cross');
19
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
-
21
- let caseNo = 0;
22
- function makeDir(label) {
23
- const dir = path.join(TMP, `${label}-${process.pid}-${Date.now()}-${caseNo++}`);
24
- fs.mkdirSync(dir, { recursive: true });
25
- return dir;
26
- }
27
-
28
- function writeQueue(dir, id) {
29
- fs.writeFileSync(path.join(dir, 'queue.jsonl'), `${JSON.stringify({ id, type: 'note', payload: { text: id } })}\n`);
30
- }
31
-
32
- function writeMemory(dir) {
33
- fs.writeFileSync(path.join(dir, 'memory.md'), '# 记忆\n## 卡片一\n- 事实 A\n');
34
- }
35
-
36
- function spawnChild(cfg) {
37
- const child = spawn(process.execPath, [CHILD, JSON.stringify(cfg)], {
38
- stdio: ['pipe', 'pipe', 'pipe'],
39
- windowsHide: true,
40
- });
41
- child.stdout.setEncoding('utf8');
42
- child.stderr.setEncoding('utf8');
43
-
44
- const lines = [];
45
- const waiters = [];
46
- let buffer = '';
47
- let stderr = '';
48
- child.stderr.on('data', (chunk) => { stderr += chunk; });
49
- child.stdout.on('data', (chunk) => {
50
- buffer += chunk;
51
- let index;
52
- while ((index = buffer.indexOf('\n')) >= 0) {
53
- const line = buffer.slice(0, index);
54
- buffer = buffer.slice(index + 1);
55
- lines.push(line);
56
- for (const waiter of [...waiters]) {
57
- // 子进程打的是 `tick <json>` / `event <json>`:必须允许前缀匹配,
58
- // 否则 waitFor('tick') 永远等不到(旧实现 3 条 cross-process 全红的原因之一)。
59
- if (waiter.want === line || line.startsWith(`${waiter.want} `)) {
60
- waiter.done = true;
61
- waiters.splice(waiters.indexOf(waiter), 1);
62
- waiter.resolve(line);
63
- }
64
- }
65
- }
66
- });
67
-
68
- function waitFor(want, timeoutMs = 20000) {
69
- const existing = lines.find((line) => line === want || line.startsWith(`${want} `));
70
- if (existing !== undefined) return Promise.resolve(existing);
71
- return new Promise((resolve, reject) => {
72
- const waiter = {
73
- want,
74
- done: false,
75
- resolve: (line) => {
76
- clearTimeout(timer);
77
- resolve(line);
78
- },
79
- };
80
- const timer = setTimeout(() => {
81
- waiters.splice(waiters.indexOf(waiter), 1);
82
- reject(new Error(`timeout waiting for "${want}" from child; stderr=${stderr}`));
83
- }, timeoutMs);
84
- waiters.push(waiter);
85
- });
86
- }
87
-
88
- function waitExit(timeoutMs = 10000) {
89
- return new Promise((resolve, reject) => {
90
- if (child.exitCode !== null) return resolve(child.exitCode);
91
- // timer 必须和 clearTimeout 处在同一个作用域;旧实现把它声明在 executor 内、
92
- // 在 .finally 里清,抛出 ReferenceError: timer is not defined。
93
- const timer = setTimeout(() => reject(new Error(`child exit timeout; stderr=${stderr}`)), timeoutMs);
94
- child.once('exit', (code) => {
95
- clearTimeout(timer);
96
- resolve(code);
97
- });
98
- });
99
- }
100
-
101
- return { child, lines, waitFor, waitExit, stderr: () => stderr };
102
- }
103
-
104
- after(() => {
105
- try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ }
106
- });
107
-
108
- async function runRace({ maxTokens, perCall, expectedUsed }) {
109
- const root = makeDir('race');
110
- const ledgerPath = path.join(root, 'budget.ledger.jsonl');
111
- const children = [];
112
- for (const id of ['a', 'b']) {
113
- const dir = path.join(root, id);
114
- fs.mkdirSync(dir, { recursive: true });
115
- writeQueue(dir, id);
116
- writeMemory(dir);
117
- children.push(spawnChild({
118
- mode: 'race',
119
- ledgerPath,
120
- auditPath: path.join(dir, 'audit.jsonl'),
121
- queuePath: path.join(dir, 'queue.jsonl'),
122
- memoryPath: path.join(dir, 'memory.md'),
123
- maxTokens,
124
- callMaxTokens: perCall,
125
- usage: perCall,
126
- }));
127
- }
128
-
129
- try {
130
- await Promise.all(children.map((child) => child.waitFor('ready')));
131
- for (const child of children) child.child.stdin.write('go\n');
132
-
133
- const tickLines = await Promise.all(children.map((child) => child.waitFor('tick')));
134
- const results = tickLines.map((line) => JSON.parse(line.slice('tick '.length)));
135
-
136
- await Promise.all(children.map((child) => child.waitExit()));
137
-
138
- const ledger = new BudgetLedger({ file: ledgerPath, runId: 'inspect', maxTokens });
139
- const snapshot = ledger.snapshot();
140
- assert.equal(snapshot.used, expectedUsed, `账本 used 必须是 ${expectedUsed}`);
141
- assert.equal(snapshot.reserved, 0, '结束后不能残留预留');
142
- assert.equal(results.filter((res) => res.ok === true).length, 1, '只能有一个进程拿到预算');
143
- assert.equal(results.reduce((sum, res) => sum + (res.processed ?? 0), 0), 1, '任务总共只能被处理一次');
144
- return { results, snapshot };
145
- } finally {
146
- for (const child of children) {
147
- if (child.child.exitCode === null) child.child.kill('SIGKILL');
148
- }
149
- }
150
- }
151
-
152
- test('跨进程 75+75、预算 100 → 只能记 75(不是 150)', async () => {
153
- const { snapshot } = await runRace({ maxTokens: 100, perCall: 75, expectedUsed: 75 });
154
- assert.equal(snapshot.remaining, 25);
155
- });
156
-
157
- test('跨进程 60+60、预算 100 → 只能记 60(不是 120)', async () => {
158
- const { snapshot } = await runRace({ maxTokens: 100, perCall: 60, expectedUsed: 60 });
159
- assert.equal(snapshot.remaining, 40);
160
- });
161
-
162
- test('P0-2 跨进程:SIGKILL 在 reserve 之后,重启剩余仍是 100(不是 25)', async () => {
163
- const root = makeDir('crash');
164
- const ledgerPath = path.join(root, 'budget.ledger.jsonl');
165
- const auditPath = path.join(root, 'audit.jsonl');
166
- const queuePath = path.join(root, 'queue.jsonl');
167
- const memoryPath = path.join(root, 'memory.md');
168
- writeQueue(root, 't1');
169
- writeMemory(root);
170
-
171
- const child = spawnChild({
172
- mode: 'crash',
173
- ledgerPath,
174
- auditPath,
175
- queuePath,
176
- memoryPath,
177
- maxTokens: 100,
178
- callMaxTokens: 75,
179
- });
180
-
181
- let killed = false;
182
- try {
183
- await child.waitFor('in-call');
184
- child.child.kill('SIGKILL');
185
- killed = true;
186
- await child.waitExit();
187
- await sleep(100);
188
- } finally {
189
- if (!killed && child.child.exitCode === null) child.child.kill('SIGKILL');
190
- }
191
-
192
- // 被杀前确实写下了 active reserve 75。
193
- const probe = new BudgetLedger({ file: ledgerPath, runId: 'inspect', maxTokens: 100 });
194
- const before = probe.snapshot();
195
- assert.equal(before.reserved, 75, '子进程必须已经 reserve 75 才被 kill');
196
- assert.equal(before.used, 0);
197
-
198
- const router = new Router({
199
- queuePath,
200
- auditPath,
201
- memoryPath,
202
- ledgerPath,
203
- llm: new MockLlm({ usage: { inputTokens: 75, outputTokens: 0 } }),
204
- maxTokens: 100,
205
- callMaxTokens: 75,
206
- maxTasksPerTick: 1,
207
- retry: { maxAttempts: 1, initialDelayMs: 1, maxDelayMs: 2 },
208
- });
209
-
210
- const started = router.start();
211
- assert.equal(started.ok, true, `重启必须成功:${JSON.stringify(started)}`);
212
- assert.equal(started.reclaimed.released, 1, '死进程的预留必须被回收');
213
- assert.equal(started.tokensUsed, 0, '死预留不能算成已花');
214
- assert.equal(router.budget.remaining, 100, '重启后剩余必须是 100,不是 25');
215
-
216
- const tick = await router.tick();
217
- assert.equal(tick.ok, true);
218
- assert.equal(tick.processed, 1, '回收后必须还能跑任务');
219
- assert.equal(router.tokensUsed, 75);
220
- assert.equal(router.budget.remaining, 25);
221
- await router.stop('done');
222
- });
@@ -1,85 +0,0 @@
1
- // test/fixtures/cross-child.mjs — 跨进程测试子进程(被 cross-process.test.mjs spawn)
2
- //
3
- // 协议:
4
- // 启动后打印 `ready` 并等 stdin 的 `go`;
5
- // race 模式:收到 go 后跑一次 tick,打印 `tick <json>`,然后 stop/退出;
6
- // crash 模式:tick 进入 llm.stream 后打印 `in-call` 并永久挂起,等父进程 SIGKILL。
7
-
8
- import { Router } from '../../../src/router/background/router.mjs';
9
- import { MockLlm } from '../../../src/router/background/mock-llm.mjs';
10
-
11
- const cfg = JSON.parse(process.argv[2]);
12
-
13
- function line(value) {
14
- process.stdout.write(`${value}\n`);
15
- }
16
-
17
- function finish(code) {
18
- process.exit(code);
19
- }
20
-
21
- process.on('uncaughtException', (error) => {
22
- process.stderr.write(`uncaught: ${error?.stack ?? error}\n`);
23
- finish(1);
24
- });
25
-
26
- if (cfg.mode === 'crash') {
27
- const llm = {
28
- provider: 'mock',
29
- model: 'mock-1',
30
- async *stream() {
31
- line('in-call');
32
- // 永不 resolve:父进程会在看到 in-call 之后 SIGKILL。
33
- await new Promise(() => {});
34
- },
35
- };
36
- const router = new Router({
37
- queuePath: cfg.queuePath,
38
- auditPath: cfg.auditPath,
39
- memoryPath: cfg.memoryPath,
40
- ledgerPath: cfg.ledgerPath,
41
- llm,
42
- maxTokens: cfg.maxTokens,
43
- callMaxTokens: cfg.callMaxTokens,
44
- retry: { maxAttempts: 1, initialDelayMs: 1, maxDelayMs: 2 },
45
- });
46
- const started = router.start();
47
- if (!started.ok) {
48
- line(`start-failed ${JSON.stringify(started)}`);
49
- finish(2);
50
- }
51
- line('ready');
52
- const res = await router.tick();
53
- // 正常情况下走不到这里(stream 挂起);如果超时被 abort,也要让父进程看见。
54
- line(`tick ${JSON.stringify(res)}`);
55
- finish(0);
56
- } else {
57
- const llm = new MockLlm({ usage: { inputTokens: cfg.usage, outputTokens: 0 } });
58
- const router = new Router({
59
- queuePath: cfg.queuePath,
60
- auditPath: cfg.auditPath,
61
- memoryPath: cfg.memoryPath,
62
- ledgerPath: cfg.ledgerPath,
63
- llm,
64
- maxTokens: cfg.maxTokens,
65
- callMaxTokens: cfg.callMaxTokens,
66
- maxTasksPerTick: 1,
67
- retry: { maxAttempts: 1, initialDelayMs: 1, maxDelayMs: 2 },
68
- });
69
- const started = router.start();
70
- if (!started.ok) {
71
- line(`start-failed ${JSON.stringify(started)}`);
72
- finish(2);
73
- }
74
- line('ready');
75
-
76
- await new Promise((resolve) => {
77
- if (process.stdin.readableEnded) resolve();
78
- else process.stdin.once('data', resolve);
79
- });
80
-
81
- const res = await router.tick();
82
- line(`tick ${JSON.stringify({ ...res, tokensUsed: router.tokensUsed, remaining: router.budget.remaining })}`);
83
- await router.stop('done');
84
- finish(0);
85
- }