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,135 @@
|
|
|
1
|
+
// Long-run Harness — evidence-strength model + precise candidate accounting + negative controls.
|
|
2
|
+
// Dependency-free (node builtins). Copied into the built install and imported by controller.js.
|
|
3
|
+
// This is the EVIDENCE (plane C) + candidate-accounting logic. It models WHAT a piece of evidence
|
|
4
|
+
// can prove, not the command execution itself (that lives in the controller/plugin).
|
|
5
|
+
|
|
6
|
+
export const EVIDENCE_CLASSES = ["STATIC", "UNIT", "INTEGRATION", "SYSTEM", "BROWSER", "VISION", "HUMAN/EXTERNAL"];
|
|
7
|
+
// Classes that prove something only exists, never that it is correct or visible.
|
|
8
|
+
export const PROXY_ONLY = new Set([
|
|
9
|
+
"object_exists", "mesh_count", "canvas_nonblank", "dom_exists", "file_exists", "symbol_exists",
|
|
10
|
+
]);
|
|
11
|
+
// Classes that satisfy a "must be visibly distinguishable at runtime" criterion.
|
|
12
|
+
export const RENDER_CLASSES = new Set(["BROWSER", "VISION", "HUMAN/EXTERNAL"]);
|
|
13
|
+
export const VISUAL_REQUIRED = "VISUAL"; // marker used when a criterion is visual
|
|
14
|
+
|
|
15
|
+
export function rank(cls) { const i = EVIDENCE_CLASSES.indexOf(cls); return i < 0 ? -1 : i; }
|
|
16
|
+
export function isKnownClass(cls) { return EVIDENCE_CLASSES.includes(cls); }
|
|
17
|
+
|
|
18
|
+
// ---- Evidence strength: can a receipt of class `provided` satisfy a criterion requiring
|
|
19
|
+
// `required`? A criterion may require a specific class (evidenceClass) or a set (requiresEvidence).
|
|
20
|
+
// Rules:
|
|
21
|
+
// * A criterion with NO declared required class is class-agnostic (back-compat with v1.1.2): a
|
|
22
|
+
// PASS is enough on class grounds (staleness/counts are enforced in the controller).
|
|
23
|
+
// * Proxy-only evidence (object/mesh/canvas/DOM existence) NEVER satisfies a VISUAL/render class.
|
|
24
|
+
// * A provided class weaker than the highest required class is WEAK_EVIDENCE (rejected).
|
|
25
|
+
// * Non-visual UNIT/INTEGRATION criteria do NOT require vision.
|
|
26
|
+
export function classSatisfies(provided, criterion = {}) {
|
|
27
|
+
const req = new Set();
|
|
28
|
+
if (criterion.evidenceClass) req.add(criterion.evidenceClass);
|
|
29
|
+
for (const r of criterion.requiresEvidence || []) req.add(r);
|
|
30
|
+
const visual = criterion.visual === true || RENDER_CLASSES.has(criterion.evidenceClass) ||
|
|
31
|
+
(criterion.requiresEvidence || []).some((r) => RENDER_CLASSES.has(r)) || req.has(VISUAL_REQUIRED);
|
|
32
|
+
|
|
33
|
+
if (provided && PROXY_ONLY.has(provided) && visual) {
|
|
34
|
+
return { satisfied: false, kind: "PROXY_INSUFFICIENT", note: `${provided} (existence-only) cannot prove a VISUAL/render requirement` };
|
|
35
|
+
}
|
|
36
|
+
if (req.size === 0) {
|
|
37
|
+
// No class gate; but a proxy must never satisfy a VISUAL-marked criterion.
|
|
38
|
+
if (visual && provided && rank(provided) < rank("BROWSER")) {
|
|
39
|
+
return { satisfied: false, kind: "PROXY_INSUFFICIENT", note: `visual criterion needs BROWSER/VISION/HUMAN, got ${provided}` };
|
|
40
|
+
}
|
|
41
|
+
return { satisfied: true, kind: "CLASS_AGNOSTIC" };
|
|
42
|
+
}
|
|
43
|
+
// Class-gated: the provided class must be one of the accepted classes AND at least as strong as
|
|
44
|
+
// the strongest required class (so a UNIT receipt cannot satisfy a SYSTEM/BROWSER requirement).
|
|
45
|
+
const maxReq = Math.max(...[...req].filter((r) => isKnownClass(r)).map((r) => rank(r)));
|
|
46
|
+
if (provided && req.has(provided)) {
|
|
47
|
+
if (maxReq >= 0 && rank(provided) < maxReq) return { satisfied: false, kind: "WEAK_EVIDENCE", note: `${provided} weaker than required (needs >= ${EVIDENCE_CLASSES[maxReq]})` };
|
|
48
|
+
return { satisfied: true, kind: "MATCH" };
|
|
49
|
+
}
|
|
50
|
+
if (provided && rank(provided) >= 0) {
|
|
51
|
+
if (rank(provided) < maxReq) return { satisfied: false, kind: "WEAK_EVIDENCE", note: `${provided} weaker than required class ${EVIDENCE_CLASSES[maxReq]}` };
|
|
52
|
+
return { satisfied: false, kind: "WRONG_CLASS", note: `${provided} is not an accepted class (${[...req].join("/")})` };
|
|
53
|
+
}
|
|
54
|
+
return { satisfied: false, kind: "UNKNOWN_CLASS", note: `unrecognised evidence class: ${provided}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Outstanding evidence gaps for a criterion: which required classes have no satisfying receipt.
|
|
58
|
+
export function evidenceGap(criterion, receipts = []) {
|
|
59
|
+
const req = [...new Set([criterion.evidenceClass, ...(criterion.requiresEvidence || [])].filter(Boolean))];
|
|
60
|
+
if (req.length === 0 && !criterion.visual) return { gap: false, missing: [] };
|
|
61
|
+
const missing = req.filter((r) => !receipts.some((x) => x.status === "PASS" && classSatisfies(x.evidenceClass || x.class, criterion).satisfied && (x.evidenceClass || x.class) === r));
|
|
62
|
+
if (criterion.visual && missing.length === 0 && !receipts.some((x) => x.status === "PASS" && RENDER_CLASSES.has(x.evidenceClass || x.class))) missing.push(VISUAL_REQUIRED);
|
|
63
|
+
return { gap: missing.length > 0, missing };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- Precise candidate accounting ----------------------------------------------------------
|
|
67
|
+
// A candidate = a DISTINCT relevant source state that receives an evaluation capable of changing
|
|
68
|
+
// acceptance status/loss. NOT counted: every tool call, every screenshot, every test command,
|
|
69
|
+
// unchanged-source retries, pure inspection. The ledger lives in run.state.candidates so it
|
|
70
|
+
// survives compaction/restart/resume/replan and is NEVER reset by a new conversation.
|
|
71
|
+
export function considerCandidate(run, evt) {
|
|
72
|
+
const st = (run.state = run.state || {});
|
|
73
|
+
st.candidates = st.candidates || [];
|
|
74
|
+
const fingerprint = evt.fingerprint || null;
|
|
75
|
+
const evaluated = evt.evaluated === true && evt.diagnosticOnly !== true;
|
|
76
|
+
const number = st.candidates.length + 1;
|
|
77
|
+
const changeable = evt.statusChanged === true || evt.canChangeAcceptance === true;
|
|
78
|
+
let counted = false, reason;
|
|
79
|
+
if (!evt.evaluated) { reason = "inspection_not_evaluation"; }
|
|
80
|
+
else if (evt.diagnosticOnly) { reason = "diagnostic_only"; }
|
|
81
|
+
else if (fingerprint && (fingerprint === st.lastEvalFingerprint || (evt.fingerprintAliases || []).includes(st.lastEvalFingerprint) || st.candidates.some(c => c.counted && c.fingerprint === fingerprint))) { reason = "unchanged_source_retry"; }
|
|
82
|
+
else if (!changeable) { reason = "no_status_change_capability"; }
|
|
83
|
+
else { counted = true; reason = "evaluated_new_source_state"; }
|
|
84
|
+
const candidate = {
|
|
85
|
+
n: number, runId: run.runId || null, fingerprint, ts: Date.now(),
|
|
86
|
+
hypothesis: evt.hypothesis || null, receiptIds: evt.receiptIds || [],
|
|
87
|
+
criteriaChanged: evt.criteriaChanged || [], lossBefore: evt.lossBefore ?? null,
|
|
88
|
+
lossAfter: evt.lossAfter ?? null, bestLoss: st.best ? st.best.loss ?? st.best.lossAfter ?? null : null,
|
|
89
|
+
result: evt.result || null, diagnosticOnly: !!evt.diagnosticOnly,
|
|
90
|
+
elapsedMs: evt.elapsedMs || 0, counted, reason,
|
|
91
|
+
};
|
|
92
|
+
if (evaluated && fingerprint && (counted || reason === "unchanged_source_retry")) {
|
|
93
|
+
if (!counted && fingerprint !== st.lastEvalFingerprint && (evt.fingerprintAliases || []).includes(st.lastEvalFingerprint)) {
|
|
94
|
+
st.fingerprintMigrations = st.fingerprintMigrations || [];
|
|
95
|
+
st.fingerprintMigrations.push({ previous: st.lastEvalFingerprint, current: fingerprint, reason: "observed_same_source_under_new_fingerprint_policy" });
|
|
96
|
+
}
|
|
97
|
+
st.lastEvalFingerprint = fingerprint;
|
|
98
|
+
}
|
|
99
|
+
if (counted) {
|
|
100
|
+
st.candidates.push(candidate);
|
|
101
|
+
if (candidate.result === "PASS" && (!st.best || (candidate.lossAfter != null && candidate.lossAfter <= (st.best.loss ?? st.best.lossAfter ?? Infinity)))) st.best = candidate;
|
|
102
|
+
st.current = candidate;
|
|
103
|
+
}
|
|
104
|
+
return { counted, reason, candidate };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function candidateCount(run) {
|
|
108
|
+
const st = (run && run.state) || {};
|
|
109
|
+
return (st.candidates || []).filter((c) => c.counted).length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Negative controls (verify the verifiers) ----------------------------------------------
|
|
113
|
+
// Records that a KNOWN-BROKEN state makes a protected verifier fail, proving the verifier is
|
|
114
|
+
// live (not self-satisfied). Recorded OUTSIDE the criteria/gates, separately, and NEVER allowed
|
|
115
|
+
// to mutate the user's active source. The plugin runs these against isolated fixtures / temp
|
|
116
|
+
// copies with cwd in the copy, so the active worktree is untouched.
|
|
117
|
+
export const NEGATIVE_PRIORITY = ["hard_gate", "missed_bug", "integration", "visual", "security", "determinism"];
|
|
118
|
+
export function negativeControlAllowed(check = {}) {
|
|
119
|
+
// Prioritise gates + historically-missed + complex integration + visual + security + determinism.
|
|
120
|
+
if (check.negativeControl === false) return false;
|
|
121
|
+
if (check.gate) return true;
|
|
122
|
+
if (check.kind === "test" && (check.integration || check.visual || check.security || check.determinism || check.historicalMiss)) return true;
|
|
123
|
+
if (check.negativeControl === true) return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
export function makeNegativeControl({ checkId, mode = "isolation", fixture, targetFingerprint, expected = "FAIL", observed, ok, mutatedProduction = false, command = null, exitCode = null, signal = null, error = null, startedAt = null, finishedAt = null, outputTail = null, productionFingerprintBefore = null, productionFingerprintAfter = null }) {
|
|
127
|
+
const executed = !error && !signal && (observed === "PASS" || observed === "FAIL");
|
|
128
|
+
return {
|
|
129
|
+
kind: "negative_control", checkId, mode, fixture: fixture || null, targetFingerprint: targetFingerprint || null,
|
|
130
|
+
expected, observed, ok: !!ok && executed && !mutatedProduction, mutatedProduction: !!mutatedProduction, at: Date.now(),
|
|
131
|
+
command, exitCode, signal, error, startedAt, finishedAt, outputTail, productionFingerprintBefore, productionFingerprintAfter,
|
|
132
|
+
// A negative control that mutated the active source is INVALID evidence (must be isolated).
|
|
133
|
+
valid: !mutatedProduction && executed,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Declared-check execution accounting and owned, cancellable subprocesses.
|
|
2
|
+
// This measures checks, not unobserved model thinking or arbitrary host tools.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { candidateCount } from './evidence.mjs';
|
|
6
|
+
|
|
7
|
+
export function alive(pid) {
|
|
8
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
9
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Read-only check of the owned child and its POSIX process group. The host can
|
|
13
|
+
// remain alive after a failed commit; host liveness alone does not imply work.
|
|
14
|
+
export function ownedWorkAlive(pid) {
|
|
15
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
16
|
+
if (alive(pid)) return true;
|
|
17
|
+
if (process.platform === 'win32') return false;
|
|
18
|
+
try { process.kill(-pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function usage(run) {
|
|
22
|
+
if (run.execution) return { ...run.execution, candidateCount: candidateCount(run), scope: 'declared_check_execution' };
|
|
23
|
+
const records = [...(run.receipts || []), ...(run.evidence || []).filter(x => x.kind === 'negative_control')];
|
|
24
|
+
const intervals = records.filter(r => Number.isFinite(r.startedAt) && Number.isFinite(r.finishedAt) && r.finishedAt >= r.startedAt)
|
|
25
|
+
.map(r => [r.startedAt, r.finishedAt]).sort((a, b) => a[0] - b[0]);
|
|
26
|
+
let verificationMs = 0, end = -Infinity;
|
|
27
|
+
for (const [start, finish] of intervals) { verificationMs += Math.max(0, finish - Math.max(start, end)); end = Math.max(end, finish); }
|
|
28
|
+
return { schemaVersion: 1, since: null, historicalUsageUnknown: true, verificationMs,
|
|
29
|
+
commandAttempts: records.length, inFlight: null, candidateCount: candidateCount(run), scope: 'declared_check_execution' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function initialize(run, now = Date.now()) {
|
|
33
|
+
if (!run.execution) {
|
|
34
|
+
const { candidateCount: ignored, scope, ...previous } = usage(run);
|
|
35
|
+
run.execution = { ...previous, since: now };
|
|
36
|
+
}
|
|
37
|
+
return run.execution;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function newCandidate(run, fingerprint) {
|
|
41
|
+
const hash = fingerprint?.hash || fingerprint;
|
|
42
|
+
const aliases = [hash, fingerprint?.legacyHash].filter(Boolean), state = run.state || {};
|
|
43
|
+
return !aliases.includes(state.lastEvalFingerprint) && !(state.candidates || []).some(c => c.counted && aliases.includes(c.fingerprint));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const validTime = value => Number.isFinite(value) && Math.abs(value) <= 8640000000000000;
|
|
47
|
+
|
|
48
|
+
// The ORIGINAL declared boundary: creation time + declared duration. An operator amendment never
|
|
49
|
+
// rewrites those fields or this derivation, so the original limit stays auditable forever.
|
|
50
|
+
export function originalTiming(run, now = Date.now()) {
|
|
51
|
+
const startedAt = validTime(run.createdAt) ? run.createdAt : null;
|
|
52
|
+
const seconds = run.budget?.deadlineSeconds;
|
|
53
|
+
const value = startedAt !== null && Number.isFinite(seconds) && seconds >= 0 ? startedAt + seconds * 1000 : null;
|
|
54
|
+
const deadlineAt = validTime(value) ? value : null;
|
|
55
|
+
return { observedAt: now, startedAt, deadlineAt,
|
|
56
|
+
remainingMs: deadlineAt === null ? null : Math.max(0, deadlineAt - now),
|
|
57
|
+
expired: deadlineAt === null ? null : now >= deadlineAt,
|
|
58
|
+
scope: 'absolute_wall_clock_since_run_creation' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Append-only operator-authorized absolute deadline (run.budgetAmendments). The highest value wins,
|
|
62
|
+
// so a later grant may extend but never shorten the effective boundary. The original budget fields
|
|
63
|
+
// and the original deadline are never mutated.
|
|
64
|
+
export function grantedDeadlineAt(run) {
|
|
65
|
+
const values = (Array.isArray(run.budgetAmendments) ? run.budgetAmendments : [])
|
|
66
|
+
.map(a => a && a.newDeadlineAt).filter(validTime);
|
|
67
|
+
return values.length ? Math.max(...values) : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Effective candidate limit = original declared limit + finite authorized allowance. Usage counters
|
|
71
|
+
// are never reset by a grant; the original run.budget.iterations is untouched.
|
|
72
|
+
export function effectiveIterations(run) {
|
|
73
|
+
const base = Number.isFinite(run.budget?.iterations) ? run.budget.iterations : 40;
|
|
74
|
+
const extra = (Array.isArray(run.budgetAmendments) ? run.budgetAmendments : [])
|
|
75
|
+
.reduce((n, a) => n + (a && Number.isInteger(a.additionalCandidates) && a.additionalCandidates > 0 ? a.additionalCandidates : 0), 0);
|
|
76
|
+
return base + extra;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A fresh observation, never persisted usage or a replacement for missing legacy
|
|
80
|
+
// timestamps. Share the absolute wall-clock boundary across readouts and admission.
|
|
81
|
+
// Shape stability: a run with no operator amendment returns EXACTLY the original object, so
|
|
82
|
+
// existing readouts/consumers are unchanged. The original/granted split appears only once a grant
|
|
83
|
+
// actually exists.
|
|
84
|
+
export function timing(run, now = Date.now()) {
|
|
85
|
+
const base = originalTiming(run, now);
|
|
86
|
+
const granted = grantedDeadlineAt(run);
|
|
87
|
+
if (granted === null) return base;
|
|
88
|
+
const deadlineAt = base.deadlineAt === null ? granted : Math.max(base.deadlineAt, granted);
|
|
89
|
+
return { ...base, deadlineAt, originalDeadlineAt: base.deadlineAt, grantedDeadlineAt: granted,
|
|
90
|
+
remainingMs: deadlineAt === null ? null : Math.max(0, deadlineAt - now),
|
|
91
|
+
expired: deadlineAt === null ? null : now >= deadlineAt,
|
|
92
|
+
scope: 'absolute_wall_clock_with_operator_amendment' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function budgetGuard(run, { fingerprint, mode = 'normal', now = Date.now(), canCount = true } = {}) {
|
|
96
|
+
const u = usage(run), b = run.budget || {};
|
|
97
|
+
const deadline = timing(run, now).deadlineAt;
|
|
98
|
+
const activeRemaining = Number.isFinite(b.activeSeconds) ? b.activeSeconds * 1000 - u.verificationMs : Infinity;
|
|
99
|
+
const deadlineRemaining = deadline === null ? Infinity : deadline - now;
|
|
100
|
+
const spent = {
|
|
101
|
+
candidates: mode !== 'negative' && canCount && newCandidate(run, fingerprint) && u.candidateCount >= effectiveIterations(run),
|
|
102
|
+
activeSeconds: activeRemaining <= 0,
|
|
103
|
+
deadline: deadlineRemaining <= 0,
|
|
104
|
+
toolActions: u.commandAttempts >= (b.toolActionCap ?? 200),
|
|
105
|
+
};
|
|
106
|
+
return { ok: !Object.values(spent).some(Boolean), spent, usage: u,
|
|
107
|
+
activeRemaining, deadlineRemaining, deadlineKnown: deadline !== null,
|
|
108
|
+
note: u.historicalUsageUnknown ? 'Historical unmetered usage is unknown; retained command times/attempts are lower bounds. Budgets were not reset.' : 'Time/action accounting covers declared check execution; model and other host-tool time is not measured here.' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function timeoutFor(check, guard) {
|
|
112
|
+
const choices = [
|
|
113
|
+
[Number.isFinite(check.timeoutMs) && check.timeoutMs > 0 ? check.timeoutMs : 120000, 'check_timeout'],
|
|
114
|
+
[guard.activeRemaining, 'active_time_budget'], [guard.deadlineRemaining, 'deadline_budget'],
|
|
115
|
+
].sort((a, b) => a[0] - b[0]);
|
|
116
|
+
return { timeoutMs: Math.max(1, Math.ceil(choices[0][0])), timeoutReason: choices[0][1] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Every check gets its own process group on POSIX. Timeout/abort/pause kill only that group.
|
|
120
|
+
// The grace interval is bounded; descendants cannot silently keep the verifier alive.
|
|
121
|
+
export function runCommand(command, { cwd, env, timeoutMs, timeoutReason, signal, shouldStop, onSpawn } = {}) {
|
|
122
|
+
return new Promise(resolve => {
|
|
123
|
+
const startedAt = Date.now(); let child, finished = false, terminationReason = null, error = null;
|
|
124
|
+
let stdout = '', stderr = '', bytes = 0, timer, poll, force;
|
|
125
|
+
const group = process.platform !== 'win32';
|
|
126
|
+
const kill = sig => { try { if (group && child?.pid) process.kill(-child.pid, sig); else child?.kill(sig); } catch {} };
|
|
127
|
+
const stop = reason => {
|
|
128
|
+
if (finished || terminationReason) return;
|
|
129
|
+
terminationReason = reason; kill('SIGTERM');
|
|
130
|
+
force = setTimeout(() => kill('SIGKILL'), 400);
|
|
131
|
+
};
|
|
132
|
+
const abort = () => stop('aborted');
|
|
133
|
+
const groupAlive = () => {
|
|
134
|
+
if (!group || !child?.pid) return false;
|
|
135
|
+
try { process.kill(-child.pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
136
|
+
};
|
|
137
|
+
const finish = async (status, sig) => {
|
|
138
|
+
if (finished) return; finished = true; clearTimeout(timer); clearInterval(poll);
|
|
139
|
+
signal?.removeEventListener('abort', abort);
|
|
140
|
+
// Keep an already scheduled group cleanup even if the root exited before a descendant.
|
|
141
|
+
if (!force) { kill('SIGTERM'); force = setTimeout(() => kill('SIGKILL'), 400); }
|
|
142
|
+
// 'close' only means the root/stdio closed. An ignored-stdio descendant may
|
|
143
|
+
// still be writing source. Keep the reservation until group cleanup settles,
|
|
144
|
+
// then fingerprint/commit in the caller. Fail explicitly if cleanup is uncertain.
|
|
145
|
+
for (let attempt = 0; attempt < 75 && groupAlive(); attempt++) await new Promise(done => setTimeout(done, 20));
|
|
146
|
+
const cleanupComplete = !groupAlive();
|
|
147
|
+
if (!cleanupComplete) { error = error || 'CLEANUP_INCOMPLETE'; terminationReason = terminationReason || 'child_cleanup_failed'; }
|
|
148
|
+
clearTimeout(force);
|
|
149
|
+
resolve({ status, signal: sig, error, stdout, stderr, terminationReason, cleanupComplete, startedAt, finishedAt: Date.now(), pid: child?.pid || null });
|
|
150
|
+
};
|
|
151
|
+
if (signal?.aborted) { terminationReason = 'aborted'; finish(null, null); return; }
|
|
152
|
+
try {
|
|
153
|
+
const initialStop = shouldStop?.();
|
|
154
|
+
if (initialStop) { terminationReason = initialStop; finish(null, null); return; }
|
|
155
|
+
child = spawn(command[0], command.slice(1), { cwd, env, detached: group, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
156
|
+
const collect = (name, data) => {
|
|
157
|
+
bytes += data.length;
|
|
158
|
+
if (bytes > 16 * 1024 * 1024) { error = 'OUTPUT_LIMIT'; stop('output_limit'); return; }
|
|
159
|
+
if (name === 'stdout') stdout += data.toString(); else stderr += data.toString();
|
|
160
|
+
};
|
|
161
|
+
child.stdout.on('data', d => collect('stdout', d)); child.stderr.on('data', d => collect('stderr', d));
|
|
162
|
+
child.on('error', e => { error = e.code || e.message; });
|
|
163
|
+
child.on('exit', () => {
|
|
164
|
+
// A shell can exit while leaving descendants holding stdout open.
|
|
165
|
+
kill('SIGTERM'); if (!force) force = setTimeout(() => kill('SIGKILL'), 400);
|
|
166
|
+
});
|
|
167
|
+
child.on('close', finish);
|
|
168
|
+
// Register handlers before persistence callbacks: a failed callback must not leave
|
|
169
|
+
// an unobserved child or turn a later spawn error into an uncaught exception.
|
|
170
|
+
onSpawn?.(child.pid);
|
|
171
|
+
timer = setTimeout(() => stop(timeoutReason || 'check_timeout'), timeoutMs || 120000);
|
|
172
|
+
if (shouldStop) poll = setInterval(() => {
|
|
173
|
+
try { const reason = shouldStop(); if (reason) stop(reason); }
|
|
174
|
+
catch (e) { error = e.code || e.message; stop('state_read_failed'); }
|
|
175
|
+
}, 50);
|
|
176
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
177
|
+
if (signal?.aborted) abort();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
error = e.code || e.message; stop(child?.pid ? 'setup_error' : 'launch_error');
|
|
180
|
+
if (!child?.pid) finish(null, null); // Otherwise wait for actual process closure.
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// One finite Node worker per declared check, not a server or continuation daemon.
|
|
186
|
+
// IPC ownership loss reaches the worker even if the OpenCode host is SIGKILLed.
|
|
187
|
+
// Node is already required by the installed maintenance launcher and check tooling.
|
|
188
|
+
export function runDurableCheck(job, { env, signal, onExecutor } = {}) {
|
|
189
|
+
return new Promise(resolve => {
|
|
190
|
+
let worker, error = null, stderr = '';
|
|
191
|
+
const abort = () => { if (worker?.connected) worker.send({ type: 'abort' }, () => {}); };
|
|
192
|
+
try {
|
|
193
|
+
worker = spawn('node', [fileURLToPath(new URL('./executor.mjs', import.meta.url))], {
|
|
194
|
+
env, detached: process.platform !== 'win32', stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
195
|
+
});
|
|
196
|
+
worker.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-6000); });
|
|
197
|
+
worker.on('error', e => { error = e.code || e.message; });
|
|
198
|
+
worker.on('close', (code, terminationSignal) => {
|
|
199
|
+
signal?.removeEventListener('abort', abort);
|
|
200
|
+
resolve({ executorPid: worker.pid || null, code, signal: terminationSignal, error, stderr });
|
|
201
|
+
});
|
|
202
|
+
const registered = onExecutor(worker.pid);
|
|
203
|
+
if (registered?.error) {
|
|
204
|
+
error = registered.error; worker.kill('SIGTERM'); return;
|
|
205
|
+
}
|
|
206
|
+
worker.send({ type: 'execute', job, aborted: !!signal?.aborted }, e => {
|
|
207
|
+
if (e) { error = e.code || e.message; worker.kill('SIGTERM'); }
|
|
208
|
+
});
|
|
209
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
210
|
+
if (signal?.aborted) abort();
|
|
211
|
+
} catch (e) {
|
|
212
|
+
error = e.code || e.message;
|
|
213
|
+
if (worker?.pid) worker.kill('SIGTERM');
|
|
214
|
+
else resolve({ executorPid: null, code: null, signal: null, error, stderr });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A finite declared-check executor. It never creates a run, commits a receipt,
|
|
2
|
+
// schedules another check, starts inference, or enables automatic continuation.
|
|
3
|
+
import { executeReservedCheck } from './controller.js';
|
|
4
|
+
let accepted = false, ownerLost = false, aborted = false;
|
|
5
|
+
const idle = setTimeout(() => { process.exitCode = 1; process.disconnect?.(); }, 5000);
|
|
6
|
+
process.on('disconnect', () => {
|
|
7
|
+
ownerLost = true;
|
|
8
|
+
if (!accepted) clearTimeout(idle);
|
|
9
|
+
});
|
|
10
|
+
process.on('message', async message => {
|
|
11
|
+
if (message?.type === 'abort') { aborted = true; return; }
|
|
12
|
+
if (message?.type !== 'execute' || accepted) return;
|
|
13
|
+
accepted = true; clearTimeout(idle); aborted ||= message.aborted === true;
|
|
14
|
+
try {
|
|
15
|
+
await executeReservedCheck(message.job, { ownerLost: () => ownerLost, aborted: () => aborted });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(error?.stack || String(error)); process.exitCode = 1;
|
|
18
|
+
} finally {
|
|
19
|
+
if (process.connected) process.disconnect();
|
|
20
|
+
}
|
|
21
|
+
});
|