@bastani/atomic 0.9.3-alpha.1 → 0.9.3-alpha.3

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 (175) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/builtin/cursor/CHANGELOG.md +21 -0
  3. package/dist/builtin/cursor/README.md +2 -1
  4. package/dist/builtin/cursor/package.json +2 -2
  5. package/dist/builtin/cursor/src/cursor-models-raw.json +2 -9
  6. package/dist/builtin/cursor/src/model-mapper.ts +14 -3
  7. package/dist/builtin/cursor/src/proto/protobuf-codec-base64.ts +22 -0
  8. package/dist/builtin/cursor/src/proto/protobuf-codec-request.ts +53 -13
  9. package/dist/builtin/cursor/src/proto/protobuf-codec-wire.ts +24 -7
  10. package/dist/builtin/cursor/src/proto/protobuf-codec.ts +3 -2
  11. package/dist/builtin/cursor/src/stream.ts +5 -11
  12. package/dist/builtin/cursor/src/transport-types.ts +3 -0
  13. package/dist/builtin/cursor/src/transport.ts +1 -0
  14. package/dist/builtin/intercom/CHANGELOG.md +6 -0
  15. package/dist/builtin/intercom/package.json +1 -1
  16. package/dist/builtin/mcp/CHANGELOG.md +6 -0
  17. package/dist/builtin/mcp/package.json +1 -1
  18. package/dist/builtin/subagents/CHANGELOG.md +15 -0
  19. package/dist/builtin/subagents/package.json +1 -1
  20. package/dist/builtin/subagents/src/extension/fanout-child.ts +1 -0
  21. package/dist/builtin/subagents/src/extension/index.ts +6 -3
  22. package/dist/builtin/subagents/src/extension/schemas.ts +0 -5
  23. package/dist/builtin/subagents/src/runs/background/async-job-tracker.ts +1 -4
  24. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-single.ts +15 -1
  25. package/dist/builtin/subagents/src/runs/foreground/subagent-executor.ts +35 -1
  26. package/dist/builtin/subagents/src/runs/shared/subagent-prompt-runtime.ts +4 -2
  27. package/dist/builtin/subagents/src/shared/types-async.ts +1 -0
  28. package/dist/builtin/subagents/src/slash/prompt-template-bridge.ts +27 -5
  29. package/dist/builtin/subagents/src/tui/render-layout.ts +27 -4
  30. package/dist/builtin/subagents/src/tui/render-result-animation.ts +22 -31
  31. package/dist/builtin/subagents/src/tui/render-result-compact.ts +6 -6
  32. package/dist/builtin/subagents/src/tui/render-result.ts +20 -19
  33. package/dist/builtin/subagents/src/tui/render-status-progress.ts +3 -3
  34. package/dist/builtin/subagents/src/tui/render-widget.ts +46 -7
  35. package/dist/builtin/subagents/src/tui/render.ts +2 -2
  36. package/dist/builtin/web-access/CHANGELOG.md +6 -0
  37. package/dist/builtin/web-access/package.json +1 -1
  38. package/dist/builtin/workflows/CHANGELOG.md +49 -0
  39. package/dist/builtin/workflows/README.md +1 -1
  40. package/dist/builtin/workflows/package.json +1 -1
  41. package/dist/builtin/workflows/src/authoring.d.ts +1 -1
  42. package/dist/builtin/workflows/src/durable/backend.ts +343 -0
  43. package/dist/builtin/workflows/src/durable/child-primitive.ts +79 -0
  44. package/dist/builtin/workflows/src/durable/dbos-backend.ts +421 -0
  45. package/dist/builtin/workflows/src/durable/dbos-envelope.ts +171 -0
  46. package/dist/builtin/workflows/src/durable/factory.ts +96 -0
  47. package/dist/builtin/workflows/src/durable/file-backend.ts +433 -0
  48. package/dist/builtin/workflows/src/durable/index.ts +73 -0
  49. package/dist/builtin/workflows/src/durable/resume-catalog.ts +217 -0
  50. package/dist/builtin/workflows/src/durable/resume-runtime.ts +299 -0
  51. package/dist/builtin/workflows/src/durable/scoped-backend.ts +171 -0
  52. package/dist/builtin/workflows/src/durable/stage-primitive.ts +284 -0
  53. package/dist/builtin/workflows/src/durable/tool-primitive.ts +180 -0
  54. package/dist/builtin/workflows/src/durable/types.ts +168 -0
  55. package/dist/builtin/workflows/src/durable/ui-primitive.ts +96 -0
  56. package/dist/builtin/workflows/src/engine/options.ts +3 -0
  57. package/dist/builtin/workflows/src/engine/primitives/parallel.ts +2 -2
  58. package/dist/builtin/workflows/src/engine/primitives/task.ts +4 -4
  59. package/dist/builtin/workflows/src/engine/primitives/ui.ts +22 -8
  60. package/dist/builtin/workflows/src/engine/primitives/workflow.ts +8 -0
  61. package/dist/builtin/workflows/src/engine/run-durable-finalize.ts +69 -0
  62. package/dist/builtin/workflows/src/engine/run-durable-stage-session.ts +31 -0
  63. package/dist/builtin/workflows/src/engine/run.ts +148 -6
  64. package/dist/builtin/workflows/src/engine/runtime.ts +8 -2
  65. package/dist/builtin/workflows/src/extension/extension-factory.ts +6 -12
  66. package/dist/builtin/workflows/src/extension/extension-lifecycle.ts +5 -1
  67. package/dist/builtin/workflows/src/extension/extension-runtime-state.ts +3 -0
  68. package/dist/builtin/workflows/src/extension/runtime.ts +48 -9
  69. package/dist/builtin/workflows/src/extension/workflow-run-control-command.ts +143 -4
  70. package/dist/builtin/workflows/src/runs/background/quit.ts +61 -0
  71. package/dist/builtin/workflows/src/runs/background/status.ts +1 -0
  72. package/dist/builtin/workflows/src/runs/foreground/executor-direct-helpers.ts +5 -5
  73. package/dist/builtin/workflows/src/runs/foreground/executor-stage-call.ts +74 -33
  74. package/dist/builtin/workflows/src/runs/foreground/executor-stage-context.ts +20 -1
  75. package/dist/builtin/workflows/src/runs/foreground/executor-stage-factory.ts +8 -7
  76. package/dist/builtin/workflows/src/runs/foreground/executor-stage-replay.ts +1 -0
  77. package/dist/builtin/workflows/src/runs/foreground/executor-stage-types.ts +1 -1
  78. package/dist/builtin/workflows/src/runs/foreground/executor-types.ts +19 -2
  79. package/dist/builtin/workflows/src/runs/foreground/stage-runner-context.ts +4 -0
  80. package/dist/builtin/workflows/src/runs/foreground/stage-runner-controller.ts +10 -10
  81. package/dist/builtin/workflows/src/runs/foreground/stage-runner-options.ts +5 -1
  82. package/dist/builtin/workflows/src/runs/foreground/stage-runner-send-user-message.ts +25 -0
  83. package/dist/builtin/workflows/src/runs/foreground/stage-runner-types.ts +3 -0
  84. package/dist/builtin/workflows/src/shared/authoring-contract-stage.d.ts +16 -0
  85. package/dist/builtin/workflows/src/shared/authoring-contract-stage.ts +20 -0
  86. package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +23 -1
  87. package/dist/builtin/workflows/src/shared/authoring-contract-ui.ts +30 -1
  88. package/dist/builtin/workflows/src/shared/store-public-types.ts +6 -2
  89. package/dist/builtin/workflows/src/shared/store-run-methods.ts +12 -6
  90. package/dist/builtin/workflows/src/shared/types.ts +55 -0
  91. package/dist/builtin/workflows/src/tui/graph-view-constants.ts +1 -1
  92. package/dist/builtin/workflows/src/tui/graph-view-graph-render.ts +41 -0
  93. package/dist/builtin/workflows/src/tui/graph-view-input.ts +82 -24
  94. package/dist/builtin/workflows/src/tui/graph-view-render.ts +7 -0
  95. package/dist/builtin/workflows/src/tui/graph-view-state.ts +22 -2
  96. package/dist/builtin/workflows/src/tui/graph-view-types.ts +4 -5
  97. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +9 -11
  98. package/dist/builtin/workflows/src/tui/stage-chat-view-footer-status.ts +9 -3
  99. package/dist/builtin/workflows/src/tui/stage-chat-view-input.ts +11 -2
  100. package/dist/builtin/workflows/src/tui/stage-chat-view-live-events.ts +35 -0
  101. package/dist/builtin/workflows/src/tui/stage-chat-view-state.ts +51 -17
  102. package/dist/builtin/workflows/src/tui/stage-chat-view-status.ts +36 -0
  103. package/dist/builtin/workflows/src/tui/stage-chat-view-types.ts +5 -1
  104. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +3 -1
  105. package/dist/builtin/workflows/src/tui/status-list.ts +14 -2
  106. package/dist/builtin/workflows/src/tui/widget.ts +23 -8
  107. package/dist/builtin/workflows/src/tui/workflow-attach-pane-types.ts +5 -4
  108. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +8 -8
  109. package/dist/builtin/workflows/src/tui/workflow-resume-selector.ts +151 -0
  110. package/dist/core/extensions/loader-virtual-modules.d.ts.map +1 -1
  111. package/dist/core/extensions/loader-virtual-modules.js +47 -30
  112. package/dist/core/extensions/loader-virtual-modules.js.map +1 -1
  113. package/dist/core/messages.d.ts +1 -0
  114. package/dist/core/messages.d.ts.map +1 -1
  115. package/dist/core/messages.js +46 -1
  116. package/dist/core/messages.js.map +1 -1
  117. package/dist/core/sdk.d.ts.map +1 -1
  118. package/dist/core/sdk.js +12 -0
  119. package/dist/core/sdk.js.map +1 -1
  120. package/dist/core/session-manager-core.d.ts +15 -7
  121. package/dist/core/session-manager-core.d.ts.map +1 -1
  122. package/dist/core/session-manager-core.js +20 -9
  123. package/dist/core/session-manager-core.js.map +1 -1
  124. package/dist/core/session-manager-entries.d.ts +2 -2
  125. package/dist/core/session-manager-entries.d.ts.map +1 -1
  126. package/dist/core/session-manager-entries.js +9 -3
  127. package/dist/core/session-manager-entries.js.map +1 -1
  128. package/dist/core/session-manager-history.d.ts.map +1 -1
  129. package/dist/core/session-manager-history.js +2 -1
  130. package/dist/core/session-manager-history.js.map +1 -1
  131. package/dist/core/session-manager-list.d.ts +3 -3
  132. package/dist/core/session-manager-list.d.ts.map +1 -1
  133. package/dist/core/session-manager-list.js +27 -8
  134. package/dist/core/session-manager-list.js.map +1 -1
  135. package/dist/core/session-manager-storage.d.ts +3 -1
  136. package/dist/core/session-manager-storage.d.ts.map +1 -1
  137. package/dist/core/session-manager-storage.js +55 -12
  138. package/dist/core/session-manager-storage.js.map +1 -1
  139. package/dist/core/session-manager-tool-dependencies.d.ts +10 -0
  140. package/dist/core/session-manager-tool-dependencies.d.ts.map +1 -0
  141. package/dist/core/session-manager-tool-dependencies.js +133 -0
  142. package/dist/core/session-manager-tool-dependencies.js.map +1 -0
  143. package/dist/core/session-manager-types.d.ts +22 -0
  144. package/dist/core/session-manager-types.d.ts.map +1 -1
  145. package/dist/core/session-manager-types.js.map +1 -1
  146. package/dist/core/session-manager.d.ts +2 -2
  147. package/dist/core/session-manager.d.ts.map +1 -1
  148. package/dist/core/session-manager.js +1 -1
  149. package/dist/core/session-manager.js.map +1 -1
  150. package/dist/modes/interactive/components/chat-session-host-runtime.d.ts +1 -0
  151. package/dist/modes/interactive/components/chat-session-host-runtime.d.ts.map +1 -1
  152. package/dist/modes/interactive/components/chat-session-host-runtime.js +12 -0
  153. package/dist/modes/interactive/components/chat-session-host-runtime.js.map +1 -1
  154. package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.d.ts +4 -0
  155. package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.d.ts.map +1 -0
  156. package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.js +131 -0
  157. package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.js.map +1 -0
  158. package/dist/modes/interactive/components/chat-session-host.d.ts +2 -0
  159. package/dist/modes/interactive/components/chat-session-host.d.ts.map +1 -1
  160. package/dist/modes/interactive/components/chat-session-host.js +7 -1
  161. package/dist/modes/interactive/components/chat-session-host.js.map +1 -1
  162. package/dist/modes/interactive/components/chat-transcript.d.ts.map +1 -1
  163. package/dist/modes/interactive/components/chat-transcript.js +15 -4
  164. package/dist/modes/interactive/components/chat-transcript.js.map +1 -1
  165. package/dist/modes/interactive/components/tool-execution.d.ts +3 -0
  166. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  167. package/dist/modes/interactive/components/tool-execution.js +26 -0
  168. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  169. package/docs/compaction.md +2 -0
  170. package/docs/models.md +1 -1
  171. package/docs/providers.md +2 -1
  172. package/docs/session-format.md +6 -0
  173. package/docs/sessions.md +6 -0
  174. package/docs/workflows.md +105 -3
  175. package/package.json +4 -3
