opencode-longrun-harness 1.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { reviewProjectFixture } from "./helper.mjs";
|
|
2
|
+
// v1.2.2 commissioning regression: the canonical run resolver + verify contract + resume rebind +
|
|
3
|
+
// cancel, driven through the REAL plugin tool factory + the REAL controller (LONGRUN_TEST armed so
|
|
4
|
+
// nothing can ever be written into a production state dir; nothing here touches production source,
|
|
5
|
+
// OpenCode config, model/sampler/context, permissions, compaction or continuation).
|
|
6
|
+
//
|
|
7
|
+
// It specifically reproduces the LIVE v1.2.1 failure: status/resume could see an IMPLEMENTING run
|
|
8
|
+
// but longrun_verify returned a generic `no_active_run`. The verifier must now resolve the SAME
|
|
9
|
+
// authoritative run as the control plane, and lifecycle verification must not inflate candidates.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import * as C from "../src/controller.js";
|
|
16
|
+
|
|
17
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, "..", "src", "controller.js");
|
|
18
|
+
const PLUG_URL = "../plugin/longrun.js";
|
|
19
|
+
const { F } = await import("./helper.mjs");
|
|
20
|
+
|
|
21
|
+
function state(d) { process.env.LONGRUN_STATE_DIR = d; return d; }
|
|
22
|
+
function freshState() { return state(fs.mkdtempSync(path.join(os.tmpdir(), "lr122-st-"))); }
|
|
23
|
+
function proj(file = "x") { const d = fs.mkdtempSync(path.join(os.tmpdir(), "lr122-proj-")); fs.writeFileSync(path.join(path.join(d, "a.js")), file); return d; }
|
|
24
|
+
function keyFor(dir, runId) { return C.stateKey(C.projectIdentity(dir), runId); }
|
|
25
|
+
function store() { return new C.Store(process.env.LONGRUN_STATE_DIR); }
|
|
26
|
+
function ctx(sessionID, dir) { return { sessionID, agent: "longrun", directory: dir, worktree: dir }; }
|
|
27
|
+
async function tools() { const h = await F(PLUG_URL, { client: null }); return h.tool; } // fresh factory + state
|
|
28
|
+
|
|
29
|
+
const STATIC_CAT = { "c1": { command: ["node", "-e", "process.exit(0)"], kind: "cmd", timeoutMs: 15000 } };
|
|
30
|
+
function seededRun(dir, runId, status, extra = {}) {
|
|
31
|
+
const s = store();
|
|
32
|
+
const run = C.startRun({
|
|
33
|
+
request: "commissioning lifecycle",
|
|
34
|
+
contract: { criteria: [{ id: "LC-001", required: true, weight: 1, checks: ["c1"], evidenceClass: "STATIC", status: "FAIL" }], gates: [], lossTarget: 0 },
|
|
35
|
+
budgets: { iterations: 3, activeSeconds: 900, deadlineSeconds: 28800, sameFailureLimit: 2, noProgressLimit: 2, autoDispatchCap: 6, toolActionCap: 200 },
|
|
36
|
+
sourceFingerprint: C.sourceFingerprint(dir).hash,
|
|
37
|
+
continuation: false, directory: dir, checkCatalogue: STATIC_CAT,
|
|
38
|
+
}).run;
|
|
39
|
+
run.runId = runId; run.status = status;
|
|
40
|
+
s.writeJSON(keyFor(dir, runId), "run.json", run);
|
|
41
|
+
// register the run on the canonical project identity so a NEW conversation can resolve it
|
|
42
|
+
const pk = (() => { const id = C.projectIdentity(dir); return C.stateKey(id, "proj-marker").slice(0, 24); })();
|
|
43
|
+
const projPath = path.join(process.env.LONGRUN_STATE_DIR, "projects.json");
|
|
44
|
+
let proj = {}; try { proj = JSON.parse(fs.readFileSync(projPath, "utf8")); } catch {}
|
|
45
|
+
const ck = require_keyOf(id_key(dir));
|
|
46
|
+
const p = proj[ck] || { runs: [] }; if (!p.runs.includes(keyFor(dir, runId))) p.runs.push(keyFor(dir, runId)); proj[ck] = p;
|
|
47
|
+
fs.writeFileSync(projPath, JSON.stringify(proj));
|
|
48
|
+
return { run, key: keyFor(dir, runId) };
|
|
49
|
+
}
|
|
50
|
+
// projectKey used by the plugin: sha256(identity.id) sliced to 24 (mirrors plugin keyOf)
|
|
51
|
+
function id_key(dir) { return C.projectIdentity(dir).id; }
|
|
52
|
+
import crypto from "node:crypto";
|
|
53
|
+
function require_keyOf(v) { return crypto.createHash("sha256").update(String(v)).digest("hex").slice(0, 24); }
|
|
54
|
+
|
|
55
|
+
// ---- A. same-session flow with an EXPLICIT runId ----
|
|
56
|
+
test("A: start -> status -> verify(explicit runId) -> loss 0 -> complete (same session)", async () => {
|
|
57
|
+
freshState(); const t = await tools(); const d = proj();
|
|
58
|
+
const c = ctx("sessA", d);
|
|
59
|
+
const start = JSON.parse(await t.longrun.execute({ action: "start", request: "r", criteria: [{ id: "LC-001", required: true, evidenceClass: "STATIC", checks: ["c1"] }], checkCatalogue: STATIC_CAT }, c));
|
|
60
|
+
assert.ok(start.runId, "start returned a runId");
|
|
61
|
+
const RUN = start.runId;
|
|
62
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "status", runId: RUN }, c)).state, "IMPLEMENTING");
|
|
63
|
+
const v = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, c));
|
|
64
|
+
assert.equal(v.status, "PASS", "explicit runId resolves the active run and records a real PASS");
|
|
65
|
+
assert.equal(v.candidateCount, 0, "lifecycle verify on unchanged source creates NO candidate");
|
|
66
|
+
const s = JSON.parse(await t.longrun.execute({ action: "status", runId: RUN }, c));
|
|
67
|
+
assert.deepEqual(s.remaining, [], "criterion satisfied by the receipt");
|
|
68
|
+
assert.equal(s.candidates.split("/")[0], "0", "candidate count still 0 (never inflated by verify)");
|
|
69
|
+
reviewProjectFixture(d, RUN);
|
|
70
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "complete", runId: RUN }, c)).complete, true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- B. pause/resume: verify is RUN_PAUSED while paused, succeeds after resume ----
|
|
74
|
+
test("B: pause -> verify=RUN_PAUSED -> resume -> verify(runId) succeeds -> loss 0 -> complete", async () => {
|
|
75
|
+
freshState(); const t = await tools(); const d = proj();
|
|
76
|
+
const c = ctx("sessB", d);
|
|
77
|
+
const RUN = "lr-paused01";
|
|
78
|
+
seededRun(d, RUN, "PAUSED");
|
|
79
|
+
// bound to this session for the auto-resolution path
|
|
80
|
+
fs.writeFileSync(path.join(process.env.LONGRUN_STATE_DIR, "runs.json"), JSON.stringify({ sessB: { runKey: keyFor(d, RUN), directory: d, checkCatalogue: STATIC_CAT } }));
|
|
81
|
+
const paused = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, c));
|
|
82
|
+
assert.equal(paused.error, "RUN_PAUSED", "a paused run is resolvable but not eligible for verification");
|
|
83
|
+
const rs = JSON.parse(await t.longrun.execute({ action: "resume", runId: RUN }, c));
|
|
84
|
+
assert.equal(rs.resumed, true); assert.equal(rs.state, "IMPLEMENTING");
|
|
85
|
+
const v = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, c));
|
|
86
|
+
assert.equal(v.status, "PASS", "after resume the same run verifies normally");
|
|
87
|
+
reviewProjectFixture(d, RUN);
|
|
88
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "complete", runId: RUN }, c)).complete, true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---- C. cross-session: session A primes the run; a NEW session (no binding) resumes + verifies ----
|
|
92
|
+
test("C: session A -> new session B resumes by runId + verifies (run/budgets persist)", async () => {
|
|
93
|
+
freshState();
|
|
94
|
+
// Session A
|
|
95
|
+
const tA = await tools(); const d = proj(); const A = ctx("sessA", d);
|
|
96
|
+
const start = JSON.parse(await tA.longrun.execute({ action: "start", request: "r", criteria: [{ id: "LC-001", required: true, evidenceClass: "STATIC", checks: ["c1"] }], checkCatalogue: STATIC_CAT }, A));
|
|
97
|
+
const RUN = start.runId;
|
|
98
|
+
await tA.longrun.execute({ action: "checkpoint", runId: RUN }, A);
|
|
99
|
+
assert.equal(await tA.longrun.execute({ action: "pause", runId: RUN }, A), "paused");
|
|
100
|
+
// Session B: brand-new sessionID, same project/worktree, NO session binding seeded
|
|
101
|
+
const tB = await tools(); const B = ctx("sessB-brand-new", d);
|
|
102
|
+
const sBefore = JSON.parse(await tB.longrun.execute({ action: "status", runId: RUN }, B));
|
|
103
|
+
assert.equal(sBefore.state, "PAUSED", "a new conversation re-attaches to the same run via project identity");
|
|
104
|
+
assert.equal(sBefore.runId, RUN, "same run ID across the session boundary");
|
|
105
|
+
const rs = JSON.parse(await tB.longrun.execute({ action: "resume", runId: RUN }, B));
|
|
106
|
+
assert.equal(rs.rebound, true, "resume re-binds the current session + worktree");
|
|
107
|
+
const v = JSON.parse(await tB.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, B));
|
|
108
|
+
assert.equal(v.status, "PASS", "the new conversation can verify the same authoritative run");
|
|
109
|
+
const sDone = JSON.parse(await tB.longrun.execute({ action: "status", runId: RUN }, B));
|
|
110
|
+
assert.equal(sDone.candidates.split("/")[0], "0", "candidate count unchanged across the boundary");
|
|
111
|
+
reviewProjectFixture(d, RUN);
|
|
112
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "complete", runId: RUN }, B)).complete, true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---- D. explicit-ID isolation: verify(runId=A) verifies ONLY A; no cross-project contamination ----
|
|
116
|
+
test("D: explicit runId verifies only the named run; a foreign-project runId is NO_RUN", async () => {
|
|
117
|
+
freshState(); const t = await tools();
|
|
118
|
+
const p1 = proj(), p2 = proj();
|
|
119
|
+
const A = "lr-runA", B = "lr-runB";
|
|
120
|
+
seededRun(p1, A, "IMPLEMENTING");
|
|
121
|
+
seededRun(p2, B, "IMPLEMENTING");
|
|
122
|
+
// from project P2, runId A is NOT resolvable here -> NO_RUN (never cross-project)
|
|
123
|
+
const foreign = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: A }, ctx("s", p2)));
|
|
124
|
+
assert.equal(foreign.error, "NO_RUN", "an explicit id outside this project does NOT resolve here");
|
|
125
|
+
// verify B only; A's record must be byte-unchanged (no cross-run contamination)
|
|
126
|
+
const beforeA = JSON.stringify(store().readJSON(keyFor(p1, A), "run.json"));
|
|
127
|
+
const vB = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: B }, ctx("s", p2)));
|
|
128
|
+
assert.equal(vB.status, "PASS", "the named run verifies");
|
|
129
|
+
assert.equal(vB.runId, B);
|
|
130
|
+
assert.equal(JSON.stringify(store().readJSON(keyFor(p1, A), "run.json")), beforeA, "run A was not touched");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---- E. ambiguity: no runId + multiple eligible runs -> AMBIGUOUS_RUN, never a guess ----
|
|
134
|
+
test("E: omitted runId with multiple eligible runs returns AMBIGUOUS_RUN; explicit id resolves one", async () => {
|
|
135
|
+
freshState(); const t = await tools(); const d = proj();
|
|
136
|
+
const A = "lr-dup1", B = "lr-dup2";
|
|
137
|
+
seededRun(d, A, "IMPLEMENTING");
|
|
138
|
+
seededRun(d, B, "IMPLEMENTING");
|
|
139
|
+
const amb = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC" }, ctx("s", d)));
|
|
140
|
+
assert.equal(amb.error, "AMBIGUOUS_RUN", "never guesses between multiple runs");
|
|
141
|
+
assert.deepEqual(amb.ids.sort(), [A, B].sort());
|
|
142
|
+
const one = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: A }, ctx("s", d)));
|
|
143
|
+
assert.equal(one.status, "PASS", "explicit runId disambiguates and verifies exactly one");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---- F. cancel lifecycle: terminal CANCELLED, verify rejected, continuation impossible, new start allowed + different id ----
|
|
147
|
+
test("F: start -> pause -> cancel -> status CANCELLED -> verify rejected -> new start gets a different id", async () => {
|
|
148
|
+
freshState(); const t = await tools(); const d = proj(); const c = ctx("sessF", d);
|
|
149
|
+
const start = JSON.parse(await t.longrun.execute({ action: "start", request: "r", criteria: [{ id: "LC-001", required: true, evidenceClass: "STATIC", checks: ["c1"] }], checkCatalogue: STATIC_CAT }, c));
|
|
150
|
+
const RUN = start.runId;
|
|
151
|
+
assert.equal(await t.longrun.execute({ action: "pause", runId: RUN }, c), "paused");
|
|
152
|
+
const cancel = JSON.parse(await t.longrun.execute({ action: "cancel", runId: RUN, reason: "abandoned" }, c));
|
|
153
|
+
assert.equal(cancel.cancelled, true); assert.equal(cancel.state, "CANCELLED");
|
|
154
|
+
const st = JSON.parse(await t.longrun.execute({ action: "status", runId: RUN }, c));
|
|
155
|
+
assert.equal(st.state, "CANCELLED", "status reports the terminal CANCELLED state (not deleted, not COMPLETE)");
|
|
156
|
+
const v = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, c));
|
|
157
|
+
assert.equal(v.error, "RUN_CANCELLED", "a cancelled run rejects further verification");
|
|
158
|
+
// continuation impossible: autocontinue hook suppresses on a cancelled/disabled run
|
|
159
|
+
const hooks = await F(PLUG_URL, { client: {} });
|
|
160
|
+
const out = { enabled: true };
|
|
161
|
+
await hooks["experimental.compaction.autocontinue"]({ sessionID: "sessF" }, out);
|
|
162
|
+
assert.equal(out.enabled, false, "a cancelled run cannot be auto-revived");
|
|
163
|
+
// a NEW run is allowed for the same project and receives a DIFFERENT run id
|
|
164
|
+
const again = JSON.parse(await t.longrun.execute({ action: "start", request: "r2", criteria: [{ id: "LC-002", required: true, evidenceClass: "STATIC", checks: ["c2"] }], checkCatalogue: { c2: STATIC_CAT.c1 } }, c));
|
|
165
|
+
assert.ok(again.runId, "a cancelled (terminal) run does not block a new run");
|
|
166
|
+
assert.notEqual(again.runId, RUN, "the new run has a different run id");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---- G. restart persistence: serialize, then FRESH controller/tool instances resolve + verify ----
|
|
170
|
+
test("G: fresh controller/tool instances resolve the run from disk (no in-memory-only run state)", async () => {
|
|
171
|
+
freshState();
|
|
172
|
+
const d = proj(); const RUN = "lr-restart01";
|
|
173
|
+
// "before restart": one factory records a PASS against a CHANGED-source candidate? No — keep 0
|
|
174
|
+
// candidates; instead just prove the fresh instance resolves + verifies + completes.
|
|
175
|
+
seededRun(d, RUN, "IMPLEMENTING");
|
|
176
|
+
// "restart": a brand-new controller module + fresh tool factory, same serialized state dir.
|
|
177
|
+
const CTRL2 = await import("../src/controller.js?r=" + Math.random());
|
|
178
|
+
const plug2 = await import("../plugin/longrun.js?r=" + Math.random());
|
|
179
|
+
const hooks = await plug2.default.server({ client: { app: { log: () => {} } } });
|
|
180
|
+
const t = hooks.tool;
|
|
181
|
+
const s0 = JSON.parse(await t.longrun.execute({ action: "status", runId: RUN }, ctx("sessG", d)));
|
|
182
|
+
assert.equal(s0.state, "IMPLEMENTING", "a fresh controller resolves the same authoritative run from disk");
|
|
183
|
+
assert.equal(s0.candidates, "0/3", "candidate ledger persisted (not reset by a new process)");
|
|
184
|
+
const v = JSON.parse(await t.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, ctx("sessG", d)));
|
|
185
|
+
assert.equal(v.status, "PASS");
|
|
186
|
+
reviewProjectFixture(d, RUN);
|
|
187
|
+
assert.equal(CTRL2.canComplete(store().readJSON(keyFor(d, RUN), "run.json")).complete, true, "loss reached 0 and completion is now allowed");
|
|
188
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "complete", runId: RUN }, ctx("sessG", d))).complete, true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---- the deterministic COMMISSIONING fixture: the full lifecycle end to end, real factory+controller ----
|
|
192
|
+
test("COMMISSIONING: memory -> start -> status -> checkpoint -> pause -> new session -> resume -> verify(runId) -> loss0 -> complete -> new run -> cancel", async () => {
|
|
193
|
+
freshState();
|
|
194
|
+
const d = proj();
|
|
195
|
+
// session A
|
|
196
|
+
const tA = await tools(); const A = ctx("cs-A", d);
|
|
197
|
+
assert.equal(JSON.parse(await tA.longrun.execute({ action: "memory_status" }, A)).status, "NO_MEMORY");
|
|
198
|
+
const start = JSON.parse(await tA.longrun.execute({ action: "start", request: "Verify the v1.2.2 lifecycle", criteria: [{ id: "LC-001", required: true, evidenceClass: "STATIC", checks: ["c1"] }], checkCatalogue: STATIC_CAT }, A));
|
|
199
|
+
const RUN = start.runId; assert.ok(RUN, "non-null runId");
|
|
200
|
+
assert.equal(JSON.parse(await tA.longrun.execute({ action: "status", runId: RUN }, A)).state, "IMPLEMENTING");
|
|
201
|
+
await tA.longrun.execute({ action: "checkpoint", runId: RUN }, A);
|
|
202
|
+
assert.equal(await tA.longrun.execute({ action: "pause", runId: RUN }, A), "paused");
|
|
203
|
+
// session B (new conversation / restart-equivalent)
|
|
204
|
+
const tB = await tools(); const B = ctx("cs-B", d);
|
|
205
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "status", runId: RUN }, B)).state, "PAUSED");
|
|
206
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "resume", runId: RUN }, B)).state, "IMPLEMENTING");
|
|
207
|
+
const v = JSON.parse(await tB.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: RUN }, B));
|
|
208
|
+
assert.equal(v.status, "PASS"); assert.equal(v.candidateCount, 0, "lifecycle-only: 0 candidates");
|
|
209
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "status", runId: RUN }, B)).remaining.length, 0);
|
|
210
|
+
reviewProjectFixture(d, RUN);
|
|
211
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "complete", runId: RUN }, B)).complete, true);
|
|
212
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "status", runId: RUN }, B)).state, "COMPLETE");
|
|
213
|
+
// second run + cancel
|
|
214
|
+
const start2 = JSON.parse(await tB.longrun.execute({ action: "start", request: "second", criteria: [{ id: "LC-002", required: true, evidenceClass: "STATIC", checks: ["c1"] }], checkCatalogue: STATIC_CAT }, B));
|
|
215
|
+
assert.ok(start2.runId && start2.runId !== RUN, "a second run starts after the first completed");
|
|
216
|
+
assert.equal(JSON.parse(await tB.longrun.execute({ action: "cancel", runId: start2.runId }, B)).cancelled, true);
|
|
217
|
+
assert.equal(JSON.parse(await tB.longrun_verify.execute({ checkId: "c1", evidenceClass: "STATIC", runId: start2.runId }, B)).error, "RUN_CANCELLED");
|
|
218
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// v1.2.20 — audited operator-only budget amendment (finite candidate allowance + new absolute
|
|
2
|
+
// deadline). Reproduced BEFORE implementation: every test below uses the amendment API that did
|
|
3
|
+
// not exist yet, so the pre-change run failed rather than silently passing.
|
|
4
|
+
//
|
|
5
|
+
// Trust boundary: the amendment is reachable ONLY through the installed maintenance CLI, never
|
|
6
|
+
// through the native model tool surface (RUN_ACTIONS/ACTION_PARAMS). Like the completion review it
|
|
7
|
+
// is an auditable workflow boundary, not an OS sandbox against arbitrary state-file access.
|
|
8
|
+
//
|
|
9
|
+
// These are offline deterministic tests. They are NOT native OpenCode host evidence and they never
|
|
10
|
+
// touch production source, config, model routing, permissions or the protected run.
|
|
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 { spawnSync } from 'node:child_process';
|
|
17
|
+
import * as C from '../src/controller.js';
|
|
18
|
+
import { install, VERSION } from '../src/install.mjs';
|
|
19
|
+
import { approveStoredFixtureReview, F } from './helper.mjs';
|
|
20
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
21
|
+
|
|
22
|
+
// The grant that was actually applied to the annotations run used the absolute deadline below. It is
|
|
23
|
+
// kept here for the historical record, but the tests must not depend on wall-clock time: a fixture
|
|
24
|
+
// run's own deadline is computed from the moment it is created, so once real time passed the real
|
|
25
|
+
// grant's deadline the amendment correctly reported AMENDMENT_DEADLINE_NOT_EXTENDING and these tests
|
|
26
|
+
// failed for a reason that had nothing to do with the code under test. The deadline exercised below
|
|
27
|
+
// is therefore a fixed date far in the future, so "a grant extends the deadline and becomes the
|
|
28
|
+
// effective one" stays deterministic whenever the suite runs. Every assertion is unchanged.
|
|
29
|
+
const GRANT_DEADLINE_AS_APPLIED = Date.parse('2026-09-22T23:30:00.000Z'); // historical, not used for timing
|
|
30
|
+
const TEST_GRANT_DEADLINE = Date.parse('2099-01-01T00:00:00.000Z');
|
|
31
|
+
|
|
32
|
+
const GRANT = {
|
|
33
|
+
amendmentId: 'grant-example-20260921-01',
|
|
34
|
+
additionalCandidates: 12,
|
|
35
|
+
newDeadlineAt: TEST_GRANT_DEADLINE,
|
|
36
|
+
authorization: 'User authorized 12 additional candidates (cumulative 36) and a new absolute deadline for the same paused annotations run.',
|
|
37
|
+
reason: 'The original 24-candidate allowance was exhausted and its original deadline had already expired; the remaining reviewed full scope needs a finite, audited extension.',
|
|
38
|
+
};
|
|
39
|
+
const ORIGINAL_DEADLINE = Date.parse('2026-09-21T03:59:32.883Z');
|
|
40
|
+
|
|
41
|
+
async function fixture(t, budget = {}) {
|
|
42
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1220-'));
|
|
43
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
44
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'baseline');
|
|
45
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
46
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
47
|
+
const ctx = { sessionID: 'amendment-fixture', directory: dir, worktree: dir };
|
|
48
|
+
const command = [process.execPath, '-e', "require('node:assert/strict').match(require('node:fs').readFileSync('value.txt','utf8'),/^valid/)"];
|
|
49
|
+
const start = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Isolated operator-amendment regression', criteria: [{ id: 'c', checks: ['check'] }], checkCatalogue: { check: { command, kind: 'cmd', gate: true } }, ...budget }, ctx));
|
|
50
|
+
assert.ok(start.runId, JSON.stringify(start));
|
|
51
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
52
|
+
const read = () => { const r = store.readRun(key); assert.equal(r.error, undefined, JSON.stringify(r)); return r.run; };
|
|
53
|
+
const verify = async (args = {}) => JSON.parse(await hooks.tool.longrun_verify.execute({ runId: start.runId, checkId: 'check', ...args }, ctx));
|
|
54
|
+
const amend = (args) => C.operatorBudgetAmendment(store, key, { directory: dir, runId: start.runId, ...args });
|
|
55
|
+
const bytes = () => fs.readFileSync(store._file(key, 'run.json'));
|
|
56
|
+
return { base, dir, hooks, ctx, start, store, key, read, verify, amend, bytes };
|
|
57
|
+
}
|
|
58
|
+
const basisOf = run => C.completionReviewBasis(run, run.sourceFingerprint);
|
|
59
|
+
const grantArgs = run => ({ ...GRANT, expectedRevision: run.controlGeneration || 0, expectedBasis: basisOf(run) });
|
|
60
|
+
|
|
61
|
+
// Drive a run to the real v1.2.19 stop: one counted candidate, then a NEW source refused at the cap.
|
|
62
|
+
async function exhaust(t) {
|
|
63
|
+
const s = await fixture(t, { candidateBudget: 1 });
|
|
64
|
+
fs.writeFileSync(path.join(s.dir, 'value.txt'), 'valid-one');
|
|
65
|
+
assert.equal((await s.verify()).status, 'PASS');
|
|
66
|
+
fs.writeFileSync(path.join(s.dir, 'value.txt'), 'valid-two');
|
|
67
|
+
const refused = await s.verify();
|
|
68
|
+
assert.equal(refused.error, 'BUDGET_EXHAUSTED');
|
|
69
|
+
assert.equal(refused.spent.candidates, true);
|
|
70
|
+
assert.equal(s.read().status, 'PAUSED');
|
|
71
|
+
return s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('pre-amendment: cap and expired deadline refuse, and the native model tool surface cannot amend', async t => {
|
|
75
|
+
const s = await exhaust(t);
|
|
76
|
+
assert.ok(!C.RUN_ACTIONS.includes('amend'), 'no native amend action exists');
|
|
77
|
+
assert.ok(!Object.keys(C.ACTION_PARAMS).some(a => /amend|allowance|grant/i.test(a)), 'no native amendment parameters');
|
|
78
|
+
const native = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'amend', runId: s.start.runId }, s.ctx));
|
|
79
|
+
assert.equal(native.error, 'unknown_action');
|
|
80
|
+
|
|
81
|
+
const d = await fixture(t);
|
|
82
|
+
const run = d.read();
|
|
83
|
+
run.createdAt = ORIGINAL_DEADLINE - run.budget.deadlineSeconds * 1000; // deadline already passed
|
|
84
|
+
d.store.writeJSON(d.key, 'run.json', run);
|
|
85
|
+
const expired = await d.verify();
|
|
86
|
+
assert.equal(expired.error, 'BUDGET_EXHAUSTED');
|
|
87
|
+
assert.equal(expired.spent.deadline, true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('operator amendment preserves original budget and history and exposes original vs effective limits', async t => {
|
|
91
|
+
const s = await exhaust(t);
|
|
92
|
+
const before = s.read();
|
|
93
|
+
const frozen = { budget: JSON.parse(JSON.stringify(before.budget)), receipts: before.receipts.length,
|
|
94
|
+
candidateCount: C.candidateCount(before), createdAt: before.createdAt, contractHash: before.contractHash,
|
|
95
|
+
verificationMs: before.execution?.verificationMs };
|
|
96
|
+
|
|
97
|
+
const result = await s.amend(grantArgs(before));
|
|
98
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
99
|
+
assert.equal(result.state, 'PAUSED', 'a grant never resumes a run');
|
|
100
|
+
assert.ok(!result.alreadyApplied);
|
|
101
|
+
|
|
102
|
+
const after = s.read();
|
|
103
|
+
assert.deepEqual(after.budget, frozen.budget, 'original budget fields are untouched');
|
|
104
|
+
assert.equal(after.receipts.length, frozen.receipts, 'receipt history retained');
|
|
105
|
+
assert.equal(C.candidateCount(after), frozen.candidateCount, 'usage counters never reset');
|
|
106
|
+
assert.equal(after.createdAt, frozen.createdAt, 'creation timestamp never rewritten');
|
|
107
|
+
assert.equal(after.contractHash, frozen.contractHash, 'contract untouched');
|
|
108
|
+
assert.equal(after.execution?.verificationMs, frozen.verificationMs, 'measured usage untouched');
|
|
109
|
+
assert.equal(after.status, 'PAUSED'); assert.equal(after.autoEnabled, false);
|
|
110
|
+
assert.equal(after.controlGeneration, (before.controlGeneration || 0) + 1, 'audited mutation advances generation');
|
|
111
|
+
|
|
112
|
+
assert.equal(after.budgetAmendments.length, 1);
|
|
113
|
+
const rec = after.budgetAmendments[0];
|
|
114
|
+
assert.equal(rec.schemaVersion, C.AMENDMENT_SCHEMA_VERSION);
|
|
115
|
+
assert.equal(rec.id, GRANT.amendmentId); assert.equal(rec.source, 'operator_cli');
|
|
116
|
+
assert.equal(rec.additionalCandidates, 12); assert.equal(rec.newDeadlineAt, GRANT.newDeadlineAt);
|
|
117
|
+
assert.equal(rec.authorization, GRANT.authorization.trim(), 'authorization recorded verbatim');
|
|
118
|
+
assert.equal(rec.reason, GRANT.reason.trim());
|
|
119
|
+
assert.equal(rec.originalDeadlineAt, after.createdAt + after.budget.deadlineSeconds * 1000);
|
|
120
|
+
assert.equal(rec.previousEffectiveCandidates, 1);
|
|
121
|
+
assert.match(rec.priorRunHash, /^[a-f0-9]{64}$/);
|
|
122
|
+
|
|
123
|
+
const view = C.deriveRunView(after, { currentFingerprint: after.sourceFingerprint });
|
|
124
|
+
assert.equal(view.candidates, `${frozen.candidateCount}/13`, 'effective candidate limit shown');
|
|
125
|
+
assert.equal(view.budgetLimit.original.iterations, 1, 'original limit reported separately');
|
|
126
|
+
assert.equal(view.budgetLimit.additionalCandidates, 12);
|
|
127
|
+
assert.equal(view.budgetLimit.effective.iterations, 13);
|
|
128
|
+
assert.equal(view.controlRevision, after.controlGeneration);
|
|
129
|
+
assert.match(view.amendmentBasis, /^[a-f0-9]{64}$/);
|
|
130
|
+
|
|
131
|
+
const timing = C.execution.timing(after);
|
|
132
|
+
assert.equal(timing.deadlineAt, GRANT.newDeadlineAt, 'effective deadline is the granted one');
|
|
133
|
+
assert.equal(timing.originalDeadlineAt, rec.originalDeadlineAt, 'original deadline still reported');
|
|
134
|
+
const guard = C.execution.budgetGuard(after, { fingerprint: 'brand-new-source-fingerprint' });
|
|
135
|
+
assert.equal(guard.ok, true, JSON.stringify(guard.spent));
|
|
136
|
+
assert.equal(guard.spent.candidates, false); assert.equal(guard.spent.deadline, false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('after a grant a new source candidate is admitted and ordinary tool admission is restored', async t => {
|
|
140
|
+
const s = await exhaust(t);
|
|
141
|
+
assert.equal((await s.amend(grantArgs(s.read()))).ok, true);
|
|
142
|
+
const resumed = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'resume', runId: s.start.runId }, s.ctx));
|
|
143
|
+
assert.equal(resumed.state, 'IMPLEMENTING', JSON.stringify(resumed));
|
|
144
|
+
await s.hooks['tool.execute.before']({ tool: 'bash', sessionID: s.ctx.sessionID }, { args: {} });
|
|
145
|
+
const verified = await s.verify();
|
|
146
|
+
assert.equal(verified.status, 'PASS', JSON.stringify(verified));
|
|
147
|
+
const after = s.read();
|
|
148
|
+
assert.equal(C.candidateCount(after), 2, 'the newly admitted source counts as a candidate');
|
|
149
|
+
assert.equal(after.budget.iterations, 1, 'the original declared limit is still 1');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('amendment refuses invalid, stale, conflicting, non-paused and in-flight requests without mutation', async t => {
|
|
153
|
+
const s = await fixture(t, { candidateBudget: 5 });
|
|
154
|
+
s.store.mutate(s.key, C.pauseRun);
|
|
155
|
+
const paused = s.read();
|
|
156
|
+
const good = grantArgs(paused);
|
|
157
|
+
const cases = [
|
|
158
|
+
[{ ...good, expectedBasis: 'f'.repeat(64) }, 'AMENDMENT_BASIS_CHANGED'],
|
|
159
|
+
[{ ...good, expectedRevision: (paused.controlGeneration || 0) + 7 }, 'AMENDMENT_REVISION_CHANGED'],
|
|
160
|
+
[{ ...good, amendmentId: 'short' }, 'INVALID_AMENDMENT'],
|
|
161
|
+
[{ ...good, additionalCandidates: 0 }, 'INVALID_AMENDMENT'],
|
|
162
|
+
[{ ...good, additionalCandidates: 1.5 }, 'INVALID_AMENDMENT'],
|
|
163
|
+
[{ ...good, additionalCandidates: 1e6 }, 'INVALID_AMENDMENT'],
|
|
164
|
+
[{ ...good, newDeadlineAt: Date.now() - 1000 }, 'AMENDMENT_DEADLINE_NOT_FUTURE'],
|
|
165
|
+
[{ ...good, newDeadlineAt: paused.createdAt + paused.budget.deadlineSeconds * 1000 - 1000 }, 'AMENDMENT_DEADLINE_NOT_EXTENDING'],
|
|
166
|
+
[{ ...good, authorization: ' ' }, 'INVALID_AMENDMENT'],
|
|
167
|
+
[{ ...good, reason: '' }, 'INVALID_AMENDMENT'],
|
|
168
|
+
];
|
|
169
|
+
for (const [args, expected] of cases) {
|
|
170
|
+
const before = s.bytes();
|
|
171
|
+
const result = await s.amend(args);
|
|
172
|
+
assert.equal(result.error, expected, JSON.stringify({ args, result }));
|
|
173
|
+
assert.deepEqual(s.bytes(), before, `refusal ${expected} must not mutate canonical state`);
|
|
174
|
+
}
|
|
175
|
+
for (const [mutate, expected] of [
|
|
176
|
+
[run => { run.status = 'IMPLEMENTING'; }, 'AMENDMENT_REQUIRES_PAUSE'],
|
|
177
|
+
[run => { run.status = 'COMPLETE'; }, 'RUN_COMPLETE'],
|
|
178
|
+
[run => { run.status = 'CANCELLED'; }, 'RUN_CANCELLED'],
|
|
179
|
+
[run => { run.status = 'PAUSED'; run.execution.inFlight = { ownerPid: process.pid, token: 'live' }; }, 'VERIFY_IN_FLIGHT'],
|
|
180
|
+
]) {
|
|
181
|
+
const run = s.read(); mutate(run); s.store.writeJSON(s.key, 'run.json', run);
|
|
182
|
+
const before = s.bytes();
|
|
183
|
+
assert.equal((await s.amend(grantArgs(s.read()))).error, expected);
|
|
184
|
+
assert.deepEqual(s.bytes(), before, `refusal ${expected} must not mutate canonical state`);
|
|
185
|
+
}
|
|
186
|
+
const run = s.read(); run.status = 'PAUSED'; run.execution.inFlight = null; s.store.writeJSON(s.key, 'run.json', run);
|
|
187
|
+
const before = s.bytes();
|
|
188
|
+
const mismatch = await C.operatorBudgetAmendment(s.store, s.key, { directory: s.dir, runId: 'someone-else', ...grantArgs(s.read()) });
|
|
189
|
+
assert.equal(mismatch.error, 'AMENDMENT_RUN_MISMATCH');
|
|
190
|
+
assert.deepEqual(s.bytes(), before);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('an exact repeat is idempotent, a conflicting repeat is refused, and a stale grant cannot be applied', async t => {
|
|
194
|
+
const s = await exhaust(t);
|
|
195
|
+
const first = s.read();
|
|
196
|
+
const staleBasis = basisOf(first), staleRevision = first.controlGeneration || 0;
|
|
197
|
+
assert.equal((await s.amend({ ...GRANT, expectedRevision: staleRevision, expectedBasis: staleBasis })).ok, true);
|
|
198
|
+
const afterFirst = s.read();
|
|
199
|
+
assert.equal(afterFirst.budgetAmendments.length, 1);
|
|
200
|
+
|
|
201
|
+
const repeat = await s.amend({ ...GRANT, expectedRevision: staleRevision, expectedBasis: staleBasis });
|
|
202
|
+
assert.equal(repeat.ok, true); assert.equal(repeat.alreadyApplied, true);
|
|
203
|
+
assert.equal(s.read().budgetAmendments.length, 1, 'an exact repeat does not double-count');
|
|
204
|
+
assert.equal(C.execution.effectiveIterations(s.read()), 13);
|
|
205
|
+
|
|
206
|
+
const conflict = await s.amend({ ...GRANT, additionalCandidates: 99, expectedRevision: staleRevision, expectedBasis: staleBasis });
|
|
207
|
+
assert.equal(conflict.error, 'AMENDMENT_ID_CONFLICT');
|
|
208
|
+
assert.equal(s.read().budgetAmendments.length, 1);
|
|
209
|
+
|
|
210
|
+
const stale = await s.amend({ ...GRANT, amendmentId: 'grant-second-stale-0001', additionalCandidates: 4, expectedRevision: staleRevision, expectedBasis: staleBasis });
|
|
211
|
+
assert.equal(stale.error, 'AMENDMENT_BASIS_CHANGED', 'a grant bound to a superseded state is refused');
|
|
212
|
+
|
|
213
|
+
const current = s.read();
|
|
214
|
+
const second = await s.amend({ ...GRANT, amendmentId: 'grant-example-20260921-02', additionalCandidates: 4, newDeadlineAt: GRANT.newDeadlineAt + 3600000, expectedRevision: current.controlGeneration || 0, expectedBasis: basisOf(current) });
|
|
215
|
+
assert.equal(second.ok, true, JSON.stringify(second));
|
|
216
|
+
const after = s.read();
|
|
217
|
+
assert.equal(after.budgetAmendments.length, 2);
|
|
218
|
+
assert.equal(C.execution.effectiveIterations(after), 1 + 12 + 4, 'finite allowances accumulate');
|
|
219
|
+
assert.equal(C.execution.timing(after).deadlineAt, GRANT.newDeadlineAt + 3600000);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a grant invalidates a prior completion acceptance and never resumes or self-approves', async t => {
|
|
223
|
+
const s = await fixture(t, { candidateBudget: 5 });
|
|
224
|
+
fs.writeFileSync(path.join(s.dir, 'value.txt'), 'valid-one');
|
|
225
|
+
assert.equal((await s.verify()).status, 'PASS');
|
|
226
|
+
s.store.mutate(s.key, C.pauseRun);
|
|
227
|
+
approveStoredFixtureReview(s.store, s.key);
|
|
228
|
+
const accepted = s.read();
|
|
229
|
+
const fp = accepted.sourceFingerprint;
|
|
230
|
+
assert.equal(C.completionReviewStatus(accepted, fp).status, 'ACCEPTED');
|
|
231
|
+
assert.equal(C.canComplete(accepted, { currentFingerprint: fp }).complete, true);
|
|
232
|
+
|
|
233
|
+
const result = await s.amend(grantArgs(accepted));
|
|
234
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
235
|
+
const after = s.read();
|
|
236
|
+
assert.equal(after.status, 'PAUSED', 'the grant itself never resumes');
|
|
237
|
+
assert.equal(after.autoEnabled, false);
|
|
238
|
+
assert.equal(C.completionReviewStatus(after, fp).status, 'STALE', 'prior acceptance is invalidated by the budget change');
|
|
239
|
+
const completion = C.canComplete(after, { currentFingerprint: fp });
|
|
240
|
+
assert.equal(completion.complete, false);
|
|
241
|
+
assert.equal(completion.reason, 'completion_review_stale');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('the real exhausted annotations run gains exactly a finite allowance without rewriting history', async t => {
|
|
245
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1220-copy-'));
|
|
246
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
247
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir);
|
|
248
|
+
const state = path.join(base, 'state');
|
|
249
|
+
const run = JSON.parse(fs.readFileSync(new URL('./fixtures/notes-budget-exhausted-run.json', import.meta.url)));
|
|
250
|
+
const runId = run.runId;
|
|
251
|
+
run.status = 'PAUSED'; run.autoEnabled = false; run.controlGeneration = (run.controlGeneration || 0) + 1;
|
|
252
|
+
run.directory = dir;
|
|
253
|
+
const store = new C.Store(state), key = C.stateKey(C.projectIdentity(dir), runId);
|
|
254
|
+
store.writeJSON(key, 'run.json', run);
|
|
255
|
+
const before = store.readRun(key).run;
|
|
256
|
+
assert.equal(C.candidateCount(before), 24);
|
|
257
|
+
assert.equal(before.receipts.length, 37);
|
|
258
|
+
assert.equal(before.budget.iterations, 24);
|
|
259
|
+
|
|
260
|
+
const result = await C.operatorBudgetAmendment(store, key, { directory: dir, runId,
|
|
261
|
+
amendmentId: 'grant-example-real-0001', additionalCandidates: 12,
|
|
262
|
+
newDeadlineAt: TEST_GRANT_DEADLINE,
|
|
263
|
+
authorization: GRANT.authorization, reason: GRANT.reason,
|
|
264
|
+
expectedRevision: before.controlGeneration || 0, expectedBasis: basisOf(before) });
|
|
265
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
266
|
+
|
|
267
|
+
const after = store.readRun(key).run;
|
|
268
|
+
assert.deepEqual(after.budget, before.budget, 'original 24/7200/140 limit preserved');
|
|
269
|
+
assert.equal(C.candidateCount(after), 24, '24 used candidates preserved');
|
|
270
|
+
assert.equal(after.receipts.length, 37, '37 receipts preserved');
|
|
271
|
+
assert.equal(after.execution.verificationMs, before.execution.verificationMs);
|
|
272
|
+
assert.equal(after.createdAt, before.createdAt);
|
|
273
|
+
assert.equal(after.contractHash, before.contractHash);
|
|
274
|
+
assert.equal(C.execution.effectiveIterations(after), 36, 'cumulative effective limit is 36');
|
|
275
|
+
assert.equal(C.execution.timing(after).deadlineAt, TEST_GRANT_DEADLINE);
|
|
276
|
+
assert.equal(C.execution.budgetGuard(after, { fingerprint: 'post-grant-unverified-source' }).ok, true);
|
|
277
|
+
|
|
278
|
+
const view = C.deriveRunView(after, { currentFingerprint: 'post-grant-unverified-source' });
|
|
279
|
+
assert.equal(view.historicalReceiptCount, 37);
|
|
280
|
+
assert.equal(view.candidates, '24/36');
|
|
281
|
+
assert.equal(view.checks.length, 6, 'all six declared checks still reported');
|
|
282
|
+
assert.equal(view.checks.every(c => c.effectiveStatus === 'STALE'), true, 'no historical PASS is promoted to current');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('the installed operator CLI is the only amendment path and requires the read-only basis and revision', t => {
|
|
286
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1220-cli-'));
|
|
287
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
288
|
+
const project = path.join(root, 'project'), config = path.join(root, 'config'), state = path.join(root, 'state');
|
|
289
|
+
fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'value.txt'), 'fixture');
|
|
290
|
+
install({ configDir: config });
|
|
291
|
+
const store = new C.Store(state), runId = 'amendment-cli-fixture', key = C.stateKey(C.projectIdentity(project), runId);
|
|
292
|
+
const run = { runId, directory: project, status: 'PAUSED', autoEnabled: false, controlGeneration: 2,
|
|
293
|
+
createdAt: Date.now() - 3600 * 1000, sourceFingerprint: C.sourceFingerprint(project),
|
|
294
|
+
budget: { iterations: 2, activeSeconds: 3600, deadlineSeconds: 60, sameFailureLimit: 3, noProgressLimit: 5, autoDispatchCap: 40, toolActionCap: 140 },
|
|
295
|
+
contract: { criteria: [{ id: 'c', required: true, checks: ['check'] }], gates: [{ id: 'check', required: true }], lossTarget: 0 },
|
|
296
|
+
contractHash: 'retained-mapping', checkCatalogue: { check: { command: ['true'], kind: 'cmd', gate: true } },
|
|
297
|
+
state: { candidates: [], lastEvalFingerprint: null }, receipts: [],
|
|
298
|
+
execution: { commandAttempts: 0, verificationMs: 0, inFlight: null } };
|
|
299
|
+
store.writeJSON(key, 'run.json', run);
|
|
300
|
+
const bin = path.join(config, 'longrun-harness', 'releases', VERSION, 'bin', 'longrun.mjs');
|
|
301
|
+
const invoke = (args, extra = {}) => {
|
|
302
|
+
const result = spawnSync(process.execPath, [bin, ...args], {
|
|
303
|
+
cwd: root, env: { ...process.env, LONGRUN_TEST: '1', LONGRUN_STATE_DIR: state, OPENCODE_CONFIG_DIR: config },
|
|
304
|
+
encoding: 'utf8', timeout: 10000, ...extra });
|
|
305
|
+
assert.equal(result.error, undefined);
|
|
306
|
+
return { exit: result.status, output: JSON.parse(result.stdout) };
|
|
307
|
+
};
|
|
308
|
+
const status = invoke(['status', '--json', '--project', project, '--run', runId]);
|
|
309
|
+
assert.equal(status.exit, 0, JSON.stringify(status.output));
|
|
310
|
+
assert.match(status.output.amendmentBasis, /^[a-f0-9]{64}$/);
|
|
311
|
+
assert.equal(status.output.controlRevision, 2);
|
|
312
|
+
assert.equal(status.output.budgetLimit.effective.iterations, 2);
|
|
313
|
+
|
|
314
|
+
const authFile = path.join(root, 'authorization.txt'), reasonFile = path.join(root, 'reason.txt');
|
|
315
|
+
fs.writeFileSync(authFile, GRANT.authorization); fs.writeFileSync(reasonFile, GRANT.reason);
|
|
316
|
+
const amendArgs = ['amend', '--json', '--project', project, '--run', runId,
|
|
317
|
+
'--amendment-id', 'grant-cli-fixture-0001', '--additional-candidates', '12',
|
|
318
|
+
'--new-deadline', new Date(TEST_GRANT_DEADLINE).toISOString(),
|
|
319
|
+
'--authorization-file', authFile, '--reason-file', reasonFile,
|
|
320
|
+
'--expected-basis', status.output.amendmentBasis, '--expected-revision', String(status.output.controlRevision)];
|
|
321
|
+
|
|
322
|
+
const missing = invoke(['amend', '--json', '--project', project, '--run', runId]);
|
|
323
|
+
assert.equal(missing.exit, 2); assert.equal(missing.output.error, 'AMENDMENT_ARGUMENTS_REQUIRED');
|
|
324
|
+
|
|
325
|
+
const before = fs.readFileSync(store._file(key, 'run.json'));
|
|
326
|
+
const stale = invoke(['amend', '--json', '--project', project, '--run', runId,
|
|
327
|
+
'--amendment-id', 'grant-cli-fixture-stale', '--additional-candidates', '12',
|
|
328
|
+
'--new-deadline', new Date(TEST_GRANT_DEADLINE).toISOString(),
|
|
329
|
+
'--authorization-file', authFile, '--reason-file', reasonFile,
|
|
330
|
+
'--expected-basis', 'f'.repeat(64), '--expected-revision', '2']);
|
|
331
|
+
assert.equal(stale.exit, 2); assert.equal(stale.output.error, 'AMENDMENT_BASIS_CHANGED');
|
|
332
|
+
assert.deepEqual(fs.readFileSync(store._file(key, 'run.json')), before);
|
|
333
|
+
|
|
334
|
+
const applied = invoke(amendArgs);
|
|
335
|
+
assert.equal(applied.exit, 0, JSON.stringify(applied.output));
|
|
336
|
+
assert.equal(applied.output.state, 'PAUSED');
|
|
337
|
+
const after = store.readRun(key).run;
|
|
338
|
+
assert.deepEqual(after.budget, run.budget);
|
|
339
|
+
assert.equal(after.budgetAmendments.length, 1);
|
|
340
|
+
assert.equal(C.execution.effectiveIterations(after), 14);
|
|
341
|
+
assert.equal(after.status, 'PAUSED');
|
|
342
|
+
assert.equal(after.controlGeneration, 3);
|
|
343
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// v1.2.21 — negative-fixture isolation must not be defeated by a filesystem-root anchor.
|
|
2
|
+
//
|
|
3
|
+
// Reproduced BEFORE the fix: a real OpenCode CLI host supplied context.worktree="/" so the plugin
|
|
4
|
+
// passed "/" among the project directories, and validateNegativeFixture rejected EVERY fixture
|
|
5
|
+
// (including one under /private/tmp) as "fixture and production project overlap". The original
|
|
6
|
+
// negative-control tests only ever passed the project as both directory and worktree, so this path
|
|
7
|
+
// was never exercised. These are offline deterministic tests, not native host evidence.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import * as C from '../src/controller.js';
|
|
14
|
+
|
|
15
|
+
function mk(t, prefix) {
|
|
16
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
17
|
+
t.after(() => fs.rmSync(d, { recursive: true, force: true }));
|
|
18
|
+
return d;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('a filesystem-root anchor does not make every fixture overlap', t => {
|
|
22
|
+
const base = mk(t, 'lr1221-');
|
|
23
|
+
const project = path.join(base, 'project'); fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'a.mjs'), 'x');
|
|
24
|
+
const fixture = path.join(base, 'fixture'); fs.mkdirSync(fixture); fs.writeFileSync(path.join(fixture, 'a.mjs'), 'x');
|
|
25
|
+
const fsRoot = path.parse(process.cwd()).root;
|
|
26
|
+
const result = C.validateNegativeFixture(fixture, [project, fsRoot]);
|
|
27
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
28
|
+
assert.equal(result.fixture, fs.realpathSync(fixture));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a fixture inside the project is still refused, and the refusal names the offending directory', t => {
|
|
32
|
+
const base = mk(t, 'lr1221-');
|
|
33
|
+
const project = path.join(base, 'project'); fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'a.mjs'), 'x');
|
|
34
|
+
const inside = path.join(project, 'fixture'); fs.mkdirSync(inside);
|
|
35
|
+
const result = C.validateNegativeFixture(inside, [project]);
|
|
36
|
+
assert.equal(result.ok, false);
|
|
37
|
+
assert.equal(result.error, 'FIXTURE_NOT_ISOLATED');
|
|
38
|
+
assert.match(result.detail, /overlap/);
|
|
39
|
+
assert.ok(result.detail.includes(fs.realpathSync(project)), 'detail names the overlapping project directory');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a project directory nested inside the fixture is still refused', t => {
|
|
43
|
+
const base = mk(t, 'lr1221-');
|
|
44
|
+
const fixture = path.join(base, 'fixture'); fs.mkdirSync(fixture);
|
|
45
|
+
const nested = path.join(fixture, 'project'); fs.mkdirSync(nested);
|
|
46
|
+
const result = C.validateNegativeFixture(fixture, [nested]);
|
|
47
|
+
assert.equal(result.ok, false);
|
|
48
|
+
assert.match(result.detail, /overlap/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an escaping symlink and a shared hard link are still refused', t => {
|
|
52
|
+
const base = mk(t, 'lr1221-');
|
|
53
|
+
const symlinked = path.join(base, 'symlinked'); fs.mkdirSync(symlinked);
|
|
54
|
+
fs.symlinkSync(fs.realpathSync(os.tmpdir()), path.join(symlinked, 'escape'));
|
|
55
|
+
const escaped = C.validateNegativeFixture(symlinked, []);
|
|
56
|
+
assert.equal(escaped.ok, false);
|
|
57
|
+
assert.match(escaped.detail, /symlink escapes/);
|
|
58
|
+
|
|
59
|
+
const hard = path.join(base, 'hard'); fs.mkdirSync(hard);
|
|
60
|
+
const source = path.join(base, 'hard-source.txt'); fs.writeFileSync(source, 'x');
|
|
61
|
+
fs.linkSync(source, path.join(hard, 'linked.txt'));
|
|
62
|
+
const shared = C.validateNegativeFixture(hard, []);
|
|
63
|
+
assert.equal(shared.ok, false);
|
|
64
|
+
assert.match(shared.detail, /hard link/);
|
|
65
|
+
});
|