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