@signalridge/pi-workflows 1.7.5 → 1.9.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/src/runtime.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A workflow is a raw JavaScript module. The runtime parses it under an acorn
5
5
  * gate (deterministic literal `export const meta` first, no nondeterministic
6
- * builtins), then executes the body inside a `node:vm` realm with a bounded set
6
+ * builtins), then executes the body/callbacks in an owned Node helper's vm realm
7
+ * with validated IPC proxies to the parent-owned bounded set
7
8
  * of orchestration globals (`agent`, `parallel`, `pipeline`, `workflow`,
8
9
  * quality/control helpers, `phase`, `log`, `args`, `cwd`, restricted
9
10
  * `process`, `budget`). The vm is a determinism guardrail, not a security
@@ -20,7 +21,6 @@
20
21
 
21
22
  import { AsyncLocalStorage } from "node:async_hooks";
22
23
  import { createHash } from "node:crypto";
23
- import vm from "node:vm";
24
24
  import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
25
25
  import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
26
26
  import { parse } from "acorn";
@@ -40,6 +40,7 @@ import {
40
40
  type WorkflowStrength,
41
41
  type WorkflowStrengthTable,
42
42
  } from "./strengths.js";
43
+ import { executeOwnedVm } from "./vm-helper.js";
43
44
 
44
45
  // ── Meta contract ────────────────────────────────────────────────────────────
45
46
 
