@foldrun/core 2.4.0 → 2.6.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 (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
@@ -2,11 +2,14 @@
2
2
  // group receives every prior result, labeled per agent. Optional steps ("2?")
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
+ import { latestRevisionId } from "./history.js";
6
+ import { recordCallIo, redactionsFor } from "./capture.js";
7
+ import { AUTH_REFUSAL } from "./issues.js";
5
8
  import { hookNames } from "./hooks.js";
6
9
  import fs from "node:fs";
7
10
  import os from "node:os";
8
11
  import path from "node:path";
9
- import matter from "gray-matter";
12
+ import matter, { parseLenient, configMatter } from "./matter.js";
10
13
  import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
11
14
  import { cascadeLimits, toolOwners } from "./limits.js";
12
15
  import { resolveAgentPath } from "./confine.js";
@@ -14,7 +17,8 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js
14
17
  import { eventUrl } from "./webhook.js";
15
18
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
16
19
  import { hostSafeEnv, platformApiUrl } from "./host-env.js";
17
- import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
20
+ import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.js";
21
+ import { parseJudge } from "./judge-verdict.js";
18
22
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
19
23
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
20
24
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
@@ -29,15 +33,16 @@ const isolatedRun = () => {
29
33
  };
30
34
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
31
35
  import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
32
- import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.js";
36
+ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
33
37
  import { sendPlainNotification } from "./notify.js";
34
38
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
35
39
  import { refNames } from "./refs.js";
36
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
40
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isRescueRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
37
41
  import { canReadBack } from "./tool-output.js";
42
+ import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
38
43
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
39
44
  import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
40
- import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
45
+ import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
41
46
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
42
47
  import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
43
48
  import { attachOperations, prefetchOpenApi } from "./openapi.js";
@@ -155,6 +160,105 @@ export function slotPool(size) {
155
160
  },
156
161
  };
157
162
  }
163
+ /** Steps whose model has begun its first response — the step's executor
164
+ * says so the moment the stream starts (EventExtra.responding). Memory
165
+ * only: it is scheduling, not something the run record needs. */
166
+ const respondingSteps = new WeakSet();
167
+ export function markResponding(step) {
168
+ respondingSteps.add(step);
169
+ }
170
+ /**
171
+ * Is this step's prompt in the cache yet? An entry is readable from the
172
+ * moment the response to the request that wrote it begins streaming
173
+ * (Anthropic's prompt-caching docs), which is what `responding` marks.
174
+ * The first text, tool call or recorded model call says the same a little
175
+ * later, for an executor that cannot see the stream (the stub, an older
176
+ * driver). Resolves on the first of those, or when the step is over
177
+ * without one (a refusal before the first turn, a failed start).
178
+ */
179
+ export function untilFirstReply(step, done, pollMs = 250) {
180
+ const replied = () => respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
181
+ return new Promise((resolve) => {
182
+ let finished = false;
183
+ const stop = () => {
184
+ finished = true;
185
+ resolve();
186
+ };
187
+ done.then(stop, stop);
188
+ const tick = () => {
189
+ if (finished)
190
+ return;
191
+ if (replied())
192
+ return stop();
193
+ setTimeout(tick, pollMs).unref?.();
194
+ };
195
+ tick();
196
+ });
197
+ }
198
+ /**
199
+ * Steps of one agent launched together share a prompt prefix — tools and
200
+ * system prompt — and that is what the model's cache keeps. The cache is
201
+ * written by the first request that carries a prefix and readable only
202
+ * once that response has begun, so N copies started at once all write it
203
+ * and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
204
+ * lets the first instance of each agent go alone and holds its siblings
205
+ * until its first response has begun (or it ended); then they all start
206
+ * and read what it wrote — Claude Code holds a workflow's fan-out the same
207
+ * way. Not a fixed delay: a sandbox takes seconds to start before its
208
+ * first model call, so a timer from launch would release the siblings
209
+ * before there was anything to read. Keyed by agent and model, since a
210
+ * cache is per model.
211
+ */
212
+ export function warmGate(launching, pollMs = 250) {
213
+ const key = (s) => `${s.agent}\u0000${s.model ?? ""}`;
214
+ const counts = new Map();
215
+ for (const s of launching)
216
+ counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
217
+ const leads = new Map();
218
+ const isLead = new WeakSet();
219
+ return {
220
+ /** Claim the step's place — synchronously, before anything awaits, or
221
+ * the copies that reach their first await together would all find no
222
+ * lead and all go (a `parallel:` pool's acquire was exactly that).
223
+ * The first of its agent leads and gets null; a later one gets the
224
+ * lead's gate to await before it starts; a lone step gets null too. */
225
+ claim(step) {
226
+ const k = key(step);
227
+ if ((counts.get(k) ?? 0) < 2)
228
+ return null;
229
+ const lead = leads.get(k);
230
+ if (lead)
231
+ return lead.opened;
232
+ let open;
233
+ const opened = new Promise((r) => (open = r));
234
+ leads.set(k, { opened, open });
235
+ isLead.add(step);
236
+ return null;
237
+ },
238
+ /** A lead that will not run after all (the run was stopped while it
239
+ * waited for a pool slot) still opens its gate — or its siblings,
240
+ * which also wake to the stop, would wait on it forever. */
241
+ release(step) {
242
+ if (isLead.has(step))
243
+ leads.get(key(step)).open();
244
+ },
245
+ /** Run the step; a lead opens its agent's gate on its first reply (or
246
+ * its end), whatever else happens to it. */
247
+ async run(step, launch) {
248
+ if (!isLead.has(step))
249
+ return launch();
250
+ const { open } = leads.get(key(step));
251
+ const done = launch();
252
+ try {
253
+ await untilFirstReply(step, done, pollMs);
254
+ }
255
+ finally {
256
+ open();
257
+ }
258
+ await done;
259
+ },
260
+ };
261
+ }
158
262
  const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
159
263
  // gray-matter throws on invalid YAML. Skills authored for another client
160
264
  // sometimes ship YAML that that client's parser happened to accept — most
@@ -162,31 +266,10 @@ const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" &
162
266
  // The Agent Skills client guide recommends quoting such values and retrying
163
267
  // rather than dropping the skill, so a cross-client skill still loads here.
