@osolmaz/pi-workflows 0.2.0 → 0.4.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 (152) hide show
  1. package/README.md +72 -5
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +71 -0
  6. package/dist/builtins/monitor.workflow.js +250 -0
  7. package/dist/builtins/monitor.workflow.js.map +1 -0
  8. package/dist/controllers/conditions.d.ts +6 -0
  9. package/dist/controllers/conditions.js +68 -0
  10. package/dist/controllers/conditions.js.map +1 -0
  11. package/dist/controllers/definition.d.ts +6 -0
  12. package/dist/controllers/definition.js +45 -0
  13. package/dist/controllers/definition.js.map +1 -0
  14. package/dist/controllers/effects.d.ts +14 -0
  15. package/dist/controllers/effects.js +104 -0
  16. package/dist/controllers/effects.js.map +1 -0
  17. package/dist/controllers/errors.d.ts +12 -0
  18. package/dist/controllers/errors.js +25 -0
  19. package/dist/controllers/errors.js.map +1 -0
  20. package/dist/controllers/index.d.ts +13 -0
  21. package/dist/controllers/index.js +13 -0
  22. package/dist/controllers/index.js.map +1 -0
  23. package/dist/controllers/json.d.ts +5 -0
  24. package/dist/controllers/json.js +57 -0
  25. package/dist/controllers/json.js.map +1 -0
  26. package/dist/controllers/loader.d.ts +23 -0
  27. package/dist/controllers/loader.js +74 -0
  28. package/dist/controllers/loader.js.map +1 -0
  29. package/dist/controllers/manager.d.ts +58 -0
  30. package/dist/controllers/manager.js +399 -0
  31. package/dist/controllers/manager.js.map +1 -0
  32. package/dist/controllers/results.d.ts +5 -0
  33. package/dist/controllers/results.js +32 -0
  34. package/dist/controllers/results.js.map +1 -0
  35. package/dist/controllers/sqlite.d.ts +235 -0
  36. package/dist/controllers/sqlite.js +1087 -0
  37. package/dist/controllers/sqlite.js.map +1 -0
  38. package/dist/controllers/store.d.ts +112 -0
  39. package/dist/controllers/store.js +32 -0
  40. package/dist/controllers/store.js.map +1 -0
  41. package/dist/controllers/types.d.ts +159 -0
  42. package/dist/controllers/types.js +2 -0
  43. package/dist/controllers/types.js.map +1 -0
  44. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  45. package/dist/controllers/workflow-engine-scheduler.js +95 -0
  46. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  47. package/dist/controllers/workflows.d.ts +27 -0
  48. package/dist/controllers/workflows.js +109 -0
  49. package/dist/controllers/workflows.js.map +1 -0
  50. package/dist/extension/controller-host.d.ts +47 -0
  51. package/dist/extension/controller-host.js +110 -0
  52. package/dist/extension/controller-host.js.map +1 -0
  53. package/dist/extension/executor.d.ts +3 -0
  54. package/dist/extension/executor.js +12 -2
  55. package/dist/extension/executor.js.map +1 -1
  56. package/dist/extension/index.d.ts +7 -0
  57. package/dist/extension/index.js +1118 -106
  58. package/dist/extension/index.js.map +1 -1
  59. package/dist/extension/recorder.d.ts +6 -0
  60. package/dist/extension/recorder.js +20 -8
  61. package/dist/extension/recorder.js.map +1 -1
  62. package/dist/extension/workflow-tool.d.ts +28 -0
  63. package/dist/extension/workflow-tool.js +33 -0
  64. package/dist/extension/workflow-tool.js.map +1 -0
  65. package/dist/host/processes.d.ts +24 -0
  66. package/dist/host/processes.js +114 -0
  67. package/dist/host/processes.js.map +1 -0
  68. package/dist/host/rpc-bridge.d.ts +9 -0
  69. package/dist/host/rpc-bridge.js +39 -0
  70. package/dist/host/rpc-bridge.js.map +1 -0
  71. package/dist/host/rpc-executor.d.ts +38 -0
  72. package/dist/host/rpc-executor.js +254 -0
  73. package/dist/host/rpc-executor.js.map +1 -0
  74. package/dist/host/runner.d.ts +50 -0
  75. package/dist/host/runner.js +379 -0
  76. package/dist/host/runner.js.map +1 -0
  77. package/dist/viewer/cli.d.ts +7 -3
  78. package/dist/viewer/cli.js +150 -19
  79. package/dist/viewer/cli.js.map +1 -1
  80. package/dist/workflows/catalog.d.ts +43 -0
  81. package/dist/workflows/catalog.js +79 -0
  82. package/dist/workflows/catalog.js.map +1 -0
  83. package/dist/workflows/engine.d.ts +37 -2
  84. package/dist/workflows/engine.js +297 -29
  85. package/dist/workflows/engine.js.map +1 -1
  86. package/dist/workflows/errors.d.ts +23 -0
  87. package/dist/workflows/errors.js +38 -0
  88. package/dist/workflows/errors.js.map +1 -1
  89. package/dist/workflows/graph.js +0 -5
  90. package/dist/workflows/graph.js.map +1 -1
  91. package/dist/workflows/index.d.ts +2 -2
  92. package/dist/workflows/index.js +1 -1
  93. package/dist/workflows/index.js.map +1 -1
  94. package/dist/workflows/loader.d.ts +19 -15
  95. package/dist/workflows/loader.js +63 -19
  96. package/dist/workflows/loader.js.map +1 -1
  97. package/dist/workflows/migrate-sources.d.ts +41 -0
  98. package/dist/workflows/migrate-sources.js +129 -0
  99. package/dist/workflows/migrate-sources.js.map +1 -0
  100. package/dist/workflows/schema.js +3 -2
  101. package/dist/workflows/schema.js.map +1 -1
  102. package/dist/workflows/store.d.ts +50 -6
  103. package/dist/workflows/store.js +448 -53
  104. package/dist/workflows/store.js.map +1 -1
  105. package/dist/workflows/types.d.ts +27 -3
  106. package/docs/CONTROLLERS.md +215 -0
  107. package/docs/development.md +14 -9
  108. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  109. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  110. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  111. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  112. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  113. package/docs/run-bundles.md +85 -28
  114. package/docs/workflows.md +161 -17
  115. package/examples/controllers/pull-request.controller.ts +215 -0
  116. package/package.json +10 -2
  117. package/src/builtins/catalog.ts +22 -0
  118. package/src/builtins/monitor.workflow.ts +302 -0
  119. package/src/controllers/conditions.ts +110 -0
  120. package/src/controllers/definition.ts +65 -0
  121. package/src/controllers/effects.ts +123 -0
  122. package/src/controllers/errors.ts +27 -0
  123. package/src/controllers/index.ts +90 -0
  124. package/src/controllers/json.ts +62 -0
  125. package/src/controllers/loader.ts +104 -0
  126. package/src/controllers/manager.ts +533 -0
  127. package/src/controllers/results.ts +46 -0
  128. package/src/controllers/sqlite.ts +1545 -0
  129. package/src/controllers/store.ts +160 -0
  130. package/src/controllers/types.ts +183 -0
  131. package/src/controllers/workflow-engine-scheduler.ts +148 -0
  132. package/src/controllers/workflows.ts +152 -0
  133. package/src/extension/controller-host.ts +163 -0
  134. package/src/extension/executor.ts +13 -2
  135. package/src/extension/index.ts +1325 -130
  136. package/src/extension/recorder.ts +65 -36
  137. package/src/extension/workflow-tool.ts +59 -0
  138. package/src/host/processes.ts +119 -0
  139. package/src/host/rpc-bridge.ts +44 -0
  140. package/src/host/rpc-executor.ts +299 -0
  141. package/src/host/runner.ts +445 -0
  142. package/src/viewer/cli.ts +167 -21
  143. package/src/workflows/catalog.ts +135 -0
  144. package/src/workflows/engine.ts +404 -41
  145. package/src/workflows/errors.ts +45 -0
  146. package/src/workflows/graph.ts +0 -5
  147. package/src/workflows/index.ts +2 -0
  148. package/src/workflows/loader.ts +76 -22
  149. package/src/workflows/migrate-sources.ts +167 -0
  150. package/src/workflows/schema.ts +3 -2
  151. package/src/workflows/store.ts +557 -48
  152. package/src/workflows/types.ts +22 -3
