@osolmaz/pi-workflows 0.11.2 → 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 (123) hide show
  1. package/README.md +11 -7
  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 +90 -2
  20. package/dist/controllers/sqlite.js +380 -5
  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 +286 -47
  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/workflows/command-batch.d.ts +38 -0
  35. package/dist/workflows/command-batch.js +176 -0
  36. package/dist/workflows/command-batch.js.map +1 -0
  37. package/dist/workflows/engine.js +8 -4
  38. package/dist/workflows/engine.js.map +1 -1
  39. package/dist/workflows/index.d.ts +1 -0
  40. package/dist/workflows/index.js +1 -0
  41. package/dist/workflows/index.js.map +1 -1
  42. package/dist/workflows/schema.js +2 -1
  43. package/dist/workflows/schema.js.map +1 -1
  44. package/dist/workflows/shell.d.ts +4 -0
  45. package/dist/workflows/shell.js +6 -0
  46. package/dist/workflows/shell.js.map +1 -1
  47. package/dist/workflows/store.js +3 -1
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +4 -3
  50. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  51. package/docs/2026-08-20-durable-workflow-launch-plan.md +19 -23
  52. package/docs/CONTROLLERS.md +2 -2
  53. package/docs/DEFERRED_TURNS.md +298 -0
  54. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  55. package/docs/HUMAN_DECISIONS.md +7 -7
  56. package/docs/HUMAN_DECISION_PRESENTATIONS.md +3 -3
  57. package/docs/MONITOR.md +2 -2
  58. package/docs/WORKFLOW_COMPOSITION.md +2 -2
  59. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  60. package/docs/WORKFLOW_UPDATES.md +28 -5
  61. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  62. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  63. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  64. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  65. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  66. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  67. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  68. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  69. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  70. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  71. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  72. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  73. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  74. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  75. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  76. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  77. package/docs/plans/2026-08-21-sanity-check-plan.md +175 -0
  78. package/docs/run-bundles.md +3 -1
  79. package/docs/workflows.md +62 -19
  80. package/examples/workflows/command-batch.workflow.ts +50 -0
  81. package/examples/workflows/sanity-check.workflow.ts +1 -0
  82. package/herdr-plugin.toml +3 -3
  83. package/package.json +1 -1
  84. package/schemas/decision-presentation-v1.schema.json +1 -1
  85. package/schemas/human-decision-accepted-v1.schema.json +1 -1
  86. package/schemas/human-decision-accepted-v2.schema.json +1 -1
  87. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  88. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  89. package/schemas/human-decision-continuation-v1.schema.json +1 -1
  90. package/schemas/human-decision-delivery-v1.schema.json +1 -1
  91. package/schemas/human-decision-delivery-v2.schema.json +1 -1
  92. package/schemas/human-decision-receipt-v1.schema.json +1 -1
  93. package/schemas/human-decision-receipt-v2.schema.json +1 -1
  94. package/schemas/human-decision-request-v1.schema.json +1 -1
  95. package/schemas/human-decision-request-v2.schema.json +1 -1
  96. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  97. package/schemas/human-decision-resolution-v2.schema.json +1 -1
  98. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  99. package/skills/autodoc/SKILL.md +33 -2
  100. package/skills/autoimplement/SKILL.md +66 -12
  101. package/skills/autoplan/SKILL.md +26 -2
  102. package/skills/monitor/SKILL.md +35 -21
  103. package/skills/pi-workflows/SKILL.md +19 -5
  104. package/src/builtins/autoimplement-command-batches.ts +364 -0
  105. package/src/builtins/autoimplement.workflow.ts +1065 -261
  106. package/src/builtins/catalog.ts +3 -1
  107. package/src/builtins/index.ts +13 -0
  108. package/src/builtins/sanity-check-session.ts +205 -0
  109. package/src/builtins/sanity-check.workflow.ts +624 -0
  110. package/src/controllers/sqlite.ts +580 -7
  111. package/src/extension/decision-channels.ts +7 -7
  112. package/src/extension/deferred-turn-coordinator.ts +171 -0
  113. package/src/extension/deferred-turn.ts +166 -0
  114. package/src/extension/index.ts +359 -49
  115. package/src/extension/step-message.ts +1 -0
  116. package/src/herdr/setup.ts +4 -4
  117. package/src/workflows/command-batch.ts +254 -0
  118. package/src/workflows/engine.ts +8 -5
  119. package/src/workflows/index.ts +15 -0
  120. package/src/workflows/schema.ts +2 -1
  121. package/src/workflows/shell.ts +10 -0
  122. package/src/workflows/store.ts +3 -1
  123. package/src/workflows/types.ts +4 -3
