@foldrun/core 0.3.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 (83) hide show
  1. package/README.md +27 -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/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
package/src/runner.ts CHANGED
@@ -8,11 +8,15 @@ import path from "node:path";
8
8
  import matter from "gray-matter";
9
9
  import { spawn } from "node:child_process";
10
10
  import {
11
- knownPrice, executeStep, extractJson, stepCeiling, type EventExtra } from "./step-exec.ts";
11
+ knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
12
12
  import { eventUrl } from "./webhook.ts";
13
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";
14
16
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
15
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";
16
20
 
17
21
  /** Does this process run steps in a sandbox — the container core ships, or
18
22
  * one the platform registered (a pod)? */
@@ -38,6 +42,8 @@ import {
38
42
  syncBundleFor,
39
43
  parseToolDef,
40
44
  parseFlow,
45
+ readFlow,
46
+ markerPresent,
41
47
  parseApis,
42
48
  resolveModel,
43
49
  parseProvider,
@@ -53,8 +59,12 @@ import {
53
59
  type McpSpec,
54
60
  type RunRecord,
55
61
  type StepRecord,
62
+ type StepAttempt,
63
+ sumAttempts,
64
+ parseSchemaText,
56
65
  STORAGE_DIR,
57
66
  adoptLegacyVersionKey,
67
+ listAgents,
58
68
  runCost,
59
69
  } from "./store.ts";
60
70
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
@@ -72,6 +82,7 @@ import { materializeFiles, harvestFiles } from "./storage.ts";
72
82
  import { chooseExecutor, ensureImage } from "./container.ts";
73
83
  import { stampBundle } from "./okf.ts";
74
84
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
85
+ import { trimChars } from "./paths.ts";
75
86
 
76
87
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
77
88
  // in env and headers resolve server-side, so a credential reaches the server
@@ -123,6 +134,32 @@ export function joinEarlierGroups(groupResults: (string | null)[], gi: number, c
123
134
  return parts.join("\n\n");
124
135
  }
125
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
+
126
163
  export interface DiscoveredSkill {
127
164
  name: string;
128
165
  description: string;
@@ -378,10 +415,9 @@ export function saysUntilMarker(result: string | null, until: string): boolean {
378
415
  (line) =>
379
416
  // Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
380
417
  // is a decision written by someone using bold, not a sentence.
381
- line
382
- .trim()
383
- .replace(/^[#>\-*_`\s]+/, "")
384
- .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()
385
421
  .toLowerCase() === marker,
386
422
  );
387
423
  }
@@ -471,7 +507,10 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
471
507
  map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
472
508
  }
473
509
  }
474
- 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) => {
475
514
  const hit = map.get(norm(name.trim()));
476
515
  return hit ? `\`${hit}\`` : whole;
477
516
  });
@@ -1197,6 +1236,49 @@ function repriced(
1197
1236
  return known;
1198
1237
  }
