@foldrun/core 0.6.0 → 0.7.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.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/run-container.d.ts +5 -0
- package/dist/src/run-container.js +66 -13
- package/dist/src/runner.d.ts +204 -0
- package/dist/src/runner.js +257 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +36 -4
- package/dist/src/step-exec.d.ts +11 -2
- package/dist/src/step-exec.js +52 -5
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +20 -0
- package/dist/src/store.js +89 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +65 -12
- package/src/runner.ts +264 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +56 -8
- package/src/storage.ts +13 -0
- package/src/store.ts +85 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/src/provider-health.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// It reads the same provider block a run reads, resolved the same way, so a
|
|
19
19
|
// pass here means a run would reach the same place with the same key.
|
|
20
20
|
|
|
21
|
+
import { accountModelBlock } from "./model-credential.ts";
|
|
21
22
|
import fs from "node:fs";
|
|
22
23
|
import path from "node:path";
|
|
23
24
|
import matter from "gray-matter";
|
|
@@ -236,6 +237,8 @@ export function providersInUse(tenant: string): ProviderInUse[] {
|
|
|
236
237
|
};
|
|
237
238
|
|
|
238
239
|
add(frontmatterOf(path.join(accountDir(tenant), "AGENTS.md")).provider, null, "account");
|
|
240
|
+
// The account's own key from Settings → Model, which needs no block.
|
|
241
|
+
add(accountModelBlock((name) => getSecret(tenant, name)?.value ?? null), null, "account (Settings → Model)");
|
|
239
242
|
for (const ws of safeList(() => listWorkspaces(tenant))) {
|
|
240
243
|
const dir = workspaceDir(tenant, ws.name);
|
|
241
244
|
add(frontmatterOf(path.join(dir, "AGENTS.md")).provider, ws.name, ws.name);
|
package/src/run-container.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
|
|
|
28
28
|
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
|
|
29
29
|
import type { EventExtra } from "./step-exec.ts";
|
|
30
30
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
31
|
-
import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
|
|
31
|
+
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
|
|
32
32
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
33
33
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
34
34
|
import type { SubagentSpec } from "./subagents.ts";
|
|
@@ -293,18 +293,37 @@ export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Bu
|
|
|
293
293
|
let common = 0;
|
|
294
294
|
const max = Math.min(h.length, n.length);
|
|
295
295
|
while (common < max && h.charCodeAt(common) === n.charCodeAt(common)) common++;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
296
|
+
// One pass over the shared prefix. Rebuilding and hashing the prefix at
|
|
297
|
+
// every line boundary, from the longest down, was quadratic: on 6 Oct 2026
|
|
298
|
+
// a conflicted file of a few MB held the worker's event loop for minutes,
|
|
299
|
+
// the liveness probe killed it every 4.5 minutes, and every step on the box
|
|
300
|
+
// lost its model connection.
|
|
301
|
+
let end = -1;
|
|
302
|
+
if (typeof was === "string") {
|
|
303
|
+
const run = crypto.createHash("sha256");
|
|
304
|
+
if (run.copy().digest("hex") === was) end = 0;
|
|
305
|
+
let from = 0;
|
|
306
|
+
for (let nl = h.indexOf("\n"); nl !== -1 && nl < common; nl = h.indexOf("\n", nl + 1)) {
|
|
307
|
+
run.update(h.slice(from, nl + 1), "utf8");
|
|
308
|
+
from = nl + 1;
|
|
309
|
+
if (run.copy().digest("hex") === was) end = from;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
// The base's bytes are known, so it can only end at their length.
|
|
313
|
+
const base = was.toString("utf8");
|
|
314
|
+
if (
|
|
315
|
+
base.length <= common &&
|
|
316
|
+
(base.length === 0 || base.endsWith("\n")) &&
|
|
317
|
+
h.startsWith(base) &&
|
|
318
|
+
Buffer.from(base, "utf8").equals(was)
|
|
319
|
+
)
|
|
320
|
+
end = base.length;
|
|
306
321
|
}
|
|
307
|
-
return null;
|
|
322
|
+
if (end < 0) return null;
|
|
323
|
+
const hostAdded = h.slice(end);
|
|
324
|
+
const stepAdded = n.slice(end);
|
|
325
|
+
const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
|
|
326
|
+
return Buffer.from(h.slice(0, end) + joined + stepAdded, "utf8");
|
|
308
327
|
}
|
|
309
328
|
|
|
310
329
|
export function applyContainerChanges(
|
|
@@ -408,6 +427,13 @@ export function applyContainerChanges(
|
|
|
408
427
|
*/
|
|
409
428
|
export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
|
|
410
429
|
|
|
430
|
+
/** Starts the install helper as root, in the background, before the drop to
|
|
431
|
+
* the agent user: the root half of the `install` tool (install-helper.ts).
|
|
432
|
+
* The agent reaches it only through the socket; it installs only validated
|
|
433
|
+
* Debian package names, and dies with the container. */
|
|
434
|
+
export const INSTALL_HELPER_SH = `FOLDRUN_INSTALL_SOCKET=${INSTALL_SOCKET}; export FOLDRUN_INSTALL_SOCKET
|
|
435
|
+
node /opt/runner/node_modules/@foldrun/core/dist/src/install-helper.js "$FOLDRUN_INSTALL_SOCKET" ${RUNTIME_CACHE} >/dev/null 2>&1 &`;
|
|
436
|
+
|
|
411
437
|
/**
|
|
412
438
|
* Where the platform's shared runtime layer is mounted, read-only, in a run
|
|
413
439
|
* pod — and read-write, one entry at a time, in the pod that builds an entry
|
|
@@ -548,6 +574,25 @@ try {
|
|
|
548
574
|
// meet the packages its runtime declared, not the image's bare python.
|
|
549
575
|
Object.assign(env, runtime.env);
|
|
550
576
|
|
|
577
|
+
// install: what the agent finds it needs mid-step. apt goes through the
|
|
578
|
+
// root start-up's helper (install-helper.ts); pip and npm land in a
|
|
579
|
+
// directory of this container's own, on the paths from the first turn.
|
|
580
|
+
const { buildInstallTool, INSTALL_TOOL } = await import("@foldrun/core/install-tool");
|
|
581
|
+
const { stepInstallEnv, INSTALL_SOCKET } = await import("@foldrun/core/runtime");
|
|
582
|
+
const installDir = "/home/agent/.foldrun/step";
|
|
583
|
+
const installing = (input.allowed ?? []).includes(INSTALL_TOOL);
|
|
584
|
+
if (installing) Object.assign(env, stepInstallEnv(installDir, env));
|
|
585
|
+
const install = installing
|
|
586
|
+
? buildInstallTool({
|
|
587
|
+
dir: installDir,
|
|
588
|
+
socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,
|
|
589
|
+
cacheRoot: "${RUNTIME_CACHE}",
|
|
590
|
+
python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + "/bin/python" : "python3",
|
|
591
|
+
env,
|
|
592
|
+
emit,
|
|
593
|
+
})
|
|
594
|
+
: { server: null };
|
|
595
|
+
|
|
551
596
|
// API and script tools, rebuilt in here from their specs. Secrets were
|
|
552
597
|
// substituted into API headers before the input crossed the boundary, so
|
|
553
598
|
// buildApiTools gets an already-resolved environment. verify: gets the
|
|
@@ -598,6 +643,7 @@ try {
|
|
|
598
643
|
...(history.server ? { foldrun_history: history.server } : {}),
|
|
599
644
|
...(desks.server ? { foldrun_desks: desks.server } : {}),
|
|
600
645
|
...(ask.server ? { foldrun_ask: ask.server } : {}),
|
|
646
|
+
...(install.server ? { foldrun_install: install.server } : {}),
|
|
601
647
|
...input.mcpServers,
|
|
602
648
|
},
|
|
603
649
|
...(inbox ? { inbox } : {}),
|
|
@@ -893,7 +939,14 @@ RUN node -e ' \\
|
|
|
893
939
|
FROM browsers AS full
|
|
894
940
|
${CORE_INSTALL}`;
|
|
895
941
|
|
|
942
|
+
// runtime: system: — apt as root, before the drop (runtime.ts systemInstallSh).
|
|
943
|
+
// set -e is off around it: a failed install is reported by the driver, which
|
|
944
|
+
// reads the status file and fails the step with apt's own words.
|
|
896
945
|
const ENTRY = `#!/bin/sh
|
|
946
|
+
set +e
|
|
947
|
+
${SYSTEM_FROM_INPUT_SH}
|
|
948
|
+
${systemInstallSh(RUNTIME_CACHE)}
|
|
949
|
+
${INSTALL_HELPER_SH}
|
|
897
950
|
set -e
|
|
898
951
|
chown -R agent:agent /workspace /library /opt/runner/job
|
|
899
952
|
# The runtime cache, when one is mounted. NOT recursive: a warm cache is tens
|
package/src/runner.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import matter from "gray-matter";
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
@@ -14,8 +15,8 @@ import { resolveAgentPath } from "./confine.ts";
|
|
|
14
15
|
import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts";
|
|
15
16
|
import { eventUrl } from "./webhook.ts";
|
|
16
17
|
import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
|
|
17
|
-
import { hostSafeEnv } from "./host-env.ts";
|
|
18
|
-
import { resolveModelCredential, credentialLine } from "./model-credential.ts";
|
|
18
|
+
import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
|
|
19
|
+
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
|
|
19
20
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
20
21
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
21
22
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
|
|
@@ -88,8 +89,10 @@ import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./trans
|
|
|
88
89
|
import { providerPreset } from "./providers.ts";
|
|
89
90
|
import { buildScriptTools, parseScripts, type ExecutionContext, type ScriptSpec } from "./script-tools.ts";
|
|
90
91
|
import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.ts";
|
|
91
|
-
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
|
|
92
|
-
import {
|
|
92
|
+
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, stepInstallEnv, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
|
|
93
|
+
import { buildInstallTool, INSTALL_TOOL } from "./install-tool.ts";
|
|
94
|
+
import { dataRoot } from "./paths.ts";
|
|
95
|
+
import { materializeFiles, harvestFiles, storageBaseline } from "./storage.ts";
|
|
93
96
|
import { chooseExecutor, ensureImage } from "./container.ts";
|
|
94
97
|
import { stampBundle } from "./okf.ts";
|
|
95
98
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -707,7 +710,7 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
|
|
|
707
710
|
});
|
|
708
711
|
}
|
|
709
712
|
|
|
710
|
-
function agentContext(
|
|
713
|
+
export function agentContext(
|
|
711
714
|
agentDir: string,
|
|
712
715
|
tenant: string,
|
|
713
716
|
tags: string[] = [],
|
|
@@ -1124,8 +1127,11 @@ function agentContext(
|
|
|
1124
1127
|
//
|
|
1125
1128
|
// The URL sits in git; the token is a ${SECRET} resolved server-side. An
|
|
1126
1129
|
// agent's own block wins over the workspace's, like everything else here.
|
|
1127
|
-
|
|
1130
|
+
// Then the account's own model key (Settings → Model), when it set one:
|
|
1131
|
+
// an account that brings its key need not write a block anywhere.
|
|
1132
|
+
const fileBlock =
|
|
1128
1133
|
(front.provider as Record<string, unknown> | undefined) ?? workspaceFrontmatter(agentDir, tenant).provider;
|
|
1134
|
+
const providerBlock = fileBlock ?? accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
|
|
1129
1135
|
let providerEnv: Record<string, string> = {};
|
|
1130
1136
|
let providerLabel: string | null = null;
|
|
1131
1137
|
// Secret values pulled into the provider block, name → value. Kept apart
|
|
@@ -1278,6 +1284,9 @@ function agentContext(
|
|
|
1278
1284
|
// one — the `websearch` library tool reads it. Not a secret; rides with
|
|
1279
1285
|
// the clock so it reaches scripts, the sandbox and verify alike.
|
|
1280
1286
|
...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
|
|
1287
|
+
// Where this install's own API answers (host-env.ts platformApiUrl), so a
|
|
1288
|
+
// script calls `$FOLDRUN_API_URL/workspaces/…` instead of naming a host.
|
|
1289
|
+
...(platformApiUrl() ? { FOLDRUN_API_URL: platformApiUrl()! } : {}),
|
|
1281
1290
|
// Whose index our web search asks when the agent named a search API.
|
|
1282
1291
|
// Unset means the account's own engine. The name, never the key.
|
|
1283
1292
|
...(searchChoice.shape === "direct" && searchChoice.provider
|
|
@@ -1365,7 +1374,20 @@ function agentContext(
|
|
|
1365
1374
|
);
|
|
1366
1375
|
if (scriptTools.promptLines.length) {
|
|
1367
1376
|
parts.push(
|
|
1368
|
-
`# Tools from scripts\n\nCall these directly — they run workspace scripts for you
|
|
1377
|
+
`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1378
|
+
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
// The web tool's own description lists every action, so an agent whose
|
|
1382
|
+
// search or fetch a model provider answers called web action=search,
|
|
1383
|
+
// was refused, and only then used WebSearch — eight refusals in one
|
|
1384
|
+
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1385
|
+
if (builtins.length) {
|
|
1386
|
+
const named = builtins.map((b) => b.split("="));
|
|
1387
|
+
parts.push(
|
|
1388
|
+
`# Web search and fetch on this run\n\n` +
|
|
1389
|
+
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1390
|
+
`\n\nThe web tool's other actions work as usual.`,
|
|
1369
1391
|
);
|
|
1370
1392
|
}
|
|
1371
1393
|
if (declared.length) {
|
|
@@ -1379,7 +1401,13 @@ function agentContext(
|
|
|
1379
1401
|
"Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1380
1402
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1381
1403
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
1382
|
-
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool."
|
|
1404
|
+
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
|
|
1405
|
+
// 45 refusals in 34 runs (6–7 Oct 2026), nearly all an agent rewriting
|
|
1406
|
+
// a plan or draft an earlier run left: the Write tool refuses a file
|
|
1407
|
+
// that exists and has not been Read in this step, before any hook of
|
|
1408
|
+
// ours can see the call. Said once here instead of learned per step.
|
|
1409
|
+
"To replace a file that already exists, Read it first — Write refuses a file you have not Read in this step (a new file needs no Read). " +
|
|
1410
|
+
"Built-in tools are capitalised: Read, Write, Edit, Glob, Grep, Bash.",
|
|
1383
1411
|
);
|
|
1384
1412
|
|
|
1385
1413
|
// The run's one-line summary is the first meaningful line of the last step
|
|
@@ -1449,6 +1477,12 @@ function agentContext(
|
|
|
1449
1477
|
// names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
|
|
1450
1478
|
if (toolName === "web" && !ref.linked) {
|
|
1451
1479
|
allowed.push(...providerWebTools);
|
|
1480
|
+
// web writes what it found to files and says "Read that file" — a
|
|
1481
|
+
// screenshot, a crawl, a page saved whole. An agent granted web alone
|
|
1482
|
+
// was told to Read and refused it (blog-desk claim-checker, 7 Oct
|
|
1483
|
+
// 2026: "Read is disabled for this session"). Reading is confined to
|
|
1484
|
+
// the workspace like every file tool; disallowedTools still removes it.
|
|
1485
|
+
if (!allowed.includes("Read")) allowed.push("Read");
|
|
1452
1486
|
continue;
|
|
1453
1487
|
}
|
|
1454
1488
|
|
|
@@ -1470,6 +1504,8 @@ function agentContext(
|
|
|
1470
1504
|
else if (toolName === "ask") wantAsk = true;
|
|
1471
1505
|
else wantDesks = true;
|
|
1472
1506
|
if (available[toolName]) shadowed.push(toolName);
|
|
1507
|
+
} else if (toolName === "install") {
|
|
1508
|
+
// Granted to every agent anyway (below); naming it is harmless.
|
|
1473
1509
|
} else if (BUILTIN_TOOLS.has(toolName)) {
|
|
1474
1510
|
allowed.push(toolName);
|
|
1475
1511
|
if (available[toolName]) shadowed.push(toolName);
|
|
@@ -1531,6 +1567,11 @@ function agentContext(
|
|
|
1531
1567
|
// what it exposes, which is the unit a person actually reasons about anyway.
|
|
1532
1568
|
for (const name of mcpNames) allowed.push(`mcp__${name}`, `mcp__${name}__*`);
|
|
1533
1569
|
|
|
1570
|
+
// install — every agent may install what it finds it needs mid-step (apt,
|
|
1571
|
+
// pip, npm), declared or not (install-tool.ts). Granted by default; taken
|
|
1572
|
+
// away by disallowedTools: [install], and absent in plan mode (read-only).
|
|
1573
|
+
allowed.push(INSTALL_TOOL);
|
|
1574
|
+
|
|
1534
1575
|
// Claude Code's spellings, so an agent written for it mostly runs here.
|
|
1535
1576
|
// `permissionMode: plan` is its read-only mode; ours is the `read` group.
|
|
1536
1577
|
if (front.permissionMode === "plan") {
|
|
@@ -1538,6 +1579,7 @@ function agentContext(
|
|
|
1538
1579
|
allowed.push(...TOOL_MAP.read, ...apiTools.toolNames, ...scriptTools.toolNames);
|
|
1539
1580
|
}
|
|
1540
1581
|
const denied: string[] = refNames(front.disallowedTools);
|
|
1582
|
+
if (denied.includes("install")) denied.push(INSTALL_TOOL);
|
|
1541
1583
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1542
1584
|
|
|
1543
1585
|
return {
|
|
@@ -1756,6 +1798,42 @@ async function terminalAsk(question: string, options?: string[]): Promise<string
|
|
|
1756
1798
|
}
|
|
1757
1799
|
}
|
|
1758
1800
|
|
|
1801
|
+
/**
|
|
1802
|
+
* What the person at the gate said, framed for the step's prompt — or "".
|
|
1803
|
+
*
|
|
1804
|
+
* The note is the freshest instruction the step has — later than the flow
|
|
1805
|
+
* file, aimed at this exact run. When the gate asked a question (ask:), the
|
|
1806
|
+
* note IS the answer and is framed as one.
|
|
1807
|
+
*
|
|
1808
|
+
* Both halves go through resolveDocLinks for the same reason the
|
|
1809
|
+
* instruction does: this is authored text on its way to becoming a prompt,
|
|
1810
|
+
* and the rule is that ALL of it expands. A person answering "which list?"
|
|
1811
|
+
* with [[dead-ends]] was sending the model two literal brackets — the one
|
|
1812
|
+
* reference syntax the workspace teaches, and the one place it silently did
|
|
1813
|
+
* nothing. The previous step's results are NOT resolved, deliberately: that
|
|
1814
|
+
* is model output, and a model that happens to emit [[brackets]] must not
|
|
1815
|
+
* thereby name a file.
|
|
1816
|
+
*
|
|
1817
|
+
* An ask: gate approved with no note still says so. It used to add nothing,
|
|
1818
|
+
* so the step never learned its question had been answered: gbp-desk's
|
|
1819
|
+
* rivals sender, told "a plain approval means all offices", saw no answer
|
|
1820
|
+
* at all and stopped BLOCKED (run-muu6r18y-417e, 2026-10-05).
|
|
1821
|
+
*/
|
|
1822
|
+
export function operatorBlock(step: Pick<StepRecord, "ask" | "approvalNote" | "approvedAt">, workspaceRoot: string): string {
|
|
1823
|
+
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
1824
|
+
if (step.approvalNote) {
|
|
1825
|
+
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
1826
|
+
return step.ask
|
|
1827
|
+
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
1828
|
+
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
1829
|
+
}
|
|
1830
|
+
if (step.ask && step.approvedAt) {
|
|
1831
|
+
return `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer: they approved it with no note — a plain yes.\n</operator_answer>`;
|
|
1832
|
+
}
|
|
1833
|
+
return "";
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
|
|
1759
1837
|
async function runStep(
|
|
1760
1838
|
agentDir: string,
|
|
1761
1839
|
tenant: string,
|
|
@@ -1807,6 +1885,7 @@ async function runStep(
|
|
|
1807
1885
|
// cover a script's own stdout.
|
|
1808
1886
|
let redactions: [string, string][] = [];
|
|
1809
1887
|
let fileDir: string | null = null;
|
|
1888
|
+
let installDir: string | null = null;
|
|
1810
1889
|
let disposeRuntime: (() => void) | undefined;
|
|
1811
1890
|
// Can this step's tools act outside the workspace? Set once its context is
|
|
1812
1891
|
// read; the verdict at the end of this function reads it.
|
|
@@ -1963,6 +2042,29 @@ async function runStep(
|
|
|
1963
2042
|
const line = credentialLine(credential);
|
|
1964
2043
|
push(line.type, line.text);
|
|
1965
2044
|
}
|
|
2045
|
+
// A hosted platform runs only its operator's accounts on its own model
|
|
2046
|
+
// credential; every other account brings its own API key. Refused here,
|
|
2047
|
+
// before a sandbox is rented, with what to do about it — not run on the
|
|
2048
|
+
// operator's key, and not failed later by a provider.
|
|
2049
|
+
if (!platform.platformKeyAllowed(tenant)) {
|
|
2050
|
+
const own = Object.values(providerSecrets).concat(Object.values(providerEnv));
|
|
2051
|
+
if (!providerLabel) {
|
|
2052
|
+
push(
|
|
2053
|
+
"error",
|
|
2054
|
+
`no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
|
|
2055
|
+
`(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
|
|
2056
|
+
);
|
|
2057
|
+
step.status = "failed";
|
|
2058
|
+
save();
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (own.some((v) => isClaudeLoginToken(v))) {
|
|
2062
|
+
push("error", "model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider");
|
|
2063
|
+
step.status = "failed";
|
|
2064
|
+
save();
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
1966
2068
|
for (const w of providerWarnings) push("error", w);
|
|
1967
2069
|
// A level that wrote a language nobody can read was skipped, not obeyed.
|
|
1968
2070
|
for (const l of language.lines) push("info", l);
|
|
@@ -2143,28 +2245,7 @@ async function runStep(
|
|
|
2143
2245
|
// happening to contain brackets.
|
|
2144
2246
|
prompt += `\n\n<event>\nThis step waited for an external event. What arrived:\n${step.eventPayload}\n</event>`;
|
|
2145
2247
|
}
|
|
2146
|
-
|
|
2147
|
-
// The person at the gate said something while letting the run through.
|
|
2148
|
-
// That is the freshest instruction the step has — later than the flow
|
|
2149
|
-
// file, aimed at this exact run. When the gate asked a question
|
|
2150
|
-
// (ask:), the note IS the answer and is framed as one.
|
|
2151
|
-
//
|
|
2152
|
-
// Both halves go through resolveDocLinks for the same reason the
|
|
2153
|
-
// instruction above does: this is authored text on its way to becoming
|
|
2154
|
-
// a prompt, and the rule is that ALL of it expands. A person answering
|
|
2155
|
-
// "which list?" with [[dead-ends]] was sending the model two literal
|
|
2156
|
-
// brackets — the one reference syntax the workspace teaches, and the
|
|
2157
|
-
// one place it silently did nothing.
|
|
2158
|
-
//
|
|
2159
|
-
// What is NOT resolved, deliberately: the previous step's results
|
|
2160
|
-
// above. That is model output, not something a person wrote, and a
|
|
2161
|
-
// model that happens to emit [[brackets]] must not thereby name a file.
|
|
2162
|
-
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
2163
|
-
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
2164
|
-
prompt += step.ask
|
|
2165
|
-
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
2166
|
-
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
2167
|
-
}
|
|
2248
|
+
prompt += operatorBlock(step, workspaceRoot);
|
|
2168
2249
|
if (step.delegate?.length) {
|
|
2169
2250
|
// The chooser's contract. The allowed set comes from the flow file;
|
|
2170
2251
|
// the runner parses exactly this shape back out of the result, so the
|
|
@@ -2210,6 +2291,9 @@ async function runStep(
|
|
|
2210
2291
|
// watched on both paths, because a refusal reads the same from a pod
|
|
2211
2292
|
// and from this process.
|
|
2212
2293
|
let lastRefusal = "";
|
|
2294
|
+
// The step's model connection was cut (isModelConnectionLost): the
|
|
2295
|
+
// platform's side going away under the step, not the step failing.
|
|
2296
|
+
let lastDrop = "";
|
|
2213
2297
|
// And WHY it refused, in words, when anything on the way saw the
|
|
2214
2298
|
// headers that say so: the translator here, the egress proxy over
|
|
2215
2299
|
// there. Kept apart from `lastRefusal` — that one decides whether to
|
|
@@ -2217,6 +2301,7 @@ async function runStep(
|
|
|
2217
2301
|
let refusalWhy = "";
|
|
2218
2302
|
const pushWatching: typeof push = (type, text, extra) => {
|
|
2219
2303
|
if (type === "error" && isProviderRefusal(text)) lastRefusal = text;
|
|
2304
|
+
if (type === "error" && isModelConnectionLost(text)) lastDrop = text;
|
|
2220
2305
|
const why = refusalFromLine(text);
|
|
2221
2306
|
if (why) refusalWhy = why;
|
|
2222
2307
|
push(type, text, extra);
|
|
@@ -2511,6 +2596,7 @@ async function runStep(
|
|
|
2511
2596
|
lastArgs = a;
|
|
2512
2597
|
return runIsolated(a);
|
|
2513
2598
|
};
|
|
2599
|
+
let attemptFrom = step.events.length;
|
|
2514
2600
|
let outcome = await runTracked(firstArgs);
|
|
2515
2601
|
step.sandbox = null; // whatever happens next starts its own
|
|
2516
2602
|
// A 401 from a token that rotated mid-step is not a broken key: the
|
|
@@ -2548,6 +2634,48 @@ async function runStep(
|
|
|
2548
2634
|
outcome = await runTracked(again);
|
|
2549
2635
|
outcome = withEarlierTiming(outcome, previous);
|
|
2550
2636
|
}
|
|
2637
|
+
// The model connection was cut under the step — the egress proxy or the
|
|
2638
|
+
// worker went away (6 Oct 2026: a worker restarted every 4.5 minutes and
|
|
2639
|
+
// every desk running failed on "Connection refused"). That is the
|
|
2640
|
+
// platform's fault, so the step runs again, whatever its `retry:` —
|
|
2641
|
+
// unless it can act outside the workspace and its tools already ran:
|
|
2642
|
+
// the post may be up, and a second attempt would post it again.
|
|
2643
|
+
for (let n = 1; n <= DROP_RETRIES && outcome.status === "failed" && lastDrop; n++) {
|
|
2644
|
+
if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
|
|
2645
|
+
push("info", `the model connection dropped after this step's tools ran — not retried: they can act outside the workspace, and another attempt could repeat what they did`);
|
|
2646
|
+
break;
|
|
2647
|
+
}
|
|
2648
|
+
const wait = Math.round(retryBaseMs() * 2 * n);
|
|
2649
|
+
push("info", `the model connection dropped (${lastDrop.slice(0, 80)}) — the platform's side, not the step's; running the step again in ${Math.round(wait / 1000)}s (${n} of ${DROP_RETRIES})`);
|
|
2650
|
+
await sleep(wait);
|
|
2651
|
+
lastDrop = "";
|
|
2652
|
+
lastRefusal = "";
|
|
2653
|
+
attemptFrom = step.events.length;
|
|
2654
|
+
const previous = outcome.timing;
|
|
2655
|
+
const again = isolatedArgs(platformModelEnv());
|
|
2656
|
+
await lease?.commit();
|
|
2657
|
+
outcome = withEarlierTiming(await runTracked(again), previous);
|
|
2658
|
+
}
|
|
2659
|
+
// The model wrote its tool call out as text — `<invoke name="…">` in
|
|
2660
|
+
// its last message — and stopped. Nothing ran, so nothing was done:
|
|
2661
|
+
// seo-digest's editor three times (27 Sep, 4 and 5 Oct 2026), its own
|
|
2662
|
+
// retry included, because the retry asked the same way. Run it once
|
|
2663
|
+
// more with that said plainly, on the same safety rule as a dropped
|
|
2664
|
+
// connection: never again after an outward tool already ran.
|
|
2665
|
+
for (let n = 1; n <= TEXT_CALL_RETRIES && wroteToolCallAsText(attemptText(outcome.result, step.events.slice(attemptFrom))); n++) {
|
|
2666
|
+
if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
|
|
2667
|
+
push("info", "the model wrote a tool call as text instead of making it — not run again: this step's tools can act outside the workspace and already ran");
|
|
2668
|
+
break;
|
|
2669
|
+
}
|
|
2670
|
+
push("info", `the model wrote a tool call as text instead of making it, so nothing ran — running the step again with a note (${n} of ${TEXT_CALL_RETRIES})`);
|
|
2671
|
+
attemptFrom = step.events.length;
|
|
2672
|
+
lastRefusal = "";
|
|
2673
|
+
const previous = outcome.timing;
|
|
2674
|
+
const base = isolatedArgs(platformModelEnv());
|
|
2675
|
+
const again = { ...base, input: { ...base.input, prompt: `${base.input.prompt}\n\n${TEXT_CALL_NOTE}` } };
|
|
2676
|
+
await lease?.commit();
|
|
2677
|
+
outcome = withEarlierTiming(await runTracked(again), previous);
|
|
2678
|
+
}
|
|
2551
2679
|
// The second supply, tried exactly once, and only when the primary
|
|
2552
2680
|
// refused over money/auth/limits rather than the work failing. The
|
|
2553
2681
|
// retry is a fresh sandbox — the failed one is gone, and driveRun's
|
|
@@ -2706,6 +2834,20 @@ async function runStep(
|
|
|
2706
2834
|
// finally — once, however many attempts follow.
|
|
2707
2835
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
2708
2836
|
fileDir = mat.dir;
|
|
2837
|
+
// install: pip and npm land in a directory of this step's own, gone in
|
|
2838
|
+
// the finally; apt has no helper on a bare host and says so.
|
|
2839
|
+
installDir = allowed.includes(INSTALL_TOOL) ? fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-step-")) : null;
|
|
2840
|
+
const installEnv = installDir ? stepInstallEnv(installDir, { ...process.env, ...mat.env }) : {};
|
|
2841
|
+
const installTools = installDir
|
|
2842
|
+
? buildInstallTool({
|
|
2843
|
+
dir: installDir,
|
|
2844
|
+
socket: null,
|
|
2845
|
+
cacheRoot: path.join(dataRoot(), tenant, ".runtimes"),
|
|
2846
|
+
python: mat.env.VIRTUAL_ENV ? path.join(mat.env.VIRTUAL_ENV, "bin", "python") : "python3",
|
|
2847
|
+
env: { ...hostSafeEnv(), ...mat.env, ...installEnv },
|
|
2848
|
+
emit: (type, text) => push(type, text),
|
|
2849
|
+
})
|
|
2850
|
+
: null;
|
|
2709
2851
|
const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
|
|
2710
2852
|
agentDir,
|
|
2711
2853
|
workspaceRoot,
|
|
@@ -2725,6 +2867,7 @@ async function runStep(
|
|
|
2725
2867
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2726
2868
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2727
2869
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
2870
|
+
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2728
2871
|
...mcpServers,
|
|
2729
2872
|
},
|
|
2730
2873
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
@@ -2732,7 +2875,7 @@ async function runStep(
|
|
|
2732
2875
|
// the allowlisted host environment, not process.env: the SDK's
|
|
2733
2876
|
// Bash tool runs with this, and on a platform process.env holds
|
|
2734
2877
|
// every tenant's root key and the datastore credentials.
|
|
2735
|
-
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
|
|
2878
|
+
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...installEnv, ...modelEnv },
|
|
2736
2879
|
timeoutSec: step.timeout,
|
|
2737
2880
|
budgetUsd: stepBudgetUsd,
|
|
2738
2881
|
budgetNote: stepBudgetNote,
|
|
@@ -2840,6 +2983,7 @@ async function runStep(
|
|
|
2840
2983
|
} finally {
|
|
2841
2984
|
try {
|
|
2842
2985
|
cleanupFileSecrets(fileDir);
|
|
2986
|
+
if (installDir) fs.rmSync(installDir, { recursive: true, force: true });
|
|
2843
2987
|
} catch {
|
|
2844
2988
|
// best-effort — a leftover scratch file is swept with the run's outputs
|
|
2845
2989
|
}
|
|
@@ -2996,8 +3140,12 @@ function platformFallbackEnv(): Record<string, string> | null {
|
|
|
2996
3140
|
const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
|
|
2997
3141
|
const CREDENTIAL_POLL_MS = 2_000;
|
|
2998
3142
|
|
|
2999
|
-
|
|
3000
|
-
|
|
3143
|
+
/** A refusal of the credential itself, in any of the wordings Claude Code
|
|
3144
|
+
* has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
|
|
3145
|
+
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
3146
|
+
* older two, so the step failed instead of waiting for the new token. */
|
|
3147
|
+
export function isAuthRefusal(text: string): boolean {
|
|
3148
|
+
return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
|
|
3001
3149
|
}
|
|
3002
3150
|
|
|
3003
3151
|
/**
|
|
@@ -3048,6 +3196,49 @@ function isProviderRefusal(text: string): boolean {
|
|
|
3048
3196
|
);
|
|
3049
3197
|
}
|
|
3050
3198
|
|
|
3199
|
+
/** A step's own model connection cut: the egress proxy or the worker it
|
|
3200
|
+
* runs through went away. Only the run's final error, never a tool's — a
|
|
3201
|
+
* WebFetch that could not connect is the step's business, not ours. */
|
|
3202
|
+
export function isModelConnectionLost(text: string): boolean {
|
|
3203
|
+
return /^Claude Code returned an error result: API Error: (Connection (refused|dropped|error)|.*\b(ECONNREFUSED|ECONNRESET|socket hang up)\b)/i.test(text);
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/** A tool call written out as text rather than made: the model's last
|
|
3207
|
+
* message holds `<invoke name=…>` / `<function_calls>` markup. Only the tail
|
|
3208
|
+
* counts — a reply that quotes such markup mid-way and goes on is fine. */
|
|
3209
|
+
export function wroteToolCallAsText(text: string): boolean {
|
|
3210
|
+
const tail = text.slice(-1500);
|
|
3211
|
+
const open = tail.search(/<(?:antml:)?(?:invoke|function_calls)\b[^>]*>/);
|
|
3212
|
+
if (open < 0) return false;
|
|
3213
|
+
// Quoted mid-reply and then the reply carries on: not a stalled call.
|
|
3214
|
+
const after = tail.slice(open).replace(/<\/(?:antml:)?(?:invoke|function_calls)>/g, "\u0000");
|
|
3215
|
+
const lastClose = after.lastIndexOf("\u0000");
|
|
3216
|
+
return lastClose < 0 || after.slice(lastClose + 1).trim().length < 200;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
/** What an attempt said last: its result, or — when it ended without one —
|
|
3220
|
+
* the error that carried the model's final text. */
|
|
3221
|
+
function attemptText(result: string | null | undefined, events: { type: string; text?: string }[]): string {
|
|
3222
|
+
if (result && result.trim()) return result;
|
|
3223
|
+
const lastError = [...events].reverse().find((e) => e.type === "error");
|
|
3224
|
+
return lastError?.text ?? "";
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Runs again after a tool call written as text. */
|
|
3228
|
+
export const TEXT_CALL_RETRIES = 1;
|
|
3229
|
+
export const TEXT_CALL_NOTE =
|
|
3230
|
+
"Note from the platform: your last attempt ended by writing a tool call out as text (an <invoke …> block), so it was not run and nothing happened. " +
|
|
3231
|
+
"Make every tool call as a real tool call — never type its markup into your message.";
|
|
3232
|
+
|
|
3233
|
+
/** How many times a step whose model connection was cut runs again. */
|
|
3234
|
+
export const DROP_RETRIES = 2;
|
|
3235
|
+
|
|
3236
|
+
/** May a step whose connection dropped run again? Yes, unless its tools can
|
|
3237
|
+
* act outward and one ran in this attempt (actedThenFailedCheck's rule). */
|
|
3238
|
+
export function dropRetrySafe(canActOutward: boolean, attemptEvents: { type: string }[]): boolean {
|
|
3239
|
+
return !(canActOutward && attemptEvents.some((e) => e.type === "tool"));
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3051
3242
|
/** How many times a busy provider is given another moment, and the longest
|
|
3052
3243
|
* any one wait may be. Three is the industry's habit and the point where
|
|
3053
3244
|
* waiting longer stops being kinder than failing. */
|
|
@@ -3637,6 +3828,32 @@ function driveRunInner(
|
|
|
3637
3828
|
//
|
|
3638
3829
|
// Runs on resume too — a parked run's container is long gone, and the
|
|
3639
3830
|
// one it comes back in needs the same files the first one had.
|
|
3831
|
+
//
|
|
3832
|
+
// A run re-driven after its worker died is the exception to "the store
|
|
3833
|
+
// is newer than the disk": a park harvests before it parks, a crash does
|
|
3834
|
+
// not, so what this run's finished steps wrote to storage/ is only on
|
|
3835
|
+
// disk, and materializing first wrote the store's older copy over it.
|
|
3836
|
+
// On 6 Oct 2026 a worker restart put reddit-desk's 2 Oct post back over
|
|
3837
|
+
// that morning's draft, and a rerun from the validator would have
|
|
3838
|
+
// posted it a second time. So a run that has been here before (its
|
|
3839
|
+
// baseline is on disk) stores what it changed first; files it did not
|
|
3840
|
+
// touch still come fresh from the store.
|
|
3841
|
+
const baselineFile = path.join(runRoot, "runs", run.id, "storage-baseline.json");
|
|
3842
|
+
if (fs.existsSync(baselineFile)) {
|
|
3843
|
+
try {
|
|
3844
|
+
const changedFrom = JSON.parse(fs.readFileSync(baselineFile, "utf8")) as Record<string, string>;
|
|
3845
|
+
const { saved } = await harvestFiles(tenant, workspace, `run:${run.id}`, { changedFrom });
|
|
3846
|
+
if (saved.length) {
|
|
3847
|
+
run.steps[0]?.events.push({
|
|
3848
|
+
t: new Date().toISOString(),
|
|
3849
|
+
type: "info",
|
|
3850
|
+
text: `files: kept ${saved.join(", ")} written before the run was picked up again`,
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3853
|
+
} catch {
|
|
3854
|
+
// the materialize below still runs; at worst this is the old behaviour
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3640
3857
|
try {
|
|
3641
3858
|
const brought = await materializeFiles(tenant, workspace);
|
|
3642
3859
|
if (brought.length) {
|
|
@@ -3646,6 +3863,8 @@ function driveRunInner(
|
|
|
3646
3863
|
text: `files: ${brought.length} brought into the workspace`,
|
|
3647
3864
|
});
|
|
3648
3865
|
}
|
|
3866
|
+
fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
|
|
3867
|
+
fs.writeFileSync(baselineFile, JSON.stringify(storageBaseline(tenant, workspace)));
|
|
3649
3868
|
} catch (err) {
|
|
3650
3869
|
// A file store that is down must not take the run with it: most flows
|
|
3651
3870
|
// never touch storage/, and the ones that do will fail their own verify.
|
|
@@ -4111,7 +4330,7 @@ function driveRunInner(
|
|
|
4111
4330
|
save();
|
|
4112
4331
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4113
4332
|
// everything in this group was rejected
|
|
4114
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional)) {
|
|
4333
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
4115
4334
|
run.status = "failed";
|
|
4116
4335
|
break;
|
|
4117
4336
|
}
|
|
@@ -4353,8 +4572,13 @@ function driveRunInner(
|
|
|
4353
4572
|
effort: step.effort,
|
|
4354
4573
|
timeout: step.timeout,
|
|
4355
4574
|
// The rescuer inherits the contract, not just the job: a step
|
|
4356
|
-
// that promised data still owes data when someone else does it
|
|
4575
|
+
// that promised data still owes data when someone else does it,
|
|
4576
|
+
// and a step that had to pass a check still has to pass it — a
|
|
4577
|
+
// rescue whose result stood unchecked was a way round the very
|
|
4578
|
+
// check that failed (found 7 Oct 2026, adding rescuers to
|
|
4579
|
+
// medium-desk).
|
|
4357
4580
|
output: step.output,
|
|
4581
|
+
...(step.verify ? { verify: step.verify } : {}),
|
|
4358
4582
|
attempts: 0,
|
|
4359
4583
|
status: "pending",
|
|
4360
4584
|
events: [],
|
|
@@ -4389,7 +4613,11 @@ function driveRunInner(
|
|
|
4389
4613
|
save();
|
|
4390
4614
|
}
|
|
4391
4615
|
|
|
4392
|
-
|
|
4616
|
+
// A step carried into a re-run failed in the run it came from, not
|
|
4617
|
+
// here: the person chose to start after it. Counting it stopped
|
|
4618
|
+
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
4619
|
+
// a second, before the one step it was asked to run.
|
|
4620
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
|
|
4393
4621
|
if (requiredFailed) {
|
|
4394
4622
|
run.status = "failed";
|
|
4395
4623
|
break;
|