@@ -1,28 +1,59 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
1
+ import { randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
+ import {
6
+ projectControllerStorePath,
7
+ type RunEventRecord,
8
+ SqliteControllerStore,
9
+ } from "../controllers/index.js";
10
+ import type { JsonObject } from "../controllers/types.js";
11
+ import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
7
12
  import { WorkflowEngine } from "../workflows/engine.js";
8
- import { errorMessage } from "../workflows/errors.js";
9
- import { discoverWorkflows, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
10
- import { WorkflowRunStore, createDefinitionSnapshot } from "../workflows/store.js";
13
+ import {
14
+ ClaimLostError,
15
+ errorMessage,
16
+ isClaimLostError,
17
+ TimeoutError,
18
+ } from "../workflows/errors.js";
19
+ import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
20
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
21
+ import {
22
+ createRunId,
23
+ listRunBundles,
24
+ readLastTraceEvent,
25
+ readRunBundle,
26
+ WorkflowRunStore,
27
+ createDefinitionSnapshot,
28
+ } from "../workflows/store.js";
11
29
  import type {
30
+ AgentStepContract,
12
31
  WorkflowDefinition,
13
32
  WorkflowDefinitionSnapshot,
14
33
  WorkflowRunResult,
15
34
  WorkflowRunState,
16
35
  } from "../workflows/types.js";
36
+ import {
37
+ PiControllerHost,
38
+ parseControllerArgs,
39
+ type PiChildWorkflowStarter,
40
+ } from "./controller-host.js";
17
41
  import { ConversationStepExecutor } from "./executor.js";
18
42
  import { SessionRecorder } from "./recorder.js";
19
43
  import { buildWidgetView } from "./widget.js";
44
+ import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
20
45
 
46
+ const RUN_CLAIM_LEASE_MS = 30_000;
47
+ const RUN_CLAIM_RENEW_MS = 10_000;
48
+ const RUN_SYNC_POLL_MS = 3_000;
21
49
  const WIDGET_KEY = "pi-workflows";
22
50
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
23
51
  const FINAL_WIDGET_TTL_MS = 60_000;
24
52
  const WIDGET_SCROLL_STEP = 3;
25
53
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
54
+ const MAX_STATUS_ERROR_CHARS = 4_000;
55
+ const MAX_WORKFLOW_LIST_ITEMS = 50;
56
+ const MAX_WORKFLOW_LIST_NAME_CHARS = 3_500;
26
57
  const PRESENTATION_TIMEOUT_MS = 30_000;
27
58
 
28
59
  class PresentationSupersededError extends Error {}
@@ -34,7 +65,7 @@ type PresentationPromptBuilder = Exclude<
34
65
  >;
35
66
 
36
67
  type ActiveRun = {
37
- runId: string | null;
68
+ runId: string;
38
69
  workflowName: string;
39
70
  engine: WorkflowEngine;
40
71
  executor: ConversationStepExecutor;
@@ -43,6 +74,31 @@ type ActiveRun = {
43
74
  presentationPrompt: WorkflowDefinition["presentationPrompt"];
44
75
  generation: number;
45
76
  lastState: WorkflowRunState | null;
77
+ childKey?: string;
78
+ onFinish?: (result: WorkflowSchedulerResult) => void;
79
+ completion?: Promise<void>;
80
+ interruptionRequested?: boolean;
81
+ claimToken?: string | undefined;
82
+ renewTimer?: ReturnType<typeof setInterval> | undefined;
83
+ /** True for runs this session resumed from the queue. */
84
+ resume?: boolean | undefined;
85
+ /** Set on continuation runs: the checkpointed parent run id. */
86
+ parentRunId?: string | undefined;
87
+ };
88
+
89
+ type StartRunOptions = {
90
+ runId?: string;
91
+ childKey?: string;
92
+ onFinish?: (result: WorkflowSchedulerResult) => void;
93
+ presentation?: boolean;
94
+ quiet?: boolean;
95
+ signal?: AbortSignal;
96
+ /** Continue a checkpointed run: input becomes the answer payload. */
97
+ parentRunId?: string;
98
+ /** Resume a parked run at its stopped node instead of starting fresh. */
99
+ resume?: boolean;
100
+ /** An existing queue claim token, when the caller already claimed the run. */
101
+ claimToken?: string;
46
102
  };
47
103
 
48
104
  export type ParsedWorkflowArgs =
@@ -50,6 +106,8 @@ export type ParsedWorkflowArgs =
50
106
  | { kind: "cancel" }
51
107
  | { kind: "pause" }
52
108
  | { kind: "resume" }
109
+ | { kind: "status"; runId?: string }
110
+ | { kind: "answer"; input: unknown; runId?: string | undefined }
53
111
  | { kind: "run"; ref: string; input: unknown };
54
112
 
55
113
  /** Parse `/workflow` arguments. Exported for tests. */
@@ -61,6 +119,52 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
61
119
  if (trimmed === "cancel" || trimmed === "pause" || trimmed === "resume") {
62
120
  return { kind: trimmed };
63
121
  }
122
+ if (trimmed === "status") {
123
+ return { kind: "status" };
124
+ }
125
+ if (trimmed.startsWith("status ")) {
126
+ const runId = trimmed.slice("status".length).trim();
127
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
128
+ throw new Error("status requires one valid run id");
129
+ }
130
+ return { kind: "status", runId };
131
+ }
132
+ if (trimmed === "answer" || trimmed.startsWith("answer ")) {
133
+ let rest = trimmed === "answer" ? "" : trimmed.slice("answer".length).trim();
134
+ if (rest.length === 0) {
135
+ throw new Error(
136
+ 'answer requires a JSON value or text, e.g. /workflow answer {"approved":true}',
137
+ );
138
+ }
139
+ // `/workflow answer <run-id> <json>` targets a specific waiting run,
140
+ // for example after a restart or for host-driven runs.
141
+ let runId: string | undefined;
142
+ const firstSpace = rest.search(/\s/);
143
+ if (firstSpace > 0) {
144
+ const candidate = rest.slice(0, firstSpace);
145
+ const remainder = rest.slice(firstSpace).trim();
146
+ if (
147
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(candidate) &&
148
+ remainder.length > 0 &&
149
+ (remainder.startsWith("{") || remainder.startsWith("["))
150
+ ) {
151
+ runId = candidate;
152
+ rest = remainder;
153
+ }
154
+ }
155
+ try {
156
+ return {
157
+ kind: "answer",
158
+ input: JSON.parse(rest) as unknown,
159
+ ...(runId !== undefined ? { runId } : {}),
160
+ };
161
+ } catch {
162
+ if (runId !== undefined) {
163
+ throw new Error("answer with a run id requires a JSON value");
164
+ }
165
+ return { kind: "answer", input: { answer: rest } };
166
+ }
167
+ }
64
168
  const spaceIndex = trimmed.search(/\s/);
65
169
  const ref = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
66
170
  const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex).trim();
@@ -77,13 +181,188 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
77
181
  return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
78
182
  }
79
183
 
184
+ function workflowStateSummary(state: WorkflowRunState): JsonObject {
185
+ const error =
186
+ state.error === undefined
187
+ ? undefined
188
+ : state.error.length <= MAX_STATUS_ERROR_CHARS
189
+ ? state.error
190
+ : `${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}\n… [error truncated]`;
191
+ return {
192
+ active: state.status === "running",
193
+ runId: state.runId,
194
+ workflowName: state.workflowName,
195
+ status: state.status,
196
+ steps: state.steps.length,
197
+ ...(state.currentNode !== undefined ? { currentNode: state.currentNode } : {}),
198
+ ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
199
+ ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
200
+ ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
201
+ ...(error !== undefined ? { error } : {}),
202
+ };
203
+ }
204
+
80
205
  type WidgetSource = {
81
206
  state: WorkflowRunState;
82
207
  snapshot: WorkflowDefinitionSnapshot;
83
208
  };
84
209
 