164
268
  function safeMatter(raw) {
165
- // gray-matter caches by input string AND caches a *failed* parse: the first
166
- // call on invalid YAML throws, but every later call on the same bytes returns
167
- // a hollow object with the fields silently missing. Clearing the cache makes
168
- // the throw deterministic, so the fallback below fires every time rather than
169
- // once — otherwise the same skill loads on one run and vanishes on the next.
170
- matter.clearCache();
171
- try {
172
- return matter(raw);
173
- }
174
- catch {
175
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
176
- if (fm) {
177
- const fixed = fm[1].replace(/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm, (_m, k, v) => `${k}: ${JSON.stringify(v)}`);
178
- try {
179
- // A function replacer, not a string: a fixed block containing `$&` or
180
- // `$$` (a value with those in it) would otherwise be treated as a
181
- // replacement pattern and splice the whole frontmatter into itself.
182
- return matter(raw.replace(fm[1], () => fixed));
183
- }
184
- catch {
185
- /* fall through */
186
- }
187
- }
188
- return { data: {} };
189
- }
269
+ // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
270
+ // parses every call, so the strict read throws every time and the lenient
271
+ // one (shared with check, which warns about it) fires every time.
272
+ return { data: parseLenient(raw).data };
190
273
  }
191
274
  // Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
192
275
  // <root>/<name>.md convenience form. `subdir` is the skills root under the