@@ -122,6 +123,11 @@ export interface WorkflowAgentRunner {
122
123
 
123
124
  export interface AgentRunOptions {
124
125
  label?: string;
126
+ /** Observation-only scope; never part of call hashing or dispatch policy. */
127
+ graphId?: string;
128
+ taskKey?: string;
129
+ taskId?: string;
130
+ phase?: string;
125
131
  sessionName?: string;
126
132
  /** Plain JSON Schema the result must satisfy; the runtime parses+validates client-side. */
127
133
  schema?: unknown;
@@ -211,6 +217,10 @@ export interface CheckpointOptions {
211
217
 
212
218
  export interface WorkflowRuntimeEvent {
213
219
  type: "phase" | "workflow" | "quality" | "control-attempt" | "task";
220
+ /** Resolver-proven source classification for nested workflow observations. */
221
+ sourceKind?: "saved" | "inline";
222
+ /** Observation-only frame-scoped hash of the exact attributed phase title. */
223
+ phaseKey?: string;
214
224
  [key: string]: unknown;
215
225
  }
216
226
 
@@ -290,6 +300,8 @@ export interface WorkflowRunOptions {
290
300
  agentTimeoutMs?: number | null;
291
301
  runId?: string;
292
302
  resumeJournal?: Map<string, JournalEntry>;
303
+ /** Accepted parent-frame nested generation watermarks, independent of replay eligibility. */
304
+ resumeWorkflowGenerations?: ReadonlyMap<number, number>;
293
305
  resumeFromRunId?: string;
294
306
  toolset?: string;
295
307
  excludeTools?: string[];
@@ -303,14 +315,23 @@ export interface WorkflowRunOptions {
303
315
  /** Resolve a nested `workflow(name)` reference to the script it names. */
304
316
  loadSavedWorkflow?: (name: string) => string | undefined;
305
317
  confirm?: (promptText: string, options: CheckpointOptions, signal?: AbortSignal) => Promise<unknown>;
306
- onLog?: (message: string) => void;
318
+ onLog?: (message: string, scope?: { frameId: string }) => void;
307
319
  onPhase?: (title: string) => void;
308
320
  onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
309
321
  onAgentStart?: (event: {
310
322
  id: string;
311
323
  label: string;
312
324
  phase?: string;
325
+ /** Observation-only exact title identity, scoped to this call's frame. */
326
+ phaseKey?: string;
313
327
  prompt: string;
328
+ frameId?: string;
329
+ callIndex?: number;
330
+ agentType?: string;
331
+ replayed?: boolean;
332
+ graphId?: string;
333
+ taskKey?: string;
334
+ taskId?: string;
314
335
  model?: string;
315
336
  tier?: string;
316
337
  }) => void;
@@ -318,6 +339,7 @@ export interface WorkflowRunOptions {
318
339
  id: string;
319
340
  label: string;
320
341
  phase?: string;
342
+ phaseKey?: string;
321
343
  tier?: string;
322
344
  result: unknown;
323
345
  tokens?: number;
@@ -391,6 +413,45 @@ export const MAX_AGENT_RETRIES = 3;
391
413
  */
392
414
  export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 1_800_000;
393
415
  export const MAX_SCRIPT_SYNC_MS = 1_000;
416
+ /** Identities are retained verbatim, never clipped like display text. */
417
+ export const MAX_RUNTIME_IDENTITY_LENGTH = 2_048;
418
+ const MAX_ROOT_FRAME_LENGTH = 1_024;
419
+ const MAX_EXECUTION_NONCE_LENGTH = 512;
420
+ const MAX_WORKFLOW_GENERATION = 1_000_000;
421
+
422
+ function validateRuntimeIdentity(value: string, label: string, limit: number): void {
423
+ if (typeof value !== "string" || value.length === 0 || value.length > limit) {
424
+ throw new WorkflowError(
425
+ `${label} must be a non-empty identity of at most ${limit} characters`,
426
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
427
+ { recoverable: false },
428
+ );
429
+ }
430
+ }
431
+
432
+ function copyWorkflowGenerations(values?: ReadonlyMap<number, number>): Map<number, number> {
433
+ const generations = new Map<number, number>();
434
+ for (const [index, generation] of values ?? []) {
435
+ if (
436
+ !Number.isInteger(index) ||
437
+ index < 0 ||
438
+ index >= MAX_CALLS_PER_WORKFLOW ||
439
+ !Number.isInteger(generation) ||
440
+ generation < 1 ||
441
+ generation > MAX_WORKFLOW_GENERATION
442
+ ) {
443
+ throw new WorkflowError(
444
+ "Invalid nested workflow generation watermark",
445
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
446
+ {
447
+ recoverable: false,
448
+ },
449
+ );
450
+ }
451
+ generations.set(index, generation);
452
+ }
453
+ return generations;
454
+ }
394
455
 
395
456
  // ── Determinism guardrail ────────────────────────────────────────────────────
396
457
 
@@ -895,7 +956,8 @@ export function normalizeMaxAgents(value: unknown): number {
895
956
  export function normalizeAgentTimeout(value: unknown): number | null {
896
957
  if (value === null) return null;
897
958
  if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
898
- if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
959
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 2_147_483_647)
960
+ return DEFAULT_AGENT_TIMEOUT_MS;
899
961
  return Math.floor(value);
900
962
  }
901
963
 
@@ -1028,7 +1090,10 @@ function validateValue(value: unknown, schema: SchemaNode, path: string): string
1028
1090
  if (type !== undefined) {
1029
1091
  const types = Array.isArray(type) ? type : [type];
1030
1092
  const typeOf = (v: unknown): string => (v === null ? "null" : Array.isArray(v) ? "array" : typeof v);
1031
- if (!types.includes(typeOf(value))) {
1093
+ const matches = types.some((expected) =>
1094
+ expected === "integer" ? typeof value === "number" && Number.isInteger(value) : expected === typeOf(value),
1095
+ );
1096
+ if (!matches) {
1032
1097
  errors.push(`${path}: expected type ${types.join("/")}, got ${typeOf(value)}`);
1033
1098
  return errors;
1034
1099
  }
@@ -1228,7 +1293,13 @@ export interface WorkflowRuntimeBindings {
1228
1293
  /** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
1229
1294
  const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
1230
1295
  /** Task-local phase attribution for named orchestration callbacks. */
1231
- const orchestrationScope = new AsyncLocalStorage<{ taskId: string; phase?: string }>();
1296
+ const orchestrationScope = new AsyncLocalStorage<{
1297
+ frameId: string;
1298
+ graphId: string;
1299
+ taskKey: string;
1300
+ taskId: string;
1301
+ phase?: string;
1302
+ }>();
1232
1303
 
1233
1304
  // ── The run ──────────────────────────────────────────────────────────────────
1234
1305
 
@@ -1237,10 +1308,24 @@ export async function runWorkflow<T = unknown>(
1237
1308
  options: WorkflowRunOptions = {},
1238
1309
  ): Promise<WorkflowRunResult<T>> {
1239
1310
  const started = Date.now();
1311
+ // Each frame owns cancellation, even when a parent catches a nested failure.
1312
+ const frameController = new AbortController();
1313
+ options = {
1314
+ ...options,
1315
+ signal: options.signal ? AbortSignal.any([options.signal, frameController.signal]) : frameController.signal,
1316
+ };
1240
1317
  const { meta, body } = parseWorkflowScript(script);
1241
1318
  const maxAgents = normalizeMaxAgents(options.maxAgents ?? MAX_AGENTS_PER_RUN);
1242
1319
  const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
1243
1320
  const runId = options.runId ?? `run-${started.toString(36)}`;
1321
+ // Reserve room for the one supported child level, nonce and graph/call suffixes.
1322
+ validateRuntimeIdentity(
1323
+ runId,
1324
+ "runId",
1325
+ options.workflowDepth ? MAX_RUNTIME_IDENTITY_LENGTH - 64 : MAX_ROOT_FRAME_LENGTH,
1326
+ );
1327
+ if (options.executionNonce !== undefined)
1328
+ validateRuntimeIdentity(options.executionNonce, "executionNonce", MAX_EXECUTION_NONCE_LENGTH);
1244
1329
  const baseCwd = options.cwd ?? process.cwd();
1245
1330
  // The host's catalogue, known before the first call. A script cannot name a
1246
1331
  // tier, so this is not a typo check: it is what the strength table is resolved
@@ -1260,7 +1345,7 @@ export async function runWorkflow<T = unknown>(
1260
1345
  currentPhase: meta.phases?.[0]?.title,
1261
1346
  phaseBudgets: new Map(),
1262
1347
  callSeq: 0,
1263
- workflowGenerations: new Map(),
1348
+ workflowGenerations: copyWorkflowGenerations(options.resumeWorkflowGenerations),
1264
1349
  firstMiss: Number.POSITIVE_INFINITY,
1265
1350
  };
1266
1351
 
@@ -1294,7 +1379,7 @@ export async function runWorkflow<T = unknown>(
1294
1379
  const log = (message: unknown) => {
1295
1380
  const text = String(message);
1296
1381
  state.logs.push(text);
1297
- options.onLog?.(text);
1382
+ options.onLog?.(text, { frameId: runId });
1298
1383
  };
1299
1384
  if (concurrencyClampNotice !== undefined) log(concurrencyClampNotice);
1300
1385
 
@@ -1384,14 +1469,39 @@ export async function runWorkflow<T = unknown>(
1384
1469
  );
1385
1470
  };
1386
1471
 
1472
+ let graphSequence = 0;
1473
+ let helperSequence = 0;
1387
1474
  const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
1388
1475
  try {
1389
- options.onRuntimeEvent?.(event);
1476
+ const scope = orchestrationScope.getStore();
1477
+ options.onRuntimeEvent?.(
1478
+ structuredClone({
1479
+ // Origin task scope can belong to the parent; emission frame cannot.
1480
+ ...(scope ? { graphId: scope.graphId, taskKey: scope.taskKey, taskId: scope.taskId } : {}),
1481
+ ...event,
1482
+ // A child's forwarded events must never look like its parent's phase.
1483
+ frameId: runId,
1484
+ runId,
1485
+ }),
1486
+ );
1390
1487
  } catch {
1391
1488
  // Runtime observers are diagnostic surfaces and must not change execution.
1392
1489
  }
1393
1490
  };
1394
1491
 
1492
+ // Observation identity is independent of bounded display text and call hashing.
1493
+ const phaseObservation = (title: string | undefined) => ({
1494
+ phase: title,
1495
+ phaseKey:
1496
+ title === undefined
1497
+ ? undefined
1498
+ : createHash("sha256")
1499
+ .update(JSON.stringify([runId, title]))
1500
+ .digest("hex"),
1501
+ });
1502
+ const taskObservation = (scope: ReturnType<typeof orchestrationScope.getStore>) =>
1503
+ scope ? { graphId: scope.graphId, taskKey: scope.taskKey, taskId: scope.taskId } : {};
1504
+
1395
1505
  const phase = (title: string, phaseOptions?: { budget?: number }) => {
1396
1506
  state.currentPhase = title;
1397
1507
  if (!state.phases.includes(title)) state.phases.push(title);
@@ -1404,6 +1514,7 @@ export async function runWorkflow<T = unknown>(
1404
1514
  emitRuntimeEvent({
1405
1515
  type: "phase",
1406
1516
  title,
1517
+ phaseKey: phaseObservation(title).phaseKey,
1407
1518
  budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
1408
1519
  });
1409
1520
  };
@@ -1489,6 +1600,8 @@ export async function runWorkflow<T = unknown>(
1489
1600
  // synchronously, before suspending on the limiter below.
1490
1601
  const batch = fanoutScope.getStore();
1491
1602
 
1603
+ // Preserve inherited phase attribution (and therefore existing call hashes)
1604
+ // across nested frames; a task key names its origin graph, not the call's frame.
1492
1605
  const taskScope = orchestrationScope.getStore();
1493
1606
  const assignedPhase = agentOptions.phase ?? taskScope?.phase ?? state.currentPhase;
1494
1607
 
@@ -1538,6 +1651,13 @@ export async function runWorkflow<T = unknown>(
1538
1651
  const deltaKey = `${runId}:${callIndex}`;
1539
1652
 
1540
1653
  const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount + 1);
1654
+ const observation = {
1655
+ frameId: runId,
1656
+ callIndex,
1657
+ agentType: agentOptions.agentType ?? "general-purpose",
1658
+ ...phaseObservation(assignedPhase),
1659
+ ...taskObservation(taskScope),
1660
+ };
1541
1661
 
1542
1662
  // Longest-unchanged-prefix resume: replay a cached result only while the
1543
1663
  // prefix is still intact. Once any call misses, it AND everything after it
@@ -1556,6 +1676,8 @@ export async function runWorkflow<T = unknown>(
1556
1676
  const replayTier = cached.tier ?? effectiveTier;
1557
1677
  options.onAgentStart?.({
1558
1678
  id: deltaKey,
1679
+ ...observation,
1680
+ replayed: true,
1559
1681
  label,
1560
1682
  phase: assignedPhase,
1561
1683
  prompt: promptText,
@@ -1564,7 +1686,7 @@ export async function runWorkflow<T = unknown>(
1564
1686
  options.onAgentEnd?.({
1565
1687
  id: deltaKey,
1566
1688
  label,
1567
- phase: assignedPhase,
1689
+ ...phaseObservation(assignedPhase),
1568
1690
  tier: replayTier,
1569
1691
  result: cached.result,
1570
1692
  tokens: 0,
@@ -1616,12 +1738,15 @@ export async function runWorkflow<T = unknown>(
1616
1738
  throwIfAborted();
1617
1739
  if (batch?.cancelled) throw agentLimitError();
1618
1740
 
1619
- const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
1741
+ const timeout =
1742
+ agentOptions.timeoutMs !== undefined ? normalizeAgentTimeout(agentOptions.timeoutMs) : agentTimeoutMs;
1620
1743
  const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
1621
1744
  const maxAttempts = retryAttempts + 1;
1622
1745
 
1623
1746
  options.onAgentStart?.({
1624
1747
  id: deltaKey,
1748
+ ...observation,
1749
+ replayed: false,
1625
1750
  label,
1626
1751
  phase: assignedPhase,
1627
1752
  prompt: promptText,
@@ -1637,7 +1762,7 @@ export async function runWorkflow<T = unknown>(
1637
1762
  // failed attempt does not double-count the next one.
1638
1763
  let usage: AgentUsage | undefined;
1639
1764
  const recordTokens = (result: unknown): number => {
1640
- const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(promptText);
1765
+ const tokens = usage === undefined ? estimateTokens(result) + estimateTokens(promptText) : usage.total;
1641
1766
  if (usage) {
1642
1767
  shared.tokenUsage.input += usage.input;
1643
1768
  shared.tokenUsage.output += usage.output;
@@ -1699,6 +1824,10 @@ export async function runWorkflow<T = unknown>(
1699
1824
  let restartTimeout: (() => void) | undefined;
1700
1825
  const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
1701
1826
  label,
1827
+ phase: assignedPhase,
1828
+ ...(taskScope
1829
+ ? { graphId: taskScope.graphId, taskKey: taskScope.taskKey, taskId: taskScope.taskId }
1830
+ : {}),
1702
1831
  sessionName: agentOptions.thread
1703
1832
  ? `workflow:${runId} thread:${agentOptions.thread}`
1704
1833
  : `workflow:${runId} ${label}`,
@@ -1709,7 +1838,10 @@ export async function runWorkflow<T = unknown>(
1709
1838
  agentType: agentOptions.agentType,
1710
1839
  isolation: resolvedIsolation,
1711
1840
  toolset: options.toolset ?? agentOptions.toolset,
1712
- excludeTools: options.excludeTools ?? agentOptions.excludeTools,
1841
+ excludeTools:
1842
+ options.excludeTools === undefined && agentOptions.excludeTools === undefined
1843
+ ? undefined
1844
+ : [...new Set([...(options.excludeTools ?? []), ...(agentOptions.excludeTools ?? [])])],
1713
1845
  runId,
1714
1846
  callIndex,
1715
1847
  callHash,
@@ -1800,7 +1932,7 @@ export async function runWorkflow<T = unknown>(
1800
1932
  options.onAgentEnd?.({
1801
1933
  id: deltaKey,
1802
1934
  label,
1803
- phase: assignedPhase,
1935
+ ...phaseObservation(assignedPhase),
1804
1936
  result,
1805
1937
  tier: resolvedTier,
1806
1938
  tokens,
@@ -1842,7 +1974,7 @@ export async function runWorkflow<T = unknown>(
1842
1974
  options.onAgentEnd?.({
1843
1975
  id: deltaKey,
1844
1976
  label,
1845
- phase: assignedPhase,
1977
+ ...phaseObservation(assignedPhase),
1846
1978
  result: null,
1847
1979
  tier: resolvedTier,
1848
1980
  tokens,
@@ -1973,11 +2105,20 @@ export async function runWorkflow<T = unknown>(
1973
2105
  const orchestrate = async (rawTasks: unknown, rawOptions?: unknown): Promise<OrchestrationResult> => {
1974
2106
  throwIfAborted();
1975
2107
  const normalized = normalizeOrchestration(rawTasks, rawOptions);
2108
+ const graphId = `${runId}:graph-${++graphSequence}`;
2109
+ const taskKeys = new Map(normalized.tasks.map((task, index) => [task.id, `${graphId}:task-${index}`]));
1976
2110
  const tasks = normalized.tasks.map((task) => ({
1977
2111
  ...task,
1978
2112
  run: (context: OrchestrationTaskContext) =>
1979
- orchestrationScope.run({ taskId: task.id, ...(task.phase === undefined ? {} : { phase: task.phase }) }, () =>
1980
- task.run(context),
2113
+ orchestrationScope.run(
2114
+ {
2115
+ frameId: runId,
2116
+ graphId,
2117
+ taskKey: taskKeys.get(task.id) ?? "",
2118
+ taskId: task.id,
2119
+ ...(task.phase === undefined ? {} : { phase: task.phase }),
2120
+ },
2121
+ () => task.run(context),
1981
2122
  ),
1982
2123
  }));
1983
2124
  return executeOrchestration(tasks, normalized.options, {
@@ -1988,7 +2129,15 @@ export async function runWorkflow<T = unknown>(
1988
2129
  return !workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT;
1989
2130
  },
1990
2131
  formatError: (error: unknown) => wrapError(error).message,
1991
- onEvent: emitRuntimeEvent,
2132
+ onEvent: (event) => {
2133
+ const task = normalized.tasks.find((candidate) => candidate.id === event.taskId);
2134
+ emitRuntimeEvent({
2135
+ ...event,
2136
+ graphId,
2137
+ taskKey: taskKeys.get(event.taskId),
2138
+ ...(task?.description === undefined ? {} : { description: task.description }),
2139
+ });
2140
+ },
1992
2141
  });
1993
2142
  };
1994
2143
 
@@ -2005,8 +2154,18 @@ export async function runWorkflow<T = unknown>(
2005
2154
  });
2006
2155
  }
2007
2156
  const callIndex = nextCallIndex();
2008
- const childScript = options.loadSavedWorkflow?.(String(nameOrScript)) ?? String(nameOrScript);
2157
+ const taskScope = orchestrationScope.getStore();
2158
+ const workflowPhase = taskScope?.phase ?? state.currentPhase;
2009
2159
  const workflowName = String(nameOrScript);
2160
+ const savedScript = options.loadSavedWorkflow?.(workflowName);
2161
+ const childScript = savedScript ?? workflowName;
2162
+ const observation = {
2163
+ ...phaseObservation(workflowPhase),
2164
+ ...taskObservation(taskScope),
2165
+ // Classify the resolver result, never source syntax or display text.
2166
+ sourceKind: savedScript === undefined ? ("inline" as const) : ("saved" as const),
2167
+ name: savedScript === undefined ? "nested workflow" : workflowName,
2168
+ };
2010
2169
  const argsPresent = childArgs !== undefined;
2011
2170
  const callHash = hashIdentity({
2012
2171
  kind: "workflow",
@@ -2026,17 +2185,41 @@ export async function runWorkflow<T = unknown>(
2026
2185
  });
2027
2186
  const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
2028
2187
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
2029
- state.workflowGenerations.set(callIndex, cached.generation ?? 1);
2188
+ state.workflowGenerations.set(
2189
+ callIndex,
2190
+ Math.max(state.workflowGenerations.get(callIndex) ?? 0, cached.generation ?? 1),
2191
+ );
2030
2192
  if (cached.storeDelta) store.applyDelta(cached.storeDelta);
2031
2193
  const replayedAgentCount = cached.agentCount ?? 0;
2032
2194
  shared.agentCount += replayedAgentCount;
2033
2195
  frameAgentCount += replayedAgentCount;
2196
+ emitRuntimeEvent({
2197
+ type: "workflow",
2198
+ stage: "replay",
2199
+ ...observation,
2200
+ callIndex,
2201
+ replayedAgentCount,
2202
+ });
2034
2203
  return cached.result;
2035
2204
  }
2036
2205
  if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
2037
- const generation = (state.workflowGenerations.get(callIndex) ?? cached?.generation ?? 0) + 1;
2206
+ const generation = Math.max(state.workflowGenerations.get(callIndex) ?? 0, cached?.generation ?? 0) + 1;
2207
+ if (generation > MAX_WORKFLOW_GENERATION) {
2208
+ throw new WorkflowError("Nested workflow generation limit exceeded", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
2209
+ recoverable: false,
2210
+ });
2211
+ }
2038
2212
  state.workflowGenerations.set(callIndex, generation);
2039
- emitRuntimeEvent({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
2213
+ const childFrameId = `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`;
2214
+ emitRuntimeEvent({
2215
+ type: "workflow",
2216
+ stage: "start",
2217
+ ...observation,
2218
+ args: childArgs,
2219
+ callIndex,
2220
+ childFrameId,
2221
+ });
2222
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
2040
2223
  try {
2041
2224
  const childDeltas = new Map<number, Record<string, unknown>>();
2042
2225
  const child = await runWorkflow(childScript, {
@@ -2049,6 +2232,8 @@ export async function runWorkflow<T = unknown>(
2049
2232
  // call results here would duplicate nested calls and make the synthetic
2050
2233
  // parent journal entry ambiguous.
2051
2234
  resumeJournal: undefined,
2235
+ // These watermarks belong to the parent lexical namespace only.
2236
+ resumeWorkflowGenerations: undefined,
2052
2237
  resumeFromRunId: undefined,
2053
2238
  onWorkflowJournal: undefined,
2054
2239
  onAgentJournal: (entry) => {
@@ -2060,7 +2245,7 @@ export async function runWorkflow<T = unknown>(
2060
2245
  // The call index + generation keeps changed nested calls distinct across
2061
2246
  // resume. The engine nonce additionally fences an interrupted child whose
2062
2247
  // result was never durable before a process/branch restart.
2063
- runId: `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`,
2248
+ runId: childFrameId,
2064
2249
  });
2065
2250
  const normalizedChildResult = child.result === undefined ? null : child.result;
2066
2251
  const storeDelta: Record<string, unknown> = {};
@@ -2079,14 +2264,51 @@ export async function runWorkflow<T = unknown>(
2079
2264
  agentCount: child.agentCount,
2080
2265
  ...(Object.keys(storeDelta).length > 0 ? { storeDelta } : {}),
2081
2266
  });
2267
+ outcome = "completed";
2082
2268
  return normalizedChildResult;
2083
2269
  } finally {
2084
- emitRuntimeEvent({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
2270
+ if (isAborted()) outcome = "cancelled";
2271
+ emitRuntimeEvent({
2272
+ type: "workflow",
2273
+ stage: "end",
2274
+ ...observation,
2275
+ args: childArgs,
2276
+ callIndex,
2277
+ childFrameId,
2278
+ outcome,
2279
+ });
2085
2280
  }
2086
2281
  };
2087
2282
 
2088
2283
  // ── Quality-pattern stdlib ──────────────────────────────────────────────────
2089
2284
 
2285
+ const observeQuality = async <T>(helper: string, run: () => Promise<T>): Promise<T> => {
2286
+ const helperId = `${runId}:helper-${++helperSequence}`;
2287
+ emitRuntimeEvent({ type: "quality", stage: "start", helper, helperId });
2288
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
2289
+ let accepted: boolean | undefined;
2290
+ try {
2291
+ const value = await run();
2292
+ outcome = "completed";
2293
+ if (value && typeof value === "object") {
2294
+ const verdict = value as Record<string, unknown>;
2295
+ if (typeof verdict.real === "boolean") accepted = verdict.real;
2296
+ if (typeof verdict.complete === "boolean") accepted = verdict.complete;
2297
+ }
2298
+ return value;
2299
+ } finally {
2300
+ if (isAborted()) outcome = "cancelled";
2301
+ emitRuntimeEvent({
2302
+ type: "quality",
2303
+ stage: "end",
2304
+ helper,
2305
+ helperId,
2306
+ outcome,
2307
+ ...(accepted === undefined ? {} : { accepted }),
2308
+ });
2309
+ }
2310
+ };
2311
+
2090
2312
  const VERIFY_SCHEMA = {
2091
2313
  type: "object",
2092
2314
  properties: { real: { type: "boolean" }, reason: { type: "string" } },
@@ -2101,33 +2323,34 @@ export async function runWorkflow<T = unknown>(
2101
2323
  total: number;
2102
2324
  votes: Array<{ real: boolean; reason?: string }>;
2103
2325
  }> => {
2104
- emitRuntimeEvent({ type: "quality", stage: "start", helper: "verify" });
2105
- const reviewers = Math.max(1, opts.reviewers ?? 2);
2106
- const threshold = opts.threshold ?? 0.5;
2107
- const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
2108
- const claim = typeof item === "string" ? item : JSON.stringify(item);
2109
- const votes = (
2110
- await parallel(
2111
- Array.from(
2112
- { length: reviewers },
2113
- (_v, i) => () =>
2114
- agent(
2115
- `Adversarially review whether the following is REAL/correct. Try to refute it; default to real=false if unsure.${lenses.length ? ` Focus lens: ${lenses[i % lenses.length]}.` : ""}\n\n${claim}`,
2116
- // Defaults low: numerous, narrow, and aggregated by the vote
2117
- // below, which is what carries the gate rather than any single
2118
- // reviewer's depth. A caller who wants a deeper gate says so.
2119
- { label: `verify ${i + 1}`, strength: opts.strength ?? "low", schema: VERIFY_SCHEMA },
2120
- ),
2121
- ),
2122
- )
2123
- ).filter(Boolean) as Array<{ real?: boolean; reason?: string }>;
2124
- const realCount = votes.filter((v) => v?.real).length;
2125
- return {
2126
- real: votes.length > 0 && realCount / votes.length >= threshold,
2127
- realCount,
2128
- total: votes.length,
2129
- votes: votes.map((v) => ({ real: Boolean(v?.real), reason: v?.reason })),
2130
- };
2326
+ return observeQuality("verify", async () => {
2327
+ const reviewers = Math.max(1, opts.reviewers ?? 2);
2328
+ const threshold = opts.threshold ?? 0.5;
2329
+ const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
2330
+ const claim = typeof item === "string" ? item : JSON.stringify(item);
2331
+ const votes = (
2332
+ await parallel(
2333
+ Array.from(
2334
+ { length: reviewers },
2335
+ (_v, i) => () =>
2336
+ agent(
2337
+ `Adversarially review whether the following is REAL/correct. Try to refute it; default to real=false if unsure.${lenses.length ? ` Focus lens: ${lenses[i % lenses.length]}.` : ""}\n\n${claim}`,
2338
+ // Defaults low: numerous, narrow, and aggregated by the vote
2339
+ // below, which is what carries the gate rather than any single
2340
+ // reviewer's depth. A caller who wants a deeper gate says so.
2341
+ { label: `verify ${i + 1}`, strength: opts.strength ?? "low", schema: VERIFY_SCHEMA },
2342
+ ),
2343
+ ),
2344
+ )
2345
+ ).filter(Boolean) as Array<{ real?: boolean; reason?: string }>;
2346
+ const realCount = votes.filter((v) => v?.real).length;
2347
+ return {
2348
+ real: votes.length > 0 && realCount / votes.length >= threshold,
2349
+ realCount,
2350
+ total: votes.length,
2351
+ votes: votes.map((v) => ({ real: Boolean(v?.real), reason: v?.reason })),
2352
+ };
2353
+ });
2131
2354
  };
2132
2355
 
2133
2356
  const JUDGE_SCHEMA = {
@@ -2141,58 +2364,53 @@ export async function runWorkflow<T = unknown>(
2141
2364
  ): Promise<
2142
2365
  { index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
2143
2366
  > => {
2144
- emitRuntimeEvent({ type: "quality", stage: "start", helper: "judgePanel" });
2145
- if (!Array.isArray(attempts) || attempts.length === 0) {
2146
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
2147
- return undefined;
2148
- }
2149
- const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
2150
- const rubric = opts.rubric ?? "overall quality and correctness";
2151
- const scored = (
2152
- await parallel(
2153
- (Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
2154
- const text = typeof att === "string" ? att : JSON.stringify(att);
2155
- const js = (
2156
- await parallel(
2157
- Array.from(
2158
- { length: judges },
2159
- (_v, j) => () =>
2160
- agent(
2161
- `Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
2162
- {
2163
- label: `judge ${idx + 1}.${j + 1}`,
2164
- // Same shape as verify: many narrow scores, aggregated.
2165
- strength: opts.strength ?? "low",
2166
- schema: JUDGE_SCHEMA,
2167
- },
2168
- ),
2169
- ),
2170
- )
2171
- ).filter(Boolean) as Array<{ score?: number }>;
2172
- const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
2173
- return { index: idx, attempt: att, score, judgments: js };
2174
- }),
2175
- )
2176
- ).filter(Boolean) as Array<{
2177
- index: number;
2178
- attempt: unknown;
2179
- score: number;
2180
- judgments: Array<{ score?: number; reason?: string }>;
2181
- }>;
2182
- const normalized = scored.map((s) => ({
2183
- index: s.index,
2184
- attempt: s.attempt,
2185
- score: s.score,
2186
- judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
2187
- }));
2188
- if (normalized.length === 0) {
2189
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
2190
- return undefined;
2191
- }
2192
- let best = normalized[0];
2193
- for (const s of normalized) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
2194
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
2195
- return best;
2367
+ return observeQuality("judgePanel", async () => {
2368
+ if (!Array.isArray(attempts) || attempts.length === 0) return undefined;
2369
+ const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
2370
+ const rubric = opts.rubric ?? "overall quality and correctness";
2371
+ const scored = (
2372
+ await parallel(
2373
+ (Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
2374
+ const text = typeof att === "string" ? att : JSON.stringify(att);
2375
+ const js = (
2376
+ await parallel(
2377
+ Array.from(
2378
+ { length: judges },
2379
+ (_v, j) => () =>
2380
+ agent(
2381
+ `Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
2382
+ {
2383
+ label: `judge ${idx + 1}.${j + 1}`,
2384
+ // Same shape as verify: many narrow scores, aggregated.
2385
+ strength: opts.strength ?? "low",
2386
+ schema: JUDGE_SCHEMA,
2387
+ },
2388
+ ),
2389
+ ),
2390
+ )
2391
+ ).filter(Boolean) as Array<{ score?: number }>;
2392
+ const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
2393
+ return { index: idx, attempt: att, score, judgments: js };
2394
+ }),
2395
+ )
2396
+ ).filter(Boolean) as Array<{
2397
+ index: number;
2398
+ attempt: unknown;
2399
+ score: number;
2400
+ judgments: Array<{ score?: number; reason?: string }>;
2401
+ }>;
2402
+ const normalized = scored.map((s) => ({
2403
+ index: s.index,
2404
+ attempt: s.attempt,
2405
+ score: s.score,
2406
+ judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
2407
+ }));
2408
+ if (normalized.length === 0) return undefined;
2409
+ let best = normalized[0];
2410
+ for (const s of normalized)
2411
+ if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
2412
+ return best;
2413
+ });
2196
2414
  };
2197
2415
 
2198
2416
  const loopUntilDry = async (opts: {
@@ -2220,15 +2438,20 @@ export async function runWorkflow<T = unknown>(
2220
2438
  if (code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED || code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) break;
2221
2439
  throw error;
2222
2440
  }
2223
- const fresh = (Array.isArray(items) ? items : []).filter((x) => x != null && !seen.has(key(x)));
2441
+ const fresh: Array<{ item: unknown; key: string }> = [];
2442
+ for (const item of Array.isArray(items) ? items : []) {
2443
+ if (item == null) continue;
2444
+ const identity = await key(item);
2445
+ if (!seen.has(identity)) fresh.push({ item, key: identity });
2446
+ }
2224
2447
  if (!fresh.length) {
2225
2448
  dry++;
2226
2449
  continue;
2227
2450
  }
2228
2451
  dry = 0;
2229
- for (const x of fresh) {
2230
- seen.add(key(x));
2231
- all.push(x);
2452
+ for (const entry of fresh) {
2453
+ seen.add(entry.key);
2454
+ all.push(entry.item);
2232
2455
  }
2233
2456
  }
2234
2457
  return all;
@@ -2244,16 +2467,16 @@ export async function runWorkflow<T = unknown>(
2244
2467
  results: unknown,
2245
2468
  opts: { strength?: WorkflowStrength } = {},
2246
2469
  ): Promise<{ complete: boolean; missing?: string[] } | null> => {
2247
- emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
2248
- const verdict = await agent(
2249
- `Given the task and the results gathered so far, list what is still MISSING (modalities not covered, claims unverified, gaps). Be specific and concise.\n\nTask:\n${JSON.stringify(taskArgs)}\n\nResults so far:\n${JSON.stringify(results).slice(0, 4000)}`,
2250
- // Defaults medium, not low like the other two gates: one call, no vote to
2251
- // average over, and an open-ended read of the whole task rather than one
2252
- // claim, so there is no redundancy here to carry a shallow verdict.
2253
- { label: "completeness critic", strength: opts.strength ?? "medium", schema: COMPLETENESS_SCHEMA },
2254
- );
2255
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
2256
- return verdict as { complete: boolean; missing?: string[] } | null;
2470
+ return observeQuality("completenessCheck", async () => {
2471
+ const verdict = await agent(
2472
+ `Given the task and the results gathered so far, list what is still MISSING (modalities not covered, claims unverified, gaps). Be specific and concise.\n\nTask:\n${JSON.stringify(taskArgs)}\n\nResults so far:\n${JSON.stringify(results).slice(0, 4000)}`,
2473
+ // Defaults medium, not low like the other two gates: one call, no vote to
2474
+ // average over, and an open-ended read of the whole task rather than one
2475
+ // claim, so there is no redundancy here to carry a shallow verdict.
2476
+ { label: "completeness critic", strength: opts.strength ?? "medium", schema: COMPLETENESS_SCHEMA },
2477
+ );
2478
+ return verdict as { complete: boolean; missing?: string[] } | null;
2479
+ });
2257
2480
  };
2258
2481
 
2259
2482
  const retry = async (
@@ -2265,7 +2488,7 @@ export async function runWorkflow<T = unknown>(
2265
2488
  for (let i = 0; i < attempts; i++) {
2266
2489
  throwIfAborted();
2267
2490
  last = await thunk(i);
2268
- const accepted = !opts.until || opts.until(last);
2491
+ const accepted = !opts.until || (await opts.until(last));
2269
2492
  emitRuntimeEvent({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
2270
2493
  if (accepted) return last;
2271
2494
  }
@@ -2302,10 +2525,22 @@ export async function runWorkflow<T = unknown>(
2302
2525
  const callIndex = nextCallIndex();
2303
2526
  const callHash = hashCheckpoint(promptText, checkpointOptions);
2304
2527
  const journalKey = `${runId}:${callIndex}`;
2528
+ const taskScope = orchestrationScope.getStore();
2529
+ const observation = {
2530
+ ...phaseObservation(taskScope?.phase ?? state.currentPhase),
2531
+ ...taskObservation(taskScope),
2532
+ };
2305
2533
  const cached = options.resumeJournal?.get(journalKey);
2306
2534
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
2307
2535
  shared.agentCount++;
2308
2536
  frameAgentCount++;
2537
+ emitRuntimeEvent({
2538
+ type: "control-attempt",
2539
+ helper: "checkpoint",
2540
+ stage: "replay",
2541
+ callIndex,
2542
+ ...observation,
2543
+ });
2309
2544
  return cached.result; // replay the journaled human reply without spending a slot
2310
2545
  }
2311
2546
  if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
@@ -2314,48 +2549,69 @@ export async function runWorkflow<T = unknown>(
2314
2549
  frameAgentCount++;
2315
2550
  shared.physicalAgentCount++;
2316
2551
 
2317
- let reply: unknown;
2318
- if (options.confirm) {
2319
- // Host-only cancellation must not affect author options or replay hashes.
2320
- const signal = options.signal
2321
- ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2322
- : shared.runFatalController.signal;
2323
- const confirm = options.confirm;
2324
- let onAbort: (() => void) | undefined;
2325
- try {
2326
- const cancelled = new Promise<never>((_resolve, reject) => {
2327
- onAbort = () => {
2328
- try {
2329
- throwIfAborted();
2330
- } catch (error) {
2331
- reject(error);
2332
- }
2333
- };
2334
- signal.addEventListener("abort", onAbort, { once: true });
2335
- if (signal.aborted) onAbort();
2336
- });
2337
- // Older host callbacks may ignore the optional signal. Still release
2338
- // the runtime wait, and observe any eventual callback rejection.
2339
- const answer = Promise.resolve().then(() => {
2340
- throwIfAborted();
2341
- return confirm(promptText, checkpointOptions, signal);
2342
- });
2343
- reply = await Promise.race([answer, cancelled]);
2344
- } finally {
2345
- if (onAbort) signal.removeEventListener("abort", onAbort);
2552
+ emitRuntimeEvent({
2553
+ type: "control-attempt",
2554
+ helper: "checkpoint",
2555
+ stage: "start",
2556
+ callIndex,
2557
+ ...observation,
2558
+ });
2559
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
2560
+ try {
2561
+ let reply: unknown;
2562
+ if (options.confirm) {
2563
+ // Host-only cancellation must not affect author options or replay hashes.
2564
+ const signal = options.signal
2565
+ ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2566
+ : shared.runFatalController.signal;
2567
+ const confirm = options.confirm;
2568
+ let onAbort: (() => void) | undefined;
2569
+ try {
2570
+ const cancelled = new Promise<never>((_resolve, reject) => {
2571
+ onAbort = () => {
2572
+ try {
2573
+ throwIfAborted();
2574
+ } catch (error) {
2575
+ reject(error);
2576
+ }
2577
+ };
2578
+ signal.addEventListener("abort", onAbort, { once: true });
2579
+ if (signal.aborted) onAbort();
2580
+ });
2581
+ // Older host callbacks may ignore the optional signal. Still release
2582
+ // the runtime wait, and observe any eventual callback rejection.
2583
+ const answer = Promise.resolve().then(() => {
2584
+ throwIfAborted();
2585
+ return confirm(promptText, checkpointOptions, signal);
2586
+ });
2587
+ reply = await Promise.race([answer, cancelled]);
2588
+ } finally {
2589
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2590
+ }
2591
+ } else if (checkpointOptions.headless === "abort") {
2592
+ throw new WorkflowError(
2593
+ `checkpoint "${promptText}" needs human input but none is available (headless run)`,
2594
+ WorkflowErrorCode.WORKFLOW_ABORTED,
2595
+ { recoverable: false },
2596
+ );
2597
+ } else {
2598
+ reply = checkpointOptions.default ?? true;
2346
2599
  }
2347
- } else if (checkpointOptions.headless === "abort") {
2348
- throw new WorkflowError(
2349
- `checkpoint "${promptText}" needs human input but none is available (headless run)`,
2350
- WorkflowErrorCode.WORKFLOW_ABORTED,
2351
- { recoverable: false },
2352
- );
2353
- } else {
2354
- reply = checkpointOptions.default ?? true;
2600
+ throwIfAborted();
2601
+ options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
2602
+ outcome = "completed";
2603
+ return reply;
2604
+ } finally {
2605
+ if (isAborted()) outcome = "cancelled";
2606
+ emitRuntimeEvent({
2607
+ type: "control-attempt",
2608
+ helper: "checkpoint",
2609
+ stage: "end",
2610
+ callIndex,
2611
+ ...observation,
2612
+ outcome,
2613
+ });
2355
2614
  }
2356
- throwIfAborted();
2357
- options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
2358
- return reply;
2359
2615
  };
2360
2616
 
2361
2617
  const runtimeBindings: WorkflowRuntimeBindings & { console: Record<string, (m: unknown) => void> } = {
@@ -2397,18 +2653,20 @@ export async function runWorkflow<T = unknown>(
2397
2653
  );
2398
2654
  }
2399
2655
 
2400
- const context = vm.createContext({
2401
- ...runtimeBindings,
2402
- // Object/Array/JSON/Math/Date/Promise/Set/Map/etc. come from the vm realm
2403
- // itself — we deliberately do NOT inject host built-ins, whose
2404
- // .constructor would be the host Function (a determinism-guard bypass).
2405
- // Math/Date are neutered in-realm by DETERMINISM_PRELUDE below.
2406
- });
2407
-
2408
2656
  const wrapped = `${DETERMINISM_PRELUDE}\n(async () => {\n${body}\n})()`;
2409
2657
  try {
2410
- const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context, {
2411
- timeout: MAX_SCRIPT_SYNC_MS,
2658
+ const result = await executeOwnedVm({
2659
+ script: wrapped,
2660
+ filename: `${meta.name || "workflow"}.js`,
2661
+ args: options.args,
2662
+ cwd: baseCwd,
2663
+ bindings: { ...runtimeBindings },
2664
+ total: budget.total,
2665
+ spent: budget.spent,
2666
+ cancel: () => frameController.abort(),
2667
+ signal: options.signal
2668
+ ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2669
+ : shared.runFatalController.signal,
2412
2670
  });
2413
2671
  try {
2414
2672
  const serialized = JSON.stringify(result);
@@ -2439,6 +2697,7 @@ export async function runWorkflow<T = unknown>(
2439
2697
  // reaching here does NOT seal anything (the parent may still catch
2440
2698
  // workflow()'s rejection). Idempotent: aborting an already-aborted
2441
2699
  // controller is a no-op.
2700
+ frameController.abort();
2442
2701
  if (isTopLevelRun) shared.runFatalController.abort();
2443
2702
  throw error;
2444
2703
  } finally {