@foldrun/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +31 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
@@ -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 { 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";
@@ -1158,14 +1261,17 @@ stepBudgetUsd = null) {
1158
1261
  // step: the generic HTTP tool still works without the typed ones.
1159
1262
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
1160
1263
  push("error", `openapi: ${w}`);
1161
- const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
1264
+ const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
1265
+ // A retry that moved the step up a class (an evicted or OOM-killed
1266
+ // attempt) wins over the agent's own `size:` for the attempts after.
1267
+ const size = step.sizeUp ?? agentSize;
1162
1268
  // oauth2 secrets become live access tokens here — the one async moment
1163
1269
  // in secret resolution, at the last host-side point before anything is
1164
1270
  // injected or substituted. The refresh recipes never leave this process:
1165
1271
  // what crosses into containers, headers and env files is the token. A
1166
1272
  // failed refresh fails the step naming the secret and the provider's
1167
1273
  // reason, which beats a 401 three layers later.
1168
- const liveSecrets = await materializeSecrets(secretEnv);
1274
+ const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
1169
1275
  // @file values (SSH keys, certs) still carry their content at this point.
1170
1276
  // The in-process branch materialises them to 0600 paths on this host
1171
1277
  // below; the container branch passes the content across and the driver
@@ -1289,6 +1395,11 @@ stepBudgetUsd = null) {
1289
1395
  push("info", fitted.note);
1290
1396
  effort = fitted.effort;
1291
1397
  }
1398
+ // No catalogue — the platform's own Anthropic key, or a gateway that
1399
+ // publishes no prices — but the model's NAME is often enough. Without
1400
+ // this the in-pod meter priced haiku and sonnet at Opus rates and a
1401
+ // step was stopped at up to fifteen times what it had spent.
1402
+ tokenPrice ??= knownPrice(wireModel);
1292
1403
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
1293
1404
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
1294
1405
  // Expose the workspace's shared scripts/ as ./shared so every agent reaches
@@ -1299,6 +1410,12 @@ stepBudgetUsd = null) {
1299
1410
  // The confinement boundary: agents in a workspace are one team, but the
1300
1411
  // workspace is where isolation is enforced.
1301
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
+ });
1302
1419
  let prompt = step.instruction
1303
1420
  ? resolveDocLinks(step.instruction, workspaceRoot)
1304
1421
  : "Begin your run now, following your instructions.";
@@ -1315,11 +1432,18 @@ stepBudgetUsd = null) {
1315
1432
  if (step.output === "json") {
1316
1433
  // The contract the runner parses back out — stated in the prompt so
1317
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);
1318
1438
  prompt +=
1319
1439
  `\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
1320
1440
  "```json fenced block — the value the next step will compute on. Prose before it is " +
1321
1441
  `welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
1322
- `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>`;
1323
1447
  }
1324
1448
  if (step.waitFor === "event" && step.eventPayload !== undefined) {
1325
1449
  // Not resolved for links, like the previous step's results: it is
@@ -1384,6 +1508,16 @@ stepBudgetUsd = null) {
1384
1508
  // fallback), else the platform's, and only for a step riding the
1385
1509
  // platform credential.
1386
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}`);
1387
1521
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
1388
1522
  if (isolatedRun()) {
1389
1523
  // The isolated path: the whole loop — model, built-in tools, scripts —
@@ -1412,12 +1546,17 @@ stepBudgetUsd = null) {
1412
1546
  if (!host)
1413
1547
  continue;
1414
1548
  for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
1415
- for (const name of placeholderNames(v))
1416
- if (name in liveSecrets)
1417
- 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
+ }
1418
1557
  }
1419
1558
  }
1420
- 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 });
1421
1560
  // Which secrets must still be real inside the sandbox: a script tool
1422
1561
  // reads them from its environment, and unless it says `secrets:
1423
1562
  // proxied` it needs the value, not the name. bash is a script by
@@ -1437,14 +1576,35 @@ stepBudgetUsd = null) {
1437
1576
  }
1438
1577
  // Sandbox env for the declared secrets: the value, or its own name as
