pi-ultracode 0.1.2 → 0.3.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.
@@ -3,22 +3,31 @@
3
3
  *
4
4
  * Parses a workflow script and runs its body inside a Node vm sandbox with the
5
5
  * orchestration globals: agent(), parallel(), pipeline(), phase(), log(),
6
- * workflow(), plus `args`, `cwd`, and `budget`. The sandbox omits Date / require
7
- * / fs / network from the named global surface and neuters Math.random() (it
8
- * throws) as a guardrail against accidental nondeterminism; this is cooperative
9
- * enforcement, not a hard isolation boundary (see createDeterministicMath).
6
+ * workflow(), plus `args` and `cwd`. The Worker installs context-realm wrappers,
7
+ * disables string/wasm code generation, omits Date / require / fs / network, and
8
+ * makes Math.random() throw. These are determinism and liveness guards, not a
9
+ * claim that Node's vm is a security sandbox.
10
10
  */
11
11
 
12
- import vm from "node:vm";
13
- import { AsyncLocalStorage } from "node:async_hooks";
14
- import * as fs from "node:fs";
15
12
  import * as os from "node:os";
16
13
  import * as path from "node:path";
17
14
  import { parseWorkflowScript, type WorkflowMeta } from "./parser.ts";
15
+ import { executeWorkflowScript, type ScriptExecutorHost } from "./script-executor.ts";
16
+ import {
17
+ AgentAdmission,
18
+ ABSOLUTE_MAX_AGENTS,
19
+ DEFAULT_MAX_AGENTS,
20
+ WorkflowAbortError,
21
+ WorkflowCleanupTimeoutError,
22
+ WorkflowPolicyError,
23
+ isWorkflowPolicyError,
24
+ normalizeMaxAgents,
25
+ type PanelReservation,
26
+ } from "./admission.ts";
18
27
  // Static import: a dynamic import() of this module misbehaves under Pi's jiti
19
28
  // loader ("WorkflowAgentRunner is not a constructor"). Tests inject a runner, so
20
29
  // they never construct this class; production builds it via getRunner().
