@osolmaz/pi-workflows 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +11 -7
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +32 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +2 -2
  6. package/dist/builtins/monitor.workflow.js +25 -25
  7. package/dist/builtins/monitor.workflow.js.map +1 -1
  8. package/dist/controllers/index.d.ts +1 -1
  9. package/dist/controllers/index.js.map +1 -1
  10. package/dist/controllers/sqlite.d.ts +70 -9
  11. package/dist/controllers/sqlite.js +209 -35
  12. package/dist/controllers/sqlite.js.map +1 -1
  13. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  14. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  15. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  16. package/dist/extension/executor.d.ts +3 -0
  17. package/dist/extension/executor.js +11 -1
  18. package/dist/extension/executor.js.map +1 -1
  19. package/dist/extension/index.js +157 -108
  20. package/dist/extension/index.js.map +1 -1
  21. package/dist/host/runner.d.ts +1 -0
  22. package/dist/host/runner.js +68 -20
  23. package/dist/host/runner.js.map +1 -1
  24. package/dist/render/graph-render.js +3 -0
  25. package/dist/render/graph-render.js.map +1 -1
  26. package/dist/workflows/catalog.d.ts +43 -0
  27. package/dist/workflows/catalog.js +79 -0
  28. package/dist/workflows/catalog.js.map +1 -0
  29. package/dist/workflows/definition.d.ts +2 -1
  30. package/dist/workflows/definition.js +9 -1
  31. package/dist/workflows/definition.js.map +1 -1
  32. package/dist/workflows/engine.d.ts +6 -6
  33. package/dist/workflows/engine.js +93 -33
  34. package/dist/workflows/engine.js.map +1 -1
  35. package/dist/workflows/index.d.ts +3 -3
  36. package/dist/workflows/index.js +2 -2
  37. package/dist/workflows/index.js.map +1 -1
  38. package/dist/workflows/loader.d.ts +18 -16
  39. package/dist/workflows/loader.js +58 -23
  40. package/dist/workflows/loader.js.map +1 -1
  41. package/dist/workflows/migrate-sources.d.ts +42 -0
  42. package/dist/workflows/migrate-sources.js +133 -0
  43. package/dist/workflows/migrate-sources.js.map +1 -0
  44. package/dist/workflows/schema.d.ts +2 -1
  45. package/dist/workflows/schema.js +14 -1
  46. package/dist/workflows/schema.js.map +1 -1
  47. package/dist/workflows/store.js +5 -2
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +44 -5
  50. package/docs/development.md +5 -3
  51. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  52. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  53. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  54. package/docs/run-bundles.md +22 -4
  55. package/docs/workflows.md +57 -14
  56. package/package.json +1 -1
  57. package/src/builtins/catalog.ts +32 -0
  58. package/src/builtins/monitor.workflow.ts +33 -26
  59. package/src/controllers/index.ts +1 -0
  60. package/src/controllers/sqlite.ts +353 -43
  61. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  62. package/src/extension/executor.ts +12 -1
  63. package/src/extension/index.ts +181 -140
  64. package/src/host/runner.ts +78 -20
  65. package/src/render/graph-render.ts +3 -0
  66. package/src/workflows/catalog.ts +135 -0
  67. package/src/workflows/definition.ts +11 -0
  68. package/src/workflows/engine.ts +128 -53
  69. package/src/workflows/index.ts +7 -0
  70. package/src/workflows/loader.ts +70 -26
  71. package/src/workflows/migrate-sources.ts +174 -0
  72. package/src/workflows/schema.ts +16 -1
  73. package/src/workflows/store.ts +5 -2
  74. package/src/workflows/types.ts +43 -4
@@ -1,20 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import {
4
- projectControllerStorePath,
5
- type RunEventRecord,
6
- SqliteControllerStore,
7
- } from "../controllers/index.js";
4
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
+ import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
8
6
  import type { JsonObject } from "../controllers/types.js";
9
7
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
10
8
  import { WorkflowEngine } from "../workflows/engine.js";
