pi-workflow-engine 0.10.3 → 0.12.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.
Files changed (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -1,19 +1,41 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { bindPipeline, parallel, Semaphore } from "./concurrency.ts";
3
- import { linkAbortSignal } from "./cancellation.ts";
2
+ import type { Static, TSchema } from "typebox";
3
+ import { bindParallel, bindPipeline, Semaphore } from "./concurrency.ts";
4
+ import { WorkflowAgentLimiter } from "./agent-limits.ts";
5
+ import { defaultAgentRetryScheduler, type AgentRetryScheduler } from "./agent-retry.ts";
6
+ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
7
+ import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
4
8
  import { createBudget } from "./budget.ts";
5
- import { runAgent, type RunContext } from "./agent-runner.ts";
9
+ import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
6
10
  import { ProgressTracker } from "./progress.ts";
7
- import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
8
- import { createWorkflowUsageRecorder } from "./usage.ts";
9
- import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
10
- import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
11
+ import { createPerfRecorder, type PerfSink, type PerfSnapshot } from "./perf.ts";
12
+ import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
13
+ import {
14
+ resolveWorkflowRunOptions,
15
+ type ResolvedWorkflowRunOptions,
16
+ } from "./options.ts";
17
+ import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
11
18
  import { WorkflowInspector } from "./ui/workflow-inspector.ts";
19
+ import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
12
20
  import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
13
21
  import { WorktreeRegistry } from "./worktree.ts";
14
-
15
- /** Default global cap on concurrent agents per run. */
16
- const DEFAULT_CONCURRENCY = defaultConcurrency();
22
+ import { runFinalizers } from "./finalizers.ts";
23
+ import { unknownErrorMessage } from "./unknown-error.ts";
24
+ import {
25
+ captureWorkflowResumeContext,
26
+ type AgentResumeBaseContext,
27
+ } from "./resume-context.ts";
28
+ import {
29
+ DurableWorkflowRun,
30
+ ProjectWorkflowRunStore,
31
+ workflowRunRecordPath,
32
+ type WorkflowRunStore,
33
+ } from "./workflow-run-store.ts";
34
+ import { createWorkflowRunRecord } from "./workflow-run-record.ts";
35
+ import {
36
+ createProviderUsageLimitPauseRecord,
37
+ WorkflowProviderUsageLimitError,
38
+ } from "./provider-usage-limit.ts";
17
39
 
18
40
  /** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
19
41
  export interface WorkflowProgress {
@@ -22,6 +44,9 @@ export interface WorkflowProgress {
22
44
  event(event: WorkflowProgressEvent): void;
23
45
  }
24
46
 
47
+ /** Engine state shared by the workflow and its individual agent calls. */
48
+ export type WorkflowRunContext = RunContext;
49
+
25
50
  /** Per-context knobs threaded through `runWorkflowWithContext` (and re-derived for each sub-workflow). */
26
51
  export interface WorkflowContextOptions {
27
52
  /** Controller whose `.signal` equals `rc.signal`; aborts in-flight `parallel` siblings on failure. */
@@ -29,7 +54,7 @@ export interface WorkflowContextOptions {
29
54
  /** Eager-submission cap for `parallel`. */
30
55
  submissionLimit: number;
31
56
  /** Resolve a sub-workflow reference for `api.workflow()`. Omit to disable composition. */
32
- resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
57
+ resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
33
58
  /** Nesting depth. Sub-workflows run at depth >= 1, where `api.workflow()` rejects. */
34
59
  depth?: number;
35
60
  /** Prefix applied to phase titles so sub-workflow phases nest as "<name> ▸ <title>". */
@@ -38,59 +63,126 @@ export interface WorkflowContextOptions {
38
63
  progressNamespace?: string;
39
64
  }
40
65
 
66
+ type Outcome<T> =
67
+ | { readonly ok: true; readonly value: T }
68
+ | { readonly ok: false; readonly error: unknown };
69
+
70
+ export interface WorkflowEngineDependencies {
71
+ readonly worktrees?: WorktreeRegistry;
72
+ readonly retryScheduler?: AgentRetryScheduler;
73
+ readonly modelProfiles?: ResolvedWorkflowModelProfiles;
74
+ }
75
+
41
76
  /**
42
77
  * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
43
78
  * to a shared semaphore + progress tracker), invoke the workflow, return its result.
44
79
  */
45
80
  export async function runWorkflow(
46
81
  ctx: ExtensionContext,
47
- mod: WorkflowModule,
82
+ mod: LoadedWorkflow,
48
83
  args: string,
49
84
  options: WorkflowRunOptions = {},
85
+ dependencies: WorkflowEngineDependencies = {},
50
86
  ): Promise<unknown> {
51
- const resolvedOptions = resolveWorkflowRunOptions(options);
52
- const progress = new ProgressTracker(ctx, mod.meta.name);
53
- const progressSource = { snapshot: () => progress.snapshot() };
54
- resolvedOptions.onProgressSource?.(progressSource);
55
- if (resolvedOptions.inspect && ctx.hasUI) {
56
- void ctx.ui
57
- .custom<void>(
58
- (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
59
- { overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
60
- )
61
- .catch((error: unknown) => progress.log(`inspector failed: ${error instanceof Error ? error.message : String(error)}`));
62
- }
87
+ return await runResolvedWorkflow(ctx, mod, args, resolveWorkflowRunOptions(options), dependencies);
88
+ }
63
89
 
64
- const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
65
- const usage = createWorkflowUsageRecorder();
66
- const budget = createBudget(resolvedOptions.budget ?? null, usage);
90
+ /** Execute an options-normalized workflow without re-reading environment defaults. */
91
+ export async function runResolvedWorkflow(
92
+ ctx: ExtensionContext,
93
+ mod: LoadedWorkflow,
94
+ args: string,
95
+ resolvedOptions: ResolvedWorkflowRunOptions,
96
+ dependencies: WorkflowEngineDependencies = {},
97
+ ): Promise<unknown> {
67
98
  const runId = resolvedOptions.runId ?? createWorkflowRunId();
99
+ let durableProgress: DurableWorkflowRun | undefined;
100
+ const progress = new ProgressTracker(ctx, mod.meta.name, runId, (snapshot) => durableProgress?.updateProgress(snapshot));
101
+ const progressSource = { snapshot: () => progress.snapshot() };
102
+ const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
103
+ const usage = createWorkflowUsageRecorder((snapshot) => progress.updateUsage(snapshot));
104
+ const budget = createBudget(resolvedOptions.budget, usage);
68
105
  const journalPath = workflowJournalPath(ctx.cwd, runId);
69
106
  const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
70
- const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
71
- const worktrees = new WorktreeRegistry(ctx.cwd);
72
- resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath });
73
- progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
107
+ const runStore = new ProjectWorkflowRunStore(ctx.cwd);
108
+ let persistenceFailureReported = false;
109
+ const durableRun = new DurableWorkflowRun(
110
+ runStore,
111
+ createWorkflowRunRecord({
112
+ runId,
113
+ workflow: mod,
114
+ options: resolvedOptions,
115
+ progress: progress.snapshot(),
116
+ argumentsPresent: args.length > 0,
117
+ }),
118
+ (error) => {
119
+ if (persistenceFailureReported) return;
120
+ persistenceFailureReported = true;
121
+ process.stderr.write(`[${mod.meta.name}] run persistence failed: ${unknownErrorMessage(error)}\n`);
122
+ },
123
+ );
124
+ durableProgress = durableRun;
125
+ const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
74
126
  const runAbortController = new AbortController();
75
127
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
76
128
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
77
- const rc: RunContext = {
78
- cwd: ctx.cwd,
79
- hostModel: ctx.model,
80
- modelRegistry: ctx.modelRegistry,
81
- semaphore: new Semaphore(resolvedOptions.concurrency ?? DEFAULT_CONCURRENCY),
82
- progress,
83
- signal: runAbortController.signal,
84
- perf,
85
- usage,
86
- budget,
87
- journal,
88
- worktrees,
89
- };
129
+ const workflowOutcome = await captureOutcome(async () => {
130
+ await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
131
+ if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
132
+ void ctx.ui
133
+ .custom<void>(
134
+ (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
135
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
136
+ )
137
+ .catch((error: unknown) => {
138
+ try {
139
+ progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
140
+ } catch {
141
+ // Inspector reporting is detached and must never become another rejection.
142
+ }
143
+ });
144
+ }
145
+
146
+ const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
147
+ durableRun.transition({ state: "running", progress: progress.snapshot() });
148
+ await durableRun.flush().catch(() => undefined);
149
+ await notifyLifecycleObserver(progress, "run metadata callback", () =>
150
+ resolvedOptions.onRunMetadata?.({
151
+ runId,
152
+ resumedFromRunId: resolvedOptions.resumeFromRunId,
153
+ journalPath,
154
+ recordPath: workflowRunRecordPath(ctx.cwd, runId),
155
+ }),
156
+ );
157
+ progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
158
+ const modelProfiles = dependencies.modelProfiles ?? resolveWorkflowModelProfiles({
159
+ cwd: ctx.cwd,
160
+ modelRegistry: ctx.modelRegistry,
161
+ hostModel: ctx.model,
162
+ });
163
+ const rc: WorkflowRunContext = {
164
+ cwd: ctx.cwd,
165
+ hostModel: ctx.model,
166
+ modelRegistry: ctx.modelRegistry,
167
+ semaphore: new Semaphore(resolvedOptions.concurrency),
168
+ agentLimiter: new WorkflowAgentLimiter(resolvedOptions.maxAgents),
169
+ agentTimeoutMs: resolvedOptions.agentTimeoutMs,
170
+ agentRetries: resolvedOptions.agentRetries,
171
+ pauseOnProviderUsageLimit: resolvedOptions.background !== undefined,
172
+ resumeEditedWorkflow: resolvedOptions.resumeEditedWorkflow,
173
+ retryScheduler: dependencies.retryScheduler ?? defaultAgentRetryScheduler,
174
+ modelProfiles,
175
+ progress,
176
+ signal: runAbortController.signal,
177
+ perf,
178
+ usage,
179
+ budget,
180
+ journal,
181
+ worktrees,
182
+ };
90
183
 
91
- try {
92
184
  // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
93
- return await perf.time("workflow.total_ms", () =>
185
+ return perf.time("workflow.total_ms", () =>
94
186
  runWorkflowWithContext(rc, progress, mod, args, {
95
187
  abortController: runAbortController,
96
188
  submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
@@ -99,29 +191,198 @@ export async function runWorkflow(
99
191
  progressPrefix: "",
100
192
  }),
101
193
  );
102
- } finally {
103
- try {
104
- resolvedOptions.onUsageSnapshot?.(usage.snapshot());
105
- const snapshot = perf.snapshot();
106
- if (resolvedOptions.perf) {
107
- resolvedOptions.onPerfSnapshot?.(snapshot);
108
- progress.log(formatPerfSummary(snapshot));
109
- }
110
- progress.done();
111
- resolvedOptions.onProgressSnapshot?.(progress.snapshot());
112
- } finally {
113
- resolvedOptions.onProgressSource?.(undefined);
114
- unlinkContextAbortSignal();
115
- unlinkOptionAbortSignal();
116
- const cleanupResults = await worktrees.removeAll();
117
- for (const result of cleanupResults) {
118
- if (!result.ok) {
119
- progress.log(`failed to remove isolated worktree ${result.path} (${result.error ?? (result.stderr.trim() || "unknown error")})`);
194
+ });
195
+
196
+ const finalizationOutcome = await captureOutcome(() =>
197
+ finalizeWorkflowRun({
198
+ cwd: ctx.cwd,
199
+ options: resolvedOptions,
200
+ perf,
201
+ usage,
202
+ progress,
203
+ runStore,
204
+ worktrees,
205
+ unlinkSignals: [unlinkContextAbortSignal, unlinkOptionAbortSignal],
206
+ }),
207
+ );
208
+
209
+ if (workflowOutcome.ok && finalizationOutcome.ok) {
210
+ durableRun.transition({
211
+ state: "completed",
212
+ progress: progress.snapshot(),
213
+ usage: usage.snapshot(),
214
+ result: workflowOutcome.value,
215
+ });
216
+ } else if (!workflowOutcome.ok) {
217
+ const error = finalizationOutcome.ok
218
+ ? workflowOutcome.error
219
+ : combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
220
+ persistTerminalWorkflowError(error);
221
+ } else if (!finalizationOutcome.ok) {
222
+ persistTerminalWorkflowError(finalizationOutcome.error);
223
+ }
224
+ await durableRun.flush().catch(() => undefined);
225
+
226
+ function persistTerminalWorkflowError(error: unknown): void {
227
+ const pauseError = backgroundPauseError(error, ctx.signal, resolvedOptions.signal);
228
+ if (pauseError) {
229
+ if (pauseError instanceof WorkflowProviderUsageLimitError) {
230
+ if (resolvedOptions.background === undefined) {
231
+ durableRun.transition({
232
+ state: "failed",
233
+ progress: progress.snapshot(),
234
+ usage: usage.snapshot(),
235
+ error: pauseError,
236
+ });
237
+ return;
120
238
  }
239
+ const providerPause = createProviderUsageLimitPauseRecord(
240
+ pauseError,
241
+ resolvedOptions,
242
+ mod.source.kind === "file" && args.length === 0,
243
+ );
244
+ durableRun.transition({
245
+ state: "paused",
246
+ progress: progress.snapshot(),
247
+ ...providerPause,
248
+ });
249
+ return;
121
250
  }
122
- await pruneWorkflowJournals(ctx.cwd);
251
+ durableRun.transition({
252
+ state: "paused",
253
+ progress: progress.snapshot(),
254
+ message: unknownErrorMessage(pauseError),
255
+ });
256
+ return;
123
257
  }
258
+ durableRun.transition({
259
+ state: ctx.signal?.aborted || resolvedOptions.signal?.aborted ? "stopped" : "failed",
260
+ progress: progress.snapshot(),
261
+ usage: usage.snapshot(),
262
+ error,
263
+ });
124
264
  }
265
+
266
+ if (workflowOutcome.ok) {
267
+ if (finalizationOutcome.ok) return workflowOutcome.value;
268
+ throw finalizationOutcome.error;
269
+ }
270
+ if (finalizationOutcome.ok) throw workflowOutcome.error;
271
+ throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
272
+ }
273
+
274
+ function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
275
+ if (isWorkflowPauseError(error)) return error;
276
+ for (const signal of signals) {
277
+ if (!signal?.aborted) continue;
278
+ const reason = abortReason(signal);
279
+ if (isWorkflowPauseError(reason)) return reason;
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ async function captureOutcome<T>(operation: () => Promise<T>): Promise<Outcome<T>> {
285
+ try {
286
+ return { ok: true, value: await operation() };
287
+ } catch (error) {
288
+ return { ok: false, error };
289
+ }
290
+ }
291
+
292
+ interface WorkflowFinalizationInput {
293
+ readonly cwd: string;
294
+ readonly options: ResolvedWorkflowRunOptions;
295
+ readonly perf: PerfSink;
296
+ readonly usage: WorkflowUsageSink;
297
+ readonly progress: ProgressTracker;
298
+ readonly runStore: WorkflowRunStore;
299
+ readonly worktrees: WorktreeRegistry;
300
+ readonly unlinkSignals: readonly (() => void)[];
301
+ }
302
+
303
+ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<void> {
304
+ await runFinalizers(
305
+ [
306
+ {
307
+ name: "usage snapshot callback",
308
+ criticality: "best-effort",
309
+ run: () => input.options.onUsageSnapshot?.(input.usage.snapshot()),
310
+ },
311
+ {
312
+ name: "performance snapshot callback",
313
+ criticality: "best-effort",
314
+ run: async () => {
315
+ if (!input.options.perf) return;
316
+ const snapshot = input.perf.snapshot();
317
+ await input.options.onPerfSnapshot?.(snapshot);
318
+ input.progress.log(formatPerfSummary(snapshot));
319
+ },
320
+ },
321
+ ...input.unlinkSignals.map((run, index) => ({
322
+ name: `abort signal unlink ${index + 1}`,
323
+ criticality: "required" as const,
324
+ run,
325
+ })),
326
+ {
327
+ name: "worktree cleanup",
328
+ criticality: "required",
329
+ run: () => input.worktrees.removeAll(),
330
+ },
331
+ { name: "journal pruning", criticality: "best-effort", run: () => pruneWorkflowJournals(input.cwd) },
332
+ { name: "run record pruning", criticality: "best-effort", run: () => input.runStore.prune() },
333
+ { name: "progress completion", criticality: "best-effort", run: () => input.progress.done() },
334
+ {
335
+ name: "progress snapshot callback",
336
+ criticality: "best-effort",
337
+ run: () => input.options.onProgressSnapshot?.(input.progress.snapshot()),
338
+ },
339
+ {
340
+ name: "progress source clear",
341
+ criticality: "best-effort",
342
+ run: () => input.options.onProgressSource?.(undefined),
343
+ },
344
+ ],
345
+ {
346
+ onBestEffortFailure: (failure) => {
347
+ try {
348
+ input.progress.log(`${failure.name} failed: ${unknownErrorMessage(failure.error)}`);
349
+ } catch {
350
+ // Observer failures must never replace the workflow outcome.
351
+ }
352
+ },
353
+ },
354
+ );
355
+ }
356
+
357
+ function combinedWorkflowError(workflowError: unknown, finalizationError: unknown): AggregateError {
358
+ return new AggregateError(
359
+ [workflowError, finalizationError],
360
+ `Workflow failed: ${unknownErrorMessage(workflowError)}; finalization also failed: ${unknownErrorMessage(finalizationError)}`,
361
+ );
362
+ }
363
+
364
+ function createWorkflowAgent(
365
+ rc: WorkflowRunContext,
366
+ scope: WorkflowScope,
367
+ mod: LoadedWorkflow,
368
+ resumeContext: AgentResumeBaseContext,
369
+ ): WorkflowApi["agent"] {
370
+ function agent<S extends TSchema>(
371
+ prompt: string,
372
+ opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
373
+ ): Promise<IsolatedAgentResult<Static<S>>>;
374
+ function agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
375
+ function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
376
+ function agent(prompt: string, opts?: AgentOptions): Promise<string>;
377
+ function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
378
+ const scopedOptions = scope.agentOptions(agentOpts);
379
+ const executionOptions: AgentExecutionOptions =
380
+ scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
381
+ ? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
382
+ : scopedOptions;
383
+ return runAgent(rc, prompt, executionOptions, resumeContext);
384
+ }
385
+ return agent;
125
386
  }
126
387
 
127
388
  /**
@@ -130,16 +391,19 @@ export async function runWorkflow(
130
391
  * abort signal, and perf sink. No setup/teardown of its own — that belongs to `runWorkflow`.
131
392
  */
132
393
  export async function runWorkflowWithContext(
133
- rc: RunContext,
394
+ rc: WorkflowRunContext,
134
395
  progress: WorkflowProgress,
135
- mod: WorkflowModule,
396
+ mod: LoadedWorkflow,
136
397
  args: string,
137
398
  opts: WorkflowContextOptions,
138
399
  ): Promise<unknown> {
139
400
  const depth = opts.depth ?? 0;
140
401
  const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
402
+ const resumeContext: AgentResumeBaseContext = {
403
+ workflow: await captureWorkflowResumeContext(mod, rc.signal),
404
+ };
141
405
 
142
- const agent = ((prompt: string, agentOpts?: AgentOptions) => runAgent(rc, prompt, scope.agentOptions(agentOpts))) as WorkflowApi["agent"];
406
+ const agent = createWorkflowAgent(rc, scope, mod, resumeContext);
143
407
 
144
408
  const workflow: WorkflowApi["workflow"] =
145
409
  depth >= 1
@@ -171,12 +435,11 @@ export async function runWorkflowWithContext(
171
435
  const api: WorkflowApi = {
172
436
  agent,
173
437
  workflow,
174
- parallel: (thunks) =>
175
- parallel(thunks, {
176
- signal: rc.signal,
177
- abortController: opts.abortController,
178
- limit: opts.submissionLimit,
179
- }),
438
+ parallel: bindParallel({
439
+ signal: rc.signal,
440
+ abortController: opts.abortController,
441
+ limit: opts.submissionLimit,
442
+ }),
180
443
  pipeline: bindPipeline({ signal: rc.signal, abortController: opts.abortController }),
181
444
  phase: scope.phase,
182
445
  log: scope.log,
@@ -187,7 +450,10 @@ export async function runWorkflowWithContext(
187
450
  signal: rc.signal,
188
451
  };
189
452
 
190
- return await mod.default(api);
453
+ throwIfAborted(rc.signal);
454
+ const result = await mod.default(api);
455
+ throwIfAborted(rc.signal);
456
+ return result;
191
457
  }
192
458
 
193
459
  interface WorkflowScope {
@@ -242,3 +508,19 @@ function formatPerfSummary(snapshot: PerfSnapshot): string {
242
508
  .map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
243
509
  return parts.length > 0 ? `perf: ${parts.join(", ")}` : "perf: no samples";
244
510
  }
511
+
512
+ async function notifyLifecycleObserver(
513
+ progress: ProgressTracker,
514
+ name: string,
515
+ notify: () => void | Promise<void>,
516
+ ): Promise<void> {
517
+ try {
518
+ await notify();
519
+ } catch (error) {
520
+ try {
521
+ progress.log(`${name} failed: ${unknownErrorMessage(error)}`);
522
+ } catch {
523
+ // Lifecycle observers are advisory and must not replace the workflow outcome.
524
+ }
525
+ }
526
+ }
@@ -0,0 +1,4 @@
1
+ /** Identify a missing filesystem path reported by Node's filesystem APIs. */
2
+ export function isMissingPathError(error: unknown): boolean {
3
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
4
+ }
@@ -0,0 +1,66 @@
1
+ import { unknownErrorMessage } from "./unknown-error.ts";
2
+
3
+ export type FinalizerCriticality = "required" | "best-effort";
4
+
5
+ export interface Finalizer {
6
+ readonly name: string;
7
+ readonly criticality: FinalizerCriticality;
8
+ readonly run: () => void | Promise<void>;
9
+ }
10
+
11
+ export interface FinalizerFailure {
12
+ readonly name: string;
13
+ readonly criticality: FinalizerCriticality;
14
+ readonly error: unknown;
15
+ }
16
+
17
+ export interface RunFinalizersOptions {
18
+ readonly onBestEffortFailure?: (failure: FinalizerFailure) => void;
19
+ }
20
+
21
+ export class RequiredFinalizerError extends AggregateError {
22
+ readonly failures: readonly FinalizerFailure[];
23
+
24
+ constructor(failures: readonly FinalizerFailure[]) {
25
+ super(
26
+ failures.map((failure) => failure.error),
27
+ `Required workflow finalization failed: ${failures.map((failure) => `${failure.name} (${unknownErrorMessage(failure.error)})`).join(", ")}`,
28
+ );
29
+ this.name = "RequiredFinalizerError";
30
+ this.failures = failures;
31
+ }
32
+ }
33
+
34
+ /** Execute every finalizer once, reporting advisory failures and aggregating only required failures. */
35
+ export async function runFinalizers(
36
+ finalizers: readonly Finalizer[],
37
+ options: RunFinalizersOptions = {},
38
+ ): Promise<readonly FinalizerFailure[]> {
39
+ const requiredFailures: FinalizerFailure[] = [];
40
+ const bestEffortFailures: FinalizerFailure[] = [];
41
+
42
+ for (const finalizer of finalizers) {
43
+ try {
44
+ await finalizer.run();
45
+ } catch (error) {
46
+ const failure: FinalizerFailure = {
47
+ name: finalizer.name,
48
+ criticality: finalizer.criticality,
49
+ error,
50
+ };
51
+ if (finalizer.criticality === "required") {
52
+ requiredFailures.push(failure);
53
+ continue;
54
+ }
55
+ bestEffortFailures.push(failure);
56
+ try {
57
+ options.onBestEffortFailure?.(failure);
58
+ } catch {
59
+ // Failure observers are advisory and must not change finalization semantics.
60
+ }
61
+ }
62
+ }
63
+
64
+ if (requiredFailures.length > 0) throw new RequiredFinalizerError(requiredFailures);
65
+ return bestEffortFailures;
66
+ }