@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
@@ -5,11 +5,15 @@
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import matter from "gray-matter";
8
- import { knownPrice, executeStep, extractJson, stepCeiling } from "./step-exec.js";
8
+ import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
9
9
  import { eventUrl } from "./webhook.js";
10
10
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
11
+ import { hostSafeEnv } from "./host-env.js";
12
+ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
11
13
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
12
14
  import { platform } from "./platform.js";
15
+ import { healthKey } from "./secret-health.js";
16
+ import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
13
17
  /** Does this process run steps in a sandbox — the container core ships, or
14
18
  * one the platform registered (a pod)? */
15
19
  const isolatedRun = () => {
@@ -19,7 +23,7 @@ const isolatedRun = () => {
19
23
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
20
24
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError } from "./tool-names.js";
21
25
  import { refNames } from "./refs.js";
22
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, parseFlow, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, STORAGE_DIR, adoptLegacyVersionKey, runCost, } from "./store.js";
26
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
23
27
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
24
28
  import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
25
29
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
@@ -34,6 +38,7 @@ import { mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
34
38
  import { materializeFiles, harvestFiles } from "./storage.js";
35
39
  import { chooseExecutor, ensureImage } from "./container.js";
36
40
  import { stampBundle } from "./okf.js";
41
+ import { trimChars } from "./paths.js";
37
42
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
38
43
  // in env and headers resolve server-side, so a credential reaches the server
39
44
  // process without ever entering the model's context.
@@ -84,6 +89,34 @@ export function joinEarlierGroups(groupResults, gi, cap = CONTEXT_CHARS) {
84
89
  parts.shift();
85
90
  return parts.join("\n\n");
86
91
  }
92
+ /**
93
+ * `parallel: N` — a pool of N slots the instances of one fan-out share.
94
+ * Fan-out always ran every instance at once, and still does unless the
95
+ * step says otherwise: twenty browser steps against one site, or one
96
+ * vendor's rate limit, is what the cap is for. Nothing is rented while an
97
+ * instance waits for a slot.
98
+ */
99
+ export function slotPool(size) {
100
+ let free = Math.max(1, size);
101
+ const waiting = [];
102
+ return {
103
+ acquire: () => new Promise((resolve) => {
104
+ if (free > 0) {
105
+ free -= 1;
106
+ resolve();
107
+ }
108
+ else
109
+ waiting.push(resolve);
110
+ }),
111
+ release: () => {
112
+ const next = waiting.shift();
113
+ if (next)
114
+ next();
115
+ else
116
+ free += 1;
117
+ },
118
+ };
119
+ }
87
120
  const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
88
121
  // gray-matter throws on invalid YAML. Skills authored for another client
89
122
  // sometimes ship YAML that that client's parser happened to accept — most
@@ -311,10 +344,9 @@ export function saysUntilMarker(result, until) {
311
344
  .some((line) =>
312
345
  // Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
313
346
  // is a decision written by someone using bold, not a sentence.
314
- line
315
- .trim()
316
- .replace(/^[#>\-*_`\s]+/, "")
317
- .replace(/[.!:,*_`\s]+$/, "")
347
+ // trimChars, not a `[…]+$` regex: that one is quadratic on a
348
+ // line of tabs, and this is a line an agent wrote.
349
+ trimChars(trimChars(line.trim(), "#>-*_`"), ".!:,*_`").trim()
318
350
  .toLowerCase() === marker);
319
351
  }
320
352
  export function resolveDocLinks(text, workspaceRoot) {
@@ -408,7 +440,10 @@ export function resolveDocLinks(text, workspaceRoot) {
408
440
  map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
409
441
  }
410
442
  }
411
- return text.replace(/\[\[([^\]\n]+)\]\]/g, (whole, name) => {
443
+ // `[` excluded from the name: with it allowed, a run of `[[` makes the
444
+ // engine re-scan to the end from every opening — quadratic on a result
445
+ // an agent wrote.
446
+ return text.replace(/\[\[([^\]\[\n]+)\]\]/g, (whole, name) => {
412
447
  const hit = map.get(norm(name.trim()));
413
448
  return hit ? `\`${hit}\`` : whole;
414
449
  });
@@ -1072,6 +1107,42 @@ function repriced(catalog, model, usage, sdkCost, push) {
1072
1107
  }
1073
1108
  return known;
1074
1109
  }
1110
+ /**
1111
+ * Write one attempt onto the step's history and re-total the step.
1112
+ *
1113
+ * runStep reports the attempt it just ran — its cost, tokens and sandbox
1114
+ * seconds — by assigning the step's fields. Those become this attempt's row;
1115
+ * the step's fields then become the sum of every row, which is what the
1116
+ * run's spend, the flow's budget: and the platform's bill read. A retried
1117
+ * step used to carry only its last attempt's cost, so a `retry: 2` step
1118
+ * could spend three shares while reporting one.
1119
+ */
1120
+ export function recordAttempt(step, n, status, startedAt, eventsBefore,
1121
+ /** The attempt re-attached to a sandbox a previous driver left: same
1122
+ * attempt, so its row is replaced rather than repeated. */
1123
+ reattach = false) {
1124
+ const tries = step.tries ?? [];
1125
+ const lastError = step.events
1126
+ .slice(eventsBefore)
1127
+ .filter((e) => e.type === "error")
1128
+ .at(-1)?.text;
1129
+ const row = {
1130
+ n,
1131
+ status,
1132
+ costUsd: step.costUsd,
1133
+ tokens: step.tokens ?? null,
1134
+ computeSecs: step.computeSecs ?? null,
1135
+ startedAt,
1136
+ finishedAt: step.finishedAt ?? new Date().toISOString(),
1137
+ ...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
1138
+ };
1139
+ const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
1140
+ step.tries = [...kept, row];
1141
+ const totals = sumAttempts(step.tries);
1142
+ step.costUsd = totals.costUsd;
1143
+ step.tokens = totals.tokens;
1144
+ step.computeSecs = totals.computeSecs;
1145
+ }
1075
1146
  async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
1076
1147
  /** What the previous group returned as DATA (an `output: json` step's
1077
1148
  * value), beside the prose in `context`. Undefined when nothing did. */
@@ -1079,7 +1150,16 @@ priorData,
1079
1150
  /** The most this step may spend before it is stopped mid-turn: the run's
1080
1151
  * remaining `budget:`, shared across the steps launched with it. Null
1081
1152
  * means the flow set no budget. */
1082
- stepBudgetUsd = null) {
1153
+ stepBudgetUsd = null,
1154
+ /** Which line set that ceiling, for the error that names it. */
1155
+ stepBudgetNote,
1156
+ /** The run is a test run — see test-mode.ts. Read off the run record by
1157
+ * the caller, never from anything the sandbox could have written. */
1158
+ testRun = false,
1159
+ /** Has a person asked for the run to stop? Read on a clock by the
1160
+ * in-process model loop, so a stop lands mid-step and not at the next
1161
+ * group. The isolated path needs none: stopRun destroys the sandbox. */
1162
+ stopRequested) {
1083
1163
  // Secret values are injected into scripts as environment variables and
1084
1164
  // substituted into API headers, so a model that reads one back — from a
1085
1165
  // script that prints it, an API error that echoes it, or its own `env` —
@@ -1103,6 +1183,22 @@ stepBudgetUsd = null) {
1103
1183
  out = out.split(value).join(`[redacted:${name}]`);
1104
1184
  return out;
1105
1185
  };
1186
+ // The reply, the conclusion and an `output: json` value land on the same
1187
+ // record the events do, and a model that quotes a script's stdout puts
1188
+ // the credential in its answer rather than in a tool event. Same scrub,
1189
+ // same place. The JSON value goes through its text form: a secret inside
1190
+ // a nested field is still a secret, and the key name survives either way.
1191
+ const redactText = (text) => (typeof text === "string" ? redact(text) : text);
1192
+ const redactData = (value) => {
1193
+ if (value === undefined || redactions.length === 0)
1194
+ return value;
1195
+ try {
1196
+ return JSON.parse(redact(JSON.stringify(value)));
1197
+ }
1198
+ catch {
1199
+ return value;
1200
+ }
1201
+ };
1106
1202
  const push = (type, text, extra) => {
1107
1203
  step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
1108
1204
  save();
@@ -1140,8 +1236,15 @@ stepBudgetUsd = null) {
1140
1236
  // the failure of a step that returned no JSON are testable at no cost.
1141
1237
  if (step.output === "json") {
1142
1238
  const extracted = extractJson(step.result);
1143
- if (extracted.ok)
1239
+ if (extracted.ok) {
1144
1240
  step.data = extracted.value;
1241
+ const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
1242
+ const errors = schema && looksLikeSchema(schema) ? validateSchema(step.data, schema) : [];
1243
+ if (errors.length) {
1244
+ push("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
1245
+ step.status = "failed";
1246
+ }
1247
+ }
1145
1248
  else {
1146
1249
  push("error", `output: json — ${extracted.reason}`);
1147
1250
  step.status = "failed";
@@ -1168,7 +1271,7 @@ stepBudgetUsd = null) {
1168
1271
  // what crosses into containers, headers and env files is the token. A
1169
1272
  // failed refresh fails the step naming the secret and the provider's
1170
1273
  // reason, which beats a 401 three layers later.
1171
- const liveSecrets = await materializeSecrets(secretEnv);
1274
+ const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
1172
1275
  // @file values (SSH keys, certs) still carry their content at this point.
1173
1276
  // The in-process branch materialises them to 0600 paths on this host
1174
1277
  // below; the container branch passes the content across and the driver
@@ -1307,6 +1410,12 @@ stepBudgetUsd = null) {
1307
1410
  // The confinement boundary: agents in a workspace are one team, but the
1308
1411
  // workspace is where isolation is enforced.
1309
1412
  const workspaceRoot = path.resolve(agentDir, "..", "..");
1413
+ // Where a test run's state/ and storage/ writes go, and the line each
1414
+ // one leaves: under the run, beside its archived outputs.
1415
+ const divertFor = (id) => ({
1416
+ to: path.join(workspaceRoot, "runs", id, TEST_WRITES_DIR),
1417
+ note: (rel, summary) => push("info", `test: ${summary}`, { effect: { kind: "diverted", path: rel, summary } }),
1418
+ });
1310
1419
  let prompt = step.instruction
1311
1420
  ? resolveDocLinks(step.instruction, workspaceRoot)
1312
1421
  : "Begin your run now, following your instructions.";
@@ -1323,11 +1432,18 @@ stepBudgetUsd = null) {
1323
1432
  if (step.output === "json") {
1324
1433
  // The contract the runner parses back out — stated in the prompt so
1325
1434
  // the instruction and the parser agree by construction, like delegate:.
1435
+ // The schema, when there is one, is quoted whole: the model that has
1436
+ // to satisfy it should not have to guess it from the field names.
1437
+ const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
1326
1438
  prompt +=
1327
1439
  `\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
1328
1440
  "```json fenced block — the value the next step will compute on. Prose before it is " +
1329
1441
  `welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
1330
- `names fields, use exactly those names.\n</output>`;
1442
+ `names fields, use exactly those names.` +
1443
+ (schema && looksLikeSchema(schema)
1444
+ ? ` 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)}`
1445
+ : "") +
1446
+ `\n</output>`;
1331
1447
  }
1332
1448
  if (step.waitFor === "event" && step.eventPayload !== undefined) {
1333
1449
  // Not resolved for links, like the previous step's results: it is
@@ -1392,6 +1508,16 @@ stepBudgetUsd = null) {
1392
1508
  // fallback), else the platform's, and only for a step riding the
1393
1509
  // platform credential.
1394
1510
  const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
1511
+ // `schema:` — inline on the step, or a file under the workspace read
1512
+ // now, host-side, so both executors are handed the same document. A
1513
+ // file that cannot be read is a failed step before any model runs:
1514
+ // the contract the author wrote down cannot be checked.
1515
+ const resolvedSchema = step.schema ?? (step.schemaPath ? readSchemaFile(workspaceRoot, step.schemaPath) : null);
1516
+ if (step.schemaPath && resolvedSchema === null) {
1517
+ throw new Error(`schema: ${step.schemaPath} — not a readable JSON or YAML schema under the workspace`);
1518
+ }
1519
+ if (step.maxTurns)
1520
+ push("info", `max_turns: ${step.maxTurns}`);
1395
1521
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
1396
1522
  if (isolatedRun()) {
1397
1523
  // The isolated path: the whole loop — model, built-in tools, scripts —
@@ -1420,12 +1546,17 @@ stepBudgetUsd = null) {
1420
1546
  if (!host)
1421
1547
  continue;
1422
1548
  for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
1423
- for (const name of placeholderNames(v))
1424
- if (name in liveSecrets)
1425
- addGrant(grant, name, liveSecrets[name], host);
1549
+ for (const name of placeholderNames(v)) {
1550
+ if (!(name in liveSecrets))
1551
+ continue;
1552
+ addGrant(grant, name, liveSecrets[name], host);
1553
+ // Which vault entry this is, so the proxy's record of what the
1554
+ // far end said lands on the right credential.
1555
+ (grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", path.basename(path.resolve(agentDir, "..", "..")));
1556
+ }
1426
1557
  }
1427
1558
  }
1428
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant });
1559
+ const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
1429
1560
  // Which secrets must still be real inside the sandbox: a script tool
1430
1561
  // reads them from its environment, and unless it says `secrets:
1431
1562
  // proxied` it needs the value, not the name. bash is a script by
@@ -1445,14 +1576,35 @@ stepBudgetUsd = null) {
1445
1576
  }
1446
1577
  // Sandbox env for the declared secrets: the value, or its own name as
1447
1578
  // a placeholder that the proxy fills on the way out.
1448
- const sandboxSecrets = materialise
1449
- ? liveSecrets
1450
- : Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
1579
+ const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
1580
+ let sandboxSecrets = materialise ? liveSecrets : placeholders;
1581
+ // A test run hands the sandbox no send-capable value. The env var is
1582
+ // still there, reading TEST_MODE_WITHHELD, so a script that never
1583
+ // checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
1584
+ // error — and sends nothing. The script tools say whether the step
1585
+ // has a declared sender (everything withheld) or a declared reader
1586
+ // (nothing withheld); see test-mode.ts for the order.
1587
+ if (testRun && materialise) {
1588
+ const held = withholdSecrets(liveSecrets, {
1589
+ outward: scriptSpecs.some((sc) => sc.outward),
1590
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
1591
+ });
1592
+ sandboxSecrets = held.env;
1593
+ for (const name of held.withheld) {
1594
+ push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
1595
+ }
1596
+ }
1597
+ // On a test run the http tools always go through the proxy as
1598
+ // placeholders, even when a script made the step materialise: the
1599
+ // proxy is where the outward-write policy lives, and a header
1600
+ // carrying TEST_MODE_WITHHELD would only fail where it should have
1601
+ // been sunk or refused.
1602
+ const apiValues = testRun && lease ? placeholders : sandboxSecrets;
1451
1603
  const substitutedApis = apiSpecs.map((api) => ({
1452
1604
  ...api,
1453
1605
  headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
1454
1606
  k,
1455
- v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => sandboxSecrets[name] ?? whole),
1607
+ v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => apiValues[name] ?? whole),
1456
1608
  ])),
1457
1609
  }));
1458
1610
  // The provider's own key and headers, when the step has a lease: the
@@ -1480,6 +1632,7 @@ stepBudgetUsd = null) {
1480
1632
  const isolatedArgs = (modelEnv, keyName = MODEL_KEY_NAME) => ({
1481
1633
  workspaceRoot,
1482
1634
  libraryRoot: libraryDir(tenant),
1635
+ galleryRoot: platform.galleryDir() ?? undefined,
1483
1636
  // Keys this step's dependency cache. Per-account by design — see
1484
1637
  // run-container.ts#runtimeCacheDir for why it is not shared.
1485
1638
  tenant,
@@ -1500,9 +1653,12 @@ stepBudgetUsd = null) {
1500
1653
  consults,
1501
1654
  timeoutSec: step.timeout,
1502
1655
  budgetUsd: stepBudgetUsd,
1656
+ budgetNote: stepBudgetNote,
1503
1657
  price: tokenPrice,
1504
1658
  verify: step.verify,
1505
1659
  output: step.output,
1660
+ schema: resolvedSchema ?? undefined,
1661
+ maxTurns: step.maxTurns,
1506
1662
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
1507
1663
  // The container sees the workspace at /workspace and the library
1508
1664
  // at /library; the roots are named host-side and moved here.
@@ -1532,12 +1688,16 @@ stepBudgetUsd = null) {
1532
1688
  FOLDRUN_RUN_ID: runId,
1533
1689
  FOLDRUN_AGENT: step.agent,
1534
1690
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
1691
+ ...(testRun ? testModeEnv() : {}),
1535
1692
  ...sandboxSecrets,
1536
1693
  }).filter((entry) => typeof entry[1] === "string")),
1537
1694
  emit: pushWatching,
1538
1695
  // Stamps the sandbox, so stopping this run can destroy it.
1539
1696
  runId,
1540
1697
  size,
1698
+ // A test run's state/ and storage/ changes come out under the run.
1699
+ divert: testRun && runId ? divertFor(runId) : undefined,
1700
+ test: testRun,
1541
1701
  // Resume: a sandbox a previous driver left running, recorded on the
1542
1702
  // step. Consumed once — a fresh attempt after this one (a retry, a
1543
1703
  // fallback) starts its own sandbox.
@@ -1601,21 +1761,28 @@ stepBudgetUsd = null) {
1601
1761
  outcome = withEarlierTiming(outcome, first);
1602
1762
  }
1603
1763
  if (lease) {
1604
- for (const line of await lease.drainLog())
1605
- push("info", line);
1764
+ for (const line of await lease.drainLog()) {
1765
+ if (typeof line === "string")
1766
+ push("info", line);
1767
+ else
1768
+ push("info", line.text, { effect: line.effect });
1769
+ }
1606
1770
  await lease.release();
1607
1771
  }
1608
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
1772
+ // Nothing a test run left in storage/public/ reached storage/, so
1773
+ // there is nothing to publish — and a share link is an outward act.
1774
+ if (!testRun)
1775
+ publishPublicDir(tenant, path.basename(workspaceRoot), push);
1609
1776
  step.status = outcome.status;
1610
- step.result = outcome.result;
1611
- step.conclusion = outcome.conclusion;
1777
+ step.result = redactText(outcome.result);
1778
+ step.conclusion = redactText(outcome.conclusion);
1612
1779
  step.sandbox = null;
1613
1780
  // The cluster's word on why a sandbox ended without the driver saying
1614
1781
  // so — OOMKilled, Evicted — on the record, where the retry reads it.
1615
1782
  if (outcome.status === "failed" && outcome.reason)
1616
1783
  push("error", `sandbox ended: ${outcome.reason}`);
1617
1784
  if (outcome.data !== undefined)
1618
- step.data = outcome.data;
1785
+ step.data = redactData(outcome.data);
1619
1786
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
1620
1787
  step.tokens = outcome.usage
1621
1788
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
@@ -1646,10 +1813,23 @@ stepBudgetUsd = null) {
1646
1813
  step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
1647
1814
  }