@@ -285,7 +368,7 @@ export function readAgentsMd(dir) {
285
368
  if (!fs.existsSync(file))
286
369
  continue;
287
370
  try {
288
- const parsed = matter(fs.readFileSync(file, "utf8"));
371
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
289
372
  return { data: parsed.data, body: withoutAgentRules(parsed.content).trim() };
290
373
  }
291
374
  catch {
@@ -351,6 +434,9 @@ export function workspaceRuntimePlan(tenant, workspace) {
351
434
  }
352
435
  return [...byFp.values()];
353
436
  }
437
+ /** The most tool content one step records (FOL-54 step 4): about 60 calls
438
+ * at the per-part cap; past it, metadata only. */
439
+ const STEP_CAPTURE_LIMIT = 4_000_000;
354
440
  function workspaceFrontmatter(agentDir, tenant) {
355
441
  return {
356
442
  ...(readAgentsMd(accountDir(tenant))?.data ?? {}),
@@ -650,8 +736,12 @@ grantsOnly = false) {
650
736
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
651
737
  // workspace a secret should resolve against is two levels up.
652
738
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
653
- const { data: front, content: body } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
654
- const parts = [resolveDocLinks(body.trim(), path.resolve(agentDir, "..", ".."))];
739
+ const { data: front, content: body } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
740
+ // The prompt's sections, by name and by how often they change. Added in
741
+ // the order they are computed; assembled stable first, so the model's
742
+ // cache keeps the agent's half across steps and runs (prompt-layers.ts).
743
+ const parts = new PromptLayers();
744
+ parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
655
745
  // Where the agent is standing. Every path below this line is relative to the
656
746
  // agent's own directory, and a run showed why that has to be said rather
657
747
  // than implied: told to read `../../knowledge/index.md`, the model asked for
@@ -672,12 +762,12 @@ grantsOnly = false) {
672
762
  const languageSet = language.from !== "default" && language.from !== "env";
673
763
  const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
674
764
  if (languageSet || regionSet) {
675
- parts.push(`# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
765
+ parts.add("locale", `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
676
766
  `Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
677
767
  `use these currency, units and calendar conventions in anything you produce. ` +
678
768
  `Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`);
679
769
  }
680
- parts.push(`# Where you are\n\n` +
770
+ parts.add("where", `# Where you are\n\n` +
681
771
  `Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
682
772
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
683
773
  `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
@@ -720,7 +810,7 @@ grantsOnly = false) {
720
810
  // background the rest of the prompt is read against, not an afterthought.
721
811
  const sharedContext = sharedInstructions(agentDir, tenant);
722
812
  if (sharedContext)
723
- parts.push(sharedContext);
813
+ parts.add("agents.md", sharedContext);
724
814
  // Skills follow the open Agent Skills format (agentskills.io): a folder
725
815
  // with SKILL.md, optionally bundling its own scripts/ and references/. The
726
816
  // flat `skills/<name>.md` form is also accepted for small skills.
@@ -754,7 +844,7 @@ grantsOnly = false) {
754
844
  `Full instructions: \`${s.path}\`` +
755
845
  (bundles.length ? ` (also bundles ${bundles.join(", ")})` : ""));
756
846
  });
757
- parts.push(`# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
847
+ parts.add("skills", `# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
758
848
  `read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
759
849
  (withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""));
760
850
  }
@@ -773,10 +863,11 @@ grantsOnly = false) {
773
863
  : "";
774
864
  const projectMem = workspaceMemoryIndex(tenant, workspace);
775
865
  const shared = libraryMemoryIndex(tenant);
776
- parts.push(`# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
866
+ // Dynamic: agents write memory, so the index moves between runs.
867
+ parts.add("memory", `# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
777
868
  (projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
778
869
  (shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
779
- howTo);
870
+ howTo, { dynamic: true });
780
871
  }
781
872
  // Knowledge — reference material the agent was given. Same three scopes and
782
873
  // the same nearest-wins reading order as memory, but never written back:
@@ -791,7 +882,7 @@ grantsOnly = false) {
791
882
  projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
792
883
  sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
793
884
  ].filter(Boolean);
794
- parts.push(`# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
885
+ parts.add("knowledge", `# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
795
886
  `on it rather than guessing at its contents. This is read-only: it is maintained by ` +
796
887
  `people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
797
888
  sections.join("\n\n"));
@@ -822,7 +913,7 @@ grantsOnly = false) {
822
913
  shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
823
914
  global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
824
915
  ].filter(Boolean);
825
- parts.push(`# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
916
+ parts.add("scripts", `# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
826
917
  `Read a script before running it if you are unsure what it does. Credentials your ` +
827
918
  `scripts need are already in the environment as the secrets listed below.`);
828
919
  }
@@ -837,7 +928,8 @@ grantsOnly = false) {
837
928
  // file to size it against, not before.
838
929
  const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
839
930
  if (state.length) {
840
- parts.push(`# State — what you carry between runs\n\n` +
931
+ // Dynamic: runs add and remove state files.
932
+ parts.add("state", `# State — what you carry between runs\n\n` +
841
933
  `What this workspace keeps across runs: a count, a cursor, where you got to. ` +
842
934
  `Read and write these directly, and update them before you finish so the next ` +
843
935
  `run starts where you left off.\n\n${state.join("\n")}\n\n` +
@@ -845,7 +937,7 @@ grantsOnly = false) {
845
937
  `A count reads as well in a sentence as in JSON, and a person edits these in the ` +
846
938
  `same editor as everything else — data formats are allowed here, not preferred.\n\n` +
847
939
  `This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
848
- `its provenance recorded — state is the bookmark, not the lesson.`);
940
+ `its provenance recorded — state is the bookmark, not the lesson.`, { dynamic: true });
849
941
  }
850
942
  // APIs — the agent's own definitions, plus workspace tools it granted by
851
943
  // name. Library tools are defined once and reused.
@@ -928,7 +1020,7 @@ grantsOnly = false) {
928
1020
  const keepIn = { dir: agentDir, readable: () => readsFiles };
929
1021
  const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
930
1022
  if (apiTools.promptLines.length) {
931
- parts.push(`# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1023
+ parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
932
1024
  }
933
1025
  // Secrets an agent declared become env vars for its scripts and bash.
934
1026
  //
@@ -1079,6 +1171,10 @@ grantsOnly = false) {
1079
1171
  name: spec.name,
1080
1172
  maxTokensParam: preset?.maxTokensParam,
1081
1173
  reasoningEffort: preset?.reasoningEffort,
1174
+ // The agent's prompts share an opening; the key says so to an
1175
+ // endpoint that routes its cache by one. Only where the preset
1176
+ // says the parameter is understood — elsewhere it is a 400.
1177
+ cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
1082
1178
  });
1083
1179
  // What the run is actually pointed at, in one line: the endpoint, the
1084
1180
  // tiers this gateway renames, and whether the credential landed.
@@ -1261,10 +1357,12 @@ grantsOnly = false) {
1261
1357
  FOLDRUN_WORKSPACE: workspace,
1262
1358
  ...clockEnv,
1263
1359
  }, libraryDir(tenant, "scripts"), runtime.interpreters, exec, keepIn.readable);
1264
- parts.push(`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1265
- `write or check — it is the workspace's calendar, not the container's clock.`);
1360
+ // Dynamic: the date moves daily, and would otherwise invalidate every
1361
+ // layer behind it once a day.
1362
+ parts.add("clock", `# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1363
+ `write or check — it is the workspace's calendar, not the container's clock.`, { dynamic: true });
1266
1364
  if (scriptTools.promptLines.length) {
1267
- parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1365
+ parts.add("script-tools", `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1268
1366
  `\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`);
1269
1367
  }
1270
1368
  // The web tool's own description lists every action, so an agent whose
@@ -1273,15 +1371,15 @@ grantsOnly = false) {
1273
1371
  // reddit-desk validator step on 6 Oct 2026. Say it before the first call.
1274
1372
  if (builtins.length) {
1275
1373
  const named = builtins.map((b) => b.split("="));
1276
- parts.push(`# Web search and fetch on this run\n\n` +
1374
+ parts.add("web", `# Web search and fetch on this run\n\n` +
1277
1375
  named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
1278
1376
  `\n\nThe web tool's other actions work as usual.`);
1279
1377
  }
1280
1378
  if (declared.length) {
1281
- parts.push(`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
1379
+ parts.add("secrets", `# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
1282
1380
  `Never print, echo, or write them to a file.`);
1283
1381
  }
1284
- parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1382
+ parts.add("rules", "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1285
1383
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1286
1384
  "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
1287
1385
  "directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
@@ -1298,7 +1396,7 @@ grantsOnly = false) {
1298
1396
  // run, and an agent that opened with "I'll start by cloning the repo" made
1299
1397
  // all three useless. Asking here rather than in each agent.md means every
1300
1398
  // agent obeys it, including the ones written before the summary existed.
1301
- parts.push("Open your reply with one sentence saying what you concluded or produced — the outcome, " +
1399
+ parts.add("rules", "Open your reply with one sentence saying what you concluded or produced — the outcome, " +
1302
1400
  "not your plan. Everything else follows it. That sentence is what a person sees in the " +
1303
1401
  "run list and in the notification, without opening the run.");
1304
1402
  // One list. `tools:` grants anything: a built-in group, an exact SDK tool
@@ -1340,6 +1438,7 @@ grantsOnly = false) {
1340
1438
  let wantHistory = false;
1341
1439
  let wantDesks = false;
1342
1440
  let wantAsk = false;
1441
+ let wantPeers = false;
1343
1442
  for (const ref of toolRefs(front)) {
1344
1443
  const toolName = ref.name;
1345
1444
  if (ref.mode === "ask") {
@@ -1374,7 +1473,7 @@ grantsOnly = false) {
1374
1473
  if (available[toolName])
1375
1474
  shadowed.push(toolName);
1376
1475
  }
1377
- else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
1476
+ else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
1378
1477
  // The platform's own groups, served in-process — built below, once
1379
1478
  // the roots and the records are known.
1380
1479
  if (toolName === "search")
@@ -1383,6 +1482,8 @@ grantsOnly = false) {
1383
1482
  wantHistory = true;
1384
1483
  else if (toolName === "ask")
1385
1484
  wantAsk = true;
1485
+ else if (toolName === "peers")
1486
+ wantPeers = true;
1386
1487
  else
1387
1488
  wantDesks = true;
1388
1489
  if (available[toolName])
@@ -1444,10 +1545,17 @@ grantsOnly = false) {
1444
1545
  const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
1445
1546
  if (ask) {
1446
1547
  allowed.push(ASK_TOOL);
1447
- parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1548
+ parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1448
1549
  }
1550
+ // tools: [peers] — peers_list and peers_message, to the other steps of
1551
+ // this step's parallel group. Who they are is known only when the group
1552
+ // launches, so the grant is here and the roster, the server and the
1553
+ // prompt's line are the step's (runStep).
1554
+ const peers = wantPeers;
1555
+ if (peers)
1556
+ allowed.push(...PEER_TOOLS);
1449
1557
  if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
1450
- parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1558
+ parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1451
1559
  }
1452
1560
  // An MCP server declares its tools when it connects, so they can't be listed
1453
1561
  // ahead of time. Server-level specs cover them: granting the server grants
@@ -1469,13 +1577,16 @@ grantsOnly = false) {
1469
1577
  denied.push(INSTALL_TOOL);
1470
1578
  const finalAllowed = allowed.filter((t) => !denied.includes(t));
1471
1579
  readsFiles = canReadBack(finalAllowed);
1580
+ const prompt = parts.assemble();
1472
1581
  return {
1473
1582
  front,
1474
1583
  clockEnv,
1475
1584
  /** The zone, where it came from, and anything unreadable on the way —
1476
1585
  * reported by the step, not here, so it lands in the run's trace. */
1477
1586
  clock,
1478
- systemPrompt: parts.join("\n\n"),
1587
+ systemPrompt: prompt.text,
1588
+ /** What the record keeps of each layer of that prompt. */
1589
+ promptLayers: prompt.layers,
1479
1590
  allowed: finalAllowed,
1480
1591
  disabled,
1481
1592
  unknownTools,
@@ -1499,6 +1610,7 @@ grantsOnly = false) {
1499
1610
  historyTools,
1500
1611
  deskTools,
1501
1612
  ask,
1613
+ peers,
1502
1614
  // `spec` is the MERGED declaration — the agent's own block or its
1503
1615
  // workspace's, plus every granted tool's. The isolated path builds its
1504
1616
  // runtime from this; passing the agent's frontmatter alone silently drops
@@ -1730,7 +1842,13 @@ flowTimezone,
1730
1842
  /** The step's index in run.steps, carried on its egress lease so a
1731
1843
  * person's message to this step reaches this step's own inbox and not a
1732
1844
  * sibling's running in the same group. Undefined outside a flow. */
1733
- stepIndex) {
1845
+ stepIndex,
1846
+ /** The other steps launched with this one (its parallel group), for
1847
+ * tools: [peers]. Empty when it runs alone. */
1848
+ siblings = [],
1849
+ /** The in-process colleagues' bus for this group (localPeerBus), when
1850
+ * the step runs in this process rather than a pod. */
1851
+ peerBus) {
1734
1852
  // Secret values are injected into scripts as environment variables and
1735
1853
  // substituted into API headers, so a model that reads one back — from a
1736
1854
  // script that prints it, an API error that echoes it, or its own `env` —
@@ -1776,9 +1894,65 @@ stepIndex) {
1776
1894
  return value;
1777
1895
  }
1778
1896
  };
1897
+ // What this workspace records of each step (FOL-54 step 4): `capture:` in
1898
+ // AGENTS.md — every model call always; each tool call's input and output
1899
+ // unless it says `metadata`.
1900
+ const capture = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
1901
+ // `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
1902
+ // grades with — field by field, the workspace's else the account's.
1903
+ const { judgeModel, judgeEffort } = (() => {
1904
+ const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
1905
+ const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
1906
+ const model = ws.model ?? account.model;
1907
+ const effort = resolveEffort(ws.effort ?? account.effort ?? "");
1908
+ return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
1909
+ })();
1910
+ // A step that pours out content stops being recorded past this — its
1911
+ // calls keep their names, sizes and times (FOL-54 step 4).
1912
+ let capturedChars = 0;
1913
+ let captureFull = false;
1779
1914
  const push = (type, text, extra) => {
1780
1915
  const t = new Date().toISOString();
1781
- step.events.push({ t, type, text: redact(text), ...extra });
1916
+ // The model has begun its first response: held only in memory, for
1917
+ // the fan-out gate (warmGate). Not a line in the trace.
1918
+ if (extra?.responding) {
1919
+ markResponding(step);
1920
+ return;
1921
+ }
1922
+ // A model call is a span on the step, not a line in its trace.
1923
+ // Numbered here, across attempts: a retry's calls follow the first
1924
+ // attempt's, and an id-less turn of one attempt never overwrites
1925
+ // another's. No save of its own — a turn's blocks arrive as several
1926
+ // messages, and the next event saves them all.
1927
+ if (extra?.turn) {
1928
+ const calls = (step.calls ??= []);
1929
+ // The loop counts attempts from 1; a call of the first carries none.
1930
+ const attempt = Math.max(1, step.attempts ?? 1);
1931
+ const at = calls.findIndex((c) => c.id === extra.turn.id && (c.attempt ?? 1) === attempt);
1932
+ const call = { ...extra.turn, n: at >= 0 ? calls[at].n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
1933
+ if (at >= 0)
1934
+ calls[at] = call;
1935
+ else
1936
+ calls.push(call);
1937
+ return;
1938
+ }
1939
+ // A tool call's content goes beside the run, masked (capture.ts), and the
1940
+ // event keeps only that it was recorded.
1941
+ let rest = extra;
1942
+ if (extra?.io) {
1943
+ const { io, ...others } = extra;
1944
+ rest = others;
1945
+ const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
1946
+ if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
1947
+ captureFull = true;
1948
+ step.events.push({ t, type: "info", text: `capture: this step recorded ${Math.round(capturedChars / 1000)}k characters of tool content — the rest of its calls keep only their names, sizes and times` });
1949
+ }
1950
+ if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
1951
+ capturedChars += size;
1952
+ rest = { ...others, captured: true };
1953
+ }
1954
+ }
1955
+ step.events.push({ t, type, text: redact(text), ...rest });
1782
1956
  // A person in the loop: the question, its answer, a message delivered —
1783
1957
  // onto the step, so the run page, the API and history show them.
1784
1958
  if (extra?.operator) {
@@ -1810,7 +1984,27 @@ stepIndex) {
1810
1984
  step.status = "completed";
1811
1985
  // A stub answer may open with `cost: 0.25` — a pretend price, so the
1812
1986
  // parts of orchestration that watch money (budget:) are testable too.
1813
- const answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
1987
+ let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
1988
+ // `call: <tool> <input> => <output>` lines: a pretend tool call, sent
1989
+ // through the same push a real step's events take — with its model call
1990
+ // and its content — so what the runner records of a step is testable.
1991
+ answer = answer
1992
+ .split("\n")
1993
+ .filter((line) => {
1994
+ const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
1995
+ if (!m)
1996
+ return true;
1997
+ const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
1998
+ // Twice under one id, as the SDK sends a turn's blocks: one row.
1999
+ const turn = { id: `msg_stub${(step.calls?.length ?? 0) + 1}`, n: 0, at: new Date().toISOString(), ms: 5, model: "stub", tokens: { input: 100, output: 10, cacheRead: 80, cacheWrite: 0 }, tools: 0 };
2000
+ push("info", "", { turn });
2001
+ push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
2002
+ push("tool", m[1], { call: id, ...(capture ? { io: { input: m[2], inputChars: m[2].length } } : {}) });
2003
+ push("tool", m[1], { call: id, ms: 3, io: { output: m[3], outputChars: m[3].length } });
2004
+ return false;
2005
+ })
2006
+ .join("\n")
2007
+ .trim();
1814
2008
  const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
1815
2009
  const body = (priced ? answer.slice(priced[0].length) : answer).trim();
1816
2010
  // A `===` line inside an answer separates earlier turns from the final
@@ -1849,12 +2043,15 @@ stepIndex) {
1849
2043
  // step: the generic HTTP tool still works without the typed ones.
1850
2044
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
1851
2045
  push("error", `openapi: ${w}`);
1852
- const { front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
2046
+ const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
1853
2047
  disposeRuntime = runtime.dispose;
1854
2048
  canActOutward = outward;
1855
2049
  // Sent again on every turn; with turns and the tools' result sizes, what
1856
2050
  // a token report reads to say where a step's input went.
1857
2051
  step.systemPromptChars = systemPrompt.length;
2052
+ // And each layer's size and hash, so a later reader can say which part
2053
+ // of the prompt changed between this step and the agent's last.
2054
+ step.promptLayers = promptLayers;
1858
2055
  // A retry that moved the step up a class (an evicted or OOM-killed
1859
2056
  // attempt) wins over the agent's own `size:` for the attempts after.
1860
2057
  const size = step.sizeUp ?? agentSize;
@@ -1872,11 +2069,7 @@ stepIndex) {
1872
2069
  // Populate before the first push: everything after this point may quote a
1873
2070
  // credential. Short values are skipped — a two-character secret would
1874
2071
  // redact half the English language, and anything that short is not one.
1875
- redactions = Object.entries({ ...liveSecrets, ...providerSecrets, ...providerEnv })
1876
- .filter(([, value]) => typeof value === "string" && value.length >= 8)
1877
- .map(([name, value]) => [value, name])
1878
- // Longest first, so a value containing another is replaced whole.
1879
- .sort((a, b) => b[0].length - a[0].length);
2072
+ redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
1880
2073
  // One line for the clock: which zone this step works to, and which level
1881
2074
  // set it. "Why is this dated yesterday" should be answerable from the run.
1882
2075
  push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
@@ -2115,7 +2308,14 @@ stepIndex) {
2115
2308
  ...mcpNames,
2116
2309
  ...(consults.length ? ["foldrun_agents"] : []),
2117
2310
  ...(ask ? ["foldrun_ask"] : []),
2311
+ ...(peers ? ["foldrun_peers"] : []),
2118
2312
  ];
2313
+ if (peers) {
2314
+ // The colleagues' contract, in the prompt the step reads: who runs
2315
+ // beside it this time (none, when it runs alone), and that a message
2316
+ // is a request, never a hand-over.
2317
+ prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
2318
+ }
2119
2319
  // Colleagues this agent may DELEGATE to: each with its own context and
2120
2320
  // its own tools — resolved exactly as its own step's would be, then cut
2121
2321
  // to what THIS step holds (subagents.ts). Built host-side, like consults.
@@ -2286,7 +2486,21 @@ stepIndex) {
2286
2486
  ],
2287
2487
  });
2288
2488
  grant.network = { level: network.level, hosts: network.hosts };
2289
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
2489
+ // network_enforce: true at the account or the workspace asks the proxy
2490
+ // to refuse; the proxy adds the install's own switch and has the last
2491
+ // word (egress-proxy.ts), so false here never loosens anything.
2492
+ grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
2493
+ const lease = await platform.egress.lease({
2494
+ tenant,
2495
+ runId: runId ?? "adhoc",
2496
+ grant,
2497
+ test: testRun,
2498
+ ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2499
+ // tools: [peers] — the roster the proxy serves, and the inboxes a
2500
+ // colleague's message may be addressed to. Only when granted: a
2501
+ // step without the tools has no reason to know its siblings.
2502
+ ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2503
+ });
2290
2504
  // The forward door: every program in the sandbox sends through the
2291
2505
  // proxy, which holds the site list and fills bound secrets. A proxy
2292
2506
  // without the door (an older platform) leaves programs going direct.
@@ -2357,6 +2571,18 @@ stepIndex) {
2357
2571
  // carrying TEST_MODE_WITHHELD would only fail where it should have
2358
2572
  // been sunk or refused.
2359
2573
  const apiValues = testRun && lease ? placeholders : sandboxSecrets;
2574
+ // A declared secret named like the step's own model variables
2575
+ // (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
2576
+ // and broke, or redirected, the step's model calls (FOL-19). It crosses
2577
+ // as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
2578
+ // the driver gives the agent's API and script tools the value under its
2579
+ // own name (run-container.ts) — never the model's key in its place.
2580
+ const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
2581
+ if (modelNamed.length) {
2582
+ push("info", `secrets: ${modelNamed.join(", ")} kept away from the model's own settings — ${modelNamed.length === 1 ? "that name is" : "those names are"} what the step's model call uses; ` +
2583
+ "its script and API tools and verify: get the declared value, its own shell the model's. Give the secret another name, or run the model on it with provider:");
2584
+ }
2585
+ const sandboxEnvSecrets = Object.fromEntries(Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]));
2360
2586
  const substitutedApis = apiSpecs.map((api) => ({
2361
2587
  ...api,
2362
2588
  headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
@@ -2410,15 +2636,19 @@ stepIndex) {
2410
2636
  // field. A tool's own `runtime:` is part of what this step needs.
2411
2637
  runtime: runtime.spec,
2412
2638
  consults,
2639
+ ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2413
2640
  ...(subagents.length ? { subagents } : {}),
2414
2641
  timeoutSec: step.timeout,
2415
2642
  budgetUsd: stepBudgetUsd,
2416
2643
  budgetNote: stepBudgetNote,
2417
2644
  price: tokenPrice,
2418
2645
  verify: step.verify,
2646
+ ...(judgeModel ? { judgeModel } : {}),
2647
+ ...(judgeEffort ? { judgeEffort } : {}),
2419
2648
  output: step.output,
2420
2649
  schema: resolvedSchema ?? undefined,
2421
2650
  maxTurns: step.maxTurns,
2651
+ capture,
2422
2652
  ...limitOpts,
2423
2653
  ...hookOpts,
2424
2654
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
@@ -2464,11 +2694,14 @@ stepIndex) {
2464
2694
  FOLDRUN_AGENT: step.agent,
2465
2695
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
2466
2696
  ...(testRun ? testModeEnv() : {}),
2467
- ...sandboxSecrets,
2697
+ ...sandboxEnvSecrets,
2468
2698
  }).filter((entry) => typeof entry[1] === "string")),
