@yycholla/pi-dynamic-workflows 3.3.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 (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. package/src/worktree.ts +76 -0
@@ -0,0 +1,1405 @@
1
+ /**
2
+ * Workflow manager for background execution, pause/resume, and run management.
3
+ */
4
+
5
+ import { EventEmitter } from "node:events";
6
+ import type { ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
7
+ import type { WorkflowAgent } from "./agent.js";
8
+ import { preview, type WorkflowAgentSnapshot, type WorkflowSnapshot } from "./display.js";
9
+ import { isProviderUsageLimit, WorkflowError, WorkflowErrorCode } from "./errors.js";
10
+ import {
11
+ createRunPersistence,
12
+ generateRunId,
13
+ type PersistedRunState,
14
+ type RunLease,
15
+ type RunPersistence,
16
+ type RunStatus,
17
+ } from "./run-persistence.js";
18
+ import { type JournalEntry, parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
19
+
20
+ export interface ManagedRun {
21
+ runId: string;
22
+ status: RunStatus;
23
+ snapshot: WorkflowSnapshot;
24
+ result?: WorkflowRunResult;
25
+ error?: WorkflowError;
26
+ controller: AbortController;
27
+ startedAt: Date;
28
+ /** The real script, kept so the run can be resumed. */
29
+ script: string;
30
+ args?: unknown;
31
+ /** Accumulated agent results for resume (deterministic call index -> result). */
32
+ journal: JournalEntry[];
33
+ /** Cross-process execution lease for this run, when it is actively executing. */
34
+ lease?: RunLease;
35
+ /**
36
+ * True when the run was started in the background (or resumed) and the caller is
37
+ * not awaiting its result inline. Only background runs deliver their result back
38
+ * into the conversation; a foreground sync run already returns it as the tool
39
+ * result, so re-delivering would duplicate it.
40
+ */
41
+ background: boolean;
42
+ /**
43
+ * Auto-resume eligibility for this run (see ExecOptions.autoResume). Set once
44
+ * at creation and carried through resume() so it survives pause/resume cycles.
45
+ * Undefined means eligible (default-on); false opts out.
46
+ */
47
+ autoResume?: boolean;
48
+ /**
49
+ * The run's resolved hard token budget (per-run value, else the manager
50
+ * default), fixed at run start and carried through resume() — a resumed run
51
+ * must keep the budget it started with, not re-resolve against the current
52
+ * default (an explicit `null` opt-out would otherwise regain a budget).
53
+ */
54
+ tokenBudget?: number | null;
55
+ /**
56
+ * Named toolset tag for this run (see WorkflowManagerOptions.toolsets).
57
+ * ToolDefinitions are functions and can't be persisted, so the tag is what
58
+ * survives on disk — resume() re-resolves it so e.g. a resumed
59
+ * `/deep-research` run keeps its web tools instead of silently degrading to
60
+ * the default coding tools.
61
+ */
62
+ toolset?: string;
63
+ /**
64
+ * Real per-agent start/end timestamps, captured at onAgentStart/onAgentEnd
65
+ * (never fabricated), keyed by the agent's snapshot id. A running agent has
66
+ * an entry with no endedAt; persistRun() reads from here instead of stamping
67
+ * every agent with the run's startedAt / "now".
68
+ */
69
+ agentTimestamps: Map<number, { startedAt: string; endedAt?: string }>;
70
+ /**
71
+ * Live snapshot-agent lookup keyed by the agent CALL's unique id (see
72
+ * WorkflowRunOptions.onAgentStart/onAgentEnd/onAgentHistory's `id` field in
73
+ * workflow.ts — unique per call, never per label). onAgentEnd/onAgentHistory
74
+ * must resolve the snapshot entry to update through this map, never by
75
+ * scanning managed.snapshot.agents for a label match: two concurrent agents
76
+ * routinely share a label (e.g. parallel()'s default `"${phase} agent N"`
77
+ * labeling, or an author-supplied label reused across a fan-out), and a
78
+ * label+status scan would update whichever same-label entry it happens to
79
+ * find first — misattributing one agent's end/history event to a different,
80
+ * still-running sibling.
81
+ */
82
+ agentsById: Map<string, WorkflowAgentSnapshot>;
83
+ /**
84
+ * The run's cap on total agents (per-run value, else left undefined so
85
+ * runWorkflow applies its own MAX_AGENTS_PER_RUN default), fixed at run
86
+ * start/resume and carried through resume() — mirrors ManagedRun.tokenBudget
87
+ * exactly: a resumed run must keep the cap it started with, not silently
88
+ * regain the (much larger) default because ExecOptions.maxAgents isn't
89
+ * threaded through resume()'s executeRun() call.
90
+ */
91
+ maxAgents?: number;
92
+ /**
93
+ * The run's resolved per-agent timeout (per-run value, else the manager
94
+ * default at the time), fixed at run start/resume — same rationale as
95
+ * tokenBudget/maxAgents: resume() must not re-resolve against the manager's
96
+ * CURRENT defaultAgentTimeoutMs.
97
+ */
98
+ agentTimeoutMs?: number | null;
99
+ /**
100
+ * The run's resolved concurrency (per-run value, else the manager's
101
+ * concurrency at the time), fixed at run start/resume for the same reason
102
+ * as tokenBudget.
103
+ */
104
+ concurrency?: number;
105
+ /**
106
+ * The run's resolved agent-retry count (per-run value, else the manager
107
+ * default at the time), fixed at run start/resume for the same reason as
108
+ * tokenBudget.
109
+ */
110
+ agentRetries?: number;
111
+ }
112
+
113
+ /** Per-execution options shared by sync, background, and resume runs. */
114
+ export interface ExecOptions {
115
+ /**
116
+ * Replay these journaled agent/checkpoint results for the unchanged prefix
117
+ * (resume), keyed by `${runId}:${index}` — see
118
+ * WorkflowRunOptions.resumeJournal in workflow.ts.
119
+ */
120
+ resumeJournal?: Map<string, JournalEntry>;
121
+ /** Cap on total agents for this run. */
122
+ maxAgents?: number;
123
+ /** Per-agent timeout in milliseconds. null/omitted means no hard timeout. */
124
+ agentTimeoutMs?: number | null;
125
+ /** Host signal (e.g. tool/Esc) that should abort this run when fired. */
126
+ externalSignal?: AbortSignal;
127
+ /** Called with the live snapshot on every progress event. */
128
+ onProgress?: (snapshot: WorkflowSnapshot) => void;
129
+ /** Hard token budget for this run; once spent reaches it, agent() throws. */
130
+ tokenBudget?: number | null;
131
+ /**
132
+ * Tool set for this run's subagents, replacing the default coding tools —
133
+ * e.g. built-in `/deep-research` appends web tools. Omit for the default.
134
+ * Not persistable (functions): pair with `toolset` so a resumed run can
135
+ * re-resolve the same tools.
136
+ */
137
+ tools?: ToolDefinition[];
138
+ /**
139
+ * Named toolset tag, resolved via WorkflowManagerOptions.toolsets. Persisted
140
+ * with the run and re-resolved on resume(). When both `tools` and `toolset`
141
+ * are given, `tools` wins for this execution and `toolset` is what resumes use.
142
+ */
143
+ toolset?: string;
144
+ /** Max concurrent agents for this execution. */
145
+ concurrency?: number;
146
+ /** Retry attempts after recoverable agent failures for this execution. */
147
+ agentRetries?: number;
148
+ /** Resolve a checkpoint() question with a human reply (only for UI-bearing runs). */
149
+ confirm?: (promptText: string, options: unknown) => Promise<unknown>;
150
+ /**
151
+ * Whether this run is eligible for auto-resume when it pauses on a provider
152
+ * usage limit. Default-on: omit or pass true to stay eligible, pass false to
153
+ * opt out. Persisted on the run so a cold-start UsageLimitScheduler respects
154
+ * it too. See usage-limit-scheduler.ts.
155
+ */
156
+ autoResume?: boolean;
157
+ /**
158
+ * Seed for the execution's cumulative token counters — passed through to
159
+ * runWorkflow's WorkflowRunOptions.initialTokenUsage. Only resume() sets
160
+ * this (from the persisted run's tokenUsage-at-pause), so the resumed
161
+ * execution's fresh SharedRuntime starts counting from the already-spent
162
+ * total instead of zero (see A2 in workflow-manager's resume()).
163
+ */
164
+ initialTokenUsage?: {
165
+ input: number;
166
+ output: number;
167
+ total: number;
168
+ cost: number;
169
+ cacheRead: number;
170
+ cacheWrite: number;
171
+ };
172
+ }
173
+
174
+ export interface WorkflowManagerOptions {
175
+ cwd?: string;
176
+ concurrency?: number;
177
+ /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
178
+ loadSavedWorkflow?: (name: string) => string | undefined;
179
+ /** Inject a custom agent runner (tests); defaults to a real subagent session. */
180
+ agent?: Pick<WorkflowAgent, "run">;
181
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
182
+ mainModel?: string;
183
+ /**
184
+ * The host Pi session's model registry. When provided, workflow subagents
185
+ * resolve models against the same registry as the main session, including
186
+ * extension-registered providers such as ollama-cloud.
187
+ */
188
+ modelRegistry?: ModelRegistry;
189
+ /** The pi session id to tag runs with (see setSessionId). */
190
+ sessionId?: string;
191
+ /** Default per-agent timeout when a run does not pass agentTimeoutMs. null means no hard timeout. */
192
+ defaultAgentTimeoutMs?: number | null;
193
+ /** Default retry attempts after recoverable agent failures. */
194
+ defaultAgentRetries?: number;
195
+ /** Default hard token budget when a run does not pass tokenBudget. null/omitted means no budget. */
196
+ defaultTokenBudget?: number | null;
197
+ /**
198
+ * Named toolsets resolvable by ExecOptions.toolset — e.g.
199
+ * `{ "web-research": () => [...createCodingTools(cwd), ...createWebTools()] }`.
200
+ * Called lazily per execution (including on resume). An unknown tag resolves
201
+ * to the default coding tools.
202
+ */
203
+ toolsets?: Record<string, () => ToolDefinition[]>;
204
+ /**
205
+ * Extra tool NAMES to deny in every subagent session, on top of the always-on
206
+ * `workflow`/`workflow_control` defaults (see DEFAULT_EXCLUDED_SUBAGENT_TOOLS).
207
+ * Host wiring passes settings.excludeSubagentTools here so users can also block
208
+ * other recursive-orchestration tools (#107).
209
+ */
210
+ excludeSubagentTools?: string[];
211
+ /**
212
+ * Persist each subagent transcript as a real pi session file under the
213
+ * standard sessions directory. Default false (in-memory, discarded).
214
+ */
215
+ persistAgentSessions?: boolean;
216
+ /**
217
+ * How many terminal (completed/failed/aborted) runs to retain full
218
+ * in-memory state for before the oldest is evicted from `runs` (see the
219
+ * class-level doc comment on that field). Defaults to
220
+ * DEFAULT_MAX_TERMINAL_RUNS_IN_MEMORY; exposed mainly for tests that want
221
+ * to observe eviction without creating dozens of runs.
222
+ */
223
+ maxTerminalRunsInMemory?: number;
224
+ }
225
+
226
+ /** Options that a fresh extension generation may safely refresh on a live
227
+ * manager handed across `/reload`. Execution identity (`cwd`, persistence,
228
+ * injected agent, and in-memory runs) is intentionally excluded. */
229
+ export type WorkflowManagerReloadOptions = Pick<
230
+ WorkflowManagerOptions,
231
+ | "concurrency"
232
+ | "loadSavedWorkflow"
233
+ | "defaultAgentTimeoutMs"
234
+ | "defaultAgentRetries"
235
+ | "defaultTokenBudget"
236
+ | "toolsets"
237
+ | "excludeSubagentTools"
238
+ | "persistAgentSessions"
239
+ >;
240
+
241
+ /**
242
+ * Statuses in which a run's execution has genuinely settled — no promise is
243
+ * still pending, no lease is still held, nothing will asynchronously mutate
244
+ * this ManagedRun again. "paused" is deliberately excluded: both a manual
245
+ * pause() and a usage-limit checkpoint leave the run resumable and, from the
246
+ * in-memory-retention question's point of view, still "the run the user is
247
+ * looking at" — only completed/failed/aborted runs are eviction candidates.
248
+ * See the `runs` field doc comment for the full eviction lifecycle contract.
249
+ */
250
+ const IN_MEMORY_TERMINAL_STATUSES: ReadonlySet<RunStatus> = new Set(["completed", "failed", "aborted"]);
251
+
252
+ /**
253
+ * How many terminal (completed/failed/aborted) runs' full in-memory state
254
+ * (agents array, journal, snapshot, agentTimestamps) to retain in `runs`
255
+ * before the oldest is evicted. Kept small: a terminal run's data is fully
256
+ * on disk (run-persistence.ts) by the time it's eviction-eligible, so the
257
+ * in-memory copy exists only to serve a `getRun()`/`getSnapshot()` caller
258
+ * that wants the LIVE object (vs. listRuns()'s persisted view) for a run
259
+ * that *just* finished — a handful is enough for that; unbounded retention
260
+ * is exactly the leak this bounds (run-level analog of the subagent
261
+ * memory-retention mitigation in agent.ts).
262
+ */
263
+ const DEFAULT_MAX_TERMINAL_RUNS_IN_MEMORY = 20;
264
+
265
+ export class WorkflowManager extends EventEmitter {
266
+ /**
267
+ * Lifecycle contract for `runs`:
268
+ *
269
+ * - An entry is added when a run starts (startInBackground/runSync) or is
270
+ * resumed (resume()), always with a live AbortController and (usually)
271
+ * an active RunLease.
272
+ * - While status is "running" or "paused", the entry is NEVER evicted —
273
+ * its execution could still settle (a pending executeRun() promise) or
274
+ * it is mid-usage-limit-checkpoint/manually-paused and still considered
275
+ * "the current state of this run" by callers. Eviction only ever
276
+ * considers an entry AFTER executeRun() has fully settled it to
277
+ * "completed" | "failed" | "aborted" (see IN_MEMORY_TERMINAL_STATUSES)
278
+ * and persisted + released its lease — i.e. strictly after the same
279
+ * isCurrent()-gated persistRun()/releaseRunLease() calls in
280
+ * executeRun()'s success/catch tails.
281
+ * - Once terminal, an entry becomes eviction-ELIGIBLE (recordTerminalRun())
282
+ * but is not necessarily evicted immediately: up to
283
+ * maxTerminalRunsInMemory terminal entries are kept, oldest evicted
284
+ * first, so a `getRun()` call immediately after completion (e.g. the
285
+ * "complete" event's own synchronous listeners — task-panel's result
286
+ * delivery, `/workflows watch`) still sees the live object. Once
287
+ * evicted, the entry is simply removed from `runs`; nothing else reads
288
+ * or writes it again.
289
+ * - Every caller of getRun()/getSnapshot() must treat "undefined"/null as
290
+ * "no live in-memory copy right now" and fall back to listRuns() (backed
291
+ * by run-persistence.ts, which is what's authoritative for a run once
292
+ * the in-memory copy is gone) — this mirrors how those callers already
293
+ * treat any run this process never had in memory (e.g. one started by a
294
+ * different process and only ever seen via listRuns()). resume() never
295
+ * depends on `runs` for a run's state either: it always reloads from
296
+ * persistence, so an evicted runId resumes exactly like one from a
297
+ * prior process.
298
+ * - isCurrent(managed) composes with eviction the same way it composes
299
+ * with resume()/deleteRun() replacing or removing an entry: eviction
300
+ * removes the map entry outright, so a stale execution's later settle
301
+ * (isCurrent() check) sees `this.runs.get(runId) !== managed` (in fact
302
+ * undefined) and correctly no-ops, exactly as it would after
303
+ * resume()/deleteRun().
304
+ */
305
+ private runs = new Map<string, ManagedRun>();
306
+ /**
307
+ * FIFO of runIds that reached IN_MEMORY_TERMINAL_STATUSES, oldest first —
308
+ * the eviction order for `runs` (see its doc comment). A runId can appear
309
+ * more than once (e.g. resumed after eviction, then terminates again);
310
+ * evicting is idempotent (recordTerminalRun() re-checks the CURRENT status
311
+ * of the current map entry for that id before deleting), so duplicates
312
+ * are harmless.
313
+ */
314
+ private terminalRunQueue: string[] = [];
315
+ private maxTerminalRunsInMemory: number;
316
+ private persistence: RunPersistence;
317
+ private cwd: string;
318
+ private concurrency: number;
319
+ private loadSavedWorkflow?: (name: string) => string | undefined;
320
+ private agent?: Pick<WorkflowAgent, "run">;
321
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
322
+ private mainModel?: string;
323
+ /** The host Pi session's model registry, shared with subagents. */
324
+ private modelRegistry?: ModelRegistry;
325
+ /** The current pi session id; runs are stamped with it and listRuns() filters by it. */
326
+ private sessionId?: string;
327
+ private defaultAgentTimeoutMs: number | null;
328
+ private defaultAgentRetries: number;
329
+ private defaultTokenBudget: number | null;
330
+ private toolsets?: Record<string, () => ToolDefinition[]>;
331
+ private excludeSubagentTools?: string[];
332
+ private persistAgentSessions: boolean;
333
+
334
+ constructor(options: WorkflowManagerOptions = {}) {
335
+ super();
336
+ this.cwd = options.cwd ?? process.cwd();
337
+ this.concurrency = options.concurrency ?? 8;
338
+ this.loadSavedWorkflow = options.loadSavedWorkflow;
339
+ this.agent = options.agent;
340
+ this.mainModel = options.mainModel;
341
+ this.modelRegistry = options.modelRegistry;
342
+ this.sessionId = options.sessionId;
343
+ this.defaultAgentTimeoutMs = options.defaultAgentTimeoutMs ?? null;
344
+ this.defaultAgentRetries = options.defaultAgentRetries ?? 0;
345
+ this.defaultTokenBudget = options.defaultTokenBudget ?? null;
346
+ this.toolsets = options.toolsets;
347
+ this.excludeSubagentTools = options.excludeSubagentTools;
348
+ this.persistAgentSessions = options.persistAgentSessions ?? false;
349
+ this.maxTerminalRunsInMemory = options.maxTerminalRunsInMemory ?? DEFAULT_MAX_TERMINAL_RUNS_IN_MEMORY;
350
+ this.persistence = createRunPersistence(this.cwd);
351
+ this.recoverStaleRuns();
352
+ }
353
+
354
+ /** Bind the manager to the current pi session, so new runs are tagged with it and
355
+ * the navigator/task-panel show only this session's runs (set on session_start). */
356
+ setSessionId(id: string | undefined): void {
357
+ this.sessionId = id;
358
+ }
359
+
360
+ /**
361
+ * On startup, any persisted run still marked "running" belongs to a process
362
+ * that died mid-run (this fresh manager has it nowhere in memory). Reconcile it
363
+ * to "paused" — never "failed" — so its journal is preserved and resume() can
364
+ * replay the completed prefix and finish the rest.
365
+ */
366
+ private recoverStaleRuns(): void {
367
+ try {
368
+ for (const p of this.listAllRuns()) {
369
+ if (p.status === "running" && !this.runs.has(p.runId)) {
370
+ const lease = this.persistence.acquireRunLease(p.runId);
371
+ if (!lease) continue;
372
+ try {
373
+ this.persistence.save({ ...p, status: "paused" });
374
+ } finally {
375
+ this.persistence.releaseRunLease(lease);
376
+ }
377
+ }
378
+ }
379
+ } catch {
380
+ // Recovery is best-effort; never let it block manager construction.
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Refresh host configuration after Pi reloads the extension while retaining
386
+ * this manager's live runs, controllers, leases, and event listeners.
387
+ * Existing executions keep the options they captured at start; subsequent
388
+ * runs and resumes use these refreshed defaults.
389
+ */
390
+ reconfigureAfterReload(options: WorkflowManagerReloadOptions): void {
391
+ this.concurrency = options.concurrency ?? 8;
392
+ this.loadSavedWorkflow = options.loadSavedWorkflow;
393
+ this.defaultAgentTimeoutMs = options.defaultAgentTimeoutMs ?? null;
394
+ this.defaultAgentRetries = options.defaultAgentRetries ?? 0;
395
+ this.defaultTokenBudget = options.defaultTokenBudget ?? null;
396
+ this.toolsets = options.toolsets;
397
+ this.excludeSubagentTools = options.excludeSubagentTools;
398
+ this.persistAgentSessions = options.persistAgentSessions ?? false;
399
+ }
400
+
401
+ /** Set the session's main model (provider/id). Used to auto-tier explore agents. */
402
+ setMainModel(spec: string | undefined): void {
403
+ this.mainModel = spec;
404
+ }
405
+
406
+ /** Set the host session's model registry so subagents resolve models consistently. */
407
+ setModelRegistry(registry: ModelRegistry): void {
408
+ this.modelRegistry = registry;
409
+ }
410
+
411
+ /**
412
+ * Expose the host session's model registry to integrations sharing this
413
+ * manager. Workflow execution reads the same registry internally.
414
+ */
415
+ getModelRegistry(): ModelRegistry | undefined {
416
+ return this.modelRegistry;
417
+ }
418
+
419
+ /**
420
+ * Start a workflow in the background.
421
+ * Returns immediately with a run ID; the workflow executes asynchronously.
422
+ */
423
+ startInBackground(
424
+ script: string,
425
+ args?: unknown,
426
+ exec: ExecOptions = {},
427
+ ): { runId: string; promise: Promise<WorkflowRunResult> } {
428
+ const parsed = parseWorkflowScript(script);
429
+ const slug = parsed.meta.name
430
+ ? parsed.meta.name
431
+ .toLowerCase()
432
+ .replace(/[^a-z0-9]+/g, "-")
433
+ .replace(/^-+|-+$/g, "")
434
+ .slice(0, 40) || "workflow"
435
+ : "";
436
+ const runId = slug ? `${slug}-${generateRunId()}` : generateRunId();
437
+ const controller = new AbortController();
438
+ const lease = this.persistence.acquireRunLease(runId);
439
+ if (!lease) throw new Error(`Could not acquire workflow run lease for ${runId}`);
440
+
441
+ const managed: ManagedRun = {
442
+ runId,
443
+ status: "running",
444
+ snapshot: {
445
+ name: parsed.meta.name,
446
+ description: parsed.meta.description,
447
+ phases: parsed.meta.phases?.map((p) => p.title) ?? [],
448
+ logs: [],
449
+ agents: [],
450
+ agentCount: 0,
451
+ runningCount: 0,
452
+ doneCount: 0,
453
+ errorCount: 0,
454
+ },
455
+ controller,
456
+ startedAt: new Date(),
457
+ script,
458
+ args,
459
+ journal: [],
460
+ background: true,
461
+ lease,
462
+ autoResume: exec.autoResume,
463
+ // Resolve the budget once at start and freeze it on the run (see
464
+ // ManagedRun.tokenBudget) so resume keeps start-time semantics.
465
+ tokenBudget: exec.tokenBudget !== undefined ? exec.tokenBudget : this.defaultTokenBudget,
466
+ toolset: exec.toolset,
467
+ // Same freeze-at-start pattern as tokenBudget, for the same reason: a
468
+ // resumed run must keep these values, not re-resolve against the
469
+ // manager's current defaults (see ManagedRun doc comments).
470
+ maxAgents: exec.maxAgents,
471
+ agentTimeoutMs: exec.agentTimeoutMs !== undefined ? exec.agentTimeoutMs : this.defaultAgentTimeoutMs,
472
+ concurrency: exec.concurrency !== undefined ? exec.concurrency : this.concurrency,
473
+ agentRetries: exec.agentRetries !== undefined ? exec.agentRetries : this.defaultAgentRetries,
474
+ agentTimestamps: new Map(),
475
+ agentsById: new Map(),
476
+ };
477
+
478
+ this.runs.set(runId, managed);
479
+
480
+ try {
481
+ // Persist initial state
482
+ this.persistence.save({
483
+ runId,
484
+ workflowName: parsed.meta.name,
485
+ script,
486
+ args,
487
+ sessionId: this.sessionId,
488
+ status: "running",
489
+ phases: managed.snapshot.phases,
490
+ agents: [],
491
+ logs: [],
492
+ startedAt: managed.startedAt.toISOString(),
493
+ updatedAt: managed.startedAt.toISOString(),
494
+ autoResume: managed.autoResume,
495
+ tokenBudget: managed.tokenBudget,
496
+ toolset: managed.toolset,
497
+ maxAgents: managed.maxAgents,
498
+ agentTimeoutMs: managed.agentTimeoutMs,
499
+ concurrency: managed.concurrency,
500
+ agentRetries: managed.agentRetries,
501
+ });
502
+ } catch (err) {
503
+ this.releaseRunLease(managed);
504
+ this.runs.delete(runId);
505
+ throw err;
506
+ }
507
+
508
+ // Run workflow asynchronously.
509
+ // Attach a side-channel catch to prevent Node.js unhandled-rejection crashes
510
+ // when a workflow is aborted/paused/stopped — executeRun()'s catch block
511
+ // already records status/event/persist, but the promise still rejects.
512
+ // The original promise is returned so callers can await it in try/catch.
513
+ const promise = this.executeRun(managed, script, args, exec);
514
+ promise.catch(() => {});
515
+
516
+ return { runId, promise };
517
+ }
518
+
519
+ /**
520
+ * Execute a workflow synchronously (blocking) while still tracking it like a
521
+ * background run, so the `/workflows` navigator and the live task panel see it.
522
+ * `onProgress` fires on every progress event with the current snapshot, letting
523
+ * a caller (e.g. the workflow tool) drive its own inline display.
524
+ */
525
+ async runSync(script: string, args?: unknown, exec: ExecOptions = {}): Promise<WorkflowRunResult> {
526
+ const managed = this.createManaged(script, args);
527
+ const lease = this.persistence.acquireRunLease(managed.runId);
528
+ if (!lease) throw new Error(`Could not acquire workflow run lease for ${managed.runId}`);
529
+ managed.lease = lease;
530
+ managed.autoResume = exec.autoResume;
531
+ managed.tokenBudget = exec.tokenBudget !== undefined ? exec.tokenBudget : this.defaultTokenBudget;
532
+ managed.toolset = exec.toolset;
533
+ // Same freeze-at-start pattern as tokenBudget (see startInBackground/ManagedRun).
534
+ managed.maxAgents = exec.maxAgents;
535
+ managed.agentTimeoutMs = exec.agentTimeoutMs !== undefined ? exec.agentTimeoutMs : this.defaultAgentTimeoutMs;
536
+ managed.concurrency = exec.concurrency !== undefined ? exec.concurrency : this.concurrency;
537
+ managed.agentRetries = exec.agentRetries !== undefined ? exec.agentRetries : this.defaultAgentRetries;
538
+ this.runs.set(managed.runId, managed);
539
+ // Persist the initial state immediately so listRuns()/the task panel can see
540
+ // the run the moment it starts, not only after the first agent journals.
541
+ this.persistRun(managed);
542
+ return this.executeRun(managed, script, args, exec);
543
+ }
544
+
545
+ /** Build a fresh managed run with an empty snapshot. */
546
+ private createManaged(script: string, args?: unknown): ManagedRun {
547
+ const parsed = parseWorkflowScript(script);
548
+ const slug = parsed.meta.name
549
+ ? parsed.meta.name
550
+ .toLowerCase()
551
+ .replace(/[^a-z0-9]+/g, "-")
552
+ .replace(/^-+|-+$/g, "")
553
+ .slice(0, 40) || "workflow"
554
+ : "";
555
+ const runId = slug ? `${slug}-${generateRunId()}` : generateRunId();
556
+ return {
557
+ runId,
558
+ status: "running",
559
+ snapshot: {
560
+ name: parsed.meta.name,
561
+ description: parsed.meta.description,
562
+ phases: parsed.meta.phases?.map((p) => p.title) ?? [],
563
+ logs: [],
564
+ agents: [],
565
+ agentCount: 0,
566
+ runningCount: 0,
567
+ doneCount: 0,
568
+ errorCount: 0,
569
+ },
570
+ controller: new AbortController(),
571
+ startedAt: new Date(),
572
+ script,
573
+ args,
574
+ journal: [],
575
+ background: false,
576
+ agentTimestamps: new Map(),
577
+ agentsById: new Map(),
578
+ };
579
+ }
580
+
581
+ private async executeRun(
582
+ managed: ManagedRun,
583
+ script: string,
584
+ args?: unknown,
585
+ exec: ExecOptions = {},
586
+ ): Promise<WorkflowRunResult> {
587
+ const {
588
+ resumeJournal,
589
+ maxAgents,
590
+ agentTimeoutMs,
591
+ externalSignal,
592
+ onProgress,
593
+ tokenBudget,
594
+ concurrency,
595
+ agentRetries,
596
+ confirm,
597
+ tools,
598
+ initialTokenUsage,
599
+ } = exec;
600
+ // maxAgents/agentTimeoutMs/concurrency/agentRetries were resolved (per-run
601
+ // value, else the manager default at the time) and frozen on the managed
602
+ // run at start/resume (see ManagedRun doc comments) — read them from there
603
+ // first, exactly like resolvedTokenBudget below, so a resumed run keeps the
604
+ // values it started with instead of re-resolving against the manager's
605
+ // CURRENT defaults. The exec.* fallbacks are a safety net for direct
606
+ // executeRun callers that skipped the start paths (same rationale as
607
+ // resolvedTokenBudget's tokenBudget fallback).
608
+ const resolvedMaxAgents = managed.maxAgents !== undefined ? managed.maxAgents : maxAgents;
609
+ const resolvedAgentTimeoutMs =
610
+ managed.agentTimeoutMs !== undefined
611
+ ? managed.agentTimeoutMs
612
+ : agentTimeoutMs !== undefined
613
+ ? agentTimeoutMs
614
+ : this.defaultAgentTimeoutMs;
615
+ const resolvedConcurrency =
616
+ managed.concurrency !== undefined ? managed.concurrency : (concurrency ?? this.concurrency);
617
+ const resolvedAgentRetries =
618
+ managed.agentRetries !== undefined ? managed.agentRetries : (agentRetries ?? this.defaultAgentRetries);
619
+ // The budget was resolved (per-run value, else defaultTokenBudget) and frozen
620
+ // on the managed run at start/resume — read it from there so a resumed run
621
+ // keeps the budget it started with. exec.tokenBudget is a safety net for
622
+ // direct executeRun callers that skipped the start paths.
623
+ const resolvedTokenBudget = managed.tokenBudget !== undefined ? managed.tokenBudget : (tokenBudget ?? null);
624
+ // Explicit tools win for this execution; else re-resolve the run's persisted
625
+ // toolset tag (how a resumed /deep-research keeps its web tools); else the
626
+ // agent layer's default coding tools.
627
+ const resolvedTools = tools ?? (managed.toolset ? this.toolsets?.[managed.toolset]?.() : undefined);
628
+ // Gated the same way as this.emitLive() below (see isCurrent()) — a stale
629
+ // execution's progress callback would otherwise keep driving live UI
630
+ // (task panel, etc.) for a run that's been superseded or deleted.
631
+ const progress = () => {
632
+ if (this.isCurrent(managed)) onProgress?.(managed.snapshot);
633
+ };
634
+ // Let a host abort (e.g. Esc during a blocking tool call) cancel this run.
635
+ if (externalSignal) {
636
+ if (externalSignal.aborted) managed.controller.abort();
637
+ else externalSignal.addEventListener("abort", () => managed.controller.abort(), { once: true });
638
+ }
639
+ try {
640
+ const result = await runWorkflow(script, {
641
+ cwd: this.cwd,
642
+ args,
643
+ // Use the managed run's persisted id as the workflow runId so the value
644
+ // returned in result.runId matches the id that listRuns()/resume() use.
645
+ // Otherwise runWorkflow mints an ephemeral `run-<ts>` id and the sync
646
+ // path would surface a non-resumable id to the model.
647
+ runId: managed.runId,
648
+ agent: this.agent,
649
+ mainModel: this.mainModel,
650
+ modelRegistry: this.modelRegistry,
651
+ persistAgentSessions: this.persistAgentSessions,
652
+ signal: managed.controller.signal,
653
+ concurrency: resolvedConcurrency,
654
+ agentRetries: resolvedAgentRetries,
655
+ maxAgents: resolvedMaxAgents,
656
+ agentTimeoutMs: resolvedAgentTimeoutMs,
657
+ tokenBudget: resolvedTokenBudget,
658
+ tools: resolvedTools,
659
+ excludeTools: this.excludeSubagentTools,
660
+ confirm,
661
+ loadSavedWorkflow: this.loadSavedWorkflow,
662
+ resumeJournal,
663
+ resumeFromRunId: resumeJournal ? managed.runId : undefined,
664
+ // Seed the fresh SharedRuntime's spend counter from the persisted total
665
+ // (resume()) so the hard tokenBudget cap holds cumulatively across a
666
+ // pause/resume cycle instead of resetting to zero each time (see A2 —
667
+ // runWorkflow only applies this on the fresh-SharedRuntime branch, never
668
+ // overriding an inherited options.sharedRuntime from a nested workflow()).
669
+ initialTokenUsage,
670
+ // Retried-attempt spend (see WorkflowRunOptions.onRetrySpend and A2):
671
+ // recordTokens() in workflow.ts already folded this into
672
+ // shared.spent/tokenUsage, but onAgentEnd never sees a retried
673
+ // (non-final) attempt — fold it into the same persisted aggregate here
674
+ // so a run paused after a retry doesn't under-count against the budget.
675
+ onRetrySpend: (tokens) => {
676
+ this.accumulateTokenUsage(managed, tokens);
677
+ },
678
+ onAgentJournal: (entry) => {
679
+ // Append (crash-safe-ish): keep the latest entry per (runId, index)
680
+ // pair, then persist. Matching on index ALONE would let a nested
681
+ // workflow()'s callIndex-0 entry evict the parent's own
682
+ // callIndex-0 entry (and vice versa) — they're only distinguished
683
+ // by runId (see JournalEntry.runId). This is the high-frequency
684
+ // progress persist (fires once per completed agent, can burst
685
+ // under concurrency) — throttled (trailing edge). Every
686
+ // lifecycle-critical persist below (status transitions, run end,
687
+ // pause/resume/stop) still calls persistRun() directly and flushes this.
688
+ managed.journal = managed.journal.filter((e) => !(e.index === entry.index && e.runId === entry.runId));
689
+ managed.journal.push(entry);
690
+ this.schedulePersist(managed);
691
+ },
692
+ onLog: (message) => {
693
+ managed.snapshot.logs.push(message);
694
+ this.emitLive(managed, "log", { runId: managed.runId, message });
695
+ progress();
696
+ },
697
+ onPhase: (title) => {
698
+ managed.snapshot.currentPhase = title;
699
+ if (!managed.snapshot.phases.includes(title)) {
700
+ managed.snapshot.phases.push(title);
701
+ }
702
+ this.emitLive(managed, "phase", { runId: managed.runId, title });
703
+ progress();
704
+ },
705
+ onAgentStart: (event) => {
706
+ const id = managed.snapshot.agents.length + 1;
707
+ const agentSnapshot: WorkflowAgentSnapshot = {
708
+ id,
709
+ callId: event.id,
710
+ label: event.label,
711
+ phase: event.phase,
712
+ prompt: event.prompt,
713
+ status: "running",
714
+ model: event.model,
715
+ };
716
+ managed.snapshot.agents.push(agentSnapshot);
717
+ // Index by the call's unique id (never label — see agentsById's doc
718
+ // comment) so onAgentEnd/onAgentHistory can resolve back to exactly
719
+ // THIS entry even when a concurrent sibling shares its label.
720
+ managed.agentsById.set(event.id, agentSnapshot);
721
+ // Real per-agent start time, captured the moment the agent actually
722
+ // starts (not the run's startedAt) — see agentTimestamps.
723
+ managed.agentTimestamps.set(id, { startedAt: new Date().toISOString() });
724
+ this.emitLive(managed, "agentStart", { runId: managed.runId, ...event });
725
+ progress();
726
+ },
727
+ onAgentEnd: (event) => {
728
+ const agent = managed.agentsById.get(event.id);
729
+ if (agent) {
730
+ agent.status = event.result === null ? "error" : "done";
731
+ // Keep the full value for the interactive pager; compact surfaces
732
+ // continue to use resultPreview.
733
+ agent.result = event.result;
734
+ agent.resultPreview = preview(event.result);
735
+ agent.error = event.error;
736
+ agent.errorCode = event.errorCode;
737
+ agent.recoverable = event.recoverable;
738
+ agent.tokens = event.tokens;
739
+ if (event.tokenUsage) agent.tokenUsage = event.tokenUsage;
740
+ if (event.model) agent.model = event.model;
741
+ // Real per-agent end time — only terminal agents get one; a still-
742
+ // running agent's entry keeps endedAt undefined.
743
+ const ts = managed.agentTimestamps.get(agent.id);
744
+ if (ts) ts.endedAt = new Date().toISOString();
745
+ }
746
+ // Progressive run-wide token aggregate (A2): workflow.ts's onTokenUsage
747
+ // callback below fires exactly once, only when the whole script finishes
748
+ // successfully (a deliberate, tested contract — see
749
+ // "agent() accumulates usage across multiple agents" in agent.test.ts,
750
+ // which asserts one final event, not one per agent). A run that
751
+ // pauses/aborts/fails mid-flight never reaches it, so without tracking
752
+ // it here too, a paused run's persisted tokenUsage would stay whatever
753
+ // it was (usually unset) — starving resume()'s spend-seeding of the
754
+ // very data it needs. Accumulate additively from every onAgentEnd
755
+ // instead: a cache-hit replay reports tokens: 0 (see agent()'s replay
756
+ // branch in workflow.ts), so replaying the unchanged prefix on resume
757
+ // is a no-op add here, matching the "already historically spent, don't
758
+ // double-count" semantics of journal replay.
759
+ this.accumulateTokenUsage(managed, event.tokens ?? 0, event.tokenUsage);
760
+ this.emitLive(managed, "agentEnd", { runId: managed.runId, ...event });
761
+ progress();
762
+ },
763
+ onAgentHistory: (event) => {
764
+ const agent = managed.agentsById.get(event.id);
765
+ if (agent) {
766
+ agent.history = event.history;
767
+ }
768
+ this.emitLive(managed, "agentHistory", { runId: managed.runId, agentId: agent?.id, ...event });
769
+ progress();
770
+ },
771
+ onTokenUsage: (usage) => {
772
+ managed.snapshot.tokenUsage = usage;
773
+ this.emitLive(managed, "tokenUsage", { runId: managed.runId, usage });
774
+ progress();
775
+ },
776
+ });
777
+
778
+ managed.status = "completed";
779
+ managed.result = result;
780
+ // Gated the same way as disk/lease below (see emitLive()): a stale
781
+ // execution's "complete" would otherwise still deliver a result for a
782
+ // run that's been superseded or deleted (e.g. background result
783
+ // delivery into the conversation) even though it's no longer current.
784
+ this.emitLive(managed, "complete", { runId: managed.runId, result });
785
+
786
+ // Persist final state. persistRun()/writeRunToDisk() already no-op if
787
+ // `managed` has been superseded (resume()/deleteRun() took over this
788
+ // runId) — see isCurrent(). Guard the lease release the same way: a
789
+ // stale execution settling after resume() has already acquired a NEW
790
+ // lease for this runId must not touch that newer lease's bookkeeping.
791
+ this.persistRun(managed);
792
+ if (this.isCurrent(managed)) {
793
+ this.releaseRunLease(managed);
794
+ // Now (and only now — after the run's data is safely on disk and its
795
+ // lease released) does this run become eviction-eligible; see the
796
+ // `runs` field doc comment.
797
+ this.recordTerminalRun(managed.runId);
798
+ }
799
+
800
+ return result;
801
+ } catch (error) {
802
+ const workflowError =
803
+ error instanceof WorkflowError
804
+ ? error
805
+ : new WorkflowError(
806
+ error instanceof Error ? error.message : String(error),
807
+ WorkflowErrorCode.WORKFLOW_ABORTED,
808
+ { recoverable: true },
809
+ );
810
+
811
+ const usageLimitPaused = !managed.controller.signal.aborted && isProviderUsageLimit(workflowError);
812
+ if (managed.controller.signal.aborted) {
813
+ // Intentional abort (pause/stop/Esc) — preserve status set by pause()/stop()
814
+ if (managed.status === "running") {
815
+ managed.status = "aborted";
816
+ }
817
+ } else if (usageLimitPaused) {
818
+ // Provider quota/usage limit: NOT a failure. Checkpoint the run as paused so
819
+ // the persisted journal (completed agent results) is replayed by resume()
820
+ // once the budget refills — instead of the user starting from scratch.
821
+ managed.status = "paused";
822
+ } else {
823
+ managed.status = "failed";
824
+ }
825
+ managed.error = workflowError;
826
+ // Both branches gated via emitLive() (see its doc comment) — a stale
827
+ // execution's "paused"/"error" is equally misleading once superseded.
828
+ if (usageLimitPaused) {
829
+ this.emitLive(managed, "paused", {
830
+ runId: managed.runId,
831
+ reason: "usage_limit",
832
+ error: workflowError,
833
+ resetHint: workflowError.resetHint,
834
+ });
835
+ } else if (this.listenerCount("error") > 0) {
836
+ // Guarded: EventEmitter throws on an unlistened "error" emit, which
837
+ // would abort this catch block mid-way — skipping the final persist,
838
+ // the lease release, and the real error rethrow below.
839
+ this.emitLive(managed, "error", { runId: managed.runId, error: workflowError });
840
+ }
841
+
842
+ // Persist final state (see the success-path comment above for the
843
+ // isCurrent() rationale — same guard, same reason).
844
+ this.persistRun(managed);
845
+ if (this.isCurrent(managed)) {
846
+ this.releaseRunLease(managed);
847
+ // "paused" (manual pause() or a usage-limit checkpoint) is
848
+ // deliberately NOT eviction-eligible — only a genuinely settled
849
+ // terminal status is (see IN_MEMORY_TERMINAL_STATUSES / the `runs`
850
+ // field doc comment). recordTerminalRun() itself re-checks this too,
851
+ // but skip the call entirely here so a paused run never even enters
852
+ // the eviction queue.
853
+ if (IN_MEMORY_TERMINAL_STATUSES.has(managed.status)) this.recordTerminalRun(managed.runId);
854
+ }
855
+
856
+ throw workflowError;
857
+ }
858
+ }
859
+
860
+ /**
861
+ * True when `managed` is still the live, current entry for its runId in
862
+ * `this.runs` — false once resume() has replaced it with a new ManagedRun
863
+ * object for the same runId, or deleteRun() has removed it entirely. A
864
+ * superseded ManagedRun's async completion (executeRun's promise settling
865
+ * well after something else already took over or tore down that runId)
866
+ * must not write to disk or touch lease state on the newer execution's
867
+ * behalf — see writeRunToDisk() and executeRun()'s post-await persist calls.
868
+ */
869
+ private isCurrent(managed: ManagedRun): boolean {
870
+ return this.runs.get(managed.runId) === managed;
871
+ }
872
+
873
+ /**
874
+ * Emit an event on behalf of `managed`, but only while it's still the
875
+ * current entry for its runId (see isCurrent()) — mirrors the disk/lease
876
+ * guard for the observer-facing side of the same problem. A superseded
877
+ * execution's progress/terminal events (log, phase, agentStart/End,
878
+ * tokenUsage, complete, error, paused) are not just stale-but-harmless:
879
+ * "complete" in particular can drive background result delivery into the
880
+ * conversation, so letting a deleted/superseded run's stale settle still
881
+ * fire it would deliver a result for a run that, from the caller's POV, no
882
+ * longer exists (or has since been superseded by a newer execution whose
883
+ * own events already tell the true story). No event in this set has a
884
+ * legitimate reason to still reach listeners once superseded — unlike
885
+ * disk writes there's no "expected race, harmless no-op" nuance here, it's
886
+ * simply wrong to notify twice (or for a run that's gone). Events emitted
887
+ * directly by pause()/stop()/resume()/deleteRun() themselves are NOT routed
888
+ * through this helper — those methods own the transition and ARE current
889
+ * at the moment they fire, same precedent as their persist/lease calls.
890
+ */
891
+ private emitLive(managed: ManagedRun, event: string, payload: unknown): void {
892
+ if (this.isCurrent(managed)) this.emit(event, payload);
893
+ }
894
+
895
+ /**
896
+ * Mark `runId` as eviction-eligible now that its execution has genuinely
897
+ * settled to a terminal status (completed/failed/aborted — see
898
+ * IN_MEMORY_TERMINAL_STATUSES), and evict the oldest eligible entries
899
+ * beyond maxTerminalRunsInMemory. Callers must only invoke this after the
900
+ * same isCurrent()-gated persistRun()/releaseRunLease() sequence executeRun()
901
+ * already uses (see the `runs` field doc comment for the full contract) —
902
+ * this method itself re-validates the CURRENT entry's status before
903
+ * deleting anything, so it never evicts a run that isn't (or is no longer)
904
+ * genuinely terminal, including one resumed back to "running" after being
905
+ * queued here but before its turn to be evicted came up.
906
+ */
907
+ private recordTerminalRun(runId: string): void {
908
+ this.terminalRunQueue.push(runId);
909
+ while (this.terminalRunQueue.length > this.maxTerminalRunsInMemory) {
910
+ const oldest = this.terminalRunQueue.shift();
911
+ if (oldest === undefined) break;
912
+ const current = this.runs.get(oldest);
913
+ // Re-check the CURRENT entry for this id (not the ManagedRun object
914
+ // that was terminal when queued) — resume() may have since replaced
915
+ // it with a fresh, live execution, which must never be evicted here.
916
+ if (current && IN_MEMORY_TERMINAL_STATUSES.has(current.status)) {
917
+ this.runs.delete(oldest);
918
+ }
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Additively fold one agent-call's token cost into the run-wide persisted
924
+ * aggregate (managed.snapshot.tokenUsage), seeded (on resume) from the
925
+ * persisted total-at-pause — see A2. Shared by onAgentEnd (a completed or
926
+ * finally-failed agent call) and onRetrySpend (a failed attempt that WILL
927
+ * be retried, whose cost recordTokens() already folded into
928
+ * shared.spent/tokenUsage in workflow.ts, but which onAgentEnd never sees —
929
+ * see WorkflowRunOptions.onRetrySpend for why that needs its own channel).
930
+ */
931
+ private accumulateTokenUsage(
932
+ managed: ManagedRun,
933
+ tokens: number,
934
+ tokenUsage?: { input: number; output: number; cost: number; cacheRead: number; cacheWrite: number },
935
+ ): void {
936
+ const prior = managed.snapshot.tokenUsage;
937
+ const usage = {
938
+ input: prior?.input ?? 0,
939
+ output: prior?.output ?? 0,
940
+ total: prior?.total ?? 0,
941
+ cost: prior?.cost ?? 0,
942
+ cacheRead: prior?.cacheRead ?? 0,
943
+ cacheWrite: prior?.cacheWrite ?? 0,
944
+ };
945
+ usage.total += tokens;
946
+ if (tokenUsage) {
947
+ usage.input += tokenUsage.input;
948
+ usage.output += tokenUsage.output;
949
+ usage.cost += tokenUsage.cost;
950
+ usage.cacheRead += tokenUsage.cacheRead;
951
+ usage.cacheWrite += tokenUsage.cacheWrite;
952
+ }
953
+ managed.snapshot.tokenUsage = usage;
954
+ }
955
+
956
+ private releaseRunLease(managed: ManagedRun): void {
957
+ if (!managed.lease) return;
958
+ this.persistence.releaseRunLease(managed.lease);
959
+ managed.lease = undefined;
960
+ }
961
+
962
+ /** Trailing-edge throttle window for high-frequency progress persists (see schedulePersist). */
963
+ private static readonly PERSIST_THROTTLE_MS = 400;
964
+
965
+ /** Pending trailing-edge persist timers for high-frequency progress events, keyed by runId. */
966
+ private persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
967
+
968
+ /**
969
+ * Coalesce rapid progress persists (currently: onAgentJournal, which fires
970
+ * once per completed agent and can burst under concurrency) to at most one
971
+ * disk write per PERSIST_THROTTLE_MS (trailing edge) instead of one write
972
+ * per tick — persistRun() does a full JSON.stringify of the run plus up to
973
+ * 3 sync writes, so firing it once per agent in a long run is O(N^2).
974
+ *
975
+ * Lifecycle-critical writes (status transitions, run end, pause/resume/stop)
976
+ * must NOT use this — call persistRun() directly, which flushes (and cancels)
977
+ * any pending timer first so a stale trailing write can never fire after, and
978
+ * resurrect, a terminal state.
979
+ */
980
+ private schedulePersist(managed: ManagedRun): void {
981
+ if (this.persistTimers.has(managed.runId)) return; // already scheduled; the trailing write reads live state
982
+ const timer = setTimeout(() => {
983
+ this.persistTimers.delete(managed.runId);
984
+ this.writeRunToDisk(managed);
985
+ }, WorkflowManager.PERSIST_THROTTLE_MS);
986
+ // A pending progress persist should never keep the process alive on its own.
987
+ timer.unref?.();
988
+ this.persistTimers.set(managed.runId, timer);
989
+ }
990
+
991
+ /**
992
+ * Persist immediately and synchronously. Cancels any pending throttled write
993
+ * for this run first, so the write that lands is always the caller's current
994
+ * (final) state — never superseded by a stale deferred write. Use this for
995
+ * every lifecycle-critical persist: run start, status transitions, run end,
996
+ * pause()/resume()/stop().
997
+ */
998
+ private persistRun(managed: ManagedRun): void {
999
+ // A superseded execution's persist call must not touch the CURRENT
1000
+ // execution's pending-timer bookkeeping for this runId (see isCurrent()).
1001
+ // writeRunToDisk() below re-checks this too (it's the sole choke point
1002
+ // schedulePersist()'s deferred timer also funnels through), so this is a
1003
+ // belt-and-suspenders early-out specifically for the timer-clearing side
1004
+ // effect, which writeRunToDisk() alone wouldn't prevent.
1005
+ if (!this.isCurrent(managed)) return;
1006
+ const timer = this.persistTimers.get(managed.runId);
1007
+ if (timer) {
1008
+ clearTimeout(timer);
1009
+ this.persistTimers.delete(managed.runId);
1010
+ }
1011
+ this.writeRunToDisk(managed);
1012
+ }
1013
+
1014
+ private writeRunToDisk(managed: ManagedRun) {
1015
+ // The sole choke point for every disk write (both persistRun()'s direct
1016
+ // calls and schedulePersist()'s deferred timer funnel through here) — skip
1017
+ // silently when `managed` is no longer the current entry for its runId
1018
+ // (see isCurrent()). This is an expected race outcome (resume() replaced
1019
+ // it, or deleteRun() removed it), not an error: writing anyway would
1020
+ // resurrect a torn-down run's file, or clobber a newer execution's
1021
+ // in-progress/completed state with this stale one's.
1022
+ //
1023
+ // This check is redundant with persistRun()'s own early-return for every
1024
+ // CURRENT call site — it earns its keep solely for schedulePersist()'s
1025
+ // deferred setTimeout callback, the one path into this method that skips
1026
+ // persistRun() entirely. That callback only fires from onAgentJournal, and
1027
+ // onAgentJournal only fires for a call that got PAST agent()'s
1028
+ // throwIfAborted() check (see workflow.ts) — which, since run-fatal abort
1029
+ // (SharedRuntime.runFatalController) now seals every top-level run's
1030
+ // shared runtime the instant any error escapes it uncaught, means a
1031
+ // genuinely superseded-but-never-aborted execution (the only kind that
1032
+ // could previously still journal a stray call after resume() replaced it)
1033
+ // is structurally impossible to construct anymore — see the "unreachable
1034
+ // defense-in-depth (#2)" test in workflow-manager.test.ts for the worked
1035
+ // example and its own note. This check is KEPT anyway: it costs nothing,
1036
+ // and removing it would silently reopen a stale-write path the moment any
1037
+ // future change (e.g. a new way to journal without throwIfAborted()'s
1038
+ // gate) reintroduces a producer for it.
1039
+ if (!this.isCurrent(managed)) return;
1040
+ try {
1041
+ // Resumable states need their journal; completed/aborted states need rich
1042
+ // agent details. Persist exactly one full copy of each agent result instead
1043
+ // of writing it to both agents[].result and journal[].result.
1044
+ const keepsResumeJournal = managed.status !== "completed" && managed.status !== "aborted";
1045
+ this.persistence.save({
1046
+ runId: managed.runId,
1047
+ workflowName: managed.snapshot.name,
1048
+ // Persist the real script + journal so the run can be resumed. Runs live
1049
+ // in workflow run storage — protect via directory permissions, not blanking.
1050
+ script: managed.script,
1051
+ args: managed.args,
1052
+ sessionId: this.sessionId,
1053
+ journal: keepsResumeJournal ? managed.journal : undefined,
1054
+ status: managed.status,
1055
+ // Persisted every write (not just at pause) so a stale read during the
1056
+ // "paused" event race (see UsageLimitScheduler) is still correct — this
1057
+ // is fixed at run-start and doesn't change over the run's lifetime.
1058
+ autoResume: managed.autoResume,
1059
+ // Start-time execution context, re-read by resume() (see ManagedRun).
1060
+ tokenBudget: managed.tokenBudget,
1061
+ toolset: managed.toolset,
1062
+ maxAgents: managed.maxAgents,
1063
+ agentTimeoutMs: managed.agentTimeoutMs,
1064
+ concurrency: managed.concurrency,
1065
+ agentRetries: managed.agentRetries,
1066
+ // Why a usage-limit pause happened, so the navigator / a future cold start
1067
+ // can show it and (eventually) re-arm resume after the budget refills.
1068
+ pauseReason: managed.status === "paused" && isProviderUsageLimit(managed.error) ? "usage_limit" : undefined,
1069
+ resetHint:
1070
+ managed.status === "paused" && isProviderUsageLimit(managed.error) ? managed.error.resetHint : undefined,
1071
+ phases: managed.snapshot.phases,
1072
+ currentPhase: managed.snapshot.currentPhase,
1073
+ // Real per-agent timestamps only (see agentTimestamps) — never the run's
1074
+ // own startedAt or "now" stamped onto every agent on every write. A
1075
+ // still-running agent is persisted with no endedAt.
1076
+ agents: managed.snapshot.agents.map((a) => {
1077
+ const { result, ...summary } = a;
1078
+ const ts = managed.agentTimestamps.get(a.id);
1079
+ return {
1080
+ ...summary,
1081
+ // Live runs keep the rich value in memory. Cold resumable runs use
1082
+ // the journal and retain resultPreview until replay reconstructs it.
1083
+ ...(keepsResumeJournal || result === undefined ? {} : { result }),
1084
+ startedAt: ts?.startedAt,
1085
+ endedAt: ts?.endedAt,
1086
+ };
1087
+ }),
1088
+ logs: managed.snapshot.logs,
1089
+ result: managed.result?.result,
1090
+ tokenUsage: managed.snapshot.tokenUsage
1091
+ ? {
1092
+ input: managed.snapshot.tokenUsage.input,
1093
+ output: managed.snapshot.tokenUsage.output,
1094
+ total: managed.snapshot.tokenUsage.total,
1095
+ cost: managed.snapshot.tokenUsage.cost,
1096
+ cacheRead: managed.snapshot.tokenUsage.cacheRead,
1097
+ cacheWrite: managed.snapshot.tokenUsage.cacheWrite,
1098
+ }
1099
+ : undefined,
1100
+ startedAt: managed.startedAt.toISOString(),
1101
+ updatedAt: new Date().toISOString(),
1102
+ completedAt: managed.status === "completed" ? new Date().toISOString() : undefined,
1103
+ durationMs: managed.result?.durationMs,
1104
+ });
1105
+ } catch (err) {
1106
+ // Persistence is best-effort: the run is still healthy in memory.
1107
+ // Log so an operator debugging state-loss has a lead, but never crash
1108
+ // the workflow over a disk-full situation.
1109
+ console.warn("[workflow-manager] Persist run failed:", err);
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Pause a running workflow.
1115
+ */
1116
+ pause(runId: string): boolean {
1117
+ const managed = this.runs.get(runId);
1118
+ if (managed?.status !== "running") return false;
1119
+
1120
+ managed.controller.abort();
1121
+ managed.status = "paused";
1122
+ this.emit("paused", { runId });
1123
+ this.persistRun(managed);
1124
+ this.releaseRunLease(managed);
1125
+ return true;
1126
+ }
1127
+
1128
+ /**
1129
+ * Resume an interrupted run: replay journaled results for the unchanged prefix
1130
+ * and run the rest live. Returns false if there is nothing resumable.
1131
+ *
1132
+ * `opts.script` lets the orchestrating model resume with an EDITED script
1133
+ * (cached-prefix reuse / iteration): unchanged agent() calls whose content
1134
+ * hash still matches the journal entry at their positional callIndex replay
1135
+ * from cache, while the first changed or newly inserted call — and everything
1136
+ * after it — re-runs live. When `opts.script` is omitted, resume behaves
1137
+ * exactly as before and uses the persisted script (auto-resume, TUI resume);
1138
+ * this keeps the existing single-arg `resume(runId)` callers (e.g. the
1139
+ * UsageLimitScheduler) unchanged. `opts.args` overrides the persisted args
1140
+ * only when provided; otherwise the persisted args are kept.
1141
+ */
1142
+ async resume(runId: string, opts?: { script?: string; args?: unknown }): Promise<boolean> {
1143
+ // Guard: refuse to resume a run that is already running, or one that was
1144
+ // intentionally aborted (pause/stop/Esc). Paused and failed runs can restart.
1145
+ const active = this.runs.get(runId);
1146
+ if (active?.status === "running") return false;
1147
+ if (active?.status === "aborted") return false;
1148
+
1149
+ const persisted = this.persistence.load(runId);
1150
+ if (!persisted?.script || persisted.status === "completed" || persisted.status === "aborted") return false;
1151
+ const lease = this.persistence.acquireRunLease(runId);
1152
+ if (!lease) return false;
1153
+
1154
+ // Use the edited script when supplied, else the persisted one (backward-compat).
1155
+ const script = opts?.script ?? persisted.script;
1156
+ const args = opts?.args !== undefined ? opts.args : persisted.args;
1157
+
1158
+ // Normalize the persisted total-at-pause once: PersistedRunState.tokenUsage
1159
+ // has optional cost/cacheRead/cacheWrite (legacy runs may lack them), but
1160
+ // both the seeded snapshot and initialTokenUsage need concrete numbers.
1161
+ const priorTokenUsage = persisted.tokenUsage
1162
+ ? {
1163
+ input: persisted.tokenUsage.input,
1164
+ output: persisted.tokenUsage.output,
1165
+ total: persisted.tokenUsage.total,
1166
+ cost: persisted.tokenUsage.cost ?? 0,
1167
+ cacheRead: persisted.tokenUsage.cacheRead ?? 0,
1168
+ cacheWrite: persisted.tokenUsage.cacheWrite ?? 0,
1169
+ }
1170
+ : undefined;
1171
+
1172
+ const controller = new AbortController();
1173
+ const managed: ManagedRun = {
1174
+ runId,
1175
+ status: "running",
1176
+ snapshot: {
1177
+ name: persisted.workflowName,
1178
+ phases: persisted.phases ?? [],
1179
+ logs: persisted.logs ?? [],
1180
+ agents: [],
1181
+ agentCount: 0,
1182
+ runningCount: 0,
1183
+ doneCount: 0,
1184
+ errorCount: 0,
1185
+ // Seed the live snapshot's aggregate from the persisted total-at-pause
1186
+ // (see A2) so a pause that lands before this resume's first agent
1187
+ // completes doesn't lose the prior spend — onAgentEnd accumulates on
1188
+ // top of this rather than starting from scratch.
1189
+ tokenUsage: priorTokenUsage,
1190
+ },
1191
+ controller,
1192
+ startedAt: new Date(),
1193
+ // The (possibly edited) script + args become the run's own — persistRun()
1194
+ // writes them below, so a later resume of this run sees the edited script.
1195
+ script,
1196
+ args,
1197
+ journal: persisted.journal ?? [],
1198
+ background: true,
1199
+ lease,
1200
+ // Carry the original opt-out forward across resumes; it's fixed at
1201
+ // run-start and persistRun() re-persists it on every subsequent write.
1202
+ autoResume: persisted.autoResume,
1203
+ // Restore start-time execution context: the budget the run started with
1204
+ // (legacy runs without one resume unbudgeted — never re-apply the current
1205
+ // default to a run that predates it) and the toolset tag executeRun
1206
+ // re-resolves so e.g. a resumed /deep-research keeps its web tools.
1207
+ tokenBudget: persisted.tokenBudget !== undefined ? persisted.tokenBudget : null,
1208
+ toolset: persisted.toolset,
1209
+ // Restore the same start-time execution context for the other four
1210
+ // per-run knobs (see ManagedRun doc comments) — same rationale as
1211
+ // tokenBudget: never re-resolve against the manager's CURRENT defaults.
1212
+ // maxAgents: legacy/never-set runs resume with no cap carried forward
1213
+ // (runWorkflow's own MAX_AGENTS_PER_RUN default applies), exactly as if
1214
+ // maxAgents had never been passed at all.
1215
+ maxAgents: persisted.maxAgents,
1216
+ // agentTimeoutMs: unlike tokenBudget, a legacy run's real timeout at
1217
+ // start was never "no timeout" by omission — it was always
1218
+ // this.defaultAgentTimeoutMs, because pre-A1 resume() never threaded
1219
+ // agentTimeoutMs through at all and unconditionally fell back to the
1220
+ // manager default (see executeRun's resolvedAgentTimeoutMs fallback
1221
+ // chain). Falling back to null here would change what a legacy run's
1222
+ // resume actually does versus both its original start AND pre-fix
1223
+ // resume behavior. So — deliberately unlike tokenBudget's null
1224
+ // fallback — legacy runs resume with the manager's CURRENT default,
1225
+ // matching the only semantics such a run ever had.
1226
+ agentTimeoutMs: persisted.agentTimeoutMs !== undefined ? persisted.agentTimeoutMs : this.defaultAgentTimeoutMs,
1227
+ // concurrency/agentRetries have no "explicit opt-out sentinel" the way
1228
+ // tokenBudget's null does — a legacy run without a persisted value falls
1229
+ // back to the manager's current values, matching how this execution
1230
+ // resolved unset concurrency/agentRetries before this fix ever existed.
1231
+ concurrency: persisted.concurrency !== undefined ? persisted.concurrency : this.concurrency,
1232
+ agentRetries: persisted.agentRetries !== undefined ? persisted.agentRetries : this.defaultAgentRetries,
1233
+ // Fresh per-resume: agents (and any prior timing) are rebuilt live as
1234
+ // onAgentStart/onAgentEnd fire again for this attempt (see `agents: []`
1235
+ // above); the journal, not this map, is what makes replayed agents cheap.
1236
+ agentTimestamps: new Map(),
1237
+ agentsById: new Map(),
1238
+ };
1239
+ this.runs.set(runId, managed);
1240
+ // Persist before notifying renderers: listRuns() is their source of truth for
1241
+ // lifecycle status, while getRun() supplies the live in-memory snapshot.
1242
+ this.persistRun(managed);
1243
+
1244
+ // Namespace by (runId, index) exactly like the live onAgentJournal dedup
1245
+ // above and like SharedStore's deltaKey — see JournalEntry.runId. A
1246
+ // legacy entry persisted before namespacing existed has no `runId`; it is
1247
+ // assumed to belong to this run's own top-level runId (the only frame
1248
+ // that existed before nested workflow() journaling was namespaced), so it
1249
+ // still resume-hits for a top-level call and safely cache-misses (re-runs
1250
+ // live, does not misapply) for what was actually a nested-run entry.
1251
+ const resumeJournal = new Map((persisted.journal ?? []).map((e) => [`${e.runId ?? runId}:${e.index}`, e] as const));
1252
+ this.emit("resumed", { runId });
1253
+ // Run in the background; executeRun records status/errors on the managed run.
1254
+ // initialTokenUsage seeds the resumed execution's fresh SharedRuntime.spent
1255
+ // (A2) from the persisted total-at-pause, so the tokenBudget cap holds
1256
+ // cumulatively instead of resetting to zero. Note: shared.agentCount is
1257
+ // deliberately NOT seeded the same way — it doesn't need to be. Unlike
1258
+ // token spend (whose cache-hit replay branch skips recordTokens() to avoid
1259
+ // double-counting already-spent tokens), agent()'s shared.agentCount++
1260
+ // fires unconditionally for EVERY call, cache-hit or live, before the
1261
+ // replay check runs (see workflow.ts). Because resume() always replays the
1262
+ // whole script from callIndex 0, that replay alone reconstructs the
1263
+ // correct cumulative count inside this fresh SharedRuntime by the time any
1264
+ // new live agent runs — so maxAgents (via A1) is already a genuine
1265
+ // cumulative cap across resume with no extra seeding required.
1266
+ void this.executeRun(managed, script, args, { resumeJournal, initialTokenUsage: priorTokenUsage }).catch(() => {});
1267
+ return true;
1268
+ }
1269
+
1270
+ /**
1271
+ * Stop a running workflow.
1272
+ *
1273
+ * Fast path: the run is live in this process (`this.runs`) — abort its
1274
+ * controller and persist "aborted" as before. Fallback: the run is not in
1275
+ * memory but is persisted as "running" or "paused" — e.g. it belongs to a
1276
+ * prior pi session that this process's recoverStaleRuns() flipped to
1277
+ * "paused" on disk without repopulating this.runs (see workflow-control-tool's
1278
+ * findRun(), which resolves candidates from disk via listRuns()). There is no
1279
+ * live controller to abort in that case — the run simply isn't executing in
1280
+ * this process — so mark it aborted on disk directly, mirroring resume()'s
1281
+ * persisted-fallback lease handling.
1282
+ */
1283
+ stop(runId: string): boolean {
1284
+ const managed = this.runs.get(runId);
1285
+ if (managed) {
1286
+ if (managed.status !== "running" && managed.status !== "paused") return false;
1287
+ // Whether this run's OWN executeRun() promise has already fully settled
1288
+ // matters for whether stop() itself must be the one to call
1289
+ // recordTerminalRun(): a usage-limit checkpoint runs executeRun()'s
1290
+ // catch tail to completion before "paused" is ever observable (it
1291
+ // deliberately skipped recordTerminalRun() then, since "paused" isn't
1292
+ // terminal) — so there is no FUTURE tail left that will ever call it
1293
+ // for this managed object. A manual pause() sets "paused" while its
1294
+ // cooperative abort may still be settling; in that narrow window the
1295
+ // tail later settles this object to "aborted" (terminal) and records a
1296
+ // SECOND time — a tolerated duplicate: recordTerminalRun() is
1297
+ // idempotent-safe under duplicates (re-validates the current entry),
1298
+ // the lease was already cleared here, and the worst case is the
1299
+ // stopped run leaving memory earlier than FIFO order (persistence
1300
+ // fallback covers every consumer). A "running" run, by contrast,
1301
+ // always still has that tail pending;
1302
+ // it (not stop()) is what calls recordTerminalRun() once it actually
1303
+ // settles to "aborted" — see the `runs` field doc comment's rule that
1304
+ // eviction eligibility must wait for the real settle, not a request to
1305
+ // abort. Without this, stopping an already-paused run left it in
1306
+ // `runs` forever (no future tail to mark it eviction-eligible) — a
1307
+ // small leak in exactly the class this manager otherwise bounds.
1308
+ const hadNoPendingSettle = managed.status === "paused";
1309
+ managed.controller.abort();
1310
+ managed.status = "aborted";
1311
+ this.emit("stopped", { runId });
1312
+ this.persistRun(managed);
1313
+ this.releaseRunLease(managed);
1314
+ if (hadNoPendingSettle) this.recordTerminalRun(runId);
1315
+ return true;
1316
+ }
1317
+
1318
+ const persisted = this.persistence.load(runId);
1319
+ if (!persisted || (persisted.status !== "running" && persisted.status !== "paused")) return false;
1320
+ const lease = this.persistence.acquireRunLease(runId);
1321
+ if (!lease) return false;
1322
+ try {
1323
+ this.persistence.save({ ...persisted, status: "aborted", updatedAt: new Date().toISOString() });
1324
+ } finally {
1325
+ this.persistence.releaseRunLease(lease);
1326
+ }
1327
+ this.emit("stopped", { runId });
1328
+ return true;
1329
+ }
1330
+
1331
+ /**
1332
+ * Get status of a specific run.
1333
+ */
1334
+ getRun(runId: string): ManagedRun | undefined {
1335
+ return this.runs.get(runId);
1336
+ }
1337
+
1338
+ /**
1339
+ * List all runs (active + persisted).
1340
+ */
1341
+ /**
1342
+ * Runs for the navigator/task panel. Once bound to a session (setSessionId), only
1343
+ * that session's runs are returned — runs from other sessions stay on disk and
1344
+ * reappear when you switch back. Unbound (tests/legacy) returns everything.
1345
+ */
1346
+ listRuns(): PersistedRunState[] {
1347
+ const all = this.persistence.list();
1348
+ return this.sessionId ? all.filter((r) => r.sessionId === this.sessionId) : all;
1349
+ }
1350
+
1351
+ /** All persisted runs regardless of session (used by cross-session recovery). */
1352
+ listAllRuns(): PersistedRunState[] {
1353
+ return this.persistence.list();
1354
+ }
1355
+
1356
+ /**
1357
+ * Get snapshot of a run.
1358
+ */
1359
+ getSnapshot(runId: string): WorkflowSnapshot | null {
1360
+ return this.runs.get(runId)?.snapshot ?? null;
1361
+ }
1362
+
1363
+ /**
1364
+ * Delete a persisted run.
1365
+ *
1366
+ * If `runId` is still live in this process (running or paused-in-memory),
1367
+ * abort its controller FIRST, before any teardown below — a live run left
1368
+ * un-aborted would otherwise keep executing in the background indefinitely
1369
+ * (burning API calls/tokens/holding a worktree) after its record is gone.
1370
+ * Aborting first, while `managed` is still `this.runs.get(runId)`, costs
1371
+ * nothing extra: the abort signal is fire-and-forget (cooperative — the
1372
+ * execution winds down on its own schedule), so the exact instant we flip
1373
+ * `this.runs`/release the lease/delete files relative to it doesn't matter
1374
+ * for correctness. What DOES matter is that once this method returns, the
1375
+ * aborted execution's eventual settle (executeRun's success/catch path,
1376
+ * asynchronously, possibly much later) must be a harmless no-op rather than
1377
+ * a resurrection — that's what isCurrent() guarantees: `this.runs.delete()`
1378
+ * below means executeRun's later persistRun()/releaseRunLease() calls on
1379
+ * this same `managed` object find `this.runs.get(runId) !== managed` (in
1380
+ * fact `undefined`, since the entry is gone) and skip writing/releasing.
1381
+ */
1382
+ deleteRun(runId: string): boolean {
1383
+ const managed = this.runs.get(runId);
1384
+ if (managed) {
1385
+ if (!managed.controller.signal.aborted) managed.controller.abort();
1386
+ this.releaseRunLease(managed);
1387
+ }
1388
+ this.runs.delete(runId);
1389
+ // Cancel any pending throttled write so a deferred persist can't fire after
1390
+ // deletion and resurrect the run's file on disk.
1391
+ const timer = this.persistTimers.get(runId);
1392
+ if (timer) {
1393
+ clearTimeout(timer);
1394
+ this.persistTimers.delete(runId);
1395
+ }
1396
+ return this.persistence.delete(runId);
1397
+ }
1398
+
1399
+ /**
1400
+ * Get the persistence layer (for saving workflows).
1401
+ */
1402
+ getPersistence(): RunPersistence {
1403
+ return this.persistence;
1404
+ }
1405
+ }