@@ -14,6 +14,8 @@ import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/pr
14
14
  import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
15
15
  import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
16
16
  import { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
17
+ import { DeferredTurnCoordinator, } from "./deferred-turn-coordinator.js";
18
+ import { buildDeferredTurnContent, createDeferredTurnDescriptor, DEFERRED_TURN_MESSAGE_TYPE, deferredTurnMessageDetails, deferredTurnMessageId, deferredTurnSourceEventId, } from "./deferred-turn.js";
17
19
  import { ConversationStepExecutor } from "./executor.js";
18
20
  import { HerdrWorkflowViewer, parseViewerPlacement, PIW_SHORTCUT, PIW_SHORTCUT_HINT, VIEWER_PLACEMENTS, } from "./herdr-viewer.js";
19
21
  import { SessionRecorder } from "./recorder.js";
@@ -25,8 +27,10 @@ const RUN_CLAIM_LEASE_MS = 30_000;
25
27
  const RUN_CLAIM_RENEW_MS = 10_000;
26
28
  const RUN_SYNC_POLL_MS = 3_000;
27
29
  const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
30
+ const TURN_INTENT_DELIVERY_LEASE_MS = 30_000;
28
31
  const WIDGET_KEY = "pi-workflows";
29
32
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
33
+ const PRESENTATION_MESSAGE_SCHEMA = "pi-workflows.presentation-message.v1";
30
34
  const FINAL_WIDGET_TTL_MS = 60_000;
31
35
  const WIDGET_SCROLL_STEP = 3;
32
36
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
@@ -220,6 +224,7 @@ export default function piWorkflows(pi) {
220
224
  let activationRecovery;
221
225
  let decisionRecoveryTimer = null;
222
226
  let decisionRecoveryActive = false;
227
+ let turnCoordinator;
223
228
  const recordRunEvent = (event) => {
224
229
  try {
225
230
  runQueueStore?.recordRunEvent({ ...event, runnerId });
@@ -266,9 +271,7 @@ export default function piWorkflows(pi) {
266
271
  runId: notification.runId,
267
272
  kind: notification.kind,
268
273
  },
269
- }, notification.kind === "launch_failure"
270
- ? { triggerTurn: true, deliverAs: "followUp" }
271
- : { triggerTurn: false });
274
+ }, { triggerTurn: false });
272
275
  alreadyDelivered.add(notification.notificationId);
273
276
  }
274
277
  runQueueStore.markWorkflowNotificationDelivered({
@@ -277,6 +280,19 @@ export default function piWorkflows(pi) {
277
280
  claimToken,
278
281
  });
279
282
  }
283
+ const idle = ctx.isIdle() && systemTurnAbort === null && !sessionClosed && !runHeld();
284
+ turnCoordinator.flushNatural(idle);
285
+ turnCoordinator.deliverFallbacks({
286
+ targetSessionId: sessionId,
287
+ send: (intent) => {
288
+ pi.sendMessage({
289
+ customType: DEFERRED_TURN_MESSAGE_TYPE,
290
+ content: buildDeferredTurnContent(intent),
291
+ display: true,
292
+ details: deferredTurnMessageDetails(intent),
293
+ }, { triggerTurn: true, deliverAs: "followUp" });
294
+ },
295
+ }, idle);
280
296
  }