85
210
  export default function piWorkflows(pi: ExtensionAPI) {
211
+ // One runner identity per session; it names this session in run claims.
212
+ const runnerId = randomUUID();
213
+ let runQueueStore: SqliteControllerStore | null = null;
214
+ const migrationBlockedRuns = new Set<string>();
215
+ const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
216
+ runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
217
+ return runQueueStore;
218
+ };
219
+
220
+ // Session sync: a per-session watermark over the run event feed keeps this
221
+ // session's context current with runs other runners drove.
222
+ // The sync watermark is project-scoped: a reopened session catches up
223
+ // where the last one stopped, and two open sessions share one pointer.
224
+ const SYNC_WATERMARK_KEY = "project";
225
+ let syncArmed = false;
226
+ let runSyncTimer: ReturnType<typeof setInterval> | null = null;
227
+
228
+ const recordRunEvent = (event: {
229
+ runId: string;
230
+ workflowRef: string;
231
+ type: string;
232
+ payload?: JsonObject;
233
+ }) => {
234
+ try {
235
+ runQueueStore?.recordRunEvent({ ...event, runnerId });
236
+ } catch {
237
+ // The event feed is best-effort; never fail a run for it.
238
+ }
239
+ };
240
+
241
+ const describeRunEvent = (event: RunEventRecord): string => {
242
+ const label = `${event.workflowRef} run ${event.runId}`;
243
+ switch (event.type) {
244
+ case "waiting": {
245
+ const waitingOn =
246
+ typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
247
+ return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
248
+ }
249
+ case "parked":
250
+ return `${label} was parked and will resume when a runner is available`;
251
+ case "failed": {
252
+ const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
253
+ return `${label} failed${detail}`;
254
+ }
255
+ default:
256
+ return `${label} ${event.type}`;
257
+ }
258
+ };
259
+
260
+ const runSyncPass = async (ctx: ExtensionContext): Promise<void> => {
261
+ if (runQueueStore === null || !syncArmed) {
262
+ return;
263
+ }
264
+ try {
265
+ const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
266
+ if (watermark === 0) {
267
+ // First sync ever for this project: never replay the feed (stale
268
+ // "waits at checkpoint" lines included). Fast-forward, then catch
269
+ // up from current state instead — what is parked, resuming, or
270
+ // waiting for an answer right now.
271
+ const latest = runQueueStore.latestRunEventSeq();
272
+ if (latest > 0) {
273
+ runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
274
+ }
275
+ await sendStateSnapshot(ctx);
276
+ return;
277
+ }
278
+ const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
279
+ if (events.length === 0) {
280
+ return;
281
+ }
282
+ // Persist the watermark first. A crash after this point skips the
283
+ // message, but snapshots recompute from the store, so no information
284
+ // stays lost; a duplicated state line is the worst outcome.
285
+ runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
286
+ const noteworthy = events.filter(
287
+ (event) =>
288
+ event.runnerId !== runnerId &&
289
+ ["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(
290
+ event.type,
291
+ ),
292
+ );
293
+ if (noteworthy.length === 0) {
294
+ return;
295
+ }
296
+ const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
297
+ pi.sendMessage(
298
+ { customType: "pi-workflows-run-sync", content, display: false },
299
+ { deliverAs: "steer", triggerTurn: false },
300
+ );
301
+ notify(ctx, noteworthy.map(describeRunEvent).join("; "));
302
+ } catch {
303
+ // Sync is observational.
304
+ }
305
+ };
306
+
307
+ // The first-use catch-up: a snapshot of runs that need attention now.
308
+ const sendStateSnapshot = async (ctx: ExtensionContext) => {
309
+ if (runQueueStore === null) {
310
+ return;
311
+ }
312
+ const lines: string[] = [];
313
+ const rows = runQueueStore.listWorkflowRuns();
314
+ for (const row of rows) {
315
+ if (row.status === "parked") {
316
+ lines.push(`${row.workflowName} run ${row.runId} is parked and will resume`);
317
+ }
318
+ }
319
+ const known = new Set(rows.map((row) => row.runId));
320
+ const continued = new Set(
321
+ rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
322
+ );
323
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
324
+ for (const bundle of bundles) {
325
+ if (
326
+ bundle.state.status === "waiting" &&
327
+ known.has(bundle.state.runId) &&
328
+ !continued.has(bundle.state.runId)
329
+ ) {
330
+ lines.push(
331
+ `${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`,
332
+ );
333
+ }
334
+ }
335
+ if (lines.length === 0) {
336
+ return;
337
+ }
338
+ const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
339
+ pi.sendMessage(
340
+ { customType: "pi-workflows-run-sync", content, display: false },
341
+ { deliverAs: "steer", triggerTurn: false },
342
+ );
343
+ notify(ctx, lines.join("; "));
344
+ };
345
+
346
+ const startRunSync = (ctx: ExtensionContext) => {
347
+ if (runSyncTimer !== null) {
348
+ return;
349
+ }
350
+ ensureRunQueueStore(ctx.cwd);
351
+ void runSyncPass(ctx);
352
+ runSyncTimer = setInterval(() => void runSyncPass(ctx), RUN_SYNC_POLL_MS);
353
+ runSyncTimer.unref?.();
354
+ };
86
355
  let activeRun: ActiveRun | null = null;
356
+ let systemTurnAbort: AgentStepContract | null = null;
357
+ let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
358
+ let pendingToolLaunch: {
359
+ ctx: ExtensionContext;
360
+ ref: string;
361
+ input: unknown;
362
+ options?: StartRunOptions;
363
+ } | null = null;
364
+ // The interactive run currently parked at a checkpoint, if any.
365
+ let lastWaitingRunId: string | null = null;
87
366
  let widgetTimer: NodeJS.Timeout | null = null;
88
367
  let widgetTicker: NodeJS.Timeout | null = null;
89
368
  // Manual widget scroll: null follows the active node; a number is the
@@ -97,6 +376,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
97
376
  let runGeneration = 0;
98
377
  let presentationAbort: AbortController | null = null;
99
378
  let presentationPending: number | null = null;
379
+ let controllerHost: PiControllerHost | undefined;
380
+ let controllerContext: ExtensionContext | null = null;
100
381
 
101
382
  // UI updates are best-effort: a captured ctx becomes stale after session
102
383
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
@@ -232,7 +513,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
232
513
  if (
233
514
  sessionClosed ||
234
515
  run.generation !== runGeneration ||
235
- state.status === "cancelled" ||
516
+ (state.status !== "completed" && state.status !== "waiting") ||
236
517
  run.presentationPrompt === undefined
237
518
  ) {
238
519
  return;
@@ -292,10 +573,60 @@ export default function piWorkflows(pi: ExtensionAPI) {
292
573
  }
293
574
  };
294
575
 
