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,75 @@
1
+ // v1.2.22 — a declared check that is mapped from a criterion with an evidenceClass should not
2
+ // silently lose its evidence class when the model omits the optional per-call argument.
3
+ //
4
+ // Reproduced twice on real runs (annotations lr-00000000a1b2 and another run lr-00000000c3d4): the final
5
+ // whole-suite round called longrun_verify without evidenceClass, every receipt became classless, and
6
+ // status reported loss 1 / required_unverified / UNKNOWN_CLASS even though all six checks PASSED.
7
+ //
8
+ // Fix: when args.evidenceClass is absent, derive the receipt class from the run's OWN contract when
9
+ // exactly one required criterion that maps this check declares an evidenceClass. Explicit arguments
10
+ // always win, and ambiguous mappings must NOT guess.
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import * as C from '../src/controller.js';
17
+ import { F } from './helper.mjs';
18
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
19
+
20
+ async function fixture(t, criteria) {
21
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1222-'));
22
+ t.after(() => fs.rmSync(base, { recursive: true, force: true }));
23
+ const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'valid');
24
+ process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
25
+ const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
26
+ const ctx = { sessionID: 'evidence-class-fixture', directory: dir, worktree: dir };
27
+ const command = [process.execPath, '-e', "require('node:assert/strict').match(require('node:fs').readFileSync('value.txt','utf8'),/^valid/)"];
28
+ const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Isolated evidence-class regression', criteria, checkCatalogue: { check: { command, kind: 'cmd' }, gate: { command, kind: 'cmd', gate: true } } }, ctx));
29
+ assert.ok(start.runId, JSON.stringify(start));
30
+ const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
31
+ const read = () => { const r = store.readRun(key); assert.equal(r.error, undefined, JSON.stringify(r)); return r.run; };
32
+ const verify = async (args = {}) => JSON.parse(await hooks.tool.longrun_verify.execute({ runId: start.runId, checkId: 'check', ...args }, ctx));
33
+ return { dir, hooks, ctx, start, store, key, read, verify };
34
+ }
35
+
36
+ test('an omitted evidenceClass is derived from the criterion that maps the check', async t => {
37
+ const s = await fixture(t, [{ id: 'c', checks: ['check'], evidenceClass: 'BROWSER' }]);
38
+ const res = await s.verify();
39
+ assert.equal(res.status, 'PASS', JSON.stringify(res));
40
+ const receipt = s.read().receipts.at(-1);
41
+ assert.equal(receipt.evidenceClass, 'BROWSER', 'derived class recorded on the receipt');
42
+ const view = C.deriveRunView(s.read(), { currentFingerprint: s.read().sourceFingerprint });
43
+ assert.equal(view.criterionStates.find(c => c.id === 'c').satisfied, true, 'criterion satisfied without a per-call argument');
44
+ });
45
+
46
+ test('an explicit evidenceClass still overrides the derived default', async t => {
47
+ const s = await fixture(t, [{ id: 'c', checks: ['check'], evidenceClass: 'BROWSER' }]);
48
+ const res = await s.verify({ evidenceClass: 'UNIT' });
49
+ assert.equal(res.status, 'PASS', JSON.stringify(res));
50
+ assert.equal(s.read().receipts.at(-1).evidenceClass, 'UNIT', 'explicit argument wins');
51
+ const view = C.deriveRunView(s.read(), { currentFingerprint: s.read().sourceFingerprint });
52
+ assert.equal(view.criterionStates.find(c => c.id === 'c').satisfied, false, 'a weaker explicit class does not satisfy a BROWSER criterion');
53
+ });
54
+
55
+ test('an ambiguous mapping is not guessed', async t => {
56
+ const s = await fixture(t, [
57
+ { id: 'c1', checks: ['check'], evidenceClass: 'BROWSER' },
58
+ { id: 'c2', checks: ['check'], evidenceClass: 'UNIT' },
59
+ ]);
60
+ const res = await s.verify();
61
+ assert.equal(res.status, 'PASS', JSON.stringify(res));
62
+ assert.equal(s.read().receipts.at(-1).evidenceClass ?? null, null, 'ambiguous criteria must not auto-assign a class');
63
+ });
64
+
65
+ test('defaultEvidenceClass unit behaviour', () => {
66
+ const run = { contract: { criteria: [
67
+ { id: 'a', checks: ['x'], evidenceClass: 'INTEGRATION' },
68
+ { id: 'b', checks: ['y'], evidenceClass: 'BROWSER' },
69
+ ] } };
70
+ assert.equal(C.defaultEvidenceClass(run, 'x'), 'INTEGRATION');
71
+ assert.equal(C.defaultEvidenceClass(run, 'y'), 'BROWSER');
72
+ assert.equal(C.defaultEvidenceClass(run, 'z'), null, 'unmapped check has no default');
73
+ assert.equal(C.defaultEvidenceClass({ contract: { criteria: [{ id: 'a', checks: ['x'], evidenceClass: 'UNIT' }, { id: 'b', checks: ['x'], evidenceClass: 'BROWSER' }] } }, 'x'), null, 'conflicting classes are ambiguous');
74
+ assert.equal(C.defaultEvidenceClass(run, 'x', 'SYSTEM'), 'SYSTEM', 'explicit argument wins');
75
+ });
@@ -0,0 +1,120 @@
1
+ import { reviewProjectFixture } from "./helper.mjs";
2
+ // v1.2.3 end-to-end reproduction through the REAL plugin tool factory + controller (LONGRUN_TEST
3
+ // armed; isolated state dir). Reproduces the class of failure at the tool surface:
4
+ // a hard gate must be decided by its own CURRENT evidence, a negative control against a copied
5
+ // fixture must not become a project failure, and a passing build must let the run complete.
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import * as C from "../src/controller.js";
12
+
13
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, "..", "src", "controller.js");
14
+ const PLUG_URL = "../plugin/longrun.js";
15
+ const { F } = await import("./helper.mjs");
16
+
17
+ function freshState() { const d = fs.mkdtempSync(path.join(os.tmpdir(), "lr123-st-")); process.env.LONGRUN_STATE_DIR = d; return d; }
18
+
19
+ const VERIFY_ALL = `
20
+ import fs from "node:fs";
21
+ const src = fs.readFileSync("./app.txt", "utf8");
22
+ console.log("checks run: 3");
23
+ process.exit(src.includes("BROKEN") ? 1 : 0);
24
+ `;
25
+ function project(good = true) {
26
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), "lr123-proj-"));
27
+ fs.writeFileSync(path.join(d, "app.txt"), good ? "GOOD hero scene" : "BROKEN nothing");
28
+ fs.writeFileSync(path.join(d, "typecheck.mjs"), "process.exit(0);\n");
29
+ fs.writeFileSync(path.join(d, "build.mjs"), "process.exit(0);\n");
30
+ fs.writeFileSync(path.join(d, "verify-all.mjs"), VERIFY_ALL);
31
+ return d;
32
+ }
33
+ const CAT = {
34
+ "c-typecheck": { command: ["node", "typecheck.mjs"], kind: "cmd", gate: true },
35
+ "c-build": { command: ["node", "build.mjs"], kind: "cmd", gate: true },
36
+ "c-verify-all": { command: ["node", "verify-all.mjs"], kind: "cmd", gate: true, negativeControl: true },
37
+ };
38
+ // ENG is class-agnostic (no evidenceClass) so the criterion is satisfied by ANY clean receipt and
39
+ // the ONLY thing under test is the hard-gate recomputation. Criteria supplied as a JSON STRING to
40
+ // prove the single MTPLX-input normalizer; the gate checks are all NON-test (kind cmd).
41
+ const START = {
42
+ action: "start", request: "Verify the v1.2.3 build/gate reliability release",
43
+ criteria: JSON.stringify([{ id: "ENG", required: true, weight: 1, checks: ["c-typecheck", "c-build", "c-verify-all"] }]),
44
+ hardGates: JSON.stringify(["c-typecheck", "c-build", "c-verify-all"]),
45
+ checkCatalogue: JSON.stringify(CAT),
46
+ };
47
+ const CTX = (d) => ({ sessionID: "s123", agent: "longrun", directory: d, worktree: d });
48
+
49
+ async function tools() { freshState(); return (await F(PLUG_URL, { client: null })).tool; }
50
+ const gateRow = (st, id) => st.gates.find((g) => g.id === id);
51
+
52
+ test("start accepts JSON-string criteria/hardGates/checkCatalogue and creates a real run", async () => {
53
+ const t = await tools(); const d = project(); const c = CTX(d);
54
+ const start = JSON.parse(await t.longrun.execute({ ...START }, c));
55
+ assert.ok(start.runId, "run created from stringified structured params");
56
+ assert.equal(start.initialLoss, 1);
57
+ const st = JSON.parse(await t.longrun.execute({ action: "status" }, c));
58
+ assert.equal(st.runId, start.runId, "the stringified catalogue bound correctly (status resolves the run)");
59
+ assert.equal(st.gates.length, 3, "the three hard gates are surfaced in the status readout");
60
+ });
61
+
62
+ test("SABOTAGE (isolated fixture) records a negative control, NOT a project failure", async () => {
63
+ const t = await tools(); const d = project(true); const c = CTX(d);
64
+ const start = JSON.parse(await t.longrun.execute({ ...START }, c));
65
+ const RUN = start.runId;
66
+ const fx = fs.mkdtempSync(path.join(os.tmpdir(), "lr123-fx-"));
67
+ fs.writeFileSync(path.join(fx, "app.txt"), "BROKEN nothing");
68
+ fs.writeFileSync(path.join(fx, "verify-all.mjs"), VERIFY_ALL);
69
+ const neg = JSON.parse(await t.longrun_verify.execute({ checkId: "c-verify-all", mode: "negative", fixture: fx, runId: RUN }, c));
70
+ assert.equal(neg.kind, "negative_control", "the sabotage ran through the dedicated negative-control channel");
71
+ assert.equal(neg.ok, true, "the broken fixture correctly failed the verifier");
72
+ const run = new C.Store(process.env.LONGRUN_STATE_DIR).readJSON(C.stateKey(C.projectIdentity(d), RUN), "run.json");
73
+ assert.equal((run.receipts || []).filter((r) => r.checkId === "c-verify-all").length, 0, "no positive receipt was made from the sabotage");
74
+ assert.ok((run.evidence || []).some((e) => e.kind === "negative_control" && e.checkId === "c-verify-all"), "negative control lives in its own channel");
75
+ });
76
+
77
+ test("passing build + restored verify-all complete a gate-bound run (the reported failure is fixed)", async () => {
78
+ const t = await tools(); const d = project(true); const c = CTX(d);
79
+ const start = JSON.parse(await t.longrun.execute({ ...START }, c));
80
+ const RUN = start.runId;
81
+ const fx = fs.mkdtempSync(path.join(os.tmpdir(), "lr123-fx-"));
82
+ fs.writeFileSync(path.join(fx, "app.txt"), "BROKEN nothing"); fs.writeFileSync(path.join(fx, "verify-all.mjs"), VERIFY_ALL);
83
+ await t.longrun_verify.execute({ checkId: "c-verify-all", mode: "negative", fixture: fx, runId: RUN }, c); // sabotage first
84
+ for (const id of ["c-typecheck", "c-build", "c-verify-all"]) {
85
+ const v = JSON.parse(await t.longrun_verify.execute({ checkId: id, runId: RUN }, c));
86
+ assert.equal(v.status, "PASS", `${id} passes as a non-test gate (no test receipt required)`);
87
+ assert.equal(v.fpScope, "project", "a project-run receipt is recorded against the project scope");
88
+ }
89
+ const st = JSON.parse(await t.longrun.execute({ action: "status" }, c));
90
+ assert.deepEqual(st.gates.filter((g) => !g.satisfied).map((g) => g.id), [], "all hard gates now read satisfied");
91
+ reviewProjectFixture(d, RUN);
92
+ const done = JSON.parse(await t.longrun.execute({ action: "complete" }, c));
93
+ assert.equal(done.complete, true, "a passing build/tests gate the run -> completion, no longer stuck FAIL");
94
+ });
95
+
96
+ test("REGRESSION GUARD: a genuinely newer failure is not masked by an earlier PASS", async () => {
97
+ const t = await tools(); const d = project(true); const c = CTX(d);
98
+ const start = JSON.parse(await t.longrun.execute({ ...START }, c));
99
+ const RUN = start.runId;
100
+ for (const id of ["c-typecheck", "c-build"]) await t.longrun_verify.execute({ checkId: id, runId: RUN }, c);
101
+ await t.longrun_verify.execute({ checkId: "c-verify-all", runId: RUN }, c); // PASS on the healthy source
102
+ fs.writeFileSync(path.join(d, "app.txt"), "BROKEN nothing"); // regress, WITHOUT completing
103
+ const fail = JSON.parse(await t.longrun_verify.execute({ checkId: "c-verify-all", runId: RUN }, c));
104
+ assert.equal(fail.status, "FAIL", "the re-run honestly fails on the broken source");
105
+ const st = JSON.parse(await t.longrun.execute({ action: "status" }, c));
106
+ assert.equal(gateRow(st, "c-verify-all").status, "FAIL", "the gate reads the newest FAIL, not the older PASS");
107
+ const done = JSON.parse(await t.longrun.execute({ action: "complete" }, c));
108
+ assert.equal(done.complete, false, "completion re-blocks; the older PASS does not mask the regression");
109
+ });
110
+
111
+ test("STALE GUARD: an old PASS does not survive a source change (blocked until re-verified)", async () => {
112
+ const t = await tools(); const d = project(true); const c = CTX(d);
113
+ const start = JSON.parse(await t.longrun.execute({ ...START }, c));
114
+ const RUN = start.runId;
115
+ for (const id of ["c-typecheck", "c-build", "c-verify-all"]) await t.longrun_verify.execute({ checkId: id, runId: RUN }, c);
116
+ fs.writeFileSync(path.join(d, "extra.txt"), "changed"); // unrelated source edit changes the fingerprint
117
+ const done = JSON.parse(await t.longrun.execute({ action: "complete" }, c));
118
+ assert.equal(done.complete, false, "after the source changed the earlier gate passes are stale");
119
+ assert.equal(done.reason, "stale_evidence", "reported distinctly as stale evidence, not a silent pass");
120
+ });
@@ -0,0 +1,185 @@
1
+ import { approveFixtureReview } from "./helper.mjs";
2
+ // v1.2.3 reliability regression: the authoritative effective-receipt model + hard-gate recomputation.
3
+ // Reproduces the class of the live failure (a passing build/typecheck/tests still
4
+ // reported ENG incomplete / hard_gates_failed) using COPIED run/receipt state, then shows the
5
+ // upgraded logic completes. Nothing here touches production source, OpenCode config, or any live run.
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import * as C from "../src/controller.js";
9
+
10
+ const CUR = "curnt"; // the current, correct project fingerprint
11
+ function rc(checkId, status, fp, finishedAt, extra = {}) {
12
+ return C.makeReceipt({
13
+ checkId, command: "x", exitCode: status === "PASS" ? 0 : 1, output: "",
14
+ requirementKind: extra.kind || "cmd", sourceFingerprint: fp,
15
+ startedAt: finishedAt - 1, finishedAt, fpScope: extra.scope || "project", evidenceClass: extra.ec,
16
+ });
17
+ }
18
+ function mkRun({ criteria, gates, receipts }) {
19
+ return { status: "VERIFYING", sourceFingerprint: CUR, faults: [], contract: { criteria, gates, lossTarget: 0 }, receipts, state: {} };
20
+ }
21
+
22
+ // ---- anti-masking: a NEWER project failure is not hidden behind an older PASS ----------------
23
+ test("effective receipt: a newer FAIL masks an older PASS (real regression)", () => {
24
+ const run = mkRun({ criteria: [{ id: "ENG", required: true, checks: ["c-build"], status: "PASS" }], gates: [], receipts: [rc("c-build", "PASS", CUR, 10), rc("c-build", "FAIL", CUR, 20)] });
25
+ assert.equal(C.effectiveStatus(run, "c-build", CUR), "FAIL", "the most recent result governs, not 'any PASS'");
26
+ });
27
+ test("effective receipt: a restore PASS supersedes an earlier FAIL (sabotage then repair)", () => {
28
+ const run = mkRun({ criteria: [{ id: "ENG", required: true, checks: ["c-build"], status: "PASS" }], gates: [], receipts: [rc("c-build", "FAIL", CUR, 10), rc("c-build", "PASS", CUR, 20)] });
29
+ assert.equal(C.effectiveStatus(run, "c-build", CUR), "PASS", "the repaired (most recent) state governs");
30
+ });
31
+
32
+ // ---- fingerprint authority: fixture/copy results never decide a project check ------------------
33
+ test("a copied/fixture FAIL does not invalidate a current project PASS", () => {
34
+ const run = mkRun({
35
+ criteria: [{ id: "ENG", required: true, checks: ["c-verify-all"], status: "PASS" }], gates: [],
36
+ receipts: [rc("c-verify-all", "PASS", CUR, 10, { ec: "INTEGRATION" }), rc("c-verify-all", "FAIL", "copiesab", 20, { scope: "copy" })],
37
+ });
38
+ assert.equal(C.effectiveStatus(run, "c-verify-all", CUR), "PASS", "the copied sabotage is not project evidence and cannot turn it red");
39
+ });
40
+ test("an OLD project PASS is STALE on changed source, never trusted", () => {
41
+ const run = mkRun({ criteria: [{ id: "ENG", required: true, checks: ["c-build"], status: "PASS" }], gates: [], receipts: [rc("c-build", "PASS", "oldfp", 10)] });
42
+ assert.equal(C.effectiveStatus(run, "c-build", CUR), "STALE", "a pass from a different source state is not a current pass");
43
+ });
44
+
45
+ // ---- hard-gate readout / completion: independent of test count, recomputed from evidence ------
46
+ test("a build (non-test) hard gate completes on its own clean cmd receipt, no test receipt needed", () => {
47
+ const run = mkRun({
48
+ criteria: [{ id: "ENG", required: true, checks: ["c-typecheck", "c-build"], status: "PASS" }],
49
+ gates: [{ id: "c-typecheck", required: true, status: "FAIL" }, { id: "c-build", required: true, status: "FAIL" }],
50
+ receipts: [rc("c-typecheck", "PASS", CUR, 1), rc("c-build", "PASS", CUR, 2)],
51
+ });
52
+ approveFixtureReview(run, CUR);
53
+ const cc = C.canComplete(run, { currentFingerprint: CUR });
54
+ assert.equal(cc.complete, true, "both gates now read PASS from current evidence, despite a cached FAIL status");
55
+ });
56
+ test("a gate whose only evidence is a STALE pass blocks as stale_evidence (not trusting old PASS)", () => {
57
+ const run = mkRun({
58
+ criteria: [{ id: "ENG", required: true, checks: ["c-build"], status: "PASS" }],
59
+ gates: [{ id: "c-build", required: true, status: "FAIL" }],
60
+ receipts: [rc("c-build", "PASS", "oldfp", 1)], // stale: recorded on a different source state
61
+ });
62
+ const cc = C.canComplete(run, { currentFingerprint: CUR });
63
+ assert.equal(cc.complete, false);
64
+ assert.equal(cc.reason, "stale_evidence", "stale evidence is reported distinctly, not as a plain pass");
65
+ });
66
+ test("a gate with no project evidence is unverified (a copy sabotage is not a green light either)", () => {
67
+ const run = mkRun({
68
+ criteria: [{ id: "ENG", required: true, checks: ["c-verify-all"], status: "PASS" }],
69
+ gates: [{ id: "c-verify-all", required: true, status: "FAIL" }],
70
+ receipts: [rc("c-verify-all", "FAIL", "copiesab", 1, { scope: "copy" })], // only a copied-data failure, no project run
71
+ });
72
+ const cc = C.canComplete(run, { currentFingerprint: CUR });
73
+ assert.equal(cc.complete, false);
74
+ assert.match(cc.reason, /hard_gates_failed/, "no real project verification => the gate stays failed");
75
+ });
76
+
77
+ test("a multi-check criterion is not pinned 'unverified' by a stale marker when its checks pass", () => {
78
+ // Mirrors the 2nd-verify STALE flip: the criterion status is STALE but every mapped check has a
79
+ // current PASS. Completion must read the evidence, not the marker (the live 'ENG incomplete'
80
+ // symptom that sat alongside a passing build).
81
+ const run = mkRun({
82
+ criteria: [{ id: "ENG", required: true, checks: ["c-typecheck", "c-build"], evidenceClass: null, status: "STALE" }],
83
+ gates: [],
84
+ receipts: [rc("c-typecheck", "PASS", CUR, 1), rc("c-build", "PASS", CUR, 2)],
85
+ });
86
+ assert.deepEqual(C.effectiveRemaining(run, CUR), [], "criterion reports satisfied from current evidence");
87
+ approveFixtureReview(run, CUR);
88
+ assert.equal(C.canComplete(run, { currentFingerprint: CUR }).complete, true, "completion follows evidence, not the stale marker");
89
+ });
90
+ test("class awareness is preserved: a UNIT-only proof cannot satisfy a BROWSER criterion", () => {
91
+ const run = mkRun({
92
+ criteria: [{ id: "VIS", required: true, checks: ["c-vis"], evidenceClass: "BROWSER", status: "FAIL" }],
93
+ gates: [],
94
+ receipts: [rc("c-vis", "PASS", CUR, 1, { ec: "UNIT" })], // class too weak for a BROWSER requirement
95
+ });
96
+ const cc = C.canComplete(run, { currentFingerprint: CUR });
97
+ assert.equal(cc.complete, false);
98
+ assert.equal(cc.reason, "required_unverified", "a weak proof is an evidence gap, not a completion");
99
+ });
100
+
101
+ // ---- THE REPRODUCTION: a faithful copy of the run shape -------------------------
102
+ // Gates c-typecheck / c-build / c-verify-all are cached FAIL; every underlying check has a PASS on
103
+ // current source; a build sabotage was recorded against COPIED data (a negative control). The OLD
104
+ // logic (trust cached gate FAIL, or "any PASS") would keep ENG incomplete / hard_gates_failed; the
105
+ // v1.2.3 logic reads the effective receipt and completes.
106
+ function exampleRun() {
107
+ const receipts = [
108
+ rc("c-fog-unit", "PASS", CUR, 1, { ec: "UNIT" }),
109
+ rc("c-detect-unit", "PASS", CUR, 2, { ec: "UNIT" }),
110
+ rc("c-ui-e2e", "PASS", CUR, 3, { ec: "SYSTEM" }),
111
+ rc("c-verify-all", "FAIL", "copsab", 4, { scope: "copy" }), // sabotage against a copied workspace
112
+ rc("c-typecheck", "PASS", CUR, 5),
113
+ rc("c-build", "PASS", CUR, 6),
114
+ rc("c-verify-all", "PASS", CUR, 7, { ec: "INTEGRATION" }), // restore, on current source
115
+ ];
116
+ const gates = ["c-typecheck", "c-build", "c-verify-all"].map((id) => ({ id, required: true, status: "FAIL" }));
117
+ const criteria = [
118
+ { id: "DETERM", required: true, checks: ["c-fog-unit", "c-detect-unit"], evidenceClass: null, status: "PASS" },
119
+ { id: "ENG", required: true, checks: ["c-typecheck", "c-build", "c-verify-all"], evidenceClass: "INTEGRATION", status: "PASS" },
120
+ ];
121
+ return mkRun({ criteria, gates, receipts });
122
+ }
123
+
124
+ test("REPRODUCE + FIX: passing build/tests no longer pin the hard gates shut", () => {
125
+ const run = exampleRun();
126
+ // (old behaviour) a cached FAIL that nothing ever recomputed => permanent block:
127
+ const oldBlock = run.contract.gates.some((g) => g.required && g.status !== "PASS");
128
+ assert.equal(oldBlock, true, "the run still carries stale cached gate FAIL values (the reported symptom)");
129
+ // the sabotage must not be counted as the project's c-verify-all state:
130
+ assert.equal(C.effectiveStatus(run, "c-verify-all", CUR), "PASS", "copied sabotage excluded; restore PASS governs");
131
+ for (const id of ["c-typecheck", "c-build", "c-verify-all"]) assert.equal(C.effectiveStatus(run, id, CUR), "PASS", `${id} reads PASS on current source`);
132
+ assert.deepEqual(C.gateStatuses(run, CUR).filter((g) => !g.satisfied).map((g) => g.id), [], "no hard gate stays stuck FAIL");
133
+ // (new behaviour) completion follows the effective receipt:
134
+ approveFixtureReview(run, CUR);
135
+ const cc = C.canComplete(run, { currentFingerprint: CUR });
136
+ assert.equal(cc.complete, true, "all hard gates + criteria evaluate PASS from current evidence -> completion allowed");
137
+ });
138
+
139
+ test("SABOTAGE ISOLATION: a copied failure alone does not make a previously-good gate passable", () => {
140
+ // If the ONLY evidence for a gate check is the copied sabotage, there is no real project proof.
141
+ const run = mkRun({
142
+ criteria: [{ id: "ENG", required: true, checks: ["c-verify-all"], status: "PASS" }],
143
+ gates: [{ id: "c-verify-all", required: true, status: "FAIL" }],
144
+ receipts: [rc("c-verify-all", "FAIL", "copiesab", 1, { scope: "copy" })],
145
+ });
146
+ const readout = C.gateStatuses(run, CUR);
147
+ assert.equal(readout[0].status, "NOT_RUN", "a copied result is not project evidence; the gate shows no project proof");
148
+ });
149
+
150
+ // ---- parseTestCounts: discover real counts; zero is a coverage gap ----------------------------
151
+ test("parseTestCounts discovers vitest/playwright/node:test counts and treats zero as a gap", () => {
152
+ assert.equal(C.parseTestCounts("Tests 12 passed (12)"), 12);
153
+ assert.equal(C.parseTestCounts("18 passed (18)"), 18);
154
+ assert.equal(C.parseTestCounts("ℹ tests 5\nℹ pass 5"), 5);
155
+ assert.equal(C.parseTestCounts("RESULT pass=4 fail=0 total=4"), 4);
156
+ assert.equal(C.parseTestCounts("Tests 1 failed (1)\nTests 0 passed"), 0);
157
+ });
158
+ test("a NON-test hard gate (kind cmd) is never forced into NOT_RUN by the zero-test rule", () => {
159
+ const build = C.makeReceipt({ checkId: "c-build", command: "npm run build", exitCode: 0, testCount: undefined, requirementKind: "cmd", sourceFingerprint: CUR, startedAt: 1, finishedAt: 2 });
160
+ assert.equal(build.status, "PASS", "build/typecheck pass on exit code; a test receipt is not required");
161
+ });
162
+
163
+ // ---- normalizeStartArgs: stringified structured params + string budgets/booleans -------------
164
+ test("normalizeStartArgs parses JSON-string criteria/catalogue/hardGates and numeric/bool params", () => {
165
+ const raw = {
166
+ request: "x",
167
+ criteria: '[{"id":"ENG","checks":["c-build"]}]',
168
+ checkCatalogue: '{"c-build":{"command":["npm","run","build"],"kind":"cmd"}}',
169
+ hardGates: '["c-build"]',
170
+ candidateBudget: "6", timeBudgetHours: "0.5", autoContinue: "false",
171
+ };
172
+ const n = C.normalizeStartArgs(raw);
173
+ assert.ok(Array.isArray(n.criteria) && n.criteria[0].id === "ENG", "criteria stringified array parsed");
174
+ assert.ok(!Array.isArray(n.checkCatalogue) && n.checkCatalogue["c-build"], "catalogue object parsed");
175
+ assert.deepEqual(n.hardGates, ["c-build"], "hardGates string parsed");
176
+ assert.equal(n.candidateBudget, 6, "budget coerced to number");
177
+ assert.equal(n.timeBudgetHours, 0.5);
178
+ assert.equal(n.autoContinue, false, "string 'false' is not truthy");
179
+ });
180
+ test("normalizeStartArgs never fabricates: malformed JSON -> empty, real arrays untouched", () => {
181
+ const n = C.normalizeStartArgs({ criteria: "not-json", checkCatalogue: "{bad", candidateBudget: "abc" });
182
+ assert.deepEqual(n.criteria, [], "malformed criteria becomes empty (=> start refuses, no fake run)");
183
+ assert.deepEqual(n.checkCatalogue, {}, "malformed catalogue becomes empty");
184
+ assert.equal(n.candidateBudget, undefined, "non-numeric budget dropped to a safe default");
185
+ });
@@ -0,0 +1,147 @@
1
+ import { reviewProjectFixture } from "./helper.mjs";
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import * as C from '../src/controller.js';
8
+ process.env.LONGRUN_TEST = '1';
9
+ process.env.LONGRUN_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(),'lr124-state-'));
10
+ process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname,'../src/controller.js');
11
+ const plugin = await import('../plugin/longrun.js');
12
+ const temp = () => fs.mkdtempSync(path.join(os.tmpdir(),'lr124-project-'));
13
+ const write = (d,p,s) => { fs.mkdirSync(path.dirname(path.join(d,p)),{recursive:true}); fs.writeFileSync(path.join(d,p),s); };
14
+ const rc = (id, fp, exitCode=0, extra={}) => C.makeReceipt({checkId:id,command:'assertions',sourceFingerprint:fp,exitCode,startedAt:1,finishedAt:2,...extra});
15
+ const run = () => ({runId:'fixture',status:'VERIFYING',sourceFingerprint:'a',receipts:[],state:{},contract:{criteria:['one','two'].map(id=>({id,required:true,checks:[id]})),gates:[],lossTarget:0}});
16
+
17
+ test('canonical loss replays 1 -> .5 -> 0 -> .5; cached best/current cannot manufacture zero',()=>{
18
+ const r=run();
19
+ assert.equal(C.deriveRunView(r).currentLoss,1);
20
+ C.applyVerification(r,{receipt:rc('one','a')}); assert.equal(C.deriveRunView(r).currentLoss,.5);
21
+ C.applyVerification(r,{receipt:rc('two','a')}); assert.equal(C.deriveRunView(r).bestLoss,0);
22
+ C.applyVerification(r,{receipt:rc('two','a',1,{finishedAt:3})});
23
+ const v=C.deriveRunView(r); assert.equal(v.currentLoss,.5);assert.equal(v.bestLoss,0);
24
+ r.state.best={loss:-100};r.state.current={loss:-100};assert.equal(C.deriveRunView(r).bestLoss,0);
25
+ const missing=run();missing.state.best={loss:0};assert.equal(C.deriveRunView(missing).bestLoss,1);
26
+ });
27
+ test('hard gate only can block with weighted loss zero',()=>{
28
+ const r=run();r.receipts=[rc('one','a'),rc('two','a')];r.contract.gates=[{id:'build',checks:['build'],required:true}];
29
+ const v=C.deriveRunView(r);assert.equal(v.currentLoss,0);assert.equal(v.completionBlocked,true);assert.deepEqual(v.hardGateBlockers.map(g=>g.id),['build']);
30
+ });
31
+ test('missing legacy fingerprint is retained, labelled, and genuinely superseded',()=>{
32
+ const r=run();r.receipts=[rc('one',undefined)];const before=JSON.stringify(r.receipts);
33
+ let v=C.deriveRunView(r);const d=v.checks.find(c=>c.checkId==='one');
34
+ assert.equal(d.staleReason,'LEGACY_STALE_MISSING_FINGERPRINT');assert.equal(d.effectiveReceipt,null);assert.equal(d.historicalReceipts[0].missingField,'sourceFingerprint');
35
+ assert.equal(JSON.stringify(r.receipts),before);
36
+ C.applyVerification(r,{receipt:rc('one','a',0,{finishedAt:3})});v=C.deriveRunView(r);
37
+ assert.equal(v.checks[0].effectiveStatus,'PASS');assert.equal(v.checks[0].historicalReceiptCount,2);assert.equal(JSON.stringify(r.receipts.slice(0,1)),before);
38
+ });
39
+ test('contract/evaluator mismatch and negative controls cannot supply current PASS',()=>{
40
+ const r=run();r.contractHash='contract';r.evaluatorHash='evaluator';
41
+ r.receipts=[rc('one','a',0,{contractHash:'old'})];assert.equal(C.deriveRunView(r).checks[0].staleReason,'CONTRACT_MISMATCH');
42
+ r.receipts=[rc('one','a',0,{contractHash:'contract',evaluatorHash:'old'})];assert.equal(C.deriveRunView(r).checks[0].staleReason,'EVALUATOR_MISMATCH');
43
+ r.receipts=[rc('one','a',0,{contractHash:'contract',evaluatorHash:'evaluator'}),{...rc('one','a',1,{finishedAt:3}),mode:'negative'}];assert.equal(C.deriveRunView(r).checks[0].effectiveStatus,'PASS');
44
+ });
45
+ test('fingerprints exclude generated evidence/checkpoints/harness outputs but include app/test/config edits',()=>{
46
+ const d=temp();write(d,'src/app.ts','export const a=1');write(d,'package.json','{}');
47
+ const base=C.sourceFingerprint(d);
48
+ for(const p of ['.longrun/events.log','docs/CHECKPOINT.md','artifacts/screen.png','artifacts/receipt.json','test-results/x.json','packages/web/dist/index.html','longrun-harness/releases/x/controller.js'])write(d,p,'generated');
49
+ assert.equal(C.sourceFingerprint(d).hash,base.hash);assert.notEqual(C.sourceFingerprint(d).legacyHash,base.legacyHash);
50
+ for(const p of ['src/app.ts','test/app.test.ts','package.json']){const old=C.sourceFingerprint(d).hash;write(d,p,'changed');assert.notEqual(C.sourceFingerprint(d).hash,old);}
51
+ });
52
+ test('fingerprint policy migration recognizes only observed legacy identity; revisiting a candidate is not new',()=>{
53
+ const r=run();r.state.lastEvalFingerprint='old';
54
+ const receipt=rc('one','new',0,{fingerprintSchemaVersion:2});
55
+ const a=C.applyVerification(r,{receipt,currentFingerprint:{hash:'new',legacyHash:'old'}});assert.equal(a.counted,false);
56
+ assert.equal(C.applyVerification(r,{receipt:rc('one','new',0,{fingerprintSchemaVersion:2}),currentFingerprint:{hash:'new',legacyHash:'metadata-changed'}}).counted,false);
57
+ C.applyVerification(r,{receipt:rc('one','b')});C.applyVerification(r,{receipt:rc('one','c')});
58
+ const n=C.candidateCount(r);C.applyVerification(r,{receipt:rc('one','b')});assert.equal(C.candidateCount(r),n);
59
+ });
60
+ test('memory generator upgrade stays fresh; ordinary edit stays fresh; precise entry change and refresh',()=>{
61
+ const d=temp();write(d,'package.json','{}');write(d,'src/index.ts','export const x=1');write(d,'src/body.ts','export const b=1');
62
+ C.memory.initDeep(d,{harnessVersion:'1.2.2'});const index=C.memory.readMemoryIndex(d);index.harnessVersion='1.2.2';
63
+ assert.equal(C.memory.assessStaleness(d,index).status,'FRESH');write(d,'src/body.ts','export const b=2');assert.equal(C.memory.assessStaleness(d,index).status,'FRESH');
64
+ write(d,'src/index.ts','export const x=2');const v=C.memory.assessStaleness(d,index);assert.equal(v.status,'STALE');assert.ok(v.changedDependencies.some(x=>x.path==='src/index.ts' && x.previousFingerprint!==x.currentFingerprint));
65
+ C.memory.initDeep(d,{harnessVersion:'1.2.4'});assert.equal(C.memory.assessStaleness(d,C.memory.readMemoryIndex(d)).status,'FRESH');
66
+ });
67
+ test('old memory digest mismatch is honest about absent path history and schema mismatch',()=>{
68
+ const d=temp();write(d,'package.json','{}');C.memory.initDeep(d,{harnessVersion:'1.2.2'});const index=C.memory.readMemoryIndex(d);delete index.structuralDependencies;
69
+ write(d,'package.json','{"scripts":{"test":"node --test"}}');let v=C.memory.assessStaleness(d,index);
70
+ assert.ok(v.changedDependencies.some(x=>x.reason==='LEGACY_STRUCTURAL_DIGEST_CHANGED' && x.exactPathsKnown===false));
71
+ index.memorySchemaVersion=99;v=C.memory.assessStaleness(d,index);assert.ok(v.changedDependencies.some(x=>x.reason==='MEMORY_SCHEMA_INCOMPATIBLE'));
72
+ });
73
+ async function seed(r,d) {
74
+ const sd=fs.mkdtempSync(path.join(os.tmpdir(),'lr124-store-'));process.env.LONGRUN_STATE_DIR=sd;
75
+ const key=C.stateKey(C.projectIdentity(d),r.runId);r.directory=d;const store=new C.Store(sd);store.writeJSON(key,'run.json',r);
76
+ fs.writeFileSync(path.join(sd,'runs.json'),JSON.stringify({offline:{runKey:key,directory:d,runId:r.runId,checkCatalogue:r.checkCatalogue}}));
77
+ const hooks=await plugin.default.server({client:null});
78
+ const t=hooks.tool,ctx={sessionID:'offline',directory:d,worktree:d};
79
+ const call=async action=>{const text=await t.longrun.execute({action,runId:r.runId},ctx);try{return JSON.parse(text)}catch{return text}};
80
+ return {t,ctx,call,store,key,hooks};
81
+ }
82
+ test('compaction recovery matches explicit recovery after source and memory dependencies change',async(testContext)=>{
83
+ const d=temp();write(d,'src/index.ts','export const value=1');write(d,'package.json','{}');
84
+ C.memory.initDeep(d,{harnessVersion:'1.2.4'});
85
+ const r=run();r.checkCatalogue={one:{kind:'cmd',command:[process.execPath,'-e',"require('node:assert/strict').equal(require('node:fs').readFileSync('src/index.ts','utf8'),'export const value=1')"]}};
86
+ const {t,ctx,call,store,key,hooks}=await seed(r,d);
87
+ assert.equal(JSON.parse(await t.longrun_verify.execute({checkId:'one',runId:r.runId},ctx)).status,'PASS');
88
+ write(d,'src/index.ts','export const value=2');
89
+ const before=store.readJSON(key,'run.json');
90
+ const observedAt=Date.now();testContext.mock.method(Date,'now',()=>observedAt); // compare the same observation, including live deadline timing
91
+ const packet=await call('resume-context');
92
+ assert.match(packet,/CURRENT LOSS: 1/);assert.match(packet,/MEMORY STATUS: STALE/);
93
+ const output={context:['existing host context']};
94
+ await hooks['experimental.session.compacting']({sessionID:'offline'},output);
95
+ assert.equal(output.context.length,2);
96
+ assert.equal(output.context[0],'existing host context');
97
+ assert.match(output.context[1],/^## Long-run recovery\nPRE-COMPACTION SNAPSHOT:/);
98
+ assert.ok(output.context[1].endsWith(packet));
99
+ assert.deepEqual(store.readJSON(key,'run.json'),before,'compaction must not rewrite ledger or fingerprints');
100
+ });
101
+ test('real recorded receipt fixture: 18 stale plus 4 absent, 36 retained, 13 candidates and contract untouched across tool projections',async(t)=>{
102
+ const r=JSON.parse(fs.readFileSync(new URL('./fixtures/example-app-run.json',import.meta.url)));const original=JSON.stringify(r.contract), receipts=JSON.stringify(r.receipts),budget=JSON.stringify(r.budget);
103
+ assert.equal(r.receipts.length,36);assert.ok(r.receipts.every(x=>x.sourceFingerprint));
104
+ const d=temp();write(d,'app.ts','isolated fixture');const {call,store,key}=await seed(r,d);
105
+ const observedAt=Date.now();t.mock.method(Date,'now',()=>observedAt); // stable clock for exact projection equality
106
+ const status=await call('status'),verify=await call('verify'),complete=await call('complete');
107
+ for(const k of ['currentLoss','bestLoss','targetLoss','memoryStatus','candidateCount','budgets','criterionStates','hardGateBlockers','evidenceGaps','completionBlocked']){assert.deepEqual(status[k],verify[k]);assert.deepEqual(status[k],complete[k]);}
108
+ assert.equal(status.candidateCount,13);assert.equal(status.currentLoss,1);assert.equal(status.staleEvidence.length,18);assert.equal(status.historicalReceiptCount,36);assert.equal(status.completionBlocked,true);
109
+ assert.deepEqual(status.checks.filter(x=>x.blockingReason==='NO_RECEIPT').map(x=>x.checkId), ['c-detect-e2e','c-sec-e2e','c-ai-e2e','c-mission-e2e']);
110
+ assert.ok(status.staleEvidence.every(x=>x.staleReason==='SOURCE_FINGERPRINT_MISMATCH'));
111
+ for(const a of ['next','resume-context']){const packet=await call(a);assert.match(packet,/CURRENT LOSS: 1/);assert.match(packet,/HISTORICAL RECEIPTS: 36/);assert.ok(packet.includes(`BEST LOSS: ${status.bestLoss}`));}
112
+ assert.equal((await call('memory_status')).status,status.memoryStatus);
113
+ await call('checkpoint');assert.deepEqual(C.summarizeRunView(store.readJSON(key,'checkpoint.json').view),status);
114
+ const after=store.readJSON(key,'run.json');assert.equal(JSON.stringify(after.contract),original);assert.equal(JSON.stringify(after.receipts),receipts);assert.equal(JSON.stringify(after.budget),budget);
115
+ });
116
+ test('actual declared assertion receipt clears a legacy block without contract changes; current failure reblocks',async()=>{
117
+ const d=temp();write(d,'app.txt','valid');
118
+ const r=run();r.contract.criteria=[{id:'one',required:true,checks:['one']}];r.contract.gates=[{id:'one',required:true}];
119
+ r.checkCatalogue={one:{kind:'cmd',command:['node','-e',"require('node:assert/strict').equal(require('node:fs').readFileSync('app.txt','utf8'),'valid')"]}};
120
+ r.receipts=[rc('one',undefined)];const original=JSON.stringify(r.contract);const {call,t,ctx,store,key}=await seed(r,d);
121
+ assert.equal((await call('complete')).complete,false);
122
+ const v=JSON.parse(await t.longrun_verify.execute({checkId:'one',runId:r.runId},ctx));assert.equal(v.status,'PASS');assert.equal((await call('status')).currentLoss,0);
123
+ write(d,'app.txt','broken');assert.equal(JSON.parse(await t.longrun_verify.execute({checkId:'one',runId:r.runId},ctx)).status,'FAIL');assert.equal((await call('complete')).complete,false);
124
+ write(d,'app.txt','valid');await t.longrun_verify.execute({checkId:'one',runId:r.runId},ctx);reviewProjectFixture(d,r.runId);assert.equal((await call('complete')).complete,true);
125
+ const saved=store.readJSON(key,'run.json');assert.equal(JSON.stringify(saved.contract),original);assert.equal(saved.receipts.length,4);
126
+ });
127
+
128
+ test('source changed during verifier execution cannot generate a current PASS',async()=>{
129
+ const d=temp();write(d,'app.txt','valid');const r=run();r.checkCatalogue={one:{kind:'cmd',command:['node','-e',"require('node:fs').writeFileSync('app.txt','changed')"]}};
130
+ const {t,ctx,call}=await seed(r,d);
131
+ const result=JSON.parse(await t.longrun_verify.execute({checkId:'one',runId:r.runId},ctx));assert.equal(result.status,'STALE');
132
+ assert.equal((await call('complete')).complete,false);
133
+ });
134
+ test('missing receipts never inherit cached PASS, including gate-only checks',()=>{
135
+ const r=run();r.contract.criteria.forEach(c=>c.status='PASS');r.checkCatalogue={build:{command:['node','build.js']}};r.contract.gates=[{id:'build',required:true,status:'PASS'}];
136
+ const v=C.deriveRunView(r);assert.equal(v.currentLoss,1);assert.equal(v.gates[0].status,'NOT_RUN');assert.equal(v.completionBlocked,true);
137
+ });
138
+ test('schema migration cannot invent a fingerprint for a legacy PASS',()=>{
139
+ const r=run();r.receipts=[rc('one','old-hash')];
140
+ const diag=C.checkDiagnostics(r,'one',{hash:'new-hash',legacyHash:'old-hash',schemaVersion:2});assert.equal(diag.effectiveStatus,'PASS');assert.equal(diag.receiptFingerprint,'old-hash');
141
+ assert.equal(C.checkDiagnostics(r,'one',{hash:'new-hash',legacyHash:'different'}).effectiveStatus,'STALE');
142
+ });
143
+ test('explicit unknown control-plane run cannot complete or pause the current run',async()=>{
144
+ const d=temp(),r=run();const {t,ctx,store,key}=await seed(r,d);
145
+ for(const action of ['status','complete','pause'])assert.equal(JSON.parse(await t.longrun.execute({action,runId:'wrong'},ctx)).state,'NO_RUN');
146
+ assert.equal(store.readJSON(key,'run.json').status,'VERIFYING');
147
+ });
@@ -0,0 +1,48 @@
1
+ import { test } from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import { spawnSync } from 'node:child_process';import { pathToFileURL } from 'node:url';
2
+ import { install,VERSION } from '../src/install.mjs';
3
+ process.env.LONGRUN_TEST='1';
4
+ process.env.LONGRUN_STATE_DIR=fs.mkdtempSync(path.join(os.tmpdir(),'lr124-installed-state-'));
5
+ delete process.env.LONGRUN_CONTROLLER_FILE;
6
+ test('installed artifact executes canonical v1.2.4 independently of source and preserves unrelated JSONC',async(t)=>{
7
+ const config=fs.mkdtempSync(path.join(os.tmpdir(),'lr124-config-')),project=fs.mkdtempSync(path.join(os.tmpdir(),'lr124-installed-project-'));
8
+ fs.writeFileSync(path.join(project,'value.txt'),'tested');
9
+ const jsonc='// keep comments\n{"model":"unchanged/provider","permission":"ask"}\n';fs.writeFileSync(path.join(config,'opencode.jsonc'),jsonc);fs.mkdirSync(path.join(config,'plugins'));fs.writeFileSync(path.join(config,'plugins/unrelated.js'),'// unrelated\n');
10
+ assert.equal(install({configDir:config}).conflicts.length,0);
11
+ assert.equal(fs.readFileSync(path.join(config,'opencode.jsonc'),'utf8'),jsonc);
12
+ const pluginPath=path.join(config,'plugins/longrun.js'),module=await import(pathToFileURL(pluginPath));assert.deepEqual(Object.keys(module),['default']);assert.equal(module.default.id,'longrun');
13
+ assert.ok(fs.readFileSync(pluginPath,'utf8').includes(`/releases/${VERSION}/lib/controller.js`));
14
+ const hooks=await module.default.server({client:null});
15
+ const tools=hooks.tool,ctx={sessionID:'isolated',directory:project,worktree:project};
16
+ const call=async args=>JSON.parse(await tools.longrun.execute(args,ctx));
17
+ assert.equal((await call({action:'help'})).harnessVersion,VERSION);
18
+ const command=['node','-e',"require('node:assert/strict').equal(require('node:fs').readFileSync('value.txt','utf8'),'tested')"];
19
+ const start=await call({action:'start',request:'Verify installed assertion',criteria:[{id:'assertion',checks:['assertion']}],checkCatalogue:{assertion:{command,kind:'cmd'}}});
20
+ assert.ok(start.runId,JSON.stringify(start));
21
+ const saved=await call({action:'checkpoint',runId:start.runId,progress:{currentSlice:'installed recovery',nextAction:'execute declared assertion'}});
22
+ assert.equal(saved.checkpointed,true);
23
+ assert.match(await tools.longrun.execute({action:'resume-context',runId:start.runId},ctx),/NEXT ACTION: execute declared assertion/);
24
+ assert.equal(JSON.parse(await tools.longrun_verify.execute({runId:start.runId,checkId:'assertion'},ctx)).status,'PASS');
25
+ assert.equal((await call({action:'complete'})).blockReason,'completion_review_required');
26
+ await tools.longrun.execute({action:'pause',runId:start.runId},ctx);
27
+ const pending=await call({action:'status'});
28
+ const reasonFile=path.join(config,'review-reason.txt');fs.writeFileSync(reasonFile,'Independent isolated installed-artifact review.');
29
+ const env={...process.env,OPENCODE_CONFIG_DIR:config};delete env.LONGRUN_CONTROLLER_FILE;delete env.NODE_TEST_CONTEXT;
30
+ const review=spawnSync(path.join(config,'longrun-harness/longrun'),['review','--project',project,'--run',start.runId,'--verdict','accept','--expected-basis',pending.completionReview.basis,'--review-id','installed-review','--reason-file',reasonFile,'--json'],{cwd:os.tmpdir(),env,encoding:'utf8'});
31
+ assert.equal(review.status,0,review.stdout+review.stderr);assert.equal(JSON.parse(review.stdout).completionReview.status,'ACCEPTED');
32
+ const status=await call({action:'status'}),verify=await call({action:'verify'}),complete=await call({action:'complete'});
33
+ for(const key of ['currentLoss','bestLoss','targetLoss','candidateCount','memoryStatus','completionBlocked','hardGateBlockers','evidenceGaps']){assert.deepEqual(verify[key],status[key]);assert.deepEqual(complete[key],status[key]);}
34
+ assert.equal(complete.complete,true);
35
+
36
+ const doctor=spawnSync(path.join(config,'longrun-harness/longrun'),['doctor','--json'],{cwd:project,env,encoding:'utf8'});
37
+ assert.equal(doctor.status,0,doctor.stdout+doctor.stderr);const report=JSON.parse(doctor.stdout);assert.equal(report.installedVersion,VERSION);assert.equal(report.ok,true);assert.equal(report.executedControllerVersion,VERSION);
38
+ const cli=spawnSync(path.join(config,'longrun-harness/longrun'),['status','--project',project,'--run',start.runId,'--json'],{cwd:project,env,encoding:'utf8'});
39
+ assert.equal(cli.status,0,cli.stdout+cli.stderr);const cliView=JSON.parse(cli.stdout);assert.equal(cliView.currentLoss,status.currentLoss);assert.equal(cliView.bestLoss,status.bestLoss);assert.equal(cliView.memoryStatus,status.memoryStatus);
40
+ fs.writeFileSync(path.join(project,'value.txt'),'changed after verification');
41
+ const observedAt=Date.now();t.mock.method(Date,'now',()=>observedAt); // both recovery paths observe the same deadline instant
42
+ const packet=await tools.longrun.execute({action:'resume-context',runId:start.runId},ctx);
43
+ assert.match(packet,/CURRENT LOSS: 1/);
44
+ const output={context:[]};await hooks['experimental.session.compacting']({sessionID:'isolated'},output);
45
+ assert.equal(output.context.length,1);
46
+ assert.match(output.context[0],/^## Long-run recovery\nPRE-COMPACTION SNAPSHOT:/);
47
+ assert.ok(output.context[0].endsWith(packet));
48
+ });