281
297
  catch {
282
298
  // Delivery retries on the next poll. It never affects workflow execution.
@@ -312,6 +328,35 @@ export default function piWorkflows(pi) {
312
328
  let presentationPending = null;
313
329
  let controllerHost;
314
330
  let controllerContext = null;
331
+ const branchIntentResolution = (intentId) => {
332
+ if (controllerContext === null)
333
+ return null;
334
+ for (const entry of controllerContext.sessionManager.getBranch()) {
335
+ if (entry.type !== "custom_message")
336
+ continue;
337
+ const details = entry.details;
338
+ if (details === null || typeof details !== "object" || Array.isArray(details))
339
+ continue;
340
+ if (details.turnIntentId !== intentId)
341
+ continue;
342
+ let resolution = null;
343
+ if (entry.customType === WORKFLOW_AGENT_STEP_MESSAGE_TYPE)
344
+ resolution = "workflowPrompt";
345
+ if (entry.customType === PRESENTATION_MESSAGE_TYPE)
346
+ resolution = "presentation";
347
+ if (entry.customType === DEFERRED_TURN_MESSAGE_TYPE)
348
+ resolution = "fallback";
349
+ if (resolution !== null) {
350
+ return { resolution, messageId: deferredTurnMessageId(intentId, resolution) };
351
+ }
352
+ }
353
+ return null;
354
+ };
355
+ turnCoordinator = new DeferredTurnCoordinator({
356
+ store: () => runQueueStore,
357
+ branchResolution: branchIntentResolution,
358
+ leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
359
+ });
315
360
  // UI updates are best-effort: a captured ctx becomes stale after session
316
361
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
317
362
  // A workflow finishing right as the session goes away must not crash pi.
@@ -352,6 +397,115 @@ export default function piWorkflows(pi) {
352
397
  };
353
398
  /** True when the run is held for the user (escape or /workflow pause). */
354
399
  const runHeld = () => activeRun !== null && (activeRun.engine.pauseRequested || activeRun.executor.held);
400
+ const originSessionId = (ctx, runId) => ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId)?.originSessionId ??
401
+ ctx.sessionManager.getSessionId();
402
+ const storeTurnDescriptor = (ctx, descriptor, eligible) => {
403
+ ensureRunQueueStore(ctx.cwd).ensureWorkflowTurnIntent({ ...descriptor, eligible });
404
+ };
405
+ const ensureAbortTurnIntent = (ctx, run, cause, contract, reason) => {
406
+ if (run.abortProvenance?.descriptor !== undefined) {
407
+ return run.abortProvenance.descriptor;
408
+ }
409
+ const descriptor = createDeferredTurnDescriptor({
410
+ runId: run.runId,
411
+ workflowName: run.workflowName,
412
+ targetSessionId: originSessionId(ctx, run.runId),
413
+ cause,
414
+ sourceEventId: deferredTurnSourceEventId({
415
+ runId: run.runId,
416
+ cause,
417
+ nodeId: contract.nodeId,
418
+ attemptId: contract.attemptId,
419
+ source: "agent-step-abort",
420
+ }),
421
+ observedState: cause === "claimLost" ? "handedOff" : "interrupted",
422
+ nodeId: contract.nodeId,
423
+ attemptId: contract.attemptId,
424
+ reason: errorMessage(reason),
425
+ handoff: cause === "claimLost",
426
+ });
427
+ run.abortProvenance = { cause, descriptor };
428
+ try {
429
+ storeTurnDescriptor(ctx, descriptor, false);
430
+ }
431
+ catch (error) {
432
+ run.abortProvenance.storageError = errorMessage(error);
433
+ }
434
+ return descriptor;
435
+ };
436
+ const makeRunTurnIntentEligible = (ctx, run, observedState, reason) => {
437
+ if (sessionClosed ||
438
+ run.suppressTurnIntent === true ||
439
+ run.abortProvenance?.cause === "claimLost") {
440
+ return;
441
+ }
442
+ const targetSessionId = originSessionId(ctx, run.runId);
443
+ const pending = ensureRunQueueStore(ctx.cwd).findPendingWorkflowTurnIntent({
444
+ runId: run.runId,
445
+ targetSessionId,
446
+ });
447
+ if (run.childKey !== undefined &&
448
+ run.abortProvenance === undefined &&
449
+ pending?.cause !== "claimLost") {
450
+ return;
451
+ }
452
+ const cause = pending?.cause ??
453
+ run.abortProvenance?.cause ??
454
+ (observedState === "timed_out" ? "timedOut" : "failed");
455
+ if (observedState === "cancelled" &&
456
+ run.abortProvenance === undefined &&
457
+ pending === undefined) {
458
+ return;
459
+ }
460
+ const previous = run.abortProvenance?.descriptor;
461
+ const sourceEventId = pending?.sourceEventId ??
462
+ previous?.sourceEventId ??
463
+ deferredTurnSourceEventId({
464
+ runId: run.runId,
465
+ cause,
466
+ nodeId: "$terminal",
467
+ source: "terminal",
468
+ });
469
+ const descriptor = createDeferredTurnDescriptor({
470
+ runId: run.runId,
471
+ workflowName: pending?.workflowRef ?? run.workflowName,
472
+ targetSessionId,
473
+ cause,
474
+ sourceEventId,
475
+ observedState,
476
+ nodeId: pending?.nodeId ?? previous?.nodeId ?? "$terminal",
477
+ attemptId: pending?.attemptId ?? previous?.attemptId ?? null,
478
+ reason: reason ?? null,
479
+ handoff: false,
480
+ });
481
+ run.abortProvenance = {
482
+ cause,
483
+ descriptor,
484
+ ...(run.abortProvenance?.storageError === undefined
485
+ ? {}
486
+ : { storageError: run.abortProvenance.storageError }),
487
+ };
488
+ try {
489
+ storeTurnDescriptor(ctx, descriptor, false);
490
+ ensureRunQueueStore(ctx.cwd).makeWorkflowTurnIntentEligible({
491
+ intentId: descriptor.intentId,
492
+ fallbackFacts: descriptor.fallbackFacts,
493
+ });
494
+ delete run.abortProvenance.storageError;
495
+ syncArmed = true;
496
+ }
497
+ catch (error) {
498
+ const message = errorMessage(error);
499
+ run.abortProvenance.storageError = message;
500
+ recordRunEvent({
501
+ runId: run.runId,
502
+ workflowRef: run.workflowName,
503
+ type: "turn_intent_failed",
504
+ payload: { error: message },
505
+ });
506
+ notify(ctx, `Workflow ${run.workflowName} ended, but Pi Workflows could not preserve its successor turn: ${message}`, "warning");
507
+ }
508
+ };
355
509
  const footerStatus = (state) => {
356
510
  const label = runHeld() || state.paused ? "paused" : state.status;
357
511
  const node = state.currentNode ?? state.waitingOn;
@@ -512,20 +666,36 @@ export default function piWorkflows(pi) {
512
666
  const instructions = typeof run.presentationPrompt === "function"
513
667
  ? await resolvePresentationPrompt(run.presentationPrompt, state, abort.signal)
514
668
  : run.presentationPrompt;
515
- if (sessionClosed ||
516
- run.generation !== runGeneration ||
517
- abort.signal.aborted ||
518
- instructions === undefined ||
519
- instructions.trim().length === 0) {
669
+ if (sessionClosed || run.generation !== runGeneration || abort.signal.aborted) {
520
670
  return;
521
671
  }
522
- presentationPending = run.generation;
523
- suppressWorkflowAssistantTail = false;
524
- pi.sendMessage({
525
- customType: PRESENTATION_MESSAGE_TYPE,
526
- content: buildPresentationMessage(instructions, state),
527
- display: false,
528
- }, { deliverAs: "steer", triggerTurn: true });
672
+ if (instructions === undefined || instructions.trim().length === 0) {
673
+ if (run.abortProvenance !== undefined) {
674
+ makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
675
+ }
676
+ return;
677
+ }
678
+ turnCoordinator.sendNatural({
679
+ runId: run.runId,
680
+ targetSessionId: originSessionId(ctx, run.runId),
681
+ resolution: "presentation",
682
+ send: (turnIntentId) => {
683
+ presentationPending = run.generation;
684
+ suppressWorkflowAssistantTail = false;
685
+ pi.sendMessage({
686
+ customType: PRESENTATION_MESSAGE_TYPE,
687
+ content: buildPresentationMessage(instructions, state),
688
+ display: false,
689
+ details: {
690
+ schema: PRESENTATION_MESSAGE_SCHEMA,
691
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
692
+ },
693
+ }, {
694
+ deliverAs: turnIntentId === undefined ? "steer" : "followUp",
695
+ triggerTurn: true,
696
+ });
697
+ },
698
+ }, ctx.isIdle() && systemTurnAbort === null);
529
699
  }
530
700
  catch (error) {
531
701
  if (presentationPending === run.generation) {
@@ -540,6 +710,9 @@ export default function piWorkflows(pi) {
540
710
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
541
711
  : errorMessage(error);
542
712
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
713
+ if (run.abortProvenance !== undefined) {
714
+ makeRunTurnIntentEligible(ctx, run, state.status, message);
715
+ }
543
716
  }
544
717
  finally {
545
718
  clearTimeout(timer);
@@ -590,6 +763,19 @@ export default function piWorkflows(pi) {
590
763
  clearWidget(ctx);
591
764
  return;
592
765
  }
766
+ if (run.abortProvenance?.cause === "claimLost") {
767
+ await run.recorder?.stop().catch(() => undefined);
768
+ releaseClaim(run, "lost");
769
+ recordRunEvent({
770
+ runId: run.runId,
771
+ workflowRef: run.workflowName,
772
+ type: "claim_lost",
773
+ });
774
+ stopWidgetTicker();
775
+ clearWidget(ctx);
776
+ notify(ctx, `Workflow ${run.workflowName} continues under another runner (run ${run.runId}).`);
777
+ return;
778
+ }
593
779
  releaseClaim(run, "done");
594
780
  recordRunEvent({
595
781
  runId: run.runId,
@@ -638,6 +824,14 @@ export default function piWorkflows(pi) {
638
824
  catch (error) {
639
825
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
640
826
  }
827
+ if (state.status === "failed" || state.status === "timed_out" || state.status === "cancelled") {
828
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
829
+ }
830
+ else if (run.abortProvenance !== undefined &&
831
+ run.presentationPrompt === undefined &&
832
+ (state.status === "completed" || state.status === "waiting")) {
833
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
834
+ }
641
835
  void presentRun(ctx, run, state);
642
836
  if (pendingDecision !== null && state.status === "waiting") {
643
837
  const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
@@ -744,21 +938,29 @@ export default function piWorkflows(pi) {
744
938
  }
745
939
  const executor = new ConversationStepExecutor({
746
940
  sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
747
- const details = {
748
- schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
749
- kind,
750
- contract,
751
- ...(presentation !== undefined ? { presentation } : {}),
752
- };
753
- pi.sendMessage({
754
- customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
755
- content: prompt,
756
- display: true,
757
- details,
758
- }, {
759
- triggerTurn: true,
760
- deliverAs: streaming ? "steer" : "followUp",
761
- });
941
+ turnCoordinator.sendNatural({
942
+ runId,
943
+ targetSessionId: originSessionId(ctx, runId),
944
+ resolution: "workflowPrompt",
945
+ send: (turnIntentId) => {
946
+ const details = {
947
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
948
+ kind,
949
+ contract,
950
+ ...(presentation !== undefined ? { presentation } : {}),
951
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
952
+ };
953
+ pi.sendMessage({
954
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
955
+ content: prompt,
956
+ display: true,
957
+ details,
958
+ }, {
959
+ triggerTurn: true,
960
+ deliverAs: streaming ? "steer" : "followUp",
961
+ });
962
+ },
963
+ }, ctx.isIdle() && systemTurnAbort === null);
762
964
  },
763
965
  onAbort: (contract, reason) => {
764
966
  lastExpiredAttempt = {
@@ -766,7 +968,15 @@ export default function piWorkflows(pi) {
766
968
  reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
767
969
  };
768
970
  if (!executor.held && !ctx.isIdle()) {
769
- systemTurnAbort = contract;
971
+ const cause = reason instanceof TimeoutError ? "timedOut" : run.pendingAbortCause;
972
+ const descriptor = cause === undefined
973
+ ? undefined
974
+ : ensureAbortTurnIntent(ctx, run, cause, contract, reason);
975
+ run.pendingAbortCause = undefined;
976
+ systemTurnAbort = {
977
+ contract,
978
+ ...(descriptor === undefined ? {} : { intentId: descriptor.intentId }),
979
+ };
770
980
  ctx.abort();
771
981
  }
772
982
  },
@@ -859,6 +1069,7 @@ export default function piWorkflows(pi) {
859
1069
  run.renewTimer = setInterval(() => {
860
1070
  try {
861
1071
  if (!store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })) {
1072
+ run.pendingAbortCause = "claimLost";
862
1073
  run.engine.cancel();
863
1074
  }
864
1075
  }
@@ -952,6 +1163,11 @@ export default function piWorkflows(pi) {
952
1163
  }
953
1164
  if (terminalStatus !== undefined) {
954
1165
  recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
1166
+ if (terminalStatus === "failed" ||
1167
+ terminalStatus === "timed_out" ||
1168
+ terminalStatus === "cancelled") {
1169
+ makeRunTurnIntentEligible(ctx, run, terminalStatus, message);
1170
+ }
955
1171
  notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
956
1172
  return;
957
1173
  }
@@ -961,6 +1177,7 @@ export default function piWorkflows(pi) {
961
1177
  type: "failed",
962
1178
  payload: { error: message },
963
1179
  });
1180
+ makeRunTurnIntentEligible(ctx, run, "failed", message);
964
1181
  try {
965
1182
  run.onFinish?.({ state: "failed", runId, error: message });
966
1183
  }
@@ -1155,10 +1372,12 @@ export default function piWorkflows(pi) {
1155
1372
  }
1156
1373
  return null;
1157
1374
  };
1158
- const cancelWorkflowControl = async (ctx, requestedRunId) => {
1375
+ const cancelWorkflowControl = async (ctx, origin, requestedRunId) => {
1159
1376
  if (activeRun && (requestedRunId === undefined || requestedRunId === activeRun.runId)) {
1160
1377
  const workflowName = activeRun.workflowName;
1161
1378
  const runId = activeRun.runId;
1379
+ activeRun.pendingAbortCause = origin === "agent" ? "agentCancelled" : undefined;
1380
+ activeRun.suppressTurnIntent = origin === "user";
1162
1381
  activeRun.engine.cancel();
1163
1382
  return {
1164
1383
  message: `Cancelling workflow ${workflowName}…`,
@@ -1250,6 +1469,7 @@ export default function piWorkflows(pi) {
1250
1469
  },
1251
1470
  };
1252
1471
  }
1472
+ activeRun.suppressTurnIntent = true;
1253
1473
  activeRun.engine.pause();
1254
1474
  renderWidget(ctx);
1255
1475
  return {
@@ -1282,6 +1502,7 @@ export default function piWorkflows(pi) {
1282
1502
  level: "warning",
1283
1503
  };
1284
1504
  }
1505
+ activeRun.suppressTurnIntent = false;
1285
1506
  activeRun.engine.resume();
1286
1507
  activeRun.executor.release();
1287
1508
  renderWidget(ctx);
@@ -1758,20 +1979,28 @@ export default function piWorkflows(pi) {
1758
1979
  payload: { errorCode: safe.code, error: safe.message },
1759
1980
  });
1760
1981
  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.`;
1761
- try {
1762
- queue.enqueueWorkflowNotification({
1982
+ const cause = "launchFailed";
1983
+ const descriptor = createDeferredTurnDescriptor({
1984
+ runId: claimed.runId,
1985
+ workflowName: claimed.workflowName,
1986
+ targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
1987
+ cause,
1988
+ sourceEventId: deferredTurnSourceEventId({
1763
1989
  runId: claimed.runId,
1990
+ cause,
1764
1991
  nodeId: "$launch",
1765
- attemptId: claimed.runId,
1766
- notificationIndex: 1,
1767
- targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
1768
- kind: "launch_failure",
1769
- content,
1770
- notificationId: `launch-failure:${claimed.runId}`,
1771
- });
1992
+ source: "queued-launch",
1993
+ }),
1994
+ observedState: "failedToStart",
1995
+ nodeId: "$launch",
1996
+ reason: safe.message,
1997
+ });
1998
+ try {
1999
+ storeTurnDescriptor(ctx, descriptor, true);
2000
+ syncArmed = true;
1772
2001
  }
1773
- catch {
1774
- // A deterministic notification id makes duplicate insertion harmless.
2002
+ catch (intentError) {
2003
+ notify(ctx, `Workflow ${claimed.workflowName} failed to start, but Pi Workflows could not preserve its successor turn: ${errorMessage(intentError)}`, "warning");
1775
2004
  }
1776
2005
  notify(ctx, content, "error");
1777
2006
  runSyncPass(ctx);
@@ -1830,7 +2059,7 @@ export default function piWorkflows(pi) {
1830
2059
  return;
1831
2060
  }
1832
2061
  if (parsed.kind === "cancel") {
1833
- const result = await cancelWorkflowControl(ctx);
2062
+ const result = await cancelWorkflowControl(ctx, "user");
1834
2063
  notify(ctx, result.message, result.level);
1835
2064
  return;
1836
2065
  }
@@ -1990,6 +2219,7 @@ export default function piWorkflows(pi) {
1990
2219
  case "stop":
1991
2220
  if (activeRun?.childKey !== undefined) {
1992
2221
  activeRun.interruptionRequested = true;
2222
+ activeRun.pendingAbortCause = "controllerInterrupted";
1993
2223
  activeRun.engine.cancel();
1994
2224
  }
1995
2225
  await host.stop();
@@ -2006,7 +2236,7 @@ export default function piWorkflows(pi) {
2006
2236
  name: "workflow",
2007
2237
  label: "Workflow",
2008
2238
  description: [
2009
- "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
2239
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2010
2240
  "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.",
2011
2241
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
2012
2242
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
@@ -2032,7 +2262,7 @@ export default function piWorkflows(pi) {
2032
2262
  control = resumeWorkflowControl(ctx);
2033
2263
  break;
2034
2264
  case "cancel":
2035
- control = await cancelWorkflowControl(ctx, params.runId);
2265
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
2036
2266
  break;
2037
2267
  case "answer": {
2038
2268
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
@@ -2190,6 +2420,7 @@ export default function piWorkflows(pi) {
2190
2420
  if (!aborted || runHeld()) {
2191
2421
  return;
2192
2422
  }
2423
+ run.suppressTurnIntent = true;
2193
2424
  run.engine.pause();
2194
2425
  run.executor.hold();
2195
2426
  renderWidget(ctx);
@@ -2228,6 +2459,7 @@ export default function piWorkflows(pi) {
2228
2459
  activeRun?.recorder?.handleToolEnd(event);
2229
2460
  });
2230
2461
  pi.on("agent_settled", async (_event, ctx) => {
2462
+ systemTurnAbort = null;
2231
2463
  if (activeRun === null) {
2232
2464
  try {
2233
2465
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
@@ -2241,23 +2473,30 @@ export default function piWorkflows(pi) {
2241
2473
  return;
2242
2474
  }
2243
2475
  }
2476
+ const flushedNatural = turnCoordinator.flushNatural(ctx.isIdle() && !sessionClosed && !runHeld());
2244
2477
  const run = activeRun;
2245
2478
  if (!run) {
2246
2479
  presentationPending = null;
2480
+ runSyncPass(ctx);
2247
2481
  return;
2248
2482
  }
2249
2483
  await run.recorder?.synchronize(ctx).catch(() => undefined);
2250
2484
  run.recorder?.settleAttempt();
2251
2485
  run.executor.setStreaming(false);
2252
- run.executor.handleAgentSettled();
2486
+ if (flushedNatural === 0)
2487
+ run.executor.handleAgentSettled();
2488
+ runSyncPass(ctx);
2253
2489
  });
2254
2490
  pi.on("session_shutdown", async () => {
2255
2491
  sessionClosed = true;
2256
2492
  herdrProbeGeneration += 1;
2257
2493
  systemTurnAbort = null;
2258
2494
  suppressWorkflowAssistantTail = false;
2495
+ turnCoordinator.clearDeferred();
2259
2496
  supersedePresentation();
2260
2497
  const run = activeRun;
2498
+ if (run !== null)
2499
+ run.suppressTurnIntent = true;
2261
2500
  if (run !== null && run.claimToken !== undefined) {
2262
2501
  // Queued interactive runs park: no terminal event, no recorded partial
2263
2502
  // attempt, and the claim releases so another runner can resume.