@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.
- package/LICENSE +21 -0
- package/README.md +329 -0
- package/dist/src/agents/antigravity-launch.js +162 -0
- package/dist/src/agents/antigravity.js +251 -0
- package/dist/src/agents/claude-launch.js +117 -0
- package/dist/src/agents/claude.js +271 -0
- package/dist/src/agents/codex-launch.js +82 -0
- package/dist/src/agents/codex.js +261 -0
- package/dist/src/agents/composite-runner.js +13 -0
- package/dist/src/agents/extraction.js +442 -0
- package/dist/src/agents/failure-feedback.js +132 -0
- package/dist/src/agents/gemini-launch.js +96 -0
- package/dist/src/agents/gemini.js +392 -0
- package/dist/src/agents/job-agent-assignments.js +180 -0
- package/dist/src/agents/lifecycle-feedback.js +147 -0
- package/dist/src/agents/limit-detector.js +83 -0
- package/dist/src/agents/opencode-launch.js +138 -0
- package/dist/src/agents/opencode.js +156 -0
- package/dist/src/agents/plan-breakdown-feedback.js +97 -0
- package/dist/src/agents/pool.js +168 -0
- package/dist/src/agents/prompt.js +462 -0
- package/dist/src/agents/provider-sessions.js +105 -0
- package/dist/src/agents/review-feedback.js +255 -0
- package/dist/src/agents/selection.js +113 -0
- package/dist/src/agents/service.js +1087 -0
- package/dist/src/agents/types.js +1 -0
- package/dist/src/agents/wave-assessment-feedback.js +69 -0
- package/dist/src/attention/derive.js +337 -0
- package/dist/src/capabilities/index.js +2 -0
- package/dist/src/capabilities/projection.js +183 -0
- package/dist/src/capabilities/types.js +1 -0
- package/dist/src/capabilities/validator.js +87 -0
- package/dist/src/cli/commands/broker-smoke.js +96 -0
- package/dist/src/cli/commands/broker.js +56 -0
- package/dist/src/cli/commands/init.js +4 -0
- package/dist/src/cli/commands/journal.js +43 -0
- package/dist/src/cli/commands/run-loop.js +56 -0
- package/dist/src/cli/commands/run.js +1241 -0
- package/dist/src/cli/commands/tui.js +187 -0
- package/dist/src/cli/dispatcher.js +51 -0
- package/dist/src/cli/errors.js +2 -0
- package/dist/src/cli/init.js +92 -0
- package/dist/src/cli/options.js +86 -0
- package/dist/src/cli/output.js +231 -0
- package/dist/src/cli/run-journal.js +342 -0
- package/dist/src/cli/run-types.js +1 -0
- package/dist/src/cli/run.js +32 -0
- package/dist/src/cli/runtime/dispatch-lifecycle.js +772 -0
- package/dist/src/cli/runtime/provider-runtime-types.js +1 -0
- package/dist/src/cli/runtime/provider-runtime.js +152 -0
- package/dist/src/cli/runtime/provider-tools.js +73 -0
- package/dist/src/cli/runtime/providers/antigravity.js +26 -0
- package/dist/src/cli/runtime/providers/claude.js +20 -0
- package/dist/src/cli/runtime/providers/codex.js +20 -0
- package/dist/src/cli/runtime/providers/gemini.js +20 -0
- package/dist/src/cli/runtime/providers/opencode.js +20 -0
- package/dist/src/cli/runtime/run-context.js +131 -0
- package/dist/src/cli/tui/index.js +3 -0
- package/dist/src/cli/tui/screen.js +388 -0
- package/dist/src/cli/tui/tracker.js +558 -0
- package/dist/src/cli/tui/types.js +1 -0
- package/dist/src/cli/tui/views.js +1006 -0
- package/dist/src/config/loader.js +136 -0
- package/dist/src/config/schemas.js +403 -0
- package/dist/src/config/types.js +1 -0
- package/dist/src/context/types.js +331 -0
- package/dist/src/domain/entities.js +1 -0
- package/dist/src/git/backburner-git-tool-service.js +680 -0
- package/dist/src/git/gitops-lifecycle.js +56 -0
- package/dist/src/git/merge-prep-workspace.js +357 -0
- package/dist/src/git/types.js +484 -0
- package/dist/src/github/broker-model-content.js +215 -0
- package/dist/src/github/broker-server.js +237 -0
- package/dist/src/github/broker.js +638 -0
- package/dist/src/github/composed.js +53 -0
- package/dist/src/github/gateway.js +478 -0
- package/dist/src/github/normalize.js +222 -0
- package/dist/src/github/pr-classification.js +73 -0
- package/dist/src/github/scoped-broker.js +35 -0
- package/dist/src/github/security.js +126 -0
- package/dist/src/github/service.js +332 -0
- package/dist/src/github/types.js +1 -0
- package/dist/src/github/utils.js +16 -0
- package/dist/src/journal/index.js +4 -0
- package/dist/src/journal/reader.js +62 -0
- package/dist/src/journal/renderer.js +50 -0
- package/dist/src/journal/service.js +72 -0
- package/dist/src/journal/types.js +1 -0
- package/dist/src/journal/writer.js +19 -0
- package/dist/src/mcps/bridge.js +166 -0
- package/dist/src/mcps/index.js +5 -0
- package/dist/src/mcps/reconcile.js +113 -0
- package/dist/src/mcps/smithery-client.js +186 -0
- package/dist/src/mcps/smithery.js +164 -0
- package/dist/src/mcps/types.js +1 -0
- package/dist/src/memory/json-store.js +71 -0
- package/dist/src/memory/prompt.js +16 -0
- package/dist/src/memory/scope.js +31 -0
- package/dist/src/memory/store.js +1 -0
- package/dist/src/memory/tool-service.js +142 -0
- package/dist/src/memory/types.js +12 -0
- package/dist/src/onboarding/config-writer.js +96 -0
- package/dist/src/onboarding/engine.js +86 -0
- package/dist/src/onboarding/prompt.js +102 -0
- package/dist/src/onboarding/services/provider-discovery.js +58 -0
- package/dist/src/onboarding/services/repo-discovery.js +246 -0
- package/dist/src/onboarding/steps/check-github-auth.js +32 -0
- package/dist/src/onboarding/steps/check-requirements.js +34 -0
- package/dist/src/onboarding/steps/configure-models.js +105 -0
- package/dist/src/onboarding/steps/configure-paths.js +33 -0
- package/dist/src/onboarding/steps/discover-providers.js +38 -0
- package/dist/src/onboarding/steps/discover-repos.js +39 -0
- package/dist/src/onboarding/steps/generate-config.js +106 -0
- package/dist/src/onboarding/steps/readiness-validation.js +158 -0
- package/dist/src/onboarding/steps/select-repos.js +76 -0
- package/dist/src/onboarding/steps/show-summary.js +97 -0
- package/dist/src/onboarding/steps/welcome.js +27 -0
- package/dist/src/onboarding/types.js +1 -0
- package/dist/src/onboarding/validators/git-access.js +63 -0
- package/dist/src/onboarding/validators/github-auth.js +85 -0
- package/dist/src/onboarding/validators/tool-checker.js +153 -0
- package/dist/src/onboarding/validators/workspace.js +83 -0
- package/dist/src/prompts/resolver.js +106 -0
- package/dist/src/retrospectives/candidates.js +224 -0
- package/dist/src/retrospectives/derive.js +169 -0
- package/dist/src/retrospectives/ingest.js +321 -0
- package/dist/src/retrospectives/management-prs.js +380 -0
- package/dist/src/retrospectives/proposals.js +199 -0
- package/dist/src/retrospectives/scopes.js +272 -0
- package/dist/src/retrospectives/scoring.js +171 -0
- package/dist/src/retrospectives/types.js +1 -0
- package/dist/src/state/loader.js +473 -0
- package/dist/src/state/types.js +15 -0
- package/dist/src/tasks/derivation/builders.js +70 -0
- package/dist/src/tasks/derivation/handlers/comment-response.js +169 -0
- package/dist/src/tasks/derivation/handlers/implement-plan.js +103 -0
- package/dist/src/tasks/derivation/handlers/index.js +54 -0
- package/dist/src/tasks/derivation/handlers/packet-pr-control.js +92 -0
- package/dist/src/tasks/derivation/handlers/packet-worktree.js +29 -0
- package/dist/src/tasks/derivation/handlers/plan-breakdown.js +45 -0
- package/dist/src/tasks/derivation/handlers/prepare-for-merge.js +91 -0
- package/dist/src/tasks/derivation/handlers/product-discovery.js +36 -0
- package/dist/src/tasks/derivation/handlers/retry-failed-task.js +125 -0
- package/dist/src/tasks/derivation/handlers/review-pr.js +28 -0
- package/dist/src/tasks/derivation/handlers/shared.js +24 -0
- package/dist/src/tasks/derivation/handlers/sync-parent-branch.js +100 -0
- package/dist/src/tasks/derivation/handlers/wave-assessment.js +223 -0
- package/dist/src/tasks/derivation/handlers/write-plan.js +101 -0
- package/dist/src/tasks/derivation/handlers/write-product-spec.js +49 -0
- package/dist/src/tasks/derivation/runner.js +18 -0
- package/dist/src/tasks/derivation/types.js +1 -0
- package/dist/src/tasks/derive.js +810 -0
- package/dist/src/tasks/mcp-skip.js +10 -0
- package/dist/src/tasks/retry.js +10 -0
- package/dist/src/tasks/task.js +184 -0
- package/dist/src/tasks/types.js +1 -0
- package/dist/src/utils/command-runner.js +192 -0
- package/dist/src/utils/json.js +38 -0
- package/dist/src/utils/logger.js +43 -0
- package/dist/src/utils/paths.js +38 -0
- package/dist/src/utils/slug.js +10 -0
- package/dist/src/workflows/registry.js +174 -0
- package/dist/src/workflows/triage.js +90 -0
- package/dist/src/workflows/workstream-machine/derive-events.js +121 -0
- package/dist/src/workflows/workstream-machine/evaluate.js +24 -0
- package/dist/src/workflows/workstream-machine/events.js +1 -0
- package/dist/src/workflows/workstream-machine/machine.js +355 -0
- package/dist/src/workflows/workstream-machine/selectors.js +19 -0
- package/dist/src/workflows/workstream-machine/types.js +1 -0
- package/dist/src/workstreams/branch-sync.js +87 -0
- package/dist/src/workstreams/derive.js +629 -0
- package/dist/src/workstreams/mcp-blockers.js +420 -0
- package/dist/src/workstreams/packet-candidates.js +42 -0
- package/dist/src/workstreams/packet-lifecycle-feedback.js +60 -0
- package/dist/src/workstreams/packet-lifecycle-report.js +199 -0
- package/dist/src/workstreams/packet-plan.js +597 -0
- package/dist/src/workstreams/packet-projections.js +190 -0
- package/dist/src/workstreams/parent-branch-sync-action.js +312 -0
- package/dist/src/workstreams/parent-branch-sync.js +148 -0
- package/dist/src/workstreams/phases.js +4 -0
- package/dist/src/workstreams/plan-breakdown-generator/export.js +19 -0
- package/dist/src/workstreams/plan-breakdown-generator/index.js +6 -0
- package/dist/src/workstreams/plan-breakdown-generator/inspect.js +50 -0
- package/dist/src/workstreams/plan-breakdown-generator/patch.js +139 -0
- package/dist/src/workstreams/plan-breakdown-generator/session.js +19 -0
- package/dist/src/workstreams/plan-breakdown-generator/store.js +27 -0
- package/dist/src/workstreams/plan-breakdown-generator/tools.js +407 -0
- package/dist/src/workstreams/plan-breakdown-generator/types.js +6 -0
- package/dist/src/workstreams/plan-breakdown-generator/validate.js +212 -0
- package/dist/src/workstreams/plan-breakdown-schema.js +175 -0
- package/dist/src/workstreams/utils.js +15 -0
- package/dist/src/workstreams/wave-assessment/checkpoint.js +172 -0
- package/dist/src/workstreams/wave-assessment/index.js +3 -0
- package/dist/src/workstreams/wave-assessment/session.js +84 -0
- package/dist/src/workstreams/wave-assessment/store.js +51 -0
- package/dist/src/workstreams/wave-assessment/tools.js +419 -0
- package/dist/src/workstreams/wave-assessment/types.js +1 -0
- package/dist/src/workstreams/wave-assessment/validate.js +78 -0
- package/dist/src/worktrees/service.js +839 -0
- package/dist/src/worktrees/workspace-ownership.js +34 -0
- package/dist/src/worktrees/workspace-recovery.js +177 -0
- 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
|
+
}
|