1648
1815
  else {
1649
- const consultTools = buildConsultTools(consults, { ...process.env, ...liveSecrets, ...providerEnv }, (type, text) => push(type, text));
1816
+ // Same withholding as the sandbox path — the in-process step is what
1817
+ // `foldrun run --test` drives on a laptop, where there is no proxy
1818
+ // and this is the only guard there is.
1819
+ const held = testRun
1820
+ ? withholdSecrets(liveSecrets, {
1821
+ outward: scriptSpecs.some((sc) => sc.outward),
1822
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
1823
+ })
1824
+ : { env: liveSecrets, withheld: [] };
1825
+ for (const name of held.withheld) {
1826
+ push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
1827
+ }
1828
+ const stepSecrets = held.env;
1829
+ const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
1650
1830
  // @file secrets become 0600 paths here (host run), cleaned up in the
1651
1831
  // finally — once, however many attempts follow.
1652
- const mat = materializeFileSecrets(agentDir, liveSecrets);
1832
+ const mat = materializeFileSecrets(agentDir, stepSecrets);
1653
1833
  fileDir = mat.dir;
1654
1834
  const attemptInProcess = (modelEnv) => executeStep({
1655
1835
  agentDir,
@@ -1671,13 +1851,20 @@ stepBudgetUsd = null) {
1671
1851
  ...mcpServers,
1672
1852
  },
1673
1853
  // Declared secrets reach the agent's scripts as env vars; the model
1674
- // only ever sees the variable names, not the values.
1675
- env: { ...process.env, ...clockEnv, ...mat.env, ...modelEnv },
1854
+ // only ever sees the variable names, not the values. The base is
1855
+ // the allowlisted host environment, not process.env: the SDK's
1856
+ // Bash tool runs with this, and on a platform process.env holds
1857
+ // every tenant's root key and the datastore credentials.
1858
+ env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
1676
1859
  timeoutSec: step.timeout,
1677
1860
  budgetUsd: stepBudgetUsd,
1861
+ budgetNote: stepBudgetNote,
1678
1862
  price: tokenPrice,
1679
1863
  verify: step.verify,
1680
1864
  output: step.output,
1865
+ schema: resolvedSchema ?? undefined,
1866
+ maxTurns: step.maxTurns,
1867
+ stopRequested,
1681
1868
  // What the scripts saw, the verify sees: a flow can then check that
1682
1869
  // the proof a step left names THIS run, not one that came before —
1683
1870
  // copy-back never propagates a deletion, so a marker from an earlier
@@ -1687,23 +1874,33 @@ stepBudgetUsd = null) {
1687
1874
  ...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
1688
1875
  FOLDRUN_AGENT: step.agent,
1689
1876
  FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
1690
- ...liveSecrets,
1877
+ ...(testRun ? testModeEnv() : {}),
1878
+ ...stepSecrets,
1691
1879
  },
1692
1880
  emit: pushWatching,
1693
1881
  });
