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.
- package/README.md +47 -42
- package/examples/workflows/loop-until-dry-bugs.workflow.js +16 -6
- package/extensions/ultracode.ts +0 -1
- package/package.json +7 -7
- package/src/commands.ts +19 -23
- package/src/index.ts +1 -1
- package/src/mode.ts +6 -40
- package/src/prompts.ts +18 -28
- package/src/workflow/admission.ts +318 -0
- package/src/workflow/agent-runner.ts +12 -0
- package/src/workflow/display.ts +17 -13
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/json-schema.ts +39 -17
- package/src/workflow/leases.ts +60 -0
- package/src/workflow/parser.ts +809 -5
- package/src/workflow/registry.ts +39 -22
- package/src/workflow/run-artifacts.ts +231 -0
- package/src/workflow/run-details.ts +106 -28
- package/src/workflow/runtime.ts +640 -303
- package/src/workflow/script-executor.ts +317 -0
- package/src/workflow/script-worker.mjs +678 -0
- package/src/workflow/structured-output.ts +2 -0
- package/src/workflow/tool.ts +207 -74
- package/src/workflow/value-limits.ts +363 -0
- package/src/workflow/worktree.ts +5 -3
- package/types/workflow.d.ts +60 -22
package/src/workflow/runtime.ts
CHANGED
|
@@ -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
|
|
7
|
-
* /
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
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; //
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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.
|
|
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
|
|
219
|
-
|
|
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
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
const assignedPhase = opts.phase ?? this.executionContext.getStore()?.currentPhase ?? this.state.currentPhase;
|
|
509
|
+
return { ...loaded, callPath };
|
|
510
|
+
}
|
|
316
511
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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 = [...
|
|
324
|
-
const
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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:
|
|
708
|
+
result: clonedValue,
|
|
463
709
|
status: "done",
|
|
464
710
|
usage,
|
|
465
711
|
modelId: result.modelId,
|
|
466
712
|
effort: result.effort,
|
|
467
|
-
});
|
|
468
|
-
|
|
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
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
throw
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
/**
|
|
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, "
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
959
|
+
if (artifactPathExists(full)) {
|
|
960
|
+
return parseWorkflowScript(readContainedArtifactFile(root, full, "saved nested workflow", 16 * 1024 * 1024));
|
|
961
|
+
}
|
|
731
962
|
}
|
|
732
963
|
}
|
|
733
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
active++;
|
|
1088
|
+
await acquire();
|
|
759
1089
|
try {
|
|
760
1090
|
return await fn();
|
|
761
1091
|
} finally {
|
|
762
|
-
|
|
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
|
}
|