@osolmaz/pi-workflows 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +15 -6
  2. package/dist/builtins/autoimplement-command-batches.d.ts +52 -0
  3. package/dist/builtins/autoimplement-command-batches.js +258 -0
  4. package/dist/builtins/autoimplement-command-batches.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +29 -3
  6. package/dist/builtins/autoimplement.workflow.js +876 -231
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/catalog.js +3 -1
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +2 -0
  11. package/dist/builtins/index.js +1 -0
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/sanity-check-session.d.ts +17 -0
  14. package/dist/builtins/sanity-check-session.js +168 -0
  15. package/dist/builtins/sanity-check-session.js.map +1 -0
  16. package/dist/builtins/sanity-check.workflow.d.ts +83 -0
  17. package/dist/builtins/sanity-check.workflow.js +398 -0
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -0
  19. package/dist/controllers/sqlite.d.ts +141 -5
  20. package/dist/controllers/sqlite.js +568 -46
  21. package/dist/controllers/sqlite.js.map +1 -1
  22. package/dist/extension/decision-channels.js +7 -7
  23. package/dist/extension/deferred-turn-coordinator.d.ts +32 -0
  24. package/dist/extension/deferred-turn-coordinator.js +143 -0
  25. package/dist/extension/deferred-turn-coordinator.js.map +1 -0
  26. package/dist/extension/deferred-turn.d.ts +44 -0
  27. package/dist/extension/deferred-turn.js +110 -0
  28. package/dist/extension/deferred-turn.js.map +1 -0
  29. package/dist/extension/index.js +515 -84
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/step-message.d.ts +1 -0
  32. package/dist/extension/step-message.js.map +1 -1
  33. package/dist/herdr/setup.js +4 -4
  34. package/dist/host/runner.js +3 -0
  35. package/dist/host/runner.js.map +1 -1
  36. package/dist/workflows/command-batch.d.ts +38 -0
  37. package/dist/workflows/command-batch.js +176 -0
  38. package/dist/workflows/command-batch.js.map +1 -0
  39. package/dist/workflows/engine.js +8 -4
  40. package/dist/workflows/engine.js.map +1 -1
  41. package/dist/workflows/index.d.ts +1 -0
  42. package/dist/workflows/index.js +1 -0
  43. package/dist/workflows/index.js.map +1 -1
  44. package/dist/workflows/migrate-sources.d.ts +1 -1
  45. package/dist/workflows/migrate-sources.js.map +1 -1
  46. package/dist/workflows/schema.js +2 -1
  47. package/dist/workflows/schema.js.map +1 -1
  48. package/dist/workflows/shell.d.ts +4 -0
  49. package/dist/workflows/shell.js +6 -0
  50. package/dist/workflows/shell.js.map +1 -1
  51. package/dist/workflows/store.js +3 -1
  52. package/dist/workflows/store.js.map +1 -1
  53. package/dist/workflows/tool-input.d.ts +1 -0
  54. package/dist/workflows/tool-input.js +2 -2
  55. package/dist/workflows/tool-input.js.map +1 -1
  56. package/dist/workflows/types.d.ts +4 -3
  57. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  58. package/docs/2026-08-20-durable-workflow-launch-plan.md +445 -0
  59. package/docs/CONTROLLERS.md +2 -2
  60. package/docs/DEFERRED_TURNS.md +298 -0
  61. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  62. package/docs/HUMAN_DECISIONS.md +7 -7
  63. package/docs/HUMAN_DECISION_PRESENTATIONS.md +3 -3
  64. package/docs/MONITOR.md +2 -2
  65. package/docs/WORKFLOW_COMPOSITION.md +2 -2
  66. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  67. package/docs/WORKFLOW_UPDATES.md +28 -5
  68. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  69. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  70. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  71. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  72. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  73. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  74. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  75. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  76. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  77. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  78. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  79. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  80. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  81. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  82. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  83. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  84. package/docs/plans/2026-08-21-sanity-check-plan.md +175 -0
  85. package/docs/run-bundles.md +3 -1
  86. package/docs/workflows.md +67 -17
  87. package/examples/workflows/command-batch.workflow.ts +50 -0
  88. package/examples/workflows/sanity-check.workflow.ts +1 -0
  89. package/herdr-plugin.toml +3 -3
  90. package/package.json +1 -1
  91. package/schemas/decision-presentation-v1.schema.json +1 -1
  92. package/schemas/human-decision-accepted-v1.schema.json +1 -1
  93. package/schemas/human-decision-accepted-v2.schema.json +1 -1
  94. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  95. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  96. package/schemas/human-decision-continuation-v1.schema.json +1 -1
  97. package/schemas/human-decision-delivery-v1.schema.json +1 -1
  98. package/schemas/human-decision-delivery-v2.schema.json +1 -1
  99. package/schemas/human-decision-receipt-v1.schema.json +1 -1
  100. package/schemas/human-decision-receipt-v2.schema.json +1 -1
  101. package/schemas/human-decision-request-v1.schema.json +1 -1
  102. package/schemas/human-decision-request-v2.schema.json +1 -1
  103. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  104. package/schemas/human-decision-resolution-v2.schema.json +1 -1
  105. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  106. package/skills/autodoc/SKILL.md +33 -2
  107. package/skills/autoimplement/SKILL.md +66 -12
  108. package/skills/autoplan/SKILL.md +26 -2
  109. package/skills/monitor/SKILL.md +35 -21
  110. package/skills/pi-workflows/SKILL.md +19 -5
  111. package/src/builtins/autoimplement-command-batches.ts +364 -0
  112. package/src/builtins/autoimplement.workflow.ts +1065 -261
  113. package/src/builtins/catalog.ts +3 -1
  114. package/src/builtins/index.ts +13 -0
  115. package/src/builtins/sanity-check-session.ts +205 -0
  116. package/src/builtins/sanity-check.workflow.ts +624 -0
  117. package/src/controllers/sqlite.ts +878 -51
  118. package/src/extension/decision-channels.ts +7 -7
  119. package/src/extension/deferred-turn-coordinator.ts +171 -0
  120. package/src/extension/deferred-turn.ts +166 -0
  121. package/src/extension/index.ts +646 -91
  122. package/src/extension/step-message.ts +1 -0
  123. package/src/herdr/setup.ts +4 -4
  124. package/src/host/runner.ts +3 -0
  125. package/src/workflows/command-batch.ts +254 -0
  126. package/src/workflows/engine.ts +8 -5
  127. package/src/workflows/index.ts +15 -0
  128. package/src/workflows/migrate-sources.ts +5 -1
  129. package/src/workflows/schema.ts +2 -1
  130. package/src/workflows/shell.ts +10 -0
  131. package/src/workflows/store.ts +3 -1
  132. package/src/workflows/tool-input.ts +5 -2
  133. package/src/workflows/types.ts +4 -3
