@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.
- package/CHANGELOG.md +70 -1508
- package/LICENSE +1 -1
- package/QUICKSTART.md +34 -13
- package/README.en.md +18 -7
- package/README.md +34 -9
- package/SPEC.md +5 -5
- package/cordis.patch.yml +23 -9
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +168 -35
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +96 -28
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +4 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +47 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +760 -0
- package/src/activity/model.js +351 -0
- package/src/activity/store.js +644 -0
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +171 -47
- package/src/deps.js +6 -4
- package/src/features.js +182 -6
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/live.js +22 -2
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +105 -16
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/queue.mjs +1 -1
- package/src/router/service/service.mjs +27 -11
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -385
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -204
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -568
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1020
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -182
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1044
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -299
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -179
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- 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
|
-
});
|