@mcoda/core 0.1.8 → 0.1.9

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 (70) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/api/AgentsApi.d.ts +8 -1
  3. package/dist/api/AgentsApi.d.ts.map +1 -1
  4. package/dist/api/AgentsApi.js +70 -0
  5. package/dist/api/QaTasksApi.d.ts.map +1 -1
  6. package/dist/api/QaTasksApi.js +2 -0
  7. package/dist/api/TasksApi.d.ts.map +1 -1
  8. package/dist/api/TasksApi.js +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  13. package/dist/prompts/PdrPrompts.js +3 -1
  14. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  15. package/dist/prompts/SdsPrompts.js +2 -0
  16. package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
  17. package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
  18. package/dist/services/agents/AgentRatingFormula.js +45 -0
  19. package/dist/services/agents/AgentRatingService.d.ts +41 -0
  20. package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
  21. package/dist/services/agents/AgentRatingService.js +299 -0
  22. package/dist/services/agents/GatewayAgentService.d.ts +3 -0
  23. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  24. package/dist/services/agents/GatewayAgentService.js +68 -24
  25. package/dist/services/agents/GatewayHandoff.d.ts +7 -0
  26. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
  27. package/dist/services/agents/GatewayHandoff.js +108 -0
  28. package/dist/services/backlog/TaskOrderingService.d.ts +1 -0
  29. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  30. package/dist/services/backlog/TaskOrderingService.js +19 -16
  31. package/dist/services/docs/DocsService.d.ts +11 -1
  32. package/dist/services/docs/DocsService.d.ts.map +1 -1
  33. package/dist/services/docs/DocsService.js +240 -52
  34. package/dist/services/execution/GatewayTrioService.d.ts +133 -0
  35. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
  36. package/dist/services/execution/GatewayTrioService.js +1125 -0
  37. package/dist/services/execution/QaFollowupService.d.ts +1 -0
  38. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  39. package/dist/services/execution/QaFollowupService.js +1 -0
  40. package/dist/services/execution/QaProfileService.d.ts +6 -0
  41. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  42. package/dist/services/execution/QaProfileService.js +165 -3
  43. package/dist/services/execution/QaTasksService.d.ts +18 -0
  44. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  45. package/dist/services/execution/QaTasksService.js +712 -34
  46. package/dist/services/execution/WorkOnTasksService.d.ts +14 -0
  47. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  48. package/dist/services/execution/WorkOnTasksService.js +1497 -240
  49. package/dist/services/openapi/OpenApiService.d.ts +10 -0
  50. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  51. package/dist/services/openapi/OpenApiService.js +66 -10
  52. package/dist/services/planning/CreateTasksService.d.ts +6 -0
  53. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  54. package/dist/services/planning/CreateTasksService.js +261 -28
  55. package/dist/services/planning/RefineTasksService.d.ts +5 -0
  56. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  57. package/dist/services/planning/RefineTasksService.js +184 -35
  58. package/dist/services/review/CodeReviewService.d.ts +14 -0
  59. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  60. package/dist/services/review/CodeReviewService.js +657 -61
  61. package/dist/services/shared/ProjectGuidance.d.ts +6 -0
  62. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
  63. package/dist/services/shared/ProjectGuidance.js +21 -0
  64. package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
  65. package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
  66. package/dist/services/tasks/TaskCommentFormatter.js +54 -0
  67. package/dist/workspace/WorkspaceManager.d.ts +4 -0
  68. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  69. package/dist/workspace/WorkspaceManager.js +3 -0
  70. package/package.json +5 -5
