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.
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,121 @@
|
|
|
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 { spawn } from 'node:child_process';
|
|
7
|
+
import * as C from '../src/controller.js';
|
|
8
|
+
import { F } from './helper.mjs';
|
|
9
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
test('actual host death cleans the stubborn owned check and leaves genuine recoverable ERROR evidence', async () => {
|
|
12
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1214-crash-')), dir = path.join(base, 'project');
|
|
13
|
+
fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'fixture');
|
|
14
|
+
const host = spawn(process.execPath, [path.join(import.meta.dirname, 'fixtures/durable-host.mjs'), base], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
15
|
+
let diagnostics = ''; host.stderr.on('data', d => diagnostics += d);
|
|
16
|
+
const exited = new Promise(resolve => host.once('exit', (code, signal) => resolve({ code, signal })));
|
|
17
|
+
let childPid;
|
|
18
|
+
try {
|
|
19
|
+
for (let i = 0; i < 500 && !fs.existsSync(path.join(base, 'child')); i++) await sleep(10);
|
|
20
|
+
assert.ok(fs.existsSync(path.join(base, 'child')), diagnostics);
|
|
21
|
+
childPid = Number(fs.readFileSync(path.join(base, 'child'), 'utf8'));
|
|
22
|
+
const runId = fs.readFileSync(path.join(base, 'run-id'), 'utf8');
|
|
23
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
24
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
25
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), runId);
|
|
26
|
+
const reserved = store.readJSON(key, 'run.json');
|
|
27
|
+
assert.equal(reserved.execution.inFlight.ownerPid, host.pid);
|
|
28
|
+
assert.equal(reserved.execution.inFlight.childPid, childPid);
|
|
29
|
+
host.kill('SIGKILL'); // Only our directly spawned offline host, never OpenCode/MTPLX.
|
|
30
|
+
assert.equal((await exited).signal, 'SIGKILL');
|
|
31
|
+
const token = reserved.execution.inFlight.token;
|
|
32
|
+
let journal;
|
|
33
|
+
for (let i = 0; i < 400; i++) {
|
|
34
|
+
journal = store.readJSON(key, `execution-${token}.json`);
|
|
35
|
+
if (journal && !C.execution.ownedWorkAlive(childPid)) break;
|
|
36
|
+
await sleep(10);
|
|
37
|
+
}
|
|
38
|
+
assert.ok(journal, 'worker must persist real execution evidence after owner death');
|
|
39
|
+
assert.equal(C.execution.ownedWorkAlive(childPid), false);
|
|
40
|
+
assert.equal(journal.result.terminationReason, 'owner_lost');
|
|
41
|
+
assert.equal(journal.receipt.status, 'ERROR');
|
|
42
|
+
assert.equal(store.readJSON(key, 'run.json').receipts.length, 0, 'worker journals; explicit reconciliation owns ledger commit');
|
|
43
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
44
|
+
const ctx = { sessionID: 'fresh-recovery', directory: dir, worktree: dir };
|
|
45
|
+
const call = async action => JSON.parse(await hooks.tool.longrun.execute({ action, runId }, ctx));
|
|
46
|
+
assert.equal((await call('reconcile')).reconciled, true);
|
|
47
|
+
const after = store.readJSON(key, 'run.json');
|
|
48
|
+
assert.equal(after.receipts.length, 1); assert.equal(after.receipts[0].status, 'ERROR');
|
|
49
|
+
assert.equal(after.execution.commandAttempts, 1); assert.equal(after.execution.inFlight, null);
|
|
50
|
+
assert.equal(after.execution.verificationMs, journal.result.finishedAt - journal.result.startedAt);
|
|
51
|
+
assert.deepEqual(after.budget, reserved.budget); assert.deepEqual(after.contract, reserved.contract);
|
|
52
|
+
assert.equal(after.createdAt, reserved.createdAt); assert.equal(after.autoEnabled, false);
|
|
53
|
+
assert.equal((await call('reconcile')).nothingPending, true);
|
|
54
|
+
assert.equal(store.readJSON(key, 'run.json').receipts.length, 1);
|
|
55
|
+
} finally {
|
|
56
|
+
// Baseline failure must not strand the deliberately stubborn fixture.
|
|
57
|
+
if (childPid && C.execution.ownedWorkAlive(childPid)) { try { process.kill(-childPid, 'SIGKILL'); } catch {} }
|
|
58
|
+
if (host.exitCode === null && host.signalCode === null) host.kill('SIGKILL');
|
|
59
|
+
await exited;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('host killed after actual completion preserves PASS and commits exactly once without rerunning', async () => {
|
|
64
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1214-finished-')), dir = path.join(base, 'project');
|
|
65
|
+
fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'fixture');
|
|
66
|
+
const host = spawn(process.execPath, [path.join(import.meta.dirname, 'fixtures/durable-host.mjs'), base, 'complete'], { stdio: 'ignore' });
|
|
67
|
+
const exited = new Promise(resolve => host.once('exit', (code, signal) => resolve({ code, signal })));
|
|
68
|
+
try {
|
|
69
|
+
for (let i = 0; i < 500 && !fs.existsSync(path.join(base, 'run-id')); i++) await sleep(10);
|
|
70
|
+
const runId = fs.readFileSync(path.join(base, 'run-id'), 'utf8');
|
|
71
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
72
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
73
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), runId);
|
|
74
|
+
let journal;
|
|
75
|
+
for (let i = 0; i < 500 && !journal; i++) {
|
|
76
|
+
const token = store.readJSON(key, 'run.json').execution.inFlight?.token;
|
|
77
|
+
if (token) journal = store.readJSON(key, `execution-${token}.json`);
|
|
78
|
+
if (!journal) await sleep(10);
|
|
79
|
+
}
|
|
80
|
+
assert.ok(journal); assert.equal(journal.receipt.status, 'PASS');
|
|
81
|
+
assert.equal(journal.result.status, 0); assert.match(journal.receipt.outputTail, /actual assertion passed/);
|
|
82
|
+
host.kill('SIGKILL'); assert.equal((await exited).signal, 'SIGKILL');
|
|
83
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
84
|
+
const ctx = { sessionID: 'after-crash', directory: dir, worktree: dir };
|
|
85
|
+
const reconcile = async () => JSON.parse(await hooks.tool.longrun.execute({ action: 'reconcile', runId }, ctx));
|
|
86
|
+
assert.equal((await reconcile()).reconciled, true);
|
|
87
|
+
const before = fs.readFileSync(store._file(key, 'run.json'));
|
|
88
|
+
assert.equal((await reconcile()).nothingPending, true);
|
|
89
|
+
assert.deepEqual(fs.readFileSync(store._file(key, 'run.json')), before);
|
|
90
|
+
const run = store.readJSON(key, 'run.json');
|
|
91
|
+
assert.equal(run.receipts.length, 1); assert.equal(run.execution.commandAttempts, 1);
|
|
92
|
+
assert.equal(run.execution.verificationMs, journal.result.finishedAt - journal.result.startedAt);
|
|
93
|
+
assert.equal(run.receipts[0].status, 'PASS');
|
|
94
|
+
} finally {
|
|
95
|
+
if (host.exitCode === null && host.signalCode === null) host.kill('SIGKILL');
|
|
96
|
+
await exited;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a live executor reservation cannot be bypassed before it records a child PID', async () => {
|
|
101
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1214-reservation-')), dir = path.join(base, 'project');
|
|
102
|
+
fs.mkdirSync(dir);
|
|
103
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
104
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
105
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
106
|
+
const ctx = { sessionID: 'reservation-test', directory: dir, worktree: dir };
|
|
107
|
+
const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Offline executor reservation fixture',
|
|
108
|
+
criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check: { command: [process.execPath, '-e', 'throw Error("must not launch")'] } } }, ctx));
|
|
109
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
110
|
+
const worker = spawn(process.execPath, ['-e', 'setTimeout(()=>{},10000)'], { stdio: 'ignore' });
|
|
111
|
+
const exited = new Promise(resolve => worker.once('exit', resolve));
|
|
112
|
+
try {
|
|
113
|
+
store.mutate(key, run => { run.execution.commandAttempts = 1;
|
|
114
|
+
run.execution.inFlight = { token: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', ownerPid: -1, executorPid: worker.pid, childPid: null,
|
|
115
|
+
checkId: 'check', mode: 'normal', startedAt: Date.now(), generation: 0 }; return { ok: true }; });
|
|
116
|
+
const before = fs.readFileSync(store._file(key, 'run.json'));
|
|
117
|
+
assert.equal(JSON.parse(await hooks.tool.longrun_verify.execute({ checkId: 'check', runId: start.runId }, ctx)).error, 'VERIFY_IN_FLIGHT');
|
|
118
|
+
assert.equal(JSON.parse(await hooks.tool.longrun.execute({ action: 'reconcile', runId: start.runId }, ctx)).error, 'VERIFY_IN_FLIGHT');
|
|
119
|
+
assert.deepEqual(fs.readFileSync(store._file(key, 'run.json')), before);
|
|
120
|
+
} finally { worker.kill('SIGTERM'); await exited; }
|
|
121
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
|
|
9
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
10
|
+
async function setup(t) {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1215-'));
|
|
12
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
13
|
+
const dir = path.join(root, 'project'); fs.mkdirSync(dir);
|
|
14
|
+
process.env.LONGRUN_STATE_DIR = path.join(root, 'state');
|
|
15
|
+
const hooks = await F('../plugin/longrun.js', { client: null });
|
|
16
|
+
const ctx = { sessionID: 'guidance', directory: dir, worktree: dir };
|
|
17
|
+
const call = async args => hooks.tool.longrun.execute(args, ctx);
|
|
18
|
+
const start = JSON.parse(await call({ action: 'start', request: 'Error guidance fixture', criteria: [{ id: 'assertion', checks: ['check'] }], checkCatalogue: { check: { command: ['node', '-e', 'process.exit(0)'] } } }));
|
|
19
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
20
|
+
return { call, store, key };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('actual rejected checkpoint shapes include usable correction and observed lifecycle without mutation', async t => {
|
|
24
|
+
const s = await setup(t);
|
|
25
|
+
for (const progress of ['recovered and paused', JSON.stringify({ summary: 'done', status: 'PAUSED' }), { nextAction: 'x'.repeat(1001) }]) {
|
|
26
|
+
const before = s.store.readJSON(s.key, 'run.json');
|
|
27
|
+
const result = JSON.parse(await s.call({ action: 'checkpoint', progress }));
|
|
28
|
+
assert.equal(result.error, 'INVALID_PROGRESS');
|
|
29
|
+
assert.equal(result.state, 'IMPLEMENTING');
|
|
30
|
+
assert.equal(result.continuation, false);
|
|
31
|
+
assert.equal(result.runId, before.runId);
|
|
32
|
+
assert.equal(result.unchanged, true);
|
|
33
|
+
assert.deepEqual(Object.keys(result.progressSchema.fields), ['currentSlice', 'nextAction', 'decisions', 'failedHypotheses', 'memoryNodes', 'artifacts']);
|
|
34
|
+
assert.equal(result.progressSchema.fields.nextAction.maxLength, 1000);
|
|
35
|
+
assert.equal(result.progressSchema.maxCombinedCharacters, 6000);
|
|
36
|
+
assert.match(result.lifecycleGuidance, /OFF.*does not.*PAUSED/);
|
|
37
|
+
assert.deepEqual(s.store.readJSON(s.key, 'run.json'), before);
|
|
38
|
+
const copy = structuredClone(before);
|
|
39
|
+
assert.equal(C.saveAgentProgress(copy, result.progressSchema.example).ok, true);
|
|
40
|
+
assert.equal(copy.status, before.status);
|
|
41
|
+
}
|
|
42
|
+
await s.call({ action: 'pause' });
|
|
43
|
+
const paused = JSON.parse(await s.call({ action: 'checkpoint', progress: { status: 'IMPLEMENTING' } }));
|
|
44
|
+
assert.equal(paused.state, 'PAUSED');
|
|
45
|
+
assert.equal(s.store.readJSON(s.key, 'run.json').status, 'PAUSED');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('help points to actual paginated receipts and identifies attempt cap scope', async t => {
|
|
49
|
+
const s = await setup(t), help = JSON.parse(await s.call({ action: 'help' }));
|
|
50
|
+
assert.match(help.receiptInspection, /receipts/);
|
|
51
|
+
assert.match(help.receiptInspection, /receiptId/);
|
|
52
|
+
assert.match(help.executionLimits, /toolActionCap.*declared.check attempts/);
|
|
53
|
+
assert.match(help.lifecycleGuidance, /OFF.*does not.*PAUSED/);
|
|
54
|
+
const state = JSON.parse(await s.call({ action: 'status' }));
|
|
55
|
+
assert.equal(state.state, 'IMPLEMENTING');
|
|
56
|
+
assert.match(state.lifecycleGuidance, /OFF.*does not.*PAUSED/);
|
|
57
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import * as C from '../src/controller.js';
|
|
5
|
+
|
|
6
|
+
test('recorded failing Vitest summary is readable without replacing historical acceptance evidence', () => {
|
|
7
|
+
const observed = JSON.parse(fs.readFileSync(new URL('./fixtures/vitest-failed-receipt.json', import.meta.url)));
|
|
8
|
+
const r = observed.receipt;
|
|
9
|
+
const run = { runId: observed.runId, contractHash: r.contractHash, receipts: [r] };
|
|
10
|
+
const before = JSON.stringify(run);
|
|
11
|
+
const detailed = C.receiptReadout(run, { receiptId: C.receiptReadout(run).receipts[0].receiptId }).receipt;
|
|
12
|
+
assert.equal(detailed.historicalStatus, 'FAIL');
|
|
13
|
+
assert.equal(detailed.testCount, 0);
|
|
14
|
+
assert.deepEqual(detailed.reportedTests, {
|
|
15
|
+
source: 'recorded_output_tail', runner: 'vitest', total: 15, passed: 14, failed: 1,
|
|
16
|
+
skipped: null, todo: null, summary: 'Tests 1 failed | 14 passed (15)',
|
|
17
|
+
});
|
|
18
|
+
assert.match(detailed.testCountMeaning, /zero.*not.*zero discovered/i);
|
|
19
|
+
assert.equal(JSON.stringify(run), before);
|
|
20
|
+
assert.equal(C.effectiveStatus(run, r.checkId, r.sourceFingerprint), 'FAIL');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('summary diagnostics decline ambiguous or incomplete output and never change acceptance', () => {
|
|
24
|
+
const report = outputTail => C.receiptReadout({ receipts: [{ checkId: 'c', status: 'NOT_RUN', testCount: 0, outputTail }] }).receipts[0];
|
|
25
|
+
for (const text of [undefined, '', 'Test Files 1 failed (1)', 'Error: Tests 1 failed (1)',
|
|
26
|
+
'Tests 1 failed | 14 passed (16)', 'Tests 1 failed (1)\nTests 2 passed (2)',
|
|
27
|
+
'Tests 1 failed | 14 unknown (15)', 'Tests 1 failed | 1 failed (2)']) {
|
|
28
|
+
assert.equal(report(text).reportedTests, null);
|
|
29
|
+
}
|
|
30
|
+
const ansi = report('\u001b[31m Tests 2 passed | 1 skipped (3)\u001b[0m');
|
|
31
|
+
assert.equal(ansi.reportedTests.total, 3);
|
|
32
|
+
assert.equal(ansi.reportedTests.passed, 2);
|
|
33
|
+
assert.equal(ansi.reportedTests.failed, null);
|
|
34
|
+
assert.equal(ansi.reportedTests.skipped, 1);
|
|
35
|
+
assert.equal(ansi.historicalStatus, 'NOT_RUN');
|
|
36
|
+
assert.equal(report('Tests 0 passed (0)').reportedTests.total, 0);
|
|
37
|
+
assert.equal(C.parseTestCounts('Tests 1 failed | 14 passed (15)'), 0);
|
|
38
|
+
assert.equal(C.makeReceipt({ exitCode: 0, testCount: 0, requirementKind: 'test' }).status, 'NOT_RUN');
|
|
39
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
9
|
+
const observed = JSON.parse(fs.readFileSync(new URL('./fixtures/notes-recovery-run.json', import.meta.url)));
|
|
10
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
11
|
+
|
|
12
|
+
async function setup(t) {
|
|
13
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1217-'));
|
|
14
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
15
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir);
|
|
16
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
17
|
+
const ctx = { sessionID: 'recovery', directory: dir, worktree: dir };
|
|
18
|
+
const hooks = await F('../plugin/longrun.js', ctx), store = new C.Store(process.env.LONGRUN_STATE_DIR);
|
|
19
|
+
// Actual failed-recovery record; only the fixture's project identity is relocated.
|
|
20
|
+
const run = structuredClone(observed); run.directory = dir;
|
|
21
|
+
const add = value => {
|
|
22
|
+
const key = C.stateKey(C.projectIdentity(value.directory), value.runId);
|
|
23
|
+
store.writeJSON(key, 'run.json', value); return key;
|
|
24
|
+
};
|
|
25
|
+
const key = add(run), bytes = () => fs.readFileSync(store._file(key, 'run.json'));
|
|
26
|
+
return { hooks, ctx, run, add, bytes, dir, base };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('copied native wrong-ID reads expose exact canonical recovery identity without changing the run', async t => {
|
|
30
|
+
const s = await setup(t), before = s.bytes();
|
|
31
|
+
for (const action of ['status', 'receipts', 'resume-context', 'resume', 'pause', 'cancel', 'complete']) {
|
|
32
|
+
const out = JSON.parse(await s.hooks.tool.longrun.execute({ action, runId: 'lr-20260920T195102Z' }, s.ctx));
|
|
33
|
+
assert.equal(out.state, 'NO_RUN'); assert.equal(out.runId, 'lr-20260920T195102Z');
|
|
34
|
+
assert.deepEqual(out.discovery.suggestedRead, { action: 'resume-context', runId: observed.runId });
|
|
35
|
+
assert.deepEqual(out.discovery.availableRuns, [{ runId: observed.runId, state: 'RECOVERY_REQUIRED' }]);
|
|
36
|
+
assert.match(out.discovery.detail, /never.*replacement/i);
|
|
37
|
+
assert.deepEqual(s.bytes(), before, action);
|
|
38
|
+
}
|
|
39
|
+
assert.equal(s.run.receipts.length, 6); assert.equal(s.run.state.candidates.length, 5);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('wrong-ID verification remains NO_RUN and only offers project-local discovery', async t => {
|
|
43
|
+
const s = await setup(t), before = s.bytes();
|
|
44
|
+
const foreign = path.join(s.base, 'foreign'); fs.mkdirSync(foreign);
|
|
45
|
+
s.add({ ...s.run, runId: 'lr-foreign-private', directory: foreign });
|
|
46
|
+
const out = JSON.parse(await s.hooks.tool.longrun_verify.execute({ runId: 'lr-foreign-private', checkId: 'c-notes-server' }, s.ctx));
|
|
47
|
+
assert.equal(out.error, 'NO_RUN'); assert.equal(out.ok, false);
|
|
48
|
+
assert.deepEqual(out.discovery.availableRuns, [{ runId: observed.runId, state: 'RECOVERY_REQUIRED' }]);
|
|
49
|
+
assert.deepEqual(out.discovery.suggestedRead, { action: 'resume-context', runId: observed.runId });
|
|
50
|
+
assert.deepEqual(s.bytes(), before);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('ambiguous recovery discovery is bounded and never recommends an arbitrary run', async t => {
|
|
54
|
+
const s = await setup(t), before = s.bytes();
|
|
55
|
+
for (let i = 0; i < 8; i++) s.add({ ...s.run, runId: `lr-extra-${i}` });
|
|
56
|
+
const out = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'status', runId: 'wrong' }, s.ctx));
|
|
57
|
+
assert.equal(out.discovery.totalRuns, 9); assert.equal(out.discovery.truncated, true);
|
|
58
|
+
assert.equal(out.discovery.availableRuns.length, 5); assert.equal(out.discovery.suggestedRead, null);
|
|
59
|
+
assert.match(out.discovery.detail, /multiple/i); assert.ok(JSON.stringify(out).length < 2200);
|
|
60
|
+
assert.deepEqual(s.bytes(), before);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('terminal-only and empty discovery do not invent resumable work', async t => {
|
|
64
|
+
const s = await setup(t);
|
|
65
|
+
s.add({ ...s.run, status: 'COMPLETE' });
|
|
66
|
+
let out = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'status', runId: 'wrong' }, s.ctx));
|
|
67
|
+
assert.deepEqual(out.discovery.suggestedRead, { action: 'status', runId: s.run.runId });
|
|
68
|
+
assert.match(out.discovery.detail, /terminal/i);
|
|
69
|
+
const empty = path.join(s.base, 'empty'); fs.mkdirSync(empty);
|
|
70
|
+
out = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'status', runId: 'wrong' }, { ...s.ctx, directory: empty, worktree: empty }));
|
|
71
|
+
assert.equal(out.discovery.totalRuns, 0); assert.equal(out.discovery.suggestedRead, null);
|
|
72
|
+
assert.deepEqual(out.discovery.availableRuns, []);
|
|
73
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
|
|
10
|
+
// Actual recorded, prematurely completed annotations trial. Read-only copied evidence;
|
|
11
|
+
// its real receipts are not fabricated, modified or written into a live store.
|
|
12
|
+
const actual = () => JSON.parse(fs.readFileSync(new URL('./fixtures/notes-premature-complete-run.json', import.meta.url)));
|
|
13
|
+
|
|
14
|
+
test('actual all-green annotations record distinguishes check success from missing independent completion review', () => {
|
|
15
|
+
const run = actual(), before = JSON.stringify(run);
|
|
16
|
+
const view = C.deriveRunView(run, { currentFingerprint: run.sourceFingerprint });
|
|
17
|
+
assert.equal(view.currentLoss, 0, 'retain the genuine declared-check calculation');
|
|
18
|
+
assert.ok(view.checks.every(c => c.effectiveStatus === 'PASS'));
|
|
19
|
+
assert.equal(view.completionBlocked, true, 'unreviewed checks cannot authorize completion');
|
|
20
|
+
assert.equal(view.blockReason, 'completion_review_required');
|
|
21
|
+
assert.equal(JSON.stringify(run), before, 'readouts preserve the recorded premature COMPLETE and all evidence');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('offline plugin tool factory refuses premature completion after real passing command without changing lifecycle or evidence', async () => {
|
|
25
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1218-review-'));
|
|
26
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'app.txt'), 'fixture');
|
|
27
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
28
|
+
const hooks = await F('../plugin/longrun.js', { client: null });
|
|
29
|
+
const ctx = { sessionID: 'offline-review', directory: dir, worktree: dir };
|
|
30
|
+
const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Complete a reviewed feature; PAUSE for independent review, do not COMPLETE from checks alone.', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check: { command: [process.execPath, '-e', "require('node:assert/strict').equal(2+2,4)"], kind: 'cmd' } } }, ctx));
|
|
31
|
+
assert.ok(start.runId);
|
|
32
|
+
const pass = JSON.parse(await hooks.tool.longrun_verify.execute({ runId: start.runId, checkId: 'check' }, ctx));
|
|
33
|
+
assert.equal(pass.status, 'PASS');
|
|
34
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
35
|
+
const before = store.readJSON(key, 'run.json');
|
|
36
|
+
const completed = JSON.parse(await hooks.tool.longrun.execute({ action: 'complete', runId: start.runId }, ctx));
|
|
37
|
+
assert.equal(completed.complete, false);
|
|
38
|
+
assert.equal(completed.blockReason, 'completion_review_required');
|
|
39
|
+
assert.deepEqual(store.readJSON(key, 'run.json'), before);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function fixture(t) {
|
|
43
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1218-operator-'));
|
|
44
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
45
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'app.txt'), 'valid');
|
|
46
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
47
|
+
const hooks = await F('../plugin/longrun.js', { client: null });
|
|
48
|
+
const ctx = { sessionID: 'review-fixture', directory: dir, worktree: dir };
|
|
49
|
+
const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Independent completion review fixture', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check: { command: [process.execPath, '-e', "require('node:assert/strict').equal(require('node:fs').readFileSync('app.txt','utf8'),'valid')"], kind: 'cmd' } } }, ctx));
|
|
50
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
51
|
+
const read = () => store.readJSON(key, 'run.json');
|
|
52
|
+
const call = async (action, extra = {}) => {
|
|
53
|
+
const value = await hooks.tool.longrun.execute({ action, runId: start.runId, ...extra }, ctx);
|
|
54
|
+
try { return JSON.parse(value); } catch { return value; }
|
|
55
|
+
};
|
|
56
|
+
const verify = async () => JSON.parse(await hooks.tool.longrun_verify.execute({ runId: start.runId, checkId: 'check' }, ctx));
|
|
57
|
+
assert.equal((await verify()).status, 'PASS');
|
|
58
|
+
await call('pause');
|
|
59
|
+
const args = (verdict = 'accept', reviewId = 'operator-review-1') => ({ directory: dir, runId: start.runId, verdict, reviewId,
|
|
60
|
+
reason: 'Independent offline fixture review', expectedBasis: C.completionReviewBasis(read(), C.sourceFingerprint(dir)) });
|
|
61
|
+
return { base, dir, store, key, read, call, verify, args, hooks, ctx, start };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('real checks plus paused operator approval permit completion; a model payload does not', async t => {
|
|
65
|
+
const s = await fixture(t), before = s.read();
|
|
66
|
+
assert.equal((await s.call('complete', { completionReview: { verdict: 'accept' } })).complete, false);
|
|
67
|
+
assert.equal((await s.call('checkpoint', { progress: { completionReview: 'accepted' } })).error, 'INVALID_PROGRESS');
|
|
68
|
+
assert.deepEqual(s.read(), before);
|
|
69
|
+
const args = s.args();
|
|
70
|
+
assert.equal((await C.operatorCompletionReview(s.store, s.key, args)).ok, true);
|
|
71
|
+
assert.equal((await C.operatorCompletionReview(s.store, s.key, args)).alreadyRecorded, true);
|
|
72
|
+
assert.equal(s.read().completionReviews.length, 1);
|
|
73
|
+
assert.equal((await s.call('complete')).complete, true);
|
|
74
|
+
assert.equal(s.read().status, 'COMPLETE');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('approval binds source, request, contract, catalogue, budgets and genuine evidence', async t => {
|
|
78
|
+
const s = await fixture(t);
|
|
79
|
+
await C.operatorCompletionReview(s.store, s.key, s.args());
|
|
80
|
+
const run = s.read(), fp = C.sourceFingerprint(s.dir);
|
|
81
|
+
assert.equal(C.canComplete(run, { currentFingerprint: fp }).complete, true);
|
|
82
|
+
for (const mutate of [r => r.originalRequest += ' changed', r => r.contract.extra = true,
|
|
83
|
+
r => r.checkCatalogue.check.timeoutMs = 99, r => r.budget.iterations++, r => r.createdAt--,
|
|
84
|
+
r => r.receipts.push({ ...r.receipts[0], finishedAt: Date.now() }), r => r.evidence = [{ kind: 'negative_control', ok: false }]]) {
|
|
85
|
+
const clone = structuredClone(run); mutate(clone);
|
|
86
|
+
assert.equal(C.completionReviewStatus(clone, fp).status, 'STALE');
|
|
87
|
+
assert.equal(C.canComplete(clone, { currentFingerprint: fp }).complete, false);
|
|
88
|
+
}
|
|
89
|
+
fs.writeFileSync(path.join(s.dir, 'app.txt'), 'changed');
|
|
90
|
+
assert.equal(C.completionReviewStatus(run, C.sourceFingerprint(s.dir)).status, 'STALE');
|
|
91
|
+
fs.writeFileSync(path.join(s.dir, 'app.txt'), 'valid');
|
|
92
|
+
await s.call('resume'); await s.verify();
|
|
93
|
+
assert.equal((await s.call('complete')).blockReason, 'completion_review_stale');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('completion recomputes source under the writer lock instead of trusting its initial view', async t => {
|
|
97
|
+
const s = await fixture(t); await C.operatorCompletionReview(s.store, s.key, s.args());
|
|
98
|
+
const original = C.Store.prototype.mutate; let changed = false;
|
|
99
|
+
C.Store.prototype.mutate = function(key, callback) {
|
|
100
|
+
if (key === s.key && !changed) { changed = true; fs.writeFileSync(path.join(s.dir, 'app.txt'), 'changed'); }
|
|
101
|
+
return original.call(this, key, callback);
|
|
102
|
+
};
|
|
103
|
+
try { assert.equal((await s.call('complete')).error, 'EVIDENCE_CHANGED'); }
|
|
104
|
+
finally { C.Store.prototype.mutate = original; }
|
|
105
|
+
assert.equal(s.read().status, 'PAUSED');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('rejecting copied premature COMPLETE archives every field and preserves exhausted budgets', async t => {
|
|
109
|
+
const s = await fixture(t), run = actual();
|
|
110
|
+
run.directory = s.dir; run.runId = s.start.runId;
|
|
111
|
+
run.createdAt = Date.now() - run.budget.deadlineSeconds * 1000 - 1000;
|
|
112
|
+
run.budget.iterations = C.candidateCount(run);
|
|
113
|
+
s.store.writeJSON(s.key, 'run.json', run);
|
|
114
|
+
const before = s.read(), args = s.args('reject');
|
|
115
|
+
const result = await C.operatorCompletionReview(s.store, s.key, args);
|
|
116
|
+
assert.equal(result.ok, true); assert.equal(result.state, 'PAUSED');
|
|
117
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(result.archivedSnapshot)), before);
|
|
118
|
+
const after = s.read();
|
|
119
|
+
for (const key of Object.keys(before).filter(k => !['status', 'autoEnabled', 'controlGeneration', 'completionReviews'].includes(k)))
|
|
120
|
+
assert.deepEqual(after[key], before[key], key);
|
|
121
|
+
assert.equal(after.autoEnabled, false); assert.equal(after.controlGeneration, before.controlGeneration + 1);
|
|
122
|
+
assert.equal(after.completionReviews[0].previousState, 'COMPLETE');
|
|
123
|
+
const resumed = await s.call('resume'); assert.equal(resumed.error, 'BUDGET_EXHAUSTED'); assert.equal(resumed.spent.deadline, true);
|
|
124
|
+
assert.equal(s.read().receipts.length, 26);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('archive conflict, cancellation, in-flight work, stale basis and unready checks fail closed', async t => {
|
|
128
|
+
const s = await fixture(t);
|
|
129
|
+
const check = async (mutate, expected, verdict = 'accept', adjust = x => x) => {
|
|
130
|
+
const original = s.read(); const run = structuredClone(original); mutate(run); s.store.writeJSON(s.key, 'run.json', run);
|
|
131
|
+
const args = adjust(s.args(verdict)); const before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
132
|
+
assert.equal((await C.operatorCompletionReview(s.store, s.key, args)).error, expected);
|
|
133
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
134
|
+
s.store.writeJSON(s.key, 'run.json', original);
|
|
135
|
+
};
|
|
136
|
+
await check(r => r.status = 'CANCELLED', 'RUN_CANCELLED', 'reject');
|
|
137
|
+
await check(r => r.execution.inFlight = { token: 'live' }, 'VERIFY_IN_FLIGHT');
|
|
138
|
+
await check(r => r.receipts = [], 'REVIEW_CHECKS_NOT_READY');
|
|
139
|
+
await check(r => r.status = 'IMPLEMENTING', 'REVIEW_REQUIRES_PAUSE');
|
|
140
|
+
await check(() => {}, 'REVIEW_BASIS_CHANGED', 'accept', a => ({ ...a, expectedBasis: '0'.repeat(64) }));
|
|
141
|
+
await check(() => {}, 'INVALID_COMPLETION_REVIEW', 'accept', a => ({ ...a, reviewId: '../outside' }));
|
|
142
|
+
const archive = s.store._file(s.key, 'completion-review-operator-review-1.json'); fs.writeFileSync(archive, 'do not overwrite');
|
|
143
|
+
await check(r => r.status = 'COMPLETE', 'REVIEW_ARCHIVE_FAILED', 'reject');
|
|
144
|
+
assert.equal(fs.readFileSync(archive, 'utf8'), 'do not overwrite');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('operator review uses the existing writer lock and cannot reopen beside a newer task', async t => {
|
|
148
|
+
const s = await fixture(t), before = s.read();
|
|
149
|
+
assert.equal(s.store.tryLock(s.key, 'held'), true);
|
|
150
|
+
try { assert.equal((await C.operatorCompletionReview(s.store, s.key, s.args())).error, 'STATE_BUSY'); }
|
|
151
|
+
finally { s.store.releaseLock(s.key); }
|
|
152
|
+
assert.deepEqual(s.read(), before);
|
|
153
|
+
const run = s.read(); run.status = 'COMPLETE'; s.store.writeJSON(s.key, 'run.json', run);
|
|
154
|
+
const other = structuredClone(run); other.runId = 'another-active-run'; other.status = 'IMPLEMENTING';
|
|
155
|
+
s.store.writeJSON(C.stateKey(C.projectIdentity(s.dir), other.runId), 'run.json', other);
|
|
156
|
+
assert.equal((await C.operatorCompletionReview(s.store, s.key, s.args('reject'))).error, 'EXISTING_RUN');
|
|
157
|
+
assert.deepEqual(s.read(), run);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('failed canonical write preserves the archive but grants no review or lifecycle change', async t => {
|
|
161
|
+
const s = await fixture(t), run = s.read(); run.status = 'COMPLETE'; s.store.writeJSON(s.key, 'run.json', run);
|
|
162
|
+
const args = s.args('reject'), before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
163
|
+
const write = s.store.writeJSON.bind(s.store);
|
|
164
|
+
s.store.writeJSON = (key, name, value) => { if (name === 'run.json') throw new Error('injected write failure'); return write(key, name, value); };
|
|
165
|
+
await assert.rejects(C.operatorCompletionReview(s.store, s.key, args), /injected write failure/);
|
|
166
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
167
|
+
const archive = s.store._file(s.key, 'completion-review-operator-review-1.json');
|
|
168
|
+
assert.deepEqual(fs.readFileSync(archive), before);
|
|
169
|
+
s.store.writeJSON = write;
|
|
170
|
+
assert.equal((await C.operatorCompletionReview(s.store, s.key, args)).ok, true);
|
|
171
|
+
assert.deepEqual(fs.readFileSync(archive), before, 'retry never overwrites a different historical snapshot');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('concurrent native new-task admission and operator rejection share one project slot', async t => {
|
|
175
|
+
const s = await fixture(t), run = s.read(); run.status = 'COMPLETE'; s.store.writeJSON(s.key, 'run.json', run);
|
|
176
|
+
const args = s.args('reject');
|
|
177
|
+
const [review, started] = await Promise.all([
|
|
178
|
+
C.operatorCompletionReview(s.store, s.key, args),
|
|
179
|
+
s.hooks.tool.longrun.execute({ action: 'start', request: 'different task', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: run.checkCatalogue }, { ...s.ctx, sessionID: 'different-task' }).then(JSON.parse),
|
|
180
|
+
]);
|
|
181
|
+
const runs = fs.readdirSync(path.join(s.store.dir, 'state')).filter(x => /^[a-f0-9]{32}$/.test(x)).map(key => s.store.readRun(key).run);
|
|
182
|
+
assert.equal(runs.filter(r => !C.RUN_TERMINAL_STATES.includes(r.status)).length, 1);
|
|
183
|
+
assert.ok(review.ok ? started.error === 'EXISTING_RUN' : review.error === 'EXISTING_RUN' && started.runId);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('reviewed completion can be explicitly rejected and resumed with original identity and limits', async t => {
|
|
187
|
+
const s = await fixture(t);
|
|
188
|
+
await C.operatorCompletionReview(s.store, s.key, s.args());
|
|
189
|
+
assert.equal((await s.call('complete')).complete, true);
|
|
190
|
+
const before = s.read();
|
|
191
|
+
assert.equal((await s.call('resume')).error, 'RUN_COMPLETE');
|
|
192
|
+
const rejected = await C.operatorCompletionReview(s.store, s.key, { ...s.args('reject', 'operator-review-2'), reason: 'Independent review discovered an unmet requirement.' });
|
|
193
|
+
assert.equal(rejected.ok, true); assert.deepEqual(JSON.parse(fs.readFileSync(rejected.archivedSnapshot)), before);
|
|
194
|
+
assert.equal((await s.call('complete')).blockReason, 'completion_review_rejected');
|
|
195
|
+
assert.equal((await s.call('resume')).resumed, true);
|
|
196
|
+
assert.equal((await s.verify()).status, 'PASS');
|
|
197
|
+
const after = s.read();
|
|
198
|
+
for (const key of ['runId','originalRequest','contract','contractHash','budget','createdAt']) assert.deepEqual(after[key], before[key]);
|
|
199
|
+
assert.equal(after.receipts.length, before.receipts.length + 1);
|
|
200
|
+
assert.deepEqual(after.receipts.slice(0, -1), before.receipts);
|
|
201
|
+
assert.equal(after.completionReviews.length, 2); assert.equal(after.autoEnabled, false);
|
|
202
|
+
assert.equal((await s.call('complete')).complete, false);
|
|
203
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
10
|
+
async function fixture(t, budget = {}) {
|
|
11
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1219-'));
|
|
12
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
13
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'baseline');
|
|
14
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
15
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
16
|
+
const ctx = { sessionID: 'budget-fixture', directory: dir, worktree: dir };
|
|
17
|
+
const command = [process.execPath, '-e', "require('node:assert/strict').match(require('node:fs').readFileSync('value.txt','utf8'),/^valid/)"];
|
|
18
|
+
const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Isolated budget-refusal stopping regression', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check: { command, kind: 'cmd', gate: true } }, ...budget }, ctx));
|
|
19
|
+
assert.ok(start.runId);
|
|
20
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
21
|
+
const read = () => store.readJSON(key, 'run.json');
|
|
22
|
+
const verify = async (args = {}) => JSON.parse(await hooks.tool.longrun_verify.execute({ runId: start.runId, checkId: 'check', ...args }, ctx));
|
|
23
|
+
return { base, dir, hooks, ctx, start, store, key, read, verify };
|
|
24
|
+
}
|
|
25
|
+
function preservedPause(before, after) {
|
|
26
|
+
assert.equal(after.status, 'PAUSED'); assert.equal(after.autoEnabled, false);
|
|
27
|
+
assert.equal(after.controlGeneration, (before.controlGeneration || 0) + 1);
|
|
28
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
29
|
+
if (!['status', 'autoEnabled', 'controlGeneration'].includes(key)) assert.deepEqual(after[key], before[key], key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('actual copied annotations refusal pauses without rewriting 24 candidates, 37 receipts or missing evidence', async t => {
|
|
34
|
+
const s = await fixture(t);
|
|
35
|
+
const run = JSON.parse(fs.readFileSync(new URL('./fixtures/notes-budget-exhausted-run.json', import.meta.url)));
|
|
36
|
+
assert.equal(run.status, 'IMPLEMENTING'); assert.equal(C.candidateCount(run), 24); assert.equal(run.receipts.length, 37);
|
|
37
|
+
// Relocation only: genuine catalogue and evidence remain historical. Budget
|
|
38
|
+
// refusal prevents these application commands from running in this tiny copy.
|
|
39
|
+
run.directory = s.dir; run.runId = s.start.runId;
|
|
40
|
+
s.store.writeJSON(s.key, 'run.json', run);
|
|
41
|
+
// A fresh factory discovers canonical catalogue instead of fixture routing.
|
|
42
|
+
fs.writeFileSync(path.join(s.store.dir, 'runs.json'), '{}');
|
|
43
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: s.dir, worktree: s.dir });
|
|
44
|
+
const result = JSON.parse(await hooks.tool.longrun_verify.execute({ runId: run.runId, checkId: Object.keys(run.checkCatalogue)[0] }, { ...s.ctx, sessionID: 'fresh-copy' }));
|
|
45
|
+
assert.equal(result.error, 'BUDGET_EXHAUSTED'); assert.equal(result.spent.candidates, true);
|
|
46
|
+
assert.equal(result.state, 'PAUSED'); assert.equal(result.cancelledContinuations, true);
|
|
47
|
+
preservedPause(run, s.read());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('last counted candidate can finish checks; new source refusal stops edits across stale routing and fresh hosts', async t => {
|
|
51
|
+
const s = await fixture(t, { candidateBudget: 1 });
|
|
52
|
+
fs.writeFileSync(path.join(s.dir, 'value.txt'), 'valid-one');
|
|
53
|
+
assert.equal((await s.verify()).status, 'PASS');
|
|
54
|
+
assert.equal((await s.verify()).status, 'PASS');
|
|
55
|
+
assert.equal(s.read().status, 'IMPLEMENTING'); assert.equal(C.candidateCount(s.read()), 1);
|
|
56
|
+
fs.writeFileSync(path.join(s.dir, 'value.txt'), 'valid-two');
|
|
57
|
+
const before = s.read();
|
|
58
|
+
assert.equal((await s.verify()).error, 'BUDGET_EXHAUSTED');
|
|
59
|
+
preservedPause(before, s.read());
|
|
60
|
+
const frozen = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
61
|
+
assert.equal((await s.verify()).error, 'RUN_PAUSED');
|
|
62
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), frozen);
|
|
63
|
+
const fresh = await F('../plugin/longrun.js', { client: null, directory: s.dir, worktree: s.dir });
|
|
64
|
+
for (const [hooks, sessionID] of [[s.hooks, s.ctx.sessionID], [fresh, 'fresh-unbound']]) {
|
|
65
|
+
for (const tool of ['edit', 'bash', 'task']) await assert.rejects(hooks['tool.execute.before']({ tool, sessionID }, { args: {} }), /LONGRUN_RUN_PAUSED/);
|
|
66
|
+
await hooks['tool.execute.before']({ tool: 'longrun', sessionID }, { args: { action: 'status' } });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('deadline, attempt and active-time refusals pause normal and negative checks without spending anything', async t => {
|
|
71
|
+
for (const reason of ['deadline', 'toolActions', 'activeSeconds']) {
|
|
72
|
+
const s = await fixture(t), run = s.read();
|
|
73
|
+
if (reason === 'deadline') run.createdAt -= run.budget.deadlineSeconds * 1000 + 1000;
|
|
74
|
+
if (reason === 'toolActions') run.budget.toolActionCap = run.execution.commandAttempts;
|
|
75
|
+
if (reason === 'activeSeconds') run.budget.activeSeconds = 0;
|
|
76
|
+
s.store.writeJSON(s.key, 'run.json', run);
|
|
77
|
+
const dir = path.join(s.base, 'negative'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'invalid');
|
|
78
|
+
const result = await s.verify(reason === 'deadline' ? { mode: 'negative', fixture: dir } : {});
|
|
79
|
+
assert.equal(result.error, 'BUDGET_EXHAUSTED'); assert.equal(result.spent[reason], true);
|
|
80
|
+
preservedPause(run, s.read());
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('terminal, paused and live or orphaned reservations take precedence over exhaustion', async t => {
|
|
85
|
+
for (const [status, inFlight, expected] of [
|
|
86
|
+
['PAUSED', null, 'RUN_PAUSED'], ['COMPLETE', null, 'RUN_COMPLETE'], ['CANCELLED', null, 'RUN_CANCELLED'],
|
|
87
|
+
['IMPLEMENTING', { ownerPid: process.pid }, 'VERIFY_IN_FLIGHT'],
|
|
88
|
+
['IMPLEMENTING', { ownerPid: -1, childPid: null }, 'EXECUTION_RECOVERY_REQUIRED'],
|
|
89
|
+
]) {
|
|
90
|
+
const s = await fixture(t), run = s.read();
|
|
91
|
+
run.status = status; run.budget.activeSeconds = 0; run.execution.inFlight = inFlight;
|
|
92
|
+
s.store.writeJSON(s.key, 'run.json', run);
|
|
93
|
+
const before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
94
|
+
assert.equal((await s.verify()).error, expected);
|
|
95
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('refusal uses writer lock and never reports a pause if canonical write fails', async t => {
|
|
100
|
+
const s = await fixture(t), run = s.read(); run.budget.activeSeconds = 0; s.store.writeJSON(s.key, 'run.json', run);
|
|
101
|
+
const before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
102
|
+
s.store.tryLock(s.key, 'concurrent-writer');
|
|
103
|
+
try { assert.equal((await s.verify()).error, 'STATE_BUSY'); }
|
|
104
|
+
finally { s.store.releaseLock(s.key); }
|
|
105
|
+
const write = C.Store.prototype.writeJSON;
|
|
106
|
+
C.Store.prototype.writeJSON = function(key, name, value) {
|
|
107
|
+
if (key === s.key && name === 'run.json') throw new Error('injected canonical write failure');
|
|
108
|
+
return write.call(this, key, name, value);
|
|
109
|
+
};
|
|
110
|
+
try { await assert.rejects(s.verify(), /injected canonical write failure/); }
|
|
111
|
+
finally { C.Store.prototype.writeJSON = write; }
|
|
112
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
113
|
+
assert.equal(s.store.whoHoldsLock(s.key), null);
|
|
114
|
+
assert.equal((await s.verify()).state, 'PAUSED');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a concurrent cancellation wins; competing budget refusals increment pause generation only once', async t => {
|
|
118
|
+
const s = await fixture(t), initial = s.read(); initial.budget.activeSeconds = 0; s.store.writeJSON(s.key, 'run.json', initial);
|
|
119
|
+
const mutate = C.Store.prototype.mutate; let intercepted = false;
|
|
120
|
+
C.Store.prototype.mutate = function(key, callback) {
|
|
121
|
+
if (key === s.key && !intercepted) {
|
|
122
|
+
intercepted = true;
|
|
123
|
+
mutate.call(this, key, run => { run.status = 'CANCELLED'; return { ok: true }; });
|
|
124
|
+
}
|
|
125
|
+
return mutate.call(this, key, callback);
|
|
126
|
+
};
|
|
127
|
+
try { assert.equal((await s.verify()).error, 'RUN_CANCELLED'); }
|
|
128
|
+
finally { C.Store.prototype.mutate = mutate; }
|
|
129
|
+
assert.deepEqual(s.read(), { ...initial, status: 'CANCELLED' });
|
|
130
|
+
s.store.writeJSON(s.key, 'run.json', initial);
|
|
131
|
+
const results = await Promise.all([s.verify(), s.verify()]);
|
|
132
|
+
assert.deepEqual(results.map(r => r.error).sort(), ['BUDGET_EXHAUSTED', 'RUN_PAUSED']);
|
|
133
|
+
preservedPause(initial, s.read());
|
|
134
|
+
});
|