1199
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
+
1200
1282
  async function runStep(
1201
1283
  agentDir: string,
1202
1284
  tenant: string,
@@ -1214,6 +1296,15 @@ async function runStep(
1214
1296
  * remaining `budget:`, shared across the steps launched with it. Null
1215
1297
  * means the flow set no budget. */
1216
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,
1217
1308
  ) {
1218
1309
  // Secret values are injected into scripts as environment variables and
1219
1310
  // substituted into API headers, so a model that reads one back — from a
@@ -1238,6 +1329,22 @@ async function runStep(
1238
1329
  return out;
1239
1330
  };
1240
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
+
1241
1348
  const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
1242
1349
  step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
1243
1350
  save();
@@ -1277,8 +1384,15 @@ async function runStep(
1277
1384
  // the failure of a step that returned no JSON are testable at no cost.
1278
1385
  if (step.output === "json") {
1279
1386
  const extracted = extractJson(step.result);
1280
- if (extracted.ok) step.data = extracted.value;
1281
- 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 {
1282
1396
  push("error", `output: json — ${extracted.reason}`);
1283
1397
  step.status = "failed";
1284
1398
  }
@@ -1313,7 +1427,7 @@ async function runStep(
1313
1427
  // what crosses into containers, headers and env files is the token. A
1314
1428
  // failed refresh fails the step naming the secret and the provider's
1315
1429
  // reason, which beats a 401 three layers later.
1316
- const liveSecrets = await materializeSecrets(secretEnv);
1430
+ const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
1317
1431
  // @file values (SSH keys, certs) still carry their content at this point.
1318
1432
  // The in-process branch materialises them to 0600 paths on this host
1319
1433
  // below; the container branch passes the content across and the driver
@@ -1461,6 +1575,13 @@ async function runStep(
1461
1575
  // The confinement boundary: agents in a workspace are one team, but the
1462
1576
  // workspace is where isolation is enforced.
1463
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
+ });
1464
1585
 
1465
1586
  let prompt = step.instruction
1466
1587
  ? resolveDocLinks(step.instruction, workspaceRoot)
@@ -1477,11 +1598,18 @@ async function runStep(
1477
1598
  if (step.output === "json") {
1478
1599
  // The contract the runner parses back out — stated in the prompt so
1479
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);
1480
1604
  prompt +=
1481
1605
  `\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
1482
1606
  "```json fenced block — the value the next step will compute on. Prose before it is " +
1483
1607
  `welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
1484
- `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>`;
1485
1613
  }
1486
1614
  if (step.waitFor === "event" && step.eventPayload !== undefined) {
1487
1615
  // Not resolved for links, like the previous step's results: it is
@@ -1548,6 +1676,16 @@ async function runStep(
1548
1676
  // platform credential.
1549
1677
  const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
1550
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
+
1551
1689
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
1552
1690
  if (isolatedRun()) {
1553
1691
  // The isolated path: the whole loop — model, built-in tools, scripts —
@@ -1575,10 +1713,20 @@ async function runStep(
1575
1713
  const host = hostOf(api.base);
1576
1714
  if (!host) continue;
1577
1715
  for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
1578
- 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
+ }
1579
1727
  }
1580
1728
  }
1581
- 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 });
1582
1730
  // Which secrets must still be real inside the sandbox: a script tool
1583
1731
  // reads them from its environment, and unless it says `secrets:
1584
1732
  // proxied` it needs the value, not the name. bash is a script by
@@ -1601,15 +1749,36 @@ async function runStep(
1601
1749
  }
1602
1750
  // Sandbox env for the declared secrets: the value, or its own name as
1603
1751
  // a placeholder that the proxy fills on the way out.
1604
- const sandboxSecrets: Record<string, string> = materialise
1605
- ? liveSecrets
1606
- : 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;
1607
1776
  const substitutedApis = apiSpecs.map((api) => ({
1608
1777
  ...api,
1609
1778
  headers: Object.fromEntries(
1610
1779
  Object.entries(api.headers).map(([k, v]) => [
1611
1780
  k,
1612
- 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),
1613
1782
  ]),
1614
1783
  ),
1615
1784
  }));
@@ -1639,6 +1808,7 @@ async function runStep(
1639
1808
  const isolatedArgs = (modelEnv: Record<string, string | undefined>, keyName = MODEL_KEY_NAME) => ({
1640
1809
  workspaceRoot,
1641
1810
  libraryRoot: libraryDir(tenant),
1811
+ galleryRoot: platform.galleryDir() ?? undefined,
1642
1812
  // Keys this step's dependency cache. Per-account by design — see
1643
1813
  // run-container.ts#runtimeCacheDir for why it is not shared.
1644
1814
  tenant,
@@ -1659,9 +1829,12 @@ async function runStep(
1659
1829
  consults,
1660
1830
  timeoutSec: step.timeout,
1661
1831
  budgetUsd: stepBudgetUsd,
1832
+ budgetNote: stepBudgetNote,
1662
1833
  price: tokenPrice,
1663
1834
  verify: step.verify,
1664
1835
  output: step.output,
1836
+ schema: resolvedSchema ?? undefined,
1837
+ maxTurns: step.maxTurns,
1665
1838
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
1666
1839
  // The container sees the workspace at /workspace and the library
1667
1840
  // at /library; the roots are named host-side and moved here.
@@ -1692,6 +1865,7 @@ async function runStep(
1692
1865
  FOLDRUN_RUN_ID: runId,
1693
1866
  FOLDRUN_AGENT: step.agent,
1694
1867
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
1868
+ ...(testRun ? testModeEnv() : {}),
1695
1869
  ...sandboxSecrets,
1696
1870
  }).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
1697
1871
  ),
@@ -1699,6 +1873,9 @@ async function runStep(
1699
1873
  // Stamps the sandbox, so stopping this run can destroy it.
1700
1874
  runId,
1701
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,
1702
1879
  // Resume: a sandbox a previous driver left running, recorded on the
1703
1880
  // step. Consumed once — a fresh attempt after this one (a retry, a
1704
1881
  // fallback) starts its own sandbox.
@@ -1767,18 +1944,23 @@ async function runStep(
1767
1944
  outcome = withEarlierTiming(outcome, first);
1768
1945
  }
1769
1946
  if (lease) {
1770
- 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
+ }
1771
1951
  await lease.release();
1772
1952
  }
1773
- 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);
1774
1956
  step.status = outcome.status;
1775
- step.result = outcome.result;
1776
- step.conclusion = outcome.conclusion;
1957
+ step.result = redactText(outcome.result);
1958
+ step.conclusion = redactText(outcome.conclusion);
1777
1959
  step.sandbox = null;
1778
1960
  // The cluster's word on why a sandbox ended without the driver saying
1779
1961
  // so — OOMKilled, Evicted — on the record, where the retry reads it.
1780
1962
  if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
1781
- if (outcome.data !== undefined) step.data = outcome.data;
1963
+ if (outcome.data !== undefined) step.data = redactData(outcome.data);
1782
1964
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
1783
1965
  step.tokens = outcome.usage
1784
1966
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
@@ -1811,14 +1993,27 @@ async function runStep(
1811
1993
  })();
1812
1994
  step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
1813
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;
1814
2009
  const consultTools = buildConsultTools(
1815
2010
  consults,
1816
- { ...process.env, ...liveSecrets, ...providerEnv },
2011
+ { ...hostSafeEnv(), ...stepSecrets, ...providerEnv },
1817
2012
  (type, text) => push(type, text),
1818
2013
  );
1819
2014
  // @file secrets become 0600 paths here (host run), cleaned up in the
1820
2015
  // finally — once, however many attempts follow.
1821
- const mat = materializeFileSecrets(agentDir, liveSecrets);
2016
+ const mat = materializeFileSecrets(agentDir, stepSecrets);
1822
2017
  fileDir = mat.dir;
1823
2018
  const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
1824
2019
  agentDir,
@@ -1840,13 +2035,20 @@ async function runStep(
1840
2035
  ...mcpServers,
1841
2036
  },
1842
2037
  // Declared secrets reach the agent's scripts as env vars; the model
1843
- // only ever sees the variable names, not the values.
1844
- 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 },
1845
2043
  timeoutSec: step.timeout,
1846
2044
  budgetUsd: stepBudgetUsd,
2045
+ budgetNote: stepBudgetNote,
1847
2046
  price: tokenPrice,
1848
2047
  verify: step.verify,
1849
2048
  output: step.output,
2049
+ schema: resolvedSchema ?? undefined,
2050
+ maxTurns: step.maxTurns,
2051
+ stopRequested,
1850
2052
  // What the scripts saw, the verify sees: a flow can then check that
1851
2053
  // the proof a step left names THIS run, not one that came before —
1852
2054
  // copy-back never propagates a deletion, so a marker from an earlier
@@ -1856,20 +2058,31 @@ async function runStep(
1856
2058
  ...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
1857
2059
  FOLDRUN_AGENT: step.agent,
1858
2060
  FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
1859
- ...liveSecrets,
2061
+ ...(testRun ? testModeEnv() : {}),
2062
+ ...stepSecrets,
1860
2063
  },
1861
2064
  emit: pushWatching,
1862
2065
  });
1863
2066
  // With a translator the step's endpoint is the translator: started
1864
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.
1865
2072
  const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
1866
- if (!spec) return attemptInProcess(modelEnv);
1867
- const t = await startTranslator(spec);
2073
+ const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2074
+ const t = spec ? await startTranslator(spec) : null;
1868
2075
  try {
1869
- return await attemptInProcess({ ...modelEnv, ...t.env });
2076
+ return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
1870
2077
  } finally {
1871
- for (const line of t.drainLog()) push("info", line.startsWith("translator") ? line : `translator: ${line}`);
1872
- 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
+ }
1873
2086
  }