1694
1882
  // With a translator the step's endpoint is the translator: started
1695
1883
  // here, pointed at by the SDK's env, closed when the attempt ends.
1884
+ // A test run brackets each attempt with a snapshot of state/ and
1885
+ // storage/: the step writes them for real, because there is no
1886
+ // sandbox to write instead, and afterwards what changed is moved
1887
+ // under the run and the originals come back.
1696
1888
  const attemptThrough = async (modelEnv, spec) => {
1697
- if (!spec)
1698
- return attemptInProcess(modelEnv);
1699
- const t = await startTranslator(spec);
1889
+ const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
1890
+ const t = spec ? await startTranslator(spec) : null;
1700
1891
  try {
1701
- return await attemptInProcess({ ...modelEnv, ...t.env });
1892
+ return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
1702
1893
  }
1703
1894
  finally {
1704
- for (const line of t.drainLog())
1705
- push("info", line.startsWith("translator") ? line : `translator: ${line}`);
1706
- await t.close();
1895
+ if (t) {
1896
+ for (const line of t.drainLog())
1897
+ push("info", line.startsWith("translator") ? line : `translator: ${line}`);
1898
+ await t.close();
1899
+ }
1900
+ if (before && runId) {
1901
+ const d = divertFor(runId);
1902
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
1903
+ }
1707
1904
  }
1708
1905
  };
