@foldrun/core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/deploy.js +4 -2
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +112 -2
- package/dist/src/platform.js +20 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +6 -5
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/deploy.ts +4 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/dist/src/runner.js
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import matter from "gray-matter";
|
|
8
|
-
import { executeStep, extractJson, stepCeiling } from "./step-exec.js";
|
|
8
|
+
import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
|
|
9
9
|
import { eventUrl } from "./webhook.js";
|
|
10
10
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
11
|
+
import { hostSafeEnv } from "./host-env.js";
|
|
12
|
+
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
11
13
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
12
14
|
import { platform } from "./platform.js";
|
|
15
|
+
import { healthKey } from "./secret-health.js";
|
|
16
|
+
import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
|
|
13
17
|
/** Does this process run steps in a sandbox — the container core ships, or
|
|
14
18
|
* one the platform registered (a pod)? */
|
|
15
19
|
const isolatedRun = () => {
|
|
@@ -19,7 +23,7 @@ const isolatedRun = () => {
|
|
|
19
23
|
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
20
24
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError } from "./tool-names.js";
|
|
21
25
|
import { refNames } from "./refs.js";
|
|
22
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef,
|
|
26
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
23
27
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
24
28
|
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
|
|
25
29
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
@@ -34,6 +38,7 @@ import { mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
|
|
|
34
38
|
import { materializeFiles, harvestFiles } from "./storage.js";
|
|
35
39
|
import { chooseExecutor, ensureImage } from "./container.js";
|
|
36
40
|
import { stampBundle } from "./okf.js";
|
|
41
|
+
import { trimChars } from "./paths.js";
|
|
37
42
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
38
43
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
39
44
|
// process without ever entering the model's context.
|
|
@@ -84,6 +89,34 @@ export function joinEarlierGroups(groupResults, gi, cap = CONTEXT_CHARS) {
|
|
|
84
89
|
parts.shift();
|
|
85
90
|
return parts.join("\n\n");
|
|
86
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* `parallel: N` — a pool of N slots the instances of one fan-out share.
|
|
94
|
+
* Fan-out always ran every instance at once, and still does unless the
|
|
95
|
+
* step says otherwise: twenty browser steps against one site, or one
|
|
96
|
+
* vendor's rate limit, is what the cap is for. Nothing is rented while an
|
|
97
|
+
* instance waits for a slot.
|
|
98
|
+
*/
|
|
99
|
+
export function slotPool(size) {
|
|
100
|
+
let free = Math.max(1, size);
|
|
101
|
+
const waiting = [];
|
|
102
|
+
return {
|
|
103
|
+
acquire: () => new Promise((resolve) => {
|
|
104
|
+
if (free > 0) {
|
|
105
|
+
free -= 1;
|
|
106
|
+
resolve();
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
waiting.push(resolve);
|
|
110
|
+
}),
|
|
111
|
+
release: () => {
|
|
112
|
+
const next = waiting.shift();
|
|
113
|
+
if (next)
|
|
114
|
+
next();
|
|
115
|
+
else
|
|
116
|
+
free += 1;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
87
120
|
const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
|
|
88
121
|
// gray-matter throws on invalid YAML. Skills authored for another client
|
|
89
122
|
// sometimes ship YAML that that client's parser happened to accept — most
|
|
@@ -311,10 +344,9 @@ export function saysUntilMarker(result, until) {
|
|
|
311
344
|
.some((line) =>
|
|
312
345
|
// Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
|
|
313
346
|
// is a decision written by someone using bold, not a sentence.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
.replace(/[.!:,*_`\s]+$/, "")
|
|
347
|
+
// trimChars, not a `[…]+$` regex: that one is quadratic on a
|
|
348
|
+
// line of tabs, and this is a line an agent wrote.
|
|
349
|
+
trimChars(trimChars(line.trim(), "#>-*_`"), ".!:,*_`").trim()
|
|
318
350
|
.toLowerCase() === marker);
|
|
319
351
|
}
|
|
320
352
|
export function resolveDocLinks(text, workspaceRoot) {
|
|
@@ -408,7 +440,10 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
408
440
|
map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
|
|
409
441
|
}
|
|
410
442
|
}
|
|
411
|
-
|
|
443
|
+
// `[` excluded from the name: with it allowed, a run of `[[` makes the
|
|
444
|
+
// engine re-scan to the end from every opening — quadratic on a result
|
|
445
|
+
// an agent wrote.
|
|
446
|
+
return text.replace(/\[\[([^\]\[\n]+)\]\]/g, (whole, name) => {
|
|
412
447
|
const hit = map.get(norm(name.trim()));
|
|
413
448
|
return hit ? `\`${hit}\`` : whole;
|
|
414
449
|
});
|
|
@@ -1072,6 +1107,42 @@ function repriced(catalog, model, usage, sdkCost, push) {
|
|
|
1072
1107
|
}
|
|
1073
1108
|
return known;
|
|
1074
1109
|
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Write one attempt onto the step's history and re-total the step.
|
|
1112
|
+
*
|
|
1113
|
+
* runStep reports the attempt it just ran — its cost, tokens and sandbox
|
|
1114
|
+
* seconds — by assigning the step's fields. Those become this attempt's row;
|
|
1115
|
+
* the step's fields then become the sum of every row, which is what the
|
|
1116
|
+
* run's spend, the flow's budget: and the platform's bill read. A retried
|
|
1117
|
+
* step used to carry only its last attempt's cost, so a `retry: 2` step
|
|
1118
|
+
* could spend three shares while reporting one.
|
|
1119
|
+
*/
|
|
1120
|
+
export function recordAttempt(step, n, status, startedAt, eventsBefore,
|
|
1121
|
+
/** The attempt re-attached to a sandbox a previous driver left: same
|
|
1122
|
+
* attempt, so its row is replaced rather than repeated. */
|
|
1123
|
+
reattach = false) {
|
|
1124
|
+
const tries = step.tries ?? [];
|
|
1125
|
+
const lastError = step.events
|
|
1126
|
+
.slice(eventsBefore)
|
|
1127
|
+
.filter((e) => e.type === "error")
|
|
1128
|
+
.at(-1)?.text;
|
|
1129
|
+
const row = {
|
|
1130
|
+
n,
|
|
1131
|
+
status,
|
|
1132
|
+
costUsd: step.costUsd,
|
|
1133
|
+
tokens: step.tokens ?? null,
|
|
1134
|
+
computeSecs: step.computeSecs ?? null,
|
|
1135
|
+
startedAt,
|
|
1136
|
+
finishedAt: step.finishedAt ?? new Date().toISOString(),
|
|
1137
|
+
...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
|
|
1138
|
+
};
|
|
1139
|
+
const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
|
|
1140
|
+
step.tries = [...kept, row];
|
|
1141
|
+
const totals = sumAttempts(step.tries);
|
|
1142
|
+
step.costUsd = totals.costUsd;
|
|
1143
|
+
step.tokens = totals.tokens;
|
|
1144
|
+
step.computeSecs = totals.computeSecs;
|
|
1145
|
+
}
|
|
1075
1146
|
async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
|
|
1076
1147
|
/** What the previous group returned as DATA (an `output: json` step's
|
|
1077
1148
|
* value), beside the prose in `context`. Undefined when nothing did. */
|
|
@@ -1079,7 +1150,16 @@ priorData,
|
|
|
1079
1150
|
/** The most this step may spend before it is stopped mid-turn: the run's
|
|
1080
1151
|
* remaining `budget:`, shared across the steps launched with it. Null
|
|
1081
1152
|
* means the flow set no budget. */
|
|
1082
|
-
stepBudgetUsd = null
|
|
1153
|
+
stepBudgetUsd = null,
|
|
1154
|
+
/** Which line set that ceiling, for the error that names it. */
|
|
1155
|
+
stepBudgetNote,
|
|
1156
|
+
/** The run is a test run — see test-mode.ts. Read off the run record by
|
|
1157
|
+
* the caller, never from anything the sandbox could have written. */
|
|
1158
|
+
testRun = false,
|
|
1159
|
+
/** Has a person asked for the run to stop? Read on a clock by the
|
|
1160
|
+
* in-process model loop, so a stop lands mid-step and not at the next
|
|
1161
|
+
* group. The isolated path needs none: stopRun destroys the sandbox. */
|
|
1162
|
+
stopRequested) {
|
|
1083
1163
|
// Secret values are injected into scripts as environment variables and
|
|
1084
1164
|
// substituted into API headers, so a model that reads one back — from a
|
|
1085
1165
|
// script that prints it, an API error that echoes it, or its own `env` —
|
|
@@ -1103,6 +1183,22 @@ stepBudgetUsd = null) {
|
|
|
1103
1183
|
out = out.split(value).join(`[redacted:${name}]`);
|
|
1104
1184
|
return out;
|
|
1105
1185
|
};
|
|
1186
|
+
// The reply, the conclusion and an `output: json` value land on the same
|
|
1187
|
+
// record the events do, and a model that quotes a script's stdout puts
|
|
1188
|
+
// the credential in its answer rather than in a tool event. Same scrub,
|
|
1189
|
+
// same place. The JSON value goes through its text form: a secret inside
|
|
1190
|
+
// a nested field is still a secret, and the key name survives either way.
|
|
1191
|
+
const redactText = (text) => (typeof text === "string" ? redact(text) : text);
|
|
1192
|
+
const redactData = (value) => {
|
|
1193
|
+
if (value === undefined || redactions.length === 0)
|
|
1194
|
+
return value;
|
|
1195
|
+
try {
|
|
1196
|
+
return JSON.parse(redact(JSON.stringify(value)));
|
|
1197
|
+
}
|
|
1198
|
+
catch {
|
|
1199
|
+
return value;
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1106
1202
|
const push = (type, text, extra) => {
|
|
1107
1203
|
step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
|
|
1108
1204
|
save();
|
|
@@ -1140,8 +1236,15 @@ stepBudgetUsd = null) {
|
|
|
1140
1236
|
// the failure of a step that returned no JSON are testable at no cost.
|
|
1141
1237
|
if (step.output === "json") {
|
|
1142
1238
|
const extracted = extractJson(step.result);
|
|
1143
|
-
if (extracted.ok)
|
|
1239
|
+
if (extracted.ok) {
|
|
1144
1240
|
step.data = extracted.value;
|
|
1241
|
+
const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
|
|
1242
|
+
const errors = schema && looksLikeSchema(schema) ? validateSchema(step.data, schema) : [];
|
|
1243
|
+
if (errors.length) {
|
|
1244
|
+
push("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
|
|
1245
|
+
step.status = "failed";
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1145
1248
|
else {
|
|
1146
1249
|
push("error", `output: json — ${extracted.reason}`);
|
|
1147
1250
|
step.status = "failed";
|
|
@@ -1158,14 +1261,17 @@ stepBudgetUsd = null) {
|
|
|
1158
1261
|
// step: the generic HTTP tool still works without the typed ones.
|
|
1159
1262
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
|
|
1160
1263
|
push("error", `openapi: ${w}`);
|
|
1161
|
-
const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
|
|
1264
|
+
const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
|
|
1265
|
+
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
1266
|
+
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
1267
|
+
const size = step.sizeUp ?? agentSize;
|
|
1162
1268
|
// oauth2 secrets become live access tokens here — the one async moment
|
|
1163
1269
|
// in secret resolution, at the last host-side point before anything is
|
|
1164
1270
|
// injected or substituted. The refresh recipes never leave this process:
|
|
1165
1271
|
// what crosses into containers, headers and env files is the token. A
|
|
1166
1272
|
// failed refresh fails the step naming the secret and the provider's
|
|
1167
1273
|
// reason, which beats a 401 three layers later.
|
|
1168
|
-
const liveSecrets = await materializeSecrets(secretEnv);
|
|
1274
|
+
const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
|
|
1169
1275
|
// @file values (SSH keys, certs) still carry their content at this point.
|
|
1170
1276
|
// The in-process branch materialises them to 0600 paths on this host
|
|
1171
1277
|
// below; the container branch passes the content across and the driver
|
|
@@ -1289,6 +1395,11 @@ stepBudgetUsd = null) {
|
|
|
1289
1395
|
push("info", fitted.note);
|
|
1290
1396
|
effort = fitted.effort;
|
|
1291
1397
|
}
|
|
1398
|
+
// No catalogue — the platform's own Anthropic key, or a gateway that
|
|
1399
|
+
// publishes no prices — but the model's NAME is often enough. Without
|
|
1400
|
+
// this the in-pod meter priced haiku and sonnet at Opus rates and a
|
|
1401
|
+
// step was stopped at up to fifteen times what it had spent.
|
|
1402
|
+
tokenPrice ??= knownPrice(wireModel);
|
|
1292
1403
|
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
1293
1404
|
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
1294
1405
|
// Expose the workspace's shared scripts/ as ./shared so every agent reaches
|
|
@@ -1299,6 +1410,12 @@ stepBudgetUsd = null) {
|
|
|
1299
1410
|
// The confinement boundary: agents in a workspace are one team, but the
|
|
1300
1411
|
// workspace is where isolation is enforced.
|
|
1301
1412
|
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
1413
|
+
// Where a test run's state/ and storage/ writes go, and the line each
|
|
1414
|
+
// one leaves: under the run, beside its archived outputs.
|
|
1415
|
+
const divertFor = (id) => ({
|
|
1416
|
+
to: path.join(workspaceRoot, "runs", id, TEST_WRITES_DIR),
|
|
1417
|
+
note: (rel, summary) => push("info", `test: ${summary}`, { effect: { kind: "diverted", path: rel, summary } }),
|
|
1418
|
+
});
|
|
1302
1419
|
let prompt = step.instruction
|
|
1303
1420
|
? resolveDocLinks(step.instruction, workspaceRoot)
|
|
1304
1421
|
: "Begin your run now, following your instructions.";
|
|
@@ -1315,11 +1432,18 @@ stepBudgetUsd = null) {
|
|
|
1315
1432
|
if (step.output === "json") {
|
|
1316
1433
|
// The contract the runner parses back out — stated in the prompt so
|
|
1317
1434
|
// the instruction and the parser agree by construction, like delegate:.
|
|
1435
|
+
// The schema, when there is one, is quoted whole: the model that has
|
|
1436
|
+
// to satisfy it should not have to guess it from the field names.
|
|
1437
|
+
const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
|
|
1318
1438
|
prompt +=
|
|
1319
1439
|
`\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
|
|
1320
1440
|
"```json fenced block — the value the next step will compute on. Prose before it is " +
|
|
1321
1441
|
`welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
|
|
1322
|
-
`names fields, use exactly those names
|
|
1442
|
+
`names fields, use exactly those names.` +
|
|
1443
|
+
(schema && looksLikeSchema(schema)
|
|
1444
|
+
? ` The value must satisfy this JSON Schema, and the step fails if it does not:\n${JSON.stringify(schema, null, 2).slice(0, 20_000)}`
|
|
1445
|
+
: "") +
|
|
1446
|
+
`\n</output>`;
|
|
1323
1447
|
}
|
|
1324
1448
|
if (step.waitFor === "event" && step.eventPayload !== undefined) {
|
|
1325
1449
|
// Not resolved for links, like the previous step's results: it is
|
|
@@ -1384,6 +1508,16 @@ stepBudgetUsd = null) {
|
|
|
1384
1508
|
// fallback), else the platform's, and only for a step riding the
|
|
1385
1509
|
// platform credential.
|
|
1386
1510
|
const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
|
|
1511
|
+
// `schema:` — inline on the step, or a file under the workspace read
|
|
1512
|
+
// now, host-side, so both executors are handed the same document. A
|
|
1513
|
+
// file that cannot be read is a failed step before any model runs:
|
|
1514
|
+
// the contract the author wrote down cannot be checked.
|
|
1515
|
+
const resolvedSchema = step.schema ?? (step.schemaPath ? readSchemaFile(workspaceRoot, step.schemaPath) : null);
|
|
1516
|
+
if (step.schemaPath && resolvedSchema === null) {
|
|
1517
|
+
throw new Error(`schema: ${step.schemaPath} — not a readable JSON or YAML schema under the workspace`);
|
|
1518
|
+
}
|
|
1519
|
+
if (step.maxTurns)
|
|
1520
|
+
push("info", `max_turns: ${step.maxTurns}`);
|
|
1387
1521
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
1388
1522
|
if (isolatedRun()) {
|
|
1389
1523
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -1412,12 +1546,17 @@ stepBudgetUsd = null) {
|
|
|
1412
1546
|
if (!host)
|
|
1413
1547
|
continue;
|
|
1414
1548
|
for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
1415
|
-
for (const name of placeholderNames(v))
|
|
1416
|
-
if (name in liveSecrets)
|
|
1417
|
-
|
|
1549
|
+
for (const name of placeholderNames(v)) {
|
|
1550
|
+
if (!(name in liveSecrets))
|
|
1551
|
+
continue;
|
|
1552
|
+
addGrant(grant, name, liveSecrets[name], host);
|
|
1553
|
+
// Which vault entry this is, so the proxy's record of what the
|
|
1554
|
+
// far end said lands on the right credential.
|
|
1555
|
+
(grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", path.basename(path.resolve(agentDir, "..", "..")));
|
|
1556
|
+
}
|
|
1418
1557
|
}
|
|
1419
1558
|
}
|
|
1420
|
-
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant });
|
|
1559
|
+
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
|
|
1421
1560
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
1422
1561
|
// reads them from its environment, and unless it says `secrets:
|
|
1423
1562
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -1437,14 +1576,35 @@ stepBudgetUsd = null) {
|
|
|
1437
1576
|
}
|
|
1438
1577
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
1439
1578
|
// a placeholder that the proxy fills on the way out.
|
|
1440
|
-
const
|
|
1441
|
-
|
|
1442
|
-
|
|
1579
|
+
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
1580
|
+
let sandboxSecrets = materialise ? liveSecrets : placeholders;
|
|
1581
|
+
// A test run hands the sandbox no send-capable value. The env var is
|
|
1582
|
+
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
1583
|
+
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
1584
|
+
// error — and sends nothing. The script tools say whether the step
|
|
1585
|
+
// has a declared sender (everything withheld) or a declared reader
|
|
1586
|
+
// (nothing withheld); see test-mode.ts for the order.
|
|
1587
|
+
if (testRun && materialise) {
|
|
1588
|
+
const held = withholdSecrets(liveSecrets, {
|
|
1589
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
1590
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
1591
|
+
});
|
|
1592
|
+
sandboxSecrets = held.env;
|
|
1593
|
+
for (const name of held.withheld) {
|
|
1594
|
+
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
// On a test run the http tools always go through the proxy as
|
|
1598
|
+
// placeholders, even when a script made the step materialise: the
|
|
1599
|
+
// proxy is where the outward-write policy lives, and a header
|
|
1600
|
+
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
1601
|
+
// been sunk or refused.
|
|
1602
|
+
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
1443
1603
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
1444
1604
|
...api,
|
|
1445
1605
|
headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
|
|
1446
1606
|
k,
|
|
1447
|
-
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) =>
|
|
1607
|
+
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => apiValues[name] ?? whole),
|
|
1448
1608
|
])),
|
|
1449
1609
|
}));
|
|
1450
1610
|
// The provider's own key and headers, when the step has a lease: the
|
|
@@ -1472,6 +1632,7 @@ stepBudgetUsd = null) {
|
|
|
1472
1632
|
const isolatedArgs = (modelEnv, keyName = MODEL_KEY_NAME) => ({
|
|
1473
1633
|
workspaceRoot,
|
|
1474
1634
|
libraryRoot: libraryDir(tenant),
|
|
1635
|
+
galleryRoot: platform.galleryDir() ?? undefined,
|
|
1475
1636
|
// Keys this step's dependency cache. Per-account by design — see
|
|
1476
1637
|
// run-container.ts#runtimeCacheDir for why it is not shared.
|
|
1477
1638
|
tenant,
|
|
@@ -1492,9 +1653,12 @@ stepBudgetUsd = null) {
|
|
|
1492
1653
|
consults,
|
|
1493
1654
|
timeoutSec: step.timeout,
|
|
1494
1655
|
budgetUsd: stepBudgetUsd,
|
|
1656
|
+
budgetNote: stepBudgetNote,
|
|
1495
1657
|
price: tokenPrice,
|
|
1496
1658
|
verify: step.verify,
|
|
1497
1659
|
output: step.output,
|
|
1660
|
+
schema: resolvedSchema ?? undefined,
|
|
1661
|
+
maxTurns: step.maxTurns,
|
|
1498
1662
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
1499
1663
|
// The container sees the workspace at /workspace and the library
|
|
1500
1664
|
// at /library; the roots are named host-side and moved here.
|
|
@@ -1524,12 +1688,29 @@ stepBudgetUsd = null) {
|
|
|
1524
1688
|
FOLDRUN_RUN_ID: runId,
|
|
1525
1689
|
FOLDRUN_AGENT: step.agent,
|
|
1526
1690
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
1691
|
+
...(testRun ? testModeEnv() : {}),
|
|
1527
1692
|
...sandboxSecrets,
|
|
1528
1693
|
}).filter((entry) => typeof entry[1] === "string")),
|
|
1529
1694
|
emit: pushWatching,
|
|
1530
1695
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
1531
1696
|
runId,
|
|
1532
1697
|
size,
|
|
1698
|
+
// A test run's state/ and storage/ changes come out under the run.
|
|
1699
|
+
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
1700
|
+
test: testRun,
|
|
1701
|
+
// Resume: a sandbox a previous driver left running, recorded on the
|
|
1702
|
+
// step. Consumed once — a fresh attempt after this one (a retry, a
|
|
1703
|
+
// fallback) starts its own sandbox.
|
|
1704
|
+
resume: step.sandbox && step.sandbox.kind === isolation ? { ref: step.sandbox.ref, consumed: step.sandbox.consumed } : null,
|
|
1705
|
+
// Checkpoint: where the executor is, on the record, so a driver
|
|
1706
|
+
// that inherits this run can attach where this one left off. The
|
|
1707
|
+
// executor calls it before applying each line; the line's own emit
|
|
1708
|
+
// saves the record, so this only updates the fields.
|
|
1709
|
+
checkpoint: (ref, consumed) => {
|
|
1710
|
+
step.sandbox = { kind: isolation, ref, consumed, since: step.sandbox?.since ?? new Date().toISOString() };
|
|
1711
|
+
if (consumed === 0)
|
|
1712
|
+
save();
|
|
1713
|
+
},
|
|
1533
1714
|
});
|
|
1534
1715
|
// A busy provider gets another moment on the SAME supply before the
|
|
1535
1716
|
// fallback is considered: under a fan-out, twenty steps against one
|
|
@@ -1539,6 +1720,7 @@ stepBudgetUsd = null) {
|
|
|
1539
1720
|
const firstArgs = isolatedArgs(platformModelEnv());
|
|
1540
1721
|
await lease?.commit();
|
|
1541
1722
|
let outcome = await runIsolated(firstArgs);
|
|
1723
|
+
step.sandbox = null; // whatever happens next starts its own
|
|
1542
1724
|
for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
|
|
1543
1725
|
const wait = backoffMs(n, lastRefusal);
|
|
1544
1726
|
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
@@ -1579,16 +1761,28 @@ stepBudgetUsd = null) {
|
|
|
1579
1761
|
outcome = withEarlierTiming(outcome, first);
|
|
1580
1762
|
}
|
|
1581
1763
|
if (lease) {
|
|
1582
|
-
for (const line of await lease.drainLog())
|
|
1583
|
-
|
|
1764
|
+
for (const line of await lease.drainLog()) {
|
|
1765
|
+
if (typeof line === "string")
|
|
1766
|
+
push("info", line);
|
|
1767
|
+
else
|
|
1768
|
+
push("info", line.text, { effect: line.effect });
|
|
1769
|
+
}
|
|
1584
1770
|
await lease.release();
|
|
1585
1771
|
}
|
|
1586
|
-
|
|
1772
|
+
// Nothing a test run left in storage/public/ reached storage/, so
|
|
1773
|
+
// there is nothing to publish — and a share link is an outward act.
|
|
1774
|
+
if (!testRun)
|
|
1775
|
+
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1587
1776
|
step.status = outcome.status;
|
|
1588
|
-
step.result = outcome.result;
|
|
1589
|
-
step.conclusion = outcome.conclusion;
|
|
1777
|
+
step.result = redactText(outcome.result);
|
|
1778
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
1779
|
+
step.sandbox = null;
|
|
1780
|
+
// The cluster's word on why a sandbox ended without the driver saying
|
|
1781
|
+
// so — OOMKilled, Evicted — on the record, where the retry reads it.
|
|
1782
|
+
if (outcome.status === "failed" && outcome.reason)
|
|
1783
|
+
push("error", `sandbox ended: ${outcome.reason}`);
|
|
1590
1784
|
if (outcome.data !== undefined)
|
|
1591
|
-
step.data = outcome.data;
|
|
1785
|
+
step.data = redactData(outcome.data);
|
|
1592
1786
|
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
|
|
1593
1787
|
step.tokens = outcome.usage
|
|
1594
1788
|
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
@@ -1619,10 +1813,23 @@ stepBudgetUsd = null) {
|
|
|
1619
1813
|
step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
|
|
1620
1814
|
}
|
|
1621
1815
|
else {
|
|
1622
|
-
|
|
1816
|
+
// Same withholding as the sandbox path — the in-process step is what
|
|
1817
|
+
// `foldrun run --test` drives on a laptop, where there is no proxy
|
|
1818
|
+
// and this is the only guard there is.
|
|
1819
|
+
const held = testRun
|
|
1820
|
+
? withholdSecrets(liveSecrets, {
|
|
1821
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
1822
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
1823
|
+
})
|
|
1824
|
+
: { env: liveSecrets, withheld: [] };
|
|
1825
|
+
for (const name of held.withheld) {
|
|
1826
|
+
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
1827
|
+
}
|
|
1828
|
+
const stepSecrets = held.env;
|
|
1829
|
+
const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
|
|
1623
1830
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
1624
1831
|
// finally — once, however many attempts follow.
|
|
1625
|
-
const mat = materializeFileSecrets(agentDir,
|
|
1832
|
+
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
1626
1833
|
fileDir = mat.dir;
|
|
1627
1834
|
const attemptInProcess = (modelEnv) => executeStep({
|
|
1628
1835
|
agentDir,
|
|
@@ -1644,13 +1851,20 @@ stepBudgetUsd = null) {
|
|
|
1644
1851
|
...mcpServers,
|
|
1645
1852
|
},
|
|
1646
1853
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
1647
|
-
// only ever sees the variable names, not the values.
|
|
1648
|
-
|
|
1854
|
+
// only ever sees the variable names, not the values. The base is
|
|
1855
|
+
// the allowlisted host environment, not process.env: the SDK's
|
|
1856
|
+
// Bash tool runs with this, and on a platform process.env holds
|
|
1857
|
+
// every tenant's root key and the datastore credentials.
|
|
1858
|
+
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
|
|
1649
1859
|
timeoutSec: step.timeout,
|
|
1650
1860
|
budgetUsd: stepBudgetUsd,
|
|
1861
|
+
budgetNote: stepBudgetNote,
|
|
1651
1862
|
price: tokenPrice,
|
|
1652
1863
|
verify: step.verify,
|
|
1653
1864
|
output: step.output,
|
|
1865
|
+
schema: resolvedSchema ?? undefined,
|
|
1866
|
+
maxTurns: step.maxTurns,
|
|
1867
|
+
stopRequested,
|
|
1654
1868
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
1655
1869
|
// the proof a step left names THIS run, not one that came before —
|
|
1656
1870
|
// copy-back never propagates a deletion, so a marker from an earlier
|
|
@@ -1660,23 +1874,33 @@ stepBudgetUsd = null) {
|
|
|
1660
1874
|
...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
|
|
1661
1875
|
FOLDRUN_AGENT: step.agent,
|
|
1662
1876
|
FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
|
|
1663
|
-
...
|
|
1877
|
+
...(testRun ? testModeEnv() : {}),
|
|
1878
|
+
...stepSecrets,
|
|
1664
1879
|
},
|
|
1665
1880
|
emit: pushWatching,
|
|
1666
1881
|
});
|
|
1667
1882
|
// With a translator the step's endpoint is the translator: started
|
|
1668
1883
|
// here, pointed at by the SDK's env, closed when the attempt ends.
|
|
1884
|
+
// A test run brackets each attempt with a snapshot of state/ and
|
|
1885
|
+
// storage/: the step writes them for real, because there is no
|
|
1886
|
+
// sandbox to write instead, and afterwards what changed is moved
|
|
1887
|
+
// under the run and the originals come back.
|
|
1669
1888
|
const attemptThrough = async (modelEnv, spec) => {
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
const t = await startTranslator(spec);
|
|
1889
|
+
const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
|
|
1890
|
+
const t = spec ? await startTranslator(spec) : null;
|
|
1673
1891
|
try {
|
|
1674
|
-
return await attemptInProcess({ ...modelEnv, ...t.env });
|
|
1892
|
+
return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
|
|
1675
1893
|
}
|
|
1676
1894
|
finally {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1895
|
+
if (t) {
|
|
1896
|
+
for (const line of t.drainLog())
|
|
1897
|
+
push("info", line.startsWith("translator") ? line : `translator: ${line}`);
|
|
1898
|
+
await t.close();
|
|
1899
|
+
}
|
|
1900
|
+
if (before && runId) {
|
|
1901
|
+
const d = divertFor(runId);
|
|
1902
|
+
restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
|
|
1903
|
+
}
|
|
1680
1904
|
}
|
|
1681
1905
|
};
|
|
1682
1906
|
let outcome = await attemptThrough(providerEnv, translator);
|
|
@@ -1695,12 +1919,13 @@ stepBudgetUsd = null) {
|
|
|
1695
1919
|
push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
|
|
1696
1920
|
outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
|
|
1697
1921
|
}
|
|
1698
|
-
|
|
1922
|
+
if (!testRun)
|
|
1923
|
+
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1699
1924
|
step.status = outcome.status;
|
|
1700
|
-
step.result = outcome.result;
|
|
1701
|
-
step.conclusion = outcome.conclusion;
|
|
1925
|
+
step.result = redactText(outcome.result);
|
|
1926
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
1702
1927
|
if (outcome.data !== undefined)
|
|
1703
|
-
step.data = outcome.data;
|
|
1928
|
+
step.data = redactData(outcome.data);
|
|
1704
1929
|
// A consult's spend belongs to the step that asked.
|
|
1705
1930
|
const consultCost = consultTools.drainCost();
|
|
1706
1931
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
|
|
@@ -2006,12 +2231,35 @@ function csvItems(workspaceDir, step, take) {
|
|
|
2006
2231
|
}
|
|
2007
2232
|
return data.slice(0, take).map((r) => `${header}\n${r}`);
|
|
2008
2233
|
}
|
|
2009
|
-
|
|
2234
|
+
/**
|
|
2235
|
+
* `schema: <path>` — the file, read and parsed, or null. Agent-relative like
|
|
2236
|
+
* every other path in a flow (`../../schemas/lead.json`), confined to the
|
|
2237
|
+
* workspace: a schema is the author's document, never something a step
|
|
2238
|
+
* can point outside.
|
|
2239
|
+
*/
|
|
2240
|
+
export function readSchemaFile(workspaceRoot, rel) {
|
|
2241
|
+
const root = path.resolve(workspaceRoot);
|
|
2242
|
+
// Written agent-relative (`../../schemas/x.json`) or workspace-relative
|
|
2243
|
+
// (`schemas/x.json`); both land on the same file.
|
|
2244
|
+
const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
|
|
2245
|
+
for (const abs of candidates) {
|
|
2246
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
2247
|
+
continue;
|
|
2248
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
|
|
2249
|
+
continue;
|
|
2250
|
+
const parsed = parseSchemaText(fs.readFileSync(abs, "utf8"));
|
|
2251
|
+
return "problem" in parsed ? null : parsed.schema;
|
|
2252
|
+
}
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {}) {
|
|
2010
2256
|
const run = {
|
|
2011
2257
|
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
2012
2258
|
flow: flowName,
|
|
2013
2259
|
tags,
|
|
2014
2260
|
status,
|
|
2261
|
+
...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
|
|
2262
|
+
...(opts.test ? { test: true } : {}),
|
|
2015
2263
|
startedAt: new Date().toISOString(),
|
|
2016
2264
|
finishedAt: null,
|
|
2017
2265
|
steps: steps.map((s) => ({
|
|
@@ -2041,6 +2289,10 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
|
|
|
2041
2289
|
preview: s.preview,
|
|
2042
2290
|
delegate: s.delegate,
|
|
2043
2291
|
output: s.output,
|
|
2292
|
+
schema: s.schema,
|
|
2293
|
+
schemaPath: s.schemaPath,
|
|
2294
|
+
parallel: s.parallel,
|
|
2295
|
+
maxTurns: s.maxTurns,
|
|
2044
2296
|
attempts: 0,
|
|
2045
2297
|
status: "pending",
|
|
2046
2298
|
events: [],
|
|
@@ -2066,8 +2318,8 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
|
|
|
2066
2318
|
}
|
|
2067
2319
|
return run;
|
|
2068
2320
|
}
|
|
2069
|
-
export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride) {
|
|
2070
|
-
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
|
|
2321
|
+
export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride, startedBy = null, opts = {}) {
|
|
2322
|
+
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
|
|
2071
2323
|
void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
|
|
2072
2324
|
return run;
|
|
2073
2325
|
}
|
|
@@ -2262,16 +2514,26 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2262
2514
|
// never happened, which reconcile can then never see. Reset it to
|
|
2263
2515
|
// pending and it simply runs again; driveRun's whole contract is that
|
|
2264
2516
|
// re-driving a partly-finished record is safe.
|
|
2517
|
+
// Unless the step's sandbox outlived its driver AND the executor can
|
|
2518
|
+
// re-attach to it: then the record carries the pod and how far the
|
|
2519
|
+
// last driver got, runStep passes that as `resume`, and the step
|
|
2520
|
+
// carries on from where it was — a deploy no longer costs the run.
|
|
2265
2521
|
let orphaned = false;
|
|
2266
2522
|
for (const step of run.steps) {
|
|
2267
2523
|
if (step.status !== "running")
|
|
2268
2524
|
continue;
|
|
2269
|
-
|
|
2525
|
+
const resumable = !!step.sandbox && platform.sandboxResumable(step.sandbox.kind);
|
|
2526
|
+
if (!resumable) {
|
|
2527
|
+
orphaned = true;
|
|
2528
|
+
step.sandbox = null;
|
|
2529
|
+
}
|
|
2270
2530
|
step.status = "pending";
|
|
2271
2531
|
step.events.push({
|
|
2272
2532
|
t: new Date().toISOString(),
|
|
2273
2533
|
type: "info",
|
|
2274
|
-
text:
|
|
2534
|
+
text: resumable
|
|
2535
|
+
? `driver changed mid-step — re-attaching to the running sandbox (${step.sandbox.ref}, ${step.sandbox.consumed} lines already applied)`
|
|
2536
|
+
: "interrupted mid-step (platform restart) — running again from the start of the step",
|
|
2275
2537
|
});
|
|
2276
2538
|
}
|
|
2277
2539
|
if (orphaned) {
|
|
@@ -2344,6 +2606,19 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2344
2606
|
// is assembled) keeps the newest: when the total is over it, the
|
|
2345
2607
|
// oldest groups are dropped whole before anything is cut mid-text.
|
|
2346
2608
|
const contextBefore = (gi) => joinEarlierGroups(groupResults, gi);
|
|
2609
|
+
// What `when:` and `case:` read: the PREVIOUS group's result alone —
|
|
2610
|
+
// the nearest earlier group that produced one, so a routed-past group
|
|
2611
|
+
// between is looked through rather than read as silence. The prompt
|
|
2612
|
+
// gets every earlier group joined (contextBefore); routing must not,
|
|
2613
|
+
// because a verdict two groups back would still open a gate a later
|
|
2614
|
+
// group had overruled: a classifier's BUG line matched a `case: BUG`
|
|
2615
|
+
// three groups on, after the debugger had already answered it.
|
|
2616
|
+
const routingBefore = (gi) => {
|
|
2617
|
+
for (let i = gi - 1; i >= 0; i--)
|
|
2618
|
+
if (groupResults[i])
|
|
2619
|
+
return groupResults[i];
|
|
2620
|
+
return null;
|
|
2621
|
+
};
|
|
2347
2622
|
// The data half of the same handoff: what the nearest earlier group
|
|
2348
2623
|
// returned through `output: json`. Rebuilt from the record rather than
|
|
2349
2624
|
// kept only in memory, so a resumed run hands step 3 the value step 1
|
|
@@ -2407,8 +2682,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2407
2682
|
const group = ordered[gi];
|
|
2408
2683
|
context = contextBefore(gi);
|
|
2409
2684
|
ctxData = dataBefore(gi);
|
|
2410
|
-
|
|
2411
|
-
|
|
2685
|
+
// What the run has spent, not just what its tokens cost: on an
|
|
2686
|
+
// install that prices sandbox seconds, a step that called no model
|
|
2687
|
+
// still spent money, and a cap that ignored it capped nothing.
|
|
2688
|
+
if (run.budgetUsd && platform.runSpend(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
|
|
2689
|
+
const spent = platform.runSpend(run);
|
|
2412
2690
|
const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2413
2691
|
last?.events.push({
|
|
2414
2692
|
t: new Date().toISOString(),
|
|
@@ -2494,6 +2772,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2494
2772
|
t: new Date().toISOString(),
|
|
2495
2773
|
type: "info",
|
|
2496
2774
|
text: `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
|
|
2775
|
+
(step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
|
|
2497
2776
|
(dropped ? ` (more items existed — capped at ${cap})` : ""),
|
|
2498
2777
|
});
|
|
2499
2778
|
const at = run.steps.indexOf(step);
|
|
@@ -2508,6 +2787,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2508
2787
|
result: null,
|
|
2509
2788
|
costUsd: null,
|
|
2510
2789
|
attempts: 0,
|
|
2790
|
+
tries: undefined,
|
|
2511
2791
|
}));
|
|
2512
2792
|
run.steps.splice(at + 1, 0, ...instances);
|
|
2513
2793
|
ordered = orderedGroups();
|
|
@@ -2520,12 +2800,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2520
2800
|
// this is what keeps the *next* group from starting.
|
|
2521
2801
|
const stopCheck = readRun(tenant, workspace, run.id);
|
|
2522
2802
|
if (stopCheck?.stopRequested) {
|
|
2803
|
+
let skipped = 0;
|
|
2523
2804
|
for (const s of run.steps) {
|
|
2524
2805
|
if (s.status === "pending" || s.status === "running") {
|
|
2525
2806
|
s.status = "skipped";
|
|
2526
2807
|
s.skipReason = "run stopped";
|
|
2808
|
+
skipped += 1;
|
|
2527
2809
|
}
|
|
2528
2810
|
}
|
|
2811
|
+
// Say so on the trail. stopRun wrote "run stopped" onto the
|
|
2812
|
+
// record's first step, but the save() below writes this process's
|
|
2813
|
+
// copy of the record over it, so the only witness was the
|
|
2814
|
+
// stopRequested flag — and a run that "failed" with no error line
|
|
2815
|
+
// anywhere took a code read to explain (rank-desk, 2026-09-13).
|
|
2816
|
+
const witness = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2817
|
+
witness?.events.push({
|
|
2818
|
+
t: new Date().toISOString(),
|
|
2819
|
+
type: "info",
|
|
2820
|
+
text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
|
|
2821
|
+
});
|
|
2529
2822
|
run.status = "failed";
|
|
2530
2823
|
run.stopRequested = true;
|
|
2531
2824
|
save();
|
|
@@ -2560,39 +2853,51 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2560
2853
|
break;
|
|
2561
2854
|
}
|
|
2562
2855
|
const ctx = context;
|
|
2563
|
-
//
|
|
2856
|
+
// The previous group's result — see routingBefore.
|
|
2857
|
+
const routing = routingBefore(gi);
|
|
2858
|
+
// `when:` — skip a step whose condition isn't met by the previous
|
|
2859
|
+
// group's result.
|
|
2564
2860
|
for (const step of freshGroup) {
|
|
2565
2861
|
if (step.status !== "pending" || !step.when)
|
|
2566
2862
|
continue;
|
|
2567
|
-
|
|
2863
|
+
// A marker at the start of a line, not a word anywhere in the
|
|
2864
|
+
// prose — see markerPresent. "There are no BLOCKED items" must
|
|
2865
|
+
// not open a gate keyed on BLOCKED.
|
|
2866
|
+
const met = markerPresent(routing, step.when);
|
|
2568
2867
|
if (!met) {
|
|
2569
2868
|
step.status = "skipped";
|
|
2570
2869
|
step.skipReason = `condition not met: when "${step.when}"`;
|
|
2571
2870
|
step.events.push({
|
|
2572
2871
|
t: new Date().toISOString(),
|
|
2573
2872
|
type: "info",
|
|
2574
|
-
text: `skipped — previous
|
|
2873
|
+
text: `skipped — the previous group's result has no line beginning "${step.when}" (when: reads a marker at the start of a line of the previous result, not a word in a sentence)`,
|
|
2575
2874
|
});
|
|
2576
2875
|
}
|
|
2577
2876
|
}
|
|
2578
2877
|
save();
|
|
2579
2878
|
// `case:`/`else:` — exclusive routing. Of this group's case steps,
|
|
2580
|
-
// the FIRST whose
|
|
2581
|
-
// rest are routed past. `else:` runs only when no case matched.
|
|
2879
|
+
// the FIRST whose marker leads a line of the previous results runs;
|
|
2880
|
+
// the rest are routed past. `else:` runs only when no case matched.
|
|
2582
2881
|
// (`when:` above stays independent — every matching when runs —
|
|
2583
2882
|
// which is why routing is its own vocabulary instead of a mode.)
|
|
2883
|
+
//
|
|
2884
|
+
// Same marker rule as `when:`, and it matters more here: routing is
|
|
2885
|
+
// exclusive, so a label matched out of a sentence does not merely
|
|
2886
|
+
// run an extra step, it sends the flow down the wrong branch and
|
|
2887
|
+
// skips the right one. "This is not a COMPLAINT, it is a QUESTION"
|
|
2888
|
+
// used to route to the complaints handler.
|
|
2584
2889
|
const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
|
|
2585
2890
|
if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
|
|
2586
2891
|
let matchedCase = null;
|
|
2587
2892
|
for (const step of caseSteps) {
|
|
2588
|
-
if (!matchedCase && (
|
|
2893
|
+
if (!matchedCase && markerPresent(routing, step.case)) {
|
|
2589
2894
|
matchedCase = step;
|
|
2590
2895
|
continue;
|
|
2591
2896
|
}
|
|
2592
2897
|
step.status = "skipped";
|
|
2593
2898
|
step.skipReason = matchedCase
|
|
2594
2899
|
? `routed past — "${matchedCase.case}" matched first`
|
|
2595
|
-
: `case not matched: "${step.case}"`;
|
|
2900
|
+
: `case not matched: the previous group's result has no line beginning "${step.case}"`;
|
|
2596
2901
|
step.events.push({
|
|
2597
2902
|
t: new Date().toISOString(),
|
|
2598
2903
|
type: "info",
|
|
@@ -2634,6 +2939,20 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2634
2939
|
});
|
|
2635
2940
|
}
|
|
2636
2941
|
run.status = "awaiting-approval";
|
|
2942
|
+
// approve_within: — the deadline is stamped when the gate opens,
|
|
2943
|
+
// not counted from the run's start, so "two days to answer" means
|
|
2944
|
+
// two days from being asked however long the work before it took.
|
|
2945
|
+
// Only a gate that asks a PERSON gets one: an external event has
|
|
2946
|
+
// no one to chase.
|
|
2947
|
+
const within = readFlow(tenant, workspace, run.flow)?.approveWithin ?? null;
|
|
2948
|
+
// Always assigned, never only set: a run with two gates would
|
|
2949
|
+
// otherwise carry the FIRST gate's deadline into the second, and
|
|
2950
|
+
// a deadline already in the past expires a gate the moment it
|
|
2951
|
+
// opens. Null when this gate has none.
|
|
2952
|
+
run.approveBy =
|
|
2953
|
+
within && needsApproval.some((s) => s.waitFor !== "event")
|
|
2954
|
+
? new Date(Date.now() + within * 1000).toISOString()
|
|
2955
|
+
: null;
|
|
2637
2956
|
save();
|
|
2638
2957
|
// The gate's declared preview, resolved now against what the run
|
|
2639
2958
|
// has actually produced — a glob at park time, a list of files on
|
|
@@ -2706,18 +3025,88 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2706
3025
|
// twenty steps over — the shares sum to what was left. Between
|
|
2707
3026
|
// groups the check above still refuses to start the next one.
|
|
2708
3027
|
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
2709
|
-
|
|
3028
|
+
// An agent's own `budget:` — the most it may spend in one run — is
|
|
3029
|
+
// read at launch, so a cap raised mid-run applies to the next step.
|
|
3030
|
+
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
3031
|
+
// The ceiling for one attempt of one step, read NOW: what is left of
|
|
3032
|
+
// the run's budget shared across the group, or the agent's own cap
|
|
3033
|
+
// less what its steps have cost. Computed before every attempt, not
|
|
3034
|
+
// once before the loop — a retry's share is what is left after the
|
|
3035
|
+
// failed attempt spent, and the step's own figure already carries
|
|
3036
|
+
// that spend (it is the sum of its tries).
|
|
3037
|
+
const ceilingFor = (step) => stepCeilingFor(stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length), agentBudgets.get(step.agent), run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0), step.agent);
|
|
3038
|
+
// parallel: — the instances of one fan-out template share a pool.
|
|
3039
|
+
// Keyed by what the instances share (agent + instruction), since
|
|
3040
|
+
// the template itself is behind them in the record as "expanded".
|
|
3041
|
+
const pools = new Map();
|
|
3042
|
+
const poolFor = (step) => {
|
|
3043
|
+
if (!step.parallel || !step.item)
|
|
3044
|
+
return null;
|
|
3045
|
+
const key = `${step.agent}\u0000${step.instruction}`;
|
|
3046
|
+
let pool = pools.get(key);
|
|
3047
|
+
if (!pool)
|
|
3048
|
+
pools.set(key, (pool = slotPool(step.parallel)));
|
|
3049
|
+
return pool;
|
|
3050
|
+
};
|
|
2710
3051
|
await Promise.all(launching
|
|
2711
3052
|
.map(async (step) => {
|
|
3053
|
+
const pool = poolFor(step);
|
|
3054
|
+
if (pool)
|
|
3055
|
+
await pool.acquire();
|
|
3056
|
+
try {
|
|
3057
|
+
await launchStep(step);
|
|
3058
|
+
}
|
|
3059
|
+
finally {
|
|
3060
|
+
pool?.release();
|
|
3061
|
+
}
|
|
3062
|
+
}));
|
|
3063
|
+
async function launchStep(step) {
|
|
2712
3064
|
const attempts = (step.retry ?? 0) + 1;
|
|
2713
|
-
|
|
3065
|
+
// A step re-attaching to its sandbox is still on the attempt
|
|
3066
|
+
// that started it; a step re-run after a backoff picks up the
|
|
3067
|
+
// count it left. Only a step nobody has touched starts at 1.
|
|
3068
|
+
const interruptedInBackoff = !step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
|
|
3069
|
+
const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts + 1) : 1;
|
|
3070
|
+
for (let attempt = from; attempt <= attempts; attempt++) {
|
|
3071
|
+
const ceiling = ceilingFor(step);
|
|
3072
|
+
// Nothing left is a refusal, not "no ceiling": a zero passed
|
|
3073
|
+
// down would read as unset, and the one step that must not
|
|
3074
|
+
// run would run with no cap at all. On a retry this is what
|
|
3075
|
+
// stops the second attempt spending a share the first one
|
|
3076
|
+
// already spent.
|
|
3077
|
+
if (ceiling.ceilingUsd === 0) {
|
|
3078
|
+
step.status = "failed";
|
|
3079
|
+
step.events.push({
|
|
3080
|
+
t: new Date().toISOString(),
|
|
3081
|
+
type: "error",
|
|
3082
|
+
text: attempt === from
|
|
3083
|
+
? `over budget — nothing left to spend before this step (${ceiling.note}); it was not started`
|
|
3084
|
+
: `over budget — nothing left to spend for attempt ${attempt} (${ceiling.note}); not retried`,
|
|
3085
|
+
});
|
|
3086
|
+
save();
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
// Re-attaching to a sandbox is the same attempt, so its row
|
|
3090
|
+
// is replaced; anything else is a new row, a loop cycle's
|
|
3091
|
+
// re-run included.
|
|
3092
|
+
const reattach = !!step.sandbox;
|
|
3093
|
+
const attemptStartedAt = new Date().toISOString();
|
|
3094
|
+
const eventsBefore = step.events.length;
|
|
2714
3095
|
step.attempts = attempt;
|
|
2715
3096
|
step.status = "running";
|
|
2716
3097
|
save();
|
|
2717
|
-
await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceilingUsd
|
|
3098
|
+
await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
|
|
3099
|
+
// save() merges a stop written by another process into
|
|
3100
|
+
// this record; the model loop polls this on a clock.
|
|
3101
|
+
() => {
|
|
3102
|
+
save();
|
|
3103
|
+
return run.stopRequested === true;
|
|
3104
|
+
});
|
|
2718
3105
|
// runStep mutates step.status; read it through a widened local
|
|
2719
3106
|
// so TS doesn't keep the "running" narrowing from above.
|
|
2720
3107
|
const outcome = step.status;
|
|
3108
|
+
recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
|
|
3109
|
+
save();
|
|
2721
3110
|
if (outcome !== "failed" || attempt === attempts)
|
|
2722
3111
|
break;
|
|
2723
3112
|
// A stop destroys the step's sandbox, which reads here as a
|
|
@@ -2727,14 +3116,45 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2727
3116
|
save();
|
|
2728
3117
|
if (run.stopRequested)
|
|
2729
3118
|
break;
|
|
3119
|
+
// The retry policy. An attempt the cluster ended for want of
|
|
3120
|
+
// memory or disk is retried one size class up — the same
|
|
3121
|
+
// size would end the same way. Every retry waits: 15 s, 30 s,
|
|
3122
|
+
// 1 m, 2 m, 4 m, jittered, so a provider or a site that was
|
|
3123
|
+
// struggling gets a moment, and twenty fan-out steps do not
|
|
3124
|
+
// hammer it in lock-step. The wait holds a worker slot, not
|
|
3125
|
+
// a sandbox — nothing is rented while it waits — and a stop
|
|
3126
|
+
// ends it. The step is `pending` meanwhile, so a driver that
|
|
3127
|
+
// inherits the run mid-wait simply runs the attempt.
|
|
3128
|
+
const last = [...step.events].reverse().find((e) => e.type === "error")?.text ?? "";
|
|
3129
|
+
let sizeNote = "";
|
|
3130
|
+
if (/OOMKilled|Evicted|ephemeral-storage|out of memory/i.test(last)) {
|
|
3131
|
+
const up = step.sizeUp === "large" || (!step.sizeUp && step.size === "large") ? "heavy" : step.sizeUp === "heavy" || step.size === "heavy" ? null : "large";
|
|
3132
|
+
if (up) {
|
|
3133
|
+
step.sizeUp = up;
|
|
3134
|
+
sizeNote = ` at size: ${up}`;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
const waitMs = Math.round(Math.min(retryBaseMs() * 16, retryBaseMs() * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5));
|
|
3138
|
+
step.status = "pending";
|
|
2730
3139
|
step.events.push({
|
|
2731
3140
|
t: new Date().toISOString(),
|
|
2732
3141
|
type: "info",
|
|
2733
|
-
text: `attempt ${attempt} failed — retrying (${attempts - attempt} left)`,
|
|
3142
|
+
text: `attempt ${attempt} failed — retrying in ${Math.round(waitMs / 1000)}s${sizeNote} (${attempts - attempt} left)`,
|
|
2734
3143
|
});
|
|
2735
3144
|
save();
|
|
3145
|
+
const until = Date.now() + waitMs;
|
|
3146
|
+
while (Date.now() < until) {
|
|
3147
|
+
await sleep(Math.min(1000, until - Date.now()));
|
|
3148
|
+
save();
|
|
3149
|
+
if (run.stopRequested)
|
|
3150
|
+
break;
|
|
3151
|
+
}
|
|
3152
|
+
if (run.stopRequested) {
|
|
3153
|
+
step.status = "failed";
|
|
3154
|
+
break;
|
|
3155
|
+
}
|
|
2736
3156
|
}
|
|
2737
|
-
}
|
|
3157
|
+
}
|
|
2738
3158
|
// on-fail: another agent takes the step over. Sequential and rare:
|
|
2739
3159
|
// the rescue is a second full attempt at the step's job with the
|
|
2740
3160
|
// failure in front of it, and its success is the step's success —
|
|
@@ -2786,7 +3206,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2786
3206
|
rescue.attempts = 1;
|
|
2787
3207
|
rescue.status = "running";
|
|
2788
3208
|
save();
|
|
2789
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData);
|
|
3209
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
|
|
2790
3210
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
2791
3211
|
const rescueOutcome = rescue.status;
|
|
2792
3212
|
if (rescueOutcome === "completed") {
|
|
@@ -2919,16 +3339,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2919
3339
|
}
|
|
2920
3340
|
catch (err) {
|
|
2921
3341
|
run.status = "failed";
|
|
3342
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
3343
|
+
let told = false;
|
|
2922
3344
|
run.steps.forEach((s) => {
|
|
2923
3345
|
if (s.status === "running") {
|
|
2924
3346
|
s.status = "failed";
|
|
2925
|
-
s.events.push({
|
|
2926
|
-
|
|
2927
|
-
type: "error",
|
|
2928
|
-
text: err instanceof Error ? err.message : String(err),
|
|
2929
|
-
});
|
|
3347
|
+
s.events.push({ t: new Date().toISOString(), type: "error", text });
|
|
3348
|
+
told = true;
|
|
2930
3349
|
}
|
|
2931
3350
|
});
|
|
3351
|
+
// Thrown between groups, with nothing running: the error still has to
|
|
3352
|
+
// land on some step's trail, or the run reads as failed for no reason.
|
|
3353
|
+
if (!told) {
|
|
3354
|
+
const witness = run.steps.findLast((s) => s.status !== "pending") ?? run.steps[0];
|
|
3355
|
+
witness?.events.push({
|
|
3356
|
+
t: new Date().toISOString(),
|
|
3357
|
+
type: "error",
|
|
3358
|
+
text: `run failed between steps: ${text}`,
|
|
3359
|
+
});
|
|
3360
|
+
}
|
|
2932
3361
|
}
|
|
2933
3362
|
finally {
|
|
2934
3363
|
if (!parked) {
|
|
@@ -2939,6 +3368,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2939
3368
|
// learn, and the runs list and the notification stop having to say
|
|
2940
3369
|
// only "completed".
|
|
2941
3370
|
run.summary = runSummary(run);
|
|
3371
|
+
// A test run's headline says so wherever the headline goes — the
|
|
3372
|
+
// runs list, the history tool, a later run's recall.
|
|
3373
|
+
if (run.test && run.summary)
|
|
3374
|
+
run.summary = testHeadline(run.summary);
|
|
2942
3375
|
// Archive before the next run resets outputs/ — a failed run's
|
|
2943
3376
|
// artifacts are usually the most interesting ones, so this runs on
|
|
2944
3377
|
// failure too.
|
|
@@ -2976,17 +3409,6 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2976
3409
|
}
|
|
2977
3410
|
})();
|
|
2978
3411
|
}
|
|
2979
|
-
function readFlow(tenant, workspace, flowName) {
|
|
2980
|
-
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
2981
|
-
if (!fs.existsSync(dir))
|
|
2982
|
-
return null;
|
|
2983
|
-
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
2984
|
-
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2985
|
-
if (flow.name === flowName)
|
|
2986
|
-
return flow;
|
|
2987
|
-
}
|
|
2988
|
-
return null;
|
|
2989
|
-
}
|
|
2990
3412
|
// Splice `[[flow:other]]` steps into the parent's step list, renumbering
|
|
2991
3413
|
// groups so the nested flow's own parallelism survives. Depth-limited, and
|
|
2992
3414
|
// a cycle raises rather than expanding forever.
|
|
@@ -3051,6 +3473,9 @@ export function loadFlow(tenant, workspace, flowName) {
|
|
|
3051
3473
|
// once it has been quiet for two minutes — the margin is for a record
|
|
3052
3474
|
// written a beat before its driver registered, not a judgement about pace.
|
|
3053
3475
|
const ABANDONED_AFTER_MS = 2 * 60 * 1000;
|
|
3476
|
+
/** The first retry's wait; each one after doubles it, to sixteen times
|
|
3477
|
+
* this (15 s → 4 min by default). An env, so a test can make it small. */
|
|
3478
|
+
const retryBaseMs = () => Number(process.env.FOLDRUN_RETRY_BASE_MS) || 15_000;
|
|
3054
3479
|
/** The most recent moment a run showed any sign of life. */
|
|
3055
3480
|
function lastActivity(run) {
|
|
3056
3481
|
let latest = Date.parse(run.startedAt);
|
|
@@ -3082,12 +3507,19 @@ function lastActivity(run) {
|
|
|
3082
3507
|
* outlives any process. A run whose person already answered is not waiting,
|
|
3083
3508
|
* and is picked back up — see below.
|
|
3084
3509
|
*/
|
|
3085
|
-
export function reconcileRuns(tenant, now = Date.now()) {
|
|
3510
|
+
export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
3086
3511
|
const closed = [];
|
|
3087
3512
|
for (const workspace of listWorkspaces(tenant)) {
|
|
3088
3513
|
for (const summary of listRuns(tenant, workspace.name)) {
|
|
3089
3514
|
if (drivingRuns.has(summary.id))
|
|
3090
3515
|
continue;
|
|
3516
|
+
// Driven by another process — a second worker pod, on a platform with
|
|
3517
|
+
// more than one. The idle window protects a quiet step from THIS
|
|
3518
|
+
// process's judgement only; a worker that boots beside a live one
|
|
3519
|
+
// would otherwise close every long step its neighbour is driving.
|
|
3520
|
+
// The caller says which runs those are (a heartbeated claim).
|
|
3521
|
+
if (live?.has(summary.id))
|
|
3522
|
+
continue;
|
|
3091
3523
|
if (now - lastActivity(summary) < ABANDONED_AFTER_MS)
|
|
3092
3524
|
continue;
|
|
3093
3525
|
// Approved, then abandoned. Approval only writes to the run record; the
|
|
@@ -3168,8 +3600,8 @@ export function reconcileRuns(tenant, now = Date.now()) {
|
|
|
3168
3600
|
* one hard-coded account left every other account's interrupted runs marked
|
|
3169
3601
|
* `running` forever, with no process left to finish them.
|
|
3170
3602
|
*/
|
|
3171
|
-
export function reconcileAllRuns(now = Date.now()) {
|
|
3172
|
-
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now));
|
|
3603
|
+
export function reconcileAllRuns(now = Date.now(), live) {
|
|
3604
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
|
|
3173
3605
|
}
|
|
3174
3606
|
/**
|
|
3175
3607
|
* Wait for a run to stop being in progress.
|