@foldrun/core 0.2.0 → 0.4.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.
Files changed (85) hide show
  1. package/README.md +31 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
package/src/runner.ts CHANGED
@@ -7,11 +7,16 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import matter from "gray-matter";
9
9
  import { spawn } from "node:child_process";
10
- import { executeStep, extractJson, stepCeiling, type EventExtra } from "./step-exec.ts";
10
+ import {
11
+ knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
11
12
  import { eventUrl } from "./webhook.ts";
12
13
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
14
+ import { hostSafeEnv } from "./host-env.ts";
15
+ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
13
16
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
14
17
  import { platform } from "./platform.ts";
18
+ import { healthKey } from "./secret-health.ts";
19
+ import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
15
20
 
16
21
  /** Does this process run steps in a sandbox — the container core ships, or
17
22
  * one the platform registered (a pod)? */
@@ -37,6 +42,8 @@ import {
37
42
  syncBundleFor,
38
43
  parseToolDef,
39
44
  parseFlow,
45
+ readFlow,
46
+ markerPresent,
40
47
  parseApis,
41
48
  resolveModel,
42
49
  parseProvider,
@@ -52,8 +59,12 @@ import {
52
59
  type McpSpec,
53
60
  type RunRecord,
54
61
  type StepRecord,
62
+ type StepAttempt,
63
+ sumAttempts,
64
+ parseSchemaText,
55
65
  STORAGE_DIR,
56
66
  adoptLegacyVersionKey,
67
+ listAgents,
57
68
  runCost,
58
69
  } from "./store.ts";
59
70
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
@@ -71,6 +82,7 @@ import { materializeFiles, harvestFiles } from "./storage.ts";
71
82
  import { chooseExecutor, ensureImage } from "./container.ts";
72
83
  import { stampBundle } from "./okf.ts";
73
84
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
85
+ import { trimChars } from "./paths.ts";
74
86
 
75
87
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
76
88
  // in env and headers resolve server-side, so a credential reaches the server
@@ -122,6 +134,32 @@ export function joinEarlierGroups(groupResults: (string | null)[], gi: number, c
122
134
  return parts.join("\n\n");
123
135
  }
124
136
 
137
+ /**
138
+ * `parallel: N` — a pool of N slots the instances of one fan-out share.
139
+ * Fan-out always ran every instance at once, and still does unless the
140
+ * step says otherwise: twenty browser steps against one site, or one
141
+ * vendor's rate limit, is what the cap is for. Nothing is rented while an
142
+ * instance waits for a slot.
143
+ */
144
+ export function slotPool(size: number): { acquire: () => Promise<void>; release: () => void } {
145
+ let free = Math.max(1, size);
146
+ const waiting: (() => void)[] = [];
147
+ return {
148
+ acquire: () =>
149
+ new Promise<void>((resolve) => {
150
+ if (free > 0) {
151
+ free -= 1;
152
+ resolve();
153
+ } else waiting.push(resolve);
154
+ }),
155
+ release: () => {
156
+ const next = waiting.shift();
157
+ if (next) next();
158
+ else free += 1;
159
+ },
160
+ };
161
+ }
162
+
125
163
  export interface DiscoveredSkill {
126
164
  name: string;
127
165
  description: string;
@@ -377,10 +415,9 @@ export function saysUntilMarker(result: string | null, until: string): boolean {
377
415
  (line) =>
378
416
  // Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
379
417
  // is a decision written by someone using bold, not a sentence.
380
- line
381
- .trim()
382
- .replace(/^[#>\-*_`\s]+/, "")
383
- .replace(/[.!:,*_`\s]+$/, "")
418
+ // trimChars, not a `[…]+$` regex: that one is quadratic on a
419
+ // line of tabs, and this is a line an agent wrote.
420
+ trimChars(trimChars(line.trim(), "#>-*_`"), ".!:,*_`").trim()
384
421
  .toLowerCase() === marker,
385
422
  );
386
423
  }
@@ -470,7 +507,10 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
470
507
  map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
471
508
  }
472
509
  }
473
- return text.replace(/\[\[([^\]\n]+)\]\]/g, (whole, name: string) => {
510
+ // `[` excluded from the name: with it allowed, a run of `[[` makes the
511
+ // engine re-scan to the end from every opening — quadratic on a result
512
+ // an agent wrote.
513
+ return text.replace(/\[\[([^\]\[\n]+)\]\]/g, (whole, name: string) => {
474
514
  const hit = map.get(norm(name.trim()));
475
515
  return hit ? `\`${hit}\`` : whole;
476
516
  });
@@ -1196,6 +1236,49 @@ function repriced(
1196
1236
  return known;
1197
1237
  }
1198
1238
 
1239
+ /**
1240
+ * Write one attempt onto the step's history and re-total the step.
1241
+ *
1242
+ * runStep reports the attempt it just ran — its cost, tokens and sandbox
1243
+ * seconds — by assigning the step's fields. Those become this attempt's row;
1244
+ * the step's fields then become the sum of every row, which is what the
1245
+ * run's spend, the flow's budget: and the platform's bill read. A retried
1246
+ * step used to carry only its last attempt's cost, so a `retry: 2` step
1247
+ * could spend three shares while reporting one.
1248
+ */
1249
+ export function recordAttempt(
1250
+ step: StepRecord,
1251
+ n: number,
1252
+ status: "completed" | "failed",
1253
+ startedAt: string,
1254
+ eventsBefore: number,
1255
+ /** The attempt re-attached to a sandbox a previous driver left: same
1256
+ * attempt, so its row is replaced rather than repeated. */
1257
+ reattach = false,
1258
+ ) {
1259
+ const tries = step.tries ?? [];
1260
+ const lastError = step.events
1261
+ .slice(eventsBefore)
1262
+ .filter((e) => e.type === "error")
1263
+ .at(-1)?.text;
1264
+ const row: StepAttempt = {
1265
+ n,
1266
+ status,
1267
+ costUsd: step.costUsd,
1268
+ tokens: step.tokens ?? null,
1269
+ computeSecs: step.computeSecs ?? null,
1270
+ startedAt,
1271
+ finishedAt: step.finishedAt ?? new Date().toISOString(),
1272
+ ...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
1273
+ };
1274
+ const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
1275
+ step.tries = [...kept, row];
1276
+ const totals = sumAttempts(step.tries);
1277
+ step.costUsd = totals.costUsd;
1278
+ step.tokens = totals.tokens;
1279
+ step.computeSecs = totals.computeSecs;
1280
+ }
1281
+
1199
1282
  async function runStep(
1200
1283
  agentDir: string,
1201
1284
  tenant: string,
@@ -1213,6 +1296,15 @@ async function runStep(
1213
1296
  * remaining `budget:`, shared across the steps launched with it. Null
1214
1297
  * means the flow set no budget. */
1215
1298
  stepBudgetUsd: number | null = null,
1299
+ /** Which line set that ceiling, for the error that names it. */
1300
+ stepBudgetNote?: string,
1301
+ /** The run is a test run — see test-mode.ts. Read off the run record by
1302
+ * the caller, never from anything the sandbox could have written. */
1303
+ testRun = false,
1304
+ /** Has a person asked for the run to stop? Read on a clock by the
1305
+ * in-process model loop, so a stop lands mid-step and not at the next
1306
+ * group. The isolated path needs none: stopRun destroys the sandbox. */
1307
+ stopRequested?: () => boolean,
1216
1308
  ) {
1217
1309
  // Secret values are injected into scripts as environment variables and
1218
1310
  // substituted into API headers, so a model that reads one back — from a
@@ -1237,6 +1329,22 @@ async function runStep(
1237
1329
  return out;
1238
1330
  };
1239
1331
 
1332
+ // The reply, the conclusion and an `output: json` value land on the same
1333
+ // record the events do, and a model that quotes a script's stdout puts
1334
+ // the credential in its answer rather than in a tool event. Same scrub,
1335
+ // same place. The JSON value goes through its text form: a secret inside
1336
+ // a nested field is still a secret, and the key name survives either way.
1337
+ const redactText = <T extends string | null | undefined>(text: T): T =>
1338
+ (typeof text === "string" ? redact(text) : text) as T;
1339
+ const redactData = (value: unknown): unknown => {
1340
+ if (value === undefined || redactions.length === 0) return value;
1341
+ try {
1342
+ return JSON.parse(redact(JSON.stringify(value)));
1343
+ } catch {
1344
+ return value;
1345
+ }
1346
+ };
1347
+
1240
1348
  const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
1241
1349
  step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
1242
1350
  save();
@@ -1276,8 +1384,15 @@ async function runStep(
1276
1384
  // the failure of a step that returned no JSON are testable at no cost.
1277
1385
  if (step.output === "json") {
1278
1386
  const extracted = extractJson(step.result);
1279
- if (extracted.ok) step.data = extracted.value;
1280
- else {
1387
+ if (extracted.ok) {
1388
+ step.data = extracted.value;
1389
+ const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
1390
+ const errors = schema && looksLikeSchema(schema) ? validateSchema(step.data, schema) : [];
1391
+ if (errors.length) {
1392
+ push("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
1393
+ step.status = "failed";
1394
+ }
1395
+ } else {
1281
1396
  push("error", `output: json — ${extracted.reason}`);
1282
1397
  step.status = "failed";
1283
1398
  }
@@ -1297,11 +1412,14 @@ async function runStep(
1297
1412
  front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools,
1298
1413
  secretEnv, secretScopes, missingSecrets, missingTools, runtime,
1299
1414
  unknownTools, shadowed, legacyUse, mcpServers, mcpNames,
1300
- apiSpecs, scriptSpecs, brokenTools, size,
1415
+ apiSpecs, scriptSpecs, brokenTools, size: agentSize,
1301
1416
  providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning,
1302
1417
  fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
1303
1418
  translator, fallbackTranslator,
1304
1419
  } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
1420
+ // A retry that moved the step up a class (an evicted or OOM-killed
1421
+ // attempt) wins over the agent's own `size:` for the attempts after.
1422
+ const size = step.sizeUp ?? agentSize;
1305
1423
 
1306
1424
  // oauth2 secrets become live access tokens here — the one async moment
1307
1425
  // in secret resolution, at the last host-side point before anything is
@@ -1309,7 +1427,7 @@ async function runStep(
1309
1427
  // what crosses into containers, headers and env files is the token. A
1310
1428
  // failed refresh fails the step naming the secret and the provider's
1311
1429
  // reason, which beats a 401 three layers later.
1312
- const liveSecrets = await materializeSecrets(secretEnv);
1430
+ const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
1313
1431
  // @file values (SSH keys, certs) still carry their content at this point.
1314
1432
  // The in-process branch materialises them to 0600 paths on this host
1315
1433
  // below; the container branch passes the content across and the driver
@@ -1439,6 +1557,11 @@ async function runStep(
1439
1557
  if (fitted.note) push("info", fitted.note);
1440
1558
  effort = fitted.effort;
1441
1559
  }
1560
+ // No catalogue — the platform's own Anthropic key, or a gateway that
1561
+ // publishes no prices — but the model's NAME is often enough. Without
1562
+ // this the in-pod meter priced haiku and sonnet at Opus rates and a
1563
+ // step was stopped at up to fifteen times what it had spent.
1564
+ tokenPrice ??= knownPrice(wireModel);
1442
1565
 
1443
1566
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
1444
1567
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
@@ -1452,6 +1575,13 @@ async function runStep(
1452
1575
  // The confinement boundary: agents in a workspace are one team, but the
1453
1576
  // workspace is where isolation is enforced.
1454
1577
  const workspaceRoot = path.resolve(agentDir, "..", "..");
1578
+ // Where a test run's state/ and storage/ writes go, and the line each
1579
+ // one leaves: under the run, beside its archived outputs.
1580
+ const divertFor = (id: string) => ({
1581
+ to: path.join(workspaceRoot, "runs", id, TEST_WRITES_DIR),
1582
+ note: (rel: string, summary: string) =>
1583
+ push("info", `test: ${summary}`, { effect: { kind: "diverted" as const, path: rel, summary } }),
1584
+ });
1455
1585
 
1456
1586
  let prompt = step.instruction
1457
1587
  ? resolveDocLinks(step.instruction, workspaceRoot)
@@ -1468,11 +1598,18 @@ async function runStep(
1468
1598
  if (step.output === "json") {
1469
1599
  // The contract the runner parses back out — stated in the prompt so
1470
1600
  // the instruction and the parser agree by construction, like delegate:.
1601
+ // The schema, when there is one, is quoted whole: the model that has
1602
+ // to satisfy it should not have to guess it from the field names.
1603
+ const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
1471
1604
  prompt +=
1472
1605
  `\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
1473
1606
  "```json fenced block — the value the next step will compute on. Prose before it is " +
1474
1607
  `welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
1475
- `names fields, use exactly those names.\n</output>`;
1608
+ `names fields, use exactly those names.` +
1609
+ (schema && looksLikeSchema(schema)
1610
+ ? ` The value must satisfy this JSON Schema, and the step fails if it does not:\n${JSON.stringify(schema, null, 2).slice(0, 20_000)}`
1611
+ : "") +
1612
+ `\n</output>`;
1476
1613
  }
1477
1614
  if (step.waitFor === "event" && step.eventPayload !== undefined) {
1478
1615
  // Not resolved for links, like the previous step's results: it is
@@ -1539,6 +1676,16 @@ async function runStep(
1539
1676
  // platform credential.
1540
1677
  const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
1541
1678
 
1679
+ // `schema:` — inline on the step, or a file under the workspace read
1680
+ // now, host-side, so both executors are handed the same document. A
1681
+ // file that cannot be read is a failed step before any model runs:
1682
+ // the contract the author wrote down cannot be checked.
1683
+ const resolvedSchema: Record<string, unknown> | boolean | null = step.schema ?? (step.schemaPath ? readSchemaFile(workspaceRoot, step.schemaPath) : null);
1684
+ if (step.schemaPath && resolvedSchema === null) {
1685
+ throw new Error(`schema: ${step.schemaPath} — not a readable JSON or YAML schema under the workspace`);
1686
+ }
1687
+ if (step.maxTurns) push("info", `max_turns: ${step.maxTurns}`);
1688
+
1542
1689
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
1543
1690
  if (isolatedRun()) {
1544
1691
  // The isolated path: the whole loop — model, built-in tools, scripts —
@@ -1566,10 +1713,20 @@ async function runStep(
1566
1713
  const host = hostOf(api.base);
1567
1714
  if (!host) continue;
1568
1715
  for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
1569
- for (const name of placeholderNames(v)) if (name in liveSecrets) addGrant(grant, name, liveSecrets[name], host);
1716
+ for (const name of placeholderNames(v)) {
1717
+ if (!(name in liveSecrets)) continue;
1718
+ addGrant(grant, name, liveSecrets[name], host);
1719
+ // Which vault entry this is, so the proxy's record of what the
1720
+ // far end said lands on the right credential.
1721
+ (grant.healthKeys ??= {})[name] = healthKey(
1722
+ name,
1723
+ secretScopes[name] === "workspace" ? "workspace" : "account",
1724
+ path.basename(path.resolve(agentDir, "..", "..")),
1725
+ );
1726
+ }
1570
1727
  }
1571
1728
  }
1572
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant });
1729
+ const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
1573
1730
  // Which secrets must still be real inside the sandbox: a script tool
1574
1731
  // reads them from its environment, and unless it says `secrets:
1575
1732
  // proxied` it needs the value, not the name. bash is a script by
@@ -1592,15 +1749,36 @@ async function runStep(
1592
1749
  }
1593
1750
  // Sandbox env for the declared secrets: the value, or its own name as
1594
1751
  // a placeholder that the proxy fills on the way out.
1595
- const sandboxSecrets: Record<string, string> = materialise
1596
- ? liveSecrets
1597
- : Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
1752
+ const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
1753
+ let sandboxSecrets: Record<string, string> = materialise ? liveSecrets : placeholders;
1754
+ // A test run hands the sandbox no send-capable value. The env var is
1755
+ // still there, reading TEST_MODE_WITHHELD, so a script that never
1756
+ // checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
1757
+ // error — and sends nothing. The script tools say whether the step
1758
+ // has a declared sender (everything withheld) or a declared reader
1759
+ // (nothing withheld); see test-mode.ts for the order.
1760
+ if (testRun && materialise) {
1761
+ const held = withholdSecrets(liveSecrets, {
1762
+ outward: scriptSpecs.some((sc) => sc.outward),
1763
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
1764
+ });
1765
+ sandboxSecrets = held.env;
1766
+ for (const name of held.withheld) {
1767
+ push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
1768
+ }
1769
+ }
1770
+ // On a test run the http tools always go through the proxy as
1771
+ // placeholders, even when a script made the step materialise: the
1772
+ // proxy is where the outward-write policy lives, and a header
1773
+ // carrying TEST_MODE_WITHHELD would only fail where it should have
1774
+ // been sunk or refused.
1775
+ const apiValues = testRun && lease ? placeholders : sandboxSecrets;
1598
1776
  const substitutedApis = apiSpecs.map((api) => ({
1599
1777
  ...api,
1600
1778
  headers: Object.fromEntries(
1601
1779
  Object.entries(api.headers).map(([k, v]) => [
1602
1780
  k,
1603
- v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => sandboxSecrets[name] ?? whole),
1781
+ v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => apiValues[name] ?? whole),
1604
1782
  ]),
1605
1783
  ),
1606
1784
  }));
@@ -1630,6 +1808,7 @@ async function runStep(
1630
1808
  const isolatedArgs = (modelEnv: Record<string, string | undefined>, keyName = MODEL_KEY_NAME) => ({
1631
1809
  workspaceRoot,
1632
1810
  libraryRoot: libraryDir(tenant),
1811
+ galleryRoot: platform.galleryDir() ?? undefined,
1633
1812
  // Keys this step's dependency cache. Per-account by design — see
1634
1813
  // run-container.ts#runtimeCacheDir for why it is not shared.
1635
1814
  tenant,
@@ -1650,9 +1829,12 @@ async function runStep(
1650
1829
  consults,
1651
1830
  timeoutSec: step.timeout,
1652
1831
  budgetUsd: stepBudgetUsd,
1832
+ budgetNote: stepBudgetNote,
1653
1833
  price: tokenPrice,
1654
1834
  verify: step.verify,
1655
1835
  output: step.output,
1836
+ schema: resolvedSchema ?? undefined,
1837
+ maxTurns: step.maxTurns,
1656
1838
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
1657
1839
  // The container sees the workspace at /workspace and the library
1658
1840
  // at /library; the roots are named host-side and moved here.
@@ -1683,6 +1865,7 @@ async function runStep(
1683
1865
  FOLDRUN_RUN_ID: runId,
1684
1866
  FOLDRUN_AGENT: step.agent,
1685
1867
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
1868
+ ...(testRun ? testModeEnv() : {}),
1686
1869
  ...sandboxSecrets,
1687
1870
  }).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
1688
1871
  ),
@@ -1690,6 +1873,21 @@ async function runStep(
1690
1873
  // Stamps the sandbox, so stopping this run can destroy it.
1691
1874
  runId,
1692
1875
  size,
1876
+ // A test run's state/ and storage/ changes come out under the run.
1877
+ divert: testRun && runId ? divertFor(runId) : undefined,
1878
+ test: testRun,
1879
+ // Resume: a sandbox a previous driver left running, recorded on the
1880
+ // step. Consumed once — a fresh attempt after this one (a retry, a
1881
+ // fallback) starts its own sandbox.
1882
+ resume: step.sandbox && step.sandbox.kind === isolation ? { ref: step.sandbox.ref, consumed: step.sandbox.consumed } : null,
1883
+ // Checkpoint: where the executor is, on the record, so a driver
1884
+ // that inherits this run can attach where this one left off. The
1885
+ // executor calls it before applying each line; the line's own emit
1886
+ // saves the record, so this only updates the fields.
1887
+ checkpoint: (ref: string, consumed: number) => {
1888
+ step.sandbox = { kind: isolation!, ref, consumed, since: step.sandbox?.since ?? new Date().toISOString() };
1889
+ if (consumed === 0) save();
1890
+ },
1693
1891
  });
1694
1892
 
1695
1893
  // A busy provider gets another moment on the SAME supply before the
@@ -1700,6 +1898,7 @@ async function runStep(
1700
1898
  const firstArgs = isolatedArgs(platformModelEnv());
1701
1899
  await lease?.commit();
1702
1900
  let outcome = await runIsolated(firstArgs);
1901
+ step.sandbox = null; // whatever happens next starts its own
1703
1902
  for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
1704
1903
  const wait = backoffMs(n, lastRefusal);
1705
1904
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
@@ -1745,14 +1944,23 @@ async function runStep(
1745
1944
  outcome = withEarlierTiming(outcome, first);
1746
1945
  }
1747
1946
  if (lease) {
1748
- for (const line of await lease.drainLog()) push("info", line);
1947
+ for (const line of await lease.drainLog()) {
1948
+ if (typeof line === "string") push("info", line);
1949
+ else push("info", line.text, { effect: line.effect });
1950
+ }
1749
1951
  await lease.release();
1750
1952
  }
1751
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
1953
+ // Nothing a test run left in storage/public/ reached storage/, so
1954
+ // there is nothing to publish — and a share link is an outward act.
1955
+ if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
1752
1956
  step.status = outcome.status;
1753
- step.result = outcome.result;
1754
- step.conclusion = outcome.conclusion;
1755
- if (outcome.data !== undefined) step.data = outcome.data;
1957
+ step.result = redactText(outcome.result);
1958
+ step.conclusion = redactText(outcome.conclusion);
1959
+ step.sandbox = null;
1960
+ // The cluster's word on why a sandbox ended without the driver saying
1961
+ // so — OOMKilled, Evicted — on the record, where the retry reads it.
1962
+ if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
1963
+ if (outcome.data !== undefined) step.data = redactData(outcome.data);
1756
1964
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
1757
1965
  step.tokens = outcome.usage
1758
1966
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
@@ -1785,14 +1993,27 @@ async function runStep(
1785
1993
  })();
1786
1994
  step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
1787
1995
  } else {
1996
+ // Same withholding as the sandbox path — the in-process step is what
1997
+ // `foldrun run --test` drives on a laptop, where there is no proxy
1998
+ // and this is the only guard there is.
1999
+ const held = testRun
2000
+ ? withholdSecrets(liveSecrets, {
2001
+ outward: scriptSpecs.some((sc) => sc.outward),
2002
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2003
+ })
2004
+ : { env: liveSecrets, withheld: [] as string[] };
2005
+ for (const name of held.withheld) {
2006
+ push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2007
+ }
2008
+ const stepSecrets = held.env;
1788
2009
  const consultTools = buildConsultTools(
1789
2010
  consults,
1790
- { ...process.env, ...liveSecrets, ...providerEnv },
2011
+ { ...hostSafeEnv(), ...stepSecrets, ...providerEnv },
1791
2012
  (type, text) => push(type, text),
1792
2013
  );
1793
2014
  // @file secrets become 0600 paths here (host run), cleaned up in the
1794
2015
  // finally — once, however many attempts follow.
1795
- const mat = materializeFileSecrets(agentDir, liveSecrets);
2016
+ const mat = materializeFileSecrets(agentDir, stepSecrets);
1796
2017
  fileDir = mat.dir;
1797
2018
  const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
1798
2019
  agentDir,
@@ -1814,13 +2035,20 @@ async function runStep(
1814
2035
  ...mcpServers,
1815
2036
  },
1816
2037
  // Declared secrets reach the agent's scripts as env vars; the model
1817
- // only ever sees the variable names, not the values.
1818
- env: { ...process.env, ...clockEnv, ...mat.env, ...modelEnv },
2038
+ // only ever sees the variable names, not the values. The base is
2039
+ // the allowlisted host environment, not process.env: the SDK's
2040
+ // Bash tool runs with this, and on a platform process.env holds
2041
+ // every tenant's root key and the datastore credentials.
2042
+ env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
1819
2043
  timeoutSec: step.timeout,
1820
2044
  budgetUsd: stepBudgetUsd,
2045
+ budgetNote: stepBudgetNote,
1821
2046
  price: tokenPrice,
1822
2047
  verify: step.verify,
1823
2048
  output: step.output,
2049
+ schema: resolvedSchema ?? undefined,
2050
+ maxTurns: step.maxTurns,
2051
+ stopRequested,
1824
2052
  // What the scripts saw, the verify sees: a flow can then check that
1825
2053
  // the proof a step left names THIS run, not one that came before —
1826
2054
  // copy-back never propagates a deletion, so a marker from an earlier
@@ -1830,20 +2058,31 @@ async function runStep(
1830
2058
  ...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
1831
2059
  FOLDRUN_AGENT: step.agent,
1832
2060
  FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
1833
- ...liveSecrets,
2061
+ ...(testRun ? testModeEnv() : {}),
2062
+ ...stepSecrets,
1834
2063
  },
1835
2064
  emit: pushWatching,
1836
2065
  });
1837
2066
  // With a translator the step's endpoint is the translator: started
1838
2067
  // here, pointed at by the SDK's env, closed when the attempt ends.
2068
+ // A test run brackets each attempt with a snapshot of state/ and
2069
+ // storage/: the step writes them for real, because there is no
2070
+ // sandbox to write instead, and afterwards what changed is moved
2071
+ // under the run and the originals come back.
1839
2072
  const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
1840
- if (!spec) return attemptInProcess(modelEnv);
1841
- const t = await startTranslator(spec);
2073
+ const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2074
+ const t = spec ? await startTranslator(spec) : null;
1842
2075
  try {
1843
- return await attemptInProcess({ ...modelEnv, ...t.env });
2076
+ return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
1844
2077
  } finally {
1845
- for (const line of t.drainLog()) push("info", line.startsWith("translator") ? line : `translator: ${line}`);
1846
- await t.close();
2078
+ if (t) {
2079
+ for (const line of t.drainLog()) push("info", line.startsWith("translator") ? line : `translator: ${line}`);
2080
+ await t.close();
2081
+ }
2082
+ if (before && runId) {
2083
+ const d = divertFor(runId);
2084
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
2085
+ }
1847
2086
  }
1848
2087
  };
1849
2088
  let outcome = await attemptThrough(providerEnv, translator);
@@ -1865,11 +2104,11 @@ async function runStep(
1865
2104
  );
1866
2105
  outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
1867
2106
  }
1868
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
2107
+ if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
1869
2108
  step.status = outcome.status;
1870
- step.result = outcome.result;
1871
- step.conclusion = outcome.conclusion;
1872
- if (outcome.data !== undefined) step.data = outcome.data;
2109
+ step.result = redactText(outcome.result);
2110
+ step.conclusion = redactText(outcome.conclusion);
2111
+ if (outcome.data !== undefined) step.data = redactData(outcome.data);
1873
2112
  // A consult's spend belongs to the step that asked.
1874
2113
  const consultCost = consultTools.drainCost();
1875
2114
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
@@ -2163,6 +2402,26 @@ function csvItems(workspaceDir: string, step: StepRecord, take: number): string[
2163
2402
  return data.slice(0, take).map((r) => `${header}\n${r}`);
2164
2403
  }
2165
2404
 
2405
+ /**
2406
+ * `schema: <path>` — the file, read and parsed, or null. Agent-relative like
2407
+ * every other path in a flow (`../../schemas/lead.json`), confined to the
2408
+ * workspace: a schema is the author's document, never something a step
2409
+ * can point outside.
2410
+ */
2411
+ export function readSchemaFile(workspaceRoot: string, rel: string): Record<string, unknown> | boolean | null {
2412
+ const root = path.resolve(workspaceRoot);
2413
+ // Written agent-relative (`../../schemas/x.json`) or workspace-relative
2414
+ // (`schemas/x.json`); both land on the same file.
2415
+ const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
2416
+ for (const abs of candidates) {
2417
+ if (abs !== root && !abs.startsWith(root + path.sep)) continue;
2418
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
2419
+ const parsed = parseSchemaText(fs.readFileSync(abs, "utf8"));
2420
+ return "problem" in parsed ? null : parsed.schema;
2421
+ }
2422
+ return null;
2423
+ }
2424
+
2166
2425
  export function createFlowRun(
2167
2426
  tenant: string,
2168
2427
  workspace: string,
@@ -2170,12 +2429,16 @@ export function createFlowRun(
2170
2429
  flowName: string,
2171
2430
  status: "queued" | "running",
2172
2431
  tags: string[] = [],
2432
+ startedBy: string | null = null,
2433
+ opts: { test?: boolean } = {},
2173
2434
  ): RunRecord {
2174
2435
  const run: RunRecord = {
2175
2436
  id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
2176
2437
  flow: flowName,
2177
2438
  tags,
2178
2439
  status,
2440
+ ...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
2441
+ ...(opts.test ? { test: true } : {}),
2179
2442
  startedAt: new Date().toISOString(),
2180
2443
  finishedAt: null,
2181
2444
  steps: steps.map((s) => ({
@@ -2205,6 +2468,10 @@ export function createFlowRun(
2205
2468
  preview: s.preview,
2206
2469
  delegate: s.delegate,
2207
2470
  output: s.output,
2471
+ schema: s.schema,
2472
+ schemaPath: s.schemaPath,
2473
+ parallel: s.parallel,
2474
+ maxTurns: s.maxTurns,
2208
2475
  attempts: 0,
2209
2476
  status: "pending",
2210
2477
  events: [],
@@ -2241,8 +2508,10 @@ export function startFlowRun(
2241
2508
  modelOverride?: string | null,
2242
2509
  tags: string[] = [],
2243
2510
  effortOverride?: string | null,
2511
+ startedBy: string | null = null,
2512
+ opts: { test?: boolean } = {},
2244
2513
  ): RunRecord {
2245
- const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
2514
+ const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
2246
2515
  void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
2247
2516
  return run;
2248
2517
  }
@@ -2450,15 +2719,25 @@ function driveRunInner(
2450
2719
  // never happened, which reconcile can then never see. Reset it to
2451
2720
  // pending and it simply runs again; driveRun's whole contract is that
2452
2721
  // re-driving a partly-finished record is safe.
2722
+ // Unless the step's sandbox outlived its driver AND the executor can
2723
+ // re-attach to it: then the record carries the pod and how far the
2724
+ // last driver got, runStep passes that as `resume`, and the step
2725
+ // carries on from where it was — a deploy no longer costs the run.
2453
2726
  let orphaned = false;
2454
2727
  for (const step of run.steps) {
2455
2728
  if (step.status !== "running") continue;
2456
- orphaned = true;
2729
+ const resumable = !!step.sandbox && platform.sandboxResumable(step.sandbox.kind);
2730
+ if (!resumable) {
2731
+ orphaned = true;
2732
+ step.sandbox = null;
2733
+ }
2457
2734
  step.status = "pending";
2458
2735
  step.events.push({
2459
2736
  t: new Date().toISOString(),
2460
2737
  type: "info",
2461
- text: "interrupted mid-step (platform restart) — running again from the start of the step",
2738
+ text: resumable
2739
+ ? `driver changed mid-step — re-attaching to the running sandbox (${step.sandbox!.ref}, ${step.sandbox!.consumed} lines already applied)`
2740
+ : "interrupted mid-step (platform restart) — running again from the start of the step",
2462
2741
  });
2463
2742
  }
2464
2743
  if (orphaned) {
@@ -2530,6 +2809,17 @@ function driveRunInner(
2530
2809
  // is assembled) keeps the newest: when the total is over it, the
2531
2810
  // oldest groups are dropped whole before anything is cut mid-text.
2532
2811
  const contextBefore = (gi: number) => joinEarlierGroups(groupResults, gi);
2812
+ // What `when:` and `case:` read: the PREVIOUS group's result alone —
2813
+ // the nearest earlier group that produced one, so a routed-past group
2814
+ // between is looked through rather than read as silence. The prompt
2815
+ // gets every earlier group joined (contextBefore); routing must not,
2816
+ // because a verdict two groups back would still open a gate a later
2817
+ // group had overruled: a classifier's BUG line matched a `case: BUG`
2818
+ // three groups on, after the debugger had already answered it.
2819
+ const routingBefore = (gi: number): string | null => {
2820
+ for (let i = gi - 1; i >= 0; i--) if (groupResults[i]) return groupResults[i];
2821
+ return null;
2822
+ };
2533
2823
  // The data half of the same handoff: what the nearest earlier group
2534
2824
  // returned through `output: json`. Rebuilt from the record rather than
2535
2825
  // kept only in memory, so a resumed run hands step 3 the value step 1
@@ -2594,8 +2884,11 @@ function driveRunInner(
2594
2884
  context = contextBefore(gi);
2595
2885
  ctxData = dataBefore(gi);
2596
2886
 
2597
- if (run.budgetUsd && runCost(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
2598
- const spent = runCost(run);
2887
+ // What the run has spent, not just what its tokens cost: on an
2888
+ // install that prices sandbox seconds, a step that called no model
2889
+ // still spent money, and a cap that ignored it capped nothing.
2890
+ if (run.budgetUsd && platform.runSpend(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
2891
+ const spent = platform.runSpend(run);
2599
2892
  const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
2600
2893
  last?.events.push({
2601
2894
  t: new Date().toISOString(),
@@ -2684,6 +2977,7 @@ function driveRunInner(
2684
2977
  type: "info",
2685
2978
  text:
2686
2979
  `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
2980
+ (step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
2687
2981
  (dropped ? ` (more items existed — capped at ${cap})` : ""),
2688
2982
  });
2689
2983
  const at = run.steps.indexOf(step);
@@ -2698,6 +2992,7 @@ function driveRunInner(
2698
2992
  result: null,
2699
2993
  costUsd: null,
2700
2994
  attempts: 0,
2995
+ tries: undefined,
2701
2996
  }));
2702
2997
  run.steps.splice(at + 1, 0, ...instances);
2703
2998
  ordered = orderedGroups();
@@ -2711,12 +3006,25 @@ function driveRunInner(
2711
3006
  // this is what keeps the *next* group from starting.
2712
3007
  const stopCheck = readRun(tenant, workspace, run.id);
2713
3008
  if (stopCheck?.stopRequested) {
3009
+ let skipped = 0;
2714
3010
  for (const s of run.steps) {
2715
3011
  if (s.status === "pending" || s.status === "running") {
2716
3012
  s.status = "skipped";
2717
3013
  s.skipReason = "run stopped";
3014
+ skipped += 1;
2718
3015
  }
2719
3016
  }
3017
+ // Say so on the trail. stopRun wrote "run stopped" onto the
3018
+ // record's first step, but the save() below writes this process's
3019
+ // copy of the record over it, so the only witness was the
3020
+ // stopRequested flag — and a run that "failed" with no error line
3021
+ // anywhere took a code read to explain (rank-desk, 2026-09-13).
3022
+ const witness = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
3023
+ witness?.events.push({
3024
+ t: new Date().toISOString(),
3025
+ type: "info",
3026
+ text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
3027
+ });
2720
3028
  run.status = "failed";
2721
3029
  run.stopRequested = true;
2722
3030
  save();
@@ -2752,40 +3060,52 @@ function driveRunInner(
2752
3060
  }
2753
3061
 
2754
3062
  const ctx = context;
3063
+ // The previous group's result — see routingBefore.
3064
+ const routing = routingBefore(gi);
2755
3065
 
2756
- // `when:` — skip a step whose condition isn't met by prior results.
3066
+ // `when:` — skip a step whose condition isn't met by the previous
3067
+ // group's result.
2757
3068
  for (const step of freshGroup) {
2758
3069
  if (step.status !== "pending" || !step.when) continue;
2759
- const met = (ctx ?? "").toLowerCase().includes(step.when.toLowerCase());
3070
+ // A marker at the start of a line, not a word anywhere in the
3071
+ // prose — see markerPresent. "There are no BLOCKED items" must
3072
+ // not open a gate keyed on BLOCKED.
3073
+ const met = markerPresent(routing, step.when);
2760
3074
  if (!met) {
2761
3075
  step.status = "skipped";
2762
3076
  step.skipReason = `condition not met: when "${step.when}"`;
2763
3077
  step.events.push({
2764
3078
  t: new Date().toISOString(),
2765
3079
  type: "info",
2766
- text: `skipped — previous results do not mention "${step.when}"`,
3080
+ text: `skipped — the previous group's result has no line beginning "${step.when}" (when: reads a marker at the start of a line of the previous result, not a word in a sentence)`,
2767
3081
  });
2768
3082
  }
2769
3083
  }
2770
3084
  save();
2771
3085
 
2772
3086
  // `case:`/`else:` — exclusive routing. Of this group's case steps,
2773
- // the FIRST whose text appears in the previous results runs; the
2774
- // rest are routed past. `else:` runs only when no case matched.
3087
+ // the FIRST whose marker leads a line of the previous results runs;
3088
+ // the rest are routed past. `else:` runs only when no case matched.
2775
3089
  // (`when:` above stays independent — every matching when runs —
2776
3090
  // which is why routing is its own vocabulary instead of a mode.)
3091
+ //
3092
+ // Same marker rule as `when:`, and it matters more here: routing is
3093
+ // exclusive, so a label matched out of a sentence does not merely
3094
+ // run an extra step, it sends the flow down the wrong branch and
3095
+ // skips the right one. "This is not a COMPLAINT, it is a QUESTION"
3096
+ // used to route to the complaints handler.
2777
3097
  const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
2778
3098
  if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
2779
3099
  let matchedCase: StepRecord | null = null;
2780
3100
  for (const step of caseSteps) {
2781
- if (!matchedCase && (ctx ?? "").toLowerCase().includes(step.case!.toLowerCase())) {
3101
+ if (!matchedCase && markerPresent(routing, step.case!)) {
2782
3102
  matchedCase = step;
2783
3103
  continue;
2784
3104
  }
2785
3105
  step.status = "skipped";
2786
3106
  step.skipReason = matchedCase
2787
3107
  ? `routed past — "${matchedCase.case}" matched first`
2788
- : `case not matched: "${step.case}"`;
3108
+ : `case not matched: the previous group's result has no line beginning "${step.case}"`;
2789
3109
  step.events.push({
2790
3110
  t: new Date().toISOString(),
2791
3111
  type: "info",
@@ -2829,6 +3149,20 @@ function driveRunInner(
2829
3149
  });
2830
3150
  }
2831
3151
  run.status = "awaiting-approval";
3152
+ // approve_within: — the deadline is stamped when the gate opens,
3153
+ // not counted from the run's start, so "two days to answer" means
3154
+ // two days from being asked however long the work before it took.
3155
+ // Only a gate that asks a PERSON gets one: an external event has
3156
+ // no one to chase.
3157
+ const within = readFlow(tenant, workspace, run.flow)?.approveWithin ?? null;
3158
+ // Always assigned, never only set: a run with two gates would
3159
+ // otherwise carry the FIRST gate's deadline into the second, and
3160
+ // a deadline already in the past expires a gate the moment it
3161
+ // opens. Null when this gate has none.
3162
+ run.approveBy =
3163
+ within && needsApproval.some((s) => s.waitFor !== "event")
3164
+ ? new Date(Date.now() + within * 1000).toISOString()
3165
+ : null;
2832
3166
  save();
2833
3167
 
2834
3168
  // The gate's declared preview, resolved now against what the run
@@ -2903,12 +3237,79 @@ function driveRunInner(
2903
3237
  // twenty steps over — the shares sum to what was left. Between
2904
3238
  // groups the check above still refuses to start the next one.
2905
3239
  const launching = freshGroup.filter((s) => s.status === "pending");
2906
- const ceilingUsd = stepCeiling(run.budgetUsd, runCost(run), launching.length);
3240
+ // An agent's own `budget:` — the most it may spend in one run — is
3241
+ // read at launch, so a cap raised mid-run applies to the next step.
3242
+ const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
3243
+ // The ceiling for one attempt of one step, read NOW: what is left of
3244
+ // the run's budget shared across the group, or the agent's own cap
3245
+ // less what its steps have cost. Computed before every attempt, not
3246
+ // once before the loop — a retry's share is what is left after the
3247
+ // failed attempt spent, and the step's own figure already carries
3248
+ // that spend (it is the sum of its tries).
3249
+ const ceilingFor = (step: StepRecord) =>
3250
+ stepCeilingFor(
3251
+ stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length),
3252
+ agentBudgets.get(step.agent),
3253
+ run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
3254
+ step.agent,
3255
+ );
3256
+ // parallel: — the instances of one fan-out template share a pool.
3257
+ // Keyed by what the instances share (agent + instruction), since
3258
+ // the template itself is behind them in the record as "expanded".
3259
+ const pools = new Map<string, ReturnType<typeof slotPool>>();
3260
+ const poolFor = (step: StepRecord) => {
3261
+ if (!step.parallel || !step.item) return null;
3262
+ const key = `${step.agent}\u0000${step.instruction}`;
3263
+ let pool = pools.get(key);
3264
+ if (!pool) pools.set(key, (pool = slotPool(step.parallel)));
3265
+ return pool;
3266
+ };
2907
3267
  await Promise.all(
2908
3268
  launching
2909
3269
  .map(async (step) => {
3270
+ const pool = poolFor(step);
3271
+ if (pool) await pool.acquire();
3272
+ try {
3273
+ await launchStep(step);
3274
+ } finally {
3275
+ pool?.release();
3276
+ }
3277
+ }),
3278
+ );
3279
+ async function launchStep(step: StepRecord) {
2910
3280
  const attempts = (step.retry ?? 0) + 1;
2911
- for (let attempt = 1; attempt <= attempts; attempt++) {
3281
+ // A step re-attaching to its sandbox is still on the attempt
3282
+ // that started it; a step re-run after a backoff picks up the
3283
+ // count it left. Only a step nobody has touched starts at 1.
3284
+ const interruptedInBackoff =
3285
+ !step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
3286
+ const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts! + 1) : 1;
3287
+ for (let attempt = from; attempt <= attempts; attempt++) {
3288
+ const ceiling = ceilingFor(step);
3289
+ // Nothing left is a refusal, not "no ceiling": a zero passed
3290
+ // down would read as unset, and the one step that must not
3291
+ // run would run with no cap at all. On a retry this is what
3292
+ // stops the second attempt spending a share the first one
3293
+ // already spent.
3294
+ if (ceiling.ceilingUsd === 0) {
3295
+ step.status = "failed";
3296
+ step.events.push({
3297
+ t: new Date().toISOString(),
3298
+ type: "error",
3299
+ text:
3300
+ attempt === from
3301
+ ? `over budget — nothing left to spend before this step (${ceiling.note}); it was not started`
3302
+ : `over budget — nothing left to spend for attempt ${attempt} (${ceiling.note}); not retried`,
3303
+ });
3304
+ save();
3305
+ return;
3306
+ }
3307
+ // Re-attaching to a sandbox is the same attempt, so its row
3308
+ // is replaced; anything else is a new row, a loop cycle's
3309
+ // re-run included.
3310
+ const reattach = !!step.sandbox;
3311
+ const attemptStartedAt = new Date().toISOString();
3312
+ const eventsBefore = step.events.length;
2912
3313
  step.attempts = attempt;
2913
3314
  step.status = "running";
2914
3315
  save();
@@ -2923,11 +3324,21 @@ function driveRunInner(
2923
3324
  effortOverride,
2924
3325
  run.id,
2925
3326
  ctxData,
2926
- ceilingUsd,
3327
+ ceiling.ceilingUsd,
3328
+ ceiling.note,
3329
+ run.test === true,
3330
+ // save() merges a stop written by another process into
3331
+ // this record; the model loop polls this on a clock.
3332
+ () => {
3333
+ save();
3334
+ return run.stopRequested === true;
3335
+ },
2927
3336
  );
2928
3337
  // runStep mutates step.status; read it through a widened local
2929
3338
  // so TS doesn't keep the "running" narrowing from above.
2930
3339
  const outcome: string = step.status;
3340
+ recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
3341
+ save();
2931
3342
  if (outcome !== "failed" || attempt === attempts) break;
2932
3343
  // A stop destroys the step's sandbox, which reads here as a
2933
3344
  // failed attempt — and a failed attempt used to be retried in
@@ -2935,15 +3346,44 @@ function driveRunInner(
2935
3346
  // more. save() merges the flag from the record.
2936
3347
  save();
2937
3348
  if (run.stopRequested) break;
3349
+ // The retry policy. An attempt the cluster ended for want of
3350
+ // memory or disk is retried one size class up — the same
3351
+ // size would end the same way. Every retry waits: 15 s, 30 s,
3352
+ // 1 m, 2 m, 4 m, jittered, so a provider or a site that was
3353
+ // struggling gets a moment, and twenty fan-out steps do not
3354
+ // hammer it in lock-step. The wait holds a worker slot, not
3355
+ // a sandbox — nothing is rented while it waits — and a stop
3356
+ // ends it. The step is `pending` meanwhile, so a driver that
3357
+ // inherits the run mid-wait simply runs the attempt.
3358
+ const last = [...step.events].reverse().find((e) => e.type === "error")?.text ?? "";
3359
+ let sizeNote = "";
3360
+ if (/OOMKilled|Evicted|ephemeral-storage|out of memory/i.test(last)) {
3361
+ const up = step.sizeUp === "large" || (!step.sizeUp && step.size === "large") ? "heavy" : step.sizeUp === "heavy" || step.size === "heavy" ? null : "large";
3362
+ if (up) {
3363
+ step.sizeUp = up;
3364
+ sizeNote = ` at size: ${up}`;
3365
+ }
3366
+ }
3367
+ const waitMs = Math.round(Math.min(retryBaseMs() * 16, retryBaseMs() * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5));
3368
+ step.status = "pending";
2938
3369
  step.events.push({
2939
3370
  t: new Date().toISOString(),
2940
3371
  type: "info",
2941
- text: `attempt ${attempt} failed — retrying (${attempts - attempt} left)`,
3372
+ text: `attempt ${attempt} failed — retrying in ${Math.round(waitMs / 1000)}s${sizeNote} (${attempts - attempt} left)`,
2942
3373
  });
2943
3374
  save();
3375
+ const until = Date.now() + waitMs;
3376
+ while (Date.now() < until) {
3377
+ await sleep(Math.min(1000, until - Date.now()));
3378
+ save();
3379
+ if (run.stopRequested) break;
3380
+ }
3381
+ if (run.stopRequested) {
3382
+ step.status = "failed";
3383
+ break;
3384
+ }
2944
3385
  }
2945
- }),
2946
- );
3386
+ }
2947
3387
 
2948
3388
  // on-fail: another agent takes the step over. Sequential and rare:
2949
3389
  // the rescue is a second full attempt at the step's job with the
@@ -2995,7 +3435,7 @@ function driveRunInner(
2995
3435
  rescue.attempts = 1;
2996
3436
  rescue.status = "running";
2997
3437
  save();
2998
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData);
3438
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
2999
3439
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
3000
3440
  const rescueOutcome: string = rescue.status;
3001
3441
  if (rescueOutcome === "completed") {
@@ -3125,16 +3565,25 @@ function driveRunInner(
3125
3565
  for (const s of run.steps) if (s.status === "pending") s.status = "skipped";
3126
3566
  } catch (err) {
3127
3567
  run.status = "failed";
3568
+ const text = err instanceof Error ? err.message : String(err);
3569
+ let told = false;
3128
3570
  run.steps.forEach((s) => {
3129
3571
  if (s.status === "running") {
3130
3572
  s.status = "failed";
3131
- s.events.push({
3132
- t: new Date().toISOString(),
3133
- type: "error",
3134
- text: err instanceof Error ? err.message : String(err),
3135
- });
3573
+ s.events.push({ t: new Date().toISOString(), type: "error", text });
3574
+ told = true;
3136
3575
  }
3137
3576
  });
3577
+ // Thrown between groups, with nothing running: the error still has to
3578
+ // land on some step's trail, or the run reads as failed for no reason.
3579
+ if (!told) {
3580
+ const witness = run.steps.findLast((s) => s.status !== "pending") ?? run.steps[0];
3581
+ witness?.events.push({
3582
+ t: new Date().toISOString(),
3583
+ type: "error",
3584
+ text: `run failed between steps: ${text}`,
3585
+ });
3586
+ }
3138
3587
  } finally {
3139
3588
  if (!parked) {
3140
3589
  run.finishedAt = new Date().toISOString();
@@ -3144,6 +3593,9 @@ function driveRunInner(
3144
3593
  // learn, and the runs list and the notification stop having to say
3145
3594
  // only "completed".
3146
3595
  run.summary = runSummary(run);
3596
+ // A test run's headline says so wherever the headline goes — the
3597
+ // runs list, the history tool, a later run's recall.
3598
+ if (run.test && run.summary) run.summary = testHeadline(run.summary);
3147
3599
  // Archive before the next run resets outputs/ — a failed run's
3148
3600
  // artifacts are usually the most interesting ones, so this runs on
3149
3601
  // failure too.
@@ -3184,16 +3636,6 @@ function driveRunInner(
3184
3636
  })();
3185
3637
  }
3186
3638
 
3187
- function readFlow(tenant: string, workspace: string, flowName: string) {
3188
- const dir = path.join(workspaceDir(tenant, workspace), "flows");
3189
- if (!fs.existsSync(dir)) return null;
3190
- for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
3191
- const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
3192
- if (flow.name === flowName) return flow;
3193
- }
3194
- return null;
3195
- }
3196
-
3197
3639
  // Splice `[[flow:other]]` steps into the parent's step list, renumbering
3198
3640
  // groups so the nested flow's own parallelism survives. Depth-limited, and
3199
3641
  // a cycle raises rather than expanding forever.
@@ -3270,6 +3712,10 @@ export function loadFlow(tenant: string, workspace: string, flowName: string) {
3270
3712
  // written a beat before its driver registered, not a judgement about pace.
3271
3713
  const ABANDONED_AFTER_MS = 2 * 60 * 1000;
3272
3714
 
3715
+ /** The first retry's wait; each one after doubles it, to sixteen times
3716
+ * this (15 s → 4 min by default). An env, so a test can make it small. */
3717
+ const retryBaseMs = () => Number(process.env.FOLDRUN_RETRY_BASE_MS) || 15_000;
3718
+
3273
3719
  /** The most recent moment a run showed any sign of life. */
3274
3720
  function lastActivity(run: RunRecord): number {
3275
3721
  let latest = Date.parse(run.startedAt);
@@ -3311,12 +3757,18 @@ export interface Reconciliation {
3311
3757
  * outlives any process. A run whose person already answered is not waiting,
3312
3758
  * and is picked back up — see below.
3313
3759
  */
3314
- export function reconcileRuns(tenant: string, now = Date.now()): Reconciliation[] {
3760
+ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
3315
3761
  const closed: Reconciliation[] = [];
3316
3762
 
3317
3763
  for (const workspace of listWorkspaces(tenant)) {
3318
3764
  for (const summary of listRuns(tenant, workspace.name)) {
3319
3765
  if (drivingRuns.has(summary.id)) continue;
3766
+ // Driven by another process — a second worker pod, on a platform with
3767
+ // more than one. The idle window protects a quiet step from THIS
3768
+ // process's judgement only; a worker that boots beside a live one
3769
+ // would otherwise close every long step its neighbour is driving.
3770
+ // The caller says which runs those are (a heartbeated claim).
3771
+ if (live?.has(summary.id)) continue;
3320
3772
  if (now - lastActivity(summary) < ABANDONED_AFTER_MS) continue;
3321
3773
 
3322
3774
  // Approved, then abandoned. Approval only writes to the run record; the
@@ -3396,8 +3848,8 @@ export function reconcileRuns(tenant: string, now = Date.now()): Reconciliation[
3396
3848
  * one hard-coded account left every other account's interrupted runs marked
3397
3849
  * `running` forever, with no process left to finish them.
3398
3850
  */
3399
- export function reconcileAllRuns(now = Date.now()): Reconciliation[] {
3400
- return listTenants().flatMap((tenant) => reconcileRuns(tenant, now));
3851
+ export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
3852
+ return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
3401
3853
  }
3402
3854
 
3403
3855
  /**