@@ -1,8 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
- import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
5
+ import { projectControllerStorePath, SqliteControllerStore, } from "../controllers/index.js";
6
+ import { compositionMetadata } from "../workflows/composition.js";
6
7
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
7
8
  import { WorkflowEngine } from "../workflows/engine.js";
8
9
  import { ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
@@ -13,6 +14,8 @@ import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/pr
13
14
  import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
14
15
  import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
15
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";
16
19
  import { ConversationStepExecutor } from "./executor.js";
17
20
  import { HerdrWorkflowViewer, parseViewerPlacement, PIW_SHORTCUT, PIW_SHORTCUT_HINT, VIEWER_PLACEMENTS, } from "./herdr-viewer.js";
18
21
  import { SessionRecorder } from "./recorder.js";
@@ -24,8 +27,10 @@ const RUN_CLAIM_LEASE_MS = 30_000;
24
27
  const RUN_CLAIM_RENEW_MS = 10_000;
25
28
  const RUN_SYNC_POLL_MS = 3_000;
26
29
  const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
30
+ const TURN_INTENT_DELIVERY_LEASE_MS = 30_000;
27
31
  const WIDGET_KEY = "pi-workflows";
28
32
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
33
+ const PRESENTATION_MESSAGE_SCHEMA = "pi-workflows.presentation-message.v1";
29
34
  const FINAL_WIDGET_TTL_MS = 60_000;
30
35
  const WIDGET_SCROLL_STEP = 3;
31
36
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
@@ -56,6 +61,44 @@ function humanDecisionRequest(value) {
56
61
  }
57
62
  return value;
58
63
  }
