@jjh32456/dsh-longlive 1.6.7 → 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 (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  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 +4 -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 +47 -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 +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  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 +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -1,602 +0,0 @@
1
- // FIX-B regression suite — CR-04/05/06/07/09/10/11/13/16/17.
2
- //
3
- // Every test here exists for a specific red-team finding; the RESULT file
4
- // records the file:line of each one. These tests use real child processes and
5
- // real on-disk crash seams, never mocks of the protocol itself.
6
-
7
- import assert from 'node:assert/strict';
8
- import { spawn } from 'node:child_process';
9
- import crypto from 'node:crypto';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import test from 'node:test';
13
- import { pathToFileURL } from 'node:url';
14
-
15
- import { createTickLock } from '../../src/router/service/ticklock.mjs';
16
- import { createWal } from '../../src/router/service/wal.mjs';
17
- import { createWriterLease } from '../../src/router/service/lease.mjs';
18
- import {
19
- readProposal,
20
- scanProposalDir,
21
- updateProposalStrict,
22
- writeProposalFile,
23
- } from '../../src/router/service/store.mjs';
24
- import { makeService, readJson, tempRoot, writeMemory } from './helpers.mjs';
25
-
26
- const WAL_MODULE = pathToFileURL(path.resolve('src/router/service/wal.mjs')).href;
27
-
28
- function sha256Buffer(buffer) {
29
- return crypto.createHash('sha256').update(buffer).digest('hex');
30
- }
31
-
32
- async function setupService(prefix, config = {}, deps = {}) {
33
- const temp = tempRoot(prefix);
34
- const memoryRoot = path.join(temp, 'memory');
35
- fs.mkdirSync(memoryRoot, { recursive: true });
36
- writeMemory(memoryRoot);
37
- const vaultDir = path.join(temp, 'vault');
38
- const env = makeService({
39
- temp,
40
- memoryRoot,
41
- vaultDir,
42
- config: {
43
- proposalEnabled: true,
44
- applyEnabled: true,
45
- tickMs: 60000,
46
- proposalsPerSessionHour: 60,
47
- ...config,
48
- },
49
- deps: { installTimers: false, installListener: false, ...deps },
50
- });
51
- const started = await env.service.start();
52
- assert.equal(started.ok, true, `service start failed: ${JSON.stringify(started)}`);
53
- return { ...env, temp, memoryRoot, vaultDir, engine: env.service.proposalEngine };
54
- }
55
-
56
- function proposeJournal(env, content, target = 'journal/2026-11.md') {
57
- return env.service.proposeProposal({
58
- op: 'journal.append',
59
- target,
60
- title: 'fixb',
61
- content,
62
- }, { caller: { kind: 'root', sessionId: 'root-session' } });
63
- }
64
-
65
- async function approve(env, id) {
66
- const result = await env.service.requestApply({ mode: 'approve', id, confirm: id.slice(-6) }, { caller: { kind: 'root', sessionId: 'root-session' } });
67
- assert.equal(result.ok, true, `approve failed: ${JSON.stringify(result)}`);
68
- return result;
69
- }
70
-
71
- function apply(env, id) {
72
- return env.service.requestApply({ mode: 'apply', id, confirm: id.slice(-6) }, { caller: { kind: 'root', sessionId: 'root-session' } });
73
- }
74
-
75
- function rollback(env, id) {
76
- return env.service.requestApply({ mode: 'rollback', id, confirm: id.slice(-6) }, { caller: { kind: 'root', sessionId: 'root-session' } });
77
- }
78
-
79
- function walEvents(vaultDir) {
80
- const file = path.join(vaultDir, 'proposals', 'wal.jsonl');
81
- if (!fs.existsSync(file)) return [];
82
- return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
83
- }
84
-
85
- function cleanup(env) {
86
- env.cleanup();
87
- fs.rmSync(env.temp, { recursive: true, force: true });
88
- }
89
-
90
- function sleep(ms) {
91
- return new Promise((resolve) => setTimeout(resolve, ms));
92
- }
93
-
94
- async function waitFor(check, timeoutMs = 10000) {
95
- const deadline = Date.now() + timeoutMs;
96
- while (Date.now() < deadline) {
97
- if (check()) return true;
98
- await sleep(10);
99
- }
100
- return false;
101
- }
102
-
103
- // CR-04 / D-CR6: two independent Node processes append to one WAL after a
104
- // barrier. The pre-fix behavior was 20/20 duplicate seq and broken=true.
105
- test('CR-04: two real processes append 20 events without a duplicate seq or a broken chain', async () => {
106
- const temp = tempRoot('fixb-wal-');
107
- const proposalsDir = path.join(temp, 'vault', 'proposals');
108
- const goFile = path.join(temp, 'go');
109
- fs.mkdirSync(proposalsDir, { recursive: true });
110
- const walFile = path.join(proposalsDir, 'wal.jsonl');
111
- const wal = createWal({ file: walFile });
112
- for (let index = 0; index < 5; index += 1) wal.append('queue.reconcile', { seed: index });
113
-
114
- const script = path.join(temp, 'append-worker.mjs');
115
- fs.writeFileSync(script, `
116
- import fs from 'node:fs';
117
- import { createWal } from ${JSON.stringify(WAL_MODULE)};
118
- const [walFile, goFile, worker, readyFile] = process.argv.slice(2);
119
- const wal = createWal({ file: walFile });
120
- fs.writeFileSync(readyFile, 'ready');
121
- const sleeper = new Int32Array(new SharedArrayBuffer(4));
122
- while (!fs.existsSync(goFile)) Atomics.wait(sleeper, 0, 0, 5);
123
- for (let index = 0; index < 10; index += 1) wal.append('queue.reconcile', { worker, index });
124
- `, 'utf8');
125
-
126
- const children = [];
127
- try {
128
- for (const worker of ['a', 'b']) {
129
- const readyFile = path.join(temp, `ready-${worker}`);
130
- const child = spawn(process.execPath, [script, walFile, goFile, worker, readyFile], { stdio: ['ignore', 'ignore', 'pipe'] });
131
- const stderr = [];
132
- child.stderr.on('data', (chunk) => stderr.push(chunk));
133
- children.push({ child, stderr, readyFile });
134
- }
135
- for (const entry of children) {
136
- assert.equal(await waitFor(() => fs.existsSync(entry.readyFile)), true, 'worker did not reach the barrier');
137
- }
138
- fs.writeFileSync(goFile, 'go');
139
- const codes = await Promise.all(children.map((entry) => new Promise((resolve) => entry.child.on('exit', resolve))));
140
- for (let index = 0; index < children.length; index += 1) {
141
- assert.equal(codes[index], 0, `worker failed: ${Buffer.concat(children[index].stderr).toString('utf8')}`);
142
- }
143
-
144
- const read = wal.read();
145
- assert.equal(read.broken, false, `chain broken at ${read.brokenAt}: ${read.error ?? ''}`);
146
- assert.equal(read.lines.length, 25, '5 seed + 2 workers x 10 events');
147
- for (let index = 0; index < read.lines.length; index += 1) {
148
- assert.equal(read.lines[index].seq, index + 1, 'seq is continuous and unique');
149
- if (index > 0) assert.equal(read.lines[index].prev, read.lines[index - 1].hash, 'prev/hash chain intact');
150
- }
151
- assert.deepEqual(
152
- read.lines.slice(5).map((line) => `${line.worker}:${line.index}`).sort(),
153
- ['a:0', 'a:1', 'a:2', 'a:3', 'a:4', 'a:5', 'a:6', 'a:7', 'a:8', 'a:9', 'b:0', 'b:1', 'b:2', 'b:3', 'b:4', 'b:5', 'b:6', 'b:7', 'b:8', 'b:9'],
154
- );
155
- assert.equal(fs.existsSync(path.join(temp, 'vault', 'locks', 'wal.lock')), false, 'wal.lock released');
156
- } finally {
157
- fs.rmSync(temp, { recursive: true, force: true });
158
- }
159
- });
160
-
161
- // CR-04 / D-CR6: a crashed writer's stale wal.lock is taken over with evidence,
162
- // so one dead pid can never close the shared WAL forever.
163
- test('CR-04b: a stale wal.lock is taken over with evidence and append proceeds', () => {
164
- const temp = tempRoot('fixb-wallock-');
165
- const proposalsDir = path.join(temp, 'vault', 'proposals');
166
- const locksDir = path.join(temp, 'vault', 'locks');
167
- fs.mkdirSync(proposalsDir, { recursive: true });
168
- fs.mkdirSync(locksDir, { recursive: true });
169
- const lockFile = path.join(locksDir, 'wal.lock');
170
- fs.writeFileSync(lockFile, JSON.stringify({ v: 1, pid: 999995, host: 'local', startedAt: new Date(Date.now() - 3600000).toISOString(), nonce: 'dead' }));
171
- const audit = [];
172
- const wal = createWal({ file: path.join(proposalsDir, 'wal.jsonl'), audit: (kind, data = {}) => audit.push({ kind, ...data }) });
173
- try {
174
- wal.append('queue.reconcile', { afterStaleLock: true });
175
- assert.equal(wal.read().broken, false);
176
- assert.equal(wal.read().lines.length, 1);
177
- assert.ok(fs.readdirSync(locksDir).some((name) => name.startsWith('wal.lock.reclaimed-')), 'old lock kept as evidence');
178
- assert.ok(audit.some((row) => row.kind === 'wal.lock.reclaimed'));
179
- } finally {
180
- fs.rmSync(temp, { recursive: true, force: true });
181
- }
182
- });
183
-
184
- // CR-07 / D-CR7: a stale rev must lose, never overwrite; service retries a lost
185
- // CAS once instead of surfacing a silent overwrite.
186
- test('CR-07: rev CAS rejects a stale write (E_STATE_RACE) and service retries a lost race', async () => {
187
- const env = await setupService('fixb-cas-');
188
- try {
189
- const proposed = await proposeJournal(env, 'cas body');
190
- const file = path.join(env.vaultDir, 'proposals', `${proposed.id}.json`);
191
- const original = readJson(file);
192
- writeProposalFile(file, { ...original, title: 'winner', rev: original.rev + 1 });
193
- const raced = readJson(file);
194
- assert.throws(
195
- () => updateProposalStrict(env.vaultDir ? path.dirname(file) : path.dirname(file), proposed.id, original.rev, { title: 'loser' }),
196
- (error) => error?.code === 'E_STATE_RACE' && error.rev === raced.rev,
197
- );
198
- assert.equal(readJson(file).title, 'winner', 'stale writer changed nothing');
199
-
200
- // Bounded retry: the first approve loses the CAS, the second reads fresh.
201
- const engine = env.service.proposalEngine;
202
- const originalApprove = engine.approve;
203
- let calls = 0;
204
- engine.approve = async function wrapped(...args) {
205
- calls += 1;
206
- if (calls === 1) return { ok: false, code: 'E_STATE_RACE', message: 'injected lost update' };
207
- return originalApprove.apply(this, args);
208
- };
209
- const approved = await env.service.requestApply({ mode: 'approve', id: proposed.id, confirm: proposed.id.slice(-6) }, { caller: { kind: 'root', sessionId: 'root-session' } });
210
- assert.equal(calls, 2, 'service retried exactly once');
211
- assert.equal(approved.ok, true, JSON.stringify(approved));
212
- assert.equal(readJson(file).status, 'approved');
213
- assert.equal(walEvents(env.vaultDir).filter((line) => line.ev === 'queue.approve').length, 1, 'approve WAL written once');
214
- } finally {
215
- cleanup(env);
216
- }
217
- });
218
-
219
- // CR-05 / D-CR8: the final pre-rename recheck refuses a target that changed
220
- // after the earlier check, with zero canonical bytes and no rollback.done.
221
- test('CR-05: rollback refuses a target changed after the initial check with zero bytes', async () => {
222
- const env = await setupService('fixb-rb-race-');
223
- try {
224
- const proposed = await proposeJournal(env, 'rollback race body');
225
- await approve(env, proposed.id);
226
- const applied = await apply(env, proposed.id);
227
- assert.equal(applied.ok, true, JSON.stringify(applied));
228
- const targetFile = path.join(env.memoryRoot, 'journal', '2026-11.md');
229
- const doneBytes = fs.readFileSync(targetFile);
230
- env.service.applier.testHooks = {
231
- onRollbackRecheck: () => { fs.writeFileSync(targetFile, 'owner edit during rollback\n'); },
232
- };
233
- const raced = await rollback(env, proposed.id);
234
- assert.equal(raced.ok, false);
235
- assert.equal(raced.code, 'E_BASE_MOVED');
236
- assert.equal(raced.reason, 'owner-write-race');
237
- assert.equal(fs.readFileSync(targetFile, 'utf8'), 'owner edit during rollback\n', 'the owner bytes win');
238
- assert.notDeepEqual(fs.readFileSync(targetFile), doneBytes);
239
- const tmpDir = path.dirname(targetFile);
240
- assert.equal(fs.readdirSync(tmpDir).some((name) => name.includes('.tmp-')), false, 'tmp removed');
241
- const proposal = readJson(path.join(env.vaultDir, 'proposals', `${proposed.id}.json`));
242
- assert.equal(proposal.status, 'conflict');
243
- assert.equal(proposal.conflict.reason, 'owner-write-race');
244
- const events = walEvents(env.vaultDir).map((line) => line.ev);
245
- assert.ok(events.includes('rollback.begin'));
246
- assert.equal(events.includes('rollback.done'), false);
247
- assert.ok(walEvents(env.vaultDir).some((line) => line.ev === 'apply.abort' && line.reason === 'owner-write-race' && line.phase === 'rollback-pre-rename'));
248
- } finally {
249
- cleanup(env);
250
- }
251
- });
252
-
253
- // CR-06 / D-CR8: crash after rename but before the state flip is recovered to
254
- // rolled_back; crash before rename is recovered to done; a third state is a
255
- // conflict with RESTORE_REQUIRED. Never auto-restore.
256
- test('CR-06: rollback crash recovery has exactly three outcomes', async () => {
257
- // Branch 1 — R-06: this apply created the file, so its rollback removes it;
258
- // a crash after the removal but before the state flip recovers to rolled_back.
259
- const crashed = await setupService('fixb-rb-crash-');
260
- try {
261
- const proposed = await proposeJournal(crashed, 'crash branch body');
262
- await approve(crashed, proposed.id);
263
- assert.equal((await apply(crashed, proposed.id)).ok, true);
264
- const targetFile = path.join(crashed.memoryRoot, 'journal', '2026-11.md');
265
- crashed.service.applier.testHooks = { failRollbackAfterRename: true };
266
- await assert.rejects(() => crashed.service.applier.rollback({ id: proposed.id }), (error) => error?.code === 'E_TEST_CRASH');
267
- let proposal = readJson(path.join(crashed.vaultDir, 'proposals', `${proposed.id}.json`));
268
- const beforeSha = proposal.rollback.beforeSha;
269
- assert.equal(proposal.status, 'applying');
270
- assert.equal(proposal.rollback.active, true);
271
- assert.equal(proposal.rollback.removeTarget, true, 'new-file rollback removes instead of restoring');
272
- assert.equal(beforeSha, sha256Buffer(Buffer.alloc(0)), 'beforeSha is the empty-buffer sha');
273
- assert.equal(fs.existsSync(targetFile), false, 'target removed by rollback');
274
- const recovered = crashed.service.applier.recoverApplying();
275
- assert.ok(recovered.rolledBack.includes(proposed.id), JSON.stringify(recovered));
276
- proposal = readJson(path.join(crashed.vaultDir, 'proposals', `${proposed.id}.json`));
277
- assert.equal(proposal.status, 'rolled_back');
278
- assert.equal(proposal.result.restoredSha, null);
279
- assert.equal(proposal.result.removed, true);
280
- assert.equal(fs.existsSync(targetFile), false, 'still absent after recovery');
281
- assert.ok(walEvents(crashed.vaultDir).some((line) => line.ev === 'rollback.done' && line.proposalId === proposed.id
282
- && line.recovered === true && line.removed === true && line.restoredSha === null));
283
- assert.equal(fs.existsSync(path.join(crashed.vaultDir, 'locks', 'writer.lease.json')), false, 'recovery released its own lease');
284
- } finally {
285
- cleanup(crashed);
286
- }
287
-
288
- // Branch 2 — crash before rename: target is still afterSha -> done.
289
- const pending = await setupService('fixb-rb-done-');
290
- try {
291
- const proposed = await proposeJournal(pending, 'not-landed body');
292
- await approve(pending, proposed.id);
293
- assert.equal((await apply(pending, proposed.id)).ok, true);
294
- const file = path.join(pending.vaultDir, 'proposals', `${proposed.id}.json`);
295
- let proposal = readJson(file);
296
- const { beforeSha, afterSha } = proposal.result;
297
- proposal = updateProposalStrict(path.dirname(file), proposed.id, proposal.rev, (current) => ({
298
- ...current,
299
- status: 'applying',
300
- rollback: { active: true, beforeSha, afterSha },
301
- queue: { ...current.queue, lease: null },
302
- }));
303
- pending.service.wal.append('rollback.begin', { ticket: proposal.queue.ticket, proposalId: proposed.id, beforeSha, afterSha });
304
- const targetFile = path.join(pending.memoryRoot, 'journal', '2026-11.md');
305
- assert.equal(sha256Buffer(fs.readFileSync(targetFile)), afterSha);
306
- const recovered = pending.service.applier.recoverApplying();
307
- assert.ok(recovered.done.includes(proposed.id), JSON.stringify(recovered));
308
- proposal = readJson(file);
309
- assert.equal(proposal.status, 'done');
310
- assert.equal(sha256Buffer(fs.readFileSync(targetFile)), afterSha, 'no canonical write on this branch');
311
- } finally {
312
- cleanup(pending);
313
- }
314
-
315
- // Branch 3 — neither beforeSha nor afterSha: conflict + RESTORE_REQUIRED.
316
- const conflict = await setupService('fixb-rb-conflict-');
317
- try {
318
- const proposed = await proposeJournal(conflict, 'conflict branch body');
319
- await approve(conflict, proposed.id);
320
- assert.equal((await apply(conflict, proposed.id)).ok, true);
321
- const file = path.join(conflict.vaultDir, 'proposals', `${proposed.id}.json`);
322
- let proposal = readJson(file);
323
- const { beforeSha, afterSha } = proposal.result;
324
- proposal = updateProposalStrict(path.dirname(file), proposed.id, proposal.rev, (current) => ({
325
- ...current,
326
- status: 'applying',
327
- rollback: { active: true, beforeSha, afterSha },
328
- queue: { ...current.queue, lease: null },
329
- }));
330
- conflict.service.wal.append('rollback.begin', { ticket: proposal.queue.ticket, proposalId: proposed.id, beforeSha, afterSha });
331
- fs.writeFileSync(path.join(conflict.memoryRoot, 'journal', '2026-11.md'), 'third party content\n');
332
- const recovered = conflict.service.applier.recoverApplying();
333
- assert.ok(recovered.conflicts.includes(proposed.id), JSON.stringify(recovered));
334
- proposal = readJson(file);
335
- assert.equal(proposal.status, 'conflict');
336
- assert.equal(proposal.conflict.code, 'RESTORE_REQUIRED');
337
- assert.equal(proposal.conflict.restoreRequired, true);
338
- assert.equal(fs.readFileSync(path.join(conflict.memoryRoot, 'journal', '2026-11.md'), 'utf8'), 'third party content\n');
339
- assert.equal(conflict.service.status().warnings.includes('RESTORE_REQUIRED'), true);
340
- } finally {
341
- cleanup(conflict);
342
- }
343
- });
344
-
345
- // CR-15 / D-CR12: a read-back mismatch in the lock acquire path is a busy
346
- // round, never a successful acquisition with no owner on disk.
347
- test('CR-15: tick.lock read-back mismatch reports busy and leaves no lock', () => {
348
- const temp = tempRoot('fixb-ticknull-');
349
- const lockFile = path.join(temp, 'tick.lock');
350
- const lock = createTickLock({ file: lockFile, maxHoldMs: 1000 });
351
- const realRead = fs.readFileSync;
352
- fs.readFileSync = (file, ...rest) => {
353
- if (file === lockFile) return JSON.stringify({ v: 1, pid: process.pid, startedAt: 'not-the-payload' });
354
- return realRead.call(fs, file, ...rest);
355
- };
356
- try {
357
- const result = lock.tryAcquire();
358
- assert.equal(result.ok, false);
359
- assert.equal(result.reason, 'busy');
360
- assert.equal(fs.existsSync(lockFile), false, 'mismatched read-back deleted its own file');
361
- } finally {
362
- fs.readFileSync = realRead;
363
- }
364
- try {
365
- assert.equal(lock.status().held, false);
366
- } finally {
367
- fs.rmSync(temp, { recursive: true, force: true });
368
- }
369
- });
370
-
371
- // CR-09 / D-CR9: an abandoned reclaim mutex with a dead local owner is taken
372
- // over with evidence; a live owner's mutex is never stolen.
373
- test('CR-09: stale tick/issue reclaim locks are taken over, live ones are not', async () => {
374
- const env = await setupService('fixb-reclaim-');
375
- try {
376
- const tickFile = path.join(env.vaultDir, 'tick.lock');
377
- const tickReclaim = path.join(env.vaultDir, 'tick.reclaim.lock');
378
- fs.mkdirSync(env.vaultDir, { recursive: true });
379
- const audit = [];
380
- fs.writeFileSync(tickFile, JSON.stringify({ v: 1, pid: 999999, host: 'local', startedAt: new Date(Date.now() - 3600000).toISOString() }));
381
- fs.writeFileSync(tickReclaim, JSON.stringify({ v: 1, pid: 999998, host: 'local', startedAt: new Date(Date.now() - 3600000).toISOString(), nonce: 'dead' }));
382
- const lock = createTickLock({ file: tickFile, maxHoldMs: 1000, audit: (kind, data = {}) => audit.push({ kind, ...data }) });
383
- const taken = lock.tryAcquire();
384
- assert.equal(taken.ok, true, JSON.stringify(taken));
385
- assert.equal(taken.reclaimed, true);
386
- assert.ok(fs.readdirSync(env.vaultDir).some((name) => name.startsWith('tick.reclaim.lock.reclaimed-')));
387
- assert.ok(audit.some((row) => row.kind === 'tick.reclaim_lock.reclaimed'));
388
- lock.release();
389
-
390
- // A live reclaim mutex is a quiet busy, and the stale tick lock stays put.
391
- fs.writeFileSync(tickFile, JSON.stringify({ v: 1, pid: 999999, host: 'local', startedAt: new Date(Date.now() - 3600000).toISOString() }));
392
- fs.writeFileSync(tickReclaim, JSON.stringify({ v: 1, pid: process.pid, host: 'local', startedAt: new Date().toISOString(), nonce: 'live' }));
393
- const busy = lock.tryAcquire();
394
- assert.equal(busy.ok, false);
395
- assert.equal(busy.reason, 'reclaim-busy');
396
- assert.equal(JSON.parse(fs.readFileSync(tickFile, 'utf8')).pid, 999999);
397
-
398
- // The lease reclaim mutex uses the same stale-takeover rule: a dead pid's
399
- // reclaim.lock must not freeze lease maintenance.
400
- const leaseFile = path.join(env.vaultDir, 'locks', 'writer.lease.json');
401
- const leaseReclaim = path.join(env.vaultDir, 'locks', 'reclaim.lock');
402
- const leaseAudit = [];
403
- const lease = createWriterLease({
404
- file: leaseFile,
405
- lockDir: path.join(env.vaultDir, 'locks'),
406
- holder: { sessionId: 'test', runId: 'test-owner', pid: process.pid, host: 'local' },
407
- audit: (kind, data = {}) => leaseAudit.push({ kind, ...data }),
408
- });
409
- const nowMs = Date.now();
410
- fs.mkdirSync(path.join(env.vaultDir, 'locks'), { recursive: true });
411
- const writeDeadLease = () => fs.writeFileSync(leaseFile, JSON.stringify({
412
- v: 1,
413
- holder: { sessionId: 'dead', runId: 'dead-owner', pid: 999994, host: 'local' },
414
- ticket: 'q-0001',
415
- proposalId: null,
416
- acquiredAt: new Date(nowMs - 1000).toISOString(),
417
- expiresAt: new Date(nowMs + 60000).toISOString(),
418
- ttlMs: 60000,
419
- renewals: 0,
420
- }));
421
- writeDeadLease();
422
- fs.writeFileSync(leaseReclaim, JSON.stringify({ v: 1, pid: 999993, host: 'local', startedAt: new Date(nowMs - 3600000).toISOString(), nonce: 'dead' }));
423
- const reclaimed = lease.reclaimIfNeeded();
424
- assert.equal(reclaimed.ok, true, JSON.stringify(reclaimed));
425
- assert.equal(fs.existsSync(leaseFile), false);
426
- assert.ok(leaseAudit.some((row) => row.kind === 'lease.reclaim_lock.reclaimed'), 'stale lease reclaim mutex taken over');
427
- assert.ok(fs.readdirSync(path.join(env.vaultDir, 'locks')).some((name) => name.startsWith('reclaim.lock.reclaimed-')));
428
-
429
- writeDeadLease();
430
- fs.writeFileSync(leaseReclaim, JSON.stringify({ v: 1, pid: process.pid, host: 'local', startedAt: new Date().toISOString(), nonce: 'live' }));
431
- const busyLease = lease.reclaimIfNeeded();
432
- assert.equal(busyLease.ok, false);
433
- assert.equal(busyLease.reason, 'reclaim-busy');
434
- assert.equal(fs.existsSync(leaseFile), true, 'live reclaim mutex prevented the steal');
435
-
436
- // Same protocol for locks/issue.lock: a dead holder's reclaim lock must not
437
- // block proposals forever.
438
- const issueLock = path.join(env.vaultDir, 'locks', 'issue.lock');
439
- const issueReclaim = path.join(env.vaultDir, 'locks', 'issue.reclaim.lock');
440
- fs.mkdirSync(path.dirname(issueLock), { recursive: true });
441
- fs.writeFileSync(issueLock, JSON.stringify({ v: 1, pid: 999997, host: 'local', startedAt: new Date(Date.now() - 3600000).toISOString(), proposalId: null }));
442
- fs.writeFileSync(issueReclaim, JSON.stringify({ v: 1, pid: 999996, host: 'local', startedAt: new Date(Date.now() - 3600000).toISOString(), nonce: 'dead' }));
443
- const old = new Date(Date.now() - 3600000);
444
- fs.utimesSync(issueLock, old, old);
445
- fs.utimesSync(issueReclaim, old, old);
446
- const proposed = await proposeJournal(env, 'after stale issue lock');
447
- assert.equal(proposed.ok, true, JSON.stringify(proposed));
448
- assert.ok(fs.readdirSync(path.join(env.vaultDir, 'locks')).some((name) => name.startsWith('issue.reclaimed-')));
449
- } finally {
450
- cleanup(env);
451
- }
452
- });
453
-
454
- // CR-10 / D-CR10: a foreign host's unexpired lease is alive; local dead pid and
455
- // expired leases are reclaimed into evidence files.
456
- test('CR-10: foreign unexpired lease is not stolen; dead local and expired leases are', () => {
457
- const temp = tempRoot('fixb-lease-');
458
- const lockDir = path.join(temp, 'vault', 'locks');
459
- fs.mkdirSync(lockDir, { recursive: true });
460
- const leaseFile = path.join(lockDir, 'writer.lease.json');
461
- const holder = (host, pid, runId) => ({ sessionId: 'x', runId, pid, host });
462
- const writeLease = (host, pid, runId, acquiredMs, expiresMs) => {
463
- fs.writeFileSync(leaseFile, JSON.stringify({
464
- v: 1,
465
- holder: holder(host, pid, runId),
466
- ticket: 'q-0001',
467
- proposalId: null,
468
- acquiredAt: new Date(acquiredMs).toISOString(),
469
- expiresAt: new Date(expiresMs).toISOString(),
470
- ttlMs: 60000,
471
- renewals: 0,
472
- }));
473
- };
474
- try {
475
- const lease = createWriterLease({
476
- file: leaseFile,
477
- lockDir,
478
- holder: { sessionId: 'test', runId: 'test-owner', pid: process.pid, host: 'local' },
479
- now: () => new Date(),
480
- audit: () => {},
481
- });
482
- const now = Date.now();
483
- writeLease('other-host', 1, 'foreign-live', now - 1000, now + 60000);
484
- assert.deepEqual(lease.reclaimIfNeeded(), { ok: true, reclaimed: false, reason: 'alive' });
485
- assert.equal(fs.existsSync(leaseFile), true);
486
-
487
- writeLease('local', 999999, 'local-dead', now - 1000, now + 60000);
488
- const local = lease.reclaimIfNeeded();
489
- assert.equal(local.ok, true, JSON.stringify(local));
490
- assert.equal(fs.existsSync(leaseFile), false);
491
- assert.ok(local.evidence && fs.existsSync(local.evidence), 'dead lease preserved as evidence');
492
-
493
- writeLease('other-host', 1, 'foreign-expired', now - 200000, now - 1000);
494
- const expired = lease.reclaimIfNeeded();
495
- assert.equal(expired.ok, true, JSON.stringify(expired));
496
- assert.ok(expired.evidence && fs.existsSync(expired.evidence));
497
- assert.equal(fs.existsSync(leaseFile), false);
498
- const evidence = fs.readdirSync(lockDir).filter((name) => name.startsWith('reclaimed-'));
499
- assert.ok(evidence.length >= 2, `evidence files kept: ${evidence.join(',')}`);
500
- } finally {
501
- fs.rmSync(temp, { recursive: true, force: true });
502
- }
503
- });
504
-
505
- // CR-11 / D-CR11: approving an expired proposal re-queues it at the tail.
506
- test('CR-11: expired approve issues a new tail ticket and queue.assign.recovered', async () => {
507
- const env = await setupService('fixb-expired-');
508
- try {
509
- const proposed = await proposeJournal(env, 'expired body');
510
- await approve(env, proposed.id);
511
- const file = path.join(env.vaultDir, 'proposals', `${proposed.id}.json`);
512
- let proposal = readJson(file);
513
- proposal.queue.deadlineAt = new Date(Date.now() - 1000).toISOString();
514
- fs.writeFileSync(file, JSON.stringify(proposal));
515
- env.service.queueManager.maintain();
516
- proposal = readJson(file);
517
- assert.equal(proposal.status, 'blocked-on-user');
518
- proposal.queue.deadlineAt = new Date(Date.now() - 1000).toISOString();
519
- fs.writeFileSync(file, JSON.stringify(proposal));
520
- env.service.queueManager.maintain();
521
- proposal = readJson(file);
522
- assert.equal(proposal.status, 'expired');
523
-
524
- const requeued = await approve(env, proposed.id);
525
- assert.equal(requeued.state, 'pending');
526
- assert.equal(requeued.requeued, true);
527
- assert.notEqual(requeued.ticket, proposal.queue.ticket);
528
- proposal = readJson(file);
529
- assert.equal(proposal.status, 'pending');
530
- assert.equal(proposal.queue.ticket, requeued.ticket);
531
- assert.equal(proposal.queue.lease, null);
532
- assert.equal(proposal.queue.deadlineAt, null);
533
- assert.ok(walEvents(env.vaultDir).some((line) => line.ev === 'queue.assign.recovered'
534
- && line.proposalId === proposed.id && line.reason === 'expired-requeue' && line.ticket === requeued.ticket));
535
- } finally {
536
- cleanup(env);
537
- }
538
- });
539
-
540
- // CR-13 / D-CR12: the 16 KB limit applies to the whole serialized proposal on
541
- // write and on read, not just to the content field.
542
- test('CR-13: full-JSON size is enforced on write and read', async () => {
543
- const env = await setupService('fixb-size-');
544
- try {
545
- const proposed = await proposeJournal(env, 'size body');
546
- const file = path.join(env.vaultDir, 'proposals', `${proposed.id}.json`);
547
- const original = readJson(file);
548
- const before = fs.readFileSync(file, 'utf8');
549
- assert.throws(
550
- () => writeProposalFile(file, { ...original, rationale: 'x'.repeat(20000) }),
551
- (error) => error?.code === 'E_CONTENT_TOO_LARGE',
552
- );
553
- assert.equal(fs.readFileSync(file, 'utf8'), before, 'oversized proposal never lands');
554
-
555
- const oversized = path.join(env.vaultDir, 'proposals', 'p-20261105T000000-abcdef.json');
556
- fs.writeFileSync(oversized, `${JSON.stringify({ v: 1 })}\n${' '.repeat(17000)}`);
557
- const scan = scanProposalDir(path.join(env.vaultDir, 'proposals'));
558
- assert.ok(scan.invalid.some((entry) => entry.file === oversized && entry.error === 'E_CONTENT_TOO_LARGE'));
559
- assert.equal(scan.proposals.some((entry) => entry.id === 'p-20261105T000000-abcdef'), false);
560
- } finally {
561
- cleanup(env);
562
- }
563
- });
564
-
565
- // CR-16 / D-CR12: a read failure marks the log broken and append refuses; it
566
- // must never silently restart the chain at seq 1.
567
- test('CR-16: WAL read failure sets broken and append throws E_WAL_BROKEN', () => {
568
- const temp = tempRoot('fixb-walbroken-');
569
- const proposalsDir = path.join(temp, 'vault', 'proposals');
570
- fs.mkdirSync(proposalsDir, { recursive: true });
571
- const walFile = path.join(proposalsDir, 'wal.jsonl');
572
- const wal = createWal({ file: walFile });
573
- try {
574
- wal.append('queue.reconcile', { seed: 1 });
575
- wal.append('queue.reconcile', { seed: 2 });
576
- fs.rmSync(walFile);
577
- fs.mkdirSync(walFile); // readFileSync -> EISDIR, not ENOENT
578
- const read = wal.read();
579
- assert.equal(read.broken, true);
580
- assert.ok(read.error);
581
- assert.throws(() => wal.append('queue.reconcile', { seed: 3 }), (error) => error?.code === 'E_WAL_BROKEN');
582
- assert.equal(fs.statSync(walFile).isDirectory(), true, 'append did not recreate the chain');
583
- } finally {
584
- fs.rmSync(temp, { recursive: true, force: true });
585
- }
586
- });
587
-
588
- // CR-17 / D-CR17: warnings produced inside apply reach the service status. The
589
- // validate-time misreport is covered in router-apply.test.mjs (K4/K19).
590
- test('CR-17: step-7 owner-write-race warning reaches service.status().warnings', async () => {
591
- const env = await setupService('fixb-warn-', {}, { testHooks: { forceInjection: true } });
592
- try {
593
- const proposed = await proposeJournal(env, 'warn body');
594
- await approve(env, proposed.id);
595
- const result = await apply(env, proposed.id);
596
- assert.equal(result.code, 'E_BASE_MOVED');
597
- assert.equal(result.reason, 'owner-write-race');
598
- assert.equal(env.service.status().warnings.includes('OWNER_WRITE_RACE'), true);
599
- } finally {
600
- cleanup(env);
601
- }
602
- });