1439
1578
  // a placeholder that the proxy fills on the way out.
1440
- const sandboxSecrets = materialise
1441
- ? liveSecrets
1442
- : 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;
1443
1603
  const substitutedApis = apiSpecs.map((api) => ({
1444
1604
  ...api,
1445
1605
  headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
1446
1606
  k,
1447
- 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),
1448
1608
  ])),
1449
1609
  }));
1450
1610
  // The provider's own key and headers, when the step has a lease: the
@@ -1472,6 +1632,7 @@ stepBudgetUsd = null) {
1472
1632
  const isolatedArgs = (modelEnv, keyName = MODEL_KEY_NAME) => ({
1473
1633
  workspaceRoot,
1474
1634
  libraryRoot: libraryDir(tenant),
1635
+ galleryRoot: platform.galleryDir() ?? undefined,
1475
1636
  // Keys this step's dependency cache. Per-account by design — see
1476
1637
  // run-container.ts#runtimeCacheDir for why it is not shared.
1477
1638
  tenant,
@@ -1492,9 +1653,12 @@ stepBudgetUsd = null) {
1492
1653
  consults,
1493
1654
  timeoutSec: step.timeout,
1494
1655
  budgetUsd: stepBudgetUsd,
1656
+ budgetNote: stepBudgetNote,
1495
1657
  price: tokenPrice,
1496
1658
  verify: step.verify,
1497
1659
  output: step.output,
1660
+ schema: resolvedSchema ?? undefined,
1661
+ maxTurns: step.maxTurns,
1498
1662
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
1499
1663
  // The container sees the workspace at /workspace and the library
1500
1664
  // at /library; the roots are named host-side and moved here.
@@ -1524,12 +1688,29 @@ stepBudgetUsd = null) {
1524
1688
  FOLDRUN_RUN_ID: runId,
1525
1689
  FOLDRUN_AGENT: step.agent,
1526
1690
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
1691
+ ...(testRun ? testModeEnv() : {}),
1527
1692
  ...sandboxSecrets,
1528
1693
  }).filter((entry) => typeof entry[1] === "string")),
1529
1694
  emit: pushWatching,
1530
1695
  // Stamps the sandbox, so stopping this run can destroy it.
1531
1696
  runId,
1532
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,
1701
+ // Resume: a sandbox a previous driver left running, recorded on the
1702
+ // step. Consumed once — a fresh attempt after this one (a retry, a
1703
+ // fallback) starts its own sandbox.
1704
+ resume: step.sandbox && step.sandbox.kind === isolation ? { ref: step.sandbox.ref, consumed: step.sandbox.consumed } : null,
1705
+ // Checkpoint: where the executor is, on the record, so a driver
1706
+ // that inherits this run can attach where this one left off. The
1707
+ // executor calls it before applying each line; the line's own emit
1708
+ // saves the record, so this only updates the fields.
1709
+ checkpoint: (ref, consumed) => {
1710
+ step.sandbox = { kind: isolation, ref, consumed, since: step.sandbox?.since ?? new Date().toISOString() };
1711
+ if (consumed === 0)
1712
+ save();
1713
+ },
1533
1714
  });
1534
1715
  // A busy provider gets another moment on the SAME supply before the
1535
1716
  // fallback is considered: under a fan-out, twenty steps against one
@@ -1539,6 +1720,7 @@ stepBudgetUsd = null) {
1539
1720
  const firstArgs = isolatedArgs(platformModelEnv());
1540
1721
  await lease?.commit();
1541
1722
  let outcome = await runIsolated(firstArgs);
1723
+ step.sandbox = null; // whatever happens next starts its own
1542
1724
  for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
1543
1725
  const wait = backoffMs(n, lastRefusal);
1544
1726
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
@@ -1579,16 +1761,28 @@ stepBudgetUsd = null) {
1579
1761
  outcome = withEarlierTiming(outcome, first);
1580
1762
  }
1581
1763
  if (lease) {
1582
- for (const line of await lease.drainLog())
1583
- 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
+ }
1584
1770
  await lease.release();
