@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/src/runner.ts
CHANGED
|
@@ -7,11 +7,16 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import matter from "gray-matter";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
|
|
11
12
|
import { eventUrl } from "./webhook.ts";
|
|
12
13
|
import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
|
|
14
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
15
|
+
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
13
16
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
14
17
|
import { platform } from "./platform.ts";
|
|
18
|
+
import { healthKey } from "./secret-health.ts";
|
|
19
|
+
import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
|
|
15
20
|
|
|
16
21
|
/** Does this process run steps in a sandbox — the container core ships, or
|
|
17
22
|
* one the platform registered (a pod)? */
|
|
@@ -37,6 +42,8 @@ import {
|
|
|
37
42
|
syncBundleFor,
|
|
38
43
|
parseToolDef,
|
|
39
44
|
parseFlow,
|
|
45
|
+
readFlow,
|
|
46
|
+
markerPresent,
|
|
40
47
|
parseApis,
|
|
41
48
|
resolveModel,
|
|
42
49
|
parseProvider,
|
|
@@ -52,8 +59,12 @@ import {
|
|
|
52
59
|
type McpSpec,
|
|
53
60
|
type RunRecord,
|
|
54
61
|
type StepRecord,
|
|
62
|
+
type StepAttempt,
|
|
63
|
+
sumAttempts,
|
|
64
|
+
parseSchemaText,
|
|
55
65
|
STORAGE_DIR,
|
|
56
66
|
adoptLegacyVersionKey,
|
|
67
|
+
listAgents,
|
|
57
68
|
runCost,
|
|
58
69
|
} from "./store.ts";
|
|
59
70
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
|
|
@@ -71,6 +82,7 @@ import { materializeFiles, harvestFiles } from "./storage.ts";
|
|
|
71
82
|
import { chooseExecutor, ensureImage } from "./container.ts";
|
|
72
83
|
import { stampBundle } from "./okf.ts";
|
|
73
84
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
85
|
+
import { trimChars } from "./paths.ts";
|
|
74
86
|
|
|
75
87
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
76
88
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
@@ -122,6 +134,32 @@ export function joinEarlierGroups(groupResults: (string | null)[], gi: number, c
|
|
|
122
134
|
return parts.join("\n\n");
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
/**
|
|
138
|
+
* `parallel: N` — a pool of N slots the instances of one fan-out share.
|
|
139
|
+
* Fan-out always ran every instance at once, and still does unless the
|
|
140
|
+
* step says otherwise: twenty browser steps against one site, or one
|
|
141
|
+
* vendor's rate limit, is what the cap is for. Nothing is rented while an
|
|
142
|
+
* instance waits for a slot.
|
|
143
|
+
*/
|
|
144
|
+
export function slotPool(size: number): { acquire: () => Promise<void>; release: () => void } {
|
|
145
|
+
let free = Math.max(1, size);
|
|
146
|
+
const waiting: (() => void)[] = [];
|
|
147
|
+
return {
|
|
148
|
+
acquire: () =>
|
|
149
|
+
new Promise<void>((resolve) => {
|
|
150
|
+
if (free > 0) {
|
|
151
|
+
free -= 1;
|
|
152
|
+
resolve();
|
|
153
|
+
} else waiting.push(resolve);
|
|
154
|
+
}),
|
|
155
|
+
release: () => {
|
|
156
|
+
const next = waiting.shift();
|
|
157
|
+
if (next) next();
|
|
158
|
+
else free += 1;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
125
163
|
export interface DiscoveredSkill {
|
|
126
164
|
name: string;
|
|
127
165
|
description: string;
|
|
@@ -377,10 +415,9 @@ export function saysUntilMarker(result: string | null, until: string): boolean {
|
|
|
377
415
|
(line) =>
|
|
378
416
|
// Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
|
|
379
417
|
// is a decision written by someone using bold, not a sentence.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
.replace(/[.!:,*_`\s]+$/, "")
|
|
418
|
+
// trimChars, not a `[…]+$` regex: that one is quadratic on a
|
|
419
|
+
// line of tabs, and this is a line an agent wrote.
|
|
420
|
+
trimChars(trimChars(line.trim(), "#>-*_`"), ".!:,*_`").trim()
|
|
384
421
|
.toLowerCase() === marker,
|
|
385
422
|
);
|
|
386
423
|
}
|
|
@@ -470,7 +507,10 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
|
|
|
470
507
|
map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
|
|
471
508
|
}
|
|
472
509
|
}
|
|
473
|
-
|
|
510
|
+
// `[` excluded from the name: with it allowed, a run of `[[` makes the
|
|
511
|
+
// engine re-scan to the end from every opening — quadratic on a result
|
|
512
|
+
// an agent wrote.
|
|
513
|
+
return text.replace(/\[\[([^\]\[\n]+)\]\]/g, (whole, name: string) => {
|
|
474
514
|
const hit = map.get(norm(name.trim()));
|
|
475
515
|
return hit ? `\`${hit}\`` : whole;
|
|
476
516
|
});
|
|
@@ -1196,6 +1236,49 @@ function repriced(
|
|
|
1196
1236
|
return known;
|
|
1197
1237
|
}
|
|
1198
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* Write one attempt onto the step's history and re-total the step.
|
|
1241
|
+
*
|
|
1242
|
+
* runStep reports the attempt it just ran — its cost, tokens and sandbox
|
|
1243
|
+
* seconds — by assigning the step's fields. Those become this attempt's row;
|
|
1244
|
+
* the step's fields then become the sum of every row, which is what the
|
|
1245
|
+
* run's spend, the flow's budget: and the platform's bill read. A retried
|
|
1246
|
+
* step used to carry only its last attempt's cost, so a `retry: 2` step
|
|
1247
|
+
* could spend three shares while reporting one.
|
|
1248
|
+
*/
|
|
1249
|
+
export function recordAttempt(
|
|
1250
|
+
step: StepRecord,
|
|
1251
|
+
n: number,
|
|
1252
|
+
status: "completed" | "failed",
|
|
1253
|
+
startedAt: string,
|
|
1254
|
+
eventsBefore: number,
|
|
1255
|
+
/** The attempt re-attached to a sandbox a previous driver left: same
|
|
1256
|
+
* attempt, so its row is replaced rather than repeated. */
|
|
1257
|
+
reattach = false,
|
|
1258
|
+
) {
|
|
1259
|
+
const tries = step.tries ?? [];
|
|
1260
|
+
const lastError = step.events
|
|
1261
|
+
.slice(eventsBefore)
|
|
1262
|
+
.filter((e) => e.type === "error")
|
|
1263
|
+
.at(-1)?.text;
|
|
1264
|
+
const row: StepAttempt = {
|
|
1265
|
+
n,
|
|
1266
|
+
status,
|
|
1267
|
+
costUsd: step.costUsd,
|
|
1268
|
+
tokens: step.tokens ?? null,
|
|
1269
|
+
computeSecs: step.computeSecs ?? null,
|
|
1270
|
+
startedAt,
|
|
1271
|
+
finishedAt: step.finishedAt ?? new Date().toISOString(),
|
|
1272
|
+
...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
|
|
1273
|
+
};
|
|
1274
|
+
const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
|
|
1275
|
+
step.tries = [...kept, row];
|
|
1276
|
+
const totals = sumAttempts(step.tries);
|
|
1277
|
+
step.costUsd = totals.costUsd;
|
|
1278
|
+
step.tokens = totals.tokens;
|
|
1279
|
+
step.computeSecs = totals.computeSecs;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1199
1282
|
async function runStep(
|
|
1200
1283
|
agentDir: string,
|
|
1201
1284
|
tenant: string,
|
|
@@ -1213,6 +1296,15 @@ async function runStep(
|
|
|
1213
1296
|
* remaining `budget:`, shared across the steps launched with it. Null
|
|
1214
1297
|
* means the flow set no budget. */
|
|
1215
1298
|
stepBudgetUsd: number | null = null,
|
|
1299
|
+
/** Which line set that ceiling, for the error that names it. */
|
|
1300
|
+
stepBudgetNote?: string,
|
|
1301
|
+
/** The run is a test run — see test-mode.ts. Read off the run record by
|
|
1302
|
+
* the caller, never from anything the sandbox could have written. */
|
|
1303
|
+
testRun = false,
|
|
1304
|
+
/** Has a person asked for the run to stop? Read on a clock by the
|
|
1305
|
+
* in-process model loop, so a stop lands mid-step and not at the next
|
|
1306
|
+
* group. The isolated path needs none: stopRun destroys the sandbox. */
|
|
1307
|
+
stopRequested?: () => boolean,
|
|
1216
1308
|
) {
|
|
1217
1309
|
// Secret values are injected into scripts as environment variables and
|
|
1218
1310
|
// substituted into API headers, so a model that reads one back — from a
|
|
@@ -1237,6 +1329,22 @@ async function runStep(
|
|
|
1237
1329
|
return out;
|
|
1238
1330
|
};
|
|
1239
1331
|
|
|
1332
|
+
// The reply, the conclusion and an `output: json` value land on the same
|
|
1333
|
+
// record the events do, and a model that quotes a script's stdout puts
|
|
1334
|
+
// the credential in its answer rather than in a tool event. Same scrub,
|
|
1335
|
+
// same place. The JSON value goes through its text form: a secret inside
|
|
1336
|
+
// a nested field is still a secret, and the key name survives either way.
|
|
1337
|
+
const redactText = <T extends string | null | undefined>(text: T): T =>
|
|
1338
|
+
(typeof text === "string" ? redact(text) : text) as T;
|
|
1339
|
+
const redactData = (value: unknown): unknown => {
|
|
1340
|
+
if (value === undefined || redactions.length === 0) return value;
|
|
1341
|
+
try {
|
|
1342
|
+
return JSON.parse(redact(JSON.stringify(value)));
|
|
1343
|
+
} catch {
|
|
1344
|
+
return value;
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1240
1348
|
const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
|
|
1241
1349
|
step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
|
|
1242
1350
|
save();
|
|
@@ -1276,8 +1384,15 @@ async function runStep(
|
|
|
1276
1384
|
// the failure of a step that returned no JSON are testable at no cost.
|
|
1277
1385
|
if (step.output === "json") {
|
|
1278
1386
|
const extracted = extractJson(step.result);
|
|
1279
|
-
if (extracted.ok)
|
|
1280
|
-
|
|
1387
|
+
if (extracted.ok) {
|
|
1388
|
+
step.data = extracted.value;
|
|
1389
|
+
const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
|
|
1390
|
+
const errors = schema && looksLikeSchema(schema) ? validateSchema(step.data, schema) : [];
|
|
1391
|
+
if (errors.length) {
|
|
1392
|
+
push("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
|
|
1393
|
+
step.status = "failed";
|
|
1394
|
+
}
|
|
1395
|
+
} else {
|
|
1281
1396
|
push("error", `output: json — ${extracted.reason}`);
|
|
1282
1397
|
step.status = "failed";
|
|
1283
1398
|
}
|
|
@@ -1297,11 +1412,14 @@ async function runStep(
|
|
|
1297
1412
|
front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools,
|
|
1298
1413
|
secretEnv, secretScopes, missingSecrets, missingTools, runtime,
|
|
1299
1414
|
unknownTools, shadowed, legacyUse, mcpServers, mcpNames,
|
|
1300
|
-
apiSpecs, scriptSpecs, brokenTools, size,
|
|
1415
|
+
apiSpecs, scriptSpecs, brokenTools, size: agentSize,
|
|
1301
1416
|
providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning,
|
|
1302
1417
|
fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
|
|
1303
1418
|
translator, fallbackTranslator,
|
|
1304
1419
|
} = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
|
|
1420
|
+
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
1421
|
+
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
1422
|
+
const size = step.sizeUp ?? agentSize;
|
|
1305
1423
|
|
|
1306
1424
|
// oauth2 secrets become live access tokens here — the one async moment
|
|
1307
1425
|
// in secret resolution, at the last host-side point before anything is
|
|
@@ -1309,7 +1427,7 @@ async function runStep(
|
|
|
1309
1427
|
// what crosses into containers, headers and env files is the token. A
|
|
1310
1428
|
// failed refresh fails the step naming the secret and the provider's
|
|
1311
1429
|
// reason, which beats a 401 three layers later.
|
|
1312
|
-
const liveSecrets = await materializeSecrets(secretEnv);
|
|
1430
|
+
const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
|
|
1313
1431
|
// @file values (SSH keys, certs) still carry their content at this point.
|
|
1314
1432
|
// The in-process branch materialises them to 0600 paths on this host
|
|
1315
1433
|
// below; the container branch passes the content across and the driver
|
|
@@ -1439,6 +1557,11 @@ async function runStep(
|
|
|
1439
1557
|
if (fitted.note) push("info", fitted.note);
|
|
1440
1558
|
effort = fitted.effort;
|
|
1441
1559
|
}
|
|
1560
|
+
// No catalogue — the platform's own Anthropic key, or a gateway that
|
|
1561
|
+
// publishes no prices — but the model's NAME is often enough. Without
|
|
1562
|
+
// this the in-pod meter priced haiku and sonnet at Opus rates and a
|
|
1563
|
+
// step was stopped at up to fifteen times what it had spent.
|
|
1564
|
+
tokenPrice ??= knownPrice(wireModel);
|
|
1442
1565
|
|
|
1443
1566
|
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
1444
1567
|
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
@@ -1452,6 +1575,13 @@ async function runStep(
|
|
|
1452
1575
|
// The confinement boundary: agents in a workspace are one team, but the
|
|
1453
1576
|
// workspace is where isolation is enforced.
|
|
1454
1577
|
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
1578
|
+
// Where a test run's state/ and storage/ writes go, and the line each
|
|
1579
|
+
// one leaves: under the run, beside its archived outputs.
|
|
1580
|
+
const divertFor = (id: string) => ({
|
|
1581
|
+
to: path.join(workspaceRoot, "runs", id, TEST_WRITES_DIR),
|
|
1582
|
+
note: (rel: string, summary: string) =>
|
|
1583
|
+
push("info", `test: ${summary}`, { effect: { kind: "diverted" as const, path: rel, summary } }),
|
|
1584
|
+
});
|
|
1455
1585
|
|
|
1456
1586
|
let prompt = step.instruction
|
|
1457
1587
|
? resolveDocLinks(step.instruction, workspaceRoot)
|
|
@@ -1468,11 +1598,18 @@ async function runStep(
|
|
|
1468
1598
|
if (step.output === "json") {
|
|
1469
1599
|
// The contract the runner parses back out — stated in the prompt so
|
|
1470
1600
|
// the instruction and the parser agree by construction, like delegate:.
|
|
1601
|
+
// The schema, when there is one, is quoted whole: the model that has
|
|
1602
|
+
// to satisfy it should not have to guess it from the field names.
|
|
1603
|
+
const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
|
|
1471
1604
|
prompt +=
|
|
1472
1605
|
`\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
|
|
1473
1606
|
"```json fenced block — the value the next step will compute on. Prose before it is " +
|
|
1474
1607
|
`welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
|
|
1475
|
-
`names fields, use exactly those names
|
|
1608
|
+
`names fields, use exactly those names.` +
|
|
1609
|
+
(schema && looksLikeSchema(schema)
|
|
1610
|
+
? ` 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)}`
|
|
1611
|
+
: "") +
|
|
1612
|
+
`\n</output>`;
|
|
1476
1613
|
}
|
|
1477
1614
|
if (step.waitFor === "event" && step.eventPayload !== undefined) {
|
|
1478
1615
|
// Not resolved for links, like the previous step's results: it is
|
|
@@ -1539,6 +1676,16 @@ async function runStep(
|
|
|
1539
1676
|
// platform credential.
|
|
1540
1677
|
const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
|
|
1541
1678
|
|
|
1679
|
+
// `schema:` — inline on the step, or a file under the workspace read
|
|
1680
|
+
// now, host-side, so both executors are handed the same document. A
|
|
1681
|
+
// file that cannot be read is a failed step before any model runs:
|
|
1682
|
+
// the contract the author wrote down cannot be checked.
|
|
1683
|
+
const resolvedSchema: Record<string, unknown> | boolean | null = step.schema ?? (step.schemaPath ? readSchemaFile(workspaceRoot, step.schemaPath) : null);
|
|
1684
|
+
if (step.schemaPath && resolvedSchema === null) {
|
|
1685
|
+
throw new Error(`schema: ${step.schemaPath} — not a readable JSON or YAML schema under the workspace`);
|
|
1686
|
+
}
|
|
1687
|
+
if (step.maxTurns) push("info", `max_turns: ${step.maxTurns}`);
|
|
1688
|
+
|
|
1542
1689
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
1543
1690
|
if (isolatedRun()) {
|
|
1544
1691
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -1566,10 +1713,20 @@ async function runStep(
|
|
|
1566
1713
|
const host = hostOf(api.base);
|
|
1567
1714
|
if (!host) continue;
|
|
1568
1715
|
for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
1569
|
-
for (const name of placeholderNames(v))
|
|
1716
|
+
for (const name of placeholderNames(v)) {
|
|
1717
|
+
if (!(name in liveSecrets)) continue;
|
|
1718
|
+
addGrant(grant, name, liveSecrets[name], host);
|
|
1719
|
+
// Which vault entry this is, so the proxy's record of what the
|
|
1720
|
+
// far end said lands on the right credential.
|
|
1721
|
+
(grant.healthKeys ??= {})[name] = healthKey(
|
|
1722
|
+
name,
|
|
1723
|
+
secretScopes[name] === "workspace" ? "workspace" : "account",
|
|
1724
|
+
path.basename(path.resolve(agentDir, "..", "..")),
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1570
1727
|
}
|
|
1571
1728
|
}
|
|
1572
|
-
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant });
|
|
1729
|
+
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
|
|
1573
1730
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
1574
1731
|
// reads them from its environment, and unless it says `secrets:
|
|
1575
1732
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -1592,15 +1749,36 @@ async function runStep(
|
|
|
1592
1749
|
}
|
|
1593
1750
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
1594
1751
|
// a placeholder that the proxy fills on the way out.
|
|
1595
|
-
const
|
|
1596
|
-
|
|
1597
|
-
|
|
1752
|
+
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
1753
|
+
let sandboxSecrets: Record<string, string> = materialise ? liveSecrets : placeholders;
|
|
1754
|
+
// A test run hands the sandbox no send-capable value. The env var is
|
|
1755
|
+
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
1756
|
+
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
1757
|
+
// error — and sends nothing. The script tools say whether the step
|
|
1758
|
+
// has a declared sender (everything withheld) or a declared reader
|
|
1759
|
+
// (nothing withheld); see test-mode.ts for the order.
|
|
1760
|
+
if (testRun && materialise) {
|
|
1761
|
+
const held = withholdSecrets(liveSecrets, {
|
|
1762
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
1763
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
1764
|
+
});
|
|
1765
|
+
sandboxSecrets = held.env;
|
|
1766
|
+
for (const name of held.withheld) {
|
|
1767
|
+
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
// On a test run the http tools always go through the proxy as
|
|
1771
|
+
// placeholders, even when a script made the step materialise: the
|
|
1772
|
+
// proxy is where the outward-write policy lives, and a header
|
|
1773
|
+
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
1774
|
+
// been sunk or refused.
|
|
1775
|
+
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
1598
1776
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
1599
1777
|
...api,
|
|
1600
1778
|
headers: Object.fromEntries(
|
|
1601
1779
|
Object.entries(api.headers).map(([k, v]) => [
|
|
1602
1780
|
k,
|
|
1603
|
-
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) =>
|
|
1781
|
+
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => apiValues[name] ?? whole),
|
|
1604
1782
|
]),
|
|
1605
1783
|
),
|
|
1606
1784
|
}));
|
|
@@ -1630,6 +1808,7 @@ async function runStep(
|
|
|
1630
1808
|
const isolatedArgs = (modelEnv: Record<string, string | undefined>, keyName = MODEL_KEY_NAME) => ({
|
|
1631
1809
|
workspaceRoot,
|
|
1632
1810
|
libraryRoot: libraryDir(tenant),
|
|
1811
|
+
galleryRoot: platform.galleryDir() ?? undefined,
|
|
1633
1812
|
// Keys this step's dependency cache. Per-account by design — see
|
|
1634
1813
|
// run-container.ts#runtimeCacheDir for why it is not shared.
|
|
1635
1814
|
tenant,
|
|
@@ -1650,9 +1829,12 @@ async function runStep(
|
|
|
1650
1829
|
consults,
|
|
1651
1830
|
timeoutSec: step.timeout,
|
|
1652
1831
|
budgetUsd: stepBudgetUsd,
|
|
1832
|
+
budgetNote: stepBudgetNote,
|
|
1653
1833
|
price: tokenPrice,
|
|
1654
1834
|
verify: step.verify,
|
|
1655
1835
|
output: step.output,
|
|
1836
|
+
schema: resolvedSchema ?? undefined,
|
|
1837
|
+
maxTurns: step.maxTurns,
|
|
1656
1838
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
1657
1839
|
// The container sees the workspace at /workspace and the library
|
|
1658
1840
|
// at /library; the roots are named host-side and moved here.
|
|
@@ -1683,6 +1865,7 @@ async function runStep(
|
|
|
1683
1865
|
FOLDRUN_RUN_ID: runId,
|
|
1684
1866
|
FOLDRUN_AGENT: step.agent,
|
|
1685
1867
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
1868
|
+
...(testRun ? testModeEnv() : {}),
|
|
1686
1869
|
...sandboxSecrets,
|
|
1687
1870
|
}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
1688
1871
|
),
|
|
@@ -1690,6 +1873,21 @@ async function runStep(
|
|
|
1690
1873
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
1691
1874
|
runId,
|
|
1692
1875
|
size,
|
|
1876
|
+
// A test run's state/ and storage/ changes come out under the run.
|
|
1877
|
+
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
1878
|
+
test: testRun,
|
|
1879
|
+
// Resume: a sandbox a previous driver left running, recorded on the
|
|
1880
|
+
// step. Consumed once — a fresh attempt after this one (a retry, a
|
|
1881
|
+
// fallback) starts its own sandbox.
|
|
1882
|
+
resume: step.sandbox && step.sandbox.kind === isolation ? { ref: step.sandbox.ref, consumed: step.sandbox.consumed } : null,
|
|
1883
|
+
// Checkpoint: where the executor is, on the record, so a driver
|
|
1884
|
+
// that inherits this run can attach where this one left off. The
|
|
1885
|
+
// executor calls it before applying each line; the line's own emit
|
|
1886
|
+
// saves the record, so this only updates the fields.
|
|
1887
|
+
checkpoint: (ref: string, consumed: number) => {
|
|
1888
|
+
step.sandbox = { kind: isolation!, ref, consumed, since: step.sandbox?.since ?? new Date().toISOString() };
|
|
1889
|
+
if (consumed === 0) save();
|
|
1890
|
+
},
|
|
1693
1891
|
});
|
|
1694
1892
|
|
|
1695
1893
|
// A busy provider gets another moment on the SAME supply before the
|
|
@@ -1700,6 +1898,7 @@ async function runStep(
|
|
|
1700
1898
|
const firstArgs = isolatedArgs(platformModelEnv());
|
|
1701
1899
|
await lease?.commit();
|
|
1702
1900
|
let outcome = await runIsolated(firstArgs);
|
|
1901
|
+
step.sandbox = null; // whatever happens next starts its own
|
|
1703
1902
|
for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
|
|
1704
1903
|
const wait = backoffMs(n, lastRefusal);
|
|
1705
1904
|
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
@@ -1745,14 +1944,23 @@ async function runStep(
|
|
|
1745
1944
|
outcome = withEarlierTiming(outcome, first);
|
|
1746
1945
|
}
|
|
1747
1946
|
if (lease) {
|
|
1748
|
-
for (const line of await lease.drainLog())
|
|
1947
|
+
for (const line of await lease.drainLog()) {
|
|
1948
|
+
if (typeof line === "string") push("info", line);
|
|
1949
|
+
else push("info", line.text, { effect: line.effect });
|
|
1950
|
+
}
|
|
1749
1951
|
await lease.release();
|
|
1750
1952
|
}
|
|
1751
|
-
|
|
1953
|
+
// Nothing a test run left in storage/public/ reached storage/, so
|
|
1954
|
+
// there is nothing to publish — and a share link is an outward act.
|
|
1955
|
+
if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1752
1956
|
step.status = outcome.status;
|
|
1753
|
-
step.result = outcome.result;
|
|
1754
|
-
step.conclusion = outcome.conclusion;
|
|
1755
|
-
|
|
1957
|
+
step.result = redactText(outcome.result);
|
|
1958
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
1959
|
+
step.sandbox = null;
|
|
1960
|
+
// The cluster's word on why a sandbox ended without the driver saying
|
|
1961
|
+
// so — OOMKilled, Evicted — on the record, where the retry reads it.
|
|
1962
|
+
if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
|
|
1963
|
+
if (outcome.data !== undefined) step.data = redactData(outcome.data);
|
|
1756
1964
|
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
|
|
1757
1965
|
step.tokens = outcome.usage
|
|
1758
1966
|
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
@@ -1785,14 +1993,27 @@ async function runStep(
|
|
|
1785
1993
|
})();
|
|
1786
1994
|
step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
|
|
1787
1995
|
} else {
|
|
1996
|
+
// Same withholding as the sandbox path — the in-process step is what
|
|
1997
|
+
// `foldrun run --test` drives on a laptop, where there is no proxy
|
|
1998
|
+
// and this is the only guard there is.
|
|
1999
|
+
const held = testRun
|
|
2000
|
+
? withholdSecrets(liveSecrets, {
|
|
2001
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2002
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2003
|
+
})
|
|
2004
|
+
: { env: liveSecrets, withheld: [] as string[] };
|
|
2005
|
+
for (const name of held.withheld) {
|
|
2006
|
+
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
2007
|
+
}
|
|
2008
|
+
const stepSecrets = held.env;
|
|
1788
2009
|
const consultTools = buildConsultTools(
|
|
1789
2010
|
consults,
|
|
1790
|
-
{ ...
|
|
2011
|
+
{ ...hostSafeEnv(), ...stepSecrets, ...providerEnv },
|
|
1791
2012
|
(type, text) => push(type, text),
|
|
1792
2013
|
);
|
|
1793
2014
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
1794
2015
|
// finally — once, however many attempts follow.
|
|
1795
|
-
const mat = materializeFileSecrets(agentDir,
|
|
2016
|
+
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
1796
2017
|
fileDir = mat.dir;
|
|
1797
2018
|
const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
|
|
1798
2019
|
agentDir,
|
|
@@ -1814,13 +2035,20 @@ async function runStep(
|
|
|
1814
2035
|
...mcpServers,
|
|
1815
2036
|
},
|
|
1816
2037
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
1817
|
-
// only ever sees the variable names, not the values.
|
|
1818
|
-
|
|
2038
|
+
// only ever sees the variable names, not the values. The base is
|
|
2039
|
+
// the allowlisted host environment, not process.env: the SDK's
|
|
2040
|
+
// Bash tool runs with this, and on a platform process.env holds
|
|
2041
|
+
// every tenant's root key and the datastore credentials.
|
|
2042
|
+
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
|
|
1819
2043
|
timeoutSec: step.timeout,
|
|
1820
2044
|
budgetUsd: stepBudgetUsd,
|
|
2045
|
+
budgetNote: stepBudgetNote,
|
|
1821
2046
|
price: tokenPrice,
|
|
1822
2047
|
verify: step.verify,
|
|
1823
2048
|
output: step.output,
|
|
2049
|
+
schema: resolvedSchema ?? undefined,
|
|
2050
|
+
maxTurns: step.maxTurns,
|
|
2051
|
+
stopRequested,
|
|
1824
2052
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
1825
2053
|
// the proof a step left names THIS run, not one that came before —
|
|
1826
2054
|
// copy-back never propagates a deletion, so a marker from an earlier
|
|
@@ -1830,20 +2058,31 @@ async function runStep(
|
|
|
1830
2058
|
...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
|
|
1831
2059
|
FOLDRUN_AGENT: step.agent,
|
|
1832
2060
|
FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
|
|
1833
|
-
...
|
|
2061
|
+
...(testRun ? testModeEnv() : {}),
|
|
2062
|
+
...stepSecrets,
|
|
1834
2063
|
},
|
|
1835
2064
|
emit: pushWatching,
|
|
1836
2065
|
});
|
|
1837
2066
|
// With a translator the step's endpoint is the translator: started
|
|
1838
2067
|
// here, pointed at by the SDK's env, closed when the attempt ends.
|
|
2068
|
+
// A test run brackets each attempt with a snapshot of state/ and
|
|
2069
|
+
// storage/: the step writes them for real, because there is no
|
|
2070
|
+
// sandbox to write instead, and afterwards what changed is moved
|
|
2071
|
+
// under the run and the originals come back.
|
|
1839
2072
|
const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
|
|
1840
|
-
|
|
1841
|
-
const t = await startTranslator(spec);
|
|
2073
|
+
const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
|
|
2074
|
+
const t = spec ? await startTranslator(spec) : null;
|
|
1842
2075
|
try {
|
|
1843
|
-
return await attemptInProcess({ ...modelEnv, ...t.env });
|
|
2076
|
+
return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
|
|
1844
2077
|
} finally {
|
|
1845
|
-
|
|
1846
|
-
|
|
2078
|
+
if (t) {
|
|
2079
|
+
for (const line of t.drainLog()) push("info", line.startsWith("translator") ? line : `translator: ${line}`);
|
|
2080
|
+
await t.close();
|
|
2081
|
+
}
|
|
2082
|
+
if (before && runId) {
|
|
2083
|
+
const d = divertFor(runId);
|
|
2084
|
+
restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
|
|
2085
|
+
}
|
|
1847
2086
|
}
|
|
1848
2087
|
};
|
|
1849
2088
|
let outcome = await attemptThrough(providerEnv, translator);
|
|
@@ -1865,11 +2104,11 @@ async function runStep(
|
|
|
1865
2104
|
);
|
|
1866
2105
|
outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
|
|
1867
2106
|
}
|
|
1868
|
-
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
2107
|
+
if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1869
2108
|
step.status = outcome.status;
|
|
1870
|
-
step.result = outcome.result;
|
|
1871
|
-
step.conclusion = outcome.conclusion;
|
|
1872
|
-
if (outcome.data !== undefined) step.data = outcome.data;
|
|
2109
|
+
step.result = redactText(outcome.result);
|
|
2110
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
2111
|
+
if (outcome.data !== undefined) step.data = redactData(outcome.data);
|
|
1873
2112
|
// A consult's spend belongs to the step that asked.
|
|
1874
2113
|
const consultCost = consultTools.drainCost();
|
|
1875
2114
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
|
|
@@ -2163,6 +2402,26 @@ function csvItems(workspaceDir: string, step: StepRecord, take: number): string[
|
|
|
2163
2402
|
return data.slice(0, take).map((r) => `${header}\n${r}`);
|
|
2164
2403
|
}
|
|
2165
2404
|
|
|
2405
|
+
/**
|
|
2406
|
+
* `schema: <path>` — the file, read and parsed, or null. Agent-relative like
|
|
2407
|
+
* every other path in a flow (`../../schemas/lead.json`), confined to the
|
|
2408
|
+
* workspace: a schema is the author's document, never something a step
|
|
2409
|
+
* can point outside.
|
|
2410
|
+
*/
|
|
2411
|
+
export function readSchemaFile(workspaceRoot: string, rel: string): Record<string, unknown> | boolean | null {
|
|
2412
|
+
const root = path.resolve(workspaceRoot);
|
|
2413
|
+
// Written agent-relative (`../../schemas/x.json`) or workspace-relative
|
|
2414
|
+
// (`schemas/x.json`); both land on the same file.
|
|
2415
|
+
const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
|
|
2416
|
+
for (const abs of candidates) {
|
|
2417
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) continue;
|
|
2418
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
|
2419
|
+
const parsed = parseSchemaText(fs.readFileSync(abs, "utf8"));
|
|
2420
|
+
return "problem" in parsed ? null : parsed.schema;
|
|
2421
|
+
}
|
|
2422
|
+
return null;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2166
2425
|
export function createFlowRun(
|
|
2167
2426
|
tenant: string,
|
|
2168
2427
|
workspace: string,
|
|
@@ -2170,12 +2429,16 @@ export function createFlowRun(
|
|
|
2170
2429
|
flowName: string,
|
|
2171
2430
|
status: "queued" | "running",
|
|
2172
2431
|
tags: string[] = [],
|
|
2432
|
+
startedBy: string | null = null,
|
|
2433
|
+
opts: { test?: boolean } = {},
|
|
2173
2434
|
): RunRecord {
|
|
2174
2435
|
const run: RunRecord = {
|
|
2175
2436
|
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
2176
2437
|
flow: flowName,
|
|
2177
2438
|
tags,
|
|
2178
2439
|
status,
|
|
2440
|
+
...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
|
|
2441
|
+
...(opts.test ? { test: true } : {}),
|
|
2179
2442
|
startedAt: new Date().toISOString(),
|
|
2180
2443
|
finishedAt: null,
|
|
2181
2444
|
steps: steps.map((s) => ({
|
|
@@ -2205,6 +2468,10 @@ export function createFlowRun(
|
|
|
2205
2468
|
preview: s.preview,
|
|
2206
2469
|
delegate: s.delegate,
|
|
2207
2470
|
output: s.output,
|
|
2471
|
+
schema: s.schema,
|
|
2472
|
+
schemaPath: s.schemaPath,
|
|
2473
|
+
parallel: s.parallel,
|
|
2474
|
+
maxTurns: s.maxTurns,
|
|
2208
2475
|
attempts: 0,
|
|
2209
2476
|
status: "pending",
|
|
2210
2477
|
events: [],
|
|
@@ -2241,8 +2508,10 @@ export function startFlowRun(
|
|
|
2241
2508
|
modelOverride?: string | null,
|
|
2242
2509
|
tags: string[] = [],
|
|
2243
2510
|
effortOverride?: string | null,
|
|
2511
|
+
startedBy: string | null = null,
|
|
2512
|
+
opts: { test?: boolean } = {},
|
|
2244
2513
|
): RunRecord {
|
|
2245
|
-
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
|
|
2514
|
+
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
|
|
2246
2515
|
void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
|
|
2247
2516
|
return run;
|
|
2248
2517
|
}
|
|
@@ -2450,15 +2719,25 @@ function driveRunInner(
|
|
|
2450
2719
|
// never happened, which reconcile can then never see. Reset it to
|
|
2451
2720
|
// pending and it simply runs again; driveRun's whole contract is that
|
|
2452
2721
|
// re-driving a partly-finished record is safe.
|
|
2722
|
+
// Unless the step's sandbox outlived its driver AND the executor can
|
|
2723
|
+
// re-attach to it: then the record carries the pod and how far the
|
|
2724
|
+
// last driver got, runStep passes that as `resume`, and the step
|
|
2725
|
+
// carries on from where it was — a deploy no longer costs the run.
|
|
2453
2726
|
let orphaned = false;
|
|
2454
2727
|
for (const step of run.steps) {
|
|
2455
2728
|
if (step.status !== "running") continue;
|
|
2456
|
-
|
|
2729
|
+
const resumable = !!step.sandbox && platform.sandboxResumable(step.sandbox.kind);
|
|
2730
|
+
if (!resumable) {
|
|
2731
|
+
orphaned = true;
|
|
2732
|
+
step.sandbox = null;
|
|
2733
|
+
}
|
|
2457
2734
|
step.status = "pending";
|
|
2458
2735
|
step.events.push({
|
|
2459
2736
|
t: new Date().toISOString(),
|
|
2460
2737
|
type: "info",
|
|
2461
|
-
text:
|
|
2738
|
+
text: resumable
|
|
2739
|
+
? `driver changed mid-step — re-attaching to the running sandbox (${step.sandbox!.ref}, ${step.sandbox!.consumed} lines already applied)`
|
|
2740
|
+
: "interrupted mid-step (platform restart) — running again from the start of the step",
|
|
2462
2741
|
});
|
|
2463
2742
|
}
|
|
2464
2743
|
if (orphaned) {
|
|
@@ -2530,6 +2809,17 @@ function driveRunInner(
|
|
|
2530
2809
|
// is assembled) keeps the newest: when the total is over it, the
|
|
2531
2810
|
// oldest groups are dropped whole before anything is cut mid-text.
|
|
2532
2811
|
const contextBefore = (gi: number) => joinEarlierGroups(groupResults, gi);
|
|
2812
|
+
// What `when:` and `case:` read: the PREVIOUS group's result alone —
|
|
2813
|
+
// the nearest earlier group that produced one, so a routed-past group
|
|
2814
|
+
// between is looked through rather than read as silence. The prompt
|
|
2815
|
+
// gets every earlier group joined (contextBefore); routing must not,
|
|
2816
|
+
// because a verdict two groups back would still open a gate a later
|
|
2817
|
+
// group had overruled: a classifier's BUG line matched a `case: BUG`
|
|
2818
|
+
// three groups on, after the debugger had already answered it.
|
|
2819
|
+
const routingBefore = (gi: number): string | null => {
|
|
2820
|
+
for (let i = gi - 1; i >= 0; i--) if (groupResults[i]) return groupResults[i];
|
|
2821
|
+
return null;
|
|
2822
|
+
};
|
|
2533
2823
|
// The data half of the same handoff: what the nearest earlier group
|
|
2534
2824
|
// returned through `output: json`. Rebuilt from the record rather than
|
|
2535
2825
|
// kept only in memory, so a resumed run hands step 3 the value step 1
|
|
@@ -2594,8 +2884,11 @@ function driveRunInner(
|
|
|
2594
2884
|
context = contextBefore(gi);
|
|
2595
2885
|
ctxData = dataBefore(gi);
|
|
2596
2886
|
|
|
2597
|
-
|
|
2598
|
-
|
|
2887
|
+
// What the run has spent, not just what its tokens cost: on an
|
|
2888
|
+
// install that prices sandbox seconds, a step that called no model
|
|
2889
|
+
// still spent money, and a cap that ignored it capped nothing.
|
|
2890
|
+
if (run.budgetUsd && platform.runSpend(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
|
|
2891
|
+
const spent = platform.runSpend(run);
|
|
2599
2892
|
const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2600
2893
|
last?.events.push({
|
|
2601
2894
|
t: new Date().toISOString(),
|
|
@@ -2684,6 +2977,7 @@ function driveRunInner(
|
|
|
2684
2977
|
type: "info",
|
|
2685
2978
|
text:
|
|
2686
2979
|
`fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
|
|
2980
|
+
(step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
|
|
2687
2981
|
(dropped ? ` (more items existed — capped at ${cap})` : ""),
|
|
2688
2982
|
});
|
|
2689
2983
|
const at = run.steps.indexOf(step);
|
|
@@ -2698,6 +2992,7 @@ function driveRunInner(
|
|
|
2698
2992
|
result: null,
|
|
2699
2993
|
costUsd: null,
|
|
2700
2994
|
attempts: 0,
|
|
2995
|
+
tries: undefined,
|
|
2701
2996
|
}));
|
|
2702
2997
|
run.steps.splice(at + 1, 0, ...instances);
|
|
2703
2998
|
ordered = orderedGroups();
|
|
@@ -2711,12 +3006,25 @@ function driveRunInner(
|
|
|
2711
3006
|
// this is what keeps the *next* group from starting.
|
|
2712
3007
|
const stopCheck = readRun(tenant, workspace, run.id);
|
|
2713
3008
|
if (stopCheck?.stopRequested) {
|
|
3009
|
+
let skipped = 0;
|
|
2714
3010
|
for (const s of run.steps) {
|
|
2715
3011
|
if (s.status === "pending" || s.status === "running") {
|
|
2716
3012
|
s.status = "skipped";
|
|
2717
3013
|
s.skipReason = "run stopped";
|
|
3014
|
+
skipped += 1;
|
|
2718
3015
|
}
|
|
2719
3016
|
}
|
|
3017
|
+
// Say so on the trail. stopRun wrote "run stopped" onto the
|
|
3018
|
+
// record's first step, but the save() below writes this process's
|
|
3019
|
+
// copy of the record over it, so the only witness was the
|
|
3020
|
+
// stopRequested flag — and a run that "failed" with no error line
|
|
3021
|
+
// anywhere took a code read to explain (rank-desk, 2026-09-13).
|
|
3022
|
+
const witness = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
3023
|
+
witness?.events.push({
|
|
3024
|
+
t: new Date().toISOString(),
|
|
3025
|
+
type: "info",
|
|
3026
|
+
text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
|
|
3027
|
+
});
|
|
2720
3028
|
run.status = "failed";
|
|
2721
3029
|
run.stopRequested = true;
|
|
2722
3030
|
save();
|
|
@@ -2752,40 +3060,52 @@ function driveRunInner(
|
|
|
2752
3060
|
}
|
|
2753
3061
|
|
|
2754
3062
|
const ctx = context;
|
|
3063
|
+
// The previous group's result — see routingBefore.
|
|
3064
|
+
const routing = routingBefore(gi);
|
|
2755
3065
|
|
|
2756
|
-
// `when:` — skip a step whose condition isn't met by
|
|
3066
|
+
// `when:` — skip a step whose condition isn't met by the previous
|
|
3067
|
+
// group's result.
|
|
2757
3068
|
for (const step of freshGroup) {
|
|
2758
3069
|
if (step.status !== "pending" || !step.when) continue;
|
|
2759
|
-
|
|
3070
|
+
// A marker at the start of a line, not a word anywhere in the
|
|
3071
|
+
// prose — see markerPresent. "There are no BLOCKED items" must
|
|
3072
|
+
// not open a gate keyed on BLOCKED.
|
|
3073
|
+
const met = markerPresent(routing, step.when);
|
|
2760
3074
|
if (!met) {
|
|
2761
3075
|
step.status = "skipped";
|
|
2762
3076
|
step.skipReason = `condition not met: when "${step.when}"`;
|
|
2763
3077
|
step.events.push({
|
|
2764
3078
|
t: new Date().toISOString(),
|
|
2765
3079
|
type: "info",
|
|
2766
|
-
text: `skipped — previous
|
|
3080
|
+
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)`,
|
|
2767
3081
|
});
|
|
2768
3082
|
}
|
|
2769
3083
|
}
|
|
2770
3084
|
save();
|
|
2771
3085
|
|
|
2772
3086
|
// `case:`/`else:` — exclusive routing. Of this group's case steps,
|
|
2773
|
-
// the FIRST whose
|
|
2774
|
-
// rest are routed past. `else:` runs only when no case matched.
|
|
3087
|
+
// the FIRST whose marker leads a line of the previous results runs;
|
|
3088
|
+
// the rest are routed past. `else:` runs only when no case matched.
|
|
2775
3089
|
// (`when:` above stays independent — every matching when runs —
|
|
2776
3090
|
// which is why routing is its own vocabulary instead of a mode.)
|
|
3091
|
+
//
|
|
3092
|
+
// Same marker rule as `when:`, and it matters more here: routing is
|
|
3093
|
+
// exclusive, so a label matched out of a sentence does not merely
|
|
3094
|
+
// run an extra step, it sends the flow down the wrong branch and
|
|
3095
|
+
// skips the right one. "This is not a COMPLAINT, it is a QUESTION"
|
|
3096
|
+
// used to route to the complaints handler.
|
|
2777
3097
|
const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
|
|
2778
3098
|
if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
|
|
2779
3099
|
let matchedCase: StepRecord | null = null;
|
|
2780
3100
|
for (const step of caseSteps) {
|
|
2781
|
-
if (!matchedCase && (
|
|
3101
|
+
if (!matchedCase && markerPresent(routing, step.case!)) {
|
|
2782
3102
|
matchedCase = step;
|
|
2783
3103
|
continue;
|
|
2784
3104
|
}
|
|
2785
3105
|
step.status = "skipped";
|
|
2786
3106
|
step.skipReason = matchedCase
|
|
2787
3107
|
? `routed past — "${matchedCase.case}" matched first`
|
|
2788
|
-
: `case not matched: "${step.case}"`;
|
|
3108
|
+
: `case not matched: the previous group's result has no line beginning "${step.case}"`;
|
|
2789
3109
|
step.events.push({
|
|
2790
3110
|
t: new Date().toISOString(),
|
|
2791
3111
|
type: "info",
|
|
@@ -2829,6 +3149,20 @@ function driveRunInner(
|
|
|
2829
3149
|
});
|
|
2830
3150
|
}
|
|
2831
3151
|
run.status = "awaiting-approval";
|
|
3152
|
+
// approve_within: — the deadline is stamped when the gate opens,
|
|
3153
|
+
// not counted from the run's start, so "two days to answer" means
|
|
3154
|
+
// two days from being asked however long the work before it took.
|
|
3155
|
+
// Only a gate that asks a PERSON gets one: an external event has
|
|
3156
|
+
// no one to chase.
|
|
3157
|
+
const within = readFlow(tenant, workspace, run.flow)?.approveWithin ?? null;
|
|
3158
|
+
// Always assigned, never only set: a run with two gates would
|
|
3159
|
+
// otherwise carry the FIRST gate's deadline into the second, and
|
|
3160
|
+
// a deadline already in the past expires a gate the moment it
|
|
3161
|
+
// opens. Null when this gate has none.
|
|
3162
|
+
run.approveBy =
|
|
3163
|
+
within && needsApproval.some((s) => s.waitFor !== "event")
|
|
3164
|
+
? new Date(Date.now() + within * 1000).toISOString()
|
|
3165
|
+
: null;
|
|
2832
3166
|
save();
|
|
2833
3167
|
|
|
2834
3168
|
// The gate's declared preview, resolved now against what the run
|
|
@@ -2903,12 +3237,79 @@ function driveRunInner(
|
|
|
2903
3237
|
// twenty steps over — the shares sum to what was left. Between
|
|
2904
3238
|
// groups the check above still refuses to start the next one.
|
|
2905
3239
|
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
2906
|
-
|
|
3240
|
+
// An agent's own `budget:` — the most it may spend in one run — is
|
|
3241
|
+
// read at launch, so a cap raised mid-run applies to the next step.
|
|
3242
|
+
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
3243
|
+
// The ceiling for one attempt of one step, read NOW: what is left of
|
|
3244
|
+
// the run's budget shared across the group, or the agent's own cap
|
|
3245
|
+
// less what its steps have cost. Computed before every attempt, not
|
|
3246
|
+
// once before the loop — a retry's share is what is left after the
|
|
3247
|
+
// failed attempt spent, and the step's own figure already carries
|
|
3248
|
+
// that spend (it is the sum of its tries).
|
|
3249
|
+
const ceilingFor = (step: StepRecord) =>
|
|
3250
|
+
stepCeilingFor(
|
|
3251
|
+
stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length),
|
|
3252
|
+
agentBudgets.get(step.agent),
|
|
3253
|
+
run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
|
|
3254
|
+
step.agent,
|
|
3255
|
+
);
|
|
3256
|
+
// parallel: — the instances of one fan-out template share a pool.
|
|
3257
|
+
// Keyed by what the instances share (agent + instruction), since
|
|
3258
|
+
// the template itself is behind them in the record as "expanded".
|
|
3259
|
+
const pools = new Map<string, ReturnType<typeof slotPool>>();
|
|
3260
|
+
const poolFor = (step: StepRecord) => {
|
|
3261
|
+
if (!step.parallel || !step.item) return null;
|
|
3262
|
+
const key = `${step.agent}\u0000${step.instruction}`;
|
|
3263
|
+
let pool = pools.get(key);
|
|
3264
|
+
if (!pool) pools.set(key, (pool = slotPool(step.parallel)));
|
|
3265
|
+
return pool;
|
|
3266
|
+
};
|
|
2907
3267
|
await Promise.all(
|
|
2908
3268
|
launching
|
|
2909
3269
|
.map(async (step) => {
|
|
3270
|
+
const pool = poolFor(step);
|
|
3271
|
+
if (pool) await pool.acquire();
|
|
3272
|
+
try {
|
|
3273
|
+
await launchStep(step);
|
|
3274
|
+
} finally {
|
|
3275
|
+
pool?.release();
|
|
3276
|
+
}
|
|
3277
|
+
}),
|
|
3278
|
+
);
|
|
3279
|
+
async function launchStep(step: StepRecord) {
|
|
2910
3280
|
const attempts = (step.retry ?? 0) + 1;
|
|
2911
|
-
|
|
3281
|
+
// A step re-attaching to its sandbox is still on the attempt
|
|
3282
|
+
// that started it; a step re-run after a backoff picks up the
|
|
3283
|
+
// count it left. Only a step nobody has touched starts at 1.
|
|
3284
|
+
const interruptedInBackoff =
|
|
3285
|
+
!step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
|
|
3286
|
+
const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts! + 1) : 1;
|
|
3287
|
+
for (let attempt = from; attempt <= attempts; attempt++) {
|
|
3288
|
+
const ceiling = ceilingFor(step);
|
|
3289
|
+
// Nothing left is a refusal, not "no ceiling": a zero passed
|
|
3290
|
+
// down would read as unset, and the one step that must not
|
|
3291
|
+
// run would run with no cap at all. On a retry this is what
|
|
3292
|
+
// stops the second attempt spending a share the first one
|
|
3293
|
+
// already spent.
|
|
3294
|
+
if (ceiling.ceilingUsd === 0) {
|
|
3295
|
+
step.status = "failed";
|
|
3296
|
+
step.events.push({
|
|
3297
|
+
t: new Date().toISOString(),
|
|
3298
|
+
type: "error",
|
|
3299
|
+
text:
|
|
3300
|
+
attempt === from
|
|
3301
|
+
? `over budget — nothing left to spend before this step (${ceiling.note}); it was not started`
|
|
3302
|
+
: `over budget — nothing left to spend for attempt ${attempt} (${ceiling.note}); not retried`,
|
|
3303
|
+
});
|
|
3304
|
+
save();
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
// Re-attaching to a sandbox is the same attempt, so its row
|
|
3308
|
+
// is replaced; anything else is a new row, a loop cycle's
|
|
3309
|
+
// re-run included.
|
|
3310
|
+
const reattach = !!step.sandbox;
|
|
3311
|
+
const attemptStartedAt = new Date().toISOString();
|
|
3312
|
+
const eventsBefore = step.events.length;
|
|
2912
3313
|
step.attempts = attempt;
|
|
2913
3314
|
step.status = "running";
|
|
2914
3315
|
save();
|
|
@@ -2923,11 +3324,21 @@ function driveRunInner(
|
|
|
2923
3324
|
effortOverride,
|
|
2924
3325
|
run.id,
|
|
2925
3326
|
ctxData,
|
|
2926
|
-
ceilingUsd,
|
|
3327
|
+
ceiling.ceilingUsd,
|
|
3328
|
+
ceiling.note,
|
|
3329
|
+
run.test === true,
|
|
3330
|
+
// save() merges a stop written by another process into
|
|
3331
|
+
// this record; the model loop polls this on a clock.
|
|
3332
|
+
() => {
|
|
3333
|
+
save();
|
|
3334
|
+
return run.stopRequested === true;
|
|
3335
|
+
},
|
|
2927
3336
|
);
|
|
2928
3337
|
// runStep mutates step.status; read it through a widened local
|
|
2929
3338
|
// so TS doesn't keep the "running" narrowing from above.
|
|
2930
3339
|
const outcome: string = step.status;
|
|
3340
|
+
recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
|
|
3341
|
+
save();
|
|
2931
3342
|
if (outcome !== "failed" || attempt === attempts) break;
|
|
2932
3343
|
// A stop destroys the step's sandbox, which reads here as a
|
|
2933
3344
|
// failed attempt — and a failed attempt used to be retried in
|
|
@@ -2935,15 +3346,44 @@ function driveRunInner(
|
|
|
2935
3346
|
// more. save() merges the flag from the record.
|
|
2936
3347
|
save();
|
|
2937
3348
|
if (run.stopRequested) break;
|
|
3349
|
+
// The retry policy. An attempt the cluster ended for want of
|
|
3350
|
+
// memory or disk is retried one size class up — the same
|
|
3351
|
+
// size would end the same way. Every retry waits: 15 s, 30 s,
|
|
3352
|
+
// 1 m, 2 m, 4 m, jittered, so a provider or a site that was
|
|
3353
|
+
// struggling gets a moment, and twenty fan-out steps do not
|
|
3354
|
+
// hammer it in lock-step. The wait holds a worker slot, not
|
|
3355
|
+
// a sandbox — nothing is rented while it waits — and a stop
|
|
3356
|
+
// ends it. The step is `pending` meanwhile, so a driver that
|
|
3357
|
+
// inherits the run mid-wait simply runs the attempt.
|
|
3358
|
+
const last = [...step.events].reverse().find((e) => e.type === "error")?.text ?? "";
|
|
3359
|
+
let sizeNote = "";
|
|
3360
|
+
if (/OOMKilled|Evicted|ephemeral-storage|out of memory/i.test(last)) {
|
|
3361
|
+
const up = step.sizeUp === "large" || (!step.sizeUp && step.size === "large") ? "heavy" : step.sizeUp === "heavy" || step.size === "heavy" ? null : "large";
|
|
3362
|
+
if (up) {
|
|
3363
|
+
step.sizeUp = up;
|
|
3364
|
+
sizeNote = ` at size: ${up}`;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
const waitMs = Math.round(Math.min(retryBaseMs() * 16, retryBaseMs() * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5));
|
|
3368
|
+
step.status = "pending";
|
|
2938
3369
|
step.events.push({
|
|
2939
3370
|
t: new Date().toISOString(),
|
|
2940
3371
|
type: "info",
|
|
2941
|
-
text: `attempt ${attempt} failed — retrying (${attempts - attempt} left)`,
|
|
3372
|
+
text: `attempt ${attempt} failed — retrying in ${Math.round(waitMs / 1000)}s${sizeNote} (${attempts - attempt} left)`,
|
|
2942
3373
|
});
|
|
2943
3374
|
save();
|
|
3375
|
+
const until = Date.now() + waitMs;
|
|
3376
|
+
while (Date.now() < until) {
|
|
3377
|
+
await sleep(Math.min(1000, until - Date.now()));
|
|
3378
|
+
save();
|
|
3379
|
+
if (run.stopRequested) break;
|
|
3380
|
+
}
|
|
3381
|
+
if (run.stopRequested) {
|
|
3382
|
+
step.status = "failed";
|
|
3383
|
+
break;
|
|
3384
|
+
}
|
|
2944
3385
|
}
|
|
2945
|
-
|
|
2946
|
-
);
|
|
3386
|
+
}
|
|
2947
3387
|
|
|
2948
3388
|
// on-fail: another agent takes the step over. Sequential and rare:
|
|
2949
3389
|
// the rescue is a second full attempt at the step's job with the
|
|
@@ -2995,7 +3435,7 @@ function driveRunInner(
|
|
|
2995
3435
|
rescue.attempts = 1;
|
|
2996
3436
|
rescue.status = "running";
|
|
2997
3437
|
save();
|
|
2998
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData);
|
|
3438
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
|
|
2999
3439
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
3000
3440
|
const rescueOutcome: string = rescue.status;
|
|
3001
3441
|
if (rescueOutcome === "completed") {
|
|
@@ -3125,16 +3565,25 @@ function driveRunInner(
|
|
|
3125
3565
|
for (const s of run.steps) if (s.status === "pending") s.status = "skipped";
|
|
3126
3566
|
} catch (err) {
|
|
3127
3567
|
run.status = "failed";
|
|
3568
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
3569
|
+
let told = false;
|
|
3128
3570
|
run.steps.forEach((s) => {
|
|
3129
3571
|
if (s.status === "running") {
|
|
3130
3572
|
s.status = "failed";
|
|
3131
|
-
s.events.push({
|
|
3132
|
-
|
|
3133
|
-
type: "error",
|
|
3134
|
-
text: err instanceof Error ? err.message : String(err),
|
|
3135
|
-
});
|
|
3573
|
+
s.events.push({ t: new Date().toISOString(), type: "error", text });
|
|
3574
|
+
told = true;
|
|
3136
3575
|
}
|
|
3137
3576
|
});
|
|
3577
|
+
// Thrown between groups, with nothing running: the error still has to
|
|
3578
|
+
// land on some step's trail, or the run reads as failed for no reason.
|
|
3579
|
+
if (!told) {
|
|
3580
|
+
const witness = run.steps.findLast((s) => s.status !== "pending") ?? run.steps[0];
|
|
3581
|
+
witness?.events.push({
|
|
3582
|
+
t: new Date().toISOString(),
|
|
3583
|
+
type: "error",
|
|
3584
|
+
text: `run failed between steps: ${text}`,
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3138
3587
|
} finally {
|
|
3139
3588
|
if (!parked) {
|
|
3140
3589
|
run.finishedAt = new Date().toISOString();
|
|
@@ -3144,6 +3593,9 @@ function driveRunInner(
|
|
|
3144
3593
|
// learn, and the runs list and the notification stop having to say
|
|
3145
3594
|
// only "completed".
|
|
3146
3595
|
run.summary = runSummary(run);
|
|
3596
|
+
// A test run's headline says so wherever the headline goes — the
|
|
3597
|
+
// runs list, the history tool, a later run's recall.
|
|
3598
|
+
if (run.test && run.summary) run.summary = testHeadline(run.summary);
|
|
3147
3599
|
// Archive before the next run resets outputs/ — a failed run's
|
|
3148
3600
|
// artifacts are usually the most interesting ones, so this runs on
|
|
3149
3601
|
// failure too.
|
|
@@ -3184,16 +3636,6 @@ function driveRunInner(
|
|
|
3184
3636
|
})();
|
|
3185
3637
|
}
|
|
3186
3638
|
|
|
3187
|
-
function readFlow(tenant: string, workspace: string, flowName: string) {
|
|
3188
|
-
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
3189
|
-
if (!fs.existsSync(dir)) return null;
|
|
3190
|
-
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
3191
|
-
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
3192
|
-
if (flow.name === flowName) return flow;
|
|
3193
|
-
}
|
|
3194
|
-
return null;
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
3639
|
// Splice `[[flow:other]]` steps into the parent's step list, renumbering
|
|
3198
3640
|
// groups so the nested flow's own parallelism survives. Depth-limited, and
|
|
3199
3641
|
// a cycle raises rather than expanding forever.
|
|
@@ -3270,6 +3712,10 @@ export function loadFlow(tenant: string, workspace: string, flowName: string) {
|
|
|
3270
3712
|
// written a beat before its driver registered, not a judgement about pace.
|
|
3271
3713
|
const ABANDONED_AFTER_MS = 2 * 60 * 1000;
|
|
3272
3714
|
|
|
3715
|
+
/** The first retry's wait; each one after doubles it, to sixteen times
|
|
3716
|
+
* this (15 s → 4 min by default). An env, so a test can make it small. */
|
|
3717
|
+
const retryBaseMs = () => Number(process.env.FOLDRUN_RETRY_BASE_MS) || 15_000;
|
|
3718
|
+
|
|
3273
3719
|
/** The most recent moment a run showed any sign of life. */
|
|
3274
3720
|
function lastActivity(run: RunRecord): number {
|
|
3275
3721
|
let latest = Date.parse(run.startedAt);
|
|
@@ -3311,12 +3757,18 @@ export interface Reconciliation {
|
|
|
3311
3757
|
* outlives any process. A run whose person already answered is not waiting,
|
|
3312
3758
|
* and is picked back up — see below.
|
|
3313
3759
|
*/
|
|
3314
|
-
export function reconcileRuns(tenant: string, now = Date.now()): Reconciliation[] {
|
|
3760
|
+
export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
|
|
3315
3761
|
const closed: Reconciliation[] = [];
|
|
3316
3762
|
|
|
3317
3763
|
for (const workspace of listWorkspaces(tenant)) {
|
|
3318
3764
|
for (const summary of listRuns(tenant, workspace.name)) {
|
|
3319
3765
|
if (drivingRuns.has(summary.id)) continue;
|
|
3766
|
+
// Driven by another process — a second worker pod, on a platform with
|
|
3767
|
+
// more than one. The idle window protects a quiet step from THIS
|
|
3768
|
+
// process's judgement only; a worker that boots beside a live one
|
|
3769
|
+
// would otherwise close every long step its neighbour is driving.
|
|
3770
|
+
// The caller says which runs those are (a heartbeated claim).
|
|
3771
|
+
if (live?.has(summary.id)) continue;
|
|
3320
3772
|
if (now - lastActivity(summary) < ABANDONED_AFTER_MS) continue;
|
|
3321
3773
|
|
|
3322
3774
|
// Approved, then abandoned. Approval only writes to the run record; the
|
|
@@ -3396,8 +3848,8 @@ export function reconcileRuns(tenant: string, now = Date.now()): Reconciliation[
|
|
|
3396
3848
|
* one hard-coded account left every other account's interrupted runs marked
|
|
3397
3849
|
* `running` forever, with no process left to finish them.
|
|
3398
3850
|
*/
|
|
3399
|
-
export function reconcileAllRuns(now = Date.now()): Reconciliation[] {
|
|
3400
|
-
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now));
|
|
3851
|
+
export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
|
|
3852
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
|
|
3401
3853
|
}
|
|
3402
3854
|
|
|
3403
3855
|
/**
|