64
+ function definitionDigest(snapshot) {
65
+ return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
66
+ }
67
+ function launchSourceIdentity(workflow, root) {
68
+ return {
69
+ root,
70
+ mounted: compositionMetadata(workflow)?.sources ?? [],
71
+ };
72
+ }
73
+ function preparedLaunchOptions(options) {
74
+ return {
75
+ ...(options.presentation !== undefined ? { presentation: options.presentation } : {}),
76
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
77
+ ...(options.humanDecision !== undefined ? { humanDecision: options.humanDecision } : {}),
78
+ };
79
+ }
80
+ function parsePreparedLaunchOptions(value) {
81
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
82
+ throw new Error("Stored workflow launch options are invalid");
83
+ }
84
+ return value;
85
+ }
86
+ function safeLaunchError(error) {
87
+ const raw = errorMessage(error)
88
+ .replace(/Bearer\s+\S+/giu, "Bearer [redacted]")
89
+ .replace(/(token|api[_-]?key|secret|password)(\s*[:=]\s*)\S+/giu, "$1$2[redacted]")
90
+ .replaceAll("\n", " ")
91
+ .trim();
92
+ const code = /not found|cannot find|unknown workflow/iu.test(raw)
93
+ ? "workflow_not_found"
94
+ : /source changed|source mismatch/iu.test(raw)
95
+ ? "source_changed"
96
+ : /invalid|must be/iu.test(raw)
97
+ ? "workflow_invalid"
98
+ : "activation_failed";
99
+ const message = raw.length <= 500 ? raw : `${raw.slice(0, 480)}… [error truncated]`;
100
+ return { code, message: message || "The deferred workflow could not start" };
101
+ }
59
102
  /** Parse `/workflow` arguments. Exported for tests. */
