pi-workflow-engine 0.11.0 → 0.13.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 (79) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  8. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  10. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  17. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  18. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  19. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  20. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
  21. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  22. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  31. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  32. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  33. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  34. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  35. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  36. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  37. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
  38. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  48. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  49. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  50. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  51. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  52. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  53. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  54. package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
  55. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  56. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  57. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  58. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
  59. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  61. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  64. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  65. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  66. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  67. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  68. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  70. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  71. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
  72. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
  73. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
  74. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
  75. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
  76. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  77. package/README.md +72 -141
  78. package/USAGE.md +270 -17
  79. package/package.json +1 -1
@@ -1,7 +1,17 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { KeyId } from "@earendil-works/pi-tui";
2
+ import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { EditorComponent, KeyId } from "@earendil-works/pi-tui";
4
+ import { completeCurrentArgument } from "./command-completions.ts";
4
5
  import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
6
+ import { sessionKey } from "./session-identity.ts";
7
+ import { unknownErrorMessage } from "./unknown-error.ts";
8
+ import {
9
+ decorateDynamaxEditor,
10
+ resolveDynamaxEffect,
11
+ type DynamaxAnimationScheduler,
12
+ type DynamaxEditorDecoration,
13
+ type DynamaxEffect,
14
+ } from "./ui/dynamax-editor-decoration.ts";
5
15
 
6
16
  export interface DynamaxState {
7
17
  sticky: boolean;
@@ -15,13 +25,34 @@ export interface DynamaxRuntime {
15
25
  }
16
26
 
17
27
  export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
28
+
18
29
  export interface DynamaxRegistrationOptions {
19
30
  openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
31
+ effect?: DynamaxEffect;
32
+ animationScheduler?: DynamaxAnimationScheduler;
20
33
  }
21
34
 
22
35
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
23
36
  export const DYNAMAX_STATUS_KEY = "dynamax";
24
- export const DYNAMAX_WIDGET_KEY = "workflow-dynamax";
37
+
38
+ type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
39
+
40
+ interface DynamaxEditorInstallation {
41
+ factory: EditorFactory;
42
+ previousFactory: EditorFactory | undefined;
43
+ decorations: Set<DynamaxEditorDecoration>;
44
+ }
45
+
46
+ function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): void {
47
+ for (const decoration of decorations) decoration.dispose();
48
+ decorations.clear();
49
+ }
50
+
51
+ const DYNAMAX_ACTION_COMPLETIONS = [
52
+ { value: "on", description: "Keep Dynamax enabled for this session" },
53
+ { value: "off", description: "Disable sticky and pending Dynamax modes" },
54
+ { value: "status", description: "Show the current Dynamax state" },
55
+ ] as const;
25
56
 