@@ -141,6 +141,9 @@ export function createWorkflowExtensionRuntimeState(
141
141
  dispatch(args, options) { return runtimeRef.current.dispatch(args, options); },
142
142
  runDirect(args, options) { return runtimeRef.current.runDirect(args, options); },
143
143
  resumeFailedRun(sourceRunId, stageId, options) { return runtimeRef.current.resumeFailedRun(sourceRunId, stageId, options); },
144
+ resumeDurableWorkflow(workflowIdOrPrefix, options) { return runtimeRef.current.resumeDurableWorkflow(workflowIdOrPrefix, options); },
145
+ listDurableResumable(sessionDir) { return runtimeRef.current.listDurableResumable(sessionDir); },
146
+ prepareDurableResumable(workflowIdOrPrefix, sessionDir) { return runtimeRef.current.prepareDurableResumable(workflowIdOrPrefix, sessionDir); },
144
147
  };
145
148
 
146
149
  function workflowModelCatalogFromContext(ctx?: PiModelContext): WorkflowModelCatalogPort | undefined {
@@ -43,6 +43,10 @@ import { runDetached } from "../runs/background/runner.js";
43
43
  import type { JobTracker } from "../runs/background/job-tracker.js";
44
44
  import { appendRunEnd } from "../shared/persistence-session-entries.js";
45
45
  import { classifyWorkflowFailure } from "../shared/workflow-failures.js";
46
+ import { resumeDurableWorkflow as resumeDurableWorkflowAdapter, prepareRuntimeDurableResumable, isBackendTerminal, type ResumeDurableDeps, type ResumeDurableResult } from "../durable/resume-runtime.js";
47
+ import { getDurableBackend, initializeDbosDurableBackendFromEnv } from "../durable/factory.js";
48
+ import { scanResumableWorkflows } from "../durable/resume-catalog.js";
49
+ import type { ResumableWorkflowEntry } from "../durable/types.js";
46
50
  import { directMode, directModelRequests, directOptions, directProgressTotal } from "./runtime-direct.js";
47
51
 
48
52
  // ---------------------------------------------------------------------------
@@ -87,11 +91,9 @@ export interface ExtensionRuntimeOpts {
87
91
  /** Resolve the host's non-default session directory for workflow stage transcripts. */
88
92
  resolveDefaultStageSessionDir?: () => string | undefined;
89
93
  }
90
-
91
94
  // ---------------------------------------------------------------------------
92
95
  // Public interface
93
96
  // ---------------------------------------------------------------------------
94
-
95
97
  export type ResumeFailedRunResult =
96
98
  | { ok: true; runId: string; sourceRunId: string; resumeFromStageId: string; message: string }
97
99
  | { ok: false; reason: "run_not_found" | "not_resumable" | "workflow_not_found" | "insufficient_state"; message: string };
@@ -114,12 +116,21 @@ export interface ExtensionRuntime {
114
116
 
115
117
  /** Start a linked continuation for a failed resumable named workflow run. */
116
118
  resumeFailedRun(sourceRunId: string, stageId?: string, options?: RuntimeDispatchOptions): ResumeFailedRunResult;
117
- }
118
119
 
120
+ /**
121
+ * Resume a durable workflow by top-level workflow id when no live run exists.
122
+ * Re-dispatches the workflow with the cached inputs and original workflow id
123
+ * so durable checkpoints replay (skipping completed side effects).
124
+ *
125
+ * cross-ref: issue #1498 — cross-session /workflow resume selector.
126
+ */
127
+ resumeDurableWorkflow(workflowIdOrPrefix: string, options?: RuntimeDispatchOptions): import("../durable/resume-runtime.js").ResumeDurableResult;
128
+ listDurableResumable(sessionDir?: string): readonly import("../durable/types.js").ResumableWorkflowEntry[];
129
+ prepareDurableResumable(workflowIdOrPrefix?: string, sessionDir?: string): Promise<readonly import("../durable/types.js").ResumableWorkflowEntry[]>;
130
+ }
119
131
  export interface RuntimeDispatchOptions {
120
132
  readonly policy?: WorkflowExecutionPolicy;
121
133
  }
