opencode-longrun-harness 1.2.22

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/docs/V1.2.20_EVIDENCE.md +114 -0
  4. package/docs/V1.2.21_EVIDENCE.md +68 -0
  5. package/docs/V1.2.22_EVIDENCE.md +52 -0
  6. package/harness/commissioning/README.md +16 -0
  7. package/harness/commissioning/inspect-copied-run.mjs +25 -0
  8. package/harness/commissioning/verify-copied-case.mjs +35 -0
  9. package/harness/plugin/longrun.js +677 -0
  10. package/harness/src/cli.mjs +40 -0
  11. package/harness/src/controller.js +1413 -0
  12. package/harness/src/evidence.mjs +135 -0
  13. package/harness/src/execution.mjs +217 -0
  14. package/harness/src/executor.mjs +21 -0
  15. package/harness/src/install.mjs +435 -0
  16. package/harness/src/maintenance.mjs +257 -0
  17. package/harness/src/memory.mjs +472 -0
  18. package/harness/test/candidates.test.mjs +73 -0
  19. package/harness/test/checkpoint.test.mjs +65 -0
  20. package/harness/test/controller.test.mjs +230 -0
  21. package/harness/test/evidence.test.mjs +57 -0
  22. package/harness/test/fixtures/durable-host.mjs +27 -0
  23. package/harness/test/fixtures/example-app-run.json +1375 -0
  24. package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
  25. package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
  26. package/harness/test/fixtures/notes-recovery-run.json +622 -0
  27. package/harness/test/fixtures/presets-readout-run.json +825 -0
  28. package/harness/test/fixtures/routing-worker.mjs +35 -0
  29. package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
  30. package/harness/test/helper.mjs +41 -0
  31. package/harness/test/install.test.mjs +117 -0
  32. package/harness/test/lifecycle.test.mjs +102 -0
  33. package/harness/test/maintenance.test.mjs +204 -0
  34. package/harness/test/memory.test.mjs +145 -0
  35. package/harness/test/negative-control.test.mjs +91 -0
  36. package/harness/test/plugin.test.mjs +169 -0
  37. package/harness/test/recovery-runner.test.mjs +435 -0
  38. package/harness/test/recovery.test.mjs +68 -0
  39. package/harness/test/repair-mechanics.test.mjs +122 -0
  40. package/harness/test/toolbehavior.test.mjs +75 -0
  41. package/harness/test/v121-commissioning.test.mjs +177 -0
  42. package/harness/test/v1210-deadline.test.mjs +134 -0
  43. package/harness/test/v1211-pause.test.mjs +81 -0
  44. package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
  45. package/harness/test/v1213-readout.test.mjs +82 -0
  46. package/harness/test/v1214-durable.test.mjs +121 -0
  47. package/harness/test/v1215-guidance.test.mjs +57 -0
  48. package/harness/test/v1216-test-summary.test.mjs +39 -0
  49. package/harness/test/v1217-discovery.test.mjs +73 -0
  50. package/harness/test/v1218-completion-review.test.mjs +203 -0
  51. package/harness/test/v1219-budget-pause.test.mjs +134 -0
  52. package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
  53. package/harness/test/v1220-budget-amendment.test.mjs +343 -0
  54. package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
  55. package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
  56. package/harness/test/v123-plugin-e2e.test.mjs +120 -0
  57. package/harness/test/v123-receipt-model.test.mjs +185 -0
  58. package/harness/test/v124-canonical.test.mjs +147 -0
  59. package/harness/test/v124-installed.test.mjs +48 -0
  60. package/harness/test/v125-stability.test.mjs +183 -0
  61. package/harness/test/v126-execution.test.mjs +183 -0
  62. package/harness/test/v127-reconciliation.test.mjs +139 -0
  63. package/harness/test/v128-compaction.test.mjs +156 -0
  64. package/harness/test/v129-routing.test.mjs +165 -0
  65. package/harness/tools/audit-receipts.mjs +121 -0
  66. package/harness/tools/recovery-runner.mjs +499 -0
  67. package/package.json +49 -0