1585
1771
  }
1586
- 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);
1587
1776
  step.status = outcome.status;
1588
- step.result = outcome.result;
1589
- step.conclusion = outcome.conclusion;
1777
+ step.result = redactText(outcome.result);
1778
+ step.conclusion = redactText(outcome.conclusion);
1779
+ step.sandbox = null;
1780
+ // The cluster's word on why a sandbox ended without the driver saying
1781
+ // so — OOMKilled, Evicted — on the record, where the retry reads it.
1782
+ if (outcome.status === "failed" && outcome.reason)
1783
+ push("error", `sandbox ended: ${outcome.reason}`);
1590
1784
  if (outcome.data !== undefined)
1591
- step.data = outcome.data;
1785
+ step.data = redactData(outcome.data);
1592
1786
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
1593
1787
  step.tokens = outcome.usage
1594
1788
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
@@ -1619,10 +1813,23 @@ stepBudgetUsd = null) {
1619
1813
  step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
1620
1814
  }
1621
1815
  else {
1622
- 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));
1623
1830
  // @file secrets become 0600 paths here (host run), cleaned up in the
1624
1831
  // finally — once, however many attempts follow.
1625
- const mat = materializeFileSecrets(agentDir, liveSecrets);
1832
+ const mat = materializeFileSecrets(agentDir, stepSecrets);
1626
1833
  fileDir = mat.dir;
1627
1834
  const attemptInProcess = (modelEnv) => executeStep({
1628
1835
  agentDir,
@@ -1644,13 +1851,20 @@ stepBudgetUsd = null) {
1644
1851
  ...mcpServers,
1645
1852
  },
1646
1853
  // Declared secrets reach the agent's scripts as env vars; the model
1647
- // only ever sees the variable names, not the values.
1648
- 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 },
1649
1859
  timeoutSec: step.timeout,
1650
1860
  budgetUsd: stepBudgetUsd,
1861
+ budgetNote: stepBudgetNote,
1651
1862
  price: tokenPrice,
1652
1863
  verify: step.verify,
1653
1864
  output: step.output,
1865
+ schema: resolvedSchema ?? undefined,
1866
+ maxTurns: step.maxTurns,
1867
+ stopRequested,
1654
1868
  // What the scripts saw, the verify sees: a flow can then check that
1655
1869
  // the proof a step left names THIS run, not one that came before —
1656
1870
  // copy-back never propagates a deletion, so a marker from an earlier
@@ -1660,23 +1874,33 @@ stepBudgetUsd = null) {
1660
1874
  ...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
1661
1875
  FOLDRUN_AGENT: step.agent,
1662
1876
  FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
1663
- ...liveSecrets,
1877
+ ...(testRun ? testModeEnv() : {}),
1878
+ ...stepSecrets,
1664
1879
  },
1665
1880
  emit: pushWatching,
1666
1881
  });
1667
1882
  // With a translator the step's endpoint is the translator: started
1668
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.
1669
1888
  const attemptThrough = async (modelEnv, spec) => {
1670
- if (!spec)
1671
- return attemptInProcess(modelEnv);
1672
- const t = await startTranslator(spec);
1889
+ const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
1890
+ const t = spec ? await startTranslator(spec) : null;
1673
1891
  try {
1674
- return await attemptInProcess({ ...modelEnv, ...t.env });
1892
+ return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
1675
1893
  }
1676
1894
  finally {
1677
- for (const line of t.drainLog())
1678
- push("info", line.startsWith("translator") ? line : `translator: ${line}`);
1679
- 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
+ }
1680
1904
  }
1681
1905
  };
1682
1906
  let outcome = await attemptThrough(providerEnv, translator);
@@ -1695,12 +1919,13 @@ stepBudgetUsd = null) {
1695
1919
  push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
1696
1920
  outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
1697
1921
  }
1698
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
1922
+ if (!testRun)
1923
+ publishPublicDir(tenant, path.basename(workspaceRoot), push);
1699
1924
  step.status = outcome.status;