21
- import { WorkflowAgentRunner } from "./agent-runner.ts";
30
+ import { resolveModelSelection, WorkflowAgentRunner } from "./agent-runner.ts";
22
31
  import type {
23
32
  AgentActivityInput,
24
33
  AgentRunResult,
@@ -30,42 +39,26 @@ import type {
30
39
  ThinkingLevel,
31
40
  } from "./agent-runner.ts";
32
41
  import { safeDisplayText } from "./display-text.ts";
42
+ import {
43
+ artifactPathExists,
44
+ readArtifactFile,
45
+ readContainedArtifactFile,
46
+ standaloneWorkflowRunsDir,
47
+ } from "./run-artifacts.ts";
48
+ import {
49
+ assertWorkflowArgsLimit,
50
+ assertWorkflowOutputLimit,
51
+ assertWorkflowSchemaLimit,
52
+ } from "./value-limits.ts";
33
53
 
34
- /**
35
- * A frozen copy of `Math` with `random` replaced by a throwing function. Workflow
36
- * scripts get the useful Math surface (max/min/floor/round/PI/E/...) but the
37
- * NAMED Math.random() throws, guardrailing against accidental nondeterminism.
38
- *
39
- * NOTE: this is COOPERATIVE enforcement, not a hard isolation boundary. Node's
40
- * `vm` is not a sandbox: a determined script can still reach the real Math.random
41
- * (and Date.now/performance/crypto) via any host-realm function's `.constructor`
42
- * (the host Function constructor), e.g. `Object.constructor("return Math.random()")()`.
43
- * Closing that requires not passing host-realm intrinsics into the context (or
44
- * using isolated-vm); the parser's AST check + this shim are defense-in-depth
45
- * against ACCIDENTAL nondeterminism, matching the workflow guidelines' "vary
46
- * randomness by agent index" framing.
47
- */
48
- export function createDeterministicMath(): Record<string, unknown> {
49
- const m = {} as Record<string | symbol, unknown>;
50
- for (const key of Object.getOwnPropertyNames(Math)) {
51
- if (key === "random") continue;
52
- m[key] = (Math as unknown as Record<string | symbol, unknown>)[key];
53
- }
54
- // Preserve symbol-keyed members (e.g. Symbol.toStringTag = 'Math') for parity.
55
- for (const sym of Object.getOwnPropertySymbols(Math)) {
56
- m[sym] = (Math as unknown as Record<string | symbol, unknown>)[sym];
57
- }
58
- m.random = function random() {
59
- throw new Error(
60
- "Math.random() is non-deterministic and forbidden in workflow scripts; vary randomness by agent index instead (see the workflow guidelines).",
61
- );
62
- };
63
- return Object.freeze(m) as unknown as Record<string, unknown>;
64
- }
65
-
66
- const DETERMINISTIC_MATH = createDeterministicMath();
67
54
  import { discoverAgentTypes, resolveAgentType, type AgentTypeDef } from "./agent-types.ts";
68
- import { agentCallKey, RunJournal, type JournalAgentRecord } from "./journal.ts";
55
+ import {
56
+ agentCallKey,
57
+ hashString,
58
+ stableStringify,
59
+ RunJournal,
60
+ type JournalAgentRecord,
61
+ } from "./journal.ts";
69
62
  import {
70
63
  applyPatch,
71
64
  captureWorktreeDiff,
@@ -79,13 +72,14 @@ import {
79
72
  } from "./worktree.ts";
80
73
 
81
74
  const MAX_CONCURRENCY = 16;
82
- const MAX_AGENTS_PER_RUN = 1000;
83
- const MAX_ITEMS_PER_CALL = 4096;
75
+ const DEFAULT_CLEANUP_TIMEOUT_MS = 25_000;
76
+ export { DEFAULT_MAX_AGENTS, ABSOLUTE_MAX_AGENTS };
84
77
  export const MAX_WORKFLOW_LOGS = 256;
85
78
  export const WORKFLOW_LOG_OMITTED_TEXT = "additional workflow logs omitted";
86
79
 
87
80
  export interface AgentEventBase {
88
81
  id: number;
82
+ callPath: string;
89
83
  label: string;
90
84
  phase?: string;
91
85
  workflowPath?: string[];
@@ -99,7 +93,16 @@ export interface WorkflowRunOptions {
99
93
  args?: unknown;
100
94
  signal?: AbortSignal;
101
95
  concurrency?: number;
102
- tokenBudget?: number | null;
96
+ /** Logical cap on agent() slots for this workflow. Defaults to 128; absolute range 1..1024. */
97
+ maxAgents?: number;
98
+ /** Internal test seam for worker liveness watchdog; not exposed as a tool parameter. */
99
+ stallTimeoutMs?: number;
100
+ /** Internal cleanup deadline after cancellation; production defaults to 25 seconds. */
101
+ cleanupTimeoutMs?: number;
102
+ /** Internal test seam for result-bearing/event host-call fuel; production defaults to 10,000. */
103
+ hostCallLimit?: number;
104
+ /** Internal test seam for the script Worker's V8 old-generation heap cap. */
105
+ workerMemoryLimitMb?: number;
103
106
  /** Synchronous extension facade used for model selection. */
104
107
  modelRegistry?: ModelRegistryLike;
105
108
  /** Canonical model/auth runtime shared across child sessions. */
@@ -142,6 +145,8 @@ export interface WorkflowRunResult<T = unknown> {
142
145
  logs: string[];
143
146
  phases: string[];
144
147
  agentCount: number;
148
+ /** Lifetime live-agent admissions across this run and all resumes. */
149
+ agentsUsed: number;
145
150
  cachedCount: number;
146
151
  spentTokens: number;
147
152
  /** Actual input+output tokens incurred by live child sessions. */
@@ -149,6 +154,13 @@ export interface WorkflowRunResult<T = unknown> {
149
154
  /** Original input+output usage represented by cached replayed tasks. */
150
155
  replayedTokens: number;
151
156
  durationMs: number;
157
+ /** Effective logical agent-slot cap used by this run. */
158
+ maxAgents: number;
159
+ }
160
+
161
+ interface ActivePanelTrace {
162
+ branchCount: number;
163
+ calls: Array<{ callPath: string; status: "pending" | "success" | "failed" }>;
152
164
  }
153
165
 
154
166
  interface RuntimeState {
@@ -157,79 +169,128 @@ interface RuntimeState {
157
169
  phases: string[];
158
170
  agentCount: number; // number of agent() invocations (for ids / cap)
159
171
  cachedCount: number;
160
- spent: number; // real output tokens (budget compatibility)
172
+ spent: number; // observed output tokens for usage display
161
173
  newTokens: number;
162
174
  replayedTokens: number;
175
+ maxAgents: number;
163
176
  }
164
177
 
165
178
  export async function runWorkflow<T = unknown>(
166
179
  rawScript: string,
167
180
  options: WorkflowRunOptions = {},
168
181
  ): Promise<WorkflowRunResult<T>> {
182
+ if (options.signal?.aborted) throw new WorkflowAbortError();
183
+ assertWorkflowArgsLimit(options.args);
169
184
  const started = Date.now();
185
+ const maxAgents = normalizeMaxAgents(options.maxAgents ?? options.journal?.effectiveMaxAgents);
170
186
  const { meta, body } = parseWorkflowScript(rawScript);
171
- const runtime = new Runtime(options);
172
- const result = await runtime.runBody(body, options.args, 0, meta.name);
173
- await runtime.drain();
174
- // structuredClone both validates serialisability and lifts the value out of the
175
- // vm realm so callers get plain host-realm objects.
176
- const cloned = cloneResult(result, "workflow result");
177
- return {
178
- meta,
179
- result: cloned as T,
180
- logs: runtime.state.logs,
181
- phases: runtime.state.phases,
182
- agentCount: runtime.state.agentCount,
183
- cachedCount: runtime.state.cachedCount,
184
- spentTokens: runtime.state.spent,
185
- newTokens: runtime.state.newTokens,
186
- replayedTokens: runtime.state.replayedTokens,
187
- durationMs: Date.now() - started,
188
- };
187
+ const runtime = new Runtime(options, maxAgents);
188
+ const onOuterAbort = () => runtime.abort();
189
+ options.signal?.addEventListener("abort", onOuterAbort, { once: true });
190
+ if (options.signal?.aborted) runtime.abort();
191
+ try {
192
+ const result = await runtime.runBody(body, options.args, meta.name);
193
+ if (runtime.policyError) throw runtime.policyError;
194
+ await runtime.drain(normalizeCleanupTimeout(options.cleanupTimeoutMs));
195
+ if (runtime.policyError) throw runtime.policyError;
196
+ assertWorkflowOutputLimit(result);
197
+ // Keep the public value detached from the Worker message object.
198
+ const cloned = cloneResult(result, "workflow result");
199
+ return {
200
+ meta,
201
+ result: cloned as T,
202
+ logs: runtime.state.logs,
203
+ phases: runtime.state.phases,
204
+ agentCount: runtime.state.agentCount,
205
+ agentsUsed: runtime.agentsUsed,
206
+ cachedCount: runtime.state.cachedCount,
207
+ spentTokens: runtime.state.spent,
208
+ newTokens: runtime.state.newTokens,
209
+ replayedTokens: runtime.state.replayedTokens,
210
+ durationMs: Date.now() - started,
211
+ maxAgents,
212
+ };
213
+ } catch (error) {
214
+ runtime.abort();
215
+ if (error instanceof WorkflowCleanupTimeoutError) throw error;
216
+ const cleanupTimeoutMs = normalizeCleanupTimeout(options.cleanupTimeoutMs);
217
+ try {
218
+ await runtime.drain(cleanupTimeoutMs);
219
+ } catch (cleanupError) {
220
+ throw cleanupError;
221
+ }
222
+ if (runtime.policyError) throw runtime.policyError;
223
+ if (options.signal?.aborted && !isWorkflowPolicyError(error)) {
224
+ throw new WorkflowAbortError();
225
+ }
226
+ throw error;
227
+ } finally {
228
+ options.signal?.removeEventListener("abort", onOuterAbort);
229
+ }
189
230
  }
190
231
 
191
- class Runtime {
192
- readonly state: RuntimeState = {
193
- logs: [],
194
- phases: [],
195
- agentCount: 0,
196
- cachedCount: 0,
197
- spent: 0,
198
- newTokens: 0,
199
- replayedTokens: 0,
200
- };
232
+ class Runtime implements ScriptExecutorHost {
233
+ readonly state: RuntimeState;
201
234
  private readonly options: WorkflowRunOptions;
202
235
  private readonly cwd: string;
203
236
  private runnerInstance: { run: WorkflowAgentRunner["run"] } | undefined;
204
237
  private readonly agentTypes: Map<string, AgentTypeDef>;
205
238
  private readonly limiter: <R>(fn: () => Promise<R>) => Promise<R>;
206
239
  private readonly pending = new Set<Promise<unknown>>();
207
- private readonly tokenBudget: number | null;
208
240
  private readonly applyLock = new Mutex();
209
- private readonly executionContext = new AsyncLocalStorage<{ workflowPath: string[]; depth: number; currentPhase?: string }>();
241
+ private readonly childController = new AbortController();
242
+ private readonly scriptController = new AbortController();
243
+ private readonly admission: AgentAdmission;
244
+ private readonly seenCallPaths = new Set<string>();
245
+ private readonly agentRequestOccurrences = new Map<string, number>();
246
+ private readonly nestedRequestOccurrences = new Map<string, number>();
247
+ private readonly activePanelTraces = new Map<string, ActivePanelTrace>();
248
+ policyError: WorkflowPolicyError | undefined;
210
249
 
211
- constructor(options: WorkflowRunOptions) {
250
+ constructor(options: WorkflowRunOptions, maxAgents: number) {
212
251
  this.options = options;
213
252
  this.cwd = options.cwd ?? process.cwd();
214
253
  this.runnerInstance = options.runner;
215
254
  this.agentTypes = discoverAgentTypes(this.cwd);
216
- this.tokenBudget = options.tokenBudget ?? null;
255
+ this.admission = new AgentAdmission(maxAgents, options.journal?.agentsUsed ?? 0);
256
+ this.state = {
257
+ logs: [],
258
+ phases: [],
259
+ agentCount: 0,
260
+ cachedCount: 0,
261
+ spent: 0,
262
+ newTokens: 0,
263
+ replayedTokens: 0,
264
+ maxAgents,
265
+ };
217
266
  const cores = (globalThis as any).navigator?.hardwareConcurrency ?? os.cpus().length ?? 8;
218
- const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, cores - 2), MAX_CONCURRENCY));
219
- this.limiter = createLimiter(concurrency);
267
+ const defaultConcurrency = Math.max(1, Math.min(Math.max(1, cores - 2), MAX_CONCURRENCY));
268
+ const concurrency = normalizeConcurrency(options.concurrency, defaultConcurrency);
269
+ this.limiter = createLimiter(concurrency, this.childController.signal);
220
270
  }
221
271
 
222
- get budget() {
223
- return Object.freeze({
224
- total: this.tokenBudget,
225
- spent: () => this.state.spent,
226
- remaining: () =>
227
- this.tokenBudget == null ? Infinity : Math.max(0, this.tokenBudget - this.state.spent),
228
- });
272
+ get agentsUsed(): number {
273
+ return this.admission.usedAgents;
274
+ }
275
+
276
+ abort(): void {
277
+ if (!this.childController.signal.aborted) this.childController.abort();
278
+ if (!this.scriptController.signal.aborted) this.scriptController.abort();
229
279
  }
230
280
 
231
- async drain(): Promise<void> {
232
- await Promise.allSettled([...this.pending]);
281
+ async drain(timeoutMs?: number): Promise<void> {
282
+ const configuredTimeoutMs = timeoutMs;
283
+ const deadline = configuredTimeoutMs === undefined ? undefined : Date.now() + configuredTimeoutMs;
284
+ while (this.pending.size > 0) {
285
+ const batch = Promise.allSettled([...this.pending]);
286
+ if (deadline === undefined) {
287
+ await batch;
288
+ continue;
289
+ }
290
+ const remainingMs = deadline - Date.now();
291
+ if (remainingMs <= 0) throw new WorkflowCleanupTimeoutError(configuredTimeoutMs!);
292
+ await raceWithCleanupTimeout(batch, remainingMs, configuredTimeoutMs!);
293
+ }
233
294
  }
234
295
 
235
296
  /** Lazily construct the default in-memory runner (skipped when a runner is injected). */
@@ -246,86 +307,277 @@ class Runtime {
246
307
  return this.runnerInstance;
247
308
  }
248
309
 
249
- /** Execute one workflow body (top-level or nested) with shared runtime state. */
250
- async runBody(body: string, args: unknown, depth: number, name: string): Promise<unknown> {
251
- const context = vm.createContext(this.buildSandbox(args));
252
- const wrapped = `(async () => {\n${body}\n})()`;
253
- const parent = this.executionContext.getStore();
254
- const parentPath = parent?.workflowPath ?? [];
255
- const execution = { workflowPath: [...parentPath, name || `workflow-${depth}`], depth, currentPhase: parent?.currentPhase };
256
- return this.executionContext.run(
257
- execution,
258
- async () => await new vm.Script(wrapped, { filename: `${name || "workflow"}.js` }).runInContext(context),
259
- );
260
- }
261
-
262
- private buildSandbox(args: unknown): Record<string, unknown> {
263
- const log = (message: unknown) => this.logLine(String(message));
264
- return {
265
- agent: this.agent.bind(this),
266
- parallel: this.parallel.bind(this),
267
- pipeline: this.pipeline.bind(this),
268
- phase: this.phase.bind(this),
269
- log,
270
- workflow: this.workflow.bind(this),
271
- args,
310
+ async runBody(body: string, args: unknown, name: string): Promise<unknown> {
311
+ this.throwIfAborted();
312
+ return await executeWorkflowScript(body, this, {
272
313
  cwd: this.cwd,
273
- process: Object.freeze({ cwd: () => this.cwd }),
274
- budget: this.budget,
275
- console: {
276
- log,
277
- info: log,
278
- warn: (m: unknown) => log(`[warn] ${String(m)}`),
279
- error: (m: unknown) => log(`[error] ${String(m)}`),
280
- },
281
- JSON,
282
- Math: DETERMINISTIC_MATH,
283
- Array,
284
- Object,
285
- String,
286
- Number,
287
- Boolean,
288
- Set,
289
- Map,
290
- Promise,
291
- structuredClone,
292
- };
314
+ args,
315
+ name,
316
+ signal: this.scriptController.signal,
317
+ stallTimeoutMs: this.options.stallTimeoutMs,
318
+ hostCallLimit: this.options.hostCallLimit,
319
+ workerMemoryLimitMb: this.options.workerMemoryLimitMb,
320
+ });
293
321
  }
294
322
 
295
323
  private throwIfAborted(): void {
296
- if (this.options.signal?.aborted) throw new Error("workflow aborted");
324
+ if (this.childController.signal.aborted || this.options.signal?.aborted) throw new WorkflowAbortError();
325
+ }
326
+
327
+ phase(title: string): void {
328
+ if (!title) return;
329
+ this.state.currentPhase = title;
330
+ if (!this.state.phases.includes(title)) this.state.phases.push(title);
331
+ this.options.onPhase?.(title);
332
+ }
333
+
334
+ log(message: string): void {
335
+ this.logLine(message);
336
+ }
337
+
338
+ async reservePanel(payload: {
339
+ callPath?: unknown;
340
+ reserveAgents: number;
341
+ branchCount: number;
342
+ parentReservationIds?: string[];
343
+ }): Promise<PanelReservation> {
344
+ this.throwIfAborted();
345
+ try {
346
+ const callPath = requireString(payload.callPath, "parallel callPath");
347
+ const parentReservationIds = Array.isArray(payload.parentReservationIds)
348
+ ? payload.parentReservationIds
349
+ : [];
350
+ let replay: ReturnType<RunJournal["panelReplayPlan"]> | undefined;
351
+ try {
352
+ replay = this.options.journal?.panelReplayPlan(callPath, payload.branchCount);
353
+ } catch (error) {
354
+ throw journalPolicyError(error, "workflow panel replay lookup failed");
355
+ }
356
+ const liveSlots = replay
357
+ ? Math.max(
358
+ replay.branchNeedsSlot.filter(Boolean).length,
359
+ payload.reserveAgents - replay.slotCredit,
360
+ )
361
+ : payload.reserveAgents;
362
+ const panel = replay
363
+ ? this.admission.reservePanelWithBranchNeeds(
364
+ liveSlots,
365
+ replay.branchNeedsSlot,
366
+ parentReservationIds,
367
+ payload.reserveAgents - liveSlots,
368
+ )
369
+ : this.admission.reservePanelWithBranches(payload.reserveAgents, payload.branchCount, parentReservationIds);
370
+ try {
371
+ this.options.journal?.recordPanelOpen(callPath, payload.reserveAgents, payload.branchCount);
372
+ } catch (error) {
373
+ for (const reservationId of panel.branchReservationIds) this.admission.releasePanel(reservationId);
374
+ this.admission.releasePanel(panel.panelReservationId);
375
+ throw journalPolicyError(error, "workflow panel-open commit failed");
376
+ }
377
+ if (this.activePanelTraces.has(callPath)) {
378
+ for (const reservationId of panel.branchReservationIds) this.admission.releasePanel(reservationId);
379
+ this.admission.releasePanel(panel.panelReservationId);
380
+ throw new WorkflowPolicyError(`workflow produced duplicate parallel callPath: ${callPath}`);
381
+ }
382
+ this.activePanelTraces.set(callPath, { branchCount: payload.branchCount, calls: [] });
383
+ return panel;
384
+ } catch (error) {
385
+ this.recordPolicyError(error);
386
+ throw error;
387
+ }
297
388
  }
298
389
 
299
- private phase(title: unknown): void {
300
- const text = requireString(title, "phase title");
301
- this.state.currentPhase = text;
302
- const execution = this.executionContext.getStore();
303
- if (execution) execution.currentPhase = text;
304
- if (!this.state.phases.includes(text)) this.state.phases.push(text);
305
- this.options.onPhase?.(text);
390
+ async completePanelBranch(payload: {
391
+ callPath?: unknown;
392
+ branchIndex: number;
393
+ outcome: "success" | "failed";
394
+ }): Promise<void> {
395
+ try {
396
+ const callPath = requireString(payload.callPath, "parallel callPath");
397
+ const trace = this.activePanelTraces.get(callPath);
398
+ if (
399
+ !trace
400
+ || !Number.isInteger(payload.branchIndex)
401
+ || payload.branchIndex < 0
402
+ || payload.branchIndex >= trace.branchCount
403
+ || (payload.outcome !== "success" && payload.outcome !== "failed")
404
+ ) {
405
+ throw new WorkflowPolicyError(`workflow returned invalid branch completion for ${callPath}`);
406
+ }
407
+ const prefix = `${callPath}/b:${payload.branchIndex}/`;
408
+ const calls = trace.calls.filter((call) => call.callPath.startsWith(prefix));
409
+ if (calls.some((call) => call.status === "pending")) {
410
+ throw new WorkflowPolicyError(`workflow branch completed with pending agent calls at ${callPath}`);
411
+ }
412
+ this.options.journal?.recordPanelBranch(
413
+ callPath,
414
+ payload.branchIndex,
415
+ payload.outcome,
416
+ calls.map((call) => ({
417
+ callPath: call.callPath,
418
+ status: call.status === "success" ? "success" : "failed",
419
+ })),
420
+ );
421
+ } catch (error) {
422
+ const failure = journalPolicyError(error, "workflow panel-branch commit failed");
423
+ this.recordPolicyError(failure);
424
+ throw failure;
425
+ }
306
426
  }
307
427
 
308
- private async agent(promptValue: unknown, optionsValue: unknown = {}): Promise<unknown> {
428
+ async releasePanel(payload: {
429
+ reservationId: string;
430
+ callPath?: unknown;
431
+ completed?: boolean;
432
+ branchOutcomes?: unknown;
433
+ }): Promise<void> {
434
+ const callPath = typeof payload.callPath === "string" ? payload.callPath : undefined;
435
+ const trace = callPath ? this.activePanelTraces.get(callPath) : undefined;
436
+ if (payload.completed && callPath && trace) {
437
+ const branchOutcomes = Array.isArray(payload.branchOutcomes)
438
+ ? payload.branchOutcomes
439
+ : [];
440
+ if (
441
+ branchOutcomes.length !== trace.branchCount
442
+ || branchOutcomes.some((outcome) => outcome !== "success" && outcome !== "failed")
443
+ ) {
444
+ throw new WorkflowPolicyError(`workflow returned invalid branch outcomes for ${callPath}`);
445
+ }
446
+ try {
447
+ this.options.journal?.recordPanelComplete(
448
+ callPath,
449
+ trace.branchCount,
450
+ branchOutcomes as Array<"success" | "failed">,
451
+ trace.calls.map((call) => ({
452
+ callPath: call.callPath,
453
+ status: call.status === "success" ? "success" : "failed",
454
+ })),
455
+ );
456
+ } catch (error) {
457
+ const failure = journalPolicyError(error, "workflow panel completion commit failed");
458
+ this.recordPolicyError(failure);
459
+ throw failure;
460
+ }
461
+ }
462
+ this.admission.releasePanel(payload.reservationId);
463
+ if (callPath) this.activePanelTraces.delete(callPath);
464
+ }
465
+
466
+ private beginPanelAgent(callPath: string): void {
467
+ for (const [panelPath, trace] of this.activePanelTraces) {
468
+ if (!callPath.startsWith(`${panelPath}/`)) continue;
469
+ trace.calls.push({ callPath, status: "pending" });
470
+ }
471
+ }
472
+
473
+ private finishPanelAgent(callPath: string, status: "success" | "failed"): void {
474
+ for (const [panelPath, trace] of this.activePanelTraces) {
475
+ if (!callPath.startsWith(`${panelPath}/`)) continue;
476
+ const call = trace.calls.find((candidate) => candidate.callPath === callPath);
477
+ if (call) call.status = status;
478
+ }
479
+ }
480
+
481
+ async loadWorkflow(payload: {
482
+ nameOrRef: unknown;
483
+ callPath?: unknown;
484
+ args?: unknown;
485
+ }): Promise<{ meta: WorkflowMeta; body: string; callPath: string }> {
309
486
  this.throwIfAborted();
310
- if (this.tokenBudget != null && this.budget.remaining() <= 0) {
311
- throw new Error("workflow token budget exhausted");
487
+ assertWorkflowArgsLimit(payload.args);
488
+ const workerCallPath = requireString(payload.callPath, "nested workflow callPath");
489
+ const ref = normalizeWorkflowRef(payload.nameOrRef);
490
+ const argsJson = JSON.stringify(payload.args);
491
+ const sourceCallPath = workerCallPath.replace(/:\d+$/, "");
492
+ const requestHash = hashString(`${stableStringify(ref)}\u0000${argsJson}`).slice(0, 16);
493
+ const requestBase = `${sourceCallPath}/q:${requestHash}`;
494
+ const requestOccurrence = this.nestedRequestOccurrences.get(requestBase) ?? 0;
495
+ this.nestedRequestOccurrences.set(requestBase, requestOccurrence + 1);
496
+ const callPath = `${requestBase}:${requestOccurrence}`;
497
+ const loader = this.options.loadSavedWorkflow ?? ((r) => loadSavedWorkflowFromDisk(r, this.cwd));
498
+ const loaded = loader(ref);
499
+ const sourceHash = hashString(
500
+ `${stableStringify(loaded.meta)}\u0000${loaded.body}\u0000${argsJson}`,
501
+ );
502
+ try {
503
+ this.options.journal?.recordNestedSource(callPath, sourceHash);
504
+ } catch (error) {
505
+ const failure = journalPolicyError(error, "nested workflow identity commit failed");
506
+ this.recordPolicyError(failure);
507
+ throw failure;
312
508
  }
313
- const prompt = requireString(promptValue, "agent prompt");
314
- const opts = normalizeAgentOptions(optionsValue);
315
- const assignedPhase = opts.phase ?? this.executionContext.getStore()?.currentPhase ?? this.state.currentPhase;
509
+ return { ...loaded, callPath };
510
+ }
316
511
 
317
- const seq = ++this.state.agentCount;
318
- if (seq > MAX_AGENTS_PER_RUN) {
319
- throw new Error(`workflow exceeded the ${MAX_AGENTS_PER_RUN}-agent cap (runaway loop?)`);
512
+ async validateOutput(payload: { value: unknown; label?: unknown }): Promise<void> {
513
+ try {
514
+ assertWorkflowOutputLimit(payload.value, "nested workflow output");
515
+ } catch (error) {
516
+ this.recordPolicyError(error);
517
+ throw error;
320
518
  }
321
- const id = seq;
519
+ }
520
+
521
+ async agent(payload: {
522
+ callPath?: unknown;
523
+ prompt: unknown;
524
+ options?: unknown;
525
+ assignedPhase?: string;
526
+ workflowPath?: string[];
527
+ reservationIds?: string[];
528
+ }): Promise<unknown> {
529
+ this.throwIfAborted();
530
+ const workerCallPath = requireString(payload.callPath, "agent callPath");
531
+ if (this.seenCallPaths.has(workerCallPath)) {
532
+ const error = new WorkflowPolicyError(`workflow produced duplicate agent callPath: ${workerCallPath}`);
533
+ this.recordPolicyError(error);
534
+ throw error;
535
+ }
536
+ this.seenCallPaths.add(workerCallPath);
537
+ const prompt = requireString(payload.prompt, "agent prompt");
538
+ const opts = normalizeAgentOptions(payload.options);
539
+ // The Worker owns phase scope. Falling back to host-global display state here
540
+ // would make cache identity depend on parallel branch completion order.
541
+ const assignedPhase = opts.phase ?? payload.assignedPhase;
542
+ const sourceCallPath = workerCallPath.replace(/:\d+$/, "");
543
+ const requestHash = hashString(stableStringify({
544
+ prompt,
545
+ options: { ...opts, phase: assignedPhase },
546
+ })).slice(0, 16);
547
+ const requestBase = `${sourceCallPath}/q:${requestHash}`;
548
+ const requestOccurrence = this.agentRequestOccurrences.get(requestBase) ?? 0;
549
+ this.agentRequestOccurrences.set(requestBase, requestOccurrence + 1);
550
+ const callPath = `${requestBase}:${requestOccurrence}`;
551
+ const id = ++this.state.agentCount;
322
552
  const label = opts.label?.trim() || defaultLabel(assignedPhase, id);
323
- const workflowPath = [...(this.executionContext.getStore()?.workflowPath ?? [])];
324
- const key = agentCallKey(prompt, { ...opts, phase: assignedPhase });
553
+ const workflowPath = Array.isArray(payload.workflowPath) ? [...payload.workflowPath] : [];
554
+ const agentTypeDef = resolveAgentType(opts.agentType, this.agentTypes);
555
+ const selection = resolveModelSelection({
556
+ pattern: opts.model,
557
+ roleModel: agentTypeDef?.model,
558
+ roleThinking: agentTypeDef?.thinking,
559
+ defaultModel: this.options.model,
560
+ defaultThinking: this.options.thinkingLevel,
561
+ models: this.options.modelRegistry?.getAvailable(),
562
+ });
563
+ const key = agentCallKey(prompt, {
564
+ ...opts,
565
+ phase: assignedPhase,
566
+ agentTypeDefinition: agentTypeDef,
567
+ effectiveModel: modelIdentity(selection.model),
568
+ effectiveThinking: selection.thinkingLevel,
569
+ });
325
570
 
326
- // Resume: cached prefix replay.
327
- const cached = this.options.journal?.lookup(seq, key);
571
+ let cached: JournalAgentRecord | undefined;
572
+ try {
573
+ cached = this.options.journal?.lookup(callPath, key);
574
+ } catch (error) {
575
+ const failure = journalPolicyError(error, "workflow cache lookup failed");
576
+ this.recordPolicyError(failure);
577
+ throw failure;
578
+ }
328
579
  if (cached) {
580
+ this.beginPanelAgent(callPath);
329
581
  this.state.cachedCount++;
330
582
  this.state.spent += cached.outputTokens ?? 0;
331
583
  this.state.replayedTokens += cached.totalTokens ?? 0;
@@ -341,8 +593,10 @@ class Runtime {
341
593
  retries: cached.retries,
342
594
  compactions: cached.compactions,
343
595
  };
344
- this.options.onAgentStart?.({
596
+ const replayValue = cloneResult(cached.value, "cached agent result");
597
+ this.notifyObserver(() => this.options.onAgentStart?.({
345
598
  id,
599
+ callPath,
346
600
  label,
347
601
  phase: assignedPhase,
348
602
  workflowPath,
@@ -353,26 +607,39 @@ class Runtime {
353
607
  agentType: opts.agentType,
354
608
  isolation: opts.isolation,
355
609
  structuredOutput: opts.schema != null,
356
- cachedRecord: cached,
357
- });
358
- this.options.onAgentEnd?.({
610
+ cachedRecord: cloneResult(cached, "cached agent record"),
611
+ }));
612
+ this.notifyObserver(() => this.options.onAgentEnd?.({
359
613
  id,
614
+ callPath,
360
615
  label,
361
616
  phase: assignedPhase,
362
617
  workflowPath,
363
- result: cached.value,
618
+ result: cloneResult(replayValue, "cached observer result"),
364
619
  status: "done",
365
620
  usage: cachedUsage,
366
621
  modelId: cached.modelId,
367
622
  effort: cached.effort,
368
- cachedRecord: cached,
369
- });
370
- return cached.value;
623
+ cachedRecord: cloneResult(cached, "cached agent record"),
624
+ }));
625
+ this.finishPanelAgent(callPath, "success");
626
+ return replayValue;
627
+ }
628
+
629
+ try {
630
+ const ordinal = this.admission.consumeAgent(Array.isArray(payload.reservationIds) ? payload.reservationIds : []);
631
+ this.options.journal?.recordAdmission(callPath, key, ordinal);
632
+ } catch (error) {
633
+ const failure = journalPolicyError(error, "workflow admission commit failed");
634
+ this.recordPolicyError(failure);
635
+ throw failure;
371
636
  }
372
637
 
638
+ this.beginPanelAgent(callPath);
373
639
  const run = this.limiter(async () => {
374
- this.options.onAgentStart?.({
640
+ this.notifyObserver(() => this.options.onAgentStart?.({
375
641
  id,
642
+ callPath,
376
643
  label,
377
644
  phase: assignedPhase,
378
645
  workflowPath,
@@ -383,13 +650,12 @@ class Runtime {
383
650
  agentType: opts.agentType,
384
651
  isolation: opts.isolation,
385
652
  structuredOutput: opts.schema != null,
386
- });
653
+ }));
387
654
  let worktree: Worktree | undefined;
388
655
  let keepWorktree = false;
656
+ let observedUsage: AgentUsage | undefined;
389
657
  try {
390
658
  this.throwIfAborted();
391
- const agentTypeDef = resolveAgentType(opts.agentType, this.agentTypes);
392
-
393
659
  if (opts.isolation === "worktree") {
394
660
  worktree = this.tryCreateWorktree(id);
395
661
  }
@@ -397,18 +663,18 @@ class Runtime {
397
663
  const runner = this.getRunner();
398
664
  const onActivity: ((e: AgentActivityInput) => void) | undefined = this.options.onAgentActivity
399
665
  ? (e: AgentActivityInput) =>
400
- this.options.onAgentActivity!({ id, label, phase: assignedPhase, workflowPath, ...e })
666
+ this.options.onAgentActivity!({ id, callPath, label, phase: assignedPhase, workflowPath, ...e })
401
667
  : undefined;
402
668
  const onTelemetry: ((e: AgentTelemetryEvent) => void) | undefined = this.options.onAgentTelemetry
403
669
  ? (e: AgentTelemetryEvent) =>
404
- this.options.onAgentTelemetry!({ id, label, phase: assignedPhase, workflowPath, ...e })
670
+ this.options.onAgentTelemetry!({ id, callPath, label, phase: assignedPhase, workflowPath, ...e })
405
671
  : undefined;
406
672
  const agentStartedAt = Date.now();
407
673
  const result: AgentRunResult = await runner.run({
408
674
  prompt,
409
675
  label,
410
676
  schema: opts.schema,
411
- signal: this.options.signal,
677
+ signal: this.childController.signal,
412
678
  instructions: buildInstructions(assignedPhase, opts),
413
679
  modelPattern: opts.model,
414
680
  agentTypeDef,
@@ -417,68 +683,87 @@ class Runtime {
417
683
  onTelemetry,
418
684
  });
419
685
  this.throwIfAborted();
420
-
421
- if (worktree) keepWorktree = await this.integrateWorktree(worktree, id, label);
422
-
423
- const inputTokens = result.usage.inputTokens
424
- ?? Math.max(0, result.usage.totalTokens - result.usage.outputTokens);
425
- const usage: AgentUsage = {
426
- ...result.usage,
427
- inputTokens,
428
- totalTokens: inputTokens + result.usage.outputTokens,
429
- };
686
+ const usage = normalizeAgentUsage(result.usage);
430
687
  this.state.spent += usage.outputTokens;
431
688
  this.state.newTokens += usage.totalTokens;
432
- this.options.journal?.recordAgent({
433
- seq,
434
- key,
435
- label,
436
- value: result.value,
437
- inputTokens: usage.inputTokens,
438
- outputTokens: usage.outputTokens,
439
- totalTokens: usage.totalTokens,
440
- cacheReadTokens: usage.cacheReadTokens,
441
- cacheWriteTokens: usage.cacheWriteTokens,
442
- cost: usage.cost,
443
- turns: usage.turns,
444
- toolUses: usage.toolUses,
445
- retries: usage.retries,
446
- compactions: usage.compactions,
447
- requestedModelId: opts.model,
448
- requestedEffort: this.options.thinkingLevel,
449
- modelId: result.modelId,
450
- effort: result.effort,
451
- agentType: opts.agentType,
452
- isolation: opts.isolation,
453
- structuredOutput: opts.schema != null,
454
- startedAt: agentStartedAt,
455
- durationMs: Date.now() - agentStartedAt,
456
- });
457
- this.options.onAgentEnd?.({
689
+ observedUsage = usage;
690
+
691
+ // Clone failures are ordinary runner-result failures. Values that clone
692
+ // successfully but violate the durable JSON contract are fatal policy.
693
+ const clonedValue = cloneResult(result.value, "agent result");
694
+ try {
695
+ assertWorkflowOutputLimit(clonedValue, "workflow agent output");
696
+ } catch (error) {
697
+ this.recordPolicyError(error);
698
+ throw error;
699
+ }
700
+
701
+ if (worktree) keepWorktree = await this.integrateWorktree(worktree, id, label);
702
+ this.notifyObserver(() => this.options.onAgentEnd?.({
458
703
  id,
704
+ callPath,
459
705
  label,
460
706
  phase: assignedPhase,
461
707
  workflowPath,
462
- result: result.value,
708
+ result: clonedValue,
463
709
  status: "done",
464
710
  usage,
465
711
  modelId: result.modelId,
466
712
  effort: result.effort,
467
- });
468
- return result.value;
713
+ }));
714
+ // Commit resume state only after observers accepted the successful
715
+ // completion. Otherwise a callback failure could return null live but
716
+ // replay a success from the journal on the next generation.
717
+ try {
718
+ this.options.journal?.recordAgent({
719
+ callPath,
720
+ seq: id,
721
+ key,
722
+ label,
723
+ value: clonedValue,
724
+ inputTokens: usage.inputTokens,
725
+ outputTokens: usage.outputTokens,
726
+ totalTokens: usage.totalTokens,
727
+ cacheReadTokens: usage.cacheReadTokens,
728
+ cacheWriteTokens: usage.cacheWriteTokens,
729
+ cost: usage.cost,
730
+ turns: usage.turns,
731
+ toolUses: usage.toolUses,
732
+ retries: usage.retries,
733
+ compactions: usage.compactions,
734
+ requestedModelId: opts.model,
735
+ requestedEffort: this.options.thinkingLevel,
736
+ modelId: result.modelId,
737
+ effort: result.effort,
738
+ agentType: opts.agentType,
739
+ isolation: opts.isolation,
740
+ structuredOutput: opts.schema != null,
741
+ startedAt: agentStartedAt,
742
+ durationMs: Date.now() - agentStartedAt,
743
+ });
744
+ } catch (error) {
745
+ const failure = journalPolicyError(error, "workflow agent-result commit failed");
746
+ this.recordPolicyError(failure);
747
+ throw failure;
748
+ }
749
+ this.finishPanelAgent(callPath, "success");
750
+ return clonedValue;
469
751
  } catch (error) {
470
- if (this.options.signal?.aborted) throw error;
752
+ if (this.childController.signal.aborted || this.options.signal?.aborted || isWorkflowPolicyError(error)) throw error;
471
753
  const message = error instanceof Error ? error.message : String(error);
754
+ this.finishPanelAgent(callPath, "failed");
472
755
  this.logLine(`agent ${label} failed: ${message}`);
473
- this.options.onAgentEnd?.({
756
+ this.notifyObserver(() => this.options.onAgentEnd?.({
474
757
  id,
758
+ callPath,
475
759
  label,
476
760
  phase: assignedPhase,
477
761
  workflowPath,
478
762
  result: null,
479
763
  status: "error",
480
764
  error: message,
481
- });
765
+ usage: observedUsage,
766
+ }));
482
767
  return null;
483
768
  } finally {
484
769
  if (worktree && !keepWorktree) {
@@ -491,73 +776,25 @@ class Runtime {
491
776
  }
492
777
  });
493
778
  this.track(run);
494
- return run;
779
+ return await run;
495
780
  }
496
781
 
497
- private async parallel(thunks: unknown): Promise<unknown[]> {
498
- this.throwIfAborted();
499
- if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
500
- if (thunks.length > MAX_ITEMS_PER_CALL) {
501
- throw new Error(`parallel() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${thunks.length})`);
502
- }
503
- if (thunks.some((thunk) => typeof thunk !== "function")) {
504
- throw new TypeError(
505
- "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)",
506
- );
782
+ private notifyObserver(callback: () => void): void {
783
+ try {
784
+ callback();
785
+ } catch (error) {
786
+ const failure = new WorkflowPolicyError(`workflow observer failed: ${errorMessage(error)}`);
787
+ (failure as Error & { cause?: unknown }).cause = error;
788
+ this.recordPolicyError(failure);
789
+ throw failure;
507
790
  }
508
- return Promise.all(
509
- (thunks as Array<() => Promise<unknown>>).map(async (thunk, index) => {
510
- try {
511
- return await thunk();
512
- } catch (error) {
513
- if (this.options.signal?.aborted) throw error;
514
- this.logLine(`parallel[${index}] failed: ${errorMessage(error)}`);
515
- return null;
516
- }
517
- }),
518
- );
519
791
  }
520
792
 
521
- private async pipeline(items: unknown, ...stages: unknown[]): Promise<unknown[]> {
522
- this.throwIfAborted();
523
- if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
524
- if (items.length > MAX_ITEMS_PER_CALL) {
525
- throw new Error(`pipeline() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${items.length})`);
526
- }
527
- if (stages.some((stage) => typeof stage !== "function")) {
528
- throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
793
+ private recordPolicyError(error: unknown): void {
794
+ if (isWorkflowPolicyError(error)) {
795
+ this.policyError ??= error;
796
+ this.abort();
529
797
  }
530
- const fns = stages as Array<(prev: unknown, original: unknown, index: number) => unknown>;
531
- return Promise.all(
532
- items.map(async (item, index) => {
533
- let value: unknown = item;
534
- for (const stage of fns) {
535
- try {
536
- this.throwIfAborted();
537
- value = await stage(value, item, index);
538
- this.throwIfAborted();
539
- } catch (error) {
540
- if (this.options.signal?.aborted) throw error;
541
- this.logLine(`pipeline[${index}] failed: ${errorMessage(error)}`);
542
- return null;
543
- }
544
- }
545
- return value;
546
- }),
547
- );
548
- }
549
-
550
- private async workflow(nameOrRef: unknown, args: unknown): Promise<unknown> {
551
- this.throwIfAborted();
552
- const depth = this.executionContext.getStore()?.depth ?? 0;
553
- if (depth >= 1) {
554
- throw new Error("workflow() nesting is one level deep only; cannot call workflow() inside a child workflow");
555
- }
556
- const ref = normalizeWorkflowRef(nameOrRef);
557
- const loader = this.options.loadSavedWorkflow ?? ((r) => loadSavedWorkflowFromDisk(r, this.cwd));
558
- const { meta, body } = loader(ref);
559
- this.logLine(`▸ nested workflow: ${meta.name}`);
560
- return this.runBody(body, args, depth + 1, meta.name);
561
798
  }
562
799
 
563
800
  private tryCreateWorktree(index: number): Worktree | undefined {
@@ -643,12 +880,11 @@ class Runtime {
643
880
  }
644
881
  }
645
882
 
646
- /** Where to write rescue patches: the session runs dir (co-located with the
647
- * journal, never inside the repo working tree), or .pi/ultracode/patches. */
883
+ /** Write rescue patches beside the journal or in a user-owned cwd-hash scope. */
648
884
  private rescueDir(): string {
649
885
  const journalDir = this.options.journal ? path.dirname(this.options.journal.filePath) : undefined;
650
886
  if (journalDir) return path.join(journalDir, "patches");
651
- return path.join(this.cwd, ".pi", "ultracode", "patches");
887
+ return path.join(standaloneWorkflowRunsDir(this.cwd), "patches");
652
888
  }
653
889
 
654
890
  private track(promise: Promise<unknown>): void {
@@ -684,6 +920,7 @@ function normalizeAgentOptions(value: unknown): AgentOptions {
684
920
  if (value == null) return {};
685
921
  if (typeof value !== "object") throw new TypeError("agent options must be an object");
686
922
  const options = value as AgentOptions;
923
+ assertWorkflowSchemaLimit(options.schema);
687
924
  return {
688
925
  label: optionalString(options.label, "agent label"),
689
926
  phase: optionalString(options.phase, "agent phase"),
@@ -695,7 +932,12 @@ function normalizeAgentOptions(value: unknown): AgentOptions {
695
932
  }
696
933
 
697
934
  function normalizeWorkflowRef(value: unknown): string | { scriptPath: string } {
698
- if (typeof value === "string") return value;
935
+ if (typeof value === "string") {
936
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)) {
937
+ throw new TypeError("workflow() name must contain only letters, numbers, underscore, or hyphen");
938
+ }
939
+ return value;
940
+ }
699
941
  if (value && typeof value === "object" && typeof (value as any).scriptPath === "string") {
700
942
  return { scriptPath: (value as any).scriptPath };
701
943
  }
@@ -706,31 +948,20 @@ export function loadSavedWorkflowFromDisk(
706
948
  ref: string | { scriptPath: string },
707
949
  cwd: string,
708
950
  ): { meta: WorkflowMeta; body: string } {
709
- let scriptPath: string | undefined;
710
951
  if (typeof ref === "object") {
711
- scriptPath = path.isAbsolute(ref.scriptPath) ? ref.scriptPath : path.join(cwd, ref.scriptPath);
712
- } else {
713
- scriptPath = resolveSavedWorkflowPath(ref, cwd);
714
- }
715
- if (!scriptPath || !fs.existsSync(scriptPath)) {
716
- throw new Error(`workflow() could not find a saved workflow for ${JSON.stringify(ref)}`);
952
+ const scriptPath = path.isAbsolute(ref.scriptPath) ? ref.scriptPath : path.join(cwd, ref.scriptPath);
953
+ return parseWorkflowScript(readArtifactFile(scriptPath, "nested workflow script", 16 * 1024 * 1024));
717
954
  }
718
- return parseWorkflowScript(fs.readFileSync(scriptPath, "utf8"));
719
- }
720
-
721
- function resolveSavedWorkflowPath(name: string, cwd: string): string | undefined {
722
- const dirs = [
723
- path.join(cwd, ".pi", "ultracode", "workflows"),
724
- path.join(os.homedir(), ".pi", "ultracode", "workflows"),
725
- ];
726
- const candidates = [`${name}.workflow.js`, `${name}.js`, name];
727
- for (const dir of dirs) {
728
- for (const candidate of candidates) {
955
+ for (const root of [cwd, os.homedir()]) {
956
+ const dir = path.join(root, ".pi", "ultracode", "workflows");
957
+ for (const candidate of [`${ref}.workflow.js`, `${ref}.js`]) {
729
958
  const full = path.join(dir, candidate);
730
- if (fs.existsSync(full)) return full;
959
+ if (artifactPathExists(full)) {
960
+ return parseWorkflowScript(readContainedArtifactFile(root, full, "saved nested workflow", 16 * 1024 * 1024));
961
+ }
731
962
  }
732
963
  }
733
- return undefined;
964
+ throw new Error(`workflow() could not find a saved workflow for ${JSON.stringify(ref)}`);
734
965
  }
735
966
 
736
967
  function buildInstructions(phase: string | undefined, opts: AgentOptions): string | undefined {
@@ -742,24 +973,123 @@ function buildInstructions(phase: string | undefined, opts: AgentOptions): strin
742
973
  return lines.length ? lines.join("\n") : undefined;
743
974
  }
744
975
 
976
+ function normalizeAgentUsage(value: AgentUsage): AgentUsage {
977
+ const outputTokens = usageInteger(value.outputTokens) ?? 0;
978
+ const totalHint = usageInteger(value.totalTokens) ?? outputTokens;
979
+ const inputTokens = usageInteger(value.inputTokens) ?? Math.max(0, totalHint - outputTokens);
980
+ return {
981
+ inputTokens,
982
+ outputTokens,
983
+ totalTokens: inputTokens + outputTokens,
984
+ cacheReadTokens: optionalUsageInteger(value.cacheReadTokens),
985
+ cacheWriteTokens: optionalUsageInteger(value.cacheWriteTokens),
986
+ cost: typeof value.cost === "number" && Number.isFinite(value.cost) && value.cost >= 0 ? value.cost : 0,
987
+ turns: optionalUsageInteger(value.turns),
988
+ toolUses: optionalUsageInteger(value.toolUses),
989
+ retries: optionalUsageInteger(value.retries),
990
+ compactions: optionalUsageInteger(value.compactions),
991
+ };
992
+ }
993
+
994
+ function usageInteger(value: unknown): number | undefined {
995
+ const maxComponent = Math.floor(Number.MAX_SAFE_INTEGER / 2);
996
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
997
+ ? Math.min(maxComponent, Math.floor(value))
998
+ : undefined;
999
+ }
1000
+
1001
+ function optionalUsageInteger(value: unknown): number | undefined {
1002
+ return value === undefined ? undefined : usageInteger(value) ?? 0;
1003
+ }
1004
+
1005
+ function modelIdentity(model: ModelLike | undefined): unknown {
1006
+ if (!model) return undefined;
1007
+ return {
1008
+ provider: model.provider,
1009
+ id: model.id,
1010
+ name: model.name,
1011
+ thinkingLevelMap: model.thinkingLevelMap,
1012
+ };
1013
+ }
1014
+
745
1015
  function defaultLabel(phase: string | undefined, index: number): string {
746
1016
  return phase ? `${phase} agent ${index}` : `agent ${index}`;
747
1017
  }
748
1018
 
749
- function createLimiter(limit: number): <T>(fn: () => Promise<T>) => Promise<T> {
1019
+ function normalizeConcurrency(value: number | undefined, fallback: number): number {
1020
+ if (value === undefined) return fallback;
1021
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > MAX_CONCURRENCY) {
1022
+ throw new WorkflowPolicyError(`workflow concurrency must be an integer between 1 and ${MAX_CONCURRENCY}`);
1023
+ }
1024
+ return value;
1025
+ }
1026
+
1027
+ function normalizeCleanupTimeout(value: number | undefined): number {
1028
+ if (value === undefined) return DEFAULT_CLEANUP_TIMEOUT_MS;
1029
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
1030
+ throw new WorkflowPolicyError("workflow cleanupTimeoutMs must be a positive integer");
1031
+ }
1032
+ return value;
1033
+ }
1034
+
1035
+ async function raceWithCleanupTimeout(
1036
+ work: Promise<unknown>,
1037
+ remainingMs: number,
1038
+ configuredTimeoutMs: number,
1039
+ ): Promise<void> {
1040
+ let timer: ReturnType<typeof setTimeout> | undefined;
1041
+ try {
1042
+ await Promise.race([
1043
+ work,
1044
+ new Promise<never>((_resolve, reject) => {
1045
+ timer = setTimeout(() => reject(new WorkflowCleanupTimeoutError(configuredTimeoutMs)), remainingMs);
1046
+ }),
1047
+ ]);
1048
+ } finally {
1049
+ if (timer) clearTimeout(timer);
1050
+ }
1051
+ }
1052
+
1053
+ function createLimiter(limit: number, signal: AbortSignal): <T>(fn: () => Promise<T>) => Promise<T> {
750
1054
  let active = 0;
751
- const queue: Array<() => void> = [];
752
- const next = () => {
1055
+ const queue: Array<{ resolve: () => void; reject: (error: Error) => void }> = [];
1056
+
1057
+ const rejectQueued = () => {
1058
+ const error = new WorkflowAbortError();
1059
+ for (const waiter of queue.splice(0)) waiter.reject(error);
1060
+ };
1061
+ signal.addEventListener("abort", rejectQueued, { once: true });
1062
+
1063
+ const acquire = async (): Promise<void> => {
1064
+ if (signal.aborted) throw new WorkflowAbortError();
1065
+ if (active < limit) {
1066
+ active++;
1067
+ return;
1068
+ }
1069
+ await new Promise<void>((resolve, reject) => {
1070
+ queue.push({ resolve, reject });
1071
+ if (signal.aborted) rejectQueued();
1072
+ });
1073
+ };
1074
+
1075
+ const release = () => {
753
1076
  active--;
754
- queue.shift()?.();
1077
+ if (signal.aborted) {
1078
+ rejectQueued();
1079
+ return;
1080
+ }
1081
+ const waiter = queue.shift();
1082
+ if (!waiter) return;
1083
+ active++;
1084
+ waiter.resolve();
755
1085
  };
1086
+
756
1087
  return async <T>(fn: () => Promise<T>): Promise<T> => {
757
- if (active >= limit) await new Promise<void>((resolve) => queue.push(resolve));
758
- active++;
1088
+ await acquire();
759
1089
  try {
760
1090
  return await fn();
761
1091
  } finally {
762
- next();
1092
+ release();
763
1093
  }
764
1094
  };
765
1095
  }
@@ -786,6 +1116,13 @@ function optionalString(value: unknown, name: string): string | undefined {
786
1116
  return requireString(value, name);
787
1117
  }
788
1118
 
1119
+ function journalPolicyError(error: unknown, operation: string): WorkflowPolicyError {
1120
+ if (isWorkflowPolicyError(error)) return error;
1121
+ const failure = new WorkflowPolicyError(`${operation}: ${errorMessage(error)}`);
1122
+ (failure as Error & { cause?: unknown }).cause = error;
1123
+ return failure;
1124
+ }
1125
+
789
1126
  function errorMessage(error: unknown): string {
790
1127
  return error instanceof Error ? error.message : String(error);
791
1128
  }