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,435 @@
1
+ // Recovery-runner tests: the operator-side supervised-resume tool.
2
+ // These are OFFLINE tests of the decision logic and the CLI's refusal paths. They deliberately do not
3
+ // dispatch a model turn (no --skip-model-check path reaches a spawn), so no host is started here.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { decideRecovery, evaluateOutcome, resumePrompt, freshSessionPrompt, extractSessionId, applyRebindGuard, dispatchLogStem, defaultMaintenanceBin, findRunKey, sessionForRun, boundSessions, hostLive, pauseViaMaintenance, inferenceBaseFromConfig, configFileSupplyingBase, resolveInferenceBase, parseListeners, isMtplxRuntime, isLocalModelRuntime, requiredModel, requiredProviderModel, opencodeBin, runtimeMatcher, DEFAULT_REQUIRED_MODEL, DEFAULT_OPENCODE_BIN, DEFAULT_RUNTIME_MATCHER, discoverMtplxBases, inferenceCandidates, pickServedBase, pinnedConfigContent, DEFAULT_INFERENCE_BASE, REQUIRED_MODEL, REQUIRED_PROVIDER_MODEL } from '../tools/recovery-runner.mjs';
11
+
12
+ const RUNNER = path.resolve(import.meta.dirname, '..', 'tools', 'recovery-runner.mjs');
13
+
14
+ function stateFixture(t, run, runs) {
15
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr-runner-'));
16
+ t.after(() => fs.rmSync(base, { recursive: true, force: true }));
17
+ const key = 'a'.repeat(32);
18
+ fs.mkdirSync(path.join(base, 'state', key), { recursive: true });
19
+ fs.writeFileSync(path.join(base, 'state', key, 'run.json'), JSON.stringify(run));
20
+ fs.writeFileSync(path.join(base, 'runs.json'), JSON.stringify(runs || {}));
21
+ return { base, key };
22
+ }
23
+
24
+ test('refuses to dispatch beside a live host, in-flight work, or the wrong status', () => {
25
+ assert.equal(decideRecovery({ status: 'RECOVERY_REQUIRED', hostLive: true, sessionId: 's' }).reason, 'HOST_LIVE');
26
+ assert.equal(decideRecovery({ status: 'RECOVERY_REQUIRED', inFlight: { token: 't' }, sessionId: 's' }).reason, 'VERIFY_IN_FLIGHT');
27
+ assert.equal(decideRecovery({ status: 'IMPLEMENTING', sessionId: 's' }).reason, 'NOT_RECOVERY_REQUIRED');
28
+ assert.equal(decideRecovery({ status: 'PAUSED', sessionId: 's' }).reason, 'NOT_RECOVERY_REQUIRED');
29
+ assert.equal(decideRecovery({ status: 'RECOVERY_REQUIRED' }).reason, 'NO_SESSION_BINDING', 'no binding means a new host cannot reach the same session');
30
+ assert.equal(decideRecovery({ status: 'RECOVERY_REQUIRED', sessionId: 's', attempt: 2, maxAttempts: 2 }).action, 'give_up');
31
+ });
32
+
33
+ test('dispatches only for a bound RECOVERY_REQUIRED run and counts attempts', () => {
34
+ const first = decideRecovery({ status: 'RECOVERY_REQUIRED', sessionId: 's', attempt: 0, maxAttempts: 2 });
35
+ assert.equal(first.action, 'dispatch'); assert.equal(first.attempt, 1);
36
+ const second = decideRecovery({ status: 'RECOVERY_REQUIRED', sessionId: 's', attempt: 1, maxAttempts: 2 });
37
+ assert.equal(second.action, 'dispatch'); assert.equal(second.attempt, 2);
38
+ assert.equal(decideRecovery({ status: 'PAUSED', sessionId: 's', allowStatus: ['PAUSED'] }).action, 'dispatch', 'explicit opt-in statuses are honoured');
39
+ });
40
+
41
+ test('outcome evaluation only reports recovered when the run actually left RECOVERY_REQUIRED', () => {
42
+ assert.deepEqual(evaluateOutcome({ status: 'RECOVERY_REQUIRED' }), { recovered: false, retry: true, status: 'RECOVERY_REQUIRED' });
43
+ assert.equal(evaluateOutcome({ status: 'IMPLEMENTING' }).recovered, true);
44
+ assert.equal(evaluateOutcome({ status: 'PAUSED' }).recovered, true);
45
+ assert.equal(evaluateOutcome({ status: 'RECOVERY_REQUIRED', inFlight: { token: 'x' } }).retry, false, 'an in-flight check must not be retried over');
46
+ });
47
+
48
+ test('a terminal run counts as a successful ending and is never asked to rebind', () => {
49
+ // Regression: the completion dispatch drove the run to COMPLETE, then the rebind guard scored that
50
+ // success as NO_REBIND, retried, and exited ATTEMPT_LIMIT — reporting a fully completed run as a
51
+ // runner failure. A terminal run cannot legitimately be adopted, so it must not be required to be.
52
+ for (const status of ['COMPLETE', 'CANCELLED', 'BLOCKED']) {
53
+ const outcome = evaluateOutcome({ status });
54
+ assert.equal(outcome.recovered, true, `${status} is an ending, not a failure`);
55
+ assert.equal(outcome.retry, false, `${status} must not be retried`);
56
+ assert.equal(outcome.terminal, true, `${status} is marked terminal`);
57
+ // even with no rebound session at all, the guard must pass it through unchanged
58
+ const guarded = applyRebindGuard(outcome, { newSessionId: null, boundSessions: [], compactionSessionID: null });
59
+ assert.equal(guarded.outcome.recovered, true, `${status} must survive the rebind guard`);
60
+ assert.notEqual(guarded.outcome.reason, 'NO_REBIND', `${status} must not be scored NO_REBIND`);
61
+ assert.equal(guarded.rebound, false);
62
+ }
63
+ // a NON-terminal success still requires proof that the new conversation adopted the run
64
+ const paused = evaluateOutcome({ status: 'PAUSED' });
65
+ assert.equal(paused.terminal, undefined);
66
+ assert.equal(applyRebindGuard(paused, { newSessionId: null, boundSessions: [] }).outcome.reason, 'NO_REBIND',
67
+ 'a non-terminal turn that proved nothing must still be rejected');
68
+ });
69
+
70
+ test('the inference endpoint is discovered from config, not hardcoded to one port', () => {
71
+ // Regression: the port was a literal. When the environment moved the required model from :8000 to
72
+ // :8001 while another local router took :8000, the identity check queried the wrong server and
73
+ // reported MODEL_MISMATCH even though the required model WAS being served — a false alarm that
74
+ // blocks every dispatch. The endpoint must follow the live OpenCode config, which is also what the
75
+ // dispatched session itself uses.
76
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'longrun-cfg-'));
77
+ try {
78
+ assert.equal(inferenceBaseFromConfig({ configDir: dir }), null, 'no config => no derived base');
79
+ assert.equal(resolveInferenceBase({ configDir: dir }), DEFAULT_INFERENCE_BASE,
80
+ 'falls back to the historic default only when nothing else is configured');
81
+
82
+ fs.writeFileSync(path.join(dir, 'opencode.json'), JSON.stringify({
83
+ model: 'mtplx/mtplx-flash-next-optimized-speed',
84
+ provider: { mtplx: { options: { baseURL: 'http://127.0.0.1:8001/v1' } } },
85
+ }));
86
+ assert.equal(inferenceBaseFromConfig({ configDir: dir }), 'http://127.0.0.1:8001/v1',
87
+ 'reads the live baseURL from the OpenCode config');
88
+ assert.equal(resolveInferenceBase({ configDir: dir }), 'http://127.0.0.1:8001/v1');
89
+ // a trailing slash must not produce a doubled path when /models is appended
90
+ fs.writeFileSync(path.join(dir, 'opencode.json'), JSON.stringify({
91
+ provider: { mtplx: { options: { baseURL: 'http://127.0.0.1:8001/v1///' } } },
92
+ }));
93
+ assert.equal(resolveInferenceBase({ configDir: dir }), 'http://127.0.0.1:8001/v1');
94
+
95
+ // explicit overrides win, so an operator can point at a non-default endpoint deliberately
96
+ assert.equal(resolveInferenceBase({ flagValue: 'http://127.0.0.1:9999/v1', configDir: dir }),
97
+ 'http://127.0.0.1:9999/v1', 'an explicit flag wins over the config');
98
+ assert.equal(resolveInferenceBase({ env: { LONGRUN_MODEL_BASE: 'http://127.0.0.1:7777/v1' }, configDir: dir }),
99
+ 'http://127.0.0.1:7777/v1', 'the env override wins over the config');
100
+
101
+ // a malformed config must not crash the resolver or silently invent an endpoint
102
+ fs.writeFileSync(path.join(dir, 'opencode.json'), '{ not json');
103
+ assert.equal(resolveInferenceBase({ configDir: dir }), DEFAULT_INFERENCE_BASE);
104
+ } finally {
105
+ fs.rmSync(dir, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ test('a dispatch pins the config file the endpoint check actually read', () => {
110
+ // Regression: OpenCode MERGES every config file it finds. With opencode.json naming :8001 and a
111
+ // stale opencode.jsonc still naming :8000, the identity check read :8001 and passed, then the
112
+ // session was dispatched against :8000 and 404'd with "model not found". The check and the dispatch
113
+ // must use the same endpoint, so the runner pins the resolved file via OPENCODE_CONFIG_CONTENT.
114
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'longrun-pin-'));
115
+ try {
116
+ assert.equal(configFileSupplyingBase({ configDir: dir }), null, 'nothing to pin when no config defines an endpoint');
117
+
118
+ const good = path.join(dir, 'opencode.json');
119
+ fs.writeFileSync(good, JSON.stringify({ provider: { mtplx: { options: { baseURL: 'http://127.0.0.1:8001/v1' } } } }));
120
+ assert.equal(configFileSupplyingBase({ configDir: dir }), good, 'pins the .json that supplies the endpoint');
121
+
122
+ // the stale sibling must NOT be chosen while the .json still defines a baseURL
123
+ fs.writeFileSync(path.join(dir, 'opencode.jsonc'), JSON.stringify({ provider: { mtplx: { options: { baseURL: 'http://127.0.0.1:8000/v1' } } } }));
124
+ assert.equal(configFileSupplyingBase({ configDir: dir }), good, '.json keeps precedence over the stale .jsonc');
125
+
126
+ // if only the .jsonc remains, it is what the check reads — so it is what must be pinned
127
+ fs.rmSync(good);
128
+ assert.equal(configFileSupplyingBase({ configDir: dir }), path.join(dir, 'opencode.jsonc'));
129
+ assert.equal(resolveInferenceBase({ configDir: dir }), 'http://127.0.0.1:8000/v1');
130
+ } finally {
131
+ fs.rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ test('the supervised resume prompt is bounded and preserves the safety invariants', () => {
136
+ const p = resumePrompt('lr-test');
137
+ for (const needle of ['lr-test', 'resume-context', 'action=resume', 'Do not start a new run', 'keep automatic continuation OFF', 'do not COMPLETE']) {
138
+ assert.ok(p.includes(needle), `prompt should mention: ${needle}`);
139
+ }
140
+ });
141
+
142
+ test('state helpers locate the run key and its session binding', t => {
143
+ const { base } = stateFixture(t, { runId: 'lr-x', status: 'RECOVERY_REQUIRED', execution: { inFlight: null } }, { ses_abc: { runId: 'lr-x', directory: '/tmp/p' } });
144
+ assert.equal(findRunKey(base, 'lr-x').run.runId, 'lr-x');
145
+ assert.equal(findRunKey(base, 'lr-missing'), null);
146
+ assert.equal(sessionForRun(base, 'lr-x'), 'ses_abc');
147
+ assert.equal(sessionForRun(base, 'lr-none'), null);
148
+ });
149
+
150
+ test('hostLive is false for a directory no host owns', () => {
151
+ assert.equal(hostLive('/nonexistent/lr-runner-probe-' + process.pid), false);
152
+ });
153
+
154
+ test('the CLI refuses a run that is not in RECOVERY_REQUIRED, without dispatching', t => {
155
+ const { base } = stateFixture(t, { runId: 'lr-paused', status: 'PAUSED', execution: { inFlight: null } }, { ses_abc: { runId: 'lr-paused' } });
156
+ const res = spawnSync(process.execPath, [RUNNER, '--state-dir', base, '--project', '/nonexistent/project', '--run', 'lr-paused', '--skip-model-check', '--json'], { encoding: 'utf8', timeout: 30000 });
157
+ assert.equal(res.status, 3, res.stdout + res.stderr);
158
+ const out = JSON.parse(res.stdout);
159
+ assert.equal(out.error, 'NOT_RECOVERY_REQUIRED');
160
+ assert.equal(out.attempts.length, 1);
161
+ assert.equal(out.attempts[0].decision.action, 'refuse');
162
+ });
163
+
164
+ test('the CLI passes the environment identity guard for the required model', () => {
165
+ assert.equal(REQUIRED_MODEL, 'mtplx-flash-next-optimized-speed');
166
+ assert.equal(REQUIRED_PROVIDER_MODEL, 'mtplx/mtplx-flash-next-optimized-speed');
167
+ });
168
+
169
+ test('reduced-context mode adopts a non-terminal run even with no usable session binding', () => {
170
+ // The whole point: an over-window or missing binding is why a fresh conversation is needed at all.
171
+ const d = decideRecovery({ status: 'RECOVERY_REQUIRED', freshSession: true });
172
+ assert.equal(d.action, 'dispatch');
173
+ assert.equal(d.reason, 'FRESH_SESSION_REBIND');
174
+ assert.equal(decideRecovery({ status: 'PAUSED', freshSession: true }).action, 'dispatch');
175
+ assert.equal(decideRecovery({ status: 'IMPLEMENTING', freshSession: true }).reason, 'NOT_RESUMABLE');
176
+ assert.equal(decideRecovery({ status: 'COMPLETE', freshSession: true }).reason, 'NOT_RESUMABLE');
177
+ assert.equal(decideRecovery({ status: 'CANCELLED', freshSession: true }).reason, 'NOT_RESUMABLE');
178
+ // Safety refusals are not weakened by the mode.
179
+ assert.equal(decideRecovery({ status: 'PAUSED', freshSession: true, hostLive: true }).reason, 'HOST_LIVE');
180
+ assert.equal(decideRecovery({ status: 'PAUSED', freshSession: true, inFlight: { token: 't' } }).reason, 'VERIFY_IN_FLIGHT');
181
+ assert.equal(decideRecovery({ status: 'PAUSED', freshSession: true, attempt: 2, maxAttempts: 2 }).action, 'give_up');
182
+ // Without the flag the old contract is unchanged: PAUSED and a missing binding still refuse.
183
+ assert.equal(decideRecovery({ status: 'PAUSED' }).reason, 'NOT_RECOVERY_REQUIRED');
184
+ });
185
+
186
+ test('the reduced-context prompt forbids a new run and reserves completion to the operator', () => {
187
+ const p = freshSessionPrompt('lr-test', { phase: 'verify' });
188
+ for (const needle of ['lr-test', 'NEW conversation', 'action=resume', 'rebound', 'longrun_verify',
189
+ 'Do not start a new run', 'keep automatic continuation OFF', 'Do not COMPLETE']) {
190
+ assert.ok(p.includes(needle), `verify prompt should mention: ${needle}`);
191
+ }
192
+ const c = freshSessionPrompt('lr-test', { phase: 'complete' });
193
+ assert.ok(c.includes('action=complete'), 'complete phase asks for completion after the operator acceptance');
194
+ assert.ok(c.includes('operator has already accepted'), 'complete phase states who authorised it');
195
+ assert.ok(!c.includes('Do not COMPLETE'), 'complete phase must not forbid the authorised completion');
196
+ });
197
+
198
+ test('a dispatched session id is read back from the event stream, never guessed', () => {
199
+ const line = (sid) => JSON.stringify({ type: 'step_start', sessionID: sid, part: { type: 'step-start' } });
200
+ assert.equal(extractSessionId([line('ses_first'), line('ses_second')].join('\n')), 'ses_first');
201
+ assert.equal(extractSessionId('not json\n' + line('ses_only')), 'ses_only');
202
+ assert.equal(extractSessionId('{"type":"text","part":{}}\n'), null, 'an event without a sessionID is not a binding');
203
+ assert.equal(extractSessionId(''), null);
204
+ assert.equal(extractSessionId('garbage "sessionID":"ses_fallback"'), 'ses_fallback');
205
+ });
206
+
207
+ test('a reduced-context turn that did not adopt the run is never reported as recovered', () => {
208
+ const recovered = { recovered: true, retry: false, status: 'IMPLEMENTING' };
209
+ // A real re-bind leaves BOTH markers on the new session: the routing entry and the run's own owner.
210
+ const adopted = applyRebindGuard(recovered, { newSessionId: 'ses_new', boundSessions: ['ses_old', 'ses_new'], compactionSessionID: 'ses_new' });
211
+ assert.equal(adopted.rebound, true);
212
+ assert.equal(adopted.outcome.recovered, true, 'an adopted run is the success case');
213
+ // The historical conversation stays in runs.json, so reading only the first entry would have shown
214
+ // ses_old and wrongly reported failure: that was a real tool bug, pinned here.
215
+ const firstEntryOnly = applyRebindGuard(recovered, { newSessionId: 'ses_new', boundSessions: ['ses_old'], compactionSessionID: 'ses_new' });
216
+ assert.equal(firstEntryOnly.rebound, false);
217
+ assert.equal(firstEntryOnly.outcome.reason, 'NO_REBIND');
218
+ // Bound in routing but the run's own owner marker never moved: not adoption.
219
+ const notOwned = applyRebindGuard(recovered, { newSessionId: 'ses_new', boundSessions: ['ses_old', 'ses_new'], compactionSessionID: 'ses_old' });
220
+ assert.equal(notOwned.rebound, false, 'a routing entry alone is not evidence that the run moved');
221
+ assert.equal(applyRebindGuard(recovered, { newSessionId: null, boundSessions: ['ses_old'] }).outcome.reason, 'NO_REBIND',
222
+ 'a dispatch that reported no session id cannot be credited');
223
+ // A legacy run with no owner marker is judged by its routing binding.
224
+ assert.equal(applyRebindGuard(recovered, { newSessionId: 'ses_new', boundSessions: ['ses_new'], compactionSessionID: null }).rebound, true);
225
+ // A run that is still un-recovered stays un-recovered whatever the binding says.
226
+ const stuck = applyRebindGuard({ recovered: false, retry: true, status: 'RECOVERY_REQUIRED' }, { newSessionId: 'ses_new', boundSessions: ['ses_new'], compactionSessionID: 'ses_new' });
227
+ assert.equal(stuck.outcome.retry, true);
228
+ });
229
+
230
+ test('every session bound to a run is discoverable, not just the first', t => {
231
+ const { base } = stateFixture(t, { runId: 'lr-x' }, { ses_old: { runId: 'lr-x' }, ses_new: { runId: 'lr-x' }, ses_other: { runId: 'lr-y' } });
232
+ assert.deepEqual(boundSessions(base, 'lr-x').sort(), ['ses_new', 'ses_old']);
233
+ assert.deepEqual(boundSessions(base, 'lr-absent'), []);
234
+ assert.equal(sessionForRun(base, 'lr-x'), 'ses_old', 'the historical helper still reports the earliest binding');
235
+ });
236
+
237
+ test('the CLI refuses conflicting or unstated reduced-context arguments before any state or network use', () => {
238
+ const conflict = spawnSync(process.execPath, [RUNNER, '--state-dir', '/nonexistent/state', '--project', '/nonexistent/project',
239
+ '--run', 'lr-x', '--fresh-session', '--session', 'ses_old', '--json'], { encoding: 'utf8', timeout: 30000 });
240
+ assert.equal(conflict.status, 3, conflict.stdout + conflict.stderr);
241
+ assert.equal(JSON.parse(conflict.stdout).error, 'ARGUMENT_CONFLICT');
242
+
243
+ const unstated = spawnSync(process.execPath, [RUNNER, '--state-dir', '/nonexistent/state', '--project', '/nonexistent/project',
244
+ '--run', 'lr-x', '--allow-status', 'PAUSED', '--json'], { encoding: 'utf8', timeout: 30000 });
245
+ assert.equal(unstated.status, 3, unstated.stdout + unstated.stderr);
246
+ assert.equal(JSON.parse(unstated.stdout).error, 'PROMPT_REQUIRED');
247
+
248
+ const badPhase = spawnSync(process.execPath, [RUNNER, '--state-dir', '/nonexistent/state', '--project', '/nonexistent/project',
249
+ '--run', 'lr-x', '--fresh-session', '--phase', 'whenever', '--json'], { encoding: 'utf8', timeout: 30000 });
250
+ assert.equal(badPhase.status, 3, badPhase.stdout + badPhase.stderr);
251
+ assert.equal(JSON.parse(badPhase.stdout).error, 'INVALID_PHASE');
252
+ });
253
+
254
+ test('a reduced-context dispatch to a PAUSED run reaches the attempt cap without touching the network', t => {
255
+ const { base } = stateFixture(t, { runId: 'lr-fresh', status: 'PAUSED', execution: { inFlight: null }, receipts: [] }, { ses_old: { runId: 'lr-fresh' } });
256
+ const promptFile = path.join(base, 'phase.txt');
257
+ fs.writeFileSync(promptFile, 'operator-authored intent');
258
+ const res = spawnSync(process.execPath, [RUNNER, '--state-dir', base, '--project', '/nonexistent/project', '--run', 'lr-fresh',
259
+ '--fresh-session', '--allow-status', 'PAUSED', '--prompt-file', promptFile, '--max-attempts', '0', '--skip-model-check', '--json'],
260
+ { encoding: 'utf8', timeout: 30000 });
261
+ assert.equal(res.status, 2, res.stdout + res.stderr);
262
+ const out = JSON.parse(res.stdout);
263
+ assert.equal(out.freshSession, true);
264
+ assert.equal(out.boundSession, 'ses_old');
265
+ assert.equal(out.sessionId, null, 'a fresh conversation must not be dispatched into the old session');
266
+ assert.equal(out.attempts[0].decision.action, 'give_up', 'the widened status may not be refused as NOT_RESUMABLE');
267
+ assert.equal(out.attempts[0].decision.reason, 'ATTEMPT_LIMIT');
268
+ assert.equal(out.attempts[0].status, 'PAUSED', 'the widened --allow-status value is what was evaluated');
269
+ assert.equal(out.attempts[0].receiptsBefore, 0);
270
+ assert.equal(out.attempts[0].dispatch, undefined, 'nothing may be dispatched once the attempt cap is reached');
271
+ });
272
+
273
+ test('raw dispatch streams get unique, phase-tagged names so evidence is never overwritten', () => {
274
+ const at = Date.parse('2026-09-22T09:29:48.118Z');
275
+ const a = dispatchLogStem({ logDir: '/tmp/ev', attempt: 1, runId: 'lr-x', phase: 'verify', at });
276
+ const b = dispatchLogStem({ logDir: '/tmp/ev', attempt: 1, runId: 'lr-x', phase: 'complete', at });
277
+ assert.notEqual(a, b, 'the same run and attempt number in different phases must not collide');
278
+ assert.ok(a.includes('-verify-'), a);
279
+ assert.ok(b.includes('-complete-'), b);
280
+ assert.ok(!a.includes(':') && !a.includes('.'), 'a stem must be a safe filename: ' + a);
281
+ // Distinct dispatches of the same phase and attempt still differ once time moves on.
282
+ const later = dispatchLogStem({ logDir: '/tmp/ev', attempt: 1, runId: 'lr-x', phase: 'verify', at: at + 60000 });
283
+ assert.notEqual(a, later);
284
+ // Non-fresh dispatches keep the historical shape (no phase tag).
285
+ assert.ok(dispatchLogStem({ logDir: '/tmp/ev', attempt: 2, runId: 'lr-x', at }).includes('recovery-attempt-2-lr-x-'));
286
+ });
287
+
288
+ test('the operator fallback binary resolves under HOME, not dirname(HOME)', () => {
289
+ // Regression: the give-up path once used path.dirname(HOME), giving <dirname>/.config/<...> which
290
+ // does not exist, so the pause fallback failed silently with exitCode null.
291
+ const home = '/home/example';
292
+ const bin = defaultMaintenanceBin(home);
293
+ assert.equal(bin, '/home/example/.config/opencode/longrun-harness/longrun');
294
+ assert.ok(bin.startsWith(home + '/'), 'must live under HOME: ' + bin);
295
+ assert.ok(!bin.startsWith('/home/.config'), 'must not be dirname(HOME)-rooted: ' + bin);
296
+ // A different HOME must move the path with it.
297
+ assert.ok(defaultMaintenanceBin('/tmp/fake-home').startsWith('/tmp/fake-home/.config/'));
298
+ });
299
+
300
+ test('a fallback that never ran reports why, instead of a bare failure', t => {
301
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr-runner-bin-'));
302
+ t.after(() => fs.rmSync(base, { recursive: true, force: true }));
303
+ const res = pauseViaMaintenance({ bin: path.join(base, 'does-not-exist'), stateDir: base, project: base, runId: 'lr-x' });
304
+ assert.equal(res.ok, false);
305
+ assert.equal(res.exitCode, null, 'no exit code because the process never started');
306
+ assert.ok(res.bin.endsWith('does-not-exist'), 'the attempted binary is reported');
307
+ assert.ok(res.error, 'the spawn error is reported: ' + JSON.stringify(res));
308
+ });
309
+
310
+ test('the operator pause fallback lands a thrashing run in a controlled state', t => {
311
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr-runner-pause-'));
312
+ t.after(() => fs.rmSync(base, { recursive: true, force: true }));
313
+ const stub = path.join(base, 'longrun-stub');
314
+ fs.writeFileSync(stub, '#!/bin/sh\nprintf \'%s\\n\' \'{"ok":true,"state":"PAUSED"}\'\n');
315
+ fs.chmodSync(stub, 0o755);
316
+ const res = pauseViaMaintenance({ bin: stub, stateDir: base, project: base, runId: 'lr-x' });
317
+ assert.equal(res.ok, true);
318
+ assert.equal(res.state, 'PAUSED');
319
+ });
320
+
321
+ test('an exhausted run is paused rather than left looping (CLI path, no dispatch)', t => {
322
+ const { base } = stateFixture(t, { runId: 'lr-stuck', status: 'RECOVERY_REQUIRED', execution: { inFlight: null } }, { ses_abc: { runId: 'lr-stuck' } });
323
+ const stub = path.join(base, 'longrun-stub');
324
+ fs.writeFileSync(stub, '#!/bin/sh\nprintf \'%s\\n\' \'{"ok":true,"state":"PAUSED"}\'\n');
325
+ fs.chmodSync(stub, 0o755);
326
+ const res = spawnSync(process.execPath, [RUNNER, '--state-dir', base, '--project', '/nonexistent/project', '--run', 'lr-stuck',
327
+ '--max-attempts', '0', '--maintenance-bin', stub, '--skip-model-check', '--json'], { encoding: 'utf8', timeout: 30000 });
328
+ assert.equal(res.status, 2, res.stdout + res.stderr);
329
+ const out = JSON.parse(res.stdout);
330
+ assert.equal(out.error, 'ATTEMPT_LIMIT');
331
+ assert.equal(out.pausedAfterAttemptLimit.ok, true);
332
+ assert.equal(out.state, 'PAUSED');
333
+ });
334
+
335
+ test('the MTPLX endpoint is discovered from its own listening socket, then still held to the strict bar', async () => {
336
+ // A configured port can be stale in the other direction: MTPLX's Settings say :8000 and it only fell
337
+ // back to :8001 because another app held :8000, so the config can point at a port MTPLX has left.
338
+ // Discovery changes only WHERE we look; the identity bar is unchanged (exactly one model, the required one).
339
+ const lsof = ['p39575', 'n127.0.0.1:8000', 'p48754', 'n127.0.0.1:8001', 'p999', 'n*:5199'].join('\n');
340
+ assert.deepEqual(parseListeners(lsof), [
341
+ { pid: '39575', port: 8000 }, { pid: '48754', port: 8001 }, { pid: '999', port: 5199 },
342
+ ]);
343
+ assert.deepEqual(parseListeners(''), []);
344
+ assert.deepEqual(parseListeners('garbage'), []);
345
+
346
+ // only MTPLX's bundled runtime counts as MTPLX — the router on :8000 must not be mistaken for it
347
+ assert.equal(isMtplxRuntime('/home/example/Library/Application Support/MTPLX/runtime-venv/bin/python'), true);
348
+ assert.equal(isMtplxRuntime('/Applications/MTPLX.app/Contents/MacOS/MTPLXApp'), true);
349
+ assert.equal(isMtplxRuntime('/opt/homebrew/bin/python3.1'), false);
350
+ assert.equal(isMtplxRuntime(''), false);
351
+
352
+ // discovery maps a listening socket to a base URL, and several ports can be probed
353
+ const fakeExec = (cmd, args) => {
354
+ if (cmd === 'lsof') return { stdout: 'p100\nn127.0.0.1:8000\np200\nn127.0.0.1:8001\n' };
355
+ const pid = args[args.length - 1];
356
+ return { stdout: pid === '200' ? '/x/MTPLX/runtime-venv/bin/python\n' : '/opt/homebrew/bin/python3.1\n' };
357
+ };
358
+ assert.deepEqual(discoverMtplxBases({ exec: fakeExec }), ['http://127.0.0.1:8001/v1']);
359
+
360
+ // the first candidate serving EXACTLY the required model wins; a wrong model is not accepted
361
+ const probe = async (b) => ({
362
+ 'http://a/v1': ['local-governor', 'qwen-local'],
363
+ 'http://b/v1': [REQUIRED_MODEL],
364
+ 'http://c/v1': [REQUIRED_MODEL, 'something-else'],
365
+ }[b] || (() => { throw new Error('refused'); })());
366
+ const picked = await pickServedBase({ bases: ['http://a/v1', 'http://b/v1', 'http://c/v1'], probe });
367
+ assert.equal(picked.base, 'http://b/v1', 'picks the endpoint serving exactly the required model');
368
+ assert.equal(picked.models.length, 1);
369
+ // a reachable endpoint that serves the right model plus another is NOT accepted
370
+ const strict = await pickServedBase({ bases: ['http://c/v1'], probe });
371
+ assert.equal(strict.base, null, 'exactly one served model is still required');
372
+ // so is an unreachable one — and the failure names what was tried instead of guessing
373
+ const none = await pickServedBase({ bases: ['http://dead/v1'], probe });
374
+ assert.equal(none.base, null);
375
+ assert.equal(none.tried[0].base, 'http://dead/v1');
376
+ assert.ok(none.tried[0].error, 'an unreachable candidate records why');
377
+
378
+ // candidate order: explicit operator value, then MTPLX discovery, then config, then default
379
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'longrun-cand-'));
380
+ try {
381
+ fs.writeFileSync(path.join(dir, 'opencode.json'), JSON.stringify({ provider: { mtplx: { options: { baseURL: 'http://cfg/v1' } } } }));
382
+ const cands = inferenceCandidates({ env: {}, configDir: dir, exec: fakeExec });
383
+ assert.equal(cands[0], 'http://127.0.0.1:8001/v1', 'the discovered MTPLX socket outranks the stale config');
384
+ assert.ok(cands.includes('http://cfg/v1'), 'the config is still probed as a fallback');
385
+ assert.ok(cands.includes(DEFAULT_INFERENCE_BASE), 'and the historic default last');
386
+ } finally {
387
+ fs.rmSync(dir, { recursive: true, force: true });
388
+ }
389
+
390
+ // pinning rewrites baseURL to the VERIFIED endpoint, so the session cannot reach another server
391
+ const pdir = fs.mkdtempSync(path.join(os.tmpdir(), 'longrun-pinbase-'));
392
+ try {
393
+ const f = path.join(pdir, 'opencode.json');
394
+ fs.writeFileSync(f, JSON.stringify({ model: 'mtplx/x', provider: { mtplx: { name: 'MTPLX (local)', options: { baseURL: 'http://stale/v1', apiKey: 'local' } } } }));
395
+ const content = pinnedConfigContent({ file: f, base: 'http://127.0.0.1:8001/v1' });
396
+ const parsed = JSON.parse(content);
397
+ assert.equal(parsed.provider.mtplx.options.baseURL, 'http://127.0.0.1:8001/v1', 'baseURL is forced to the verified endpoint');
398
+ assert.equal(parsed.provider.mtplx.options.apiKey, 'local', 'other provider options survive the pin');
399
+ assert.equal(parsed.provider.mtplx.name, 'MTPLX (local)');
400
+ assert.equal(pinnedConfigContent({ file: path.join(pdir, 'missing.json'), base: 'x' }), null);
401
+ } finally {
402
+ fs.rmSync(pdir, { recursive: true, force: true });
403
+ }
404
+ });
405
+
406
+
407
+ test('the toolchain is configurable, not hardcoded to one local setup', () => {
408
+ // The harness should drive whatever local OpenCode + model an operator runs. The shipped MTPLX values
409
+ // are DEFAULTS; every one of them must be overridable, or the tool cannot be reused across setups.
410
+ assert.equal(requiredModel({}), DEFAULT_REQUIRED_MODEL, 'default model unchanged');
411
+ assert.equal(requiredModel({ LONGRUN_REQUIRED_MODEL: 'other-model' }), 'other-model', 'model overridable');
412
+ assert.equal(requiredProviderModel({}), 'mtplx/mtplx-flash-next-optimized-speed');
413
+ assert.equal(requiredProviderModel({ LONGRUN_REQUIRED_PROVIDER_MODEL: 'vendor/other' }), 'vendor/other');
414
+ assert.equal(opencodeBin({}), DEFAULT_OPENCODE_BIN);
415
+ assert.equal(opencodeBin({ LONGRUN_OPENCODE_BIN: '/usr/local/bin/opencode' }), '/usr/local/bin/opencode', 'binary path overridable');
416
+ assert.equal(runtimeMatcher({}), DEFAULT_RUNTIME_MATCHER);
417
+ assert.equal(runtimeMatcher({ LONGRUN_RUNTIME_MATCHER: 'acme' }), 'acme');
418
+
419
+ // a custom matcher must change which runtime is recognised — otherwise discovery is still vendor-locked
420
+ assert.equal(isLocalModelRuntime('/Applications/MTPLX.app/x'), true, 'default matcher finds MTPLX');
421
+ assert.equal(isLocalModelRuntime('/opt/acme/runtime-venv/bin/python', 'acme[\\/].*runtime-venv'), true, 'custom matcher finds another runtime');
422
+ assert.equal(isLocalModelRuntime('/Applications/MTPLX.app/x', 'acme'), false, 'custom matcher excludes the default vendor');
423
+ assert.equal(isLocalModelRuntime('/x', '('), false, 'an invalid matcher fails closed rather than throwing');
424
+
425
+ // discovery and the strict identity check must both honour the configured toolchain
426
+ const fakeExec = (cmd, args) => {
427
+ if (cmd === 'lsof') return { stdout: 'p100\nn127.0.0.1:9100\n' };
428
+ return { stdout: '/opt/acme/runtime-venv/bin/python\n' };
429
+ };
430
+ assert.deepEqual(discoverMtplxBases({ exec: fakeExec }), [], 'default matcher ignores a foreign runtime');
431
+ assert.deepEqual(discoverMtplxBases({ exec: fakeExec, matcher: 'acme[\\/].*runtime-venv' }),
432
+ ['http://127.0.0.1:9100/v1'], 'configured matcher discovers the foreign runtime');
433
+ const cands = inferenceCandidates({ env: {}, configDir: '/nonexistent', exec: fakeExec, matcher: 'acme[\\/].*runtime-venv' });
434
+ assert.equal(cands[0], 'http://127.0.0.1:9100/v1', 'discovery leads the candidate list');
435
+ });
@@ -0,0 +1,68 @@
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
+ process.env.LONGRUN_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "recstate-"));
8
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, "..", "src", "controller.js");
9
+ // The plugin entrypoint now default-exports exactly one value: the V1 async factory.
10
+ const PLUG = await import("../plugin/longrun.js");
11
+ const { F, readRuns, writeRuns } = await import("./helper.mjs");
12
+ const PLUG_URL = "../plugin/longrun.js";
13
+
14
+ function seedRun() {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "recproj-"));
16
+ fs.writeFileSync(path.join(dir, "a.js"), "x");
17
+ const id = C.projectIdentity(dir);
18
+ const key = C.stateKey(id, "default");
19
+ const store = new C.Store(process.env.LONGRUN_STATE_DIR);
20
+ const run = {
21
+ status: "IMPLEMENTING",
22
+ originalRequest: "Build task-list feature",
23
+ contractHash: "contract-hash-abc", evaluatorHash: "eval-hash-def",
24
+ contract: { criteria: [
25
+ { id: "c1", required: true, checks: ["accept"], status: "PASS" },
26
+ { id: "c2", required: true, checks: ["accept"], status: "FAIL" },
27
+ ], gates: [], lossTarget: 0 },
28
+ state: {
29
+ iterations: 3, noProgressStreak: 1, sameFailureStreak: 1,
30
+ currentSlice: "c2-persistence", nextAction: "fix persistence then re-run accept",
31
+ failedHypotheses: ["assumed in-memory store was enough"],
32
+ budgets: { autoDispatches: 2 },
33
+ },
34
+ };
35
+ store.writeJSON(key, "run.json", run);
36
+ const runs = readRuns();
37
+ runs["sess-1"] = { runKey: key, directory: dir, checkCatalogue: {} };
38
+ writeRuns(runs);
39
+ return { dir, key, store };
40
+ }
41
+ const CTX = (dir) => ({ sessionID: "sess-1", agent: "longrun", directory: dir, worktree: dir });
42
+
43
+ // SIMULATED: compaction event flips state to recovery; NOT a real summarize/compaction.
44
+ test("SIMULATED compaction -> recovery: state + budgets + contract survive", async () => {
45
+ const { dir, key, store } = seedRun();
46
+ const hooks = await F(PLUG_URL);
47
+ const before = store.readJSON(key, "run.json");
48
+
49
+ await hooks.event({ event: { type: "session.compacted", properties: { sessionID: "sess-1" } } });
50
+ const afterCompact = store.readJSON(key, "run.json");
51
+ assert.equal(afterCompact.status, "RECOVERY_REQUIRED", "compaction requires reconciliation before edits");
52
+
53
+ // reconcile: resume-context must reproduce contract IDs, budgets, failed hypotheses
54
+ const packet = await hooks.tool.longrun.execute({ action: "resume-context" }, CTX(dir));
55
+ assert.match(packet, /REMAINING CRITERIA: c2/, "remaining criterion IDs survive");
56
+ assert.match(packet, /contract-hash-abc/, "contract hash survives");
57
+ assert.match(packet, /assumed in-memory store/, "unsuccessful approaches survive");
58
+ assert.match(packet, /fix persistence then re-run accept/, "next action survives");
59
+ assert.match(packet, /iters=3/, "budget counters persist across compaction");
60
+
61
+ // cancellation stays authoritative through recovery
62
+ const paused = structuredClone(before); paused.status = "PAUSED"; store.writeJSON(key, "run.json", paused);
63
+ const hooks2 = await F(PLUG_URL, { client: {} });
64
+ const out = { enabled: true };
65
+ const runs = readRuns(); runs["sess-1"].paused = true; writeRuns(runs);
66
+ await hooks2["experimental.compaction.autocontinue"]({ sessionID: "sess-1" }, out);
67
+ assert.equal(out.enabled, false, "a paused/cancelled run is not auto-resumed after compaction");
68
+ });
@@ -0,0 +1,122 @@
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 { spawnSync } from "node:child_process";
7
+ import * as C from "../src/controller.js";
8
+
9
+ // Disposable task-list app. The acceptance runner is a PLAIN `node` program (NOT `node --test`)
10
+ // so it can be spawned honestly: nesting `node --test` inside a `node --test` worker makes the
11
+ // child emit no result + exit 0 (a runner artifact, verified). A plain self-checking runner keeps
12
+ // REAL command execution + real test counts and does not depend on that artifact. Receipts come
13
+ // from actual command execution (exit code + reported counts), not caller-supplied PASS flags.
14
+ // This proves harness MECHANICS + integrity; the Qwen-autonomous repair in a live desktop run is
15
+ // a separate, non-faked step.
16
+
17
+ const DEFECTIVE = `
18
+ export class TaskStore {
19
+ constructor(file){ this.file=file; }
20
+ add(title){ this.list=this.list||[]; this.list.push({title}); return {ok:true,tasks:this.list}; } // no validation, no persistence, hides errors
21
+ }
22
+ `;
23
+ const FIXED = `
24
+ import fs from "node:fs";
25
+ export class TaskStore {
26
+ constructor(file){ this.file=file; this.load(); }
27
+ load(){ try { this.list = JSON.parse(fs.readFileSync(this.file,"utf8")); } catch { this.list = []; } }
28
+ add(title){
29
+ if (typeof title !== "string" || title.trim()==="") return {ok:false, error:"empty_title"};
30
+ this.list.push({ title: title.trim(), id: String(this.list.length+1) });
31
+ fs.writeFileSync(this.file, JSON.stringify(this.list));
32
+ return {ok:true, tasks:this.list};
33
+ }
34
+ }
35
+ `;
36
+ // The protected acceptance runner: 4 checks; exit 0 only if all pass; prints counts.
37
+ const RUNNER = `
38
+ import { TaskStore } from "./app.mjs";
39
+ import fs from "node:fs"; import os from "node:os"; import path from "node:path";
40
+ let pass=0, fail=0;
41
+ const chk=(n,fn)=>{ try{ fn(); pass++; }catch(e){ fail++; console.log("FAIL",n,String(e&&e.message||e)); } };
42
+ const tmp=()=>{const d=fs.mkdtempSync(path.join(os.tmpdir(),"a-"));return path.join(d,"d.json");};
43
+ chk("c1-valid-creates", ()=>{ const f=tmp(); const r=new TaskStore(f).add("Buy milk"); if(!(r.ok && r.tasks.some(t=>t.title==="Buy milk"))) throw new Error("not created"); });
44
+ chk("c2-empty-rejected", ()=>{ const r=new TaskStore(tmp()).add(" "); if(!(r.ok===false && r.error==="empty_title")) throw new Error("empty accepted"); });
45
+ chk("c3-persist-restart", ()=>{ const f=tmp(); new TaskStore(f).add("Persist me"); const s2=new TaskStore(f); if(!(s2.list && s2.list.some(t=>t.title==="Persist me"))) throw new Error("did not persist"); });
46
+ chk("c4-honest-error", ()=>{ const r=new TaskStore(tmp()).add(""); if(!(r.ok===false && !!r.error)) throw new Error("false success"); });
47
+ console.log("RESULT pass="+pass+" fail="+fail+" total="+(pass+fail));
48
+ process.exit(fail===0 ? 0 : 1);
49
+ `;
50
+
51
+ function project(appSrc) {
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "repairdemo-"));
53
+ fs.writeFileSync(path.join(dir, "app.mjs"), appSrc);
54
+ fs.writeFileSync(path.join(dir, "run_acceptance.mjs"), RUNNER); // protected
55
+ return dir;
56
+ }
57
+ function runAccept(dir) {
58
+ const env = { ...process.env }; delete env.NODE_OPTIONS;
59
+ const r = spawnSync(process.execPath, ["run_acceptance.mjs"], { cwd: dir, encoding: "utf8", timeout: 60000, env });
60
+ const out = (r.stdout || "") + (r.stderr || "");
61
+ const m = /RESULT pass=(\d+) fail=(\d+) total=(\d+)/.exec(out);
62
+ return { exit: r.status, pass: m ? +m[1] : 0, fail: m ? +m[2] : 0, testCount: m ? +m[3] : 0, out };
63
+ }
64
+ function mkContract() {
65
+ return { criteria: ["c1", "c2", "c3", "c4"].map(id => ({ id, required: true, checks: ["accept"], status: "FAIL", weight: 1 })),
66
+ gates: [{ id: "test-gate", required: true, status: "PENDING" }], lossTarget: 0 };
67
+ }
68
+
69
+ test("repair mechanics: failing -> fix -> passing -> loss 1 -> 0 -> completion", () => {
70
+ const dir = project(DEFECTIVE);
71
+ const contract = mkContract();
72
+ const r1run = runAccept(dir);
73
+ assert.equal(r1run.exit, 1, "defective implementation fails acceptance (exit)");
74
+ assert.equal(r1run.fail, 3, "exactly the 3 defective behaviours fail");
75
+ const fp1 = C.sourceFingerprint(dir).hash;
76
+ const rc1 = C.makeReceipt({ checkId: "accept", command: "node run_acceptance.mjs", exitCode: r1run.exit, output: r1run.out, testCount: r1run.testCount, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: fp1 });
77
+ assert.equal(rc1.status, "FAIL");
78
+ assert.ok(r1run.testCount > 0, "test command discovered tests (not a zero-test pass)");
79
+ assert.equal(C.defaultLoss(contract).loss, 1, "initial loss is full");
80
+
81
+ // APPLY FIX to the same protected tests; re-run PASSES; loss improves to 0.
82
+ fs.writeFileSync(path.join(dir, "app.mjs"), FIXED);
83
+ const r2run = runAccept(dir);
84
+ assert.equal(r2run.exit, 0, "fixed implementation passes acceptance");
85
+ assert.equal(r2run.pass, 4, "all 4 criteria now satisfied");
86
+ const fp2 = C.sourceFingerprint(dir).hash;
87
+ assert.notEqual(fp1, fp2, "source changed between runs");
88
+ const rc2 = C.makeReceipt({ checkId: "accept", command: "node run_acceptance.mjs", exitCode: r2run.exit, output: r2run.out, testCount: r2run.testCount, requirementKind: "test", startedAt: 3, finishedAt: 4, sourceFingerprint: fp2 });
89
+ assert.equal(rc2.status, "PASS");
90
+ for (const c of contract.criteria) c.status = "PASS";
91
+ contract.gates[0].status = "PASS";
92
+ assert.equal(C.defaultLoss(contract).loss, 0, "loss reached target after a real fix");
93
+ assert.equal(C.canComplete({ contract, state: {}, status: "VERIFYING" }).complete, true, "completion needs all required PASS + loss met");
94
+ });
95
+
96
+ test("staleness: edit after a PASS receipt invalidates it", () => {
97
+ const dir = project(FIXED);
98
+ const passRun = runAccept(dir);
99
+ assert.equal(passRun.testCount, 4);
100
+ const fpA = C.sourceFingerprint(dir).hash;
101
+ const receipt = C.makeReceipt({ checkId: "accept", command: "node run_acceptance.mjs", exitCode: passRun.exit, output: "", testCount: passRun.testCount, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: fpA });
102
+ assert.equal(receipt.status, "PASS");
103
+ fs.writeFileSync(path.join(dir, "app.mjs"), DEFECTIVE); // regress after receipt
104
+ const fpB = C.sourceFingerprint(dir).hash;
105
+ assert.notEqual(fpA, fpB);
106
+ assert.equal(C.resolveReceiptStatus(receipt, fpB), "STALE", "evidence must not survive a source change");
107
+ });
108
+
109
+ test("protected-runner integrity: weakening it is detected via fingerprint", () => {
110
+ const dir = project(DEFECTIVE);
111
+ const protectHash = C.sourceFingerprint(dir).hash;
112
+ fs.writeFileSync(path.join(dir, "run_acceptance.mjs"), "// neutered: no checks\nconsole.log('ok');\n");
113
+ assert.notEqual(protectHash, C.sourceFingerprint(dir).hash, "weakening a protected runner changes the fingerprint and is detectable");
114
+ });
115
+
116
+ test("a command with zero checks cannot satisfy a test requirement (NOT_RUN on exit 0)", () => {
117
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "notests-"));
118
+ fs.writeFileSync(path.join(dir, "run_acceptance.mjs"), "console.log('RESULT pass=0 fail=0 total=0');\n");
119
+ const r = spawnSync(process.execPath, ["run_acceptance.mjs"], { cwd: dir, encoding: "utf8" });
120
+ const rc = C.makeReceipt({ checkId: "t", command: "node run_acceptance.mjs", exitCode: r.status, output: r.stdout, testCount: 0, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "x" });
121
+ assert.equal(rc.status, "NOT_RUN", "exit 0 with zero checks is NOT_RUN");
122
+ });