@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.
- 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 +53 -1
- package/dist/src/run-container.js +131 -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 +149 -7
- package/src/runner.ts +60 -14
- package/src/starter.ts +1 -0
- package/src/store.ts +11 -0
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,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
|
|
1553
|
-
//
|
|
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
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
"--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,
|
|
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
|
|
1300
|
-
//
|
|
1301
|
-
// line in any trace
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3330
|
-
|
|
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;
|