@foldrun/core 2.12.0 → 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/runner.js
CHANGED
|
@@ -16,7 +16,8 @@ import { cascadeLimits, toolOwners } from "./limits.js";
|
|
|
16
16
|
import { resolveAgentPath } from "./confine.js";
|
|
17
17
|
import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js";
|
|
18
18
|
import { eventUrl } from "./webhook.js";
|
|
19
|
-
import { runStepInContainer,
|
|
19
|
+
import { runStepInContainer, resolveResources, memoryGiB, killRunSandboxes } from "./run-container.js";
|
|
20
|
+
import { readResources, hasOverride } from "./resources.js";
|
|
20
21
|
import { hostSafeEnv, platformApiUrl } from "./host-env.js";
|
|
21
22
|
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.js";
|
|
22
23
|
import { parseJudge } from "./judge-verdict.js";
|
|
@@ -1157,9 +1158,10 @@ grantsOnly = false) {
|
|
|
1157
1158
|
// is large — the install's configured limits — so existing agents change
|
|
1158
1159
|
// nothing. `size: small` is the opt-in for agents that wait on APIs more
|
|
1159
1160
|
// than they work; `size: heavy` for a rendered browser or real data in
|
|
1160
|
-
// memory. Memory
|
|
1161
|
-
//
|
|
1162
|
-
// line in any trace
|
|
1161
|
+
// memory. Memory and cores are hard ceilings per class and bill at the
|
|
1162
|
+
// class rate (cores capped since 26 Oct 2026), so heavy both may use more
|
|
1163
|
+
// and costs more. The actuals line in any trace — busy, throttled, peak —
|
|
1164
|
+
// is the evidence for right-sizing.
|
|
1163
1165
|
const size = front.size === "small" ? "small" : front.size === "heavy" ? "heavy" : "large";
|
|
1164
1166
|
// Provider: which endpoint the model calls run against. Anthropic by
|
|
1165
1167
|
// default; an Anthropic-compatible gateway (z.ai, LiteLLM, OpenRouter) by
|
|
@@ -2117,6 +2119,17 @@ delegateJob) {
|
|
|
2117
2119
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
2118
2120
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
2119
2121
|
const size = step.sizeUp ?? agentSize;
|
|
2122
|
+
// `resources:` over the class's figures, key by key. A value check would
|
|
2123
|
+
// refuse is dropped here too (the class's figure holds) and said so, so
|
|
2124
|
+
// `foldrun check` and the sandbox never disagree. After a retry moved up
|
|
2125
|
+
// a class, an override never holds the step below the new class.
|
|
2126
|
+
const asked = readResources(front.resources);
|
|
2127
|
+
for (const p of asked.problems)
|
|
2128
|
+
push("error", `resources: ${p}`);
|
|
2129
|
+
const limits = resolveResources(size, asked.value, { atLeastClass: !!step.sizeUp });
|
|
2130
|
+
// What the sandbox holds while it exists — `step.holding`, what the
|
|
2131
|
+
// account's compute in use sums (Settings, `foldrun usage compute`).
|
|
2132
|
+
const held = { cpus: Number(limits.cpus) || 2, memGiB: memoryGiB(limits.memory) };
|
|
2120
2133
|
// oauth2 secrets become live access tokens here — the one async moment
|
|
2121
2134
|
// in secret resolution, at the last host-side point before anything is
|
|
2122
2135
|
// injected or substituted. The refresh recipes never leave this process:
|
|
@@ -2150,7 +2163,10 @@ delegateJob) {
|
|
|
2150
2163
|
const noKey = !providerLabel && !platform.platformKeyAllowed(tenant);
|
|
2151
2164
|
const credential = providerLabel || noKey ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
|
|
2152
2165
|
step.credential = credential ? credential.label : noKey ? "none — this account has no model key" : `provider: ${providerLabel}`;
|
|
2153
|
-
|
|
2166
|
+
// Core's own Docker container has no login to fall back to (below):
|
|
2167
|
+
// there, "none set" is refused with what to do, not described.
|
|
2168
|
+
const noCredentialInContainer = credential?.kind === "none" && process.env.FOLDRUN_RUN_ISOLATION === "container";
|
|
2169
|
+
if (credential && !noCredentialInContainer) {
|
|
2154
2170
|
const line = credentialLine(credential);
|
|
2155
2171
|
push(line.type, line.text);
|
|
2156
2172
|
}
|
|
@@ -2174,6 +2190,21 @@ delegateJob) {
|
|
|
2174
2190
|
return;
|
|
2175
2191
|
}
|
|
2176
2192
|
}
|
|
2193
|
+
// A step in core's own Docker container (self-hosting) has no machine
|
|
2194
|
+
// login to fall back to: with no credential at all it could only fail at
|
|
2195
|
+
// the model, with Claude Code's own "Not logged in · Please run /login".
|
|
2196
|
+
// Refused here, before a sandbox is rented, saying what to set (a
|
|
2197
|
+
// self-hosted install with no key, 26 Oct 2026). A local `foldrun run`
|
|
2198
|
+
// keeps the machine's login; a registered executor brings its own.
|
|
2199
|
+
if (noCredentialInContainer) {
|
|
2200
|
+
push("error", "no model credential: this install has none and the account has no key of its own. Set ANTHROPIC_API_KEY " +
|
|
2201
|
+
"(or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`) in the platform's environment — for Docker Compose, " +
|
|
2202
|
+
"self-hosting/.env, then `docker compose up -d` — or add the account's key in Settings → Model " +
|
|
2203
|
+
"(`foldrun account model set <provider> --key …`)");
|
|
2204
|
+
step.status = "failed";
|
|
2205
|
+
save();
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2177
2208
|
for (const w of providerWarnings)
|
|
2178
2209
|
push("error", w);
|
|
2179
2210
|
// A level that wrote a language nobody can read was skipped, not obeyed.
|
|
@@ -2869,6 +2900,7 @@ delegateJob) {
|
|
|
2869
2900
|
// executor asks this before it creates one (pod-room.ts).
|
|
2870
2901
|
...(stopRequested ? { stopRequested } : {}),
|
|
2871
2902
|
size,
|
|
2903
|
+
limits,
|
|
2872
2904
|
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2873
2905
|
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
2874
2906
|
network: { level: network.level, enforced: forward?.enforce === true },
|
|
@@ -2885,6 +2917,9 @@ delegateJob) {
|
|
|
2885
2917
|
// saves the record, so this only updates the fields.
|
|
2886
2918
|
checkpoint: (ref, consumed) => {
|
|
2887
2919
|
step.sandbox = { kind: isolation, ref, consumed, since: step.sandbox?.since ?? new Date().toISOString() };
|
|
2920
|
+
// The sandbox exists: past the room gate, it now holds its figures.
|
|
2921
|
+
if (consumed === 0)
|
|
2922
|
+
step.holding = held;
|
|
2888
2923
|
if (consumed === 0)
|
|
2889
2924
|
save();
|
|
2890
2925
|
},
|
|
@@ -2900,9 +2935,24 @@ delegateJob) {
|
|
|
2900
2935
|
// The arguments of the attempt whose outcome is current — what a
|
|
2901
2936
|
// re-run on the full image repeats, on the same model supply.
|
|
2902
2937
|
let lastArgs = firstArgs;
|
|
2903
|
-
const runTracked = (a) => {
|
|
2938
|
+
const runTracked = async (a) => {
|
|
2904
2939
|
lastArgs = a;
|
|
2905
|
-
|
|
2940
|
+
// An executor that cannot report its sandbox (Docker: no room gate,
|
|
2941
|
+
// it starts at once) holds from the call; one that can says so at
|
|
2942
|
+
// its first checkpoint, after any wait for room.
|
|
2943
|
+
if (!platform.sandboxResumable(isolation)) {
|
|
2944
|
+
step.holding = held;
|
|
2945
|
+
save();
|
|
2946
|
+
}
|
|
2947
|
+
try {
|
|
2948
|
+
return await runIsolated(a);
|
|
2949
|
+
}
|
|
2950
|
+
finally {
|
|
2951
|
+
// Saved now: the clean-up after an attempt (the log drain, the
|
|
2952
|
+
// lease, publishing) can take a while, and the sandbox is gone.
|
|
2953
|
+
step.holding = null;
|
|
2954
|
+
save();
|
|
2955
|
+
}
|
|
2906
2956
|
};
|
|
2907
2957
|
let attemptFrom = step.events.length;
|
|
2908
2958
|
let outcome = await runTracked(firstArgs);
|
|
@@ -3096,6 +3146,7 @@ delegateJob) {
|
|
|
3096
3146
|
if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
|
|
3097
3147
|
const mb = (n) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
|
|
3098
3148
|
push("info", `resources: cpu busy ${outcome.res.busyCpuSecs?.toFixed(1) ?? "?"}s · ` +
|
|
3149
|
+
(outcome.res.throttledSecs ? `throttled ${outcome.res.throttledSecs.toFixed(1)}s · ` : "") +
|
|
3099
3150
|
`peak mem ${mb(outcome.res.peakMemBytes)}MB · ` +
|
|
3100
3151
|
`net ${mb(outcome.res.rxBytes)}MB in / ${mb(outcome.res.txBytes)}MB out`);
|
|
3101
3152
|
}
|
|
@@ -3115,13 +3166,9 @@ delegateJob) {
|
|
|
3115
3166
|
// is per core-second and per GiB-second, and what was held must come
|
|
3116
3167
|
// from the record, never from what the config says at billing time.
|
|
3117
3168
|
step.size = size;
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
const n = m ? Number(m[1]) : 2;
|
|
3122
|
-
return (m?.[2] ?? "Gi").toLowerCase().startsWith("m") ? n / 1024 : n;
|
|
3123
|
-
})();
|
|
3124
|
-
step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
|
|
3169
|
+
if (hasOverride(asked.value))
|
|
3170
|
+
step.resources = asked.value;
|
|
3171
|
+
step.reserved = held;
|
|
3125
3172
|
}
|
|
3126
3173
|
else {
|
|
3127
3174
|
// Same withholding as the sandbox path — the in-process step is what
|
package/dist/src/starter.js
CHANGED
|
@@ -474,6 +474,7 @@ owner: ana@example.com # who answers for it; else the workspace's, else the
|
|
|
474
474
|
model: default # fast | default | max
|
|
475
475
|
effort: low # how hard to think about it
|
|
476
476
|
size: large # small | large | heavy — the sandbox it rents
|
|
477
|
+
resources: { memory: 4Gi } # optional: cpu, memory, disk in numbers, over the size class
|
|
477
478
|
tools: [web, read, ask, peers, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step, \`peers\` lets it message the steps running beside it
|
|
478
479
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
479
480
|
skills: [house-style] # from skills/
|
package/dist/src/store.d.ts
CHANGED
|
@@ -1312,6 +1312,21 @@ export interface StepRecord {
|
|
|
1312
1312
|
cpus: number;
|
|
1313
1313
|
memGiB: number;
|
|
1314
1314
|
} | null;
|
|
1315
|
+
/** The agent's `resources:` as applied (resources.ts), when it set any:
|
|
1316
|
+
* why `reserved` differs from the class's figures. Absent before 26 Oct
|
|
1317
|
+
* 2026 and on steps of an agent that wrote none. */
|
|
1318
|
+
resources?: {
|
|
1319
|
+
cpu?: number;
|
|
1320
|
+
memory?: string;
|
|
1321
|
+
disk?: string;
|
|
1322
|
+
};
|
|
1323
|
+
/** What the step's sandbox holds while it exists — set once it is past
|
|
1324
|
+
* the room gate, cleared when the attempt ends. What an account's
|
|
1325
|
+
* compute in use sums; `reserved` is the bill's record of the same. */
|
|
1326
|
+
holding?: {
|
|
1327
|
+
cpus: number;
|
|
1328
|
+
memGiB: number;
|
|
1329
|
+
} | null;
|
|
1315
1330
|
/** Token counts behind costUsd, kept so usage reports can say "how many"
|
|
1316
1331
|
* and not only "how much" — the price of a token changes, the count is
|
|
1317
1332
|
* the fact. Absent on steps recorded before this existed. */
|
|
@@ -1336,6 +1351,9 @@ export interface StepRecord {
|
|
|
1336
1351
|
actual?: {
|
|
1337
1352
|
busyCpuSecs: number | null;
|
|
1338
1353
|
peakMemBytes: number | null;
|
|
1354
|
+
/** Seconds the CPU cap (the class's cores) held the sandbox back, from
|
|
1355
|
+
* cpu.stat. Absent before 26 Oct 2026. */
|
|
1356
|
+
throttledSecs?: number | null;
|
|
1339
1357
|
rxBytes: number | null;
|
|
1340
1358
|
txBytes: number | null;
|
|
1341
1359
|
} | null;
|
package/package.json
CHANGED
package/src/completions.ts
CHANGED
|
@@ -143,6 +143,7 @@ const FIELDS: Record<string, Completion[]> = {
|
|
|
143
143
|
{ label: "skills", insert: "skills:\n - ", hint: "allowlist; omit to inherit all" },
|
|
144
144
|
{ label: "scripts", insert: "scripts:\n - name: \n run: \n description: " },
|
|
145
145
|
{ label: "runtime", insert: "runtime:\n python: \"3\"\n packages: []" },
|
|
146
|
+
{ label: "resources", insert: "resources:\n cpu: 2\n memory: 4Gi", hint: "cores, memory and disk in numbers, over the size class's" },
|
|
146
147
|
{ label: "mcpServers", insert: "mcpServers:\n " },
|
|
147
148
|
{ label: "ask", insert: "ask:\n timeout: 30m", hint: "tools: [ask] — how long ask_person waits for a person (max 24h)" },
|
|
148
149
|
{ label: "permissionMode", insert: "permissionMode: plan", hint: "plan = read-only" },
|
|
@@ -79,6 +79,7 @@ const AGENT: FieldSpec[] = [
|
|
|
79
79
|
{ key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
|
|
80
80
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
|
|
81
81
|
{ key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
|
|
82
|
+
{ 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" },
|
|
82
83
|
{ key: "permissionMode", type: "string", values: ["plan"], help: "plan = read-only; no other mode changes anything here" },
|
|
83
84
|
{ key: "tools", type: ["list", "string"], help: "the one grant list" },
|
|
84
85
|
{ key: "disallowedTools", type: ["list", "string"], help: "subtract from what it would otherwise have" },
|
|
@@ -130,6 +131,7 @@ const AGENTS_MD: FieldSpec[] = [
|
|
|
130
131
|
{ key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
|
|
131
132
|
{ key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
|
|
132
133
|
{ key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
|
|
134
|
+
{ key: "compute", type: "object", help: "account: {vcpu, memory} its running steps may hold together — lowers the plan's, never raises it" },
|
|
133
135
|
{ 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" },
|
|
134
136
|
{ 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)" },
|
|
135
137
|
{ 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" },
|
|
@@ -244,7 +246,7 @@ const SAID_WHAT_IT_IS: FieldSpec[] = [
|
|
|
244
246
|
const JOB_FIELDS: Partial<Record<SchemaKind, Partial<Record<Job, readonly string[]>>>> = {
|
|
245
247
|
agent: {
|
|
246
248
|
governance: ["owner", "budget"],
|
|
247
|
-
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox"],
|
|
249
|
+
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox", "resources"],
|
|
248
250
|
},
|
|
249
251
|
flow: {
|
|
250
252
|
governance: ["owner", "approvers", "approve_within", "budget", "priority"],
|
|
@@ -252,7 +254,7 @@ const JOB_FIELDS: Partial<Record<SchemaKind, Partial<Record<Job, readonly string
|
|
|
252
254
|
observability: ["issues", "sla"],
|
|
253
255
|
},
|
|
254
256
|
"agents-md": {
|
|
255
|
-
governance: ["owner", "budget", "priority", "concurrency", "deploy_block"],
|
|
257
|
+
governance: ["owner", "budget", "priority", "concurrency", "compute", "deploy_block"],
|
|
256
258
|
guardrail: ["limits", "network", "network_enforce", "hooks"],
|
|
257
259
|
observability: ["alerts", "telemetry", "notify", "capture"],
|
|
258
260
|
evaluation: ["judge"],
|
|
@@ -8,6 +8,7 @@ import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS
|
|
|
8
8
|
import { budgetProblem } from "./budget.ts";
|
|
9
9
|
import { cronProblem } from "./cron.ts";
|
|
10
10
|
import { modelSecretCollision } from "./model-credential.ts";
|
|
11
|
+
import { resourcesProblem, computeProblem } from "./resources.ts";
|
|
11
12
|
|
|
12
13
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
13
14
|
|
|
@@ -33,11 +34,13 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
33
34
|
apis: apisProblem,
|
|
34
35
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
35
36
|
ask: (v) => askProblem(v),
|
|
37
|
+
resources: resourcesProblem,
|
|
36
38
|
secrets: modelSecretCollision,
|
|
37
39
|
},
|
|
38
40
|
"agents-md": {
|
|
39
41
|
budget: (v) => budgetProblem(v, ["day", "week", "month"]),
|
|
40
42
|
concurrency: (v) => (Number.isInteger(Number(v)) && Number(v) > 0 ? null : `${String(v)} is not a whole number of runs, 1 or more`),
|
|
43
|
+
compute: computeProblem,
|
|
41
44
|
},
|
|
42
45
|
"flow": {
|
|
43
46
|
schedule: cronProblem,
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
|
|
17
|
+
/** What an agent may ask for, and in what steps. */
|
|
18
|
+
export const RESOURCE_BOUNDS = {
|
|
19
|
+
cpu: { min: 0.5, max: 8, step: 0.5 },
|
|
20
|
+
memoryMi: { min: 256, max: 16 * 1024, step: 256 },
|
|
21
|
+
diskGi: { min: 1, max: 40, step: 1 },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** The overrides an agent asked for, normalised: cores as a number, memory
|
|
25
|
+
* in Mi and disk in Gi as quantities both Kubernetes and Docker accept. */
|
|
26
|
+
export interface ResourceOverride {
|
|
27
|
+
cpu?: number;
|
|
28
|
+
memory?: string;
|
|
29
|
+
disk?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KEYS = ["cpu", "memory", "disk"] as const;
|
|
33
|
+
|
|
34
|
+
/** A size in MiB: 512Mi, 4Gi, 1.5Gi (and Docker's 4g, 512m). NaN when it is
|
|
35
|
+
* not one. A bare number is refused, because 4 could mean four of anything;
|
|
36
|
+
* so are G and M, which are decimal to Kubernetes and binary to Docker —
|
|
37
|
+
* an agent's figure means one thing everywhere it is read. */
|
|
38
|
+
export function quantityMi(q: unknown): number {
|
|
39
|
+
if (typeof q !== "string") return NaN;
|
|
40
|
+
const m = /^(\d+(?:\.\d+)?)\s*(Mi|Gi|m|g)$/.exec(q.trim());
|
|
41
|
+
if (!m) return NaN;
|
|
42
|
+
const n = Number(m[1]);
|
|
43
|
+
return m[2] === "Gi" || m[2] === "g" ? n * 1024 : n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A class's memory figure in MiB, in any spelling an install may set it:
|
|
47
|
+
* bytes, Kubernetes' Ki/Mi/Gi/Ti and K/M/G/T, Docker's k/m/g/t. Wider than
|
|
48
|
+
* quantityMi, which reads only what an agent may write. NaN when none. */
|
|
49
|
+
export function anyMemoryMi(q: unknown): number {
|
|
50
|
+
const m = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|K|M|G|T|k|m|g|t|b)?$/.exec(String(q ?? "").trim());
|
|
51
|
+
if (!m) return NaN;
|
|
52
|
+
const n = Number(m[1]);
|
|
53
|
+
const MiB = 2 ** 20;
|
|
54
|
+
const bin: Record<string, number> = { Ki: 2 ** 10, Mi: MiB, Gi: 2 ** 30, Ti: 2 ** 40, k: 2 ** 10, m: MiB, g: 2 ** 30, t: 2 ** 40, b: 1 };
|
|
55
|
+
const dec: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
|
56
|
+
const unit = m[2] ?? "b";
|
|
57
|
+
return (n * (unit in bin ? bin[unit] : dec[unit])) / MiB;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const shown = (v: unknown) => (typeof v === "string" || typeof v === "number" ? String(v) : Array.isArray(v) ? "a list" : "a map");
|
|
61
|
+
|
|
62
|
+
/** Read `resources:` — what applies, and why anything else does not. A key
|
|
63
|
+
* that fails is dropped (the class's figure holds), never guessed at. */
|
|
64
|
+
export function readResources(raw: unknown): { value: ResourceOverride; problems: string[] } {
|
|
65
|
+
const value: ResourceOverride = {};
|
|
66
|
+
const problems: string[] = [];
|
|
67
|
+
if (raw === undefined || raw === null) return { value, problems };
|
|
68
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
69
|
+
return { value, problems: [`is ${shown(raw)}, not a map — write {cpu: 2, memory: 4Gi, disk: 10Gi}`] };
|
|
70
|
+
}
|
|
71
|
+
const r = raw as Record<string, unknown>;
|
|
72
|
+
for (const k of Object.keys(r)) {
|
|
73
|
+
if (!(KEYS as readonly string[]).includes(k)) problems.push(`\`${k}\` is not one of cpu, memory, disk — it is ignored`);
|
|
74
|
+
}
|
|
75
|
+
const { cpu: C, memoryMi: M, diskGi: D } = RESOURCE_BOUNDS;
|
|
76
|
+
if (r.cpu !== undefined && r.cpu !== null) {
|
|
77
|
+
// A number, or a string of one — never a list or a map coerced (`[2]`
|
|
78
|
+
// is 2 to Number(), and a map's own toString may throw).
|
|
79
|
+
const numeric = typeof r.cpu === "number" || (typeof r.cpu === "string" && /^\s*\d+(\.\d+)?\s*$/.test(r.cpu));
|
|
80
|
+
const n = numeric ? Number(r.cpu) : NaN;
|
|
81
|
+
if (!Number.isFinite(n)) problems.push(`cpu: ${shown(r.cpu)} is not a number of cores — write 2 or 0.5`);
|
|
82
|
+
else if (n < C.min || n > C.max || Math.round(n / C.step) * C.step !== n) problems.push(`cpu: ${n} is not ${C.min} to ${C.max} cores in halves — the class's cores are used`);
|
|
83
|
+
else value.cpu = n;
|
|
84
|
+
}
|
|
85
|
+
if (r.memory !== undefined && r.memory !== null) {
|
|
86
|
+
const mi = quantityMi(r.memory);
|
|
87
|
+
if (!Number.isFinite(mi)) 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)" : ""}`);
|
|
88
|
+
else if (mi < M.min || mi > M.max || mi % M.step !== 0) problems.push(`memory: ${shown(r.memory)} is not 256Mi to 16Gi in steps of 256Mi — the class's memory is used`);
|
|
89
|
+
else value.memory = `${mi}Mi`;
|
|
90
|
+
}
|
|
91
|
+
if (r.disk !== undefined && r.disk !== null) {
|
|
92
|
+
const mi = quantityMi(r.disk);
|
|
93
|
+
const gi = mi / 1024;
|
|
94
|
+
if (!Number.isFinite(mi)) problems.push(`disk: ${shown(r.disk)} is not a size — write 10Gi`);
|
|
95
|
+
else if (!Number.isInteger(gi) || gi < D.min || gi > D.max) problems.push(`disk: ${shown(r.disk)} is not a whole 1Gi to 40Gi — the class's disk is used`);
|
|
96
|
+
else value.disk = `${gi}Gi`;
|
|
97
|
+
}
|
|
98
|
+
return { value, problems };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** For `foldrun check`: every reason, in one message, or null. */
|
|
102
|
+
export function resourcesProblem(v: unknown): string | null {
|
|
103
|
+
const { problems } = readResources(v);
|
|
104
|
+
return problems.length ? problems.join("; ") : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether an override changes anything. */
|
|
108
|
+
export function hasOverride(o: ResourceOverride | null | undefined): o is ResourceOverride {
|
|
109
|
+
return !!o && (o.cpu !== undefined || o.memory !== undefined || o.disk !== undefined);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** An account's own `compute:` in AGENTS.md — the most its running steps may
|
|
113
|
+
* hold together. It can only lower what the plan or install allows
|
|
114
|
+
* (platform limits.ts accountCompute); here it is only read:
|
|
115
|
+
*
|
|
116
|
+
* compute: { vcpu: 4, memory: 8Gi }
|
|
117
|
+
*/
|
|
118
|
+
export interface ComputeAsk {
|
|
119
|
+
vcpu?: number;
|
|
120
|
+
memGiB?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function readCompute(raw: unknown): { value: ComputeAsk; problems: string[] } {
|
|
124
|
+
const value: ComputeAsk = {};
|
|
125
|
+
const problems: string[] = [];
|
|
126
|
+
if (raw === undefined || raw === null) return { value, problems };
|
|
127
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
128
|
+
return { value, problems: [`is ${shown(raw)}, not a map — write {vcpu: 4, memory: 8Gi}`] };
|
|
129
|
+
}
|
|
130
|
+
const r = raw as Record<string, unknown>;
|
|
131
|
+
for (const k of Object.keys(r)) {
|
|
132
|
+
if (k !== "vcpu" && k !== "memory") problems.push(`\`${k}\` is not vcpu or memory — it is ignored`);
|
|
133
|
+
}
|
|
134
|
+
if (r.vcpu !== undefined && r.vcpu !== null) {
|
|
135
|
+
const numeric = typeof r.vcpu === "number" || (typeof r.vcpu === "string" && /^\s*\d+(\.\d+)?\s*$/.test(r.vcpu));
|
|
136
|
+
const n = numeric ? Number(r.vcpu) : NaN;
|
|
137
|
+
if (!Number.isFinite(n) || n < 0.5 || Math.round(n * 2) !== n * 2) problems.push(`vcpu: ${shown(r.vcpu)} is not a number of cores, 0.5 or more in halves`);
|
|
138
|
+
else value.vcpu = n;
|
|
139
|
+
}
|
|
140
|
+
if (r.memory !== undefined && r.memory !== null) {
|
|
141
|
+
const mi = quantityMi(r.memory);
|
|
142
|
+
if (!Number.isFinite(mi) || mi < 256) problems.push(`memory: ${shown(r.memory)} is not a size of 256Mi or more — write 8Gi`);
|
|
143
|
+
else value.memGiB = mi / 1024;
|
|
144
|
+
}
|
|
145
|
+
return { value, problems };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function computeProblem(v: unknown): string | null {
|
|
149
|
+
const { problems } = readCompute(v);
|
|
150
|
+
return problems.length ? problems.join("; ") : null;
|
|
151
|
+
}
|
package/src/run-container.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type { TokenPrice } from "./catalog.ts";
|
|
|
33
33
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
34
34
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
|
|
35
35
|
import { egressCaSh, type NetworkLevel } from "./network.ts";
|
|
36
|
+
import { anyMemoryMi, quantityMi, type ResourceOverride } from "./resources.ts";
|
|
36
37
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
37
38
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
38
39
|
import type { DelegateSpec, SubagentSpec } from "./subagents.ts";
|
|
@@ -181,6 +182,8 @@ export function parseDriverMark(line: string): { name: StartupPhase; at: number
|
|
|
181
182
|
export interface StepActuals {
|
|
182
183
|
busyCpuSecs: number | null;
|
|
183
184
|
peakMemBytes: number | null;
|
|
185
|
+
/** Seconds the CPU cap held the sandbox back. Absent before 26 Oct 2026. */
|
|
186
|
+
throttledSecs?: number | null;
|
|
184
187
|
rxBytes: number | null;
|
|
185
188
|
txBytes: number | null;
|
|
186
189
|
}
|
|
@@ -619,6 +622,11 @@ function readActuals() {
|
|
|
619
622
|
const cpuStat = read("/sys/fs/cgroup/cpu.stat");
|
|
620
623
|
const usage = cpuStat?.match(/^usage_usec (\\d+)/m);
|
|
621
624
|
const cpuacctNs = usage ? NaN : Number(read("/sys/fs/cgroup/cpuacct/cpuacct.usage")?.trim());
|
|
625
|
+
// Time the CPU cap held the sandbox back: v2's throttled_usec in the same
|
|
626
|
+
// cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against
|
|
627
|
+
// busy time means the step wanted more cores than its class gives.
|
|
628
|
+
const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);
|
|
629
|
+
const throttledNs = throttledUs ? null : read("/sys/fs/cgroup/cpu/cpu.stat")?.match(/^throttled_time (\\d+)/m);
|
|
622
630
|
const peak =
|
|
623
631
|
read("/sys/fs/cgroup/memory.peak") ??
|
|
624
632
|
read("/sys/fs/cgroup/memory/memory.max_usage_in_bytes") ??
|
|
@@ -638,6 +646,7 @@ function readActuals() {
|
|
|
638
646
|
? cpuacctNs / 1e9
|
|
639
647
|
: null,
|
|
640
648
|
peakMemBytes: peak ? Number(peak.trim()) || null : null,
|
|
649
|
+
throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,
|
|
641
650
|
rxBytes: sawNet ? rx : null,
|
|
642
651
|
txBytes: sawNet ? tx : null,
|
|
643
652
|
};
|
|
@@ -1262,7 +1271,8 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
|
|
|
1262
1271
|
const key: string[] = [];
|
|
1263
1272
|
for (const raw of log.split("\n")) {
|
|
1264
1273
|
const line = raw.replace(/^#\d+ \d+(\.\d+)? /, "").trim();
|
|
1265
|
-
|
|
1274
|
+
// apt's own errors start "E: "; the legacy builder ends with "returned a non-zero code".
|
|
1275
|
+
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)) continue;
|
|
1266
1276
|
const cut = line.length > 300 ? line.slice(0, 300) + " …" : line;
|
|
1267
1277
|
if (seen.has(cut)) continue;
|
|
1268
1278
|
seen.add(cut);
|
|
@@ -1270,6 +1280,11 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
|
|
|
1270
1280
|
}
|
|
1271
1281
|
// The ones that say what is missing first: a reader stops at the top.
|
|
1272
1282
|
key.sort((a, b) => Number(/MISSING ENGINES/.test(b)) - Number(/MISSING ENGINES/.test(a)));
|
|
1283
|
+
// A full Docker disk is the commonest cause on one machine, and the fix is
|
|
1284
|
+
// not in foldrun: say it first, in plain words.
|
|
1285
|
+
if (/enough free space|no space left on device/i.test(log)) {
|
|
1286
|
+
key.unshift("the Docker disk is full: free space (`docker system df`, then `docker builder prune` / `docker image prune`) and run it again");
|
|
1287
|
+
}
|
|
1273
1288
|
const head = key.length
|
|
1274
1289
|
? key.slice(0, maxLines).map((l) => ` ${l}`).join("\n") + (key.length > maxLines ? `\n … ${key.length - maxLines} more` : "")
|
|
1275
1290
|
: " (no engine or ERROR line in the log)";
|
|
@@ -1372,6 +1387,8 @@ export function parseDriverLine(
|
|
|
1372
1387
|
? {
|
|
1373
1388
|
busyCpuSecs: typeof parsed.res.busyCpuSecs === "number" ? parsed.res.busyCpuSecs : null,
|
|
1374
1389
|
peakMemBytes: typeof parsed.res.peakMemBytes === "number" ? parsed.res.peakMemBytes : null,
|
|
1390
|
+
// A driver from before 26 Oct 2026 sends none: unknown, not zero.
|
|
1391
|
+
throttledSecs: typeof parsed.res.throttledSecs === "number" ? parsed.res.throttledSecs : null,
|
|
1375
1392
|
rxBytes: typeof parsed.res.rxBytes === "number" ? parsed.res.rxBytes : null,
|
|
1376
1393
|
txBytes: typeof parsed.res.txBytes === "number" ? parsed.res.txBytes : null,
|
|
1377
1394
|
}
|
|
@@ -1408,6 +1425,54 @@ function ensureRunNetwork(): string {
|
|
|
1408
1425
|
return RUN_NETWORK;
|
|
1409
1426
|
}
|
|
1410
1427
|
|
|
1428
|
+
// The cores the container engine can hand out, asked once. `--cpus` above it
|
|
1429
|
+
// is refused outright ("range of CPUs is from 0.01 to N"), so a heavy step
|
|
1430
|
+
// on a two-core server would fail before it began. The engine may be a VM
|
|
1431
|
+
// (Docker Desktop) with fewer cores than this machine, which is why it is
|
|
1432
|
+
// asked rather than read from os; podman answers a different template, and
|
|
1433
|
+
// an engine that does not answer falls back to this machine's count.
|
|
1434
|
+
let engineCpuCount: number | null = null;
|
|
1435
|
+
function engineCpus(): number {
|
|
1436
|
+
if (engineCpuCount !== null) return engineCpuCount;
|
|
1437
|
+
const out = spawnSync(cli(), ["info", "--format", "{{.NCPU}}"], { encoding: "utf8", timeout: 10_000 });
|
|
1438
|
+
const n = Number((out.stdout ?? "").trim());
|
|
1439
|
+
engineCpuCount = Number.isInteger(n) && n > 0 ? n : os.availableParallelism();
|
|
1440
|
+
return engineCpuCount;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* A memory figure Docker will accept: the mirror of k8sMemory.
|
|
1445
|
+
*
|
|
1446
|
+
* Docker reads b/k/m/g (binary) and refuses Kubernetes' Mi and Gi outright
|
|
1447
|
+
* ("invalid suffix: 'mi'", Docker 29). The class defaults for small and
|
|
1448
|
+
* heavy are Kubernetes quantities (1Gi, 8Gi), and `resources: memory` is
|
|
1449
|
+
* normalised to Mi, so before 26 Oct 2026 every small and heavy step failed
|
|
1450
|
+
* to start on the docker executor; only large's `2g` worked. Binary suffixes
|
|
1451
|
+
* become whole bytes (1.5Gi has no Docker spelling), Docker's own pass
|
|
1452
|
+
* through, and anything else is handed over for Docker's error to name.
|
|
1453
|
+
*/
|
|
1454
|
+
export function dockerMemory(value: string): string {
|
|
1455
|
+
const s = String(value).trim();
|
|
1456
|
+
// Lowercase only: case decides, as in k8sMemory — `1G` is Kubernetes'
|
|
1457
|
+
// decimal gigabyte, and Docker would read it as binary.
|
|
1458
|
+
if (/^\d+(\.\d+)?[bkmg]?$/.test(s)) return s;
|
|
1459
|
+
const m = /^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|K|M|G|T)$/.exec(s);
|
|
1460
|
+
if (!m) return s;
|
|
1461
|
+
const pow: Record<string, number> = { Ki: 1, Mi: 2, Gi: 3, Ti: 4 };
|
|
1462
|
+
const dec: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
|
1463
|
+
const bytes = m[2] in pow ? Number(m[1]) * 1024 ** pow[m[2]] : Number(m[1]) * dec[m[2]];
|
|
1464
|
+
return String(Math.round(bytes));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** The `--cpus` a step's container gets: its class's billed cores, no more
|
|
1468
|
+
* than the engine has. Null when the figure is not a number, so the flag is
|
|
1469
|
+
* left off rather than failing the step on an install's typo. */
|
|
1470
|
+
export function dockerCpus(cpus: string, available: number = engineCpus()): string | null {
|
|
1471
|
+
const n = Number(cpus);
|
|
1472
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
1473
|
+
return String(Math.min(n, available));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1411
1476
|
export interface RunInContainerArgs {
|
|
1412
1477
|
workspaceRoot: string;
|
|
1413
1478
|
libraryRoot: string;
|
|
@@ -1435,6 +1500,9 @@ export interface RunInContainerArgs {
|
|
|
1435
1500
|
stopRequested?: () => boolean;
|
|
1436
1501
|
/** The reservation class — which limits this step's sandbox holds. */
|
|
1437
1502
|
size?: "small" | "large" | "heavy";
|
|
1503
|
+
/** The class's figures with the agent's `resources:` over them
|
|
1504
|
+
* (resolveResources). Absent: the class's own. */
|
|
1505
|
+
limits?: StepLimits;
|
|
1438
1506
|
/** Re-attach to a sandbox a previous driver started, instead of creating
|
|
1439
1507
|
* one: its handle, and how many output lines that driver had applied.
|
|
1440
1508
|
* The executor replays nothing before `consumed`, and reads the step's
|
|
@@ -1549,8 +1617,8 @@ export function sizeLimits(size?: "small" | "large" | "heavy"): { cpus: string;
|
|
|
1549
1617
|
// For steps that render a browser or hold real data. The memory figure is
|
|
1550
1618
|
// a hard ceiling (memory is not compressible — over it, the kernel OOM-kills
|
|
1551
1619
|
// the pod, which on a single-box install can take neighbours with it). The
|
|
1552
|
-
// cpus figure is
|
|
1553
|
-
//
|
|
1620
|
+
// cpus figure is both the rate its seconds are priced at and, since
|
|
1621
|
+
// 26 Oct 2026, the cores the sandbox is capped at.
|
|
1554
1622
|
return {
|
|
1555
1623
|
cpus: process.env.FOLDRUN_RUNNER_CPUS_HEAVY ?? "4",
|
|
1556
1624
|
memory: process.env.FOLDRUN_RUNNER_MEMORY_HEAVY ?? "8Gi",
|
|
@@ -1562,6 +1630,66 @@ export function sizeLimits(size?: "small" | "large" | "heavy"): { cpus: string;
|
|
|
1562
1630
|
};
|
|
1563
1631
|
}
|
|
1564
1632
|
|
|
1633
|
+
/** What one step's sandbox holds: cores and memory (each a hard limit and a
|
|
1634
|
+
* billing figure) and, where the agent set it, disk. */
|
|
1635
|
+
export interface StepLimits {
|
|
1636
|
+
cpus: string;
|
|
1637
|
+
memory: string;
|
|
1638
|
+
/** The agent's `resources: disk`, or null for the class's (the k8s tier's
|
|
1639
|
+
* diskFor; Docker has no per-container disk cap). */
|
|
1640
|
+
disk: string | null;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* The class's figures with an agent's `resources:` over them, key by key.
|
|
1645
|
+
*
|
|
1646
|
+
* `atLeastClass` is for a retry that moved the step up a class after the
|
|
1647
|
+
* cluster ended it for want of memory or disk: each override is raised to
|
|
1648
|
+
* the bigger class's figure, so `memory: 3Gi` OOM-killed at large retries at
|
|
1649
|
+
* heavy's 8Gi rather than the same 3Gi that already failed.
|
|
1650
|
+
*/
|
|
1651
|
+
export function resolveResources(
|
|
1652
|
+
size?: "small" | "large" | "heavy",
|
|
1653
|
+
override?: ResourceOverride | null,
|
|
1654
|
+
opts: { atLeastClass?: boolean } = {},
|
|
1655
|
+
): StepLimits {
|
|
1656
|
+
const base = sizeLimits(size);
|
|
1657
|
+
const bigger = (mine: number, theirs: number) => (opts.atLeastClass ? Math.max(mine, theirs) : mine);
|
|
1658
|
+
const baseMi = anyMemoryMi(base.memory);
|
|
1659
|
+
let cpus = base.cpus;
|
|
1660
|
+
if (override?.cpu !== undefined) {
|
|
1661
|
+
const b = Number(base.cpus);
|
|
1662
|
+
cpus = String(Number.isFinite(b) ? bigger(override.cpu, b) : override.cpu);
|
|
1663
|
+
}
|
|
1664
|
+
let memory = base.memory;
|
|
1665
|
+
if (override?.memory !== undefined) {
|
|
1666
|
+
const mi = quantityMi(override.memory);
|
|
1667
|
+
memory = Number.isFinite(baseMi) && opts.atLeastClass && baseMi > mi ? base.memory : override.memory;
|
|
1668
|
+
}
|
|
1669
|
+
// Disk the same way: a step evicted for disk at its own `disk: 2Gi` would
|
|
1670
|
+
// fail again at 2Gi, whatever class the retry moved it to.
|
|
1671
|
+
let disk = override?.disk ?? null;
|
|
1672
|
+
if (disk && opts.atLeastClass) {
|
|
1673
|
+
const classMi = anyMemoryMi(classDisk(size));
|
|
1674
|
+
if (Number.isFinite(classMi) && classMi > quantityMi(disk)) disk = null;
|
|
1675
|
+
}
|
|
1676
|
+
return { cpus, memory, disk };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** The node disk a class may write (the k8s `ephemeral-storage` limit;
|
|
1680
|
+
* Docker has no per-container cap). Env-tunable like the cores and memory. */
|
|
1681
|
+
export function classDisk(size?: "small" | "large" | "heavy"): string {
|
|
1682
|
+
if (size === "small") return process.env.FOLDRUN_RUNNER_DISK_SMALL ?? "2Gi";
|
|
1683
|
+
if (size === "heavy") return process.env.FOLDRUN_RUNNER_DISK_HEAVY ?? "20Gi";
|
|
1684
|
+
return process.env.FOLDRUN_RUNNER_DISK ?? "8Gi";
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** A memory figure in GiB, for the step's reservation record. */
|
|
1688
|
+
export function memoryGiB(memory: string): number {
|
|
1689
|
+
const mi = anyMemoryMi(memory);
|
|
1690
|
+
return Number.isFinite(mi) ? mi / 1024 : 2;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1565
1693
|
/** The label both executors stamp, so one name means one thing. */
|
|
1566
1694
|
export const RUN_LABEL = "foldrun-run-id";
|
|
1567
1695
|
|
|
@@ -1624,6 +1752,8 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
1624
1752
|
{ mode: 0o600 },
|
|
1625
1753
|
);
|
|
1626
1754
|
|
|
1755
|
+
const lim = args.limits ?? resolveResources(args.size);
|
|
1756
|
+
const cpus = dockerCpus(lim.cpus);
|
|
1627
1757
|
const flags = [
|
|
1628
1758
|
"--security-opt", "no-new-privileges",
|
|
1629
1759
|
...(args.runId ? ["--label", `${RUN_LABEL}=${args.runId}`] : []),
|
|
@@ -1633,10 +1763,11 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
1633
1763
|
"--cap-add", "CHOWN", "--cap-add", "SETUID", "--cap-add", "SETGID",
|
|
1634
1764
|
"--pids-limit", "512",
|
|
1635
1765
|
// Memory is a hard ceiling (not compressible — an OOM protects the host).
|
|
1636
|
-
// CPU is
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
"--memory",
|
|
1766
|
+
// CPU is capped at the class's billed cores, matching the k8s path
|
|
1767
|
+
// (since 26 Oct 2026): a step runs on what it pays for and cannot take
|
|
1768
|
+
// the whole machine from the steps beside it.
|
|
1769
|
+
"--memory", dockerMemory(lim.memory),
|
|
1770
|
+
...(cpus ? ["--cpus", cpus] : []),
|
|
1640
1771
|
"--env-file", envFile,
|
|
1641
1772
|
];
|
|
1642
1773
|
// gVisor (or kata) where the host has it: one env var, because the flag
|