@osolmaz/pi-workflows 0.11.2 → 0.12.1

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 (177) hide show
  1. package/README.md +15 -10
  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 +509 -124
  6. package/dist/builtins/autoimplement.workflow.js +884 -327
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/catalog.js +5 -3
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +4 -1
  11. package/dist/builtins/index.js +3 -1
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/monitor.workflow.d.ts +2 -4
  14. package/dist/builtins/monitor.workflow.js +26 -128
  15. package/dist/builtins/monitor.workflow.js.map +1 -1
  16. package/dist/builtins/pi-agent-group.d.ts +72 -0
  17. package/dist/builtins/pi-agent-group.js +1087 -0
  18. package/dist/builtins/pi-agent-group.js.map +1 -0
  19. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  20. package/dist/builtins/plan-approval.workflow.js +92 -14
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  22. package/dist/builtins/plan-change.workflow.d.ts +301 -0
  23. package/dist/builtins/plan-change.workflow.js +256 -0
  24. package/dist/builtins/plan-change.workflow.js.map +1 -0
  25. package/dist/builtins/plan-presentation.js +2 -2
  26. package/dist/builtins/plan-presentation.js.map +1 -1
  27. package/dist/builtins/sanity-check.workflow.d.ts +85 -0
  28. package/dist/builtins/sanity-check.workflow.js +482 -0
  29. package/dist/builtins/sanity-check.workflow.js.map +1 -0
  30. package/dist/controllers/sqlite.d.ts +90 -2
  31. package/dist/controllers/sqlite.js +380 -5
  32. package/dist/controllers/sqlite.js.map +1 -1
  33. package/dist/extension/decision-channels.d.ts +2 -2
  34. package/dist/extension/decision-channels.js +29 -35
  35. package/dist/extension/decision-channels.js.map +1 -1
  36. package/dist/extension/deferred-turn-coordinator.d.ts +32 -0
  37. package/dist/extension/deferred-turn-coordinator.js +143 -0
  38. package/dist/extension/deferred-turn-coordinator.js.map +1 -0
  39. package/dist/extension/deferred-turn.d.ts +44 -0
  40. package/dist/extension/deferred-turn.js +110 -0
  41. package/dist/extension/deferred-turn.js.map +1 -0
  42. package/dist/extension/index.js +348 -80
  43. package/dist/extension/index.js.map +1 -1
  44. package/dist/extension/session-events.d.ts +2 -2
  45. package/dist/extension/step-message.d.ts +1 -0
  46. package/dist/extension/step-message.js.map +1 -1
  47. package/dist/extension/widget.js +23 -3
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/herdr/setup.js +4 -4
  50. package/dist/render/graph-render.js +1 -2
  51. package/dist/render/graph-render.js.map +1 -1
  52. package/dist/viewer/render.js +7 -6
  53. package/dist/viewer/render.js.map +1 -1
  54. package/dist/workflows/catalog.js +7 -2
  55. package/dist/workflows/catalog.js.map +1 -1
  56. package/dist/workflows/command-batch.d.ts +38 -0
  57. package/dist/workflows/command-batch.js +176 -0
  58. package/dist/workflows/command-batch.js.map +1 -0
  59. package/dist/workflows/composition.js +8 -0
  60. package/dist/workflows/composition.js.map +1 -1
  61. package/dist/workflows/decision-presentation.d.ts +1 -1
  62. package/dist/workflows/decision-presentation.js +51 -38
  63. package/dist/workflows/decision-presentation.js.map +1 -1
  64. package/dist/workflows/engine.d.ts +2 -2
  65. package/dist/workflows/engine.js +22 -17
  66. package/dist/workflows/engine.js.map +1 -1
  67. package/dist/workflows/errors.d.ts +13 -0
  68. package/dist/workflows/errors.js +15 -0
  69. package/dist/workflows/errors.js.map +1 -1
  70. package/dist/workflows/human-decision.d.ts +16 -4
  71. package/dist/workflows/human-decision.js +175 -72
  72. package/dist/workflows/human-decision.js.map +1 -1
  73. package/dist/workflows/index.d.ts +3 -2
  74. package/dist/workflows/index.js +2 -1
  75. package/dist/workflows/index.js.map +1 -1
  76. package/dist/workflows/progress.d.ts +1 -0
  77. package/dist/workflows/progress.js +15 -3
  78. package/dist/workflows/progress.js.map +1 -1
  79. package/dist/workflows/schema.js +12 -1
  80. package/dist/workflows/schema.js.map +1 -1
  81. package/dist/workflows/shell.d.ts +4 -0
  82. package/dist/workflows/shell.js +6 -0
  83. package/dist/workflows/shell.js.map +1 -1
  84. package/dist/workflows/store.js +8 -1
  85. package/dist/workflows/store.js.map +1 -1
  86. package/dist/workflows/types.d.ts +37 -48
  87. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  88. package/docs/2026-08-20-durable-workflow-launch-plan.md +19 -23
  89. package/docs/CONTROLLERS.md +2 -2
  90. package/docs/DEFERRED_TURNS.md +298 -0
  91. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  92. package/docs/HUMAN_DECISIONS.md +30 -40
  93. package/docs/HUMAN_DECISION_PRESENTATIONS.md +17 -27
  94. package/docs/MONITOR.md +7 -13
  95. package/docs/WORKFLOW_COMPOSITION.md +10 -9
  96. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  97. package/docs/WORKFLOW_UPDATES.md +28 -5
  98. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  99. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  100. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  101. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  102. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  103. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  104. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  105. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  106. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  107. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  108. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  109. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  110. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  111. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  112. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  113. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  114. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  115. package/docs/plans/2026-08-21-sanity-check-plan.md +283 -0
  116. package/docs/run-bundles.md +8 -6
  117. package/docs/workflows.md +85 -22
  118. package/examples/workflows/approved-plan.workflow.ts +19 -46
  119. package/examples/workflows/command-batch.workflow.ts +50 -0
  120. package/examples/workflows/sanity-check.workflow.ts +1 -0
  121. package/herdr-plugin.toml +3 -3
  122. package/package.json +7 -7
  123. package/schemas/decision-presentation-v1.schema.json +1 -1
  124. package/schemas/human-decision-accepted-v1.schema.json +16 -4
  125. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  126. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  127. package/schemas/human-decision-continuation-v1.schema.json +11 -2
  128. package/schemas/human-decision-delivery-v1.schema.json +9 -1
  129. package/schemas/human-decision-receipt-v1.schema.json +9 -1
  130. package/schemas/human-decision-request-v1.schema.json +25 -5
  131. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  132. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  133. package/skills/autodoc/SKILL.md +33 -2
  134. package/skills/autoimplement/SKILL.md +93 -12
  135. package/skills/autoplan/SKILL.md +26 -2
  136. package/skills/monitor/SKILL.md +66 -24
  137. package/skills/pi-workflows/SKILL.md +21 -6
  138. package/skills/sanity-check/SKILL.md +44 -0
  139. package/src/builtins/autoimplement-command-batches.ts +364 -0
  140. package/src/builtins/autoimplement.workflow.ts +1075 -370
  141. package/src/builtins/catalog.ts +5 -3
  142. package/src/builtins/index.ts +24 -0
  143. package/src/builtins/monitor.workflow.ts +27 -150
  144. package/src/builtins/pi-agent-group.ts +1407 -0
  145. package/src/builtins/plan-approval.workflow.ts +157 -24
  146. package/src/builtins/plan-change.workflow.ts +321 -0
  147. package/src/builtins/plan-presentation.ts +2 -2
  148. package/src/builtins/sanity-check.workflow.ts +769 -0
  149. package/src/controllers/sqlite.ts +580 -7
  150. package/src/extension/decision-channels.ts +36 -66
  151. package/src/extension/deferred-turn-coordinator.ts +171 -0
  152. package/src/extension/deferred-turn.ts +166 -0
  153. package/src/extension/index.ts +438 -90
  154. package/src/extension/session-events.ts +2 -2
  155. package/src/extension/step-message.ts +1 -0
  156. package/src/extension/widget.ts +24 -5
  157. package/src/herdr/setup.ts +4 -4
  158. package/src/render/graph-render.ts +1 -2
  159. package/src/viewer/render.ts +7 -6
  160. package/src/workflows/catalog.ts +7 -2
  161. package/src/workflows/command-batch.ts +254 -0
  162. package/src/workflows/composition.ts +9 -0
  163. package/src/workflows/decision-presentation.ts +56 -43
  164. package/src/workflows/engine.ts +25 -20
  165. package/src/workflows/errors.ts +24 -0
  166. package/src/workflows/human-decision.ts +218 -101
  167. package/src/workflows/index.ts +20 -11
  168. package/src/workflows/progress.ts +18 -3
  169. package/src/workflows/schema.ts +19 -1
  170. package/src/workflows/shell.ts +10 -0
  171. package/src/workflows/store.ts +8 -1
  172. package/src/workflows/types.ts +43 -59
  173. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  174. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  175. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  176. package/schemas/human-decision-request-v2.schema.json +0 -69
  177. package/schemas/human-decision-resolution-v2.schema.json +0 -27