1700
- step.result = outcome.result;
1701
- step.conclusion = outcome.conclusion;
1925
+ step.result = redactText(outcome.result);
1926
+ step.conclusion = redactText(outcome.conclusion);
1702
1927
  if (outcome.data !== undefined)
1703
- step.data = outcome.data;
1928
+ step.data = redactData(outcome.data);
1704
1929
  // A consult's spend belongs to the step that asked.
1705
1930
  const consultCost = consultTools.drainCost();
1706
1931
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
@@ -2006,12 +2231,35 @@ function csvItems(workspaceDir, step, take) {
2006
2231
  }
2007
2232
  return data.slice(0, take).map((r) => `${header}\n${r}`);
2008
2233
  }
2009
- 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 = {}) {
2010
2256
  const run = {
2011
2257
  id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
2012
2258
  flow: flowName,
2013
2259
  tags,
2014
2260
  status,
2261
+ ...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
2262
+ ...(opts.test ? { test: true } : {}),
2015
2263
  startedAt: new Date().toISOString(),
2016
2264
  finishedAt: null,
2017
2265
  steps: steps.map((s) => ({
@@ -2041,6 +2289,10 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
2041
2289
  preview: s.preview,
2042
2290
  delegate: s.delegate,
2043
2291
  output: s.output,
2292
+ schema: s.schema,
2293
+ schemaPath: s.schemaPath,
2294
+ parallel: s.parallel,
2295
+ maxTurns: s.maxTurns,
2044
2296
  attempts: 0,
2045
2297
  status: "pending",
2046
2298
  events: [],
@@ -2066,8 +2318,8 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
2066
2318
  }
2067
2319
  return run;
2068
2320
  }
2069
- export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride) {
2070
- 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);
2071
2323
  void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
2072
2324
  return run;
2073
2325
  }
@@ -2262,16 +2514,26 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2262
2514
  // never happened, which reconcile can then never see. Reset it to
2263
2515
  // pending and it simply runs again; driveRun's whole contract is that
2264
2516
  // re-driving a partly-finished record is safe.
2517
+ // Unless the step's sandbox outlived its driver AND the executor can
2518
+ // re-attach to it: then the record carries the pod and how far the
2519
+ // last driver got, runStep passes that as `resume`, and the step
2520
+ // carries on from where it was — a deploy no longer costs the run.
2265
2521
  let orphaned = false;
2266
2522
  for (const step of run.steps) {
2267
2523
  if (step.status !== "running")
2268
2524
  continue;
2269
- orphaned = true;
2525
+ const resumable = !!step.sandbox && platform.sandboxResumable(step.sandbox.kind);
2526
+ if (!resumable) {
2527
+ orphaned = true;
2528
+ step.sandbox = null;
2529
+ }
2270
2530
  step.status = "pending";
2271
2531
  step.events.push({
2272
2532
  t: new Date().toISOString(),
2273
2533
  type: "info",
2274
- text: "interrupted mid-step (platform restart) — running again from the start of the step",
2534
+ text: resumable
2535
+ ? `driver changed mid-step — re-attaching to the running sandbox (${step.sandbox.ref}, ${step.sandbox.consumed} lines already applied)`
2536
+ : "interrupted mid-step (platform restart) — running again from the start of the step",
2275
2537
  });
2276
2538
  }
2277
2539
  if (orphaned) {
@@ -2344,6 +2606,19 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2344
2606
  // is assembled) keeps the newest: when the total is over it, the
2345
2607
  // oldest groups are dropped whole before anything is cut mid-text.
2346
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
+ };
2347
2622
  // The data half of the same handoff: what the nearest earlier group
2348
2623
  // returned through `output: json`. Rebuilt from the record rather than
2349
2624
  // kept only in memory, so a resumed run hands step 3 the value step 1
@@ -2407,8 +2682,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2407
2682
  const group = ordered[gi];
2408
2683
  context = contextBefore(gi);
2409
2684
  ctxData = dataBefore(gi);
2410
- if (run.budgetUsd && runCost(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
2411
- 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);
2412
2690
  const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
2413
2691
  last?.events.push({
2414
2692
  t: new Date().toISOString(),
@@ -2494,6 +2772,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2494
2772
  t: new Date().toISOString(),
2495
2773
  type: "info",
2496
2774
  text: `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
2775
+ (step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
2497
2776
  (dropped ? ` (more items existed — capped at ${cap})` : ""),
2498
2777
  });
2499
2778
  const at = run.steps.indexOf(step);
@@ -2508,6 +2787,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2508
2787
  result: null,
2509
2788
  costUsd: null,
2510
2789
  attempts: 0,
2790
+ tries: undefined,
2511
2791
  }));
2512
2792
  run.steps.splice(at + 1, 0, ...instances);
2513
2793
  ordered = orderedGroups();
@@ -2520,12 +2800,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2520
2800
  // this is what keeps the *next* group from starting.
2521
2801
  const stopCheck = readRun(tenant, workspace, run.id);
2522
2802
  if (stopCheck?.stopRequested) {
2803
+ let skipped = 0;
2523
2804
  for (const s of run.steps) {
2524
2805
  if (s.status === "pending" || s.status === "running") {
2525
2806
  s.status = "skipped";
2526
2807
  s.skipReason = "run stopped";
2808
+ skipped += 1;
2527
2809
  }
2528
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
+ });
2529
2822
  run.status = "failed";
2530
2823
  run.stopRequested = true;
2531
2824
  save();
@@ -2560,39 +2853,51 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2560
2853
  break;
2561
2854
  }
2562
2855
  const ctx = context;
2563
- // `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.
2564
2860
  for (const step of freshGroup) {
2565
2861
  if (step.status !== "pending" || !step.when)
2566
2862
  continue;
2567
- 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);
2568
2867
  if (!met) {
2569
2868
  step.status = "skipped";
2570
2869
  step.skipReason = `condition not met: when "${step.when}"`;
2571
2870
  step.events.push({
2572
2871
  t: new Date().toISOString(),
2573
2872
  type: "info",
2574
- 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)`,
2575
2874
  });
2576
2875
  }
2577
2876
  }
2578
2877
  save();
2579
2878
  // `case:`/`else:` — exclusive routing. Of this group's case steps,
2580
- // the FIRST whose text appears in the previous results runs; the
2581
- // 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.
2582
2881
  // (`when:` above stays independent — every matching when runs —
2583
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.
2584
2889
  const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
2585
2890
  if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
2586
2891
  let matchedCase = null;
2587
2892
  for (const step of caseSteps) {
2588
- if (!matchedCase && (ctx ?? "").toLowerCase().includes(step.case.toLowerCase())) {
2893
+ if (!matchedCase && markerPresent(routing, step.case)) {
2589
2894
  matchedCase = step;
2590
2895
  continue;
2591
2896
  }
2592
2897
  step.status = "skipped";
2593
2898
  step.skipReason = matchedCase
2594
2899
  ? `routed past — "${matchedCase.case}" matched first`
2595
- : `case not matched: "${step.case}"`;
2900
+ : `case not matched: the previous group's result has no line beginning "${step.case}"`;
2596
2901
  step.events.push({
2597
2902
  t: new Date().toISOString(),
2598
2903
  type: "info",
@@ -2634,6 +2939,20 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2634
2939
  });
2635
2940
  }
2636
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;
2637
2956
  save();
2638
2957
  // The gate's declared preview, resolved now against what the run
2639
2958
  // has actually produced — a glob at park time, a list of files on
@@ -2706,18 +3025,88 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2706
3025
  // twenty steps over — the shares sum to what was left. Between
2707
3026
  // groups the check above still refuses to start the next one.
2708
3027
  const launching = freshGroup.filter((s) => s.status === "pending");
2709
- 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
+ };
2710
3051
  await Promise.all(launching
2711
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) {
2712
3064
  const attempts = (step.retry ?? 0) + 1;
2713
- for (let attempt = 1; attempt <= attempts; attempt++) {
3065
+ // A step re-attaching to its sandbox is still on the attempt
3066
+ // that started it; a step re-run after a backoff picks up the
3067
+ // count it left. Only a step nobody has touched starts at 1.
3068
+ const interruptedInBackoff = !step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
3069
+ const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts + 1) : 1;
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;
2714
3095
  step.attempts = attempt;
2715
3096
  step.status = "running";
2716
3097
  save();
2717
- 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
+ });
2718
3105
  // runStep mutates step.status; read it through a widened local
2719
3106
  // so TS doesn't keep the "running" narrowing from above.
2720
3107
  const outcome = step.status;
3108
+ recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
3109
+ save();
2721
3110
  if (outcome !== "failed" || attempt === attempts)
2722
3111
  break;
2723
3112
  // A stop destroys the step's sandbox, which reads here as a
@@ -2727,14 +3116,45 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2727
3116
  save();
2728
3117
  if (run.stopRequested)
2729
3118
  break;
3119
+ // The retry policy. An attempt the cluster ended for want of
3120
+ // memory or disk is retried one size class up — the same
3121
+ // size would end the same way. Every retry waits: 15 s, 30 s,
3122
+ // 1 m, 2 m, 4 m, jittered, so a provider or a site that was
3123
+ // struggling gets a moment, and twenty fan-out steps do not
3124
+ // hammer it in lock-step. The wait holds a worker slot, not
3125
+ // a sandbox — nothing is rented while it waits — and a stop
3126
+ // ends it. The step is `pending` meanwhile, so a driver that
3127
+ // inherits the run mid-wait simply runs the attempt.
3128
+ const last = [...step.events].reverse().find((e) => e.type === "error")?.text ?? "";
3129
+ let sizeNote = "";
3130
+ if (/OOMKilled|Evicted|ephemeral-storage|out of memory/i.test(last)) {
3131
+ const up = step.sizeUp === "large" || (!step.sizeUp && step.size === "large") ? "heavy" : step.sizeUp === "heavy" || step.size === "heavy" ? null : "large";
3132
+ if (up) {
3133
+ step.sizeUp = up;
3134
+ sizeNote = ` at size: ${up}`;
3135
+ }
3136
+ }
3137
+ const waitMs = Math.round(Math.min(retryBaseMs() * 16, retryBaseMs() * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5));
3138
+ step.status = "pending";
2730
3139
  step.events.push({
2731
3140
  t: new Date().toISOString(),
2732
3141
  type: "info",
2733
- text: `attempt ${attempt} failed — retrying (${attempts - attempt} left)`,
3142
+ text: `attempt ${attempt} failed — retrying in ${Math.round(waitMs / 1000)}s${sizeNote} (${attempts - attempt} left)`,
2734
3143
  });
2735
3144
  save();
3145
+ const until = Date.now() + waitMs;
3146
+ while (Date.now() < until) {
3147
+ await sleep(Math.min(1000, until - Date.now()));
3148
+ save();
3149
+ if (run.stopRequested)
3150
+ break;
3151
+ }
3152
+ if (run.stopRequested) {
3153
+ step.status = "failed";
3154
+ break;
3155
+ }
2736
3156
  }
2737
- }));
3157
+ }
2738
3158
  // on-fail: another agent takes the step over. Sequential and rare:
2739
3159
  // the rescue is a second full attempt at the step's job with the
2740
3160
  // failure in front of it, and its success is the step's success —
@@ -2786,7 +3206,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2786
3206
  rescue.attempts = 1;
2787
3207
  rescue.status = "running";
2788
3208
  save();
2789
- 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);
2790
3210
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
2791
3211
  const rescueOutcome = rescue.status;
2792
3212
  if (rescueOutcome === "completed") {
@@ -2919,16 +3339,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2919
3339
  }
2920
3340
  catch (err) {
2921
3341
  run.status = "failed";
3342
+ const text = err instanceof Error ? err.message : String(err);
3343
+ let told = false;
2922
3344
  run.steps.forEach((s) => {
2923
3345
  if (s.status === "running") {
2924
3346
  s.status = "failed";
2925
- s.events.push({
2926
- t: new Date().toISOString(),
2927
- type: "error",
2928
- text: err instanceof Error ? err.message : String(err),
2929
- });
3347
+ s.events.push({ t: new Date().toISOString(), type: "error", text });
3348
+ told = true;
2930
3349
  }
2931
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
+ }
2932
3361
  }
2933
3362
  finally {
2934
3363
  if (!parked) {
@@ -2939,6 +3368,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2939
3368
  // learn, and the runs list and the notification stop having to say
2940
3369
  // only "completed".
2941
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);
2942
3375
  // Archive before the next run resets outputs/ — a failed run's
2943
3376
  // artifacts are usually the most interesting ones, so this runs on
2944
3377
  // failure too.
@@ -2976,17 +3409,6 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2976
3409
  }
2977
3410
  })();