@@ -0,0 +1,1125 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { WorkspaceRepository } from "@mcoda/db";
4
+ import { PathHelper } from "@mcoda/shared";
5
+ import { GatewayAgentService } from "../agents/GatewayAgentService.js";
6
+ import { buildGatewayHandoffContent, buildGatewayHandoffDocdexUsage, withGatewayHandoff, writeGatewayHandoffFile, } from "../agents/GatewayHandoff.js";
7
+ import { JobService } from "../jobs/JobService.js";
8
+ import { TaskSelectionService } from "./TaskSelectionService.js";
9
+ import { WorkOnTasksService } from "./WorkOnTasksService.js";
10
+ import { CodeReviewService } from "../review/CodeReviewService.js";
11
+ import { QaTasksService } from "./QaTasksService.js";
12
+ const DEFAULT_STATUS_FILTER = ["not_started", "in_progress", "ready_to_review", "ready_to_qa"];
13
+ const TERMINAL_STATUSES = new Set(["completed", "cancelled", "failed"]);
14
+ const BLOCKED_STATUSES = new Set(["blocked"]);
15
+ const RETRYABLE_BLOCK_REASONS = new Set([
16
+ "missing_patch",
17
+ "patch_failed",
18
+ "tests_not_configured",
19
+ "tests_failed",
20
+ "no_changes",
21
+ "qa_infra_issue",
22
+ "review_blocked",
23
+ ]);
24
+ const ESCALATION_REASONS = new Set(["missing_patch", "patch_failed", "tests_failed", "agent_timeout"]);
25
+ const NO_CHANGE_REASON = "no_changes";
26
+ const DONE_DEPENDENCY_STATUSES = new Set(["completed", "cancelled"]);
27
+ const HEARTBEAT_INTERVAL_MS = 30000;
28
+ export class GatewayTrioService {
29
+ constructor(workspace, deps) {
30
+ this.workspace = workspace;
31
+ this.deps = deps;
32
+ this.projectKeyCache = new Map();
33
+ this.selectionService =
34
+ deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
35
+ }
36
+ static async create(workspace, options = {}) {
37
+ const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
38
+ const jobService = new JobService(workspace, workspaceRepo);
39
+ const gatewayService = await GatewayAgentService.create(workspace);
40
+ const workService = await WorkOnTasksService.create(workspace);
41
+ const reviewService = await CodeReviewService.create(workspace);
42
+ const qaService = await QaTasksService.create(workspace, { noTelemetry: options.noTelemetry ?? false });
43
+ const selectionService = new TaskSelectionService(workspace, workspaceRepo);
44
+ return new GatewayTrioService(workspace, {
45
+ workspaceRepo,
46
+ jobService,
47
+ gatewayService,
48
+ workService,
49
+ reviewService,
50
+ qaService,
51
+ selectionService,
52
+ });
53
+ }
54
+ async close() {
55
+ const maybeClose = async (target) => {
56
+ try {
57
+ if (target?.close)
58
+ await target.close();
59
+ }
60
+ catch {
61
+ /* ignore */
62
+ }
63
+ };
64
+ await maybeClose(this.selectionService);
65
+ await maybeClose(this.deps.gatewayService);
66
+ await maybeClose(this.deps.workService);
67
+ await maybeClose(this.deps.reviewService);
68
+ await maybeClose(this.deps.qaService);
69
+ await maybeClose(this.deps.jobService);
70
+ await maybeClose(this.deps.workspaceRepo);
71
+ }
72
+ trioDir(jobId) {
73
+ return path.join(this.workspace.workspaceRoot, ".mcoda", "jobs", jobId, "gateway-trio");
74
+ }
75
+ statePath(jobId) {
76
+ return path.join(this.trioDir(jobId), "state.json");
77
+ }
78
+ async writeState(state) {
79
+ await PathHelper.ensureDir(this.trioDir(state.job_id));
80
+ await fs.writeFile(this.statePath(state.job_id), JSON.stringify(state, null, 2), "utf8");
81
+ }
82
+ async loadState(jobId) {
83
+ try {
84
+ const raw = await fs.readFile(this.statePath(jobId), "utf8");
85
+ return JSON.parse(raw);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ }
91
+ async readManifest(jobId) {
92
+ const manifestPath = path.join(this.workspace.workspaceRoot, ".mcoda", "jobs", jobId, "manifest.json");
93
+ try {
94
+ const raw = await fs.readFile(manifestPath, "utf8");
95
+ return JSON.parse(raw);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ async cleanupExpiredTaskLocks(warnings) {
102
+ const cleared = await this.deps.workspaceRepo.cleanupExpiredTaskLocks();
103
+ if (cleared.length > 0) {
104
+ warnings.push(`Cleared ${cleared.length} expired task lock(s): ${cleared.join(", ")}`);
105
+ }
106
+ }
107
+ assertResumeAllowed(job, manifest) {
108
+ const state = job.jobState ?? job.state ?? job.status ?? "unknown";
109
+ if (["completed", "cancelled"].includes(state)) {
110
+ throw new Error(`Job ${job.id} is ${state}; cannot resume.`);
111
+ }
112
+ if (["running", "queued", "checkpointing"].includes(state)) {
113
+ throw new Error(`Job ${job.id} is ${state}; wait for it to finish or cancel before resuming.`);
114
+ }
115
+ const supported = job.resumeSupported ?? job.resume_supported ?? job.payload?.resumeSupported ?? job.payload?.resume_supported;
116
+ if (supported === 0 || supported === false) {
117
+ throw new Error(`Job ${job.id} does not support resume.`);
118
+ }
119
+ if (!manifest) {
120
+ throw new Error(`Missing manifest for job ${job.id}; cannot resume safely.`);
121
+ }
122
+ const manifestJobId = manifest.job_id ?? manifest.id;
123
+ if (manifestJobId && manifestJobId !== job.id) {
124
+ throw new Error(`Checkpoint manifest for ${job.id} does not match job id (${manifestJobId}); aborting resume.`);
125
+ }
126
+ const manifestType = manifest.type ?? manifest.job_type;
127
+ if (manifestType && manifestType !== job.type) {
128
+ throw new Error(`Checkpoint manifest type (${manifestType}) does not match job type (${job.type}); cannot resume.`);
129
+ }
130
+ const manifestCommand = manifest.command ?? manifest.command_name ?? manifest.commandName;
131
+ if (manifestCommand && job.commandName && manifestCommand !== job.commandName) {
132
+ throw new Error(`Checkpoint manifest command (${manifestCommand}) does not match job command (${job.commandName}); cannot resume.`);
133
+ }
134
+ }
135
+ async writeHandoffArtifact(jobId, taskKey, step, attempt, content) {
136
+ const dir = path.join(this.trioDir(jobId), "handoffs");
137
+ await PathHelper.ensureDir(dir);
138
+ const safeKey = taskKey.replace(/[^a-z0-9_-]+/gi, "_");
139
+ const filename = `${String(attempt).padStart(2, "0")}-${safeKey}-${step}.md`;
140
+ const target = path.join(dir, filename);
141
+ await fs.writeFile(target, content, "utf8");
142
+ return target;
143
+ }
144
+ async prepareHandoff(jobId, taskKey, step, attempt, content) {
145
+ const safeKey = taskKey.replace(/[^a-z0-9_-]+/gi, "_");
146
+ const handoffId = `${safeKey}-${step}-${String(attempt).padStart(2, "0")}`;
147
+ const handoffPath = await writeGatewayHandoffFile(this.workspace.workspaceRoot, handoffId, content, "gateway-trio");
148
+ await this.writeHandoffArtifact(jobId, taskKey, step, attempt, content);
149
+ return handoffPath;
150
+ }
151
+ async projectKeyForTask(projectId) {
152
+ if (!projectId)
153
+ return undefined;
154
+ if (this.projectKeyCache.has(projectId))
155
+ return this.projectKeyCache.get(projectId);
156
+ const project = await this.deps.workspaceRepo.getProjectById(projectId);
157
+ if (!project)
158
+ return undefined;
159
+ this.projectKeyCache.set(projectId, project.key);
160
+ return project.key;
161
+ }
162
+ async seedExplicitTasks(state, explicitTasks, warnings) {
163
+ for (const taskKey of explicitTasks) {
164
+ if (state.tasks[taskKey])
165
+ continue;
166
+ const task = await this.deps.workspaceRepo.getTaskByKey(taskKey);
167
+ if (!task) {
168
+ warnings.push(`Explicit task ${taskKey} not found; skipping.`);
169
+ continue;
170
+ }
171
+ this.ensureProgress(state, taskKey);
172
+ }
173
+ }
174
+ ensureProgress(state, taskKey) {
175
+ const existing = state.tasks[taskKey];
176
+ if (existing) {
177
+ if (!existing.chosenAgents)
178
+ existing.chosenAgents = {};
179
+ if (!existing.failureHistory)
180
+ existing.failureHistory = [];
181
+ return existing;
182
+ }
183
+ const created = {
184
+ taskKey,
185
+ attempts: 0,
186
+ status: "pending",
187
+ chosenAgents: {},
188
+ failureHistory: [],
189
+ };
190
+ state.tasks[taskKey] = created;
191
+ return created;
192
+ }
193
+ hasReachedMaxIterations(progress, maxIterations) {
194
+ if (maxIterations === undefined)
195
+ return false;
196
+ const attempts = progress?.attempts ?? 0;
197
+ return attempts >= maxIterations;
198
+ }
199
+ hasIterationsRemaining(progress, maxIterations) {
200
+ if (maxIterations === undefined)
201
+ return true;
202
+ return progress.attempts < maxIterations;
203
+ }
204
+ async reopenRetryableBlockedTasks(state, explicitTasks, maxIterations, warnings) {
205
+ const keys = new Set([...explicitTasks, ...Object.keys(state.tasks)]);
206
+ for (const taskKey of keys) {
207
+ const progress = state.tasks[taskKey];
208
+ if (progress?.status === "completed")
209
+ continue;
210
+ if (this.hasReachedMaxIterations(progress, maxIterations))
211
+ continue;
212
+ const task = await this.deps.workspaceRepo.getTaskByKey(taskKey);
213
+ if (!task)
214
+ continue;
215
+ const status = this.normalizeStatus(task.status);
216
+ const metadata = task.metadata ?? {};
217
+ const blockedReason = typeof metadata.blocked_reason === "string" ? metadata.blocked_reason : undefined;
218
+ if (status === "blocked") {
219
+ if (!blockedReason)
220
+ continue;
221
+ const testsFailedCount = this.countFailures(progress, "work", "tests_failed");
222
+ if (blockedReason === "tests_failed" && testsFailedCount >= 2) {
223
+ warnings.push(`Task ${taskKey} remains blocked after repeated tests_failed; skipping reopen.`);
224
+ continue;
225
+ }
226
+ if (blockedReason === "dependency_not_ready") {
227
+ const depsReady = await this.dependenciesReady(task.id, warnings);
228
+ if (!depsReady)
229
+ continue;
230
+ }
231
+ else if (!RETRYABLE_BLOCK_REASONS.has(blockedReason)) {
232
+ continue;
233
+ }
234
+ }
235
+ else if (progress?.status !== "failed") {
236
+ continue;
237
+ }
238
+ const nextMetadata = { ...metadata };
239
+ delete nextMetadata.blocked_reason;
240
+ if (status === "blocked") {
241
+ await this.deps.workspaceRepo.updateTask(task.id, {
242
+ status: "in_progress",
243
+ metadata: nextMetadata,
244
+ });
245
+ }
246
+ if (progress) {
247
+ progress.status = "pending";
248
+ progress.lastError = undefined;
249
+ state.tasks[taskKey] = progress;
250
+ }
251
+ warnings.push(status === "blocked"
252
+ ? `Reopened blocked task ${taskKey} (reason=${blockedReason}) for retry.`
253
+ : `Reopened failed task ${taskKey} for retry (attempts=${progress?.attempts ?? 0}${maxIterations !== undefined ? `/${maxIterations}` : ""}).`);
254
+ }
255
+ }
256
+ async dependenciesReady(taskId, warnings) {
257
+ const deps = await this.deps.workspaceRepo.getTaskDependencies([taskId]);
258
+ if (!deps.length)
259
+ return true;
260
+ const depIds = deps.map((dep) => dep.dependsOnTaskId).filter((id) => Boolean(id));
261
+ if (!depIds.length)
262
+ return true;
263
+ const depTasks = await this.deps.workspaceRepo.getTasksByIds(depIds);
264
+ const depMap = new Map(depTasks.map((task) => [task.id, task]));
265
+ for (const depId of depIds) {
266
+ const depTask = depMap.get(depId);
267
+ if (!depTask) {
268
+ warnings.push(`Dependency ${depId} not found for task ${taskId}; treating as not ready.`);
269
+ return false;
270
+ }
271
+ const status = this.normalizeStatus(depTask.status);
272
+ if (!status || !DONE_DEPENDENCY_STATUSES.has(status))
273
+ return false;
274
+ }
275
+ return true;
276
+ }
277
+ normalizeStatus(status) {
278
+ return status ? status.toLowerCase().trim() : undefined;
279
+ }
280
+ resolveRequest(request, payload) {
281
+ if (!payload)
282
+ return request;
283
+ const raw = payload;
284
+ const payloadTasks = Array.isArray(raw.tasks) ? raw.tasks : undefined;
285
+ const payloadStatuses = Array.isArray(raw.statusFilter) ? raw.statusFilter : undefined;
286
+ return {
287
+ ...request,
288
+ projectKey: request.projectKey ?? raw.projectKey,
289
+ epicKey: request.epicKey ?? raw.epicKey,
290
+ storyKey: request.storyKey ?? raw.storyKey,
291
+ taskKeys: request.taskKeys && request.taskKeys.length ? request.taskKeys : payloadTasks,
292
+ statusFilter: request.statusFilter && request.statusFilter.length ? request.statusFilter : payloadStatuses,
293
+ limit: request.limit ?? raw.limit,
294
+ parallel: request.parallel ?? raw.parallel,
295
+ maxIterations: request.maxIterations ?? raw.maxIterations,
296
+ maxCycles: request.maxCycles ?? raw.maxCycles,
297
+ gatewayAgentName: request.gatewayAgentName ?? raw.gatewayAgentName,
298
+ workAgentName: request.workAgentName ?? raw.workAgentName,
299
+ reviewAgentName: request.reviewAgentName ?? raw.reviewAgentName,
300
+ qaAgentName: request.qaAgentName ?? raw.qaAgentName,
301
+ maxDocs: request.maxDocs ?? raw.maxDocs,
302
+ agentStream: request.agentStream ?? raw.agentStream,
303
+ noCommit: request.noCommit ?? raw.noCommit,
304
+ dryRun: request.dryRun ?? raw.dryRun,
305
+ reviewBase: request.reviewBase ?? raw.reviewBase,
306
+ maxAgentSeconds: request.maxAgentSeconds ?? raw.maxAgentSeconds,
307
+ qaProfileName: request.qaProfileName ?? raw.qaProfileName,
308
+ qaLevel: request.qaLevel ?? raw.qaLevel,
309
+ qaTestCommand: request.qaTestCommand ?? raw.qaTestCommand,
310
+ qaMode: request.qaMode ?? raw.qaMode,
311
+ qaFollowups: request.qaFollowups ?? raw.qaFollowups,
312
+ qaResult: request.qaResult ?? raw.qaResult,
313
+ qaNotes: request.qaNotes ?? raw.qaNotes,
314
+ qaEvidenceUrl: request.qaEvidenceUrl ?? raw.qaEvidenceUrl,
315
+ qaAllowDirty: request.qaAllowDirty ?? raw.qaAllowDirty,
316
+ escalateOnNoChange: request.escalateOnNoChange ?? raw.escalateOnNoChange,
317
+ };
318
+ }
319
+ async buildStatusFilter(request, warnings) {
320
+ const base = request.statusFilter && request.statusFilter.length ? request.statusFilter : DEFAULT_STATUS_FILTER;
321
+ const normalized = new Set(base.map((s) => this.normalizeStatus(s)).filter(Boolean));
322
+ const explicit = request.taskKeys ?? [];
323
+ for (const key of explicit) {
324
+ const task = await this.deps.workspaceRepo.getTaskByKey(key);
325
+ if (!task) {
326
+ warnings.push(`Task not found: ${key}`);
327
+ continue;
328
+ }
329
+ const status = this.normalizeStatus(task.status);
330
+ if (!status)
331
+ continue;
332
+ if (TERMINAL_STATUSES.has(status)) {
333
+ warnings.push(`Skipping terminal task ${key} (${status}).`);
334
+ continue;
335
+ }
336
+ if (!normalized.has(status))
337
+ normalized.add(status);
338
+ }
339
+ return Array.from(normalized);
340
+ }
341
+ async refreshTaskStatus(taskKey, warnings) {
342
+ const task = await this.deps.workspaceRepo.getTaskByKey(taskKey);
343
+ if (!task) {
344
+ warnings.push(`Task ${taskKey} not found while refreshing status.`);
345
+ return undefined;
346
+ }
347
+ return this.normalizeStatus(task.status);
348
+ }
349
+ parseWorkResult(taskKey, result) {
350
+ const entry = result.results.find((r) => r.taskKey === taskKey);
351
+ if (!entry) {
352
+ return { step: "work", status: "failed", error: "Task not processed by work-on-tasks" };
353
+ }
354
+ if (entry.status === "succeeded") {
355
+ if (entry.notes === "no_changes")
356
+ return { step: "work", status: "failed", error: "no_changes" };
357
+ return { step: "work", status: "succeeded" };
358
+ }
359
+ if (entry.status === "blocked")
360
+ return { step: "work", status: "blocked", error: entry.notes };
361
+ if (entry.status === "skipped")
362
+ return { step: "work", status: "skipped", error: entry.notes };
363
+ return { step: "work", status: "failed", error: entry.notes };
364
+ }
365
+ parseReviewResult(taskKey, result) {
366
+ const entry = result.tasks.find((t) => t.taskKey === taskKey);
367
+ if (!entry) {
368
+ return { step: "review", status: "failed", error: "Task not processed by code-review" };
369
+ }
370
+ if (entry.error) {
371
+ return { step: "review", status: "failed", error: entry.error };
372
+ }
373
+ const decision = entry.decision ?? "error";
374
+ if (decision === "approve" || decision === "info_only")
375
+ return { step: "review", status: "succeeded", decision };
376
+ if (decision === "block")
377
+ return { step: "review", status: "blocked", decision };
378
+ return { step: "review", status: "failed", decision };
379
+ }
380
+ parseQaResult(taskKey, result) {
381
+ const entry = result.results.find((r) => r.taskKey === taskKey);
382
+ if (!entry) {
383
+ return { step: "qa", status: "failed", error: "Task not processed by qa-tasks" };
384
+ }
385
+ if (entry.outcome === "pass")
386
+ return { step: "qa", status: "succeeded", outcome: entry.outcome };
387
+ if (entry.outcome === "infra_issue")
388
+ return { step: "qa", status: "blocked", outcome: entry.outcome };
389
+ if (entry.outcome === "fix_required" || entry.outcome === "unclear") {
390
+ return { step: "qa", status: "failed", outcome: entry.outcome };
391
+ }
392
+ return { step: "qa", status: "failed", outcome: entry.outcome };
393
+ }
394
+ shouldRetryAfter(step) {
395
+ if (step.status === "blocked" || step.status === "skipped")
396
+ return false;
397
+ return step.status !== "succeeded";
398
+ }
399
+ escalationReasons(escalateOnNoChange) {
400
+ const reasons = new Set(ESCALATION_REASONS);
401
+ if (escalateOnNoChange)
402
+ reasons.add(NO_CHANGE_REASON);
403
+ return reasons;
404
+ }
405
+ recordFailure(progress, step, attempt) {
406
+ if (step.status !== "failed" && step.status !== "blocked")
407
+ return;
408
+ const reason = step.error ?? step.decision ?? step.outcome;
409
+ const agent = step.chosenAgent;
410
+ if (!reason || !agent)
411
+ return;
412
+ const history = progress.failureHistory ?? [];
413
+ history.push({ step: step.step, agent, reason, attempt, timestamp: new Date().toISOString() });
414
+ progress.failureHistory = history;
415
+ }
416
+ countFailures(progress, step, reason) {
417
+ if (!progress?.failureHistory?.length)
418
+ return 0;
419
+ return progress.failureHistory.filter((failure) => failure.step === step && failure.reason === reason).length;
420
+ }
421
+ prioritizeFeedbackTasks(ordered, state) {
422
+ const feedback = new Set();
423
+ for (const progress of Object.values(state.tasks)) {
424
+ if (progress.lastDecision === "changes_requested") {
425
+ feedback.add(progress.taskKey);
426
+ continue;
427
+ }
428
+ if (progress.lastOutcome === "fix_required" || progress.lastOutcome === "unclear") {
429
+ feedback.add(progress.taskKey);
430
+ }
431
+ }
432
+ if (feedback.size === 0)
433
+ return ordered;
434
+ const prioritized = [];
435
+ const remaining = [];
436
+ for (const entry of ordered) {
437
+ if (feedback.has(entry.task.key)) {
438
+ prioritized.push(entry);
439
+ }
440
+ else {
441
+ remaining.push(entry);
442
+ }
443
+ }
444
+ return [...prioritized, ...remaining];
445
+ }
446
+ buildAgentOptions(progress, step, request) {
447
+ const reasons = this.escalationReasons(request.escalateOnNoChange !== false);
448
+ const avoid = new Set();
449
+ const history = progress.failureHistory ?? [];
450
+ for (const failure of history) {
451
+ if (failure.step !== step)
452
+ continue;
453
+ if (!reasons.has(failure.reason))
454
+ continue;
455
+ avoid.add(failure.agent);
456
+ }
457
+ const avoidAgents = Array.from(avoid);
458
+ return { avoidAgents, forceStronger: avoidAgents.length > 0 };
459
+ }
460
+ recordRating(progress, summary) {
461
+ if (!summary)
462
+ return;
463
+ const existing = progress.ratings ?? [];
464
+ const next = existing.filter((entry) => entry.step !== summary.step);
465
+ next.push(summary);
466
+ progress.ratings = next;
467
+ }
468
+ async loadRatingSummary(jobId, step, agent) {
469
+ if (!jobId)
470
+ return undefined;
471
+ try {
472
+ const payload = await fs.readFile(path.join(this.workspace.workspaceRoot, ".mcoda", "jobs", jobId, "rating.json"), "utf8");
473
+ const parsed = JSON.parse(payload);
474
+ const rating = typeof parsed.rating === "number" ? parsed.rating : undefined;
475
+ const maxComplexity = typeof parsed.maxComplexity === "number" ? parsed.maxComplexity : undefined;
476
+ const runScore = typeof parsed.runScore === "number" ? parsed.runScore : undefined;
477
+ const qualityScore = typeof parsed.qualityScore === "number" ? parsed.qualityScore : undefined;
478
+ return { step, agent, rating, maxComplexity, runScore, qualityScore };
479
+ }
480
+ catch {
481
+ return undefined;
482
+ }
483
+ }
484
+ async runStepWithTimeout(step, jobId, taskKey, attempt, maxAgentSeconds, fn) {
485
+ await this.deps.jobService.writeCheckpoint(jobId, {
486
+ stage: `task:${taskKey}:${step}:start`,
487
+ timestamp: new Date().toISOString(),
488
+ details: { taskKey, attempt, step },
489
+ });
490
+ const timeoutMs = typeof maxAgentSeconds === "number" && maxAgentSeconds > 0 ? maxAgentSeconds * 1000 : undefined;
491
+ let timeoutHandle;
492
+ const controller = new AbortController();
493
+ const heartbeat = setInterval(() => {
494
+ void this.deps.jobService.writeCheckpoint(jobId, {
495
+ stage: `task:${taskKey}:${step}:heartbeat`,
496
+ timestamp: new Date().toISOString(),
497
+ details: { taskKey, attempt, step },
498
+ });
499
+ }, HEARTBEAT_INTERVAL_MS);
500
+ if (typeof heartbeat.unref === "function") {
501
+ heartbeat.unref();
502
+ }
503
+ try {
504
+ if (timeoutMs) {
505
+ const timeoutPromise = new Promise((_, reject) => {
506
+ timeoutHandle = setTimeout(() => {
507
+ controller.abort("agent_timeout");
508
+ reject(new Error("agent_timeout"));
509
+ }, timeoutMs);
510
+ });
511
+ return await Promise.race([fn(controller.signal), timeoutPromise]);
512
+ }
513
+ return await fn(controller.signal);
514
+ }
515
+ catch (error) {
516
+ const message = error?.message ?? String(error);
517
+ return { step, status: "failed", error: message === "agent_timeout" ? "agent_timeout" : message };
518
+ }
519
+ finally {
520
+ if (timeoutHandle)
521
+ clearTimeout(timeoutHandle);
522
+ clearInterval(heartbeat);
523
+ }
524
+ }
525
+ async runGateway(job, taskKey, projectKey, request, agentOptions) {
526
+ return this.deps.gatewayService.run({
527
+ workspace: this.workspace,
528
+ job,
529
+ projectKey,
530
+ taskKeys: [taskKey],
531
+ gatewayAgentName: request.gatewayAgentName,
532
+ maxDocs: request.maxDocs,
533
+ agentStream: request.agentStream,
534
+ rateAgents: request.rateAgents,
535
+ avoidAgents: agentOptions?.avoidAgents,
536
+ forceStronger: agentOptions?.forceStronger,
537
+ });
538
+ }
539
+ async runWorkStep(jobId, attempt, taskKey, projectKey, statusFilter, request, warnings, agentOptions, abortSignal, onResolvedAgent) {
540
+ let gateway;
541
+ let handoff;
542
+ let resolvedAgent;
543
+ try {
544
+ gateway = await this.runGateway("work-on-tasks", taskKey, projectKey, request, agentOptions);
545
+ handoff = buildGatewayHandoffContent(gateway);
546
+ resolvedAgent = request.workAgentName ?? gateway.chosenAgent.agentSlug;
547
+ }
548
+ catch (error) {
549
+ const message = error instanceof Error ? error.message : String(error);
550
+ if (!request.workAgentName)
551
+ throw error;
552
+ resolvedAgent = request.workAgentName;
553
+ handoff = [
554
+ "# Gateway Handoff",
555
+ "",
556
+ `Gateway agent failed; proceeding with override agent ${resolvedAgent}.`,
557
+ `Error: ${message}`,
558
+ "",
559
+ buildGatewayHandoffDocdexUsage(),
560
+ ].join("\n");
561
+ warnings.push(`Gateway agent failed for work ${taskKey}; using override ${resolvedAgent}: ${message}`);
562
+ }
563
+ if (!resolvedAgent) {
564
+ throw new Error(`No agent resolved for work step on ${taskKey}`);
565
+ }
566
+ if (onResolvedAgent) {
567
+ await onResolvedAgent(resolvedAgent);
568
+ }
569
+ const handoffPath = await this.prepareHandoff(jobId, taskKey, "work", attempt, handoff);
570
+ const result = await withGatewayHandoff(handoffPath, async () => this.deps.workService.workOnTasks({
571
+ workspace: this.workspace,
572
+ projectKey,
573
+ taskKeys: [taskKey],
574
+ statusFilter,
575
+ limit: 1,
576
+ noCommit: request.noCommit,
577
+ dryRun: request.dryRun,
578
+ agentName: resolvedAgent,
579
+ agentStream: request.agentStream,
580
+ rateAgents: request.rateAgents,
581
+ abortSignal,
582
+ maxAgentSeconds: request.maxAgentSeconds,
583
+ }));
584
+ const parsed = this.parseWorkResult(taskKey, result);
585
+ const ratingSummary = request.rateAgents
586
+ ? await this.loadRatingSummary(result.jobId, "work", resolvedAgent)
587
+ : undefined;
588
+ return { ...parsed, chosenAgent: resolvedAgent, ratingSummary };
589
+ }
590
+ async runReviewStep(jobId, attempt, taskKey, projectKey, statusFilter, request, warnings, agentOptions, abortSignal, onResolvedAgent) {
591
+ let gateway;
592
+ let handoff;
593
+ let resolvedAgent;
594
+ try {
595
+ gateway = await this.runGateway("code-review", taskKey, projectKey, request, agentOptions);
596
+ handoff = buildGatewayHandoffContent(gateway);
597
+ resolvedAgent = request.reviewAgentName ?? gateway.chosenAgent.agentSlug;
598
+ }
599
+ catch (error) {
600
+ const message = error instanceof Error ? error.message : String(error);
601
+ if (!request.reviewAgentName)
602
+ throw error;
603
+ resolvedAgent = request.reviewAgentName;
604
+ handoff = [
605
+ "# Gateway Handoff",
606
+ "",
607
+ `Gateway agent failed; proceeding with override agent ${resolvedAgent}.`,
608
+ `Error: ${message}`,
609
+ "",
610
+ buildGatewayHandoffDocdexUsage(),
611
+ ].join("\n");
612
+ warnings.push(`Gateway agent failed for review ${taskKey}; using override ${resolvedAgent}: ${message}`);
613
+ }
614
+ if (!resolvedAgent) {
615
+ throw new Error(`No agent resolved for review step on ${taskKey}`);
616
+ }
617
+ if (onResolvedAgent) {
618
+ await onResolvedAgent(resolvedAgent);
619
+ }
620
+ const handoffPath = await this.prepareHandoff(jobId, taskKey, "review", attempt, handoff);
621
+ const result = await withGatewayHandoff(handoffPath, async () => this.deps.reviewService.reviewTasks({
622
+ workspace: this.workspace,
623
+ projectKey,
624
+ taskKeys: [taskKey],
625
+ statusFilter,
626
+ baseRef: request.reviewBase,
627
+ dryRun: request.dryRun,
628
+ agentName: resolvedAgent,
629
+ agentStream: request.agentStream,
630
+ rateAgents: request.rateAgents,
631
+ abortSignal,
632
+ }));
633
+ const parsed = this.parseReviewResult(taskKey, result);
634
+ const ratingSummary = request.rateAgents
635
+ ? await this.loadRatingSummary(result.jobId, "review", resolvedAgent)
636
+ : undefined;
637
+ return { ...parsed, chosenAgent: resolvedAgent, ratingSummary };
638
+ }
639
+ async runQaStep(jobId, attempt, taskKey, projectKey, statusFilter, request, warnings, agentOptions, abortSignal, onResolvedAgent) {
640
+ let gateway;
641
+ let handoff;
642
+ let resolvedAgent;
643
+ try {
644
+ gateway = await this.runGateway("qa-tasks", taskKey, projectKey, request, agentOptions);
645
+ handoff = buildGatewayHandoffContent(gateway);
646
+ resolvedAgent = request.qaAgentName ?? gateway.chosenAgent.agentSlug;
647
+ }
648
+ catch (error) {
649
+ const message = error instanceof Error ? error.message : String(error);
650
+ if (!request.qaAgentName)
651
+ throw error;
652
+ resolvedAgent = request.qaAgentName;
653
+ handoff = [
654
+ "# Gateway Handoff",
655
+ "",
656
+ `Gateway agent failed; proceeding with override agent ${resolvedAgent}.`,
657
+ `Error: ${message}`,
658
+ "",
659
+ buildGatewayHandoffDocdexUsage(),
660
+ ].join("\n");
661
+ warnings.push(`Gateway agent failed for QA ${taskKey}; using override ${resolvedAgent}: ${message}`);
662
+ }
663
+ if (!resolvedAgent) {
664
+ throw new Error(`No agent resolved for QA step on ${taskKey}`);
665
+ }
666
+ if (onResolvedAgent) {
667
+ await onResolvedAgent(resolvedAgent);
668
+ }
669
+ const handoffPath = await this.prepareHandoff(jobId, taskKey, "qa", attempt, handoff);
670
+ const result = await withGatewayHandoff(handoffPath, async () => this.deps.qaService.run({
671
+ workspace: this.workspace,
672
+ projectKey,
673
+ taskKeys: [taskKey],
674
+ statusFilter,
675
+ mode: request.qaMode ?? "auto",
676
+ profileName: request.qaProfileName,
677
+ level: request.qaLevel,
678
+ testCommand: request.qaTestCommand,
679
+ agentName: resolvedAgent,
680
+ agentStream: request.agentStream,
681
+ rateAgents: request.rateAgents,
682
+ createFollowupTasks: request.qaFollowups ?? "auto",
683
+ dryRun: request.dryRun,
684
+ result: request.qaResult,
685
+ notes: request.qaNotes,
686
+ evidenceUrl: request.qaEvidenceUrl,
687
+ allowDirty: request.qaAllowDirty,
688
+ abortSignal,
689
+ }));
690
+ const parsed = this.parseQaResult(taskKey, result);
691
+ const ratingSummary = request.rateAgents
692
+ ? await this.loadRatingSummary(result.jobId, "qa", resolvedAgent)
693
+ : undefined;
694
+ return { ...parsed, chosenAgent: resolvedAgent, ratingSummary };
695
+ }
696
+ toSummary(state) {
697
+ return Object.values(state.tasks).map((task) => ({
698
+ taskKey: task.taskKey,
699
+ attempts: task.attempts,
700
+ status: task.status,
701
+ lastStep: task.lastStep,
702
+ lastDecision: task.lastDecision,
703
+ lastOutcome: task.lastOutcome,
704
+ lastError: task.lastError,
705
+ chosenAgents: task.chosenAgents,
706
+ ratings: task.ratings,
707
+ }));
708
+ }
709
+ async run(request) {
710
+ const warnings = [];
711
+ let resumeJob;
712
+ if (request.resumeJobId) {
713
+ const job = await this.deps.jobService.getJob(request.resumeJobId);
714
+ if (!job)
715
+ throw new Error(`Job not found: ${request.resumeJobId}`);
716
+ const manifest = await this.readManifest(job.id);
717
+ this.assertResumeAllowed(job, manifest);
718
+ const command = (job.commandName ?? job.type ?? "").toLowerCase();
719
+ if (command !== "gateway-trio") {
720
+ throw new Error(`Job ${request.resumeJobId} is not a gateway-trio job`);
721
+ }
722
+ resumeJob = job;
723
+ }
724
+ const resolvedRequest = this.resolveRequest(request, resumeJob?.payload);
725
+ const maxIterations = resolvedRequest.maxIterations;
726
+ const maxCycles = resolvedRequest.maxCycles;
727
+ const maxAgentSeconds = resolvedRequest.maxAgentSeconds;
728
+ if (!resolvedRequest.rateAgents) {
729
+ warnings.push("Agent rating disabled; use --rate-agents to track rating/complexity updates.");
730
+ }
731
+ const statusFilter = await this.buildStatusFilter(resolvedRequest, warnings);
732
+ const commandRun = await this.deps.jobService.startCommandRun("gateway-trio", resolvedRequest.projectKey, {
733
+ taskIds: resolvedRequest.taskKeys,
734
+ jobId: request.resumeJobId,
735
+ });
736
+ let jobId = request.resumeJobId;
737
+ let state;
738
+ if (request.resumeJobId) {
739
+ state = await this.loadState(request.resumeJobId);
740
+ if (!state)
741
+ throw new Error(`Missing gateway-trio state for job ${request.resumeJobId}`);
742
+ await this.deps.jobService.updateJobStatus(request.resumeJobId, "running", {
743
+ job_state_detail: "resuming",
744
+ });
745
+ }
746
+ else {
747
+ const job = await this.deps.jobService.startJob("gateway-trio", commandRun.id, resolvedRequest.projectKey, {
748
+ commandName: "gateway-trio",
749
+ payload: {
750
+ projectKey: resolvedRequest.projectKey,
751
+ epicKey: resolvedRequest.epicKey,
752
+ storyKey: resolvedRequest.storyKey,
753
+ tasks: resolvedRequest.taskKeys,
754
+ statusFilter,
755
+ maxIterations,
756
+ maxCycles,
757
+ limit: resolvedRequest.limit,
758
+ parallel: resolvedRequest.parallel,
759
+ gatewayAgentName: resolvedRequest.gatewayAgentName,
760
+ workAgentName: resolvedRequest.workAgentName,
761
+ reviewAgentName: resolvedRequest.reviewAgentName,
762
+ qaAgentName: resolvedRequest.qaAgentName,
763
+ maxDocs: resolvedRequest.maxDocs,
764
+ agentStream: resolvedRequest.agentStream,
765
+ noCommit: resolvedRequest.noCommit,
766
+ dryRun: resolvedRequest.dryRun,
767
+ reviewBase: resolvedRequest.reviewBase,
768
+ maxAgentSeconds,
769
+ qaProfileName: resolvedRequest.qaProfileName,
770
+ qaLevel: resolvedRequest.qaLevel,
771
+ qaTestCommand: resolvedRequest.qaTestCommand,
772
+ qaMode: resolvedRequest.qaMode,
773
+ qaFollowups: resolvedRequest.qaFollowups,
774
+ qaResult: resolvedRequest.qaResult,
775
+ qaNotes: resolvedRequest.qaNotes,
776
+ qaEvidenceUrl: resolvedRequest.qaEvidenceUrl,
777
+ qaAllowDirty: resolvedRequest.qaAllowDirty,
778
+ escalateOnNoChange: resolvedRequest.escalateOnNoChange,
779
+ resumeSupported: true,
780
+ },
781
+ totalItems: 0,
782
+ processedItems: 0,
783
+ });
784
+ jobId = job.id;
785
+ state = {
786
+ schema_version: 1,
787
+ job_id: job.id,
788
+ command_run_id: commandRun.id,
789
+ cycle: 0,
790
+ tasks: {},
791
+ };
792
+ await this.writeState(state);
793
+ }
794
+ if (!jobId || !state) {
795
+ throw new Error("gateway-trio job initialization failed");
796
+ }
797
+ if (resolvedRequest.onJobStart) {
798
+ resolvedRequest.onJobStart(jobId, commandRun.id);
799
+ }
800
+ await this.cleanupExpiredTaskLocks(warnings);
801
+ const explicitTasks = new Set(resolvedRequest.taskKeys ?? []);
802
+ await this.seedExplicitTasks(state, explicitTasks, warnings);
803
+ await this.writeState(state);
804
+ let cycle = state.cycle ?? 0;
805
+ try {
806
+ while (maxCycles === undefined || cycle < maxCycles) {
807
+ await this.reopenRetryableBlockedTasks(state, explicitTasks, maxIterations, warnings);
808
+ await this.writeState(state);
809
+ const selection = await this.selectionService.selectTasks({
810
+ projectKey: resolvedRequest.projectKey,
811
+ epicKey: resolvedRequest.epicKey,
812
+ storyKey: resolvedRequest.storyKey,
813
+ taskKeys: resolvedRequest.taskKeys,
814
+ statusFilter,
815
+ limit: resolvedRequest.limit,
816
+ parallel: resolvedRequest.parallel,
817
+ });
818
+ const blockedKeys = new Set(selection.blocked.map((t) => t.task.key));
819
+ if (selection.warnings.length)
820
+ warnings.push(...selection.warnings);
821
+ const ordered = this.prioritizeFeedbackTasks(selection.ordered, state);
822
+ for (const blocked of selection.blocked) {
823
+ const taskKey = blocked.task.key;
824
+ if (explicitTasks.has(taskKey))
825
+ continue;
826
+ const progress = this.ensureProgress(state, taskKey);
827
+ progress.status = "skipped";
828
+ progress.lastError = "dependency_blocked";
829
+ state.tasks[taskKey] = progress;
830
+ }
831
+ await this.writeState(state);
832
+ await this.deps.jobService.updateJobStatus(jobId, "running", {
833
+ totalItems: ordered.length,
834
+ processedItems: 0,
835
+ });
836
+ let completedThisCycle = 0;
837
+ let processedThisCycle = 0;
838
+ let attemptedThisCycle = 0;
839
+ for (const entry of ordered) {
840
+ const taskKey = entry.task.key;
841
+ if (blockedKeys.has(taskKey) && !explicitTasks.has(taskKey)) {
842
+ warnings.push(`Task ${taskKey} blocked by dependencies; skipping this cycle.`);
843
+ const progress = this.ensureProgress(state, taskKey);
844
+ progress.status = "skipped";
845
+ progress.lastError = "dependency_blocked";
846
+ state.tasks[taskKey] = progress;
847
+ await this.writeState(state);
848
+ continue;
849
+ }
850
+ const normalizedStatus = this.normalizeStatus(entry.task.status);
851
+ if (normalizedStatus && TERMINAL_STATUSES.has(normalizedStatus)) {
852
+ warnings.push(`Skipping terminal task ${taskKey} (${normalizedStatus}).`);
853
+ continue;
854
+ }
855
+ const progress = this.ensureProgress(state, taskKey);
856
+ if (progress.status === "skipped") {
857
+ progress.status = "pending";
858
+ progress.lastError = undefined;
859
+ }
860
+ if (progress.status === "completed" || progress.status === "blocked") {
861
+ continue;
862
+ }
863
+ if (progress.status === "failed") {
864
+ if (this.hasReachedMaxIterations(progress, maxIterations)) {
865
+ continue;
866
+ }
867
+ progress.status = "pending";
868
+ progress.lastError = undefined;
869
+ state.tasks[taskKey] = progress;
870
+ }
871
+ if (this.hasReachedMaxIterations(progress, maxIterations)) {
872
+ progress.status = "failed";
873
+ progress.lastError = "max_iterations_reached";
874
+ state.tasks[taskKey] = progress;
875
+ if (maxIterations !== undefined) {
876
+ warnings.push(`Task ${taskKey} hit max iterations (${maxIterations}).`);
877
+ }
878
+ continue;
879
+ }
880
+ const attemptIndex = progress.attempts + 1;
881
+ attemptedThisCycle += 1;
882
+ const projectKey = await this.projectKeyForTask(entry.task.projectId);
883
+ const statusBefore = this.normalizeStatus(entry.task.status);
884
+ const workWasSkipped = statusBefore === "ready_to_review" || statusBefore === "ready_to_qa";
885
+ const reviewWasSkipped = statusBefore === "ready_to_qa";
886
+ progress.attempts = attemptIndex;
887
+ progress.lastError = undefined;
888
+ state.tasks[taskKey] = progress;
889
+ await this.writeState(state);
890
+ if (!workWasSkipped) {
891
+ const workAgentOptions = this.buildAgentOptions(progress, "work", resolvedRequest);
892
+ progress.lastStep = "work";
893
+ state.tasks[taskKey] = progress;
894
+ await this.writeState(state);
895
+ const workOutcome = await this.runStepWithTimeout("work", jobId, taskKey, attemptIndex, maxAgentSeconds, (signal) => this.runWorkStep(jobId, attemptIndex, taskKey, projectKey, statusFilter, resolvedRequest, warnings, workAgentOptions, signal, async (agent) => {
896
+ progress.chosenAgents.work = agent;
897
+ state.tasks[taskKey] = progress;
898
+ await this.writeState(state);
899
+ }));
900
+ progress.lastStep = "work";
901
+ progress.lastError = workOutcome.error;
902
+ progress.chosenAgents.work = workOutcome.chosenAgent ?? progress.chosenAgents.work;
903
+ this.recordFailure(progress, workOutcome, attemptIndex);
904
+ this.recordRating(progress, workOutcome.ratingSummary);
905
+ state.tasks[taskKey] = progress;
906
+ await this.writeState(state);
907
+ await this.deps.jobService.writeCheckpoint(jobId, {
908
+ stage: `task:${taskKey}:work`,
909
+ timestamp: new Date().toISOString(),
910
+ details: { taskKey, attempt: attemptIndex, outcome: workOutcome },
911
+ });
912
+ if (workOutcome.error === "tests_failed") {
913
+ const testsFailedCount = this.countFailures(progress, "work", "tests_failed");
914
+ if (testsFailedCount >= 2) {
915
+ progress.status = "blocked";
916
+ progress.lastError = "tests_failed";
917
+ state.tasks[taskKey] = progress;
918
+ await this.writeState(state);
919
+ warnings.push(`Task ${taskKey} blocked after repeated tests_failed.`);
920
+ continue;
921
+ }
922
+ warnings.push(`Retrying ${taskKey} after tests_failed with stronger agent.`);
923
+ state.tasks[taskKey] = progress;
924
+ await this.writeState(state);
925
+ continue;
926
+ }
927
+ if (workOutcome.status === "blocked") {
928
+ progress.status = "blocked";
929
+ progress.lastError = workOutcome.error ?? "blocked";
930
+ state.tasks[taskKey] = progress;
931
+ await this.writeState(state);
932
+ continue;
933
+ }
934
+ if (workOutcome.status === "skipped") {
935
+ progress.status = "skipped";
936
+ progress.lastError = workOutcome.error ?? "skipped";
937
+ state.tasks[taskKey] = progress;
938
+ await this.writeState(state);
939
+ continue;
940
+ }
941
+ if (workOutcome.status !== "succeeded") {
942
+ if (this.shouldRetryAfter(workOutcome)) {
943
+ warnings.push(`Retrying ${taskKey} after work step (${workOutcome.status}).`);
944
+ state.tasks[taskKey] = progress;
945
+ await this.writeState(state);
946
+ continue;
947
+ }
948
+ }
949
+ if (!resolvedRequest.dryRun) {
950
+ const statusAfterWork = await this.refreshTaskStatus(taskKey, warnings);
951
+ if (statusAfterWork && statusAfterWork !== "ready_to_review") {
952
+ warnings.push(`Task ${taskKey} status ${statusAfterWork} after work; retrying work step.`);
953
+ continue;
954
+ }
955
+ }
956
+ }
957
+ else {
958
+ progress.lastStep = "work";
959
+ progress.lastError = undefined;
960
+ state.tasks[taskKey] = progress;
961
+ await this.writeState(state);
962
+ await this.deps.jobService.writeCheckpoint(jobId, {
963
+ stage: `task:${taskKey}:work:skipped`,
964
+ timestamp: new Date().toISOString(),
965
+ details: { taskKey, attempt: attemptIndex, reason: statusBefore ?? "ready" },
966
+ });
967
+ }
968
+ if (!reviewWasSkipped) {
969
+ const reviewAgentOptions = this.buildAgentOptions(progress, "review", resolvedRequest);
970
+ progress.lastStep = "review";
971
+ state.tasks[taskKey] = progress;
972
+ await this.writeState(state);
973
+ const reviewOutcome = await this.runStepWithTimeout("review", jobId, taskKey, attemptIndex, maxAgentSeconds, (signal) => this.runReviewStep(jobId, attemptIndex, taskKey, projectKey, ["ready_to_review", ...statusFilter], resolvedRequest, warnings, reviewAgentOptions, signal, async (agent) => {
974
+ progress.chosenAgents.review = agent;
975
+ state.tasks[taskKey] = progress;
976
+ await this.writeState(state);
977
+ }));
978
+ progress.lastStep = "review";
979
+ progress.lastDecision = reviewOutcome.decision;
980
+ progress.lastError = reviewOutcome.error;
981
+ progress.chosenAgents.review = reviewOutcome.chosenAgent ?? progress.chosenAgents.review;
982
+ this.recordFailure(progress, reviewOutcome, attemptIndex);
983
+ this.recordRating(progress, reviewOutcome.ratingSummary);
984
+ state.tasks[taskKey] = progress;
985
+ await this.writeState(state);
986
+ await this.deps.jobService.writeCheckpoint(jobId, {
987
+ stage: `task:${taskKey}:review`,
988
+ timestamp: new Date().toISOString(),
989
+ details: { taskKey, attempt: attemptIndex, outcome: reviewOutcome },
990
+ });
991
+ if (reviewOutcome.status === "blocked") {
992
+ progress.status = "blocked";
993
+ progress.lastError = reviewOutcome.error ?? "blocked";
994
+ state.tasks[taskKey] = progress;
995
+ await this.writeState(state);
996
+ continue;
997
+ }
998
+ if (this.shouldRetryAfter(reviewOutcome)) {
999
+ warnings.push(`Retrying ${taskKey} after review (${reviewOutcome.decision ?? reviewOutcome.status}).`);
1000
+ state.tasks[taskKey] = progress;
1001
+ await this.writeState(state);
1002
+ continue;
1003
+ }
1004
+ if (!resolvedRequest.dryRun) {
1005
+ const statusAfterReview = await this.refreshTaskStatus(taskKey, warnings);
1006
+ if (statusAfterReview && statusAfterReview !== "ready_to_qa") {
1007
+ warnings.push(`Task ${taskKey} status ${statusAfterReview} after review; retrying work step.`);
1008
+ continue;
1009
+ }
1010
+ }
1011
+ }
1012
+ else {
1013
+ progress.lastStep = "review";
1014
+ progress.lastDecision = "skipped";
1015
+ progress.lastError = undefined;
1016
+ state.tasks[taskKey] = progress;
1017
+ await this.writeState(state);
1018
+ await this.deps.jobService.writeCheckpoint(jobId, {
1019
+ stage: `task:${taskKey}:review:skipped`,
1020
+ timestamp: new Date().toISOString(),
1021
+ details: { taskKey, attempt: attemptIndex, reason: statusBefore ?? "ready_to_qa" },
1022
+ });
1023
+ }
1024
+ progress.lastStep = "qa";
1025
+ state.tasks[taskKey] = progress;
1026
+ await this.writeState(state);
1027
+ const qaOutcome = await this.runStepWithTimeout("qa", jobId, taskKey, attemptIndex, maxAgentSeconds, (signal) => this.runQaStep(jobId, attemptIndex, taskKey, projectKey, ["ready_to_qa", ...statusFilter], resolvedRequest, warnings, this.buildAgentOptions(progress, "qa", resolvedRequest), signal, async (agent) => {
1028
+ progress.chosenAgents.qa = agent;
1029
+ state.tasks[taskKey] = progress;
1030
+ await this.writeState(state);
1031
+ }));
1032
+ progress.lastStep = "qa";
1033
+ progress.lastOutcome = qaOutcome.outcome;
1034
+ progress.lastError = qaOutcome.error;
1035
+ progress.chosenAgents.qa = qaOutcome.chosenAgent ?? progress.chosenAgents.qa;
1036
+ this.recordFailure(progress, qaOutcome, attemptIndex);
1037
+ this.recordRating(progress, qaOutcome.ratingSummary);
1038
+ state.tasks[taskKey] = progress;
1039
+ await this.writeState(state);
1040
+ await this.deps.jobService.writeCheckpoint(jobId, {
1041
+ stage: `task:${taskKey}:qa`,
1042
+ timestamp: new Date().toISOString(),
1043
+ details: { taskKey, attempt: attemptIndex, outcome: qaOutcome },
1044
+ });
1045
+ if (qaOutcome.status === "blocked") {
1046
+ progress.status = "blocked";
1047
+ progress.lastError = qaOutcome.error ?? "blocked";
1048
+ state.tasks[taskKey] = progress;
1049
+ await this.writeState(state);
1050
+ continue;
1051
+ }
1052
+ if (this.shouldRetryAfter(qaOutcome)) {
1053
+ warnings.push(`Retrying ${taskKey} after QA (${qaOutcome.outcome ?? qaOutcome.status}).`);
1054
+ state.tasks[taskKey] = progress;
1055
+ await this.writeState(state);
1056
+ continue;
1057
+ }
1058
+ if (!resolvedRequest.dryRun) {
1059
+ const statusAfterQa = await this.refreshTaskStatus(taskKey, warnings);
1060
+ if (statusAfterQa && statusAfterQa !== "completed") {
1061
+ warnings.push(`Task ${taskKey} status ${statusAfterQa} after QA; retrying work step.`);
1062
+ state.tasks[taskKey] = progress;
1063
+ await this.writeState(state);
1064
+ continue;
1065
+ }
1066
+ }
1067
+ progress.status = "completed";
1068
+ state.tasks[taskKey] = progress;
1069
+ await this.writeState(state);
1070
+ completedThisCycle += 1;
1071
+ processedThisCycle += 1;
1072
+ await this.deps.jobService.updateJobStatus(jobId, "running", {
1073
+ processedItems: processedThisCycle,
1074
+ });
1075
+ }
1076
+ cycle += 1;
1077
+ state.cycle = cycle;
1078
+ await this.writeState(state);
1079
+ if (attemptedThisCycle === 0) {
1080
+ const hasPending = Object.values(state.tasks).some((task) => task.status === "pending" && this.hasIterationsRemaining(task, maxIterations));
1081
+ if (hasPending) {
1082
+ if (maxCycles === undefined) {
1083
+ warnings.push("No tasks attempted in this cycle; pending tasks remain, stopping to avoid infinite loop without max-cycles.");
1084
+ break;
1085
+ }
1086
+ warnings.push("No tasks attempted in this cycle; pending tasks remain, continuing.");
1087
+ continue;
1088
+ }
1089
+ warnings.push("No tasks attempted in this cycle; stopping to avoid infinite loop.");
1090
+ break;
1091
+ }
1092
+ }
1093
+ const summaries = this.toSummary(state);
1094
+ const blocked = summaries.filter((t) => t.status === "blocked").map((t) => t.taskKey);
1095
+ const failed = summaries.filter((t) => t.status === "failed").map((t) => t.taskKey);
1096
+ const skipped = summaries.filter((t) => t.status === "skipped").map((t) => t.taskKey);
1097
+ const pending = summaries.filter((t) => t.status === "pending").map((t) => t.taskKey);
1098
+ const failureCount = failed.length + blocked.length + skipped.length + pending.length;
1099
+ const endState = failureCount === 0 ? "completed" : "partial";
1100
+ const errorSummary = failureCount ? `${failureCount} task(s) not fully completed` : undefined;
1101
+ await this.deps.jobService.updateJobStatus(jobId, endState, { errorSummary });
1102
+ await this.deps.jobService.finishCommandRun(commandRun.id, endState === "completed" ? "succeeded" : "failed", errorSummary);
1103
+ await this.deps.jobService.writeCheckpoint(jobId, {
1104
+ stage: "completed",
1105
+ timestamp: new Date().toISOString(),
1106
+ details: { cycle, tasks: summaries },
1107
+ });
1108
+ return {
1109
+ jobId,
1110
+ commandRunId: commandRun.id,
1111
+ tasks: summaries,
1112
+ warnings,
1113
+ blocked,
1114
+ failed,
1115
+ skipped,
1116
+ };
1117
+ }
1118
+ catch (error) {
1119
+ const message = error instanceof Error ? error.message : String(error);
1120
+ await this.deps.jobService.updateJobStatus(jobId, "failed", { errorSummary: message });
1121
+ await this.deps.jobService.finishCommandRun(commandRun.id, "failed", message);
1122
+ throw error;
1123
+ }
1124
+ }
1125
+ }