26
57
  export const ADAPTIVE_WORKFLOW_GUIDANCE = `
27
58
  Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out when it is sufficient. When the first pass may expose gaps, conflicts, weak claims, or missing evidence:
@@ -40,7 +71,7 @@ The user has opted into dynamax multi-agent orchestration. The workflow tool is
40
71
  Inline workflow rules:
41
72
  - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
42
73
  - Do not use import statements or dynamic import() in inline workflow scripts.
43
- - Set thinkingLevel explicitly on each agent() call so fan-out remains bounded.
74
+ - Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override.
44
75
  - Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set.
45
76
  - Provide exactly one of workflow.name or workflow.script.
46
77
 
@@ -57,12 +88,8 @@ export function createDynamaxRuntime(): DynamaxRuntime {
57
88
  return { state: createDynamaxState() };
58
89
  }
59
90
 
60
- export function dynamaxSessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
61
- return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
62
- }
63
-
64
91
  export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
65
- const key = dynamaxSessionKey(ctx);
92
+ const key = sessionKey(ctx);
66
93
  const existing = store.get(key);
67
94
  if (existing) return existing;
68
95
  const runtime = createDynamaxRuntime();
@@ -78,12 +105,8 @@ export function markDynamaxOneShot(state: DynamaxState): void {
78
105
  state.oneShotPending = true;
79
106
  }
80
107
 
81
- export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
82
- state.sticky = sticky;
83
- if (!sticky) {
84
- state.oneShotPending = false;
85
- state.turnActive = false;
86
- }
108
+ export function enableDynamaxSticky(state: DynamaxState): void {
109
+ state.sticky = true;
87
110
  }
88
111
 
89
112
  export function clearDynamax(state: DynamaxState): void {
@@ -92,12 +115,6 @@ export function clearDynamax(state: DynamaxState): void {
92
115
  state.turnActive = false;
93
116
  }
94
117
 
95
- export function consumeDynamaxOneShot(state: DynamaxState): boolean {
96
- const pending = state.oneShotPending;
97
- state.oneShotPending = false;
98
- return pending;
99
- }
100
-
101
118
  export function isDynamaxActive(state: DynamaxState): boolean {
102
119
  return state.sticky || state.oneShotPending || state.turnActive;
103
120
  }
@@ -139,23 +156,84 @@ export function registerDynamax(
139
156
  options: DynamaxRegistrationOptions = {},
140
157
  ): void {
141
158
  const runtimes: DynamaxRuntimeStore = new Map();
159
+ const effect = options.effect ?? resolveDynamaxEffect();
160
+ let editorInstallation: DynamaxEditorInstallation | undefined;
142
161
  const openInspector =
143
162
  options.openInspector ??
144
163
  ((ctx: ExtensionContext): void => {
145
164
  ctx.ui.notify("No workflow inspector is available yet", "warning");
146
165
  });
147
- const refreshIfVisible = (ctx: ExtensionContext): void => {
148
- const runtime = getDynamaxRuntime(runtimes, ctx);
149
- if (isDynamaxActive(runtime.state) || runtime.runningWorkflow) {
150
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
166
+ const installEditor = (ctx: ExtensionContext): void => {
167
+ if (ctx.mode !== "tui" || effect === "off") return;
168
+ const previousFactory = ctx.ui.getEditorComponent();
169
+ if (editorInstallation && editorInstallation.factory === previousFactory) return;
170
+
171
+ const decorations = new Set<DynamaxEditorDecoration>();
172
+ const installation: DynamaxEditorInstallation = {
173
+ previousFactory,
174
+ decorations,
175
+ factory: (tui, theme, keybindings) => {
176
+ const decorate = (editor: EditorComponent): EditorComponent => {
177
+ const decoration = decorateDynamaxEditor(editor, () => tui.requestRender(), {
178
+ effect,
179
+ scheduler: options.animationScheduler,
180
+ isActive: () => editorInstallation === installation && ctx.ui.getEditorComponent() === installation.factory,
181
+ });
182
+ decorations.add(decoration);
183
+ return decoration.editor;
184
+ };
185
+ if (!previousFactory) return decorate(new CustomEditor(tui, theme, keybindings));
186
+
187
+ let editor: EditorComponent;
188
+ try {
189
+ editor = previousFactory(tui, theme, keybindings);
190
+ } catch (error) {
191
+ ctx.ui.notify(
192
+ `Dynamax could not compose the existing custom editor (${unknownErrorMessage(error)}); using pi's stock-compatible CustomEditor so highlighting stays enabled`,
193
+ "warning",
194
+ );
195
+ installation.previousFactory = undefined;
196
+ return decorate(new CustomEditor(tui, theme, keybindings));
197
+ }
198
+
199
+ try {
200
+ return decorate(editor);
201
+ } catch (error) {
202
+ ctx.ui.notify(
203
+ `Dynamax could not decorate the existing custom editor (${unknownErrorMessage(error)}); using pi's stock-compatible CustomEditor so highlighting stays enabled`,
204
+ "warning",
205
+ );
206
+ return decorate(new CustomEditor(tui, theme, keybindings));
207
+ }
208
+ },
209
+ };
210
+
211
+ try {
212
+ ctx.ui.setEditorComponent(installation.factory);
213
+ } catch (error) {
214
+ disposeDynamaxDecorations(decorations);
215
+ ctx.ui.notify(`Dynamax highlighting could not be installed: ${unknownErrorMessage(error)}`, "error");
216
+ throw error;
217
+ }
218
+ editorInstallation = installation;
219
+ };
220
+ const uninstallEditor = (ctx: ExtensionContext): void => {
221
+ const installation = editorInstallation;
222
+ if (!installation) return;
223
+ editorInstallation = undefined;
224
+ disposeDynamaxDecorations(installation.decorations);
225
+ if (ctx.mode === "tui" && ctx.ui.getEditorComponent() === installation.factory) {
226
+ ctx.ui.setEditorComponent(installation.previousFactory);
151
227
  }
152
228
  };
