@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
@@ -6,7 +6,7 @@ import { projectControllerStorePath, SqliteControllerStore, } from "../controlle
6
6
  import { compositionMetadata } from "../workflows/composition.js";
7
7
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
8
8
  import { WorkflowEngine } from "../workflows/engine.js";
9
- import { ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
9
+ import { BuiltinWorkflowRevisionChangedError, ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
10
10
  import { HumanDecisionStore, createHumanDecisionAttemptId, validateHumanDecisionResponse, } from "../workflows/human-decision.js";
11
11
  import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
12
12
  import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
@@ -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;
@@ -51,10 +55,8 @@ function humanDecisionRequest(value) {
51
55
  const schema = value !== null && typeof value === "object"
52
56
  ? value.schema
53
57
  : undefined;
54
- if (schema !== "pi-workflows.human-decision-request.v1" &&
55
- schema !== "pi-workflows.human-decision-request.v2") {
58
+ if (schema !== "pi-workflows.human-decision-request.v1")
56
59
  return null;
57
- }
58
60
  return value;
59
61
  }
60
62
  function definitionDigest(snapshot) {
@@ -66,6 +68,20 @@ function launchSourceIdentity(workflow, root) {
66
68
  mounted: compositionMetadata(workflow)?.sources ?? [],
67
69
  };
68
70
  }
71
+ function continuationSourceChangedError(runId, previous, current) {
72
+ if (previous.kind === "builtin" &&
73
+ current.kind === "builtin" &&
74
+ previous.id === current.id &&
75
+ previous.revision !== current.revision) {
76
+ return new BuiltinWorkflowRevisionChangedError({
77
+ runId,
78
+ workflowId: current.id,
79
+ previousRevision: previous.revision,
80
+ currentRevision: current.revision,
81
+ });
82
+ }
83
+ return new Error(`Workflow source changed since run ${runId} started; revert the edit to answer its checkpoint`);
84
+ }
69
85
  function preparedLaunchOptions(options) {
70
86
  return {
71
87
  ...(options.presentation !== undefined ? { presentation: options.presentation } : {}),
@@ -220,6 +236,7 @@ export default function piWorkflows(pi) {
220
236
  let activationRecovery;
221
237
  let decisionRecoveryTimer = null;
222
238
  let decisionRecoveryActive = false;
239
+ let turnCoordinator;
223
240
  const recordRunEvent = (event) => {
224
241
  try {
225
242
  runQueueStore?.recordRunEvent({ ...event, runnerId });
@@ -266,9 +283,7 @@ export default function piWorkflows(pi) {
266
283
  runId: notification.runId,
267
284
  kind: notification.kind,
268
285
  },
269
- }, notification.kind === "launch_failure"
270
- ? { triggerTurn: true, deliverAs: "followUp" }
271
- : { triggerTurn: false });
286
+ }, { triggerTurn: false });
272
287
  alreadyDelivered.add(notification.notificationId);
273
288
  }
274
289
  runQueueStore.markWorkflowNotificationDelivered({
@@ -277,6 +292,19 @@ export default function piWorkflows(pi) {
277
292
  claimToken,
278
293
  });
279
294
  }
295
+ const idle = ctx.isIdle() && systemTurnAbort === null && !sessionClosed && !runHeld();
296
+ turnCoordinator.flushNatural(idle);
297
+ turnCoordinator.deliverFallbacks({
298
+ targetSessionId: sessionId,
299
+ send: (intent) => {
300
+ pi.sendMessage({
301
+ customType: DEFERRED_TURN_MESSAGE_TYPE,
302
+ content: buildDeferredTurnContent(intent),
303
+ display: true,
304
+ details: deferredTurnMessageDetails(intent),
305
+ }, { triggerTurn: true, deliverAs: "followUp" });
306
+ },
307
+ }, idle);
280
308
  }
281
309
  catch {
282
310
  // Delivery retries on the next poll. It never affects workflow execution.
@@ -312,6 +340,35 @@ export default function piWorkflows(pi) {
312
340
  let presentationPending = null;
313
341
  let controllerHost;
314
342
  let controllerContext = null;
343
+ const branchIntentResolution = (intentId) => {
344
+ if (controllerContext === null)
345
+ return null;
346
+ for (const entry of controllerContext.sessionManager.getBranch()) {
347
+ if (entry.type !== "custom_message")
348
+ continue;
349
+ const details = entry.details;
350
+ if (details === null || typeof details !== "object" || Array.isArray(details))
351
+ continue;
352
+ if (details.turnIntentId !== intentId)
353
+ continue;
354
+ let resolution = null;
355
+ if (entry.customType === WORKFLOW_AGENT_STEP_MESSAGE_TYPE)
356
+ resolution = "workflowPrompt";
357
+ if (entry.customType === PRESENTATION_MESSAGE_TYPE)
358
+ resolution = "presentation";
359
+ if (entry.customType === DEFERRED_TURN_MESSAGE_TYPE)
360
+ resolution = "fallback";
361
+ if (resolution !== null) {
362
+ return { resolution, messageId: deferredTurnMessageId(intentId, resolution) };
363
+ }
364
+ }
365
+ return null;
366
+ };
367
+ turnCoordinator = new DeferredTurnCoordinator({
368
+ store: () => runQueueStore,
369
+ branchResolution: branchIntentResolution,
370
+ leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
371
+ });
315
372
  // UI updates are best-effort: a captured ctx becomes stale after session
316
373
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
317
374
  // A workflow finishing right as the session goes away must not crash pi.
@@ -352,6 +409,115 @@ export default function piWorkflows(pi) {
352
409
  };
353
410
  /** True when the run is held for the user (escape or /workflow pause). */
354
411
  const runHeld = () => activeRun !== null && (activeRun.engine.pauseRequested || activeRun.executor.held);
412
+ const originSessionId = (ctx, runId) => ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId)?.originSessionId ??
413
+ ctx.sessionManager.getSessionId();
414
+ const storeTurnDescriptor = (ctx, descriptor, eligible) => {
415
+ ensureRunQueueStore(ctx.cwd).ensureWorkflowTurnIntent({ ...descriptor, eligible });
416
+ };
417
+ const ensureAbortTurnIntent = (ctx, run, cause, contract, reason) => {
418
+ if (run.abortProvenance?.descriptor !== undefined) {
419
+ return run.abortProvenance.descriptor;
420
+ }
421
+ const descriptor = createDeferredTurnDescriptor({
422
+ runId: run.runId,
423
+ workflowName: run.workflowName,
424
+ targetSessionId: originSessionId(ctx, run.runId),
425
+ cause,
426
+ sourceEventId: deferredTurnSourceEventId({
427
+ runId: run.runId,
428
+ cause,
429
+ nodeId: contract.nodeId,
430
+ attemptId: contract.attemptId,
431
+ source: "agent-step-abort",
432
+ }),
433
+ observedState: cause === "claimLost" ? "handedOff" : "interrupted",
434
+ nodeId: contract.nodeId,
435
+ attemptId: contract.attemptId,
436
+ reason: errorMessage(reason),
437
+ handoff: cause === "claimLost",
438
+ });
439
+ run.abortProvenance = { cause, descriptor };
440
+ try {
441
+ storeTurnDescriptor(ctx, descriptor, false);
442
+ }
443
+ catch (error) {
444
+ run.abortProvenance.storageError = errorMessage(error);
445
+ }
446
+ return descriptor;
447
+ };
448
+ const makeRunTurnIntentEligible = (ctx, run, observedState, reason) => {
449
+ if (sessionClosed ||
450
+ run.suppressTurnIntent === true ||
451
+ run.abortProvenance?.cause === "claimLost") {
452
+ return;
453
+ }
454
+ const targetSessionId = originSessionId(ctx, run.runId);
455
+ const pending = ensureRunQueueStore(ctx.cwd).findPendingWorkflowTurnIntent({
456
+ runId: run.runId,
457
+ targetSessionId,
458
+ });
459
+ if (run.childKey !== undefined &&
460
+ run.abortProvenance === undefined &&
461
+ pending?.cause !== "claimLost") {
462
+ return;
463
+ }
464
+ const cause = pending?.cause ??
465
+ run.abortProvenance?.cause ??
466
+ (observedState === "timed_out" ? "timedOut" : "failed");
467
+ if (observedState === "cancelled" &&
468
+ run.abortProvenance === undefined &&
469
+ pending === undefined) {
470
+ return;
471
+ }
472
+ const previous = run.abortProvenance?.descriptor;
473
+ const sourceEventId = pending?.sourceEventId ??
474
+ previous?.sourceEventId ??
475
+ deferredTurnSourceEventId({
476
+ runId: run.runId,
477
+ cause,
478
+ nodeId: "$terminal",
479
+ source: "terminal",
480
+ });
481
+ const descriptor = createDeferredTurnDescriptor({
482
+ runId: run.runId,
483
+ workflowName: pending?.workflowRef ?? run.workflowName,
484
+ targetSessionId,
485
+ cause,
486
+ sourceEventId,
487
+ observedState,
488
+ nodeId: pending?.nodeId ?? previous?.nodeId ?? "$terminal",
489
+ attemptId: pending?.attemptId ?? previous?.attemptId ?? null,
490
+ reason: reason ?? null,
491
+ handoff: false,
492
+ });
493
+ run.abortProvenance = {
494
+ cause,
495
+ descriptor,
496
+ ...(run.abortProvenance?.storageError === undefined
497
+ ? {}
498
+ : { storageError: run.abortProvenance.storageError }),
499
+ };
500
+ try {
501
+ storeTurnDescriptor(ctx, descriptor, false);
502
+ ensureRunQueueStore(ctx.cwd).makeWorkflowTurnIntentEligible({
503
+ intentId: descriptor.intentId,
504
+ fallbackFacts: descriptor.fallbackFacts,
505
+ });
506
+ delete run.abortProvenance.storageError;
507
+ syncArmed = true;
508
+ }
509
+ catch (error) {
510
+ const message = errorMessage(error);
511
+ run.abortProvenance.storageError = message;
512
+ recordRunEvent({
513
+ runId: run.runId,
514
+ workflowRef: run.workflowName,
515
+ type: "turn_intent_failed",
516
+ payload: { error: message },
517
+ });
518
+ notify(ctx, `Workflow ${run.workflowName} ended, but Pi Workflows could not preserve its successor turn: ${message}`, "warning");
519
+ }
520
+ };
355
521
  const footerStatus = (state) => {
356
522
  const label = runHeld() || state.paused ? "paused" : state.status;
357
523
  const node = state.currentNode ?? state.waitingOn;
@@ -512,20 +678,36 @@ export default function piWorkflows(pi) {
512
678
  const instructions = typeof run.presentationPrompt === "function"
513
679
  ? await resolvePresentationPrompt(run.presentationPrompt, state, abort.signal)
514
680
  : run.presentationPrompt;
515
- if (sessionClosed ||
516
- run.generation !== runGeneration ||
517
- abort.signal.aborted ||
518
- instructions === undefined ||
519
- instructions.trim().length === 0) {
681
+ if (sessionClosed || run.generation !== runGeneration || abort.signal.aborted) {
682
+ return;
683
+ }
684
+ if (instructions === undefined || instructions.trim().length === 0) {
685
+ if (run.abortProvenance !== undefined) {
686
+ makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
687
+ }
520
688
  return;
521
689
  }
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 });
690
+ turnCoordinator.sendNatural({
691
+ runId: run.runId,
692
+ targetSessionId: originSessionId(ctx, run.runId),
693
+ resolution: "presentation",
694
+ send: (turnIntentId) => {
695
+ presentationPending = run.generation;
696
+ suppressWorkflowAssistantTail = false;
697
+ pi.sendMessage({
698
+ customType: PRESENTATION_MESSAGE_TYPE,
699
+ content: buildPresentationMessage(instructions, state),
700
+ display: false,
701
+ details: {
702
+ schema: PRESENTATION_MESSAGE_SCHEMA,
703
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
704
+ },
705
+ }, {
706
+ deliverAs: turnIntentId === undefined ? "steer" : "followUp",
707
+ triggerTurn: true,
708
+ });
709
+ },
710
+ }, ctx.isIdle() && systemTurnAbort === null);
529
711
  }
530
712
  catch (error) {
531
713
  if (presentationPending === run.generation) {
@@ -540,6 +722,9 @@ export default function piWorkflows(pi) {
540
722
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
541
723
  : errorMessage(error);
542
724
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
725
+ if (run.abortProvenance !== undefined) {
726
+ makeRunTurnIntentEligible(ctx, run, state.status, message);
727
+ }
543
728
  }
544
729
  finally {
545
730
  clearTimeout(timer);
@@ -590,6 +775,19 @@ export default function piWorkflows(pi) {
590
775
  clearWidget(ctx);
591
776
  return;
592
777
  }
778
+ if (run.abortProvenance?.cause === "claimLost") {
779
+ await run.recorder?.stop().catch(() => undefined);
780
+ releaseClaim(run, "lost");
781
+ recordRunEvent({
782
+ runId: run.runId,
783
+ workflowRef: run.workflowName,
784
+ type: "claim_lost",
785
+ });
786
+ stopWidgetTicker();
787
+ clearWidget(ctx);
788
+ notify(ctx, `Workflow ${run.workflowName} continues under another runner (run ${run.runId}).`);
789
+ return;
790
+ }
593
791
  releaseClaim(run, "done");
594
792
  recordRunEvent({
595
793
  runId: run.runId,
@@ -638,6 +836,14 @@ export default function piWorkflows(pi) {
638
836
  catch (error) {
639
837
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
640
838
  }
839
+ if (state.status === "failed" || state.status === "timed_out" || state.status === "cancelled") {
840
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
841
+ }
842
+ else if (run.abortProvenance !== undefined &&
843
+ run.presentationPrompt === undefined &&
844
+ (state.status === "completed" || state.status === "waiting")) {
845
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
846
+ }
641
847
  void presentRun(ctx, run, state);
642
848
  if (pendingDecision !== null && state.status === "waiting") {
643
849
  const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
@@ -694,7 +900,7 @@ export default function piWorkflows(pi) {
694
900
  }
695
901
  if (parent.state.workflowSource !== undefined &&
696
902
  !isDeepStrictEqual(parent.state.workflowSource, workflowSource)) {
697
- throw new Error(`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`);
903
+ throw continuationSourceChangedError(options.parentRunId, parent.state.workflowSource, workflowSource);
698
904
  }
699
905
  }
700
906
  // Interactive runs are queued and claimed atomically, so this session
@@ -744,21 +950,29 @@ export default function piWorkflows(pi) {
744
950
  }
745
951
  const executor = new ConversationStepExecutor({
746
952
  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
- });
953
+ turnCoordinator.sendNatural({
954
+ runId,
955
+ targetSessionId: originSessionId(ctx, runId),
956
+ resolution: "workflowPrompt",
957
+ send: (turnIntentId) => {
958
+ const details = {
959
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
960
+ kind,
961
+ contract,
962
+ ...(presentation !== undefined ? { presentation } : {}),
963
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
964
+ };
965
+ pi.sendMessage({
966
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
967
+ content: prompt,
968
+ display: true,
969
+ details,
970
+ }, {
971
+ triggerTurn: true,
972
+ deliverAs: streaming ? "steer" : "followUp",
973
+ });
974
+ },
975
+ }, ctx.isIdle() && systemTurnAbort === null);
762
976
  },
763
977
  onAbort: (contract, reason) => {
764
978
  lastExpiredAttempt = {
@@ -766,7 +980,15 @@ export default function piWorkflows(pi) {
766
980
  reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
767
981
  };
768
982
  if (!executor.held && !ctx.isIdle()) {
769
- systemTurnAbort = contract;
983
+ const cause = reason instanceof TimeoutError ? "timedOut" : run.pendingAbortCause;
984
+ const descriptor = cause === undefined
985
+ ? undefined
986
+ : ensureAbortTurnIntent(ctx, run, cause, contract, reason);
987
+ run.pendingAbortCause = undefined;
988
+ systemTurnAbort = {
989
+ contract,
990
+ ...(descriptor === undefined ? {} : { intentId: descriptor.intentId }),
991
+ };
770
992
  ctx.abort();
771
993
  }
772
994
  },
@@ -859,6 +1081,7 @@ export default function piWorkflows(pi) {
859
1081
  run.renewTimer = setInterval(() => {
860
1082
  try {
861
1083
  if (!store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })) {
1084
+ run.pendingAbortCause = "claimLost";
862
1085
  run.engine.cancel();
863
1086
  }
864
1087
  }
@@ -952,6 +1175,11 @@ export default function piWorkflows(pi) {
952
1175
  }
953
1176
  if (terminalStatus !== undefined) {
954
1177
  recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
1178
+ if (terminalStatus === "failed" ||
1179
+ terminalStatus === "timed_out" ||
1180
+ terminalStatus === "cancelled") {
1181
+ makeRunTurnIntentEligible(ctx, run, terminalStatus, message);
1182
+ }
955
1183
  notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
956
1184
  return;
957
1185
  }
@@ -961,6 +1189,7 @@ export default function piWorkflows(pi) {
961
1189
  type: "failed",
962
1190
  payload: { error: message },
963
1191
  });
1192
+ makeRunTurnIntentEligible(ctx, run, "failed", message);
964
1193
  try {
965
1194
  run.onFinish?.({ state: "failed", runId, error: message });
966
1195
  }
@@ -1155,10 +1384,12 @@ export default function piWorkflows(pi) {
1155
1384
  }
1156
1385
  return null;
1157
1386
  };
1158
- const cancelWorkflowControl = async (ctx, requestedRunId) => {
1387
+ const cancelWorkflowControl = async (ctx, origin, requestedRunId) => {
1159
1388
  if (activeRun && (requestedRunId === undefined || requestedRunId === activeRun.runId)) {
1160
1389
  const workflowName = activeRun.workflowName;
1161
1390
  const runId = activeRun.runId;
1391
+ activeRun.pendingAbortCause = origin === "agent" ? "agentCancelled" : undefined;
1392
+ activeRun.suppressTurnIntent = origin === "user";
1162
1393
  activeRun.engine.cancel();
1163
1394
  return {
1164
1395
  message: `Cancelling workflow ${workflowName}…`,
@@ -1250,6 +1481,7 @@ export default function piWorkflows(pi) {
1250
1481
  },
1251
1482
  };
1252
1483
  }
1484
+ activeRun.suppressTurnIntent = true;
1253
1485
  activeRun.engine.pause();
1254
1486
  renderWidget(ctx);
1255
1487
  return {
@@ -1282,6 +1514,7 @@ export default function piWorkflows(pi) {
1282
1514
  level: "warning",
1283
1515
  };
1284
1516
  }
1517
+ activeRun.suppressTurnIntent = false;
1285
1518
  activeRun.engine.resume();
1286
1519
  activeRun.executor.release();
1287
1520
  renderWidget(ctx);
@@ -1412,7 +1645,7 @@ export default function piWorkflows(pi) {
1412
1645
  };
1413
1646
  const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1414
1647
  const acceptance = await store.accept(request, submission);
1415
- if (acceptance.status === "conflict") {
1648
+ if (acceptance.status === "conflict" || acceptance.decision.provenance !== "human") {
1416
1649
  throw new Error("That human decision was already answered differently.");
1417
1650
  }
1418
1651
  accepted = acceptance.decision;
@@ -1442,6 +1675,7 @@ export default function piWorkflows(pi) {
1442
1675
  schema: "pi-workflows.human-decision-continuation.v1",
1443
1676
  decisionId: request.decisionId,
1444
1677
  requestDigest: request.requestDigest,
1678
+ provenance: accepted.provenance,
1445
1679
  parentRunId: waiting.parentRunId,
1446
1680
  runId: continuationRunId,
1447
1681
  createdAt: accepted.acceptedAt,
@@ -1554,60 +1788,76 @@ export default function piWorkflows(pi) {
1554
1788
  const ownedBySession = queueRecord !== undefined &&
1555
1789
  (queueRecord.originSessionId === null ||
1556
1790
  queueRecord.originSessionId === ctx.sessionManager.getSessionId());
1557
- const accepted = await store.readAccepted(request.decisionId);
1558
- if (accepted === null) {
1791
+ let resolved = await store.readResolved(request.decisionId);
1792
+ if (resolved === null) {
1559
1793
  let cancellation = await store.readCancellation(request.decisionId);
1560
- if (cancellation === null &&
1561
- request.expiresAt !== undefined &&
1562
- Date.parse(request.expiresAt) <= Date.now()) {
1563
- await store.cancel(request, "expired");
1564
- cancellation = await store.readCancellation(request.decisionId);
1794
+ const expired = request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now();
1795
+ if (cancellation === null && expired) {
1796
+ if (request.defaultResponse === undefined) {
1797
+ await store.cancel(request, "expired");
1798
+ cancellation = await store.readCancellation(request.decisionId);
1799
+ }
1800
+ else {
1801
+ try {
1802
+ resolved = (await store.resolveTimeout(request)).decision;
1803
+ }
1804
+ catch {
1805
+ cancellation = await store.readCancellation(request.decisionId);
1806
+ resolved = await store.readResolved(request.decisionId);
1807
+ }
1808
+ }
1565
1809
  }
1566
1810
  if (cancellation !== null) {
1567
1811
  await settleHumanDecisionChannels(cancellation);
1568
1812
  continue;
1569
1813
  }
1570
- if (!deliverPending || !ownedBySession)
1814
+ if (resolved === null) {
1815
+ if (!deliverPending || !ownedBySession)
1816
+ continue;
1817
+ const channels = audienceChannels(decisionChannelConfig, request.audience);
1818
+ for (const channelId of channels) {
1819
+ const channel = telegramDecisionChannels.get(channelId);
1820
+ if (channel !== undefined)
1821
+ await channel.deliver(humanDecisionChannelRequest(request));
1822
+ }
1823
+ if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
1824
+ lastWaitingRunId = request.runId;
1825
+ queueMicrotask(() => void promptHumanDecision(ctx, request));
1826
+ }
1571
1827
  continue;
1572
- const channels = audienceChannels(decisionChannelConfig, request.audience);
1573
- for (const channelId of channels) {
1574
- const channel = telegramDecisionChannels.get(channelId);
1575
- if (channel !== undefined)
1576
- await channel.deliver(humanDecisionChannelRequest(request));
1577
- }
1578
- if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
1579
- lastWaitingRunId = request.runId;
1580
- queueMicrotask(() => void promptHumanDecision(ctx, request));
1581
1828
  }
1582
- continue;
1583
1829
  }
1584
1830
  if (!ownedBySession)
1585
1831
  continue;
1832
+ const currentParent = await readRunBundle(runStore.runDirFor(request.runId));
1833
+ if (currentParent === null || currentParent.state.status !== "waiting")
1834
+ continue;
1586
1835
  const runId = `continuation-${request.decisionId.slice("decision-".length)}`;
1587
1836
  const continuation = (await store.readContinuation(request.decisionId)) ?? {
1588
1837
  schema: "pi-workflows.human-decision-continuation.v1",
1589
1838
  decisionId: request.decisionId,
1590
1839
  requestDigest: request.requestDigest,
1840
+ provenance: resolved.provenance,
1591
1841
  parentRunId: request.runId,
1592
1842
  runId,
1593
- createdAt: accepted.acceptedAt,
1843
+ createdAt: resolved.acceptedAt,
1594
1844
  };
1595
1845
  await store.recordContinuation(request.decisionId, continuation);
1596
1846
  const existing = await readRunBundle(runStore.runDirFor(continuation.runId));
1597
1847
  if (existing === null && activeRun === null) {
1598
- if (parent.state.workflowSource === undefined)
1848
+ if (currentParent.state.workflowSource === undefined)
1599
1849
  continue;
1600
- const workflowRef = parent.state.workflowSource.kind === "builtin"
1601
- ? `builtin:${parent.state.workflowSource.id}`
1602
- : parent.state.workflowSource.path;
1603
- await startRun(ctx, workflowRef, parent.state.input, {
1850
+ const workflowRef = currentParent.state.workflowSource.kind === "builtin"
1851
+ ? `builtin:${currentParent.state.workflowSource.id}`
1852
+ : currentParent.state.workflowSource.path;
1853
+ await startRun(ctx, workflowRef, currentParent.state.input, {
1604
1854
  parentRunId: request.runId,
1605
- humanDecision: accepted,
1855
+ humanDecision: resolved,
1606
1856
  runId: continuation.runId,
1607
1857
  quiet: true,
1608
1858
  });
1609
1859
  }
1610
- await settleHumanDecisionChannels(accepted);
1860
+ await settleHumanDecisionChannels(resolved);
1611
1861
  if (activeRun !== null)
1612
1862
  break;
1613
1863
  }
@@ -1669,7 +1919,7 @@ export default function piWorkflows(pi) {
1669
1919
  }
1670
1920
  if (parent.state.workflowSource !== undefined &&
1671
1921
  !isDeepStrictEqual(parent.state.workflowSource, workflowSource)) {
1672
- throw new Error(`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`);
1922
+ throw continuationSourceChangedError(options.parentRunId, parent.state.workflowSource, workflowSource);
1673
1923
  }
1674
1924
  }
1675
1925
  const snapshot = createDefinitionSnapshot(workflow);
@@ -1758,20 +2008,28 @@ export default function piWorkflows(pi) {
1758
2008
  payload: { errorCode: safe.code, error: safe.message },
1759
2009
  });
1760
2010
  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({
2011
+ const cause = "launchFailed";
2012
+ const descriptor = createDeferredTurnDescriptor({
2013
+ runId: claimed.runId,
2014
+ workflowName: claimed.workflowName,
2015
+ targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
2016
+ cause,
2017
+ sourceEventId: deferredTurnSourceEventId({
1763
2018
  runId: claimed.runId,
2019
+ cause,
1764
2020
  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
- });
2021
+ source: "queued-launch",
2022
+ }),
2023
+ observedState: "failedToStart",
2024
+ nodeId: "$launch",
2025
+ reason: safe.message,
2026
+ });
2027
+ try {
2028
+ storeTurnDescriptor(ctx, descriptor, true);
2029
+ syncArmed = true;
1772
2030
  }
1773
- catch {
1774
- // A deterministic notification id makes duplicate insertion harmless.
2031
+ catch (intentError) {
2032
+ notify(ctx, `Workflow ${claimed.workflowName} failed to start, but Pi Workflows could not preserve its successor turn: ${errorMessage(intentError)}`, "warning");
1775
2033
  }
1776
2034
  notify(ctx, content, "error");
1777
2035
  runSyncPass(ctx);
@@ -1830,7 +2088,7 @@ export default function piWorkflows(pi) {
1830
2088
  return;
1831
2089
  }
1832
2090
  if (parsed.kind === "cancel") {
1833
- const result = await cancelWorkflowControl(ctx);
2091
+ const result = await cancelWorkflowControl(ctx, "user");
1834
2092
  notify(ctx, result.message, result.level);
1835
2093
  return;
1836
2094
  }
@@ -1990,6 +2248,7 @@ export default function piWorkflows(pi) {
1990
2248
  case "stop":
1991
2249
  if (activeRun?.childKey !== undefined) {
1992
2250
  activeRun.interruptionRequested = true;
2251
+ activeRun.pendingAbortCause = "controllerInterrupted";
1993
2252
  activeRun.engine.cancel();
1994
2253
  }
1995
2254
  await host.stop();
@@ -2006,7 +2265,7 @@ export default function piWorkflows(pi) {
2006
2265
  name: "workflow",
2007
2266
  label: "Workflow",
2008
2267
  description: [
2009
- "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
2268
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2010
2269
  "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
2270
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
2012
2271
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
@@ -2032,7 +2291,7 @@ export default function piWorkflows(pi) {
2032
2291
  control = resumeWorkflowControl(ctx);
2033
2292
  break;
2034
2293
  case "cancel":
2035
- control = await cancelWorkflowControl(ctx, params.runId);
2294
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
2036
2295
  break;
2037
2296
  case "answer": {
2038
2297
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
@@ -2190,6 +2449,7 @@ export default function piWorkflows(pi) {
2190
2449
  if (!aborted || runHeld()) {
2191
2450
  return;
2192
2451
  }
2452
+ run.suppressTurnIntent = true;
2193
2453
  run.engine.pause();
2194
2454
  run.executor.hold();
2195
2455
  renderWidget(ctx);
@@ -2228,6 +2488,7 @@ export default function piWorkflows(pi) {
2228
2488
  activeRun?.recorder?.handleToolEnd(event);
2229
2489
  });
2230
2490
  pi.on("agent_settled", async (_event, ctx) => {
2491
+ systemTurnAbort = null;
2231
2492
  if (activeRun === null) {
2232
2493
  try {
2233
2494
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
@@ -2241,23 +2502,30 @@ export default function piWorkflows(pi) {
2241
2502
  return;
2242
2503
  }
2243
2504
  }
2505
+ const flushedNatural = turnCoordinator.flushNatural(ctx.isIdle() && !sessionClosed && !runHeld());
2244
2506
  const run = activeRun;
2245
2507
  if (!run) {
2246
2508
  presentationPending = null;
2509
+ runSyncPass(ctx);
2247
2510
  return;
2248
2511
  }
2249
2512
  await run.recorder?.synchronize(ctx).catch(() => undefined);
2250
2513
  run.recorder?.settleAttempt();
2251
2514
  run.executor.setStreaming(false);
2252
- run.executor.handleAgentSettled();
2515
+ if (flushedNatural === 0)
2516
+ run.executor.handleAgentSettled();
2517
+ runSyncPass(ctx);
2253
2518
  });
2254
2519
  pi.on("session_shutdown", async () => {
2255
2520
  sessionClosed = true;
2256
2521
  herdrProbeGeneration += 1;
2257
2522
  systemTurnAbort = null;
2258
2523
  suppressWorkflowAssistantTail = false;
2524
+ turnCoordinator.clearDeferred();
2259
2525
  supersedePresentation();
2260
2526
  const run = activeRun;
2527
+ if (run !== null)
2528
+ run.suppressTurnIntent = true;
2261
2529
  if (run !== null && run.claimToken !== undefined) {
2262
2530
  // Queued interactive runs park: no terminal event, no recorded partial
2263
2531
  // attempt, and the claim releases so another runner can resume.