@osolmaz/pi-workflows 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +15 -6
  2. package/dist/builtins/autoimplement-command-batches.d.ts +52 -0
  3. package/dist/builtins/autoimplement-command-batches.js +258 -0
  4. package/dist/builtins/autoimplement-command-batches.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +29 -3
  6. package/dist/builtins/autoimplement.workflow.js +876 -231
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/catalog.js +3 -1
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +2 -0
  11. package/dist/builtins/index.js +1 -0
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/sanity-check-session.d.ts +17 -0
  14. package/dist/builtins/sanity-check-session.js +168 -0
  15. package/dist/builtins/sanity-check-session.js.map +1 -0
  16. package/dist/builtins/sanity-check.workflow.d.ts +83 -0
  17. package/dist/builtins/sanity-check.workflow.js +398 -0
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -0
  19. package/dist/controllers/sqlite.d.ts +141 -5
  20. package/dist/controllers/sqlite.js +568 -46
  21. package/dist/controllers/sqlite.js.map +1 -1
  22. package/dist/extension/decision-channels.js +7 -7
  23. package/dist/extension/deferred-turn-coordinator.d.ts +32 -0
  24. package/dist/extension/deferred-turn-coordinator.js +143 -0
  25. package/dist/extension/deferred-turn-coordinator.js.map +1 -0
  26. package/dist/extension/deferred-turn.d.ts +44 -0
  27. package/dist/extension/deferred-turn.js +110 -0
  28. package/dist/extension/deferred-turn.js.map +1 -0
  29. package/dist/extension/index.js +515 -84
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/step-message.d.ts +1 -0
  32. package/dist/extension/step-message.js.map +1 -1
  33. package/dist/herdr/setup.js +4 -4
  34. package/dist/host/runner.js +3 -0
  35. package/dist/host/runner.js.map +1 -1
  36. package/dist/workflows/command-batch.d.ts +38 -0
  37. package/dist/workflows/command-batch.js +176 -0
  38. package/dist/workflows/command-batch.js.map +1 -0
  39. package/dist/workflows/engine.js +8 -4
  40. package/dist/workflows/engine.js.map +1 -1
  41. package/dist/workflows/index.d.ts +1 -0
  42. package/dist/workflows/index.js +1 -0
  43. package/dist/workflows/index.js.map +1 -1
  44. package/dist/workflows/migrate-sources.d.ts +1 -1
  45. package/dist/workflows/migrate-sources.js.map +1 -1
  46. package/dist/workflows/schema.js +2 -1
  47. package/dist/workflows/schema.js.map +1 -1
  48. package/dist/workflows/shell.d.ts +4 -0
  49. package/dist/workflows/shell.js +6 -0
  50. package/dist/workflows/shell.js.map +1 -1
  51. package/dist/workflows/store.js +3 -1
  52. package/dist/workflows/store.js.map +1 -1
  53. package/dist/workflows/tool-input.d.ts +1 -0
  54. package/dist/workflows/tool-input.js +2 -2
  55. package/dist/workflows/tool-input.js.map +1 -1
  56. package/dist/workflows/types.d.ts +4 -3
  57. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  58. package/docs/2026-08-20-durable-workflow-launch-plan.md +445 -0
  59. package/docs/CONTROLLERS.md +2 -2
  60. package/docs/DEFERRED_TURNS.md +298 -0
  61. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  62. package/docs/HUMAN_DECISIONS.md +7 -7
  63. package/docs/HUMAN_DECISION_PRESENTATIONS.md +3 -3
  64. package/docs/MONITOR.md +2 -2
  65. package/docs/WORKFLOW_COMPOSITION.md +2 -2
  66. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  67. package/docs/WORKFLOW_UPDATES.md +28 -5
  68. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  69. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  70. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  71. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  72. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  73. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  74. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  75. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  76. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  77. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  78. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  79. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  80. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  81. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  82. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  83. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  84. package/docs/plans/2026-08-21-sanity-check-plan.md +175 -0
  85. package/docs/run-bundles.md +3 -1
  86. package/docs/workflows.md +67 -17
  87. package/examples/workflows/command-batch.workflow.ts +50 -0
  88. package/examples/workflows/sanity-check.workflow.ts +1 -0
  89. package/herdr-plugin.toml +3 -3
  90. package/package.json +1 -1
  91. package/schemas/decision-presentation-v1.schema.json +1 -1
  92. package/schemas/human-decision-accepted-v1.schema.json +1 -1
  93. package/schemas/human-decision-accepted-v2.schema.json +1 -1
  94. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  95. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  96. package/schemas/human-decision-continuation-v1.schema.json +1 -1
  97. package/schemas/human-decision-delivery-v1.schema.json +1 -1
  98. package/schemas/human-decision-delivery-v2.schema.json +1 -1
  99. package/schemas/human-decision-receipt-v1.schema.json +1 -1
  100. package/schemas/human-decision-receipt-v2.schema.json +1 -1
  101. package/schemas/human-decision-request-v1.schema.json +1 -1
  102. package/schemas/human-decision-request-v2.schema.json +1 -1
  103. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  104. package/schemas/human-decision-resolution-v2.schema.json +1 -1
  105. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  106. package/skills/autodoc/SKILL.md +33 -2
  107. package/skills/autoimplement/SKILL.md +66 -12
  108. package/skills/autoplan/SKILL.md +26 -2
  109. package/skills/monitor/SKILL.md +35 -21
  110. package/skills/pi-workflows/SKILL.md +19 -5
  111. package/src/builtins/autoimplement-command-batches.ts +364 -0
  112. package/src/builtins/autoimplement.workflow.ts +1065 -261
  113. package/src/builtins/catalog.ts +3 -1
  114. package/src/builtins/index.ts +13 -0
  115. package/src/builtins/sanity-check-session.ts +205 -0
  116. package/src/builtins/sanity-check.workflow.ts +624 -0
  117. package/src/controllers/sqlite.ts +878 -51
  118. package/src/extension/decision-channels.ts +7 -7
  119. package/src/extension/deferred-turn-coordinator.ts +171 -0
  120. package/src/extension/deferred-turn.ts +166 -0
  121. package/src/extension/index.ts +646 -91
  122. package/src/extension/step-message.ts +1 -0
  123. package/src/herdr/setup.ts +4 -4
  124. package/src/host/runner.ts +3 -0
  125. package/src/workflows/command-batch.ts +254 -0
  126. package/src/workflows/engine.ts +8 -5
  127. package/src/workflows/index.ts +15 -0
  128. package/src/workflows/migrate-sources.ts +5 -1
  129. package/src/workflows/schema.ts +2 -1
  130. package/src/workflows/shell.ts +10 -0
  131. package/src/workflows/store.ts +3 -1
  132. package/src/workflows/tool-input.ts +5 -2
  133. package/src/workflows/types.ts +4 -3