11
- import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
12
9
  import {
13
- discoverWorkflows,
14
- hashWorkflowSource,
15
- loadWorkflowFile,
16
- resolveWorkflowRef,
17
- } from "../workflows/loader.js";
10
+ ClaimLostError,
11
+ errorMessage,
12
+ isClaimLostError,
13
+ TimeoutError,
14
+ } from "../workflows/errors.js";
15
+ import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
16
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
18
17
  import {
19
18
  createRunId,
20
19
  listRunBundles,
@@ -24,6 +23,7 @@ import {
24
23
  createDefinitionSnapshot,
25
24
  } from "../workflows/store.js";
26
25
  import type {
26
+ AgentStepContract,
27
27
  WorkflowDefinition,
28
28
  WorkflowDefinitionSnapshot,
29
29
  WorkflowRunResult,
@@ -42,6 +42,7 @@ import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.
42
42
  const RUN_CLAIM_LEASE_MS = 30_000;
43
43
  const RUN_CLAIM_RENEW_MS = 10_000;
44
44
  const RUN_SYNC_POLL_MS = 3_000;
45
+ const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
45
46
  const WIDGET_KEY = "pi-workflows";
46
47
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
47
48
  const FINAL_WIDGET_TTL_MS = 60_000;
@@ -207,16 +208,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
207
208
  // One runner identity per session; it names this session in run claims.
208
209
  const runnerId = randomUUID();
209
210
  let runQueueStore: SqliteControllerStore | null = null;
211
+ const migrationBlockedRuns = new Set<string>();
210
212
  const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
211
213
  runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
212
214
  return runQueueStore;
213
215
  };
214
216
 
215
- // Session sync: a per-session watermark over the run event feed keeps this
216
- // session's context current with runs other runners drove.
217
- // The sync watermark is project-scoped: a reopened session catches up
218
- // where the last one stopped, and two open sessions share one pointer.
219
- const SYNC_WATERMARK_KEY = "project";
217
+ // Session-addressed delivery: each session polls only its durable outbox.
218
+ // Run events remain an audit feed and never enter a conversation.
220
219
  let syncArmed = false;
221
220
  let runSyncTimer: ReturnType<typeof setInterval> | null = null;
222
221
 
@@ -233,109 +232,54 @@ export default function piWorkflows(pi: ExtensionAPI) {
233
232
  }
234
233
  };
235
234
 