295
- const finishRun = (ctx: ExtensionContext, run: ActiveRun, result: WorkflowRunResult) => {
576
+ // Release the queue claim exactly once. "done" marks the queue row
577
+ // terminal, "park" leaves it claimable for another runner, and "lost"
578
+ // leaves it for the new claim holder.
579
+ const releaseClaim = (run: ActiveRun, outcome: "done" | "lost" | "park") => {
580
+ if (run.renewTimer !== undefined) {
581
+ clearInterval(run.renewTimer);
582
+ run.renewTimer = undefined;
583
+ }
584
+ if (run.claimToken === undefined || runQueueStore === null || outcome === "lost") {
585
+ return;
586
+ }
587
+ try {
588
+ if (outcome === "park") {
589
+ runQueueStore.parkWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
590
+ } else {
591
+ runQueueStore.completeWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
592
+ }
593
+ } catch {
594
+ // Queue bookkeeping is best-effort next to the durable bundle.
595
+ } finally {
596
+ run.claimToken = undefined;
597
+ }
598
+ };
599
+
600
+ const finishRun = async (
601
+ ctx: ExtensionContext,
602
+ run: ActiveRun,
603
+ result: WorkflowRunResult,
604
+ ): Promise<void> => {
296
605
  if (activeRun === run) {
297
606
  activeRun = null;
298
607
  }
608
+ if (result.state.status === "running") {
609
+ // The run was parked for another runner; its bundle stays resumable
610
+ // and its queue row becomes claimable. The recorder drains first: its
611
+ // finalization writes share the fenced store, so the claim must stay
612
+ // valid until capture is complete. Nothing else to present.
613
+ await run.recorder?.stop().catch(() => undefined);
614
+ releaseClaim(run, "park");
615
+ recordRunEvent({ runId: run.runId, workflowRef: run.workflowName, type: "parked" });
616
+ stopWidgetTicker();
617
+ clearWidget(ctx);
618
+ return;
619
+ }
620
+ releaseClaim(run, "done");
621
+ recordRunEvent({
622
+ runId: run.runId,
623
+ workflowRef: run.workflowName,
624
+ type: result.state.status,
625
+ payload: {
626
+ ...(result.state.error !== undefined ? { error: result.state.error } : {}),
627
+ ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
628
+ },
629
+ });
299
630
  // Normally already stopped via onRunFinishing; this covers observers of
300
631
  // runs that ended without reaching that hook.
301
632
  void run.recorder?.stop();
@@ -309,37 +640,148 @@ export default function piWorkflows(pi: ExtensionAPI) {
309
640
  widgetTimer = setTimeout(() => clearWidget(ctx), FINAL_WIDGET_TTL_MS);
310
641
  widgetTimer.unref?.();
311
642
  }
643
+ if (state.status === "waiting" && run.childKey === undefined) {
644
+ lastWaitingRunId = run.runId;
645
+ }
312
646
  const summary =
313
647
  state.status === "waiting" && state.waitingOn
314
- ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — run ended, awaiting your decision (run ${state.runId})`
315
- : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
648
+ ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
649
+ : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${
650
+ state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""
651
+ }`;
316
652
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
653
+ try {
654
+ const childResult =
655
+ run.childKey !== undefined &&
656
+ (sessionClosed || run.interruptionRequested === true) &&
657
+ state.status === "cancelled"
658
+ ? {
659
+ state: "interrupted" as const,
660
+ runId: state.runId,
661
+ ...(state.error !== undefined ? { error: state.error } : {}),
662
+ }
663
+ : workflowSchedulerResult(state);
664
+ run.onFinish?.(childResult);
665
+ } catch (error) {
666
+ notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
667
+ }
317
668
  void presentRun(ctx, run, state);
318
669
  };
319
670
 
320
- const startRun = async (ctx: ExtensionCommandContext, ref: string, input: unknown) => {
671
+ const startRun = async (
672
+ ctx: ExtensionContext,
673
+ ref: string,
674
+ input: unknown,
675
+ options: StartRunOptions = {},
676
+ ): Promise<string | undefined> => {
677
+ if (options.signal?.aborted) {
678
+ throw options.signal.reason ?? new Error("Workflow startup aborted");
679
+ }
321
680
  if (activeRun) {
322
- notify(
323
- ctx,
324
- `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`,
325
- "error",
326
- );
327
- return;
681
+ if (!options.quiet) {
682
+ notify(
683
+ ctx,
684
+ `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`,
685
+ "error",
686
+ );
687
+ }
688
+ return undefined;
328
689
  }
329
690
  if (presentationPending !== null) {
330
- notify(ctx, "The previous workflow result is still being presented. Wait for it to finish.");
331
- return;
691
+ if (!options.quiet) {
692
+ notify(
693
+ ctx,
694
+ "The previous workflow result is still being presented. Wait for it to finish.",
695
+ );
696
+ }
697
+ return undefined;
332
698
  }
333
699
  supersedePresentation();
334
700
  const generation = runGeneration;
335
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
336
- const workflow = await loadWorkflowFile(resolved.path);
701
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
702
+ const workflow = resolved.definition;
703
+ if (options.signal?.aborted) {
704
+ throw options.signal.reason ?? new Error("Workflow startup aborted");
705
+ }
337
706
  const snapshot = createDefinitionSnapshot(workflow);
707
+ const workflowSource = resolved.source;
708
+ const runId = options.runId ?? createRunId(workflow.name);
709
+
710
+ // Continuations validate the parent before touching the queue: a
711
+ // refused continuation (edited source, missing parent) must not consume
712
+ // the parent's one-continuation slot.
713
+ if (options.parentRunId !== undefined) {
714
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(options.parentRunId));
715
+ if (parent === null || parent.state.status !== "waiting") {
716
+ throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
717
+ }
718
+ if (
719
+ parent.state.workflowSource !== undefined &&
720
+ !isDeepStrictEqual(parent.state.workflowSource, workflowSource)
721
+ ) {
722
+ throw new Error(
723
+ `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
724
+ );
725
+ }
726
+ }
727
+
728
+ // Interactive runs are queued and claimed atomically, so this session
729
+ // owns the run from birth (origin affinity). Controller child runs keep
730
+ // their own scheduling and stay out of the run queue. A resume caller
731
+ // arrives with its claim already taken.
732
+ let claimToken = options.claimToken;
733
+ let queueStore: SqliteControllerStore | null = null;
734
+ if (options.childKey === undefined) {
735
+ queueStore = ensureRunQueueStore(ctx.cwd);
736
+ if (claimToken === undefined) {
737
+ const token = randomUUID();
738
+ queueStore.enqueueWorkflowRun({
739
+ runId,
740
+ workflowName: workflow.name,
741
+ workflowSourceRef:
742
+ workflowSource.kind === "builtin"
743
+ ? `builtin:${workflowSource.id}`
744
+ : workflowSource.path,
745
+ input,
746
+ runnerId,
747
+ claimToken: token,
748
+ leaseMs: RUN_CLAIM_LEASE_MS,
749
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
750
+ });
751
+ claimToken = token;
752
+ recordRunEvent({
753
+ runId,
754
+ workflowRef: ref,
755
+ type: "queued",
756
+ payload: options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {},
757
+ });
758
+ }
759
+ }
760
+ let fence: (() => void) | undefined;
761
+ if (queueStore !== null && claimToken !== undefined) {
762
+ const claimedStore = queueStore;
763
+ const token = claimToken;
764
+ fence = () => {
765
+ if (!claimedStore.verifyWorkflowRunClaim({ runId, claimToken: token })) {
766
+ throw new ClaimLostError(runId);
767
+ }
768
+ };
769
+ }
338
770
 
339
771
  const executor = new ConversationStepExecutor({
340
772
  sendPrompt: ({ prompt, streaming }) => {
341
773
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
342
774
  },
775
+ onAbort: (contract, reason) => {
776
+ lastExpiredAttempt = {
777
+ contract,
778
+ reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
779
+ };
780
+ if (!executor.held && !ctx.isIdle()) {
781
+ systemTurnAbort = contract;
782
+ ctx.abort();
783
+ }
784
+ },
343
785
  conversation: {
344
786
  beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
345
787
  mark: () => run.recorder?.mark() ?? 0,
@@ -347,8 +789,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
347
789
  },
348
790
  });
349
791
  // The store is shared between the engine and the session recorder so the
350
- // trace sequence stays single-writer (see docs/run-bundles.md).
351
- const store = new WorkflowRunStore();
792
+ // trace sequence stays single-writer (see docs/run-bundles.md). Queued
793
+ // runs are fenced: every write proves the claim is still ours first.
794
+ const store = new WorkflowRunStore(
795
+ undefined,
796
+ fence === undefined ? {} : { fenceProvider: () => fence },
797
+ );
352
798
  const engine = new WorkflowEngine({
353
799
  executor,
354
800
  store,
@@ -371,67 +817,617 @@ export default function piWorkflows(pi: ExtensionAPI) {
371
817
  await run.recorder?.finish();
372
818
  },
373
819
  onEvent: (_event, state: WorkflowRunState) => {
374
- if (run.runId === null) {
375
- run.runId = state.runId;
376
- }
377
820
  run.lastState = state;
378
821
  updateWidget(ctx, state, snapshot);
379
822
  },
380
823
  });
381
824
  const run: ActiveRun = {
382
- runId: null,
825
+ runId,
383
826
  workflowName: workflow.name,
384
827
  engine,
385
828
  executor,
386
829
  recorder: null,
387
830
  snapshot,
388
- presentationPrompt: workflow.presentationPrompt,
831
+ presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
389
832
  generation,
390
833
  lastState: null,
834
+ ...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
835
+ ...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
836
+ ...(claimToken !== undefined ? { claimToken } : {}),
837
+ ...(options.resume === true ? { resume: true } : {}),
838
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
391
839
  };
392
840
  activeRun = run;
841
+ if (queueStore !== null && claimToken !== undefined) {
842
+ const store = queueStore;
843
+ const token = claimToken;
844
+ // Renew the claim while the run executes. If renewal says the claim is
845
+ // gone, cancel so the fence trips on the next write instead of letting
846
+ // this runner interleave with the new claim holder.
847
+ run.renewTimer = setInterval(() => {
848
+ try {
849
+ if (
850
+ !store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })
851
+ ) {
852
+ run.engine.cancel();
853
+ }
854
+ } catch {
855
+ // Transient store errors leave fencing to decide ownership.
856
+ }
857
+ }, RUN_CLAIM_RENEW_MS);
858
+ run.renewTimer.unref?.();
859
+ }
393
860
  clearWidgetTimer();
394
861
  startWidgetTicker(ctx, run);
395
- notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
862
+ if (!options.quiet) {
863
+ notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
864
+ }
396
865
 
397
- engine
398
- .run(workflow, input, { workflowPath: resolved.path })
866
+ run.completion = (
867
+ options.resume === true
868
+ ? engine.resumeRun(workflow, runId, { workflowSource })
869
+ : options.parentRunId === undefined
870
+ ? engine.run(workflow, input, { workflowSource, runId })
871
+ : engine.continueRun(workflow, options.parentRunId, input, {
872
+ workflowSource,
873
+ runId,
874
+ })
875
+ )
399
876
  .then((result) => finishRun(ctx, run, result))
400
- .catch((error: unknown) => {
877
+ .catch(async (error: unknown) => {
401
878
  if (activeRun === run) {
402
879
  activeRun = null;
403
880
  }
881
+ const message = errorMessage(error);
882
+ // A resume that fails before executing (for example edited workflow
883
+ // source) must not strand the run: park it so it stays claimable
884
+ // once the cause is fixed, and say so in the feed.
885
+ let resumeFailed = run.resume === true && !isClaimLostError(error);
886
+ let terminalStatus: string | undefined;
887
+ // Re-parking only makes sense while the bundle is still resumable.
888
+ // A terminal or waiting bundle closes the queue row instead, or
889
+ // every session start would retry it forever — and the feed reports
890
+ // the bundle's real state, not a bogus failure.
891
+ if (resumeFailed) {
892
+ try {
893
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
894
+ if (bundle === null || bundle.state.status !== "running") {
895
+ resumeFailed = false;
896
+ terminalStatus = bundle?.state.status;
897
+ }
898
+ } catch {
899
+ // Unreadable bundles close the row too.
900
+ resumeFailed = false;
901
+ }
902
+ }
903
+ // A continuation that failed before its bundle exists frees the
904
+ // parent's continuation slot instead of consuming it forever.
905
+ let continuationSlotFreed = false;
906
+ if (
907
+ run.parentRunId !== undefined &&
908
+ run.claimToken !== undefined &&
909
+ !isClaimLostError(error)
910
+ ) {
911
+ try {
912
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
913
+ if (bundle === null) {
914
+ continuationSlotFreed =
915
+ runQueueStore?.deleteWorkflowRun({ runId, claimToken: run.claimToken }) === true;
916
+ if (continuationSlotFreed) {
917
+ run.claimToken = undefined;
918
+ }
919
+ }
920
+ } catch {
921
+ // The row may remain; a later cleanup pass can remove it.
922
+ }
923
+ }
924
+ releaseClaim(
925
+ run,
926
+ isClaimLostError(error) || continuationSlotFreed
927
+ ? "lost"
928
+ : resumeFailed
929
+ ? "park"
930
+ : "done",
931
+ );
404
932
  void run.recorder?.stop();
405
933
  stopWidgetTicker();
406
934
  clearWidget(ctx);
407
- notify(ctx, `Workflow ${workflow.name} crashed: ${errorMessage(error)}`, "error");
935
+ if (isClaimLostError(error)) {
936
+ notify(ctx, `Workflow ${workflow.name} continues under another runner (run ${runId}).`);
937
+ return;
938
+ }
939
+ if (resumeFailed) {
940
+ recordRunEvent({ runId, workflowRef: workflow.name, type: "parked", payload: {} });
941
+ notify(
942
+ ctx,
943
+ `Could not resume workflow ${workflow.name}: ${message}. The run is parked again.`,
944
+ "warning",
945
+ );
946
+ return;
947
+ }
948
+ if (terminalStatus !== undefined) {
949
+ recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
950
+ notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
951
+ return;
952
+ }
953
+ recordRunEvent({
954
+ runId,
955
+ workflowRef: workflow.name,
956
+ type: "failed",
957
+ payload: { error: message },
958
+ });
959
+ try {
960
+ run.onFinish?.({ state: "failed", runId, error: message });
961
+ } catch {
962
+ // The original workflow failure remains the primary error.
963
+ }
964
+ notify(ctx, `Workflow ${workflow.name} crashed: ${message}`, "error");
408
965
  });
966
+ return runId;
409
967
  };
410
968
 
411
- const listWorkflows = async (ctx: ExtensionCommandContext) => {
412
- const discovered = await discoverWorkflows({ cwd: ctx.cwd });
969
+ // Reclaim and resume a parked run when this session opens without an
970
+ // active run. The claim comes first; the engine resumes at the stopped
971
+ // node only after the queue proves ownership.
972
+ const resumeParkedRun = async (ctx: ExtensionContext): Promise<void> => {
973
+ if (activeRun !== null) {
974
+ return;
975
+ }
976
+ const queueStore = ensureRunQueueStore(ctx.cwd);
977
+ const claimToken = randomUUID();
978
+ const claimed = queueStore.claimNextWorkflowRun({
979
+ runnerId,
980
+ claimToken,
981
+ leaseMs: RUN_CLAIM_LEASE_MS,
982
+ excludeRunIds: [...migrationBlockedRuns],
983
+ });
984
+ if (claimed === undefined) {
985
+ return;
986
+ }
987
+ let started: string | undefined;
988
+ try {
989
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
990
+ const sourceRef =
991
+ bundle?.state.workflowSource === undefined
992
+ ? claimed.workflowSourceRef
993
+ : bundle.state.workflowSource.kind === "builtin"
994
+ ? `builtin:${bundle.state.workflowSource.id}`
995
+ : bundle.state.workflowSource.path;
996
+ started = await startRun(ctx, sourceRef, claimed.input, {
997
+ resume: true,
998
+ runId: claimed.runId,
999
+ claimToken,
1000
+ });
1001
+ } catch (error) {
1002
+ queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
1003
+ throw error;
1004
+ }
1005
+ if (started !== undefined) {
1006
+ notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
1007
+ } else {
1008
+ queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
1009
+ }
1010
+ };
1011
+
1012
+ const startChild: PiChildWorkflowStarter = async (request, signal, onComplete) => {
1013
+ if (signal.aborted) {
1014
+ throw signal.reason ?? new Error("Child workflow scheduling aborted");
1015
+ }
1016
+ const childKey = `${request.requestId}:${request.attempt}`;
1017
+ if (activeRun !== null) {
1018
+ if (activeRun.childKey !== childKey) {
1019
+ return { state: "pending" };
1020
+ }
1021
+ return activeRun.lastState === null
1022
+ ? { state: "running", runId: activeRun.runId }
1023
+ : workflowSchedulerResult(activeRun.lastState);
1024
+ }
1025
+ const store = new WorkflowRunStore();
1026
+ const bundle = await readRunBundle(store.runDirFor(request.runId));
1027
+ if (bundle !== null) {
1028
+ const recovered =
1029
+ bundle.state.status === "running" ? await store.markRunInterrupted(request.runId) : bundle;
1030
+ if (recovered !== null) {
1031
+ const lastTraceEvent = await readLastTraceEvent(
1032
+ recovered.runDir,
1033
+ recovered.manifest.paths.trace,
1034
+ );
1035
+ return workflowSchedulerResult(recovered.state, lastTraceEvent?.type === "run_interrupted");
1036
+ }
1037
+ return { state: "pending" };
1038
+ }
1039
+ if (controllerContext === null) {
1040
+ return { state: "pending" };
1041
+ }
1042
+ await store.quarantineIncompleteRun(request.runId);
1043
+ const runId = await startRun(controllerContext, request.workflow, request.input, {
1044
+ runId: request.runId,
1045
+ childKey,
1046
+ onFinish: onComplete,
1047
+ presentation: false,
1048
+ quiet: true,
1049
+ signal,
1050
+ });
1051
+ return runId === undefined ? { state: "pending" } : { state: "running", runId };
1052
+ };
1053
+
1054
+ const updateControllerStatus = (ctx: ExtensionContext) => {
1055
+ if (controllerHost === undefined) {
1056
+ ctx.ui.setStatus("pi-controllers", undefined);
1057
+ return;
1058
+ }
1059
+ const count = controllerHost.store.listResources().length;
1060
+ ctx.ui.setStatus("pi-controllers", `${count} controller resource${count === 1 ? "" : "s"}`);
1061
+ };
1062
+
1063
+ const ensureControllerHost = async (
1064
+ ctx: ExtensionContext,
1065
+ ): Promise<PiControllerHost | undefined> => {
1066
+ controllerContext = ctx;
1067
+ if (controllerHost !== undefined) {
1068
+ return controllerHost;
1069
+ }
1070
+ controllerHost = await PiControllerHost.create({ cwd: ctx.cwd, startChild });
1071
+ controllerHost?.start();
1072
+ updateControllerStatus(ctx);
1073
+ return controllerHost;
1074
+ };
1075
+
1076
+ type WorkflowControlResult = {
1077
+ message: string;
1078
+ details: JsonObject;
1079
+ level?: "info" | "warning" | "error";
1080
+ };
1081
+
1082
+ const listWorkflowControl = async (
1083
+ ctx: ExtensionContext,
1084
+ offset = 0,
1085
+ ): Promise<WorkflowControlResult> => {
1086
+ const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
413
1087
  if (discovered.length === 0) {
414
- notify(
415
- ctx,
416
- "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
417
- "warning",
1088
+ return {
1089
+ message:
1090
+ "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
1091
+ details: { workflows: [], total: 0, offset: 0 },
1092
+ level: "warning",
1093
+ };
1094
+ }
1095
+ if (!Number.isInteger(offset) || offset < 0 || offset > discovered.length) {
1096
+ throw new Error(
1097
+ `Workflow list offset must be an integer from 0 through ${discovered.length}.`,
418
1098
  );
419
- return;
420
1099
  }
421
- const names = discovered.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
422
- notify(ctx, `Workflows: ${names}. Run one with /workflow <name> [task].`);
1100
+ const page = [];
1101
+ let nameChars = 0;
1102
+ for (const workflow of discovered.slice(offset)) {
1103
+ const renderedName = `${workflow.name} (${workflow.source})`;
1104
+ if (
1105
+ page.length >= MAX_WORKFLOW_LIST_ITEMS ||
1106
+ nameChars + renderedName.length > MAX_WORKFLOW_LIST_NAME_CHARS
1107
+ ) {
1108
+ break;
1109
+ }
1110
+ page.push(workflow);
1111
+ nameChars += renderedName.length;
1112
+ }
1113
+ const names = page.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
1114
+ const nextOffset = offset + page.length;
1115
+ const omitted = discovered.length - nextOffset;
1116
+ return {
1117
+ message: [
1118
+ `Workflows: ${names}.`,
1119
+ omitted > 0 ? `${omitted} more omitted; list again with offset ${nextOffset}.` : "",
1120
+ "Run one with /workflow <name> [task].",
1121
+ ]
1122
+ .filter(Boolean)
1123
+ .join(" "),
1124
+ details: {
1125
+ workflows: page.map((workflow) => ({
1126
+ name: workflow.name,
1127
+ source: workflow.source,
1128
+ ...(workflow.name === "monitor"
1129
+ ? {
1130
+ description:
1131
+ "Repeatedly check a target, report requested changes, and stop on a condition.",
1132
+ }
1133
+ : {}),
1134
+ })),
1135
+ total: discovered.length,
1136
+ offset,
1137
+ omitted,
1138
+ ...(omitted > 0 ? { nextOffset } : {}),
1139
+ },
1140
+ };
1141
+ };
1142
+
1143
+ const listWorkflows = async (ctx: ExtensionContext) => {
1144
+ const result = await listWorkflowControl(ctx);
1145
+ notify(ctx, result.message, result.level);
1146
+ };
1147
+
1148
+ const cancelWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1149
+ if (activeRun) {
1150
+ const workflowName = activeRun.workflowName;
1151
+ const runId = activeRun.runId;
1152
+ activeRun.engine.cancel();
1153
+ return {
1154
+ message: `Cancelling workflow ${workflowName}…`,
1155
+ details: { action: "cancel", workflowName, runId },
1156
+ };
1157
+ }
1158
+ if (pendingToolLaunch !== null) {
1159
+ const ref = pendingToolLaunch.ref;
1160
+ pendingToolLaunch = null;
1161
+ return {
1162
+ message: `Cancelled the queued workflow launch for ${ref}.`,
1163
+ details: { action: "cancel", workflow: ref, queued: false },
1164
+ };
1165
+ }
1166
+ if (widgetSource) {
1167
+ const { state } = widgetSource;
1168
+ clearWidgetTimer();
1169
+ clearWidget(ctx);
1170
+ const detail =
1171
+ state.status === "waiting" && state.waitingOn
1172
+ ? `already ended at checkpoint ${state.waitingOn}`
1173
+ : `already ${state.status}`;
1174
+ return {
1175
+ message: `Workflow ${state.workflowName} ${detail}; cleared its widget.`,
1176
+ details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
1177
+ };
1178
+ }
1179
+ return {
1180
+ message: "No workflow is running.",
1181
+ details: { action: "cancel", active: false },
1182
+ level: "warning",
1183
+ };
1184
+ };
1185
+
1186
+ const pauseWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1187
+ if (!activeRun) {
1188
+ return {
1189
+ message: "No workflow is running.",
1190
+ details: { action: "pause", active: false },
1191
+ level: "warning",
1192
+ };
1193
+ }
1194
+ if (runHeld()) {
1195
+ return {
1196
+ message: `Workflow ${activeRun.workflowName} is already pausing or paused.`,
1197
+ details: {
1198
+ action: "pause",
1199
+ workflowName: activeRun.workflowName,
1200
+ runId: activeRun.runId,
1201
+ paused: true,
1202
+ },
1203
+ };
1204
+ }
1205
+ activeRun.engine.pause();
1206
+ renderWidget(ctx);
1207
+ return {
1208
+ message: `Pausing workflow ${activeRun.workflowName}; the current step will finish before the run holds.`,
1209
+ details: {
1210
+ action: "pause",
1211
+ workflowName: activeRun.workflowName,
1212
+ runId: activeRun.runId,
1213
+ paused: true,
1214
+ },
1215
+ };
1216
+ };
1217
+
1218
+ const resumeWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1219
+ if (!activeRun) {
1220
+ return {
1221
+ message: "No workflow is running.",
1222
+ details: { action: "resume", active: false },
1223
+ level: "warning",
1224
+ };
1225
+ }
1226
+ if (!runHeld()) {
1227
+ return {
1228
+ message: `Workflow ${activeRun.workflowName} is not paused.`,
1229
+ details: {
1230
+ action: "resume",
1231
+ workflowName: activeRun.workflowName,
1232
+ runId: activeRun.runId,
1233
+ paused: false,
1234
+ },
1235
+ level: "warning",
1236
+ };
1237
+ }
1238
+ activeRun.engine.resume();
1239
+ activeRun.executor.release();
1240
+ renderWidget(ctx);
1241
+ return {
1242
+ message: `Workflow ${activeRun.workflowName} resumed.`,
1243
+ details: {
1244
+ action: "resume",
1245
+ workflowName: activeRun.workflowName,
1246
+ runId: activeRun.runId,
1247
+ paused: false,
1248
+ },
1249
+ };
1250
+ };
1251
+
1252
+ const statusWorkflowControl = async (
1253
+ ctx: ExtensionContext,
1254
+ runId?: string,
1255
+ ): Promise<WorkflowControlResult> => {
1256
+ if (runId !== undefined) {
1257
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
1258
+ if (bundle === null) {
1259
+ throw new Error(`Workflow run not found: ${runId}`);
1260
+ }
1261
+ const { state } = bundle;
1262
+ return {
1263
+ message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1264
+ details: workflowStateSummary(state),
1265
+ };
1266
+ }
1267
+ const state = activeRun?.lastState ?? widgetSource?.state;
1268
+ if ((state === undefined || state === null) && pendingToolLaunch !== null) {
1269
+ return {
1270
+ message: `Workflow ${pendingToolLaunch.ref} is queued until the current turn finishes.`,
1271
+ details: { active: false, queued: true, workflow: pendingToolLaunch.ref },
1272
+ };
1273
+ }
1274
+ if (state === undefined || state === null) {
1275
+ return {
1276
+ message: "No workflow run is active or displayed.",
1277
+ details: { active: false },
1278
+ level: "warning",
1279
+ };
1280
+ }
1281
+ return {
1282
+ message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1283
+ details: workflowStateSummary(state),
1284
+ };
1285
+ };
1286
+
1287
+ const resolveWaitingWorkflow = async (
1288
+ ctx: ExtensionContext,
1289
+ requestedRunId?: string,
1290
+ ): Promise<{ parentRunId: string; workflowRef: string }> => {
1291
+ let parentRunId = requestedRunId ?? lastWaitingRunId;
1292
+ if (parentRunId === null) {
1293
+ const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1294
+ const known = new Set(rows.map((row) => row.runId));
1295
+ const continued = new Set(
1296
+ rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
1297
+ );
1298
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
1299
+ parentRunId =
1300
+ bundles.find(
1301
+ (bundle) =>
1302
+ bundle.state.status === "waiting" &&
1303
+ known.has(bundle.state.runId) &&
1304
+ !continued.has(bundle.state.runId),
1305
+ )?.state.runId ?? null;
1306
+ }
1307
+ if (parentRunId === null) {
1308
+ throw new Error("No workflow is waiting for an answer.");
1309
+ }
1310
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1311
+ if (
1312
+ parent === null ||
1313
+ parent.state.status !== "waiting" ||
1314
+ parent.state.workflowSource === undefined
1315
+ ) {
1316
+ if (parentRunId === lastWaitingRunId) {
1317
+ lastWaitingRunId = null;
1318
+ }
1319
+ throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
1320
+ }
1321
+ return {
1322
+ parentRunId,
1323
+ workflowRef:
1324
+ parent.state.workflowSource.kind === "builtin"
1325
+ ? `builtin:${parent.state.workflowSource.id}`
1326
+ : parent.state.workflowSource.path,
1327
+ };
1328
+ };
1329
+
1330
+ const answerWorkflowControl = async (
1331
+ ctx: ExtensionContext,
1332
+ input: unknown,
1333
+ requestedRunId?: string,
1334
+ ): Promise<WorkflowControlResult> => {
1335
+ const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1336
+ const continued = await startRun(ctx, waiting.workflowRef, input, {
1337
+ parentRunId: waiting.parentRunId,
1338
+ });
1339
+ if (continued === undefined) {
1340
+ throw new Error("Could not start the checkpoint continuation.");
1341
+ }
1342
+ lastWaitingRunId = null;
1343
+ return {
1344
+ message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
1345
+ details: {
1346
+ action: "answer",
1347
+ parentRunId: waiting.parentRunId,
1348
+ runId: continued,
1349
+ },
1350
+ };
1351
+ };
1352
+
1353
+ const startWorkflowControl = async (
1354
+ ctx: ExtensionContext,
1355
+ ref: string,
1356
+ input: unknown,
1357
+ ): Promise<WorkflowControlResult> => {
1358
+ if (activeRun !== null) {
1359
+ throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
1360
+ }
1361
+ if (pendingToolLaunch !== null) {
1362
+ throw new Error("A workflow launch is already waiting for the current turn to finish.");
1363
+ }
1364
+ if (presentationPending !== null) {
1365
+ throw new Error("The previous workflow result is still being presented.");
1366
+ }
1367
+ const runId = await startRun(ctx, ref, input);
1368
+ if (runId === undefined) {
1369
+ throw new Error("The workflow could not start.");
1370
+ }
1371
+ return {
1372
+ message: `Workflow ${ref} started (run ${runId}).`,
1373
+ details: { action: "start", workflow: ref, runId },
1374
+ };
1375
+ };
1376
+
1377
+ const queueToolLaunch = async (
1378
+ ctx: ExtensionContext,
1379
+ ref: string,
1380
+ input: unknown,
1381
+ options: StartRunOptions = {},
1382
+ ): Promise<WorkflowControlResult> => {
1383
+ if (activeRun !== null) {
1384
+ throw new Error(
1385
+ `A workflow is already running: ${activeRun.workflowName}. Cancel it before starting another.`,
1386
+ );
1387
+ }
1388
+ if (pendingToolLaunch !== null) {
1389
+ throw new Error("A workflow launch is already waiting for the current turn to finish.");
1390
+ }
1391
+ if (presentationPending !== null) {
1392
+ throw new Error("The previous workflow result is still being presented.");
1393
+ }
1394
+ const reservation = { ctx, ref, input, options };
1395
+ pendingToolLaunch = reservation;
1396
+ try {
1397
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1398
+ const workflow = resolved.definition;
1399
+ if (pendingToolLaunch !== reservation) {
1400
+ throw new Error("The queued workflow launch was cancelled before validation finished.");
1401
+ }
1402
+ return {
1403
+ message: `Workflow ${workflow.name} will start after this turn finishes.`,
1404
+ details: {
1405
+ action: "start",
1406
+ workflow: workflow.name,
1407
+ source: resolved.source,
1408
+ queued: true,
1409
+ },
1410
+ };
1411
+ } catch (error) {
1412
+ if (pendingToolLaunch === reservation) {
1413
+ pendingToolLaunch = null;
1414
+ }
1415
+ throw error;
1416
+ }
423
1417
  };
424
1418
 
425
1419
  pi.registerCommand("workflow", {
426
1420
  description:
427
- "Run a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: pause, resume, cancel",
1421
+ "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
428
1422
  getArgumentCompletions: async (prefix: string) => {
429
- const discovered = await discoverWorkflows({ cwd: process.cwd() });
1423
+ const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
430
1424
  const items = [
431
1425
  ...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
1426
+ { value: "status", label: "status" },
432
1427
  { value: "pause", label: "pause" },
433
1428
  { value: "resume", label: "resume" },
434
1429
  { value: "cancel", label: "cancel" },
1430
+ { value: "answer", label: "answer" },
435
1431
  ].filter((item) => item.value.startsWith(prefix));
436
1432
  return items.length > 0 ? items : null;
437
1433
  },
@@ -448,102 +1444,194 @@ export default function piWorkflows(pi: ExtensionAPI) {
448
1444
  return;
449
1445
  }
450
1446
  if (parsed.kind === "cancel") {
451
- if (activeRun) {
452
- activeRun.engine.cancel();
453
- notify(ctx, `Cancelling workflow ${activeRun.workflowName}…`);
454
- return;
455
- }
456
- // No live run, but a parked (waiting) or recently finished run may
457
- // still occupy the widget; cancel clears it.
458
- if (widgetSource) {
459
- const { state } = widgetSource;
460
- clearWidgetTimer();
461
- clearWidget(ctx);
462
- const detail =
463
- state.status === "waiting" && state.waitingOn
464
- ? `already ended at checkpoint ${state.waitingOn}`
465
- : `already ${state.status}`;
466
- notify(ctx, `Workflow ${state.workflowName} ${detail}; cleared its widget.`);
467
- return;
468
- }
469
- notify(ctx, "No workflow is running.", "warning");
1447
+ const result = cancelWorkflowControl(ctx);
1448
+ notify(ctx, result.message, result.level);
470
1449
  return;
471
1450
  }
472
1451
  if (parsed.kind === "pause") {
473
- if (!activeRun) {
474
- notify(ctx, "No workflow is running.", "warning");
475
- return;
476
- }
477
- if (runHeld()) {
478
- notify(ctx, `Workflow ${activeRun.workflowName} is already pausing or paused.`);
479
- return;
480
- }
481
- activeRun.engine.pause();
482
- renderWidget(ctx);
483
- notify(
484
- ctx,
485
- `Pausing workflow ${activeRun.workflowName} — the current step finishes, then the run holds. /workflow resume to continue.`,
486
- );
1452
+ const result = pauseWorkflowControl(ctx);
1453
+ notify(ctx, result.message, result.level);
487
1454
  return;
488
1455
  }
489
1456
  if (parsed.kind === "resume") {
490
- if (!activeRun) {
491
- notify(ctx, "No workflow is running.", "warning");
492
- return;
1457
+ const result = resumeWorkflowControl(ctx);
1458
+ notify(ctx, result.message, result.level);
1459
+ return;
1460
+ }
1461
+ if (parsed.kind === "status") {
1462
+ try {
1463
+ const result = await statusWorkflowControl(ctx, parsed.runId);
1464
+ notify(ctx, result.message, result.level);
1465
+ } catch (error) {
1466
+ notify(ctx, errorMessage(error), "error");
493
1467
  }
494
- if (!runHeld()) {
495
- notify(ctx, `Workflow ${activeRun.workflowName} is not paused.`);
496
- return;
1468
+ return;
1469
+ }
1470
+ if (parsed.kind === "answer") {
1471
+ try {
1472
+ const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
1473
+ notify(ctx, result.message, result.level);
1474
+ } catch (error) {
1475
+ const message = errorMessage(error);
1476
+ notify(
1477
+ ctx,
1478
+ /workflow_run_queue_parent/.test(message)
1479
+ ? "That checkpoint was already answered; see its continuation run."
1480
+ : `Could not continue workflow: ${message}`,
1481
+ "error",
1482
+ );
497
1483
  }
498
- activeRun.engine.resume();
499
- activeRun.executor.release();
500
- renderWidget(ctx);
501
- notify(ctx, `Workflow ${activeRun.workflowName} resumed.`);
502
1484
  return;
503
1485
  }
504
1486
  try {
505
- await startRun(ctx, parsed.ref, parsed.input);
1487
+ const result = await startWorkflowControl(ctx, parsed.ref, parsed.input);
1488
+ notify(ctx, result.message, result.level);
506
1489
  } catch (error) {
507
1490
  notify(ctx, `Could not start workflow: ${errorMessage(error)}`, "error");
508
1491
  }
509
1492
  },
510
1493
  });
511
1494
 
1495
+ pi.registerCommand("controller", {
1496
+ description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
1497
+ getArgumentCompletions: async (prefix: string) => {
1498
+ const items = ["list", "get", "apply", "reconcile", "delete", "start", "stop"]
1499
+ .filter((value) => value.startsWith(prefix))
1500
+ .map((value) => ({ value, label: value }));
1501
+ return items.length > 0 ? items : null;
1502
+ },
1503
+ handler: async (args, ctx) => {
1504
+ let parsed;
1505
+ try {
1506
+ parsed = parseControllerArgs(args);
1507
+ } catch (error) {
1508
+ notify(ctx, errorMessage(error), "error");
1509
+ return;
1510
+ }
1511
+ const host = await ensureControllerHost(ctx);
1512
+ if (host === undefined) {
1513
+ notify(
1514
+ ctx,
1515
+ "No controllers found. Put *.controller.ts files in .pi/controllers/ or ~/.pi/agent/controllers/.",
1516
+ "warning",
1517
+ );
1518
+ return;
1519
+ }
1520
+ try {
1521
+ switch (parsed.kind) {
1522
+ case "list":
1523
+ notify(ctx, host.list());
1524
+ break;
1525
+ case "get":
1526
+ notify(ctx, host.get(parsed.controller, parsed.key));
1527
+ break;
1528
+ case "apply": {
1529
+ const resource = host.apply(parsed.controller, parsed.key, parsed.spec);
1530
+ updateControllerStatus(ctx);
1531
+ notify(
1532
+ ctx,
1533
+ `Applied ${resource.metadata.controller}/${resource.metadata.key} generation ${resource.metadata.generation}.`,
1534
+ );
1535
+ break;
1536
+ }
1537
+ case "reconcile":
1538
+ host.reconcile(parsed.controller, parsed.key);
1539
+ notify(ctx, `Queued ${parsed.controller}/${parsed.key}.`);
1540
+ break;
1541
+ case "delete":
1542
+ host.delete(parsed.controller, parsed.key);
1543
+ notify(ctx, `Requested deletion of ${parsed.controller}/${parsed.key}.`);
1544
+ break;
1545
+ case "start":
1546
+ host.start();
1547
+ notify(ctx, "Controller workers started.");
1548
+ break;
1549
+ case "stop":
1550
+ if (activeRun?.childKey !== undefined) {
1551
+ activeRun.interruptionRequested = true;
1552
+ activeRun.engine.cancel();
1553
+ }
1554
+ await host.stop();
1555
+ notify(ctx, "Controller workers stopped.");
1556
+ break;
1557
+ }
1558
+ } catch (error) {
1559
+ notify(ctx, `Controller command failed: ${errorMessage(error)}`, "error");
1560
+ }
1561
+ },
1562
+ });
1563
+
512
1564
  pi.registerTool({
513
1565
  name: "workflow",
514
1566
  label: "Workflow",
515
1567
  description: [
516
- "Submit the output for the pending workflow step.",
517
- "Only call this tool when a workflow step contract in the conversation asks you to.",
518
- "Pass the exact step id from the contract and your result as the output.",
1568
+ "List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
1569
+ "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, reportWhen, stopWhen, and optional maxChecks.",
1570
+ "Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1571
+ "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
519
1572
  ].join(" "),
520
- parameters: Type.Object({
521
- step: Type.String({ description: "The step id from the workflow step contract" }),
522
- attempt: Type.String({ description: "The attempt id from the workflow step contract" }),
523
- output: Type.Unknown({ description: "The step output, matching the expected output shape" }),
524
- }),
525
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
526
- if (!activeRun) {
527
- throw new Error(
528
- "No workflow is running. Do not call the workflow tool outside a workflow.",
529
- );
530
- }
531
- // Flush the conversation into the bundle before accepting, so the
532
- // attempt's recorded range includes the assistant message that carries
533
- // this submission. Pi guarantees ctx.sessionManager is synchronized
534
- // through the current assistant tool-calling message before tool_call
535
- // dispatch, which precedes tool execution (docs/extensions.md, "Tool
536
- // Events"). The tool result of this call itself lands after the range
537
- // by design: it is the submission receipt, not the submission.
538
- await activeRun.recorder?.record(ctx).catch(() => undefined);
539
- await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
540
- const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
541
- if (!result.accepted) {
542
- throw new Error(result.message);
1573
+ parameters: WorkflowToolParameters,
1574
+ async execute(_toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1575
+ let control: WorkflowControlResult;
1576
+ switch (params.action) {
1577
+ case "list":
1578
+ control = await listWorkflowControl(ctx, params.offset);
1579
+ break;
1580
+ case "start":
1581
+ control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
1582
+ break;
1583
+ case "status":
1584
+ control = await statusWorkflowControl(ctx, params.runId);
1585
+ break;
1586
+ case "pause":
1587
+ control = pauseWorkflowControl(ctx);
1588
+ break;
1589
+ case "resume":
1590
+ control = resumeWorkflowControl(ctx);
1591
+ break;
1592
+ case "cancel":
1593
+ control = cancelWorkflowControl(ctx);
1594
+ break;
1595
+ case "answer": {
1596
+ const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1597
+ control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
1598
+ parentRunId: waiting.parentRunId,
1599
+ });
1600
+ break;
1601
+ }
1602
+ case "submit": {
1603
+ if (!activeRun) {
1604
+ if (
1605
+ lastExpiredAttempt?.contract.attemptId === params.attempt &&
1606
+ lastExpiredAttempt.contract.nodeId === params.step
1607
+ ) {
1608
+ throw new Error(
1609
+ `Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
1610
+ );
1611
+ }
1612
+ throw new Error("No workflow step is waiting for output.");
1613
+ }
1614
+ // Flush the conversation into the bundle before accepting, so the
1615
+ // attempt range includes the assistant message carrying this call.
1616
+ await activeRun.recorder?.record(ctx).catch(() => undefined);
1617
+ await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
1618
+ const result = await activeRun.executor.submit(
1619
+ params.step,
1620
+ params.attempt,
1621
+ params.output,
1622
+ );
1623
+ if (!result.accepted) {
1624
+ throw new Error(result.message);
1625
+ }
1626
+ return {
1627
+ content: [{ type: "text", text: result.message }],
1628
+ details: { action: "submit", step: params.step, accepted: true },
1629
+ };
1630
+ }
543
1631
  }
