@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,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
- });