122
-
123
134
  // ---------------------------------------------------------------------------
124
135
  // Factory
125
136
  // ---------------------------------------------------------------------------
@@ -150,6 +161,9 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
150
161
  const jobs = opts.jobs;
151
162
  const runtimeCwd = opts.cwd ?? process.cwd();
152
163
  const resolveDefaultStageSessionDir = opts.resolveDefaultStageSessionDir;
164
+ const dbosReady = initializeDbosDurableBackendFromEnv().catch((err) => process.emitWarning(`Atomic workflow DBOS durability unavailable; using file-backed durability: ${err instanceof Error ? err.message : String(err)}`));
165
+ const ensureDbosReady = async (): Promise<void> => { await dbosReady; };
166
+ let preparedDurableCatalog: readonly ResumableWorkflowEntry[] = [];
153
167
 
154
168
  function runOptions(args: WorkflowToolArgs, policy?: WorkflowExecutionPolicy): RunOpts {
155
169
  const argConcurrency =
@@ -182,14 +196,12 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
182
196
  cwd: runtimeCwd,
183
197
  };
184
198
  }
185
-
186
199
  function explicitIntercomDelivery(args: WorkflowToolArgs): WorkflowIntercomDelivery | undefined {
187
200
  if (args.intercom?.enabled === false) return "off";
188
201
  if (args.intercom?.delivery !== undefined) return args.intercom.delivery;
189
202
  if (args.intercom?.enabled === true) return "control-and-result";
190
203
  return undefined;
191
204
  }
192
-
193
205
  function effectiveIntercomDelivery(args: WorkflowToolArgs, mode: WorkflowDetails["mode"]): WorkflowIntercomDelivery {
194
206
  const explicit = explicitIntercomDelivery(args);
195
207
  if (explicit !== undefined) return explicit;
@@ -202,7 +214,6 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
202
214
  }
203
215
  return "off";
204
216
  }