1709
1906
  let outcome = await attemptThrough(providerEnv, translator);
@@ -1722,12 +1919,13 @@ stepBudgetUsd = null) {
1722
1919
  push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
1723
1920
  outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
1724
1921
  }
1725
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
1922
+ if (!testRun)
1923
+ publishPublicDir(tenant, path.basename(workspaceRoot), push);
1726
1924
  step.status = outcome.status;
1727
- step.result = outcome.result;
1728
- step.conclusion = outcome.conclusion;
1925
+ step.result = redactText(outcome.result);
1926
+ step.conclusion = redactText(outcome.conclusion);
1729
1927
  if (outcome.data !== undefined)
1730
- step.data = outcome.data;
1928
+ step.data = redactData(outcome.data);
1731
1929
  // A consult's spend belongs to the step that asked.
1732
1930
  const consultCost = consultTools.drainCost();
1733
1931
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
@@ -2033,12 +2231,35 @@ function csvItems(workspaceDir, step, take) {
2033
2231
  }
2034
2232
  return data.slice(0, take).map((r) => `${header}\n${r}`);
2035
2233
  }
2036
- export function createFlowRun(tenant, workspace, steps, flowName, status, tags = []) {
2234
+ /**
2235
+ * `schema: <path>` — the file, read and parsed, or null. Agent-relative like
2236
+ * every other path in a flow (`../../schemas/lead.json`), confined to the
2237
+ * workspace: a schema is the author's document, never something a step
2238
+ * can point outside.
2239
+ */
2240
+ export function readSchemaFile(workspaceRoot, rel) {
2241
+ const root = path.resolve(workspaceRoot);
2242
+ // Written agent-relative (`../../schemas/x.json`) or workspace-relative
2243
+ // (`schemas/x.json`); both land on the same file.
2244
+ const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
2245
+ for (const abs of candidates) {
2246
+ if (abs !== root && !abs.startsWith(root + path.sep))
2247
+ continue;
2248
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
2249
+ continue;
2250
+ const parsed = parseSchemaText(fs.readFileSync(abs, "utf8"));
2251
+ return "problem" in parsed ? null : parsed.schema;
2252
+ }
2253
+ return null;
2254
+ }
2255
+ export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {}) {
2037
2256
  const run = {
2038
2257
  id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
2039
2258
  flow: flowName,
2040
2259
  tags,
2041
2260
  status,
2261
+ ...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
2262
+ ...(opts.test ? { test: true } : {}),
2042
2263
  startedAt: new Date().toISOString(),
2043
2264
  finishedAt: null,
2044
2265
  steps: steps.map((s) => ({
@@ -2068,6 +2289,10 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
2068
2289
  preview: s.preview,
2069
2290
  delegate: s.delegate,
2070
2291
  output: s.output,
2292
+ schema: s.schema,
2293
+ schemaPath: s.schemaPath,
2294
+ parallel: s.parallel,
2295
+ maxTurns: s.maxTurns,
2071
2296
  attempts: 0,
2072
2297
  status: "pending",
2073
2298
  events: [],
@@ -2093,8 +2318,8 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
2093
2318
  }
2094
2319
  return run;
2095
2320
  }
2096
- export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride) {
2097
- const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
2321
+ export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride, startedBy = null, opts = {}) {
2322
+ const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
2098
2323
  void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
2099
2324
  return run;
2100
2325
  }
@@ -2381,6 +2606,19 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2381
2606
  // is assembled) keeps the newest: when the total is over it, the
2382
2607
  // oldest groups are dropped whole before anything is cut mid-text.
2383
2608
  const contextBefore = (gi) => joinEarlierGroups(groupResults, gi);
2609
+ // What `when:` and `case:` read: the PREVIOUS group's result alone —
2610
+ // the nearest earlier group that produced one, so a routed-past group
2611
+ // between is looked through rather than read as silence. The prompt
2612
+ // gets every earlier group joined (contextBefore); routing must not,
2613
+ // because a verdict two groups back would still open a gate a later
2614
+ // group had overruled: a classifier's BUG line matched a `case: BUG`
2615
+ // three groups on, after the debugger had already answered it.
2616
+ const routingBefore = (gi) => {
2617
+ for (let i = gi - 1; i >= 0; i--)
2618
+ if (groupResults[i])
2619
+ return groupResults[i];
2620
+ return null;
2621
+ };
2384
2622
  // The data half of the same handoff: what the nearest earlier group
2385
2623
  // returned through `output: json`. Rebuilt from the record rather than
2386
2624
  // kept only in memory, so a resumed run hands step 3 the value step 1
@@ -2444,8 +2682,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2444
2682
  const group = ordered[gi];
2445
2683
  context = contextBefore(gi);
2446
2684
  ctxData = dataBefore(gi);
2447
- if (run.budgetUsd && runCost(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
2448
- const spent = runCost(run);
2685
+ // What the run has spent, not just what its tokens cost: on an
2686
+ // install that prices sandbox seconds, a step that called no model
2687
+ // still spent money, and a cap that ignored it capped nothing.
2688
+ if (run.budgetUsd && platform.runSpend(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
2689
+ const spent = platform.runSpend(run);
2449
2690
  const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
2450
2691
  last?.events.push({
2451
2692
  t: new Date().toISOString(),
@@ -2531,6 +2772,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2531
2772
  t: new Date().toISOString(),
2532
2773
  type: "info",
2533
2774
  text: `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
2775
+ (step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
2534
2776
  (dropped ? ` (more items existed — capped at ${cap})` : ""),
2535
2777
  });
2536
2778
  const at = run.steps.indexOf(step);
@@ -2545,6 +2787,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2545
2787
  result: null,
2546
2788
  costUsd: null,
2547
2789
  attempts: 0,
2790
+ tries: undefined,
2548
2791
  }));
2549
2792
  run.steps.splice(at + 1, 0, ...instances);
2550
2793
  ordered = orderedGroups();
@@ -2557,12 +2800,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2557
2800
  // this is what keeps the *next* group from starting.
2558
2801
  const stopCheck = readRun(tenant, workspace, run.id);
2559
2802
  if (stopCheck?.stopRequested) {
2803
+ let skipped = 0;
2560
2804
  for (const s of run.steps) {
2561
2805
  if (s.status === "pending" || s.status === "running") {
2562
2806
  s.status = "skipped";
2563
2807
  s.skipReason = "run stopped";
2808
+ skipped += 1;
2564
2809
  }
2565
2810
  }
2811
+ // Say so on the trail. stopRun wrote "run stopped" onto the
2812
+ // record's first step, but the save() below writes this process's
2813
+ // copy of the record over it, so the only witness was the
2814
+ // stopRequested flag — and a run that "failed" with no error line
2815
+ // anywhere took a code read to explain (rank-desk, 2026-09-13).
2816
+ const witness = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
2817
+ witness?.events.push({
2818
+ t: new Date().toISOString(),
2819
+ type: "info",
2820
+ text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
2821
+ });
2566
2822
  run.status = "failed";
2567
2823
  run.stopRequested = true;
2568
2824
  save();
@@ -2597,39 +2853,51 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2597
2853
  break;
2598
2854
  }
2599
2855
  const ctx = context;
2600
- // `when:` — skip a step whose condition isn't met by prior results.
2856
+ // The previous group's result — see routingBefore.
2857
+ const routing = routingBefore(gi);
2858
+ // `when:` — skip a step whose condition isn't met by the previous
2859
+ // group's result.
2601
2860
  for (const step of freshGroup) {
2602
2861
  if (step.status !== "pending" || !step.when)
2603
2862
  continue;
2604
- const met = (ctx ?? "").toLowerCase().includes(step.when.toLowerCase());
2863
+ // A marker at the start of a line, not a word anywhere in the
2864
+ // prose — see markerPresent. "There are no BLOCKED items" must
2865
+ // not open a gate keyed on BLOCKED.
2866
+ const met = markerPresent(routing, step.when);
2605
2867
  if (!met) {
2606
2868
  step.status = "skipped";
2607
2869
  step.skipReason = `condition not met: when "${step.when}"`;
2608
2870
  step.events.push({
2609
2871
  t: new Date().toISOString(),
2610
2872
  type: "info",
2611
- text: `skipped — previous results do not mention "${step.when}"`,
2873
+ 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)`,
2612
2874
  });
2613
2875
  }
2614
2876
  }
2615
2877
  save();
2616
2878
  // `case:`/`else:` — exclusive routing. Of this group's case steps,
2617
- // the FIRST whose text appears in the previous results runs; the
2618
- // rest are routed past. `else:` runs only when no case matched.
2879
+ // the FIRST whose marker leads a line of the previous results runs;
2880
+ // the rest are routed past. `else:` runs only when no case matched.
2619
2881
  // (`when:` above stays independent — every matching when runs —
2620
2882
  // which is why routing is its own vocabulary instead of a mode.)
2883
+ //
2884
+ // Same marker rule as `when:`, and it matters more here: routing is
2885
+ // exclusive, so a label matched out of a sentence does not merely
2886
+ // run an extra step, it sends the flow down the wrong branch and
2887
+ // skips the right one. "This is not a COMPLAINT, it is a QUESTION"
2888
+ // used to route to the complaints handler.
2621
2889
  const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
2622
2890
  if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
2623
2891
  let matchedCase = null;
2624
2892
  for (const step of caseSteps) {
2625
- if (!matchedCase && (ctx ?? "").toLowerCase().includes(step.case.toLowerCase())) {
2893
+ if (!matchedCase && markerPresent(routing, step.case)) {
2626
2894
  matchedCase = step;
2627
2895
  continue;
2628
2896
  }
2629
2897
  step.status = "skipped";
2630
2898
  step.skipReason = matchedCase
2631
2899
  ? `routed past — "${matchedCase.case}" matched first`
2632
- : `case not matched: "${step.case}"`;
2900
+ : `case not matched: the previous group's result has no line beginning "${step.case}"`;
2633
2901
  step.events.push({
2634
2902
  t: new Date().toISOString(),
2635
2903
  type: "info",
@@ -2671,6 +2939,20 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2671
2939
  });
2672
2940
  }
2673
2941
  run.status = "awaiting-approval";
2942
+ // approve_within: — the deadline is stamped when the gate opens,
2943
+ // not counted from the run's start, so "two days to answer" means
2944
+ // two days from being asked however long the work before it took.
2945
+ // Only a gate that asks a PERSON gets one: an external event has
2946
+ // no one to chase.
2947
+ const within = readFlow(tenant, workspace, run.flow)?.approveWithin ?? null;
2948
+ // Always assigned, never only set: a run with two gates would
2949
+ // otherwise carry the FIRST gate's deadline into the second, and
2950
+ // a deadline already in the past expires a gate the moment it
2951
+ // opens. Null when this gate has none.
2952
+ run.approveBy =
2953
+ within && needsApproval.some((s) => s.waitFor !== "event")
2954
+ ? new Date(Date.now() + within * 1000).toISOString()
2955
+ : null;
2674
2956
  save();
2675
2957
  // The gate's declared preview, resolved now against what the run
2676
2958
  // has actually produced — a glob at park time, a list of files on
@@ -2743,9 +3025,42 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2743
3025
  // twenty steps over — the shares sum to what was left. Between
2744
3026
  // groups the check above still refuses to start the next one.
2745
3027
  const launching = freshGroup.filter((s) => s.status === "pending");
2746
- const ceilingUsd = stepCeiling(run.budgetUsd, runCost(run), launching.length);
3028
+ // An agent's own `budget:` — the most it may spend in one run — is
3029
+ // read at launch, so a cap raised mid-run applies to the next step.
3030
+ const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
3031
+ // The ceiling for one attempt of one step, read NOW: what is left of
3032
+ // the run's budget shared across the group, or the agent's own cap
3033
+ // less what its steps have cost. Computed before every attempt, not
3034
+ // once before the loop — a retry's share is what is left after the
3035
+ // failed attempt spent, and the step's own figure already carries
3036
+ // that spend (it is the sum of its tries).
3037
+ const ceilingFor = (step) => stepCeilingFor(stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length), agentBudgets.get(step.agent), run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0), step.agent);
3038
+ // parallel: — the instances of one fan-out template share a pool.
3039
+ // Keyed by what the instances share (agent + instruction), since
3040
+ // the template itself is behind them in the record as "expanded".
3041
+ const pools = new Map();
3042
+ const poolFor = (step) => {
3043
+ if (!step.parallel || !step.item)
3044
+ return null;
3045
+ const key = `${step.agent}\u0000${step.instruction}`;
3046
+ let pool = pools.get(key);
3047
+ if (!pool)
3048
+ pools.set(key, (pool = slotPool(step.parallel)));
3049
+ return pool;
3050
+ };
2747
3051
  await Promise.all(launching
2748
3052
  .map(async (step) => {
3053
+ const pool = poolFor(step);
3054
+ if (pool)
3055
+ await pool.acquire();
3056
+ try {
3057
+ await launchStep(step);
3058
+ }
3059
+ finally {
3060
+ pool?.release();
3061
+ }
3062
+ }));
3063
+ async function launchStep(step) {
2749
3064
  const attempts = (step.retry ?? 0) + 1;
2750
3065
  // A step re-attaching to its sandbox is still on the attempt
2751
3066
  // that started it; a step re-run after a backoff picks up the
@@ -2753,13 +3068,45 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2753
3068
  const interruptedInBackoff = !step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
2754
3069
  const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts + 1) : 1;
2755
3070
  for (let attempt = from; attempt <= attempts; attempt++) {
3071
+ const ceiling = ceilingFor(step);
3072
+ // Nothing left is a refusal, not "no ceiling": a zero passed
3073
+ // down would read as unset, and the one step that must not
3074
+ // run would run with no cap at all. On a retry this is what
3075
+ // stops the second attempt spending a share the first one
3076
+ // already spent.
3077
+ if (ceiling.ceilingUsd === 0) {
3078
+ step.status = "failed";
3079
+ step.events.push({
3080
+ t: new Date().toISOString(),
3081
+ type: "error",
3082
+ text: attempt === from
3083
+ ? `over budget — nothing left to spend before this step (${ceiling.note}); it was not started`
3084
+ : `over budget — nothing left to spend for attempt ${attempt} (${ceiling.note}); not retried`,
3085
+ });
3086
+ save();
3087
+ return;
3088
+ }
3089
+ // Re-attaching to a sandbox is the same attempt, so its row
3090
+ // is replaced; anything else is a new row, a loop cycle's
3091
+ // re-run included.
3092
+ const reattach = !!step.sandbox;
3093
+ const attemptStartedAt = new Date().toISOString();
3094
+ const eventsBefore = step.events.length;
2756
3095
  step.attempts = attempt;
2757
3096
  step.status = "running";
2758
3097
  save();
2759
- await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceilingUsd);
3098
+ await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
3099
+ // save() merges a stop written by another process into
3100
+ // this record; the model loop polls this on a clock.
3101
+ () => {
3102
+ save();
3103
+ return run.stopRequested === true;
3104
+ });
2760
3105
  // runStep mutates step.status; read it through a widened local
2761
3106
  // so TS doesn't keep the "running" narrowing from above.
2762
3107
  const outcome = step.status;
3108
+ recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
3109
+ save();
2763
3110
  if (outcome !== "failed" || attempt === attempts)
2764
3111
  break;
2765
3112
  // A stop destroys the step's sandbox, which reads here as a
@@ -2807,7 +3154,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2807
3154
  break;
2808
3155
  }
2809
3156
  }