2469
2699
  emit: pushWatching,
2470
2700
  // Stamps the sandbox, so stopping this run can destroy it.
2471
2701
  runId,
2702
+ // A step still waiting for room has no sandbox to destroy; the
2703
+ // executor asks this before it creates one (pod-room.ts).
2704
+ ...(stopRequested ? { stopRequested } : {}),
2472
2705
  size,
2473
2706
  // The pod's network, for the executor's own wall (the k8s tier's
2474
2707
  // network policy): a filtered pod reaches the proxy and DNS only.
@@ -2742,6 +2975,13 @@ stepIndex) {
2742
2975
  // On this machine nobody can write in and there is no proxy: the
2743
2976
  // question goes to the terminal when there is one, else unanswered.
2744
2977
  const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
2978
+ // Colleagues in the same group reach each other over the run's own
2979
+ // bus here — no proxy, so no person can write in, but a sibling can.
2980
+ // Every grouped step gets the inbox (a pod reads the proxy's inbox
2981
+ // whatever its tools); only tools: [peers] gets the tools to send.
2982
+ const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
2983
+ const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
2984
+ const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
2745
2985
  // @file secrets become 0600 paths here (host run), cleaned up in the
2746
2986
  // finally — once, however many attempts follow.
2747
2987
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -2765,6 +3005,9 @@ stepIndex) {
2765
3005
  workspaceRoot,
2766
3006
  libraryRoot: libraryDir(tenant),
2767
3007
  ...(subagents.length ? { subagents } : {}),
3008
+ // The colleagues' inbox, read after every tool call — the same hook
3009
+ // a pod uses for a person's messages.
3010
+ ...(localInbox ? { inbox: localInbox } : {}),
2768
3011
  prompt,
2769
3012
  model,
2770
3013
  effort,
@@ -2779,6 +3022,7 @@ stepIndex) {
2779
3022
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2780
3023
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2781
3024
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3025
+ ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
2782
3026
  ...(installTools ? { foldrun_install: installTools.server } : {}),
2783
3027
  ...mcpServers,
2784
3028
  },