544
1632
  return {
545
- content: [{ type: "text", text: result.message }],
546
- details: { step: params.step, accepted: true },
1633
+ content: [{ type: "text", text: control.message }],
1634
+ details: control.details,
547
1635
  };
548
1636
  },
549
1637
  });
@@ -558,6 +1646,45 @@ export default function piWorkflows(pi: ExtensionAPI) {
558
1646
  handler: (ctx) => scrollWidget(ctx, WIDGET_SCROLL_STEP),
559
1647
  });
560
1648
 
1649
+ pi.on("session_start", async (_event, ctx) => {
1650
+ sessionClosed = false;
1651
+ controllerContext = ctx;
1652
+ try {
1653
+ const queue = ensureRunQueueStore(ctx.cwd);
1654
+ const migration = await migrateLegacyWorkflowSources({
1655
+ catalog: builtinWorkflowCatalog,
1656
+ queue,
1657
+ });
1658
+ migrationBlockedRuns.clear();
1659
+ for (const blocked of migration.blocked) migrationBlockedRuns.add(blocked.runId);
1660
+ if (migration.blocked.length > 0) {
1661
+ notify(
1662
+ ctx,
1663
+ `Could not migrate ${migration.blocked.length} legacy workflow source(s).`,
1664
+ "warning",
1665
+ );
1666
+ }
1667
+ } catch (error) {
1668
+ notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
1669
+ }
1670
+ try {
1671
+ syncArmed = true;
1672
+ startRunSync(ctx);
1673
+ } catch {
1674
+ // Session sync is best-effort; runs themselves never depend on it.
1675
+ }
1676
+ try {
1677
+ await resumeParkedRun(ctx);
1678
+ } catch (error) {
1679
+ notify(ctx, `Could not resume a parked workflow: ${errorMessage(error)}`, "warning");
1680
+ }
1681
+ try {
1682
+ await ensureControllerHost(ctx);
1683
+ } catch (error) {
1684
+ notify(ctx, `Could not start controller workers: ${errorMessage(error)}`, "warning");
1685
+ }
1686
+ });
1687
+
561
1688
  pi.on("agent_start", () => {
562
1689
  if (!activeRun && presentationPending === null && presentationAbort) {
563
1690
  // A normal user turn started while an async presentation prompt was
@@ -569,13 +1696,6 @@ export default function piWorkflows(pi: ExtensionAPI) {
569
1696
  });
570
1697
 
571
1698
  pi.on("agent_end", (event, ctx) => {
572
- const run = activeRun;
573
- if (!run) {
574
- return;
575
- }
576
- // An aborted turn means the user hit escape to take the conversation
577
- // back. Nudging or dispatching the next step would immediately steal it
578
- // again, so hold the run until an explicit /workflow resume.
579
1699
  const aborted = event.messages.some(
580
1700
  (message) =>
581
1701
  typeof message === "object" &&
@@ -583,6 +1703,17 @@ export default function piWorkflows(pi: ExtensionAPI) {
583
1703
  "stopReason" in message &&
584
1704
  (message as { stopReason?: string }).stopReason === "aborted",
585
1705
  );
1706
+ if (aborted && systemTurnAbort !== null) {
1707
+ systemTurnAbort = null;
1708
+ return;
1709
+ }
1710
+ const run = activeRun;
1711
+ if (!run) {
1712
+ return;
1713
+ }
1714
+ // An aborted turn means the user hit escape to take the conversation
1715
+ // back. Nudging or dispatching the next step would immediately steal it
1716
+ // again, so hold the run until an explicit /workflow resume.
586
1717
  if (!aborted || runHeld()) {
587
1718
  return;
588
1719
  }
@@ -628,6 +1759,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
628
1759
  });
629
1760
 
630
1761
  pi.on("agent_settled", async (_event, ctx) => {
1762
+ if (activeRun === null && pendingToolLaunch !== null) {
1763
+ const launch = pendingToolLaunch;
1764
+ pendingToolLaunch = null;
1765
+ try {
1766
+ const runId = await startRun(launch.ctx, launch.ref, launch.input, launch.options);
1767
+ if (runId === undefined) {
1768
+ notify(launch.ctx, "The queued workflow could not start.", "error");
1769
+ } else if (launch.options?.parentRunId !== undefined) {
1770
+ lastWaitingRunId = null;
1771
+ }
1772
+ } catch (error) {
1773
+ notify(launch.ctx, `Could not start queued workflow: ${errorMessage(error)}`, "error");
1774
+ }
1775
+ return;
1776
+ }
631
1777
  const run = activeRun;
632
1778
  if (!run) {
633
1779
  presentationPending = null;
@@ -639,13 +1785,33 @@ export default function piWorkflows(pi: ExtensionAPI) {
639
1785
  run.executor.handleAgentSettled();
640
1786
  });
641
1787
 
642
- pi.on("session_shutdown", () => {
1788
+ pi.on("session_shutdown", async () => {
643
1789
  sessionClosed = true;
1790
+ systemTurnAbort = null;
644
1791
  supersedePresentation();
645
1792
  const run = activeRun;
646
- run?.engine.cancel();
647
- void run?.recorder?.stop();
1793
+ if (run !== null && run.claimToken !== undefined) {
1794
+ // Queued interactive runs park: no terminal event, no recorded partial
1795
+ // attempt, and the claim releases so another runner can resume.
1796
+ run.engine.park();
1797
+ } else {
1798
+ run?.engine.cancel();
1799
+ }
1800
+ await run?.recorder?.stop().catch(() => undefined);
1801
+ await run?.completion?.catch(() => undefined);
648
1802
  activeRun = null;
1803
+ pendingToolLaunch = null;
1804
+ lastWaitingRunId = null;
1805
+ if (runSyncTimer !== null) {
1806
+ clearInterval(runSyncTimer);
1807
+ runSyncTimer = null;
1808
+ }
1809
+ syncArmed = false;
1810
+ await controllerHost?.close().catch(() => undefined);
1811
+ controllerHost = undefined;
1812
+ controllerContext = null;
1813
+ runQueueStore?.close();
1814
+ runQueueStore = null;
649
1815
  presentationPending = null;
650
1816
  clearWidgetTimer();
651
1817
  stopWidgetTicker();
@@ -654,6 +1820,35 @@ export default function piWorkflows(pi: ExtensionAPI) {
654
1820
  });
655
1821
  }
656
1822
 
1823
+ function workflowSchedulerResult(
1824
+ state: WorkflowRunState,
1825
+ interrupted = false,
1826
+ ): WorkflowSchedulerResult {
1827
+ if (interrupted) {
1828
+ return {
1829
+ state: "interrupted",
1830
+ runId: state.runId,
1831
+ ...(state.error !== undefined ? { error: state.error } : {}),
1832
+ };
1833
+ }
1834
+ switch (state.status) {
1835
+ case "running":
1836
+ return { state: "running", runId: state.runId };
1837
+ case "waiting":
1838
+ return { state: "waiting", runId: state.runId };
1839
+ case "completed":
1840
+ return { state: "succeeded", runId: state.runId };
1841
+ case "failed":
1842
+ case "timed_out":
1843
+ case "cancelled":
1844
+ return {
1845
+ state: "failed",
1846
+ runId: state.runId,
1847
+ ...(state.error !== undefined ? { error: state.error } : {}),
1848
+ };
1849
+ }
1850
+ }
1851
+
657
1852
  async function resolvePresentationPrompt(
658
1853
  buildPrompt: PresentationPromptBuilder,
659
1854
  state: WorkflowRunState,