@foldrun/core 2.12.1 → 2.13.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/dist/src/completions.js +1 -0
- package/dist/src/frontmatter-fields.js +4 -2
- package/dist/src/frontmatter-schema.js +3 -0
- package/dist/src/resources.d.ts +59 -0
- package/dist/src/resources.js +142 -0
- package/dist/src/run-container.d.ts +48 -1
- package/dist/src/run-container.js +120 -7
- package/dist/src/runner.js +61 -14
- package/dist/src/starter.js +1 -0
- package/dist/src/store.d.ts +18 -0
- package/package.json +1 -1
- package/src/completions.ts +1 -0
- package/src/frontmatter-fields.ts +4 -2
- package/src/frontmatter-schema.ts +3 -0
- package/src/resources.ts +151 -0
- package/src/run-container.ts +138 -7
- package/src/runner.ts +60 -14
- package/src/starter.ts +1 -0
- package/src/store.ts +11 -0
package/dist/src/completions.js
CHANGED
|
@@ -94,6 +94,7 @@ const FIELDS = {
|
|
|
94
94
|
{ label: "skills", insert: "skills:\n - ", hint: "allowlist; omit to inherit all" },
|
|
95
95
|
{ label: "scripts", insert: "scripts:\n - name: \n run: \n description: " },
|
|
96
96
|
{ label: "runtime", insert: "runtime:\n python: \"3\"\n packages: []" },
|
|
97
|
+
{ label: "resources", insert: "resources:\n cpu: 2\n memory: 4Gi", hint: "cores, memory and disk in numbers, over the size class's" },
|
|
97
98
|
{ label: "mcpServers", insert: "mcpServers:\n " },
|
|
98
99
|
{ label: "ask", insert: "ask:\n timeout: 30m", hint: "tools: [ask] — how long ask_person waits for a person (max 24h)" },
|
|
99
100
|
{ label: "permissionMode", insert: "permissionMode: plan", hint: "plan = read-only" },
|
|
@@ -34,6 +34,7 @@ const AGENT = [
|
|
|
34
34
|
{ key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
|
|
35
35
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
|
|
36
36
|
{ key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
|
|
37
|
+
{ key: "resources", type: "object", help: "{cpu, memory, disk} in numbers, over the size class's figures key by key — cpu 0.5–8, memory 256Mi–16Gi, disk 1–40Gi" },
|
|
37
38
|
{ key: "permissionMode", type: "string", values: ["plan"], help: "plan = read-only; no other mode changes anything here" },
|
|
38
39
|
{ key: "tools", type: ["list", "string"], help: "the one grant list" },
|
|
39
40
|
{ key: "disallowedTools", type: ["list", "string"], help: "subtract from what it would otherwise have" },
|
|
@@ -84,6 +85,7 @@ const AGENTS_MD = [
|
|
|
84
85
|
{ key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
|
|
85
86
|
{ key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
|
|
86
87
|
{ key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
|
|
88
|
+
{ key: "compute", type: "object", help: "account: {vcpu, memory} its running steps may hold together — lowers the plan's, never raises it" },
|
|
87
89
|
{ key: "alerts", type: "object", validated: true, help: "thresholds that tell notify: — failure_rate (30%), min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host" },
|
|
88
90
|
{ key: "capture", type: "string", values: ["full", "metadata"], help: "what runs record of each tool call: full (its input and output, secrets masked) or metadata (name, size and time only)" },
|
|
89
91
|
{ key: "network_enforce", type: "boolean", help: "true: a site off a step's network: list is refused, not only named in the trace. The account's or the workspace's true is enough" },
|
|
@@ -191,7 +193,7 @@ const SAID_WHAT_IT_IS = [
|
|
|
191
193
|
const JOB_FIELDS = {
|
|
192
194
|
agent: {
|
|
193
195
|
governance: ["owner", "budget"],
|
|
194
|
-
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox"],
|
|
196
|
+
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox", "resources"],
|
|
195
197
|
},
|
|
196
198
|
flow: {
|
|
197
199
|
governance: ["owner", "approvers", "approve_within", "budget", "priority"],
|
|
@@ -199,7 +201,7 @@ const JOB_FIELDS = {
|
|
|
199
201
|
observability: ["issues", "sla"],
|
|
200
202
|
},
|
|
201
203
|
"agents-md": {
|
|
202
|
-
governance: ["owner", "budget", "priority", "concurrency", "deploy_block"],
|
|
204
|
+
governance: ["owner", "budget", "priority", "concurrency", "compute", "deploy_block"],
|
|
203
205
|
guardrail: ["limits", "network", "network_enforce", "hooks"],
|
|
204
206
|
observability: ["alerts", "telemetry", "notify", "capture"],
|
|
205
207
|
evaluation: ["judge"],
|
|
@@ -7,6 +7,7 @@ import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS
|
|
|
7
7
|
import { budgetProblem } from "./budget.js";
|
|
8
8
|
import { cronProblem } from "./cron.js";
|
|
9
9
|
import { modelSecretCollision } from "./model-credential.js";
|
|
10
|
+
import { resourcesProblem, computeProblem } from "./resources.js";
|
|
10
11
|
export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
|
|
11
12
|
/** A value for a message: a scalar as written, anything else by its shape.
|
|
12
13
|
* Never String() of a map — its `toString` is whatever the YAML says. */
|
|
@@ -31,11 +32,13 @@ const CHECKS = {
|
|
|
31
32
|
apis: apisProblem,
|
|
32
33
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
33
34
|
ask: (v) => askProblem(v),
|
|
35
|
+
resources: resourcesProblem,
|
|
34
36
|
secrets: modelSecretCollision,
|
|
35
37
|
},
|
|
36
38
|
"agents-md": {
|
|
37
39
|
budget: (v) => budgetProblem(v, ["day", "week", "month"]),
|
|
38
40
|
concurrency: (v) => (Number.isInteger(Number(v)) && Number(v) > 0 ? null : `${String(v)} is not a whole number of runs, 1 or more`),
|
|
41
|
+
compute: computeProblem,
|
|
39
42
|
},
|
|
40
43
|
"flow": {
|
|
41
44
|
schedule: cronProblem,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** What an agent may ask for, and in what steps. */
|
|
2
|
+
export declare const RESOURCE_BOUNDS: {
|
|
3
|
+
readonly cpu: {
|
|
4
|
+
readonly min: 0.5;
|
|
5
|
+
readonly max: 8;
|
|
6
|
+
readonly step: 0.5;
|
|
7
|
+
};
|
|
8
|
+
readonly memoryMi: {
|
|
9
|
+
readonly min: 256;
|
|
10
|
+
readonly max: number;
|
|
11
|
+
readonly step: 256;
|
|
12
|
+
};
|
|
13
|
+
readonly diskGi: {
|
|
14
|
+
readonly min: 1;
|
|
15
|
+
readonly max: 40;
|
|
16
|
+
readonly step: 1;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/** The overrides an agent asked for, normalised: cores as a number, memory
|
|
20
|
+
* in Mi and disk in Gi as quantities both Kubernetes and Docker accept. */
|
|
21
|
+
export interface ResourceOverride {
|
|
22
|
+
cpu?: number;
|
|
23
|
+
memory?: string;
|
|
24
|
+
disk?: string;
|
|
25
|
+
}
|
|
26
|
+
/** A size in MiB: 512Mi, 4Gi, 1.5Gi (and Docker's 4g, 512m). NaN when it is
|
|
27
|
+
* not one. A bare number is refused, because 4 could mean four of anything;
|
|
28
|
+
* so are G and M, which are decimal to Kubernetes and binary to Docker —
|
|
29
|
+
* an agent's figure means one thing everywhere it is read. */
|
|
30
|
+
export declare function quantityMi(q: unknown): number;
|
|
31
|
+
/** A class's memory figure in MiB, in any spelling an install may set it:
|
|
32
|
+
* bytes, Kubernetes' Ki/Mi/Gi/Ti and K/M/G/T, Docker's k/m/g/t. Wider than
|
|
33
|
+
* quantityMi, which reads only what an agent may write. NaN when none. */
|
|
34
|
+
export declare function anyMemoryMi(q: unknown): number;
|
|
35
|
+
/** Read `resources:` — what applies, and why anything else does not. A key
|
|
36
|
+
* that fails is dropped (the class's figure holds), never guessed at. */
|
|
37
|
+
export declare function readResources(raw: unknown): {
|
|
38
|
+
value: ResourceOverride;
|
|
39
|
+
problems: string[];
|
|
40
|
+
};
|
|
41
|
+
/** For `foldrun check`: every reason, in one message, or null. */
|
|
42
|
+
export declare function resourcesProblem(v: unknown): string | null;
|
|
43
|
+
/** Whether an override changes anything. */
|
|
44
|
+
export declare function hasOverride(o: ResourceOverride | null | undefined): o is ResourceOverride;
|
|
45
|
+
/** An account's own `compute:` in AGENTS.md — the most its running steps may
|
|
46
|
+
* hold together. It can only lower what the plan or install allows
|
|
47
|
+
* (platform limits.ts accountCompute); here it is only read:
|
|
48
|
+
*
|
|
49
|
+
* compute: { vcpu: 4, memory: 8Gi }
|
|
50
|
+
*/
|
|
51
|
+
export interface ComputeAsk {
|
|
52
|
+
vcpu?: number;
|
|
53
|
+
memGiB?: number;
|
|
54
|
+
}
|
|
55
|
+
export declare function readCompute(raw: unknown): {
|
|
56
|
+
value: ComputeAsk;
|
|
57
|
+
problems: string[];
|
|
58
|
+
};
|
|
59
|
+
export declare function computeProblem(v: unknown): string | null;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// An agent's `resources:` — cores, memory and disk in numbers, over its
|
|
2
|
+
// `size:` class's figures one key at a time:
|
|
3
|
+
//
|
|
4
|
+
// size: large
|
|
5
|
+
// resources: { cpu: 2, memory: 4Gi, disk: 10Gi }
|
|
6
|
+
//
|
|
7
|
+
// `size:` stays the form most agents write; this is for the one that knows
|
|
8
|
+
// its shape (much memory and one core for a big CSV, much disk for a video).
|
|
9
|
+
// The figures are bounded and stepped the way sandbox platforms sell them,
|
|
10
|
+
// so steps pack onto a node predictably and a price reads plainly. One
|
|
11
|
+
// parser: `foldrun check` reports what it refuses, and the runner applies
|
|
12
|
+
// what it accepts — so the two can never disagree about a value.
|
|
13
|
+
//
|
|
14
|
+
// Pure: no fs, no env. The class's figures come from sizeLimits
|
|
15
|
+
// (run-container.ts), which resolveResources there merges these over.
|
|
16
|
+
/** What an agent may ask for, and in what steps. */
|
|
17
|
+
export const RESOURCE_BOUNDS = {
|
|
18
|
+
cpu: { min: 0.5, max: 8, step: 0.5 },
|
|
19
|
+
memoryMi: { min: 256, max: 16 * 1024, step: 256 },
|
|
20
|
+
diskGi: { min: 1, max: 40, step: 1 },
|
|
21
|
+
};
|
|
22
|
+
const KEYS = ["cpu", "memory", "disk"];
|
|
23
|
+
/** A size in MiB: 512Mi, 4Gi, 1.5Gi (and Docker's 4g, 512m). NaN when it is
|
|
24
|
+
* not one. A bare number is refused, because 4 could mean four of anything;
|
|
25
|
+
* so are G and M, which are decimal to Kubernetes and binary to Docker —
|
|
26
|
+
* an agent's figure means one thing everywhere it is read. */
|
|
27
|
+
export function quantityMi(q) {
|
|
28
|
+
if (typeof q !== "string")
|
|
29
|
+
return NaN;
|
|
30
|
+
const m = /^(\d+(?:\.\d+)?)\s*(Mi|Gi|m|g)$/.exec(q.trim());
|
|
31
|
+
if (!m)
|
|
32
|
+
return NaN;
|
|
33
|
+
const n = Number(m[1]);
|
|
34
|
+
return m[2] === "Gi" || m[2] === "g" ? n * 1024 : n;
|
|
35
|
+
}
|
|
36
|
+
/** A class's memory figure in MiB, in any spelling an install may set it:
|
|
37
|
+
* bytes, Kubernetes' Ki/Mi/Gi/Ti and K/M/G/T, Docker's k/m/g/t. Wider than
|
|
38
|
+
* quantityMi, which reads only what an agent may write. NaN when none. */
|
|
39
|
+
export function anyMemoryMi(q) {
|
|
40
|
+
const m = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|K|M|G|T|k|m|g|t|b)?$/.exec(String(q ?? "").trim());
|
|
41
|
+
if (!m)
|
|
42
|
+
return NaN;
|
|
43
|
+
const n = Number(m[1]);
|
|
44
|
+
const MiB = 2 ** 20;
|
|
45
|
+
const bin = { Ki: 2 ** 10, Mi: MiB, Gi: 2 ** 30, Ti: 2 ** 40, k: 2 ** 10, m: MiB, g: 2 ** 30, t: 2 ** 40, b: 1 };
|
|
46
|
+
const dec = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
|
47
|
+
const unit = m[2] ?? "b";
|
|
48
|
+
return (n * (unit in bin ? bin[unit] : dec[unit])) / MiB;
|
|
49
|
+
}
|
|
50
|
+
const shown = (v) => (typeof v === "string" || typeof v === "number" ? String(v) : Array.isArray(v) ? "a list" : "a map");
|
|
51
|
+
/** Read `resources:` — what applies, and why anything else does not. A key
|
|
52
|
+
* that fails is dropped (the class's figure holds), never guessed at. */
|
|
53
|
+
export function readResources(raw) {
|
|
54
|
+
const value = {};
|
|
55
|
+
const problems = [];
|
|
56
|
+
if (raw === undefined || raw === null)
|
|
57
|
+
return { value, problems };
|
|
58
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
59
|
+
return { value, problems: [`is ${shown(raw)}, not a map — write {cpu: 2, memory: 4Gi, disk: 10Gi}`] };
|
|
60
|
+
}
|
|
61
|
+
const r = raw;
|
|
62
|
+
for (const k of Object.keys(r)) {
|
|
63
|
+
if (!KEYS.includes(k))
|
|
64
|
+
problems.push(`\`${k}\` is not one of cpu, memory, disk — it is ignored`);
|
|
65
|
+
}
|
|
66
|
+
const { cpu: C, memoryMi: M, diskGi: D } = RESOURCE_BOUNDS;
|
|
67
|
+
if (r.cpu !== undefined && r.cpu !== null) {
|
|
68
|
+
// A number, or a string of one — never a list or a map coerced (`[2]`
|
|
69
|
+
// is 2 to Number(), and a map's own toString may throw).
|
|
70
|
+
const numeric = typeof r.cpu === "number" || (typeof r.cpu === "string" && /^\s*\d+(\.\d+)?\s*$/.test(r.cpu));
|
|
71
|
+
const n = numeric ? Number(r.cpu) : NaN;
|
|
72
|
+
if (!Number.isFinite(n))
|
|
73
|
+
problems.push(`cpu: ${shown(r.cpu)} is not a number of cores — write 2 or 0.5`);
|
|
74
|
+
else if (n < C.min || n > C.max || Math.round(n / C.step) * C.step !== n)
|
|
75
|
+
problems.push(`cpu: ${n} is not ${C.min} to ${C.max} cores in halves — the class's cores are used`);
|
|
76
|
+
else
|
|
77
|
+
value.cpu = n;
|
|
78
|
+
}
|
|
79
|
+
if (r.memory !== undefined && r.memory !== null) {
|
|
80
|
+
const mi = quantityMi(r.memory);
|
|
81
|
+
if (!Number.isFinite(mi))
|
|
82
|
+
problems.push(`memory: ${shown(r.memory)} is not a size — write 4Gi or 512Mi${/\d\s*[GM]$/.test(String(shown(r.memory))) ? " (Gi and Mi: G and M are read two ways)" : ""}`);
|
|
83
|
+
else if (mi < M.min || mi > M.max || mi % M.step !== 0)
|
|
84
|
+
problems.push(`memory: ${shown(r.memory)} is not 256Mi to 16Gi in steps of 256Mi — the class's memory is used`);
|
|
85
|
+
else
|
|
86
|
+
value.memory = `${mi}Mi`;
|
|
87
|
+
}
|
|
88
|
+
if (r.disk !== undefined && r.disk !== null) {
|
|
89
|
+
const mi = quantityMi(r.disk);
|
|
90
|
+
const gi = mi / 1024;
|
|
91
|
+
if (!Number.isFinite(mi))
|
|
92
|
+
problems.push(`disk: ${shown(r.disk)} is not a size — write 10Gi`);
|
|
93
|
+
else if (!Number.isInteger(gi) || gi < D.min || gi > D.max)
|
|
94
|
+
problems.push(`disk: ${shown(r.disk)} is not a whole 1Gi to 40Gi — the class's disk is used`);
|
|
95
|
+
else
|
|
96
|
+
value.disk = `${gi}Gi`;
|
|
97
|
+
}
|
|
98
|
+
return { value, problems };
|
|
99
|
+
}
|
|
100
|
+
/** For `foldrun check`: every reason, in one message, or null. */
|
|
101
|
+
export function resourcesProblem(v) {
|
|
102
|
+
const { problems } = readResources(v);
|
|
103
|
+
return problems.length ? problems.join("; ") : null;
|
|
104
|
+
}
|
|
105
|
+
/** Whether an override changes anything. */
|
|
106
|
+
export function hasOverride(o) {
|
|
107
|
+
return !!o && (o.cpu !== undefined || o.memory !== undefined || o.disk !== undefined);
|
|
108
|
+
}
|
|
109
|
+
export function readCompute(raw) {
|
|
110
|
+
const value = {};
|
|
111
|
+
const problems = [];
|
|
112
|
+
if (raw === undefined || raw === null)
|
|
113
|
+
return { value, problems };
|
|
114
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
115
|
+
return { value, problems: [`is ${shown(raw)}, not a map — write {vcpu: 4, memory: 8Gi}`] };
|
|
116
|
+
}
|
|
117
|
+
const r = raw;
|
|
118
|
+
for (const k of Object.keys(r)) {
|
|
119
|
+
if (k !== "vcpu" && k !== "memory")
|
|
120
|
+
problems.push(`\`${k}\` is not vcpu or memory — it is ignored`);
|
|
121
|
+
}
|
|
122
|
+
if (r.vcpu !== undefined && r.vcpu !== null) {
|
|
123
|
+
const numeric = typeof r.vcpu === "number" || (typeof r.vcpu === "string" && /^\s*\d+(\.\d+)?\s*$/.test(r.vcpu));
|
|
124
|
+
const n = numeric ? Number(r.vcpu) : NaN;
|
|
125
|
+
if (!Number.isFinite(n) || n < 0.5 || Math.round(n * 2) !== n * 2)
|
|
126
|
+
problems.push(`vcpu: ${shown(r.vcpu)} is not a number of cores, 0.5 or more in halves`);
|
|
127
|
+
else
|
|
128
|
+
value.vcpu = n;
|
|
129
|
+
}
|
|
130
|
+
if (r.memory !== undefined && r.memory !== null) {
|
|
131
|
+
const mi = quantityMi(r.memory);
|
|
132
|
+
if (!Number.isFinite(mi) || mi < 256)
|
|
133
|
+
problems.push(`memory: ${shown(r.memory)} is not a size of 256Mi or more — write 8Gi`);
|
|
134
|
+
else
|
|
135
|
+
value.memGiB = mi / 1024;
|
|
136
|
+
}
|
|
137
|
+
return { value, problems };
|
|
138
|
+
}
|
|
139
|
+
export function computeProblem(v) {
|
|
140
|
+
const { problems } = readCompute(v);
|
|
141
|
+
return problems.length ? problems.join("; ") : null;
|
|
142
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { CallIo, EventExtra, ExecUsage } from "./step-exec.ts";
|
|
|
6
6
|
import type { TokenPrice } from "./catalog.ts";
|
|
7
7
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
8
8
|
import { type NetworkLevel } from "./network.ts";
|
|
9
|
+
import { type ResourceOverride } from "./resources.ts";
|
|
9
10
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
10
11
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
11
12
|
import type { DelegateSpec, SubagentSpec } from "./subagents.ts";
|
|
@@ -144,6 +145,8 @@ export declare function parseDriverMark(line: string): {
|
|
|
144
145
|
export interface StepActuals {
|
|
145
146
|
busyCpuSecs: number | null;
|
|
146
147
|
peakMemBytes: number | null;
|
|
148
|
+
/** Seconds the CPU cap held the sandbox back. Absent before 26 Oct 2026. */
|
|
149
|
+
throttledSecs?: number | null;
|
|
147
150
|
rxBytes: number | null;
|
|
148
151
|
txBytes: number | null;
|
|
149
152
|
}
|
|
@@ -296,7 +299,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
|
296
299
|
target: string;
|
|
297
300
|
} | null;
|
|
298
301
|
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
299
|
-
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
302
|
+
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
300
303
|
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
301
304
|
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
302
305
|
export declare const UV_VERSION = "0.11.25";
|
|
@@ -406,6 +409,22 @@ export declare function parseDriverLine(line: string): {
|
|
|
406
409
|
} | ({
|
|
407
410
|
e: "done";
|
|
408
411
|
} & ContainerStepOutcome) | null;
|
|
412
|
+
/**
|
|
413
|
+
* A memory figure Docker will accept: the mirror of k8sMemory.
|
|
414
|
+
*
|
|
415
|
+
* Docker reads b/k/m/g (binary) and refuses Kubernetes' Mi and Gi outright
|
|
416
|
+
* ("invalid suffix: 'mi'", Docker 29). The class defaults for small and
|
|
417
|
+
* heavy are Kubernetes quantities (1Gi, 8Gi), and `resources: memory` is
|
|
418
|
+
* normalised to Mi, so before 26 Oct 2026 every small and heavy step failed
|
|
419
|
+
* to start on the docker executor; only large's `2g` worked. Binary suffixes
|
|
420
|
+
* become whole bytes (1.5Gi has no Docker spelling), Docker's own pass
|
|
421
|
+
* through, and anything else is handed over for Docker's error to name.
|
|
422
|
+
*/
|
|
423
|
+
export declare function dockerMemory(value: string): string;
|
|
424
|
+
/** The `--cpus` a step's container gets: its class's billed cores, no more
|
|
425
|
+
* than the engine has. Null when the figure is not a number, so the flag is
|
|
426
|
+
* left off rather than failing the step on an install's typo. */
|
|
427
|
+
export declare function dockerCpus(cpus: string, available?: number): string | null;
|
|
409
428
|
export interface RunInContainerArgs {
|
|
410
429
|
workspaceRoot: string;
|
|
411
430
|
libraryRoot: string;
|
|
@@ -436,6 +455,9 @@ export interface RunInContainerArgs {
|
|
|
436
455
|
stopRequested?: () => boolean;
|
|
437
456
|
/** The reservation class — which limits this step's sandbox holds. */
|
|
438
457
|
size?: "small" | "large" | "heavy";
|
|
458
|
+
/** The class's figures with the agent's `resources:` over them
|
|
459
|
+
* (resolveResources). Absent: the class's own. */
|
|
460
|
+
limits?: StepLimits;
|
|
439
461
|
/** Re-attach to a sandbox a previous driver started, instead of creating
|
|
440
462
|
* one: its handle, and how many output lines that driver had applied.
|
|
441
463
|
* The executor replays nothing before `consumed`, and reads the step's
|
|
@@ -506,6 +528,31 @@ export declare function sizeLimits(size?: "small" | "large" | "heavy"): {
|
|
|
506
528
|
cpus: string;
|
|
507
529
|
memory: string;
|
|
508
530
|
};
|
|
531
|
+
/** What one step's sandbox holds: cores and memory (each a hard limit and a
|
|
532
|
+
* billing figure) and, where the agent set it, disk. */
|
|
533
|
+
export interface StepLimits {
|
|
534
|
+
cpus: string;
|
|
535
|
+
memory: string;
|
|
536
|
+
/** The agent's `resources: disk`, or null for the class's (the k8s tier's
|
|
537
|
+
* diskFor; Docker has no per-container disk cap). */
|
|
538
|
+
disk: string | null;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The class's figures with an agent's `resources:` over them, key by key.
|
|
542
|
+
*
|
|
543
|
+
* `atLeastClass` is for a retry that moved the step up a class after the
|
|
544
|
+
* cluster ended it for want of memory or disk: each override is raised to
|
|
545
|
+
* the bigger class's figure, so `memory: 3Gi` OOM-killed at large retries at
|
|
546
|
+
* heavy's 8Gi rather than the same 3Gi that already failed.
|
|
547
|
+
*/
|
|
548
|
+
export declare function resolveResources(size?: "small" | "large" | "heavy", override?: ResourceOverride | null, opts?: {
|
|
549
|
+
atLeastClass?: boolean;
|
|
550
|
+
}): StepLimits;
|
|
551
|
+
/** The node disk a class may write (the k8s `ephemeral-storage` limit;
|
|
552
|
+
* Docker has no per-container cap). Env-tunable like the cores and memory. */
|
|
553
|
+
export declare function classDisk(size?: "small" | "large" | "heavy"): string;
|
|
554
|
+
/** A memory figure in GiB, for the step's reservation record. */
|
|
555
|
+
export declare function memoryGiB(memory: string): number;
|
|
509
556
|
/** The label both executors stamp, so one name means one thing. */
|
|
510
557
|
export declare const RUN_LABEL = "foldrun-run-id";
|
|
511
558
|
/**
|
|
@@ -28,6 +28,7 @@ import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.
|
|
|
28
28
|
import { isFileValue, fileContent } from "./secrets.js";
|
|
29
29
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
|
|
30
30
|
import { egressCaSh } from "./network.js";
|
|
31
|
+
import { anyMemoryMi, quantityMi } from "./resources.js";
|
|
31
32
|
import { isOperatorEvent } from "./operator.js";
|
|
32
33
|
import { parseBrowserPod } from "./browser-pod.js";
|
|
33
34
|
/**
|
|
@@ -466,6 +467,11 @@ function readActuals() {
|
|
|
466
467
|
const cpuStat = read("/sys/fs/cgroup/cpu.stat");
|
|
467
468
|
const usage = cpuStat?.match(/^usage_usec (\\d+)/m);
|
|
468
469
|
const cpuacctNs = usage ? NaN : Number(read("/sys/fs/cgroup/cpuacct/cpuacct.usage")?.trim());
|
|
470
|
+
// Time the CPU cap held the sandbox back: v2's throttled_usec in the same
|
|
471
|
+
// cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against
|
|
472
|
+
// busy time means the step wanted more cores than its class gives.
|
|
473
|
+
const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);
|
|
474
|
+
const throttledNs = throttledUs ? null : read("/sys/fs/cgroup/cpu/cpu.stat")?.match(/^throttled_time (\\d+)/m);
|
|
469
475
|
const peak =
|
|
470
476
|
read("/sys/fs/cgroup/memory.peak") ??
|
|
471
477
|
read("/sys/fs/cgroup/memory/memory.max_usage_in_bytes") ??
|
|
@@ -485,6 +491,7 @@ function readActuals() {
|
|
|
485
491
|
? cpuacctNs / 1e9
|
|
486
492
|
: null,
|
|
487
493
|
peakMemBytes: peak ? Number(peak.trim()) || null : null,
|
|
494
|
+
throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,
|
|
488
495
|
rxBytes: sawNet ? rx : null,
|
|
489
496
|
txBytes: sawNet ? tx : null,
|
|
490
497
|
};
|
|
@@ -1098,7 +1105,8 @@ export function buildFailureSummary(log, opts = {}) {
|
|
|
1098
1105
|
const key = [];
|
|
1099
1106
|
for (const raw of log.split("\n")) {
|
|
1100
1107
|
const line = raw.replace(/^#\d+ \d+(\.\d+)? /, "").trim();
|
|
1101
|
-
|
|
1108
|
+
// apt's own errors start "E: "; the legacy builder ends with "returned a non-zero code".
|
|
1109
|
+
if (!/^runner image:|^ERROR\b|^error:|^E: /i.test(line) && !/did not complete successfully|returned a non-zero code|no space left on device/i.test(line))
|
|
1102
1110
|
continue;
|
|
1103
1111
|
const cut = line.length > 300 ? line.slice(0, 300) + " …" : line;
|
|
1104
1112
|
if (seen.has(cut))
|
|
@@ -1108,6 +1116,11 @@ export function buildFailureSummary(log, opts = {}) {
|
|
|
1108
1116
|
}
|
|
1109
1117
|
// The ones that say what is missing first: a reader stops at the top.
|
|
1110
1118
|
key.sort((a, b) => Number(/MISSING ENGINES/.test(b)) - Number(/MISSING ENGINES/.test(a)));
|
|
1119
|
+
// A full Docker disk is the commonest cause on one machine, and the fix is
|
|
1120
|
+
// not in foldrun: say it first, in plain words.
|
|
1121
|
+
if (/enough free space|no space left on device/i.test(log)) {
|
|
1122
|
+
key.unshift("the Docker disk is full: free space (`docker system df`, then `docker builder prune` / `docker image prune`) and run it again");
|
|
1123
|
+
}
|
|
1111
1124
|
const head = key.length
|
|
1112
1125
|
? key.slice(0, maxLines).map((l) => ` ${l}`).join("\n") + (key.length > maxLines ? `\n … ${key.length - maxLines} more` : "")
|
|
1113
1126
|
: " (no engine or ERROR line in the log)";
|
|
@@ -1205,6 +1218,8 @@ export function parseDriverLine(line) {
|
|
|
1205
1218
|
? {
|
|
1206
1219
|
busyCpuSecs: typeof parsed.res.busyCpuSecs === "number" ? parsed.res.busyCpuSecs : null,
|
|
1207
1220
|
peakMemBytes: typeof parsed.res.peakMemBytes === "number" ? parsed.res.peakMemBytes : null,
|
|
1221
|
+
// A driver from before 26 Oct 2026 sends none: unknown, not zero.
|
|
1222
|
+
throttledSecs: typeof parsed.res.throttledSecs === "number" ? parsed.res.throttledSecs : null,
|
|
1208
1223
|
rxBytes: typeof parsed.res.rxBytes === "number" ? parsed.res.rxBytes : null,
|
|
1209
1224
|
txBytes: typeof parsed.res.txBytes === "number" ? parsed.res.txBytes : null,
|
|
1210
1225
|
}
|
|
@@ -1236,6 +1251,55 @@ function ensureRunNetwork() {
|
|
|
1236
1251
|
}
|
|
1237
1252
|
return RUN_NETWORK;
|
|
1238
1253
|
}
|
|
1254
|
+
// The cores the container engine can hand out, asked once. `--cpus` above it
|
|
1255
|
+
// is refused outright ("range of CPUs is from 0.01 to N"), so a heavy step
|
|
1256
|
+
// on a two-core server would fail before it began. The engine may be a VM
|
|
1257
|
+
// (Docker Desktop) with fewer cores than this machine, which is why it is
|
|
1258
|
+
// asked rather than read from os; podman answers a different template, and
|
|
1259
|
+
// an engine that does not answer falls back to this machine's count.
|
|
1260
|
+
let engineCpuCount = null;
|
|
1261
|
+
function engineCpus() {
|
|
1262
|
+
if (engineCpuCount !== null)
|
|
1263
|
+
return engineCpuCount;
|
|
1264
|
+
const out = spawnSync(cli(), ["info", "--format", "{{.NCPU}}"], { encoding: "utf8", timeout: 10_000 });
|
|
1265
|
+
const n = Number((out.stdout ?? "").trim());
|
|
1266
|
+
engineCpuCount = Number.isInteger(n) && n > 0 ? n : os.availableParallelism();
|
|
1267
|
+
return engineCpuCount;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* A memory figure Docker will accept: the mirror of k8sMemory.
|
|
1271
|
+
*
|
|
1272
|
+
* Docker reads b/k/m/g (binary) and refuses Kubernetes' Mi and Gi outright
|
|
1273
|
+
* ("invalid suffix: 'mi'", Docker 29). The class defaults for small and
|
|
1274
|
+
* heavy are Kubernetes quantities (1Gi, 8Gi), and `resources: memory` is
|
|
1275
|
+
* normalised to Mi, so before 26 Oct 2026 every small and heavy step failed
|
|
1276
|
+
* to start on the docker executor; only large's `2g` worked. Binary suffixes
|
|
1277
|
+
* become whole bytes (1.5Gi has no Docker spelling), Docker's own pass
|
|
1278
|
+
* through, and anything else is handed over for Docker's error to name.
|
|
1279
|
+
*/
|
|
1280
|
+
export function dockerMemory(value) {
|
|
1281
|
+
const s = String(value).trim();
|
|
1282
|
+
// Lowercase only: case decides, as in k8sMemory — `1G` is Kubernetes'
|
|
1283
|
+
// decimal gigabyte, and Docker would read it as binary.
|
|
1284
|
+
if (/^\d+(\.\d+)?[bkmg]?$/.test(s))
|
|
1285
|
+
return s;
|
|
1286
|
+
const m = /^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|K|M|G|T)$/.exec(s);
|
|
1287
|
+
if (!m)
|
|
1288
|
+
return s;
|
|
1289
|
+
const pow = { Ki: 1, Mi: 2, Gi: 3, Ti: 4 };
|
|
1290
|
+
const dec = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
|
1291
|
+
const bytes = m[2] in pow ? Number(m[1]) * 1024 ** pow[m[2]] : Number(m[1]) * dec[m[2]];
|
|
1292
|
+
return String(Math.round(bytes));
|
|
1293
|
+
}
|
|
1294
|
+
/** The `--cpus` a step's container gets: its class's billed cores, no more
|
|
1295
|
+
* than the engine has. Null when the figure is not a number, so the flag is
|
|
1296
|
+
* left off rather than failing the step on an install's typo. */
|
|
1297
|
+
export function dockerCpus(cpus, available = engineCpus()) {
|
|
1298
|
+
const n = Number(cpus);
|
|
1299
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
1300
|
+
return null;
|
|
1301
|
+
return String(Math.min(n, available));
|
|
1302
|
+
}
|
|
1239
1303
|
/**
|
|
1240
1304
|
* What crosses into the container as its environment, and how.
|
|
1241
1305
|
*
|
|
@@ -1318,8 +1382,8 @@ export function sizeLimits(size) {
|
|
|
1318
1382
|
// For steps that render a browser or hold real data. The memory figure is
|
|
1319
1383
|
// a hard ceiling (memory is not compressible — over it, the kernel OOM-kills
|
|
1320
1384
|
// the pod, which on a single-box install can take neighbours with it). The
|
|
1321
|
-
// cpus figure is
|
|
1322
|
-
//
|
|
1385
|
+
// cpus figure is both the rate its seconds are priced at and, since
|
|
1386
|
+
// 26 Oct 2026, the cores the sandbox is capped at.
|
|
1323
1387
|
return {
|
|
1324
1388
|
cpus: process.env.FOLDRUN_RUNNER_CPUS_HEAVY ?? "4",
|
|
1325
1389
|
memory: process.env.FOLDRUN_RUNNER_MEMORY_HEAVY ?? "8Gi",
|
|
@@ -1330,6 +1394,52 @@ export function sizeLimits(size) {
|
|
|
1330
1394
|
memory: process.env.FOLDRUN_RUNNER_MEMORY ?? "2g",
|
|
1331
1395
|
};
|
|
1332
1396
|
}
|
|
1397
|
+
/**
|
|
1398
|
+
* The class's figures with an agent's `resources:` over them, key by key.
|
|
1399
|
+
*
|
|
1400
|
+
* `atLeastClass` is for a retry that moved the step up a class after the
|
|
1401
|
+
* cluster ended it for want of memory or disk: each override is raised to
|
|
1402
|
+
* the bigger class's figure, so `memory: 3Gi` OOM-killed at large retries at
|
|
1403
|
+
* heavy's 8Gi rather than the same 3Gi that already failed.
|
|
1404
|
+
*/
|
|
1405
|
+
export function resolveResources(size, override, opts = {}) {
|
|
1406
|
+
const base = sizeLimits(size);
|
|
1407
|
+
const bigger = (mine, theirs) => (opts.atLeastClass ? Math.max(mine, theirs) : mine);
|
|
1408
|
+
const baseMi = anyMemoryMi(base.memory);
|
|
1409
|
+
let cpus = base.cpus;
|
|
1410
|
+
if (override?.cpu !== undefined) {
|
|
1411
|
+
const b = Number(base.cpus);
|
|
1412
|
+
cpus = String(Number.isFinite(b) ? bigger(override.cpu, b) : override.cpu);
|
|
1413
|
+
}
|
|
1414
|
+
let memory = base.memory;
|
|
1415
|
+
if (override?.memory !== undefined) {
|
|
1416
|
+
const mi = quantityMi(override.memory);
|
|
1417
|
+
memory = Number.isFinite(baseMi) && opts.atLeastClass && baseMi > mi ? base.memory : override.memory;
|
|
1418
|
+
}
|
|
1419
|
+
// Disk the same way: a step evicted for disk at its own `disk: 2Gi` would
|
|
1420
|
+
// fail again at 2Gi, whatever class the retry moved it to.
|
|
1421
|
+
let disk = override?.disk ?? null;
|
|
1422
|
+
if (disk && opts.atLeastClass) {
|
|
1423
|
+
const classMi = anyMemoryMi(classDisk(size));
|
|
1424
|
+
if (Number.isFinite(classMi) && classMi > quantityMi(disk))
|
|
1425
|
+
disk = null;
|
|
1426
|
+
}
|
|
1427
|
+
return { cpus, memory, disk };
|
|
1428
|
+
}
|
|
1429
|
+
/** The node disk a class may write (the k8s `ephemeral-storage` limit;
|
|
1430
|
+
* Docker has no per-container cap). Env-tunable like the cores and memory. */
|
|
1431
|
+
export function classDisk(size) {
|
|
1432
|
+
if (size === "small")
|
|
1433
|
+
return process.env.FOLDRUN_RUNNER_DISK_SMALL ?? "2Gi";
|
|
1434
|
+
if (size === "heavy")
|
|
1435
|
+
return process.env.FOLDRUN_RUNNER_DISK_HEAVY ?? "20Gi";
|
|
1436
|
+
return process.env.FOLDRUN_RUNNER_DISK ?? "8Gi";
|
|
1437
|
+
}
|
|
1438
|
+
/** A memory figure in GiB, for the step's reservation record. */
|
|
1439
|
+
export function memoryGiB(memory) {
|
|
1440
|
+
const mi = anyMemoryMi(memory);
|
|
1441
|
+
return Number.isFinite(mi) ? mi / 1024 : 2;
|
|
1442
|
+
}
|
|
1333
1443
|
/** The label both executors stamp, so one name means one thing. */
|
|
1334
1444
|
export const RUN_LABEL = "foldrun-run-id";
|
|
1335
1445
|
/**
|
|
@@ -1387,6 +1497,8 @@ export async function runStepInContainer(args) {
|
|
|
1387
1497
|
const containerEnv = stageContainerEnv(args.env, wsIn, args.input.agentRel, args.emit);
|
|
1388
1498
|
const envFile = path.join(staging, "env");
|
|
1389
1499
|
fs.writeFileSync(envFile, Object.entries(containerEnv).map(([k, v]) => `${k}=${v}`).join("\n"), { mode: 0o600 });
|
|
1500
|
+
const lim = args.limits ?? resolveResources(args.size);
|
|
1501
|
+
const cpus = dockerCpus(lim.cpus);
|
|
1390
1502
|
const flags = [
|
|
1391
1503
|
"--security-opt", "no-new-privileges",
|
|
1392
1504
|
...(args.runId ? ["--label", `${RUN_LABEL}=${args.runId}`] : []),
|
|
@@ -1396,10 +1508,11 @@ export async function runStepInContainer(args) {
|
|
|
1396
1508
|
"--cap-add", "CHOWN", "--cap-add", "SETUID", "--cap-add", "SETGID",
|
|
1397
1509
|
"--pids-limit", "512",
|
|
1398
1510
|
// Memory is a hard ceiling (not compressible — an OOM protects the host).
|
|
1399
|
-
// CPU is
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
"--memory",
|
|
1511
|
+
// CPU is capped at the class's billed cores, matching the k8s path
|
|
1512
|
+
// (since 26 Oct 2026): a step runs on what it pays for and cannot take
|
|
1513
|
+
// the whole machine from the steps beside it.
|
|
1514
|
+
"--memory", dockerMemory(lim.memory),
|
|
1515
|
+
...(cpus ? ["--cpus", cpus] : []),
|
|
1403
1516
|
"--env-file", envFile,
|
|
1404
1517
|
];
|
|
1405
1518
|
// gVisor (or kata) where the host has it: one env var, because the flag
|