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,230 @@
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
+ function tmp(prefix = "lrtest") {
10
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix + "-"));
11
+ return d;
12
+ }
13
+ function gitAvail() {
14
+ const r = spawnSync("git", ["--version"]);
15
+ return r.status === 0;
16
+ }
17
+ function git(dir, args) { return spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" }); }
18
+
19
+ // --- 3: two repos/worktrees and non-git projects do NOT share state (identity keying)
20
+ test("identity: git vs two non-git dirs key differently; git uses toplevel+HEAD", (t) => {
21
+ if (!gitAvail()) { t.skip("no git"); return; }
22
+ const g = tmp("git"); git(g, ["init", "-q"]); git(g, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "init"]);
23
+ const idG = C.projectIdentity(g);
24
+ assert.equal(idG.kind, "git");
25
+ assert.match(idG.id, /^git:/);
26
+ assert.ok(idG.head && idG.head.length === 40, "git identity carries HEAD");
27
+
28
+ const a = tmp("dira"); const b = tmp("dirb");
29
+ const ida = C.projectIdentity(a), idb = C.projectIdentity(b);
30
+ assert.equal(ida.kind, "dir");
31
+ assert.notEqual(ida.id, idb.id, "two non-git dirs must not collapse to one identity");
32
+ // subdir of a git repo maps to the repo root identity, not to itself
33
+ const sub = path.join(g, "pkg"); fs.mkdirSync(sub);
34
+ const idSub = C.projectIdentity(sub);
35
+ assert.equal(idSub.root, C.projectIdentity(g).root, "subdir resolves to repo root identity");
36
+ });
37
+
38
+ // --- 3: separate key per (identity, runId)
39
+ test("stateKey separates runs and identities", () => {
40
+ const id = { kind: "dir", id: "dir:/x" };
41
+ const k1 = C.stateKey(id, "runA");
42
+ const k2 = C.stateKey(id, "runB");
43
+ const k3 = C.stateKey({ kind: "dir", id: "dir:/y" }, "runA");
44
+ assert.notEqual(k1, k2);
45
+ assert.notEqual(k1, k3);
46
+ });
47
+
48
+ // --- 5 + 6: loss function integrity
49
+ test("loss: empty required set rejected", () => {
50
+ assert.deepEqual(C.defaultLoss({ criteria: [{ id: "c1", required: false, status: "PASS" }] }), { error: "empty_required_set" });
51
+ });
52
+ test("loss: zero denominator rejected", () => {
53
+ const r = C.defaultLoss({ criteria: [{ id: "c1", required: true, weight: 0, status: "FAIL" }] });
54
+ assert.equal(r.error, "zero_denominator");
55
+ });
56
+ test("loss: unverified required counts as unsatisfied; weighted fraction", () => {
57
+ const c = { criteria: [
58
+ { id: "a", required: true, weight: 1, status: "PASS" },
59
+ { id: "b", required: true, weight: 1, status: "FAIL" }, // missing/stale => unsatisfied
60
+ { id: "c", required: true, weight: 2, status: "STALE" },
61
+ ] };
62
+ const r = C.defaultLoss(c);
63
+ assert.equal(r.denom, 4);
64
+ assert.equal(r.num, 3);
65
+ assert.ok(Math.abs(r.loss - 0.75) < 1e-9);
66
+ });
67
+ test("loss: non-finite evaluator value is an error, never a score", () => {
68
+ assert.equal(C.evaluateEvaluatorResult({ value: NaN }).error, "non_finite_or_missing_value");
69
+ assert.equal(C.evaluateEvaluatorResult({ value: Infinity }).error, "non_finite_or_missing_value");
70
+ assert.equal(C.evaluateEvaluatorResult(null).error, "invalid_evaluator_output");
71
+ });
72
+
73
+ // --- 6 + 15: a reduced soft loss cannot outweigh a failed hard gate / required check
74
+ test("completion: soft loss cannot beat hard gate or required failure", () => {
75
+ const runNoGate = { contract: { lossTarget: 0, criteria: [{ id: "a", required: true, status: "PASS", weight: 1 }], gates: [] }, state: {}, status: "VERIFYING" };
76
+ assert.equal(C.canComplete(runNoGate).complete, true, "all pass + loss met -> complete");
77
+
78
+ const gateFail = structuredClone(runNoGate);
79
+ gateFail.contract.gates = [{ id: "build", required: true, status: "FAIL" }];
80
+ const r1 = C.canComplete(gateFail);
81
+ assert.equal(r1.complete, false);
82
+ assert.equal(r1.reason, "hard_gates_failed");
83
+
84
+ const ctrlFault = structuredClone(runNoGate);
85
+ ctrlFault.faults = ["evaluator_crashed"];
86
+ const r2 = C.canComplete(ctrlFault);
87
+ assert.equal(r2.complete, false);
88
+ assert.equal(r2.reason, "controller_fault");
89
+ });
90
+
91
+ // --- 4 + 5: receipts; only PASS satisfies; 0 tests cannot pass; console optimism ignored
92
+ test("receipt: exit code decides, not console text", () => {
93
+ const bad = C.makeReceipt({ checkId: "t", command: "npm test", exitCode: 1, output: "ALL TESTS PASSED", testCount: 5, startedAt: 1, finishedAt: 2, sourceFingerprint: "fp1" });
94
+ assert.equal(bad.status, "FAIL");
95
+ assert.ok(!C.statusSatisfies(bad.status));
96
+ });
97
+ test("receipt: zero discovered tests is NOT_RUN (cannot satisfy coverage)", () => {
98
+ const zero = C.makeReceipt({ checkId: "t", command: "jest", exitCode: 0, testCount: 0, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "fp1" });
99
+ assert.equal(zero.status, "NOT_RUN");
100
+ });
101
+ test("receipt: stale when source fingerprint changed (tracked/untracked/shell edits)", () => {
102
+ const r = C.makeReceipt({ checkId: "t", command: "true", exitCode: 0, testCount: 3, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "fpA" });
103
+ assert.equal(C.resolveReceiptStatus(r, "fpA"), "PASS");
104
+ assert.equal(C.resolveReceiptStatus(r, "fpB"), "STALE");
105
+ });
106
+
107
+ // --- 7: source fingerprint catches untracked + deletions and self-excludes controller output
108
+ test("sourceFingerprint: untracked changes hash; deletion changes hash; deletions listed", (t) => {
109
+ if (!gitAvail()) { t.skip("no git"); return; }
110
+ const g = tmp("fp"); git(g, ["init", "-q"]); git(g, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "i"]);
111
+ fs.writeFileSync(path.join(g, "a.js"), "1");
112
+ git(g, ["add", "a.js"]); git(g, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "-m", "add a"]);
113
+ const fp0 = C.sourceFingerprint(g).hash;
114
+ // modify tracked
115
+ fs.writeFileSync(path.join(g, "a.js"), "2");
116
+ const fp1 = C.sourceFingerprint(g).hash;
117
+ assert.notEqual(fp0, fp1, "tracked modification invalidates");
118
+ // add untracked
119
+ fs.writeFileSync(path.join(g, "note.txt"), "untracked");
120
+ const fp2 = C.sourceFingerprint(g).hash;
121
+ assert.notEqual(fp1, fp2, "untracked addition invalidates (git HEAD alone is insufficient)");
122
+ // delete tracked
123
+ fs.rmSync(path.join(g, "a.js"));
124
+ const fp3 = C.sourceFingerprint(g);
125
+ assert.notEqual(fp2, fp3.hash, "deletion invalidates");
126
+ // controller's own receipt/log must NOT change fingerprint (no perpetual self-invalidation)
127
+ fs.mkdirSync(path.join(g, ".longrun"));
128
+ fs.writeFileSync(path.join(g, ".longrun", "events.log"), "x");
129
+ fs.writeFileSync(path.join(g, "RESUME.md"), "y");
130
+ const fp4 = C.sourceFingerprint(g);
131
+ assert.equal(fp4.hash, fp3.hash, "controller logs + RESUME.md are excluded -> evidence does not self-invalidate");
132
+ // now remove RESUME/events but keep deletion -> compare to a copy where only deletion differs
133
+ const g2 = tmp("fp2"); git(g2, ["init", "-q"]); git(g2, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "i"]);
134
+ fs.writeFileSync(path.join(g2, "a.js"), "2"); git(g2, ["add", "a.js"]); git(g2, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "-m", "a"]);
135
+ fs.writeFileSync(path.join(g2, "note.txt"), "untracked");
136
+ fs.rmSync(path.join(g2, "a.js"));
137
+ const g3 = tmp("fp3"); git(g3, ["init", "-q"]); git(g3, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "i"]);
138
+ fs.writeFileSync(path.join(g3, "a.js"), "2"); git(g3, ["add", "a.js"]); git(g3, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "-m", "a"]);
139
+ fs.writeFileSync(path.join(g3, "note.txt"), "untracked");
140
+ fs.rmSync(path.join(g3, "a.js"));
141
+ fs.mkdirSync(path.join(g3, ".longrun")); fs.writeFileSync(path.join(g3, ".longrun", "events.log"), "x"); fs.writeFileSync(path.join(g3, "RESUME.md"), "y");
142
+ assert.equal(C.sourceFingerprint(g2).hash, C.sourceFingerprint(g3).hash, "self logs/RESUME excluded so evidence does not self-invalidate");
143
+ });
144
+
145
+ // --- 4: one writer per worktree (lock not double-acquired)
146
+ test("store: writer lock is exclusive and released", () => {
147
+ const dir = tmp("store");
148
+ const s = new C.Store(dir);
149
+ const key = "k1";
150
+ assert.equal(s.tryLock(key, "t1"), true);
151
+ assert.equal(s.tryLock(key, "t2"), false, "second writer rejected");
152
+ s.releaseLock(key);
153
+ assert.equal(s.tryLock(key, "t3"), true, "reacquire after release");
154
+ });
155
+
156
+ // --- 4: append-only event log dedups duplicate/reordered events (no double-dispatch)
157
+ test("event log: dedup duplicate events; distinct events append", () => {
158
+ const dir = tmp("store");
159
+ const s = new C.Store(dir);
160
+ const key = "k1";
161
+ assert.equal(s.appendEvent(key, { dedupeKey: "e1", type: "x" }), true);
162
+ assert.equal(s.appendEvent(key, { dedupeKey: "e1", type: "x" }), false, "dup ignored");
163
+ assert.equal(s.appendEvent(key, { dedupeKey: "e2", type: "x" }), true);
164
+ });
165
+
166
+ // --- 7 + 8 + 11: scheduler guards
167
+ test("scheduler: idle alone is never authorisation; helper ignored; dup/single-flight/generation", () => {
168
+ const dir = tmp("sched");
169
+ const s = new C.Store(dir);
170
+ const sched = new C.Scheduler(s);
171
+ // AUTO disabled by default
172
+ assert.equal(sched.requestContinuation({ runKey: "r", eventId: "1", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: false, messageAuthorised: true }).reason, "auto_disabled");
173
+ // helper session ignored even if auto on
174
+ assert.equal(sched.requestContinuation({ runKey: "r", eventId: "2", sessionID: "child", isHelper: true, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true }).reason, "helper_session");
175
+ // idle-only (not authorised) blocked
176
+ assert.equal(sched.requestContinuation({ runKey: "r", eventId: "3", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: false }).reason, "idle_not_authorisation");
177
+ // first authorised dispatch passes
178
+ const first = sched.requestContinuation({ runKey: "r", eventId: "4", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true });
179
+ assert.equal(first.dispatch, true, "first dispatch");
180
+ // duplicate event (same id) does not double-dispatch
181
+ const dup = sched.requestContinuation({ runKey: "r", eventId: "4", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true });
182
+ assert.equal(dup.reason, "duplicate_event");
183
+ // complete, then single-flight clears
184
+ sched.completeDispatch("r");
185
+ const second = sched.requestContinuation({ runKey: "r", eventId: "5", sessionID: "s1", generation: 2, pendingGeneration: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true });
186
+ assert.equal(second.reason, "generation_guard", "stale generation blocked");
187
+ });
188
+
189
+ // --- 4 + 8: resume authorization
190
+ test("resume: assistant prose / repo file text / stray event cannot resume", () => {
191
+ const run = { status: "PAUSED" };
192
+ assert.equal(C.canResume(run, "user_cli").ok, true);
193
+ assert.equal(C.canResume(run, "assistant_text").ok, false);
194
+ assert.equal(C.canResume(run, "repo_file_text").ok, false, "text in a repository file must not resume a cancelled run");
195
+ assert.equal(C.canResume(run, "event").ok, false);
196
+ });
197
+
198
+ // --- 8 + 9: same-failure / no-progress => bounded replan then pause; counters persist
199
+ test("afterEvaluation: 3x same failure -> replan; 5x no-progress -> replan then pause", () => {
200
+ const mk = () => ({ status: "REPAIRING", budget: C.defaultBudget(), state: {} });
201
+ let run = mk();
202
+ let res;
203
+ for (let i = 0; i < 3; i++) res = C.afterEvaluation(run, { progress: true, failureSignature: "boom" });
204
+ assert.equal(res.next, "NEEDS_REPLAN", "3x same failure requires replan");
205
+ run = mk();
206
+ let r2;
207
+ for (let i = 0; i < 5; i++) r2 = C.afterEvaluation(run, { progress: false });
208
+ assert.equal(r2.next, "NEEDS_REPLAN", "5x no-progress -> one bounded replan");
209
+ const r3 = C.afterEvaluation(run, { progress: false });
210
+ assert.equal(r3.next, "PAUSED", "still stalled -> pause (no infinite loop)");
211
+ });
212
+
213
+ // --- 9: recovery packet bounded + from authoritative records
214
+ test("recovery packet: includes contract refs, remaining ids, budgets; word-capped", () => {
215
+ const run = {
216
+ status: "REPAIRING", contractHash: "abc", evaluatorHash: "def",
217
+ originalRequest: "Build X feature",
218
+ contract: { criteria: [{ id: "c1", required: true, status: "PASS" }, { id: "c2", required: true, status: "FAIL" }] },
219
+ state: { iterations: 7, noProgressStreak: 2, sameFailureStreak: 1, currentSlice: "sliceA", nextAction: "verify c2", verifiedRefs: ["r1"], decisions: ["use existing stack"], relevantPaths: ["src/x"], failedHypotheses: ["h1"] },
220
+ };
221
+ const { packet, words } = C.buildRecoveryPacket(run);
222
+ assert.match(packet, /REMAINING CRITERIA: c2/);
223
+ assert.match(packet, /Build X feature/);
224
+ assert.match(packet, /iters=7/);
225
+ assert.ok(words < 1500);
226
+ // big packet truncates
227
+ run.originalRequest = "word ".repeat(2000);
228
+ const big = C.buildRecoveryPacket(run, 1500);
229
+ assert.ok(big.words <= 1500, "bounded under limit");
230
+ });
@@ -0,0 +1,57 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as EV from "../src/evidence.mjs";
4
+
5
+ // ---- weak evidence cannot satisfy a stronger required class --------------------------------
6
+ test("weak UNIT evidence cannot satisfy a BROWSER-required criterion", () => {
7
+ const c = { evidenceClass: "BROWSER" };
8
+ const r = EV.classSatisfies("UNIT", c);
9
+ assert.equal(r.satisfied, false);
10
+ assert.match(r.kind, /WEAK_EVIDENCE|WRONG_CLASS/);
11
+ });
12
+ test("strong BROWSER evidence satisfies a BROWSER-required criterion", () => {
13
+ const r = EV.classSatisfies("BROWSER", { evidenceClass: "BROWSER" });
14
+ assert.equal(r.satisfied, true, r.note);
15
+ });
16
+
17
+ // ---- a visual criterion cannot pass from mesh/object existence ----------------------------
18
+ test("object-existence / mesh / canvas / DOM proxies never satisfy a VISUAL criterion", () => {
19
+ const visual = { visual: true };
20
+ for (const proxy of ["object_exists", "mesh_count", "canvas_nonblank", "dom_exists"]) {
21
+ const r = EV.classSatisfies(proxy, visual);
22
+ assert.equal(r.satisfied, false, `${proxy} must not satisfy visual: ${JSON.stringify(r)}`);
23
+ assert.equal(r.kind, "PROXY_INSUFFICIENT");
24
+ }
25
+ });
26
+ test("a unit-level STATIC/UNIT pass also fails a VISUAL criterion (needs render class)", () => {
27
+ assert.equal(EV.classSatisfies("UNIT", { visual: true }).satisfied, false);
28
+ assert.equal(EV.classSatisfies("BROWSER", { visual: true }).satisfied, true);
29
+ });
30
+
31
+ // ---- a non-visual unit criterion does NOT require vision -----------------------------------
32
+ test("non-visual UNIT criterion is satisfied by a UNIT receipt (no vision forced)", () => {
33
+ const c = { evidenceClass: "UNIT" };
34
+ assert.equal(EV.classSatisfies("UNIT", c).satisfied, true);
35
+ // an over-strong VISION receipt is not WRONG for a UNIT criterion (no forced ceiling), just not
36
+ // the matched class; a UNIT one matches.
37
+ assert.equal(EV.classSatisfies("STATIC", c).satisfied, false);
38
+ });
39
+
40
+ // ---- evidence gap stays separate from a plain test failure --------------------------------
41
+ test("evidenceGap reports missing required classes, not the exit status", () => {
42
+ const c = { evidenceClass: "BROWSER", checks: ["ui"] };
43
+ const gapOnly = EV.evidenceGap(c, [{ status: "PASS", checkId: "ui", evidenceClass: "UNIT" }]);
44
+ assert.equal(gapOnly.gap, true, "a UNIT pass leaves a BROWSER gap open");
45
+ assert.deepEqual(gapOnly.missing, ["BROWSER"]);
46
+ const satisfied = EV.evidenceGap(c, [{ status: "PASS", checkId: "ui", evidenceClass: "BROWSER" }]);
47
+ assert.equal(satisfied.gap, false, "browser evidence closes the gap");
48
+ });
49
+
50
+ // ---- rank ordering --------------------------------------------------------------------------
51
+ test("evidence classes are ordered weakest -> strongest", () => {
52
+ assert.ok(EV.rank("STATIC") < EV.rank("UNIT"));
53
+ assert.ok(EV.rank("UNIT") < EV.rank("INTEGRATION"));
54
+ assert.ok(EV.rank("INTEGRATION") < EV.rank("SYSTEM"));
55
+ assert.ok(EV.rank("SYSTEM") < EV.rank("BROWSER"));
56
+ assert.ok(EV.rank("BROWSER") < EV.rank("VISION"));
57
+ });
@@ -0,0 +1,27 @@
1
+ // Exact owned offline host for destructive crash injection. Never a live app host.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import * as C from '../../src/controller.js';
5
+ import { F } from '../helper.mjs';
6
+ const base = process.argv[2], dir = path.join(base, 'project');
7
+ process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
8
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../../src/controller.js');
9
+ const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
10
+ const context = { sessionID: 'crash-host', directory: dir, worktree: dir };
11
+ const completed = process.argv[3] === 'complete';
12
+ const command = [process.execPath, '-e', completed
13
+ ? "require('node:assert/strict').equal(require('node:fs').readFileSync('value.txt','utf8'),'fixture');console.log('actual assertion passed')"
14
+ : `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(path.join(base, 'child'))},String(process.pid));process.on('SIGTERM',()=>{});setTimeout(()=>{require('node:assert/strict').equal(fs.readFileSync('value.txt','utf8'),'fixture');},30000)`];
15
+ const started = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Offline durable execution crash test',
16
+ criteria: [{ id: 'assertion', checks: ['check'] }], checkCatalogue: { check: { command, timeoutMs: 35000, kind: 'cmd' } },
17
+ candidateBudget: 2, timeBudgetHours: 0.02, deadlineHours: 0.1, autoContinue: false }, context));
18
+ fs.writeFileSync(path.join(base, 'run-id'), started.runId);
19
+ if (completed) {
20
+ // Hold only the final ledger commit; the independently produced real journal is untouched.
21
+ const mutate = C.Store.prototype.mutate;
22
+ C.Store.prototype.mutate = function(key, callback) {
23
+ if (fs.readdirSync(this.keyPath(key)).some(n => /^execution-.*\.json$/.test(n))) return { error: 'STATE_BUSY' };
24
+ return mutate.call(this, key, callback);
25
+ };
26
+ }
27
+ await hooks.tool.longrun_verify.execute({ checkId: 'check', runId: started.runId }, context);