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,677 @@
|
|
|
1
|
+
// Long-run Harness plugin — the ONE loader-facing entry point.
|
|
2
|
+
// Installed file is a COPY of this. The file exposes exactly ONE module export: `default`, in the
|
|
3
|
+
// OpenCode V1 server-plugin shape `{ id, server }`, where `server` is the async factory. All
|
|
4
|
+
// helpers/state/records stay internal; nothing runs at module scope.
|
|
5
|
+
//
|
|
6
|
+
// Auto-continuation is OFF by default. Live-evidence records are emitted ONLY when the factory has
|
|
7
|
+
// successfully built BOTH required tools inside a genuine host process — never at import, never
|
|
8
|
+
// under a test/probe context, and never inside a plain `node` process.
|
|
9
|
+
//
|
|
10
|
+
// v1.2.0 changes: the native `longrun` tool exposes a COMPLETE explicit action enum (no ellipsis,
|
|
11
|
+
// no hidden guessing); `start` creates an authoritative run; candidate accounting is exact and
|
|
12
|
+
// persisted; `verify` enforces evidence-strength; negative-control runs target isolated fixtures.
|
|
13
|
+
//
|
|
14
|
+
// v1.2.1 changes: `start` REJECTS an incomplete contract (INVALID_CONTRACT, no run created);
|
|
15
|
+
// `memory_status`/`memory_init`/`memory_refresh` work WITHOUT an active run; `action=verify` is a
|
|
16
|
+
// read-only readout while execution stays exclusively in `longrun_verify` (no shell bypass).
|
|
17
|
+
//
|
|
18
|
+
// v1.2.2 changes: ONE canonical run resolver (in the controller) is shared by BOTH native tools, so
|
|
19
|
+
// the lifecycle and the verifier never disagree about which run is "active". `longrun_verify` and the
|
|
20
|
+
// control plane accept an explicit runId and resolve via session binding + canonical project/worktree
|
|
21
|
+
// identity (never a cross-project/arbitrary run). State maps to EXPLICIT codes (RUN_PAUSED /
|
|
22
|
+
// RUN_COMPLETE / RUN_CANCELLED / RUN_STALLED / NO_RUN / AMBIGUOUS_RUN) — never a generic
|
|
23
|
+
// "no_active_run". `resume` re-binds so a NEW conversation can continue verifying the same run.
|
|
24
|
+
// `cancel` is a terminal action. Lifecycle verification records a real receipt but does NOT count a
|
|
25
|
+
// source candidate (candidate accounting is unchanged and reserved for real candidate evaluation).
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import crypto from "node:crypto";
|
|
30
|
+
|
|
31
|
+
let CONTROLLER = null;
|
|
32
|
+
let TOOL_HELPER; // undefined = untried, null = unavailable, fn = available
|
|
33
|
+
const CANDIDATES = [];
|
|
34
|
+
if (process.env.LONGRUN_CONTROLLER_FILE) CANDIDATES.push("file://" + process.env.LONGRUN_CONTROLLER_FILE);
|
|
35
|
+
const BAKED = "__LONGRUN_CONTROLLER_URL__";
|
|
36
|
+
if (!/^\w+$/.test(BAKED)) CANDIDATES.push(BAKED); // replaced at install time
|
|
37
|
+
CANDIDATES.push(new URL("./_controller.js", import.meta.url).href);
|
|
38
|
+
const VERSION = "1.2.22";
|
|
39
|
+
|
|
40
|
+
// ---- tool-discovery contract: the authoritative action list (shared with controller) --------
|
|
41
|
+
const ACTIONS = ["help", "start", "status", "receipts", "next", "checkpoint", "verify", "pause", "resume", "cancel", "complete", "reconcile", "memory_init", "memory_refresh", "memory_status", "resume-context"];
|
|
42
|
+
const DEFAULT_PARAMS = { help: ["session"], start: ["request", "criteria", "hardGates", "candidateBudget", "timeBudgetHours", "deadlineHours", "toolActionCap", "sameFailureThreshold", "noProgressThreshold", "autoContinue", "checkCatalogue"], status: ["runId"], receipts: ["runId", "checkId", "receiptId", "offset", "limit"], next: ["runId"], checkpoint: ["runId", "progress"], verify: ["checkId", "evidenceClass", "mode", "fixture", "runId"], pause: ["runId"], resume: ["runId"], cancel: ["runId", "reason"], complete: ["runId"], reconcile: ["runId"], memory_init: ["maxDepth", "dryRun", "regenerate"], memory_refresh: ["maxDepth"], memory_status: [], "resume-context": ["runId"] };
|
|
43
|
+
|
|
44
|
+
function isTestContext() {
|
|
45
|
+
return Boolean(process.env.NODE_TEST_CONTEXT || process.env.NODE_OPTIONS?.includes("--test") || process.env.LONGRUN_TEST);
|
|
46
|
+
}
|
|
47
|
+
function isHostProcess() {
|
|
48
|
+
if (isTestContext()) return false;
|
|
49
|
+
if (process.env.OPENCODE_CLIENT) return true;
|
|
50
|
+
if (process.versions.electron) return true;
|
|
51
|
+
const ep = process.execPath || "";
|
|
52
|
+
return /[.]app[\/]/i.test(ep) || /(^|[\/-])opencode([\/.\-]|$)/i.test(path.basename(ep));
|
|
53
|
+
}
|
|
54
|
+
const DEFAULT_STATE = path.join(os.homedir() || os.tmpdir(), ".local", "state", "opencode-longrun", "v1");
|
|
55
|
+
function stateDir() { if (process.env.LONGRUN_STATE_DIR) return process.env.LONGRUN_STATE_DIR; return isHostProcess() ? DEFAULT_STATE : null; }
|
|
56
|
+
async function loadController() { if (CONTROLLER) return CONTROLLER; for (const u of CANDIDATES) { try { const m = await import(u); if (m && m.STATES) { CONTROLLER = m; return m; } } catch {} } return null; }
|
|
57
|
+
async function getToolHelper() { if (TOOL_HELPER !== undefined) return TOOL_HELPER; try { const m = await import("@opencode-ai/plugin"); TOOL_HELPER = m.tool || null; } catch { TOOL_HELPER = null; } return TOOL_HELPER; }
|
|
58
|
+
function sanitizeEnv() { const c = { ...process.env }; delete c.NODE_OPTIONS; delete c.NODE_REPL_MODE; return c; }
|
|
59
|
+
|
|
60
|
+
class RoutingError extends Error {
|
|
61
|
+
constructor(detail, code = "ROUTING_STORE_ERROR") { super(`${code}: ${detail}`); this.code = code; }
|
|
62
|
+
}
|
|
63
|
+
function readIndex(dir, name) {
|
|
64
|
+
if (!dir) return {};
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
67
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected an object map");
|
|
68
|
+
for (const entry of Object.values(value)) {
|
|
69
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error("invalid index entry");
|
|
70
|
+
if (name === "projects.json" && (!Array.isArray(entry.runs) || entry.runs.some(key => !/^[a-f0-9]{32}$/.test(key)))) throw new Error("invalid project run list");
|
|
71
|
+
if (name === "runs.json" && !/^[a-f0-9]{32}$/.test(entry.runKey || "")) throw new Error("invalid session run key");
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === "ENOENT") return {};
|
|
76
|
+
throw new RoutingError(`${name} could not be read safely (${error.code || error.name}); original bytes preserved`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function writeIndex(dir, name, value) {
|
|
80
|
+
if (!dir) throw new RoutingError("state directory unavailable");
|
|
81
|
+
const final = path.join(dir, name), temp = `${final}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
82
|
+
try { fs.writeFileSync(temp, JSON.stringify(value)); fs.renameSync(temp, final); }
|
|
83
|
+
catch (error) { throw new RoutingError(`${name} write failed (${error.code || error.name}); a canonical lifecycle change may already be committed. Inspect status and resume the SAME run after storage recovery; never start a replacement.`); }
|
|
84
|
+
finally { try { fs.unlinkSync(temp); } catch {} }
|
|
85
|
+
}
|
|
86
|
+
const readRuns = dir => readIndex(dir, "runs.json");
|
|
87
|
+
const writeRuns = (dir, value) => writeIndex(dir, "runs.json", value);
|
|
88
|
+
const readProj = dir => readIndex(dir, "projects.json");
|
|
89
|
+
const writeProj = (dir, value) => writeIndex(dir, "projects.json", value);
|
|
90
|
+
|
|
91
|
+
// Serialize the short admission/index transaction across hosts. The canonical run
|
|
92
|
+
// remains authoritative; the two indices are recoverable routing hints. No lock is
|
|
93
|
+
// held over inference or a verification subprocess. An abandoned/invalid lock fails
|
|
94
|
+
// closed, rather than racing another host by unlinking a possibly replaced lock.
|
|
95
|
+
async function withRoutingLock(dir, callback) {
|
|
96
|
+
const C = await loadController();
|
|
97
|
+
return C.withStoreRoutingLock(dir, () => {
|
|
98
|
+
readRuns(dir); readProj(dir); // detect corrupt indices before any lifecycle mutation
|
|
99
|
+
return callback();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function runFor(dir, sid) { const e = readRuns(dir)[sid]; if (!e || e.disabled) return null; return e; }
|
|
103
|
+
function keyOf(v) { return crypto.createHash("sha256").update(String(v)).digest("hex").slice(0, 24); }
|
|
104
|
+
function tail(s, n) { return s && s.length > n ? "..." + s.slice(-n) : s; }
|
|
105
|
+
function isDisabledGlobally() { try { return fs.existsSync(path.join(process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), ".config", "opencode"), "longrun-harness", "DISABLED")); } catch { return false; } }
|
|
106
|
+
|
|
107
|
+
// ---- trustworthy live-evidence records (unchanged from v1.1.2 trust rules) -----------------
|
|
108
|
+
function writeLoadRecord(ctx) {
|
|
109
|
+
if (!isHostProcess()) return;
|
|
110
|
+
const dir = stateDir(); if (!dir) return;
|
|
111
|
+
try {
|
|
112
|
+
const ld = path.join(dir, "load"); fs.mkdirSync(ld, { recursive: true });
|
|
113
|
+
const exec = (() => { try { return fs.realpathSync(process.execPath); } catch { return process.execPath; } })();
|
|
114
|
+
const nonce = process.pid + "-" + Date.now() + "-" + crypto.randomBytes(4).toString("hex");
|
|
115
|
+
const f = path.join(ld, "host-" + keyOf(process.pid + "|" + (ctx?.project?.id || "global")) + ".json");
|
|
116
|
+
fs.writeFileSync(f, JSON.stringify({
|
|
117
|
+
harnessVersion: VERSION, at: Date.now(), nonce, hookActivity: 0, toolsBuilt: true, test: false,
|
|
118
|
+
project: ctx?.project?.id || ctx?.directory || "global",
|
|
119
|
+
pid: process.pid, runtime: process.version, exec,
|
|
120
|
+
client: process.env.OPENCODE_CLIENT || null,
|
|
121
|
+
}));
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
function bumpActivity(ctx, sid) {
|
|
125
|
+
if (!isHostProcess()) return;
|
|
126
|
+
const dir = stateDir(); if (!dir) return;
|
|
127
|
+
try {
|
|
128
|
+
const ld = path.join(dir, "load"); if (!fs.existsSync(ld)) return;
|
|
129
|
+
const want = "host-" + keyOf(process.pid + "|" + (ctx?.project?.id || "global"));
|
|
130
|
+
for (const name of fs.readdirSync(ld)) {
|
|
131
|
+
if (!name.startsWith(want)) continue;
|
|
132
|
+
const f = path.join(ld, name);
|
|
133
|
+
let rec; try { rec = JSON.parse(fs.readFileSync(f, "utf8")); } catch { continue; }
|
|
134
|
+
if (!rec || !rec.nonce || rec.toolsBuilt !== true) continue;
|
|
135
|
+
rec.hookActivity = (rec.hookActivity || 0) + 1; rec.at = Date.now();
|
|
136
|
+
fs.writeFileSync(f, JSON.stringify(rec)); return;
|
|
137
|
+
}
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const server = async (input) => {
|
|
142
|
+
const C = await loadController();
|
|
143
|
+
const helper = await getToolHelper();
|
|
144
|
+
try { input?.client?.app?.log?.({ body: { service: "longrun", level: C && !isDisabledGlobally() ? "info" : "warn", message: !C ? "controller NOT found; plugin inert" : isDisabledGlobally() ? "DISABLED" : "loaded" } }); } catch {}
|
|
145
|
+
if (!C || isDisabledGlobally()) return {};
|
|
146
|
+
const SD = stateDir();
|
|
147
|
+
const mkTool = (def) => helper ? helper(def) : { ...def, __plain: true };
|
|
148
|
+
const enumArg = (arr) => helper ? helper.schema.enum(arr) : { type: "string", enum: arr };
|
|
149
|
+
|
|
150
|
+
// ---- ONE canonical run resolver for BOTH native tools (delegates eligibility + state codes to
|
|
151
|
+
// the controller). It gathers candidate runs from (a) this session's authorised binding, (b) the
|
|
152
|
+
// canonical project/worktree identity run list, and (c) an explicit runId, deduped by store key.
|
|
153
|
+
function buildCandidates(ctx, explicitRunId) {
|
|
154
|
+
const out = []; const seen = new Set();
|
|
155
|
+
const identities = new Set([ctx.directory, ctx.worktree].filter(Boolean).map(d => C.projectIdentity(d).id));
|
|
156
|
+
const add = (key, dir, cat) => {
|
|
157
|
+
if (!key || !/^[a-f0-9]{32}$/.test(key) || seen.has(key)) return;
|
|
158
|
+
let r;
|
|
159
|
+
try {
|
|
160
|
+
r = JSON.parse(fs.readFileSync(path.join(SD, "state", key, "run.json"), "utf8"));
|
|
161
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) throw new Error("invalid canonical record");
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error.code === "ENOENT" && !dir) return;
|
|
164
|
+
throw new RoutingError(`canonical record ${key} unreadable (${error.code || error.name}); preserve it, never create a replacement from an empty fallback`);
|
|
165
|
+
}
|
|
166
|
+
const canonicalDir = r.directory || dir;
|
|
167
|
+
if (!canonicalDir || !identities.has(C.projectIdentity(canonicalDir).id)) return;
|
|
168
|
+
seen.add(key);
|
|
169
|
+
out.push({ key, run: r, dir: canonicalDir, cat: r.checkCatalogue || cat || null });
|
|
170
|
+
};
|
|
171
|
+
// (a) session binding (authorised routing metadata only; absent in a fresh conversation)
|
|
172
|
+
const e = runFor(SD, ctx.sessionID);
|
|
173
|
+
if (e && e.runKey) add(e.runKey, e.directory, e.checkCatalogue);
|
|
174
|
+
// (b) canonical project/worktree identity -> its recorded run list
|
|
175
|
+
for (const d of [ctx.directory, ctx.worktree]) {
|
|
176
|
+
if (!d) continue;
|
|
177
|
+
let id; try { id = C.projectIdentity(d); } catch { continue; }
|
|
178
|
+
const p = readProj(SD)[keyOf(id.id)];
|
|
179
|
+
if (p && Array.isArray(p.runs)) for (const rk of p.runs.slice().reverse()) add(rk, d, null);
|
|
180
|
+
}
|
|
181
|
+
// A crash can persist run.json before either index. Discover canonical records
|
|
182
|
+
// as well, so an incomplete index cannot hide a run or admit its replacement.
|
|
183
|
+
let storedKeys = [];
|
|
184
|
+
try { storedKeys = fs.readdirSync(path.join(SD, "state")); }
|
|
185
|
+
catch (error) { if (error.code !== "ENOENT") throw new RoutingError(`canonical run directory unreadable (${error.code || error.name})`); }
|
|
186
|
+
for (const key of storedKeys) add(key, null, null);
|
|
187
|
+
// (c) explicit runId must be one of the above (NEVER a cross-project / arbitrary fallback)
|
|
188
|
+
if (explicitRunId) {
|
|
189
|
+
const hit = out.find((x) => x.run.runId === explicitRunId);
|
|
190
|
+
if (hit) return { entries: [hit], exact: true };
|
|
191
|
+
// fall through so resolveVerification reports NO_RUN for an id not in this project
|
|
192
|
+
return { entries: out, exact: false };
|
|
193
|
+
}
|
|
194
|
+
return { entries: out, exact: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A wrong explicit ID must still fail, but loss of the ID during compaction
|
|
198
|
+
// must not hide the canonical project run. Offer bounded read-only discovery;
|
|
199
|
+
// never substitute another run into the requested lifecycle/check operation.
|
|
200
|
+
function runDiscovery(entries) {
|
|
201
|
+
const runs = entries.map(e => e.run);
|
|
202
|
+
const open = runs.filter(r => !C.RUN_TERMINAL_STATES.includes(r.status));
|
|
203
|
+
const target = open.length === 1 ? open[0] : open.length === 0 && runs.length === 1 ? runs[0] : null;
|
|
204
|
+
const ordered = [...open, ...runs.filter(r => C.RUN_TERMINAL_STATES.includes(r.status))];
|
|
205
|
+
return {
|
|
206
|
+
totalRuns: runs.length,
|
|
207
|
+
availableRuns: ordered.slice(0, 5).map(r => ({ runId: r.runId, state: r.status })),
|
|
208
|
+
truncated: runs.length > 5,
|
|
209
|
+
suggestedRead: target ? { action: C.RUN_TERMINAL_STATES.includes(target.status) ? "status" : "resume-context", runId: target.runId } : null,
|
|
210
|
+
detail: open.length > 1 ? "Multiple nonterminal project runs; identify the intended run from canonical context. Never guess an ID or create a replacement to bypass recovery."
|
|
211
|
+
: target && open.length ? "The requested ID was not selected. Read the suggested canonical resume-context, then explicitly resume the SAME run only for already-authorized work and within its original limits. Never treat a copied checkpoint as current run authority or create a replacement to bypass recovery."
|
|
212
|
+
: runs.length ? "Only terminal project runs remain; inspect status with an exact ID. Terminal runs cannot resume. Never rewrite their state or create a replacement to bypass limits."
|
|
213
|
+
: "No canonical run was found in this project/worktree. Check the project and state-store context; never guess IDs or create a replacement to bypass recovery.",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A session binding routes to a run; its cached paused/disabled flags cannot
|
|
218
|
+
// authorize continuation after another host changes that run. Both compaction
|
|
219
|
+
// callbacks converge on the same persisted state transition under its lock.
|
|
220
|
+
async function recordCompaction(entry, sessionID) {
|
|
221
|
+
if (!entry.runKey) return { error: "NO_RUN" };
|
|
222
|
+
const store = new C.Store(SD);
|
|
223
|
+
let result;
|
|
224
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
225
|
+
result = store.mutate(entry.runKey, latest => {
|
|
226
|
+
if (latest.compactionSessionID && latest.compactionSessionID !== sessionID) return { ok: true, staleSession: true, state: latest.status };
|
|
227
|
+
if (C.RUN_VERIFY_STATES.includes(latest.status) || latest.status === "COMPACTING") {
|
|
228
|
+
latest.status = "RECOVERY_REQUIRED";
|
|
229
|
+
latest.controlGeneration = (latest.controlGeneration || 0) + 1;
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, state: latest.status };
|
|
232
|
+
});
|
|
233
|
+
if (result.error !== "STATE_BUSY") break;
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ------- longrun: the control plane (read-only; never arbitrary shell) -------
|
|
240
|
+
const longrunTool = mkTool({
|
|
241
|
+
description: "Long-run lifecycle, memory and checkpoint control for the CURRENT authorised run; never arbitrary shell. Call action=help if unsure of the action name — do NOT guess. For tracked workflows keep the runId returned by start and pass it here (optional; single active run may auto-resolve).",
|
|
242
|
+
args: {
|
|
243
|
+
action: enumArg(ACTIONS),
|
|
244
|
+
request: helper ? helper.schema.string().optional() : { type: "string" },
|
|
245
|
+
criteria: helper ? helper.schema.union([helper.schema.array(helper.schema.any()), helper.schema.string()]).optional() : { type: ["array", "string"] },
|
|
246
|
+
checkCatalogue: helper ? helper.schema.any().optional() : { type: "object" },
|
|
247
|
+
hardGates: helper ? helper.schema.union([helper.schema.array(helper.schema.any()), helper.schema.string()]).optional() : { type: ["array", "string"] },
|
|
248
|
+
candidateBudget: helper ? helper.schema.union([helper.schema.number(), helper.schema.string()]).optional() : { type: ["number", "string"] },
|
|
249
|
+
timeBudgetHours: helper ? helper.schema.union([helper.schema.number(), helper.schema.string()]).optional() : { type: ["number", "string"] },
|
|
250
|
+
deadlineHours: helper ? helper.schema.union([helper.schema.number(), helper.schema.string()]).optional() : { type: ["number", "string"] },
|
|
251
|
+
toolActionCap: helper ? helper.schema.union([helper.schema.number(), helper.schema.string()]).optional() : { type: ["number", "string"] },
|
|
252
|
+
sameFailureThreshold: helper ? helper.schema.union([helper.schema.number(), helper.schema.string()]).optional() : { type: ["number", "string"] },
|
|
253
|
+
noProgressThreshold: helper ? helper.schema.union([helper.schema.number(), helper.schema.string()]).optional() : { type: ["number", "string"] },
|
|
254
|
+
autoContinue: helper ? helper.schema.union([helper.schema.boolean(), helper.schema.string()]).optional() : { type: ["boolean", "string"] },
|
|
255
|
+
checkId: helper ? helper.schema.string().optional() : { type: "string" },
|
|
256
|
+
receiptId: helper ? helper.schema.string().optional() : { type: "string" },
|
|
257
|
+
offset: helper ? helper.schema.number().optional() : { type: "number" },
|
|
258
|
+
limit: helper ? helper.schema.number().optional() : { type: "number" },
|
|
259
|
+
evidenceClass: helper ? helper.schema.string().optional() : { type: "string" },
|
|
260
|
+
mode: helper ? helper.schema.enum(["normal", "negative"]).optional() : { type: "string" },
|
|
261
|
+
runId: helper ? helper.schema.string().optional() : { type: "string" },
|
|
262
|
+
reason: helper ? helper.schema.string().optional() : { type: "string" },
|
|
263
|
+
progress: helper ? helper.schema.union([
|
|
264
|
+
helper.schema.object({
|
|
265
|
+
currentSlice: helper.schema.string().max(240).optional(),
|
|
266
|
+
nextAction: helper.schema.string().max(1000).optional(),
|
|
267
|
+
decisions: helper.schema.array(helper.schema.string().max(500)).max(12).optional(),
|
|
268
|
+
failedHypotheses: helper.schema.array(helper.schema.string().max(500)).max(12).optional(),
|
|
269
|
+
memoryNodes: helper.schema.array(helper.schema.string().max(500)).max(12).optional(),
|
|
270
|
+
artifacts: helper.schema.array(helper.schema.string().max(500)).max(12).optional(),
|
|
271
|
+
}).strict(),
|
|
272
|
+
helper.schema.string(), // local tool adapters may encode a nested object as JSON
|
|
273
|
+
]).optional() : { type: ["object", "string"] },
|
|
274
|
+
maxDepth: helper ? helper.schema.number().optional() : { type: "number" },
|
|
275
|
+
dryRun: helper ? helper.schema.boolean().optional() : { type: "boolean" },
|
|
276
|
+
regenerate: helper ? helper.schema.boolean().optional() : { type: "boolean" },
|
|
277
|
+
},
|
|
278
|
+
async execute(args, context) {
|
|
279
|
+
bumpActivity(context, context.sessionID);
|
|
280
|
+
const dir = context.directory || context.worktree || process.cwd();
|
|
281
|
+
const a = (args && args.action) || "";
|
|
282
|
+
const rid = (args && (args.runId || args.run)) || null;
|
|
283
|
+
if (a === "help") {
|
|
284
|
+
const { entries } = buildCandidates(context, rid);
|
|
285
|
+
const cur = C.pickCurrentRun(entries);
|
|
286
|
+
const mem = C.memory.assessStaleness(dir, C.memory.readMemoryIndex(dir));
|
|
287
|
+
return JSON.stringify({
|
|
288
|
+
harnessVersion: VERSION, lifecycleSchema: C.LIFECYCLE_SCHEMA_VERSION,
|
|
289
|
+
actions: ACTIONS, params: DEFAULT_PARAMS,
|
|
290
|
+
run: cur && cur.run ? { runId: cur.run.runId, state: cur.run.status } : null,
|
|
291
|
+
continuation: { enabled: false, note: "automatic continuation is OFF by default in v1.2.22" },
|
|
292
|
+
criteriaSchema: "criteria: [{id, required?(default true), weight?(default 1), evidenceClass?, checks:[checkId,...]}]. Every REQUIRED criterion MUST map to >=1 declared check, else start returns INVALID_CONTRACT and creates NO run.",
|
|
293
|
+
checkCatalogueSchema: "checkCatalogue: {checkId:{command:[...argv], kind:'cmd'|'test', timeoutMs?, countTests?, proxyOnly?, integration?, visual?, security?, determinism?, negativeControl?, gate?}}; evidenceClass STATIC is accepted; kind:'test' needs discovered test counts (zero tests cannot satisfy a test criterion).",
|
|
294
|
+
mappingFields: "the mapping from a criterion to its evidence is criterion.checks -> checkCatalogue keys; longrun_verify(checkId=...) executes ONLY those declared checks.",
|
|
295
|
+
executionLimits: "Declared checks enforce candidate, absolute deadline, measured check-time and check-attempt limits. toolActionCap counts declared-check attempts; it does not count help, status, checkpoint or all host-tool calls. Ordinary host-tool admission is refused after the absolute deadline, including fresh sessions in the same project; native status/checkpoint/pause and explicit check refusal remain available. This cannot interrupt an already-running ordinary tool or model inference. Time/actions outside declared checks are unmetered; historical usage may be incomplete. A candidate already admitted can finish its checks within the other limits. Verifier BUDGET_EXHAUSTED atomically leaves the run PAUSED/OFF without a new command attempt or receipt. Reaching the candidate cap alone does not block ordinary edits before a verifier refusal. Preserve the stop; do not bypass through bash, reset budgets or create a replacement run. VERIFY_IN_FLIGHT means wait; after a finished result or absent owner, use action=reconcile(runId). RESULT_COMMIT_PENDING and EXECUTION_RECOVERY_REQUIRED require reconciliation before retrying. Reconcile commits only a consistent finished execution record once, never launches a check, and preserves pause/terminal state. Each declared check runs in a finite Node executor that can clean up and journal an actual result after its host disconnects. It never commits or schedules another check; explicit reconciliation owns recovery. Missing evidence, executor failure or unconfirmed cleanup stays blocked. Same-failure/no-progress thresholds remain workflow guidance, not enforcement on this path.",
|
|
296
|
+
receiptInspection: "status and action=verify provide summaries and receipt references. Use action=receipts with offset/limit (1..20), optionally checkId, to page recorded metadata; use receiptId to retrieve one actual command/argv, exit, timestamps, output tail and termination details. Absent legacy fields remain null. Inspect recorded evidence before searching files or rerunning a check.",
|
|
297
|
+
lifecycleGuidance: C.LIFECYCLE_GUIDANCE,
|
|
298
|
+
completionReview: "Tracked runs require both current passing declared evidence and a current independent operator completion review. Checks alone cannot authorize complete. Save a checkpoint and pause for that review; do not approve your own work through the maintenance CLI or state edits. The operator may be an authorized supervising agent, not necessarily a human. Approval becomes stale when its source, contract, budget or recorded evidence changes. Only an explicit operator rejection can archive a premature COMPLETE and return that same run to PAUSED without resetting its limits; ordinary resume/pause cannot reopen terminal runs. CANCELLED remains terminal. This is an auditable workflow boundary, not an OS security sandbox.",
|
|
299
|
+
budgetAmendment: "A finite additional candidate allowance and/or a new absolute deadline may only be granted through the installed operator maintenance CLI (amend), never through this tool surface. There is no native amendment action, and a model must not grant, request through tool arguments, reset or rewrite its own limits, or self-approve. A grant preserves the original limits/usage/receipts/contract/failure history byte-for-byte, is recorded append-only with the explicit authorization, invalidates any prior completion approval, is bounded and finite, and never resumes the run. Do not bypass an exhausted or expired budget through another tool, session, state edit or replacement run.",
|
|
300
|
+
progressSchema: C.progressSchema(),
|
|
301
|
+
verifyExec: "longrun_verify performs/records declared verification checks; longrun action=verify is a READ-ONLY readout of declared checks + outstanding gaps (it never executes anything). When evidenceClass is omitted the receipt inherits the class required by the single criterion that maps this check; an explicit evidenceClass always wins, and a check mapped with conflicting classes (or unmapped) needs an explicit class because the harness will not guess.",
|
|
302
|
+
lifecycleAdmission: "Canonical paused/stalled/terminal states also block ordinary execution, edits, delegation and memory writes. Read tools and native control stay available; native verification/resume keep their own eligibility checks. Pausing from another host or opening a fresh session cannot bypass that state. Native start remains available for a genuinely new authorized task after a terminal run, never as a budget workaround. This does not interrupt an ordinary tool already running.",
|
|
303
|
+
compactionRecovery: "After actual compaction, the current authorized session enters RECOVERY_REQUIRED. Read resume-context then explicitly resume before verifying. Paused/terminal/blocked runs, expired budgets, in-flight checks, unknown legacy bindings and stale older sessions cannot auto-continue. A legacy active run needs an authorized resume to establish its session binding. This governs OpenCode compaction of the active turn; Longrun autonomous scheduling stays OFF.",
|
|
304
|
+
checkpointProgress: "checkpoint accepts optional progress as an object or JSON-encoded object: {currentSlice, nextAction, decisions:[], failedHypotheses:[], memoryNodes:[], artifacts:[]}. Limits: currentSlice 240 characters, nextAction 1000; each array at most 12 strings of 500 characters; combined fields at most 6000 characters. Partial updates merge; arrays replace. Advisory context only, never acceptance/status/budget/receipt fields. Reference exact artifact paths. Saved context survives session changes and supplements compaction. After INVALID_PROGRESS correct only the payload; do not search for a CLI workaround or edit the state files.",
|
|
305
|
+
runResolution: "ONE canonical resolver is shared by longrun + longrun_verify: session binding -> canonical project/worktree identity -> optional explicit runId. It never verifies an arbitrary or cross-project run. State codes: OK/ELIGIBLE | RUN_PAUSED | RUN_COMPLETE | RUN_CANCELLED | RUN_STALLED | NO_RUN | AMBIGUOUS_RUN. After a new conversation call resume(runId) to re-bind, then verify(runId). Use cancel for abandoned/impossible runs; never overwrite a non-terminal run; never brute-force run IDs. An unknown explicit ID remains NO_RUN and offers bounded canonical project discovery plus a suggested read when unambiguous; it never silently selects another run or executes a check.",
|
|
306
|
+
routingIntegrity: "Lifecycle admission and routing writes are serialized across hosts. Canonical run records remain discoverable after missing/partial indices; authorized resume repairs that same run's binding. ROUTING_STORE_ERROR preserves corrupt bytes and may report a partially committed lifecycle change: inspect storage and status, never create a replacement. ROUTING_BUSY is bounded contention; retry after the owner finishes. Abandoned locks require inspected maintenance recovery, never a guessed PID kill or unconditional deletion. These are offline-tested controls, not a claim of an OS sandbox or total host accounting.",
|
|
307
|
+
completionRequires: "complete is blocked until every required criterion has a current PASS receipt strong enough for its evidence class AND every required gate PASSes AND loss<=target(0); a reduced soft loss cannot beat a failed hard gate/required check.",
|
|
308
|
+
memoryStatus: mem.status,
|
|
309
|
+
}, null, 2);
|
|
310
|
+
}
|
|
311
|
+
if (!ACTIONS.includes(a)) {
|
|
312
|
+
return JSON.stringify({ error: "unknown_action", detail: "Do NOT brute-force action names. Supported actions and parameters:", actions: ACTIONS, params: DEFAULT_PARAMS });
|
|
313
|
+
}
|
|
314
|
+
const store = new C.Store(SD);
|
|
315
|
+
if (a === "start") {
|
|
316
|
+
const budgetValidation = C.validateBudgetArgs(args);
|
|
317
|
+
if (budgetValidation.error) return JSON.stringify(budgetValidation);
|
|
318
|
+
const A = C.normalizeStartArgs(args); // v1.2.3: one place normalizes stringified arrays/objects + string budgets/booleans
|
|
319
|
+
if (A.autoContinue === true) return JSON.stringify({ error: "AUTO_CONTINUATION_UNAVAILABLE", detail: "automatic continuation is OFF; start with autoContinue=false" });
|
|
320
|
+
const req = A.request || context.request || "";
|
|
321
|
+
const crit = Array.isArray(A.criteria) ? A.criteria : [];
|
|
322
|
+
const catalogue = A.checkCatalogue || {};
|
|
323
|
+
if (!req || !crit.length) return JSON.stringify({ error: "no_contract", detail: "start needs request text + >=1 acceptance criterion; refusing an untracked run" });
|
|
324
|
+
// Resolve any existing project run. A NON-terminal run may NOT be overwritten; a terminal
|
|
325
|
+
// (COMPLETE/CANCELLED) run does NOT block a new run. Never silently overwrite nonterminal.
|
|
326
|
+
const { entries } = buildCandidates(context, null);
|
|
327
|
+
const nonterm = entries.filter((x) => x.run && !C.RUN_TERMINAL_STATES.includes(x.run.status));
|
|
328
|
+
if (nonterm.length === 1) {
|
|
329
|
+
const r = nonterm[0].run;
|
|
330
|
+
const match = (catalogue && r.checkCatalogue && keyOf(JSON.stringify(catalogue)) === keyOf(JSON.stringify(r.checkCatalogue))) || null;
|
|
331
|
+
return JSON.stringify({ error: "EXISTING_RUN", runId: r.runId, state: r.status, contractHashMatch: match, candidates: `${C.candidateCount(r)}/${(r.budget && r.budget.iterations) || 40}`, nextActions: ["resume", "status", "cancel"], note: "a non-terminal run already exists for this project; cancel it or resume it before starting a new one (a non-terminal run is never overwritten)" });
|
|
332
|
+
}
|
|
333
|
+
if (nonterm.length > 1) {
|
|
334
|
+
return JSON.stringify({ error: "AMBIGUOUS_RUN", ids: nonterm.map((x) => x.run.runId), nextActions: ["cancel", "status"], detail: "multiple non-terminal runs; pass an explicit runId — never guess" });
|
|
335
|
+
}
|
|
336
|
+
const vchk = C.validateStartContract({ criteria: crit, checkCatalogue: catalogue });
|
|
337
|
+
if (!vchk.ok) return JSON.stringify({ error: "INVALID_CONTRACT", detail: "every REQUIRED criterion must map (via its checks field) to at least one declared check in checkCatalogue; criterion text alone is not verifiable and cannot reach loss=0. NO run was created — add the missing check mappings and retry action=start.", problems: vchk.problems });
|
|
338
|
+
const contract = C.makeContract({ criteria: crit, hardGates: A.hardGates || [] });
|
|
339
|
+
const fp = C.sourceFingerprint(dir);
|
|
340
|
+
const mem = C.memory.assessStaleness(dir, C.memory.readMemoryIndex(dir));
|
|
341
|
+
const budgets = C.defaultBudget();
|
|
342
|
+
if (A.candidateBudget) budgets.iterations = A.candidateBudget;
|
|
343
|
+
if (A.timeBudgetHours) budgets.activeSeconds = A.timeBudgetHours * 3600;
|
|
344
|
+
if (A.deadlineHours) budgets.deadlineSeconds = A.deadlineHours * 3600;
|
|
345
|
+
if (A.toolActionCap) budgets.toolActionCap = A.toolActionCap;
|
|
346
|
+
if (A.sameFailureThreshold) budgets.sameFailureLimit = A.sameFailureThreshold;
|
|
347
|
+
if (A.noProgressThreshold) budgets.noProgressLimit = A.noProgressThreshold;
|
|
348
|
+
const out = C.startRun({ request: req, contract, budgets, sourceFingerprint: fp.hash, memoryStatus: mem.status, continuation: A.autoContinue === true, directory: dir, checkCatalogue: catalogue });
|
|
349
|
+
if (out.error) return JSON.stringify(out);
|
|
350
|
+
const run = out.run;
|
|
351
|
+
run.compactionSessionID = context.sessionID;
|
|
352
|
+
const key = C.stateKey(C.projectIdentity(dir), run.runId);
|
|
353
|
+
store.writeJSON(key, "run.json", run);
|
|
354
|
+
const runs = readRuns(SD); runs[context.sessionID] = { runKey: key, directory: dir, runId: run.runId, active: true, paused: false, checkCatalogue: catalogue, contractHash: run.contractHash }; writeRuns(SD, runs);
|
|
355
|
+
const pk = keyOf(C.projectIdentity(dir).id); const proj = readProj(SD); const p = proj[pk] || { runs: [] }; if (!p.runs.includes(key)) p.runs.push(key); proj[pk] = p; writeProj(SD, proj);
|
|
356
|
+
return JSON.stringify({ runId: run.runId, contractHash: run.contractHash, initialLoss: run.loss, budgets, memoryStatus: mem.status, sourceFingerprint: fp.hash, nextAction: "inspect code + choose first slice; then longrun_verify(checkId=..., runId=this)", continuation: false });
|
|
357
|
+
}
|
|
358
|
+
// v1.2.1: hierarchical-memory ops are STRUCTURAL and run WITHOUT an active tracked run.
|
|
359
|
+
if (a === "memory_status") { const m = C.memory.assessStaleness(dir, C.memory.readMemoryIndex(dir)); return JSON.stringify({ state: "MEMORY", ...m, run: null }); }
|
|
360
|
+
if (a === "memory_init") { const rep = C.memory.initDeep(dir, { maxDepth: args.maxDepth, dryRun: args.dryRun, regenerate: args.regenerate, harnessVersion: VERSION }); return JSON.stringify({ state: "MEMORY", dryRun: !!args.dryRun, created: rep.created.map((c) => ({ rel: c.rel, action: c.action })), preserved: rep.preserved.map((c) => c.rel), skipped: rep.skipped.length, run: null }); }
|
|
361
|
+
if (a === "memory_refresh") { const rep = C.memory.initDeep(dir, { maxDepth: args.maxDepth, harnessVersion: VERSION }); return JSON.stringify({ state: "MEMORY", updated: rep.updated.map((c) => c.rel), unchanged: rep.unchanged.map((c) => c.rel), preserved: rep.preserved.map((c) => c.rel), created: rep.created.map((c) => c.rel), run: null }); }
|
|
362
|
+
|
|
363
|
+
// ---- resolve the CURRENT project run (may be terminal; state codes handled per action) ----
|
|
364
|
+
const { entries, exact } = buildCandidates(context, rid);
|
|
365
|
+
if (rid && !exact) return JSON.stringify({ state: "NO_RUN", runId: rid, detail: "explicit runId is not bound to this project", discovery: runDiscovery(entries) }, null, 2);
|
|
366
|
+
const cur = C.pickCurrentRun(entries);
|
|
367
|
+
if (!cur || !cur.run) return JSON.stringify({ state: "NO_RUN", detail: "no active tracked run; call action=start (a prompt alone is NOT a tracked run). Project memory: memory_status/memory_init work without a run." });
|
|
368
|
+
const run = cur.run; const rkey = cur.key;
|
|
369
|
+
const observedFingerprint = C.sourceFingerprint(dir);
|
|
370
|
+
const view = C.deriveRunView(run, { currentFingerprint: observedFingerprint,
|
|
371
|
+
projectMemoryStatus: C.memory.assessStaleness(dir, C.memory.readMemoryIndex(dir)) });
|
|
372
|
+
if (a === "status") return JSON.stringify(C.summarizeRunView(view), null, 2);
|
|
373
|
+
if (a === "receipts") return JSON.stringify(C.receiptReadout(run, { ...args, currentFingerprint: observedFingerprint }), null, 2);
|
|
374
|
+
if (a === "resume-context" || a === "next") return C.buildRecoveryPacket(run, 1500, view).packet;
|
|
375
|
+
if (a === "verify") return JSON.stringify({ ...C.summarizeRunView(view), note: "no check executed (readout only)", execVia: "longrun_verify",
|
|
376
|
+
declaredChecks: Object.keys(cur.cat || run.checkCatalogue || {}), detail: "Call longrun_verify with a declared checkId and runId." }, null, 2);
|
|
377
|
+
if (a === "reconcile") {
|
|
378
|
+
const recovered = store.mutate(rkey, latest => {
|
|
379
|
+
const active = latest.execution?.inFlight;
|
|
380
|
+
if (!active) return { ok: true, reconciled: false, nothingPending: true };
|
|
381
|
+
if (!/^[a-f0-9-]{36}$/.test(active.token || "")) return { error: "INVALID_EXECUTION_RECORD", detail: "invalid reservation token" };
|
|
382
|
+
if (C.execution.ownedWorkAlive(active.childPid)) return { error: "VERIFY_IN_FLIGHT" };
|
|
383
|
+
const journal = store.readJSON(rkey, `execution-${active.token}.json`);
|
|
384
|
+
if (!journal) return { error: C.execution.alive(active.executorPid) || !active.childPid && C.execution.alive(active.ownerPid) ? "VERIFY_IN_FLIGHT" : "EXECUTION_RECORD_MISSING", detail: "No finished result is available; keep the reservation and inspect execution evidence. Never infer a PASS or rerun concurrently." };
|
|
385
|
+
const valid = C.validateExecutionRecord(latest, journal);
|
|
386
|
+
if (!valid.ok) return valid;
|
|
387
|
+
const result = C.commitExecutionResult(latest, journal, { currentFingerprint: C.sourceFingerprint(dir) });
|
|
388
|
+
return result.error ? result : { ...result, reconciled: true, executionToken: active.token, state: latest.status };
|
|
389
|
+
});
|
|
390
|
+
return JSON.stringify({ runId: run.runId, ...recovered });
|
|
391
|
+
}
|
|
392
|
+
if (C.RUN_TERMINAL_STATES.includes(run.status)) {
|
|
393
|
+
return JSON.stringify({ ok: false, error: run.status === "COMPLETE" ? "RUN_COMPLETE" : "RUN_CANCELLED", runId: run.runId, state: run.status, detail: `run ${run.runId} is ${run.status}; ordinary controls cannot modify it. An independent operator may explicitly reject and archive a premature COMPLETE for same-run correction; CANCELLED stays terminal. A new run is only for a genuinely new authorized task, never a budget or review bypass.` });
|
|
394
|
+
}
|
|
395
|
+
// Recheck terminal status under the same lock as the mutation: another host
|
|
396
|
+
// can complete/cancel between our readout above and acquiring that lock.
|
|
397
|
+
const mutateActive = callback => store.mutate(rkey, latest => {
|
|
398
|
+
if (C.RUN_TERMINAL_STATES.includes(latest.status)) return { error: latest.status === "COMPLETE" ? "RUN_COMPLETE" : "RUN_CANCELLED", state: latest.status };
|
|
399
|
+
return callback(latest);
|
|
400
|
+
});
|
|
401
|
+
if (a === "checkpoint") {
|
|
402
|
+
if (args.progress !== undefined) {
|
|
403
|
+
const saved = mutateActive(latest => C.saveAgentProgress(latest, args.progress, { sessionID: context.sessionID, fingerprint: view.currentFingerprint }));
|
|
404
|
+
if (saved.error) return JSON.stringify(saved);
|
|
405
|
+
}
|
|
406
|
+
const current = store.readJSON(rkey, "run.json");
|
|
407
|
+
const currentView = C.deriveRunView(current, { currentFingerprint: observedFingerprint, projectMemoryStatus: view.memory });
|
|
408
|
+
store.writeJSON(rkey, "checkpoint.json", { at: Date.now(), state: current.status, candidates: C.candidateCount(current), fingerprint: currentView.currentFingerprint, view: currentView, packet: C.buildRecoveryPacket(current, 1500, currentView).packet });
|
|
409
|
+
return args.progress === undefined ? "checkpointed" : JSON.stringify({ checkpointed: true, runId: current.runId, agentProgress: current.agentProgress });
|
|
410
|
+
}
|
|
411
|
+
if (a === "pause") {
|
|
412
|
+
const changed = mutateActive(C.pauseRun);
|
|
413
|
+
if (changed.error) return JSON.stringify(changed);
|
|
414
|
+
const runs = readRuns(SD); if (runs[context.sessionID]) { runs[context.sessionID].paused = true; writeRuns(SD, runs); }
|
|
415
|
+
return "paused";
|
|
416
|
+
}
|
|
417
|
+
if (a === "resume") {
|
|
418
|
+
const okR = C.canResume(run, "user_session_command");
|
|
419
|
+
if (!okR.ok) return JSON.stringify({ resumed: false, reason: okR.reason });
|
|
420
|
+
const changed = mutateActive(latest => {
|
|
421
|
+
const guard = C.execution.budgetGuard(latest, { fingerprint: observedFingerprint, canCount: false });
|
|
422
|
+
if (!guard.ok) return { error: "BUDGET_EXHAUSTED", spent: guard.spent, detail: guard.note };
|
|
423
|
+
if (latest.execution?.inFlight) return { error: "VERIFY_IN_FLIGHT", detail: "wait for the owned check to finish or reconcile its recorded result" };
|
|
424
|
+
latest.status = "IMPLEMENTING"; latest.controlGeneration = (latest.controlGeneration || 0) + 1;
|
|
425
|
+
latest.compactionSessionID = context.sessionID;
|
|
426
|
+
return { ok: true };
|
|
427
|
+
});
|
|
428
|
+
if (changed.error) return JSON.stringify({ resumed: false, ...changed });
|
|
429
|
+
// RE-BIND (v1.2.2): re-establish the current-session + current-worktree routing so a NEW
|
|
430
|
+
// conversation can continue verifying the SAME run after compaction/restart.
|
|
431
|
+
const runs = readRuns(SD); runs[context.sessionID] = { runKey: rkey, directory: dir, runId: run.runId, active: true, paused: false, checkCatalogue: run.checkCatalogue || {}, contractHash: run.contractHash }; writeRuns(SD, runs);
|
|
432
|
+
const pk = keyOf(C.projectIdentity(dir).id); const proj = readProj(SD); const p = proj[pk] || { runs: [] }; if (!p.runs.includes(rkey)) p.runs.push(rkey); proj[pk] = p; writeProj(SD, proj);
|
|
433
|
+
return JSON.stringify({ resumed: true, runId: run.runId, state: "IMPLEMENTING", rebound: true, note: "session+worktree binding re-established; pass runId to longrun_verify to continue in this new conversation" });
|
|
434
|
+
}
|
|
435
|
+
if (a === "cancel") {
|
|
436
|
+
const changed = mutateActive(latest => {
|
|
437
|
+
latest.status = "CANCELLED"; latest.autoEnabled = false; latest.controlGeneration = (latest.controlGeneration || 0) + 1;
|
|
438
|
+
latest.state = latest.state || {}; latest.state.cancellation = { reason: args.reason || "cancelled", at: Date.now() }; return { ok: true };
|
|
439
|
+
});
|
|
440
|
+
if (changed.error) return JSON.stringify(changed);
|
|
441
|
+
// preserve the record (no delete). disable further verification/modification + continuation,
|
|
442
|
+
// and free the project so a future `start` may create a NEW run.
|
|
443
|
+
const runs = readRuns(SD); if (runs[context.sessionID]) { runs[context.sessionID].paused = true; runs[context.sessionID].disabled = true; writeRuns(SD, runs); }
|
|
444
|
+
return JSON.stringify({ cancelled: true, runId: run.runId, state: "CANCELLED", reason: args.reason || "cancelled", detail: "terminal; receipts/candidates/history preserved; further verification is RUN_CANCELLED; a new run may now be started" });
|
|
445
|
+
}
|
|
446
|
+
if (a === "complete") {
|
|
447
|
+
const cc = C.canComplete(run, { view });
|
|
448
|
+
if (!cc.complete) return JSON.stringify(C.summarizeRunView(cc), null, 2);
|
|
449
|
+
const changed = mutateActive(latest => {
|
|
450
|
+
if (latest.execution?.inFlight) return { error: "VERIFY_IN_FLIGHT" };
|
|
451
|
+
const finalView = C.deriveRunView(latest, { currentFingerprint: C.sourceFingerprint(dir), projectMemoryStatus: view.memory });
|
|
452
|
+
if (!C.canComplete(latest, { view: finalView }).complete) return { error: "EVIDENCE_CHANGED", detail: "refresh status before completing" };
|
|
453
|
+
latest.status = "COMPLETE"; latest.controlGeneration = (latest.controlGeneration || 0) + 1; return { ok: true };
|
|
454
|
+
});
|
|
455
|
+
if (changed.error) return JSON.stringify(changed);
|
|
456
|
+
const runs = readRuns(SD); if (runs[context.sessionID]) { runs[context.sessionID].active = false; writeRuns(SD, runs); }
|
|
457
|
+
return JSON.stringify({ ...C.summarizeRunView(cc), state: "COMPLETE" }, null, 2);
|
|
458
|
+
}
|
|
459
|
+
return "handled";
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ------- longrun_verify: declared-catalogue execution + evidence class + negative controls -------
|
|
464
|
+
const verifyTool = mkTool({
|
|
465
|
+
description: "Run a DECLARED project check by id against the authoritative tracked run and record an evidence receipt. Refuses arbitrary commands. Optional runId selects the run (strongly preferred for tracked workflows; a single active run may auto-resolve). Optional mode='negative' runs a NEGATIVE CONTROL against an isolated fixture (never the active source).",
|
|
466
|
+
args: {
|
|
467
|
+
checkId: helper ? helper.schema.string() : { type: "string" },
|
|
468
|
+
evidenceClass: helper ? helper.schema.enum(["STATIC", "UNIT", "INTEGRATION", "SYSTEM", "BROWSER", "VISION", "HUMAN/EXTERNAL", "proxy"]).optional() : { type: "string" },
|
|
469
|
+
mode: helper ? helper.schema.enum(["normal", "negative"]).optional() : { type: "string" },
|
|
470
|
+
fixture: helper ? helper.schema.string().optional() : { type: "string" },
|
|
471
|
+
runId: helper ? helper.schema.string().optional() : { type: "string" },
|
|
472
|
+
},
|
|
473
|
+
async execute(args, context) {
|
|
474
|
+
bumpActivity(context, context.sessionID);
|
|
475
|
+
const checkId = args.checkId;
|
|
476
|
+
const rid = (args && (args.runId || args.run)) || null;
|
|
477
|
+
const { entries, exact } = buildCandidates(context, rid);
|
|
478
|
+
// single authoritative run, resolved the SAME way the control plane resolves it; state -> code.
|
|
479
|
+
const rr = C.resolveVerification({ entries, explicitRunId: rid });
|
|
480
|
+
if (!rr.ok) return JSON.stringify({ ok: false, error: rr.code, state: rr.state || null, runId: rr.runId || rid || null, ids: rr.ids || undefined, detail: rr.detail || "no eligible active run to verify", ...(rid && !exact ? { discovery: runDiscovery(entries) } : {}) }, null, 2);
|
|
481
|
+
const entry = rr.entry || {}; const run = rr.run; const rkey = entry.key;
|
|
482
|
+
const catalogue = entry.cat || run.checkCatalogue || {};
|
|
483
|
+
const cwd = context.directory || context.worktree || entry.dir;
|
|
484
|
+
const check = catalogue[checkId];
|
|
485
|
+
if (!check || !Array.isArray(check.command)) return JSON.stringify({ ok: false, error: "undeclared_check", detail: "not in the resolved run's declared catalogue (no shell bypass)" });
|
|
486
|
+
const store = new C.Store(SD), negative = args.mode === "negative";
|
|
487
|
+
let fixture;
|
|
488
|
+
if (negative) {
|
|
489
|
+
if (!C.evidence.negativeControlAllowed(check)) return JSON.stringify({ ok: false, error: "not_eligible", detail: "negative controls are for declared eligible checks" });
|
|
490
|
+
if (!args.fixture || !fs.existsSync(args.fixture)) return JSON.stringify({ ok: false, error: "no_fixture", detail: "provide a physically isolated fixture" });
|
|
491
|
+
const isolated = C.validateNegativeFixture(args.fixture, [run.directory, entry.dir, context.directory, context.worktree]);
|
|
492
|
+
if (!isolated.ok) return JSON.stringify(isolated);
|
|
493
|
+
fixture = isolated.fixture;
|
|
494
|
+
}
|
|
495
|
+
const projectRoot = run.directory || entry.dir || cwd;
|
|
496
|
+
const fpBefore = C.sourceFingerprint(cwd || entry.dir);
|
|
497
|
+
const ranInProject = projectRoot && path.resolve(cwd || "") === path.resolve(projectRoot);
|
|
498
|
+
const fpScope = ranInProject ? "project" : "copy";
|
|
499
|
+
const targetFingerprint = negative ? C.sourceFingerprint(fixture).hash : null;
|
|
500
|
+
const productionBefore = negative ? C.sourceFingerprint(projectRoot).hash : null;
|
|
501
|
+
const token = crypto.randomUUID();
|
|
502
|
+
const reservation = store.mutate(rkey, latest => {
|
|
503
|
+
const category = C.runVerifyCategory(latest);
|
|
504
|
+
if (!category.eligible) return { error: category.code, state: latest.status };
|
|
505
|
+
const active = latest.execution?.inFlight;
|
|
506
|
+
if (active) {
|
|
507
|
+
if (C.execution.alive(active.ownerPid) || C.execution.alive(active.executorPid) || C.execution.ownedWorkAlive(active.childPid)) return { error: "VERIFY_IN_FLIGHT", execution: active, detail: "the recorded owner or owned check is still live; do not launch a competing check" };
|
|
508
|
+
return { error: "EXECUTION_RECOVERY_REQUIRED", execution: active, detail: "owner/executor/owned check are absent; call longrun action=reconcile with this runId before retrying, never infer a PASS" };
|
|
509
|
+
}
|
|
510
|
+
const guard = C.execution.budgetGuard(latest, { fingerprint: fpBefore, mode: args.mode, canCount: ranInProject });
|
|
511
|
+
if (!guard.ok) {
|
|
512
|
+
const paused = C.pauseRun(latest);
|
|
513
|
+
// Store.mutate commits only non-error callback results. Commit the
|
|
514
|
+
// canonical pause before exposing the refusal; never reserve a check
|
|
515
|
+
// or depend on the model to stop after it receives the budget error.
|
|
516
|
+
return { refused: { error: "BUDGET_EXHAUSTED", state: paused.state,
|
|
517
|
+
cancelledContinuations: paused.cancelledContinuations, spent: guard.spent, usage: guard.usage,
|
|
518
|
+
detail: `Run paused; no check launched or usage added. Preserve the original limits and evidence; do not bypass through another tool/session or a replacement run. ${guard.note}` } };
|
|
519
|
+
}
|
|
520
|
+
const usage = C.execution.initialize(latest);
|
|
521
|
+
usage.commandAttempts++;
|
|
522
|
+
usage.inFlight = { token, ownerPid: process.pid, childPid: null, sessionID: context.sessionID,
|
|
523
|
+
checkId, declaredCheck: structuredClone(check), mode: negative ? "negative" : "normal", startedAt: Date.now(), generation: latest.controlGeneration || 0 };
|
|
524
|
+
return { guard, generation: latest.controlGeneration || 0 };
|
|
525
|
+
});
|
|
526
|
+
if (reservation.error) return JSON.stringify({ ok: false, runId: run.runId, ...reservation });
|
|
527
|
+
if (reservation.refused) return JSON.stringify({ ok: false, runId: run.runId, ...reservation.refused });
|
|
528
|
+
const worker = await C.execution.runDurableCheck({ stateDir: SD, runKey: rkey, token, runId: run.runId, checkId,
|
|
529
|
+
cwd: cwd || entry.dir, projectRoot, fixture, fpBefore, fpScope, targetFingerprint, productionBefore,
|
|
530
|
+
evidenceClass: C.defaultEvidenceClass(run, checkId, args.evidenceClass), timeout: C.execution.timeoutFor(check, reservation.guard),
|
|
531
|
+
deadlineAt: C.execution.timing(run).deadlineAt,
|
|
532
|
+
}, { env: sanitizeEnv(), signal: context.abort,
|
|
533
|
+
onExecutor: pid => store.mutate(rkey, latest => {
|
|
534
|
+
if (latest.execution?.inFlight?.token !== token) return { error: "RESERVATION_CHANGED" };
|
|
535
|
+
latest.execution.inFlight.executorPid = pid || null; return { ok: true };
|
|
536
|
+
}),
|
|
537
|
+
});
|
|
538
|
+
const journal = store.readJSON(rkey, `execution-${token}.json`);
|
|
539
|
+
if (!journal) return JSON.stringify({ ok: false, error: "EXECUTION_RECORD_MISSING", runId: run.runId, executionToken: token,
|
|
540
|
+
worker, detail: "The executor left no completed evidence. Reservation retained; inspect/reconcile, never infer a result or rerun concurrently." });
|
|
541
|
+
const { receipt, negativeControl, fingerprintAfter: fp } = journal;
|
|
542
|
+
let committed;
|
|
543
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
544
|
+
committed = store.mutate(rkey, latest => C.commitExecutionResult(latest, journal));
|
|
545
|
+
if (committed.error !== "STATE_BUSY") break;
|
|
546
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
547
|
+
}
|
|
548
|
+
if (!committed?.ok) return JSON.stringify({ ok: false, error: "RESULT_COMMIT_PENDING", runId: run.runId, executionToken: token, detail: "actual execution evidence saved; call longrun action=reconcile with this runId; do not rerun the command", cause: committed?.error });
|
|
549
|
+
if (negative) return JSON.stringify({ ...negativeControl, runId: run.runId, executionUsage: committed.usage,
|
|
550
|
+
detail: negativeControl.ok ? "Fixture exited nonzero; review output for the intended assertion failure." : "Expected defect detection was not demonstrated; inspect execution details." });
|
|
551
|
+
return JSON.stringify({ status: receipt.status, statusIfStale: C.resolveReceiptStatus(receipt, fp.hash), fpScope,
|
|
552
|
+
runId: run.runId, candidateCounted: committed.apply.candidate.counted, candidateCount: committed.candidateCount,
|
|
553
|
+
gap: committed.apply.gaps, outputTail: receipt.outputTail, receipt: C.checkDiagnostics(store.readJSON(rkey, "run.json"), checkId, fp).historicalReceipts.at(-1), executionUsage: committed.usage });
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const result = {
|
|
558
|
+
tool: { longrun: longrunTool, longrun_verify: verifyTool },
|
|
559
|
+
|
|
560
|
+
"tool.execute.before": async (call, output) => {
|
|
561
|
+
if (!SD) return; // no configured store outside an actual host/test context
|
|
562
|
+
readProj(SD); // corrupt routing cannot silently disable the recovery guard
|
|
563
|
+
const entry = readRuns(SD)[call.sessionID];
|
|
564
|
+
let run;
|
|
565
|
+
if (entry?.runKey) {
|
|
566
|
+
// Store.readJSON deliberately has a fallback for other callers. Admission
|
|
567
|
+
// cannot treat missing/corrupt canonical bytes as an untracked session.
|
|
568
|
+
try {
|
|
569
|
+
run = JSON.parse(fs.readFileSync(path.join(SD, "state", entry.runKey, "run.json"), "utf8"));
|
|
570
|
+
if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error("invalid canonical record");
|
|
571
|
+
} catch (error) {
|
|
572
|
+
throw new RoutingError(`bound canonical record ${entry.runKey} unreadable (${error.code || error.name}); preserve it and recover storage before execution`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const hostDirectories = [input?.directory, input?.worktree].filter(Boolean);
|
|
576
|
+
if (run && hostDirectories.length) {
|
|
577
|
+
const canonicalDirectory = run.directory || entry.directory;
|
|
578
|
+
if (!canonicalDirectory || !hostDirectories.some(dir => C.projectIdentity(dir).id === C.projectIdentity(canonicalDirectory).id)) run = null;
|
|
579
|
+
}
|
|
580
|
+
if (!run && hostDirectories.length) {
|
|
581
|
+
// A fresh host session must not evade the project's deadline merely by
|
|
582
|
+
// lacking a binding. Use the real factory directory and canonical discovery,
|
|
583
|
+
// not model-supplied tool arguments or another project's cached metadata.
|
|
584
|
+
const { entries } = buildCandidates({ directory: input.directory, worktree: input.worktree, sessionID: call.sessionID }, null);
|
|
585
|
+
const open = entries.filter(e => !C.RUN_TERMINAL_STATES.includes(e.run.status));
|
|
586
|
+
const expired = open.find(e => C.execution.timing(e.run).expired);
|
|
587
|
+
run = expired?.run || C.pickCurrentRun(open)?.run;
|
|
588
|
+
}
|
|
589
|
+
if (!run) return;
|
|
590
|
+
const timing = C.execution.timing(run);
|
|
591
|
+
if (timing.expired) {
|
|
592
|
+
const action = output?.args?.action;
|
|
593
|
+
const bookkeeping = ["help", "status", "receipts", "next", "resume-context", "verify", "checkpoint", "pause", "cancel", "complete", "reconcile", "resume", "memory_status"];
|
|
594
|
+
const nativeControl = call.tool === "longrun" && (bookkeeping.includes(action) || (action === "start" && C.RUN_TERMINAL_STATES.includes(run.status)));
|
|
595
|
+
// The verifier remains callable so it can return its structured refusal;
|
|
596
|
+
// it already rechecks admission under the run's writer lock. Control-plane
|
|
597
|
+
// resume also keeps its own budget check. No counter or receipt is changed here.
|
|
598
|
+
if (!nativeControl && call.tool !== "longrun_verify") throw new Error(`LONGRUN_DEADLINE_EXPIRED: run ${run.runId}; deadline ${new Date(timing.deadlineAt).toISOString()}, observed ${new Date(timing.observedAt).toISOString()}. Stop implementation and checks; use native longrun status/checkpoint/pause. Do not bypass through another tool/session, reset budgets or create a replacement run. This admission guard cannot stop an ordinary tool already executing.`);
|
|
599
|
+
}
|
|
600
|
+
if (C.isVerifyEligible(run.status)) return;
|
|
601
|
+
// The canonical lifecycle, not a cached active flag or a model's summary,
|
|
602
|
+
// controls ordinary implementation too. Reads and native recovery remain
|
|
603
|
+
// available; memory regeneration is a write even though it is a native tool.
|
|
604
|
+
const readsAndControl = ["read", "glob", "grep", "list", "skill", "question", "todowrite", "longrun", "longrun_verify"];
|
|
605
|
+
const memoryWrite = call.tool === "longrun" && ["memory_init", "memory_refresh"].includes(output?.args?.action);
|
|
606
|
+
if (readsAndControl.includes(call.tool) && !memoryWrite) return;
|
|
607
|
+
if (run.status === "RECOVERY_REQUIRED") throw new Error(`LONGRUN_RECOVERY_REQUIRED: run ${run.runId}; call longrun action=resume-context with this runId, then authorized action=resume before execution or edits. Do not bypass through bash, another tool or a replacement run.`);
|
|
608
|
+
const category = C.runVerifyCategory(run);
|
|
609
|
+
throw new Error(`LONGRUN_${category.code}: run ${run.runId} is ${run.status}; ordinary execution, edits and memory writes are stopped. Native status/resume-context/checkpoint remain available. Resume the SAME nonterminal run only for authorized work; terminal runs remain terminal. Do not bypass through another tool or session.`);
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
event: async ({ event }) => {
|
|
613
|
+
if (!event || !event.properties) return; const sid = event.properties.sessionID; if (!sid) return;
|
|
614
|
+
const e = runFor(SD, sid); if (!e || e.paused) return;
|
|
615
|
+
if (event.type === "session.compacted") {
|
|
616
|
+
return recordCompaction(e, sid);
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
"experimental.session.compacting": async (input, output) => {
|
|
621
|
+
const e = runFor(SD, input.sessionID); if (!e) return;
|
|
622
|
+
const store = new C.Store(SD); const run = store.readJSON(e.runKey, "run.json"); if (!run) return;
|
|
623
|
+
const dir = e.directory || run.directory; if (!dir) return;
|
|
624
|
+
// Compaction needs the same current-source view as explicit recovery. The run's
|
|
625
|
+
// persisted fingerprint describes its last verification, not necessarily today's files.
|
|
626
|
+
const view = C.deriveRunView(run, { currentFingerprint: C.sourceFingerprint(dir),
|
|
627
|
+
projectMemoryStatus: C.memory.assessStaleness(dir, C.memory.readMemoryIndex(dir)) });
|
|
628
|
+
const preface = `## Long-run recovery\nPRE-COMPACTION SNAPSHOT: the state below precedes the completed-compaction event. After compaction, first call longrun action=resume-context with runId=${run.runId}, then, only for authorized active work, action=resume for the SAME run before execution or edits. Do not infer continued IMPLEMENTING from this snapshot. Paused/terminal runs stay stopped; budget refusals are not permission to bypass. Preserve this recovery instruction in the summary.\n`;
|
|
629
|
+
const { packet } = C.buildRecoveryPacket(run, 1500 - preface.split(/\s+/).filter(Boolean).length, view);
|
|
630
|
+
if (Array.isArray(output.context)) output.context.push(preface + packet); // supplement, do not replace the default prompt
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
"experimental.compaction.autocontinue": async (input, output) => {
|
|
634
|
+
let e;
|
|
635
|
+
try { readProj(SD); e = readRuns(SD)[input.sessionID]; }
|
|
636
|
+
catch (error) { if (error instanceof RoutingError) { if (output) output.enabled = false; return; } throw error; }
|
|
637
|
+
if (!e) return;
|
|
638
|
+
if (e.paused || e.disabled) { if (output) output.enabled = false; return; }
|
|
639
|
+
// This is OpenCode's continuation of an already active user turn, not the
|
|
640
|
+
// Longrun scheduler (which remains OFF). Preserve another plugin's veto.
|
|
641
|
+
// Repeat the idempotent transition here in case event delivery was delayed
|
|
642
|
+
// or its writer lock stayed busy. Uncertain state must not start more work.
|
|
643
|
+
const recorded = await recordCompaction(e, input.sessionID);
|
|
644
|
+
if (!recorded?.ok || recorded.staleSession) { if (output) output.enabled = false; return; }
|
|
645
|
+
const store = new C.Store(SD);
|
|
646
|
+
const checked = store.mutate(e.runKey, latest => {
|
|
647
|
+
// Legacy runs without this explicit start/resume binding need an authorized
|
|
648
|
+
// resume before continuing; never guess an owner from cached routing flags.
|
|
649
|
+
if (latest.compactionSessionID !== input.sessionID || latest.status !== "RECOVERY_REQUIRED" || latest.execution?.inFlight) return { allowed: false };
|
|
650
|
+
let fingerprint;
|
|
651
|
+
try { fingerprint = C.sourceFingerprint(latest.directory || e.directory); }
|
|
652
|
+
catch { return { allowed: false }; }
|
|
653
|
+
const guard = C.execution.budgetGuard(latest, { fingerprint });
|
|
654
|
+
return { allowed: guard.ok };
|
|
655
|
+
});
|
|
656
|
+
if (!checked.allowed && output) output.enabled = false;
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const executeControl = longrunTool.execute;
|
|
661
|
+
longrunTool.execute = async (args, context) => {
|
|
662
|
+
try {
|
|
663
|
+
if (["start", "resume", "pause", "cancel", "complete"].includes(args?.action)) {
|
|
664
|
+
return await withRoutingLock(SD, () => executeControl(args, context));
|
|
665
|
+
}
|
|
666
|
+
return await executeControl(args, context);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (error instanceof RoutingError || ["ROUTING_BUSY", "ROUTING_STORE_ERROR"].includes(error.code)) return JSON.stringify({ error: error.code, detail: error.message });
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
if (result.tool && result.tool.longrun && result.tool.longrun_verify) { try { writeLoadRecord(input || {}); } catch {} }
|
|
674
|
+
return result;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
export default { id: "longrun", server };
|