2810
- }));
3157
+ }
2811
3158
  // on-fail: another agent takes the step over. Sequential and rare:
2812
3159
  // the rescue is a second full attempt at the step's job with the
2813
3160
  // failure in front of it, and its success is the step's success —
@@ -2859,7 +3206,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2859
3206
  rescue.attempts = 1;
2860
3207
  rescue.status = "running";
2861
3208
  save();
2862
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData);
3209
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
2863
3210
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
2864
3211
  const rescueOutcome = rescue.status;
2865
3212
  if (rescueOutcome === "completed") {
@@ -2992,16 +3339,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2992
3339
  }
2993
3340
  catch (err) {
2994
3341
  run.status = "failed";
3342
+ const text = err instanceof Error ? err.message : String(err);
3343
+ let told = false;
2995
3344
  run.steps.forEach((s) => {
2996
3345
  if (s.status === "running") {
2997
3346
  s.status = "failed";
2998
- s.events.push({
2999
- t: new Date().toISOString(),
3000
- type: "error",
3001
- text: err instanceof Error ? err.message : String(err),
3002
- });
3347
+ s.events.push({ t: new Date().toISOString(), type: "error", text });
3348
+ told = true;
3003
3349
  }
3004
3350
  });
3351
+ // Thrown between groups, with nothing running: the error still has to
3352
+ // land on some step's trail, or the run reads as failed for no reason.
3353
+ if (!told) {
3354
+ const witness = run.steps.findLast((s) => s.status !== "pending") ?? run.steps[0];
3355
+ witness?.events.push({
3356
+ t: new Date().toISOString(),
3357
+ type: "error",
3358
+ text: `run failed between steps: ${text}`,
3359
+ });
3360
+ }
3005
3361
  }
