@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.
@@ -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, sizeLimits, killRunSandboxes } from "./run-container.js";
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 is a hard ceiling per class; CPU bursts uncapped and bills
1161
- // at the class rate, so heavy both may use more and costs more. The actuals
1162
- // line in any trace is the evidence for right-sizing.
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
- if (credential) {
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
- return runIsolated(a);
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
- const lim = sizeLimits(size);
3119
- const gib = (() => {
3120
- const m = lim.memory.match(/^(\d+(?:\.\d+)?)\s*(Gi?|Mi?|g|m)?/i);
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
@@ -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/
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -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,
@@ -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
+ }
@@ -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
- if (!/^runner image:|^ERROR\b|^error:/i.test(line) && !/did not complete successfully/.test(line)) continue;
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 a billing weight, not a ceiling: on k8s the pod bursts CPU
1553
- // freely, and this is the rate its seconds are priced at.
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 deliberately NOT capped, matching the k8s path: a cap only
1637
- // throttles work the customer pays for, and the tier's cpu figure is a
1638
- // billing weight, applied in usage.ts, not a docker --cpus limit here.
1639
- "--memory", sizeLimits(args.size).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