@foldrun/core 0.3.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 +27 -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/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 +102 -2
- package/dist/src/platform.js +18 -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 +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- 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 +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -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 +4 -4
- 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/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 +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -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
|
@@ -8,11 +8,15 @@ import path from "node:path";
|
|
|
8
8
|
import matter from "gray-matter";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import {
|
|
11
|
-
knownPrice, executeStep, extractJson, stepCeiling, type EventExtra } from "./step-exec.ts";
|
|
11
|
+
knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
|
|
12
12
|
import { eventUrl } from "./webhook.ts";
|
|
13
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";
|
|
14
16
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
15
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";
|
|
16
20
|
|
|
17
21
|
/** Does this process run steps in a sandbox — the container core ships, or
|
|
18
22
|
* one the platform registered (a pod)? */
|
|
@@ -38,6 +42,8 @@ import {
|
|
|
38
42
|
syncBundleFor,
|
|
39
43
|
parseToolDef,
|
|
40
44
|
parseFlow,
|
|
45
|
+
readFlow,
|
|
46
|
+
markerPresent,
|
|
41
47
|
parseApis,
|
|
42
48
|
resolveModel,
|
|
43
49
|
parseProvider,
|
|
@@ -53,8 +59,12 @@ import {
|
|
|
53
59
|
type McpSpec,
|
|
54
60
|
type RunRecord,
|
|
55
61
|
type StepRecord,
|
|
62
|
+
type StepAttempt,
|
|
63
|
+
sumAttempts,
|
|
64
|
+
parseSchemaText,
|
|
56
65
|
STORAGE_DIR,
|
|
57
66
|
adoptLegacyVersionKey,
|
|
67
|
+
listAgents,
|
|
58
68
|
runCost,
|
|
59
69
|
} from "./store.ts";
|
|
60
70
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
|
|
@@ -72,6 +82,7 @@ import { materializeFiles, harvestFiles } from "./storage.ts";
|
|
|
72
82
|
import { chooseExecutor, ensureImage } from "./container.ts";
|
|
73
83
|
import { stampBundle } from "./okf.ts";
|
|
74
84
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
85
|
+
import { trimChars } from "./paths.ts";
|
|
75
86
|
|
|
76
87
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
77
88
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
@@ -123,6 +134,32 @@ export function joinEarlierGroups(groupResults: (string | null)[], gi: number, c
|
|
|
123
134
|
return parts.join("\n\n");
|
|
124
135
|
}
|
|
125
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
|
+
|
|
126
163
|
export interface DiscoveredSkill {
|
|
127
164
|
name: string;
|
|
128
165
|
description: string;
|
|
@@ -378,10 +415,9 @@ export function saysUntilMarker(result: string | null, until: string): boolean {
|
|
|
378
415
|
(line) =>
|
|
379
416
|
// Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
|
|
380
417
|
// is a decision written by someone using bold, not a sentence.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
.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()
|
|
385
421
|
.toLowerCase() === marker,
|
|
386
422
|
);
|
|
387
423
|
}
|
|
@@ -471,7 +507,10 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
|
|
|
471
507
|
map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
|
|
472
508
|
}
|
|
473
509
|
}
|
|
474
|
-
|
|
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) => {
|
|
475
514
|
const hit = map.get(norm(name.trim()));
|
|
476
515
|
return hit ? `\`${hit}\`` : whole;
|
|
477
516
|
});
|
|
@@ -1197,6 +1236,49 @@ function repriced(
|
|
|
1197
1236
|
return known;
|
|
1198
1237
|
}
|
|
1199
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
|
+
|
|
1200
1282
|
async function runStep(
|
|
1201
1283
|
agentDir: string,
|
|
1202
1284
|
tenant: string,
|
|
@@ -1214,6 +1296,15 @@ async function runStep(
|
|
|
1214
1296
|
* remaining `budget:`, shared across the steps launched with it. Null
|
|
1215
1297
|
* means the flow set no budget. */
|
|
1216
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,
|
|
1217
1308
|
) {
|
|
1218
1309
|
// Secret values are injected into scripts as environment variables and
|
|
1219
1310
|
// substituted into API headers, so a model that reads one back — from a
|
|
@@ -1238,6 +1329,22 @@ async function runStep(
|
|
|
1238
1329
|
return out;
|
|
1239
1330
|
};
|
|
1240
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
|
+
|
|
1241
1348
|
const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
|
|
1242
1349
|
step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
|
|
1243
1350
|
save();
|
|
@@ -1277,8 +1384,15 @@ async function runStep(
|
|
|
1277
1384
|
// the failure of a step that returned no JSON are testable at no cost.
|
|
1278
1385
|
if (step.output === "json") {
|
|
1279
1386
|
const extracted = extractJson(step.result);
|
|
1280
|
-
if (extracted.ok)
|
|
1281
|
-
|
|
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 {
|
|
1282
1396
|
push("error", `output: json — ${extracted.reason}`);
|
|
1283
1397
|
step.status = "failed";
|
|
1284
1398
|
}
|
|
@@ -1313,7 +1427,7 @@ async function runStep(
|
|
|
1313
1427
|
// what crosses into containers, headers and env files is the token. A
|
|
1314
1428
|
// failed refresh fails the step naming the secret and the provider's
|
|
1315
1429
|
// reason, which beats a 401 three layers later.
|
|
1316
|
-
const liveSecrets = await materializeSecrets(secretEnv);
|
|
1430
|
+
const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
|
|
1317
1431
|
// @file values (SSH keys, certs) still carry their content at this point.
|
|
1318
1432
|
// The in-process branch materialises them to 0600 paths on this host
|
|
1319
1433
|
// below; the container branch passes the content across and the driver
|
|
@@ -1461,6 +1575,13 @@ async function runStep(
|
|
|
1461
1575
|
// The confinement boundary: agents in a workspace are one team, but the
|
|
1462
1576
|
// workspace is where isolation is enforced.
|
|
1463
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
|
+
});
|
|
1464
1585
|
|
|
1465
1586
|
let prompt = step.instruction
|
|
1466
1587
|
? resolveDocLinks(step.instruction, workspaceRoot)
|
|
@@ -1477,11 +1598,18 @@ async function runStep(
|
|
|
1477
1598
|
if (step.output === "json") {
|
|
1478
1599
|
// The contract the runner parses back out — stated in the prompt so
|
|
1479
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);
|
|
1480
1604
|
prompt +=
|
|
1481
1605
|
`\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
|
|
1482
1606
|
"```json fenced block — the value the next step will compute on. Prose before it is " +
|
|
1483
1607
|
`welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
|
|
1484
|
-
`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>`;
|
|
1485
1613
|
}
|
|
1486
1614
|
if (step.waitFor === "event" && step.eventPayload !== undefined) {
|
|
1487
1615
|
// Not resolved for links, like the previous step's results: it is
|
|
@@ -1548,6 +1676,16 @@ async function runStep(
|
|
|
1548
1676
|
// platform credential.
|
|
1549
1677
|
const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
|
|
1550
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
|
+
|
|
1551
1689
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
1552
1690
|
if (isolatedRun()) {
|
|
1553
1691
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -1575,10 +1713,20 @@ async function runStep(
|
|
|
1575
1713
|
const host = hostOf(api.base);
|
|
1576
1714
|
if (!host) continue;
|
|
1577
1715
|
for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
1578
|
-
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
|
+
}
|
|
1579
1727
|
}
|
|
1580
1728
|
}
|
|
1581
|
-
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 });
|
|
1582
1730
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
1583
1731
|
// reads them from its environment, and unless it says `secrets:
|
|
1584
1732
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -1601,15 +1749,36 @@ async function runStep(
|
|
|
1601
1749
|
}
|
|
1602
1750
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
1603
1751
|
// a placeholder that the proxy fills on the way out.
|
|
1604
|
-
const
|
|
1605
|
-
|
|
1606
|
-
|
|
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;
|
|
1607
1776
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
1608
1777
|
...api,
|
|
1609
1778
|
headers: Object.fromEntries(
|
|
1610
1779
|
Object.entries(api.headers).map(([k, v]) => [
|
|
1611
1780
|
k,
|
|
1612
|
-
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) =>
|
|
1781
|
+
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => apiValues[name] ?? whole),
|
|
1613
1782
|
]),
|
|
1614
1783
|
),
|
|
1615
1784
|
}));
|
|
@@ -1639,6 +1808,7 @@ async function runStep(
|
|
|
1639
1808
|
const isolatedArgs = (modelEnv: Record<string, string | undefined>, keyName = MODEL_KEY_NAME) => ({
|
|
1640
1809
|
workspaceRoot,
|
|
1641
1810
|
libraryRoot: libraryDir(tenant),
|
|
1811
|
+
galleryRoot: platform.galleryDir() ?? undefined,
|
|
1642
1812
|
// Keys this step's dependency cache. Per-account by design — see
|
|
1643
1813
|
// run-container.ts#runtimeCacheDir for why it is not shared.
|
|
1644
1814
|
tenant,
|
|
@@ -1659,9 +1829,12 @@ async function runStep(
|
|
|
1659
1829
|
consults,
|
|
1660
1830
|
timeoutSec: step.timeout,
|
|
1661
1831
|
budgetUsd: stepBudgetUsd,
|
|
1832
|
+
budgetNote: stepBudgetNote,
|
|
1662
1833
|
price: tokenPrice,
|
|
1663
1834
|
verify: step.verify,
|
|
1664
1835
|
output: step.output,
|
|
1836
|
+
schema: resolvedSchema ?? undefined,
|
|
1837
|
+
maxTurns: step.maxTurns,
|
|
1665
1838
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
1666
1839
|
// The container sees the workspace at /workspace and the library
|
|
1667
1840
|
// at /library; the roots are named host-side and moved here.
|
|
@@ -1692,6 +1865,7 @@ async function runStep(
|
|
|
1692
1865
|
FOLDRUN_RUN_ID: runId,
|
|
1693
1866
|
FOLDRUN_AGENT: step.agent,
|
|
1694
1867
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
1868
|
+
...(testRun ? testModeEnv() : {}),
|
|
1695
1869
|
...sandboxSecrets,
|
|
1696
1870
|
}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
1697
1871
|
),
|
|
@@ -1699,6 +1873,9 @@ async function runStep(
|
|
|
1699
1873
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
1700
1874
|
runId,
|
|
1701
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,
|
|
1702
1879
|
// Resume: a sandbox a previous driver left running, recorded on the
|
|
1703
1880
|
// step. Consumed once — a fresh attempt after this one (a retry, a
|
|
1704
1881
|
// fallback) starts its own sandbox.
|
|
@@ -1767,18 +1944,23 @@ async function runStep(
|
|
|
1767
1944
|
outcome = withEarlierTiming(outcome, first);
|
|
1768
1945
|
}
|
|
1769
1946
|
if (lease) {
|
|
1770
|
-
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
|
+
}
|
|
1771
1951
|
await lease.release();
|
|
1772
1952
|
}
|
|
1773
|
-
|
|
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);
|
|
1774
1956
|
step.status = outcome.status;
|
|
1775
|
-
step.result = outcome.result;
|
|
1776
|
-
step.conclusion = outcome.conclusion;
|
|
1957
|
+
step.result = redactText(outcome.result);
|
|
1958
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
1777
1959
|
step.sandbox = null;
|
|
1778
1960
|
// The cluster's word on why a sandbox ended without the driver saying
|
|
1779
1961
|
// so — OOMKilled, Evicted — on the record, where the retry reads it.
|
|
1780
1962
|
if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
|
|
1781
|
-
if (outcome.data !== undefined) step.data = outcome.data;
|
|
1963
|
+
if (outcome.data !== undefined) step.data = redactData(outcome.data);
|
|
1782
1964
|
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
|
|
1783
1965
|
step.tokens = outcome.usage
|
|
1784
1966
|
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
@@ -1811,14 +1993,27 @@ async function runStep(
|
|
|
1811
1993
|
})();
|
|
1812
1994
|
step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
|
|
1813
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;
|
|
1814
2009
|
const consultTools = buildConsultTools(
|
|
1815
2010
|
consults,
|
|
1816
|
-
{ ...
|
|
2011
|
+
{ ...hostSafeEnv(), ...stepSecrets, ...providerEnv },
|
|
1817
2012
|
(type, text) => push(type, text),
|
|
1818
2013
|
);
|
|
1819
2014
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
1820
2015
|
// finally — once, however many attempts follow.
|
|
1821
|
-
const mat = materializeFileSecrets(agentDir,
|
|
2016
|
+
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
1822
2017
|
fileDir = mat.dir;
|
|
1823
2018
|
const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
|
|
1824
2019
|
agentDir,
|
|
@@ -1840,13 +2035,20 @@ async function runStep(
|
|
|
1840
2035
|
...mcpServers,
|
|
1841
2036
|
},
|
|
1842
2037
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
1843
|
-
// only ever sees the variable names, not the values.
|
|
1844
|
-
|
|
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 },
|
|
1845
2043
|
timeoutSec: step.timeout,
|
|
1846
2044
|
budgetUsd: stepBudgetUsd,
|
|
2045
|
+
budgetNote: stepBudgetNote,
|
|
1847
2046
|
price: tokenPrice,
|
|
1848
2047
|
verify: step.verify,
|
|
1849
2048
|
output: step.output,
|
|
2049
|
+
schema: resolvedSchema ?? undefined,
|
|
2050
|
+
maxTurns: step.maxTurns,
|
|
2051
|
+
stopRequested,
|
|
1850
2052
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
1851
2053
|
// the proof a step left names THIS run, not one that came before —
|
|
1852
2054
|
// copy-back never propagates a deletion, so a marker from an earlier
|
|
@@ -1856,20 +2058,31 @@ async function runStep(
|
|
|
1856
2058
|
...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
|
|
1857
2059
|
FOLDRUN_AGENT: step.agent,
|
|
1858
2060
|
FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
|
|
1859
|
-
...
|
|
2061
|
+
...(testRun ? testModeEnv() : {}),
|
|
2062
|
+
...stepSecrets,
|
|
1860
2063
|
},
|
|
1861
2064
|
emit: pushWatching,
|
|
1862
2065
|
});
|
|
1863
2066
|
// With a translator the step's endpoint is the translator: started
|
|
1864
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.
|
|
1865
2072
|
const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
|
|
1866
|
-
|
|
1867
|
-
const t = await startTranslator(spec);
|
|
2073
|
+
const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
|
|
2074
|
+
const t = spec ? await startTranslator(spec) : null;
|
|
1868
2075
|
try {
|
|
1869
|
-
return await attemptInProcess({ ...modelEnv, ...t.env });
|
|
2076
|
+
return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
|
|
1870
2077
|
} finally {
|
|
1871
|
-
|
|
1872
|
-
|
|
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
|
+
}
|
|
1873
2086
|
}
|
|
1874
2087
|
};
|
|
1875
2088
|
let outcome = await attemptThrough(providerEnv, translator);
|
|
@@ -1891,11 +2104,11 @@ async function runStep(
|
|
|
1891
2104
|
);
|
|
1892
2105
|
outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
|
|
1893
2106
|
}
|
|
1894
|
-
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
2107
|
+
if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1895
2108
|
step.status = outcome.status;
|
|
1896
|
-
step.result = outcome.result;
|
|
1897
|
-
step.conclusion = outcome.conclusion;
|
|
1898
|
-
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);
|
|
1899
2112
|
// A consult's spend belongs to the step that asked.
|
|
1900
2113
|
const consultCost = consultTools.drainCost();
|
|
1901
2114
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
|
|
@@ -2189,6 +2402,26 @@ function csvItems(workspaceDir: string, step: StepRecord, take: number): string[
|
|
|
2189
2402
|
return data.slice(0, take).map((r) => `${header}\n${r}`);
|
|
2190
2403
|
}
|
|
2191
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
|
+
|
|
2192
2425
|
export function createFlowRun(
|
|
2193
2426
|
tenant: string,
|
|
2194
2427
|
workspace: string,
|
|
@@ -2196,12 +2429,16 @@ export function createFlowRun(
|
|
|
2196
2429
|
flowName: string,
|
|
2197
2430
|
status: "queued" | "running",
|
|
2198
2431
|
tags: string[] = [],
|
|
2432
|
+
startedBy: string | null = null,
|
|
2433
|
+
opts: { test?: boolean } = {},
|
|
2199
2434
|
): RunRecord {
|
|
2200
2435
|
const run: RunRecord = {
|
|
2201
2436
|
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
2202
2437
|
flow: flowName,
|
|
2203
2438
|
tags,
|
|
2204
2439
|
status,
|
|
2440
|
+
...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
|
|
2441
|
+
...(opts.test ? { test: true } : {}),
|
|
2205
2442
|
startedAt: new Date().toISOString(),
|
|
2206
2443
|
finishedAt: null,
|
|
2207
2444
|
steps: steps.map((s) => ({
|
|
@@ -2231,6 +2468,10 @@ export function createFlowRun(
|
|
|
2231
2468
|
preview: s.preview,
|
|
2232
2469
|
delegate: s.delegate,
|
|
2233
2470
|
output: s.output,
|
|
2471
|
+
schema: s.schema,
|
|
2472
|
+
schemaPath: s.schemaPath,
|
|
2473
|
+
parallel: s.parallel,
|
|
2474
|
+
maxTurns: s.maxTurns,
|
|
2234
2475
|
attempts: 0,
|
|
2235
2476
|
status: "pending",
|
|
2236
2477
|
events: [],
|
|
@@ -2267,8 +2508,10 @@ export function startFlowRun(
|
|
|
2267
2508
|
modelOverride?: string | null,
|
|
2268
2509
|
tags: string[] = [],
|
|
2269
2510
|
effortOverride?: string | null,
|
|
2511
|
+
startedBy: string | null = null,
|
|
2512
|
+
opts: { test?: boolean } = {},
|
|
2270
2513
|
): RunRecord {
|
|
2271
|
-
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
|
|
2514
|
+
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
|
|
2272
2515
|
void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
|
|
2273
2516
|
return run;
|
|
2274
2517
|
}
|
|
@@ -2566,6 +2809,17 @@ function driveRunInner(
|
|
|
2566
2809
|
// is assembled) keeps the newest: when the total is over it, the
|
|
2567
2810
|
// oldest groups are dropped whole before anything is cut mid-text.
|
|
2568
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
|
+
};
|
|
2569
2823
|
// The data half of the same handoff: what the nearest earlier group
|
|
2570
2824
|
// returned through `output: json`. Rebuilt from the record rather than
|
|
2571
2825
|
// kept only in memory, so a resumed run hands step 3 the value step 1
|
|
@@ -2630,8 +2884,11 @@ function driveRunInner(
|
|
|
2630
2884
|
context = contextBefore(gi);
|
|
2631
2885
|
ctxData = dataBefore(gi);
|
|
2632
2886
|
|
|
2633
|
-
|
|
2634
|
-
|
|
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);
|
|
2635
2892
|
const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2636
2893
|
last?.events.push({
|
|
2637
2894
|
t: new Date().toISOString(),
|
|
@@ -2720,6 +2977,7 @@ function driveRunInner(
|
|
|
2720
2977
|
type: "info",
|
|
2721
2978
|
text:
|
|
2722
2979
|
`fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
|
|
2980
|
+
(step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
|
|
2723
2981
|
(dropped ? ` (more items existed — capped at ${cap})` : ""),
|
|
2724
2982
|
});
|
|
2725
2983
|
const at = run.steps.indexOf(step);
|
|
@@ -2734,6 +2992,7 @@ function driveRunInner(
|
|
|
2734
2992
|
result: null,
|
|
2735
2993
|
costUsd: null,
|
|
2736
2994
|
attempts: 0,
|
|
2995
|
+
tries: undefined,
|
|
2737
2996
|
}));
|
|
2738
2997
|
run.steps.splice(at + 1, 0, ...instances);
|
|
2739
2998
|
ordered = orderedGroups();
|
|
@@ -2747,12 +3006,25 @@ function driveRunInner(
|
|
|
2747
3006
|
// this is what keeps the *next* group from starting.
|
|
2748
3007
|
const stopCheck = readRun(tenant, workspace, run.id);
|
|
2749
3008
|
if (stopCheck?.stopRequested) {
|
|
3009
|
+
let skipped = 0;
|
|
2750
3010
|
for (const s of run.steps) {
|
|
2751
3011
|
if (s.status === "pending" || s.status === "running") {
|
|
2752
3012
|
s.status = "skipped";
|
|
2753
3013
|
s.skipReason = "run stopped";
|
|
3014
|
+
skipped += 1;
|
|
2754
3015
|
}
|
|
2755
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
|
+
});
|
|
2756
3028
|
run.status = "failed";
|
|
2757
3029
|
run.stopRequested = true;
|
|
2758
3030
|
save();
|
|
@@ -2788,40 +3060,52 @@ function driveRunInner(
|
|
|
2788
3060
|
}
|
|
2789
3061
|
|
|
2790
3062
|
const ctx = context;
|
|
3063
|
+
// The previous group's result — see routingBefore.
|
|
3064
|
+
const routing = routingBefore(gi);
|
|
2791
3065
|
|
|
2792
|
-
// `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.
|
|
2793
3068
|
for (const step of freshGroup) {
|
|
2794
3069
|
if (step.status !== "pending" || !step.when) continue;
|
|
2795
|
-
|
|
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);
|
|
2796
3074
|
if (!met) {
|
|
2797
3075
|
step.status = "skipped";
|
|
2798
3076
|
step.skipReason = `condition not met: when "${step.when}"`;
|
|
2799
3077
|
step.events.push({
|
|
2800
3078
|
t: new Date().toISOString(),
|
|
2801
3079
|
type: "info",
|
|
2802
|
-
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)`,
|
|
2803
3081
|
});
|
|
2804
3082
|
}
|
|
2805
3083
|
}
|
|
2806
3084
|
save();
|
|
2807
3085
|
|
|
2808
3086
|
// `case:`/`else:` — exclusive routing. Of this group's case steps,
|
|
2809
|
-
// the FIRST whose
|
|
2810
|
-
// 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.
|
|
2811
3089
|
// (`when:` above stays independent — every matching when runs —
|
|
2812
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.
|
|
2813
3097
|
const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
|
|
2814
3098
|
if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
|
|
2815
3099
|
let matchedCase: StepRecord | null = null;
|
|
2816
3100
|
for (const step of caseSteps) {
|
|
2817
|
-
if (!matchedCase && (
|
|
3101
|
+
if (!matchedCase && markerPresent(routing, step.case!)) {
|
|
2818
3102
|
matchedCase = step;
|
|
2819
3103
|
continue;
|
|
2820
3104
|
}
|
|
2821
3105
|
step.status = "skipped";
|
|
2822
3106
|
step.skipReason = matchedCase
|
|
2823
3107
|
? `routed past — "${matchedCase.case}" matched first`
|
|
2824
|
-
: `case not matched: "${step.case}"`;
|
|
3108
|
+
: `case not matched: the previous group's result has no line beginning "${step.case}"`;
|
|
2825
3109
|
step.events.push({
|
|
2826
3110
|
t: new Date().toISOString(),
|
|
2827
3111
|
type: "info",
|
|
@@ -2865,6 +3149,20 @@ function driveRunInner(
|
|
|
2865
3149
|
});
|
|
2866
3150
|
}
|
|
2867
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;
|
|
2868
3166
|
save();
|
|
2869
3167
|
|
|
2870
3168
|
// The gate's declared preview, resolved now against what the run
|
|
@@ -2939,10 +3237,46 @@ function driveRunInner(
|
|
|
2939
3237
|
// twenty steps over — the shares sum to what was left. Between
|
|
2940
3238
|
// groups the check above still refuses to start the next one.
|
|
2941
3239
|
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
2942
|
-
|
|
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
|
+
};
|
|
2943
3267
|
await Promise.all(
|
|
2944
3268
|
launching
|
|
2945
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) {
|
|
2946
3280
|
const attempts = (step.retry ?? 0) + 1;
|
|
2947
3281
|
// A step re-attaching to its sandbox is still on the attempt
|
|
2948
3282
|
// that started it; a step re-run after a backoff picks up the
|
|
@@ -2951,6 +3285,31 @@ function driveRunInner(
|
|
|
2951
3285
|
!step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
|
|
2952
3286
|
const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts! + 1) : 1;
|
|
2953
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;
|
|
2954
3313
|
step.attempts = attempt;
|
|
2955
3314
|
step.status = "running";
|
|
2956
3315
|
save();
|
|
@@ -2965,11 +3324,21 @@ function driveRunInner(
|
|
|
2965
3324
|
effortOverride,
|
|
2966
3325
|
run.id,
|
|
2967
3326
|
ctxData,
|
|
2968
|
-
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
|
+
},
|
|
2969
3336
|
);
|
|
2970
3337
|
// runStep mutates step.status; read it through a widened local
|
|
2971
3338
|
// so TS doesn't keep the "running" narrowing from above.
|
|
2972
3339
|
const outcome: string = step.status;
|
|
3340
|
+
recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
|
|
3341
|
+
save();
|
|
2973
3342
|
if (outcome !== "failed" || attempt === attempts) break;
|
|
2974
3343
|
// A stop destroys the step's sandbox, which reads here as a
|
|
2975
3344
|
// failed attempt — and a failed attempt used to be retried in
|
|
@@ -3014,8 +3383,7 @@ function driveRunInner(
|
|
|
3014
3383
|
break;
|
|
3015
3384
|
}
|
|
3016
3385
|
}
|
|
3017
|
-
|
|
3018
|
-
);
|
|
3386
|
+
}
|
|
3019
3387
|
|
|
3020
3388
|
// on-fail: another agent takes the step over. Sequential and rare:
|
|
3021
3389
|
// the rescue is a second full attempt at the step's job with the
|
|
@@ -3067,7 +3435,7 @@ function driveRunInner(
|
|
|
3067
3435
|
rescue.attempts = 1;
|
|
3068
3436
|
rescue.status = "running";
|
|
3069
3437
|
save();
|
|
3070
|
-
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);
|
|
3071
3439
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
3072
3440
|
const rescueOutcome: string = rescue.status;
|
|
3073
3441
|
if (rescueOutcome === "completed") {
|
|
@@ -3197,16 +3565,25 @@ function driveRunInner(
|
|
|
3197
3565
|
for (const s of run.steps) if (s.status === "pending") s.status = "skipped";
|
|
3198
3566
|
} catch (err) {
|
|
3199
3567
|
run.status = "failed";
|
|
3568
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
3569
|
+
let told = false;
|
|
3200
3570
|
run.steps.forEach((s) => {
|
|
3201
3571
|
if (s.status === "running") {
|
|
3202
3572
|
s.status = "failed";
|
|
3203
|
-
s.events.push({
|
|
3204
|
-
|
|
3205
|
-
type: "error",
|
|
3206
|
-
text: err instanceof Error ? err.message : String(err),
|
|
3207
|
-
});
|
|
3573
|
+
s.events.push({ t: new Date().toISOString(), type: "error", text });
|
|
3574
|
+
told = true;
|
|
3208
3575
|
}
|
|
3209
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
|
+
}
|
|
3210
3587
|
} finally {
|
|
3211
3588
|
if (!parked) {
|
|
3212
3589
|
run.finishedAt = new Date().toISOString();
|
|
@@ -3216,6 +3593,9 @@ function driveRunInner(
|
|
|
3216
3593
|
// learn, and the runs list and the notification stop having to say
|
|
3217
3594
|
// only "completed".
|
|
3218
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);
|
|
3219
3599
|
// Archive before the next run resets outputs/ — a failed run's
|
|
3220
3600
|
// artifacts are usually the most interesting ones, so this runs on
|
|
3221
3601
|
// failure too.
|
|
@@ -3256,16 +3636,6 @@ function driveRunInner(
|
|
|
3256
3636
|
})();
|
|
3257
3637
|
}
|
|
3258
3638
|
|
|
3259
|
-
function readFlow(tenant: string, workspace: string, flowName: string) {
|
|
3260
|
-
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
3261
|
-
if (!fs.existsSync(dir)) return null;
|
|
3262
|
-
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
3263
|
-
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
3264
|
-
if (flow.name === flowName) return flow;
|
|
3265
|
-
}
|
|
3266
|
-
return null;
|
|
3267
|
-
}
|
|
3268
|
-
|
|
3269
3639
|
// Splice `[[flow:other]]` steps into the parent's step list, renumbering
|
|
3270
3640
|
// groups so the nested flow's own parallelism survives. Depth-limited, and
|
|
3271
3641
|
// a cycle raises rather than expanding forever.
|