@foldrun/core 2.12.1 → 2.13.1

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.
@@ -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,65 @@ 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
+
1476
+ /** The cores a step's sandbox actually holds, for its record (the bill and
1477
+ * the compute in use): the asked-for figure, except on the docker
1478
+ * executor, which lowers --cpus to what the engine has (dockerCpus). Until
1479
+ * 26 Oct 2026 the record said heavy's 4 on a two-core machine that gave 2. */
1480
+ export function heldCores(cpus: string, isolation: string | undefined, available?: number): number {
1481
+ const asked = Number(cpus) || 2;
1482
+ if (isolation !== "container") return asked;
1483
+ const given = Number(available === undefined ? dockerCpus(cpus) : dockerCpus(cpus, available));
1484
+ return Number.isFinite(given) && given > 0 ? given : asked;
1485
+ }
1486
+
1411
1487
  export interface RunInContainerArgs {
1412
1488
  workspaceRoot: string;
1413
1489
  libraryRoot: string;
@@ -1435,6 +1511,9 @@ export interface RunInContainerArgs {
1435
1511
  stopRequested?: () => boolean;
1436
1512
  /** The reservation class — which limits this step's sandbox holds. */
1437
1513
  size?: "small" | "large" | "heavy";
1514
+ /** The class's figures with the agent's `resources:` over them
1515
+ * (resolveResources). Absent: the class's own. */
1516
+ limits?: StepLimits;
1438
1517
  /** Re-attach to a sandbox a previous driver started, instead of creating
1439
1518
  * one: its handle, and how many output lines that driver had applied.
1440
1519
  * The executor replays nothing before `consumed`, and reads the step's
@@ -1549,8 +1628,8 @@ export function sizeLimits(size?: "small" | "large" | "heavy"): { cpus: string;
1549
1628
  // For steps that render a browser or hold real data. The memory figure is
1550
1629
  // a hard ceiling (memory is not compressible — over it, the kernel OOM-kills
1551
1630
  // 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.
1631
+ // cpus figure is both the rate its seconds are priced at and, since
1632
+ // 26 Oct 2026, the cores the sandbox is capped at.
1554
1633
  return {
1555
1634
  cpus: process.env.FOLDRUN_RUNNER_CPUS_HEAVY ?? "4",
1556
1635
  memory: process.env.FOLDRUN_RUNNER_MEMORY_HEAVY ?? "8Gi",
@@ -1562,6 +1641,66 @@ export function sizeLimits(size?: "small" | "large" | "heavy"): { cpus: string;
1562
1641
  };
1563
1642
  }
1564
1643
 
1644
+ /** What one step's sandbox holds: cores and memory (each a hard limit and a
1645
+ * billing figure) and, where the agent set it, disk. */
1646
+ export interface StepLimits {
1647
+ cpus: string;
1648
+ memory: string;
1649
+ /** The agent's `resources: disk`, or null for the class's (the k8s tier's
1650
+ * diskFor; Docker has no per-container disk cap). */
1651
+ disk: string | null;
1652
+ }
1653
+
1654
+ /**
1655
+ * The class's figures with an agent's `resources:` over them, key by key.
1656
+ *
1657
+ * `atLeastClass` is for a retry that moved the step up a class after the
1658
+ * cluster ended it for want of memory or disk: each override is raised to
1659
+ * the bigger class's figure, so `memory: 3Gi` OOM-killed at large retries at
1660
+ * heavy's 8Gi rather than the same 3Gi that already failed.
1661
+ */
1662
+ export function resolveResources(
1663
+ size?: "small" | "large" | "heavy",
1664
+ override?: ResourceOverride | null,
1665
+ opts: { atLeastClass?: boolean } = {},
1666
+ ): StepLimits {
1667
+ const base = sizeLimits(size);
1668
+ const bigger = (mine: number, theirs: number) => (opts.atLeastClass ? Math.max(mine, theirs) : mine);
1669
+ const baseMi = anyMemoryMi(base.memory);
1670
+ let cpus = base.cpus;
1671
+ if (override?.cpu !== undefined) {
1672
+ const b = Number(base.cpus);
1673
+ cpus = String(Number.isFinite(b) ? bigger(override.cpu, b) : override.cpu);
1674
+ }
1675
+ let memory = base.memory;
1676
+ if (override?.memory !== undefined) {
1677
+ const mi = quantityMi(override.memory);
1678
+ memory = Number.isFinite(baseMi) && opts.atLeastClass && baseMi > mi ? base.memory : override.memory;
1679
+ }
1680
+ // Disk the same way: a step evicted for disk at its own `disk: 2Gi` would
1681
+ // fail again at 2Gi, whatever class the retry moved it to.
1682
+ let disk = override?.disk ?? null;
1683
+ if (disk && opts.atLeastClass) {
1684
+ const classMi = anyMemoryMi(classDisk(size));
1685
+ if (Number.isFinite(classMi) && classMi > quantityMi(disk)) disk = null;
1686
+ }
1687
+ return { cpus, memory, disk };
1688
+ }
1689
+
1690
+ /** The node disk a class may write (the k8s `ephemeral-storage` limit;
1691
+ * Docker has no per-container cap). Env-tunable like the cores and memory. */
1692
+ export function classDisk(size?: "small" | "large" | "heavy"): string {
1693
+ if (size === "small") return process.env.FOLDRUN_RUNNER_DISK_SMALL ?? "2Gi";
1694
+ if (size === "heavy") return process.env.FOLDRUN_RUNNER_DISK_HEAVY ?? "20Gi";
1695
+ return process.env.FOLDRUN_RUNNER_DISK ?? "8Gi";
1696
+ }
1697
+
1698
+ /** A memory figure in GiB, for the step's reservation record. */
1699
+ export function memoryGiB(memory: string): number {
1700
+ const mi = anyMemoryMi(memory);
1701
+ return Number.isFinite(mi) ? mi / 1024 : 2;
1702
+ }
1703
+
1565
1704
  /** The label both executors stamp, so one name means one thing. */
1566
1705
  export const RUN_LABEL = "foldrun-run-id";
1567
1706
 
@@ -1624,6 +1763,8 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1624
1763
  { mode: 0o600 },
1625
1764
  );
1626
1765
 
1766
+ const lim = args.limits ?? resolveResources(args.size);
1767
+ const cpus = dockerCpus(lim.cpus);
1627
1768
  const flags = [
1628
1769
  "--security-opt", "no-new-privileges",
1629
1770
  ...(args.runId ? ["--label", `${RUN_LABEL}=${args.runId}`] : []),
@@ -1633,10 +1774,11 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1633
1774
  "--cap-add", "CHOWN", "--cap-add", "SETUID", "--cap-add", "SETGID",
1634
1775
  "--pids-limit", "512",
1635
1776
  // 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,
1777
+ // CPU is capped at the class's billed cores, matching the k8s path
1778
+ // (since 26 Oct 2026): a step runs on what it pays for and cannot take
1779
+ // the whole machine from the steps beside it.
1780
+ "--memory", dockerMemory(lim.memory),
1781
+ ...(cpus ? ["--cpus", cpus] : []),
1640
1782
  "--env-file", envFile,
1641
1783
  ];
1642
1784
  // gVisor (or kata) where the host has it: one env var, because the flag
package/src/runner.ts CHANGED
@@ -19,7 +19,8 @@ import { cascadeLimits, toolOwners } from "./limits.ts";
19
19
  import { resolveAgentPath } from "./confine.ts";
20
20
  import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts";
21
21
  import { eventUrl } from "./webhook.ts";
22
- import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
22
+ import { runStepInContainer, resolveResources, memoryGiB, heldCores, killRunSandboxes, type StepTiming } from "./run-container.ts";
23
+ import { readResources, hasOverride } from "./resources.ts";
23
24
  import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
24
25
  import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
25
26
  import { parseJudge } from "./judge-verdict.ts";
@@ -1296,9 +1297,10 @@ export function agentContext(
1296
1297
  // is large — the install's configured limits — so existing agents change
1297
1298
  // nothing. `size: small` is the opt-in for agents that wait on APIs more
1298
1299
  // than they work; `size: heavy` for a rendered browser or real data in
1299
- // memory. Memory is a hard ceiling per class; CPU bursts uncapped and bills
1300
- // at the class rate, so heavy both may use more and costs more. The actuals
1301
- // line in any trace is the evidence for right-sizing.
1300
+ // memory. Memory and cores are hard ceilings per class and bill at the
1301
+ // class rate (cores capped since 26 Oct 2026), so heavy both may use more
1302
+ // and costs more. The actuals line in any trace — busy, throttled, peak —
1303
+ // is the evidence for right-sizing.
1302
1304
  const size: "small" | "large" | "heavy" =
1303
1305
  front.size === "small" ? "small" : front.size === "heavy" ? "heavy" : "large";
1304
1306
 
@@ -2311,6 +2313,16 @@ async function runStep(
2311
2313
  // A retry that moved the step up a class (an evicted or OOM-killed
2312
2314
  // attempt) wins over the agent's own `size:` for the attempts after.
2313
2315
  const size = step.sizeUp ?? agentSize;
2316
+ // `resources:` over the class's figures, key by key. A value check would
2317
+ // refuse is dropped here too (the class's figure holds) and said so, so
2318
+ // `foldrun check` and the sandbox never disagree. After a retry moved up
2319
+ // a class, an override never holds the step below the new class.
2320
+ const asked = readResources(front.resources);
2321
+ for (const p of asked.problems) push("error", `resources: ${p}`);
2322
+ const limits = resolveResources(size, asked.value, { atLeastClass: !!step.sizeUp });
2323
+ // What the sandbox holds while it exists — `step.holding`, what the
2324
+ // account's compute in use sums (Settings, `foldrun usage compute`).
2325
+ const held = { cpus: heldCores(limits.cpus, process.env.FOLDRUN_RUN_ISOLATION), memGiB: memoryGiB(limits.memory) };
2314
2326
 
2315
2327
  // oauth2 secrets become live access tokens here — the one async moment
2316
2328
  // in secret resolution, at the last host-side point before anything is
@@ -2345,7 +2357,10 @@ async function runStep(
2345
2357
  const noKey = !providerLabel && !platform.platformKeyAllowed(tenant);
2346
2358
  const credential = providerLabel || noKey ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
2347
2359
  step.credential = credential ? credential.label : noKey ? "none — this account has no model key" : `provider: ${providerLabel}`;
2348
- if (credential) {
2360
+ // Core's own Docker container has no login to fall back to (below):
2361
+ // there, "none set" is refused with what to do, not described.
2362
+ const noCredentialInContainer = credential?.kind === "none" && process.env.FOLDRUN_RUN_ISOLATION === "container";
2363
+ if (credential && !noCredentialInContainer) {
2349
2364
  const line = credentialLine(credential);
2350
2365
  push(line.type, line.text);
2351
2366
  }
@@ -2372,6 +2387,24 @@ async function runStep(
2372
2387
  return;
2373
2388
  }
2374
2389
  }
2390
+ // A step in core's own Docker container (self-hosting) has no machine
2391
+ // login to fall back to: with no credential at all it could only fail at
2392
+ // the model, with Claude Code's own "Not logged in · Please run /login".
2393
+ // Refused here, before a sandbox is rented, saying what to set (a
2394
+ // self-hosted install with no key, 26 Oct 2026). A local `foldrun run`
2395
+ // keeps the machine's login; a registered executor brings its own.
2396
+ if (noCredentialInContainer) {
2397
+ push(
2398
+ "error",
2399
+ "no model credential: this install has none and the account has no key of its own. Set ANTHROPIC_API_KEY " +
2400
+ "(or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`) in the platform's environment — for Docker Compose, " +
2401
+ "self-hosting/.env, then `docker compose up -d` — or add the account's key in Settings → Model " +
2402
+ "(`foldrun account model set <provider> --key …`)",
2403
+ );
2404
+ step.status = "failed";
2405
+ save();
2406
+ return;
2407
+ }
2375
2408
  for (const w of providerWarnings) push("error", w);
2376
2409
  // A level that wrote a language nobody can read was skipped, not obeyed.
2377
2410
  for (const l of language.lines) push("info", l);
@@ -3080,6 +3113,7 @@ async function runStep(
3080
3113
  // executor asks this before it creates one (pod-room.ts).
3081
3114
  ...(stopRequested ? { stopRequested } : {}),
3082
3115
  size,
3116
+ limits,
3083
3117
  // The pod's network, for the executor's own wall (the k8s tier's
3084
3118
  // network policy): a filtered pod reaches the proxy and DNS only.
3085
3119
  network: { level: network.level, enforced: forward?.enforce === true },
@@ -3096,6 +3130,8 @@ async function runStep(
3096
3130
  // saves the record, so this only updates the fields.
3097
3131
  checkpoint: (ref: string, consumed: number) => {
3098
3132
  step.sandbox = { kind: isolation!, ref, consumed, since: step.sandbox?.since ?? new Date().toISOString() };
3133
+ // The sandbox exists: past the room gate, it now holds its figures.
3134
+ if (consumed === 0) step.holding = held;
3099
3135
  if (consumed === 0) save();
3100
3136
  },
3101
3137
  });
@@ -3111,9 +3147,23 @@ async function runStep(
3111
3147
  // The arguments of the attempt whose outcome is current — what a
3112
3148
  // re-run on the full image repeats, on the same model supply.
3113
3149
  let lastArgs: typeof firstArgs = firstArgs;
3114
- const runTracked = (a: typeof firstArgs) => {
3150
+ const runTracked = async (a: typeof firstArgs) => {
3115
3151
  lastArgs = a;
3116
- return runIsolated(a);
3152
+ // An executor that cannot report its sandbox (Docker: no room gate,
3153
+ // it starts at once) holds from the call; one that can says so at
3154
+ // its first checkpoint, after any wait for room.
3155
+ if (!platform.sandboxResumable(isolation!)) {
3156
+ step.holding = held;
3157
+ save();
3158
+ }
3159
+ try {
3160
+ return await runIsolated(a);
3161
+ } finally {
3162
+ // Saved now: the clean-up after an attempt (the log drain, the
3163
+ // lease, publishing) can take a while, and the sandbox is gone.
3164
+ step.holding = null;
3165
+ save();
3166
+ }
3117
3167
  };
3118
3168
  let attemptFrom = step.events.length;
3119
3169
  let outcome = await runTracked(firstArgs);
@@ -3306,6 +3356,7 @@ async function runStep(
3306
3356
  push(
3307
3357
  "info",
3308
3358
  `resources: cpu busy ${outcome.res.busyCpuSecs?.toFixed(1) ?? "?"}s · ` +
3359
+ (outcome.res.throttledSecs ? `throttled ${outcome.res.throttledSecs.toFixed(1)}s · ` : "") +
3309
3360
  `peak mem ${mb(outcome.res.peakMemBytes)}MB · ` +
3310
3361
  `net ${mb(outcome.res.rxBytes)}MB in / ${mb(outcome.res.txBytes)}MB out`,
3311
3362
  );
@@ -3326,13 +3377,8 @@ async function runStep(
3326
3377
  // is per core-second and per GiB-second, and what was held must come
3327
3378
  // from the record, never from what the config says at billing time.
3328
3379
  step.size = size;
3329
- const lim = sizeLimits(size);
3330
- const gib = (() => {
3331
- const m = lim.memory.match(/^(\d+(?:\.\d+)?)\s*(Gi?|Mi?|g|m)?/i);
3332
- const n = m ? Number(m[1]) : 2;
3333
- return (m?.[2] ?? "Gi").toLowerCase().startsWith("m") ? n / 1024 : n;
3334
- })();
3335
- step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
3380
+ if (hasOverride(asked.value)) step.resources = asked.value;
3381
+ step.reserved = held;
3336
3382
  } else {
3337
3383
  // Same withholding as the sandbox path — the in-process step is what
3338
3384
  // `foldrun run --test` drives on a laptop, where there is no proxy
package/src/starter.ts CHANGED
@@ -490,6 +490,7 @@ owner: ana@example.com # who answers for it; else the workspace's, else the
490
490
  model: default # fast | default | max
491
491
  effort: low # how hard to think about it
492
492
  size: large # small | large | heavy — the sandbox it rents
493
+ resources: { memory: 4Gi } # optional: cpu, memory, disk in numbers, over the size class
493
494
  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
494
495
  disallowedTools: [Bash] # subtract from what it would otherwise have
495
496
  skills: [house-style] # from skills/
package/src/store.ts CHANGED
@@ -3533,6 +3533,14 @@ export interface StepRecord {
3533
3533
  * from before sizes existed — billing falls back to the flat rate. */
3534
3534
  size?: "small" | "large" | "heavy";
3535
3535
  reserved?: { cpus: number; memGiB: number } | null;
3536
+ /** The agent's `resources:` as applied (resources.ts), when it set any:
3537
+ * why `reserved` differs from the class's figures. Absent before 26 Oct
3538
+ * 2026 and on steps of an agent that wrote none. */
3539
+ resources?: { cpu?: number; memory?: string; disk?: string };
3540
+ /** What the step's sandbox holds while it exists — set once it is past
3541
+ * the room gate, cleared when the attempt ends. What an account's
3542
+ * compute in use sums; `reserved` is the bill's record of the same. */
3543
+ holding?: { cpus: number; memGiB: number } | null;
3536
3544
  /** Token counts behind costUsd, kept so usage reports can say "how many"
3537
3545
  * and not only "how much" — the price of a token changes, the count is
3538
3546
  * the fact. Absent on steps recorded before this existed. */
@@ -3557,6 +3565,9 @@ export interface StepRecord {
3557
3565
  actual?: {
3558
3566
  busyCpuSecs: number | null;
3559
3567
  peakMemBytes: number | null;
3568
+ /** Seconds the CPU cap (the class's cores) held the sandbox back, from
3569
+ * cpu.stat. Absent before 26 Oct 2026. */
3570
+ throttledSecs?: number | null;
3560
3571
  rxBytes: number | null;
3561
3572
  txBytes: number | null;
3562
3573
  } | null;