60
103
  export function parseWorkflowArgs(args) {
61
104
  const trimmed = args.trim();
@@ -178,8 +221,10 @@ export default function piWorkflows(pi) {
178
221
  // Run events remain an audit feed and never enter a conversation.
179
222
  let syncArmed = false;
180
223
  let runSyncTimer = null;
224
+ let activationRecovery;
181
225
  let decisionRecoveryTimer = null;
182
226
  let decisionRecoveryActive = false;
227
+ let turnCoordinator;
183
228
  const recordRunEvent = (event) => {
184
229
  try {
185
230
  runQueueStore?.recordRunEvent({ ...event, runnerId });
@@ -207,6 +252,7 @@ export default function piWorkflows(pi) {
207
252
  if (runQueueStore === null || !syncArmed)
208
253
  return;
209
254
  try {
255
+ activationRecovery?.(ctx);
210
256
  const sessionId = ctx.sessionManager.getSessionId();
211
257
  const alreadyDelivered = deliveredNotificationIds(ctx);
212
258
  const claimToken = randomUUID();
@@ -234,6 +280,19 @@ export default function piWorkflows(pi) {
234
280
  claimToken,
235
281
  });
236
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);
237
296
  }
238
297
  catch {
239
298
  // Delivery retries on the next poll. It never affects workflow execution.
@@ -252,7 +311,6 @@ export default function piWorkflows(pi) {
252
311
  let systemTurnAbort = null;
253
312
  let suppressWorkflowAssistantTail = false;
254
313
  let lastExpiredAttempt = null;
255
- let pendingToolLaunch = null;
256
314
  // The interactive run currently parked at a checkpoint, if any.
257
315
  let lastWaitingRunId = null;
258
316
  let widgetTimer = null;
@@ -270,6 +328,35 @@ export default function piWorkflows(pi) {
270
328
  let presentationPending = null;
271
329
  let controllerHost;
272
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
+ });
273
360
  // UI updates are best-effort: a captured ctx becomes stale after session
274
361
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
275
362
  // A workflow finishing right as the session goes away must not crash pi.
@@ -310,6 +397,115 @@ export default function piWorkflows(pi) {
310
397
  };
311
398
  /** True when the run is held for the user (escape or /workflow pause). */
312
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
+ };
313
509
  const footerStatus = (state) => {
314
510
  const label = runHeld() || state.paused ? "paused" : state.status;
315
511
  const node = state.currentNode ?? state.waitingOn;
@@ -470,20 +666,36 @@ export default function piWorkflows(pi) {
470
666
  const instructions = typeof run.presentationPrompt === "function"
471
667
  ? await resolvePresentationPrompt(run.presentationPrompt, state, abort.signal)
472
668
  : run.presentationPrompt;
473
- if (sessionClosed ||
474
- run.generation !== runGeneration ||
475
- abort.signal.aborted ||
476
- instructions === undefined ||
477
- instructions.trim().length === 0) {
669
+ if (sessionClosed || run.generation !== runGeneration || abort.signal.aborted) {
670
+ return;
671
+ }
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
+ }
478
676
  return;
479
677
  }
480
- presentationPending = run.generation;
481
- suppressWorkflowAssistantTail = false;
482
- pi.sendMessage({
483
- customType: PRESENTATION_MESSAGE_TYPE,
484
- content: buildPresentationMessage(instructions, state),
485
- display: false,
486
- }, { deliverAs: "steer", triggerTurn: true });
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);
487
699
  }
488
700
  catch (error) {
489
701
  if (presentationPending === run.generation) {
@@ -498,6 +710,9 @@ export default function piWorkflows(pi) {
498
710
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
499
711
  : errorMessage(error);
500
712
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
713
+ if (run.abortProvenance !== undefined) {
714
+ makeRunTurnIntentEligible(ctx, run, state.status, message);
715
+ }
501
716
  }
502
717
  finally {
503
718
  clearTimeout(timer);
@@ -548,6 +763,19 @@ export default function piWorkflows(pi) {
548
763
  clearWidget(ctx);
549
764
  return;
550
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
+ }
551
779
  releaseClaim(run, "done");
552
780
  recordRunEvent({
553
781
  runId: run.runId,
@@ -596,6 +824,14 @@ export default function piWorkflows(pi) {
596
824
  catch (error) {
597
825
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
598
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
+ }
599
835
  void presentRun(ctx, run, state);
600
836
  if (pendingDecision !== null && state.status === "waiting") {
601
837
  const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
@@ -671,7 +907,10 @@ export default function piWorkflows(pi) {
671
907
  workflowSourceRef: workflowSource.kind === "builtin"
672
908
  ? `builtin:${workflowSource.id}`
673
909
  : workflowSource.path,
910
+ workflowSource: launchSourceIdentity(workflow, workflowSource),
911
+ definitionDigest: definitionDigest(snapshot),
674
912
  input,
913
+ launchOptions: preparedLaunchOptions(options),
675
914
  runnerId,
676
915
  claimToken: token,
677
916
  leaseMs: RUN_CLAIM_LEASE_MS,
@@ -699,21 +938,29 @@ export default function piWorkflows(pi) {
699
938
  }
700
939
  const executor = new ConversationStepExecutor({
701
940
  sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
702
- const details = {
703
- schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
704
- kind,
705
- contract,
706
- ...(presentation !== undefined ? { presentation } : {}),
707
- };
708
- pi.sendMessage({
709
- customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
710
- content: prompt,
711
- display: true,
712
- details,
713
- }, {
714
- triggerTurn: true,
715
- deliverAs: streaming ? "steer" : "followUp",
716
- });
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);
717
964
  },
718
965
  onAbort: (contract, reason) => {
719
966
  lastExpiredAttempt = {
@@ -721,7 +968,15 @@ export default function piWorkflows(pi) {
721
968
  reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
722
969
  };
723
970
  if (!executor.held && !ctx.isIdle()) {
724
- 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
+ };
725
980
  ctx.abort();
726
981
  }
727
982
  },
@@ -799,6 +1054,11 @@ export default function piWorkflows(pi) {
799
1054
  ...(options.resume === true ? { resume: true } : {}),
800
1055
  ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
801
1056
  };
1057
+ if (queueStore !== null &&
1058
+ claimToken !== undefined &&
1059
+ !queueStore.markWorkflowRunRunning({ runId, claimToken })) {
1060
+ throw new ClaimLostError(runId);
1061
+ }
802
1062
  activeRun = run;
803
1063
  if (queueStore !== null && claimToken !== undefined) {
804
1064
  const store = queueStore;
@@ -809,6 +1069,7 @@ export default function piWorkflows(pi) {
809
1069
  run.renewTimer = setInterval(() => {
810
1070
  try {
811
1071
  if (!store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })) {
1072
+ run.pendingAbortCause = "claimLost";
812
1073
  run.engine.cancel();
813
1074
  }
814
1075
  }
@@ -902,6 +1163,11 @@ export default function piWorkflows(pi) {
902
1163
  }
903
1164
  if (terminalStatus !== undefined) {
904
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
+ }
905
1171
  notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
906
1172
  return;
907
1173
  }
@@ -911,6 +1177,7 @@ export default function piWorkflows(pi) {
911
1177
  type: "failed",
912
1178
  payload: { error: message },
913
1179
  });
1180
+ makeRunTurnIntentEligible(ctx, run, "failed", message);
914
1181
  try {
915
1182
  run.onFinish?.({ state: "failed", runId, error: message });
916
1183
  }
@@ -948,8 +1215,10 @@ export default function piWorkflows(pi) {
948
1215
  : bundle.state.workflowSource.kind === "builtin"
949
1216
  ? `builtin:${bundle.state.workflowSource.id}`
950
1217
  : bundle.state.workflowSource.path;
1218
+ const launchOptions = parsePreparedLaunchOptions(claimed.launchOptions);
951
1219
  started = await startRun(ctx, sourceRef, claimed.input, {
952
- resume: true,
1220
+ ...launchOptions,
1221
+ resume: bundle !== null,
953
1222
  runId: claimed.runId,
954
1223
  claimToken,
955
1224
  });
@@ -1103,22 +1372,42 @@ export default function piWorkflows(pi) {
1103
1372
  }
1104
1373
  return null;
1105
1374
  };
1106
- const cancelWorkflowControl = async (ctx) => {
1107
- if (activeRun) {
1375
+ const cancelWorkflowControl = async (ctx, origin, requestedRunId) => {
1376
+ if (activeRun && (requestedRunId === undefined || requestedRunId === activeRun.runId)) {
1108
1377
  const workflowName = activeRun.workflowName;
1109
1378
  const runId = activeRun.runId;
1379
+ activeRun.pendingAbortCause = origin === "agent" ? "agentCancelled" : undefined;
1380
+ activeRun.suppressTurnIntent = origin === "user";
1110
1381
  activeRun.engine.cancel();
1111
1382
  return {
1112
1383
  message: `Cancelling workflow ${workflowName}…`,
1113
1384
  details: { action: "cancel", workflowName, runId },
1114
1385
  };
1115
1386
  }
1116
- if (pendingToolLaunch !== null) {
1117
- const ref = pendingToolLaunch.ref;
1118
- pendingToolLaunch = null;
1387
+ const queue = ensureRunQueueStore(ctx.cwd);
1388
+ const queued = requestedRunId === undefined
1389
+ ? queue.findSessionReservation(ctx.sessionManager.getSessionId())
1390
+ : queue.getWorkflowRun(requestedRunId);
1391
+ if (queued !== undefined &&
1392
+ ["queued", "starting"].includes(queued.status) &&
1393
+ (queued.originSessionId === null ||
1394
+ queued.originSessionId === ctx.sessionManager.getSessionId())) {
1395
+ if (!queue.cancelWorkflowRun({ runId: queued.runId })) {
1396
+ throw new Error(`Workflow ${queued.runId} could not be cancelled because its state changed.`);
1397
+ }
1398
+ recordRunEvent({
1399
+ runId: queued.runId,
1400
+ workflowRef: queued.workflowName,
1401
+ type: "cancelled",
1402
+ });
1119
1403
  return {
1120
- message: `Cancelled the queued workflow launch for ${ref}.`,
1121
- details: { action: "cancel", workflow: ref, queued: false },
1404
+ message: `Cancelled queued workflow ${queued.workflowName} (run ${queued.runId}).`,
1405
+ details: {
1406
+ action: "cancel",
1407
+ workflow: queued.workflowName,
1408
+ runId: queued.runId,
1409
+ queued: false,
1410
+ },
1122
1411
  };
1123
1412
  }
1124
1413
  if (widgetSource) {
@@ -1180,6 +1469,7 @@ export default function piWorkflows(pi) {
1180
1469
  },
1181
1470
  };
1182
1471
  }
1472
+ activeRun.suppressTurnIntent = true;
1183
1473
  activeRun.engine.pause();
1184
1474
  renderWidget(ctx);
1185
1475
  return {
@@ -1212,6 +1502,7 @@ export default function piWorkflows(pi) {
1212
1502
  level: "warning",
1213
1503
  };
1214
1504
  }
1505
+ activeRun.suppressTurnIntent = false;
1215
1506
  activeRun.engine.resume();
1216
1507
  activeRun.executor.release();
1217
1508
  renderWidget(ctx);
@@ -1225,11 +1516,28 @@ export default function piWorkflows(pi) {
1225
1516
  },
1226
1517
  };
1227
1518
  };
1519
+ const workflowLaunchStatus = (record) => ({
1520
+ message: `Workflow ${record.workflowName} is ${record.status} (run ${record.runId}).`,
1521
+ details: {
1522
+ action: "status",
1523
+ active: ["starting", "running"].includes(record.status),
1524
+ queued: record.status === "queued",
1525
+ workflowName: record.workflowName,
1526
+ runId: record.runId,
1527
+ status: record.status,
1528
+ ...(record.errorCode === null ? {} : { errorCode: record.errorCode }),
1529
+ ...(record.errorMessage === null ? {} : { error: record.errorMessage }),
1530
+ },
1531
+ ...(["failed", "cancelled"].includes(record.status) ? { level: "warning" } : {}),
1532
+ });
1228
1533
  const statusWorkflowControl = async (ctx, runId) => {
1229
1534
  if (runId !== undefined) {
1230
1535
  const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
1231
1536
  if (bundle === null) {
1232
- throw new Error(`Workflow run not found: ${runId}`);
1537
+ const launch = ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId);
1538
+ if (launch === undefined)
1539
+ throw new Error(`Workflow run not found: ${runId}`);
1540
+ return workflowLaunchStatus(launch);
1233
1541
  }
1234
1542
  const { state } = bundle;
1235
1543
  return {
@@ -1238,11 +1546,10 @@ export default function piWorkflows(pi) {
1238
1546
  };
1239
1547
  }
1240
1548
  const state = activeRun?.lastState ?? widgetSource?.state;
1241
- if ((state === undefined || state === null) && pendingToolLaunch !== null) {
1242
- return {
1243
- message: `Workflow ${pendingToolLaunch.ref} is queued until the current turn finishes.`,
1244
- details: { active: false, queued: true, workflow: pendingToolLaunch.ref },
1245
- };
1549
+ if (state === undefined || state === null) {
1550
+ const queued = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
1551
+ if (queued !== undefined)
1552
+ return workflowLaunchStatus(queued);
1246
1553
  }
1247
1554
  if (state === undefined || state === null) {
1248
1555
  return {
@@ -1545,8 +1852,9 @@ export default function piWorkflows(pi) {
1545
1852
  if (activeRun !== null) {
1546
1853
  throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
1547
1854
  }
1548
- if (pendingToolLaunch !== null) {
1549
- throw new Error("A workflow launch is already waiting for the current turn to finish.");
1855
+ const reserved = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
1856
+ if (reserved !== undefined) {
1857
+ throw new Error(`Workflow ${reserved.workflowName} is already ${reserved.status} (run ${reserved.runId}).`);
1550
1858
  }
1551
1859
  if (presentationPending !== null) {
1552
1860
  throw new Error("The previous workflow result is still being presented.");
@@ -1564,36 +1872,148 @@ export default function piWorkflows(pi) {
1564
1872
  if (activeRun !== null) {
1565
1873
  throw new Error(`A workflow is already running: ${activeRun.workflowName}. Cancel it before starting another.`);
1566
1874
  }
1567
- if (pendingToolLaunch !== null) {
1568
- throw new Error("A workflow launch is already waiting for the current turn to finish.");
1875
+ const queue = ensureRunQueueStore(ctx.cwd);
1876
+ const existing = queue.findSessionReservation(ctx.sessionManager.getSessionId());
1877
+ if (existing !== undefined) {
1878
+ throw new Error(`Workflow ${existing.workflowName} is already ${existing.status} (run ${existing.runId}).`);
1569
1879
  }
1570
1880
  if (presentationPending !== null) {
1571
1881
  throw new Error("The previous workflow result is still being presented.");
1572
1882
  }
1573
- const reservation = { ctx, ref, input, options };
1574
- pendingToolLaunch = reservation;
1575
- try {
1576
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1577
- const workflow = resolved.definition;
1578
- if (pendingToolLaunch !== reservation) {
1579
- throw new Error("The queued workflow launch was cancelled before validation finished.");
1883
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1884
+ const workflow = resolved.definition;
1885
+ const workflowSource = resolved.source;
1886
+ if (options.parentRunId !== undefined) {
1887
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(options.parentRunId));
1888
+ if (parent === null || parent.state.status !== "waiting") {
1889
+ throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
1580
1890
  }
1581
- return {
1582
- message: `Workflow ${workflow.name} will start after this turn finishes.`,
1583
- details: {
1584
- action: "start",
1585
- workflow: workflow.name,
1586
- source: resolved.source,
1587
- queued: true,
1588
- },
1589
- };
1891
+ if (parent.state.workflowSource !== undefined &&
1892
+ !isDeepStrictEqual(parent.state.workflowSource, workflowSource)) {
1893
+ throw new Error(`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`);
1894
+ }
1895
+ }
1896
+ const snapshot = createDefinitionSnapshot(workflow);
1897
+ const runId = createRunId(workflow.name);
1898
+ try {
1899
+ queue.reserveWorkflowRun({
1900
+ runId,
1901
+ workflowName: workflow.name,
1902
+ workflowSourceRef: workflowSource.kind === "builtin" ? `builtin:${workflowSource.id}` : workflowSource.path,
1903
+ workflowSource: launchSourceIdentity(workflow, workflowSource),
1904
+ definitionDigest: definitionDigest(snapshot),
1905
+ input,
1906
+ launchOptions: preparedLaunchOptions(options),
1907
+ runnerId,
1908
+ originSessionId: ctx.sessionManager.getSessionId(),
1909
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
1910
+ });
1590
1911
  }
1591
1912
  catch (error) {
1592
- if (pendingToolLaunch === reservation) {
1593
- pendingToolLaunch = null;
1913
+ const reserved = queue.findSessionReservation(ctx.sessionManager.getSessionId());
1914
+ if (reserved !== undefined) {
1915
+ throw new Error(`A workflow launch is already waiting: ${reserved.workflowName} (run ${reserved.runId}).`, { cause: error });
1594
1916
  }
1595
1917
  throw error;
1596
1918
  }
1919
+ recordRunEvent({
1920
+ runId,
1921
+ workflowRef: workflow.name,
1922
+ type: "queued",
1923
+ payload: options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId },
1924
+ });
1925
+ syncArmed = true;
1926
+ return {
1927
+ message: `Workflow ${workflow.name} queued (run ${runId}).`,
1928
+ details: {
1929
+ action: "start",
1930
+ workflow: workflow.name,
1931
+ runId,
1932
+ source: workflowSource,
1933
+ queued: true,
1934
+ },
1935
+ };
1936
+ };
1937
+ const activatePreparedLaunch = async (ctx, prepared) => {
1938
+ const queue = ensureRunQueueStore(ctx.cwd);
1939
+ const claimToken = randomUUID();
1940
+ const claimed = queue.claimWorkflowRun({
1941
+ runId: prepared.runId,
1942
+ runnerId,
1943
+ claimToken,
1944
+ leaseMs: RUN_CLAIM_LEASE_MS,
1945
+ });
1946
+ if (claimed === undefined)
1947
+ return false;
1948
+ try {
1949
+ const resolved = await resolveWorkflowRef(claimed.workflowSourceRef, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1950
+ const snapshot = createDefinitionSnapshot(resolved.definition);
1951
+ if (!isDeepStrictEqual(launchSourceIdentity(resolved.definition, resolved.source), claimed.workflowSource) ||
1952
+ definitionDigest(snapshot) !== claimed.definitionDigest) {
1953
+ throw new Error("Workflow source changed after the launch was queued");
1954
+ }
1955
+ const launchOptions = parsePreparedLaunchOptions(claimed.launchOptions);
1956
+ const started = await startRun(ctx, claimed.workflowSourceRef, claimed.input, {
1957
+ ...launchOptions,
1958
+ runId: claimed.runId,
1959
+ claimToken,
1960
+ });
1961
+ if (started === undefined)
1962
+ throw new Error("The queued workflow could not start");
1963
+ if (launchOptions.parentRunId !== undefined)
1964
+ lastWaitingRunId = null;
1965
+ return true;
1966
+ }
1967
+ catch (error) {
1968
+ const safe = safeLaunchError(error);
1969
+ queue.failWorkflowRun({
1970
+ runId: claimed.runId,
1971
+ claimToken,
1972
+ errorCode: safe.code,
1973
+ errorMessage: safe.message,
1974
+ });
1975
+ recordRunEvent({
1976
+ runId: claimed.runId,
1977
+ workflowRef: claimed.workflowName,
1978
+ type: "launch_failed",
1979
+ payload: { errorCode: safe.code, error: safe.message },
1980
+ });
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.`;
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({
1989
+ runId: claimed.runId,
1990
+ cause,
1991
+ nodeId: "$launch",
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;
2001
+ }
2002
+ catch (intentError) {
2003
+ notify(ctx, `Workflow ${claimed.workflowName} failed to start, but Pi Workflows could not preserve its successor turn: ${errorMessage(intentError)}`, "warning");
2004
+ }
2005
+ notify(ctx, content, "error");
2006
+ runSyncPass(ctx);
2007
+ return false;
2008
+ }
2009
+ };
2010
+ activationRecovery = (ctx) => {
2011
+ if (activeRun !== null)
2012
+ return;
2013
+ const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
2014
+ if (prepared !== undefined && ["queued", "starting"].includes(prepared.status)) {
2015
+ void activatePreparedLaunch(ctx, prepared).catch(() => undefined);
2016
+ }
1597
2017
  };
1598
2018
  pi.registerCommand("piw", {
1599
2019
  description: "Open the current workflow run in piw through Herdr",
@@ -1639,7 +2059,7 @@ export default function piWorkflows(pi) {
1639
2059
  return;
1640
2060
  }
1641
2061
  if (parsed.kind === "cancel") {
1642
- const result = await cancelWorkflowControl(ctx);
2062
+ const result = await cancelWorkflowControl(ctx, "user");
1643
2063
  notify(ctx, result.message, result.level);
1644
2064
  return;
1645
2065
  }
@@ -1799,6 +2219,7 @@ export default function piWorkflows(pi) {
1799
2219
  case "stop":
1800
2220
  if (activeRun?.childKey !== undefined) {
1801
2221
  activeRun.interruptionRequested = true;
2222
+ activeRun.pendingAbortCause = "controllerInterrupted";
1802
2223
  activeRun.engine.cancel();
1803
2224
  }
1804
2225
  await host.stop();
@@ -1815,7 +2236,7 @@ export default function piWorkflows(pi) {
1815
2236
  name: "workflow",
1816
2237
  label: "Workflow",
1817
2238
  description: [
1818
- "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.",
1819
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.",
1820
2241
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1821
2242
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
@@ -1841,7 +2262,7 @@ export default function piWorkflows(pi) {
1841
2262
  control = resumeWorkflowControl(ctx);
1842
2263
  break;
1843
2264
  case "cancel":
1844
- control = await cancelWorkflowControl(ctx);
2265
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
1845
2266
  break;
1846
2267
  case "answer": {
1847
2268
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
@@ -1951,7 +2372,13 @@ export default function piWorkflows(pi) {
1951
2372
  notify(ctx, `Could not recover human decisions: ${errorMessage(error)}`, "warning");
1952
2373
  }
1953
2374
  try {
1954
- await resumeParkedRun(ctx);
2375
+ const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
2376
+ if (prepared !== undefined && ["queued", "starting"].includes(prepared.status)) {
2377
+ await activatePreparedLaunch(ctx, prepared);
2378
+ }
2379
+ else {
2380
+ await resumeParkedRun(ctx);
2381
+ }
1955
2382
  }
1956
2383
  catch (error) {
1957
2384
  notify(ctx, `Could not resume a parked workflow: ${errorMessage(error)}`, "warning");
@@ -1993,6 +2420,7 @@ export default function piWorkflows(pi) {
1993
2420
  if (!aborted || runHeld()) {
1994
2421
  return;
1995
2422
  }
2423
+ run.suppressTurnIntent = true;
1996
2424
  run.engine.pause();
1997
2425
  run.executor.hold();
1998
2426
  renderWidget(ctx);
@@ -2031,40 +2459,44 @@ export default function piWorkflows(pi) {
2031
2459
  activeRun?.recorder?.handleToolEnd(event);
2032
2460
  });
2033
2461
  pi.on("agent_settled", async (_event, ctx) => {
2034
- if (activeRun === null && pendingToolLaunch !== null) {
2035
- const launch = pendingToolLaunch;
2036
- pendingToolLaunch = null;
2462
+ systemTurnAbort = null;
2463
+ if (activeRun === null) {
2037
2464
  try {
2038
- const runId = await startRun(launch.ctx, launch.ref, launch.input, launch.options);
2039
- if (runId === undefined) {
2040
- notify(launch.ctx, "The queued workflow could not start.", "error");
2041
- }
2042
- else if (launch.options?.parentRunId !== undefined) {
2043
- lastWaitingRunId = null;
2465
+ const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
2466
+ if (prepared !== undefined && ["queued", "starting"].includes(prepared.status)) {
2467
+ await activatePreparedLaunch(ctx, prepared);
2468
+ return;
2044
2469
  }
2045
2470
  }
2046
2471
  catch (error) {
2047
- notify(launch.ctx, `Could not start queued workflow: ${errorMessage(error)}`, "error");
2472
+ notify(ctx, `Could not activate a queued workflow: ${safeLaunchError(error).message}`, "warning");
2473
+ return;
2048
2474
  }
2049
- return;
2050
2475
  }
2476
+ const flushedNatural = turnCoordinator.flushNatural(ctx.isIdle() && !sessionClosed && !runHeld());
2051
2477
  const run = activeRun;
2052
2478
  if (!run) {
2053
2479
  presentationPending = null;
2480
+ runSyncPass(ctx);
2054
2481
  return;
2055
2482
  }
2056
2483
  await run.recorder?.synchronize(ctx).catch(() => undefined);
2057
2484
  run.recorder?.settleAttempt();
2058
2485
  run.executor.setStreaming(false);
2059
- run.executor.handleAgentSettled();
2486
+ if (flushedNatural === 0)
2487
+ run.executor.handleAgentSettled();
2488
+ runSyncPass(ctx);
2060
2489
  });
2061
2490
  pi.on("session_shutdown", async () => {
2062
2491
  sessionClosed = true;
2063
2492
  herdrProbeGeneration += 1;
2064
2493
  systemTurnAbort = null;
2065
2494
  suppressWorkflowAssistantTail = false;
2495
+ turnCoordinator.clearDeferred();
2066
2496
  supersedePresentation();
2067
2497
  const run = activeRun;
2498
+ if (run !== null)
2499
+ run.suppressTurnIntent = true;
2068
2500
  if (run !== null && run.claimToken !== undefined) {
2069
2501
  // Queued interactive runs park: no terminal event, no recorded partial
2070
2502
  // attempt, and the claim releases so another runner can resume.
@@ -2076,7 +2508,6 @@ export default function piWorkflows(pi) {
2076
2508
  await run?.recorder?.stop().catch(() => undefined);
2077
2509
  await run?.completion?.catch(() => undefined);
2078
2510
  activeRun = null;
2079
- pendingToolLaunch = null;
2080
2511
  lastWaitingRunId = null;
2081
2512
  if (runSyncTimer !== null) {
2082
2513
  clearInterval(runSyncTimer);