153
229
 
154
230
  pi.on("session_start", (_event, ctx) => {
231
+ installEditor(ctx);
155
232
  updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
156
233
  });
157
234
 
158
235
  pi.on("session_shutdown", (_event, ctx) => {
236
+ uninstallEditor(ctx);
159
237
  clearDynamaxSurfaces(ctx);
160
238
  });
161
239
 
@@ -177,26 +255,6 @@ export function registerDynamax(
177
255
  return { systemPrompt };
178
256
  });
179
257
 
180
- pi.on("agent_start", (_event, ctx) => {
181
- refreshIfVisible(ctx);
182
- });
183
-
184
- pi.on("message_start", (_event, ctx) => {
185
- refreshIfVisible(ctx);
186
- });
187
-
188
- pi.on("message_update", (_event, ctx) => {
189
- refreshIfVisible(ctx);
190
- });
191
-
192
- pi.on("message_end", (_event, ctx) => {
193
- refreshIfVisible(ctx);
194
- });
195
-
196
- pi.on("turn_end", (_event, ctx) => {
197
- refreshIfVisible(ctx);
198
- });
199
-
200
258
  pi.on("agent_end", (_event, ctx) => {
201
259
  const runtime = getDynamaxRuntime(runtimes, ctx);
202
260
  runtime.runningWorkflow = undefined;
@@ -205,23 +263,17 @@ export function registerDynamax(
205
263
  });
206
264
 
207
265
  pi.on("tool_execution_start", (event, ctx) => {
266
+ if (event.toolName !== "workflow") return undefined;
208
267
  const runtime = getDynamaxRuntime(runtimes, ctx);
209
- if (event.toolName === "workflow") {
210
- runtime.runningWorkflow = workflowLabel(event.args);
211
- }
268
+ runtime.runningWorkflow = workflowLabel(event.args);
212
269
  updateDynamaxSurfaces(ctx, runtime, shortcuts);
213
270
  return undefined;
214
271
  });
215
272
 
216
- pi.on("tool_execution_update", (_event, ctx) => {
217
- refreshIfVisible(ctx);
218
- });
219
-
220
273
  pi.on("tool_execution_end", (event, ctx) => {
274
+ if (event.toolName !== "workflow") return undefined;
221
275
  const runtime = getDynamaxRuntime(runtimes, ctx);
222
- if (event.toolName === "workflow") {
223
- runtime.runningWorkflow = undefined;
224
- }
276
+ runtime.runningWorkflow = undefined;
225
277
  updateDynamaxSurfaces(ctx, runtime, shortcuts);
226
278
  return undefined;
227
279
  });
@@ -244,6 +296,7 @@ export function registerDynamax(
244
296
 
245
297
  pi.registerCommand("workflow:dynamax", {
246
298
  description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
299
+ getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
247
300
  handler: async (args, ctx) => {
248
301
  const runtime = getDynamaxRuntime(runtimes, ctx);
249
302
  const action = args.trim().toLowerCase();
@@ -252,7 +305,7 @@ export function registerDynamax(
252
305
  return;
253
306
  }
254
307
  if (action === "on") {
255
- setDynamaxSticky(runtime.state, true);
308
+ enableDynamaxSticky(runtime.state);
256
309
  updateDynamaxSurfaces(ctx, runtime, shortcuts);
257
310
  ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
258
311
  return;
@@ -274,8 +327,6 @@ export function registerDynamax(
274
327
 
275
328
  export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
276
329
  if (!ctx.hasUI) return;
277
- ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
278
- ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
279
330
  if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
280
331
  clearDynamaxSurfaces(ctx);
281
332
  return;
@@ -287,9 +338,7 @@ export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui"
287
338
 
288
339
  export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">): void {
289
340
  if (!ctx.hasUI) return;
290
- ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
291
341
  ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
292
- ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
293
342
  }
294
343
 
295
344
  function workflowLabel(args: unknown): string {
@@ -1,14 +1,22 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Static, TSchema } from "typebox";
2
3
  import { bindParallel, bindPipeline, Semaphore } from "./concurrency.ts";
3
- import { linkAbortSignal, throwIfAborted } from "./cancellation.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
9
  import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
6
10
  import { ProgressTracker } from "./progress.ts";
7
11
  import { createPerfRecorder, type PerfSink, type PerfSnapshot } from "./perf.ts";
8
12
  import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
9
- import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
10
- import type { AgentOptions, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.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
22
  import { runFinalizers } from "./finalizers.ts";
@@ -17,6 +25,17 @@ import {
17
25
  captureWorkflowResumeContext,
18
26
  type AgentResumeBaseContext,
19
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";
20
39
 
21
40
  /** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
22
41
  export interface WorkflowProgress {
@@ -50,6 +69,8 @@ type Outcome<T> =
50
69
 
51
70
  export interface WorkflowEngineDependencies {
52
71
  readonly worktrees?: WorktreeRegistry;
72
+ readonly retryScheduler?: AgentRetryScheduler;
73
+ readonly modelProfiles?: ResolvedWorkflowModelProfiles;
53
74
  }
54
75
 
55
76
  /**
@@ -74,25 +95,44 @@ export async function runResolvedWorkflow(
74
95
  resolvedOptions: ResolvedWorkflowRunOptions,
75
96
  dependencies: WorkflowEngineDependencies = {},
76
97
  ): Promise<unknown> {
77
- const progress = new ProgressTracker(ctx, mod.meta.name);
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));
78
101
  const progressSource = { snapshot: () => progress.snapshot() };
79
102
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
80
- const usage = createWorkflowUsageRecorder();
103
+ const usage = createWorkflowUsageRecorder((snapshot) => progress.updateUsage(snapshot));
81
104
  const budget = createBudget(resolvedOptions.budget, usage);
82
- const runId = resolvedOptions.runId ?? createWorkflowRunId();
83
105
  const journalPath = workflowJournalPath(ctx.cwd, runId);
84
106
  const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
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;
85
125
  const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
86
126
  const runAbortController = new AbortController();
87
127
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
88
128
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
89
129
  const workflowOutcome = await captureOutcome(async () => {
90
130
  await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
91
- if (resolvedOptions.inspect && ctx.hasUI) {
131
+ if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
92
132
  void ctx.ui
93
133
  .custom<void>(
94
134
  (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
95
- { overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
135
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
96
136
  )
97
137
  .catch((error: unknown) => {
98
138
  try {
@@ -104,15 +144,34 @@ export async function runResolvedWorkflow(
104
144
  }
105
145
 
106
146
  const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
147
+ durableRun.transition({ state: "running", progress: progress.snapshot() });
148
+ await durableRun.flush().catch(() => undefined);
107
149
  await notifyLifecycleObserver(progress, "run metadata callback", () =>
108
- resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath }),
150
+ resolvedOptions.onRunMetadata?.({
151
+ runId,
152
+ resumedFromRunId: resolvedOptions.resumeFromRunId,
153
+ journalPath,
154
+ recordPath: workflowRunRecordPath(ctx.cwd, runId),
155
+ }),
109
156
  );
110
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
+ });
111
163
  const rc: WorkflowRunContext = {
112
164
  cwd: ctx.cwd,
113
165
  hostModel: ctx.model,
114
166
  modelRegistry: ctx.modelRegistry,
115
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,
116
175
  progress,
117
176
  signal: runAbortController.signal,
118
177
  perf,
@@ -141,11 +200,69 @@ export async function runResolvedWorkflow(
141
200
  perf,
142
201
  usage,
143
202
  progress,
203
+ runStore,
144
204
  worktrees,
145
205
  unlinkSignals: [unlinkContextAbortSignal, unlinkOptionAbortSignal],
146
206
  }),
147
207
  );
148
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;
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;
250
+ }
251
+ durableRun.transition({
252
+ state: "paused",
253
+ progress: progress.snapshot(),
254
+ message: unknownErrorMessage(pauseError),
255
+ });
256
+ return;
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
+ });
264
+ }
265
+
149
266
  if (workflowOutcome.ok) {
150
267
  if (finalizationOutcome.ok) return workflowOutcome.value;
151
268
  throw finalizationOutcome.error;
@@ -154,6 +271,16 @@ export async function runResolvedWorkflow(
154
271
  throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
155
272
  }
156
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
+
157
284
  async function captureOutcome<T>(operation: () => Promise<T>): Promise<Outcome<T>> {
158
285
  try {
159
286
  return { ok: true, value: await operation() };
@@ -168,6 +295,7 @@ interface WorkflowFinalizationInput {
168
295
  readonly perf: PerfSink;
169
296
  readonly usage: WorkflowUsageSink;
170
297
  readonly progress: ProgressTracker;
298
+ readonly runStore: WorkflowRunStore;
171
299
  readonly worktrees: WorktreeRegistry;
172
300
  readonly unlinkSignals: readonly (() => void)[];
173
301
  }
@@ -201,6 +329,7 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
201
329
  run: () => input.worktrees.removeAll(),
202
330
  },
203
331
  { name: "journal pruning", criticality: "best-effort", run: () => pruneWorkflowJournals(input.cwd) },
332
+ { name: "run record pruning", criticality: "best-effort", run: () => input.runStore.prune() },
204
333
  { name: "progress completion", criticality: "best-effort", run: () => input.progress.done() },
205
334
  {
206
335
  name: "progress snapshot callback",
@@ -232,6 +361,30 @@ function combinedWorkflowError(workflowError: unknown, finalizationError: unknow
232
361
  );
233
362
  }
234
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;
386
+ }
387
+
235
388
  /**
236
389
  * Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
237
390
  * the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
@@ -250,14 +403,7 @@ export async function runWorkflowWithContext(
250
403
  workflow: await captureWorkflowResumeContext(mod, rc.signal),
251
404
  };
252
405
 
253
- const agent = ((prompt: string, agentOpts?: AgentOptions) => {
254
- const scopedOptions = scope.agentOptions(agentOpts);
255
- const executionOptions: AgentExecutionOptions =
256
- scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
257
- ? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
258
- : scopedOptions;
259
- return runAgent(rc, prompt, executionOptions, resumeContext);
260
- }) as WorkflowApi["agent"];
406
+ const agent = createWorkflowAgent(rc, scope, mod, resumeContext);
261
407
 
262
408
  const workflow: WorkflowApi["workflow"] =
263
409
  depth >= 1
@@ -305,7 +451,9 @@ export async function runWorkflowWithContext(
305
451
  };
306
452
 
307
453
  throwIfAborted(rc.signal);
308
- return await mod.default(api);
454
+ const result = await mod.default(api);
455
+ throwIfAborted(rc.signal);
456
+ return result;
309
457
  }
310
458
 
311
459
  interface WorkflowScope {
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { createHash } from "node:crypto";
3
3
  import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
4
+ import { unknownErrorMessage } from "./unknown-error.ts";
4
5
  import { parseWorkflowMeta } from "./workflow-module.ts";
5
6
 
6
7
  /**
@@ -138,8 +139,7 @@ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
138
139
  try {
139
140
  return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
140
141
  } catch (error) {
141
- const message = error instanceof Error ? error.message : String(error);
142
- throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
142
+ throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${unknownErrorMessage(error)}`);
143
143
  }
144
144
  }
145
145
 
@@ -9,6 +9,7 @@ import {
9
9
  isAgentResumeContext,
10
10
  resumeContextMismatchReason,
11
11
  type AgentResumeContext,
12
+ type ResumeContextComparisonOptions,
12
13
  } from "./resume-context.ts";
13
14
 
14
15
  export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
@@ -47,7 +48,7 @@ export type AgentJournalKeyCapture =
47
48
  | { readonly kind: "unverifiable"; readonly reason: string };
48
49
 
49
50
  export interface WorkflowJournal {
50
- lookup(key: string, identity: AgentResumeContext): JournalLookup;
51
+ lookup(key: string, identity: AgentResumeContext, options?: ResumeContextComparisonOptions): JournalLookup;
51
52
  record(key: string, result: unknown, identity: AgentResumeContext): Promise<JournalRecordResult>;
52
53
  }
53
54
 
@@ -60,7 +61,7 @@ export function createWorkflowRunId(): string {
60
61
  }
61
62
 
62
63
  export function workflowJournalPath(cwd: string, runId: string): string {
63
- return join(cwd, WORKFLOW_RUNS_DIR, `${validateRunId(runId)}.jsonl`);
64
+ return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}.jsonl`);
64
65
  }
65
66
 
66
67
  export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
@@ -110,9 +111,11 @@ function captureAgentCallHash(
110
111
  thinkingLevel: opts.thinkingLevel,
111
112
  tools: opts.tools,
112
113
  toolHints: opts.toolHints,
114
+ requireToolHints: opts.requireToolHints === true ? true : undefined,
113
115
  skills: opts.skills,
114
116
  schema: opts.schema,
115
117
  isolation: opts.isolation,
118
+ candidatePatch: opts.candidatePatch ? { baselineOid: opts.candidatePatch.baselineOid, hash: createHash("sha256").update(opts.candidatePatch.patch).digest("hex") } : undefined,
116
119
  worktreeBaseline: worktreeBaseline
117
120
  ? {
118
121
  ref: worktreeBaseline.ref,
@@ -170,6 +173,8 @@ export async function createWorkflowJournal(options: {
170
173
  readonly writePath: string;
171
174
  }): Promise<WorkflowJournal> {
172
175
  const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
176
+ await mkdir(dirname(options.writePath), { recursive: true });
177
+ await appendFile(options.writePath, "", "utf8");
173
178
  return createMemoryBackedJournal(priorEntries, options.writePath);
174
179
  }
175
180
 
@@ -182,7 +187,7 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
182
187
  }
183
188
 
184
189
  return {
185
- lookup(key, identity) {
190
+ lookup(key, identity, options) {
186
191
  const entries = priorByKey.get(key);
187
192
  if (!entries || entries.length === 0) return { hit: false };
188
193
 
@@ -197,10 +202,12 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
197
202
  : "journal entry predates replay contract v2",
198
203
  };
199
204
  }
200
- const matches = current.filter((entry) => resumeContextMismatchReason(entry.identity, identity) === undefined);
205
+ const matches = current.filter(
206
+ (entry) => resumeContextMismatchReason(entry.identity, identity, options) === undefined,
207
+ );
201
208
  if (matches.length === 1) return { hit: true, value: matches[0]!.result };
202
209
  if (matches.length > 1) return { hit: false, reason: "multiple cached entries match this agent call" };
203
- return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity) };
210
+ return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity, options) };
204
211
  },
205
212
  async record(key, result, identity) {
206
213
  const entry: JournalEntryV2 = { version: 2, key, result, identity };
@@ -248,7 +255,7 @@ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL
248
255
  await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
249
256
  }
250
257
 
251
- function validateRunId(runId: string): string {
258
+ export function validateWorkflowRunId(runId: string): string {
252
259
  const trimmed = runId.trim();
253
260
  if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
254
261
  throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");