236
- const describeRunEvent = (event: RunEventRecord): string => {
237
- const label = `${event.workflowRef} run ${event.runId}`;
238
- switch (event.type) {
239
- case "waiting": {
240
- const waitingOn =
241
- typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
242
- return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
235
+ const deliveredNotificationIds = (ctx: ExtensionContext): Set<string> => {
236
+ const ids = new Set<string>();
237
+ for (const entry of ctx.sessionManager.getBranch()) {
238
+ if (entry.type !== "custom_message" || entry.customType !== "pi-workflows-notification") {
239
+ continue;
243
240
  }
244
- case "parked":
245
- return `${label} was parked and will resume when a runner is available`;
246
- case "failed": {
247
- const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
248
- return `${label} failed${detail}`;
241
+ const details = entry.details;
242
+ if (details !== null && typeof details === "object" && !Array.isArray(details)) {
243
+ const notificationId = (details as { notificationId?: unknown }).notificationId;
244
+ if (typeof notificationId === "string") ids.add(notificationId);
249
245
  }
250
- default:
251
- return `${label} ${event.type}`;
252
246
  }
247
+ return ids;
253
248
  };
254
249
 
255
- const runSyncPass = async (ctx: ExtensionContext): Promise<void> => {
256
- if (runQueueStore === null || !syncArmed) {
257
- return;
258
- }
250
+ const runSyncPass = (ctx: ExtensionContext): void => {
251
+ if (runQueueStore === null || !syncArmed) return;
259
252
  try {
260
- const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
261
- if (watermark === 0) {
262
- // First sync ever for this project: never replay the feed (stale
263
- // "waits at checkpoint" lines included). Fast-forward, then catch
264
- // up from current state instead — what is parked, resuming, or
265
- // waiting for an answer right now.
266
- const latest = runQueueStore.latestRunEventSeq();
267
- if (latest > 0) {
268
- runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
253
+ const sessionId = ctx.sessionManager.getSessionId();
254
+ const alreadyDelivered = deliveredNotificationIds(ctx);
255
+ const claimToken = randomUUID();
256
+ for (const notification of runQueueStore.claimPendingWorkflowNotifications({
257
+ targetSessionId: sessionId,
258
+ claimToken,
259
+ leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
260
+ })) {
261
+ if (!alreadyDelivered.has(notification.notificationId)) {
262
+ pi.sendMessage({
263
+ customType: "pi-workflows-notification",
264
+ content: notification.content,
265
+ display: true,
266
+ details: {
267
+ notificationId: notification.notificationId,
268
+ runId: notification.runId,
269
+ kind: notification.kind,
270
+ },
271
+ });
272
+ alreadyDelivered.add(notification.notificationId);
269
273
  }
270
- await sendStateSnapshot(ctx);
271
- return;
272
- }
273
- const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
274
- if (events.length === 0) {
275
- return;
276
- }
277
- // Persist the watermark first. A crash after this point skips the
278
- // message, but snapshots recompute from the store, so no information
279
- // stays lost; a duplicated state line is the worst outcome.
280
- runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
281
- const noteworthy = events.filter(
282
- (event) =>
283
- event.runnerId !== runnerId &&
284
- ["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(
285
- event.type,
286
- ),
287
- );
288
- if (noteworthy.length === 0) {
289
- return;
274
+ runQueueStore.markWorkflowNotificationDelivered({
275
+ notificationId: notification.notificationId,
276
+ targetSessionId: sessionId,
277
+ claimToken,
278
+ });
290
279
  }
291
- const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
292
- pi.sendMessage(
293
- { customType: "pi-workflows-run-sync", content, display: false },
294
- { deliverAs: "steer", triggerTurn: false },
295
- );
296
- notify(ctx, noteworthy.map(describeRunEvent).join("; "));
297
280
  } catch {
298
- // Sync is observational.
299
- }
300
- };
301
-
302
- // The first-use catch-up: a snapshot of runs that need attention now.
303
- const sendStateSnapshot = async (ctx: ExtensionContext) => {
304
- if (runQueueStore === null) {
305
- return;
281
+ // Delivery retries on the next poll. It never affects workflow execution.
306
282
  }
307
- const lines: string[] = [];
308
- const rows = runQueueStore.listWorkflowRuns();
309
- for (const row of rows) {
310
- if (row.status === "parked") {
311
- lines.push(`${row.workflowRef} run ${row.runId} is parked and will resume`);
312
- }
313
- }
314
- const known = new Set(rows.map((row) => row.runId));
315
- const continued = new Set(
316
- rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
317
- );
318
- const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
319
- for (const bundle of bundles) {
320
- if (
321
- bundle.state.status === "waiting" &&
322
- known.has(bundle.state.runId) &&
323
- !continued.has(bundle.state.runId)
324
- ) {
325
- lines.push(
326
- `${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`,
327
- );
328
- }
329
- }
330
- if (lines.length === 0) {
331
- return;
332
- }
333
- const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
334
- pi.sendMessage(
335
- { customType: "pi-workflows-run-sync", content, display: false },
336
- { deliverAs: "steer", triggerTurn: false },
337
- );
338
- notify(ctx, lines.join("; "));
339
283
  };
340
284
 
341
285
  const startRunSync = (ctx: ExtensionContext) => {
@@ -348,6 +292,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
348
292
  runSyncTimer.unref?.();
349
293
  };
350
294
  let activeRun: ActiveRun | null = null;
295
+ let systemTurnAbort: AgentStepContract | null = null;
296
+ let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
351
297
  let pendingToolLaunch: {
352
298
  ctx: ExtensionContext;
353
299
  ref: string;
@@ -506,7 +452,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
506
452
  if (
507
453
  sessionClosed ||
508
454
  run.generation !== runGeneration ||
509
- state.status === "cancelled" ||
455
+ (state.status !== "completed" && state.status !== "waiting") ||
510
456
  run.presentationPrompt === undefined
511
457
  ) {
512
458
  return;
@@ -639,7 +585,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
639
585
  const summary =
640
586
  state.status === "waiting" && state.waitingOn
641
587
  ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
642
- : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
588
+ : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${
589
+ state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""
590
+ }`;
643
591
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
644
592
  try {
645
593
  const childResult =
@@ -689,13 +637,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
689
637
  }
690
638
  supersedePresentation();
691
639
  const generation = runGeneration;
692
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
693
- const workflow = await loadWorkflowFile(resolved.path);
640
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
641
+ const workflow = resolved.definition;
694
642
  if (options.signal?.aborted) {
695
643
  throw options.signal.reason ?? new Error("Workflow startup aborted");
696
644
  }
697
645
  const snapshot = createDefinitionSnapshot(workflow);
698
- const workflowHash = await hashWorkflowSource(resolved.path);
646
+ const workflowSource = resolved.source;
699
647
  const runId = options.runId ?? createRunId(workflow.name);
700
648
 
701
649
  // Continuations validate the parent before touching the queue: a
@@ -706,7 +654,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
706
654
  if (parent === null || parent.state.status !== "waiting") {
707
655
  throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
708
656
  }
709
- if (parent.state.workflowHash !== undefined && parent.state.workflowHash !== workflowHash) {
657
+ if (
658
+ parent.state.workflowSource !== undefined &&
659
+ !isDeepStrictEqual(parent.state.workflowSource, workflowSource)
660
+ ) {
710
661
  throw new Error(
711
662
  `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
712
663
  );
@@ -725,12 +676,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
725
676
  const token = randomUUID();
726
677
  queueStore.enqueueWorkflowRun({
727
678
  runId,
728
- workflowRef: ref,
729
- workflowPath: resolved.path,
679
+ workflowName: workflow.name,
680
+ workflowSourceRef:
681
+ workflowSource.kind === "builtin"
682
+ ? `builtin:${workflowSource.id}`
683
+ : workflowSource.path,
730
684
  input,
731
685
  runnerId,
732
686
  claimToken: token,
733
687
  leaseMs: RUN_CLAIM_LEASE_MS,
688
+ originSessionId: ctx.sessionManager.getSessionId(),
734
689
  ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
735
690
  });
736
691
  claimToken = token;
@@ -757,6 +712,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
757
712
  sendPrompt: ({ prompt, streaming }) => {
758
713
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
759
714
  },
715
+ onAbort: (contract, reason) => {
716
+ lastExpiredAttempt = {
717
+ contract,
718
+ reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
719
+ };
720
+ if (!executor.held && !ctx.isIdle()) {
721
+ systemTurnAbort = contract;
722
+ ctx.abort();
723
+ }
724
+ },
760
725
  conversation: {
761
726
  beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
762
727
  mark: () => run.recorder?.mark() ?? 0,
@@ -773,6 +738,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
773
738
  const engine = new WorkflowEngine({
774
739
  executor,
775
740
  store,
741
+ notificationSink: {
742
+ notify: (request) => {
743
+ fence?.();
744
+ if (queueStore === null) throw new Error("Workflow notifications require a queued run");
745
+ const record = queueStore.getWorkflowRun(request.runId);
746
+ if (record?.originSessionId === null || record?.originSessionId === undefined) {
747
+ throw new Error(`Workflow run ${request.runId} has no origin session`);
748
+ }
749
+ const notification = queueStore.enqueueWorkflowNotification({
750
+ ...request,
751
+ targetSessionId: record.originSessionId,
752
+ });
753
+ return {
754
+ notificationId: notification.notificationId,
755
+ targetSessionId: notification.targetSessionId,
756
+ };
757
+ },
758
+ },
776
759
  // Awaited by the engine after run_started is persisted, so the session
777
760
  // binding and its trace event always precede node and terminal events.
778
761
  onRunStarted: async (runDir, state) => {
@@ -840,12 +823,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
840
823
 
841
824
  run.completion = (
842
825
  options.resume === true
843
- ? engine.resumeRun(workflow, runId, { workflowHash })
826
+ ? engine.resumeRun(workflow, runId, { workflowSource })
844
827
  : options.parentRunId === undefined
845
- ? engine.run(workflow, input, { workflowPath: resolved.path, workflowHash, runId })
828
+ ? engine.run(workflow, input, { workflowSource, runId })
846
829
  : engine.continueRun(workflow, options.parentRunId, input, {
847
- workflowPath: resolved.path,
848
- workflowHash,
830
+ workflowSource,
849
831
  runId,
850
832
  })
851
833
  )
@@ -955,13 +937,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
955
937
  runnerId,
956
938
  claimToken,
957
939
  leaseMs: RUN_CLAIM_LEASE_MS,
940
+ excludeRunIds: [...migrationBlockedRuns],
941
+ sessionId: ctx.sessionManager.getSessionId(),
958
942
  });
959
943
  if (claimed === undefined) {
960
944
  return;
961
945
  }
962
946
  let started: string | undefined;
963
947
  try {
964
- started = await startRun(ctx, claimed.workflowPath, claimed.input, {
948
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
949
+ const sourceRef =
950
+ bundle?.state.workflowSource === undefined
951
+ ? claimed.workflowSourceRef
952
+ : bundle.state.workflowSource.kind === "builtin"
953
+ ? `builtin:${bundle.state.workflowSource.id}`
954
+ : bundle.state.workflowSource.path;
955
+ started = await startRun(ctx, sourceRef, claimed.input, {
965
956
  resume: true,
966
957
  runId: claimed.runId,
967
958
  claimToken,
@@ -971,7 +962,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
971
962
  throw error;
972
963
  }
973
964
  if (started !== undefined) {
974
- notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowRef}).`);
965
+ notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
975
966
  } else {
976
967
  queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
977
968
  }
@@ -1051,7 +1042,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1051
1042
  ctx: ExtensionContext,
1052
1043
  offset = 0,
1053
1044
  ): Promise<WorkflowControlResult> => {
1054
- const discovered = await discoverWorkflows({ cwd: ctx.cwd });
1045
+ const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
1055
1046
  if (discovered.length === 0) {
1056
1047
  return {
1057
1048
  message:
@@ -1255,11 +1246,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
1255
1246
  const resolveWaitingWorkflow = async (
1256
1247
  ctx: ExtensionContext,
1257
1248
  requestedRunId?: string,
1258
- ): Promise<{ parentRunId: string; workflowPath: string }> => {
1249
+ ): Promise<{ parentRunId: string; workflowRef: string }> => {
1259
1250
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1260
1251
  if (parentRunId === null) {
1261
1252
  const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1262
- const known = new Set(rows.map((row) => row.runId));
1253
+ const sessionId = ctx.sessionManager.getSessionId();
1254
+ const known = new Set(
1255
+ rows
1256
+ .filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
1257
+ .map((row) => row.runId),
1258
+ );
1263
1259
  const continued = new Set(
1264
1260
  rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
1265
1261
  );
@@ -1275,18 +1271,32 @@ export default function piWorkflows(pi: ExtensionAPI) {
1275
1271
  if (parentRunId === null) {
1276
1272
  throw new Error("No workflow is waiting for an answer.");
1277
1273
  }
1274
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
1275
+ if (
1276
+ queueRecord?.originSessionId !== null &&
1277
+ queueRecord?.originSessionId !== undefined &&
1278
+ queueRecord.originSessionId !== ctx.sessionManager.getSessionId()
1279
+ ) {
1280
+ throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
1281
+ }
1278
1282
  const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1279
1283
  if (
1280
1284
  parent === null ||
1281
1285
  parent.state.status !== "waiting" ||
1282
- parent.state.workflowPath === undefined
1286
+ parent.state.workflowSource === undefined
1283
1287
  ) {
1284
1288
  if (parentRunId === lastWaitingRunId) {
1285
1289
  lastWaitingRunId = null;
1286
1290
  }
1287
1291
  throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
1288
1292
  }
1289
- return { parentRunId, workflowPath: parent.state.workflowPath };
1293
+ return {
1294
+ parentRunId,
1295
+ workflowRef:
1296
+ parent.state.workflowSource.kind === "builtin"
1297
+ ? `builtin:${parent.state.workflowSource.id}`
1298
+ : parent.state.workflowSource.path,
1299
+ };
1290
1300
  };
1291
1301
 
1292
1302
  const answerWorkflowControl = async (
@@ -1295,7 +1305,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1295
1305
  requestedRunId?: string,
1296
1306
  ): Promise<WorkflowControlResult> => {
1297
1307
  const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1298
- const continued = await startRun(ctx, waiting.workflowPath, input, {
1308
+ const continued = await startRun(ctx, waiting.workflowRef, input, {
1299
1309
  parentRunId: waiting.parentRunId,
1300
1310
  });
1301
1311
  if (continued === undefined) {
@@ -1356,8 +1366,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
1356
1366
  const reservation = { ctx, ref, input, options };
1357
1367
  pendingToolLaunch = reservation;
1358
1368
  try {
1359
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
1360
- const workflow = await loadWorkflowFile(resolved.path);
1369
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1370
+ const workflow = resolved.definition;
1361
1371
  if (pendingToolLaunch !== reservation) {
1362
1372
  throw new Error("The queued workflow launch was cancelled before validation finished.");
1363
1373
  }
@@ -1382,7 +1392,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1382
1392
  description:
1383
1393
  "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
1384
1394
  getArgumentCompletions: async (prefix: string) => {
1385
- const discovered = await discoverWorkflows({ cwd: process.cwd() });
1395
+ const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
1386
1396
  const items = [
1387
1397
  ...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
1388
1398
  { value: "status", label: "status" },
@@ -1556,13 +1566,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
1556
1566
  break;
1557
1567
  case "answer": {
1558
1568
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1559
- control = await queueToolLaunch(ctx, waiting.workflowPath, params.input, {
1569
+ control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
1560
1570
  parentRunId: waiting.parentRunId,
1561
1571
  });
1562
1572
  break;
1563
1573
  }
1564
1574
  case "submit": {
1565
1575
  if (!activeRun) {
1576
+ if (
1577
+ lastExpiredAttempt?.contract.attemptId === params.attempt &&
1578
+ lastExpiredAttempt.contract.nodeId === params.step
1579
+ ) {
1580
+ throw new Error(
1581
+ `Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
1582
+ );
1583
+ }
1566
1584
  throw new Error("No workflow step is waiting for output.");
1567
1585
  }
1568
1586
  // Flush the conversation into the bundle before accepting, so the
@@ -1603,6 +1621,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
1603
1621
  pi.on("session_start", async (_event, ctx) => {
1604
1622
  sessionClosed = false;
1605
1623
  controllerContext = ctx;
1624
+ try {
1625
+ const queue = ensureRunQueueStore(ctx.cwd);
1626
+ const migration = await migrateLegacyWorkflowSources({
1627
+ catalog: builtinWorkflowCatalog,
1628
+ queue,
1629
+ });
1630
+ migrationBlockedRuns.clear();
1631
+ for (const blocked of migration.blocked) migrationBlockedRuns.add(blocked.runId);
1632
+ if (migration.blocked.length > 0) {
1633
+ notify(
1634
+ ctx,
1635
+ `Could not migrate ${migration.blocked.length} legacy workflow source(s).`,
1636
+ "warning",
1637
+ );
1638
+ }
1639
+ } catch (error) {
1640
+ notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
1641
+ }
1606
1642
  try {
1607
1643
  syncArmed = true;
1608
1644
  startRunSync(ctx);
@@ -1632,13 +1668,6 @@ export default function piWorkflows(pi: ExtensionAPI) {
1632
1668
  });
1633
1669
 
1634
1670
  pi.on("agent_end", (event, ctx) => {
1635
- const run = activeRun;
1636
- if (!run) {
1637
- return;
1638
- }
1639
- // An aborted turn means the user hit escape to take the conversation
1640
- // back. Nudging or dispatching the next step would immediately steal it
1641
- // again, so hold the run until an explicit /workflow resume.
1642
1671
  const aborted = event.messages.some(
1643
1672
  (message) =>
1644
1673
  typeof message === "object" &&
@@ -1646,6 +1675,17 @@ export default function piWorkflows(pi: ExtensionAPI) {
1646
1675
  "stopReason" in message &&
1647
1676
  (message as { stopReason?: string }).stopReason === "aborted",
1648
1677
  );
1678
+ if (aborted && systemTurnAbort !== null) {
1679
+ systemTurnAbort = null;
1680
+ return;
1681
+ }
1682
+ const run = activeRun;
1683
+ if (!run) {
1684
+ return;
1685
+ }
1686
+ // An aborted turn means the user hit escape to take the conversation
1687
+ // back. Nudging or dispatching the next step would immediately steal it
1688
+ // again, so hold the run until an explicit /workflow resume.
1649
1689
  if (!aborted || runHeld()) {
1650
1690
  return;
1651
1691
  }
@@ -1719,6 +1759,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1719
1759
 
1720
1760
  pi.on("session_shutdown", async () => {
1721
1761
  sessionClosed = true;
1762
+ systemTurnAbort = null;
1722
1763
  supersedePresentation();
1723
1764
  const run = activeRun;
1724
1765
  if (run !== null && run.claimToken !== undefined) {