@@ -0,0 +1,183 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import * as C from '../src/controller.js';
7
+ import * as M from '../src/memory.mjs';
8
+ import { F } from './helper.mjs';
9
+
10
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
11
+ const tmp = label => fs.mkdtempSync(path.join(os.tmpdir(), `lr125-${label}-`));
12
+ const write = (p, s) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, s); };
13
+ async function setup(command = ['node', '-e', 'process.exit(1)'], timeoutMs = 2000) {
14
+ process.env.LONGRUN_STATE_DIR = tmp('state');
15
+ const dir = tmp('project'), fixture = tmp('fixture');
16
+ write(path.join(dir, 'value.txt'), 'protected');
17
+ write(path.join(fixture, 'value.txt'), 'broken');
18
+ const hooks = await F('../plugin/longrun.js', { client: null });
19
+ const ctx = { sessionID: 'first', directory: dir, worktree: dir };
20
+ const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Harness commissioning', criteria: [{ id: 'c', checks: ['gate'] }], checkCatalogue: { gate: { command, timeoutMs, kind: 'cmd', gate: true } } }, ctx));
21
+ assert.ok(start.runId);
22
+ const key = C.stateKey(C.projectIdentity(dir), start.runId), store = new C.Store(process.env.LONGRUN_STATE_DIR);
23
+ return { hooks, ctx, dir, fixture, store, key, runId: start.runId };
24
+ }
25
+
26
+ test('memory refresh is independent of directory enumeration and preserves unchanged bytes/mtime', t => {
27
+ const dir = tmp('memory');
28
+ write(path.join(dir, 'package.json'), '{"scripts":{"test":"node --test"}}');
29
+ for (const name of ['zeta', 'alpha']) {
30
+ write(path.join(dir, name, 'package.json'), '{}');
31
+ for (let n = 0; n < 12; n++) write(path.join(dir, name, `f${n}.ts`), `export const x${n} = ${n};\n`);
32
+ write(path.join(dir, name, 'index.ts'), 'export const entry = true;\n');
33
+ }
34
+ M.initDeep(dir, { harnessVersion: 'test' });
35
+ const paths = ['AGENTS.md', 'alpha/AGENTS.md', 'zeta/AGENTS.md', M.MEMORY_PATH];
36
+ const before = paths.map(p => ({ p, bytes: fs.readFileSync(path.join(dir, p)), mtime: fs.statSync(path.join(dir, p)).mtimeMs }));
37
+ const readdir = fs.readdirSync;
38
+ t.mock.method(fs, 'readdirSync', function (...args) { return readdir.apply(fs, args).reverse(); });
39
+ const refresh = M.initDeep(dir, { harnessVersion: 'test' });
40
+ for (const old of before) {
41
+ assert.deepEqual(fs.readFileSync(path.join(dir, old.p)), old.bytes, old.p);
42
+ assert.equal(fs.statSync(path.join(dir, old.p)).mtimeMs, old.mtime, old.p);
43
+ }
44
+ assert.deepEqual(refresh.updated, []);
45
+ assert.equal(M.assessStaleness(dir, M.readMemoryIndex(dir)).status, 'FRESH');
46
+ });
47
+
48
+ test('managed refresh preserves surrounding text exactly and retains source subject/location', () => {
49
+ const dir = tmp('prose');
50
+ write(path.join(dir, 'main.ts'), '// Presentation-only hash helper: NEVER use this function for combat rules.\nexport const hash = 1;\n');
51
+ const before = 'Curated preamble\n\n\nkeep exact spacing\n', after = '\n\n\nCurated ending\n';
52
+ write(path.join(dir, 'AGENTS.md'), before + M.wrapManaged('stale\n').trimEnd() + after);
53
+ M.initDeep(dir);
54
+ const content = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf8');
55
+ assert.equal(content.slice(0, content.indexOf(M.MANAGED_BEGIN)), before);
56
+ assert.equal(content.slice(content.indexOf(M.MANAGED_END) + M.MANAGED_END.length), after);
57
+ assert.match(content, /main\.ts:1.*Presentation-only hash helper: NEVER/);
58
+ });
59
+
60
+ test('checkpoint persists advisory progress across session rebind/compaction without changing machine evidence', async t => {
61
+ const s = await setup(), before = s.store.readJSON(s.key, 'run.json');
62
+ const progress = { currentSlice: 'recover the fixture', nextAction: 'inspect the independent oracle failure', decisions: ['keep the original contract'], failedHypotheses: ['a timeout alone proves the verifier works'], memoryNodes: ['AGENTS.md'], artifacts: [path.join(s.fixture, 'evidence.log')] };
63
+ const result = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'checkpoint', runId: s.runId, progress }, s.ctx));
64
+ assert.equal(result.checkpointed, true);
65
+ const saved = s.store.readJSON(s.key, 'run.json');
66
+ assert.deepEqual(saved.agentProgress.fields, progress);
67
+ for (const key of ['contract', 'contractHash', 'budget', 'receipts', 'state', 'status']) assert.deepEqual(saved[key], before[key], key);
68
+ await s.hooks.tool.longrun.execute({ action: 'pause', runId: s.runId }, s.ctx);
69
+ const ctx = { ...s.ctx, sessionID: 'fresh' };
70
+ await s.hooks.tool.longrun.execute({ action: 'resume', runId: s.runId }, ctx);
71
+ const observedAt = Date.now(); t.mock.method(Date, 'now', () => observedAt); // exact packet comparison at one instant
72
+ const packet = await s.hooks.tool.longrun.execute({ action: 'resume-context', runId: s.runId }, ctx);
73
+ assert.match(packet, /AGENT PROGRESS \(advisory; not verification evidence\)/);
74
+ assert.match(packet, /NEXT ACTION: inspect the independent oracle failure/);
75
+ const output = { context: [] }; await s.hooks['experimental.session.compacting']({ sessionID: 'fresh' }, output);
76
+ assert.equal(output.context.length, 1);
77
+ assert.match(output.context[0], /^## Long-run recovery\nPRE-COMPACTION SNAPSHOT:/);
78
+ assert.ok(output.context[0].endsWith(packet));
79
+ const invalid = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'checkpoint', progress: { status: 'COMPLETE', loss: 0 } }, ctx));
80
+ assert.equal(invalid.error, 'INVALID_PROGRESS');
81
+ assert.equal(s.store.readJSON(s.key, 'run.json').status, 'IMPLEMENTING');
82
+ });
83
+
84
+ test('negative control refuses production, nested and symlink aliases before executing', async () => {
85
+ const s = await setup(['node', '-e', "require('node:fs').writeFileSync('should-not-run','bad');process.exit(1)"]);
86
+ const nested = path.join(s.dir, 'copy'); fs.mkdirSync(nested);
87
+ const alias = path.join(tmp('alias'), 'linked'); fs.symlinkSync(s.dir, alias);
88
+ const dependency = tmp('dependency'); fs.symlinkSync(s.dir, path.join(dependency, 'node_modules'));
89
+ for (const fixture of [s.dir, nested, alias, dependency]) {
90
+ const result = JSON.parse(await s.hooks.tool.longrun_verify.execute({ checkId: 'gate', mode: 'negative', fixture }, s.ctx));
91
+ assert.equal(result.error, 'FIXTURE_NOT_ISOLATED', fixture);
92
+ assert.equal(fs.existsSync(path.join(fixture, 'should-not-run')), false);
93
+ }
94
+ assert.equal((s.store.readJSON(s.key, 'run.json').evidence || []).length, 0);
95
+ });
96
+
97
+ test('negative control classifies launch failure and timeout as invalid execution, not expected FAIL', async () => {
98
+ for (const [command, timeout, observed] of [
99
+ [['/nonexistent/longrun-test-executable'], 1000, 'ERROR'],
100
+ [['node', '-e', 'setInterval(()=>{},1000)'], 40, 'TIMEOUT'],
101
+ ]) {
102
+ const s = await setup(command, timeout);
103
+ const result = JSON.parse(await s.hooks.tool.longrun_verify.execute({ checkId: 'gate', mode: 'negative', fixture: s.fixture }, s.ctx));
104
+ assert.equal(result.observed, observed);
105
+ assert.equal(result.ok, false);
106
+ const evidence = s.store.readJSON(s.key, 'run.json').evidence.at(-1);
107
+ assert.equal(evidence.valid, false);
108
+ assert.deepEqual(evidence.command, command);
109
+ assert.ok(evidence.finishedAt >= evidence.startedAt);
110
+ assert.equal(s.store.readJSON(s.key, 'run.json').receipts.length, 0);
111
+ }
112
+ });
113
+
114
+ test('long original requests cannot crowd the next action and machine status out of recovery', () => {
115
+ const run = C.startRun({ request: 'original request '.repeat(2000), contract: C.makeContract({ criteria: [{ id: 'c', checks: ['check'] }] }), checkCatalogue: { check: { command: ['node', '-e', 'process.exit(1)'] } } }).run;
116
+ const original = run.originalRequest;
117
+ assert.equal(C.saveAgentProgress(run, { currentSlice: 'API persistence', nextAction: 'verify reload preserves the saved record' }).ok, true);
118
+ const { packet, words, truncated } = C.buildRecoveryPacket(run);
119
+ assert.match(packet, /NEXT ACTION: verify reload preserves the saved record/);
120
+ assert.match(packet, /COMPLETION BLOCKED: true/);
121
+ assert.match(packet, /ORIGINAL GOAL EXCERPT/);
122
+ assert.equal(truncated, true);
123
+ assert.equal(words, packet.split(/\s+/).filter(Boolean).length);
124
+ assert.ok(words <= 1500);
125
+ assert.equal(run.originalRequest, original);
126
+ });
127
+
128
+ test('progress merges bounded revisions without allowing unknown fields or path traversal', () => {
129
+ const run = { state: {}, status: 'PAUSED', receipts: [], budget: { iterations: 40 } };
130
+ assert.equal(C.saveAgentProgress(run, { currentSlice: 'slice one', decisions: ['keep scope'] }).ok, true);
131
+ assert.equal(C.saveAgentProgress(run, { nextAction: 'review result' }).ok, true);
132
+ assert.equal(run.agentProgress.fields.currentSlice, 'slice one');
133
+ assert.equal(run.agentProgressHistory.length, 2);
134
+ assert.equal(run.agentProgressHistory[0].fields.nextAction, undefined);
135
+ for (const progress of [{ nextAction: 'x'.repeat(1001) }, { decisions: ['x'.repeat(501)] }, { memoryNodes: ['../AGENTS.md'] }, { receipts: [] }]) {
136
+ const before = JSON.stringify(run);
137
+ assert.equal(C.saveAgentProgress(run, progress).error, 'INVALID_PROGRESS');
138
+ assert.equal(JSON.stringify(run), before);
139
+ }
140
+ });
141
+
142
+ test('checkpoint accepts JSON-string objects emitted by the local adapter and validates their contents', async () => {
143
+ const s = await setup();
144
+ const fields = { currentSlice: 'adapter recovery', nextAction: 'inspect the declared result' };
145
+ const result = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'checkpoint', progress: JSON.stringify(fields) }, s.ctx));
146
+ assert.equal(result.checkpointed, true);
147
+ assert.deepEqual(s.store.readJSON(s.key, 'run.json').agentProgress.fields, fields);
148
+ for (const progress of ['not JSON', '[]', '{"status":"COMPLETE"}', JSON.stringify({ currentSlice: 'x'.repeat(241) })]) {
149
+ const before = s.store.readJSON(s.key, 'run.json');
150
+ const result = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'checkpoint', progress }, s.ctx));
151
+ assert.equal(result.error, 'INVALID_PROGRESS');
152
+ assert.deepEqual(s.store.readJSON(s.key, 'run.json'), before);
153
+ }
154
+ });
155
+
156
+ test('ambiguous managed markers preserve the file and are reported for review', () => {
157
+ const existing = 'Curated notes\n' + M.MANAGED_BEGIN + '\nIncomplete managed block\nStill curated';
158
+ const result = M.mergeManaged(existing, M.wrapManaged('new content\n'));
159
+ assert.equal(result.content, existing);
160
+ assert.equal(result.preserved, true);
161
+ assert.match(result.note, /ambiguous/);
162
+ });
163
+
164
+ test('isolation allows internal dependency links, rejects shared hardlinks and detects source mutation', async () => {
165
+ const s = await setup();
166
+ fs.symlinkSync('value.txt', path.join(s.fixture, 'internal'));
167
+ assert.equal(C.validateNegativeFixture(s.fixture, [s.dir]).ok, true);
168
+ fs.linkSync(path.join(s.dir, 'value.txt'), path.join(s.fixture, 'shared'));
169
+ assert.equal(C.validateNegativeFixture(s.fixture, [s.dir]).ok, false);
170
+ fs.unlinkSync(path.join(s.fixture, 'shared'));
171
+ const run = s.store.readJSON(s.key, 'run.json');
172
+ run.checkCatalogue.gate.command = ['node', '-e', `require('node:fs').writeFileSync(${JSON.stringify(path.join(s.dir, 'value.txt'))},'unexpected mutation');process.exit(1)`];
173
+ s.store.writeJSON(s.key, 'run.json', run);
174
+ // The persisted session catalogue is authoritative for this deliberately hostile runner too.
175
+ const indexPath = path.join(process.env.LONGRUN_STATE_DIR, 'runs.json');
176
+ const index = JSON.parse(fs.readFileSync(indexPath)); index.first.checkCatalogue = run.checkCatalogue;
177
+ fs.writeFileSync(indexPath, JSON.stringify(index));
178
+ const result = JSON.parse(await s.hooks.tool.longrun_verify.execute({ checkId: 'gate', mode: 'negative', fixture: s.fixture }, s.ctx));
179
+ assert.equal(result.mutatedProduction, true);
180
+ assert.equal(result.valid, false);
181
+ assert.equal(result.ok, false);
182
+ assert.equal(s.store.readJSON(s.key, 'run.json').receipts.length, 0);
183
+ });
@@ -0,0 +1,183 @@
1
+ import { reviewProjectFixture } from "./helper.mjs";
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import * as C from '../src/controller.js';
8
+ import { F } from './helper.mjs';
9
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
10
+ const tmp = name => fs.mkdtempSync(path.join(os.tmpdir(), `lr126-${name}-`));
11
+ const command = ['node', '-e', "require('node:assert/strict').match(require('node:fs').readFileSync('value.txt','utf8'),/^good/)"];
12
+ async function setup(options = {}, check = { command, kind: 'cmd' }) {
13
+ process.env.LONGRUN_STATE_DIR = tmp('state');
14
+ const dir = tmp('project'); fs.writeFileSync(path.join(dir, 'value.txt'), 'baseline');
15
+ const hooks = await F('../plugin/longrun.js', { client: null });
16
+ const ctx = { sessionID: 'first', directory: dir, worktree: dir };
17
+ const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Isolated execution regression', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check }, ...options }, ctx));
18
+ const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = start.runId && C.stateKey(C.projectIdentity(dir), start.runId);
19
+ const call = async (action, args = {}, context = ctx) => hooks.tool.longrun.execute({ action, runId: start.runId, ...args }, context);
20
+ const verify = async (args = {}, context = ctx) => JSON.parse(await hooks.tool.longrun_verify.execute({ checkId: 'check', runId: start.runId, ...args }, context));
21
+ return { dir, hooks, ctx, start, store, key, call, verify };
22
+ }
23
+
24
+ test('candidate cap blocks only a new source evaluation and survives a session rebind', async () => {
25
+ const s = await setup({ candidateBudget: 1 });
26
+ fs.writeFileSync(path.join(s.dir, 'value.txt'), 'good-one');
27
+ assert.equal((await s.verify()).status, 'PASS');
28
+ assert.equal((await s.verify()).candidateCounted, false);
29
+ await s.call('pause'); await s.call('resume', {}, { ...s.ctx, sessionID: 'fresh' });
30
+ fs.writeFileSync(path.join(s.dir, 'value.txt'), 'good-two');
31
+ const refused = await s.verify({}, { ...s.ctx, sessionID: 'fresh' });
32
+ assert.equal(refused.error, 'BUDGET_EXHAUSTED');
33
+ assert.ok(refused.spent.candidates);
34
+ const run = s.store.readJSON(s.key, 'run.json');
35
+ assert.equal(C.candidateCount(run), 1); assert.equal(run.receipts.length, 2);
36
+ assert.equal(run.budget.iterations, 1);
37
+ });
38
+
39
+ test('the real tool schema exposes finite budget and hard-gate inputs; invalid limits do not silently default', async () => {
40
+ const s = await setup();
41
+ for (const key of ['candidateBudget', 'timeBudgetHours', 'deadlineHours', 'toolActionCap', 'sameFailureThreshold', 'noProgressThreshold', 'hardGates', 'autoContinue']) assert.ok(s.hooks.tool.longrun.args[key], key);
42
+ for (const options of [{ candidateBudget: 0 }, { candidateBudget: 1.5 }, { timeBudgetHours: 'garbage' }, { toolActionCap: -1 }]) {
43
+ const bad = await setup(options); assert.equal(bad.start.error, 'INVALID_BUDGET');
44
+ }
45
+ const auto = await setup({ autoContinue: true }); assert.equal(auto.start.error, 'AUTO_CONTINUATION_UNAVAILABLE');
46
+ });
47
+
48
+ test('read-only receipt diagnostics expose exact command, exit and timestamps without making up absent legacy fields', async () => {
49
+ const s = await setup(); fs.writeFileSync(path.join(s.dir, 'value.txt'), 'good');
50
+ const result = await s.verify(); assert.equal(result.status, 'PASS');
51
+ const view = JSON.parse(await s.call('status'));
52
+ const receipt = JSON.parse(await s.call('receipts', { receiptId: view.checks[0].effectiveReceiptId })).receipt;
53
+ assert.deepEqual(receipt.argv, command); assert.equal(receipt.exitCode, 0);
54
+ assert.ok(receipt.finishedAt >= receipt.startedAt); assert.equal(receipt.outputTail, '');
55
+ const run = s.store.readJSON(s.key, 'run.json');
56
+ delete run.receipts[0].exitCode; delete run.receipts[0].command; delete run.receipts[0].argv;
57
+ s.store.writeJSON(s.key, 'run.json', run);
58
+ const legacyView = JSON.parse(await s.call('verify'));
59
+ const legacy = JSON.parse(await s.call('receipts', { receiptId: legacyView.checks[0].effectiveReceiptId })).receipt;
60
+ assert.equal(legacy.exitCode, null); assert.equal(legacy.command, null); assert.equal(legacy.argv, null);
61
+ });
62
+
63
+ test('deadline and command-attempt caps refuse execution, including a negative control', async () => {
64
+ const s = await setup({ toolActionCap: 1 }); fs.writeFileSync(path.join(s.dir, 'value.txt'), 'good');
65
+ assert.equal((await s.verify()).status, 'PASS');
66
+ assert.equal((await s.verify()).error, 'BUDGET_EXHAUSTED');
67
+ assert.equal(s.store.readJSON(s.key, 'run.json').receipts.length, 1);
68
+ const d = await setup({}, { command, gate: true });
69
+ const run = d.store.readJSON(d.key, 'run.json'); run.createdAt = Date.now() - run.budget.deadlineSeconds * 1000 - 1; d.store.writeJSON(d.key, 'run.json', run);
70
+ const fixture = tmp('negative'); fs.writeFileSync(path.join(fixture, 'value.txt'), 'broken');
71
+ const result = await d.verify({ mode: 'negative', fixture });
72
+ assert.equal(result.error, 'BUDGET_EXHAUSTED'); assert.ok(result.spent.deadline);
73
+ assert.equal((d.store.readJSON(d.key, 'run.json').evidence || []).length, 0);
74
+ });
75
+
76
+ test('active execution allowance caps a subprocess and records measured usage across reloads', async () => {
77
+ const s = await setup({ timeBudgetHours: 0.00008 }, { command: ['node', '-e', 'setInterval(()=>{},1000)'], timeoutMs: 5000, kind: 'cmd' });
78
+ const start = Date.now(), result = await s.verify();
79
+ assert.equal(result.status, 'TIMEOUT'); assert.ok(Date.now() - start < 2000);
80
+ const run = s.store.readJSON(s.key, 'run.json');
81
+ assert.ok(run.execution.verificationMs >= 200);
82
+ assert.equal(run.execution.commandAttempts, 1);
83
+ assert.equal((await s.verify()).error, 'BUDGET_EXHAUSTED');
84
+ assert.equal(run.receipts[0].terminationReason, 'active_time_budget');
85
+ });
86
+
87
+ test('pause is responsive during a check; abort cannot overwrite pause or a concurrent checkpoint', async () => {
88
+ const s = await setup({}, { command: ['node', '-e', 'setTimeout(()=>process.exit(0),1000)'], kind: 'cmd' });
89
+ const pending = s.verify();
90
+ await new Promise(resolve => setTimeout(resolve, 80));
91
+ await s.call('checkpoint', { progress: { nextAction: 'inspect interrupted check' } });
92
+ await s.call('pause');
93
+ const result = await pending;
94
+ assert.equal(result.status, 'ERROR');
95
+ const run = s.store.readJSON(s.key, 'run.json');
96
+ assert.equal(run.status, 'PAUSED'); assert.equal(run.agentProgress.fields.nextAction, 'inspect interrupted check');
97
+ assert.equal(run.receipts[0].terminationReason, 'run_paused');
98
+ assert.equal(run.receipts.length, 1);
99
+ });
100
+
101
+ test('concurrent verifier calls cannot both execute, and context cancellation terminates the owned child', async () => {
102
+ const s = await setup({}, { command: ['node', '-e', 'setTimeout(()=>process.exit(0),600)'], kind: 'cmd' });
103
+ const abort = new AbortController(); const pending = s.verify({}, { ...s.ctx, abort: abort.signal });
104
+ await new Promise(resolve => setTimeout(resolve, 50));
105
+ assert.equal((await s.verify()).error, 'VERIFY_IN_FLIGHT');
106
+ abort.abort(); const result = await pending; assert.equal(result.status, 'ERROR');
107
+ const run = s.store.readJSON(s.key, 'run.json'); assert.equal(run.receipts[0].terminationReason, 'aborted');
108
+ assert.equal(run.execution.inFlight, null);
109
+ });
110
+
111
+ test('unlaunched and pre-aborted checks retain errors without spending a source candidate', async () => {
112
+ for (const abortFirst of [false, true]) {
113
+ const s = await setup({}, { command: [path.join(tmp('missing'), 'absent')], kind: 'cmd' });
114
+ fs.writeFileSync(path.join(s.dir, 'value.txt'), 'new source');
115
+ const controller = new AbortController(); if (abortFirst) controller.abort();
116
+ const result = await s.verify({}, { ...s.ctx, abort: controller.signal });
117
+ assert.equal(result.status, 'ERROR'); assert.equal(result.candidateCounted, false);
118
+ const run = s.store.readJSON(s.key, 'run.json');
119
+ assert.equal(run.receipts.length, 1); assert.equal(run.execution.commandAttempts, 1);
120
+ assert.equal(C.candidateCount(run), 0); assert.equal(run.execution.inFlight, null);
121
+ }
122
+ });
123
+
124
+ test('root exit cleans up its stubborn descendant and preserves the actual root result', async () => {
125
+ const dir = tmp('child-cleanup'), pidFile = path.join(dir, 'child.pid');
126
+ const child = `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setInterval(()=>{},1000)`;
127
+ const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(child)}],{stdio:'inherit'});setTimeout(()=>process.exit(0),150)`;
128
+ const before = Date.now();
129
+ const result = await C.execution.runCommand([process.execPath, '-e', parent], { cwd: dir, timeoutMs: 3000 });
130
+ assert.equal(result.status, 0); assert.equal(result.terminationReason, null);
131
+ assert.ok(Date.now() - before < 2000);
132
+ const pid = Number(fs.readFileSync(pidFile, 'utf8'));
133
+ for (let i = 0; i < 40 && C.execution.alive(pid); i++) await new Promise(resolve => setTimeout(resolve, 25));
134
+ assert.equal(C.execution.alive(pid), false, 'owned descendant exited');
135
+ });
136
+
137
+ test('closed descendant stdio cannot return PASS before a late source mutation and group cleanup', async () => {
138
+ const pidFile = path.join(tmp('silent-child'), 'pid');
139
+ const child = `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setTimeout(()=>require('node:fs').writeFileSync('value.txt','late mutation'),250);setInterval(()=>{},1000)`;
140
+ const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(child)}],{stdio:'ignore'});setTimeout(()=>process.exit(0),100)`;
141
+ const s = await setup({}, { command: [process.execPath, '-e', parent], kind: 'cmd' });
142
+ const result = await s.verify();
143
+ assert.equal(result.status, 'STALE', 'fingerprint comparison happens after owned descendants exit');
144
+ assert.equal(C.execution.alive(Number(fs.readFileSync(pidFile, 'utf8'))), false);
145
+ assert.equal(s.store.readJSON(s.key, 'run.json').execution.inFlight, null);
146
+ });
147
+
148
+ test('terminal state committed by another writer cannot be overwritten by stale lifecycle actions', async () => {
149
+ for (const action of ['pause', 'resume', 'cancel', 'checkpoint', 'complete']) {
150
+ const s = await setup(); fs.writeFileSync(path.join(s.dir, 'value.txt'), 'good');
151
+ await s.verify();
152
+ if (action === "complete") reviewProjectFixture(s.dir, s.start.runId);
153
+ const mutate = C.Store.prototype.mutate; let intercepted = false;
154
+ C.Store.prototype.mutate = function(key, callback) {
155
+ if (!intercepted && key === s.key) {
156
+ intercepted = true;
157
+ mutate.call(this, key, run => { run.status = 'CANCELLED'; return { ok: true }; });
158
+ }
159
+ return mutate.call(this, key, callback);
160
+ };
161
+ try {
162
+ const result = JSON.parse(await s.call(action, { progress: { nextAction: 'must not persist' } }));
163
+ assert.equal(result.error, 'RUN_CANCELLED', action);
164
+ const run = s.store.readJSON(s.key, 'run.json');
165
+ assert.equal(run.status, 'CANCELLED'); assert.equal(run.agentProgress?.fields?.nextAction, undefined);
166
+ } finally { C.Store.prototype.mutate = mutate; }
167
+ }
168
+ });
169
+
170
+ test('legacy usage is a labelled lower bound and orphaned reservations fail closed without invented receipts', async () => {
171
+ const s = await setup(); const run = s.store.readJSON(s.key, 'run.json');
172
+ delete run.execution;
173
+ run.receipts = [{ startedAt: 100, finishedAt: 180 }, { startedAt: 150, finishedAt: 200 }, {}];
174
+ const usage = C.execution.usage(run);
175
+ assert.equal(usage.verificationMs, 100); assert.equal(usage.commandAttempts, 3);
176
+ assert.equal(usage.historicalUsageUnknown, true);
177
+ C.execution.initialize(run);
178
+ run.execution.inFlight = { token: 'interrupted-fixture', ownerPid: -1, childPid: null };
179
+ s.store.writeJSON(s.key, 'run.json', run);
180
+ const before = fs.readFileSync(s.store._file(s.key, 'run.json'));
181
+ assert.equal((await s.verify()).error, 'EXECUTION_RECOVERY_REQUIRED');
182
+ assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
183
+ });
@@ -0,0 +1,139 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import * as C from '../src/controller.js';
7
+ import { F } from './helper.mjs';
8
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
9
+ async function setup({ delay = 0, negative = false, timeoutMs, executable = process.execPath } = {}) {
10
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr127-'));
11
+ process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
12
+ const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'before');
13
+ const counter = path.join(base, 'commands.txt');
14
+ const command = [executable, '-e', `require('node:fs').appendFileSync(${JSON.stringify(counter)},'1');setTimeout(()=>require('node:assert/strict').equal(require('node:fs').readFileSync('value.txt','utf8'),'after'),${delay})`];
15
+ const hooks = await F('../plugin/longrun.js', { client: null });
16
+ const context = { sessionID: 'initial', directory: dir, worktree: dir };
17
+ const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Real command reconciliation fixture', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check: { command, kind: 'cmd', negativeControl: true, timeoutMs } } }, context));
18
+ const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
19
+ fs.writeFileSync(path.join(dir, 'value.txt'), 'after');
20
+ const call = async (action, args = {}, ctx = context) => hooks.tool.longrun.execute({ action, runId: start.runId, ...args }, ctx);
21
+ const verify = async () => {
22
+ const args = { checkId: 'check', runId: start.runId };
23
+ if (negative) { args.mode = 'negative'; args.fixture = path.join(base, 'fixture'); fs.mkdirSync(args.fixture); fs.writeFileSync(path.join(args.fixture, 'value.txt'), 'broken'); }
24
+ return JSON.parse(await hooks.tool.longrun_verify.execute(args, context));
25
+ };
26
+ const pending = async () => {
27
+ const mutate = C.Store.prototype.mutate;
28
+ C.Store.prototype.mutate = function(k, cb) {
29
+ if (k === key && fs.readdirSync(this.keyPath(k)).some(n => /^execution-.*\.json$/.test(n))) return { error: 'STATE_BUSY' };
30
+ return mutate.call(this, k, cb);
31
+ };
32
+ try { assert.equal((await verify()).error, 'RESULT_COMMIT_PENDING'); }
33
+ finally { C.Store.prototype.mutate = mutate; }
34
+ const token = store.readJSON(key, 'run.json').execution.inFlight.token;
35
+ return { token, name: `execution-${token}.json`, journal: store.readJSON(key, `execution-${token}.json`) };
36
+ };
37
+ return { base, dir, counter, hooks, context, runId: start.runId, store, key, call, verify, pending };
38
+ }
39
+
40
+ test('a fresh session reconciles a genuine saved result once without rerunning or unpausing', async () => {
41
+ const s = await setup(), { journal } = await s.pending();
42
+ await s.call('checkpoint', { progress: { nextAction: 'review recovered evidence' } });
43
+ await s.call('pause');
44
+ const before = s.store.readJSON(s.key, 'run.json');
45
+ const fresh = await F('../plugin/longrun.js', { client: null });
46
+ const reconcile = () => fresh.tool.longrun.execute({ action: 'reconcile', runId: s.runId }, { ...s.context, sessionID: 'fresh' });
47
+ const result = JSON.parse(await reconcile()); assert.equal(result.reconciled, true);
48
+ const run = s.store.readJSON(s.key, 'run.json');
49
+ assert.equal(run.status, 'PAUSED'); assert.equal(run.autoEnabled, false);
50
+ assert.equal(run.agentProgress.fields.nextAction, 'review recovered evidence');
51
+ assert.deepEqual(run.budget, before.budget); assert.equal(run.contractHash, before.contractHash);
52
+ assert.equal(run.receipts.length, 1); assert.equal(run.receipts[0].status, 'PASS');
53
+ assert.equal(C.candidateCount(run), 1); assert.equal(run.execution.commandAttempts, 1);
54
+ assert.equal(run.execution.verificationMs, journal.result.finishedAt - journal.result.startedAt);
55
+ assert.equal(run.execution.inFlight, null); assert.equal(fs.readFileSync(s.counter, 'utf8'), '1');
56
+ const second = JSON.parse(await reconcile()); assert.equal(second.nothingPending, true);
57
+ assert.equal(s.store.readJSON(s.key, 'run.json').receipts.length, 1);
58
+ assert.equal(fs.readFileSync(s.counter, 'utf8'), '1');
59
+ });
60
+
61
+ test('reconciliation retains old-source evidence as stale and preserves terminal cancellation', async () => {
62
+ const s = await setup(); await s.pending(); await s.call('cancel', { reason: 'fixture cancelled after command finished' });
63
+ fs.writeFileSync(path.join(s.dir, 'value.txt'), 'newer source');
64
+ assert.equal(JSON.parse(await s.call('reconcile')).reconciled, true);
65
+ const view = JSON.parse(await s.call('status'));
66
+ assert.equal(view.state, 'CANCELLED'); assert.equal(view.checks[0].effectiveStatus, 'STALE');
67
+ assert.equal(view.historicalReceiptCount, 1); assert.equal(view.completionBlocked, true);
68
+ });
69
+
70
+ test('a recovered negative control remains separate from product receipts and candidates', async () => {
71
+ const s = await setup({ negative: true }); await s.pending();
72
+ assert.equal(JSON.parse(await s.call('reconcile')).reconciled, true);
73
+ const run = s.store.readJSON(s.key, 'run.json');
74
+ assert.equal(run.receipts.length, 0); assert.equal(C.candidateCount(run), 0);
75
+ assert.equal(run.evidence.length, 1); assert.equal(run.evidence[0].observed, 'FAIL');
76
+ assert.equal(run.execution.commandAttempts, 1); assert.equal(run.execution.inFlight, null);
77
+ await s.call('reconcile'); assert.equal(s.store.readJSON(s.key, 'run.json').evidence.length, 1);
78
+ });
79
+
80
+ test('missing or inconsistent journals cannot release the reservation or manufacture a receipt', async () => {
81
+ for (const corruption of ['missing', 'fingerprint', 'command', 'cleanup', 'exit']) {
82
+ const s = await setup(), { name, journal } = await s.pending();
83
+ if (corruption === 'missing') fs.rmSync(s.store._file(s.key, name));
84
+ else {
85
+ if (corruption === 'fingerprint') delete journal.receipt.sourceFingerprint;
86
+ if (corruption === 'command') journal.receipt.argv = ['arbitrary'];
87
+ if (corruption === 'cleanup') journal.result.cleanupComplete = false;
88
+ if (corruption === 'exit') journal.result.status = 1;
89
+ s.store.writeJSON(s.key, name, journal);
90
+ }
91
+ const before = fs.readFileSync(s.store._file(s.key, 'run.json'));
92
+ const result = JSON.parse(await s.call('reconcile'));
93
+ assert.match(result.error, /EXECUTION_RECORD|EXECUTION_CLEANUP/);
94
+ assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
95
+ assert.equal(fs.readFileSync(s.counter, 'utf8'), '1');
96
+ }
97
+ });
98
+
99
+ test('live checks and a busy writer cannot be bypassed by reconciliation', async () => {
100
+ const s = await setup({ delay: 600 }); const pending = s.verify();
101
+ await new Promise(resolve => setTimeout(resolve, 100));
102
+ assert.equal(JSON.parse(await s.call('reconcile')).error, 'VERIFY_IN_FLIGHT');
103
+ assert.equal((await pending).status, 'PASS');
104
+ const p = await setup(); await p.pending(); p.store.tryLock(p.key, 'fixture-owner');
105
+ try { assert.equal(JSON.parse(await p.call('reconcile')).error, 'STATE_BUSY'); }
106
+ finally { p.store.releaseLock(p.key); }
107
+ assert.equal(JSON.parse(await p.call('reconcile')).reconciled, true);
108
+ assert.equal(p.store.readJSON(p.key, 'run.json').receipts.length, 1);
109
+ });
110
+
111
+ test('real timeouts and launch failures retain their status without fabricating successful work', async () => {
112
+ for (const [options, expected] of [[{ delay: 5000, timeoutMs: 150 }, 'TIMEOUT'], [{ executable: '/longrun-no-such-executable' }, 'ERROR']]) {
113
+ const s = await setup(options); await s.pending();
114
+ const result = JSON.parse(await s.call('reconcile')); assert.equal(result.reconciled, true);
115
+ const run = s.store.readJSON(s.key, 'run.json');
116
+ assert.equal(run.receipts[0].status, expected); assert.equal(run.execution.commandAttempts, 1);
117
+ if (expected === 'ERROR') { assert.equal(C.candidateCount(run), 0); assert.equal(fs.existsSync(s.counter), false); }
118
+ }
119
+ });
120
+
121
+ test('legacy v1.2.6 result journals recover under an expired budget without restarting the run', async () => {
122
+ const s = await setup(), { name, journal } = await s.pending();
123
+ delete journal.runId; s.store.writeJSON(s.key, name, journal);
124
+ s.store.mutate(s.key, run => { run.createdAt -= 86400000; run.status = 'RECOVERY_REQUIRED'; return { ok: true }; });
125
+ const before = s.store.readJSON(s.key, 'run.json');
126
+ assert.equal(JSON.parse(await s.call('reconcile')).reconciled, true);
127
+ const run = s.store.readJSON(s.key, 'run.json');
128
+ assert.equal(run.createdAt, before.createdAt); assert.deepEqual(run.budget, before.budget);
129
+ assert.equal(run.status, 'RECOVERY_REQUIRED'); assert.equal(run.receipts.length, 1);
130
+ assert.equal(JSON.parse(await s.call('resume')).error, 'BUDGET_EXHAUSTED');
131
+ });
132
+
133
+ test('an executor retry after reconciliation cannot charge or append the same result again', async () => {
134
+ const s = await setup(), { journal } = await s.pending(); await s.call('reconcile');
135
+ const before = s.store.readJSON(s.key, 'run.json');
136
+ const retry = s.store.mutate(s.key, run => C.commitExecutionResult(run, journal));
137
+ assert.equal(retry.alreadyRecorded, true); assert.equal(retry.apply.candidate.counted, false);
138
+ assert.deepEqual(s.store.readJSON(s.key, 'run.json'), before);
139
+ });