@@ -1,11 +1,20 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
5
5
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
6
- import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
6
+ import {
7
+ projectControllerStorePath,
8
+ SqliteControllerStore,
9
+ type WorkflowRunQueueRecord,
10
+ } from "../controllers/index.js";
11
+ import type {
12
+ WorkflowTurnIntentCause,
13
+ WorkflowTurnIntentResolution,
14
+ } from "../controllers/sqlite.js";
7
15
  import type { JsonObject } from "../controllers/types.js";
8
16
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
17
+ import { compositionMetadata } from "../workflows/composition.js";
9
18
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
10
19
  import { WorkflowEngine } from "../workflows/engine.js";
11
20
  import {
@@ -58,6 +67,19 @@ import {
58
67
  type HumanDecisionChannelAnswer,
59
68
  type SettledHumanDecision,
60
69
  } from "./decision-channels.js";
70
+ import {
71
+ DeferredTurnCoordinator,
72
+ type BranchIntentResolution,
73
+ } from "./deferred-turn-coordinator.js";
74
+ import {
75
+ buildDeferredTurnContent,
76
+ createDeferredTurnDescriptor,
77
+ DEFERRED_TURN_MESSAGE_TYPE,
78
+ deferredTurnMessageDetails,
79
+ deferredTurnMessageId,
80
+ deferredTurnSourceEventId,
81
+ type DeferredTurnDescriptor,
82
+ } from "./deferred-turn.js";
61
83
  import { ConversationStepExecutor } from "./executor.js";
62
84
  import {
63
85
  HerdrWorkflowViewer,
@@ -103,8 +125,10 @@ const RUN_CLAIM_LEASE_MS = 30_000;
103
125
  const RUN_CLAIM_RENEW_MS = 10_000;
104
126
  const RUN_SYNC_POLL_MS = 3_000;
105
127
  const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
128
+ const TURN_INTENT_DELIVERY_LEASE_MS = 30_000;
106
129
  const WIDGET_KEY = "pi-workflows";
107
130
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
131
+ const PRESENTATION_MESSAGE_SCHEMA = "pi-workflows.presentation-message.v1";
108
132
  const FINAL_WIDGET_TTL_MS = 60_000;
109
133
  const WIDGET_SCROLL_STEP = 3;
110
134
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
@@ -133,6 +157,12 @@ type PresentationPromptBuilder = Exclude<
133
157
  string | undefined
134
158
  >;
135
159
 
160
+ type AbortProvenance = {
161
+ cause: WorkflowTurnIntentCause;
162
+ descriptor?: DeferredTurnDescriptor;
163
+ storageError?: string;
164
+ };
165
+
136
166
  type ActiveRun = {
137
167
  runId: string;
138
168
  workflowName: string;
@@ -148,6 +178,9 @@ type ActiveRun = {
148
178
  onFinish?: (result: WorkflowSchedulerResult) => void;
149
179
  completion?: Promise<void>;
150
180
  interruptionRequested?: boolean;
181
+ pendingAbortCause?: WorkflowTurnIntentCause | undefined;
182
+ abortProvenance?: AbortProvenance | undefined;
183
+ suppressTurnIntent?: boolean | undefined;
151
184
  claimToken?: string | undefined;
152
185
  renewTimer?: ReturnType<typeof setInterval> | undefined;
153
186
  /** True for runs this session resumed from the queue. */
@@ -170,6 +203,12 @@ function humanDecisionRequest(value: unknown): HumanDecisionRequest | null {
170
203
  return value as HumanDecisionRequest;
171
204
  }
172
205
 
206
+ type PreparedLaunchOptions = {
207
+ presentation?: boolean;
208
+ parentRunId?: string;
209
+ humanDecision?: AcceptedHumanDecision;
210
+ };
211
+
173
212
  type StartRunOptions = {
174
213
  runId?: string;
175
214
  childKey?: string;
@@ -187,6 +226,49 @@ type StartRunOptions = {
187
226
  claimToken?: string;
188
227
  };
189
228
 
229
+ function definitionDigest(snapshot: WorkflowDefinitionSnapshot): string {
230
+ return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
231
+ }
232
+
233
+ function launchSourceIdentity(workflow: WorkflowDefinition, root: unknown): unknown {
234
+ return {
235
+ root,
236
+ mounted: compositionMetadata(workflow)?.sources ?? [],
237
+ };
238
+ }
239
+
240
+ function preparedLaunchOptions(options: StartRunOptions): PreparedLaunchOptions {
241
+ return {
242
+ ...(options.presentation !== undefined ? { presentation: options.presentation } : {}),
243
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
244
+ ...(options.humanDecision !== undefined ? { humanDecision: options.humanDecision } : {}),
245
+ };
246
+ }
247
+
248
+ function parsePreparedLaunchOptions(value: unknown): PreparedLaunchOptions {
249
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
250
+ throw new Error("Stored workflow launch options are invalid");
251
+ }
252
+ return value as PreparedLaunchOptions;
253
+ }
254
+
255
+ function safeLaunchError(error: unknown): { code: string; message: string } {
256
+ const raw = errorMessage(error)
257
+ .replace(/Bearer\s+\S+/giu, "Bearer [redacted]")
258
+ .replace(/(token|api[_-]?key|secret|password)(\s*[:=]\s*)\S+/giu, "$1$2[redacted]")
259
+ .replaceAll("\n", " ")
260
+ .trim();
261
+ const code = /not found|cannot find|unknown workflow/iu.test(raw)
262
+ ? "workflow_not_found"
263
+ : /source changed|source mismatch/iu.test(raw)
264
+ ? "source_changed"
265
+ : /invalid|must be/iu.test(raw)
266
+ ? "workflow_invalid"
267
+ : "activation_failed";
268
+ const message = raw.length <= 500 ? raw : `${raw.slice(0, 480)}… [error truncated]`;
269
+ return { code, message: message || "The deferred workflow could not start" };
270
+ }
271
+
190
272
  export type ParsedWorkflowArgs =
191
273
  | { kind: "list" }
192
274
  | { kind: "cancel" }
@@ -343,8 +425,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
343
425
  // Run events remain an audit feed and never enter a conversation.
344
426
  let syncArmed = false;
345
427
  let runSyncTimer: ReturnType<typeof setInterval> | null = null;
428
+ let activationRecovery: ((ctx: ExtensionContext) => void) | undefined;
346
429
  let decisionRecoveryTimer: ReturnType<typeof setInterval> | null = null;
347
430
  let decisionRecoveryActive = false;
431
+ let turnCoordinator: DeferredTurnCoordinator;
348
432
 
349
433
  const recordRunEvent = (event: {
350
434
  runId: string;
@@ -377,6 +461,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
377
461
  const runSyncPass = (ctx: ExtensionContext): void => {
378
462
  if (runQueueStore === null || !syncArmed) return;
379
463
  try {
464
+ activationRecovery?.(ctx);
380
465
  const sessionId = ctx.sessionManager.getSessionId();
381
466
  const alreadyDelivered = deliveredNotificationIds(ctx);
382
467
  const claimToken = randomUUID();
@@ -407,6 +492,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
407
492
  claimToken,
408
493
  });
409
494
  }
495
+ const idle = ctx.isIdle() && systemTurnAbort === null && !sessionClosed && !runHeld();
496
+ turnCoordinator.flushNatural(idle);
497
+ turnCoordinator.deliverFallbacks(
498
+ {
499
+ targetSessionId: sessionId,
500
+ send: (intent) => {
501
+ pi.sendMessage(
502
+ {
503
+ customType: DEFERRED_TURN_MESSAGE_TYPE,
504
+ content: buildDeferredTurnContent(intent),
505
+ display: true,
506
+ details: deferredTurnMessageDetails(intent),
507
+ },
508
+ { triggerTurn: true, deliverAs: "followUp" },
509
+ );
510
+ },
511
+ },
512
+ idle,
513
+ );
410
514
  } catch {
411
515
  // Delivery retries on the next poll. It never affects workflow execution.
412
516
  }
@@ -422,15 +526,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
422
526
  runSyncTimer.unref?.();
423
527
  };
424
528
  let activeRun: ActiveRun | null = null;
425
- let systemTurnAbort: AgentStepContract | null = null;
529
+ let systemTurnAbort: { contract: AgentStepContract; intentId?: string } | null = null;
426
530
  let suppressWorkflowAssistantTail = false;
427
531
  let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
428
- let pendingToolLaunch: {
429
- ctx: ExtensionContext;
430
- ref: string;
431
- input: unknown;
432
- options?: StartRunOptions;
433
- } | null = null;
434
532
  // The interactive run currently parked at a checkpoint, if any.
435
533
  let lastWaitingRunId: string | null = null;
436
534
  let widgetTimer: NodeJS.Timeout | null = null;
@@ -449,6 +547,30 @@ export default function piWorkflows(pi: ExtensionAPI) {
449
547
  let controllerHost: PiControllerHost | undefined;
450
548
  let controllerContext: ExtensionContext | null = null;
451
549
 
550
+ const branchIntentResolution = (intentId: string): BranchIntentResolution | null => {
551
+ if (controllerContext === null) return null;
552
+ for (const entry of controllerContext.sessionManager.getBranch()) {
553
+ if (entry.type !== "custom_message") continue;
554
+ const details = entry.details;
555
+ if (details === null || typeof details !== "object" || Array.isArray(details)) continue;
556
+ if ((details as { turnIntentId?: unknown }).turnIntentId !== intentId) continue;
557
+ let resolution: WorkflowTurnIntentResolution | null = null;
558
+ if (entry.customType === WORKFLOW_AGENT_STEP_MESSAGE_TYPE) resolution = "workflowPrompt";
559
+ if (entry.customType === PRESENTATION_MESSAGE_TYPE) resolution = "presentation";
560
+ if (entry.customType === DEFERRED_TURN_MESSAGE_TYPE) resolution = "fallback";
561
+ if (resolution !== null) {
562
+ return { resolution, messageId: deferredTurnMessageId(intentId, resolution) };
563
+ }
564
+ }
565
+ return null;
566
+ };
567
+
568
+ turnCoordinator = new DeferredTurnCoordinator({
569
+ store: () => runQueueStore,
570
+ branchResolution: branchIntentResolution,
571
+ leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
572
+ });
573
+
452
574
  // UI updates are best-effort: a captured ctx becomes stale after session
453
575
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
454
576
  // A workflow finishing right as the session goes away must not crash pi.
@@ -490,6 +612,145 @@ export default function piWorkflows(pi: ExtensionAPI) {
490
612
  const runHeld = (): boolean =>
491
613
  activeRun !== null && (activeRun.engine.pauseRequested || activeRun.executor.held);
492
614
 
615
+ const originSessionId = (ctx: ExtensionContext, runId: string): string =>
616
+ ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId)?.originSessionId ??
617
+ ctx.sessionManager.getSessionId();
618
+
619
+ const storeTurnDescriptor = (
620
+ ctx: ExtensionContext,
621
+ descriptor: DeferredTurnDescriptor,
622
+ eligible: boolean,
623
+ ): void => {
624
+ ensureRunQueueStore(ctx.cwd).ensureWorkflowTurnIntent({ ...descriptor, eligible });
625
+ };
626
+
627
+ const ensureAbortTurnIntent = (
628
+ ctx: ExtensionContext,
629
+ run: ActiveRun,
630
+ cause: WorkflowTurnIntentCause,
631
+ contract: AgentStepContract,
632
+ reason: unknown,
633
+ ): DeferredTurnDescriptor => {
634
+ if (run.abortProvenance?.descriptor !== undefined) {
635
+ return run.abortProvenance.descriptor;
636
+ }
637
+ const descriptor = createDeferredTurnDescriptor({
638
+ runId: run.runId,
639
+ workflowName: run.workflowName,
640
+ targetSessionId: originSessionId(ctx, run.runId),
641
+ cause,
642
+ sourceEventId: deferredTurnSourceEventId({
643
+ runId: run.runId,
644
+ cause,
645
+ nodeId: contract.nodeId,
646
+ attemptId: contract.attemptId,
647
+ source: "agent-step-abort",
648
+ }),
649
+ observedState: cause === "claimLost" ? "handedOff" : "interrupted",
650
+ nodeId: contract.nodeId,
651
+ attemptId: contract.attemptId,
652
+ reason: errorMessage(reason),
653
+ handoff: cause === "claimLost",
654
+ });
655
+ run.abortProvenance = { cause, descriptor };
656
+ try {
657
+ storeTurnDescriptor(ctx, descriptor, false);
658
+ } catch (error) {
659
+ run.abortProvenance.storageError = errorMessage(error);
660
+ }
661
+ return descriptor;
662
+ };
663
+
664
+ const makeRunTurnIntentEligible = (
665
+ ctx: ExtensionContext,
666
+ run: ActiveRun,
667
+ observedState: string,
668
+ reason?: string,
669
+ ): void => {
670
+ if (
671
+ sessionClosed ||
672
+ run.suppressTurnIntent === true ||
673
+ run.abortProvenance?.cause === "claimLost"
674
+ ) {
675
+ return;
676
+ }
677
+ const targetSessionId = originSessionId(ctx, run.runId);
678
+ const pending = ensureRunQueueStore(ctx.cwd).findPendingWorkflowTurnIntent({
679
+ runId: run.runId,
680
+ targetSessionId,
681
+ });
682
+ if (
683
+ run.childKey !== undefined &&
684
+ run.abortProvenance === undefined &&
685
+ pending?.cause !== "claimLost"
686
+ ) {
687
+ return;
688
+ }
689
+ const cause =
690
+ pending?.cause ??
691
+ run.abortProvenance?.cause ??
692
+ (observedState === "timed_out" ? "timedOut" : "failed");
693
+ if (
694
+ observedState === "cancelled" &&
695
+ run.abortProvenance === undefined &&
696
+ pending === undefined
697
+ ) {
698
+ return;
699
+ }
700
+ const previous = run.abortProvenance?.descriptor;
701
+ const sourceEventId =
702
+ pending?.sourceEventId ??
703
+ previous?.sourceEventId ??
704
+ deferredTurnSourceEventId({
705
+ runId: run.runId,
706
+ cause,
707
+ nodeId: "$terminal",
708
+ source: "terminal",
709
+ });
710
+ const descriptor = createDeferredTurnDescriptor({
711
+ runId: run.runId,
712
+ workflowName: pending?.workflowRef ?? run.workflowName,
713
+ targetSessionId,
714
+ cause,
715
+ sourceEventId,
716
+ observedState,
717
+ nodeId: pending?.nodeId ?? previous?.nodeId ?? "$terminal",
718
+ attemptId: pending?.attemptId ?? previous?.attemptId ?? null,
719
+ reason: reason ?? null,
720
+ handoff: false,
721
+ });
722
+ run.abortProvenance = {
723
+ cause,
724
+ descriptor,
725
+ ...(run.abortProvenance?.storageError === undefined
726
+ ? {}
727
+ : { storageError: run.abortProvenance.storageError }),
728
+ };
729
+ try {
730
+ storeTurnDescriptor(ctx, descriptor, false);
731
+ ensureRunQueueStore(ctx.cwd).makeWorkflowTurnIntentEligible({
732
+ intentId: descriptor.intentId,
733
+ fallbackFacts: descriptor.fallbackFacts,
734
+ });
735
+ delete run.abortProvenance.storageError;
736
+ syncArmed = true;
737
+ } catch (error) {
738
+ const message = errorMessage(error);
739
+ run.abortProvenance.storageError = message;
740
+ recordRunEvent({
741
+ runId: run.runId,
742
+ workflowRef: run.workflowName,
743
+ type: "turn_intent_failed",
744
+ payload: { error: message },
745
+ });
746
+ notify(
747
+ ctx,
748
+ `Workflow ${run.workflowName} ended, but Pi Workflows could not preserve its successor turn: ${message}`,
749
+ "warning",
750
+ );
751
+ }
752
+ };
753
+
493
754
  const footerStatus = (state: WorkflowRunState): string => {
494
755
  const label = runHeld() || state.paused ? "paused" : state.status;
495
756
  const node = state.currentNode ?? state.waitingOn;
@@ -690,24 +951,41 @@ export default function piWorkflows(pi: ExtensionAPI) {
690
951
  typeof run.presentationPrompt === "function"
691
952
  ? await resolvePresentationPrompt(run.presentationPrompt, state, abort.signal)
692
953
  : run.presentationPrompt;
693
- if (
694
- sessionClosed ||
695
- run.generation !== runGeneration ||
696
- abort.signal.aborted ||
697
- instructions === undefined ||
698
- instructions.trim().length === 0
699
- ) {
954
+ if (sessionClosed || run.generation !== runGeneration || abort.signal.aborted) {
700
955
  return;
701
956
  }
702
- presentationPending = run.generation;
703
- suppressWorkflowAssistantTail = false;
704
- pi.sendMessage(
957
+ if (instructions === undefined || instructions.trim().length === 0) {
958
+ if (run.abortProvenance !== undefined) {
959
+ makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
960
+ }
961
+ return;
962
+ }
963
+ turnCoordinator.sendNatural(
705
964
  {
706
- customType: PRESENTATION_MESSAGE_TYPE,
707
- content: buildPresentationMessage(instructions, state),
708
- display: false,
965
+ runId: run.runId,
966
+ targetSessionId: originSessionId(ctx, run.runId),
967
+ resolution: "presentation",
968
+ send: (turnIntentId) => {
969
+ presentationPending = run.generation;
970
+ suppressWorkflowAssistantTail = false;
971
+ pi.sendMessage(
972
+ {
973
+ customType: PRESENTATION_MESSAGE_TYPE,
974
+ content: buildPresentationMessage(instructions, state),
975
+ display: false,
976
+ details: {
977
+ schema: PRESENTATION_MESSAGE_SCHEMA,
978
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
979
+ },
980
+ },
981
+ {
982
+ deliverAs: turnIntentId === undefined ? "steer" : "followUp",
983
+ triggerTurn: true,
984
+ },
985
+ );
986
+ },
709
987
  },
710
- { deliverAs: "steer", triggerTurn: true },
988
+ ctx.isIdle() && systemTurnAbort === null,
711
989
  );
712
990
  } catch (error) {
713
991
  if (presentationPending === run.generation) {
@@ -725,6 +1003,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
725
1003
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
726
1004
  : errorMessage(error);
727
1005
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
1006
+ if (run.abortProvenance !== undefined) {
1007
+ makeRunTurnIntentEligible(ctx, run, state.status, message);
1008
+ }
728
1009
  } finally {
729
1010
  clearTimeout(timer);
730
1011
  if (presentationAbort === abort) {
@@ -777,6 +1058,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
777
1058
  clearWidget(ctx);
778
1059
  return;
779
1060
  }
1061
+ if (run.abortProvenance?.cause === "claimLost") {
1062
+ await run.recorder?.stop().catch(() => undefined);
1063
+ releaseClaim(run, "lost");
1064
+ recordRunEvent({
1065
+ runId: run.runId,
1066
+ workflowRef: run.workflowName,
1067
+ type: "claim_lost",
1068
+ });
1069
+ stopWidgetTicker();
1070
+ clearWidget(ctx);
1071
+ notify(
1072
+ ctx,
1073
+ `Workflow ${run.workflowName} continues under another runner (run ${run.runId}).`,
1074
+ );
1075
+ return;
1076
+ }
780
1077
  releaseClaim(run, "done");
781
1078
  recordRunEvent({
782
1079
  runId: run.runId,
@@ -828,6 +1125,15 @@ export default function piWorkflows(pi: ExtensionAPI) {
828
1125
  } catch (error) {
829
1126
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
830
1127
  }
1128
+ if (state.status === "failed" || state.status === "timed_out" || state.status === "cancelled") {
1129
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
1130
+ } else if (
1131
+ run.abortProvenance !== undefined &&
1132
+ run.presentationPrompt === undefined &&
1133
+ (state.status === "completed" || state.status === "waiting")
1134
+ ) {
1135
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
1136
+ }
831
1137
  void presentRun(ctx, run, state);
832
1138
  if (pendingDecision !== null && state.status === "waiting") {
833
1139
  const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
@@ -927,7 +1233,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
927
1233
  workflowSource.kind === "builtin"
928
1234
  ? `builtin:${workflowSource.id}`
929
1235
  : workflowSource.path,
1236
+ workflowSource: launchSourceIdentity(workflow, workflowSource),
1237
+ definitionDigest: definitionDigest(snapshot),
930
1238
  input,
1239
+ launchOptions: preparedLaunchOptions(options),
931
1240
  runnerId,
932
1241
  claimToken: token,
933
1242
  leaseMs: RUN_CLAIM_LEASE_MS,
@@ -956,23 +1265,34 @@ export default function piWorkflows(pi: ExtensionAPI) {
956
1265
 
957
1266
  const executor = new ConversationStepExecutor({
958
1267
  sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
959
- const details: WorkflowAgentStepMessageDetails = {
960
- schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
961
- kind,
962
- contract,
963
- ...(presentation !== undefined ? { presentation } : {}),
964
- };
965
- pi.sendMessage(
1268
+ turnCoordinator.sendNatural(
966
1269
  {
967
- customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
968
- content: prompt,
969
- display: true,
970
- details,
971
- },
972
- {
973
- triggerTurn: true,
974
- deliverAs: streaming ? "steer" : "followUp",
1270
+ runId,
1271
+ targetSessionId: originSessionId(ctx, runId),
1272
+ resolution: "workflowPrompt",
1273
+ send: (turnIntentId) => {
1274
+ const details: WorkflowAgentStepMessageDetails = {
1275
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
1276
+ kind,
1277
+ contract,
1278
+ ...(presentation !== undefined ? { presentation } : {}),
1279
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
1280
+ };
1281
+ pi.sendMessage(
1282
+ {
1283
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
1284
+ content: prompt,
1285
+ display: true,
1286
+ details,
1287
+ },
1288
+ {
1289
+ triggerTurn: true,
1290
+ deliverAs: streaming ? "steer" : "followUp",
1291
+ },
1292
+ );
1293
+ },
975
1294
  },
1295
+ ctx.isIdle() && systemTurnAbort === null,
976
1296
  );
977
1297
  },
978
1298
  onAbort: (contract, reason) => {
@@ -981,7 +1301,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
981
1301
  reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
982
1302
  };
983
1303
  if (!executor.held && !ctx.isIdle()) {
984
- systemTurnAbort = contract;
1304
+ const cause = reason instanceof TimeoutError ? "timedOut" : run.pendingAbortCause;
1305
+ const descriptor =
1306
+ cause === undefined
1307
+ ? undefined
1308
+ : ensureAbortTurnIntent(ctx, run, cause, contract, reason);
1309
+ run.pendingAbortCause = undefined;
1310
+ systemTurnAbort = {
1311
+ contract,
1312
+ ...(descriptor === undefined ? {} : { intentId: descriptor.intentId }),
1313
+ };
985
1314
  ctx.abort();
986
1315
  }
987
1316
  },
@@ -1063,6 +1392,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1063
1392
  ...(options.resume === true ? { resume: true } : {}),
1064
1393
  ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
1065
1394
  };
1395
+ if (
1396
+ queueStore !== null &&
1397
+ claimToken !== undefined &&
1398
+ !queueStore.markWorkflowRunRunning({ runId, claimToken })
1399
+ ) {
1400
+ throw new ClaimLostError(runId);
1401
+ }
1066
1402
  activeRun = run;
1067
1403
  if (queueStore !== null && claimToken !== undefined) {
1068
1404
  const store = queueStore;
@@ -1075,6 +1411,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1075
1411
  if (
1076
1412
  !store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })
1077
1413
  ) {
1414
+ run.pendingAbortCause = "claimLost";
1078
1415
  run.engine.cancel();
1079
1416
  }
1080
1417
  } catch {
@@ -1177,6 +1514,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1177
1514
  }
1178
1515
  if (terminalStatus !== undefined) {
1179
1516
  recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
1517
+ if (
1518
+ terminalStatus === "failed" ||
1519
+ terminalStatus === "timed_out" ||
1520
+ terminalStatus === "cancelled"
1521
+ ) {
1522
+ makeRunTurnIntentEligible(ctx, run, terminalStatus, message);
1523
+ }
1180
1524
  notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
1181
1525
  return;
1182
1526
  }
@@ -1186,6 +1530,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1186
1530
  type: "failed",
1187
1531
  payload: { error: message },
1188
1532
  });
1533
+ makeRunTurnIntentEligible(ctx, run, "failed", message);
1189
1534
  try {
1190
1535
  run.onFinish?.({ state: "failed", runId, error: message });
1191
1536
  } catch {
@@ -1224,8 +1569,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
1224
1569
  : bundle.state.workflowSource.kind === "builtin"
1225
1570
  ? `builtin:${bundle.state.workflowSource.id}`
1226
1571
  : bundle.state.workflowSource.path;
1572
+ const launchOptions = parsePreparedLaunchOptions(claimed.launchOptions);
1227
1573
  started = await startRun(ctx, sourceRef, claimed.input, {
1228
- resume: true,
1574
+ ...launchOptions,
1575
+ resume: bundle !== null,
1229
1576
  runId: claimed.runId,
1230
1577
  claimToken,
1231
1578
  });
@@ -1412,22 +1759,51 @@ export default function piWorkflows(pi: ExtensionAPI) {
1412
1759
  return null;
1413
1760
  };
1414
1761
 
1415
- const cancelWorkflowControl = async (ctx: ExtensionContext): Promise<WorkflowControlResult> => {
1416
- if (activeRun) {
1762
+ const cancelWorkflowControl = async (
1763
+ ctx: ExtensionContext,
1764
+ origin: "agent" | "user",
1765
+ requestedRunId?: string,
1766
+ ): Promise<WorkflowControlResult> => {
1767
+ if (activeRun && (requestedRunId === undefined || requestedRunId === activeRun.runId)) {
1417
1768
  const workflowName = activeRun.workflowName;
1418
1769
  const runId = activeRun.runId;
1770
+ activeRun.pendingAbortCause = origin === "agent" ? "agentCancelled" : undefined;
1771
+ activeRun.suppressTurnIntent = origin === "user";
1419
1772
  activeRun.engine.cancel();
1420
1773
  return {
1421
1774
  message: `Cancelling workflow ${workflowName}…`,
1422
1775
  details: { action: "cancel", workflowName, runId },
1423
1776
  };
1424
1777
  }
1425
- if (pendingToolLaunch !== null) {
1426
- const ref = pendingToolLaunch.ref;
1427
- pendingToolLaunch = null;
1778
+ const queue = ensureRunQueueStore(ctx.cwd);
1779
+ const queued =
1780
+ requestedRunId === undefined
1781
+ ? queue.findSessionReservation(ctx.sessionManager.getSessionId())
1782
+ : queue.getWorkflowRun(requestedRunId);
1783
+ if (
1784
+ queued !== undefined &&
1785
+ ["queued", "starting"].includes(queued.status) &&
1786
+ (queued.originSessionId === null ||
1787
+ queued.originSessionId === ctx.sessionManager.getSessionId())
1788
+ ) {
1789
+ if (!queue.cancelWorkflowRun({ runId: queued.runId })) {
1790
+ throw new Error(
1791
+ `Workflow ${queued.runId} could not be cancelled because its state changed.`,
1792
+ );
1793
+ }
1794
+ recordRunEvent({
1795
+ runId: queued.runId,
1796
+ workflowRef: queued.workflowName,
1797
+ type: "cancelled",
1798
+ });
1428
1799
  return {
1429
- message: `Cancelled the queued workflow launch for ${ref}.`,
1430
- details: { action: "cancel", workflow: ref, queued: false },
1800
+ message: `Cancelled queued workflow ${queued.workflowName} (run ${queued.runId}).`,
1801
+ details: {
1802
+ action: "cancel",
1803
+ workflow: queued.workflowName,
1804
+ runId: queued.runId,
1805
+ queued: false,
1806
+ },
1431
1807
  };
1432
1808
  }
1433
1809
  if (widgetSource) {
@@ -1491,6 +1867,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1491
1867
  },
1492
1868
  };
1493
1869
  }
1870
+ activeRun.suppressTurnIntent = true;
1494
1871
  activeRun.engine.pause();
1495
1872
  renderWidget(ctx);
1496
1873
  return {
@@ -1524,6 +1901,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1524
1901
  level: "warning",
1525
1902
  };
1526
1903
  }
1904
+ activeRun.suppressTurnIntent = false;
1527
1905
  activeRun.engine.resume();
1528
1906
  activeRun.executor.release();
1529
1907
  renderWidget(ctx);
@@ -1538,6 +1916,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
1538
1916
  };
1539
1917
  };
1540
1918
 
1919
+ const workflowLaunchStatus = (record: WorkflowRunQueueRecord): WorkflowControlResult => ({
1920
+ message: `Workflow ${record.workflowName} is ${record.status} (run ${record.runId}).`,
1921
+ details: {
1922
+ action: "status",
1923
+ active: ["starting", "running"].includes(record.status),
1924
+ queued: record.status === "queued",
1925
+ workflowName: record.workflowName,
1926
+ runId: record.runId,
1927
+ status: record.status,
1928
+ ...(record.errorCode === null ? {} : { errorCode: record.errorCode }),
1929
+ ...(record.errorMessage === null ? {} : { error: record.errorMessage }),
1930
+ },
1931
+ ...(["failed", "cancelled"].includes(record.status) ? { level: "warning" as const } : {}),
1932
+ });
1933
+
1541
1934
  const statusWorkflowControl = async (
1542
1935
  ctx: ExtensionContext,
1543
1936
  runId?: string,
@@ -1545,7 +1938,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
1545
1938
  if (runId !== undefined) {
1546
1939
  const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
1547
1940
  if (bundle === null) {
1548
- throw new Error(`Workflow run not found: ${runId}`);
1941
+ const launch = ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId);
1942
+ if (launch === undefined) throw new Error(`Workflow run not found: ${runId}`);
1943
+ return workflowLaunchStatus(launch);
1549
1944
  }
1550
1945
  const { state } = bundle;
1551
1946
  return {
@@ -1554,11 +1949,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
1554
1949
  };
1555
1950
  }
1556
1951
  const state = activeRun?.lastState ?? widgetSource?.state;
1557
- if ((state === undefined || state === null) && pendingToolLaunch !== null) {
1558
- return {
1559
- message: `Workflow ${pendingToolLaunch.ref} is queued until the current turn finishes.`,
1560
- details: { active: false, queued: true, workflow: pendingToolLaunch.ref },
1561
- };
1952
+ if (state === undefined || state === null) {
1953
+ const queued = ensureRunQueueStore(ctx.cwd).findSessionReservation(
1954
+ ctx.sessionManager.getSessionId(),
1955
+ );
1956
+ if (queued !== undefined) return workflowLaunchStatus(queued);
1562
1957
  }
1563
1958
  if (state === undefined || state === null) {
1564
1959
  return {
@@ -1903,8 +2298,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1903
2298
  if (activeRun !== null) {
1904
2299
  throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
1905
2300
  }
1906
- if (pendingToolLaunch !== null) {
1907
- throw new Error("A workflow launch is already waiting for the current turn to finish.");
2301
+ const reserved = ensureRunQueueStore(ctx.cwd).findSessionReservation(
2302
+ ctx.sessionManager.getSessionId(),
2303
+ );
2304
+ if (reserved !== undefined) {
2305
+ throw new Error(
2306
+ `Workflow ${reserved.workflowName} is already ${reserved.status} (run ${reserved.runId}).`,
2307
+ );
1908
2308
  }
1909
2309
  if (presentationPending !== null) {
1910
2310
  throw new Error("The previous workflow result is still being presented.");
@@ -1930,35 +2330,171 @@ export default function piWorkflows(pi: ExtensionAPI) {
1930
2330
  `A workflow is already running: ${activeRun.workflowName}. Cancel it before starting another.`,
1931
2331
  );
1932
2332
  }
1933
- if (pendingToolLaunch !== null) {
1934
- throw new Error("A workflow launch is already waiting for the current turn to finish.");
2333
+ const queue = ensureRunQueueStore(ctx.cwd);
2334
+ const existing = queue.findSessionReservation(ctx.sessionManager.getSessionId());
2335
+ if (existing !== undefined) {
2336
+ throw new Error(
2337
+ `Workflow ${existing.workflowName} is already ${existing.status} (run ${existing.runId}).`,
2338
+ );
1935
2339
  }
1936
2340
  if (presentationPending !== null) {
1937
2341
  throw new Error("The previous workflow result is still being presented.");
1938
2342
  }
1939
- const reservation = { ctx, ref, input, options };
1940
- pendingToolLaunch = reservation;
1941
- try {
1942
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1943
- const workflow = resolved.definition;
1944
- if (pendingToolLaunch !== reservation) {
1945
- throw new Error("The queued workflow launch was cancelled before validation finished.");
2343
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
2344
+ const workflow = resolved.definition;
2345
+ const workflowSource = resolved.source;
2346
+ if (options.parentRunId !== undefined) {
2347
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(options.parentRunId));
2348
+ if (parent === null || parent.state.status !== "waiting") {
2349
+ throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
1946
2350
  }
1947
- return {
1948
- message: `Workflow ${workflow.name} will start after this turn finishes.`,
1949
- details: {
1950
- action: "start",
1951
- workflow: workflow.name,
1952
- source: resolved.source,
1953
- queued: true,
1954
- },
1955
- };
2351
+ if (
2352
+ parent.state.workflowSource !== undefined &&
2353
+ !isDeepStrictEqual(parent.state.workflowSource, workflowSource)
2354
+ ) {
2355
+ throw new Error(
2356
+ `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
2357
+ );
2358
+ }
2359
+ }
2360
+ const snapshot = createDefinitionSnapshot(workflow);
2361
+ const runId = createRunId(workflow.name);
2362
+ try {
2363
+ queue.reserveWorkflowRun({
2364
+ runId,
2365
+ workflowName: workflow.name,
2366
+ workflowSourceRef:
2367
+ workflowSource.kind === "builtin" ? `builtin:${workflowSource.id}` : workflowSource.path,
2368
+ workflowSource: launchSourceIdentity(workflow, workflowSource),
2369
+ definitionDigest: definitionDigest(snapshot),
2370
+ input,
2371
+ launchOptions: preparedLaunchOptions(options),
2372
+ runnerId,
2373
+ originSessionId: ctx.sessionManager.getSessionId(),
2374
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
2375
+ });
1956
2376
  } catch (error) {
1957
- if (pendingToolLaunch === reservation) {
1958
- pendingToolLaunch = null;
2377
+ const reserved = queue.findSessionReservation(ctx.sessionManager.getSessionId());
2378
+ if (reserved !== undefined) {
2379
+ throw new Error(
2380
+ `A workflow launch is already waiting: ${reserved.workflowName} (run ${reserved.runId}).`,
2381
+ { cause: error },
2382
+ );
1959
2383
  }
1960
2384
  throw error;
1961
2385
  }
2386
+ recordRunEvent({
2387
+ runId,
2388
+ workflowRef: workflow.name,
2389
+ type: "queued",
2390
+ payload: options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId },
2391
+ });
2392
+ syncArmed = true;
2393
+ return {
2394
+ message: `Workflow ${workflow.name} queued (run ${runId}).`,
2395
+ details: {
2396
+ action: "start",
2397
+ workflow: workflow.name,
2398
+ runId,
2399
+ source: workflowSource,
2400
+ queued: true,
2401
+ },
2402
+ };
2403
+ };
2404
+
2405
+ const activatePreparedLaunch = async (
2406
+ ctx: ExtensionContext,
2407
+ prepared: WorkflowRunQueueRecord,
2408
+ ): Promise<boolean> => {
2409
+ const queue = ensureRunQueueStore(ctx.cwd);
2410
+ const claimToken = randomUUID();
2411
+ const claimed = queue.claimWorkflowRun({
2412
+ runId: prepared.runId,
2413
+ runnerId,
2414
+ claimToken,
2415
+ leaseMs: RUN_CLAIM_LEASE_MS,
2416
+ });
2417
+ if (claimed === undefined) return false;
2418
+ try {
2419
+ const resolved = await resolveWorkflowRef(
2420
+ claimed.workflowSourceRef,
2421
+ { cwd: ctx.cwd },
2422
+ builtinWorkflowCatalog,
2423
+ );
2424
+ const snapshot = createDefinitionSnapshot(resolved.definition);
2425
+ if (
2426
+ !isDeepStrictEqual(
2427
+ launchSourceIdentity(resolved.definition, resolved.source),
2428
+ claimed.workflowSource,
2429
+ ) ||
2430
+ definitionDigest(snapshot) !== claimed.definitionDigest
2431
+ ) {
2432
+ throw new Error("Workflow source changed after the launch was queued");
2433
+ }
2434
+ const launchOptions = parsePreparedLaunchOptions(claimed.launchOptions);
2435
+ const started = await startRun(ctx, claimed.workflowSourceRef, claimed.input, {
2436
+ ...launchOptions,
2437
+ runId: claimed.runId,
2438
+ claimToken,
2439
+ });
2440
+ if (started === undefined) throw new Error("The queued workflow could not start");
2441
+ if (launchOptions.parentRunId !== undefined) lastWaitingRunId = null;
2442
+ return true;
2443
+ } catch (error) {
2444
+ const safe = safeLaunchError(error);
2445
+ queue.failWorkflowRun({
2446
+ runId: claimed.runId,
2447
+ claimToken,
2448
+ errorCode: safe.code,
2449
+ errorMessage: safe.message,
2450
+ });
2451
+ recordRunEvent({
2452
+ runId: claimed.runId,
2453
+ workflowRef: claimed.workflowName,
2454
+ type: "launch_failed",
2455
+ payload: { errorCode: safe.code, error: safe.message },
2456
+ });
2457
+ const content = `Workflow ${claimed.workflowName} failed to start (run ${claimed.runId}): ${safe.message}. Inspect the error and call workflow start again only after you correct the cause.`;
2458
+ const cause = "launchFailed" as const;
2459
+ const descriptor = createDeferredTurnDescriptor({
2460
+ runId: claimed.runId,
2461
+ workflowName: claimed.workflowName,
2462
+ targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
2463
+ cause,
2464
+ sourceEventId: deferredTurnSourceEventId({
2465
+ runId: claimed.runId,
2466
+ cause,
2467
+ nodeId: "$launch",
2468
+ source: "queued-launch",
2469
+ }),
2470
+ observedState: "failedToStart",
2471
+ nodeId: "$launch",
2472
+ reason: safe.message,
2473
+ });
2474
+ try {
2475
+ storeTurnDescriptor(ctx, descriptor, true);
2476
+ syncArmed = true;
2477
+ } catch (intentError) {
2478
+ notify(
2479
+ ctx,
2480
+ `Workflow ${claimed.workflowName} failed to start, but Pi Workflows could not preserve its successor turn: ${errorMessage(intentError)}`,
2481
+ "warning",
2482
+ );
2483
+ }
2484
+ notify(ctx, content, "error");
2485
+ runSyncPass(ctx);
2486
+ return false;
2487
+ }
2488
+ };
2489
+
2490
+ activationRecovery = (ctx) => {
2491
+ if (activeRun !== null) return;
2492
+ const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
2493
+ ctx.sessionManager.getSessionId(),
2494
+ );
2495
+ if (prepared !== undefined && ["queued", "starting"].includes(prepared.status)) {
2496
+ void activatePreparedLaunch(ctx, prepared).catch(() => undefined);
2497
+ }
1962
2498
  };
1963
2499
 
1964
2500
  pi.registerCommand("piw", {
@@ -2008,7 +2544,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2008
2544
  return;
2009
2545
  }
2010
2546
  if (parsed.kind === "cancel") {
2011
- const result = await cancelWorkflowControl(ctx);
2547
+ const result = await cancelWorkflowControl(ctx, "user");
2012
2548
  notify(ctx, result.message, result.level);
2013
2549
  return;
2014
2550
  }
@@ -2176,6 +2712,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2176
2712
  case "stop":
2177
2713
  if (activeRun?.childKey !== undefined) {
2178
2714
  activeRun.interruptionRequested = true;
2715
+ activeRun.pendingAbortCause = "controllerInterrupted";
2179
2716
  activeRun.engine.cancel();
2180
2717
  }
2181
2718
  await host.stop();
@@ -2192,7 +2729,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2192
2729
  name: "workflow",
2193
2730
  label: "Workflow",
2194
2731
  description: [
2195
- "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
2732
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2196
2733
  "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, stopWhen, and optional maxChecks.",
2197
2734
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
2198
2735
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
@@ -2218,7 +2755,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2218
2755
  control = resumeWorkflowControl(ctx);
2219
2756
  break;
2220
2757
  case "cancel":
2221
- control = await cancelWorkflowControl(ctx);
2758
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
2222
2759
  break;
2223
2760
  case "answer": {
2224
2761
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
@@ -2348,7 +2885,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
2348
2885
  notify(ctx, `Could not recover human decisions: ${errorMessage(error)}`, "warning");
2349
2886
  }
2350
2887
  try {
2351
- await resumeParkedRun(ctx);
2888
+ const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
2889
+ ctx.sessionManager.getSessionId(),
2890
+ );
2891
+ if (prepared !== undefined && ["queued", "starting"].includes(prepared.status)) {
2892
+ await activatePreparedLaunch(ctx, prepared);
2893
+ } else {
2894
+ await resumeParkedRun(ctx);
2895
+ }
2352
2896
  } catch (error) {
2353
2897
  notify(ctx, `Could not resume a parked workflow: ${errorMessage(error)}`, "warning");
2354
2898
  }
@@ -2393,6 +2937,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2393
2937
  if (!aborted || runHeld()) {
2394
2938
  return;
2395
2939
  }
2940
+ run.suppressTurnIntent = true;
2396
2941
  run.engine.pause();
2397
2942
  run.executor.hold();
2398
2943
  renderWidget(ctx);
@@ -2443,30 +2988,39 @@ export default function piWorkflows(pi: ExtensionAPI) {
2443
2988
  });
2444
2989
 
2445
2990
  pi.on("agent_settled", async (_event, ctx) => {
2446
- if (activeRun === null && pendingToolLaunch !== null) {
2447
- const launch = pendingToolLaunch;
2448
- pendingToolLaunch = null;
2991
+ systemTurnAbort = null;
2992
+ if (activeRun === null) {
2449
2993
  try {
2450
- const runId = await startRun(launch.ctx, launch.ref, launch.input, launch.options);
2451
- if (runId === undefined) {
2452
- notify(launch.ctx, "The queued workflow could not start.", "error");
2453
- } else if (launch.options?.parentRunId !== undefined) {
2454
- lastWaitingRunId = null;
2994
+ const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
2995
+ ctx.sessionManager.getSessionId(),
2996
+ );
2997
+ if (prepared !== undefined && ["queued", "starting"].includes(prepared.status)) {
2998
+ await activatePreparedLaunch(ctx, prepared);
2999
+ return;
2455
3000
  }
2456
3001
  } catch (error) {
2457
- notify(launch.ctx, `Could not start queued workflow: ${errorMessage(error)}`, "error");
3002
+ notify(
3003
+ ctx,
3004
+ `Could not activate a queued workflow: ${safeLaunchError(error).message}`,
3005
+ "warning",
3006
+ );
3007
+ return;
2458
3008
  }
2459
- return;
2460
3009
  }
3010
+ const flushedNatural = turnCoordinator.flushNatural(
3011
+ ctx.isIdle() && !sessionClosed && !runHeld(),
3012
+ );
2461
3013
  const run = activeRun;
2462
3014
  if (!run) {
2463
3015
  presentationPending = null;
3016
+ runSyncPass(ctx);
2464
3017
  return;
2465
3018
  }
2466
3019
  await run.recorder?.synchronize(ctx).catch(() => undefined);
2467
3020
  run.recorder?.settleAttempt();
2468
3021
  run.executor.setStreaming(false);
2469
- run.executor.handleAgentSettled();
3022
+ if (flushedNatural === 0) run.executor.handleAgentSettled();
3023
+ runSyncPass(ctx);
2470
3024
  });
2471
3025
 
2472
3026
  pi.on("session_shutdown", async () => {
@@ -2474,8 +3028,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
2474
3028
  herdrProbeGeneration += 1;
2475
3029
  systemTurnAbort = null;
2476
3030
  suppressWorkflowAssistantTail = false;
3031
+ turnCoordinator.clearDeferred();
2477
3032
  supersedePresentation();
2478
3033
  const run = activeRun;
3034
+ if (run !== null) run.suppressTurnIntent = true;
2479
3035
  if (run !== null && run.claimToken !== undefined) {
2480
3036
  // Queued interactive runs park: no terminal event, no recorded partial
2481
3037
  // attempt, and the claim releases so another runner can resume.
@@ -2486,7 +3042,6 @@ export default function piWorkflows(pi: ExtensionAPI) {
2486
3042
  await run?.recorder?.stop().catch(() => undefined);
2487
3043
  await run?.completion?.catch(() => undefined);
2488
3044
  activeRun = null;
2489
- pendingToolLaunch = null;
2490
3045
  lastWaitingRunId = null;
2491
3046
  if (runSyncTimer !== null) {
2492
3047
  clearInterval(runSyncTimer);