@@ -8,12 +8,17 @@ import {
8
8
  SqliteControllerStore,
9
9
  type WorkflowRunQueueRecord,
10
10
  } from "../controllers/index.js";
11
+ import type {
12
+ WorkflowTurnIntentCause,
13
+ WorkflowTurnIntentResolution,
14
+ } from "../controllers/sqlite.js";
11
15
  import type { JsonObject } from "../controllers/types.js";
12
16
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
13
17
  import { compositionMetadata } from "../workflows/composition.js";
14
18
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
15
19
  import { WorkflowEngine } from "../workflows/engine.js";
16
20
  import {
21
+ BuiltinWorkflowRevisionChangedError,
17
22
  ClaimLostError,
18
23
  errorMessage,
19
24
  isClaimLostError,
@@ -37,6 +42,7 @@ import {
37
42
  } from "../workflows/store.js";
38
43
  import type {
39
44
  AcceptedHumanDecision,
45
+ ResolvedHumanDecision,
40
46
  AgentStepContract,
41
47
  HumanDecisionRequest,
42
48
  HumanDecisionSubmission,
@@ -44,6 +50,7 @@ import type {
44
50
  WorkflowDefinitionSnapshot,
45
51
  WorkflowRunResult,
46
52
  WorkflowRunState,
53
+ WorkflowSource,
47
54
  WorkflowUpdateRecord,
48
55
  } from "../workflows/types.js";
49
56
  import {
@@ -63,6 +70,19 @@ import {
63
70
  type HumanDecisionChannelAnswer,
64
71
  type SettledHumanDecision,
65
72
  } from "./decision-channels.js";
73
+ import {
74
+ DeferredTurnCoordinator,
75
+ type BranchIntentResolution,
76
+ } from "./deferred-turn-coordinator.js";
77
+ import {
78
+ buildDeferredTurnContent,
79
+ createDeferredTurnDescriptor,
80
+ DEFERRED_TURN_MESSAGE_TYPE,
81
+ deferredTurnMessageDetails,
82
+ deferredTurnMessageId,
83
+ deferredTurnSourceEventId,
84
+ type DeferredTurnDescriptor,
85
+ } from "./deferred-turn.js";
66
86
  import { ConversationStepExecutor } from "./executor.js";
67
87
  import {
68
88
  HerdrWorkflowViewer,
@@ -108,8 +128,10 @@ const RUN_CLAIM_LEASE_MS = 30_000;
108
128
  const RUN_CLAIM_RENEW_MS = 10_000;
109
129
  const RUN_SYNC_POLL_MS = 3_000;
110
130
  const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
131
+ const TURN_INTENT_DELIVERY_LEASE_MS = 30_000;
111
132
  const WIDGET_KEY = "pi-workflows";
112
133
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
134
+ const PRESENTATION_MESSAGE_SCHEMA = "pi-workflows.presentation-message.v1";
113
135
  const FINAL_WIDGET_TTL_MS = 60_000;
114
136
  const WIDGET_SCROLL_STEP = 3;
115
137
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
@@ -138,6 +160,12 @@ type PresentationPromptBuilder = Exclude<
138
160
  string | undefined
139
161
  >;
140
162
 
163
+ type AbortProvenance = {
164
+ cause: WorkflowTurnIntentCause;
165
+ descriptor?: DeferredTurnDescriptor;
166
+ storageError?: string;
167
+ };
168
+
141
169
  type ActiveRun = {
142
170
  runId: string;
143
171
  workflowName: string;
@@ -153,6 +181,9 @@ type ActiveRun = {
153
181
  onFinish?: (result: WorkflowSchedulerResult) => void;
154
182
  completion?: Promise<void>;
155
183
  interruptionRequested?: boolean;
184
+ pendingAbortCause?: WorkflowTurnIntentCause | undefined;
185
+ abortProvenance?: AbortProvenance | undefined;
186
+ suppressTurnIntent?: boolean | undefined;
156
187
  claimToken?: string | undefined;
157
188
  renewTimer?: ReturnType<typeof setInterval> | undefined;
158
189
  /** True for runs this session resumed from the queue. */
@@ -166,19 +197,14 @@ function humanDecisionRequest(value: unknown): HumanDecisionRequest | null {
166
197
  value !== null && typeof value === "object"
167
198
  ? (value as { schema?: unknown }).schema
168
199
  : undefined;
169
- if (
170
- schema !== "pi-workflows.human-decision-request.v1" &&
171
- schema !== "pi-workflows.human-decision-request.v2"
172
- ) {
173
- return null;
174
- }
200
+ if (schema !== "pi-workflows.human-decision-request.v1") return null;
175
201
  return value as HumanDecisionRequest;
176
202
  }
177
203
 
178
204
  type PreparedLaunchOptions = {
179
205
  presentation?: boolean;
180
206
  parentRunId?: string;
181
- humanDecision?: AcceptedHumanDecision;
207
+ humanDecision?: ResolvedHumanDecision;
182
208
  };
183
209
 
184
210
  type StartRunOptions = {
@@ -190,8 +216,8 @@ type StartRunOptions = {
190
216
  signal?: AbortSignal;
191
217
  /** Continue a checkpointed run: input becomes the legacy answer payload. */
192
218
  parentRunId?: string;
193
- /** Accepted verified-human answer for a protected decision continuation. */
194
- humanDecision?: AcceptedHumanDecision;
219
+ /** Durable human or timeout response for a protected decision continuation. */
220
+ humanDecision?: ResolvedHumanDecision;
195
221
  /** Resume a parked run at its stopped node instead of starting fresh. */
196
222
  resume?: boolean;
197
223
  /** An existing queue claim token, when the caller already claimed the run. */
@@ -209,6 +235,29 @@ function launchSourceIdentity(workflow: WorkflowDefinition, root: unknown): unkn
209
235
  };
210
236
  }
211
237
 
238
+ function continuationSourceChangedError(
239
+ runId: string,
240
+ previous: WorkflowSource,
241
+ current: WorkflowSource,
242
+ ): Error {
243
+ if (
244
+ previous.kind === "builtin" &&
245
+ current.kind === "builtin" &&
246
+ previous.id === current.id &&
247
+ previous.revision !== current.revision
248
+ ) {
249
+ return new BuiltinWorkflowRevisionChangedError({
250
+ runId,
251
+ workflowId: current.id,
252
+ previousRevision: previous.revision,
253
+ currentRevision: current.revision,
254
+ });
255
+ }
256
+ return new Error(
257
+ `Workflow source changed since run ${runId} started; revert the edit to answer its checkpoint`,
258
+ );
259
+ }
260
+
212
261
  function preparedLaunchOptions(options: StartRunOptions): PreparedLaunchOptions {
213
262
  return {
214
263
  ...(options.presentation !== undefined ? { presentation: options.presentation } : {}),
@@ -400,6 +449,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
400
449
  let activationRecovery: ((ctx: ExtensionContext) => void) | undefined;
401
450
  let decisionRecoveryTimer: ReturnType<typeof setInterval> | null = null;
402
451
  let decisionRecoveryActive = false;
452
+ let turnCoordinator: DeferredTurnCoordinator;
403
453
 
404
454
  const recordRunEvent = (event: {
405
455
  runId: string;
@@ -453,9 +503,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
453
503
  kind: notification.kind,
454
504
  },
455
505
  },
456
- notification.kind === "launch_failure"
457
- ? { triggerTurn: true, deliverAs: "followUp" }
458
- : { triggerTurn: false },
506
+ { triggerTurn: false },
459
507
  );
460
508
  alreadyDelivered.add(notification.notificationId);
461
509
  }
@@ -465,6 +513,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
465
513
  claimToken,
466
514
  });
467
515
  }
516
+ const idle = ctx.isIdle() && systemTurnAbort === null && !sessionClosed && !runHeld();
517
+ turnCoordinator.flushNatural(idle);
518
+ turnCoordinator.deliverFallbacks(
519
+ {
520
+ targetSessionId: sessionId,
521
+ send: (intent) => {
522
+ pi.sendMessage(
523
+ {
524
+ customType: DEFERRED_TURN_MESSAGE_TYPE,
525
+ content: buildDeferredTurnContent(intent),
526
+ display: true,
527
+ details: deferredTurnMessageDetails(intent),
528
+ },
529
+ { triggerTurn: true, deliverAs: "followUp" },
530
+ );
531
+ },
532
+ },
533
+ idle,
534
+ );
468
535
  } catch {
469
536
  // Delivery retries on the next poll. It never affects workflow execution.
470
537
  }
@@ -480,7 +547,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
480
547
  runSyncTimer.unref?.();
481
548
  };
482
549
  let activeRun: ActiveRun | null = null;
483
- let systemTurnAbort: AgentStepContract | null = null;
550
+ let systemTurnAbort: { contract: AgentStepContract; intentId?: string } | null = null;
484
551
  let suppressWorkflowAssistantTail = false;
485
552
  let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
486
553
  // The interactive run currently parked at a checkpoint, if any.
@@ -501,6 +568,30 @@ export default function piWorkflows(pi: ExtensionAPI) {
501
568
  let controllerHost: PiControllerHost | undefined;
502
569
  let controllerContext: ExtensionContext | null = null;
503
570
 
571
+ const branchIntentResolution = (intentId: string): BranchIntentResolution | null => {
572
+ if (controllerContext === null) return null;
573
+ for (const entry of controllerContext.sessionManager.getBranch()) {
574
+ if (entry.type !== "custom_message") continue;
575
+ const details = entry.details;
576
+ if (details === null || typeof details !== "object" || Array.isArray(details)) continue;
577
+ if ((details as { turnIntentId?: unknown }).turnIntentId !== intentId) continue;
578
+ let resolution: WorkflowTurnIntentResolution | null = null;
579
+ if (entry.customType === WORKFLOW_AGENT_STEP_MESSAGE_TYPE) resolution = "workflowPrompt";
580
+ if (entry.customType === PRESENTATION_MESSAGE_TYPE) resolution = "presentation";
581
+ if (entry.customType === DEFERRED_TURN_MESSAGE_TYPE) resolution = "fallback";
582
+ if (resolution !== null) {
583
+ return { resolution, messageId: deferredTurnMessageId(intentId, resolution) };
584
+ }
585
+ }
586
+ return null;
587
+ };
588
+
589
+ turnCoordinator = new DeferredTurnCoordinator({
590
+ store: () => runQueueStore,
591
+ branchResolution: branchIntentResolution,
592
+ leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
593
+ });
594
+
504
595
  // UI updates are best-effort: a captured ctx becomes stale after session
505
596
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
506
597
  // A workflow finishing right as the session goes away must not crash pi.
@@ -542,6 +633,145 @@ export default function piWorkflows(pi: ExtensionAPI) {
542
633
  const runHeld = (): boolean =>
543
634
  activeRun !== null && (activeRun.engine.pauseRequested || activeRun.executor.held);
544
635
 
636
+ const originSessionId = (ctx: ExtensionContext, runId: string): string =>
637
+ ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId)?.originSessionId ??
638
+ ctx.sessionManager.getSessionId();
639
+
640
+ const storeTurnDescriptor = (
641
+ ctx: ExtensionContext,
642
+ descriptor: DeferredTurnDescriptor,
643
+ eligible: boolean,
644
+ ): void => {
645
+ ensureRunQueueStore(ctx.cwd).ensureWorkflowTurnIntent({ ...descriptor, eligible });
646
+ };
647
+
648
+ const ensureAbortTurnIntent = (
649
+ ctx: ExtensionContext,
650
+ run: ActiveRun,
651
+ cause: WorkflowTurnIntentCause,
652
+ contract: AgentStepContract,
653
+ reason: unknown,
654
+ ): DeferredTurnDescriptor => {
655
+ if (run.abortProvenance?.descriptor !== undefined) {
656
+ return run.abortProvenance.descriptor;
657
+ }
658
+ const descriptor = createDeferredTurnDescriptor({
659
+ runId: run.runId,
660
+ workflowName: run.workflowName,
661
+ targetSessionId: originSessionId(ctx, run.runId),
662
+ cause,
663
+ sourceEventId: deferredTurnSourceEventId({
664
+ runId: run.runId,
665
+ cause,
666
+ nodeId: contract.nodeId,
667
+ attemptId: contract.attemptId,
668
+ source: "agent-step-abort",
669
+ }),
670
+ observedState: cause === "claimLost" ? "handedOff" : "interrupted",
671
+ nodeId: contract.nodeId,
672
+ attemptId: contract.attemptId,
673
+ reason: errorMessage(reason),
674
+ handoff: cause === "claimLost",
675
+ });
676
+ run.abortProvenance = { cause, descriptor };
677
+ try {
678
+ storeTurnDescriptor(ctx, descriptor, false);
679
+ } catch (error) {
680
+ run.abortProvenance.storageError = errorMessage(error);
681
+ }
682
+ return descriptor;
683
+ };
684
+
685
+ const makeRunTurnIntentEligible = (
686
+ ctx: ExtensionContext,
687
+ run: ActiveRun,
688
+ observedState: string,
689
+ reason?: string,
690
+ ): void => {
691
+ if (
692
+ sessionClosed ||
693
+ run.suppressTurnIntent === true ||
694
+ run.abortProvenance?.cause === "claimLost"
695
+ ) {
696
+ return;
697
+ }
698
+ const targetSessionId = originSessionId(ctx, run.runId);
699
+ const pending = ensureRunQueueStore(ctx.cwd).findPendingWorkflowTurnIntent({
700
+ runId: run.runId,
701
+ targetSessionId,
702
+ });
703
+ if (
704
+ run.childKey !== undefined &&
705
+ run.abortProvenance === undefined &&
706
+ pending?.cause !== "claimLost"
707
+ ) {
708
+ return;
709
+ }
710
+ const cause =
711
+ pending?.cause ??
712
+ run.abortProvenance?.cause ??
713
+ (observedState === "timed_out" ? "timedOut" : "failed");
714
+ if (
715
+ observedState === "cancelled" &&
716
+ run.abortProvenance === undefined &&
717
+ pending === undefined
718
+ ) {
719
+ return;
720
+ }
721
+ const previous = run.abortProvenance?.descriptor;
722
+ const sourceEventId =
723
+ pending?.sourceEventId ??
724
+ previous?.sourceEventId ??
725
+ deferredTurnSourceEventId({
726
+ runId: run.runId,
727
+ cause,
728
+ nodeId: "$terminal",
729
+ source: "terminal",
730
+ });
731
+ const descriptor = createDeferredTurnDescriptor({
732
+ runId: run.runId,
733
+ workflowName: pending?.workflowRef ?? run.workflowName,
734
+ targetSessionId,
735
+ cause,
736
+ sourceEventId,
737
+ observedState,
738
+ nodeId: pending?.nodeId ?? previous?.nodeId ?? "$terminal",
739
+ attemptId: pending?.attemptId ?? previous?.attemptId ?? null,
740
+ reason: reason ?? null,
741
+ handoff: false,
742
+ });
743
+ run.abortProvenance = {
744
+ cause,
745
+ descriptor,
746
+ ...(run.abortProvenance?.storageError === undefined
747
+ ? {}
748
+ : { storageError: run.abortProvenance.storageError }),
749
+ };
750
+ try {
751
+ storeTurnDescriptor(ctx, descriptor, false);
752
+ ensureRunQueueStore(ctx.cwd).makeWorkflowTurnIntentEligible({
753
+ intentId: descriptor.intentId,
754
+ fallbackFacts: descriptor.fallbackFacts,
755
+ });
756
+ delete run.abortProvenance.storageError;
757
+ syncArmed = true;
758
+ } catch (error) {
759
+ const message = errorMessage(error);
760
+ run.abortProvenance.storageError = message;
761
+ recordRunEvent({
762
+ runId: run.runId,
763
+ workflowRef: run.workflowName,
764
+ type: "turn_intent_failed",
765
+ payload: { error: message },
766
+ });
767
+ notify(
768
+ ctx,
769
+ `Workflow ${run.workflowName} ended, but Pi Workflows could not preserve its successor turn: ${message}`,
770
+ "warning",
771
+ );
772
+ }
773
+ };
774
+
545
775
  const footerStatus = (state: WorkflowRunState): string => {
546
776
  const label = runHeld() || state.paused ? "paused" : state.status;
547
777
  const node = state.currentNode ?? state.waitingOn;
@@ -742,24 +972,41 @@ export default function piWorkflows(pi: ExtensionAPI) {
742
972
  typeof run.presentationPrompt === "function"
743
973
  ? await resolvePresentationPrompt(run.presentationPrompt, state, abort.signal)
744
974
  : run.presentationPrompt;
745
- if (
746
- sessionClosed ||
747
- run.generation !== runGeneration ||
748
- abort.signal.aborted ||
749
- instructions === undefined ||
750
- instructions.trim().length === 0
751
- ) {
975
+ if (sessionClosed || run.generation !== runGeneration || abort.signal.aborted) {
752
976
  return;
753
977
  }
754
- presentationPending = run.generation;
755
- suppressWorkflowAssistantTail = false;
756
- pi.sendMessage(
978
+ if (instructions === undefined || instructions.trim().length === 0) {
979
+ if (run.abortProvenance !== undefined) {
980
+ makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
981
+ }
982
+ return;
983
+ }
984
+ turnCoordinator.sendNatural(
757
985
  {
758
- customType: PRESENTATION_MESSAGE_TYPE,
759
- content: buildPresentationMessage(instructions, state),
760
- display: false,
986
+ runId: run.runId,
987
+ targetSessionId: originSessionId(ctx, run.runId),
988
+ resolution: "presentation",
989
+ send: (turnIntentId) => {
990
+ presentationPending = run.generation;
991
+ suppressWorkflowAssistantTail = false;
992
+ pi.sendMessage(
993
+ {
994
+ customType: PRESENTATION_MESSAGE_TYPE,
995
+ content: buildPresentationMessage(instructions, state),
996
+ display: false,
997
+ details: {
998
+ schema: PRESENTATION_MESSAGE_SCHEMA,
999
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
1000
+ },
1001
+ },
1002
+ {
1003
+ deliverAs: turnIntentId === undefined ? "steer" : "followUp",
1004
+ triggerTurn: true,
1005
+ },
1006
+ );
1007
+ },
761
1008
  },
762
- { deliverAs: "steer", triggerTurn: true },
1009
+ ctx.isIdle() && systemTurnAbort === null,
763
1010
  );
764
1011
  } catch (error) {
765
1012
  if (presentationPending === run.generation) {
@@ -777,6 +1024,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
777
1024
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
778
1025
  : errorMessage(error);
779
1026
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
1027
+ if (run.abortProvenance !== undefined) {
1028
+ makeRunTurnIntentEligible(ctx, run, state.status, message);
1029
+ }
780
1030
  } finally {
781
1031
  clearTimeout(timer);
782
1032
  if (presentationAbort === abort) {
@@ -829,6 +1079,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
829
1079
  clearWidget(ctx);
830
1080
  return;
831
1081
  }
1082
+ if (run.abortProvenance?.cause === "claimLost") {
1083
+ await run.recorder?.stop().catch(() => undefined);
1084
+ releaseClaim(run, "lost");
1085
+ recordRunEvent({
1086
+ runId: run.runId,
1087
+ workflowRef: run.workflowName,
1088
+ type: "claim_lost",
1089
+ });
1090
+ stopWidgetTicker();
1091
+ clearWidget(ctx);
1092
+ notify(
1093
+ ctx,
1094
+ `Workflow ${run.workflowName} continues under another runner (run ${run.runId}).`,
1095
+ );
1096
+ return;
1097
+ }
832
1098
  releaseClaim(run, "done");
833
1099
  recordRunEvent({
834
1100
  runId: run.runId,
@@ -880,6 +1146,15 @@ export default function piWorkflows(pi: ExtensionAPI) {
880
1146
  } catch (error) {
881
1147
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
882
1148
  }
1149
+ if (state.status === "failed" || state.status === "timed_out" || state.status === "cancelled") {
1150
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
1151
+ } else if (
1152
+ run.abortProvenance !== undefined &&
1153
+ run.presentationPrompt === undefined &&
1154
+ (state.status === "completed" || state.status === "waiting")
1155
+ ) {
1156
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
1157
+ }
883
1158
  void presentRun(ctx, run, state);
884
1159
  if (pendingDecision !== null && state.status === "waiting") {
885
1160
  const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
@@ -956,8 +1231,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
956
1231
  parent.state.workflowSource !== undefined &&
957
1232
  !isDeepStrictEqual(parent.state.workflowSource, workflowSource)
958
1233
  ) {
959
- throw new Error(
960
- `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
1234
+ throw continuationSourceChangedError(
1235
+ options.parentRunId,
1236
+ parent.state.workflowSource,
1237
+ workflowSource,
961
1238
  );
962
1239
  }
963
1240
  }
@@ -1011,23 +1288,34 @@ export default function piWorkflows(pi: ExtensionAPI) {
1011
1288
 
1012
1289
  const executor = new ConversationStepExecutor({
1013
1290
  sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
1014
- const details: WorkflowAgentStepMessageDetails = {
1015
- schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
1016
- kind,
1017
- contract,
1018
- ...(presentation !== undefined ? { presentation } : {}),
1019
- };
1020
- pi.sendMessage(
1291
+ turnCoordinator.sendNatural(
1021
1292
  {
1022
- customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
1023
- content: prompt,
1024
- display: true,
1025
- details,
1026
- },
1027
- {
1028
- triggerTurn: true,
1029
- deliverAs: streaming ? "steer" : "followUp",
1293
+ runId,
1294
+ targetSessionId: originSessionId(ctx, runId),
1295
+ resolution: "workflowPrompt",
1296
+ send: (turnIntentId) => {
1297
+ const details: WorkflowAgentStepMessageDetails = {
1298
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
1299
+ kind,
1300
+ contract,
1301
+ ...(presentation !== undefined ? { presentation } : {}),
1302
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
1303
+ };
1304
+ pi.sendMessage(
1305
+ {
1306
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
1307
+ content: prompt,
1308
+ display: true,
1309
+ details,
1310
+ },
1311
+ {
1312
+ triggerTurn: true,
1313
+ deliverAs: streaming ? "steer" : "followUp",
1314
+ },
1315
+ );
1316
+ },
1030
1317
  },
1318
+ ctx.isIdle() && systemTurnAbort === null,
1031
1319
  );
1032
1320
  },
1033
1321
  onAbort: (contract, reason) => {
@@ -1036,7 +1324,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
1036
1324
  reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
1037
1325
  };
1038
1326
  if (!executor.held && !ctx.isIdle()) {
1039
- systemTurnAbort = contract;
1327
+ const cause = reason instanceof TimeoutError ? "timedOut" : run.pendingAbortCause;
1328
+ const descriptor =
1329
+ cause === undefined
1330
+ ? undefined
1331
+ : ensureAbortTurnIntent(ctx, run, cause, contract, reason);
1332
+ run.pendingAbortCause = undefined;
1333
+ systemTurnAbort = {
1334
+ contract,
1335
+ ...(descriptor === undefined ? {} : { intentId: descriptor.intentId }),
1336
+ };
1040
1337
  ctx.abort();
1041
1338
  }
1042
1339
  },
@@ -1137,6 +1434,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1137
1434
  if (
1138
1435
  !store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })
1139
1436
  ) {
1437
+ run.pendingAbortCause = "claimLost";
1140
1438
  run.engine.cancel();
1141
1439
  }
1142
1440
  } catch {
@@ -1239,6 +1537,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1239
1537
  }
1240
1538
  if (terminalStatus !== undefined) {
1241
1539
  recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
1540
+ if (
1541
+ terminalStatus === "failed" ||
1542
+ terminalStatus === "timed_out" ||
1543
+ terminalStatus === "cancelled"
1544
+ ) {
1545
+ makeRunTurnIntentEligible(ctx, run, terminalStatus, message);
1546
+ }
1242
1547
  notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
1243
1548
  return;
1244
1549
  }
@@ -1248,6 +1553,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1248
1553
  type: "failed",
1249
1554
  payload: { error: message },
1250
1555
  });
1556
+ makeRunTurnIntentEligible(ctx, run, "failed", message);
1251
1557
  try {
1252
1558
  run.onFinish?.({ state: "failed", runId, error: message });
1253
1559
  } catch {
@@ -1478,11 +1784,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
1478
1784
 
1479
1785
  const cancelWorkflowControl = async (
1480
1786
  ctx: ExtensionContext,
1787
+ origin: "agent" | "user",
1481
1788
  requestedRunId?: string,
1482
1789
  ): Promise<WorkflowControlResult> => {
1483
1790
  if (activeRun && (requestedRunId === undefined || requestedRunId === activeRun.runId)) {
1484
1791
  const workflowName = activeRun.workflowName;
1485
1792
  const runId = activeRun.runId;
1793
+ activeRun.pendingAbortCause = origin === "agent" ? "agentCancelled" : undefined;
1794
+ activeRun.suppressTurnIntent = origin === "user";
1486
1795
  activeRun.engine.cancel();
1487
1796
  return {
1488
1797
  message: `Cancelling workflow ${workflowName}…`,
@@ -1581,6 +1890,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1581
1890
  },
1582
1891
  };
1583
1892
  }
1893
+ activeRun.suppressTurnIntent = true;
1584
1894
  activeRun.engine.pause();
1585
1895
  renderWidget(ctx);
1586
1896
  return {
@@ -1614,6 +1924,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1614
1924
  level: "warning",
1615
1925
  };
1616
1926
  }
1927
+ activeRun.suppressTurnIntent = false;
1617
1928
  activeRun.engine.resume();
1618
1929
  activeRun.executor.release();
1619
1930
  renderWidget(ctx);
@@ -1773,7 +2084,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1773
2084
  };
1774
2085
  const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1775
2086
  const acceptance = await store.accept(request, submission);
1776
- if (acceptance.status === "conflict") {
2087
+ if (acceptance.status === "conflict" || acceptance.decision.provenance !== "human") {
1777
2088
  throw new Error("That human decision was already answered differently.");
1778
2089
  }
1779
2090
  accepted = acceptance.decision;
@@ -1805,6 +2116,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1805
2116
  schema: "pi-workflows.human-decision-continuation.v1",
1806
2117
  decisionId: request.decisionId,
1807
2118
  requestDigest: request.requestDigest,
2119
+ provenance: accepted.provenance,
1808
2120
  parentRunId: waiting.parentRunId,
1809
2121
  runId: continuationRunId,
1810
2122
  createdAt: accepted.acceptedAt,
@@ -1930,60 +2242,72 @@ export default function piWorkflows(pi: ExtensionAPI) {
1930
2242
  queueRecord !== undefined &&
1931
2243
  (queueRecord.originSessionId === null ||
1932
2244
  queueRecord.originSessionId === ctx.sessionManager.getSessionId());
1933
- const accepted = await store.readAccepted(request.decisionId);
1934
- if (accepted === null) {
2245
+ let resolved = await store.readResolved(request.decisionId);
2246
+ if (resolved === null) {
1935
2247
  let cancellation = await store.readCancellation(request.decisionId);
1936
- if (
1937
- cancellation === null &&
1938
- request.expiresAt !== undefined &&
1939
- Date.parse(request.expiresAt) <= Date.now()
1940
- ) {
1941
- await store.cancel(request, "expired");
1942
- cancellation = await store.readCancellation(request.decisionId);
2248
+ const expired =
2249
+ request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now();
2250
+ if (cancellation === null && expired) {
2251
+ if (request.defaultResponse === undefined) {
2252
+ await store.cancel(request, "expired");
2253
+ cancellation = await store.readCancellation(request.decisionId);
2254
+ } else {
2255
+ try {
2256
+ resolved = (await store.resolveTimeout(request)).decision;
2257
+ } catch {
2258
+ cancellation = await store.readCancellation(request.decisionId);
2259
+ resolved = await store.readResolved(request.decisionId);
2260
+ }
2261
+ }
1943
2262
  }
1944
2263
  if (cancellation !== null) {
1945
2264
  await settleHumanDecisionChannels(cancellation);
1946
2265
  continue;
1947
2266
  }
1948
- if (!deliverPending || !ownedBySession) continue;
1949
- const channels = audienceChannels(decisionChannelConfig, request.audience);
1950
- for (const channelId of channels) {
1951
- const channel = telegramDecisionChannels.get(channelId);
1952
- if (channel !== undefined) await channel.deliver(humanDecisionChannelRequest(request));
1953
- }
1954
- if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
1955
- lastWaitingRunId = request.runId;
1956
- queueMicrotask(() => void promptHumanDecision(ctx, request));
2267
+ if (resolved === null) {
2268
+ if (!deliverPending || !ownedBySession) continue;
2269
+ const channels = audienceChannels(decisionChannelConfig, request.audience);
2270
+ for (const channelId of channels) {
2271
+ const channel = telegramDecisionChannels.get(channelId);
2272
+ if (channel !== undefined) await channel.deliver(humanDecisionChannelRequest(request));
2273
+ }
2274
+ if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
2275
+ lastWaitingRunId = request.runId;
2276
+ queueMicrotask(() => void promptHumanDecision(ctx, request));
2277
+ }
2278
+ continue;
1957
2279
  }
1958
- continue;
1959
2280
  }
1960
2281
 
1961
2282
  if (!ownedBySession) continue;
2283
+ const currentParent = await readRunBundle(runStore.runDirFor(request.runId));
2284
+ if (currentParent === null || currentParent.state.status !== "waiting") continue;
1962
2285
  const runId = `continuation-${request.decisionId.slice("decision-".length)}`;
1963
2286
  const continuation = (await store.readContinuation(request.decisionId)) ?? {
1964
2287
  schema: "pi-workflows.human-decision-continuation.v1" as const,
1965
2288
  decisionId: request.decisionId,
1966
2289
  requestDigest: request.requestDigest,
2290
+ provenance: resolved.provenance,
1967
2291
  parentRunId: request.runId,
1968
2292
  runId,
1969
- createdAt: accepted.acceptedAt,
2293
+ createdAt: resolved.acceptedAt,
1970
2294
  };
1971
2295
  await store.recordContinuation(request.decisionId, continuation);
1972
2296
  const existing = await readRunBundle(runStore.runDirFor(continuation.runId));
1973
2297
  if (existing === null && activeRun === null) {
1974
- if (parent.state.workflowSource === undefined) continue;
2298
+ if (currentParent.state.workflowSource === undefined) continue;
1975
2299
  const workflowRef =
1976
- parent.state.workflowSource.kind === "builtin"
1977
- ? `builtin:${parent.state.workflowSource.id}`
1978
- : parent.state.workflowSource.path;
1979
- await startRun(ctx, workflowRef, parent.state.input, {
2300
+ currentParent.state.workflowSource.kind === "builtin"
2301
+ ? `builtin:${currentParent.state.workflowSource.id}`
2302
+ : currentParent.state.workflowSource.path;
2303
+ await startRun(ctx, workflowRef, currentParent.state.input, {
1980
2304
  parentRunId: request.runId,
1981
- humanDecision: accepted,
2305
+ humanDecision: resolved,
1982
2306
  runId: continuation.runId,
1983
2307
  quiet: true,
1984
2308
  });
1985
2309
  }
1986
- await settleHumanDecisionChannels(accepted);
2310
+ await settleHumanDecisionChannels(resolved);
1987
2311
  if (activeRun !== null) break;
1988
2312
  }
1989
2313
  };
@@ -2064,8 +2388,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
2064
2388
  parent.state.workflowSource !== undefined &&
2065
2389
  !isDeepStrictEqual(parent.state.workflowSource, workflowSource)
2066
2390
  ) {
2067
- throw new Error(
2068
- `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
2391
+ throw continuationSourceChangedError(
2392
+ options.parentRunId,
2393
+ parent.state.workflowSource,
2394
+ workflowSource,
2069
2395
  );
2070
2396
  }
2071
2397
  }
@@ -2167,19 +2493,31 @@ export default function piWorkflows(pi: ExtensionAPI) {
2167
2493
  payload: { errorCode: safe.code, error: safe.message },
2168
2494
  });
2169
2495
  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.`;
2170
- try {
2171
- queue.enqueueWorkflowNotification({
2496
+ const cause = "launchFailed" as const;
2497
+ const descriptor = createDeferredTurnDescriptor({
2498
+ runId: claimed.runId,
2499
+ workflowName: claimed.workflowName,
2500
+ targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
2501
+ cause,
2502
+ sourceEventId: deferredTurnSourceEventId({
2172
2503
  runId: claimed.runId,
2504
+ cause,
2173
2505
  nodeId: "$launch",
2174
- attemptId: claimed.runId,
2175
- notificationIndex: 1,
2176
- targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
2177
- kind: "launch_failure",
2178
- content,
2179
- notificationId: `launch-failure:${claimed.runId}`,
2180
- });
2181
- } catch {
2182
- // A deterministic notification id makes duplicate insertion harmless.
2506
+ source: "queued-launch",
2507
+ }),
2508
+ observedState: "failedToStart",
2509
+ nodeId: "$launch",
2510
+ reason: safe.message,
2511
+ });
2512
+ try {
2513
+ storeTurnDescriptor(ctx, descriptor, true);
2514
+ syncArmed = true;
2515
+ } catch (intentError) {
2516
+ notify(
2517
+ ctx,
2518
+ `Workflow ${claimed.workflowName} failed to start, but Pi Workflows could not preserve its successor turn: ${errorMessage(intentError)}`,
2519
+ "warning",
2520
+ );
2183
2521
  }
2184
2522
  notify(ctx, content, "error");
2185
2523
  runSyncPass(ctx);
@@ -2244,7 +2582,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2244
2582
  return;
2245
2583
  }
2246
2584
  if (parsed.kind === "cancel") {
2247
- const result = await cancelWorkflowControl(ctx);
2585
+ const result = await cancelWorkflowControl(ctx, "user");
2248
2586
  notify(ctx, result.message, result.level);
2249
2587
  return;
2250
2588
  }
@@ -2412,6 +2750,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2412
2750
  case "stop":
2413
2751
  if (activeRun?.childKey !== undefined) {
2414
2752
  activeRun.interruptionRequested = true;
2753
+ activeRun.pendingAbortCause = "controllerInterrupted";
2415
2754
  activeRun.engine.cancel();
2416
2755
  }
2417
2756
  await host.stop();
@@ -2428,7 +2767,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2428
2767
  name: "workflow",
2429
2768
  label: "Workflow",
2430
2769
  description: [
2431
- "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
2770
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2432
2771
  "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.",
2433
2772
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
2434
2773
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
@@ -2454,7 +2793,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2454
2793
  control = resumeWorkflowControl(ctx);
2455
2794
  break;
2456
2795
  case "cancel":
2457
- control = await cancelWorkflowControl(ctx, params.runId);
2796
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
2458
2797
  break;
2459
2798
  case "answer": {
2460
2799
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
@@ -2636,6 +2975,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2636
2975
  if (!aborted || runHeld()) {
2637
2976
  return;
2638
2977
  }
2978
+ run.suppressTurnIntent = true;
2639
2979
  run.engine.pause();
2640
2980
  run.executor.hold();
2641
2981
  renderWidget(ctx);
@@ -2686,6 +3026,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2686
3026
  });
2687
3027
 
2688
3028
  pi.on("agent_settled", async (_event, ctx) => {
3029
+ systemTurnAbort = null;
2689
3030
  if (activeRun === null) {
2690
3031
  try {
2691
3032
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
@@ -2704,15 +3045,20 @@ export default function piWorkflows(pi: ExtensionAPI) {
2704
3045
  return;
2705
3046
  }
2706
3047
  }
3048
+ const flushedNatural = turnCoordinator.flushNatural(
3049
+ ctx.isIdle() && !sessionClosed && !runHeld(),
3050
+ );
2707
3051
  const run = activeRun;
2708
3052
  if (!run) {
2709
3053
  presentationPending = null;
3054
+ runSyncPass(ctx);
2710
3055
  return;
2711
3056
  }
2712
3057
  await run.recorder?.synchronize(ctx).catch(() => undefined);
2713
3058
  run.recorder?.settleAttempt();
2714
3059
  run.executor.setStreaming(false);
2715
- run.executor.handleAgentSettled();
3060
+ if (flushedNatural === 0) run.executor.handleAgentSettled();
3061
+ runSyncPass(ctx);
2716
3062
  });
2717
3063
 
2718
3064
  pi.on("session_shutdown", async () => {
@@ -2720,8 +3066,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
2720
3066
  herdrProbeGeneration += 1;
2721
3067
  systemTurnAbort = null;
2722
3068
  suppressWorkflowAssistantTail = false;
3069
+ turnCoordinator.clearDeferred();
2723
3070
  supersedePresentation();
2724
3071
  const run = activeRun;
3072
+ if (run !== null) run.suppressTurnIntent = true;
2725
3073
  if (run !== null && run.claimToken !== undefined) {
2726
3074
  // Queued interactive runs park: no terminal event, no recorded partial
2727
3075
  // attempt, and the claim releases so another runner can resume.