205
-
206
217
  function intercomParentSession(args: WorkflowToolArgs): string | undefined {
207
218
  if (args.intercom?.parentSession !== undefined) return args.intercom.parentSession;
208
219
  if (typeof intercom?.parentSession === "function") return intercom.parentSession();
@@ -371,6 +382,7 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
371
382
  }
372
383
 
373
384
  async function runDirectAsync(args: WorkflowToolArgs, policy?: WorkflowExecutionPolicy): Promise<WorkflowDetails> {
385
+ await ensureDbosReady();
374
386
  const runId = crypto.randomUUID();
375
387
  const mode = directMode(args);
376
388
  const delivery = effectiveIntercomDelivery(args, mode);
@@ -423,7 +435,8 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
423
435
  return registry;
424
436
  },
425
437
 
426
- dispatch(args: WorkflowToolArgs, options?: RuntimeDispatchOptions): Promise<WorkflowToolResult> {
438
+ async dispatch(args: WorkflowToolArgs, options?: RuntimeDispatchOptions): Promise<WorkflowToolResult> {
439
+ await ensureDbosReady();
427
440
  const defaultSessionDir = resolveDefaultStageSessionDir?.();
428
441
  return dispatch(args, {
429
442
  registry,
@@ -441,7 +454,8 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
441
454
  });
442
455
  },
443
456
 
444
- runDirect(args: WorkflowToolArgs, options?: RuntimeDispatchOptions): Promise<WorkflowDetails> {
457
+ async runDirect(args: WorkflowToolArgs, options?: RuntimeDispatchOptions): Promise<WorkflowDetails> {
458
+ await ensureDbosReady();
445
459
  const policy = options?.policy ?? INTERACTIVE_WORKFLOW_POLICY;
446
460
  if (args.async === true && policy.awaitTerminalRun !== true) {
447
461
  return runDirectAsync(args, policy);
@@ -457,5 +471,30 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi
457
471
  },
458
472
 
459
473
  resumeFailedRun,
474
+
475
+ resumeDurableWorkflow(workflowIdOrPrefix: string, options?: RuntimeDispatchOptions): ResumeDurableResult {
476
+ const adapterDeps: ResumeDurableDeps = {
477
+ registry,
478
+ baseRunOpts: runOptions({ workflow: "", inputs: {} }, options?.policy),
479
+ durableBackend: getDurableBackend(),
480
+ };
481
+ return resumeDurableWorkflowAdapter(workflowIdOrPrefix, adapterDeps, preparedDurableCatalog);
482
+ },
483
+ listDurableResumable(sessionDir?: string): readonly ResumableWorkflowEntry[] {
484
+ const backend = getDurableBackend();
485
+ const live = backend.listResumableWorkflows();
486
+ const dir = sessionDir ?? resolveDefaultStageSessionDir?.();
487
+ if (dir === undefined) return live;
488
+ const scanned = scanResumableWorkflows(dir);
489
+ const liveIds = new Set(live.map((e) => e.workflowId));
490
+ const compatible = scanned.filter((e) => !liveIds.has(e.workflowId) && backend.getWorkflow(e.workflowId) !== undefined && !isBackendTerminal(backend, e.workflowId));
491
+ return [...live, ...compatible];
492
+ },
493
+
494
+ async prepareDurableResumable(workflowIdOrPrefix?: string, sessionDir?: string): Promise<readonly ResumableWorkflowEntry[]> {
495
+ await ensureDbosReady();
496
+ preparedDurableCatalog = await prepareRuntimeDurableResumable(getDurableBackend, () => resolveDefaultStageSessionDir?.(), workflowIdOrPrefix, sessionDir);
497
+ return preparedDurableCatalog;
498
+ },
460
499
  };
461
500
  }
@@ -5,6 +5,7 @@ import { store } from "../shared/store.js";
5
5
  import { topLevelWorkflowRuns } from "../shared/run-visibility.js";
6
6
  import { renderSessionList } from "../tui/session-list.js";
7
7
  import { openKillConfirm, openSessionPicker } from "../tui/session-overlays.js";
8
+ import { openWorkflowResumeSelector } from "../tui/workflow-resume-selector.js";
8
9
  import { deriveGraphTheme } from "../tui/graph-theme.js";
9
10
  import { emitChatSurface } from "../tui/chat-surface-message.js";
10
11
  import type { GraphOverlayPort } from "../tui/overlay-adapter.js";
@@ -13,6 +14,8 @@ import type { ExtensionAPI, PiCommandContext } from "./public-types.js";
13
14
  import type { WorkflowCommandReporter } from "./workflow-command-utils.js";
14
15
  import { stripYesFlag } from "./workflow-command-utils.js";
15
16
  import { workflowPolicyFromContext } from "./workflow-policy.js";
17
+ import { formatResumableWorkflowList } from "../durable/resume-catalog.js";
18
+ import type { ResumableWorkflowEntry } from "../durable/types.js";
16
19
  import {
17
20
  formatAlreadyEndedRetainedMessage,
18
21
  overlaySurfaceFromContext,
@@ -37,6 +40,75 @@ function resolveAttachStageId(runId: string, stageTarget: string | undefined): s
37
40
  return byName?.id ?? false;
38
41
  }
39
42
 
43
+ function filterSelectorDurableEntries(
44
+ runtime: ExtensionRuntime,
45
+ entries: readonly ResumableWorkflowEntry[],
46
+ ): readonly ResumableWorkflowEntry[] {
47
+ const registry = runtime.registry as { has(name: string): boolean } | undefined;
48
+ if (registry === undefined) return entries;
49
+ return entries.filter((entry) => {
50
+ const requiresCurrentDefinition = entry.status === "running" || entry.status === "failed" || entry.status === "blocked";
51
+ return !requiresCurrentDefinition || registry.has(entry.name);
52
+ });
53
+ }
54
+
55
+
56
+ async function handleDurableResume(
57
+ target: string | undefined,
58
+ ctx: PiCommandContext,
59
+ reporter: WorkflowCommandReporter,
60
+ deps: WorkflowRunControlDeps,
61
+ ): Promise<boolean> {
62
+ const print = (msg: string): void => reporter.info(msg);
63
+ const fail = (msg: string): void => reporter.error(msg);
64
+ const runtime = deps.runtimeForContext(ctx);
65
+ const policy = workflowPolicyFromContext(ctx);
66
+ // Hydrate the durable backend from DBOS (if configured) before listing so a
67
+ // fresh process discovers workflows persisted by a prior session.
68
+ const prepared = await runtime.prepareDurableResumable(target);
69
+ const durable = filterSelectorDurableEntries(runtime, prepared);
70
+ if (target !== undefined) {
71
+ // Attempt resume by id/prefix against the durable catalog.
72
+ const result = runtime.resumeDurableWorkflow(target, { policy });
73
+ if (result.ok) {
74
+ print(result.message);
75
+ // Open/connect the overlay to the resumed run, analogous to live resume.
76
+ if (policy.allowInputPicker) deps.overlay.open(result.runId, overlaySurfaceFromContext(ctx));
77
+ return true;
78
+ }
79
+ // Not a durable workflow either — surface the catalog for discovery.
80
+ if (durable.length > 0) {
81
+ fail(`${result.message}\n\n${formatResumableWorkflowList(durable)}`);
82
+ } else {
83
+ fail(result.message);
84
+ }
85
+ return true;
86
+ }
87
+ // No target: show the durable selector when interactive, otherwise print.
88
+ if (durable.length === 0) {
89
+ fail("No resumable durable workflows found. Usage: /workflow resume <id> (or /resume for Atomic sessions).");
90
+ return true;
91
+ }
92
+ if (!policy.allowInputPicker) {
93
+ print(`${formatResumableWorkflowList(durable)}\n\nResume with: /workflow resume <id>`);
94
+ return true;
95
+ }
96
+ const picked = await openWorkflowResumeSelector(ctx.ui, [], durable);
97
+ if (picked.kind === "durable") {
98
+ const result = runtime.resumeDurableWorkflow(picked.workflowId, { policy });
99
+ if (result.ok) {
100
+ print(result.message);
101
+ if (policy.allowInputPicker) deps.overlay.open(result.runId, overlaySurfaceFromContext(ctx));
102
+ } else {
103
+ fail(result.message);
104
+ }
105
+ }
106
+ if (picked.kind !== "durable") {
107
+ print(`${formatResumableWorkflowList(durable)}\n\nResume with: /workflow resume <id>`);
108
+ }
109
+ return true;
110
+ }
111
+
40
112
  export async function handleRunControlCommand(
41
113
  action: "connect" | "interrupt" | "kill" | "attach" | "pause" | "resume",
42
114
  rest: string[],
@@ -115,7 +187,7 @@ export async function handleRunControlCommand(
115
187
  }
116
188
  if (failHeadlessAttachCommand("connect", resolved.runId)) return true;
117
189
  if (policy.allowInputPicker) deps.overlay.open(resolved.runId, overlaySurfaceFromContext(ctx));
118
- print(`Attached to ${resolved.runId.slice(0, 8)}. h/ctrl+d hide · q kill · esc close.`);
190
+ print(`Attached to ${resolved.runId.slice(0, 8)}. h/ctrl+d hide · q quit (resumable via /workflow resume) · esc close.`);
119
191
  return true;
120
192
  }
121
193
 
@@ -206,10 +278,64 @@ export async function handleRunControlCommand(
206
278
  if (action === "pause") {
207
279
  const active = topLevelWorkflowRuns(store.runs()).filter((r) => r.endedAt === undefined);
208
280
  fail(active.length === 0 ? "No active runs to pause." : `Picker requires an interactive UI surface. Active runs:\n${active.map((r) => ` ${r.id.slice(0, 8)} ${r.name}`).join("\n")}\n\nUsage: /workflow pause <runId> [stageId]`);
281
+ } else if (action === "attach") {
282
+ fail(`${renderSessionList(store.runs(), { theme, includeAll: true })}\n\nPicker requires an interactive UI surface. Pass a runId: /workflow attach <id> [stageId]`);
209
283
  } else {
210
- fail(action === "attach"
211
- ? `${renderSessionList(store.runs(), { theme, includeAll: true })}\n\nPicker requires an interactive UI surface. Pass a runId: /workflow attach <id> [stageId]`
212
- : "Usage: /workflow resume <runId> [stageId] [message…]");
284
+ // resume: show cross-session durable catalog in headless/print mode.
285
+ return await handleDurableResume(undefined, ctx, reporter, deps);
286
+ }
287
+ return true;
288
+ }
289
+ if (action === "resume") {
290
+ // Only inactive workflows belong in the resume selector. Live runs:
291
+ // show paused (quit) or recoverably-failed runs; actively-running live
292
+ // runs are hidden (resuming one that is executing would double-dispatch).
293
+ const liveRuns = topLevelWorkflowRuns(store.runs()).filter((run) =>
294
+ run.status === "paused" || (run.status === "failed" && run.resumable !== false),
295
+ );
296
+ // Durable entries: a `running` durable handle may be a crashed process
297
+ // (cross-session crash recovery), so it stays selectable UNLESS it
298
+ // matches an actively-executing live run in this session.
299
+ const activeLiveIds = new Set(
300
+ topLevelWorkflowRuns(store.runs())
301
+ .filter((run) => run.endedAt === undefined && run.status === "running" && run.exitReason !== "quit")
302
+ .map((run) => run.id),
303
+ );
304
+ const runtime = deps.runtimeForContext(ctx);
305
+ let durableEntries: readonly ResumableWorkflowEntry[] = [];
306
+ try {
307
+ const prepared = await runtime.prepareDurableResumable(undefined);
308
+ durableEntries = filterSelectorDurableEntries(runtime, prepared)
309
+ .filter((entry) => !activeLiveIds.has(entry.workflowId));
310
+ } catch (error) {
311
+ if (liveRuns.length === 0) {
312
+ const message = error instanceof Error ? error.message : String(error);
313
+ fail(`Failed to list resumable workflows: ${message}`);
314
+ return true;
315
+ }
316
+ }
317
+ const picked = await openWorkflowResumeSelector(ctx.ui, liveRuns, durableEntries);
318
+ if (picked.kind === "durable") return await handleDurableResume(picked.workflowId, ctx, reporter, deps);
319
+ if (picked.kind === "live") {
320
+ const resolved = resolveRunIdPrefix(picked.runId);
321
+ if (resolved.kind !== "exact") {
322
+ fail(`Run not found: ${picked.runId}`);
323
+ return true;
324
+ }
325
+ const run = store.runs().find((r) => r.id === resolved.runId);
326
+ const isPaused = run?.status === "paused" || (run?.stages.some((s) => s.status === "paused") ?? false);
327
+ const isResumableContinuation = run !== undefined && !isPaused && ((run.status === "failed" && run.endedAt !== undefined && run.resumable !== false) || (run.endedAt === undefined && run.resumable === true && run.failureRecoverability === "recoverable"));
328
+ if (isResumableContinuation) {
329
+ const continuation = deps.runtimeForContext(ctx).resumeFailedRun(resolved.runId, undefined, { policy });
330
+ continuation.ok ? print(continuation.message) : fail(continuation.message);
331
+ } else {
332
+ const result = resumeRun(resolved.runId, {});
333
+ if (result.ok && result.mode === "snapshot" && run?.exitReason === "quit") {
334
+ return await handleDurableResume(resolved.runId, ctx, reporter, deps);
335
+ }
336
+ if (result.ok && policy.allowInputPicker) deps.overlay.open(result.runId, overlaySurfaceFromContext(ctx));
337
+ result.ok ? print(result.message ?? `Resumed ${result.runId.slice(0, 8)}`) : fail(`Run not found: ${picked.runId}`);
338
+ }
213
339
  }
214
340
  return true;
215
341
  }
@@ -220,6 +346,11 @@ export async function handleRunControlCommand(
220
346
  } else {
221
347
  const resolved = resolveRunIdPrefix(target);
222
348
  if (resolved.kind === "not_found") {
349
+ // Not a live run — fall back to the cross-session durable resume catalog.
350
+ // cross-ref: issue #1498 — /workflow resume by top-level workflow id.
351
+ if (action === "resume") {
352
+ return await handleDurableResume(target, ctx, reporter, deps);
353
+ }
223
354
  fail(`Run not found: ${target}`);
224
355
  return true;
225
356
  }
@@ -260,6 +391,11 @@ export async function handleRunControlCommand(
260
391
  const run = store.runs().find((r) => r.id === stageRunId);
261
392
  const isPaused = run?.status === "paused" || (run?.stages.some((s) => s.status === "paused") ?? false);
262
393
  const isResumableContinuation = run !== undefined && !isPaused && ((run.status === "failed" && run.endedAt !== undefined && run.resumable !== false) || (run.endedAt === undefined && run.resumable === true && run.failureRecoverability === "recoverable"));
394
+ const isActivelyRunning = run !== undefined && run.endedAt === undefined && run.status === "running" && !isPaused && run.exitReason !== "quit";
395
+ if (isActivelyRunning && action === "resume") {
396
+ fail(`Workflow ${stageRunId.slice(0, 8)} is already running in this session. Attach with \`/workflow connect ${stageRunId.slice(0, 8)}\` instead of resuming.`);
397
+ return true;
398
+ }
263
399
  if (isResumableContinuation) {
264
400
  const continuation = deps.runtimeForContext(ctx).resumeFailedRun(stageRunId, stageId, { policy });
265
401
  continuation.ok ? print(continuation.message) : fail(continuation.message);
@@ -270,6 +406,9 @@ export async function handleRunControlCommand(
270
406
  fail(`Run not found: ${stageRunId.slice(0, 8)}`);
271
407
  return true;
272
408
  }
409
+ if (result.mode === "snapshot" && run?.exitReason === "quit" && action === "resume") {
410
+ return await handleDurableResume(stageRunId, ctx, reporter, deps);
411
+ }
273
412
  if (!isPaused) {
274
413
  if (policy.allowInputPicker) deps.overlay.open(result.runId, overlaySurfaceFromContext(ctx));
275
414
  print(result.message ?? `Snapshot available: run ${result.runId} (${result.snapshot.name}) — status: ${result.snapshot.status}, stages: ${result.snapshot.stages.length}`);
@@ -0,0 +1,61 @@
1
+ import { pauseRun, type PauseResult } from "./status.js";
2
+ import { store as defaultStore } from "../../shared/store.js";
3
+ import type { Store } from "../../shared/store-public-types.js";
4
+ import { topLevelWorkflowRuns } from "../../shared/run-visibility.js";
5
+ import type { StageControlRegistry } from "../foreground/stage-control-registry.js";
6
+ import { getDurableBackend } from "../../durable/factory.js";
7
+
8
+ export type QuitRunResult = PauseResult;
9
+
10
+ /**
11
+ * Quit/detach a workflow UI without authoritatively killing the workflow.
12
+ *
13
+ * This is the graph-panel/orchestrator close affordance: it pauses any live
14
+ * stage handles when possible, annotates the run as resumable via
15
+ * `/workflow resume`, and deliberately does NOT abort through the cancellation
16
+ * registry or append a terminal `workflow.run.end` entry. `/workflow kill`
17
+ * remains the only explicit non-resumable manual kill path.
18
+ */
19
+ export function quitRun(
20
+ runId: string,
21
+ opts?: {
22
+ store?: Store;
23
+ stageControlRegistry?: StageControlRegistry;
24
+ },
25
+ ): QuitRunResult {
26
+ const activeStore = opts?.store ?? defaultStore;
27
+ const run = activeStore.runs().find((candidate) => candidate.id === runId);
28
+ if (!run) return { ok: false, runId, reason: "not_found" };
29
+ if (run.endedAt !== undefined) return { ok: false, runId, reason: "already_ended" };
30
+
31
+ const paused = pauseRun(runId, opts);
32
+ if (!paused.ok && paused.reason !== "no_active_stages") return paused;
33
+ activeStore.recordRunPaused(runId, undefined, { exitReason: "quit", resumable: true });
34
+ // Mark the durable handle inactive so `/workflow resume` in this or another
35
+ // session can discover it again. While the run stays `running` in the store
36
+ // and (after a fresh dispatch) in the durable backend, it is hidden from the
37
+ // resume selector and refused on resume; quitting flips durable to `paused`.
38
+ markDurableQuit(runId);
39
+ return paused.ok ? paused : { ok: true, runId, paused: [] };
40
+ }
41
+
42
+ function markDurableQuit(runId: string): void {
43
+ let backend;
44
+ try {
45
+ backend = getDurableBackend();
46
+ } catch {
47
+ return;
48
+ }
49
+ if (backend.getWorkflow(runId) !== undefined) {
50
+ backend.setWorkflowStatus(runId, "paused", undefined, true);
51
+ }
52
+ }
53
+
54
+ export function quitAllRuns(opts?: {
55
+ store?: Store;
56
+ stageControlRegistry?: StageControlRegistry;
57
+ }): QuitRunResult[] {
58
+ const activeStore = opts?.store ?? defaultStore;
59
+ const inFlight = topLevelWorkflowRuns(activeStore.runs()).filter((r) => r.endedAt === undefined);
60
+ return inFlight.map((r) => quitRun(r.id, { store: activeStore, stageControlRegistry: opts?.stageControlRegistry }));
61
+ }
@@ -405,6 +405,7 @@ export function pauseAllRuns(opts?: {
405
405
  );
406
406
  }
407
407
 
408
+
408
409
  // ---------------------------------------------------------------------------
409
410
  // interruptRun
410
411
  // ---------------------------------------------------------------------------
@@ -67,7 +67,7 @@ export async function mapParallelSteps<T>(
67
67
  concurrency: number | undefined,
68
68
  failFast: boolean | undefined,
69
69
  mapper: (step: WorkflowTaskStep) => Promise<T>,
70
- onFirstFailure?: (error: unknown) => void,
70
+ onFirstFailure?: (error: unknown) => void | Promise<void>,
71
71
  control?: {
72
72
  readonly beforeDequeue?: () => void;
73
73
  readonly beforeMap?: () => void;
@@ -92,11 +92,11 @@ export async function mapParallelSteps<T>(
92
92
  controlSignal = error;
93
93
  if (failFastEnabled) rejectFirstFailure(error);
94
94
  };
95
- const recordFailure = (index: number, error: unknown): void => {
95
+ const recordFailure = async (index: number, error: unknown): Promise<void> => {
96
96
  failures.push({ index, error });
97
97
  if (firstFailure === undefined) {
98
98
  firstFailure = error;
99
- onFirstFailure?.(error);
99
+ await onFirstFailure?.(error);
100
100
  if (failFastEnabled) rejectFirstFailure(error);
101
101
  }
102
102
  };
@@ -112,7 +112,7 @@ export async function mapParallelSteps<T>(
112
112
  selectControlSignal(err);
113
113
  return;
114
114
  }
115
- recordFailure(nextIndex, err);
115
+ await recordFailure(nextIndex, err);
116
116
  return;
117
117
  }
118
118
  if (controlSignal !== undefined) return;
@@ -128,7 +128,7 @@ export async function mapParallelSteps<T>(
128
128
  selectControlSignal(err);
129
129
  return;
130
130
  }
131
- recordFailure(index, err);
131
+ await recordFailure(index, err);
132
132
  if (failFastEnabled) return;
133
133
  }
134
134
  }
@@ -8,6 +8,21 @@ import { hasExplicitFastModeCandidate } from "./executor-direct-helpers.js";
8
8
  import { applyFailureToStage } from "./executor-lifecycle.js";
9
9
  import { isTerminalStage } from "./executor-scheduler.js";
10
10
 
11
+ export interface TrackedStageCallOptions {
12
+ readonly eagerSession?: boolean;
13
+ readonly allowFinalized?: boolean;
14
+ }
15
+
16
+ export type TrackedStageCaller = <T>(
17
+ call: () => Promise<T>,
18
+ eagerSessionOrOptions?: boolean | TrackedStageCallOptions,
19
+ ) => Promise<T>;
20
+
21
+ function normalizeTrackedStageCallOptions(input: boolean | TrackedStageCallOptions | undefined): Required<TrackedStageCallOptions> {
22
+ if (typeof input === "boolean") return { eagerSession: input, allowFinalized: false };
23
+ return { eagerSession: input?.eagerSession === true, allowFinalized: input?.allowFinalized === true };
24
+ }
25
+
11
26
  export function createTrackedStageCaller(input: {
12
27
  readonly runtime: LiveStageRuntime;
13
28
  readonly limiter: ConcurrencyLimiter;
@@ -15,7 +30,7 @@ export function createTrackedStageCaller(input: {
15
30
  readonly adapters: StageAdapters;
16
31
  readonly hasContinuation: boolean;
17
32
  readonly hasScopedParents: boolean;
18
- }): <T>(call: () => Promise<T>, eagerSession?: boolean) => Promise<T> {
33
+ }): TrackedStageCaller {
19
34
  const { runtime } = input;
20
35
  const readinessGateEnabled = runtime.opts.confirmStageReadiness !== undefined || runtime.opts.usePromptNodesForUi === true;
21
36
  const confirmReadiness = async (): Promise<"advance" | "stay"> => {
@@ -68,22 +83,24 @@ export function createTrackedStageCaller(input: {
68
83
  return result;
69
84
  };
70
85
 
71
- return async <T>(call: () => Promise<T>, eagerSession = false): Promise<T> => {
86
+ return async <T>(call: () => Promise<T>, eagerSessionOrOptions?: boolean | TrackedStageCallOptions): Promise<T> => {
87
+ const callOptions = normalizeTrackedStageCallOptions(eagerSessionOrOptions);
72
88
  runtime.exit.throwIfWorkflowExitSelected();
73
89
  await runtime.scheduler.waitForStageRelease(runtime.stageId, runtime.releaseLiveHandle);
74
- if (runtime.state.stageFinalized) throw runtime.parallelFailFastError();
90
+ if (runtime.state.stageFinalized && !callOptions.allowFinalized) throw runtime.parallelFailFastError();
75
91
 
76
92
  await input.limiter.acquire();
77
93
  try {
78
94
  await runtime.scheduler.waitForStageRelease(runtime.stageId, runtime.releaseLiveHandle);
79
95
  runtime.exit.throwIfWorkflowExitSelected();
80
- if (runtime.state.stageFinalized) throw runtime.parallelFailFastError();
96
+ if (runtime.state.stageFinalized && !callOptions.allowFinalized) throw runtime.parallelFailFastError();
81
97
  } catch (err) {
82
98
  input.limiter.release();
83
99
  throw err;
84
100
  }
85
101
 
86
- if (!input.hasContinuation && runtime.stageSnapshot.startedAt === undefined && !input.hasScopedParents) {
102
+ const trackStageLifecycle = !runtime.state.stageFinalized;
103
+ if (trackStageLifecycle && !input.hasContinuation && runtime.stageSnapshot.startedAt === undefined && !input.hasScopedParents) {
87
104
  const actualParentIds = runtime.scheduler.tracker.currentParents();
88
105
  const sameParents = actualParentIds.length === runtime.stageSnapshot.parentIds.length &&
89
106
  actualParentIds.every((value) => runtime.stageSnapshot.parentIds.includes(value));
@@ -92,25 +109,29 @@ export function createTrackedStageCaller(input: {
92
109
  runtime.scheduler.setStageParentIds(runtime.stageSnapshot, actualParentIds);
93
110
  }
94
111
  }
95
- runtime.stageSnapshot.status = "running";
96
- runtime.stageSnapshot.startedAt = Date.now();
97
- const hasNoExplicitModelConfig = input.options?.model === undefined && input.options?.fallbackModels === undefined;
98
- const promptAdapterHandlesInitialPrompt = input.adapters.prompt !== undefined;
99
- if (eagerSession && !promptAdapterHandlesInitialPrompt && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate({
100
- model: input.options?.model,
101
- fallbackModels: input.options?.fallbackModels,
102
- models: runtime.opts.models,
103
- }))) {
104
- try {
105
- await runtime.innerCtx.__ensureSession();
106
- runtime.captureStageSessionMeta();
107
- } catch (err) {
108
- if (!(err instanceof Error && err.message.includes("prompt adapter not configured"))) throw err;
112
+ if (trackStageLifecycle) {
113
+ runtime.stageSnapshot.status = "running";
114
+ runtime.stageSnapshot.startedAt = Date.now();
115
+ const hasNoExplicitModelConfig = input.options?.model === undefined && input.options?.fallbackModels === undefined;
116
+ const promptAdapterHandlesInitialPrompt = input.adapters.prompt !== undefined;
117
+ if (callOptions.eagerSession && !promptAdapterHandlesInitialPrompt && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate({
118
+ model: input.options?.model,
119
+ fallbackModels: input.options?.fallbackModels,
120
+ models: runtime.opts.models,
121
+ }))) {
122
+ try {
123
+ await runtime.innerCtx.__ensureSession();
124
+ runtime.captureStageSessionMeta();
125
+ } catch (err) {
126
+ if (!(err instanceof Error && err.message.includes("prompt adapter not configured"))) throw err;
127
+ }
109
128
  }
129
+ runtime.applyModelFallbackMeta(runtime.innerCtx.__modelFallbackMeta());
130
+ runtime.activeStore.recordStageStart(runtime.runId, runtime.stageSnapshot);
131
+ runtime.appendStageStartOnce();
132
+ } else {
133
+ runtime.applyModelFallbackMeta(runtime.innerCtx.__modelFallbackMeta());
110
134
  }
111
- runtime.applyModelFallbackMeta(runtime.innerCtx.__modelFallbackMeta());
112
- runtime.activeStore.recordStageStart(runtime.runId, runtime.stageSnapshot);
113
- runtime.appendStageStartOnce();
114
135
 
115
136
  runtime.mcpScope.apply();
116
137
 
@@ -158,37 +179,57 @@ export function createTrackedStageCaller(input: {
158
179
  }
159
180
  runtime.captureStageSessionMeta();
160
181
  runtime.applyModelFallbackMeta(runtime.innerCtx.__modelFallbackMeta());
161
- if (runtime.stageFailFastScope?.failed === true && runtime.stageFailFastScope.activeStages.has(runtime.stageId)) {
182
+ if (trackStageLifecycle && runtime.stageFailFastScope?.failed === true && runtime.stageFailFastScope.activeStages.has(runtime.stageId)) {
162
183
  runtime.markSkippedForParallelFailFast();
163
184
  throw runtime.parallelFailFastError();
164
185
  }
165
- if (runtime.state.stageFinalized) throw runtime.parallelFailFastError();
166
- runtime.stageSnapshot.status = "completed";
167
- const assistantText = runtime.innerCtx.__getLastAssistantText();
168
- if (assistantText !== undefined) runtime.stageSnapshot.result = assistantText;
186
+ if (trackStageLifecycle && runtime.state.stageFinalized) throw runtime.parallelFailFastError();
187
+ if (trackStageLifecycle) {
188
+ runtime.stageSnapshot.status = "completed";
189
+ const assistantText = runtime.innerCtx.__getLastAssistantText();
190
+ if (assistantText !== undefined) runtime.stageSnapshot.result = assistantText;
191
+ }
169
192
  return result;
170
193
  } catch (err) {
171
194
  const workflowExitAbort = runtime.signal.aborted ? runtime.exit.currentWorkflowExitAbortReason() : undefined;
172
195
  if (workflowExitAbort !== undefined && !runtime.state.skippedForParallelFailFast) {
173
196
  runtime.state.stageClosedByWorkflowExit = true;
174
- if (!isTerminalStage(runtime.stageSnapshot)) {
197
+ if (trackStageLifecycle && !isTerminalStage(runtime.stageSnapshot)) {
175
198
  runtime.stageSnapshot.status = "skipped";
176
199
  runtime.stageSnapshot.skippedReason = runtime.exit.workflowExitSkippedReason(workflowExitAbort.reason);
177
200
  }
178
- } else if (!runtime.signal.aborted && !runtime.state.skippedForParallelFailFast) {
201
+ } else if (trackStageLifecycle && !runtime.signal.aborted && !runtime.state.skippedForParallelFailFast) {
179
202
  applyFailureToStage(runtime.stageSnapshot, runtime.classifyExecutorFailure(err));
180
203
  }
181
204
  throw err;
182
205
  } finally {
206
+ // Finalization, handle release, and limiter release are each independent.
207
+ // If finalizeStageSnapshot() throws, the limiter must still be released
208
+ // so the concurrency semaphore is not leaked.
209
+ // cross-ref: issue #1498 — durable finalization failures must not leak the stage limiter.
183
210
  runtime.mcpScope.clear();
184
211
  runtime.captureStageSessionMeta();
185
- runtime.finalizeStageSnapshot();
186
- if (runtime.state.stageClosedByWorkflowExit || runtime.exit.currentWorkflowExitAbortReason() !== undefined) {
212
+ let finalizationError: { readonly thrown: true; readonly error: unknown } | undefined;
213
+ if (trackStageLifecycle) {
214
+ try {
215
+ await runtime.finalizeStageSnapshot();
216
+ } catch (err) {
217
+ finalizationError = { thrown: true, error: err };
218
+ }
219
+ try {
220
+ if (runtime.state.stageClosedByWorkflowExit || runtime.exit.currentWorkflowExitAbortReason() !== undefined) {
221
+ await runtime.releaseLiveHandle().catch(() => {});
222
+ } else {
223
+ await runtime.dropStageControlForCompletion().catch(() => {});
224
+ }
225
+ } catch {
226
+ // Best-effort: handle release failure must not prevent limiter release.
227
+ }
228
+ } else if (runtime.state.stageClosedByWorkflowExit || runtime.exit.currentWorkflowExitAbortReason() !== undefined) {
187
229
  await runtime.releaseLiveHandle().catch(() => {});
188
- } else {
189
- await runtime.dropStageControlForCompletion().catch(() => {});
190
230
  }
191
231
  input.limiter.release();
232
+ if (finalizationError !== undefined) throw finalizationError.error;
192
233
  }
193
234
  };
194
235
  }