@tangle-network/agent-runtime 0.105.0 → 0.106.0
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/README.md +2 -2
- package/dist/activation-CRjVfOiy.js +203 -0
- package/dist/activation-CRjVfOiy.js.map +1 -0
- package/dist/activation-DyWB0K6E.d.ts +98 -0
- package/dist/agent.d.ts +216 -234
- package/dist/agent.js +615 -547
- package/dist/agent.js.map +1 -1
- package/dist/analyst-loop-DGdtVENg.js +517 -0
- package/dist/analyst-loop-DGdtVENg.js.map +1 -0
- package/dist/analyst-loop.d.ts +10 -43
- package/dist/analyst-loop.js +2 -11
- package/dist/backends-Dy8NujB4.js +713 -0
- package/dist/backends-Dy8NujB4.js.map +1 -0
- package/dist/candidate-execution/index.d.ts +4 -192
- package/dist/candidate-execution/index.js +5 -66
- package/dist/candidate-execution-DqMbv1rU.js +488 -0
- package/dist/candidate-execution-DqMbv1rU.js.map +1 -0
- package/dist/coder-yhVWbdWc.js +40 -0
- package/dist/coder-yhVWbdWc.js.map +1 -0
- package/dist/conversation-XucG_K3l.js +1265 -0
- package/dist/conversation-XucG_K3l.js.map +1 -0
- package/dist/conversation.d.ts +2 -804
- package/dist/conversation.js +2 -55
- package/dist/environment-provider-CfLUmKmf.js +1168 -0
- package/dist/environment-provider-CfLUmKmf.js.map +1 -0
- package/dist/environment-provider-IUGU3epE.d.ts +964 -0
- package/dist/environment-provider.d.ts +2 -78
- package/dist/environment-provider.js +2 -18
- package/dist/errors-DEAvWQPy.js +111 -0
- package/dist/errors-DEAvWQPy.js.map +1 -0
- package/dist/improvement-adapter-Cr5TBSji.d.ts +166 -0
- package/dist/improvement-cycle-C095kS_g.js +2624 -0
- package/dist/improvement-cycle-C095kS_g.js.map +1 -0
- package/dist/index-BGdxAwfO.d.ts +838 -0
- package/dist/index-C-FYUuFG.d.ts +193 -0
- package/dist/index-CbNM40xG.d.ts +140 -0
- package/dist/index-DruSKs3Z.d.ts +7224 -0
- package/dist/index-Efjb3nrQ.d.ts +671 -0
- package/dist/index.d.ts +14 -1058
- package/dist/index.js +2106 -2082
- package/dist/index.js.map +1 -1
- package/dist/intelligence.d.ts +768 -889
- package/dist/intelligence.js +1466 -1367
- package/dist/intelligence.js.map +1 -1
- package/dist/{kb-gate-CwHO0vz6.d.ts → kb-gate-C8z2juK8.d.ts} +33 -31
- package/dist/kb-gate-DpaSwXVx.js +80 -0
- package/dist/kb-gate-DpaSwXVx.js.map +1 -0
- package/dist/knowledge-Ku-mPP4f.js +433 -0
- package/dist/knowledge-Ku-mPP4f.js.map +1 -0
- package/dist/knowledge.d.ts +2 -150
- package/dist/knowledge.js +2 -40
- package/dist/local-harness-Dh8PJ0ot.d.ts +455 -0
- package/dist/loop-runner-bin-BXsFEUcf.d.ts +162 -0
- package/dist/loop-runner-bin-BwjQ1NfH.js +268 -0
- package/dist/loop-runner-bin-BwjQ1NfH.js.map +1 -0
- package/dist/loop-runner-bin.d.ts +2 -15
- package/dist/loop-runner-bin.js +2 -24
- package/dist/loops.d.ts +5 -4164
- package/dist/loops.js +8 -419
- package/dist/mcp/bin.d.ts +1 -1
- package/dist/mcp/bin.js +116 -146
- package/dist/mcp/bin.js.map +1 -1
- package/dist/mcp/index.d.ts +295 -436
- package/dist/mcp/index.js +1242 -1177
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/memory-bin.d.ts +1 -1
- package/dist/mcp/memory-bin.js +37 -16
- package/dist/mcp/memory-bin.js.map +1 -1
- package/dist/memory-server-DL6cE2Ag.js +234 -0
- package/dist/memory-server-DL6cE2Ag.js.map +1 -0
- package/dist/model-resolution-Btd9iIKV.js +98 -0
- package/dist/model-resolution-Btd9iIKV.js.map +1 -0
- package/dist/openai-tools-B68JaOCx.d.ts +24 -0
- package/dist/openai-tools-CKLy1C7M.js +44 -0
- package/dist/openai-tools-CKLy1C7M.js.map +1 -0
- package/dist/otel-export-D34IQW1C.js +843 -0
- package/dist/otel-export-D34IQW1C.js.map +1 -0
- package/dist/platform.d.ts +188 -185
- package/dist/platform.js +185 -218
- package/dist/platform.js.map +1 -1
- package/dist/prepare-B53sPV7M.js +4292 -0
- package/dist/prepare-B53sPV7M.js.map +1 -0
- package/dist/primeintellect/index.d.ts +149 -145
- package/dist/primeintellect/index.js +626 -1003
- package/dist/primeintellect/index.js.map +1 -1
- package/dist/profiles.d.ts +296 -490
- package/dist/profiles.js +722 -749
- package/dist/profiles.js.map +1 -1
- package/dist/protected-model-port-BLPH5VVv.js +722 -0
- package/dist/protected-model-port-BLPH5VVv.js.map +1 -0
- package/dist/protected-model-port-BP6Z4eau.d.ts +283 -0
- package/dist/protected-redaction--F3v1oo8.js +200 -0
- package/dist/protected-redaction--F3v1oo8.js.map +1 -0
- package/dist/redact-D-u-rrcn.js +82 -0
- package/dist/redact-D-u-rrcn.js.map +1 -0
- package/dist/redact-kbIOjfjM.d.ts +452 -0
- package/dist/researcher-CoVqNhfI.js +433 -0
- package/dist/researcher-CoVqNhfI.js.map +1 -0
- package/dist/runtime-BG0BziaP.js +5408 -0
- package/dist/runtime-BG0BziaP.js.map +1 -0
- package/dist/sandbox-events-DeI5xX8P.js +316 -0
- package/dist/sandbox-events-DeI5xX8P.js.map +1 -0
- package/dist/spawn-journal-CwPvKUTa.js +456 -0
- package/dist/spawn-journal-CwPvKUTa.js.map +1 -0
- package/dist/structural-rollout-IXUEplky.js +1445 -0
- package/dist/structural-rollout-IXUEplky.js.map +1 -0
- package/dist/substrate-B0TYNrXn.js +27 -0
- package/dist/substrate-B0TYNrXn.js.map +1 -0
- package/dist/{substrate-Dyiqmd3o.d.ts → substrate-BcnuSHXm.d.ts} +57 -54
- package/dist/supervise-BiRutHS9.js +7321 -0
- package/dist/supervise-BiRutHS9.js.map +1 -0
- package/dist/supervisor-DTKhF-RV.js +3826 -0
- package/dist/supervisor-DTKhF-RV.js.map +1 -0
- package/dist/testing.d.ts +4 -3
- package/dist/testing.js +2478 -2637
- package/dist/testing.js.map +1 -1
- package/dist/tool-server-RcWgLIsL.js +141 -0
- package/dist/tool-server-RcWgLIsL.js.map +1 -0
- package/dist/trace-propagation-CJJC7SVB.js +57 -0
- package/dist/trace-propagation-CJJC7SVB.js.map +1 -0
- package/dist/types-BaIpb0XT.d.ts +492 -0
- package/dist/types-BevOjfTY.d.ts +765 -0
- package/dist/types-C9j4qg6l.d.ts +500 -0
- package/dist/types-Cja5OTGh.d.ts +190 -0
- package/dist/util-MVgdwuIS.js +145 -0
- package/dist/util-MVgdwuIS.js.map +1 -0
- package/dist/workspace-archive-BosQiotA.js +575 -0
- package/dist/workspace-archive-BosQiotA.js.map +1 -0
- package/package.json +43 -56
- package/skills/build-with-agent-runtime/SKILL.md +1 -0
- package/dist/activation-Cm-9MMX1.d.ts +0 -69
- package/dist/analyst-loop.js.map +0 -1
- package/dist/backends-BO4Jqoe2.d.ts +0 -134
- package/dist/candidate-execution/index.js.map +0 -1
- package/dist/chunk-2KGAN2HM.js +0 -1256
- package/dist/chunk-2KGAN2HM.js.map +0 -1
- package/dist/chunk-2VZYRB55.js +0 -834
- package/dist/chunk-2VZYRB55.js.map +0 -1
- package/dist/chunk-3MDZX7YU.js +0 -125
- package/dist/chunk-3MDZX7YU.js.map +0 -1
- package/dist/chunk-5IBQAPVB.js +0 -29
- package/dist/chunk-5IBQAPVB.js.map +0 -1
- package/dist/chunk-5QOB7H74.js +0 -387
- package/dist/chunk-5QOB7H74.js.map +0 -1
- package/dist/chunk-C5RTIBNZ.js +0 -48
- package/dist/chunk-C5RTIBNZ.js.map +0 -1
- package/dist/chunk-CTRA64LY.js +0 -680
- package/dist/chunk-CTRA64LY.js.map +0 -1
- package/dist/chunk-DPEUKJRO.js +0 -27
- package/dist/chunk-DPEUKJRO.js.map +0 -1
- package/dist/chunk-FD2MBMOH.js +0 -494
- package/dist/chunk-FD2MBMOH.js.map +0 -1
- package/dist/chunk-H5QPIZNX.js +0 -907
- package/dist/chunk-H5QPIZNX.js.map +0 -1
- package/dist/chunk-HLKC4UYB.js +0 -3514
- package/dist/chunk-HLKC4UYB.js.map +0 -1
- package/dist/chunk-HNP72PNU.js +0 -192
- package/dist/chunk-HNP72PNU.js.map +0 -1
- package/dist/chunk-ISPWRSEC.js +0 -408
- package/dist/chunk-ISPWRSEC.js.map +0 -1
- package/dist/chunk-KRBFHMV6.js +0 -1228
- package/dist/chunk-KRBFHMV6.js.map +0 -1
- package/dist/chunk-M22PL4GH.js +0 -171
- package/dist/chunk-M22PL4GH.js.map +0 -1
- package/dist/chunk-NURIQBJQ.js +0 -72
- package/dist/chunk-NURIQBJQ.js.map +0 -1
- package/dist/chunk-OPVWXJ2H.js +0 -75
- package/dist/chunk-OPVWXJ2H.js.map +0 -1
- package/dist/chunk-PZZKQVQV.js +0 -52
- package/dist/chunk-PZZKQVQV.js.map +0 -1
- package/dist/chunk-Q2JSAVQ3.js +0 -671
- package/dist/chunk-Q2JSAVQ3.js.map +0 -1
- package/dist/chunk-RDOAVVHY.js +0 -699
- package/dist/chunk-RDOAVVHY.js.map +0 -1
- package/dist/chunk-SGKPNBXE.js +0 -60
- package/dist/chunk-SGKPNBXE.js.map +0 -1
- package/dist/chunk-SMQXZGLZ.js +0 -351
- package/dist/chunk-SMQXZGLZ.js.map +0 -1
- package/dist/chunk-UHEZW5BU.js +0 -2918
- package/dist/chunk-UHEZW5BU.js.map +0 -1
- package/dist/chunk-UPWGXKXB.js +0 -254
- package/dist/chunk-UPWGXKXB.js.map +0 -1
- package/dist/chunk-VISA6CI3.js +0 -200
- package/dist/chunk-VISA6CI3.js.map +0 -1
- package/dist/chunk-WMTCUOQL.js +0 -474
- package/dist/chunk-WMTCUOQL.js.map +0 -1
- package/dist/chunk-WRTOVNN4.js +0 -4728
- package/dist/chunk-WRTOVNN4.js.map +0 -1
- package/dist/chunk-WSTRQZYQ.js +0 -5576
- package/dist/chunk-WSTRQZYQ.js.map +0 -1
- package/dist/chunk-XBG2W2VW.js +0 -6434
- package/dist/chunk-XBG2W2VW.js.map +0 -1
- package/dist/chunk-YEJR7IXO.js +0 -68
- package/dist/chunk-YEJR7IXO.js.map +0 -1
- package/dist/chunk-YJZA2BIK.js +0 -1338
- package/dist/chunk-YJZA2BIK.js.map +0 -1
- package/dist/completion-gate-BMy5LGoP.d.ts +0 -256
- package/dist/conversation.js.map +0 -1
- package/dist/coordination-BZZSVYpZ.d.ts +0 -2570
- package/dist/environment-provider.js.map +0 -1
- package/dist/improvement-adapter-HAZz-7vK.d.ts +0 -203
- package/dist/knowledge.js.map +0 -1
- package/dist/local-harness-BDNcl6jI.d.ts +0 -175
- package/dist/loop-runner-bin-68aoM9-7.d.ts +0 -199
- package/dist/loop-runner-bin.js.map +0 -1
- package/dist/loops.js.map +0 -1
- package/dist/openai-tools-fnj6SRVg.d.ts +0 -46
- package/dist/protected-model-port-B4t-OQjL.d.ts +0 -271
- package/dist/redact-BuFjxBUJ.d.ts +0 -481
- package/dist/sanitize-DEbPNtyI.d.ts +0 -328
- package/dist/structural-rollout-DEh6CEsa.d.ts +0 -446
- package/dist/supervise-4_48wwvy.d.ts +0 -379
- package/dist/types-BCoemcXU.d.ts +0 -802
- package/dist/types-BwoZWq-i.d.ts +0 -510
- package/dist/types-Cbp72qyu.d.ts +0 -491
- package/dist/types-CmYCMbFT.d.ts +0 -203
- package/dist/types-CvNwMzQt.d.ts +0 -957
- package/dist/worktree-fanout-DxhAWr5Z.d.ts +0 -999
|
@@ -0,0 +1,3826 @@
|
|
|
1
|
+
import { c as RuntimeRunStateError, u as ValidationError } from "./errors-DEAvWQPy.js";
|
|
2
|
+
import { f as zeroTokenUsage, t as addTokenUsage } from "./util-MVgdwuIS.js";
|
|
3
|
+
import { a as contentAddress, c as replaySpawnTree, o as materializeTreeView, s as pendingWaits } from "./spawn-journal-CwPvKUTa.js";
|
|
4
|
+
import { estimateCost, isModelPriced } from "@tangle-network/agent-eval";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { applyWorkspacePlan, materializeProfile } from "@tangle-network/agent-profile-materialize";
|
|
7
|
+
import { access, chmod, copyFile, mkdir, mkdtemp, open, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { constants, createReadStream } from "node:fs";
|
|
9
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep, win32 } from "node:path";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { homedir, tmpdir } from "node:os";
|
|
12
|
+
import { stripVTControlCharacters } from "node:util";
|
|
13
|
+
//#region src/mcp/codex-diagnostics.ts
|
|
14
|
+
/** Thrown when reproducible Codex exits without one valid terminal usage event. */
|
|
15
|
+
var CodexExecutionDiagnosticError = class extends Error {
|
|
16
|
+
reason;
|
|
17
|
+
diagnostic;
|
|
18
|
+
code = "CODEX_EXECUTION_DIAGNOSTIC";
|
|
19
|
+
constructor(reason, diagnostic, cause) {
|
|
20
|
+
super(`runLocalHarness: reproducible Codex execution failed: ${reason}; diagnostic=${JSON.stringify(diagnostic)}`, { cause });
|
|
21
|
+
this.reason = reason;
|
|
22
|
+
this.diagnostic = diagnostic;
|
|
23
|
+
this.name = "CodexExecutionDiagnosticError";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const CODEX_FAILURE_STREAM_MAX_CHARS = 4096;
|
|
27
|
+
const CODEX_AUTH_MAX_BYTES = 1024 * 1024;
|
|
28
|
+
const CODEX_REDACTED = "<REDACTED_CREDENTIAL>";
|
|
29
|
+
const codexSensitiveEnvironmentName = /(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH|COOKIE)/i;
|
|
30
|
+
function createCodexExecutionDiagnosticError(opts) {
|
|
31
|
+
const redact = (value) => redactCodexDiagnostic(value, opts.codexHome, opts.redactionValues);
|
|
32
|
+
const boundedStdout = boundCodexDiagnostic(redact(opts.stdout));
|
|
33
|
+
const boundedStderr = boundCodexDiagnostic(redact(opts.stderr));
|
|
34
|
+
const diagnostic = {
|
|
35
|
+
exitCode: opts.exitCode,
|
|
36
|
+
killedBySignal: opts.killedBySignal,
|
|
37
|
+
timedOut: opts.timedOut,
|
|
38
|
+
...opts.aborted !== void 0 ? { aborted: opts.aborted } : {},
|
|
39
|
+
durationMs: opts.durationMs,
|
|
40
|
+
stdout: boundedStdout.value,
|
|
41
|
+
stderr: boundedStderr.value,
|
|
42
|
+
stdoutTruncated: boundedStdout.truncated,
|
|
43
|
+
stderrTruncated: boundedStderr.truncated
|
|
44
|
+
};
|
|
45
|
+
const safeReason = boundCodexDiagnostic(redact(opts.reason), 1024).value;
|
|
46
|
+
return new CodexExecutionDiagnosticError(safeReason, diagnostic, redactCodexErrorCause(opts.cause, safeReason, redact));
|
|
47
|
+
}
|
|
48
|
+
function redactCodexHome(value, codexHome) {
|
|
49
|
+
return codexHome ? value.replaceAll(codexHome, "<ISOLATED_CODEX_HOME>") : value;
|
|
50
|
+
}
|
|
51
|
+
function collectCodexDiagnosticRedactionValues(env, authText) {
|
|
52
|
+
const values = Object.entries(env).filter(([name, value]) => codexSensitiveEnvironmentName.test(name) && typeof value === "string").map(([, value]) => value);
|
|
53
|
+
try {
|
|
54
|
+
collectStringLeaves(JSON.parse(authText), values);
|
|
55
|
+
} catch {}
|
|
56
|
+
const encoded = values.filter((value) => value.length >= 8).flatMap((value) => [
|
|
57
|
+
Buffer.from(value, "utf8").toString("base64"),
|
|
58
|
+
Buffer.from(value, "utf8").toString("base64url"),
|
|
59
|
+
encodeURIComponent(value),
|
|
60
|
+
JSON.stringify(value).slice(1, -1)
|
|
61
|
+
]);
|
|
62
|
+
return [.../* @__PURE__ */ new Set([...values, ...encoded])];
|
|
63
|
+
}
|
|
64
|
+
async function readCodexAuthRedactionText(path) {
|
|
65
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
66
|
+
try {
|
|
67
|
+
const stats = await handle.stat();
|
|
68
|
+
if (!stats.isFile() || !Number.isSafeInteger(stats.size) || stats.size > CODEX_AUTH_MAX_BYTES) throw new Error("Codex auth.json must be a regular file no larger than 1 MiB");
|
|
69
|
+
const buffer = Buffer.alloc(stats.size);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
while (offset < buffer.length) {
|
|
72
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
73
|
+
if (bytesRead === 0) break;
|
|
74
|
+
offset += bytesRead;
|
|
75
|
+
}
|
|
76
|
+
return buffer.subarray(0, offset).toString("utf8");
|
|
77
|
+
} finally {
|
|
78
|
+
await handle.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function boundCodexDiagnostic(value, maxChars = CODEX_FAILURE_STREAM_MAX_CHARS) {
|
|
82
|
+
if (value.length <= maxChars) return {
|
|
83
|
+
value,
|
|
84
|
+
truncated: false
|
|
85
|
+
};
|
|
86
|
+
const marker = "\n<... OUTPUT TRUNCATED ...>\n";
|
|
87
|
+
const contentChars = maxChars - 28;
|
|
88
|
+
const headChars = Math.floor(contentChars / 2);
|
|
89
|
+
const tailChars = contentChars - headChars;
|
|
90
|
+
return {
|
|
91
|
+
value: `${value.slice(0, headChars)}${marker}${value.slice(-tailChars)}`,
|
|
92
|
+
truncated: true
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function redactCodexDiagnostic(value, codexHome, protectedValues) {
|
|
96
|
+
let redacted = redactCodexHome(stripVTControlCharacters(value), codexHome);
|
|
97
|
+
const exactValues = [...new Set(protectedValues.filter((item) => item.length >= 6))].sort((a, b) => b.length - a.length);
|
|
98
|
+
for (const secret of exactValues) redacted = redacted.replaceAll(secret, CODEX_REDACTED);
|
|
99
|
+
redacted = redacted.replace(/(\bBearer\s+)[A-Za-z0-9._~+/=-]{6,}/gi, `$1${CODEX_REDACTED}`).replace(/\b(?:sk|rk|pk)-[A-Za-z0-9._-]{8,}\b/g, CODEX_REDACTED).replace(/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g, CODEX_REDACTED).replace(/((?:["']?(?:api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|private[-_]?key|session[-_]?(?:id|token)|secret|password|passwd|authorization|credential|cookie)["']?)\s*[:=]\s*)(["'])[^"'\r\n]*\2/gi, `$1$2${CODEX_REDACTED}$2`).replace(/((?:["']?(?:api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|private[-_]?key|session[-_]?(?:id|token)|secret|password|passwd|authorization|credential|cookie)["']?)\s*[:=]\s*)([^\s,;}]+)/gi, `$1${CODEX_REDACTED}`);
|
|
100
|
+
for (const secret of exactValues) redacted = redacted.replaceAll(secret, CODEX_REDACTED);
|
|
101
|
+
return redacted;
|
|
102
|
+
}
|
|
103
|
+
function redactCodexErrorCause(cause, safeReason, redact) {
|
|
104
|
+
if (!(cause instanceof Error)) return void 0;
|
|
105
|
+
const safeCause = new Error(safeReason);
|
|
106
|
+
safeCause.name = cause.name;
|
|
107
|
+
if (cause.stack) safeCause.stack = boundCodexDiagnostic(redact(cause.stack)).value;
|
|
108
|
+
return safeCause;
|
|
109
|
+
}
|
|
110
|
+
function collectStringLeaves(value, output) {
|
|
111
|
+
if (typeof value === "string") {
|
|
112
|
+
output.push(value);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
for (const item of value) collectStringLeaves(item, output);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (value !== null && typeof value === "object") for (const item of Object.values(value)) collectStringLeaves(item, output);
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/mcp/local-harness.ts
|
|
123
|
+
/**
|
|
124
|
+
*
|
|
125
|
+
* Subprocess wrappers for the local coding-harness CLIs installed in the
|
|
126
|
+
* sandbox image (claude-code, codex, opencode). Used by the in-process
|
|
127
|
+
* delegation executor (`createInProcessExecutor`) so a delegated coding task
|
|
128
|
+
* spawns a real harness on a real git worktree instead of provisioning a
|
|
129
|
+
* sibling sandbox.
|
|
130
|
+
*
|
|
131
|
+
* All harness invocations:
|
|
132
|
+
* - run with `cwd` set to the worktree
|
|
133
|
+
* - inherit env from the parent (the MCP server inside the sandbox has
|
|
134
|
+
* the harness's auth already)
|
|
135
|
+
* - capture stdout/stderr
|
|
136
|
+
* - support cancellation via AbortSignal
|
|
137
|
+
* - enforce a wall-clock timeout
|
|
138
|
+
*
|
|
139
|
+
* @experimental
|
|
140
|
+
*/
|
|
141
|
+
const codexReasoningEffort = {
|
|
142
|
+
none: "none",
|
|
143
|
+
minimal: "minimal",
|
|
144
|
+
low: "low",
|
|
145
|
+
medium: "medium",
|
|
146
|
+
high: "high",
|
|
147
|
+
xhigh: "xhigh",
|
|
148
|
+
ultracode: "xhigh"
|
|
149
|
+
};
|
|
150
|
+
function codexReasoningArgs(reasoningEffort) {
|
|
151
|
+
const mapped = codexReasoningEffort[reasoningEffort];
|
|
152
|
+
if (mapped === void 0) throw new Error(`harnessInvocation: unsupported Codex reasoning effort ${String(reasoningEffort)}`);
|
|
153
|
+
return ["-c", `model_reasoning_effort="${mapped}"`];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Default per-harness command + arg shape. `buildArgs` takes ONLY the task prompt and
|
|
157
|
+
* emits the prompt-only invocation (no model, no system prompt) — the safe default shape
|
|
158
|
+
* the in-process executor's `streamPrompt` drives. `modelArgs` maps a resolved model to
|
|
159
|
+
* the harness's selector flag (every supported harness takes `-m <model>`). The §1.5
|
|
160
|
+
* profile-aware mapper `harnessInvocation` composes these to thread the full
|
|
161
|
+
* supervisor-authored profile (systemPrompt + model) into argv.
|
|
162
|
+
*/
|
|
163
|
+
const HARNESS_INVOCATIONS = {
|
|
164
|
+
claude: {
|
|
165
|
+
command: "claude",
|
|
166
|
+
buildArgs: (taskPrompt) => ["-p", taskPrompt],
|
|
167
|
+
modelArgs: (model) => ["-m", model]
|
|
168
|
+
},
|
|
169
|
+
codex: {
|
|
170
|
+
command: "codex",
|
|
171
|
+
buildArgs: (taskPrompt) => ["exec", taskPrompt],
|
|
172
|
+
modelArgs: (model) => ["-m", model],
|
|
173
|
+
reasoningArgs: codexReasoningArgs
|
|
174
|
+
},
|
|
175
|
+
opencode: {
|
|
176
|
+
command: "opencode",
|
|
177
|
+
buildArgs: (taskPrompt) => ["run", taskPrompt],
|
|
178
|
+
modelArgs: (model) => ["-m", model]
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const CODEX_REPRODUCIBLE_ARGS = [
|
|
182
|
+
"--ephemeral",
|
|
183
|
+
"--ignore-rules",
|
|
184
|
+
"--json",
|
|
185
|
+
"-c",
|
|
186
|
+
"approval_policy=\"never\"",
|
|
187
|
+
"-c",
|
|
188
|
+
"web_search=\"disabled\"",
|
|
189
|
+
"-c",
|
|
190
|
+
"project_doc_max_bytes=0",
|
|
191
|
+
"-c",
|
|
192
|
+
"skills.include_instructions=false",
|
|
193
|
+
"-c",
|
|
194
|
+
"include_apps_instructions=false",
|
|
195
|
+
"--disable",
|
|
196
|
+
"tool_suggest",
|
|
197
|
+
"-c",
|
|
198
|
+
"features.multi_agent_v2.root_agent_usage_hint_text=\"\"",
|
|
199
|
+
"-c",
|
|
200
|
+
"features.multi_agent_v2.subagent_usage_hint_text=\"\"",
|
|
201
|
+
"-c",
|
|
202
|
+
"features.multi_agent_v2.multi_agent_mode_hint_text=\"\"",
|
|
203
|
+
"--strict-config"
|
|
204
|
+
];
|
|
205
|
+
function buildHarnessArgs(harness, taskPrompt, options = {}) {
|
|
206
|
+
const args = HARNESS_INVOCATIONS[harness].buildArgs(taskPrompt);
|
|
207
|
+
if (harness === "claude" && options.dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
|
208
|
+
if (options.codexReproducible) {
|
|
209
|
+
if (harness !== "codex") throw new Error("harnessInvocation: codexReproducible requires the Codex harness");
|
|
210
|
+
args.push(...CODEX_REPRODUCIBLE_ARGS);
|
|
211
|
+
}
|
|
212
|
+
return args;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Map a supervisor-authored `AgentProfile` + the per-task prompt onto a concrete harness
|
|
216
|
+
* `command` + `args` (the §1.5 fix). UNLIKE the prompt-only `HARNESS_INVOCATIONS.buildArgs`
|
|
217
|
+
* — which drops both the authored model and the system prompt — this threads the FULL
|
|
218
|
+
* profile payload into argv:
|
|
219
|
+
*
|
|
220
|
+
* - `profile.prompt.systemPrompt` → the PROMPT channel: a portable, harness-agnostic
|
|
221
|
+
* default that prepends the system prompt above the task prompt (`<system>\n\n<task>`),
|
|
222
|
+
* so the authored standing instructions reach EVERY harness (none of the three CLIs
|
|
223
|
+
* expose a portable replace-system-prompt flag for a one-shot non-interactive run).
|
|
224
|
+
* - `profile.prompt.instructions[]` → appended prompt sections in authored order.
|
|
225
|
+
* - `profile.model.default` → the harness's `-m <model>` selector.
|
|
226
|
+
*
|
|
227
|
+
* The task prompt alone is the floor; an empty/absent profile yields exactly the legacy
|
|
228
|
+
* `buildArgs(taskPrompt)` shape so existing callers are byte-identical.
|
|
229
|
+
*/
|
|
230
|
+
function harnessInvocation(harness, profile, taskPrompt, options = {}) {
|
|
231
|
+
const invocation = HARNESS_INVOCATIONS[harness];
|
|
232
|
+
if (!invocation) throw new Error(`harnessInvocation: unknown harness ${String(harness)}`);
|
|
233
|
+
if (options.codexReproducible) {
|
|
234
|
+
const model = profile.model?.default;
|
|
235
|
+
if (typeof model !== "string" || model.trim().length === 0) throw new Error("harnessInvocation: codexReproducible requires profile.model.default");
|
|
236
|
+
if (profile.model?.reasoningEffort === void 0) throw new Error("harnessInvocation: codexReproducible requires profile.model.reasoningEffort");
|
|
237
|
+
}
|
|
238
|
+
const systemPrompt = profile.prompt?.systemPrompt;
|
|
239
|
+
const instructions = profile.prompt?.instructions;
|
|
240
|
+
if (instructions !== void 0 && (!Array.isArray(instructions) || instructions.some((instruction) => typeof instruction !== "string"))) throw new Error("harnessInvocation: profile.prompt.instructions must be an array of strings");
|
|
241
|
+
const composedPrompt = [
|
|
242
|
+
...typeof systemPrompt === "string" && systemPrompt.trim().length > 0 ? [systemPrompt] : [],
|
|
243
|
+
...instructions?.filter((instruction) => instruction.trim().length > 0) ?? [],
|
|
244
|
+
taskPrompt
|
|
245
|
+
].join("\n\n");
|
|
246
|
+
const args = buildHarnessArgs(harness, composedPrompt, options);
|
|
247
|
+
const model = profile.model?.default;
|
|
248
|
+
if (typeof model === "string" && model.length > 0) args.push(...invocation.modelArgs(model));
|
|
249
|
+
const reasoningEffort = profile.model?.reasoningEffort;
|
|
250
|
+
if (reasoningEffort !== void 0 && invocation.reasoningArgs) args.push(...invocation.reasoningArgs(reasoningEffort));
|
|
251
|
+
return {
|
|
252
|
+
command: invocation.command,
|
|
253
|
+
args,
|
|
254
|
+
prompt: composedPrompt
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const DEFAULT_TIMEOUT_MS = 300 * 1e3;
|
|
258
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
259
|
+
const processKillGraceMs = 250;
|
|
260
|
+
const processGroupExitConfirmMs = 1e3;
|
|
261
|
+
const processGroupExitPollMs = 10;
|
|
262
|
+
var RollingByteCapture = class {
|
|
263
|
+
maxBytes;
|
|
264
|
+
chunks = [];
|
|
265
|
+
bytes = 0;
|
|
266
|
+
constructor(maxBytes) {
|
|
267
|
+
this.maxBytes = maxBytes;
|
|
268
|
+
}
|
|
269
|
+
append(chunk) {
|
|
270
|
+
if (this.maxBytes === 0) return;
|
|
271
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
272
|
+
this.chunks.push(buffer);
|
|
273
|
+
this.bytes += buffer.length;
|
|
274
|
+
let excess = this.bytes - this.maxBytes;
|
|
275
|
+
while (excess > 0 && this.chunks.length > 0) {
|
|
276
|
+
const first = this.chunks[0];
|
|
277
|
+
if (first.length <= excess) {
|
|
278
|
+
this.chunks.shift();
|
|
279
|
+
this.bytes -= first.length;
|
|
280
|
+
excess -= first.length;
|
|
281
|
+
} else {
|
|
282
|
+
this.chunks[0] = first.subarray(excess);
|
|
283
|
+
this.bytes -= excess;
|
|
284
|
+
excess = 0;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
text() {
|
|
289
|
+
return Buffer.concat(this.chunks, this.bytes).toString("utf8");
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Spawn a local coding harness CLI as a subprocess + collect its output.
|
|
294
|
+
*
|
|
295
|
+
* NOT responsible for parsing the harness's output or extracting a diff —
|
|
296
|
+
* the in-process executor's `streamPrompt` orchestrates `git diff` against
|
|
297
|
+
* the worktree after this resolves. This function is intentionally narrow:
|
|
298
|
+
* spawn, wait, capture, return.
|
|
299
|
+
*
|
|
300
|
+
* Fails loud — throws when:
|
|
301
|
+
* - `cwd` doesn't exist (subprocess emits ENOENT; surfaced as Error)
|
|
302
|
+
* - the harness binary is not on PATH (ENOENT)
|
|
303
|
+
* - the caller signal was already aborted before process launch
|
|
304
|
+
*
|
|
305
|
+
* Does NOT throw when:
|
|
306
|
+
* - the subprocess exits non-zero (`result.exitCode` carries the code)
|
|
307
|
+
* - a non-reproducible subprocess is aborted / timed out (`result.aborted` /
|
|
308
|
+
* `result.timedOut` carries the reason even when a TERM-aware child exits zero)
|
|
309
|
+
*
|
|
310
|
+
* Reproducible Codex additionally requires a terminal usage event. If cancellation
|
|
311
|
+
* prevents that event, this rejects with `CodexExecutionDiagnosticError` instead of
|
|
312
|
+
* returning an incomplete reproducibility receipt.
|
|
313
|
+
*
|
|
314
|
+
* @experimental
|
|
315
|
+
*/
|
|
316
|
+
async function runLocalHarness(options) {
|
|
317
|
+
const { harness, cwd, taskPrompt } = options;
|
|
318
|
+
if (options.codexReproducible && harness !== "codex") throw new Error("runLocalHarness: codexReproducible requires the Codex harness");
|
|
319
|
+
if (options.codexReproducible && process.platform !== "linux") throw new Error("runLocalHarness: codexReproducible currently requires Linux");
|
|
320
|
+
if (options.codexReadDeniedPaths !== void 0 && !options.codexReproducible) throw new Error("runLocalHarness: codexReadDeniedPaths requires codexReproducible");
|
|
321
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
322
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
323
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) throw new Error("runLocalHarness: maxOutputBytes must be a non-negative safe integer");
|
|
324
|
+
const spawnImpl = options.spawn ?? spawn;
|
|
325
|
+
const invocation = HARNESS_INVOCATIONS[harness];
|
|
326
|
+
if (!invocation) throw new Error(`runLocalHarness: unknown harness ${String(harness)}`);
|
|
327
|
+
const startedAt = Date.now();
|
|
328
|
+
const requestedCommand = options.invocation?.command ?? invocation.command;
|
|
329
|
+
const args = options.invocation ? [...options.invocation.args] : buildHarnessArgs(harness, taskPrompt, options);
|
|
330
|
+
if (options.codexReproducible) assertCodexReproducibleInvocation(requestedCommand, args);
|
|
331
|
+
options.signal?.throwIfAborted();
|
|
332
|
+
const baseEnv = {
|
|
333
|
+
...options.env ?? process.env,
|
|
334
|
+
PWD: cwd
|
|
335
|
+
};
|
|
336
|
+
const isolated = options.codexReproducible ? await isolateCodexHome({
|
|
337
|
+
baseEnv,
|
|
338
|
+
cwd,
|
|
339
|
+
command: requestedCommand,
|
|
340
|
+
readDeniedPaths: options.codexReadDeniedPaths ?? [],
|
|
341
|
+
resolveExecutable: options.resolveCodexExecutable ?? resolveCodexNativeExecutable
|
|
342
|
+
}) : {
|
|
343
|
+
env: baseEnv,
|
|
344
|
+
command: requestedCommand,
|
|
345
|
+
cleanup: async () => void 0,
|
|
346
|
+
controlledConfigSha256: "",
|
|
347
|
+
executableSha256: "",
|
|
348
|
+
readDeniedPathsSha256: "",
|
|
349
|
+
readDeniedPathCount: 0,
|
|
350
|
+
readDeniedPaths: [],
|
|
351
|
+
diagnosticRedactionValues: [],
|
|
352
|
+
ambientAuth: "",
|
|
353
|
+
isolatedAuth: "",
|
|
354
|
+
writeProbe: "",
|
|
355
|
+
stagedWriteProbe: "",
|
|
356
|
+
deniedProbePaths: []
|
|
357
|
+
};
|
|
358
|
+
const env = isolated.env;
|
|
359
|
+
const command = isolated.command;
|
|
360
|
+
if (options.codexReproducible) assertCodexReproducibleInvocation(command, args);
|
|
361
|
+
try {
|
|
362
|
+
const evidence = options.codexReproducible ? await collectCodexExecutionEvidence({
|
|
363
|
+
command,
|
|
364
|
+
args,
|
|
365
|
+
cwd,
|
|
366
|
+
env,
|
|
367
|
+
spawn: spawnImpl,
|
|
368
|
+
controlledConfigSha256: isolated.controlledConfigSha256,
|
|
369
|
+
executableSha256: isolated.executableSha256,
|
|
370
|
+
readDeniedPathsSha256: isolated.readDeniedPathsSha256,
|
|
371
|
+
readDeniedPathCount: isolated.readDeniedPathCount,
|
|
372
|
+
readDeniedPaths: isolated.readDeniedPaths,
|
|
373
|
+
ambientAuth: isolated.ambientAuth,
|
|
374
|
+
isolatedAuth: isolated.isolatedAuth,
|
|
375
|
+
writeProbe: isolated.writeProbe,
|
|
376
|
+
stagedWriteProbe: isolated.stagedWriteProbe,
|
|
377
|
+
deniedProbePaths: isolated.deniedProbePaths,
|
|
378
|
+
...options.signal ? { signal: options.signal } : {}
|
|
379
|
+
}) : void 0;
|
|
380
|
+
return await new Promise((resolve, reject) => {
|
|
381
|
+
let child;
|
|
382
|
+
try {
|
|
383
|
+
options.signal?.throwIfAborted();
|
|
384
|
+
child = spawnImpl(command, args, {
|
|
385
|
+
cwd,
|
|
386
|
+
env,
|
|
387
|
+
stdio: "pipe",
|
|
388
|
+
detached: process.platform !== "win32"
|
|
389
|
+
});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
child.stdin?.end();
|
|
395
|
+
const stdoutCapture = new RollingByteCapture(maxOutputBytes);
|
|
396
|
+
const stderrCapture = new RollingByteCapture(maxOutputBytes);
|
|
397
|
+
let timedOut = false;
|
|
398
|
+
let aborted = false;
|
|
399
|
+
let settled = false;
|
|
400
|
+
let leaderClosed = false;
|
|
401
|
+
let timer = null;
|
|
402
|
+
let termination = null;
|
|
403
|
+
const clearLifecycle = () => {
|
|
404
|
+
if (timer) clearTimeout(timer);
|
|
405
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
406
|
+
};
|
|
407
|
+
const rejectOnce = (err) => {
|
|
408
|
+
if (settled) return;
|
|
409
|
+
settled = true;
|
|
410
|
+
clearLifecycle();
|
|
411
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
412
|
+
};
|
|
413
|
+
const terminate = () => {
|
|
414
|
+
if (!termination) {
|
|
415
|
+
termination = terminateProcessTreeAndConfirm(child, () => leaderClosed);
|
|
416
|
+
termination.catch(rejectOnce);
|
|
417
|
+
}
|
|
418
|
+
return termination;
|
|
419
|
+
};
|
|
420
|
+
timer = timeoutMs > 0 ? setTimeout(() => {
|
|
421
|
+
timedOut = true;
|
|
422
|
+
terminate();
|
|
423
|
+
}, timeoutMs) : null;
|
|
424
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
425
|
+
const onAbort = () => {
|
|
426
|
+
aborted = true;
|
|
427
|
+
terminate();
|
|
428
|
+
};
|
|
429
|
+
if (options.signal) if (options.signal.aborted) onAbort();
|
|
430
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
431
|
+
child.stdout?.on("data", (chunk) => {
|
|
432
|
+
stdoutCapture.append(chunk);
|
|
433
|
+
});
|
|
434
|
+
child.stderr?.on("data", (chunk) => {
|
|
435
|
+
stderrCapture.append(chunk);
|
|
436
|
+
});
|
|
437
|
+
const finalize = (result) => {
|
|
438
|
+
if (settled) return;
|
|
439
|
+
settled = true;
|
|
440
|
+
clearLifecycle();
|
|
441
|
+
resolve(result);
|
|
442
|
+
};
|
|
443
|
+
child.on("error", (err) => {
|
|
444
|
+
leaderClosed = true;
|
|
445
|
+
if (timer) clearTimeout(timer);
|
|
446
|
+
(async () => {
|
|
447
|
+
try {
|
|
448
|
+
if (termination) await termination;
|
|
449
|
+
rejectOnce(err);
|
|
450
|
+
} catch (terminationError) {
|
|
451
|
+
rejectOnce(terminationError);
|
|
452
|
+
}
|
|
453
|
+
})();
|
|
454
|
+
});
|
|
455
|
+
child.on("close", (code, signal) => {
|
|
456
|
+
leaderClosed = true;
|
|
457
|
+
if (timer) clearTimeout(timer);
|
|
458
|
+
(async () => {
|
|
459
|
+
const stdout = stdoutCapture.text();
|
|
460
|
+
const stderr = stderrCapture.text();
|
|
461
|
+
try {
|
|
462
|
+
if (!termination && process.platform !== "win32" && typeof child.pid === "number" && processGroupExists(child.pid)) await terminate();
|
|
463
|
+
if (termination) await termination;
|
|
464
|
+
const durationMs = Date.now() - startedAt;
|
|
465
|
+
const usage = options.codexReproducible ? parseCodexTokenUsage(stdout) : void 0;
|
|
466
|
+
finalize({
|
|
467
|
+
exitCode: code,
|
|
468
|
+
stdout,
|
|
469
|
+
stderr: redactCodexHome(stderr, env.CODEX_HOME),
|
|
470
|
+
killedBySignal: signal,
|
|
471
|
+
durationMs,
|
|
472
|
+
timedOut,
|
|
473
|
+
aborted,
|
|
474
|
+
...usage ? { usage } : {},
|
|
475
|
+
...evidence ? { evidence } : {}
|
|
476
|
+
});
|
|
477
|
+
} catch (err) {
|
|
478
|
+
if (settled) return;
|
|
479
|
+
settled = true;
|
|
480
|
+
clearLifecycle();
|
|
481
|
+
const durationMs = Date.now() - startedAt;
|
|
482
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
483
|
+
reject(options.codexReproducible ? createCodexExecutionDiagnosticError({
|
|
484
|
+
reason,
|
|
485
|
+
exitCode: code,
|
|
486
|
+
killedBySignal: signal,
|
|
487
|
+
timedOut,
|
|
488
|
+
aborted,
|
|
489
|
+
durationMs,
|
|
490
|
+
stdout,
|
|
491
|
+
stderr,
|
|
492
|
+
codexHome: env.CODEX_HOME,
|
|
493
|
+
redactionValues: isolated.diagnosticRedactionValues,
|
|
494
|
+
cause: err
|
|
495
|
+
}) : err instanceof Error ? err : new Error(reason));
|
|
496
|
+
}
|
|
497
|
+
})();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
} finally {
|
|
501
|
+
await isolated.cleanup();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const CODEX_EXECUTION_POLICY = {
|
|
505
|
+
sessionPersistence: "ephemeral",
|
|
506
|
+
userConfig: false,
|
|
507
|
+
rules: false,
|
|
508
|
+
projectInstructions: false,
|
|
509
|
+
skillInstructions: false,
|
|
510
|
+
appInstructions: false,
|
|
511
|
+
toolSuggestions: false,
|
|
512
|
+
multiAgentInstructions: false,
|
|
513
|
+
sandbox: "workspace-write",
|
|
514
|
+
permissionProfile: "agent_runtime_reproducible",
|
|
515
|
+
approvalPolicy: "never",
|
|
516
|
+
shellNetwork: false,
|
|
517
|
+
webSearch: false,
|
|
518
|
+
serviceTier: "default",
|
|
519
|
+
shellEnvironment: "core-filtered",
|
|
520
|
+
loginShell: false,
|
|
521
|
+
credentialsReadable: false,
|
|
522
|
+
hostHomeReadable: false,
|
|
523
|
+
procEnvironment: "private-sanitized",
|
|
524
|
+
sensitiveEnvironmentNamesVisible: false,
|
|
525
|
+
parentRepoRead: false,
|
|
526
|
+
gitMetadata: false,
|
|
527
|
+
temporaryDirectory: "workspace-private",
|
|
528
|
+
stagedExecutable: "static-elf-read-only",
|
|
529
|
+
callerReadDeniedPaths: "enforced",
|
|
530
|
+
containerSockets: false
|
|
531
|
+
};
|
|
532
|
+
const FORBIDDEN_CODEX_PROMPT_MARKERS = [
|
|
533
|
+
"<skills_instructions>",
|
|
534
|
+
"# AGENTS.md instructions",
|
|
535
|
+
"<apps_instructions>",
|
|
536
|
+
"<recommended_plugins>",
|
|
537
|
+
"<multi_agent_mode>",
|
|
538
|
+
"You are `/root`, the primary agent"
|
|
539
|
+
];
|
|
540
|
+
async function collectCodexExecutionEvidence(opts) {
|
|
541
|
+
assertCodexReproducibleInvocation(opts.command, opts.args);
|
|
542
|
+
const prompt = opts.args[1];
|
|
543
|
+
if (opts.args[0] !== "exec" || typeof prompt !== "string" || prompt.length === 0) throw new Error("runLocalHarness: reproducible Codex invocation must be codex exec <prompt>");
|
|
544
|
+
const cliVersion = (await runCodexProbe(opts, ["--version"])).stdout.trim();
|
|
545
|
+
if (!/^codex-cli \d+\.\d+\.\d+(?:[-+].+)?$/.test(cliVersion)) throw new Error(`runLocalHarness: unexpected Codex CLI version output ${JSON.stringify(cliVersion)}`);
|
|
546
|
+
const sandboxProof = [
|
|
547
|
+
[
|
|
548
|
+
"CODEX_HOME",
|
|
549
|
+
"CODEX_ACCESS_TOKEN",
|
|
550
|
+
"CODEX_API_KEY",
|
|
551
|
+
"OPENAI_API_KEY"
|
|
552
|
+
].map((name) => `test -z "\${${name}:-}"`).join(" && "),
|
|
553
|
+
"test -r .",
|
|
554
|
+
"! test -r .git",
|
|
555
|
+
"touch \"$1\"",
|
|
556
|
+
"rm \"$1\"",
|
|
557
|
+
"staged_dir=$(dirname \"$2\")",
|
|
558
|
+
"test -x \"$staged_dir/codex\"",
|
|
559
|
+
"! chmod 700 \"$staged_dir\" 2>/dev/null",
|
|
560
|
+
"! rm \"$staged_dir/codex\" 2>/dev/null",
|
|
561
|
+
"! touch \"$2\" 2>/dev/null",
|
|
562
|
+
"shift 2",
|
|
563
|
+
"for path in \"$@\"; do ! test -r \"$path\" || exit 1; done",
|
|
564
|
+
"touch \"$TMPDIR/.agent-runtime-tmp-proof\"",
|
|
565
|
+
"rm \"$TMPDIR/.agent-runtime-tmp-proof\""
|
|
566
|
+
].join(" && ");
|
|
567
|
+
await runCodexProbe(opts, [
|
|
568
|
+
"sandbox",
|
|
569
|
+
"-P",
|
|
570
|
+
CODEX_PERMISSION_PROFILE,
|
|
571
|
+
"-C",
|
|
572
|
+
opts.cwd,
|
|
573
|
+
"sh",
|
|
574
|
+
"-c",
|
|
575
|
+
sandboxProof,
|
|
576
|
+
"sh",
|
|
577
|
+
opts.writeProbe,
|
|
578
|
+
opts.stagedWriteProbe,
|
|
579
|
+
...opts.deniedProbePaths
|
|
580
|
+
]);
|
|
581
|
+
await runCodexProbe(opts, [
|
|
582
|
+
"sandbox",
|
|
583
|
+
"-P",
|
|
584
|
+
CODEX_PERMISSION_PROFILE,
|
|
585
|
+
"-C",
|
|
586
|
+
opts.cwd,
|
|
587
|
+
"python3",
|
|
588
|
+
"-c",
|
|
589
|
+
`import os,re
|
|
590
|
+
pattern=re.compile(br'(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH|COOKIE|CODEX_HOME)', re.I)
|
|
591
|
+
paths=('/proc/1/environ', '/proc/self/environ', f'/proc/{os.getppid()}/environ')
|
|
592
|
+
for path in dict.fromkeys(paths):
|
|
593
|
+
data=open(path,'rb').read().split(b'\\0')
|
|
594
|
+
names=(item.split(b'=',1)[0] for item in data if b'=' in item)
|
|
595
|
+
assert not any(pattern.search(name) for name in names), path`
|
|
596
|
+
]);
|
|
597
|
+
await runCodexProbe(opts, [
|
|
598
|
+
"sandbox",
|
|
599
|
+
"-P",
|
|
600
|
+
CODEX_PERMISSION_PROFILE,
|
|
601
|
+
"-C",
|
|
602
|
+
opts.cwd,
|
|
603
|
+
"python3",
|
|
604
|
+
"-c",
|
|
605
|
+
"import socket,sys\nfor path in sys.argv[1:]:\n s=socket.socket(socket.AF_UNIX)\n try: s.connect(path)\n except OSError: continue\n raise SystemExit(1)",
|
|
606
|
+
"/var/run/docker.sock",
|
|
607
|
+
"/run/docker.sock",
|
|
608
|
+
"/var/run/containerd/containerd.sock",
|
|
609
|
+
"/run/containerd/containerd.sock"
|
|
610
|
+
]);
|
|
611
|
+
const renderedPrompt = (await runCodexProbe(opts, codexPromptDebugArgs(opts.args, prompt))).stdout;
|
|
612
|
+
let parsedPrompt;
|
|
613
|
+
try {
|
|
614
|
+
parsedPrompt = JSON.parse(renderedPrompt);
|
|
615
|
+
} catch {
|
|
616
|
+
throw new Error("runLocalHarness: codex debug prompt-input returned invalid JSON");
|
|
617
|
+
}
|
|
618
|
+
const promptEvidenceText = normalizeCodexDebugPromptNewlines(prompt);
|
|
619
|
+
if (!Array.isArray(parsedPrompt) || !jsonTextContains(parsedPrompt, promptEvidenceText)) throw new Error("runLocalHarness: Codex prompt evidence did not contain the exact task prompt");
|
|
620
|
+
const promptContextWithoutTask = JSON.stringify(redactJsonText(parsedPrompt, promptEvidenceText));
|
|
621
|
+
for (const marker of FORBIDDEN_CODEX_PROMPT_MARKERS) if (promptContextWithoutTask.includes(marker)) throw new Error(`runLocalHarness: Codex prompt evidence contains forbidden marker ${marker}`);
|
|
622
|
+
for (const marker of ["<permissions instructions>", "<environment_context>"]) if (!renderedPrompt.includes(marker)) throw new Error(`runLocalHarness: Codex prompt evidence is missing required marker ${marker}`);
|
|
623
|
+
const nonPromptArgs = ["codex", ...opts.args.map((arg, index) => index === 1 ? "<PROMPT>" : arg)];
|
|
624
|
+
return {
|
|
625
|
+
cliVersion,
|
|
626
|
+
executableSha256: opts.executableSha256,
|
|
627
|
+
requestedPromptSha256: sha256(prompt),
|
|
628
|
+
effectivePromptSha256: sha256(renderedPrompt),
|
|
629
|
+
nonPromptArgsSha256: sha256(JSON.stringify(nonPromptArgs)),
|
|
630
|
+
controlledConfigSha256: opts.controlledConfigSha256,
|
|
631
|
+
readDeniedPathsSha256: opts.readDeniedPathsSha256,
|
|
632
|
+
readDeniedPathCount: opts.readDeniedPathCount,
|
|
633
|
+
readDeniedPaths: [...opts.readDeniedPaths],
|
|
634
|
+
policy: { ...CODEX_EXECUTION_POLICY }
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
function assertCodexReproducibleInvocation(command, args) {
|
|
638
|
+
const executable = basename(command);
|
|
639
|
+
if (executable !== "codex" && executable !== "codex.exe") throw new Error("runLocalHarness: reproducible Codex invocation must use the Codex executable");
|
|
640
|
+
const expectedPrefix = [
|
|
641
|
+
"exec",
|
|
642
|
+
args[1],
|
|
643
|
+
...CODEX_REPRODUCIBLE_ARGS
|
|
644
|
+
];
|
|
645
|
+
const hasExactPrefix = expectedPrefix.every((arg, index) => args[index] === arg);
|
|
646
|
+
const tail = args.slice(expectedPrefix.length);
|
|
647
|
+
const validModel = tail[0] === "-m" && typeof tail[1] === "string" && tail[1].length > 0 && !tail[1].startsWith("-");
|
|
648
|
+
const validReasoning = tail[2] === "-c" && typeof tail[3] === "string" && /^model_reasoning_effort="(?:none|minimal|low|medium|high|xhigh)"$/.test(tail[3]);
|
|
649
|
+
if (!hasExactPrefix || tail.length !== 4 || !validModel || !validReasoning) throw new Error("runLocalHarness: reproducible Codex invocation does not match the required isolated argv");
|
|
650
|
+
}
|
|
651
|
+
function codexPromptDebugArgs(execArgs, prompt) {
|
|
652
|
+
const debugArgs = ["debug", "prompt-input"];
|
|
653
|
+
for (let index = 2; index < execArgs.length; index += 1) {
|
|
654
|
+
const arg = execArgs[index];
|
|
655
|
+
const value = execArgs[index + 1];
|
|
656
|
+
if (arg === "-c" && value !== void 0) {
|
|
657
|
+
debugArgs.push("-c", value);
|
|
658
|
+
index += 1;
|
|
659
|
+
} else if (arg === "--disable" && value !== void 0) {
|
|
660
|
+
debugArgs.push("--disable", value);
|
|
661
|
+
index += 1;
|
|
662
|
+
} else if (arg === "-m" && value !== void 0) {
|
|
663
|
+
debugArgs.push("-c", `model=${JSON.stringify(value)}`);
|
|
664
|
+
index += 1;
|
|
665
|
+
} else if (arg === "-s" && value !== void 0) {
|
|
666
|
+
debugArgs.push("-c", `sandbox_mode=${JSON.stringify(value)}`);
|
|
667
|
+
index += 1;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
debugArgs.push(prompt);
|
|
671
|
+
return debugArgs;
|
|
672
|
+
}
|
|
673
|
+
function runCodexProbe(opts, args) {
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
let child;
|
|
676
|
+
try {
|
|
677
|
+
child = opts.spawn(opts.command, args, {
|
|
678
|
+
cwd: opts.cwd,
|
|
679
|
+
env: opts.env,
|
|
680
|
+
stdio: "pipe",
|
|
681
|
+
detached: process.platform !== "win32"
|
|
682
|
+
});
|
|
683
|
+
} catch (err) {
|
|
684
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
child.stdin?.end();
|
|
688
|
+
let stdout = "";
|
|
689
|
+
let stderr = "";
|
|
690
|
+
let settled = false;
|
|
691
|
+
let leaderClosed = false;
|
|
692
|
+
let termination = null;
|
|
693
|
+
let terminationReason = null;
|
|
694
|
+
const clearLifecycle = () => {
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
697
|
+
};
|
|
698
|
+
const rejectOnce = (err) => {
|
|
699
|
+
if (settled) return;
|
|
700
|
+
settled = true;
|
|
701
|
+
clearLifecycle();
|
|
702
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
703
|
+
};
|
|
704
|
+
const terminate = (reason) => {
|
|
705
|
+
if (reason) terminationReason ??= reason;
|
|
706
|
+
if (!termination) {
|
|
707
|
+
termination = terminateProcessTreeAndConfirm(child, () => leaderClosed);
|
|
708
|
+
termination.catch(rejectOnce);
|
|
709
|
+
}
|
|
710
|
+
return termination;
|
|
711
|
+
};
|
|
712
|
+
const timer = setTimeout(() => {
|
|
713
|
+
terminate("timed out");
|
|
714
|
+
}, 1e4);
|
|
715
|
+
timer.unref?.();
|
|
716
|
+
const onAbort = () => {
|
|
717
|
+
terminate("aborted");
|
|
718
|
+
};
|
|
719
|
+
if (opts.signal) if (opts.signal.aborted) onAbort();
|
|
720
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
721
|
+
child.stdout?.on("data", (chunk) => {
|
|
722
|
+
stdout += String(chunk);
|
|
723
|
+
});
|
|
724
|
+
child.stderr?.on("data", (chunk) => {
|
|
725
|
+
stderr += String(chunk);
|
|
726
|
+
});
|
|
727
|
+
child.on("error", (err) => {
|
|
728
|
+
leaderClosed = true;
|
|
729
|
+
clearTimeout(timer);
|
|
730
|
+
(async () => {
|
|
731
|
+
try {
|
|
732
|
+
if (termination) await termination;
|
|
733
|
+
rejectOnce(err);
|
|
734
|
+
} catch (terminationError) {
|
|
735
|
+
rejectOnce(terminationError);
|
|
736
|
+
}
|
|
737
|
+
})();
|
|
738
|
+
});
|
|
739
|
+
child.on("close", (code, signal) => {
|
|
740
|
+
leaderClosed = true;
|
|
741
|
+
clearTimeout(timer);
|
|
742
|
+
(async () => {
|
|
743
|
+
try {
|
|
744
|
+
if (!termination && process.platform !== "win32" && typeof child.pid === "number" && processGroupExists(child.pid)) await terminate();
|
|
745
|
+
if (termination) await termination;
|
|
746
|
+
if (settled) return;
|
|
747
|
+
settled = true;
|
|
748
|
+
clearLifecycle();
|
|
749
|
+
if (code !== 0 || terminationReason) {
|
|
750
|
+
const safeStderr = redactCodexHome(stderr, opts.env.CODEX_HOME);
|
|
751
|
+
reject(/* @__PURE__ */ new Error(`runLocalHarness: Codex evidence probe ${terminationReason ?? "failed"} (exit ${String(code)}, signal ${String(signal)}): ${safeStderr.trim()}`));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
resolve({
|
|
755
|
+
stdout,
|
|
756
|
+
stderr
|
|
757
|
+
});
|
|
758
|
+
} catch (err) {
|
|
759
|
+
rejectOnce(err);
|
|
760
|
+
}
|
|
761
|
+
})();
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function sha256(value) {
|
|
766
|
+
return createHash("sha256").update(value).digest("hex");
|
|
767
|
+
}
|
|
768
|
+
/** Codex's debug renderer converts CRLF pairs to LF. No other whitespace is equivalent. */
|
|
769
|
+
function normalizeCodexDebugPromptNewlines(value) {
|
|
770
|
+
return value.replaceAll("\r\n", "\n");
|
|
771
|
+
}
|
|
772
|
+
function jsonTextContains(value, needle) {
|
|
773
|
+
if (typeof value === "string") return value.includes(needle);
|
|
774
|
+
if (Array.isArray(value)) return value.some((item) => jsonTextContains(item, needle));
|
|
775
|
+
if (isRecord(value)) return Object.values(value).some((item) => jsonTextContains(item, needle));
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
function redactJsonText(value, text) {
|
|
779
|
+
if (typeof value === "string") return value.replaceAll(text, "");
|
|
780
|
+
if (Array.isArray(value)) return value.map((item) => redactJsonText(item, text));
|
|
781
|
+
if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactJsonText(item, text)]));
|
|
782
|
+
return value;
|
|
783
|
+
}
|
|
784
|
+
const CODEX_AUTH_ENV = [
|
|
785
|
+
"CODEX_ACCESS_TOKEN",
|
|
786
|
+
"CODEX_API_KEY",
|
|
787
|
+
"OPENAI_API_KEY"
|
|
788
|
+
];
|
|
789
|
+
const CODEX_PERMISSION_PROFILE = "agent_runtime_reproducible";
|
|
790
|
+
async function isolateCodexHome(opts) {
|
|
791
|
+
const isolatedHome = await mkdtemp(join(tmpdir(), "agent-runtime-codex-"));
|
|
792
|
+
await chmod(isolatedHome, 448);
|
|
793
|
+
let workspaceTemp = "";
|
|
794
|
+
let stagedExecutableDir = "";
|
|
795
|
+
let ownsStagedExecutableDir = false;
|
|
796
|
+
let writeProbe = "";
|
|
797
|
+
const cleanup = async () => {
|
|
798
|
+
if (ownsStagedExecutableDir) await chmod(stagedExecutableDir, 448).catch(() => void 0);
|
|
799
|
+
await Promise.all([
|
|
800
|
+
...writeProbe ? [rm(writeProbe, { force: true })] : [],
|
|
801
|
+
...workspaceTemp ? [rm(workspaceTemp, {
|
|
802
|
+
recursive: true,
|
|
803
|
+
force: true
|
|
804
|
+
})] : [],
|
|
805
|
+
...ownsStagedExecutableDir ? [rm(stagedExecutableDir, {
|
|
806
|
+
recursive: true,
|
|
807
|
+
force: true
|
|
808
|
+
})] : [],
|
|
809
|
+
rm(isolatedHome, {
|
|
810
|
+
recursive: true,
|
|
811
|
+
force: true
|
|
812
|
+
})
|
|
813
|
+
]);
|
|
814
|
+
};
|
|
815
|
+
try {
|
|
816
|
+
const readDeniedPaths = await normalizeReadDeniedPaths(opts.readDeniedPaths);
|
|
817
|
+
const candidateRoot = await realpath(opts.cwd);
|
|
818
|
+
writeProbe = join(candidateRoot, `.agent-runtime-write-proof-${randomUUID()}`);
|
|
819
|
+
const stagedPath = join(candidateRoot, ".agent-runtime-bin");
|
|
820
|
+
if (readDeniedPaths.some((path) => isSameOrDescendant(candidateRoot, path) || isSameOrDescendant(stagedPath, path) || isSameOrDescendant(path, stagedPath))) throw new Error("runLocalHarness: codexReadDeniedPaths cannot contain the candidate root or staged executable");
|
|
821
|
+
const nativeExecutable = await opts.resolveExecutable(opts.command, opts.baseEnv);
|
|
822
|
+
if (!await isStaticElfExecutable(nativeExecutable)) throw new Error("runLocalHarness: reproducible Codex mode requires a statically linked Linux Codex ELF");
|
|
823
|
+
const sourceExecutableSha256 = await sha256File(nativeExecutable);
|
|
824
|
+
stagedExecutableDir = stagedPath;
|
|
825
|
+
try {
|
|
826
|
+
await mkdir(stagedExecutableDir, { mode: 448 });
|
|
827
|
+
ownsStagedExecutableDir = true;
|
|
828
|
+
} catch (err) {
|
|
829
|
+
throw new Error(`runLocalHarness: candidate-private .agent-runtime-bin must not already exist: ${String(err.code ?? err)}`);
|
|
830
|
+
}
|
|
831
|
+
const stagedExecutable = join(stagedExecutableDir, "codex");
|
|
832
|
+
await copyFile(nativeExecutable, stagedExecutable, constants.COPYFILE_FICLONE);
|
|
833
|
+
await chmod(stagedExecutable, 320);
|
|
834
|
+
const executableSha256 = await sha256File(stagedExecutable);
|
|
835
|
+
if (executableSha256 !== sourceExecutableSha256 || !await isStaticElfExecutable(stagedExecutable)) throw new Error("runLocalHarness: staged Codex executable digest mismatch");
|
|
836
|
+
await chmod(stagedExecutableDir, 320);
|
|
837
|
+
workspaceTemp = await mkdtemp(join(candidateRoot, ".agent-runtime-tmp-"));
|
|
838
|
+
await chmod(workspaceTemp, 448);
|
|
839
|
+
const ambientHome = resolve(opts.baseEnv.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
840
|
+
const ambientAuth = join(ambientHome, "auth.json");
|
|
841
|
+
const isolatedAuth = join(isolatedHome, "auth.json");
|
|
842
|
+
let authText = "";
|
|
843
|
+
try {
|
|
844
|
+
await access(ambientAuth, constants.R_OK);
|
|
845
|
+
authText = await readCodexAuthRedactionText(ambientAuth);
|
|
846
|
+
await symlink(ambientAuth, isolatedAuth);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
throw new Error(`runLocalHarness: reproducible Codex mode requires readable auth.json: ${String(err.code ?? err)}`);
|
|
849
|
+
}
|
|
850
|
+
const deniedHostRoots = [
|
|
851
|
+
homedir(),
|
|
852
|
+
opts.baseEnv.HOME?.trim(),
|
|
853
|
+
ambientHome
|
|
854
|
+
].filter((path) => Boolean(path)).map((path) => resolve(path)).filter((path, index, paths) => paths.indexOf(path) === index);
|
|
855
|
+
const controlledConfig = codexControlledConfig({
|
|
856
|
+
ambientTmp: opts.baseEnv.TMPDIR?.trim() || tmpdir(),
|
|
857
|
+
deniedHostRoots,
|
|
858
|
+
workspaceTemp,
|
|
859
|
+
stagedExecutableDir,
|
|
860
|
+
readDeniedPaths
|
|
861
|
+
});
|
|
862
|
+
await writeFile(join(isolatedHome, "config.toml"), controlledConfig, { mode: 384 });
|
|
863
|
+
const env = { ...opts.baseEnv };
|
|
864
|
+
for (const name of Object.keys(env)) if (codexSensitiveEnvironmentName.test(name) || CODEX_AUTH_ENV.some((authName) => authName === name)) delete env[name];
|
|
865
|
+
env.CODEX_HOME = isolatedHome;
|
|
866
|
+
const home = homedir();
|
|
867
|
+
const deniedProbePaths = [
|
|
868
|
+
...deniedHostRoots,
|
|
869
|
+
ambientAuth,
|
|
870
|
+
isolatedAuth,
|
|
871
|
+
join(home, ".claude", ".credentials.json"),
|
|
872
|
+
join(home, ".claude.json"),
|
|
873
|
+
join(home, ".local", "share", "opencode", "auth.json"),
|
|
874
|
+
join(home, ".agents"),
|
|
875
|
+
join(home, "Documents"),
|
|
876
|
+
join(home, "Downloads"),
|
|
877
|
+
join(home, ".npm"),
|
|
878
|
+
join(home, "code"),
|
|
879
|
+
...readDeniedPaths
|
|
880
|
+
].filter((path, index, paths) => paths.indexOf(path) === index);
|
|
881
|
+
return {
|
|
882
|
+
env,
|
|
883
|
+
command: stagedExecutable,
|
|
884
|
+
cleanup,
|
|
885
|
+
controlledConfigSha256: sha256(controlledConfig),
|
|
886
|
+
executableSha256,
|
|
887
|
+
readDeniedPaths,
|
|
888
|
+
readDeniedPathsSha256: sha256(JSON.stringify(readDeniedPaths)),
|
|
889
|
+
readDeniedPathCount: readDeniedPaths.length,
|
|
890
|
+
diagnosticRedactionValues: collectCodexDiagnosticRedactionValues(opts.baseEnv, authText),
|
|
891
|
+
ambientAuth,
|
|
892
|
+
isolatedAuth,
|
|
893
|
+
writeProbe,
|
|
894
|
+
stagedWriteProbe: join(stagedExecutableDir, "write-proof"),
|
|
895
|
+
deniedProbePaths
|
|
896
|
+
};
|
|
897
|
+
} catch (err) {
|
|
898
|
+
await cleanup();
|
|
899
|
+
throw err;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
function codexControlledConfig(opts) {
|
|
903
|
+
const rules = /* @__PURE__ */ new Map();
|
|
904
|
+
const broadDeniedPaths = minimalPathRoots([
|
|
905
|
+
...opts.deniedHostRoots.map((path) => resolve(path)),
|
|
906
|
+
tmpdir(),
|
|
907
|
+
opts.ambientTmp,
|
|
908
|
+
"/proc",
|
|
909
|
+
"/run/docker",
|
|
910
|
+
"/run/containerd"
|
|
911
|
+
]);
|
|
912
|
+
for (const path of broadDeniedPaths) rules.set(path, "deny");
|
|
913
|
+
const workspaceRoot = dirname(opts.workspaceTemp);
|
|
914
|
+
for (const path of opts.readDeniedPaths) if (!broadDeniedPaths.some((root) => isSameOrDescendant(path, root)) || isSameOrDescendant(path, workspaceRoot)) rules.set(path, "deny");
|
|
915
|
+
if (!rules.has(opts.workspaceTemp)) rules.set(opts.workspaceTemp, "write");
|
|
916
|
+
if (!rules.has(opts.stagedExecutableDir)) rules.set(opts.stagedExecutableDir, "read");
|
|
917
|
+
const filesystemRules = [...rules].sort(([a], [b]) => a.localeCompare(b)).map(([path, access]) => `${JSON.stringify(path)} = ${JSON.stringify(access)}`).join("\n");
|
|
918
|
+
return `default_permissions = "${CODEX_PERMISSION_PROFILE}"
|
|
919
|
+
approval_policy = "never"
|
|
920
|
+
allow_login_shell = false
|
|
921
|
+
service_tier = "default"
|
|
922
|
+
|
|
923
|
+
[shell_environment_policy]
|
|
924
|
+
inherit = "core"
|
|
925
|
+
ignore_default_excludes = false
|
|
926
|
+
exclude = ["^CODEX_HOME$", "(?i).*TOKEN.*", "(?i).*KEY.*", "(?i).*SECRET.*", "(?i).*PASSWORD.*", "(?i).*CREDENTIAL.*", "(?i).*AUTH.*", "(?i).*COOKIE.*"]
|
|
927
|
+
set = { TMPDIR = ${JSON.stringify(opts.workspaceTemp)} }
|
|
928
|
+
|
|
929
|
+
[permissions.${CODEX_PERMISSION_PROFILE}.filesystem]
|
|
930
|
+
":minimal" = "read"
|
|
931
|
+
${filesystemRules}
|
|
932
|
+
":slash_tmp" = "deny"
|
|
933
|
+
|
|
934
|
+
[permissions.${CODEX_PERMISSION_PROFILE}.filesystem.":workspace_roots"]
|
|
935
|
+
"." = "write"
|
|
936
|
+
".git" = "deny"
|
|
937
|
+
|
|
938
|
+
[permissions.${CODEX_PERMISSION_PROFILE}.network]
|
|
939
|
+
enabled = false
|
|
940
|
+
|
|
941
|
+
[permissions.${CODEX_PERMISSION_PROFILE}.network.unix_sockets]
|
|
942
|
+
"/var/run/docker.sock" = "deny"
|
|
943
|
+
"/run/docker.sock" = "deny"
|
|
944
|
+
"/var/run/containerd/containerd.sock" = "deny"
|
|
945
|
+
"/run/containerd/containerd.sock" = "deny"
|
|
946
|
+
`;
|
|
947
|
+
}
|
|
948
|
+
function isSameOrDescendant(path, root) {
|
|
949
|
+
const normalizedPath = resolve(path);
|
|
950
|
+
const normalizedRoot = resolve(root);
|
|
951
|
+
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`);
|
|
952
|
+
}
|
|
953
|
+
function minimalPathRoots(paths) {
|
|
954
|
+
const ordered = [...new Set(paths.map((path) => resolve(path)))].sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
955
|
+
return ordered.filter((path, index) => !ordered.slice(0, index).some((root) => isSameOrDescendant(path, root)));
|
|
956
|
+
}
|
|
957
|
+
async function normalizeReadDeniedPaths(paths) {
|
|
958
|
+
if (!Array.isArray(paths)) throw new Error("runLocalHarness: codexReadDeniedPaths must be an array of absolute paths");
|
|
959
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
960
|
+
for (const path of paths) {
|
|
961
|
+
if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path)) throw new Error("runLocalHarness: codexReadDeniedPaths must contain absolute paths");
|
|
962
|
+
const absolute = resolve(path);
|
|
963
|
+
normalized.add(absolute);
|
|
964
|
+
try {
|
|
965
|
+
normalized.add(await realpath(absolute));
|
|
966
|
+
} catch (err) {
|
|
967
|
+
if (err.code !== "ENOENT") throw err;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return [...normalized].sort();
|
|
971
|
+
}
|
|
972
|
+
async function resolveCodexNativeExecutable(command, env) {
|
|
973
|
+
const candidates = command.includes(sep) || isAbsolute(command) ? [resolve(command)] : (env.PATH ?? "").split(delimiter).filter(Boolean).map((entry) => join(entry, command));
|
|
974
|
+
const seen = /* @__PURE__ */ new Set();
|
|
975
|
+
for (const candidate of candidates) {
|
|
976
|
+
let resolved;
|
|
977
|
+
try {
|
|
978
|
+
await access(candidate, constants.X_OK);
|
|
979
|
+
resolved = await realpath(candidate);
|
|
980
|
+
} catch {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (seen.has(resolved)) continue;
|
|
984
|
+
seen.add(resolved);
|
|
985
|
+
if (await isStaticElfExecutable(resolved)) return resolved;
|
|
986
|
+
const packageRoot = await findCodexPackageRoot(resolved);
|
|
987
|
+
if (!packageRoot) continue;
|
|
988
|
+
const bundled = await findBundledCodexExecutable(packageRoot);
|
|
989
|
+
if (bundled) return bundled;
|
|
990
|
+
}
|
|
991
|
+
throw new Error("runLocalHarness: could not resolve the native Codex executable from the requested command");
|
|
992
|
+
}
|
|
993
|
+
async function findCodexPackageRoot(file) {
|
|
994
|
+
let cursor = dirname(file);
|
|
995
|
+
while (true) {
|
|
996
|
+
try {
|
|
997
|
+
if (JSON.parse(await readFile(join(cursor, "package.json"), "utf8")).name === "@openai/codex") return cursor;
|
|
998
|
+
} catch {}
|
|
999
|
+
const parent = dirname(cursor);
|
|
1000
|
+
if (parent === cursor) return void 0;
|
|
1001
|
+
cursor = parent;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async function findBundledCodexExecutable(packageRoot) {
|
|
1005
|
+
const target = linuxCodexTarget();
|
|
1006
|
+
const candidates = [join(packageRoot, "node_modules", "@openai", target.packageName, "vendor", target.triple, "bin", "codex"), join(packageRoot, "vendor", target.triple, "bin", "codex")];
|
|
1007
|
+
for (const executable of candidates) if (await isStaticElfExecutable(executable)) return executable;
|
|
1008
|
+
}
|
|
1009
|
+
function linuxCodexTarget() {
|
|
1010
|
+
if (process.platform !== "linux") throw new Error("runLocalHarness: reproducible Codex mode requires Linux");
|
|
1011
|
+
if (process.arch === "x64") return {
|
|
1012
|
+
packageName: "codex-linux-x64",
|
|
1013
|
+
triple: "x86_64-unknown-linux-musl"
|
|
1014
|
+
};
|
|
1015
|
+
if (process.arch === "arm64") return {
|
|
1016
|
+
packageName: "codex-linux-arm64",
|
|
1017
|
+
triple: "aarch64-unknown-linux-musl"
|
|
1018
|
+
};
|
|
1019
|
+
throw new Error(`runLocalHarness: unsupported Linux architecture ${process.arch}`);
|
|
1020
|
+
}
|
|
1021
|
+
async function isStaticElfExecutable(path) {
|
|
1022
|
+
let handle;
|
|
1023
|
+
try {
|
|
1024
|
+
await access(path, constants.X_OK);
|
|
1025
|
+
handle = await open(path, "r");
|
|
1026
|
+
const header = Buffer.alloc(64);
|
|
1027
|
+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
|
1028
|
+
if (bytesRead !== header.length || !header.subarray(0, 4).equals(Buffer.from([
|
|
1029
|
+
127,
|
|
1030
|
+
69,
|
|
1031
|
+
76,
|
|
1032
|
+
70
|
|
1033
|
+
])) || header[4] !== 2 || header[5] !== 1) return false;
|
|
1034
|
+
const programHeaderOffset = Number(header.readBigUInt64LE(32));
|
|
1035
|
+
const programHeaderSize = header.readUInt16LE(54);
|
|
1036
|
+
const programHeaderCount = header.readUInt16LE(56);
|
|
1037
|
+
const expectedMachine = process.arch === "x64" ? 62 : process.arch === "arm64" ? 183 : -1;
|
|
1038
|
+
if (![2, 3].includes(header.readUInt16LE(16)) || header.readUInt16LE(18) !== expectedMachine || !Number.isSafeInteger(programHeaderOffset) || programHeaderOffset < 64 || programHeaderSize < 56 || programHeaderSize > 1024 || programHeaderCount === 0 || programHeaderCount > 1024) return false;
|
|
1039
|
+
const tableSize = programHeaderSize * programHeaderCount;
|
|
1040
|
+
const table = Buffer.alloc(tableSize);
|
|
1041
|
+
if ((await handle.read(table, 0, tableSize, programHeaderOffset)).bytesRead !== tableSize) return false;
|
|
1042
|
+
for (let index = 0; index < programHeaderCount; index += 1) if (table.readUInt32LE(index * programHeaderSize) === 3) return false;
|
|
1043
|
+
return true;
|
|
1044
|
+
} catch {
|
|
1045
|
+
return false;
|
|
1046
|
+
} finally {
|
|
1047
|
+
await handle?.close();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function sha256File(path) {
|
|
1051
|
+
return new Promise((resolveDigest, reject) => {
|
|
1052
|
+
const hash = createHash("sha256");
|
|
1053
|
+
const stream = createReadStream(path);
|
|
1054
|
+
stream.on("error", reject);
|
|
1055
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
1056
|
+
stream.on("end", () => resolveDigest(hash.digest("hex")));
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
function signalProcessTree(child, signal) {
|
|
1060
|
+
if (process.platform !== "win32" && typeof child.pid === "number") try {
|
|
1061
|
+
process.kill(-child.pid, signal);
|
|
1062
|
+
return;
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
if (err.code === "ESRCH") return;
|
|
1065
|
+
}
|
|
1066
|
+
try {
|
|
1067
|
+
child.kill(signal);
|
|
1068
|
+
} catch {}
|
|
1069
|
+
}
|
|
1070
|
+
async function terminateProcessTreeAndConfirm(child, leaderClosed, context = "runLocalHarness") {
|
|
1071
|
+
signalProcessTree(child, "SIGTERM");
|
|
1072
|
+
if (process.platform === "win32" || typeof child.pid !== "number") {
|
|
1073
|
+
const graceDeadline = Date.now() + processKillGraceMs;
|
|
1074
|
+
while (!leaderClosed() && Date.now() < graceDeadline) await delayUntilNextProcessCheck(graceDeadline);
|
|
1075
|
+
if (!leaderClosed()) signalProcessTree(child, "SIGKILL");
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const processGroupId = child.pid;
|
|
1079
|
+
if (await waitForProcessGroupExit(processGroupId, Date.now() + processKillGraceMs)) return;
|
|
1080
|
+
signalProcessTree(child, "SIGKILL");
|
|
1081
|
+
if (await waitForProcessGroupExit(processGroupId, Date.now() + processGroupExitConfirmMs)) return;
|
|
1082
|
+
throw new Error(`${context}: process group ${processGroupId} survived SIGKILL`);
|
|
1083
|
+
}
|
|
1084
|
+
async function waitForProcessGroupExit(processGroupId, deadline) {
|
|
1085
|
+
while (processGroupExists(processGroupId)) {
|
|
1086
|
+
if (Date.now() >= deadline) return false;
|
|
1087
|
+
await delayUntilNextProcessCheck(deadline);
|
|
1088
|
+
}
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
async function delayUntilNextProcessCheck(deadline) {
|
|
1092
|
+
const delayMs = Math.min(processGroupExitPollMs, Math.max(0, deadline - Date.now()));
|
|
1093
|
+
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1094
|
+
}
|
|
1095
|
+
function processGroupExists(processGroupId) {
|
|
1096
|
+
try {
|
|
1097
|
+
process.kill(-processGroupId, 0);
|
|
1098
|
+
return true;
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
if (err.code === "ESRCH") return false;
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/** Parse and validate the one terminal usage event emitted by `codex exec --json`. */
|
|
1105
|
+
function parseCodexTokenUsage(stdout) {
|
|
1106
|
+
const completed = [];
|
|
1107
|
+
for (const [index, line] of stdout.split(/\r?\n/).entries()) {
|
|
1108
|
+
if (line.trim().length === 0) continue;
|
|
1109
|
+
let event;
|
|
1110
|
+
try {
|
|
1111
|
+
event = JSON.parse(line);
|
|
1112
|
+
} catch {
|
|
1113
|
+
throw new Error(`runLocalHarness: Codex JSONL line ${index + 1} is not valid JSON`);
|
|
1114
|
+
}
|
|
1115
|
+
if (isRecord(event) && event.type === "turn.completed") completed.push(event);
|
|
1116
|
+
}
|
|
1117
|
+
if (completed.length !== 1) throw new Error(`runLocalHarness: expected exactly one Codex turn.completed usage event, received ${completed.length}`);
|
|
1118
|
+
const event = completed[0];
|
|
1119
|
+
if (!isRecord(event) || !isRecord(event.usage)) throw new Error("runLocalHarness: Codex turn.completed event is missing usage");
|
|
1120
|
+
const usage = event.usage;
|
|
1121
|
+
const inputTokens = naturalNumber(usage.input_tokens, "input_tokens");
|
|
1122
|
+
const cachedInputTokens = naturalNumber(usage.cached_input_tokens, "cached_input_tokens");
|
|
1123
|
+
const outputTokens = naturalNumber(usage.output_tokens, "output_tokens");
|
|
1124
|
+
const reasoningOutputTokens = naturalNumber(usage.reasoning_output_tokens, "reasoning_output_tokens");
|
|
1125
|
+
if (cachedInputTokens > inputTokens) throw new Error("runLocalHarness: Codex cached_input_tokens exceeds input_tokens");
|
|
1126
|
+
if (reasoningOutputTokens > outputTokens) throw new Error("runLocalHarness: Codex reasoning_output_tokens exceeds output_tokens");
|
|
1127
|
+
return {
|
|
1128
|
+
inputTokens,
|
|
1129
|
+
cachedInputTokens,
|
|
1130
|
+
outputTokens,
|
|
1131
|
+
reasoningOutputTokens
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
function isRecord(value) {
|
|
1135
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1136
|
+
}
|
|
1137
|
+
function naturalNumber(value, field) {
|
|
1138
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`runLocalHarness: Codex usage.${field} must be a non-negative safe integer`);
|
|
1139
|
+
return value;
|
|
1140
|
+
}
|
|
1141
|
+
//#endregion
|
|
1142
|
+
//#region src/mcp/worktree.ts
|
|
1143
|
+
/**
|
|
1144
|
+
*
|
|
1145
|
+
* Git worktree helpers for the in-process delegation executor. Each
|
|
1146
|
+
* delegation runs in its own worktree so multiple parallel harness
|
|
1147
|
+
* subprocesses (claude / codex / opencode in a 3-way fanout) don't clobber
|
|
1148
|
+
* each other's edits on the shared workspace.
|
|
1149
|
+
*
|
|
1150
|
+
* Worktrees live under `<repoRoot>/.agent-worktrees/<runId>/`. After the
|
|
1151
|
+
* harness exits + the diff is captured, the worktree is removed.
|
|
1152
|
+
*
|
|
1153
|
+
* All operations spawn `git` via `child_process.spawn` synchronously
|
|
1154
|
+
* (via a `runGit` helper). Stays narrow on purpose: no commits, no rebases.
|
|
1155
|
+
* Diff capture stages all changes (`git add -A`) into the ephemeral worktree's
|
|
1156
|
+
* index so created (untracked) files appear in the `--cached` diff.
|
|
1157
|
+
*
|
|
1158
|
+
* @experimental
|
|
1159
|
+
*/
|
|
1160
|
+
async function runGitAsync(args, cwd, runner) {
|
|
1161
|
+
if (runner) return runner(args, { cwd });
|
|
1162
|
+
return new Promise((resolve, reject) => {
|
|
1163
|
+
const proc = spawn("git", args, {
|
|
1164
|
+
cwd,
|
|
1165
|
+
stdio: "pipe"
|
|
1166
|
+
});
|
|
1167
|
+
let stdout = "";
|
|
1168
|
+
let stderr = "";
|
|
1169
|
+
proc.stdout?.on("data", (c) => {
|
|
1170
|
+
stdout += String(c);
|
|
1171
|
+
});
|
|
1172
|
+
proc.stderr?.on("data", (c) => {
|
|
1173
|
+
stderr += String(c);
|
|
1174
|
+
});
|
|
1175
|
+
proc.on("error", reject);
|
|
1176
|
+
proc.on("close", (code) => resolve({
|
|
1177
|
+
stdout,
|
|
1178
|
+
stderr,
|
|
1179
|
+
exitCode: code ?? -1
|
|
1180
|
+
}));
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function ensureGitOk(step, result) {
|
|
1184
|
+
if (result.exitCode !== 0) throw gitFailure(step, result);
|
|
1185
|
+
}
|
|
1186
|
+
function gitFailure(step, result) {
|
|
1187
|
+
return /* @__PURE__ */ new Error(`worktree: git ${step} failed (exit ${result.exitCode}): ${result.stderr.slice(0, 400)}`);
|
|
1188
|
+
}
|
|
1189
|
+
/** Checkout a fresh git worktree for a delegation run on a new branch under `variantsDir`. @experimental */
|
|
1190
|
+
async function createWorktree(options) {
|
|
1191
|
+
const variants = options.variantsDir ?? ".agent-worktrees";
|
|
1192
|
+
const baseRef = options.baseRef ?? "HEAD";
|
|
1193
|
+
const branch = `delegate/${options.runId}`;
|
|
1194
|
+
const path = `${options.repoRoot.replace(/\/+$/, "")}/${variants}/${options.runId}`;
|
|
1195
|
+
const headSha = await runGitAsync(["rev-parse", baseRef], options.repoRoot, options.runGit);
|
|
1196
|
+
ensureGitOk(`rev-parse ${baseRef}`, headSha);
|
|
1197
|
+
const add = await runGitAsync([
|
|
1198
|
+
"worktree",
|
|
1199
|
+
"add",
|
|
1200
|
+
"-b",
|
|
1201
|
+
branch,
|
|
1202
|
+
path,
|
|
1203
|
+
baseRef
|
|
1204
|
+
], options.repoRoot, options.runGit);
|
|
1205
|
+
ensureGitOk(`worktree add ${path}`, add);
|
|
1206
|
+
return {
|
|
1207
|
+
path,
|
|
1208
|
+
baseSha: headSha.stdout.trim(),
|
|
1209
|
+
branch
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/** Stage worker changes and return the diff + shortstat, excluding declared input paths. @experimental */
|
|
1213
|
+
async function captureWorktreeDiff(options) {
|
|
1214
|
+
const baseRef = options.baseRef ?? options.worktree.baseSha;
|
|
1215
|
+
const pathspecs = worktreeDiffPathspecs(options.excludePaths ?? []);
|
|
1216
|
+
ensureGitOk("add -A", await runGitAsync([
|
|
1217
|
+
"add",
|
|
1218
|
+
"-A",
|
|
1219
|
+
...pathspecs.length > 0 ? [
|
|
1220
|
+
"--",
|
|
1221
|
+
".",
|
|
1222
|
+
...pathspecs
|
|
1223
|
+
] : []
|
|
1224
|
+
], options.worktree.path, options.runGit));
|
|
1225
|
+
const patch = await runGitAsync([
|
|
1226
|
+
"diff",
|
|
1227
|
+
"--cached",
|
|
1228
|
+
baseRef,
|
|
1229
|
+
...pathspecs.length > 0 ? [
|
|
1230
|
+
"--",
|
|
1231
|
+
".",
|
|
1232
|
+
...pathspecs
|
|
1233
|
+
] : []
|
|
1234
|
+
], options.worktree.path, options.runGit);
|
|
1235
|
+
ensureGitOk(`diff --cached ${baseRef}`, patch);
|
|
1236
|
+
const shortstat = await runGitAsync([
|
|
1237
|
+
"diff",
|
|
1238
|
+
"--cached",
|
|
1239
|
+
"--shortstat",
|
|
1240
|
+
baseRef,
|
|
1241
|
+
...pathspecs.length > 0 ? [
|
|
1242
|
+
"--",
|
|
1243
|
+
".",
|
|
1244
|
+
...pathspecs
|
|
1245
|
+
] : []
|
|
1246
|
+
], options.worktree.path, options.runGit);
|
|
1247
|
+
ensureGitOk(`diff --cached --shortstat ${baseRef}`, shortstat);
|
|
1248
|
+
const stats = parseShortstat(shortstat.stdout);
|
|
1249
|
+
return {
|
|
1250
|
+
patch: patch.stdout,
|
|
1251
|
+
stats
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
function worktreeDiffPathspecs(paths) {
|
|
1255
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
1256
|
+
for (const path of paths) {
|
|
1257
|
+
if (typeof path !== "string" || path.length === 0 || path.includes("\\") || hasControlCharacter(path) || isAbsolute(path) || win32.isAbsolute(path)) throw new Error(`worktree: excluded path must be a canonical repository-relative path: ${path}`);
|
|
1258
|
+
if (path.split("/").some((segment) => segment.length === 0 || segment === "." || segment === "..")) throw new Error(`worktree: excluded path must be a canonical repository-relative path: ${path}`);
|
|
1259
|
+
normalized.add(path);
|
|
1260
|
+
}
|
|
1261
|
+
return [...normalized].sort((left, right) => left.localeCompare(right)).map((path) => `:(top,exclude,literal)${path}`);
|
|
1262
|
+
}
|
|
1263
|
+
function hasControlCharacter(value) {
|
|
1264
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1265
|
+
const code = value.charCodeAt(index);
|
|
1266
|
+
if (code < 32 || code === 127) return true;
|
|
1267
|
+
}
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
function parseShortstat(text) {
|
|
1271
|
+
const out = {
|
|
1272
|
+
filesChanged: 0,
|
|
1273
|
+
insertions: 0,
|
|
1274
|
+
deletions: 0
|
|
1275
|
+
};
|
|
1276
|
+
const filesMatch = text.match(/(\d+)\s+files?\s+changed/);
|
|
1277
|
+
if (filesMatch?.[1]) out.filesChanged = Number(filesMatch[1]);
|
|
1278
|
+
const insertMatch = text.match(/(\d+)\s+insertions?/);
|
|
1279
|
+
if (insertMatch?.[1]) out.insertions = Number(insertMatch[1]);
|
|
1280
|
+
const deleteMatch = text.match(/(\d+)\s+deletions?/);
|
|
1281
|
+
if (deleteMatch?.[1]) out.deletions = Number(deleteMatch[1]);
|
|
1282
|
+
return out;
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Remove a git worktree and delete its branch. Already-removed paths are harmless; every other
|
|
1286
|
+
* Git failure rejects so callers cannot report a worktree as destroyed when cleanup failed.
|
|
1287
|
+
* @experimental
|
|
1288
|
+
*/
|
|
1289
|
+
async function removeWorktree(options) {
|
|
1290
|
+
const force = options.force ?? true;
|
|
1291
|
+
const args = ["worktree", "remove"];
|
|
1292
|
+
if (force) args.push("--force");
|
|
1293
|
+
args.push(options.worktree.path);
|
|
1294
|
+
const removal = await runGitAsync(args, options.repoRoot, options.runGit);
|
|
1295
|
+
const removalError = removal.exitCode !== 0 && !/not a working tree/iu.test(removal.stderr) ? gitFailure(`worktree remove ${options.worktree.path}`, removal) : void 0;
|
|
1296
|
+
const branchRemoval = await runGitAsync([
|
|
1297
|
+
"branch",
|
|
1298
|
+
"-D",
|
|
1299
|
+
options.worktree.branch
|
|
1300
|
+
], options.repoRoot, options.runGit);
|
|
1301
|
+
const branchError = branchRemoval.exitCode !== 0 && !/branch .+ not found/iu.test(branchRemoval.stderr) ? gitFailure(`branch -D ${options.worktree.branch}`, branchRemoval) : void 0;
|
|
1302
|
+
if (removalError && branchError) throw new AggregateError([removalError, branchError], "worktree: failed to remove worktree and branch");
|
|
1303
|
+
if (removalError) throw removalError;
|
|
1304
|
+
if (branchError) throw branchError;
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1307
|
+
//#region src/mcp/worktree-harness.ts
|
|
1308
|
+
/**
|
|
1309
|
+
*
|
|
1310
|
+
* The ONE worktree-harness execution core. The physical act — run a supervisor-authored
|
|
1311
|
+
* `AgentProfile` on a local coding-harness CLI (claude / codex / opencode) against a fresh git
|
|
1312
|
+
* worktree off `repoRoot`, capture the diff, derive the test/typecheck PASS signals, then clean
|
|
1313
|
+
* up — lives here ONCE. Two executors adapt it to two ports without re-implementing it:
|
|
1314
|
+
* - `createWorktreeCliExecutor` — the `Scope`/`Supervisor` leaf `Executor`.
|
|
1315
|
+
* - `createInProcessExecutor` — the `runAgentRounds` `SandboxClient` / coder-delegate path.
|
|
1316
|
+
*
|
|
1317
|
+
* §1.5 by construction: prompt + model reach the direct invocation, while file-backed resources
|
|
1318
|
+
* are lowered by the shared profile materializer and applied before spawn. Resource instructions
|
|
1319
|
+
* join the direct prompt because reproducible Codex intentionally disables ambient project docs.
|
|
1320
|
+
*
|
|
1321
|
+
* Lifecycle: `createWorktree` → materialize profile inputs → `harnessInvocation` +
|
|
1322
|
+
* `runLocalHarness` → `captureWorktreeDiff` excluding those inputs (BEFORE checks, so the patch is
|
|
1323
|
+
* the harness's output, not polluted by files a test run writes) → configured checks → return the
|
|
1324
|
+
* result + caller-owned `cleanup`. A throw cleans up before propagating.
|
|
1325
|
+
*
|
|
1326
|
+
* @experimental
|
|
1327
|
+
*/
|
|
1328
|
+
const defaultCheckOutputCap = 16e3;
|
|
1329
|
+
function materializerHarness(harness) {
|
|
1330
|
+
return harness === "claude" ? "claude-code" : harness;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Run the one worktree-harness operation. A failed run attempts cleanup before propagating; if
|
|
1334
|
+
* cleanup also fails, both errors are preserved. The caller cleans up a successful run.
|
|
1335
|
+
*/
|
|
1336
|
+
async function runWorktreeHarness(opts) {
|
|
1337
|
+
opts.signal?.throwIfAborted();
|
|
1338
|
+
const runHarness = opts.runHarness ?? runLocalHarness;
|
|
1339
|
+
const runCommand = opts.runCommand ?? defaultRunCommand;
|
|
1340
|
+
const checkTimeoutMs = opts.checkTimeoutMs ?? opts.harnessTimeoutMs ?? 300 * 1e3;
|
|
1341
|
+
const cap = opts.checkOutputCap ?? defaultCheckOutputCap;
|
|
1342
|
+
const worktree = await createWorktree({
|
|
1343
|
+
repoRoot: opts.repoRoot,
|
|
1344
|
+
runId: opts.runId,
|
|
1345
|
+
...opts.baseRef ? { baseRef: opts.baseRef } : {},
|
|
1346
|
+
...opts.runGit ? { runGit: opts.runGit } : {}
|
|
1347
|
+
});
|
|
1348
|
+
const cleanup = () => removeWorktree({
|
|
1349
|
+
worktree,
|
|
1350
|
+
repoRoot: opts.repoRoot,
|
|
1351
|
+
...opts.runGit ? { runGit: opts.runGit } : {}
|
|
1352
|
+
});
|
|
1353
|
+
try {
|
|
1354
|
+
assertSupportedWorktreeProfile(opts.profile, opts.harness);
|
|
1355
|
+
assertSafeProfileResourcePaths(opts.profile);
|
|
1356
|
+
const resourceInstructions = resolveResourceInstructions(opts.profile);
|
|
1357
|
+
const plan = materializeProfile(materializationOnlyProfile(opts.profile), materializerHarness(opts.harness));
|
|
1358
|
+
if (plan.unsupported.length > 0) throw new Error(`runWorktreeHarness: profile cannot be materialized for ${opts.harness}: ${plan.unsupported.map(({ dimension, reason }) => `${dimension}: ${reason}`).join("; ")}`);
|
|
1359
|
+
assertSafeMaterializedPaths(plan);
|
|
1360
|
+
const applied = applyWorkspacePlan(plan, worktree.path, { existingFiles: "reject" });
|
|
1361
|
+
if (applied.unsupported.length > 0) throw new Error("runWorktreeHarness: applied profile unexpectedly retained unsupported rows");
|
|
1362
|
+
const invocationProfile = profileWithResourceInstructions(opts.profile, resourceInstructions);
|
|
1363
|
+
const { command, args } = harnessInvocation(opts.harness, invocationProfile, opts.taskPrompt, {
|
|
1364
|
+
dangerouslySkipPermissions: opts.harness === "claude",
|
|
1365
|
+
...opts.codexReproducible ? { codexReproducible: true } : {}
|
|
1366
|
+
});
|
|
1367
|
+
const harnessResult = await runHarness({
|
|
1368
|
+
harness: opts.harness,
|
|
1369
|
+
cwd: worktree.path,
|
|
1370
|
+
taskPrompt: opts.taskPrompt,
|
|
1371
|
+
invocation: {
|
|
1372
|
+
command,
|
|
1373
|
+
args: [...args, ...applied.flags]
|
|
1374
|
+
},
|
|
1375
|
+
env: {
|
|
1376
|
+
...process.env,
|
|
1377
|
+
...applied.env
|
|
1378
|
+
},
|
|
1379
|
+
...opts.codexReproducible ? { codexReproducible: true } : {},
|
|
1380
|
+
...opts.codexReadDeniedPaths ? { codexReadDeniedPaths: opts.codexReadDeniedPaths } : {},
|
|
1381
|
+
...opts.harnessTimeoutMs !== void 0 ? { timeoutMs: opts.harnessTimeoutMs } : {},
|
|
1382
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
1383
|
+
});
|
|
1384
|
+
if (harnessResult.aborted || opts.signal?.aborted) throw new Error("runWorktreeHarness: harness was cancelled by the caller", { cause: opts.signal?.reason });
|
|
1385
|
+
opts.signal?.throwIfAborted();
|
|
1386
|
+
const diff = await captureWorktreeDiff({
|
|
1387
|
+
worktree,
|
|
1388
|
+
excludePaths: applied.written,
|
|
1389
|
+
...opts.runGit ? { runGit: opts.runGit } : {}
|
|
1390
|
+
});
|
|
1391
|
+
opts.signal?.throwIfAborted();
|
|
1392
|
+
const checks = await runWorktreeChecks({
|
|
1393
|
+
worktreePath: worktree.path,
|
|
1394
|
+
...opts.testCmd !== void 0 ? { testCmd: opts.testCmd } : {},
|
|
1395
|
+
...opts.typecheckCmd !== void 0 ? { typecheckCmd: opts.typecheckCmd } : {},
|
|
1396
|
+
timeoutMs: checkTimeoutMs,
|
|
1397
|
+
cap,
|
|
1398
|
+
runCommand,
|
|
1399
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
1400
|
+
});
|
|
1401
|
+
opts.signal?.throwIfAborted();
|
|
1402
|
+
return {
|
|
1403
|
+
worktree,
|
|
1404
|
+
result: {
|
|
1405
|
+
branch: worktree.branch,
|
|
1406
|
+
patch: diff.patch,
|
|
1407
|
+
stats: diff.stats,
|
|
1408
|
+
profileMaterialization: profileMaterializationReceipt(applied, resourceInstructions),
|
|
1409
|
+
harness: {
|
|
1410
|
+
name: opts.harness,
|
|
1411
|
+
exitCode: harnessResult.exitCode,
|
|
1412
|
+
timedOut: harnessResult.timedOut,
|
|
1413
|
+
killedBySignal: harnessResult.killedBySignal,
|
|
1414
|
+
durationMs: harnessResult.durationMs,
|
|
1415
|
+
stdout: harnessResult.stdout,
|
|
1416
|
+
stderr: harnessResult.stderr,
|
|
1417
|
+
...harnessResult.usage ? { usage: harnessResult.usage } : {},
|
|
1418
|
+
...harnessResult.evidence ? {
|
|
1419
|
+
cliVersion: harnessResult.evidence.cliVersion,
|
|
1420
|
+
executableSha256: harnessResult.evidence.executableSha256,
|
|
1421
|
+
requestedPromptSha256: harnessResult.evidence.requestedPromptSha256,
|
|
1422
|
+
effectivePromptSha256: harnessResult.evidence.effectivePromptSha256,
|
|
1423
|
+
nonPromptArgsSha256: harnessResult.evidence.nonPromptArgsSha256,
|
|
1424
|
+
controlledConfigSha256: harnessResult.evidence.controlledConfigSha256,
|
|
1425
|
+
readDeniedPaths: [...harnessResult.evidence.readDeniedPaths],
|
|
1426
|
+
readDeniedPathsSha256: harnessResult.evidence.readDeniedPathsSha256,
|
|
1427
|
+
readDeniedPathCount: harnessResult.evidence.readDeniedPathCount,
|
|
1428
|
+
executionPolicy: harnessResult.evidence.policy
|
|
1429
|
+
} : {}
|
|
1430
|
+
},
|
|
1431
|
+
...checks ? { checks } : {}
|
|
1432
|
+
},
|
|
1433
|
+
cleanup
|
|
1434
|
+
};
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
try {
|
|
1437
|
+
await cleanup();
|
|
1438
|
+
} catch (cleanupError) {
|
|
1439
|
+
throw new AggregateError([err, cleanupError], "runWorktreeHarness: run failed and worktree cleanup also failed");
|
|
1440
|
+
}
|
|
1441
|
+
throw err;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
function resolveResourceInstructions(profile) {
|
|
1445
|
+
const instructions = profile.resources?.instructions;
|
|
1446
|
+
if (instructions === void 0) return void 0;
|
|
1447
|
+
if (typeof instructions === "string") return instructions.trim().length > 0 ? instructions : void 0;
|
|
1448
|
+
if (instructions.kind === "inline" && typeof instructions.content === "string") return instructions.content.trim().length > 0 ? instructions.content : void 0;
|
|
1449
|
+
throw new Error("runWorktreeHarness: resources.instructions must be a string or inline resource; remote refs require pre-resolution");
|
|
1450
|
+
}
|
|
1451
|
+
function profileWithResourceInstructions(profile, resourceInstructions) {
|
|
1452
|
+
let resources;
|
|
1453
|
+
if (profile.resources) {
|
|
1454
|
+
const { instructions: _instructions, ...structuralResources } = profile.resources;
|
|
1455
|
+
resources = structuralResources;
|
|
1456
|
+
}
|
|
1457
|
+
return {
|
|
1458
|
+
...profile,
|
|
1459
|
+
...resources ? { resources } : {},
|
|
1460
|
+
...resourceInstructions === void 0 ? {} : { prompt: {
|
|
1461
|
+
...profile.prompt,
|
|
1462
|
+
instructions: [...profile.prompt?.instructions ?? [], resourceInstructions]
|
|
1463
|
+
} }
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
function materializationOnlyProfile(profile) {
|
|
1467
|
+
const { prompt: _prompt, model: _model, resources: profileResources, ...structuralProfile } = profile;
|
|
1468
|
+
let resources;
|
|
1469
|
+
if (profileResources) {
|
|
1470
|
+
const { instructions: _instructions, ...fileBackedResources } = profileResources;
|
|
1471
|
+
resources = fileBackedResources;
|
|
1472
|
+
}
|
|
1473
|
+
return {
|
|
1474
|
+
...structuralProfile,
|
|
1475
|
+
...resources ? { resources } : {}
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
function assertSupportedWorktreeProfile(profile, harness) {
|
|
1479
|
+
const unsupportedAxes = [
|
|
1480
|
+
hasEntries(profile.tools) ? "tools" : null,
|
|
1481
|
+
hasEntries(profile.permissions) ? "permissions" : null,
|
|
1482
|
+
profile.connections && profile.connections.length > 0 ? "connections" : null,
|
|
1483
|
+
hasEntries(profile.confidential) ? "confidential" : null,
|
|
1484
|
+
hasEntries(profile.modes) ? "modes" : null,
|
|
1485
|
+
hasEntries(profile.extensions) ? "extensions" : null
|
|
1486
|
+
].filter((axis) => axis !== null);
|
|
1487
|
+
if (profile.model?.reasoningEffort !== void 0 && harness !== "codex") unsupportedAxes.push("model.reasoningEffort");
|
|
1488
|
+
for (const [name, server] of Object.entries(profile.mcp ?? {})) {
|
|
1489
|
+
const path = `mcp[${JSON.stringify(name)}]`;
|
|
1490
|
+
if (server.enabled === false && harness !== "opencode") unsupportedAxes.push(`${path}.enabled`);
|
|
1491
|
+
if (hasEntries(server.headers) && harness === "codex") unsupportedAxes.push(`${path}.headers`);
|
|
1492
|
+
if (server.cwd !== void 0 && harness === "opencode") unsupportedAxes.push(`${path}.cwd`);
|
|
1493
|
+
}
|
|
1494
|
+
if (harness === "claude") for (const [event, commands] of Object.entries(profile.hooks ?? {})) for (const [index, command] of commands.entries()) {
|
|
1495
|
+
const path = `hooks[${JSON.stringify(event)}][${index}]`;
|
|
1496
|
+
if (hasEntries(command.env)) unsupportedAxes.push(`${path}.env`);
|
|
1497
|
+
if (command.blocking !== void 0) unsupportedAxes.push(`${path}.blocking`);
|
|
1498
|
+
}
|
|
1499
|
+
for (const [name, subagent] of Object.entries(profile.subagents ?? {})) {
|
|
1500
|
+
const path = `subagents[${JSON.stringify(name)}]`;
|
|
1501
|
+
if (hasEntries(subagent.permissions)) unsupportedAxes.push(`${path}.permissions`);
|
|
1502
|
+
if (subagent.maxSteps !== void 0) unsupportedAxes.push(`${path}.maxSteps`);
|
|
1503
|
+
if (hasEntries(subagent.tools) && harness !== "claude") unsupportedAxes.push(`${path}.tools`);
|
|
1504
|
+
}
|
|
1505
|
+
if (unsupportedAxes.length > 0) throw new Error(`runWorktreeHarness: profile requests unsupported worktree behavior: ${unsupportedAxes.join(", ")}`);
|
|
1506
|
+
}
|
|
1507
|
+
function hasEntries(value) {
|
|
1508
|
+
return value !== void 0 && Object.keys(value).length > 0;
|
|
1509
|
+
}
|
|
1510
|
+
function assertSafeMaterializedPaths(plan) {
|
|
1511
|
+
for (const file of plan.files) if (file.relPath.split("/").some(isGitMetadataSegment)) throw new Error(`runWorktreeHarness: profile file cannot target reserved Git metadata: ${file.relPath}`);
|
|
1512
|
+
}
|
|
1513
|
+
function assertSafeProfileResourcePaths(profile) {
|
|
1514
|
+
for (const file of profile.resources?.files ?? []) if (file.path.split("/").some(isGitMetadataSegment)) throw new Error(`runWorktreeHarness: profile file cannot target reserved Git metadata: ${file.path}`);
|
|
1515
|
+
}
|
|
1516
|
+
function isGitMetadataSegment(segment) {
|
|
1517
|
+
const windowsCanonical = segment.replace(/[ .]+$/u, "").toLowerCase();
|
|
1518
|
+
return windowsCanonical === ".git" || windowsCanonical.startsWith(".git:");
|
|
1519
|
+
}
|
|
1520
|
+
function profileMaterializationReceipt(applied, resourceInstructions) {
|
|
1521
|
+
const instructionBytes = resourceInstructions === void 0 ? null : Buffer.from(resourceInstructions, "utf8");
|
|
1522
|
+
return {
|
|
1523
|
+
workspacePlanDigest: applied.workspacePlanDigest,
|
|
1524
|
+
writtenPaths: [...applied.written],
|
|
1525
|
+
unsupported: [...applied.unsupported],
|
|
1526
|
+
environmentNames: Object.keys(applied.env).sort(),
|
|
1527
|
+
flags: [...applied.flags],
|
|
1528
|
+
resourceInstructions: instructionBytes ? {
|
|
1529
|
+
delivery: "invocation-prompt",
|
|
1530
|
+
sha256: `sha256:${createHash("sha256").update(instructionBytes).digest("hex")}`,
|
|
1531
|
+
byteLength: instructionBytes.byteLength
|
|
1532
|
+
} : {
|
|
1533
|
+
delivery: "none",
|
|
1534
|
+
sha256: null,
|
|
1535
|
+
byteLength: 0
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
/** Run the configured test + typecheck commands in the live worktree, projecting exit codes into
|
|
1540
|
+
* `checks`. Returns `undefined` when neither was configured (so the result omits `checks`). */
|
|
1541
|
+
async function runWorktreeChecks(opts) {
|
|
1542
|
+
opts.signal?.throwIfAborted();
|
|
1543
|
+
if (opts.testCmd === void 0 && opts.typecheckCmd === void 0) return void 0;
|
|
1544
|
+
const runCommand = opts.runCommand ?? defaultRunCommand;
|
|
1545
|
+
const run = async (command) => {
|
|
1546
|
+
opts.signal?.throwIfAborted();
|
|
1547
|
+
const res = await runCommand({
|
|
1548
|
+
command,
|
|
1549
|
+
cwd: opts.worktreePath,
|
|
1550
|
+
timeoutMs: opts.timeoutMs,
|
|
1551
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
1552
|
+
});
|
|
1553
|
+
opts.signal?.throwIfAborted();
|
|
1554
|
+
return {
|
|
1555
|
+
command,
|
|
1556
|
+
passed: res.exitCode === 0,
|
|
1557
|
+
exitCode: res.exitCode,
|
|
1558
|
+
output: res.output.length > opts.cap ? res.output.slice(-opts.cap) : res.output
|
|
1559
|
+
};
|
|
1560
|
+
};
|
|
1561
|
+
const checks = {};
|
|
1562
|
+
if (opts.testCmd !== void 0) checks.tests = await run(opts.testCmd);
|
|
1563
|
+
opts.signal?.throwIfAborted();
|
|
1564
|
+
if (opts.typecheckCmd !== void 0) checks.typecheck = await run(opts.typecheckCmd);
|
|
1565
|
+
opts.signal?.throwIfAborted();
|
|
1566
|
+
return checks;
|
|
1567
|
+
}
|
|
1568
|
+
/** Internal argv-safe process runner shared by worktree checks and improvement verifiers. */
|
|
1569
|
+
async function runSettledCommand(opts) {
|
|
1570
|
+
opts.signal?.throwIfAborted();
|
|
1571
|
+
return new Promise((resolve, reject) => {
|
|
1572
|
+
const child = spawn(opts.command, opts.args ?? [], {
|
|
1573
|
+
cwd: opts.cwd,
|
|
1574
|
+
stdio: [
|
|
1575
|
+
"ignore",
|
|
1576
|
+
"pipe",
|
|
1577
|
+
"pipe"
|
|
1578
|
+
],
|
|
1579
|
+
detached: process.platform !== "win32"
|
|
1580
|
+
});
|
|
1581
|
+
const stdout = [];
|
|
1582
|
+
const stderr = [];
|
|
1583
|
+
let settled = false;
|
|
1584
|
+
let leaderClosed = false;
|
|
1585
|
+
let timedOut = false;
|
|
1586
|
+
let termination = null;
|
|
1587
|
+
const terminate = () => {
|
|
1588
|
+
termination ??= terminateProcessTreeAndConfirm(child, () => leaderClosed, "defaultRunCommand");
|
|
1589
|
+
return termination;
|
|
1590
|
+
};
|
|
1591
|
+
const timer = setTimeout(() => {
|
|
1592
|
+
timedOut = true;
|
|
1593
|
+
terminate().catch(fail);
|
|
1594
|
+
}, opts.timeoutMs);
|
|
1595
|
+
const onAbort = () => void terminate().catch(fail);
|
|
1596
|
+
if (opts.signal) if (opts.signal.aborted) onAbort();
|
|
1597
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
1598
|
+
child.stdout?.on("data", (d) => stdout.push(String(d)));
|
|
1599
|
+
child.stderr?.on("data", (d) => stderr.push(String(d)));
|
|
1600
|
+
const finish = (fn) => {
|
|
1601
|
+
if (settled) return;
|
|
1602
|
+
settled = true;
|
|
1603
|
+
clearTimeout(timer);
|
|
1604
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1605
|
+
fn();
|
|
1606
|
+
};
|
|
1607
|
+
function fail(err) {
|
|
1608
|
+
finish(() => reject(err instanceof Error ? err : new Error(String(err))));
|
|
1609
|
+
}
|
|
1610
|
+
child.on("error", (err) => {
|
|
1611
|
+
leaderClosed = true;
|
|
1612
|
+
(termination ?? Promise.resolve()).then(() => fail(err), fail);
|
|
1613
|
+
});
|
|
1614
|
+
child.on("close", (code, killedBySignal) => {
|
|
1615
|
+
leaderClosed = true;
|
|
1616
|
+
(async () => {
|
|
1617
|
+
try {
|
|
1618
|
+
await (termination ?? terminate());
|
|
1619
|
+
opts.signal?.throwIfAborted();
|
|
1620
|
+
finish(() => resolve({
|
|
1621
|
+
exitCode: code,
|
|
1622
|
+
stdout: stdout.join(""),
|
|
1623
|
+
stderr: stderr.join(""),
|
|
1624
|
+
killedBySignal,
|
|
1625
|
+
timedOut
|
|
1626
|
+
}));
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
fail(err);
|
|
1629
|
+
}
|
|
1630
|
+
})();
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
/** Default verification-command runner — `/bin/sh -c <command>` in the worktree, capturing
|
|
1635
|
+
* combined stdout+stderr. Never throws on a non-zero exit (that IS the fail signal); spawn
|
|
1636
|
+
* failures and caller cancellation reject. Timeout terminates and confirms the whole process
|
|
1637
|
+
* group before returning. */
|
|
1638
|
+
async function defaultRunCommand(opts) {
|
|
1639
|
+
const result = await runSettledCommand({
|
|
1640
|
+
command: "/bin/sh",
|
|
1641
|
+
args: ["-c", opts.command],
|
|
1642
|
+
cwd: opts.cwd,
|
|
1643
|
+
timeoutMs: opts.timeoutMs,
|
|
1644
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
1645
|
+
});
|
|
1646
|
+
return {
|
|
1647
|
+
exitCode: result.exitCode,
|
|
1648
|
+
output: result.stdout + result.stderr
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
//#endregion
|
|
1652
|
+
//#region src/runtime/tool-loop.ts
|
|
1653
|
+
/** ≈ chars/4 over content + any tool-call arguments — a cheap, provider-agnostic size proxy. The
|
|
1654
|
+
* exact constant does not matter: it only has to track GROWTH so the threshold trips as history
|
|
1655
|
+
* accumulates, which a uniform chars/4 does. */
|
|
1656
|
+
function estimateConversationTokens(messages) {
|
|
1657
|
+
let chars = 0;
|
|
1658
|
+
for (const m of messages) {
|
|
1659
|
+
const content = m.content;
|
|
1660
|
+
chars += typeof content === "string" ? content.length : safeJsonLength(content);
|
|
1661
|
+
const calls = m.tool_calls;
|
|
1662
|
+
if (calls) for (const c of calls) chars += c.function?.arguments?.length ?? 0;
|
|
1663
|
+
}
|
|
1664
|
+
return Math.ceil(chars / 4);
|
|
1665
|
+
}
|
|
1666
|
+
function safeJsonLength(value) {
|
|
1667
|
+
if (value === void 0 || value === null) return 0;
|
|
1668
|
+
try {
|
|
1669
|
+
return JSON.stringify(value)?.length ?? 0;
|
|
1670
|
+
} catch {
|
|
1671
|
+
return String(value).length;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
/** Distill the conversation's accumulated middle into one note and splice it in, IF the estimated
|
|
1675
|
+
* size exceeds the threshold. Returns true when a compaction fired. The preserved head keeps the
|
|
1676
|
+
* system + task verbatim; everything after collapses to a single `user` progress note, so no
|
|
1677
|
+
* assistant `tool_calls` is left without its `tool` replies. */
|
|
1678
|
+
async function maybeCompact(messages, c, turn) {
|
|
1679
|
+
const head = c.preserveHead !== void 0 && c.preserveHead > 0 ? c.preserveHead : 2;
|
|
1680
|
+
if (messages.length <= head + 1) return false;
|
|
1681
|
+
const estimate = c.estimateTokens ?? estimateConversationTokens;
|
|
1682
|
+
const before = estimate(messages);
|
|
1683
|
+
if (before <= c.thresholdTokens) return false;
|
|
1684
|
+
const digest = await c.distill([...messages]);
|
|
1685
|
+
messages.splice(head, messages.length - head, {
|
|
1686
|
+
role: "user",
|
|
1687
|
+
content: `[earlier work compacted to save context — progress so far]\n${digest}`
|
|
1688
|
+
});
|
|
1689
|
+
c.onCompact?.({
|
|
1690
|
+
turn,
|
|
1691
|
+
beforeTokens: before,
|
|
1692
|
+
afterTokens: estimate(messages)
|
|
1693
|
+
});
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
async function runBrainLoop(opts) {
|
|
1697
|
+
const maxTurns = opts.maxTurns ?? 4;
|
|
1698
|
+
const messages = [...opts.initialMessages];
|
|
1699
|
+
let toolCalls = 0;
|
|
1700
|
+
let lastText = "";
|
|
1701
|
+
const usage = {
|
|
1702
|
+
input: 0,
|
|
1703
|
+
output: 0
|
|
1704
|
+
};
|
|
1705
|
+
const toolTrace = [];
|
|
1706
|
+
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
1707
|
+
if (opts.hooks?.stopBefore?.(turn)) break;
|
|
1708
|
+
await opts.hooks?.beforeTurn?.(turn, messages);
|
|
1709
|
+
if (opts.compaction) await maybeCompact(messages, opts.compaction, turn);
|
|
1710
|
+
const r = await opts.chat(messages, opts.tools);
|
|
1711
|
+
if (r.usage) {
|
|
1712
|
+
usage.input += r.usage.input;
|
|
1713
|
+
usage.output += r.usage.output;
|
|
1714
|
+
opts.hooks?.onUsage?.(r.usage);
|
|
1715
|
+
}
|
|
1716
|
+
if (r.content) lastText = r.content;
|
|
1717
|
+
if (r.toolCalls.length === 0) {
|
|
1718
|
+
if (r.content) messages.push({
|
|
1719
|
+
role: "assistant",
|
|
1720
|
+
content: r.content
|
|
1721
|
+
});
|
|
1722
|
+
return {
|
|
1723
|
+
final: lastText,
|
|
1724
|
+
turns: turn,
|
|
1725
|
+
toolCalls,
|
|
1726
|
+
toolTrace,
|
|
1727
|
+
usage,
|
|
1728
|
+
messages
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
messages.push({
|
|
1732
|
+
role: "assistant",
|
|
1733
|
+
content: r.content ?? "",
|
|
1734
|
+
tool_calls: r.toolCalls.map((tc) => ({
|
|
1735
|
+
id: tc.id,
|
|
1736
|
+
type: "function",
|
|
1737
|
+
function: {
|
|
1738
|
+
name: tc.name,
|
|
1739
|
+
arguments: tc.arguments
|
|
1740
|
+
}
|
|
1741
|
+
}))
|
|
1742
|
+
});
|
|
1743
|
+
for (const tc of r.toolCalls) {
|
|
1744
|
+
toolCalls += 1;
|
|
1745
|
+
let args = {};
|
|
1746
|
+
try {
|
|
1747
|
+
args = JSON.parse(tc.arguments);
|
|
1748
|
+
} catch {
|
|
1749
|
+
messages.push({
|
|
1750
|
+
role: "tool",
|
|
1751
|
+
tool_call_id: tc.id,
|
|
1752
|
+
content: `error: arguments were not valid JSON: ${tc.arguments.slice(0, 200)}`
|
|
1753
|
+
});
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const out = await opts.execute(tc.name, args);
|
|
1757
|
+
messages.push({
|
|
1758
|
+
role: "tool",
|
|
1759
|
+
tool_call_id: tc.id,
|
|
1760
|
+
content: out
|
|
1761
|
+
});
|
|
1762
|
+
toolTrace.push({
|
|
1763
|
+
name: tc.name,
|
|
1764
|
+
args: tc.arguments,
|
|
1765
|
+
result: out
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return {
|
|
1770
|
+
final: lastText,
|
|
1771
|
+
turns: maxTurns,
|
|
1772
|
+
toolCalls,
|
|
1773
|
+
toolTrace,
|
|
1774
|
+
usage,
|
|
1775
|
+
messages
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
//#endregion
|
|
1779
|
+
//#region src/runtime/router-client.ts
|
|
1780
|
+
/**
|
|
1781
|
+
* The one router chat client: direct OpenAI-compatible completions through the
|
|
1782
|
+
* Tangle router — the cheapest dial, no sandbox. Three layers: `routerChatWithUsage`
|
|
1783
|
+
* (chat-only), `routerChatWithTools` (one completion with function tools), and
|
|
1784
|
+
* `routerToolLoop` (the off-box agentic loop over tool-calling). Shared by the
|
|
1785
|
+
* built-in executors and the bench/lab harnesses.
|
|
1786
|
+
*
|
|
1787
|
+
* Reports REAL token usage so the backend-integrity guard sees a real backend.
|
|
1788
|
+
* Returns `undefined` usage when the provider omitted it — never a fabricated 0
|
|
1789
|
+
* (a phantom 0 reads as a free call downstream, which the gate would act on).
|
|
1790
|
+
*/
|
|
1791
|
+
/** One OpenAI-compatible chat completion through the Tangle router, returning text + REAL token usage (`undefined` when the provider omits it — never a fabricated 0). */
|
|
1792
|
+
async function routerChatWithUsage(cfg, messages, opts) {
|
|
1793
|
+
const url = `${cfg.routerBaseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
1794
|
+
const headers = {
|
|
1795
|
+
"content-type": "application/json",
|
|
1796
|
+
authorization: `Bearer ${cfg.routerKey}`
|
|
1797
|
+
};
|
|
1798
|
+
let temperature = opts?.temperature ?? .2;
|
|
1799
|
+
const body = () => ({
|
|
1800
|
+
model: cfg.model,
|
|
1801
|
+
messages,
|
|
1802
|
+
temperature,
|
|
1803
|
+
max_tokens: opts?.maxTokens ?? 8192,
|
|
1804
|
+
...opts?.reasoningEffort ? { reasoning_effort: opts.reasoningEffort } : {}
|
|
1805
|
+
});
|
|
1806
|
+
if (cfg.complete) return parseChatResult(await cfg.complete(body()), cfg.model);
|
|
1807
|
+
let lastErr = "";
|
|
1808
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
1809
|
+
const res = await fetch(url, {
|
|
1810
|
+
method: "POST",
|
|
1811
|
+
headers,
|
|
1812
|
+
body: JSON.stringify(body()),
|
|
1813
|
+
...opts?.signal ? { signal: opts.signal } : {}
|
|
1814
|
+
});
|
|
1815
|
+
if (res.ok) return parseChatResult(await res.json(), cfg.model);
|
|
1816
|
+
const status = res.status;
|
|
1817
|
+
const text = (await res.text()).slice(0, 200);
|
|
1818
|
+
lastErr = `router ${status}: ${text}`;
|
|
1819
|
+
if (status === 400 && /temperature/i.test(text) && temperature !== 1) {
|
|
1820
|
+
temperature = 1;
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
if (![
|
|
1824
|
+
408,
|
|
1825
|
+
425,
|
|
1826
|
+
429,
|
|
1827
|
+
500,
|
|
1828
|
+
502,
|
|
1829
|
+
503,
|
|
1830
|
+
504,
|
|
1831
|
+
520,
|
|
1832
|
+
522,
|
|
1833
|
+
524
|
|
1834
|
+
].includes(status)) throw new Error(lastErr);
|
|
1835
|
+
if (attempt < 4) await new Promise((r) => setTimeout(r, 800 * 2 ** attempt));
|
|
1836
|
+
}
|
|
1837
|
+
throw new Error(`${lastErr} (exhausted retries)`);
|
|
1838
|
+
}
|
|
1839
|
+
function parseChatResult(json, model) {
|
|
1840
|
+
const data = json;
|
|
1841
|
+
const u = data.usage;
|
|
1842
|
+
const usage = u && typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number" ? {
|
|
1843
|
+
input: u.prompt_tokens,
|
|
1844
|
+
output: u.completion_tokens
|
|
1845
|
+
} : void 0;
|
|
1846
|
+
const costUsd = usage && isModelPriced(model) ? estimateCost(usage.input, usage.output, model) : void 0;
|
|
1847
|
+
const msg = data.choices?.[0]?.message;
|
|
1848
|
+
const { content, reasoning } = splitReasoning(msg?.content ?? "", msg?.reasoning ?? msg?.reasoning_content);
|
|
1849
|
+
return {
|
|
1850
|
+
content,
|
|
1851
|
+
...reasoning ? { reasoning } : {},
|
|
1852
|
+
...usage ? { usage } : {},
|
|
1853
|
+
...costUsd !== void 0 ? { costUsd } : {}
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Normalize the two ways providers surface thinking-model reasoning into one shape:
|
|
1858
|
+
* a separate field (OpenRouter: `reasoning`, DeepSeek/Kimi: `reasoning_content`) or a
|
|
1859
|
+
* `<think>...</think>` block inlined at the head of `content` (Groq, some local runtimes).
|
|
1860
|
+
* An UNCLOSED `<think>` (the model hit max_tokens mid-thought) yields empty content and
|
|
1861
|
+
* everything as reasoning — which is honest: no final answer was emitted.
|
|
1862
|
+
*/
|
|
1863
|
+
function splitReasoning(rawContent, fieldReasoning) {
|
|
1864
|
+
const open = rawContent.indexOf("<think>");
|
|
1865
|
+
if (open !== -1) {
|
|
1866
|
+
const close = rawContent.indexOf("</think>", open);
|
|
1867
|
+
const inline = close !== -1 ? rawContent.slice(open + 7, close) : rawContent.slice(open + 7);
|
|
1868
|
+
const rest = close !== -1 ? rawContent.slice(0, open) + rawContent.slice(close + 8) : rawContent.slice(0, open);
|
|
1869
|
+
const reasoning = [fieldReasoning, inline.trim()].filter(Boolean).join("\n");
|
|
1870
|
+
return {
|
|
1871
|
+
content: rest.trim(),
|
|
1872
|
+
...reasoning ? { reasoning } : {}
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
return {
|
|
1876
|
+
content: rawContent,
|
|
1877
|
+
...fieldReasoning ? { reasoning: fieldReasoning } : {}
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* A router completion WITH tool-calling — the operator driver's LLM seam. Passes OpenAI-shape
|
|
1882
|
+
* `messages` (system/user/assistant-with-tool_calls/tool roles) + function `tools`, and returns the
|
|
1883
|
+
* assistant text plus the tool calls the model wants run. Same fail-loud + real-usage discipline as
|
|
1884
|
+
* `routerChatWithUsage`. `tool_choice: 'auto'` lets the model decide; the driver loops on the result.
|
|
1885
|
+
*/
|
|
1886
|
+
async function routerChatWithTools(cfg, messages, tools, opts) {
|
|
1887
|
+
const body = {
|
|
1888
|
+
model: cfg.model,
|
|
1889
|
+
messages,
|
|
1890
|
+
tools,
|
|
1891
|
+
tool_choice: opts?.toolChoice ?? "auto",
|
|
1892
|
+
temperature: opts?.temperature ?? .3,
|
|
1893
|
+
...opts?.maxTokens ? { max_tokens: opts.maxTokens } : {}
|
|
1894
|
+
};
|
|
1895
|
+
const data = cfg.complete ? await cfg.complete(body) : await (async () => {
|
|
1896
|
+
const res = await fetch(`${cfg.routerBaseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
1897
|
+
method: "POST",
|
|
1898
|
+
headers: {
|
|
1899
|
+
"content-type": "application/json",
|
|
1900
|
+
authorization: `Bearer ${cfg.routerKey}`
|
|
1901
|
+
},
|
|
1902
|
+
body: JSON.stringify(body),
|
|
1903
|
+
...opts?.signal ? { signal: opts.signal } : {}
|
|
1904
|
+
});
|
|
1905
|
+
if (!res.ok) throw new Error(`router ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
1906
|
+
return res.json();
|
|
1907
|
+
})();
|
|
1908
|
+
const msg = data.choices?.[0]?.message;
|
|
1909
|
+
const toolCalls = (msg?.tool_calls ?? []).map((tc, i) => ({
|
|
1910
|
+
id: tc.id ?? `call_${i}`,
|
|
1911
|
+
name: tc.function?.name ?? "",
|
|
1912
|
+
arguments: tc.function?.arguments ?? "{}"
|
|
1913
|
+
}));
|
|
1914
|
+
const u = data.usage;
|
|
1915
|
+
const usage = u && typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number" ? {
|
|
1916
|
+
input: u.prompt_tokens,
|
|
1917
|
+
output: u.completion_tokens
|
|
1918
|
+
} : void 0;
|
|
1919
|
+
const costUsd = usage && isModelPriced(cfg.model) ? estimateCost(usage.input, usage.output, cfg.model) : void 0;
|
|
1920
|
+
return {
|
|
1921
|
+
content: msg?.content ?? null,
|
|
1922
|
+
toolCalls,
|
|
1923
|
+
...usage ? { usage } : {},
|
|
1924
|
+
...costUsd !== void 0 ? { costUsd } : {}
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* The tool-using router backend: a real agentic loop OVER the Tangle router (which
|
|
1929
|
+
* supports tool-calling), off-box — no sandbox. Each turn is one router completion
|
|
1930
|
+
* with `tools`; if the model emits tool_calls, `execute` runs them on the host and
|
|
1931
|
+
* their results are folded back as `tool` messages; the loop repeats until the
|
|
1932
|
+
* model answers without a tool call or the turn budget is hit. One turn = one
|
|
1933
|
+
* inference call, so `maxTurns` is the equal-compute unit against random@k.
|
|
1934
|
+
*
|
|
1935
|
+
* This is the depth substrate for agentic gates (the worker ACTS, observes the real
|
|
1936
|
+
* result, and continues) that the chat-only `routerChatWithUsage` cannot express.
|
|
1937
|
+
*/
|
|
1938
|
+
async function routerToolLoop(cfg, system, user, tools, execute, opts) {
|
|
1939
|
+
return runBrainLoop({
|
|
1940
|
+
chat: (messages, toolSpecs) => routerChatWithTools(cfg, messages, toolSpecs, {
|
|
1941
|
+
...opts?.temperature !== void 0 ? { temperature: opts.temperature } : {},
|
|
1942
|
+
...opts?.maxTokens ? { maxTokens: opts.maxTokens } : {},
|
|
1943
|
+
...opts?.signal ? { signal: opts.signal } : {}
|
|
1944
|
+
}),
|
|
1945
|
+
tools,
|
|
1946
|
+
execute,
|
|
1947
|
+
initialMessages: opts?.initialMessages ?? [{
|
|
1948
|
+
role: "system",
|
|
1949
|
+
content: system
|
|
1950
|
+
}, {
|
|
1951
|
+
role: "user",
|
|
1952
|
+
content: user
|
|
1953
|
+
}],
|
|
1954
|
+
maxTurns: opts?.maxTurns ?? 4
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* The router as a supervisor BRAIN: the canonical `ToolLoopChat` seam backed by the router's
|
|
1959
|
+
* tool-calling. The driver's spawn/observe/steer/await/stop turns become real router tool-calls.
|
|
1960
|
+
* The turnkey production brain — tests script a mock `ToolLoopChat`; production passes
|
|
1961
|
+
* `routerBrain(cfg)`. No message translation: the loop already speaks the router's OpenAI shape.
|
|
1962
|
+
*/
|
|
1963
|
+
function routerBrain(cfg, opts = {}) {
|
|
1964
|
+
const temperature = opts.temperature ?? .4;
|
|
1965
|
+
return (messages, tools) => routerChatWithTools(cfg, messages, tools, {
|
|
1966
|
+
temperature,
|
|
1967
|
+
toolChoice: "auto"
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
//#endregion
|
|
1971
|
+
//#region src/runtime-hooks.ts
|
|
1972
|
+
/** Identity helper that types a {@link RuntimeHooks} literal so the fields are inferred. */
|
|
1973
|
+
function defineRuntimeHooks(hooks) {
|
|
1974
|
+
return hooks;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Merge several {@link RuntimeHooks} into one. Falsy entries are dropped (so you can
|
|
1978
|
+
* pass `flag && hooks`), and every observer's `onEvent`/`onDecisionPoint` fires for each
|
|
1979
|
+
* event. Use this to attach N observers to a loop instead of a second event bus.
|
|
1980
|
+
*/
|
|
1981
|
+
function composeRuntimeHooks(...entries) {
|
|
1982
|
+
const hooks = entries.filter((entry) => !!entry);
|
|
1983
|
+
return {
|
|
1984
|
+
onEvent: hooks.some((hook) => hook.onEvent) ? (event, context) => {
|
|
1985
|
+
const pending = [];
|
|
1986
|
+
for (const hook of hooks) {
|
|
1987
|
+
const result = hook.onEvent?.(event, context);
|
|
1988
|
+
if (isThenable(result)) pending.push(Promise.resolve(result));
|
|
1989
|
+
}
|
|
1990
|
+
if (pending.length > 0) return Promise.all(pending).then(() => void 0);
|
|
1991
|
+
} : void 0,
|
|
1992
|
+
onDecisionPoint: hooks.some((hook) => hook.onDecisionPoint) ? (point, context) => {
|
|
1993
|
+
const pending = [];
|
|
1994
|
+
for (const hook of hooks) {
|
|
1995
|
+
const result = hook.onDecisionPoint?.(point, context);
|
|
1996
|
+
if (isThenable(result)) pending.push(Promise.resolve(result));
|
|
1997
|
+
}
|
|
1998
|
+
if (pending.length > 0) return Promise.all(pending).then(() => void 0);
|
|
1999
|
+
} : void 0,
|
|
2000
|
+
onHookError: hooks.some((hook) => hook.onHookError) ? (error, context) => {
|
|
2001
|
+
const pending = [];
|
|
2002
|
+
for (const hook of hooks) {
|
|
2003
|
+
const result = hook.onHookError?.(error, context);
|
|
2004
|
+
if (isThenable(result)) pending.push(Promise.resolve(result));
|
|
2005
|
+
}
|
|
2006
|
+
if (pending.length > 0) return Promise.all(pending).then(() => void 0);
|
|
2007
|
+
} : void 0
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
/** Fire `hooks.onEvent`, swallowing sync throws and surfacing async failures to `onError`. */
|
|
2011
|
+
function notifyRuntimeHookEvent(hooks, event, context = {}) {
|
|
2012
|
+
const onEvent = hooks?.onEvent;
|
|
2013
|
+
if (!onEvent) return;
|
|
2014
|
+
try {
|
|
2015
|
+
const result = onEvent(event, context);
|
|
2016
|
+
if (isThenable(result)) result.catch((error) => {
|
|
2017
|
+
notifyRuntimeHookError(hooks, toError(error), {
|
|
2018
|
+
hook: "onEvent",
|
|
2019
|
+
eventId: event.id,
|
|
2020
|
+
target: event.target,
|
|
2021
|
+
phase: event.phase
|
|
2022
|
+
});
|
|
2023
|
+
});
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
notifyRuntimeHookError(hooks, toError(error), {
|
|
2026
|
+
hook: "onEvent",
|
|
2027
|
+
eventId: event.id,
|
|
2028
|
+
target: event.target,
|
|
2029
|
+
phase: event.phase
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
/** Fire `hooks.onDecisionPoint`, swallowing sync throws and surfacing async failures to `onError`. */
|
|
2034
|
+
function notifyRuntimeDecisionPoint(hooks, point, context = {}) {
|
|
2035
|
+
const onDecisionPoint = hooks?.onDecisionPoint;
|
|
2036
|
+
if (!onDecisionPoint) return;
|
|
2037
|
+
try {
|
|
2038
|
+
const result = onDecisionPoint(point, context);
|
|
2039
|
+
if (isThenable(result)) result.catch((error) => {
|
|
2040
|
+
notifyRuntimeHookError(hooks, toError(error), {
|
|
2041
|
+
hook: "onDecisionPoint",
|
|
2042
|
+
decisionId: point.id,
|
|
2043
|
+
decisionKind: point.kind
|
|
2044
|
+
});
|
|
2045
|
+
});
|
|
2046
|
+
} catch (error) {
|
|
2047
|
+
notifyRuntimeHookError(hooks, toError(error), {
|
|
2048
|
+
hook: "onDecisionPoint",
|
|
2049
|
+
decisionId: point.id,
|
|
2050
|
+
decisionKind: point.kind
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
function notifyRuntimeHookError(hooks, error, context) {
|
|
2055
|
+
try {
|
|
2056
|
+
const result = hooks?.onHookError?.(error, context);
|
|
2057
|
+
if (isThenable(result)) result.catch(() => void 0);
|
|
2058
|
+
} catch {}
|
|
2059
|
+
}
|
|
2060
|
+
function isThenable(value) {
|
|
2061
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
2062
|
+
}
|
|
2063
|
+
function toError(error) {
|
|
2064
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
2065
|
+
}
|
|
2066
|
+
//#endregion
|
|
2067
|
+
//#region src/runtime/supervise/progress.ts
|
|
2068
|
+
/** How long a worker may produce no metered activity before a `progress()` read calls it stalled.
|
|
2069
|
+
* Deliberately generous: a coding harness routinely spends minutes inside one tool call, and a
|
|
2070
|
+
* false stall that provokes a steer is worse than a late one. */
|
|
2071
|
+
const DEFAULT_STALL_AFTER_MS = 18e4;
|
|
2072
|
+
/** Create a bounded activity ring. `limit` caps memory for a worker that runs thousands of tools. */
|
|
2073
|
+
function createActivityLog(limit = 12) {
|
|
2074
|
+
const notes = [];
|
|
2075
|
+
const cap = Math.max(1, Math.floor(limit));
|
|
2076
|
+
return {
|
|
2077
|
+
push(note) {
|
|
2078
|
+
notes.push(note);
|
|
2079
|
+
if (notes.length > cap) notes.splice(0, notes.length - cap);
|
|
2080
|
+
},
|
|
2081
|
+
read: () => [...notes],
|
|
2082
|
+
last: () => notes[notes.length - 1],
|
|
2083
|
+
size: () => notes.length
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
/** Fold the scope-derived facts and the executor's optional enrichment into one read. Pure: the
|
|
2087
|
+
* caller supplies `now`, so a test can observe a stall without waiting for one. */
|
|
2088
|
+
function readWorkerProgress(scope, executor, now, stallAfterMs = DEFAULT_STALL_AFTER_MS) {
|
|
2089
|
+
const live = scope.status !== "done" && scope.status !== "failed" && scope.status !== "cancelled";
|
|
2090
|
+
const executorActivityAt = executor?.recentActivity?.length ? executor.recentActivity[executor.recentActivity.length - 1].at : 0;
|
|
2091
|
+
const lastActivityAt = Math.max(scope.lastActivityAt, executorActivityAt);
|
|
2092
|
+
const idleMs = Math.max(0, now - lastActivityAt);
|
|
2093
|
+
const note = executor?.note;
|
|
2094
|
+
return {
|
|
2095
|
+
id: scope.id,
|
|
2096
|
+
status: scope.status,
|
|
2097
|
+
live,
|
|
2098
|
+
steerable: scope.steerable,
|
|
2099
|
+
startedAt: scope.startedAt,
|
|
2100
|
+
lastActivityAt,
|
|
2101
|
+
idleMs,
|
|
2102
|
+
stalled: live && idleMs > stallAfterMs,
|
|
2103
|
+
stallAfterMs,
|
|
2104
|
+
turns: executor?.turns ?? scope.turns,
|
|
2105
|
+
tokens: scope.tokens,
|
|
2106
|
+
usd: scope.usd,
|
|
2107
|
+
pendingMessages: executor?.pendingMessages ?? 0,
|
|
2108
|
+
recentActivity: executor?.recentActivity ?? [],
|
|
2109
|
+
...note ? { note } : {}
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
//#endregion
|
|
2113
|
+
//#region src/runtime/supervise/wait.ts
|
|
2114
|
+
/**
|
|
2115
|
+
*
|
|
2116
|
+
* WAIT-STATES — a supervision-tree node that waits on wall-clock time or an external condition
|
|
2117
|
+
* without holding a worker, an executor, a sandbox, or a single LLM turn.
|
|
2118
|
+
*
|
|
2119
|
+
* A long-horizon run spends most of its wall-clock NOT computing: waiting for CI to finish,
|
|
2120
|
+
* for a nightly job to land, for a human to reply, for a rate-limit window to reopen. Before
|
|
2121
|
+
* this, the only way to express that was to keep something alive and re-ask — which spends
|
|
2122
|
+
* driver tokens per re-ask and pins a process to the wait.
|
|
2123
|
+
*
|
|
2124
|
+
* Two node kinds, both first-class:
|
|
2125
|
+
* - `timer` — wake at an ABSOLUTE wall-clock instant (`untilMs`).
|
|
2126
|
+
* - `poll` — re-run a named predicate every `intervalMs` until it returns true, or until an
|
|
2127
|
+
* absolute `timeoutAtMs` passes (CI status, file existence, an HTTP probe, an
|
|
2128
|
+
* inbox message).
|
|
2129
|
+
*
|
|
2130
|
+
* ── How this differs from `await_event`'s 15s poll fence (`DEFAULT_AWAIT_EVENT_TIMEOUT_MS`) ──
|
|
2131
|
+
*
|
|
2132
|
+
* They look similar and are not the same mechanism. `await_event` is an IN-RUN RENDEZVOUS: the
|
|
2133
|
+
* driver blocks on the coordination bus for the next event from a live worker, and the 15s fence
|
|
2134
|
+
* exists only so a remote MCP request does not exceed the client's timeout — the caller re-polls.
|
|
2135
|
+
* Every re-poll is another driver inference turn (real tokens) against a process that must stay
|
|
2136
|
+
* up, and nothing about that wait is recorded: kill the process and the wait is simply gone.
|
|
2137
|
+
*
|
|
2138
|
+
* A wait-state is a NODE, not a call. It has a node id in the tree, a journal record carrying its
|
|
2139
|
+
* ABSOLUTE deadline, and it settles through the same `Scope.next()` cursor as any worker. Nobody
|
|
2140
|
+
* is blocked on it — the driver can stop reasoning entirely, and the process can die. Cost while
|
|
2141
|
+
* waiting: zero LLM calls, zero executor, zero sandbox, zero conserved budget (a wait reserves
|
|
2142
|
+
* nothing from the pool). The only in-process residue is one timer entry.
|
|
2143
|
+
*
|
|
2144
|
+
* ── Why probes are NAMED, not passed as closures ────────────────────────────────────────────
|
|
2145
|
+
*
|
|
2146
|
+
* A wait must survive a process restart with its original deadline intact, and a closure cannot
|
|
2147
|
+
* be journaled. So a `poll` names its predicate (`probe: 'ci-green'`) and the run resolves it
|
|
2148
|
+
* through a `WaitProbeRegistry`. A brand-new process re-resolves the SAME name against its own
|
|
2149
|
+
* registry and re-arms the wait — which is what makes "kill the box, the wait keeps waiting"
|
|
2150
|
+
* true rather than aspirational.
|
|
2151
|
+
*
|
|
2152
|
+
* Absolute instants for the same reason: `untilMs` / `timeoutAtMs` are epoch ms, not durations,
|
|
2153
|
+
* so a resumed wait counts down from the original arm, not from the restart. `timerAt`/`pollFor`
|
|
2154
|
+
* build them from a duration when that is what the caller has.
|
|
2155
|
+
*
|
|
2156
|
+
* @experimental
|
|
2157
|
+
*/
|
|
2158
|
+
/** Build a `timer` spec from a DURATION. The instant is resolved once, at arm time — a resumed
|
|
2159
|
+
* wait re-uses the journaled instant, never a fresh `now + ms`. */
|
|
2160
|
+
function timerAt(ms, now) {
|
|
2161
|
+
return {
|
|
2162
|
+
kind: "timer",
|
|
2163
|
+
untilMs: now + ms
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
/** Build a bounded `poll` spec from a duration. */
|
|
2167
|
+
function pollFor(probe, opts, now) {
|
|
2168
|
+
return {
|
|
2169
|
+
kind: "poll",
|
|
2170
|
+
probe,
|
|
2171
|
+
intervalMs: opts.intervalMs,
|
|
2172
|
+
...opts.timeoutMs !== void 0 ? { timeoutAtMs: now + opts.timeoutMs } : {},
|
|
2173
|
+
...opts.args ? { args: opts.args } : {}
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
/** Registry over a plain name→predicate record. */
|
|
2177
|
+
function createWaitProbes(entries) {
|
|
2178
|
+
const map = new Map(Object.entries(entries));
|
|
2179
|
+
return { resolve: (name) => map.get(name) };
|
|
2180
|
+
}
|
|
2181
|
+
/** Narrow a settlement's `out` to a wait outcome — a wait settles on the SAME cursor as workers,
|
|
2182
|
+
* so a driver that mixes them tags them apart with this. */
|
|
2183
|
+
function isWaitOutcome(value) {
|
|
2184
|
+
return typeof value === "object" && value !== null && value.waitOutcome === true;
|
|
2185
|
+
}
|
|
2186
|
+
/** The absolute instant a spec is bounded by, or `undefined` for an unbounded poll. */
|
|
2187
|
+
function waitUntil(spec) {
|
|
2188
|
+
return spec.kind === "timer" ? spec.untilMs : spec.timeoutAtMs;
|
|
2189
|
+
}
|
|
2190
|
+
/** Structural validation, independent of the run. Returns null when the spec is usable. */
|
|
2191
|
+
function validateWaitSpec(spec) {
|
|
2192
|
+
if (spec.kind === "timer") return Number.isFinite(spec.untilMs) ? null : "timer.untilMs must be a finite epoch-ms instant";
|
|
2193
|
+
if (typeof spec.probe !== "string" || spec.probe.length === 0) return "poll.probe must be a non-empty probe name";
|
|
2194
|
+
if (!Number.isFinite(spec.intervalMs) || spec.intervalMs <= 0) return "poll.intervalMs must be > 0";
|
|
2195
|
+
if (spec.timeoutAtMs !== void 0 && !Number.isFinite(spec.timeoutAtMs)) return "poll.timeoutAtMs must be a finite epoch-ms instant";
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* A wait must not silently outlive the run's HARD wall-clock ceiling. When the conserved pool
|
|
2200
|
+
* carries an absolute `deadlineMs`, a wait bounded past it — or an unbounded poll — is REFUSED at
|
|
2201
|
+
* arm time (`deadline-exceeded`) rather than sleeping through a ceiling the caller already set.
|
|
2202
|
+
* The alternative (extending the pool's deadline to cover the wait) would let a wait override a
|
|
2203
|
+
* hard budget guard, which is exactly what mechanic D forbids; a run that intends week-long waits
|
|
2204
|
+
* either sets a deadline that covers them or sets none.
|
|
2205
|
+
*/
|
|
2206
|
+
function assertWaitWithinDeadline(spec, deadlineMs) {
|
|
2207
|
+
if (deadlineMs <= 0) return true;
|
|
2208
|
+
const until = waitUntil(spec);
|
|
2209
|
+
if (until === void 0) return false;
|
|
2210
|
+
return until <= deadlineMs;
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Drive one wait to its resolution. Spends nothing: a `timer` is one sleep, a `poll` is a sleep
|
|
2214
|
+
* between predicate checks. No LLM call, no executor, no box, no budget reservation.
|
|
2215
|
+
*/
|
|
2216
|
+
async function runWait(args) {
|
|
2217
|
+
const { spec, label, armedAt, resumed, signal, now } = args;
|
|
2218
|
+
const sleep = args.sleep ?? defaultSleep;
|
|
2219
|
+
if (signal.aborted) return {
|
|
2220
|
+
kind: "cancelled",
|
|
2221
|
+
reason: "aborted before arming"
|
|
2222
|
+
};
|
|
2223
|
+
if (spec.kind === "timer") {
|
|
2224
|
+
const remaining = spec.untilMs - now();
|
|
2225
|
+
if (remaining > 0) await sleep(remaining, signal);
|
|
2226
|
+
if (signal.aborted) return {
|
|
2227
|
+
kind: "cancelled",
|
|
2228
|
+
reason: "wait aborted"
|
|
2229
|
+
};
|
|
2230
|
+
return {
|
|
2231
|
+
kind: "woke",
|
|
2232
|
+
outcome: outcome("timer", "fired", label, spec.untilMs, armedAt, now(), 0, 0, resumed)
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
const probe = args.probes?.resolve(spec.probe);
|
|
2236
|
+
if (!probe) throw new ValidationError(`wait '${label}': poll probe '${spec.probe}' is not registered in this run's WaitProbeRegistry`);
|
|
2237
|
+
let polls = 0;
|
|
2238
|
+
let probeErrors = 0;
|
|
2239
|
+
for (;;) {
|
|
2240
|
+
if (signal.aborted) return {
|
|
2241
|
+
kind: "cancelled",
|
|
2242
|
+
reason: "wait aborted"
|
|
2243
|
+
};
|
|
2244
|
+
polls += 1;
|
|
2245
|
+
let fired = false;
|
|
2246
|
+
try {
|
|
2247
|
+
fired = await probe(spec.args, signal);
|
|
2248
|
+
} catch {
|
|
2249
|
+
probeErrors += 1;
|
|
2250
|
+
}
|
|
2251
|
+
if (fired) return {
|
|
2252
|
+
kind: "woke",
|
|
2253
|
+
outcome: outcome("poll", "fired", label, spec.timeoutAtMs, armedAt, now(), polls, probeErrors, resumed)
|
|
2254
|
+
};
|
|
2255
|
+
if (spec.timeoutAtMs !== void 0 && now() >= spec.timeoutAtMs) return {
|
|
2256
|
+
kind: "woke",
|
|
2257
|
+
outcome: outcome("poll", "timeout", label, spec.timeoutAtMs, armedAt, now(), polls, probeErrors, resumed)
|
|
2258
|
+
};
|
|
2259
|
+
await sleep(spec.timeoutAtMs === void 0 ? spec.intervalMs : Math.min(spec.intervalMs, Math.max(0, spec.timeoutAtMs - now())), signal);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
function outcome(kind, settled, label, untilMs, armedAt, wokenAt, polls, probeErrors, resumed) {
|
|
2263
|
+
return {
|
|
2264
|
+
waitOutcome: true,
|
|
2265
|
+
kind,
|
|
2266
|
+
settled,
|
|
2267
|
+
label,
|
|
2268
|
+
...untilMs !== void 0 ? { untilMs } : {},
|
|
2269
|
+
armedAt,
|
|
2270
|
+
wokenAt,
|
|
2271
|
+
polls,
|
|
2272
|
+
probeErrors,
|
|
2273
|
+
resumed
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
/** `setTimeout` that resolves early (and clears) when the scope aborts, so a cancelled wait
|
|
2277
|
+
* releases the event loop immediately instead of holding the process to its deadline. */
|
|
2278
|
+
function defaultSleep(ms, signal) {
|
|
2279
|
+
if (ms <= 0 || signal.aborted) return Promise.resolve();
|
|
2280
|
+
return new Promise((resolve) => {
|
|
2281
|
+
const timer = setTimeout(() => {
|
|
2282
|
+
signal.removeEventListener("abort", onAbort);
|
|
2283
|
+
resolve();
|
|
2284
|
+
}, ms);
|
|
2285
|
+
function onAbort() {
|
|
2286
|
+
clearTimeout(timer);
|
|
2287
|
+
resolve();
|
|
2288
|
+
}
|
|
2289
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
//#endregion
|
|
2293
|
+
//#region src/runtime/supervise/scope.ts
|
|
2294
|
+
/**
|
|
2295
|
+
*
|
|
2296
|
+
* The reactive `Scope` impl (KEYSTONE, build step 4 + the step-8 adapter).
|
|
2297
|
+
*
|
|
2298
|
+
* An `Agent.act` runs inside a `Scope`. It `spawn`s children dynamically and reacts to
|
|
2299
|
+
* them via `next()`. The scope owns ONE in-memory nursery — the authoritative live set —
|
|
2300
|
+
* and is the single place that drives a child's lifecycle: reserve budget atomically,
|
|
2301
|
+
* resolve a `Executor` through the open registry, run it (one-shot OR streaming),
|
|
2302
|
+
* fold its normalized `UsageEvent`s into a conserved `Spend`, reconcile the reservation
|
|
2303
|
+
* (refunding the unspent remainder), persist the result blob + journal records, and
|
|
2304
|
+
* deliver the `Settled` through the `next()` cursor.
|
|
2305
|
+
*
|
|
2306
|
+
* Three invariants this impl enforces by construction:
|
|
2307
|
+
* - `next()` is a ray.wait n=1 cursor over THIS scope's live set; it assigns the
|
|
2308
|
+
* monotonic `seq` (the recorded cursor order) at the moment it yields a settlement, so
|
|
2309
|
+
* replay re-delivers in the identical order — `seq` is never wall-clock.
|
|
2310
|
+
* - Budget is reserved at spawn and reconciled at settle through the shared `BudgetPool`,
|
|
2311
|
+
* so `spawn` fails CLOSED on an exhausted pool and total ≡ free + reserved + committed.
|
|
2312
|
+
* - `view` reads the in-memory nursery, never the journal — O(live), synchronous.
|
|
2313
|
+
*
|
|
2314
|
+
* The settle path is the only writer of journal `settled` events; the spawn path the only
|
|
2315
|
+
* writer of `spawned` events. The result blob is `put` BEFORE the journal `settled` record
|
|
2316
|
+
* references its `outRef`, so a crash can never leave a journaled ref with no blob.
|
|
2317
|
+
*
|
|
2318
|
+
* @experimental
|
|
2319
|
+
*/
|
|
2320
|
+
/**
|
|
2321
|
+
* The recursion seam key. A `Scope` seeds a value of this on each child's
|
|
2322
|
+
* `ExecutorContext.seams` so a child whose executor is a DRIVER can mount a NESTED `Scope`
|
|
2323
|
+
* over the SAME conserved pool at `depth+1`. A leaf executor never reads it. Single-sourced
|
|
2324
|
+
* here so the scope and the driver-executor agree on the seam without a circular import.
|
|
2325
|
+
*/
|
|
2326
|
+
const nestedScopeSeamKey = "nested-scope";
|
|
2327
|
+
function makeNestedScopeSeam(args, childNodeId) {
|
|
2328
|
+
return {
|
|
2329
|
+
depth: args.depth,
|
|
2330
|
+
...args.maxDepth !== void 0 ? { maxDepth: args.maxDepth } : {},
|
|
2331
|
+
journalRoot: args.root,
|
|
2332
|
+
mount(nestedRoot, signal) {
|
|
2333
|
+
return createScope({
|
|
2334
|
+
parentId: childNodeId,
|
|
2335
|
+
root: nestedRoot,
|
|
2336
|
+
pool: args.pool,
|
|
2337
|
+
journal: args.journal,
|
|
2338
|
+
blobs: args.blobs,
|
|
2339
|
+
executors: args.executors,
|
|
2340
|
+
seams: args.seams,
|
|
2341
|
+
depth: args.depth + 1,
|
|
2342
|
+
...args.maxDepth !== void 0 ? { maxDepth: args.maxDepth } : {},
|
|
2343
|
+
signal,
|
|
2344
|
+
...args.now ? { now: args.now } : {},
|
|
2345
|
+
...args.hooks ? { hooks: args.hooks } : {}
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
/** Create the reactive `Scope` a driver's `Agent.act` runs inside: spawn children on an atomically reserved conserved budget, settle via the `next()` cursor, journal for replay. */
|
|
2351
|
+
function createScope(args) {
|
|
2352
|
+
const children = /* @__PURE__ */ new Map();
|
|
2353
|
+
let spawnOrdinal = args.resumeFrom ? args.resumeFrom.maxSpawnOrdinal + 1 : 0;
|
|
2354
|
+
let cursorSeq = args.resumeFrom ? args.resumeFrom.maxCursorSeq + 1 : 0;
|
|
2355
|
+
let waitOrdinal = args.resumeFrom ? args.resumeFrom.maxWaitOrdinal + 1 : 0;
|
|
2356
|
+
let meterSeq = 0;
|
|
2357
|
+
const now = args.now ?? Date.now;
|
|
2358
|
+
const unclaimedWaits = new Map((args.resumeFrom?.waits ?? []).map((w) => [w.label, w]));
|
|
2359
|
+
function spawn(agent, task, opts) {
|
|
2360
|
+
if (args.maxDepth !== void 0 && args.depth >= args.maxDepth) return {
|
|
2361
|
+
ok: false,
|
|
2362
|
+
reason: "depth-exceeded"
|
|
2363
|
+
};
|
|
2364
|
+
const spec = agent.executorSpec;
|
|
2365
|
+
if (!isAgentSpec(spec)) throw new ValidationError(`scope.spawn: agent "${agent.name}" exposes no \`executorSpec\` (AgentSpec) to resolve a Executor`);
|
|
2366
|
+
const resolved = args.executors.resolve(spec);
|
|
2367
|
+
if (!resolved.succeeded) throw new ValidationError(`scope.spawn: ${resolved.error}`);
|
|
2368
|
+
const reservation = args.pool.reserve(opts.budget);
|
|
2369
|
+
if (!reservation.ok) return {
|
|
2370
|
+
ok: false,
|
|
2371
|
+
reason: reservation.reason
|
|
2372
|
+
};
|
|
2373
|
+
try {
|
|
2374
|
+
const ordinal = spawnOrdinal++;
|
|
2375
|
+
const id = `${args.parentId}:s${ordinal}`;
|
|
2376
|
+
const childAbort = new AbortController();
|
|
2377
|
+
const cascadeAbort = () => childAbort.abort();
|
|
2378
|
+
if (args.signal.aborted) childAbort.abort();
|
|
2379
|
+
else args.signal.addEventListener("abort", cascadeAbort, { once: true });
|
|
2380
|
+
const ctx = {
|
|
2381
|
+
signal: childAbort.signal,
|
|
2382
|
+
seams: {
|
|
2383
|
+
...args.seams,
|
|
2384
|
+
[nestedScopeSeamKey]: makeNestedScopeSeam(args, id)
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
const executor = resolved.value(spec, ctx);
|
|
2388
|
+
const handle = {
|
|
2389
|
+
id,
|
|
2390
|
+
label: opts.label,
|
|
2391
|
+
get status() {
|
|
2392
|
+
return children.get(id)?.status ?? "cancelled";
|
|
2393
|
+
},
|
|
2394
|
+
abort(reason) {
|
|
2395
|
+
childAbort.abort(reason);
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
const live = {
|
|
2399
|
+
id,
|
|
2400
|
+
status: "acquiring",
|
|
2401
|
+
runtime: executor.runtime,
|
|
2402
|
+
budget: opts.budget,
|
|
2403
|
+
label: opts.label,
|
|
2404
|
+
spent: zeroSpend(),
|
|
2405
|
+
settled: void 0,
|
|
2406
|
+
delivered: false,
|
|
2407
|
+
executorDone: false,
|
|
2408
|
+
startedAt: now(),
|
|
2409
|
+
lastActivityAt: now(),
|
|
2410
|
+
...executor.deliver ? { deliver: executor.deliver.bind(executor) } : {},
|
|
2411
|
+
...executor.progress ? { readProgress: executor.progress.bind(executor) } : {},
|
|
2412
|
+
...executor.traceSource ? { readTraceSource: executor.traceSource.bind(executor) } : {}
|
|
2413
|
+
};
|
|
2414
|
+
children.set(id, live);
|
|
2415
|
+
args.journal.appendEvent(args.root, {
|
|
2416
|
+
kind: "spawned",
|
|
2417
|
+
id,
|
|
2418
|
+
parent: args.parentId,
|
|
2419
|
+
label: opts.label,
|
|
2420
|
+
budget: opts.budget,
|
|
2421
|
+
runtime: executor.runtime,
|
|
2422
|
+
seq: ordinal,
|
|
2423
|
+
at: new Date(now()).toISOString()
|
|
2424
|
+
});
|
|
2425
|
+
notifyRuntimeHookEvent(args.hooks, {
|
|
2426
|
+
id: `${id}:spawn`,
|
|
2427
|
+
runId: args.root,
|
|
2428
|
+
target: "agent.spawn",
|
|
2429
|
+
phase: "after",
|
|
2430
|
+
timestamp: now(),
|
|
2431
|
+
stepIndex: ordinal,
|
|
2432
|
+
parentId: args.parentId,
|
|
2433
|
+
payload: {
|
|
2434
|
+
childId: id,
|
|
2435
|
+
label: opts.label,
|
|
2436
|
+
runtime: executor.runtime,
|
|
2437
|
+
budget: opts.budget,
|
|
2438
|
+
depth: args.depth
|
|
2439
|
+
}
|
|
2440
|
+
}, { signal: args.signal });
|
|
2441
|
+
live.settled = runChild(live, executor, childAbort, task, opts, args.pool, reservation.ticket, args.blobs, now).then((s) => {
|
|
2442
|
+
live.resolved = s;
|
|
2443
|
+
return s;
|
|
2444
|
+
}).finally(() => {
|
|
2445
|
+
args.signal.removeEventListener("abort", cascadeAbort);
|
|
2446
|
+
});
|
|
2447
|
+
return {
|
|
2448
|
+
ok: true,
|
|
2449
|
+
handle
|
|
2450
|
+
};
|
|
2451
|
+
} catch (err) {
|
|
2452
|
+
args.pool.reconcile(reservation.ticket, zeroSpend());
|
|
2453
|
+
throw err;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
async function next() {
|
|
2457
|
+
const undelivered = () => [...children.values()].filter((c) => !c.delivered);
|
|
2458
|
+
if (undelivered().length === 0) return null;
|
|
2459
|
+
for (;;) {
|
|
2460
|
+
const pending = undelivered();
|
|
2461
|
+
if (pending.length === 0) return null;
|
|
2462
|
+
const chosen = pending.find((c) => c.resolved !== void 0) ?? await raceFirstSettled(pending);
|
|
2463
|
+
if (chosen.delivered) continue;
|
|
2464
|
+
chosen.delivered = true;
|
|
2465
|
+
const seq = cursorSeq++;
|
|
2466
|
+
const settlement = chosen.resolved;
|
|
2467
|
+
if (!settlement) throw new ValidationError(`scope.next: child '${chosen.id}' won the settle race without a resolved value`);
|
|
2468
|
+
return finalizeSettlement(chosen, settlement, seq, args, now);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
async function nextResolved() {
|
|
2472
|
+
for (;;) {
|
|
2473
|
+
const candidates = [...children.values()].filter((c) => !c.delivered);
|
|
2474
|
+
const pick = candidates.find((c) => c.resolved !== void 0) ?? candidates.find((c) => c.executorDone);
|
|
2475
|
+
if (!pick) return null;
|
|
2476
|
+
const settlement = await pick.settled;
|
|
2477
|
+
if (pick.delivered) continue;
|
|
2478
|
+
pick.delivered = true;
|
|
2479
|
+
return finalizeSettlement(pick, settlement, cursorSeq++, args, now);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
function send(nodeId, msg) {
|
|
2483
|
+
const child = children.get(nodeId);
|
|
2484
|
+
if (!child || child.delivered || !child.deliver) return false;
|
|
2485
|
+
child.deliver(msg);
|
|
2486
|
+
child.lastActivityAt = now();
|
|
2487
|
+
return true;
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Arm a wait-state node. Deliberately NOT a `spawn` with a sleeping executor: it resolves no
|
|
2491
|
+
* `AgentSpec`, constructs no `Executor`, and — the point of the whole mechanic — reserves NOTHING
|
|
2492
|
+
* from the conserved pool, so a week-long wait costs zero tokens and zero dollars and cannot
|
|
2493
|
+
* starve a worker of budget.
|
|
2494
|
+
*
|
|
2495
|
+
* Fail-closed admission, mirroring `spawn`'s typed outcome. The deadline check is the one that
|
|
2496
|
+
* matters: a wait may not silently sleep past the pool's hard wall-clock ceiling, and it may not
|
|
2497
|
+
* extend that ceiling either (that would make a wait override a budget guard).
|
|
2498
|
+
*/
|
|
2499
|
+
function wait(spec, opts) {
|
|
2500
|
+
if (validateWaitSpec(spec) !== null) return {
|
|
2501
|
+
ok: false,
|
|
2502
|
+
reason: "invalid-spec"
|
|
2503
|
+
};
|
|
2504
|
+
if (spec.kind === "poll" && args.probes?.resolve(spec.probe) === void 0) return {
|
|
2505
|
+
ok: false,
|
|
2506
|
+
reason: "unknown-probe"
|
|
2507
|
+
};
|
|
2508
|
+
const adopted = unclaimedWaits.get(opts.label);
|
|
2509
|
+
if (adopted) unclaimedWaits.delete(opts.label);
|
|
2510
|
+
const effectiveSpec = adopted?.spec ?? spec;
|
|
2511
|
+
const armedAt = adopted?.armedAt ?? now();
|
|
2512
|
+
if (!assertWaitWithinDeadline(effectiveSpec, args.pool.readout().deadlineMs)) return {
|
|
2513
|
+
ok: false,
|
|
2514
|
+
reason: "deadline-exceeded"
|
|
2515
|
+
};
|
|
2516
|
+
const id = adopted ? adopted.id : `${args.parentId}:w${waitOrdinal}`;
|
|
2517
|
+
const ordinal = adopted ? adopted.ordinal : waitOrdinal;
|
|
2518
|
+
if (!adopted) waitOrdinal += 1;
|
|
2519
|
+
const waitAbort = new AbortController();
|
|
2520
|
+
const cascadeAbort = () => waitAbort.abort();
|
|
2521
|
+
if (args.signal.aborted) waitAbort.abort();
|
|
2522
|
+
else args.signal.addEventListener("abort", cascadeAbort, { once: true });
|
|
2523
|
+
const handle = {
|
|
2524
|
+
id,
|
|
2525
|
+
label: opts.label,
|
|
2526
|
+
get status() {
|
|
2527
|
+
return children.get(id)?.status ?? "cancelled";
|
|
2528
|
+
},
|
|
2529
|
+
abort(reason) {
|
|
2530
|
+
waitAbort.abort(reason);
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
const live = {
|
|
2534
|
+
id,
|
|
2535
|
+
status: "waiting",
|
|
2536
|
+
runtime: "wait",
|
|
2537
|
+
budget: {
|
|
2538
|
+
maxIterations: 0,
|
|
2539
|
+
maxTokens: 0
|
|
2540
|
+
},
|
|
2541
|
+
label: opts.label,
|
|
2542
|
+
spent: zeroSpend(),
|
|
2543
|
+
settled: void 0,
|
|
2544
|
+
delivered: false,
|
|
2545
|
+
executorDone: false,
|
|
2546
|
+
startedAt: armedAt,
|
|
2547
|
+
lastActivityAt: now(),
|
|
2548
|
+
wait: {
|
|
2549
|
+
spec: effectiveSpec,
|
|
2550
|
+
armedAt,
|
|
2551
|
+
label: opts.label
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
children.set(id, live);
|
|
2555
|
+
if (!adopted) args.journal.appendEvent(args.root, {
|
|
2556
|
+
kind: "waiting",
|
|
2557
|
+
id,
|
|
2558
|
+
parent: args.parentId,
|
|
2559
|
+
label: opts.label,
|
|
2560
|
+
spec: effectiveSpec,
|
|
2561
|
+
armedAt,
|
|
2562
|
+
seq: ordinal,
|
|
2563
|
+
at: new Date(now()).toISOString()
|
|
2564
|
+
});
|
|
2565
|
+
notifyRuntimeHookEvent(args.hooks, {
|
|
2566
|
+
id: `${id}:waiting`,
|
|
2567
|
+
runId: args.root,
|
|
2568
|
+
target: "agent.spawn",
|
|
2569
|
+
phase: "after",
|
|
2570
|
+
timestamp: now(),
|
|
2571
|
+
stepIndex: ordinal,
|
|
2572
|
+
parentId: args.parentId,
|
|
2573
|
+
payload: {
|
|
2574
|
+
childId: id,
|
|
2575
|
+
label: opts.label,
|
|
2576
|
+
runtime: "wait",
|
|
2577
|
+
wait: effectiveSpec,
|
|
2578
|
+
armedAt,
|
|
2579
|
+
resumed: adopted !== void 0
|
|
2580
|
+
}
|
|
2581
|
+
}, { signal: args.signal });
|
|
2582
|
+
live.settled = runWait({
|
|
2583
|
+
spec: effectiveSpec,
|
|
2584
|
+
label: opts.label,
|
|
2585
|
+
armedAt,
|
|
2586
|
+
resumed: adopted !== void 0,
|
|
2587
|
+
signal: waitAbort.signal,
|
|
2588
|
+
...args.probes ? { probes: args.probes } : {},
|
|
2589
|
+
now,
|
|
2590
|
+
...args.waitSleep ? { sleep: args.waitSleep } : {}
|
|
2591
|
+
}).then(async (resolution) => {
|
|
2592
|
+
live.executorDone = true;
|
|
2593
|
+
live.lastActivityAt = now();
|
|
2594
|
+
if (resolution.kind === "cancelled") return {
|
|
2595
|
+
kind: "down",
|
|
2596
|
+
reason: resolution.reason,
|
|
2597
|
+
infra: false,
|
|
2598
|
+
restartCount: 0
|
|
2599
|
+
};
|
|
2600
|
+
const outRef = contentAddress(resolution.outcome);
|
|
2601
|
+
await args.blobs.put(outRef, resolution.outcome);
|
|
2602
|
+
return {
|
|
2603
|
+
kind: "done",
|
|
2604
|
+
out: resolution.outcome,
|
|
2605
|
+
outRef,
|
|
2606
|
+
spent: zeroSpend()
|
|
2607
|
+
};
|
|
2608
|
+
}).catch((err) => {
|
|
2609
|
+
live.executorDone = true;
|
|
2610
|
+
return {
|
|
2611
|
+
kind: "down",
|
|
2612
|
+
reason: errMessage(err),
|
|
2613
|
+
infra: true,
|
|
2614
|
+
restartCount: 0
|
|
2615
|
+
};
|
|
2616
|
+
}).then((s) => {
|
|
2617
|
+
live.resolved = s;
|
|
2618
|
+
return s;
|
|
2619
|
+
}).finally(() => {
|
|
2620
|
+
args.signal.removeEventListener("abort", cascadeAbort);
|
|
2621
|
+
});
|
|
2622
|
+
return {
|
|
2623
|
+
ok: true,
|
|
2624
|
+
handle
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
function progress(nodeId, opts = {}) {
|
|
2628
|
+
const child = children.get(nodeId);
|
|
2629
|
+
if (!child) return void 0;
|
|
2630
|
+
let fromExecutor;
|
|
2631
|
+
try {
|
|
2632
|
+
fromExecutor = child.readProgress?.();
|
|
2633
|
+
} catch {
|
|
2634
|
+
fromExecutor = void 0;
|
|
2635
|
+
}
|
|
2636
|
+
return readWorkerProgress({
|
|
2637
|
+
id: child.id,
|
|
2638
|
+
status: child.status,
|
|
2639
|
+
steerable: child.deliver !== void 0 && !child.delivered,
|
|
2640
|
+
startedAt: child.startedAt,
|
|
2641
|
+
lastActivityAt: child.lastActivityAt,
|
|
2642
|
+
turns: child.spent.iterations,
|
|
2643
|
+
tokens: child.spent.tokens,
|
|
2644
|
+
usd: child.spent.usd
|
|
2645
|
+
}, fromExecutor, opts.now ?? now(), opts.stallAfterMs ?? 18e4);
|
|
2646
|
+
}
|
|
2647
|
+
function traceSource(nodeId) {
|
|
2648
|
+
const child = children.get(nodeId);
|
|
2649
|
+
if (!child) return void 0;
|
|
2650
|
+
try {
|
|
2651
|
+
return child.readTraceSource?.();
|
|
2652
|
+
} catch {
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
async function meter(spend, detail) {
|
|
2657
|
+
const seq = meterSeq++;
|
|
2658
|
+
args.pool.observe(spend);
|
|
2659
|
+
await args.journal.appendEvent(args.root, {
|
|
2660
|
+
kind: "metered",
|
|
2661
|
+
id: args.parentId,
|
|
2662
|
+
spend,
|
|
2663
|
+
seq,
|
|
2664
|
+
at: new Date(now()).toISOString()
|
|
2665
|
+
});
|
|
2666
|
+
notifyRuntimeHookEvent(args.hooks, {
|
|
2667
|
+
id: `${args.parentId}:meter:${seq}`,
|
|
2668
|
+
runId: args.root,
|
|
2669
|
+
target: "agent.turn",
|
|
2670
|
+
phase: "after",
|
|
2671
|
+
timestamp: now(),
|
|
2672
|
+
parentId: args.parentId,
|
|
2673
|
+
payload: {
|
|
2674
|
+
spend,
|
|
2675
|
+
...detail ?? {}
|
|
2676
|
+
}
|
|
2677
|
+
}, { signal: args.signal });
|
|
2678
|
+
}
|
|
2679
|
+
const resume = args.resumeFrom ? {
|
|
2680
|
+
settled: args.resumeFrom.settled,
|
|
2681
|
+
view: args.resumeFrom.view,
|
|
2682
|
+
waits: args.resumeFrom.waits
|
|
2683
|
+
} : void 0;
|
|
2684
|
+
return {
|
|
2685
|
+
spawn,
|
|
2686
|
+
next,
|
|
2687
|
+
nextResolved,
|
|
2688
|
+
send,
|
|
2689
|
+
wait,
|
|
2690
|
+
progress,
|
|
2691
|
+
traceSource,
|
|
2692
|
+
signal: args.signal,
|
|
2693
|
+
meter,
|
|
2694
|
+
...resume ? { resume } : {},
|
|
2695
|
+
get view() {
|
|
2696
|
+
return makeTreeView(args.parentId, children);
|
|
2697
|
+
},
|
|
2698
|
+
get budget() {
|
|
2699
|
+
return args.pool.readout();
|
|
2700
|
+
}
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
/** Await whichever pending child settles first, returning the child (its `resolved` is set
|
|
2704
|
+
* by the time this resolves because `runChild`'s `.then` sets it before the promise
|
|
2705
|
+
* resolves downstream). */
|
|
2706
|
+
async function raceFirstSettled(pending) {
|
|
2707
|
+
return Promise.race(pending.map((c) => c.settled.then(() => c)));
|
|
2708
|
+
}
|
|
2709
|
+
/** Stamp the cursor `seq`, write the `settled` journal record, and project the
|
|
2710
|
+
* `PreSeqSettled` into the frozen `Settled` the driver branches on. */
|
|
2711
|
+
async function finalizeSettlement(child, settlement, seq, args, now) {
|
|
2712
|
+
const handle = frozenHandle(child);
|
|
2713
|
+
if (child.wait) return finalizeWait(child, settlement, seq, args, now, handle);
|
|
2714
|
+
if (settlement.kind === "down") {
|
|
2715
|
+
child.status = "failed";
|
|
2716
|
+
await args.journal.appendEvent(args.root, {
|
|
2717
|
+
kind: "settled",
|
|
2718
|
+
id: child.id,
|
|
2719
|
+
status: "down",
|
|
2720
|
+
spent: child.spent,
|
|
2721
|
+
infra: settlement.infra,
|
|
2722
|
+
seq,
|
|
2723
|
+
at: new Date(now()).toISOString()
|
|
2724
|
+
});
|
|
2725
|
+
if (settlement.metered) await args.journal.appendEvent(args.root, {
|
|
2726
|
+
kind: "metered",
|
|
2727
|
+
id: child.id,
|
|
2728
|
+
spend: settlement.metered,
|
|
2729
|
+
seq,
|
|
2730
|
+
at: new Date(now()).toISOString()
|
|
2731
|
+
});
|
|
2732
|
+
notifyRuntimeHookEvent(args.hooks, {
|
|
2733
|
+
id: `${child.id}:settled`,
|
|
2734
|
+
runId: args.root,
|
|
2735
|
+
target: "agent.child",
|
|
2736
|
+
phase: "after",
|
|
2737
|
+
timestamp: now(),
|
|
2738
|
+
stepIndex: seq,
|
|
2739
|
+
parentId: args.parentId,
|
|
2740
|
+
payload: {
|
|
2741
|
+
childId: child.id,
|
|
2742
|
+
status: "down",
|
|
2743
|
+
reason: settlement.reason,
|
|
2744
|
+
infra: settlement.infra,
|
|
2745
|
+
spent: child.spent
|
|
2746
|
+
}
|
|
2747
|
+
}, { signal: args.signal });
|
|
2748
|
+
return {
|
|
2749
|
+
kind: "down",
|
|
2750
|
+
handle,
|
|
2751
|
+
reason: settlement.reason,
|
|
2752
|
+
infra: settlement.infra,
|
|
2753
|
+
restartCount: settlement.restartCount,
|
|
2754
|
+
seq
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
child.status = "done";
|
|
2758
|
+
child.outRef = settlement.outRef;
|
|
2759
|
+
child.spent = settlement.spent;
|
|
2760
|
+
await args.journal.appendEvent(args.root, {
|
|
2761
|
+
kind: "settled",
|
|
2762
|
+
id: child.id,
|
|
2763
|
+
status: "done",
|
|
2764
|
+
outRef: settlement.outRef,
|
|
2765
|
+
...settlement.verdict ? { verdict: settlement.verdict } : {},
|
|
2766
|
+
spent: settlement.spent,
|
|
2767
|
+
seq,
|
|
2768
|
+
at: new Date(now()).toISOString()
|
|
2769
|
+
});
|
|
2770
|
+
if (settlement.metered) await args.journal.appendEvent(args.root, {
|
|
2771
|
+
kind: "metered",
|
|
2772
|
+
id: child.id,
|
|
2773
|
+
spend: settlement.metered,
|
|
2774
|
+
seq,
|
|
2775
|
+
at: new Date(now()).toISOString()
|
|
2776
|
+
});
|
|
2777
|
+
notifyRuntimeHookEvent(args.hooks, {
|
|
2778
|
+
id: `${child.id}:settled`,
|
|
2779
|
+
runId: args.root,
|
|
2780
|
+
target: "agent.child",
|
|
2781
|
+
phase: "after",
|
|
2782
|
+
timestamp: now(),
|
|
2783
|
+
stepIndex: seq,
|
|
2784
|
+
parentId: args.parentId,
|
|
2785
|
+
payload: {
|
|
2786
|
+
childId: child.id,
|
|
2787
|
+
status: "done",
|
|
2788
|
+
outRef: settlement.outRef,
|
|
2789
|
+
score: settlement.verdict?.score,
|
|
2790
|
+
valid: settlement.verdict?.valid,
|
|
2791
|
+
spent: settlement.spent
|
|
2792
|
+
}
|
|
2793
|
+
}, { signal: args.signal });
|
|
2794
|
+
return {
|
|
2795
|
+
kind: "done",
|
|
2796
|
+
handle,
|
|
2797
|
+
out: settlement.out,
|
|
2798
|
+
outRef: settlement.outRef,
|
|
2799
|
+
...settlement.verdict ? { verdict: settlement.verdict } : {},
|
|
2800
|
+
spent: settlement.spent,
|
|
2801
|
+
seq
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
/** Journal a wait-state's settlement as a `woken` event and project it onto the same `Settled`
|
|
2805
|
+
* the driver branches on. `by` names WHY it woke — `fired` / `timeout` / `cancelled` — which is
|
|
2806
|
+
* the fact a resumed reader needs and cannot recover from a payload it may never fetch. */
|
|
2807
|
+
async function finalizeWait(child, settlement, seq, args, now, handle) {
|
|
2808
|
+
const at = new Date(now()).toISOString();
|
|
2809
|
+
if (settlement.kind === "down") {
|
|
2810
|
+
child.status = "cancelled";
|
|
2811
|
+
await args.journal.appendEvent(args.root, {
|
|
2812
|
+
kind: "woken",
|
|
2813
|
+
id: child.id,
|
|
2814
|
+
by: "cancelled",
|
|
2815
|
+
seq,
|
|
2816
|
+
at
|
|
2817
|
+
});
|
|
2818
|
+
return {
|
|
2819
|
+
kind: "down",
|
|
2820
|
+
handle,
|
|
2821
|
+
reason: settlement.reason,
|
|
2822
|
+
infra: settlement.infra,
|
|
2823
|
+
restartCount: settlement.restartCount,
|
|
2824
|
+
seq
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
child.status = "done";
|
|
2828
|
+
child.outRef = settlement.outRef;
|
|
2829
|
+
const out = settlement.out;
|
|
2830
|
+
await args.journal.appendEvent(args.root, {
|
|
2831
|
+
kind: "woken",
|
|
2832
|
+
id: child.id,
|
|
2833
|
+
by: out.settled,
|
|
2834
|
+
outRef: settlement.outRef,
|
|
2835
|
+
seq,
|
|
2836
|
+
at
|
|
2837
|
+
});
|
|
2838
|
+
notifyRuntimeHookEvent(args.hooks, {
|
|
2839
|
+
id: `${child.id}:woken`,
|
|
2840
|
+
runId: args.root,
|
|
2841
|
+
target: "agent.child",
|
|
2842
|
+
phase: "after",
|
|
2843
|
+
timestamp: now(),
|
|
2844
|
+
stepIndex: seq,
|
|
2845
|
+
parentId: args.parentId,
|
|
2846
|
+
payload: {
|
|
2847
|
+
childId: child.id,
|
|
2848
|
+
status: "done",
|
|
2849
|
+
wait: out
|
|
2850
|
+
}
|
|
2851
|
+
}, { signal: args.signal });
|
|
2852
|
+
return {
|
|
2853
|
+
kind: "done",
|
|
2854
|
+
handle,
|
|
2855
|
+
out: settlement.out,
|
|
2856
|
+
outRef: settlement.outRef,
|
|
2857
|
+
spent: settlement.spent,
|
|
2858
|
+
seq
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Drive one child's `Executor` to a terminal `PreSeqSettled`, folding usage into the
|
|
2863
|
+
* conserved `Spend`, reconciling the reservation, and persisting the result blob. Both
|
|
2864
|
+
* executor shapes are handled here: a one-shot `Promise<ExecutorResult>` and a streaming
|
|
2865
|
+
* `AsyncIterable<UsageEvent>` whose terminal artifact is read from `resultArtifact()`.
|
|
2866
|
+
*
|
|
2867
|
+
* A thrown executor (or a real abort) becomes a TYPED `down` — never re-thrown — so a
|
|
2868
|
+
* single failing child cannot reject the `next()` cursor (the M2 typed-result discipline,
|
|
2869
|
+
* applied per child). The reservation is reconciled on EVERY path (success, abort, throw)
|
|
2870
|
+
* so the conserved pool can never leak a reservation.
|
|
2871
|
+
*/
|
|
2872
|
+
async function runChild(live, executor, childAbort, task, opts, pool, ticket, blobs, now) {
|
|
2873
|
+
let reconciled = false;
|
|
2874
|
+
const reconcileOnce = (spend) => {
|
|
2875
|
+
if (reconciled) return;
|
|
2876
|
+
reconciled = true;
|
|
2877
|
+
pool.reconcile(ticket, clampSpend(spend, opts.budget));
|
|
2878
|
+
};
|
|
2879
|
+
try {
|
|
2880
|
+
live.status = "running";
|
|
2881
|
+
const ran = executor.execute(task, childAbort.signal);
|
|
2882
|
+
let artifact;
|
|
2883
|
+
if (isAsyncIterable(ran)) {
|
|
2884
|
+
const spend = await foldStream(ran, (running) => {
|
|
2885
|
+
live.spent = running;
|
|
2886
|
+
live.lastActivityAt = now();
|
|
2887
|
+
});
|
|
2888
|
+
live.spent = spend;
|
|
2889
|
+
artifact = executor.resultArtifact();
|
|
2890
|
+
reconcileOnce(spend);
|
|
2891
|
+
} else {
|
|
2892
|
+
const terminal = await ran;
|
|
2893
|
+
live.spent = terminal.spent;
|
|
2894
|
+
artifact = terminal;
|
|
2895
|
+
reconcileOnce(terminal.spent);
|
|
2896
|
+
}
|
|
2897
|
+
live.executorDone = true;
|
|
2898
|
+
const ownMetered = executor.metered?.();
|
|
2899
|
+
if (childAbort.signal.aborted) {
|
|
2900
|
+
await teardownSafe(executor, opts.shutdown ?? "brutalKill");
|
|
2901
|
+
return downRecord("aborted before settle", true, ownMetered);
|
|
2902
|
+
}
|
|
2903
|
+
const outRef = contentAddress(artifact.out);
|
|
2904
|
+
await blobs.put(outRef, artifact.out);
|
|
2905
|
+
await teardownSafe(executor, opts.shutdown ?? "infinity");
|
|
2906
|
+
return {
|
|
2907
|
+
kind: "done",
|
|
2908
|
+
out: artifact.out,
|
|
2909
|
+
outRef,
|
|
2910
|
+
...artifact.verdict ? { verdict: artifact.verdict } : {},
|
|
2911
|
+
spent: live.spent,
|
|
2912
|
+
...ownMetered ? { metered: ownMetered } : {}
|
|
2913
|
+
};
|
|
2914
|
+
} catch (err) {
|
|
2915
|
+
live.executorDone = true;
|
|
2916
|
+
reconcileOnce(live.spent);
|
|
2917
|
+
await teardownSafe(executor, "brutalKill");
|
|
2918
|
+
const aborted = childAbort.signal.aborted || isAbortError(err);
|
|
2919
|
+
return downRecord(errMessage(err), aborted || isInfraError(err), executor.metered?.());
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* The step-8 merge-boundary adapter (M4): rehydrate a `Settled.done` into the kernel's
|
|
2924
|
+
* `Iteration` shape so `defaultSelectWinner` stays single-sourced — the supervisor selects
|
|
2925
|
+
* across settled children with the SAME argmax the loop kernel uses, not a forked copy.
|
|
2926
|
+
*
|
|
2927
|
+
* `index` is the cursor `seq` (the recorded, replay-stable order); `output`/`verdict`/
|
|
2928
|
+
* `tokenUsage`/`costUsd` are read straight off the settlement (already rehydrated from the
|
|
2929
|
+
* `outRef` blob by `next()`). Events are empty — a settled child is an opaque leaf result,
|
|
2930
|
+
* not a sandbox event stream — and the timing/cost fields project its conserved `Spend`.
|
|
2931
|
+
* Fail loud on a `down` settlement: only a `done` child is an iteration.
|
|
2932
|
+
*/
|
|
2933
|
+
function settledToIteration(settled) {
|
|
2934
|
+
if (settled.kind === "down") throw new ValidationError(`settledToIteration: cannot adapt a 'down' settlement (node '${settled.handle.id}', seq ${settled.seq}) to an Iteration`);
|
|
2935
|
+
return {
|
|
2936
|
+
index: settled.seq,
|
|
2937
|
+
task: void 0,
|
|
2938
|
+
agentRunName: settled.handle.label,
|
|
2939
|
+
output: settled.out,
|
|
2940
|
+
...settled.verdict ? { verdict: settled.verdict } : {},
|
|
2941
|
+
events: [],
|
|
2942
|
+
startedAt: 0,
|
|
2943
|
+
endedAt: settled.spent.ms,
|
|
2944
|
+
costUsd: settled.spent.usd,
|
|
2945
|
+
tokenUsage: {
|
|
2946
|
+
input: settled.spent.tokens.input,
|
|
2947
|
+
output: settled.spent.tokens.output
|
|
2948
|
+
}
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
function makeTreeView(root, children) {
|
|
2952
|
+
const nodes = [...children.values()].map((c) => ({
|
|
2953
|
+
id: c.id,
|
|
2954
|
+
parent: root,
|
|
2955
|
+
label: c.label,
|
|
2956
|
+
status: c.status,
|
|
2957
|
+
runtime: c.runtime,
|
|
2958
|
+
budget: c.budget,
|
|
2959
|
+
spent: c.spent,
|
|
2960
|
+
...c.outRef ? { outRef: c.outRef } : {}
|
|
2961
|
+
}));
|
|
2962
|
+
return {
|
|
2963
|
+
root,
|
|
2964
|
+
nodes,
|
|
2965
|
+
inFlight: nodes.filter((n) => n.status === "running" || n.status === "acquiring").length,
|
|
2966
|
+
waiting: nodes.filter((n) => n.status === "waiting").length
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
function frozenHandle(child) {
|
|
2970
|
+
return {
|
|
2971
|
+
id: child.id,
|
|
2972
|
+
label: child.label,
|
|
2973
|
+
status: child.status,
|
|
2974
|
+
abort() {}
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* Fold a streaming executor's normalized usage into the conserved `Spend`, publishing the
|
|
2979
|
+
* running total after EVERY event via `onProgress`.
|
|
2980
|
+
*
|
|
2981
|
+
* The publish is what makes a running worker observable. Before it, `live.spent` was assigned
|
|
2982
|
+
* once — after the whole stream drained — so `observe_agent` on a RUNNING worker reported
|
|
2983
|
+
* `{tokens:0, iterations:0}` for the entire run and a driver had no way to tell a worker three
|
|
2984
|
+
* turns deep from one that had produced nothing. It also means a worker that THROWS mid-stream
|
|
2985
|
+
* now reconciles its real partial spend instead of refunding work the provider already billed.
|
|
2986
|
+
*/
|
|
2987
|
+
async function foldStream(stream, onProgress) {
|
|
2988
|
+
const tokens = {
|
|
2989
|
+
input: 0,
|
|
2990
|
+
output: 0
|
|
2991
|
+
};
|
|
2992
|
+
let usd = 0;
|
|
2993
|
+
let iterations = 0;
|
|
2994
|
+
for await (const ev of stream) {
|
|
2995
|
+
if (ev.kind === "tokens") {
|
|
2996
|
+
tokens.input += ev.input;
|
|
2997
|
+
tokens.output += ev.output;
|
|
2998
|
+
} else if (ev.kind === "cost") usd += ev.usd;
|
|
2999
|
+
else iterations += 1;
|
|
3000
|
+
onProgress?.({
|
|
3001
|
+
iterations,
|
|
3002
|
+
tokens: { ...tokens },
|
|
3003
|
+
usd,
|
|
3004
|
+
ms: 0
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
return {
|
|
3008
|
+
iterations,
|
|
3009
|
+
tokens,
|
|
3010
|
+
usd,
|
|
3011
|
+
ms: 0
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
/** Clamp a child's reported spend to its reservation so the pool's fail-loud over-spend
|
|
3015
|
+
* guard never trips on a benign overshoot from an external usage report; the difference
|
|
3016
|
+
* refunds to the pool as if the child stopped at its ceiling. */
|
|
3017
|
+
function clampSpend(spend, budget) {
|
|
3018
|
+
const totalTokens = spend.tokens.input + spend.tokens.output;
|
|
3019
|
+
const tokensOk = totalTokens <= budget.maxTokens;
|
|
3020
|
+
const itersOk = spend.iterations <= budget.maxIterations;
|
|
3021
|
+
const usdOk = budget.maxUsd === void 0 || spend.usd <= budget.maxUsd;
|
|
3022
|
+
if (tokensOk && itersOk && usdOk) return spend;
|
|
3023
|
+
const ratio = !tokensOk && totalTokens > 0 ? budget.maxTokens / totalTokens : 1;
|
|
3024
|
+
return {
|
|
3025
|
+
iterations: Math.min(spend.iterations, budget.maxIterations),
|
|
3026
|
+
tokens: ratio < 1 ? {
|
|
3027
|
+
input: Math.floor(spend.tokens.input * ratio),
|
|
3028
|
+
output: Math.floor(spend.tokens.output * ratio)
|
|
3029
|
+
} : spend.tokens,
|
|
3030
|
+
usd: budget.maxUsd === void 0 ? spend.usd : Math.min(spend.usd, budget.maxUsd),
|
|
3031
|
+
...spend.usdKnown === false ? { usdKnown: false } : {},
|
|
3032
|
+
ms: spend.ms
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
async function teardownSafe(executor, grace) {
|
|
3036
|
+
try {
|
|
3037
|
+
await executor.teardown(grace);
|
|
3038
|
+
} catch {}
|
|
3039
|
+
}
|
|
3040
|
+
function downRecord(reason, infra, metered) {
|
|
3041
|
+
return {
|
|
3042
|
+
kind: "down",
|
|
3043
|
+
reason,
|
|
3044
|
+
infra,
|
|
3045
|
+
restartCount: 0,
|
|
3046
|
+
...metered ? { metered } : {}
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
function zeroSpend() {
|
|
3050
|
+
return {
|
|
3051
|
+
iterations: 0,
|
|
3052
|
+
tokens: {
|
|
3053
|
+
input: 0,
|
|
3054
|
+
output: 0
|
|
3055
|
+
},
|
|
3056
|
+
usd: 0,
|
|
3057
|
+
ms: 0
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
function isAsyncIterable(value) {
|
|
3061
|
+
return typeof value === "object" && value !== null && typeof value[Symbol.asyncIterator] === "function";
|
|
3062
|
+
}
|
|
3063
|
+
/** An `AgentSpec` is identified structurally — it carries a `profile` and a `harness`
|
|
3064
|
+
* field (`null` or a `BackendType`) and optionally an `executor`. */
|
|
3065
|
+
function isAgentSpec(value) {
|
|
3066
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3067
|
+
const v = value;
|
|
3068
|
+
return "profile" in v && "harness" in v;
|
|
3069
|
+
}
|
|
3070
|
+
function isAbortError(err) {
|
|
3071
|
+
return typeof err === "object" && err !== null && "name" in err && err.name === "AbortError";
|
|
3072
|
+
}
|
|
3073
|
+
/** External-boundary failures (network/FS/subprocess) are infra — excluded from the merge
|
|
3074
|
+
* `n` and the equal-k assertion. A `ValidationError` from a built-in executor wraps a
|
|
3075
|
+
* config/transport failure, so it counts as infra; other throws are a real bad result. */
|
|
3076
|
+
function isInfraError(err) {
|
|
3077
|
+
return err instanceof ValidationError;
|
|
3078
|
+
}
|
|
3079
|
+
function errMessage(err) {
|
|
3080
|
+
if (err instanceof Error) return err.message;
|
|
3081
|
+
return String(err);
|
|
3082
|
+
}
|
|
3083
|
+
//#endregion
|
|
3084
|
+
//#region src/runtime/supervise/driver-executor.ts
|
|
3085
|
+
/**
|
|
3086
|
+
*
|
|
3087
|
+
* The recursive driver-executor — the seam that lets a SPAWNED child be a DRIVER, so
|
|
3088
|
+
* agents drive agents drive agents over the one keystone atom.
|
|
3089
|
+
*
|
|
3090
|
+
* A spawned child resolves through the open registry to an `Executor`; the built-in
|
|
3091
|
+
* executors (router/inline, sandbox, cli) are LEAVES — `execute(task, signal)` runs the
|
|
3092
|
+
* work and settles. This executor is the recursive case: on `execute`, it mounts a NESTED
|
|
3093
|
+
* `Scope` (the scope hands it the mount via the `nested-scope` seam) over the SAME
|
|
3094
|
+
* conserved pool + shared journal/blobs + the same open registry, one `depth` deeper, then
|
|
3095
|
+
* runs the wrapped driver `Agent.act(task, nestedScope)`. The driver spawns its own
|
|
3096
|
+
* children into that nested scope; each resolves to EITHER a leaf executor (a worker child)
|
|
3097
|
+
* OR this same driver-executor (a driver child) — recursively. So a driver spawns a driver
|
|
3098
|
+
* spawns a worker, all on one budget-conserving tree.
|
|
3099
|
+
*
|
|
3100
|
+
* Why this preserves every keystone invariant (the scope owns the sharing; this executor
|
|
3101
|
+
* only runs the driver over what the scope mounts):
|
|
3102
|
+
* - Conserved budget: the nested scope reserves from the SAME `BudgetPool` the root owns
|
|
3103
|
+
* (the scope mounts it over `args.pool`), so `Σk` is conserved ACROSS depth by
|
|
3104
|
+
* construction — a deep tree cannot overspend the root ceiling (reserve-on-spawn fails
|
|
3105
|
+
* closed at any depth).
|
|
3106
|
+
* - Journal: the nested scope writes to its OWN tree key (`${journalRoot}/${nodeId}`) so
|
|
3107
|
+
* its cursor `seq`s never collide with the parent's in the per-tree uniqueness guard,
|
|
3108
|
+
* while every nested tree shares the one `SpawnJournal` — the whole recursion is one
|
|
3109
|
+
* journal, queryable tree by tree.
|
|
3110
|
+
* - Settlement bubbling: the driver child settles into its PARENT scope with the conserved
|
|
3111
|
+
* spend summed off its nested tree's settled events, so the parent's pool reconcile +
|
|
3112
|
+
* the supervisor's `spentTotal` see the whole sub-tree's spend rolled up — settlements
|
|
3113
|
+
* bubble to the root.
|
|
3114
|
+
* - Depth ceiling: the nested scope runs at `depth+1`, so the supervisor's `maxDepth`
|
|
3115
|
+
* (paired with the conserved pool per R3) fails a spawn closed once the recursion is too
|
|
3116
|
+
* deep — exactly as it does for a flat tree.
|
|
3117
|
+
*
|
|
3118
|
+
* Layering: pure keystone composition. It reuses the scope's `NestedScopeSeam` + the shared
|
|
3119
|
+
* `SpawnJournal`; it builds NO new budget, journal, or selection logic. The recursion rides
|
|
3120
|
+
* the existing atom.
|
|
3121
|
+
*
|
|
3122
|
+
* @experimental
|
|
3123
|
+
*/
|
|
3124
|
+
/** The runtime tag the registry maps a driver child to. */
|
|
3125
|
+
const driverRuntime = "driver";
|
|
3126
|
+
/** The metadata marker on a driver child's spec the recursive registry routes on. */
|
|
3127
|
+
const driverRole = "driver";
|
|
3128
|
+
/** True when a spec is a driver child (carries the role marker + a driver Agent). */
|
|
3129
|
+
function isDriverSpec(spec) {
|
|
3130
|
+
if (spec.profile.metadata?.role !== driverRole) return false;
|
|
3131
|
+
const driver = spec.driver;
|
|
3132
|
+
if (!isAgent(driver)) throw new ValidationError("driverExecutor: a driver-role spec must carry a `driver` Agent to run inside its nested scope");
|
|
3133
|
+
return true;
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* The recursive driver-executor factory. `withDriverExecutor` routes a child marked
|
|
3137
|
+
* `role: 'driver'` here; any other child resolves to a leaf built-in. On `execute`, it
|
|
3138
|
+
* reads the `nested-scope` seam the SCOPE seeded, mounts a nested `Scope` one `depth`
|
|
3139
|
+
* deeper over the shared pool/journal/blobs/registry, runs the driver
|
|
3140
|
+
* `Agent.act(task, nestedScope)`, and reports the conserved spend summed off the nested
|
|
3141
|
+
* tree's settled events — so the parent scope's reconcile rolls the whole sub-tree's spend
|
|
3142
|
+
* into the conserved total.
|
|
3143
|
+
*
|
|
3144
|
+
* A `down` from the nested driver (a thrown `act` or an aborted scope) propagates as a
|
|
3145
|
+
* thrown executor, which the parent scope types into a `down` settlement — the same
|
|
3146
|
+
* fail-loud-into-typed-down discipline a leaf gets.
|
|
3147
|
+
*/
|
|
3148
|
+
const driverExecutorFactory = (spec, ctx) => {
|
|
3149
|
+
if (!isDriverSpec(spec)) throw new ValidationError("driverExecutorFactory: spec is not a driver child (no role:\"driver\" marker)");
|
|
3150
|
+
const driver = spec.driver;
|
|
3151
|
+
const journal = spec.journal;
|
|
3152
|
+
const seam = readNestedScopeSeam(ctx);
|
|
3153
|
+
let artifact;
|
|
3154
|
+
let meteredSpend;
|
|
3155
|
+
return {
|
|
3156
|
+
runtime: driverRuntime,
|
|
3157
|
+
async execute(task, signal) {
|
|
3158
|
+
const nestedRoot = nestedTreeKey(seam, journal);
|
|
3159
|
+
await journal.beginTree(nestedRoot, (/* @__PURE__ */ new Date(0)).toISOString());
|
|
3160
|
+
const nestedScope = seam.mount(nestedRoot, signal);
|
|
3161
|
+
try {
|
|
3162
|
+
const out = await driver.act(task, nestedScope);
|
|
3163
|
+
const events = await loadTreeEvents(journal, nestedRoot);
|
|
3164
|
+
const settled = events.filter(isSettled);
|
|
3165
|
+
meteredSpend = nonZeroOrUndef(sumMetered(events));
|
|
3166
|
+
const verdict = deriveDeliveryVerdict(settled);
|
|
3167
|
+
artifact = {
|
|
3168
|
+
outRef: `${driverRuntime}:${nestedRoot}`,
|
|
3169
|
+
out,
|
|
3170
|
+
spent: sumSpend(settled),
|
|
3171
|
+
...verdict ? { verdict } : {}
|
|
3172
|
+
};
|
|
3173
|
+
return artifact;
|
|
3174
|
+
} catch (err) {
|
|
3175
|
+
meteredSpend = await safeSumMetered(journal, nestedRoot);
|
|
3176
|
+
throw err;
|
|
3177
|
+
}
|
|
3178
|
+
},
|
|
3179
|
+
metered() {
|
|
3180
|
+
return meteredSpend;
|
|
3181
|
+
},
|
|
3182
|
+
teardown() {
|
|
3183
|
+
return Promise.resolve({ destroyed: true });
|
|
3184
|
+
},
|
|
3185
|
+
resultArtifact() {
|
|
3186
|
+
if (!artifact) throw new ValidationError("driverExecutor: resultArtifact() read before execute()");
|
|
3187
|
+
return artifact;
|
|
3188
|
+
}
|
|
3189
|
+
};
|
|
3190
|
+
};
|
|
3191
|
+
/**
|
|
3192
|
+
* Register the driver-executor so a child marked `role: 'driver'` resolves to it. The base
|
|
3193
|
+
* registry resolves by harness alone (it does not read `role`), so a recursive run needs a
|
|
3194
|
+
* registry that routes the driver tag here FIRST. Returns a registry decorator: a
|
|
3195
|
+
* driver-role spec → the driver-executor; everything else → the base registry's resolution
|
|
3196
|
+
* (leaf built-ins + BYO).
|
|
3197
|
+
*/
|
|
3198
|
+
function withDriverExecutor(base) {
|
|
3199
|
+
return {
|
|
3200
|
+
register: base.register.bind(base),
|
|
3201
|
+
resolve(spec) {
|
|
3202
|
+
if (spec.profile.metadata?.role === driverRole && !spec.executor) return {
|
|
3203
|
+
succeeded: true,
|
|
3204
|
+
value: driverExecutorFactory
|
|
3205
|
+
};
|
|
3206
|
+
return base.resolve(spec);
|
|
3207
|
+
}
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
/** Mint a unique nested-tree key under the parent's journal root. Uses the parent's
|
|
3211
|
+
* `journalRoot` + a per-journal monotonic ordinal so two sibling driver trees never
|
|
3212
|
+
* collide their keys (each driver child mints exactly one nested tree). */
|
|
3213
|
+
function nestedTreeKey(seam, journal) {
|
|
3214
|
+
return `${seam.journalRoot}/d${nextNestOrdinal(journal)}`;
|
|
3215
|
+
}
|
|
3216
|
+
/** Per-journal monotonic nest counter — keyed on the journal instance so a single run's
|
|
3217
|
+
* nested-tree keys are unique without a shared module global. */
|
|
3218
|
+
const nestCounters = /* @__PURE__ */ new WeakMap();
|
|
3219
|
+
function nextNestOrdinal(journal) {
|
|
3220
|
+
let c = nestCounters.get(journal);
|
|
3221
|
+
if (!c) {
|
|
3222
|
+
c = { n: 0 };
|
|
3223
|
+
nestCounters.set(journal, c);
|
|
3224
|
+
}
|
|
3225
|
+
return c.n++;
|
|
3226
|
+
}
|
|
3227
|
+
/** The nested tree's full event list — the one evidence the spend, verdict, AND driver-inference
|
|
3228
|
+
* roll-ups read off the same journal the supervisor sums. */
|
|
3229
|
+
async function loadTreeEvents(journal, nestedRoot) {
|
|
3230
|
+
const events = await journal.loadTree(nestedRoot);
|
|
3231
|
+
if (events === void 0) throw new ValidationError(`driverExecutor: nested tree '${nestedRoot}' missing from the journal after run (corrupted log)`);
|
|
3232
|
+
return events;
|
|
3233
|
+
}
|
|
3234
|
+
function isSettled(ev) {
|
|
3235
|
+
return ev.kind === "settled";
|
|
3236
|
+
}
|
|
3237
|
+
/** Sum the conserved spend over the nested tree's settled events — the honest per-channel
|
|
3238
|
+
* roll-up of the whole sub-tree's child WORK. */
|
|
3239
|
+
function sumSpend(settled) {
|
|
3240
|
+
const total = {
|
|
3241
|
+
iterations: 0,
|
|
3242
|
+
tokens: {
|
|
3243
|
+
input: 0,
|
|
3244
|
+
output: 0
|
|
3245
|
+
},
|
|
3246
|
+
usd: 0,
|
|
3247
|
+
ms: 0
|
|
3248
|
+
};
|
|
3249
|
+
for (const ev of settled) {
|
|
3250
|
+
total.iterations += ev.spent.iterations;
|
|
3251
|
+
total.tokens.input += ev.spent.tokens.input;
|
|
3252
|
+
total.tokens.output += ev.spent.tokens.output;
|
|
3253
|
+
total.usd += ev.spent.usd;
|
|
3254
|
+
if (ev.spent.usdKnown === false) total.usdKnown = false;
|
|
3255
|
+
total.ms += ev.spent.ms;
|
|
3256
|
+
}
|
|
3257
|
+
return total;
|
|
3258
|
+
}
|
|
3259
|
+
/** Sum the nested tree's `metered` events — the sub-tree's whole driver INFERENCE (this driver's
|
|
3260
|
+
* own turns + any sub-driver inference already re-homed into this tree). Re-homed up to the parent
|
|
3261
|
+
* as one `metered` event; never reconciled (already pool-debited live via `observe`). */
|
|
3262
|
+
function sumMetered(events) {
|
|
3263
|
+
const total = {
|
|
3264
|
+
iterations: 0,
|
|
3265
|
+
tokens: {
|
|
3266
|
+
input: 0,
|
|
3267
|
+
output: 0
|
|
3268
|
+
},
|
|
3269
|
+
usd: 0,
|
|
3270
|
+
ms: 0
|
|
3271
|
+
};
|
|
3272
|
+
for (const ev of events) {
|
|
3273
|
+
if (ev.kind !== "metered") continue;
|
|
3274
|
+
total.iterations += ev.spend.iterations;
|
|
3275
|
+
total.tokens.input += ev.spend.tokens.input;
|
|
3276
|
+
total.tokens.output += ev.spend.tokens.output;
|
|
3277
|
+
total.usd += ev.spend.usd;
|
|
3278
|
+
if (ev.spend.usdKnown === false) total.usdKnown = false;
|
|
3279
|
+
total.ms += ev.spend.ms;
|
|
3280
|
+
}
|
|
3281
|
+
return total;
|
|
3282
|
+
}
|
|
3283
|
+
function isNonZeroSpend(s) {
|
|
3284
|
+
return s.iterations > 0 || s.tokens.input > 0 || s.tokens.output > 0 || s.usd > 0 || s.ms > 0;
|
|
3285
|
+
}
|
|
3286
|
+
/** A spend, or `undefined` when it is all-zero — so `metered()` returns undefined for a driver
|
|
3287
|
+
* whose sub-tree did no inference (and the parent journals no empty `metered` event). */
|
|
3288
|
+
function nonZeroOrUndef(s) {
|
|
3289
|
+
return isNonZeroSpend(s) ? s : void 0;
|
|
3290
|
+
}
|
|
3291
|
+
/** Sum the nested tree's metered events, tolerating a missing tree (a crash before `beginTree`
|
|
3292
|
+
* landed) — never throw here, or it would mask the original `act` error on the crash path. */
|
|
3293
|
+
async function safeSumMetered(journal, nestedRoot) {
|
|
3294
|
+
try {
|
|
3295
|
+
return nonZeroOrUndef(sumMetered(await loadTreeEvents(journal, nestedRoot)));
|
|
3296
|
+
} catch {
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
/** Derive the driver child's delivery verdict from its DIRECT children's settlements:
|
|
3301
|
+
* `valid` iff any direct child settled `done` AND `valid` (the keep-best finalize's pick);
|
|
3302
|
+
* `score` = the best delivered score. Returns `undefined` when no child settled at all (the
|
|
3303
|
+
* driver itself produced nothing to bubble a verdict from). Fail-closed: a child whose verdict
|
|
3304
|
+
* carried no `valid` counts as not-delivered. */
|
|
3305
|
+
function deriveDeliveryVerdict(settled) {
|
|
3306
|
+
let sawChild = false;
|
|
3307
|
+
let anyValid = false;
|
|
3308
|
+
let bestValidScore;
|
|
3309
|
+
let bestDoneScore;
|
|
3310
|
+
for (const ev of settled) {
|
|
3311
|
+
sawChild = true;
|
|
3312
|
+
if (ev.status !== "done") continue;
|
|
3313
|
+
const score = ev.verdict?.score;
|
|
3314
|
+
if (score !== void 0 && (bestDoneScore === void 0 || score > bestDoneScore)) bestDoneScore = score;
|
|
3315
|
+
if (ev.verdict?.valid === true) {
|
|
3316
|
+
anyValid = true;
|
|
3317
|
+
if (score !== void 0 && (bestValidScore === void 0 || score > bestValidScore)) bestValidScore = score;
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
if (!sawChild) return void 0;
|
|
3321
|
+
return {
|
|
3322
|
+
valid: anyValid,
|
|
3323
|
+
score: anyValid ? bestValidScore ?? 1 : bestDoneScore ?? 0
|
|
3324
|
+
};
|
|
3325
|
+
}
|
|
3326
|
+
function readNestedScopeSeam(ctx) {
|
|
3327
|
+
const seam = ctx.seams[nestedScopeSeamKey];
|
|
3328
|
+
if (!seam || typeof seam.mount !== "function") throw new ValidationError(`driverExecutor: missing required seam "${nestedScopeSeamKey}" — a driver child must be spawned through a Scope that seeds it (the keystone scope does)`);
|
|
3329
|
+
return seam;
|
|
3330
|
+
}
|
|
3331
|
+
function isAgent(value) {
|
|
3332
|
+
return typeof value === "object" && value !== null && typeof value.act === "function" && typeof value.name === "string";
|
|
3333
|
+
}
|
|
3334
|
+
//#endregion
|
|
3335
|
+
//#region src/runtime/supervise/budget.ts
|
|
3336
|
+
/**
|
|
3337
|
+
*
|
|
3338
|
+
* The conserved budget reservation pool — the invariant the whole instrument
|
|
3339
|
+
* rests on (critique M5/B3). One root `Budget` becomes a conserved pool of three
|
|
3340
|
+
* quantities (tokens, usd, iterations) plus an absolute deadline. Children reserve
|
|
3341
|
+
* atomically at spawn and reconcile at settle:
|
|
3342
|
+
*
|
|
3343
|
+
* total ≡ free + reserved + committed (invariant, always)
|
|
3344
|
+
*
|
|
3345
|
+
* `reserve` moves a child's whole ceiling from `free` → `reserved` and fails closed
|
|
3346
|
+
* when `free` can't cover it (never read-then-spawn overcommit, so `Σk(treatment) ≡
|
|
3347
|
+
* Σk(blind)` by construction). `reconcile` releases the reservation, commits ACTUAL
|
|
3348
|
+
* spend, and refunds the unspent remainder to `free`. Tokens and usd are separate
|
|
3349
|
+
* channels (`LoopTokenUsage` has no `usd`); iterations are conserved alongside them.
|
|
3350
|
+
*
|
|
3351
|
+
* Pure and deterministic: `now()` is injected, there is no I/O, and no wall-clock or
|
|
3352
|
+
* RNG read. A `reserve`/`reconcile` ticket is single-use (fail-loud on double or
|
|
3353
|
+
* unknown reconcile) so a child can never refund twice.
|
|
3354
|
+
*
|
|
3355
|
+
* @experimental
|
|
3356
|
+
*/
|
|
3357
|
+
/** Fold a normalized `UsageEvent` array into a `Spend`. Tokens and usd are separate
|
|
3358
|
+
* channels; iterations come from `'iteration'` events. Pure; `ms` stays zero (the
|
|
3359
|
+
* pool does not read wall-clock). */
|
|
3360
|
+
function spendFromUsageEvents(events) {
|
|
3361
|
+
const tokens = zeroTokenUsage();
|
|
3362
|
+
let usd = 0;
|
|
3363
|
+
let iterations = 0;
|
|
3364
|
+
for (const ev of events) if (ev.kind === "tokens") addTokenUsage(tokens, {
|
|
3365
|
+
input: ev.input,
|
|
3366
|
+
output: ev.output
|
|
3367
|
+
});
|
|
3368
|
+
else if (ev.kind === "cost") usd += ev.usd;
|
|
3369
|
+
else iterations += 1;
|
|
3370
|
+
return {
|
|
3371
|
+
iterations,
|
|
3372
|
+
tokens,
|
|
3373
|
+
usd,
|
|
3374
|
+
ms: 0
|
|
3375
|
+
};
|
|
3376
|
+
}
|
|
3377
|
+
async function foldUsage(events) {
|
|
3378
|
+
if (Array.isArray(events)) return spendFromUsageEvents(events);
|
|
3379
|
+
const tokens = zeroTokenUsage();
|
|
3380
|
+
let usd = 0;
|
|
3381
|
+
let iterations = 0;
|
|
3382
|
+
for await (const ev of events) if (ev.kind === "tokens") addTokenUsage(tokens, {
|
|
3383
|
+
input: ev.input,
|
|
3384
|
+
output: ev.output
|
|
3385
|
+
});
|
|
3386
|
+
else if (ev.kind === "cost") usd += ev.usd;
|
|
3387
|
+
else iterations += 1;
|
|
3388
|
+
return {
|
|
3389
|
+
iterations,
|
|
3390
|
+
tokens,
|
|
3391
|
+
usd,
|
|
3392
|
+
ms: 0
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3395
|
+
function totalTokens(usage) {
|
|
3396
|
+
return usage.input + usage.output;
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* Create a conserved reservation pool from a root `Budget`. `now()` is injected so the
|
|
3400
|
+
* deadline readout is deterministic; defaults to `Date.now` for non-test callers. The
|
|
3401
|
+
* absolute deadline is fixed at construction (`now() + budget.deadlineMs`) so the
|
|
3402
|
+
* readout's `deadlineMs` is a stable wall-clock instant, not a shrinking remainder.
|
|
3403
|
+
*/
|
|
3404
|
+
function createBudgetPool(root, now = Date.now) {
|
|
3405
|
+
let freeTokens = root.maxTokens;
|
|
3406
|
+
let reservedTokens = 0;
|
|
3407
|
+
let committedTokens = 0;
|
|
3408
|
+
const usdCapped = root.maxUsd !== void 0;
|
|
3409
|
+
let freeUsd = root.maxUsd ?? 0;
|
|
3410
|
+
let reservedUsd = 0;
|
|
3411
|
+
let committedUsd = 0;
|
|
3412
|
+
let freeIterations = root.maxIterations;
|
|
3413
|
+
let reservedIterations = 0;
|
|
3414
|
+
let committedIterations = 0;
|
|
3415
|
+
const absoluteDeadlineMs = root.deadlineMs !== void 0 ? now() + root.deadlineMs : 0;
|
|
3416
|
+
let nextTicketId = 0;
|
|
3417
|
+
const open = /* @__PURE__ */ new Set();
|
|
3418
|
+
function reserve(b) {
|
|
3419
|
+
const wantTokens = b.maxTokens;
|
|
3420
|
+
const wantUsd = b.maxUsd ?? 0;
|
|
3421
|
+
const wantIterations = b.maxIterations;
|
|
3422
|
+
if (wantTokens > freeTokens) return {
|
|
3423
|
+
ok: false,
|
|
3424
|
+
reason: "budget-exhausted"
|
|
3425
|
+
};
|
|
3426
|
+
if (wantIterations > freeIterations) return {
|
|
3427
|
+
ok: false,
|
|
3428
|
+
reason: "budget-exhausted"
|
|
3429
|
+
};
|
|
3430
|
+
if (wantUsd > 0 && (!usdCapped || wantUsd > freeUsd)) return {
|
|
3431
|
+
ok: false,
|
|
3432
|
+
reason: "budget-exhausted"
|
|
3433
|
+
};
|
|
3434
|
+
freeTokens -= wantTokens;
|
|
3435
|
+
reservedTokens += wantTokens;
|
|
3436
|
+
freeIterations -= wantIterations;
|
|
3437
|
+
reservedIterations += wantIterations;
|
|
3438
|
+
if (wantUsd > 0) {
|
|
3439
|
+
freeUsd -= wantUsd;
|
|
3440
|
+
reservedUsd += wantUsd;
|
|
3441
|
+
}
|
|
3442
|
+
const id = nextTicketId++;
|
|
3443
|
+
open.add(id);
|
|
3444
|
+
return {
|
|
3445
|
+
ok: true,
|
|
3446
|
+
ticket: {
|
|
3447
|
+
id,
|
|
3448
|
+
reserved: {
|
|
3449
|
+
tokens: wantTokens,
|
|
3450
|
+
usd: wantUsd,
|
|
3451
|
+
iterations: wantIterations
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
function reconcile(ticket, spent) {
|
|
3457
|
+
if (!open.has(ticket.id)) throw new Error(`budget pool: reconcile of unknown or already-settled ticket ${ticket.id}`);
|
|
3458
|
+
open.delete(ticket.id);
|
|
3459
|
+
const { tokens: rTokens, usd: rUsd, iterations: rIterations } = ticket.reserved;
|
|
3460
|
+
if (usdCapped && spent.usdKnown === false) throw new Error(`budget pool: ticket ${ticket.id} reported unknown dollar cost under a dollar-capped budget`);
|
|
3461
|
+
const spentTokens = totalTokens(spent.tokens);
|
|
3462
|
+
if (spentTokens > rTokens) throw new Error(`budget pool: ticket ${ticket.id} spent ${spentTokens} tokens > reserved ${rTokens}`);
|
|
3463
|
+
if (spent.iterations > rIterations) throw new Error(`budget pool: ticket ${ticket.id} spent ${spent.iterations} iterations > reserved ${rIterations}`);
|
|
3464
|
+
if (usdCapped && spent.usd > rUsd) throw new Error(`budget pool: ticket ${ticket.id} spent $${spent.usd} > reserved $${rUsd}`);
|
|
3465
|
+
reservedTokens -= rTokens;
|
|
3466
|
+
committedTokens += spentTokens;
|
|
3467
|
+
freeTokens += rTokens - spentTokens;
|
|
3468
|
+
reservedIterations -= rIterations;
|
|
3469
|
+
committedIterations += spent.iterations;
|
|
3470
|
+
freeIterations += rIterations - spent.iterations;
|
|
3471
|
+
if (usdCapped && rUsd > 0) {
|
|
3472
|
+
reservedUsd -= rUsd;
|
|
3473
|
+
committedUsd += spent.usd;
|
|
3474
|
+
freeUsd += rUsd - spent.usd;
|
|
3475
|
+
} else committedUsd += spent.usd;
|
|
3476
|
+
}
|
|
3477
|
+
function observe(spend) {
|
|
3478
|
+
if (usdCapped && spend.usdKnown === false) throw new Error("budget pool: cannot observe unknown dollar cost under a dollar-capped budget");
|
|
3479
|
+
const tokens = totalTokens(spend.tokens);
|
|
3480
|
+
freeTokens -= tokens;
|
|
3481
|
+
committedTokens += tokens;
|
|
3482
|
+
freeIterations -= spend.iterations;
|
|
3483
|
+
committedIterations += spend.iterations;
|
|
3484
|
+
committedUsd += spend.usd;
|
|
3485
|
+
if (usdCapped) freeUsd -= spend.usd;
|
|
3486
|
+
}
|
|
3487
|
+
function readout() {
|
|
3488
|
+
return {
|
|
3489
|
+
tokensLeft: freeTokens,
|
|
3490
|
+
usdLeft: usdCapped ? freeUsd : 0,
|
|
3491
|
+
usdCapped,
|
|
3492
|
+
deadlineMs: absoluteDeadlineMs,
|
|
3493
|
+
reservedTokens
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
function assertNoOpenTickets() {
|
|
3497
|
+
if (open.size > 0) throw new Error(`budget pool: ${open.size} reservation(s) still open at join barrier (leaked ticket ids: ${[...open].join(", ")}) — conserved-pool invariant violated`);
|
|
3498
|
+
}
|
|
3499
|
+
return {
|
|
3500
|
+
reserve,
|
|
3501
|
+
reconcile,
|
|
3502
|
+
spendFrom: foldUsage,
|
|
3503
|
+
readout,
|
|
3504
|
+
observe,
|
|
3505
|
+
assertNoOpenTickets
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
//#endregion
|
|
3509
|
+
//#region src/runtime/supervise/supervisor.ts
|
|
3510
|
+
/**
|
|
3511
|
+
*
|
|
3512
|
+
* The `Supervisor` impl (KEYSTONE, build step 5).
|
|
3513
|
+
*
|
|
3514
|
+
* Owns the four things a free-running recursive `act` cannot own itself: the GLOBAL
|
|
3515
|
+
* conserved budget pool, the event-sourced spawn log, the abort cascade over the whole
|
|
3516
|
+
* live tree, and the OTP intensity breaker. `run` builds the root `Scope` over those,
|
|
3517
|
+
* runs the root `Agent.act`, and returns a TYPED `SupervisedResult` — a no-winner is
|
|
3518
|
+
* never coerced into a best-effort `Out`.
|
|
3519
|
+
*
|
|
3520
|
+
* Three lifecycle invariants this impl enforces by construction:
|
|
3521
|
+
* - Join barrier: when `act()` settles (resolve OR reject), every still-live child is
|
|
3522
|
+
* torn down before `run` returns — the generalization of the kernel's
|
|
3523
|
+
* `finally{ Promise.allSettled(destroy) }` barrier (run-loop.ts) from boxes to the
|
|
3524
|
+
* whole sub-tree. A teardown failure is `allSettled`'d and journaled as a
|
|
3525
|
+
* `cancelled` event; it NEVER masks act()'s own outcome. act()'s rejection is the
|
|
3526
|
+
* PRIMARY error (the kernel's firstError precedence), so a teardown throw during the
|
|
3527
|
+
* barrier can never overwrite the real failure.
|
|
3528
|
+
* - Abort cascade: a root abort (caller signal, `RootHandle.abort`, a tripped breaker,
|
|
3529
|
+
* or pool exhaustion) aborts ONE internal controller whose signal is the root scope's
|
|
3530
|
+
* signal. The scope cascades that into every live child's executor abort — which, for
|
|
3531
|
+
* an `acquiring` child, chains into the `acquireSandbox` signal and reaps the
|
|
3532
|
+
* find-by-name orphan box (M1). The supervisor never reaps children directly.
|
|
3533
|
+
* - The supervisor NEVER re-enters a child (m3): the kernel/`acquireSandbox` already
|
|
3534
|
+
* retried at the leaf, and a driver re-spawns through `scope.spawn`. The breaker only
|
|
3535
|
+
* COUNTS `down` settlements within the intensity window and trips to a typed
|
|
3536
|
+
* no-winner; it does not restart anything.
|
|
3537
|
+
*
|
|
3538
|
+
* Selection lives in the driver, not here (selector≠judge): `act` returns the synthesized
|
|
3539
|
+
* winner `Out`. The supervisor content-addresses that `Out` for its replay `outRef`, reads
|
|
3540
|
+
* `spentTotal` off the journal (`settled` child work + `metered` driver inference), and wraps
|
|
3541
|
+
* it as a typed `winner` — it does not re-rank children behind the driver's back.
|
|
3542
|
+
*
|
|
3543
|
+
* @experimental
|
|
3544
|
+
*/
|
|
3545
|
+
/** Highest `seq` among events matching `pred`, or `-1` when none match (so a resumed scope's
|
|
3546
|
+
* first new ordinal/seq is 0 — the same start a fresh scope uses). */
|
|
3547
|
+
function maxSeqOf(events, pred) {
|
|
3548
|
+
let max = -1;
|
|
3549
|
+
for (const ev of events) if (pred(ev) && ev.seq > max) max = ev.seq;
|
|
3550
|
+
return max;
|
|
3551
|
+
}
|
|
3552
|
+
/** The default runtime recursion-depth ceiling, paired with the conserved pool so a
|
|
3553
|
+
* runaway recursion hits budget-exhaustion first and depth-exceeded second (R3). */
|
|
3554
|
+
const defaultMaxDepth = 4;
|
|
3555
|
+
/** Create a supervisor that owns one recursive agent execution tree. */
|
|
3556
|
+
function createSupervisor() {
|
|
3557
|
+
let attached;
|
|
3558
|
+
async function run(root, task, opts) {
|
|
3559
|
+
const now = opts.now ?? Date.now;
|
|
3560
|
+
const pool = createBudgetPool(opts.budget, now);
|
|
3561
|
+
const prior = opts.resume === true ? await opts.journal.loadTree(opts.runId) : void 0;
|
|
3562
|
+
const resuming = prior !== void 0 && prior.length > 0;
|
|
3563
|
+
let resumeFrom;
|
|
3564
|
+
if (resuming) resumeFrom = {
|
|
3565
|
+
settled: await replaySpawnTree(opts.journal, opts.blobs, opts.runId),
|
|
3566
|
+
view: materializeTreeView(prior),
|
|
3567
|
+
maxSpawnOrdinal: maxSeqOf(prior, (ev) => ev.kind === "spawned"),
|
|
3568
|
+
maxCursorSeq: maxSeqOf(prior, (ev) => ev.kind === "settled" || ev.kind === "cancelled" || ev.kind === "woken"),
|
|
3569
|
+
maxWaitOrdinal: maxSeqOf(prior, (ev) => ev.kind === "waiting"),
|
|
3570
|
+
waits: pendingWaits(prior)
|
|
3571
|
+
};
|
|
3572
|
+
else {
|
|
3573
|
+
await opts.journal.beginTree(opts.runId, new Date(now()).toISOString());
|
|
3574
|
+
await opts.journal.appendEvent(opts.runId, {
|
|
3575
|
+
kind: "spawned",
|
|
3576
|
+
id: opts.runId,
|
|
3577
|
+
label: "root",
|
|
3578
|
+
budget: opts.budget,
|
|
3579
|
+
runtime: "inline",
|
|
3580
|
+
seq: 0,
|
|
3581
|
+
at: new Date(now()).toISOString()
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3584
|
+
const controller = new AbortController();
|
|
3585
|
+
const cascadeAbort = (reason) => {
|
|
3586
|
+
if (controller.signal.aborted) return;
|
|
3587
|
+
controller.abort(reason);
|
|
3588
|
+
};
|
|
3589
|
+
const onCallerAbort = () => cascadeAbort("caller signal aborted");
|
|
3590
|
+
if (opts.signal) if (opts.signal.aborted) cascadeAbort("caller signal aborted");
|
|
3591
|
+
else opts.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
3592
|
+
const breaker = createIntensityBreaker(opts, () => cascadeAbort("intensity breaker tripped"));
|
|
3593
|
+
const journal = wrapJournalForBreaker(opts.journal, breaker);
|
|
3594
|
+
const scope = createScope({
|
|
3595
|
+
parentId: opts.runId,
|
|
3596
|
+
root: opts.runId,
|
|
3597
|
+
pool,
|
|
3598
|
+
journal,
|
|
3599
|
+
blobs: opts.blobs,
|
|
3600
|
+
executors: opts.executors,
|
|
3601
|
+
seams: {},
|
|
3602
|
+
depth: 0,
|
|
3603
|
+
maxDepth: opts.maxDepth ?? defaultMaxDepth,
|
|
3604
|
+
signal: controller.signal,
|
|
3605
|
+
now,
|
|
3606
|
+
hooks: opts.hooks,
|
|
3607
|
+
...opts.probes ? { probes: opts.probes } : {},
|
|
3608
|
+
...resumeFrom ? { resumeFrom } : {}
|
|
3609
|
+
});
|
|
3610
|
+
const openScope = scope;
|
|
3611
|
+
if (attached) attached.bind({
|
|
3612
|
+
scope: openScope,
|
|
3613
|
+
cascadeAbort,
|
|
3614
|
+
signal: pushRootSignal(cascadeAbort)
|
|
3615
|
+
});
|
|
3616
|
+
let actOutcome;
|
|
3617
|
+
try {
|
|
3618
|
+
actOutcome = {
|
|
3619
|
+
ok: true,
|
|
3620
|
+
out: await root.act(task, scope)
|
|
3621
|
+
};
|
|
3622
|
+
} catch (error) {
|
|
3623
|
+
actOutcome = {
|
|
3624
|
+
ok: false,
|
|
3625
|
+
error
|
|
3626
|
+
};
|
|
3627
|
+
} finally {
|
|
3628
|
+
await drainLiveChildren(openScope, controller);
|
|
3629
|
+
if (opts.signal) opts.signal.removeEventListener("abort", onCallerAbort);
|
|
3630
|
+
if (attached) attached.unbind();
|
|
3631
|
+
}
|
|
3632
|
+
const tree = scope.view;
|
|
3633
|
+
if (actOutcome.ok) {
|
|
3634
|
+
pool.assertNoOpenTickets();
|
|
3635
|
+
const out = actOutcome.out;
|
|
3636
|
+
if (out !== void 0) {
|
|
3637
|
+
const outRef = contentAddress(out);
|
|
3638
|
+
await opts.blobs.put(outRef, out);
|
|
3639
|
+
const { childWork, driverInference } = await spentFromJournal(journal, opts.runId);
|
|
3640
|
+
return {
|
|
3641
|
+
kind: "winner",
|
|
3642
|
+
out,
|
|
3643
|
+
outRef,
|
|
3644
|
+
tree,
|
|
3645
|
+
spentTotal: addSpend(childWork, driverInference),
|
|
3646
|
+
...isNonEmptySpend(driverInference) ? { spentBreakdown: {
|
|
3647
|
+
driverInference,
|
|
3648
|
+
childWork
|
|
3649
|
+
} } : {}
|
|
3650
|
+
};
|
|
3651
|
+
}
|
|
3652
|
+
return noWinner();
|
|
3653
|
+
}
|
|
3654
|
+
return noWinner();
|
|
3655
|
+
async function noWinner() {
|
|
3656
|
+
const { childWork, driverInference } = await spentFromJournal(journal, opts.runId);
|
|
3657
|
+
return {
|
|
3658
|
+
kind: "no-winner",
|
|
3659
|
+
reason: classifyNoWinner(controller, pool, opts, breaker),
|
|
3660
|
+
tree,
|
|
3661
|
+
downCount: breaker.downCount(),
|
|
3662
|
+
spentTotal: addSpend(childWork, driverInference)
|
|
3663
|
+
};
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
function attach(h) {
|
|
3667
|
+
const control = rootControls.get(h);
|
|
3668
|
+
if (!control) throw new RuntimeRunStateError("supervisor.attach: handle was not minted by createRootHandle (no control channel)");
|
|
3669
|
+
attached = control;
|
|
3670
|
+
}
|
|
3671
|
+
return {
|
|
3672
|
+
run,
|
|
3673
|
+
attach
|
|
3674
|
+
};
|
|
3675
|
+
}
|
|
3676
|
+
/** Module-private channel from a minted `RootHandle` to its `RootControl`, so `attach`
|
|
3677
|
+
* can prove a handle is ours and reach its binding without leaking the control onto the
|
|
3678
|
+
* frozen `RootHandle` shape. */
|
|
3679
|
+
const rootControls = /* @__PURE__ */ new WeakMap();
|
|
3680
|
+
/** A `RootSignal` sink: `cancel` cascades an abort; pause/resume/ask are observability
|
|
3681
|
+
* signals the substrate accepts but does not act on here (the chat/pi-viz client owns
|
|
3682
|
+
* pause semantics — building them now would be mechanism ahead of the gate). */
|
|
3683
|
+
function pushRootSignal(cascadeAbort) {
|
|
3684
|
+
return (msg) => {
|
|
3685
|
+
if (msg.kind === "cancel") cascadeAbort(msg.reason ?? "root signal: cancel");
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
function createIntensityBreaker(opts, trip) {
|
|
3689
|
+
const max = opts.maxRestarts;
|
|
3690
|
+
const within = opts.withinMs;
|
|
3691
|
+
const armed = max !== void 0 && within !== void 0;
|
|
3692
|
+
const recent = [];
|
|
3693
|
+
let total = 0;
|
|
3694
|
+
let isTripped = false;
|
|
3695
|
+
return {
|
|
3696
|
+
recordDown(at) {
|
|
3697
|
+
total += 1;
|
|
3698
|
+
if (!armed || isTripped) return;
|
|
3699
|
+
recent.push(at);
|
|
3700
|
+
const cutoff = at - within;
|
|
3701
|
+
while (recent.length > 0 && recent[0] < cutoff) recent.shift();
|
|
3702
|
+
if (recent.length > max) {
|
|
3703
|
+
isTripped = true;
|
|
3704
|
+
trip();
|
|
3705
|
+
}
|
|
3706
|
+
},
|
|
3707
|
+
tripped() {
|
|
3708
|
+
return isTripped;
|
|
3709
|
+
},
|
|
3710
|
+
downCount() {
|
|
3711
|
+
return total;
|
|
3712
|
+
}
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
/** Decorate the journal so the breaker observes every `settled`-`down` event the scope
|
|
3716
|
+
* appends, without the supervisor intercepting `scope.next()`. The decorator is
|
|
3717
|
+
* transparent — it forwards every method verbatim and only reads the down events. */
|
|
3718
|
+
function wrapJournalForBreaker(journal, breaker) {
|
|
3719
|
+
return {
|
|
3720
|
+
loadTree: (root) => journal.loadTree(root),
|
|
3721
|
+
beginTree: (root, at) => journal.beginTree(root, at),
|
|
3722
|
+
appendEvent: (root, ev) => {
|
|
3723
|
+
if (ev.kind === "settled" && ev.status === "down") breaker.recordDown(Date.parse(ev.at));
|
|
3724
|
+
return journal.appendEvent(root, ev);
|
|
3725
|
+
}
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3728
|
+
/**
|
|
3729
|
+
* Drain the root scope's live set so every still-running/acquiring child is torn down
|
|
3730
|
+
* before `run` returns — the join barrier. Abort the cascade controller first (so each
|
|
3731
|
+
* child's executor stops cleanly), then pull `next()` to completion so every aborted
|
|
3732
|
+
* child's teardown + reconcile runs and its `settled` event is journaled by the scope.
|
|
3733
|
+
* A child's own teardown failure is already swallowed inside `runChild`, and the cursor
|
|
3734
|
+
* itself never rejects (a failing child is typed into a `down`), so the whole barrier is
|
|
3735
|
+
* `allSettled`'d — a stray throw here is NOT the primary error (firstError precedence).
|
|
3736
|
+
*/
|
|
3737
|
+
async function drainLiveChildren(scope, controller) {
|
|
3738
|
+
const view = scope.view;
|
|
3739
|
+
if (!(view.inFlight > 0 || view.waiting > 0)) return;
|
|
3740
|
+
if (!controller.signal.aborted) controller.abort();
|
|
3741
|
+
await Promise.allSettled([drainCursor(scope)]);
|
|
3742
|
+
}
|
|
3743
|
+
async function drainCursor(scope) {
|
|
3744
|
+
for (;;) if (await scope.next() === null) return;
|
|
3745
|
+
}
|
|
3746
|
+
function classifyNoWinner(controller, pool, opts, breaker) {
|
|
3747
|
+
if (breaker.tripped()) return "all-children-down";
|
|
3748
|
+
if (controller.signal.aborted) return "aborted";
|
|
3749
|
+
if (poolExhausted(pool, opts)) return "budget-exhausted";
|
|
3750
|
+
return "all-children-down";
|
|
3751
|
+
}
|
|
3752
|
+
function poolExhausted(pool, opts) {
|
|
3753
|
+
const r = pool.readout();
|
|
3754
|
+
if (r.tokensLeft <= 0) return true;
|
|
3755
|
+
if (opts.budget.maxUsd !== void 0 && r.usdLeft <= 0) return true;
|
|
3756
|
+
if (opts.budget.deadlineMs !== void 0 && r.deadlineMs > 0 && (opts.now ?? Date.now)() >= r.deadlineMs) return true;
|
|
3757
|
+
return false;
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Sum the conserved spend over every journaled `settled` event — the honest per-channel
|
|
3761
|
+
* total (input/output/usd/iterations all preserved), read off the same evidence replay
|
|
3762
|
+
* reads. Computed AFTER the join barrier so every child's settlement is recorded. Fails
|
|
3763
|
+
* loud if the tree was never journaled (the supervisor always `beginTree`s, so a missing
|
|
3764
|
+
* tree is a corrupted journal, not a normal path).
|
|
3765
|
+
*/
|
|
3766
|
+
async function spentFromJournal(journal, root) {
|
|
3767
|
+
const events = await journal.loadTree(root);
|
|
3768
|
+
if (events === void 0) throw new RuntimeRunStateError(`supervisor: spawn tree '${root}' is missing from the journal after run (corrupted log)`);
|
|
3769
|
+
const childWork = {
|
|
3770
|
+
iterations: 0,
|
|
3771
|
+
tokens: {
|
|
3772
|
+
input: 0,
|
|
3773
|
+
output: 0
|
|
3774
|
+
},
|
|
3775
|
+
usd: 0,
|
|
3776
|
+
ms: 0
|
|
3777
|
+
};
|
|
3778
|
+
const driverInference = {
|
|
3779
|
+
iterations: 0,
|
|
3780
|
+
tokens: {
|
|
3781
|
+
input: 0,
|
|
3782
|
+
output: 0
|
|
3783
|
+
},
|
|
3784
|
+
usd: 0,
|
|
3785
|
+
ms: 0
|
|
3786
|
+
};
|
|
3787
|
+
for (const ev of events) if (ev.kind === "settled") accumulate(childWork, ev.spent);
|
|
3788
|
+
else if (ev.kind === "metered") accumulate(driverInference, ev.spend);
|
|
3789
|
+
return {
|
|
3790
|
+
childWork,
|
|
3791
|
+
driverInference
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
/** Add `b` into `a` in place, per channel. */
|
|
3795
|
+
function accumulate(a, b) {
|
|
3796
|
+
a.iterations += b.iterations;
|
|
3797
|
+
a.tokens.input += b.tokens.input;
|
|
3798
|
+
a.tokens.output += b.tokens.output;
|
|
3799
|
+
a.usd += b.usd;
|
|
3800
|
+
if (b.usdKnown === false) a.usdKnown = false;
|
|
3801
|
+
a.ms += b.ms;
|
|
3802
|
+
}
|
|
3803
|
+
/** Sum two conserved-spend tallies per channel — the child-work journal sum + the drivers' own
|
|
3804
|
+
* metered inference, so `spentTotal` is the true cost of the run. */
|
|
3805
|
+
function addSpend(a, b) {
|
|
3806
|
+
return {
|
|
3807
|
+
iterations: a.iterations + b.iterations,
|
|
3808
|
+
tokens: {
|
|
3809
|
+
input: a.tokens.input + b.tokens.input,
|
|
3810
|
+
output: a.tokens.output + b.tokens.output
|
|
3811
|
+
},
|
|
3812
|
+
usd: a.usd + b.usd,
|
|
3813
|
+
...a.usdKnown === false || b.usdKnown === false ? { usdKnown: false } : {},
|
|
3814
|
+
ms: a.ms + b.ms
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
/** True when any driver metered inference this run (so the winner carries a `spentBreakdown`).
|
|
3818
|
+
* Checks every channel `addSpend` sums — including `ms` — so the gate stays consistent with the
|
|
3819
|
+
* total even though the coordination driver currently stamps `ms: 0`. */
|
|
3820
|
+
function isNonEmptySpend(s) {
|
|
3821
|
+
return s.iterations > 0 || s.tokens.input > 0 || s.tokens.output > 0 || s.usd > 0 || s.ms > 0;
|
|
3822
|
+
}
|
|
3823
|
+
//#endregion
|
|
3824
|
+
export { removeWorktree as A, routerToolLoop as C, runWorktreeHarness as D, runWorktreeChecks as E, parseCodexTokenUsage as M, runLocalHarness as N, captureWorktreeDiff as O, CodexExecutionDiagnosticError as P, routerChatWithUsage as S, runSettledCommand as T, defineRuntimeHooks as _, createScope as a, routerBrain as b, isWaitOutcome as c, validateWaitSpec as d, waitUntil as f, composeRuntimeHooks as g, readWorkerProgress as h, withDriverExecutor as i, harnessInvocation as j, createWorktree as k, pollFor as l, createActivityLog as m, createBudgetPool as n, settledToIteration as o, DEFAULT_STALL_AFTER_MS as p, spendFromUsageEvents as r, createWaitProbes as s, createSupervisor as t, timerAt as u, notifyRuntimeDecisionPoint as v, runBrainLoop as w, routerChatWithTools as x, notifyRuntimeHookEvent as y };
|
|
3825
|
+
|
|
3826
|
+
//# sourceMappingURL=supervisor-DTKhF-RV.js.map
|