2978
3411
  }
2979
- function readFlow(tenant, workspace, flowName) {
2980
- const dir = path.join(workspaceDir(tenant, workspace), "flows");
2981
- if (!fs.existsSync(dir))
2982
- return null;
2983
- for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
2984
- const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
2985
- if (flow.name === flowName)
2986
- return flow;
2987
- }
2988
- return null;
2989
- }
2990
3412
  // Splice `[[flow:other]]` steps into the parent's step list, renumbering
2991
3413
  // groups so the nested flow's own parallelism survives. Depth-limited, and
2992
3414
  // a cycle raises rather than expanding forever.
@@ -3051,6 +3473,9 @@ export function loadFlow(tenant, workspace, flowName) {
3051
3473
  // once it has been quiet for two minutes — the margin is for a record
3052
3474
  // written a beat before its driver registered, not a judgement about pace.
3053
3475
  const ABANDONED_AFTER_MS = 2 * 60 * 1000;
3476
+ /** The first retry's wait; each one after doubles it, to sixteen times
3477
+ * this (15 s → 4 min by default). An env, so a test can make it small. */
3478
+ const retryBaseMs = () => Number(process.env.FOLDRUN_RETRY_BASE_MS) || 15_000;
3054
3479
  /** The most recent moment a run showed any sign of life. */
3055
3480
  function lastActivity(run) {
3056
3481
  let latest = Date.parse(run.startedAt);
@@ -3082,12 +3507,19 @@ function lastActivity(run) {
3082
3507
  * outlives any process. A run whose person already answered is not waiting,
3083
3508
  * and is picked back up — see below.
3084
3509
  */
3085
- export function reconcileRuns(tenant, now = Date.now()) {
3510
+ export function reconcileRuns(tenant, now = Date.now(), live) {
3086
3511
  const closed = [];
3087
3512
  for (const workspace of listWorkspaces(tenant)) {
3088
3513
  for (const summary of listRuns(tenant, workspace.name)) {
3089
3514
  if (drivingRuns.has(summary.id))
3090
3515
  continue;
3516
+ // Driven by another process — a second worker pod, on a platform with
3517
+ // more than one. The idle window protects a quiet step from THIS
3518
+ // process's judgement only; a worker that boots beside a live one
3519
+ // would otherwise close every long step its neighbour is driving.
3520
+ // The caller says which runs those are (a heartbeated claim).
3521
+ if (live?.has(summary.id))
3522
+ continue;
3091
3523
  if (now - lastActivity(summary) < ABANDONED_AFTER_MS)
3092
3524
  continue;
3093
3525
  // Approved, then abandoned. Approval only writes to the run record; the
@@ -3168,8 +3600,8 @@ export function reconcileRuns(tenant, now = Date.now()) {
3168
3600
  * one hard-coded account left every other account's interrupted runs marked
3169
3601
  * `running` forever, with no process left to finish them.
3170
3602
  */
3171
- export function reconcileAllRuns(now = Date.now()) {
3172
- return listTenants().flatMap((tenant) => reconcileRuns(tenant, now));
3603
+ export function reconcileAllRuns(now = Date.now(), live) {
3604
+ return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
3173
3605
  }
3174
3606
  /**
3175
3607
  * Wait for a run to stop being in progress.