1874
2087
  };
1875
2088
  let outcome = await attemptThrough(providerEnv, translator);
@@ -1891,11 +2104,11 @@ async function runStep(
1891
2104
  );
1892
2105
  outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
1893
2106
  }
1894
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
2107
+ if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
1895
2108
  step.status = outcome.status;
1896
- step.result = outcome.result;
1897
- step.conclusion = outcome.conclusion;
1898
- 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);
1899
2112
  // A consult's spend belongs to the step that asked.
1900
2113
  const consultCost = consultTools.drainCost();
1901
2114
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
@@ -2189,6 +2402,26 @@ function csvItems(workspaceDir: string, step: StepRecord, take: number): string[
2189
2402
  return data.slice(0, take).map((r) => `${header}\n${r}`);
2190
2403
  }
2191
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
+
2192
2425
  export function createFlowRun(
2193
2426
  tenant: string,
2194
2427
  workspace: string,
@@ -2196,12 +2429,16 @@ export function createFlowRun(
2196
2429
  flowName: string,
2197
2430
  status: "queued" | "running",
2198
2431
  tags: string[] = [],
2432
+ startedBy: string | null = null,
2433
+ opts: { test?: boolean } = {},
2199
2434
  ): RunRecord {
2200
2435
  const run: RunRecord = {
2201
2436
  id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
2202
2437
  flow: flowName,
2203
2438
  tags,
2204
2439
  status,
2440
+ ...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
2441
+ ...(opts.test ? { test: true } : {}),
2205
2442
  startedAt: new Date().toISOString(),
2206
2443
  finishedAt: null,
2207
2444
  steps: steps.map((s) => ({
@@ -2231,6 +2468,10 @@ export function createFlowRun(
2231
2468
  preview: s.preview,
2232
2469
  delegate: s.delegate,
2233
2470
  output: s.output,
2471
+ schema: s.schema,
2472
+ schemaPath: s.schemaPath,
2473
+ parallel: s.parallel,
2474
+ maxTurns: s.maxTurns,
2234
2475
  attempts: 0,
2235
2476
  status: "pending",
2236
2477
  events: [],
@@ -2267,8 +2508,10 @@ export function startFlowRun(
2267
2508
  modelOverride?: string | null,
2268
2509
  tags: string[] = [],
2269
2510
  effortOverride?: string | null,
2511
+ startedBy: string | null = null,
2512
+ opts: { test?: boolean } = {},
2270
2513
  ): RunRecord {
2271
- const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
2514
+ const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
2272
2515
  void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
2273
2516
  return run;
2274
2517
  }
@@ -2566,6 +2809,17 @@ function driveRunInner(
2566
2809
  // is assembled) keeps the newest: when the total is over it, the
2567
2810
  // oldest groups are dropped whole before anything is cut mid-text.
2568
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
+ };
2569
2823
  // The data half of the same handoff: what the nearest earlier group
2570
2824
  // returned through `output: json`. Rebuilt from the record rather than
2571
2825
  // kept only in memory, so a resumed run hands step 3 the value step 1
@@ -2630,8 +2884,11 @@ function driveRunInner(
2630
2884
  context = contextBefore(gi);
2631
2885
  ctxData = dataBefore(gi);
2632
2886
 
2633
- if (run.budgetUsd && runCost(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
2634
- 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);
2635
2892
  const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
2636
2893
  last?.events.push({
2637
2894
  t: new Date().toISOString(),
@@ -2720,6 +2977,7 @@ function driveRunInner(
2720
2977
  type: "info",
2721
2978
  text:
2722
2979
  `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
2980
+ (step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
2723
2981
  (dropped ? ` (more items existed — capped at ${cap})` : ""),
2724
2982
  });
2725
2983
  const at = run.steps.indexOf(step);
@@ -2734,6 +2992,7 @@ function driveRunInner(
2734
2992
  result: null,
2735
2993
  costUsd: null,
2736
2994
  attempts: 0,
2995
+ tries: undefined,
2737
2996
  }));
2738
2997
  run.steps.splice(at + 1, 0, ...instances);
2739
2998
  ordered = orderedGroups();
@@ -2747,12 +3006,25 @@ function driveRunInner(
2747
3006
  // this is what keeps the *next* group from starting.
2748
3007
  const stopCheck = readRun(tenant, workspace, run.id);
2749
3008
  if (stopCheck?.stopRequested) {
3009
+ let skipped = 0;
2750
3010
  for (const s of run.steps) {
2751
3011
  if (s.status === "pending" || s.status === "running") {
2752
3012
  s.status = "skipped";
2753
3013
  s.skipReason = "run stopped";
3014
+ skipped += 1;
2754
3015
  }
2755
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
+ });
2756
3028
  run.status = "failed";
2757
3029
  run.stopRequested = true;
2758
3030
  save();
@@ -2788,40 +3060,52 @@ function driveRunInner(
2788
3060
  }
2789
3061
 
2790
3062
  const ctx = context;
3063
+ // The previous group's result — see routingBefore.
3064
+ const routing = routingBefore(gi);
2791
3065
 
2792
- // `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.
2793
3068
  for (const step of freshGroup) {
2794
3069
  if (step.status !== "pending" || !step.when) continue;
2795
- 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);
2796
3074
  if (!met) {
2797
3075
  step.status = "skipped";
2798
3076
  step.skipReason = `condition not met: when "${step.when}"`;
2799
3077
  step.events.push({
2800
3078
  t: new Date().toISOString(),
2801
3079
  type: "info",
2802
- 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)`,
2803
3081
  });
2804
3082
  }
2805
3083
  }
2806
3084
  save();
2807
3085
 
2808
3086
  // `case:`/`else:` — exclusive routing. Of this group's case steps,
2809
- // the FIRST whose text appears in the previous results runs; the
2810
- // 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.
2811
3089
  // (`when:` above stays independent — every matching when runs —
2812
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.
2813
3097
  const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
2814
3098
  if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
2815
3099
  let matchedCase: StepRecord | null = null;
2816
3100
  for (const step of caseSteps) {
2817
- if (!matchedCase && (ctx ?? "").toLowerCase().includes(step.case!.toLowerCase())) {
3101
+ if (!matchedCase && markerPresent(routing, step.case!)) {
2818
3102
  matchedCase = step;
2819
3103
  continue;
2820
3104
  }
2821
3105
  step.status = "skipped";
2822
3106
  step.skipReason = matchedCase
2823
3107
  ? `routed past — "${matchedCase.case}" matched first`
2824
- : `case not matched: "${step.case}"`;
3108
+ : `case not matched: the previous group's result has no line beginning "${step.case}"`;
2825
3109
  step.events.push({
2826
3110
  t: new Date().toISOString(),
2827
3111
  type: "info",
@@ -2865,6 +3149,20 @@ function driveRunInner(
2865
3149
  });
2866
3150
  }
2867
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;
2868
3166
  save();
2869
3167
 
2870
3168
  // The gate's declared preview, resolved now against what the run
@@ -2939,10 +3237,46 @@ function driveRunInner(
2939
3237
  // twenty steps over — the shares sum to what was left. Between
2940
3238
  // groups the check above still refuses to start the next one.
2941
3239
  const launching = freshGroup.filter((s) => s.status === "pending");
2942
- 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
+ };
2943
3267
  await Promise.all(
2944
3268
  launching
2945
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) {
2946
3280
  const attempts = (step.retry ?? 0) + 1;
2947
3281
  // A step re-attaching to its sandbox is still on the attempt
2948
3282
  // that started it; a step re-run after a backoff picks up the
@@ -2951,6 +3285,31 @@ function driveRunInner(
2951
3285
  !step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
2952
3286
  const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts! + 1) : 1;
2953
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;
2954
3313
  step.attempts = attempt;
2955
3314
  step.status = "running";
2956
3315
  save();
@@ -2965,11 +3324,21 @@ function driveRunInner(
2965
3324
  effortOverride,
2966
3325
  run.id,
2967
3326
  ctxData,
2968
- 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
+ },
2969
3336
  );
2970
3337
  // runStep mutates step.status; read it through a widened local
2971
3338
  // so TS doesn't keep the "running" narrowing from above.
2972
3339
  const outcome: string = step.status;
3340
+ recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
3341
+ save();
2973
3342
  if (outcome !== "failed" || attempt === attempts) break;
2974
3343
  // A stop destroys the step's sandbox, which reads here as a
2975
3344
  // failed attempt — and a failed attempt used to be retried in
@@ -3014,8 +3383,7 @@ function driveRunInner(
3014
3383
  break;
3015
3384
  }
3016
3385
  }
3017
- }),
3018
- );
3386
+ }
3019
3387
 
3020
3388
  // on-fail: another agent takes the step over. Sequential and rare:
3021
3389
  // the rescue is a second full attempt at the step's job with the
@@ -3067,7 +3435,7 @@ function driveRunInner(
3067
3435
  rescue.attempts = 1;
3068
3436
  rescue.status = "running";
3069
3437
  save();
3070
- 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);
3071
3439
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
3072
3440
  const rescueOutcome: string = rescue.status;
3073
3441
  if (rescueOutcome === "completed") {
@@ -3197,16 +3565,25 @@ function driveRunInner(
3197
3565
  for (const s of run.steps) if (s.status === "pending") s.status = "skipped";
3198
3566
  } catch (err) {
3199
3567
  run.status = "failed";
3568
+ const text = err instanceof Error ? err.message : String(err);
3569
+ let told = false;
3200
3570
  run.steps.forEach((s) => {
3201
3571
  if (s.status === "running") {
3202
3572
  s.status = "failed";
3203
- s.events.push({
3204
- t: new Date().toISOString(),
3205
- type: "error",
3206
- text: err instanceof Error ? err.message : String(err),
3207
- });
3573
+ s.events.push({ t: new Date().toISOString(), type: "error", text });
3574
+ told = true;
3208
3575
  }
3209
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
+ }
3210
3587
  } finally {
3211
3588
  if (!parked) {
3212
3589
  run.finishedAt = new Date().toISOString();
@@ -3216,6 +3593,9 @@ function driveRunInner(
3216
3593
  // learn, and the runs list and the notification stop having to say
3217
3594
  // only "completed".
3218
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);
3219
3599
  // Archive before the next run resets outputs/ — a failed run's
3220
3600
  // artifacts are usually the most interesting ones, so this runs on
3221
3601
  // failure too.
@@ -3256,16 +3636,6 @@ function driveRunInner(
3256
3636
  })();
3257
3637
  }
3258
3638
 
3259
- function readFlow(tenant: string, workspace: string, flowName: string) {
3260
- const dir = path.join(workspaceDir(tenant, workspace), "flows");
3261
- if (!fs.existsSync(dir)) return null;
3262
- for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
3263
- const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
3264
- if (flow.name === flowName) return flow;
3265
- }
3266
- return null;
3267
- }
3268
-
3269
3639
  // Splice `[[flow:other]]` steps into the parent's step list, renumbering
3270
3640
  // groups so the nested flow's own parallelism survives. Depth-limited, and
3271
3641
  // a cycle raises rather than expanding forever.