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.
@@ -9,6 +9,7 @@
9
9
  * claim that Node's vm is a security sandbox.
10
10
  */
11
11
 
12
+ import * as fs from "node:fs";
12
13
  import * as os from "node:os";
13
14
  import * as path from "node:path";
14
15
  import { parseWorkflowScript, type WorkflowMeta } from "./parser.ts";
@@ -39,12 +40,7 @@ import type {
39
40
  ThinkingLevel,
40
41
  } from "./agent-runner.ts";
41
42
  import { safeDisplayText } from "./display-text.ts";
42
- import {
43
- artifactPathExists,
44
- readArtifactFile,
45
- readContainedArtifactFile,
46
- standaloneWorkflowRunsDir,
47
- } from "./run-artifacts.ts";
43
+ import { standaloneWorkflowRunsDir } from "./run-artifacts.ts";
48
44
  import {
49
45
  assertWorkflowArgsLimit,
50
46
  assertWorkflowOutputLimit,
@@ -52,8 +48,11 @@ import {
52
48
  } from "./value-limits.ts";
53
49
 
54
50
  import { discoverAgentTypes, resolveAgentType, type AgentTypeDef } from "./agent-types.ts";
51
+ import { readSavedWorkflowByName } from "./saved-workflow.ts";
52
+ import { resolveRepositoryContext } from "./repository-context.ts";
55
53
  import {
56
54
  agentCallKey,
55
+ hashBytes,
57
56
  hashString,
58
57
  stableStringify,
59
58
  RunJournal,
@@ -64,8 +63,8 @@ import {
64
63
  captureWorktreeDiff,
65
64
  createWorktree,
66
65
  hasChanges,
67
- isGitRepo,
68
66
  removeWorktree,
67
+ verifyAppliedPatch,
69
68
  writeRescuePatch,
70
69
  type Worktree,
71
70
  type WorktreeDiff,
@@ -73,6 +72,7 @@ import {
73
72
 
74
73
  const MAX_CONCURRENCY = 16;
75
74
  const DEFAULT_CLEANUP_TIMEOUT_MS = 25_000;
75
+ const REPOSITORY_APPLY_LOCKS = new Map<string, Mutex>();
76
76
  export { DEFAULT_MAX_AGENTS, ABSOLUTE_MAX_AGENTS };
77
77
  export const MAX_WORKFLOW_LOGS = 256;
78
78
  export const WORKFLOW_LOG_OMITTED_TEXT = "additional workflow logs omitted";
@@ -91,6 +91,10 @@ export type AgentActivityEvent = AgentEventBase & AgentActivityInput;
91
91
  export interface WorkflowRunOptions {
92
92
  cwd?: string;
93
93
  args?: unknown;
94
+ /** Parent session's immutable project-trust decision. Defaults to false. */
95
+ projectTrusted?: boolean;
96
+ /** Canonical repository and relative-cwd identity. Computed when omitted. */
97
+ targetIdentity?: string;
94
98
  signal?: AbortSignal;
95
99
  concurrency?: number;
96
100
  /** Logical cap on agent() slots for this workflow. Defaults to 128; absolute range 1..1024. */
@@ -113,7 +117,7 @@ export interface WorkflowRunOptions {
113
117
  runner?: { run: WorkflowAgentRunner["run"] };
114
118
  journal?: RunJournal;
115
119
  /** Loads a saved workflow body by name; defaults to disk discovery. */
116
- loadSavedWorkflow?: (nameOrRef: string | { scriptPath: string }) => { meta: WorkflowMeta; body: string };
120
+ loadSavedWorkflow?: (name: string) => { meta: WorkflowMeta; body: string };
117
121
  onLog?: (message: string) => void;
118
122
  onPhase?: (title: string) => void;
119
123
  onAgentStart?: (event: AgentEventBase & {
@@ -160,6 +164,8 @@ export interface WorkflowRunResult<T = unknown> {
160
164
 
161
165
  interface ActivePanelTrace {
162
166
  branchCount: number;
167
+ panelReservationId: string;
168
+ branchReservationIds: string[];
163
169
  calls: Array<{ callPath: string; status: "pending" | "success" | "failed" }>;
164
170
  }
165
171
 
@@ -175,23 +181,41 @@ interface RuntimeState {
175
181
  maxAgents: number;
176
182
  }
177
183
 
184
+ type WorktreeOutcome =
185
+ | { status: "unchanged" | "applied"; keepWorktree: false }
186
+ | { status: "rescued"; keepWorktree: false; recoveryPath: string }
187
+ | { status: "preserved"; keepWorktree: true; recoveryPath: string };
188
+
178
189
  export async function runWorkflow<T = unknown>(
179
190
  rawScript: string,
180
191
  options: WorkflowRunOptions = {},
181
192
  ): Promise<WorkflowRunResult<T>> {
182
193
  if (options.signal?.aborted) throw new WorkflowAbortError();
183
194
  assertWorkflowArgsLimit(options.args);
195
+ if (options.journal && options.journal.projectTrusted !== options.projectTrusted) {
196
+ throw new WorkflowPolicyError("workflow journal project trust context does not match this execution");
197
+ }
198
+ const actualTargetIdentity = resolveRepositoryContext(options.cwd ?? process.cwd()).identity;
199
+ if (options.targetIdentity !== undefined && options.targetIdentity !== actualTargetIdentity) {
200
+ throw new WorkflowPolicyError("workflow target identity does not match the actual cwd");
201
+ }
202
+ const targetIdentity = actualTargetIdentity;
203
+ if (options.journal && options.journal.targetIdentity !== targetIdentity) {
204
+ throw new WorkflowPolicyError("workflow journal repository/cwd target does not match this execution");
205
+ }
206
+ const cleanupTimeoutMs = normalizeCleanupTimeout(options.cleanupTimeoutMs);
207
+ const effectiveOptions: WorkflowRunOptions = { ...options, targetIdentity, cleanupTimeoutMs };
184
208
  const started = Date.now();
185
209
  const maxAgents = normalizeMaxAgents(options.maxAgents ?? options.journal?.effectiveMaxAgents);
186
210
  const { meta, body } = parseWorkflowScript(rawScript);
187
- const runtime = new Runtime(options, maxAgents);
211
+ const runtime = new Runtime(effectiveOptions, maxAgents);
188
212
  const onOuterAbort = () => runtime.abort();
189
213
  options.signal?.addEventListener("abort", onOuterAbort, { once: true });
190
214
  if (options.signal?.aborted) runtime.abort();
191
215
  try {
192
216
  const result = await runtime.runBody(body, options.args, meta.name);
193
217
  if (runtime.policyError) throw runtime.policyError;
194
- await runtime.drain(normalizeCleanupTimeout(options.cleanupTimeoutMs));
218
+ await runtime.drain(cleanupTimeoutMs);
195
219
  if (runtime.policyError) throw runtime.policyError;
196
220
  assertWorkflowOutputLimit(result);
197
221
  // Keep the public value detached from the Worker message object.
@@ -213,10 +237,14 @@ export async function runWorkflow<T = unknown>(
213
237
  } catch (error) {
214
238
  runtime.abort();
215
239
  if (error instanceof WorkflowCleanupTimeoutError) throw error;
216
- const cleanupTimeoutMs = normalizeCleanupTimeout(options.cleanupTimeoutMs);
217
240
  try {
218
241
  await runtime.drain(cleanupTimeoutMs);
219
242
  } catch (cleanupError) {
243
+ const policy = runtime.policyError ?? (isWorkflowPolicyError(error) ? error : undefined);
244
+ if (policy) {
245
+ (policy as WorkflowPolicyError & { cleanupError?: unknown }).cleanupError = cleanupError;
246
+ throw policy;
247
+ }
220
248
  throw cleanupError;
221
249
  }
222
250
  if (runtime.policyError) throw runtime.policyError;
@@ -237,7 +265,6 @@ class Runtime implements ScriptExecutorHost {
237
265
  private readonly agentTypes: Map<string, AgentTypeDef>;
238
266
  private readonly limiter: <R>(fn: () => Promise<R>) => Promise<R>;
239
267
  private readonly pending = new Set<Promise<unknown>>();
240
- private readonly applyLock = new Mutex();
241
268
  private readonly childController = new AbortController();
242
269
  private readonly scriptController = new AbortController();
243
270
  private readonly admission: AgentAdmission;
@@ -245,13 +272,14 @@ class Runtime implements ScriptExecutorHost {
245
272
  private readonly agentRequestOccurrences = new Map<string, number>();
246
273
  private readonly nestedRequestOccurrences = new Map<string, number>();
247
274
  private readonly activePanelTraces = new Map<string, ActivePanelTrace>();
275
+ private cleanupDeadline?: number;
248
276
  policyError: WorkflowPolicyError | undefined;
249
277
 
250
278
  constructor(options: WorkflowRunOptions, maxAgents: number) {
251
279
  this.options = options;
252
280
  this.cwd = options.cwd ?? process.cwd();
253
281
  this.runnerInstance = options.runner;
254
- this.agentTypes = discoverAgentTypes(this.cwd);
282
+ this.agentTypes = discoverAgentTypes(this.cwd, options.projectTrusted ?? false);
255
283
  this.admission = new AgentAdmission(maxAgents, options.journal?.agentsUsed ?? 0);
256
284
  this.state = {
257
285
  logs: [],
@@ -274,22 +302,33 @@ class Runtime implements ScriptExecutorHost {
274
302
  }
275
303
 
276
304
  abort(): void {
277
- if (!this.childController.signal.aborted) this.childController.abort();
305
+ this.cleanupDeadline ??= Date.now() + (this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS);
306
+ this.abortChildren();
278
307
  if (!this.scriptController.signal.aborted) this.scriptController.abort();
279
308
  }
280
309
 
310
+ abortChildren(): void {
311
+ this.cleanupDeadline ??= Date.now() + (this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS);
312
+ if (!this.childController.signal.aborted) this.childController.abort();
313
+ }
314
+
281
315
  async drain(timeoutMs?: number): Promise<void> {
282
316
  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;
317
+ if (configuredTimeoutMs !== undefined) this.cleanupDeadline ??= Date.now() + configuredTimeoutMs;
318
+ const deadline = configuredTimeoutMs === undefined ? undefined : this.cleanupDeadline;
319
+ try {
320
+ while (this.pending.size > 0) {
321
+ const batch = Promise.allSettled([...this.pending]);
322
+ if (deadline === undefined) {
323
+ await batch;
324
+ continue;
325
+ }
326
+ const remainingMs = deadline - Date.now();
327
+ if (remainingMs <= 0) throw new WorkflowCleanupTimeoutError(configuredTimeoutMs!);
328
+ await raceWithCleanupTimeout(batch, remainingMs, configuredTimeoutMs!);
289
329
  }
290
- const remainingMs = deadline - Date.now();
291
- if (remainingMs <= 0) throw new WorkflowCleanupTimeoutError(configuredTimeoutMs!);
292
- await raceWithCleanupTimeout(batch, remainingMs, configuredTimeoutMs!);
330
+ } finally {
331
+ this.releaseOpenPanels();
293
332
  }
294
333
  }
295
334
 
@@ -298,6 +337,7 @@ class Runtime implements ScriptExecutorHost {
298
337
  if (!this.runnerInstance) {
299
338
  this.runnerInstance = new WorkflowAgentRunner({
300
339
  cwd: this.cwd,
340
+ projectTrusted: this.options.projectTrusted ?? false,
301
341
  modelRegistry: this.options.modelRegistry,
302
342
  modelRuntime: this.options.modelRuntime,
303
343
  model: this.options.model,
@@ -317,6 +357,7 @@ class Runtime implements ScriptExecutorHost {
317
357
  stallTimeoutMs: this.options.stallTimeoutMs,
318
358
  hostCallLimit: this.options.hostCallLimit,
319
359
  workerMemoryLimitMb: this.options.workerMemoryLimitMb,
360
+ fatalDrainTimeoutMs: this.options.cleanupTimeoutMs,
320
361
  });
321
362
  }
322
363
 
@@ -379,7 +420,12 @@ class Runtime implements ScriptExecutorHost {
379
420
  this.admission.releasePanel(panel.panelReservationId);
380
421
  throw new WorkflowPolicyError(`workflow produced duplicate parallel callPath: ${callPath}`);
381
422
  }
382
- this.activePanelTraces.set(callPath, { branchCount: payload.branchCount, calls: [] });
423
+ this.activePanelTraces.set(callPath, {
424
+ branchCount: payload.branchCount,
425
+ panelReservationId: panel.panelReservationId,
426
+ branchReservationIds: [...panel.branchReservationIds],
427
+ calls: [],
428
+ });
383
429
  return panel;
384
430
  } catch (error) {
385
431
  this.recordPolicyError(error);
@@ -433,17 +479,17 @@ class Runtime implements ScriptExecutorHost {
433
479
  }): Promise<void> {
434
480
  const callPath = typeof payload.callPath === "string" ? payload.callPath : undefined;
435
481
  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 {
482
+ try {
483
+ if (payload.completed && callPath && trace) {
484
+ const branchOutcomes = Array.isArray(payload.branchOutcomes)
485
+ ? payload.branchOutcomes
486
+ : [];
487
+ if (
488
+ branchOutcomes.length !== trace.branchCount
489
+ || branchOutcomes.some((outcome) => outcome !== "success" && outcome !== "failed")
490
+ ) {
491
+ throw new WorkflowPolicyError(`workflow returned invalid branch outcomes for ${callPath}`);
492
+ }
447
493
  this.options.journal?.recordPanelComplete(
448
494
  callPath,
449
495
  trace.branchCount,
@@ -453,14 +499,25 @@ class Runtime implements ScriptExecutorHost {
453
499
  status: call.status === "success" ? "success" : "failed",
454
500
  })),
455
501
  );
456
- } catch (error) {
457
- const failure = journalPolicyError(error, "workflow panel completion commit failed");
458
- this.recordPolicyError(failure);
459
- throw failure;
460
502
  }
503
+ } catch (error) {
504
+ const failure = journalPolicyError(error, "workflow panel completion commit failed");
505
+ this.recordPolicyError(failure);
506
+ throw failure;
507
+ } finally {
508
+ this.admission.releasePanel(payload.reservationId);
509
+ if (callPath && trace) this.releasePanelTrace(callPath, trace);
461
510
  }
462
- this.admission.releasePanel(payload.reservationId);
463
- if (callPath) this.activePanelTraces.delete(callPath);
511
+ }
512
+
513
+ private releasePanelTrace(callPath: string, trace: ActivePanelTrace): void {
514
+ for (const reservationId of trace.branchReservationIds) this.admission.releasePanel(reservationId);
515
+ this.admission.releasePanel(trace.panelReservationId);
516
+ this.activePanelTraces.delete(callPath);
517
+ }
518
+
519
+ private releaseOpenPanels(): void {
520
+ for (const [callPath, trace] of this.activePanelTraces) this.releasePanelTrace(callPath, trace);
464
521
  }
465
522
 
466
523
  private beginPanelAgent(callPath: string): void {
@@ -494,7 +551,8 @@ class Runtime implements ScriptExecutorHost {
494
551
  const requestOccurrence = this.nestedRequestOccurrences.get(requestBase) ?? 0;
495
552
  this.nestedRequestOccurrences.set(requestBase, requestOccurrence + 1);
496
553
  const callPath = `${requestBase}:${requestOccurrence}`;
497
- const loader = this.options.loadSavedWorkflow ?? ((r) => loadSavedWorkflowFromDisk(r, this.cwd));
554
+ const loader = this.options.loadSavedWorkflow
555
+ ?? ((r) => loadSavedWorkflowFromDisk(r, this.cwd, this.options.projectTrusted ?? false));
498
556
  const loaded = loader(ref);
499
557
  const sourceHash = hashString(
500
558
  `${stableStringify(loaded.meta)}\u0000${loaded.body}\u0000${argsJson}`,
@@ -552,6 +610,11 @@ class Runtime implements ScriptExecutorHost {
552
610
  const label = opts.label?.trim() || defaultLabel(assignedPhase, id);
553
611
  const workflowPath = Array.isArray(payload.workflowPath) ? [...payload.workflowPath] : [];
554
612
  const agentTypeDef = resolveAgentType(opts.agentType, this.agentTypes);
613
+ if (opts.agentType !== undefined && !agentTypeDef) {
614
+ const error = new WorkflowPolicyError(`unknown agent type: ${opts.agentType}`);
615
+ this.recordPolicyError(error);
616
+ throw error;
617
+ }
555
618
  const selection = resolveModelSelection({
556
619
  pattern: opts.model,
557
620
  roleModel: agentTypeDef?.model,
@@ -566,10 +629,12 @@ class Runtime implements ScriptExecutorHost {
566
629
  agentTypeDefinition: agentTypeDef,
567
630
  effectiveModel: modelIdentity(selection.model),
568
631
  effectiveThinking: selection.thinkingLevel,
632
+ targetIdentity: this.options.targetIdentity,
569
633
  });
570
634
 
571
635
  let cached: JournalAgentRecord | undefined;
572
636
  try {
637
+ this.assertTargetIdentityCurrent();
573
638
  cached = this.options.journal?.lookup(callPath, key);
574
639
  } catch (error) {
575
640
  const failure = journalPolicyError(error, "workflow cache lookup failed");
@@ -577,6 +642,7 @@ class Runtime implements ScriptExecutorHost {
577
642
  throw failure;
578
643
  }
579
644
  if (cached) {
645
+ await this.verifyCachedDelivery(cached);
580
646
  this.beginPanelAgent(callPath);
581
647
  this.state.cachedCount++;
582
648
  this.state.spent += cached.outputTokens ?? 0;
@@ -609,7 +675,7 @@ class Runtime implements ScriptExecutorHost {
609
675
  structuredOutput: opts.schema != null,
610
676
  cachedRecord: cloneResult(cached, "cached agent record"),
611
677
  }));
612
- this.notifyObserver(() => this.options.onAgentEnd?.({
678
+ this.notifyObserverBestEffort(() => this.options.onAgentEnd?.({
613
679
  id,
614
680
  callPath,
615
681
  label,
@@ -652,12 +718,13 @@ class Runtime implements ScriptExecutorHost {
652
718
  structuredOutput: opts.schema != null,
653
719
  }));
654
720
  let worktree: Worktree | undefined;
655
- let keepWorktree = false;
721
+ let worktreeSettled = false;
722
+ let keepWorktree = true;
656
723
  let observedUsage: AgentUsage | undefined;
657
724
  try {
658
725
  this.throwIfAborted();
659
726
  if (opts.isolation === "worktree") {
660
- worktree = this.tryCreateWorktree(id);
727
+ worktree = this.createIsolatedWorktree(id);
661
728
  }
662
729
 
663
730
  const runner = this.getRunner();
@@ -698,62 +765,87 @@ class Runtime implements ScriptExecutorHost {
698
765
  throw error;
699
766
  }
700
767
 
701
- if (worktree) keepWorktree = await this.integrateWorktree(worktree, id, label);
702
- this.notifyObserver(() => this.options.onAgentEnd?.({
768
+ const commitAgent = (delivery?: { patchPath: string; patchHash: string }) => {
769
+ this.assertTargetIdentityCurrent();
770
+ try {
771
+ this.options.journal?.recordAgent({
772
+ callPath,
773
+ seq: id,
774
+ key,
775
+ label,
776
+ value: clonedValue,
777
+ inputTokens: usage.inputTokens,
778
+ outputTokens: usage.outputTokens,
779
+ totalTokens: usage.totalTokens,
780
+ cacheReadTokens: usage.cacheReadTokens,
781
+ cacheWriteTokens: usage.cacheWriteTokens,
782
+ cost: usage.cost,
783
+ turns: usage.turns,
784
+ toolUses: usage.toolUses,
785
+ retries: usage.retries,
786
+ compactions: usage.compactions,
787
+ requestedModelId: opts.model,
788
+ requestedEffort: this.options.thinkingLevel,
789
+ modelId: result.modelId,
790
+ effort: result.effort,
791
+ agentType: opts.agentType,
792
+ isolation: opts.isolation,
793
+ structuredOutput: opts.schema != null,
794
+ deliveryPatchPath: delivery?.patchPath,
795
+ deliveryPatchHash: delivery?.patchHash,
796
+ startedAt: agentStartedAt,
797
+ durationMs: Date.now() - agentStartedAt,
798
+ });
799
+ } catch (error) {
800
+ const failure = journalPolicyError(error, "workflow agent-result commit failed");
801
+ this.recordPolicyError(failure);
802
+ throw failure;
803
+ }
804
+ };
805
+
806
+ if (worktree) {
807
+ const outcome = await this.integrateWorktree(worktree, id, label, callPath, commitAgent);
808
+ worktreeSettled = true;
809
+ keepWorktree = outcome.keepWorktree;
810
+ if (outcome.status === "rescued" || outcome.status === "preserved") {
811
+ throw new Error(`worktree changes were not delivered; recover from ${outcome.recoveryPath}`);
812
+ }
813
+ } else {
814
+ commitAgent();
815
+ }
816
+ // The durable agent record is the publication point. Completion observers
817
+ // run afterward and cannot veto a committed result.
818
+ this.notifyObserverBestEffort(() => this.options.onAgentEnd?.({
703
819
  id,
704
820
  callPath,
705
821
  label,
706
822
  phase: assignedPhase,
707
823
  workflowPath,
708
- result: clonedValue,
824
+ result: cloneResult(clonedValue, "live observer result"),
709
825
  status: "done",
710
826
  usage,
711
827
  modelId: result.modelId,
712
828
  effort: result.effort,
713
829
  }));
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
830
  this.finishPanelAgent(callPath, "success");
750
831
  return clonedValue;
751
832
  } catch (error) {
833
+ if (worktree && !worktreeSettled) {
834
+ const outcome = await this.recoverFailedWorktree(worktree, id, label);
835
+ worktreeSettled = true;
836
+ keepWorktree = outcome.keepWorktree;
837
+ if (this.childController.signal.aborted || this.options.signal?.aborted || isWorkflowPolicyError(error)) {
838
+ // Recursive worktree removal is not synchronously deadline-safe.
839
+ // Preserve it on cancellation/policy cleanup instead of overrunning the budget.
840
+ keepWorktree = true;
841
+ this.logLineBestEffort(`worktree[${label}]: cleanup cancelled; worktree KEPT at ${worktree.path}`);
842
+ }
843
+ }
752
844
  if (this.childController.signal.aborted || this.options.signal?.aborted || isWorkflowPolicyError(error)) throw error;
753
845
  const message = error instanceof Error ? error.message : String(error);
754
846
  this.finishPanelAgent(callPath, "failed");
755
847
  this.logLine(`agent ${label} failed: ${message}`);
756
- this.notifyObserver(() => this.options.onAgentEnd?.({
848
+ this.notifyObserverBestEffort(() => this.options.onAgentEnd?.({
757
849
  id,
758
850
  callPath,
759
851
  label,
@@ -766,11 +858,11 @@ class Runtime implements ScriptExecutorHost {
766
858
  }));
767
859
  return null;
768
860
  } finally {
769
- if (worktree && !keepWorktree) {
861
+ if (worktree && worktreeSettled && !keepWorktree) {
770
862
  try {
771
863
  removeWorktree(worktree);
772
864
  } catch {
773
- // ignore cleanup failures
865
+ // A cleanup failure must not change the delivery outcome.
774
866
  }
775
867
  }
776
868
  }
@@ -779,6 +871,36 @@ class Runtime implements ScriptExecutorHost {
779
871
  return await run;
780
872
  }
781
873
 
874
+ private async verifyCachedDelivery(record: JournalAgentRecord): Promise<void> {
875
+ if (!record.deliveryPatchPath || !record.deliveryPatchHash) return;
876
+ const context = resolveRepositoryContext(this.cwd);
877
+ if (!context.repoRoot || !this.options.journal) {
878
+ const error = new WorkflowPolicyError(`cached worktree delivery at ${record.callPath} has no matching Git target`);
879
+ this.recordPolicyError(error);
880
+ throw error;
881
+ }
882
+ const patchRoot = path.join(path.dirname(this.options.journal.filePath), "patches");
883
+ try {
884
+ const valid = await repositoryApplyLock(context.repoRoot).run(async () =>
885
+ verifyAppliedPatch(
886
+ context.repoRoot!,
887
+ record.deliveryPatchPath!,
888
+ record.deliveryPatchHash!,
889
+ patchRoot,
890
+ ));
891
+ if (!valid) {
892
+ throw new Error("the delivered patch is no longer present in the shared working tree");
893
+ }
894
+ } catch (error) {
895
+ const failure = new WorkflowPolicyError(
896
+ `cached worktree delivery at ${record.callPath} requires recovery: ${errorMessage(error)}`,
897
+ );
898
+ (failure as Error & { cause?: unknown }).cause = error;
899
+ this.recordPolicyError(failure);
900
+ throw failure;
901
+ }
902
+ }
903
+
782
904
  private notifyObserver(callback: () => void): void {
783
905
  try {
784
906
  callback();
@@ -790,96 +912,150 @@ class Runtime implements ScriptExecutorHost {
790
912
  }
791
913
  }
792
914
 
915
+ private notifyObserverBestEffort(callback: () => void): void {
916
+ try {
917
+ callback();
918
+ } catch (error) {
919
+ this.logLineBestEffort(`workflow completion observer failed: ${errorMessage(error)}`);
920
+ }
921
+ }
922
+
793
923
  private recordPolicyError(error: unknown): void {
794
924
  if (isWorkflowPolicyError(error)) {
795
925
  this.policyError ??= error;
796
- this.abort();
926
+ // Let the Worker finish branch/panel finally blocks; only child work stops here.
927
+ this.abortChildren();
797
928
  }
798
929
  }
799
930
 
800
- private tryCreateWorktree(index: number): Worktree | undefined {
801
- if (!isGitRepo(this.cwd)) {
802
- this.logLine(`agent #${index}: isolation:'worktree' ignored — not a git repository`);
803
- return undefined;
804
- }
931
+ private createIsolatedWorktree(index: number): Worktree {
932
+ this.assertTargetIdentityCurrent();
933
+ const runId = this.options.journal ? path.basename(this.options.journal.filePath, ".jsonl") : "run";
805
934
  try {
806
- const runId = this.options.journal ? path.basename(this.options.journal.filePath, ".jsonl") : "run";
807
- return createWorktree(this.cwd, runId, index);
935
+ const worktree = createWorktree(this.cwd, runId, index);
936
+ try {
937
+ this.assertTargetIdentityCurrent();
938
+ } catch (error) {
939
+ removeWorktree(worktree);
940
+ throw error;
941
+ }
942
+ return worktree;
808
943
  } catch (error) {
809
- this.logLine(`agent #${index}: worktree setup failed (${errorMessage(error)}); running in shared cwd`);
810
- return undefined;
944
+ if (isWorkflowPolicyError(error)) throw error;
945
+ throw new Error(`agent #${index}: worktree setup failed: ${errorMessage(error)}`);
811
946
  }
812
947
  }
813
948
 
814
- /**
815
- * Fold a worktree's changes back into the shared working tree. Returns true
816
- * when the worktree must be KEPT (its changes are not safely preserved
817
- * elsewhere — e.g. apply conflicted AND the rescue write failed). Never throws:
818
- * a writeback failure must not discard the agent's already-completed result
819
- * or its token spend.
820
- */
821
- private async integrateWorktree(worktree: Worktree, id: number, label: string): Promise<boolean> {
822
- // Outer safety net: a writeback/integration failure (including a host
823
- // onUpdate callback throwing during a log line) must never discard the
824
- // agent's completed work. Fail-safe toward KEEPING the worktree.
949
+ /** Fold a successful isolated result into the shared repository. */
950
+ private async integrateWorktree(
951
+ worktree: Worktree,
952
+ id: number,
953
+ label: string,
954
+ callPath: string,
955
+ commitAgent: (delivery?: { patchPath: string; patchHash: string }) => void,
956
+ ): Promise<WorktreeOutcome> {
825
957
  try {
826
- let diff: WorktreeDiff;
827
- try {
828
- diff = captureWorktreeDiff(worktree);
829
- } catch (error) {
830
- this.logLine(
831
- `worktree[${label}]: diff capture failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
832
- );
833
- return true;
834
- }
958
+ const diff = captureWorktreeDiff(worktree);
835
959
  if (!hasChanges(diff)) {
836
- this.logLine(`worktree[${label}]: no changes (auto-removed)`);
837
- return false;
960
+ commitAgent();
961
+ this.logLineBestEffort(`worktree[${label}]: no changes (auto-removed)`);
962
+ return { status: "unchanged", keepWorktree: false };
838
963
  }
839
- // Apply patches back to the shared tree sequentially to avoid corruption.
840
- let keep = false;
841
- await this.applyLock.run(async () => {
842
- const applied = applyPatch(this.cwd, diff.patch);
843
- if (applied) {
844
- this.logLine(
845
- `worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} applied to working tree`,
964
+
965
+ // Persist the exact delivery material before the shared repository changes.
966
+ const runId = this.options.journal
967
+ ? path.basename(this.options.journal.filePath, ".jsonl")
968
+ : "run";
969
+ const durablePatch = writeRescuePatch(this.rescueDir(), runId, id, label, diff.patch);
970
+ const patchHash = hashBytes(fs.readFileSync(durablePatch));
971
+ const applyLock = repositoryApplyLock(worktree.repoRoot);
972
+ return await applyLock.run(async () => {
973
+ this.throwIfAborted();
974
+ this.assertTargetIdentityCurrent();
975
+ this.options.journal?.recordDeliveryStart(callPath, durablePatch, patchHash);
976
+ if (!applyPatch(worktree.repoRoot, diff.patch)) {
977
+ this.logLineBestEffort(
978
+ `worktree[${label}]: changes could NOT be applied cleanly; patch saved to ${durablePatch} — review and apply manually`,
846
979
  );
847
- return;
980
+ return { status: "rescued", keepWorktree: false, recoveryPath: durablePatch } as const;
848
981
  }
849
- // 3-way conflict: `applyPatch` already reverted the shared tree to its
850
- // pre-apply state. Persist the patch so the agent's work is recoverable
851
- // before the worktree is removed.
852
- const runId = this.options.journal
853
- ? path.basename(this.options.journal.filePath, ".jsonl")
854
- : "run";
855
- const rescueDir = this.rescueDir();
856
- try {
857
- const rescue = writeRescuePatch(rescueDir, runId, id, label, diff.patch);
858
- this.logLine(
859
- `worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} could NOT be auto-applied (3-way conflict); patch saved to ${rescue} — review and apply with: git apply --3way ${rescue}`,
860
- );
861
- } catch (error) {
862
- // Rescue write failed (disk full / permission / bad path). Keep the
863
- // worktree so the user can recover the changes manually.
864
- keep = true;
865
- this.logLine(
866
- `worktree[${label}]: ${diff.filesChanged} file(s) could NOT be auto-applied (3-way conflict) AND rescue write failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
867
- );
982
+ commitAgent({ patchPath: durablePatch, patchHash });
983
+ if (!this.options.journal) {
984
+ try {
985
+ fs.unlinkSync(durablePatch);
986
+ } catch {
987
+ // Standalone runs have no replay state to verify.
988
+ }
868
989
  }
990
+ this.logLineBestEffort(
991
+ `worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} applied to working tree`,
992
+ );
993
+ return { status: "applied", keepWorktree: false } as const;
869
994
  });
870
- return keep;
871
995
  } catch (error) {
872
- try {
873
- this.logLine(
874
- `worktree[${label}]: integration failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
875
- );
876
- } catch {
877
- // best-effort logging
878
- }
879
- return true;
996
+ this.logLineBestEffort(
997
+ `worktree[${label}]: integration failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} — recover with: git -C ${worktree.path} diff`,
998
+ );
999
+ if (isWorkflowPolicyError(error)) throw error;
1000
+ return { status: "preserved", keepWorktree: true, recoveryPath: worktree.path };
1001
+ }
1002
+ }
1003
+
1004
+ /** Preserve edits from a runner/policy/abort failure without publishing them. */
1005
+ private async recoverFailedWorktree(worktree: Worktree, id: number, label: string): Promise<WorktreeOutcome> {
1006
+ try {
1007
+ const diff = captureWorktreeDiff(worktree, this.remainingCleanupTimeout());
1008
+ if (!hasChanges(diff)) return { status: "unchanged", keepWorktree: false };
1009
+ return this.rescueDiff(worktree, diff, id, label, "agent failed before delivery");
1010
+ } catch (error) {
1011
+ this.logLineBestEffort(
1012
+ `worktree[${label}]: recovery capture failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} — recover with: git -C ${worktree.path} diff`,
1013
+ );
1014
+ return { status: "preserved", keepWorktree: true, recoveryPath: worktree.path };
1015
+ }
1016
+ }
1017
+
1018
+ private rescueDiff(
1019
+ worktree: Worktree,
1020
+ diff: WorktreeDiff,
1021
+ id: number,
1022
+ label: string,
1023
+ reason: string,
1024
+ ): WorktreeOutcome {
1025
+ const runId = this.options.journal
1026
+ ? path.basename(this.options.journal.filePath, ".jsonl")
1027
+ : "run";
1028
+ try {
1029
+ const rescue = writeRescuePatch(this.rescueDir(), runId, id, label, diff.patch);
1030
+ this.logLineBestEffort(
1031
+ `worktree[${label}]: ${reason}; patch saved to ${rescue} — review and apply manually`,
1032
+ );
1033
+ return { status: "rescued", keepWorktree: false, recoveryPath: rescue };
1034
+ } catch (error) {
1035
+ this.logLineBestEffort(
1036
+ `worktree[${label}]: ${reason} AND rescue write failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} — recover with: git -C ${worktree.path} diff`,
1037
+ );
1038
+ return { status: "preserved", keepWorktree: true, recoveryPath: worktree.path };
1039
+ }
1040
+ }
1041
+
1042
+ private assertTargetIdentityCurrent(): void {
1043
+ const expected = this.options.targetIdentity;
1044
+ const actual = resolveRepositoryContext(this.cwd).identity;
1045
+ if (expected !== actual) {
1046
+ const error = new WorkflowPolicyError("workflow repository/cwd target changed during execution");
1047
+ this.recordPolicyError(error);
1048
+ throw error;
880
1049
  }
881
1050
  }
882
1051
 
1052
+ private remainingCleanupTimeout(): number {
1053
+ if (this.cleanupDeadline === undefined) return DEFAULT_CLEANUP_TIMEOUT_MS;
1054
+ const remaining = this.cleanupDeadline - Date.now();
1055
+ if (remaining <= 0) throw new WorkflowCleanupTimeoutError(this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS);
1056
+ return remaining;
1057
+ }
1058
+
883
1059
  /** Write rescue patches beside the journal or in a user-owned cwd-hash scope. */
884
1060
  private rescueDir(): string {
885
1061
  const journalDir = this.options.journal ? path.dirname(this.options.journal.filePath) : undefined;
@@ -895,6 +1071,14 @@ class Runtime implements ScriptExecutorHost {
895
1071
  );
896
1072
  }
897
1073
 
1074
+ private logLineBestEffort(text: string): void {
1075
+ try {
1076
+ this.logLine(text);
1077
+ } catch {
1078
+ // Durable runtime state is authoritative; display observers cannot veto it.
1079
+ }
1080
+ }
1081
+
898
1082
  private logLine(text: string): void {
899
1083
  if (this.state.logs.length > MAX_WORKFLOW_LOGS) return;
900
1084
  const safe = safeDisplayText(text, 512);
@@ -921,47 +1105,37 @@ function normalizeAgentOptions(value: unknown): AgentOptions {
921
1105
  if (typeof value !== "object") throw new TypeError("agent options must be an object");
922
1106
  const options = value as AgentOptions;
923
1107
  assertWorkflowSchemaLimit(options.schema);
1108
+ if (options.isolation !== undefined && options.isolation !== "worktree") {
1109
+ throw new TypeError("agent isolation must be 'worktree' when provided");
1110
+ }
924
1111
  return {
925
1112
  label: optionalString(options.label, "agent label"),
926
1113
  phase: optionalString(options.phase, "agent phase"),
927
1114
  schema: options.schema,
928
1115
  model: optionalString(options.model, "agent model"),
929
- isolation: options.isolation === "worktree" ? "worktree" : undefined,
930
- agentType: optionalString(options.agentType, "agent type"),
1116
+ isolation: options.isolation,
1117
+ agentType: optionalNonEmptyTrimmedString(options.agentType, "agent type"),
931
1118
  };
932
1119
  }
933
1120
 
934
- function normalizeWorkflowRef(value: unknown): string | { scriptPath: 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;
1121
+ function normalizeWorkflowRef(value: unknown): string {
1122
+ if (typeof value !== "string") {
1123
+ throw new TypeError("workflow() expects a saved workflow name string; scriptPath is not supported");
940
1124
  }
941
- if (value && typeof value === "object" && typeof (value as any).scriptPath === "string") {
942
- return { scriptPath: (value as any).scriptPath };
1125
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)) {
1126
+ throw new TypeError("workflow() name must contain only letters, numbers, underscore, or hyphen");
943
1127
  }
944
- throw new TypeError("workflow() expects a workflow name string or { scriptPath }");
1128
+ return value;
945
1129
  }
946
1130
 
947
1131
  export function loadSavedWorkflowFromDisk(
948
- ref: string | { scriptPath: string },
1132
+ ref: string,
949
1133
  cwd: string,
1134
+ projectTrusted = false,
950
1135
  ): { meta: WorkflowMeta; body: string } {
951
- if (typeof ref === "object") {
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));
954
- }
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`]) {
958
- const full = path.join(dir, candidate);
959
- if (artifactPathExists(full)) {
960
- return parseWorkflowScript(readContainedArtifactFile(root, full, "saved nested workflow", 16 * 1024 * 1024));
961
- }
962
- }
963
- }
964
- throw new Error(`workflow() could not find a saved workflow for ${JSON.stringify(ref)}`);
1136
+ const saved = readSavedWorkflowByName(ref, cwd, projectTrusted);
1137
+ if (saved) return parseWorkflowScript(saved.script);
1138
+ throw new Error(`workflow() could not find an accessible saved workflow for ${JSON.stringify(ref)}`);
965
1139
  }
966
1140
 
967
1141
  function buildInstructions(phase: string | undefined, opts: AgentOptions): string | undefined {
@@ -1094,6 +1268,24 @@ function createLimiter(limit: number, signal: AbortSignal): <T>(fn: () => Promis
1094
1268
  };
1095
1269
  }
1096
1270
 
1271
+ function repositoryApplyLock(repoRoot: string): Mutex {
1272
+ const key = fsRealpathOrResolve(repoRoot);
1273
+ let lock = REPOSITORY_APPLY_LOCKS.get(key);
1274
+ if (!lock) {
1275
+ lock = new Mutex();
1276
+ REPOSITORY_APPLY_LOCKS.set(key, lock);
1277
+ }
1278
+ return lock;
1279
+ }
1280
+
1281
+ function fsRealpathOrResolve(value: string): string {
1282
+ try {
1283
+ return path.resolve(value);
1284
+ } catch {
1285
+ return value;
1286
+ }
1287
+ }
1288
+
1097
1289
  class Mutex {
1098
1290
  private tail: Promise<unknown> = Promise.resolve();
1099
1291
  run<T>(fn: () => Promise<T>): Promise<T> {
@@ -1116,6 +1308,14 @@ function optionalString(value: unknown, name: string): string | undefined {
1116
1308
  return requireString(value, name);
1117
1309
  }
1118
1310
 
1311
+ function optionalNonEmptyTrimmedString(value: unknown, name: string): string | undefined {
1312
+ const string = optionalString(value, name);
1313
+ if (string === undefined) return undefined;
1314
+ const trimmed = string.trim();
1315
+ if (!trimmed) throw new TypeError(`${name} must not be empty`);
1316
+ return trimmed;
1317
+ }
1318
+
1119
1319
  function journalPolicyError(error: unknown, operation: string): WorkflowPolicyError {
1120
1320
  if (isWorkflowPolicyError(error)) return error;
1121
1321
  const failure = new WorkflowPolicyError(`${operation}: ${errorMessage(error)}`);