pi-ultracode 0.3.2 → 0.5.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.
@@ -0,0 +1,37 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import {
4
+ artifactPathExists,
5
+ readContainedArtifactFile,
6
+ } from "./run-artifacts.ts";
7
+
8
+ export interface SavedWorkflowSource {
9
+ script: string;
10
+ sourcePath: string;
11
+ scope: "project" | "user";
12
+ }
13
+
14
+ /** Discover an ambient saved workflow without crossing the project-trust gate. */
15
+ export function readSavedWorkflowByName(
16
+ name: string,
17
+ cwd: string,
18
+ projectTrusted: boolean,
19
+ ): SavedWorkflowSource | undefined {
20
+ const roots: Array<{ root: string; scope: "project" | "user" }> = [
21
+ ...(projectTrusted ? [{ root: cwd, scope: "project" as const }] : []),
22
+ { root: os.homedir(), scope: "user" },
23
+ ];
24
+ for (const { root, scope } of roots) {
25
+ const directory = path.join(root, ".pi", "ultracode", "workflows");
26
+ for (const candidate of [`${name}.workflow.js`, `${name}.js`]) {
27
+ const sourcePath = path.join(directory, candidate);
28
+ if (!artifactPathExists(sourcePath)) continue;
29
+ return {
30
+ script: readContainedArtifactFile(root, sourcePath, "saved workflow", 16 * 1024 * 1024),
31
+ sourcePath,
32
+ scope,
33
+ };
34
+ }
35
+ }
36
+ return undefined;
37
+ }
@@ -14,6 +14,8 @@ import {
14
14
 
15
15
  export interface ScriptExecutorHost {
16
16
  agent(payload: any): Promise<unknown>;
17
+ /** Cancel child work after a fatal script policy without terminating branch finally blocks. */
18
+ abortChildren?(error: Error): void;
17
19
  reservePanel(payload: {
18
20
  callPath?: unknown;
19
21
  reserveAgents: number;
@@ -49,6 +51,8 @@ export interface ScriptExecutorOptions {
49
51
  hostCallLimit?: number;
50
52
  /** Internal test seam for the Worker's V8 old-generation heap cap. */
51
53
  workerMemoryLimitMb?: number;
54
+ /** Bound for fatal host-RPC draining before forced termination. */
55
+ fatalDrainTimeoutMs?: number;
52
56
  }
53
57
 
54
58
  type TerminationCause = "abort" | "stall" | "fatal" | "error" | "cleanup";
@@ -59,6 +63,7 @@ export async function executeWorkflowScript(
59
63
  options: ScriptExecutorOptions,
60
64
  ): Promise<unknown> {
61
65
  if (options.signal.aborted) throw new WorkflowAbortError();
66
+ const fatalDrainTimeoutMs = normalizeFatalDrainTimeout(options.fatalDrainTimeoutMs);
62
67
  const worker = new Worker(new URL("./script-worker.mjs", import.meta.url), {
63
68
  workerData: {
64
69
  body,
@@ -79,6 +84,7 @@ export async function executeWorkflowScript(
79
84
  let terminationCause: TerminationCause | undefined;
80
85
  let fatalError: Error | undefined;
81
86
  let watchdog: NodeJS.Timeout | undefined;
87
+ let fatalDrainTimer: NodeJS.Timeout | undefined;
82
88
 
83
89
  const terminate = (cause: TerminationCause): void => {
84
90
  terminationCause ??= cause;
@@ -97,17 +103,40 @@ export async function executeWorkflowScript(
97
103
  if (settled) return;
98
104
  settled = true;
99
105
  if (watchdog) clearInterval(watchdog);
106
+ if (fatalDrainTimer) clearTimeout(fatalDrainTimer);
100
107
  if (ok) resolve(value);
101
108
  else reject(value);
102
109
  };
110
+ let workerReportedResult = false;
111
+ const maybeSettleFatal = (): void => {
112
+ if (!fatalError || !workerReportedResult || pendingRpcCount > 0) return;
113
+ settle(false, fatalError);
114
+ terminate("fatal");
115
+ };
103
116
  const rejectAndTerminate = (error: unknown, cause: TerminationCause): void => {
104
117
  if (cause === "fatal" && error instanceof Error) fatalError ??= error;
105
118
  settle(false, error);
106
119
  terminate(cause);
107
120
  };
121
+ const beginFatalDrain = (error: Error): void => {
122
+ fatalError ??= error;
123
+ terminationCause ??= "fatal";
124
+ try {
125
+ host.abortChildren?.(fatalError);
126
+ } catch {
127
+ // Fatal draining remains authoritative even if cancellation notification fails.
128
+ }
129
+ if (!fatalDrainTimer) {
130
+ fatalDrainTimer = setTimeout(() => {
131
+ if (!settled && fatalError) rejectAndTerminate(fatalError, "fatal");
132
+ }, fatalDrainTimeoutMs);
133
+ fatalDrainTimer.unref?.();
134
+ }
135
+ maybeSettleFatal();
136
+ };
108
137
 
109
138
  watchdog = setInterval(() => {
110
- if (settled) return;
139
+ if (settled || fatalError) return;
111
140
  const now = Date.now();
112
141
  if (now - lastHeartbeat > stallTimeoutMs) {
113
142
  if (options.signal.aborted) {
@@ -143,9 +172,7 @@ export async function executeWorkflowScript(
143
172
  return;
144
173
  }
145
174
  if (message.type === "fatal") {
146
- const error = reviveError(message.error);
147
- fatalError = error;
148
- rejectAndTerminate(error, "fatal");
175
+ beginFatalDrain(reviveError(message.error));
149
176
  return;
150
177
  }
151
178
  if (message.type === "event") {
@@ -164,15 +191,23 @@ export async function executeWorkflowScript(
164
191
  void handleRpc(message).finally(() => {
165
192
  pendingRpcCount = Math.max(0, pendingRpcCount - 1);
166
193
  lastProgress = Date.now();
194
+ maybeSettleFatal();
167
195
  });
168
196
  return;
169
197
  }
170
198
  if (message.type === "result") {
171
- if (message.ok) settle(true, message.value);
172
- else {
199
+ workerReportedResult = true;
200
+ if (fatalError) {
201
+ maybeSettleFatal();
202
+ } else if (message.ok) {
203
+ settle(true, message.value);
204
+ } else {
173
205
  const error = reviveError(message.error);
174
- if (error instanceof WorkflowPolicyError) fatalError ??= error;
175
- settle(false, fatalError ?? error);
206
+ if (error instanceof WorkflowPolicyError) {
207
+ beginFatalDrain(error);
208
+ } else {
209
+ settle(false, error);
210
+ }
176
211
  }
177
212
  }
178
213
  });
@@ -180,7 +215,8 @@ export async function executeWorkflowScript(
180
215
  worker.on("error", (error: NodeJS.ErrnoException) => {
181
216
  if (settled) return;
182
217
  if (error.code === "ERR_WORKER_OUT_OF_MEMORY") {
183
- settle(false, new WorkflowPolicyError("workflow script exceeded its worker memory limit"));
218
+ workerReportedResult = true;
219
+ beginFatalDrain(new WorkflowPolicyError("workflow script exceeded its worker memory limit"));
184
220
  return;
185
221
  }
186
222
  settle(false, error);
@@ -189,7 +225,8 @@ export async function executeWorkflowScript(
189
225
  worker.on("exit", (code) => {
190
226
  if (settled) return;
191
227
  if (fatalError) {
192
- settle(false, fatalError);
228
+ workerReportedResult = true;
229
+ maybeSettleFatal();
193
230
  } else if (terminationCause === "abort" || options.signal.aborted) {
194
231
  settle(false, new WorkflowAbortError());
195
232
  } else if (terminationCause === "stall") {
@@ -240,6 +277,7 @@ export async function executeWorkflowScript(
240
277
  } finally {
241
278
  settled = true;
242
279
  if (watchdog) clearInterval(watchdog);
280
+ if (fatalDrainTimer) clearTimeout(fatalDrainTimer);
243
281
  options.signal.removeEventListener("abort", abortWorker);
244
282
  terminate("cleanup");
245
283
  await worker.terminate().catch(() => undefined);
@@ -262,6 +300,14 @@ function workerResourceLimits(value: number | undefined): {
262
300
  };
263
301
  }
264
302
 
303
+ function normalizeFatalDrainTimeout(value: number | undefined): number {
304
+ if (value === undefined) return 25_000;
305
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
306
+ throw new WorkflowPolicyError("workflow fatal drain timeout must be a positive integer");
307
+ }
308
+ return value;
309
+ }
310
+
265
311
  function normalizeHostCallLimit(value: number | undefined): number {
266
312
  if (value === undefined) return 10_000;
267
313
  if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > 1_000_000) {
@@ -77,7 +77,7 @@ async function executeBody(body, bodyArgs, depth, name, scopePath = "$") {
77
77
  });
78
78
  return executionContext.run(context, async () => {
79
79
  const scriptContext = buildScriptContext(bodyArgs);
80
- const wrapped = `(async () => {\n${body}\n})()`;
80
+ const wrapped = `(async function () {\n"use strict";\n${body}\n})()`;
81
81
  return await new vm.Script(wrapped, { filename: `${name || "workflow"}.js` }).runInContext(scriptContext);
82
82
  });
83
83
  }
@@ -154,6 +154,15 @@ function buildScriptContext(bodyArgs) {
154
154
  }
155
155
  }
156
156
  const sandboxLog = (value) => sync("log", [String(value)])
157
+ const safeArrayMap = Array.prototype.map
158
+ const safeArrayIsArray = Array.isArray
159
+ const safeReflectApply = Reflect.apply
160
+ const mapThunks = (receiver, mapper) => {
161
+ if (!safeArrayIsArray(receiver) || typeof mapper !== "function") {
162
+ throw new TypeError("parallel map requires a realm-local array and mapper")
163
+ }
164
+ return safeReflectApply(safeArrayMap, receiver, [mapper])
165
+ }
157
166
  Object.defineProperties(globalThis, {
158
167
  __ultracodeAgent: { value: (callSite, ...values) => orchestration("agent", callSite, values) },
159
168
  __ultracodeParallel: { value: (callSite, ...values) => orchestration("parallel", callSite, values) },
@@ -161,11 +170,11 @@ function buildScriptContext(bodyArgs) {
161
170
  __ultracodeWorkflow: { value: (callSite, ...values) => orchestration("workflow", callSite, values) },
162
171
  __ultracodeLoop: { value: (site) => sync("loop", [site]) },
163
172
  __ultracodeInvoke: { value: (callSite, fn, thisArg, ...values) => invoke(callSite, fn, thisArg, values) },
173
+ __ultracodeMapThunks: { value: mapThunks },
164
174
  phase: { value: (value) => sync("phase", [value]) },
165
175
  log: { value: sandboxLog },
166
176
  args: { value: localize(rawArgs), enumerable: true },
167
177
  cwd: { value: cwdValue, enumerable: true },
168
- process: { value: Object.freeze({ cwd: () => cwdValue }) },
169
178
  console: { value: Object.freeze({
170
179
  log: sandboxLog,
171
180
  info: sandboxLog,
@@ -175,6 +184,7 @@ function buildScriptContext(bodyArgs) {
175
184
  structuredClone: { value: (value) => localize(value) },
176
185
  Date: { value: undefined },
177
186
  Function: { value: undefined },
187
+ Proxy: { value: undefined },
178
188
  eval: { value: undefined },
179
189
  require: { value: undefined },
180
190
  performance: { value: undefined },
@@ -214,6 +224,8 @@ function buildScriptContext(bodyArgs) {
214
224
  })
215
225
  Object.freeze(Promise.prototype)
216
226
  Object.freeze(Promise)
227
+ Object.freeze(Array.prototype)
228
+ Object.freeze(Array)
217
229
  Object.defineProperty(Math, "random", {
218
230
  value: () => { throw new Error("Math.random() is non-deterministic and forbidden in workflow scripts; vary randomness by agent index instead.") },
219
231
  configurable: false,
@@ -279,35 +291,39 @@ async function parallelImpl(callSite, thunks, options = {}) {
279
291
  if (thunks.length > MAX_ITEMS_PER_CALL) {
280
292
  throw makePolicyError(`parallel() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${thunks.length})`);
281
293
  }
282
- if (thunks.some((thunk) => typeof thunk !== "function")) {
283
- throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
294
+ const plannedThunks = copyDenseDataArray(thunks, "parallel() thunk array");
295
+ for (const thunk of plannedThunks) {
296
+ if (typeof thunk !== "function") {
297
+ throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
298
+ }
284
299
  }
285
300
  const execution = executionContext.getStore() ?? createExecutionScope({ scopePath: "$" });
286
- const reserveAgents = normalizeReserveAgents(options, thunks.length);
301
+ const reserveAgents = normalizeReserveAgents(options, plannedThunks.length);
287
302
  const rawPanelSite = typeof callSite === "string" && callSite ? callSite : "parallel:dynamic";
288
303
  const [panelSource, panelLoops = ""] = rawPanelSite.split("@", 2);
289
- const panelSite = `${panelSource}#${thunks.length}:${reserveAgents}${panelLoops ? `@${panelLoops}` : ""}`;
304
+ const panelSite = `${panelSource}#${plannedThunks.length}:${reserveAgents}${panelLoops ? `@${panelLoops}` : ""}`;
290
305
  const panelCallPath = allocateCallPath(execution, "parallel", "p", panelSite);
291
- if (reserveAgents < thunks.length) {
292
- throw makePolicyError(`parallel reserveAgents must be at least the number of thunks (${thunks.length}); got ${reserveAgents}`);
306
+ if (reserveAgents < plannedThunks.length) {
307
+ throw makePolicyError(`parallel reserveAgents must be at least the number of thunks (${plannedThunks.length}); got ${reserveAgents}`);
293
308
  }
294
309
  const parentReservationIds = Array.isArray(execution.reservationIds) ? execution.reservationIds : [];
295
310
  const panel = await rpc("reservePanel", {
296
311
  callPath: panelCallPath,
297
312
  reserveAgents,
298
- branchCount: thunks.length,
313
+ branchCount: plannedThunks.length,
299
314
  parentReservationIds,
300
315
  });
301
316
  const panelReservationId = requireString(panel?.panelReservationId, "panelReservationId");
302
317
  const branchReservationIds = Array.isArray(panel?.branchReservationIds) ? panel.branchReservationIds : [];
303
- if (branchReservationIds.length !== thunks.length || branchReservationIds.some((id) => typeof id !== "string")) {
318
+ if (branchReservationIds.length !== plannedThunks.length || branchReservationIds.some((id) => typeof id !== "string")) {
304
319
  throw makePolicyError("workflow host returned an invalid parallel reservation panel");
305
320
  }
306
321
 
307
322
  let completed = false;
308
- const branchOutcomes = Array.from({ length: thunks.length }, () => "pending");
323
+ let firstFatal;
324
+ const branchOutcomes = Array.from({ length: plannedThunks.length }, () => "pending");
309
325
  try {
310
- const results = await Promise.all(thunks.map(async (thunk, index) => {
326
+ const branchPromises = plannedThunks.map(async (thunk, index) => {
311
327
  const branchReservationId = branchReservationIds[index];
312
328
  const childContext = createExecutionScope({
313
329
  workflowPath: [...(execution.workflowPath ?? [])],
@@ -329,7 +345,10 @@ async function parallelImpl(callSite, thunks, options = {}) {
329
345
  });
330
346
  return value;
331
347
  } catch (error) {
332
- if (isFatal(error)) throw error;
348
+ if (isFatal(error)) {
349
+ firstFatal ??= error;
350
+ throw error;
351
+ }
333
352
  branchOutcomes[index] = "failed";
334
353
  await rpc("completePanelBranch", {
335
354
  callPath: panelCallPath,
@@ -339,14 +358,19 @@ async function parallelImpl(callSite, thunks, options = {}) {
339
358
  logLine(`parallel[${index}] failed: ${messageOf(error)}`);
340
359
  return null;
341
360
  } finally {
342
- await rpc("releasePanel", { reservationId: branchReservationId }).catch(() => undefined);
361
+ await cleanupRpc("releasePanel", { reservationId: branchReservationId }).catch(() => undefined);
343
362
  }
344
363
  });
345
- }));
364
+ });
365
+ const settled = await Promise.allSettled(branchPromises);
366
+ if (firstFatal) throw firstFatal;
367
+ const rejected = settled.find((entry) => entry.status === "rejected");
368
+ if (rejected) throw rejected.reason;
369
+ const results = settled.map((entry) => entry.value);
346
370
  completed = true;
347
371
  return results;
348
372
  } finally {
349
- const release = rpc("releasePanel", {
373
+ const release = cleanupRpc("releasePanel", {
350
374
  reservationId: panelReservationId,
351
375
  callPath: panelCallPath,
352
376
  completed,
@@ -367,12 +391,13 @@ async function pipelineImpl(callSite, items, ...stages) {
367
391
  if (items.length > MAX_ITEMS_PER_CALL) {
368
392
  throw makePolicyError(`pipeline() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${items.length})`);
369
393
  }
394
+ const plannedItems = copyDenseDataArray(items, "pipeline() item array");
370
395
  if (stages.some((stage) => typeof stage !== "function")) {
371
396
  throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
372
397
  }
373
398
  const execution = executionContext.getStore() ?? createExecutionScope({ scopePath: "$" });
374
399
  const pipelineCallPath = allocateCallPath(execution, "pipeline", "l", callSite);
375
- return await Promise.all(items.map(async (item, index) => {
400
+ return await Promise.all(plannedItems.map(async (item, index) => {
376
401
  let value = item;
377
402
  for (const [stageIndex, stage] of stages.entries()) {
378
403
  try {
@@ -397,6 +422,18 @@ async function pipelineImpl(callSite, items, ...stages) {
397
422
  }));
398
423
  }
399
424
 
425
+ function copyDenseDataArray(value, label) {
426
+ const output = new Array(value.length);
427
+ for (let index = 0; index < value.length; index++) {
428
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
429
+ if (!descriptor || !Object.hasOwn(descriptor, "value")) {
430
+ throw new TypeError(`${label} must be dense and contain only data elements`);
431
+ }
432
+ output[index] = descriptor.value;
433
+ }
434
+ return output;
435
+ }
436
+
400
437
  function workflow(callSite, nameOrRef, nestedArgs) {
401
438
  return startOrchestration(() => workflowImpl(callSite, nameOrRef, nestedArgs));
402
439
  }
@@ -520,6 +557,15 @@ function normalizeReserveAgents(options, fallback) {
520
557
  function rpc(op, payload) {
521
558
  throwIfAborted();
522
559
  consumeHostCall();
560
+ return postRpc(op, payload);
561
+ }
562
+
563
+ /** Internal finally RPC that remains available after sticky fatal/abort state. */
564
+ function cleanupRpc(op, payload) {
565
+ return postRpc(op, payload);
566
+ }
567
+
568
+ function postRpc(op, payload) {
523
569
  const id = ++nextRpcId;
524
570
  return new Promise((resolve, reject) => {
525
571
  rpcWaiters.set(id, { resolve, reject });
@@ -603,9 +649,9 @@ function makePolicyError(message) {
603
649
  function markFatal(error) {
604
650
  if (!isPolicy(error)) return;
605
651
  stickyFatalError ??= error;
652
+ // Stop new orchestration, but let every already-dispatched RPC settle so
653
+ // branch and panel finally blocks reflect real sibling completion order.
606
654
  aborted = true;
607
- for (const waiter of rpcWaiters.values()) waiter.reject(stickyFatalError);
608
- rpcWaiters.clear();
609
655
  try {
610
656
  parentPort.postMessage({ type: "fatal", error: serializeError(stickyFatalError) });
611
657
  } catch {