3006
3362
  finally {
3007
3363
  if (!parked) {
@@ -3012,6 +3368,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3012
3368
  // learn, and the runs list and the notification stop having to say
3013
3369
  // only "completed".
3014
3370
  run.summary = runSummary(run);
3371
+ // A test run's headline says so wherever the headline goes — the
3372
+ // runs list, the history tool, a later run's recall.
3373
+ if (run.test && run.summary)
3374
+ run.summary = testHeadline(run.summary);
3015
3375
  // Archive before the next run resets outputs/ — a failed run's
3016
3376
  // artifacts are usually the most interesting ones, so this runs on
3017
3377
  // failure too.
@@ -3049,17 +3409,6 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3049
3409
  }
3050
3410
  })();
3051
3411
  }
3052
- function readFlow(tenant, workspace, flowName) {
3053
- const dir = path.join(workspaceDir(tenant, workspace), "flows");
3054
- if (!fs.existsSync(dir))
3055
- return null;
3056
- for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
3057
- const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
3058
- if (flow.name === flowName)
3059
- return flow;
3060
- }
3061
- return null;
3062
- }
3063
3412
  // Splice `[[flow:other]]` steps into the parent's step list, renumbering
3064
3413
  // groups so the nested flow's own parallelism survives. Depth-limited, and
3065
3414
  // a cycle raises rather than expanding forever.