@@ -2793,9 +3037,12 @@ stepIndex) {
2793
3037
  budgetNote: stepBudgetNote,
2794
3038
  price: tokenPrice,
2795
3039
  verify: step.verify,
3040
+ ...(judgeModel ? { judgeModel } : {}),
3041
+ ...(judgeEffort ? { judgeEffort } : {}),
2796
3042
  output: step.output,
2797
3043
  schema: resolvedSchema ?? undefined,
2798
3044
  maxTurns: step.maxTurns,
3045
+ capture,
2799
3046
  ...limitOpts,
2800
3047
  ...hookOpts,
2801
3048
  stopRequested,
@@ -2999,7 +3246,7 @@ export function copyTreeBytes(from, to) {
2999
3246
  function openApiSources(agentDir, tenant) {
3000
3247
  const out = new Set();
3001
3248
  try {
3002
- const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3249
+ const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3003
3250
  const front = data;
3004
3251
  for (const api of parseApis(front.apis))
3005
3252
  if (api.openapi?.startsWith("https://"))
@@ -3065,7 +3312,7 @@ const CREDENTIAL_POLL_MS = 2_000;
3065
3312
  * step started one second before the 09:07:52Z refresh) matched none of the
3066
3313
  * older two, so the step failed instead of waiting for the new token. */
3067
3314
  export function isAuthRefusal(text) {
3068
- return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
3315
+ return AUTH_REFUSAL.test(text);
3069
3316
  }
3070
3317
  /**
3071
3318
  * Wait, briefly, for the mounted credential to become one we have not
@@ -3591,10 +3838,31 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3591
3838
  *
3592
3839
  * The flag is one-way: nothing here clears it, so the read-modify-write can
3593
3840
  * only ever turn a stop on, never off.
3841
+ *
3842
+ * And once it is on, this copy may not undo what stopRun wrote: the run
3843
+ * stays failed, and steps that had not started stay skipped. Before
3844
+ * 21 Oct 2026 only the flag came across, so the next save put the record
3845
+ * back to `running` with its steps pending; a second stop then answered
3846
+ * "failed" instead of "already failed", and the group kept starting pods
3847
+ * (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
3848
+ * as it is: its sandbox is gone, it ends on its own, and records how.
3594
3849
  */
3595
3850
  const save = () => {
3596
- if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
3851
+ const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
3852
+ if (disk?.stopRequested)
3597
3853
  run.stopRequested = true;
3854
+ if (run.stopRequested) {
3855
+ if (run.status !== "completed" && run.status !== "failed") {
3856
+ run.status = "failed";
3857
+ run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
3858
+ }
3859
+ run.parkedAt = null;
3860
+ for (const s of run.steps) {
3861
+ if (s.status === "pending" || s.status === "awaiting-approval") {
3862
+ s.status = "skipped";
3863
+ s.skipReason ??= "run stopped";
3864
+ }
3865
+ }
3598
3866
  }
3599
3867
  writeRun(tenant, workspace, run);
3600
3868
  };
@@ -3649,6 +3917,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3649
3917
  try {
3650
3918
  // Entering the loop is what makes a run live, whatever state it was
3651
3919
  // saved in: `queued` from the queue, `awaiting-approval` from a park.
3920
+ // The first pick-up (FOL-54 step 4): startedAt to this is the time it
3921
+ // queued; and the files it runs are the workspace's latest revision
3922
+ // as it starts — a deploy's revision is its commit, and an edit through
3923
+ // /source makes one too. A re-run comes through here as well.
3924
+ run.drivenAt ??= new Date().toISOString();
3925
+ if (run.revision === undefined)
3926
+ run.revision = latestRevisionId(tenant, workspace);
3652
3927
  if (run.status !== "running") {
3653
3928
  run.status = "running";
3654
3929
  run.parkedAt = null;
@@ -3808,7 +4083,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3808
4083
  return groupData[i];
3809
4084
  return undefined;
3810
4085
  };
3811
- const dataOf = (group) => {
4086
+ // A rescue's result already stands on the step it took over, so the
4087
+ // three handoffs below read the flow's steps only: counting the rescue
4088
+ // too handed the next group the same output twice, or a stale one
4089
+ // from an evaluator loop's earlier cycle.
4090
+ const flowStepsOf = (group) => group.filter((s) => !isRescueRecord(s));
4091
+ const dataOf = (all) => {
4092
+ const group = flowStepsOf(all);
3812
4093
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
3813
4094
  if (carrying.length === 0)
3814
4095
  return undefined;
@@ -3816,7 +4097,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3816
4097
  return carrying[0].data;
3817
4098
  return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
3818
4099
  };
3819
- const resultsOf = (group) => {
4100
+ const resultsOf = (all) => {
4101
+ const group = flowStepsOf(all);
3820
4102
  const results = group
3821
4103
  .filter((s) => s.status === "completed" && s.result)
3822
4104
  .map((s) => s.item
@@ -3828,8 +4110,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3828
4110
  };
3829
4111
  // What each step concluded — its final turn — for the handoffs that
3830
4112
  // should read a conclusion rather than a transcript (`each: lines`).
3831
- const conclusionsOf = (group) => {
3832
- const out = group
4113
+ const conclusionsOf = (all) => {
4114
+ const out = flowStepsOf(all)
3833
4115
  .filter((s) => s.status === "completed" && (s.conclusion || s.result))
3834
4116
  .map((s) => s.conclusion || s.result);
3835
4117
  return out.length ? out.join("\n") : null;
@@ -4204,7 +4486,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4204
4486
  save();
4205
4487
  if (freshGroup.every((s) => s.status !== "pending")) {
4206
4488
  // everything in this group was rejected
4207
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4489
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4208
4490
  run.status = "failed";
4209
4491
  break;
4210
4492
  }
@@ -4217,7 +4499,17 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4217
4499
  // itself mid-turn at its share, so a fan-out of twenty cannot end
4218
4500
  // twenty steps over — the shares sum to what was left. Between
4219
4501
  // groups the check above still refuses to start the next one.
4220
- const launching = freshGroup.filter((s) => s.status === "pending");
4502
+ // A rescue record is the on-fail of a step in this group, not a step
4503
+ // of the flow: one left pending by a drive that died is picked up by
4504
+ // rescueStep below, never launched alongside the group (21 Oct 2026:
4505
+ // a resumed run launched 77 leftover fixers at once).
4506
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4507
+ // tools: [peers] — what each step of the group is told about the
4508
+ // others, and the bus they share when they run in this process.
4509
+ const peerBus = localPeerBus();
4510
+ const siblingsOf = (step) => launching
4511
+ .filter((s) => s !== step)
4512
+ .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4221
4513
  // An agent's own `budget:` — the most it may spend in one run — is
4222
4514
  // read at launch, so a cap raised mid-run applies to the next step.
4223
4515
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4249,16 +4541,49 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4249
4541
  pools.set(key, (pool = slotPool(step.parallel)));
4250
4542
  return pool;
4251
4543
  };
4544
+ // One instance of each agent goes first so its siblings read the
4545
+ // prompt cache it wrote — see warmGate. The claim is the callback's
4546
+ // first statement, before any await; the wait comes before the
4547
+ // pool, so nothing holds a slot while it waits.
4548
+ const gate = warmGate(launching);
4252
4549
  await Promise.all(launching
4253
4550
  .map(async (step) => {
4551
+ const lead = gate.claim(step);
4552
+ if (lead) {
4553
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `prompt cache: waiting for the first ${step.agent} step's reply before starting, so this one reads its prefix` });
4554
+ save();
4555
+ await lead;
4556
+ }
4254
4557
  const pool = poolFor(step);
4255
4558
  if (pool)
4256
4559
  await pool.acquire();
4257
4560
  try {
4258
- await launchStep(step);
4561
+ // A stop that arrived while this copy waited — for the
4562
+ // lead's reply or for a slot — must not start it: the
4563
+ // between-groups check runs only once this group is over,
4564
+ // and save() is what carries the flag in from the record.
4565
+ {
4566
+ save();
4567
+ if (run.stopRequested) {
4568
+ step.status = "skipped";
4569
+ step.skipReason = "run stopped";
4570
+ step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
4571
+ save();
4572
+ gate.release(step);
4573
+ return;
4574
+ }
4575
+ }
4576
+ await gate.run(step, () => launchStep(step));
4259
4577
  }
4260
4578
  finally {
4261
4579
  pool?.release();
4580
+ // Every attempt is over: off its siblings' rosters, so a
4581
+ // message to it is not reported delivered and never read.
4582
+ // Here, not per attempt — a step between retries is still
4583
+ // one its colleagues can reach.
4584
+ const index = run.steps.indexOf(step);
4585
+ peerBus.leave(index);
4586
+ await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
4262
4587
  }
4263
4588
  }));
4264
4589
  async function launchStep(step) {
@@ -4304,7 +4629,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4304
4629
  () => {
4305
4630
  save();
4306
4631
  return run.stopRequested === true;
4307
- }, flowTimezone, run.steps.indexOf(step)).finally(() => reserved.delete(step));
4632
+ }, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus).finally(() => reserved.delete(step));
4308
4633
  // runStep mutates step.status; read it through a widened local
4309
4634
  // so TS doesn't keep the "running" narrowing from above.
4310
4635
  const outcome = step.status;
@@ -4400,6 +4725,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4400
4725
  // runs out of cycles below, which used to fail the run without ever
4401
4726
  // reaching the step's on-fail (medium-desk's claim-trimmer was
4402
4727
  // written for exactly that case and could never run).
4728
+ // A rescue hears a stop the way a group step does: its model loop and
4729
+ // its executor's wait for room both poll this (no sandbox exists yet
4730
+ // for a stop to destroy while it waits).
4731
+ const stopPoll = () => {
4732
+ save();
4733
+ return run.stopRequested === true;
4734
+ };
4403
4735
  const rescueStep = async (step) => {
4404
4736
  if (!step.onFail)
4405
4737
  return;
@@ -4412,6 +4744,38 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4412
4744
  });
4413
4745
  return;
4414
4746
  }
4747
+ // One rescue per failure, across drives. A worker that died
4748
+ // mid-rescue left an unfinished record behind it; the drive that
4749
+ // resumes picks that one up instead of splicing another — a crash
4750
+ // loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
4751
+ // ~80). The leftover carries the same instruction, built from the
4752
+ // same failure, and a running one may still hold a sandbox to
4753
+ // re-attach to, so it is THE rescue; any further unfinished copies
4754
+ // are superseded. A FINISHED rescue is history, never reused: it
4755
+ // answered an earlier failure — an evaluator loop's previous cycle
4756
+ // — not this one.
4757
+ const leftovers = [];
4758
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
4759
+ const r = run.steps[j];
4760
+ if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
4761
+ leftovers.push(r);
4762
+ }
4763
+ }
4764
+ const resumed = leftovers[0];
4765
+ for (const r of leftovers.slice(1)) {
4766
+ r.status = "skipped";
4767
+ r.skipReason = "superseded on resume — one rescue per failure";
4768
+ }
4769
+ if (resumed) {
4770
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
4771
+ resumed.rescueOf ??= step.agent;
4772
+ resumed.attempts = Math.max(1, resumed.attempts ?? 0);
4773
+ resumed.status = "running";
4774
+ save();
4775
+ await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
4776
+ settleRescue(step, resumed);
4777
+ return;
4778
+ }
4415
4779
  step.events.push({
4416
4780
  t: new Date().toISOString(),
4417
4781
  type: "info",
@@ -4428,6 +4792,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4428
4792
  const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4429
4793
  const rescue = {
4430
4794
  agent: step.onFail,
4795
+ rescueOf: step.agent,
4431
4796
  instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4432
4797
  `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
4433
4798
  ...(step.ask ? { ask: step.ask } : {}),
@@ -4457,7 +4822,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4457
4822
  rescue.attempts = 1;
4458
4823
  rescue.status = "running";
4459
4824
  save();
4460
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
4825
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
4826
+ settleRescue(step, rescue);
4827
+ };
4828
+ // What a rescue's end means for the step it took over.
4829
+ const settleRescue = (step, rescue) => {
4461
4830
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
4462
4831
  const rescueOutcome = rescue.status;
4463
4832
  if (rescueOutcome === "completed") {
@@ -4492,7 +4861,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4492
4861
  // here: the person chose to start after it. Counting it stopped
4493
4862
  // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
4494
4863
  // a second, before the one step it was asked to run.
4495
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
4864
+ // A rescue record's own failure is already on the step it took over
4865
+ // ("also failed"); counted here it would fail a group whose step a
4866
+ // later rescue completed.
4867
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
4496
4868
  if (requiredFailed) {
4497
4869
  run.status = "failed";
4498
4870
  break;
@@ -4568,6 +4940,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4568
4940
  for (const s of [...ordered[gi - 1], ...freshGroup]) {
4569
4941
  if (s.status === "skipped" && s.each)
4570
4942
  continue; // an expanded template stays expanded
4943
+ // A rescue answered its own failure and stays as it ended: reset
4944
+ // to pending it read as an interrupted rescue, and the next
4945
+ // cycle's failure was handed the old one's instructions.
4946
+ if (isRescueRecord(s))
4947
+ continue;
4571
4948
  s.status = "pending";
4572
4949
  s.result = null;
4573
4950
  }
@@ -4767,26 +5144,7 @@ function lastActivity(run) {
4767
5144
  }
4768
5145
  return latest;
4769
5146
  }
4770
- /**
4771
- * Close out runs abandoned by a process that is no longer here.
4772
- *
4773
- * Runs execute in the server process, and nothing writes a terminal status
4774
- * when that process goes away — so a deploy, a crash or a Ctrl-C during a run
4775
- * leaves `status: "running"` on disk forever. The run history then contains
4776
- * entries that are not merely stale but false: they claim to be in progress,
4777
- * the dashboard renders them as live, and no one can tell them apart from a
4778
- * step that is genuinely still thinking.
4779
- *
4780
- * Called at startup, before anything reads run history. A run still being
4781
- * worked on by a live process is protected by the idle window, not by a lock —
4782
- * so this is safe to call while runs are in flight, and deliberately
4783
- * conservative about what it declares dead.
4784
- *
4785
- * A run genuinely waiting for a person is left alone: that state legitimately
4786
- * outlives any process. A run whose person already answered is not waiting,
4787
- * and is picked back up — see below.
4788
- */
4789
- export function reconcileRuns(tenant, now = Date.now(), live) {
5147
+ export function reconcileRuns(tenant, now = Date.now(), live, onEnd) {
4790
5148
  const closed = [];
4791
5149
  for (const workspace of listWorkspaces(tenant)) {
4792
5150
  // Live runs only, read whole — every other status is passed over below,
@@ -4823,7 +5181,13 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
4823
5181
  // Park at any *later* gate rather than poll: reconcile runs inside
4824
5182
  // long-lived server processes, where a 24h file-poll is exactly the
4825
5183
  // slot-burning the queue exists to avoid.
4826
- void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true });
5184
+ void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
5185
+ .then(() => {
5186
+ const after = readRun(tenant, workspace.name, run.id);
5187
+ if (onEnd && after && (after.status === "completed" || after.status === "failed"))
5188
+ return onEnd(tenant, workspace.name, run.id);
5189
+ })
5190
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
4827
5191
  closed.push({
4828
5192
  runId: run.id,
4829
5193
  tenant,
@@ -4870,6 +5234,11 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
4870
5234
  // a sandbox we cannot reach must not stop the reconcile
4871
5235
  }
4872
5236
  closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
5237
+ if (onEnd) {
5238
+ void Promise.resolve()
5239
+ .then(() => onEnd(tenant, workspace.name, run.id))
5240
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
5241
+ }
4873
5242
  }
4874
5243
  }
4875
5244
  return closed;
@@ -4881,8 +5250,8 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
4881
5250
  * one hard-coded account left every other account's interrupted runs marked
4882
5251
  * `running` forever, with no process left to finish them.
4883
5252
  */
4884
- export function reconcileAllRuns(now = Date.now(), live) {
4885
- return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
5253
+ export function reconcileAllRuns(now = Date.now(), live, onEnd) {
5254
+ return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
4886
5255
  }
4887
5256
  /**
4888
5257
  * Wait for a run to stop being in progress.