@backburner/cli 0.1.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 (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +329 -0
  3. package/dist/src/agents/antigravity-launch.js +162 -0
  4. package/dist/src/agents/antigravity.js +251 -0
  5. package/dist/src/agents/claude-launch.js +117 -0
  6. package/dist/src/agents/claude.js +271 -0
  7. package/dist/src/agents/codex-launch.js +82 -0
  8. package/dist/src/agents/codex.js +261 -0
  9. package/dist/src/agents/composite-runner.js +13 -0
  10. package/dist/src/agents/extraction.js +442 -0
  11. package/dist/src/agents/failure-feedback.js +132 -0
  12. package/dist/src/agents/gemini-launch.js +96 -0
  13. package/dist/src/agents/gemini.js +392 -0
  14. package/dist/src/agents/job-agent-assignments.js +180 -0
  15. package/dist/src/agents/lifecycle-feedback.js +147 -0
  16. package/dist/src/agents/limit-detector.js +83 -0
  17. package/dist/src/agents/opencode-launch.js +138 -0
  18. package/dist/src/agents/opencode.js +156 -0
  19. package/dist/src/agents/plan-breakdown-feedback.js +97 -0
  20. package/dist/src/agents/pool.js +168 -0
  21. package/dist/src/agents/prompt.js +462 -0
  22. package/dist/src/agents/provider-sessions.js +105 -0
  23. package/dist/src/agents/review-feedback.js +255 -0
  24. package/dist/src/agents/selection.js +113 -0
  25. package/dist/src/agents/service.js +1087 -0
  26. package/dist/src/agents/types.js +1 -0
  27. package/dist/src/agents/wave-assessment-feedback.js +69 -0
  28. package/dist/src/attention/derive.js +337 -0
  29. package/dist/src/capabilities/index.js +2 -0
  30. package/dist/src/capabilities/projection.js +183 -0
  31. package/dist/src/capabilities/types.js +1 -0
  32. package/dist/src/capabilities/validator.js +87 -0
  33. package/dist/src/cli/commands/broker-smoke.js +96 -0
  34. package/dist/src/cli/commands/broker.js +56 -0
  35. package/dist/src/cli/commands/init.js +4 -0
  36. package/dist/src/cli/commands/journal.js +43 -0
  37. package/dist/src/cli/commands/run-loop.js +56 -0
  38. package/dist/src/cli/commands/run.js +1241 -0
  39. package/dist/src/cli/commands/tui.js +187 -0
  40. package/dist/src/cli/dispatcher.js +51 -0
  41. package/dist/src/cli/errors.js +2 -0
  42. package/dist/src/cli/init.js +92 -0
  43. package/dist/src/cli/options.js +86 -0
  44. package/dist/src/cli/output.js +231 -0
  45. package/dist/src/cli/run-journal.js +342 -0
  46. package/dist/src/cli/run-types.js +1 -0
  47. package/dist/src/cli/run.js +32 -0
  48. package/dist/src/cli/runtime/dispatch-lifecycle.js +772 -0
  49. package/dist/src/cli/runtime/provider-runtime-types.js +1 -0
  50. package/dist/src/cli/runtime/provider-runtime.js +152 -0
  51. package/dist/src/cli/runtime/provider-tools.js +73 -0
  52. package/dist/src/cli/runtime/providers/antigravity.js +26 -0
  53. package/dist/src/cli/runtime/providers/claude.js +20 -0
  54. package/dist/src/cli/runtime/providers/codex.js +20 -0
  55. package/dist/src/cli/runtime/providers/gemini.js +20 -0
  56. package/dist/src/cli/runtime/providers/opencode.js +20 -0
  57. package/dist/src/cli/runtime/run-context.js +131 -0
  58. package/dist/src/cli/tui/index.js +3 -0
  59. package/dist/src/cli/tui/screen.js +388 -0
  60. package/dist/src/cli/tui/tracker.js +558 -0
  61. package/dist/src/cli/tui/types.js +1 -0
  62. package/dist/src/cli/tui/views.js +1006 -0
  63. package/dist/src/config/loader.js +136 -0
  64. package/dist/src/config/schemas.js +403 -0
  65. package/dist/src/config/types.js +1 -0
  66. package/dist/src/context/types.js +331 -0
  67. package/dist/src/domain/entities.js +1 -0
  68. package/dist/src/git/backburner-git-tool-service.js +680 -0
  69. package/dist/src/git/gitops-lifecycle.js +56 -0
  70. package/dist/src/git/merge-prep-workspace.js +357 -0
  71. package/dist/src/git/types.js +484 -0
  72. package/dist/src/github/broker-model-content.js +215 -0
  73. package/dist/src/github/broker-server.js +237 -0
  74. package/dist/src/github/broker.js +638 -0
  75. package/dist/src/github/composed.js +53 -0
  76. package/dist/src/github/gateway.js +478 -0
  77. package/dist/src/github/normalize.js +222 -0
  78. package/dist/src/github/pr-classification.js +73 -0
  79. package/dist/src/github/scoped-broker.js +35 -0
  80. package/dist/src/github/security.js +126 -0
  81. package/dist/src/github/service.js +332 -0
  82. package/dist/src/github/types.js +1 -0
  83. package/dist/src/github/utils.js +16 -0
  84. package/dist/src/journal/index.js +4 -0
  85. package/dist/src/journal/reader.js +62 -0
  86. package/dist/src/journal/renderer.js +50 -0
  87. package/dist/src/journal/service.js +72 -0
  88. package/dist/src/journal/types.js +1 -0
  89. package/dist/src/journal/writer.js +19 -0
  90. package/dist/src/mcps/bridge.js +166 -0
  91. package/dist/src/mcps/index.js +5 -0
  92. package/dist/src/mcps/reconcile.js +113 -0
  93. package/dist/src/mcps/smithery-client.js +186 -0
  94. package/dist/src/mcps/smithery.js +164 -0
  95. package/dist/src/mcps/types.js +1 -0
  96. package/dist/src/memory/json-store.js +71 -0
  97. package/dist/src/memory/prompt.js +16 -0
  98. package/dist/src/memory/scope.js +31 -0
  99. package/dist/src/memory/store.js +1 -0
  100. package/dist/src/memory/tool-service.js +142 -0
  101. package/dist/src/memory/types.js +12 -0
  102. package/dist/src/onboarding/config-writer.js +96 -0
  103. package/dist/src/onboarding/engine.js +86 -0
  104. package/dist/src/onboarding/prompt.js +102 -0
  105. package/dist/src/onboarding/services/provider-discovery.js +58 -0
  106. package/dist/src/onboarding/services/repo-discovery.js +246 -0
  107. package/dist/src/onboarding/steps/check-github-auth.js +32 -0
  108. package/dist/src/onboarding/steps/check-requirements.js +34 -0
  109. package/dist/src/onboarding/steps/configure-models.js +105 -0
  110. package/dist/src/onboarding/steps/configure-paths.js +33 -0
  111. package/dist/src/onboarding/steps/discover-providers.js +38 -0
  112. package/dist/src/onboarding/steps/discover-repos.js +39 -0
  113. package/dist/src/onboarding/steps/generate-config.js +106 -0
  114. package/dist/src/onboarding/steps/readiness-validation.js +158 -0
  115. package/dist/src/onboarding/steps/select-repos.js +76 -0
  116. package/dist/src/onboarding/steps/show-summary.js +97 -0
  117. package/dist/src/onboarding/steps/welcome.js +27 -0
  118. package/dist/src/onboarding/types.js +1 -0
  119. package/dist/src/onboarding/validators/git-access.js +63 -0
  120. package/dist/src/onboarding/validators/github-auth.js +85 -0
  121. package/dist/src/onboarding/validators/tool-checker.js +153 -0
  122. package/dist/src/onboarding/validators/workspace.js +83 -0
  123. package/dist/src/prompts/resolver.js +106 -0
  124. package/dist/src/retrospectives/candidates.js +224 -0
  125. package/dist/src/retrospectives/derive.js +169 -0
  126. package/dist/src/retrospectives/ingest.js +321 -0
  127. package/dist/src/retrospectives/management-prs.js +380 -0
  128. package/dist/src/retrospectives/proposals.js +199 -0
  129. package/dist/src/retrospectives/scopes.js +272 -0
  130. package/dist/src/retrospectives/scoring.js +171 -0
  131. package/dist/src/retrospectives/types.js +1 -0
  132. package/dist/src/state/loader.js +473 -0
  133. package/dist/src/state/types.js +15 -0
  134. package/dist/src/tasks/derivation/builders.js +70 -0
  135. package/dist/src/tasks/derivation/handlers/comment-response.js +169 -0
  136. package/dist/src/tasks/derivation/handlers/implement-plan.js +103 -0
  137. package/dist/src/tasks/derivation/handlers/index.js +54 -0
  138. package/dist/src/tasks/derivation/handlers/packet-pr-control.js +92 -0
  139. package/dist/src/tasks/derivation/handlers/packet-worktree.js +29 -0
  140. package/dist/src/tasks/derivation/handlers/plan-breakdown.js +45 -0
  141. package/dist/src/tasks/derivation/handlers/prepare-for-merge.js +91 -0
  142. package/dist/src/tasks/derivation/handlers/product-discovery.js +36 -0
  143. package/dist/src/tasks/derivation/handlers/retry-failed-task.js +125 -0
  144. package/dist/src/tasks/derivation/handlers/review-pr.js +28 -0
  145. package/dist/src/tasks/derivation/handlers/shared.js +24 -0
  146. package/dist/src/tasks/derivation/handlers/sync-parent-branch.js +100 -0
  147. package/dist/src/tasks/derivation/handlers/wave-assessment.js +223 -0
  148. package/dist/src/tasks/derivation/handlers/write-plan.js +101 -0
  149. package/dist/src/tasks/derivation/handlers/write-product-spec.js +49 -0
  150. package/dist/src/tasks/derivation/runner.js +18 -0
  151. package/dist/src/tasks/derivation/types.js +1 -0
  152. package/dist/src/tasks/derive.js +810 -0
  153. package/dist/src/tasks/mcp-skip.js +10 -0
  154. package/dist/src/tasks/retry.js +10 -0
  155. package/dist/src/tasks/task.js +184 -0
  156. package/dist/src/tasks/types.js +1 -0
  157. package/dist/src/utils/command-runner.js +192 -0
  158. package/dist/src/utils/json.js +38 -0
  159. package/dist/src/utils/logger.js +43 -0
  160. package/dist/src/utils/paths.js +38 -0
  161. package/dist/src/utils/slug.js +10 -0
  162. package/dist/src/workflows/registry.js +174 -0
  163. package/dist/src/workflows/triage.js +90 -0
  164. package/dist/src/workflows/workstream-machine/derive-events.js +121 -0
  165. package/dist/src/workflows/workstream-machine/evaluate.js +24 -0
  166. package/dist/src/workflows/workstream-machine/events.js +1 -0
  167. package/dist/src/workflows/workstream-machine/machine.js +355 -0
  168. package/dist/src/workflows/workstream-machine/selectors.js +19 -0
  169. package/dist/src/workflows/workstream-machine/types.js +1 -0
  170. package/dist/src/workstreams/branch-sync.js +87 -0
  171. package/dist/src/workstreams/derive.js +629 -0
  172. package/dist/src/workstreams/mcp-blockers.js +420 -0
  173. package/dist/src/workstreams/packet-candidates.js +42 -0
  174. package/dist/src/workstreams/packet-lifecycle-feedback.js +60 -0
  175. package/dist/src/workstreams/packet-lifecycle-report.js +199 -0
  176. package/dist/src/workstreams/packet-plan.js +597 -0
  177. package/dist/src/workstreams/packet-projections.js +190 -0
  178. package/dist/src/workstreams/parent-branch-sync-action.js +312 -0
  179. package/dist/src/workstreams/parent-branch-sync.js +148 -0
  180. package/dist/src/workstreams/phases.js +4 -0
  181. package/dist/src/workstreams/plan-breakdown-generator/export.js +19 -0
  182. package/dist/src/workstreams/plan-breakdown-generator/index.js +6 -0
  183. package/dist/src/workstreams/plan-breakdown-generator/inspect.js +50 -0
  184. package/dist/src/workstreams/plan-breakdown-generator/patch.js +139 -0
  185. package/dist/src/workstreams/plan-breakdown-generator/session.js +19 -0
  186. package/dist/src/workstreams/plan-breakdown-generator/store.js +27 -0
  187. package/dist/src/workstreams/plan-breakdown-generator/tools.js +407 -0
  188. package/dist/src/workstreams/plan-breakdown-generator/types.js +6 -0
  189. package/dist/src/workstreams/plan-breakdown-generator/validate.js +212 -0
  190. package/dist/src/workstreams/plan-breakdown-schema.js +175 -0
  191. package/dist/src/workstreams/utils.js +15 -0
  192. package/dist/src/workstreams/wave-assessment/checkpoint.js +172 -0
  193. package/dist/src/workstreams/wave-assessment/index.js +3 -0
  194. package/dist/src/workstreams/wave-assessment/session.js +84 -0
  195. package/dist/src/workstreams/wave-assessment/store.js +51 -0
  196. package/dist/src/workstreams/wave-assessment/tools.js +419 -0
  197. package/dist/src/workstreams/wave-assessment/types.js +1 -0
  198. package/dist/src/workstreams/wave-assessment/validate.js +78 -0
  199. package/dist/src/worktrees/service.js +839 -0
  200. package/dist/src/worktrees/workspace-ownership.js +34 -0
  201. package/dist/src/worktrees/workspace-recovery.js +177 -0
  202. package/package.json +61 -0
@@ -0,0 +1,1087 @@
1
+ import { buildAgentContext } from "../context/types.js";
2
+ import { applySkippedCapabilityProjection, deriveProjectedCapabilityToolNames } from "../capabilities/projection.js";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { roleForTask, NoProviderAvailableError } from "./selection.js";
6
+ import { resolveJobAgentAssignment, recordJobAgentAssignmentExecution } from "./job-agent-assignments.js";
7
+ import { getJobMemoryBankId, resolveJobMemoryScope } from "../memory/scope.js";
8
+ import { buildMemoryPrelude } from "../memory/prompt.js";
9
+ import { managementRoot } from "../utils/paths.js";
10
+ import { resolveTaskPromptContent, loadLearningOverlays } from "../prompts/resolver.js";
11
+ import { workflowRegistry } from "../workflows/registry.js";
12
+ import { resolveProviderSessionScope, getProviderSessionRecordId, evaluateProviderSessionDecision } from "./provider-sessions.js";
13
+ import { Mutex, deriveExecutionLockKeys } from "./pool.js";
14
+ import { buildPacketPlanFromPlanBreakdown } from "../workstreams/packet-plan.js";
15
+ import { buildWaveAssessmentCheckpoint } from "../workstreams/wave-assessment/checkpoint.js";
16
+ import { mapSignalToRecord } from "./limit-detector.js";
17
+ /**
18
+ * Memory tool names that are not safe to expose in concurrent mode because
19
+ * JobMemoryToolService.bind() is global mutable state and is skipped when
20
+ * isConcurrent is true. Calls would return memory_scope_unbound.
21
+ */
22
+ const CONCURRENT_UNSAFE_MEMORY_TOOLS = new Set(["memory_search", "memory_add"]);
23
+ /**
24
+ * Maximum number of memory entries to include in the prompt prelude.
25
+ * This limit balances providing sufficient historical context with prompt token budget
26
+ * and ensuring the most relevant recent memories are prioritized.
27
+ */
28
+ const MEMORY_PRELUDE_LIMIT = 10;
29
+ function getSkippedCapabilityNames(task) {
30
+ return new Set((task.context.mcpSkipContexts ?? []).flatMap((context) => context.affectedCapabilityNames));
31
+ }
32
+ function applyTaskCapabilitySkips(capabilities, task) {
33
+ if (capabilities === undefined) {
34
+ return undefined;
35
+ }
36
+ const skippedCapabilityNames = getSkippedCapabilityNames(task);
37
+ return applySkippedCapabilityProjection(capabilities, skippedCapabilityNames);
38
+ }
39
+ function applyTaskBrokerToolSkips(providerBrokerToolAllowList, baseCapabilities, taskCapabilities) {
40
+ if (baseCapabilities === undefined || taskCapabilities === undefined) {
41
+ return providerBrokerToolAllowList;
42
+ }
43
+ const baseTools = new Set(deriveProjectedCapabilityToolNames(baseCapabilities));
44
+ const taskTools = new Set(deriveProjectedCapabilityToolNames(taskCapabilities));
45
+ const skippedTools = new Set([...baseTools].filter((toolName) => !taskTools.has(toolName)));
46
+ if (skippedTools.size === 0) {
47
+ return providerBrokerToolAllowList;
48
+ }
49
+ return providerBrokerToolAllowList.filter((toolName) => !skippedTools.has(toolName));
50
+ }
51
+ function eligibleTaskRecordsHaveSkips(tasks) {
52
+ return tasks.some((task) => (task.context.mcpSkipContexts ?? []).length > 0);
53
+ }
54
+ /**
55
+ * Compute whether a single batch-level broker allow-list can safely serve
56
+ * all concurrent tasks, and what that list should be.
57
+ *
58
+ * Returns canBind=true + the common safe list when every provider whose agents
59
+ * could run a task in this batch shares an identical safe allow-list (with
60
+ * memory tools removed). Returns canBind=false when providers differ —
61
+ * the caller must fall back to sequential dispatch.
62
+ *
63
+ * Only considers providers represented by agents that are eligible to handle
64
+ * at least one task in eligibleTasks (matched by role and modelClass). Providers
65
+ * that are enabled but not selected for any task in this batch are excluded so
66
+ * they cannot unnecessarily downgrade an otherwise-safe concurrent batch.
67
+ */
68
+ function computeConcurrentBrokerBinding(eligibleTasks, agents, providerBrokerToolAllowLists) {
69
+ const participatingProviders = new Set();
70
+ for (const task of eligibleTasks) {
71
+ try {
72
+ const definition = workflowRegistry.getDefinition(task.kind);
73
+ const requiredRole = roleForTask(task);
74
+ for (const agent of agents) {
75
+ if (agent.enabled &&
76
+ agent.roles.includes(requiredRole) &&
77
+ agent.modelClass === definition.modelClass) {
78
+ participatingProviders.add(agent.provider);
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // unknown task kind — skip; doesn't affect safety
84
+ }
85
+ }
86
+ const safeLists = [];
87
+ for (const [provider, tools] of providerBrokerToolAllowLists) {
88
+ if (!participatingProviders.has(provider))
89
+ continue;
90
+ safeLists.push([...tools].filter(t => !CONCURRENT_UNSAFE_MEMORY_TOOLS.has(t)).sort());
91
+ }
92
+ if (safeLists.length === 0) {
93
+ return { canBind: true, safeList: [] };
94
+ }
95
+ const reference = safeLists[0];
96
+ const allMatch = safeLists.every(list => list.length === reference.length &&
97
+ list.every((t, i) => t === reference[i]));
98
+ return allMatch ? { canBind: true, safeList: reference } : { canBind: false };
99
+ }
100
+ export async function dispatchPendingTasks(input) {
101
+ const now = input.now ?? (() => new Date().toISOString());
102
+ const tasks = [...input.tasks];
103
+ const executions = [...input.existingExecutions];
104
+ const pool = input.agentPool;
105
+ // isConcurrent may be downgraded to false below if broker allow-lists differ.
106
+ let isConcurrent = pool !== undefined && pool.isConcurrent;
107
+ const stateMutex = pool !== undefined ? pool.stateMutex : new Mutex();
108
+ const eligibleEntries = [];
109
+ for (let i = 0; i < tasks.length; i += 1) {
110
+ const t = tasks[i];
111
+ if (t &&
112
+ t.status === "pending" &&
113
+ t.relevant !== false &&
114
+ t.kind !== "sync_parent_branch" &&
115
+ !input.mcpBlockedTaskIds?.has(t.id)) {
116
+ eligibleEntries.push({ index: i });
117
+ }
118
+ }
119
+ eligibleEntries.sort((left, right) => compareEligibleDispatchEntries(tasks, left.index, right.index));
120
+ const dispatchOneTask = async (index) => {
121
+ // Track whether sequential-mode memory binding has been established so we
122
+ // can guarantee unbind in the outer finally regardless of where failure occurs.
123
+ let memoryBoundForTask = false;
124
+ try {
125
+ // ── PRE-DISPATCH (serialized) ────────────────────────────────────────
126
+ // Resolve agent, build memory prelude, make session decision, mark task
127
+ // dispatched. All state reads and writes are inside stateMutex so
128
+ // concurrent tasks don't overwrite each other's updates to the shared
129
+ // tasks/executions arrays.
130
+ //
131
+ // Session decision is also made inside this mutex so that two tasks
132
+ // sharing the same provider session scope can never concurrently evaluate
133
+ // and resume the same headless session (fix #3).
134
+ const preDispatch = await stateMutex.runExclusive(async () => {
135
+ const state = await input.stateStore.load();
136
+ const currentAssignments = state.jobAgentAssignments ?? [];
137
+ let assignmentResolution;
138
+ try {
139
+ assignmentResolution = resolveJobAgentAssignment({
140
+ task: tasks[index],
141
+ agents: input.agents,
142
+ existingAssignments: currentAssignments,
143
+ providerLimits: state.providerLimits ?? [],
144
+ ...(input.providerBrokerToolAllowLists ? { providerBrokerToolAllowLists: input.providerBrokerToolAllowLists } : {}),
145
+ ...(input.capabilitiesByProvider ? { capabilitiesByProvider: input.capabilitiesByProvider } : {}),
146
+ now: now()
147
+ });
148
+ }
149
+ catch (err) {
150
+ if (err instanceof NoProviderAvailableError) {
151
+ const providers = err.limitedProviders;
152
+ const provider = providers[0];
153
+ const limit = (state.providerLimits ?? []).find((l) => l.provider === provider);
154
+ const resetTime = limit?.resetTime;
155
+ const task = tasks[index];
156
+ if (!task.context.notifiedQueued) {
157
+ await safelyNotifyQueued(input.lifecycleNotifier, task, providers, resetTime, input.logger);
158
+ }
159
+ const updatedTask = {
160
+ ...task,
161
+ status: "queued",
162
+ waitingOnProvider: provider,
163
+ updatedAt: now(),
164
+ context: {
165
+ ...task.context,
166
+ notifiedQueued: true,
167
+ providerLimitQueuedAt: task.context.providerLimitQueuedAt ?? now(),
168
+ providerLimitResumedProvider: undefined
169
+ }
170
+ };
171
+ tasks[index] = updatedTask;
172
+ await input.stateStore.writeTaskState(tasks);
173
+ console.log(`🤖 Task ${updatedTask.id} has been queued (waiting on providers ${providers.join(", ")} due to rate limit).`);
174
+ input.logger?.info(`[dispatch] Task ${updatedTask.id} queued, waiting on providers ${providers.join(", ")}`);
175
+ return { queued: true, providers };
176
+ }
177
+ throw err;
178
+ }
179
+ const agent = assignmentResolution.configuredAgent;
180
+ if (assignmentResolution.initialized) {
181
+ await input.stateStore.writeJobAgentAssignmentState(assignmentResolution.assignments);
182
+ if (assignmentResolution.mode === "pinned") {
183
+ const oldAssignment = currentAssignments.find((a) => a.repoKey === tasks[index].repoSlug &&
184
+ a.workstreamId === tasks[index].workstreamId &&
185
+ a.role === roleForTask(tasks[index]) &&
186
+ a.status === "active" &&
187
+ a.id !== assignmentResolution.assignment.id);
188
+ if (oldAssignment) {
189
+ console.log(`🤖 Pinned agent for task ${tasks[index].id} swapped from ${oldAssignment.configuredAgentId} (${oldAssignment.provider}) to ${agent.id} (${agent.provider}).`);
190
+ }
191
+ }
192
+ }
193
+ let memoryBackground;
194
+ if (input.jobMemoryRuntime && tasks[index].workstreamId) {
195
+ try {
196
+ const scope = resolveJobMemoryScope(tasks[index], agent);
197
+ const bankId = getJobMemoryBankId(scope);
198
+ if (!isConcurrent) {
199
+ input.jobMemoryRuntime.toolService.bind(scope, bankId, tasks[index].id);
200
+ memoryBoundForTask = true;
201
+ }
202
+ const entries = await input.jobMemoryRuntime.store.search(bankId, { limit: MEMORY_PRELUDE_LIMIT });
203
+ memoryBackground = buildMemoryPrelude(entries);
204
+ }
205
+ catch (err) {
206
+ input.logger?.warn(`[job-memory] Failed to bind memory scope for task ${tasks[index].id}, dispatching without memory: ${err}`);
207
+ }
208
+ }
209
+ let sessionDecision = undefined;
210
+ let sessionScope = undefined;
211
+ let sessionRecordId = undefined;
212
+ if (tasks[index].workstreamId) {
213
+ try {
214
+ sessionScope = resolveProviderSessionScope(tasks[index], agent);
215
+ sessionRecordId = getProviderSessionRecordId(sessionScope);
216
+ const existingRecord = state.providerSessions?.find(r => r.id === sessionRecordId);
217
+ const capability = (agent.provider === "codex" || agent.provider === "gemini" || agent.provider === "claude" || agent.provider === "antigravity")
218
+ ? "verified_headless_resume"
219
+ : "unsupported";
220
+ const nativeResumeEnabled = tasks[index].kind !== "prepare_pr_for_merge";
221
+ sessionDecision = evaluateProviderSessionDecision({
222
+ capability,
223
+ requestedScope: sessionScope,
224
+ existingRecord,
225
+ nativeResumeEnabled,
226
+ taskKind: tasks[index].kind
227
+ });
228
+ }
229
+ catch (err) {
230
+ input.logger?.warn(`[provider-sessions] Failed to evaluate session decision for task ${tasks[index].id}: ${err}`);
231
+ }
232
+ }
233
+ const preparingTask = {
234
+ ...tasks[index],
235
+ status: "dispatched",
236
+ updatedAt: now()
237
+ };
238
+ tasks[index] = preparingTask;
239
+ await input.stateStore.writeTaskState(tasks);
240
+ const isRetry = input.existingExecutions.some(e => e.taskId === preparingTask.id);
241
+ await safelyNotifyDispatch(input.lifecycleNotifier, preparingTask, isRetry, input.logger);
242
+ return {
243
+ queued: false,
244
+ agent,
245
+ assignmentResolution,
246
+ memoryBackground,
247
+ preparingTask,
248
+ sessionDecision,
249
+ sessionScope,
250
+ sessionRecordId
251
+ };
252
+ });
253
+ if (preDispatch.queued) {
254
+ return;
255
+ }
256
+ const { agent, assignmentResolution, memoryBackground, preparingTask, sessionDecision, sessionScope, sessionRecordId } = preDispatch;
257
+ // ── PROMPT RESOLUTION (parallel-safe IO, no state mutation) ─────────
258
+ const promptContent = await resolveTaskPromptContent(preparingTask, input.promptsConfig, input.managementRoot ?? managementRoot, input.logger);
259
+ let taskRole;
260
+ try {
261
+ workflowRegistry.getDefinition(preparingTask.kind);
262
+ taskRole = roleForTask(preparingTask);
263
+ }
264
+ catch {
265
+ // task.kind may not be registered (e.g., future kind); proceed without role scope
266
+ }
267
+ const overlayCtx = {
268
+ repoId: preparingTask.repoId || undefined,
269
+ provider: agent.provider,
270
+ role: taskRole,
271
+ taskKind: preparingTask.kind
272
+ };
273
+ const loadedOverlays = await loadLearningOverlays(overlayCtx, input.managementRoot ?? managementRoot, input.logger);
274
+ const promptContentWithOverlays = loadedOverlays.length > 0
275
+ ? { ...promptContent, learningOverlays: loadedOverlays }
276
+ : promptContent;
277
+ // ── AGENT EXECUTION (parallel) ───────────────────────────────────────
278
+ const baseCapabilities = input.capabilitiesByProvider?.get(agent.provider);
279
+ const taskCapabilities = applyTaskCapabilitySkips(baseCapabilities, preparingTask);
280
+ const agentContext = buildAgentContext(preparingTask, memoryBackground, taskCapabilities);
281
+ const executionCwdOverride = input.executionCwdByTaskId?.get(preparingTask.id);
282
+ const contextForDispatch = executionCwdOverride
283
+ ? { ...agentContext, localRepoPath: executionCwdOverride }
284
+ : agentContext;
285
+ // In sequential mode only: bind the broker tool scope for this task so
286
+ // the ScopedBrokerToolService enforces the per-provider allowlist.
287
+ // In concurrent mode: the scope was already set for the whole batch at
288
+ // batch start (without memory tools) and will be cleared at batch end.
289
+ if (!isConcurrent && input.brokerToolScope) {
290
+ const providerBrokerToolAllowList = input.providerBrokerToolAllowLists?.get(agent.provider);
291
+ if (providerBrokerToolAllowList) {
292
+ input.brokerToolScope.bindAllowedTools(applyTaskBrokerToolSkips(providerBrokerToolAllowList, baseCapabilities, taskCapabilities));
293
+ }
294
+ }
295
+ const result = await (async () => {
296
+ try {
297
+ return await input.agentRunner.dispatch(preparingTask, contextForDispatch, agent, sessionDecision, promptContentWithOverlays);
298
+ }
299
+ finally {
300
+ // In sequential mode, clear the broker scope after each task.
301
+ // Memory unbind is handled in the outer finally to cover pre-dispatch throws.
302
+ if (!isConcurrent) {
303
+ input.brokerToolScope?.clearAllowedTools();
304
+ }
305
+ }
306
+ })();
307
+ // ── POST-DISPATCH (serialized) ───────────────────────────────────────
308
+ await stateMutex.runExclusive(async () => {
309
+ executions.push(result.execution);
310
+ let providerLimitQueueProviders;
311
+ let providerLimitResetTime;
312
+ let swappedAfterProviderLimit = false;
313
+ if (result.providerLimitSignal) {
314
+ try {
315
+ const limitState = await input.stateStore.load();
316
+ const providerLimits = [...(limitState.providerLimits ?? [])];
317
+ const signal = result.providerLimitSignal;
318
+ const existingIdx = providerLimits.findIndex(l => l.provider === signal.provider);
319
+ const newRecord = mapSignalToRecord(signal, now());
320
+ if (existingIdx !== -1) {
321
+ providerLimits[existingIdx] = newRecord;
322
+ }
323
+ else {
324
+ providerLimits.push(newRecord);
325
+ }
326
+ await input.stateStore.writeProviderLimitState(providerLimits);
327
+ console.log(`🤖 Provider ${signal.provider} limits exceeded: status set to limited. Reset time: ${signal.resetTime}`);
328
+ input.logger?.info(`[dispatch] Provider ${signal.provider} limited until ${signal.resetTime}`);
329
+ try {
330
+ const swapResolution = resolveJobAgentAssignment({
331
+ task: preparingTask,
332
+ agents: input.agents,
333
+ existingAssignments: limitState.jobAgentAssignments ?? [],
334
+ providerLimits,
335
+ replacementForProvider: signal.provider,
336
+ ...(input.providerBrokerToolAllowLists ? { providerBrokerToolAllowLists: input.providerBrokerToolAllowLists } : {}),
337
+ ...(input.capabilitiesByProvider ? { capabilitiesByProvider: input.capabilitiesByProvider } : {}),
338
+ now: now()
339
+ });
340
+ if (swapResolution.configuredAgent.provider !== signal.provider) {
341
+ if (swapResolution.initialized) {
342
+ await input.stateStore.writeJobAgentAssignmentState(swapResolution.assignments);
343
+ }
344
+ swappedAfterProviderLimit = true;
345
+ input.logger?.info(`[dispatch] Task ${preparingTask.id} reassigned from limited provider ${signal.provider} to ${swapResolution.configuredAgent.provider}.`);
346
+ console.log(`🤖 Task ${preparingTask.id} reassigned from limited provider ${signal.provider} to ${swapResolution.configuredAgent.provider}.`);
347
+ }
348
+ else {
349
+ providerLimitQueueProviders = signal.provider;
350
+ providerLimitResetTime = signal.resetTime;
351
+ }
352
+ }
353
+ catch (err) {
354
+ if (err instanceof NoProviderAvailableError) {
355
+ providerLimitQueueProviders = err.limitedProviders.length > 0 ? err.limitedProviders : signal.provider;
356
+ providerLimitResetTime = signal.resetTime;
357
+ }
358
+ else {
359
+ throw err;
360
+ }
361
+ }
362
+ }
363
+ catch (err) {
364
+ input.logger?.warn(`[dispatch] Failed to persist provider limit updates: ${err}`);
365
+ providerLimitQueueProviders = result.providerLimitSignal.provider;
366
+ providerLimitResetTime = result.providerLimitSignal.resetTime;
367
+ }
368
+ }
369
+ const isProviderLimit = !!result.providerLimitSignal;
370
+ const shouldQueueForProviderLimit = isProviderLimit && !swappedAfterProviderLimit;
371
+ const finalStatus = shouldQueueForProviderLimit ? "queued" : swappedAfterProviderLimit ? "pending" : result.taskStatus;
372
+ if (shouldQueueForProviderLimit && !preparingTask.context.notifiedQueued) {
373
+ await safelyNotifyQueued(input.lifecycleNotifier, preparingTask, providerLimitQueueProviders ?? result.providerLimitSignal.provider, providerLimitResetTime, input.logger);
374
+ }
375
+ tasks[index] = {
376
+ ...preparingTask,
377
+ status: finalStatus,
378
+ lastExecutionId: result.execution.id,
379
+ updatedAt: result.execution.endedAt,
380
+ context: {
381
+ ...preparingTask.context,
382
+ ...(finalStatus === "failed" ? { recoveryNeeded: true } : {}),
383
+ ...(shouldQueueForProviderLimit
384
+ ? {
385
+ notifiedQueued: true,
386
+ providerLimitQueuedAt: preparingTask.context.providerLimitQueuedAt ?? result.execution.endedAt,
387
+ providerLimitResumedProvider: undefined
388
+ }
389
+ : {}),
390
+ ...(swappedAfterProviderLimit
391
+ ? {
392
+ notifiedQueued: false,
393
+ providerLimitQueuedAt: undefined,
394
+ providerLimitResumedProvider: undefined
395
+ }
396
+ : {})
397
+ },
398
+ ...(shouldQueueForProviderLimit ? { waitingOnProvider: result.providerLimitSignal.provider } : { waitingOnProvider: undefined })
399
+ };
400
+ await input.stateStore.writeExecutionState(executions);
401
+ await input.stateStore.writeTaskState(tasks);
402
+ if (shouldQueueForProviderLimit) {
403
+ console.log(`🤖 Task ${preparingTask.id} has been queued (waiting on provider ${result.providerLimitSignal.provider} due to rate limit).`);
404
+ }
405
+ if (assignmentResolution.mode === "pinned") {
406
+ try {
407
+ const assignmentState = await input.stateStore.load();
408
+ const updatedAssignments = recordJobAgentAssignmentExecution({
409
+ assignments: assignmentState.jobAgentAssignments ?? [],
410
+ assignmentId: assignmentResolution.assignment.id,
411
+ taskId: preparingTask.id,
412
+ executionId: result.execution.id,
413
+ startedAt: result.execution.startedAt,
414
+ endedAt: result.execution.endedAt,
415
+ taskStatus: result.taskStatus
416
+ });
417
+ await input.stateStore.writeJobAgentAssignmentState(updatedAssignments);
418
+ }
419
+ catch (err) {
420
+ input.logger?.warn(`[job-agent-assignments] Failed to update assignment metadata for task ${preparingTask.id}: ${err}`);
421
+ }
422
+ }
423
+ if (sessionScope && sessionRecordId) {
424
+ try {
425
+ const sessionState = await input.stateStore.load();
426
+ const providerSessions = [...(sessionState.providerSessions ?? [])];
427
+ const recordIdx = providerSessions.findIndex(r => r.id === sessionRecordId);
428
+ const capability = (agent.provider === "codex" || agent.provider === "gemini" || agent.provider === "claude" || agent.provider === "antigravity")
429
+ ? "verified_headless_resume"
430
+ : "unsupported";
431
+ const nowStr = now();
432
+ const hasSessionId = !!result.providerSession?.providerSessionId;
433
+ if (hasSessionId) {
434
+ const existing = recordIdx !== -1 ? providerSessions[recordIdx] : undefined;
435
+ const isFailed = result.taskStatus === "failed";
436
+ const isRetryable = isFailed ? isRetryableProviderSessionFailure(result.execution) : true;
437
+ const recordStatus = isRetryable ? "active" : "stale";
438
+ const sessionName = result.providerSession.providerSessionName ?? existing?.providerSessionName;
439
+ const updatedRecord = {
440
+ id: sessionRecordId,
441
+ scope: sessionScope,
442
+ capability,
443
+ status: recordStatus,
444
+ providerSessionId: result.providerSession.providerSessionId,
445
+ createdAt: existing?.createdAt ?? nowStr,
446
+ updatedAt: nowStr,
447
+ lastStartedAt: existing?.lastStartedAt ?? (sessionDecision?.mode !== "native_resume_candidate" ? nowStr : existing?.lastStartedAt),
448
+ lastResumedAt: sessionDecision?.mode === "native_resume_candidate" ? nowStr : existing?.lastResumedAt,
449
+ ...(sessionName ? { providerSessionName: sessionName } : {}),
450
+ ...(isFailed ? { lastFailedAt: nowStr } : existing?.lastFailedAt ? { lastFailedAt: existing.lastFailedAt } : {}),
451
+ notes: result.providerSession.status === "stale" ? "Marked stale after completion" : undefined
452
+ };
453
+ if (recordIdx !== -1) {
454
+ providerSessions[recordIdx] = updatedRecord;
455
+ }
456
+ else {
457
+ providerSessions.push(updatedRecord);
458
+ }
459
+ await input.stateStore.writeProviderSessionState(providerSessions);
460
+ }
461
+ else if (result.taskStatus === "failed") {
462
+ const existing = recordIdx !== -1 ? providerSessions[recordIdx] : undefined;
463
+ if (existing && existing.status === "active") {
464
+ const isRetryable = isRetryableProviderSessionFailure(result.execution);
465
+ if (isRetryable) {
466
+ existing.lastFailedAt = nowStr;
467
+ existing.updatedAt = nowStr;
468
+ }
469
+ else {
470
+ existing.status = "stale";
471
+ existing.lastFailedAt = nowStr;
472
+ existing.updatedAt = nowStr;
473
+ }
474
+ await input.stateStore.writeProviderSessionState(providerSessions);
475
+ }
476
+ }
477
+ }
478
+ catch (err) {
479
+ input.logger?.warn(`[provider-sessions] Failed to persist session updates for task ${preparingTask.id}: ${err}`);
480
+ }
481
+ }
482
+ if (result.taskStatus === "completed" && preparingTask.kind === "review_pr") {
483
+ await safelyPublishReview(input.reviewPublisher, preparingTask, result.execution);
484
+ }
485
+ if (result.taskStatus === "completed" && preparingTask.kind === "plan_breakdown") {
486
+ await safelyPublishBreakdown(input.planBreakdownPublisher, preparingTask, result.execution);
487
+ }
488
+ if (result.taskStatus === "completed" && preparingTask.kind === "wave_plan_assessment") {
489
+ const commentId = await safelyPublishWaveAssessment(input.waveAssessmentPublisher, preparingTask, result.execution);
490
+ if (commentId && result.execution.result?.waveAssessment) {
491
+ const updatedAssessment = {
492
+ ...result.execution.result.waveAssessment,
493
+ postedCommentId: commentId,
494
+ updatedAt: now()
495
+ };
496
+ const updatedExecution = {
497
+ ...result.execution,
498
+ result: {
499
+ ...result.execution.result,
500
+ waveAssessment: updatedAssessment
501
+ }
502
+ };
503
+ const executionIndex = executions.findIndex((execution) => execution.id === updatedExecution.id);
504
+ if (executionIndex !== -1) {
505
+ executions[executionIndex] = updatedExecution;
506
+ tasks[index] = {
507
+ ...tasks[index],
508
+ lastExecutionId: updatedExecution.id
509
+ };
510
+ await input.stateStore.writeExecutionState(executions);
511
+ await persistWaveAssessmentToWorkstream(input.stateStore, updatedAssessment);
512
+ }
513
+ }
514
+ else if (result.execution.result?.waveAssessment) {
515
+ await markCompletedTaskPostDispatchFailure({
516
+ input,
517
+ tasks,
518
+ executions,
519
+ index,
520
+ reason: "Wave assessment completed locally, but publishing the required parent PR assessment comment failed.",
521
+ now
522
+ });
523
+ await safelyNotifyCompletion(input.lifecycleNotifier, preparingTask, "failed", input.logger);
524
+ return;
525
+ }
526
+ }
527
+ if (result.taskStatus === "completed" && preparingTask.kind === "wave_plan_replanning") {
528
+ const planBreakdown = result.execution.result?.planBreakdown;
529
+ if (!planBreakdown) {
530
+ await markCompletedTaskPostDispatchFailure({
531
+ input,
532
+ tasks,
533
+ executions,
534
+ index,
535
+ reason: "Wave replanning completed locally, but it did not return a validated packet plan result.",
536
+ now
537
+ });
538
+ await safelyNotifyCompletion(input.lifecycleNotifier, preparingTask, "failed", input.logger);
539
+ return;
540
+ }
541
+ const replanningCompletedAt = result.execution.endedAt;
542
+ const replanningAssessment = buildReplanningCompletionAssessment(preparingTask, result.execution, planBreakdown, replanningCompletedAt);
543
+ if (replanningAssessment) {
544
+ const executionWithAssessment = {
545
+ ...result.execution,
546
+ result: {
547
+ ...result.execution.result,
548
+ waveAssessment: replanningAssessment
549
+ }
550
+ };
551
+ const commentId = await safelyPublishWaveAssessment(input.waveAssessmentPublisher, preparingTask, executionWithAssessment);
552
+ if (commentId) {
553
+ const updatedAssessment = {
554
+ ...replanningAssessment,
555
+ postedCommentId: commentId,
556
+ updatedAt: now()
557
+ };
558
+ const updatedExecution = {
559
+ ...executionWithAssessment,
560
+ result: {
561
+ ...executionWithAssessment.result,
562
+ waveAssessment: updatedAssessment
563
+ }
564
+ };
565
+ const executionIndex = executions.findIndex((execution) => execution.id === updatedExecution.id);
566
+ if (executionIndex !== -1) {
567
+ executions[executionIndex] = updatedExecution;
568
+ tasks[index] = {
569
+ ...tasks[index],
570
+ lastExecutionId: updatedExecution.id
571
+ };
572
+ await input.stateStore.writeExecutionState(executions);
573
+ await persistReplannedPacketPlanToWorkstream(input.stateStore, updatedAssessment, planBreakdown, replanningCompletedAt);
574
+ }
575
+ }
576
+ else {
577
+ await markCompletedTaskPostDispatchFailure({
578
+ input,
579
+ tasks,
580
+ executions,
581
+ index,
582
+ reason: "Wave replanning completed locally, but publishing the required parent PR assessment comment failed.",
583
+ now
584
+ });
585
+ await safelyNotifyCompletion(input.lifecycleNotifier, preparingTask, "failed", input.logger);
586
+ return;
587
+ }
588
+ }
589
+ else {
590
+ await markCompletedTaskPostDispatchFailure({
591
+ input,
592
+ tasks,
593
+ executions,
594
+ index,
595
+ reason: "Wave replanning completed locally, but the task was missing the assessment checkpoint required for handoff.",
596
+ now
597
+ });
598
+ await safelyNotifyCompletion(input.lifecycleNotifier, preparingTask, "failed", input.logger);
599
+ return;
600
+ }
601
+ }
602
+ if (result.taskStatus === "failed" && !isProviderLimit && input.deferFailureNotifications !== true) {
603
+ const failureCommentId = await safelyNotifyFailure(input.failureNotifier, tasks[index], result.execution);
604
+ if (failureCommentId !== undefined) {
605
+ tasks[index] = { ...tasks[index], lastFailureCommentId: failureCommentId };
606
+ await input.stateStore.writeTaskState(tasks);
607
+ }
608
+ }
609
+ if (!isProviderLimit) {
610
+ await safelyNotifyCompletion(input.lifecycleNotifier, preparingTask, result.taskStatus, input.logger);
611
+ }
612
+ });
613
+ }
614
+ catch (error) {
615
+ await stateMutex.runExclusive(async () => {
616
+ const failedAt = now();
617
+ const failureExecution = await buildSelectionFailureExecution(tasks[index], failedAt, input.logDir, error);
618
+ tasks[index] = {
619
+ ...tasks[index],
620
+ status: "failed",
621
+ lastExecutionId: failureExecution.id,
622
+ updatedAt: failedAt,
623
+ context: {
624
+ ...tasks[index].context,
625
+ recoveryNeeded: true
626
+ }
627
+ };
628
+ executions.push(failureExecution);
629
+ await input.stateStore.writeExecutionState(executions);
630
+ await input.stateStore.writeTaskState(tasks);
631
+ if (input.deferFailureNotifications !== true) {
632
+ const failureCommentId = await safelyNotifyFailure(input.failureNotifier, tasks[index], failureExecution);
633
+ if (failureCommentId !== undefined) {
634
+ tasks[index] = { ...tasks[index], lastFailureCommentId: failureCommentId };
635
+ await input.stateStore.writeTaskState(tasks);
636
+ }
637
+ }
638
+ });
639
+ }
640
+ finally {
641
+ // Guarantee sequential-mode memory unbind regardless of where failure
642
+ // occurred — before dispatch, during dispatch, or after dispatch (fix #5).
643
+ if (!isConcurrent && memoryBoundForTask) {
644
+ input.jobMemoryRuntime?.toolService.unbind();
645
+ }
646
+ }
647
+ };
648
+ // ── Broker scope pre-check ─────────────────────────────────────────────────
649
+ // Compute the batch-level broker allow-list for concurrent mode. If
650
+ // participating providers have different safe allow-lists (after memory tools
651
+ // are removed), downgrade to sequential so per-task binding preserves
652
+ // per-provider broker boundaries. batchBrokerSafeList is non-null only when
653
+ // concurrent dispatch with a single shared allow-list is safe.
654
+ let batchBrokerSafeList = null;
655
+ if (isConcurrent && eligibleTaskRecordsHaveSkips(eligibleEntries.map(({ index }) => tasks[index]))) {
656
+ isConcurrent = false;
657
+ input.logger?.info("[dispatch] Concurrent mode downgraded to sequential: at least one eligible task has task-scoped MCP skip contexts.");
658
+ }
659
+ if (isConcurrent && input.brokerToolScope && input.providerBrokerToolAllowLists) {
660
+ const eligibleTaskRecords = eligibleEntries.map(({ index }) => tasks[index]);
661
+ const binding = computeConcurrentBrokerBinding(eligibleTaskRecords, input.agents, input.providerBrokerToolAllowLists);
662
+ if (binding.canBind) {
663
+ batchBrokerSafeList = binding.safeList;
664
+ }
665
+ else {
666
+ isConcurrent = false;
667
+ input.logger?.info("[dispatch] Concurrent mode downgraded to sequential: participating providers have " +
668
+ "different broker tool allow-lists. To run concurrently, make all provider broker " +
669
+ "allow-lists identical or set maxConcurrentAgents: 1.");
670
+ }
671
+ }
672
+ if (pool) {
673
+ // Bind the shared broker allow-list before the batch starts. This is only
674
+ // set when isConcurrent is true (all participating providers share the same
675
+ // safe list). When isConcurrent was downgraded above, batchBrokerSafeList
676
+ // is null and per-task binding inside dispatchOneTask applies instead.
677
+ if (batchBrokerSafeList !== null) {
678
+ input.brokerToolScope.bindAllowedTools(batchBrokerSafeList);
679
+ }
680
+ try {
681
+ if (isConcurrent) {
682
+ // Each task acquires scope locks (workstream / branch / workspace) for
683
+ // its full lifetime before entering dispatchOneTask. Locks are sorted
684
+ // before acquisition to prevent deadlock (fix #2).
685
+ const results = await Promise.allSettled(eligibleEntries.map(({ index }) => pool.runWithSlot(async () => {
686
+ const task = tasks[index];
687
+ const lockKeys = deriveExecutionLockKeys(task, input.executionCwdByTaskId);
688
+ const releaseScope = await pool.scopeLocks.acquireAll(lockKeys);
689
+ try {
690
+ await dispatchOneTask(index);
691
+ }
692
+ finally {
693
+ releaseScope();
694
+ }
695
+ })));
696
+ // Surface unexpected wrapper-level rejections. Expected task failures are
697
+ // materialized as failed task executions inside dispatchOneTask and should
698
+ // not cause a rejection here. Only a bug in the dispatch wrapper itself
699
+ // should produce a rejected promise (fix #4).
700
+ const unexpectedFailures = results
701
+ .map((r, i) => ({ result: r, index: eligibleEntries[i].index }))
702
+ .filter((x) => x.result.status === "rejected");
703
+ if (unexpectedFailures.length > 0) {
704
+ const messages = unexpectedFailures.map(({ result, index }) => {
705
+ const taskId = tasks[index]?.id ?? `index:${index}`;
706
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
707
+ return `task ${taskId}: ${message}`;
708
+ });
709
+ throw new Error(`Unexpected agent dispatch failures:\n${messages.join("\n")}`);
710
+ }
711
+ }
712
+ else {
713
+ // Sequential: either pool.maxConcurrent=1 or broker lists differ.
714
+ // Per-task broker binding and memory bind/unbind are handled inside
715
+ // dispatchOneTask (isConcurrent=false path).
716
+ for (const { index } of eligibleEntries) {
717
+ await dispatchOneTask(index);
718
+ }
719
+ }
720
+ }
721
+ finally {
722
+ // Clear the batch-level broker scope after all tasks have settled.
723
+ // Only needed when batchBrokerSafeList was set (concurrent path).
724
+ if (batchBrokerSafeList !== null) {
725
+ input.brokerToolScope?.clearAllowedTools();
726
+ }
727
+ }
728
+ }
729
+ else {
730
+ for (const { index } of eligibleEntries) {
731
+ await dispatchOneTask(index);
732
+ }
733
+ }
734
+ return {
735
+ tasks,
736
+ executions
737
+ };
738
+ }
739
+ function compareEligibleDispatchEntries(tasks, leftIndex, rightIndex) {
740
+ const left = tasks[leftIndex];
741
+ const right = tasks[rightIndex];
742
+ const leftProvider = left.context.providerLimitResumedProvider;
743
+ const rightProvider = right.context.providerLimitResumedProvider;
744
+ if (leftProvider && rightProvider) {
745
+ if (leftProvider === rightProvider) {
746
+ const leftQueuedAt = left.context.providerLimitQueuedAt ?? left.updatedAt;
747
+ const rightQueuedAt = right.context.providerLimitQueuedAt ?? right.updatedAt;
748
+ const queueComparison = leftQueuedAt.localeCompare(rightQueuedAt);
749
+ if (queueComparison !== 0) {
750
+ return queueComparison;
751
+ }
752
+ return left.id.localeCompare(right.id);
753
+ }
754
+ return leftProvider.localeCompare(rightProvider);
755
+ }
756
+ if (leftProvider) {
757
+ return -1;
758
+ }
759
+ if (rightProvider) {
760
+ return 1;
761
+ }
762
+ return leftIndex - rightIndex;
763
+ }
764
+ export async function materializePreDispatchFailure(input) {
765
+ const now = input.now ?? (() => new Date().toISOString());
766
+ const tasks = [...input.tasks];
767
+ const executions = [...input.existingExecutions];
768
+ for (let index = 0; index < tasks.length; index += 1) {
769
+ const task = tasks[index];
770
+ if (!task || (task.status !== "pending" && task.status !== "dispatched") || task.relevant === false) {
771
+ continue;
772
+ }
773
+ const failedAt = now();
774
+ const execution = await buildFailureExecution(task, failedAt, input.logDir, input.error, "pre-dispatch-failure", "failed before agent execution began", "Dispatch setup failed before the agent could start.");
775
+ tasks[index] = {
776
+ ...task,
777
+ status: "failed",
778
+ lastExecutionId: execution.id,
779
+ updatedAt: failedAt,
780
+ context: {
781
+ ...task.context,
782
+ recoveryNeeded: true
783
+ }
784
+ };
785
+ executions.push(execution);
786
+ }
787
+ return {
788
+ tasks,
789
+ executions
790
+ };
791
+ }
792
+ async function safelyPublishReview(publisher, task, execution) {
793
+ if (!publisher) {
794
+ return;
795
+ }
796
+ try {
797
+ await publisher.publishReview(task, execution);
798
+ }
799
+ catch {
800
+ // Review publication failures must not break the dispatch loop.
801
+ }
802
+ }
803
+ async function safelyPublishBreakdown(publisher, task, execution) {
804
+ if (!publisher) {
805
+ return;
806
+ }
807
+ try {
808
+ await publisher.publishBreakdown(task, execution);
809
+ }
810
+ catch {
811
+ // Breakdown publication failures must not break the dispatch loop.
812
+ }
813
+ }
814
+ function buildReplanningCompletionAssessment(task, execution, planBreakdown, timestamp) {
815
+ const checkpoint = task.context.waveAssessmentCheckpoint;
816
+ const priorAssessment = task.context.finalizedWaveAssessment;
817
+ if (!checkpoint || !priorAssessment || priorAssessment.decision !== "replan") {
818
+ return undefined;
819
+ }
820
+ const revisedCheckpoint = buildWaveAssessmentCheckpoint({
821
+ ...checkpoint.identity,
822
+ planRevision: timestamp,
823
+ planNodeIds: planBreakdown.tasks.map((planTask) => planTask.id).sort()
824
+ });
825
+ return {
826
+ id: `assessment:replanning-complete:${revisedCheckpoint.id}:${execution.id}`,
827
+ sessionId: execution.id,
828
+ checkpoint: revisedCheckpoint,
829
+ decision: "continue",
830
+ summary: `Replanning completed by task ${task.id}; packet wave progression can continue for the revised checkpoint.`,
831
+ findings: [],
832
+ status: "finalized",
833
+ createdAt: timestamp,
834
+ finalizedAt: timestamp,
835
+ updatedAt: timestamp
836
+ };
837
+ }
838
+ async function safelyPublishWaveAssessment(publisher, task, execution) {
839
+ if (!publisher) {
840
+ return undefined;
841
+ }
842
+ try {
843
+ return await publisher.publishWaveAssessment(task, execution);
844
+ }
845
+ catch {
846
+ // Assessment publication failures must not break the dispatch loop. Without
847
+ // a posted comment id, the wave remains gated and can be retried.
848
+ return undefined;
849
+ }
850
+ }
851
+ async function persistWaveAssessmentToWorkstream(stateStore, assessment) {
852
+ const state = await stateStore.load();
853
+ const workstreams = state.workstreams.map((workstream) => workstream.id === assessment.checkpoint.identity.workstreamId
854
+ ? upsertWaveAssessment(workstream, assessment)
855
+ : workstream);
856
+ await stateStore.writeWorkstreamState(workstreams);
857
+ }
858
+ async function persistReplannedPacketPlanToWorkstream(stateStore, assessment, planBreakdown, generatedAt) {
859
+ const state = await stateStore.load();
860
+ const workstreams = state.workstreams.map((workstream) => {
861
+ if (workstream.id !== assessment.checkpoint.identity.workstreamId) {
862
+ return workstream;
863
+ }
864
+ const packetPlan = buildPacketPlanFromPlanBreakdown({
865
+ workstreamId: workstream.id,
866
+ planBreakdown,
867
+ generatedAt: generatedAt ??
868
+ assessment.checkpoint.identity.planRevision ??
869
+ assessment.updatedAt ??
870
+ assessment.finalizedAt ??
871
+ assessment.createdAt ??
872
+ new Date().toISOString()
873
+ });
874
+ const priorAssessments = workstream.packetPlan?.waveAssessments ?? [];
875
+ const withoutCurrent = priorAssessments.filter((item) => item.id !== assessment.id);
876
+ const priorStatus = workstream.packetPlan?.status;
877
+ const installedStatus = shouldPreserveReplannedPacketPlanStatus(priorStatus) && priorStatus
878
+ ? priorStatus
879
+ : packetPlan.status;
880
+ return {
881
+ ...workstream,
882
+ packetPlan: {
883
+ ...packetPlan,
884
+ status: installedStatus,
885
+ waveAssessments: [...withoutCurrent, assessment].sort((left, right) => left.id.localeCompare(right.id))
886
+ }
887
+ };
888
+ });
889
+ await stateStore.writeWorkstreamState(workstreams);
890
+ }
891
+ function shouldPreserveReplannedPacketPlanStatus(status) {
892
+ return status === "approved" || status === "in_progress";
893
+ }
894
+ function upsertWaveAssessment(workstream, assessment) {
895
+ if (!workstream.packetPlan) {
896
+ return workstream;
897
+ }
898
+ const existing = workstream.packetPlan.waveAssessments ?? [];
899
+ const withoutCurrent = existing.filter((item) => item.id !== assessment.id);
900
+ return {
901
+ ...workstream,
902
+ packetPlan: {
903
+ ...workstream.packetPlan,
904
+ waveAssessments: [...withoutCurrent, assessment].sort((left, right) => left.id.localeCompare(right.id))
905
+ }
906
+ };
907
+ }
908
+ async function markCompletedTaskPostDispatchFailure(input) {
909
+ const task = input.tasks[input.index];
910
+ const failure = await buildSinglePostDispatchHandoffFailureExecution({
911
+ task,
912
+ reason: input.reason,
913
+ logDir: input.input.logDir,
914
+ now: input.now
915
+ });
916
+ input.tasks[input.index] = failure.updatedTask;
917
+ input.executions.push(failure.execution);
918
+ await input.input.stateStore.writeExecutionState(input.executions);
919
+ await input.input.stateStore.writeTaskState(input.tasks);
920
+ if (input.input.deferFailureNotifications !== true) {
921
+ const failureCommentId = await safelyNotifyFailure(input.input.failureNotifier, failure.updatedTask, failure.execution);
922
+ if (failureCommentId !== undefined) {
923
+ input.tasks[input.index] = {
924
+ ...input.tasks[input.index],
925
+ lastFailureCommentId: failureCommentId
926
+ };
927
+ await input.input.stateStore.writeTaskState(input.tasks);
928
+ }
929
+ }
930
+ }
931
+ /**
932
+ * Build a failure execution record for a single task that failed before dispatch.
933
+ * Use this when a specific task cannot be dispatched due to a pre-dispatch condition
934
+ * (e.g. worktree blocked, clone creation failed, SHA mismatch).
935
+ *
936
+ * The caller is responsible for:
937
+ * 1. Writing the updated task and execution to state.
938
+ * 2. Calling safelyNotifyFailure to post a GitHub comment.
939
+ */
940
+ export async function buildSinglePreDispatchFailureExecution(input) {
941
+ const now = input.now ?? (() => new Date().toISOString());
942
+ const failedAt = now();
943
+ const execution = await buildFailureExecution(input.task, failedAt, input.logDir, new Error(input.reason), "pre-dispatch-failure", input.reason, "Dispatch setup failed before the agent could start.");
944
+ const updatedTask = {
945
+ ...input.task,
946
+ status: "failed",
947
+ lastExecutionId: execution.id,
948
+ updatedAt: failedAt,
949
+ context: {
950
+ ...input.task.context,
951
+ recoveryNeeded: true
952
+ }
953
+ };
954
+ return { updatedTask, execution };
955
+ }
956
+ /**
957
+ * Build a failure execution record for a single task whose agent run completed,
958
+ * but whose orchestrator-owned handoff failed afterward.
959
+ *
960
+ * Use this for prepare_pr_for_merge inspection failures and push/lease failures.
961
+ * A merge-prep task is only complete after the orchestrator has inspected the
962
+ * clone and pushed the prepared branch successfully.
963
+ */
964
+ export async function buildSinglePostDispatchHandoffFailureExecution(input) {
965
+ const now = input.now ?? (() => new Date().toISOString());
966
+ const failedAt = now();
967
+ const execution = await buildFailureExecution(input.task, failedAt, input.logDir, new Error(input.reason), "post-dispatch-handoff-failure", input.reason, "Handoff failed after the agent completed. The orchestrator could not safely inspect or publish the merge-prep result.");
968
+ const updatedTask = {
969
+ ...input.task,
970
+ status: "failed",
971
+ lastExecutionId: execution.id,
972
+ updatedAt: failedAt,
973
+ context: {
974
+ ...input.task.context,
975
+ recoveryNeeded: true
976
+ }
977
+ };
978
+ return { updatedTask, execution };
979
+ }
980
+ async function safelyNotifyDispatch(notifier, task, isRetry, logger) {
981
+ if (!notifier)
982
+ return;
983
+ try {
984
+ await notifier.notifyDispatch(task, { isRetry });
985
+ }
986
+ catch (err) {
987
+ logger?.error?.(`[lifecycle] Failed to notify dispatch for task ${task.id}: ${err}`);
988
+ console.error(`🤖 [lifecycle] Failed to notify dispatch for task ${task.id}:`, err);
989
+ }
990
+ }
991
+ async function safelyNotifyQueued(notifier, task, providers, resetTime, logger) {
992
+ if (!notifier)
993
+ return;
994
+ try {
995
+ await notifier.notifyQueued(task, providers, resetTime);
996
+ }
997
+ catch (err) {
998
+ const providerList = Array.isArray(providers) ? providers : [providers];
999
+ logger?.error?.(`[lifecycle] Failed to notify queued for task ${task.id} (providers: ${providerList.join(",")}): ${err}`);
1000
+ console.error(`🤖 [lifecycle] Failed to notify queued for task ${task.id}:`, err);
1001
+ }
1002
+ }
1003
+ export async function safelyNotifyResumed(notifier, task, logger) {
1004
+ if (!notifier)
1005
+ return;
1006
+ try {
1007
+ await notifier.notifyResumed(task);
1008
+ }
1009
+ catch (err) {
1010
+ logger?.error?.(`[lifecycle] Failed to notify resumed for task ${task.id}: ${err}`);
1011
+ console.error(`🤖 [lifecycle] Failed to notify resumed for task ${task.id}:`, err);
1012
+ }
1013
+ }
1014
+ async function safelyNotifyCompletion(notifier, task, status, logger) {
1015
+ if (!notifier)
1016
+ return;
1017
+ try {
1018
+ await notifier.notifyCompletionStatus(task, status);
1019
+ }
1020
+ catch (err) {
1021
+ logger?.error?.(`[lifecycle] Failed to notify completion status ${status} for task ${task.id}: ${err}`);
1022
+ console.error(`🤖 [lifecycle] Failed to notify completion status for task ${task.id}:`, err);
1023
+ }
1024
+ }
1025
+ export async function safelyNotifyFailure(notifier, task, execution, customMessage) {
1026
+ if (!notifier)
1027
+ return undefined;
1028
+ try {
1029
+ return await notifier.notifyFailure(task, execution, customMessage);
1030
+ }
1031
+ catch {
1032
+ // Failure notifications must not break the dispatch loop.
1033
+ return undefined;
1034
+ }
1035
+ }
1036
+ async function buildSelectionFailureExecution(task, timestamp, stateDir, error) {
1037
+ return buildFailureExecution(task, timestamp, stateDir, error, "selection-failure", "failed during agent selection", "Selection Context: No enabled agent was found that matches the role required for this task. Dispatch aborted.");
1038
+ }
1039
+ async function buildFailureExecution(task, timestamp, stateDir, error, executionPhase, statusLine, contextLine) {
1040
+ const message = error instanceof Error ? error.message : String(error);
1041
+ const executionId = `exec-${task.id.replace(/[^a-zA-Z0-9_-]+/g, "_")}-${executionPhase}-${timestamp.replace(/[:.]/g, "-")}`;
1042
+ const artifactRoot = path.join(stateDir, "artifacts", executionId);
1043
+ const promptArtifactPath = path.join(artifactRoot, "prompt.md");
1044
+ const stdoutArtifactPath = path.join(artifactRoot, "stdout.txt");
1045
+ const stderrArtifactPath = path.join(artifactRoot, "stderr.txt");
1046
+ const promptPacketPath = path.join(artifactRoot, "prompt-packet.json");
1047
+ await mkdir(artifactRoot, { recursive: true });
1048
+ await Promise.all([
1049
+ writeFile(promptArtifactPath, `Task: ${task.id}\nPhase: ${statusLine}\n\n${contextLine}\n`, "utf8"),
1050
+ writeFile(stdoutArtifactPath, "", "utf8"),
1051
+ writeFile(stderrArtifactPath, `Task: ${task.id}\nStatus: ${statusLine}\nError: ${message}\n\n${contextLine}\n`, "utf8"),
1052
+ writeFile(promptPacketPath, JSON.stringify({
1053
+ taskId: task.id,
1054
+ taskKind: task.kind,
1055
+ status: statusLine,
1056
+ context: contextLine,
1057
+ error: message
1058
+ }, null, 2), "utf8")
1059
+ ]);
1060
+ return {
1061
+ id: executionId,
1062
+ taskId: task.id,
1063
+ taskKind: task.kind,
1064
+ taskStatus: "failed",
1065
+ repoId: task.repoId,
1066
+ repoSlug: task.repoSlug,
1067
+ agentId: "unassigned",
1068
+ agentProvider: "unassigned",
1069
+ command: {
1070
+ executable: "none",
1071
+ args: [],
1072
+ cwd: task.context.localRepoPath ?? process.cwd(),
1073
+ timeoutMs: 0
1074
+ },
1075
+ promptArtifactPath,
1076
+ stdoutArtifactPath,
1077
+ stderrArtifactPath,
1078
+ promptPacketPath,
1079
+ startedAt: timestamp,
1080
+ endedAt: timestamp,
1081
+ exitCode: 1,
1082
+ errorMessage: message
1083
+ };
1084
+ }
1085
+ function isRetryableProviderSessionFailure(execution) {
1086
+ return execution.exitCode === 124 || /timed out|timeout/i.test(execution.errorMessage ?? "");
1087
+ }