@mcoda/core 0.1.4
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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/api/AgentsApi.d.ts +36 -0
- package/dist/api/AgentsApi.d.ts.map +1 -0
- package/dist/api/AgentsApi.js +176 -0
- package/dist/api/QaTasksApi.d.ts +8 -0
- package/dist/api/QaTasksApi.d.ts.map +1 -0
- package/dist/api/QaTasksApi.js +36 -0
- package/dist/api/TasksApi.d.ts +7 -0
- package/dist/api/TasksApi.d.ts.map +1 -0
- package/dist/api/TasksApi.js +34 -0
- package/dist/config/ConfigService.d.ts +3 -0
- package/dist/config/ConfigService.d.ts.map +1 -0
- package/dist/config/ConfigService.js +2 -0
- package/dist/domain/dependencies/Dependency.d.ts +3 -0
- package/dist/domain/dependencies/Dependency.d.ts.map +1 -0
- package/dist/domain/dependencies/Dependency.js +2 -0
- package/dist/domain/epics/Epic.d.ts +3 -0
- package/dist/domain/epics/Epic.d.ts.map +1 -0
- package/dist/domain/epics/Epic.js +2 -0
- package/dist/domain/projects/Project.d.ts +3 -0
- package/dist/domain/projects/Project.d.ts.map +1 -0
- package/dist/domain/projects/Project.js +2 -0
- package/dist/domain/tasks/Task.d.ts +3 -0
- package/dist/domain/tasks/Task.d.ts.map +1 -0
- package/dist/domain/tasks/Task.js +2 -0
- package/dist/domain/userStories/UserStory.d.ts +3 -0
- package/dist/domain/userStories/UserStory.d.ts.map +1 -0
- package/dist/domain/userStories/UserStory.js +2 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/prompts/PdrPrompts.d.ts +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -0
- package/dist/prompts/PdrPrompts.js +21 -0
- package/dist/prompts/PromptLoader.d.ts +3 -0
- package/dist/prompts/PromptLoader.d.ts.map +1 -0
- package/dist/prompts/PromptLoader.js +2 -0
- package/dist/prompts/SdsPrompts.d.ts +5 -0
- package/dist/prompts/SdsPrompts.d.ts.map +1 -0
- package/dist/prompts/SdsPrompts.js +44 -0
- package/dist/services/agents/AgentManagementService.d.ts +3 -0
- package/dist/services/agents/AgentManagementService.d.ts.map +1 -0
- package/dist/services/agents/AgentManagementService.js +2 -0
- package/dist/services/agents/GatewayAgentService.d.ts +92 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -0
- package/dist/services/agents/GatewayAgentService.js +870 -0
- package/dist/services/agents/RoutingApiClient.d.ts +23 -0
- package/dist/services/agents/RoutingApiClient.d.ts.map +1 -0
- package/dist/services/agents/RoutingApiClient.js +62 -0
- package/dist/services/agents/RoutingService.d.ts +50 -0
- package/dist/services/agents/RoutingService.d.ts.map +1 -0
- package/dist/services/agents/RoutingService.js +386 -0
- package/dist/services/agents/generated/RoutingApiClient.d.ts +21 -0
- package/dist/services/agents/generated/RoutingApiClient.d.ts.map +1 -0
- package/dist/services/agents/generated/RoutingApiClient.js +68 -0
- package/dist/services/backlog/BacklogService.d.ts +98 -0
- package/dist/services/backlog/BacklogService.d.ts.map +1 -0
- package/dist/services/backlog/BacklogService.js +453 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +88 -0
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -0
- package/dist/services/backlog/TaskOrderingService.js +675 -0
- package/dist/services/docs/DocsService.d.ts +82 -0
- package/dist/services/docs/DocsService.d.ts.map +1 -0
- package/dist/services/docs/DocsService.js +1631 -0
- package/dist/services/estimate/EstimateService.d.ts +12 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -0
- package/dist/services/estimate/EstimateService.js +103 -0
- package/dist/services/estimate/VelocityService.d.ts +19 -0
- package/dist/services/estimate/VelocityService.d.ts.map +1 -0
- package/dist/services/estimate/VelocityService.js +237 -0
- package/dist/services/estimate/types.d.ts +30 -0
- package/dist/services/estimate/types.d.ts.map +1 -0
- package/dist/services/estimate/types.js +1 -0
- package/dist/services/execution/ExecutionService.d.ts +3 -0
- package/dist/services/execution/ExecutionService.d.ts.map +1 -0
- package/dist/services/execution/ExecutionService.js +2 -0
- package/dist/services/execution/QaFollowupService.d.ts +38 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -0
- package/dist/services/execution/QaFollowupService.js +236 -0
- package/dist/services/execution/QaProfileService.d.ts +22 -0
- package/dist/services/execution/QaProfileService.d.ts.map +1 -0
- package/dist/services/execution/QaProfileService.js +142 -0
- package/dist/services/execution/QaTasksService.d.ts +101 -0
- package/dist/services/execution/QaTasksService.d.ts.map +1 -0
- package/dist/services/execution/QaTasksService.js +1117 -0
- package/dist/services/execution/TaskSelectionService.d.ts +50 -0
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -0
- package/dist/services/execution/TaskSelectionService.js +281 -0
- package/dist/services/execution/TaskStateService.d.ts +19 -0
- package/dist/services/execution/TaskStateService.d.ts.map +1 -0
- package/dist/services/execution/TaskStateService.js +59 -0
- package/dist/services/execution/WorkOnTasksService.d.ts +80 -0
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -0
- package/dist/services/execution/WorkOnTasksService.js +1833 -0
- package/dist/services/jobs/JobInsightsService.d.ts +97 -0
- package/dist/services/jobs/JobInsightsService.d.ts.map +1 -0
- package/dist/services/jobs/JobInsightsService.js +263 -0
- package/dist/services/jobs/JobResumeService.d.ts +16 -0
- package/dist/services/jobs/JobResumeService.d.ts.map +1 -0
- package/dist/services/jobs/JobResumeService.js +113 -0
- package/dist/services/jobs/JobService.d.ts +149 -0
- package/dist/services/jobs/JobService.d.ts.map +1 -0
- package/dist/services/jobs/JobService.js +490 -0
- package/dist/services/jobs/JobsApiClient.d.ts +73 -0
- package/dist/services/jobs/JobsApiClient.d.ts.map +1 -0
- package/dist/services/jobs/JobsApiClient.js +67 -0
- package/dist/services/openapi/OpenApiService.d.ts +54 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -0
- package/dist/services/openapi/OpenApiService.js +503 -0
- package/dist/services/planning/CreateTasksService.d.ts +68 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -0
- package/dist/services/planning/CreateTasksService.js +989 -0
- package/dist/services/planning/KeyHelpers.d.ts +5 -0
- package/dist/services/planning/KeyHelpers.d.ts.map +1 -0
- package/dist/services/planning/KeyHelpers.js +62 -0
- package/dist/services/planning/PlanningService.d.ts +3 -0
- package/dist/services/planning/PlanningService.d.ts.map +1 -0
- package/dist/services/planning/PlanningService.js +2 -0
- package/dist/services/planning/RefineTasksService.d.ts +56 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -0
- package/dist/services/planning/RefineTasksService.js +1328 -0
- package/dist/services/review/CodeReviewService.d.ts +103 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -0
- package/dist/services/review/CodeReviewService.js +1187 -0
- package/dist/services/system/SystemUpdateService.d.ts +55 -0
- package/dist/services/system/SystemUpdateService.d.ts.map +1 -0
- package/dist/services/system/SystemUpdateService.js +136 -0
- package/dist/services/tasks/TaskApiResolver.d.ts +7 -0
- package/dist/services/tasks/TaskApiResolver.d.ts.map +1 -0
- package/dist/services/tasks/TaskApiResolver.js +41 -0
- package/dist/services/tasks/TaskDetailService.d.ts +106 -0
- package/dist/services/tasks/TaskDetailService.d.ts.map +1 -0
- package/dist/services/tasks/TaskDetailService.js +332 -0
- package/dist/services/telemetry/TelemetryService.d.ts +53 -0
- package/dist/services/telemetry/TelemetryService.d.ts.map +1 -0
- package/dist/services/telemetry/TelemetryService.js +434 -0
- package/dist/workspace/WorkspaceManager.d.ts +35 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -0
- package/dist/workspace/WorkspaceManager.js +201 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { WorkspaceRepository } from '@mcoda/db';
|
|
4
|
+
import { PathHelper } from '@mcoda/shared';
|
|
5
|
+
import { JobService } from '../jobs/JobService.js';
|
|
6
|
+
import { TaskSelectionService } from './TaskSelectionService.js';
|
|
7
|
+
import { TaskStateService } from './TaskStateService.js';
|
|
8
|
+
import { QaProfileService } from './QaProfileService.js';
|
|
9
|
+
import { QaFollowupService } from './QaFollowupService.js';
|
|
10
|
+
import { CliQaAdapter } from '@mcoda/integrations/qa/CliQaAdapter.js';
|
|
11
|
+
import { ChromiumQaAdapter } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
|
|
12
|
+
import { MaestroQaAdapter } from '@mcoda/integrations/qa/MaestroQaAdapter.js';
|
|
13
|
+
import { VcsClient } from '@mcoda/integrations';
|
|
14
|
+
import readline from 'node:readline/promises';
|
|
15
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
16
|
+
import { AgentService } from '@mcoda/agents';
|
|
17
|
+
import { GlobalRepository } from '@mcoda/db';
|
|
18
|
+
import { DocdexClient } from '@mcoda/integrations';
|
|
19
|
+
import { RoutingService } from '../agents/RoutingService.js';
|
|
20
|
+
const DEFAULT_QA_PROMPT = [
|
|
21
|
+
'You are the QA agent. Before testing, query docdex with the task key and feature keywords (MCP `docdex_search` limit 4–8 or CLI `docdexd query --repo <repo> --query \"<term>\" --limit 6 --snippets=false`). If results look stale, reindex (`docdex_index` or `docdexd index --repo <repo>`) then re-run. Fetch snippets via `docdex_open` or `/snippet/:doc_id?text_only=true` only for specific hits.',
|
|
22
|
+
'Use docdex snippets to derive acceptance criteria, data contracts, edge cases, and non-functional requirements (performance, accessibility, offline/online assumptions). Note if docdex is unavailable and fall back to local docs.',
|
|
23
|
+
].join('\n');
|
|
24
|
+
const DEFAULT_JOB_PROMPT = 'You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.';
|
|
25
|
+
const DEFAULT_CHARACTER_PROMPT = 'Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.';
|
|
26
|
+
const MCODA_GITIGNORE_ENTRY = '.mcoda/\n';
|
|
27
|
+
export class QaTasksService {
|
|
28
|
+
constructor(workspace, deps) {
|
|
29
|
+
this.workspace = workspace;
|
|
30
|
+
this.deps = deps;
|
|
31
|
+
this.dryRunGuard = false;
|
|
32
|
+
this.selectionService = deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
|
|
33
|
+
this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
|
|
34
|
+
this.profileService = deps.profileService ?? new QaProfileService(workspace.workspaceRoot);
|
|
35
|
+
this.followupService = deps.followupService ?? new QaFollowupService(deps.workspaceRepo, workspace.workspaceRoot);
|
|
36
|
+
this.jobService = deps.jobService;
|
|
37
|
+
this.vcs = deps.vcsClient ?? new VcsClient();
|
|
38
|
+
this.agentService = deps.agentService;
|
|
39
|
+
this.docdex = deps.docdex;
|
|
40
|
+
this.repo = deps.repo;
|
|
41
|
+
this.routingService = deps.routingService;
|
|
42
|
+
}
|
|
43
|
+
static async create(workspace, options = {}) {
|
|
44
|
+
const repo = await GlobalRepository.create();
|
|
45
|
+
const agentService = new AgentService(repo);
|
|
46
|
+
const docdex = new DocdexClient({
|
|
47
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
48
|
+
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
49
|
+
});
|
|
50
|
+
const routingService = await RoutingService.create();
|
|
51
|
+
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
52
|
+
const jobService = new JobService(workspace, workspaceRepo, {
|
|
53
|
+
noTelemetry: options.noTelemetry ?? false,
|
|
54
|
+
});
|
|
55
|
+
const selectionService = new TaskSelectionService(workspace, workspaceRepo);
|
|
56
|
+
const stateService = new TaskStateService(workspaceRepo);
|
|
57
|
+
const profileService = new QaProfileService(workspace.workspaceRoot);
|
|
58
|
+
const followupService = new QaFollowupService(workspaceRepo, workspace.workspaceRoot);
|
|
59
|
+
const vcsClient = new VcsClient();
|
|
60
|
+
return new QaTasksService(workspace, {
|
|
61
|
+
workspaceRepo,
|
|
62
|
+
jobService,
|
|
63
|
+
selectionService,
|
|
64
|
+
stateService,
|
|
65
|
+
profileService,
|
|
66
|
+
followupService,
|
|
67
|
+
vcsClient,
|
|
68
|
+
agentService,
|
|
69
|
+
docdex,
|
|
70
|
+
repo,
|
|
71
|
+
routingService,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async close() {
|
|
75
|
+
const maybeClose = async (target) => {
|
|
76
|
+
try {
|
|
77
|
+
if (target?.close)
|
|
78
|
+
await target.close();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
await maybeClose(this.deps.selectionService);
|
|
85
|
+
await maybeClose(this.deps.stateService);
|
|
86
|
+
await maybeClose(this.deps.jobService);
|
|
87
|
+
await maybeClose(this.deps.workspaceRepo);
|
|
88
|
+
await maybeClose(this.agentService);
|
|
89
|
+
await maybeClose(this.repo);
|
|
90
|
+
await maybeClose(this.docdex);
|
|
91
|
+
await maybeClose(this.deps.routingService);
|
|
92
|
+
}
|
|
93
|
+
async readPromptFiles(paths) {
|
|
94
|
+
const contents = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const promptPath of paths) {
|
|
97
|
+
try {
|
|
98
|
+
const content = await fs.readFile(promptPath, 'utf8');
|
|
99
|
+
const trimmed = content.trim();
|
|
100
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
101
|
+
contents.push(trimmed);
|
|
102
|
+
seen.add(trimmed);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* optional prompt */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return contents;
|
|
110
|
+
}
|
|
111
|
+
async loadPrompts(agentId) {
|
|
112
|
+
const mcodaPromptPath = path.join(this.workspace.workspaceRoot, '.mcoda', 'prompts', 'qa-agent.md');
|
|
113
|
+
const workspacePromptPath = path.join(this.workspace.workspaceRoot, 'prompts', 'qa-agent.md');
|
|
114
|
+
try {
|
|
115
|
+
await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
116
|
+
await fs.access(mcodaPromptPath);
|
|
117
|
+
console.info(`[qa-tasks] using existing QA prompt at ${mcodaPromptPath}`);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
try {
|
|
121
|
+
await fs.access(workspacePromptPath);
|
|
122
|
+
await fs.copyFile(workspacePromptPath, mcodaPromptPath);
|
|
123
|
+
console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
console.info(`[qa-tasks] no QA prompt found at ${workspacePromptPath}; writing default prompt to ${mcodaPromptPath}`);
|
|
127
|
+
await fs.writeFile(mcodaPromptPath, DEFAULT_QA_PROMPT, 'utf8');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const commandPromptFiles = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
|
|
131
|
+
const agentPrompts = this.agentService && 'getPrompts' in this.agentService ? await this.agentService.getPrompts(agentId) : undefined;
|
|
132
|
+
const mergedCommandPrompt = (() => {
|
|
133
|
+
const parts = [...commandPromptFiles];
|
|
134
|
+
if (agentPrompts?.commandPrompts?.['qa-tasks']) {
|
|
135
|
+
parts.push(agentPrompts.commandPrompts['qa-tasks']);
|
|
136
|
+
}
|
|
137
|
+
if (!parts.length)
|
|
138
|
+
parts.push(DEFAULT_QA_PROMPT);
|
|
139
|
+
return parts.filter(Boolean).join('\n\n');
|
|
140
|
+
})();
|
|
141
|
+
return {
|
|
142
|
+
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
143
|
+
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
144
|
+
commandPrompt: mergedCommandPrompt || undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async checkpoint(jobId, stage, details) {
|
|
148
|
+
await this.jobService.writeCheckpoint(jobId, {
|
|
149
|
+
stage,
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
details,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async ensureTaskBranch(task, taskRunId) {
|
|
155
|
+
try {
|
|
156
|
+
await this.vcs.ensureRepo(this.workspace.workspaceRoot);
|
|
157
|
+
await this.vcs.ensureClean(this.workspace.workspaceRoot, true);
|
|
158
|
+
if (task.task.vcsBranch) {
|
|
159
|
+
const exists = await this.vcs.branchExists(this.workspace.workspaceRoot, task.task.vcsBranch);
|
|
160
|
+
if (!exists) {
|
|
161
|
+
return { ok: false, message: `Task branch ${task.task.vcsBranch} not found` };
|
|
162
|
+
}
|
|
163
|
+
await this.vcs.checkoutBranch(this.workspace.workspaceRoot, task.task.vcsBranch);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const base = this.workspace.config?.branch ?? 'mcoda-dev';
|
|
167
|
+
await this.vcs.ensureBaseBranch(this.workspace.workspaceRoot, base);
|
|
168
|
+
}
|
|
169
|
+
return { ok: true };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
await this.logTask(taskRunId, `VCS check failed: ${error?.message ?? error}`, 'vcs');
|
|
173
|
+
return { ok: false, message: error?.message ?? String(error) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async ensureMcoda() {
|
|
177
|
+
await PathHelper.ensureDir(this.workspace.mcodaDir);
|
|
178
|
+
const gitignorePath = `${this.workspace.workspaceRoot}/.gitignore`;
|
|
179
|
+
try {
|
|
180
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
181
|
+
if (!content.includes('.mcoda/')) {
|
|
182
|
+
await fs.writeFile(gitignorePath, `${content.trimEnd()}\n${MCODA_GITIGNORE_ENTRY}`, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
await fs.writeFile(gitignorePath, MCODA_GITIGNORE_ENTRY, 'utf8');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
adapterForProfile(profile) {
|
|
190
|
+
const runner = profile?.runner ?? 'cli';
|
|
191
|
+
if (runner === 'cli')
|
|
192
|
+
return new CliQaAdapter();
|
|
193
|
+
if (runner === 'chromium')
|
|
194
|
+
return new ChromiumQaAdapter();
|
|
195
|
+
if (runner === 'maestro')
|
|
196
|
+
return new MaestroQaAdapter();
|
|
197
|
+
return new CliQaAdapter();
|
|
198
|
+
}
|
|
199
|
+
mapOutcome(result) {
|
|
200
|
+
if (result.outcome === 'pass')
|
|
201
|
+
return 'pass';
|
|
202
|
+
if (result.outcome === 'infra_issue')
|
|
203
|
+
return 'infra_issue';
|
|
204
|
+
return 'fix_required';
|
|
205
|
+
}
|
|
206
|
+
combineOutcome(result, recommendation) {
|
|
207
|
+
const base = this.mapOutcome(result);
|
|
208
|
+
if (!recommendation)
|
|
209
|
+
return base;
|
|
210
|
+
if (base === 'infra_issue' || recommendation === 'infra_issue')
|
|
211
|
+
return 'infra_issue';
|
|
212
|
+
if (base === 'fix_required')
|
|
213
|
+
return 'fix_required';
|
|
214
|
+
if (recommendation === 'fix_required')
|
|
215
|
+
return 'fix_required';
|
|
216
|
+
if (recommendation === 'unclear')
|
|
217
|
+
return 'unclear';
|
|
218
|
+
return 'pass';
|
|
219
|
+
}
|
|
220
|
+
async gatherDocContext(task, taskRunId) {
|
|
221
|
+
if (!this.docdex)
|
|
222
|
+
return '';
|
|
223
|
+
try {
|
|
224
|
+
const querySeeds = [task.key, task.title, ...(task.acceptanceCriteria ?? [])]
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.join(' ')
|
|
227
|
+
.slice(0, 200);
|
|
228
|
+
const docs = await this.docdex.search({
|
|
229
|
+
projectKey: task.projectId,
|
|
230
|
+
profile: 'qa',
|
|
231
|
+
query: querySeeds,
|
|
232
|
+
});
|
|
233
|
+
const snippets = [];
|
|
234
|
+
for (const doc of docs.slice(0, 5)) {
|
|
235
|
+
const segments = (doc.segments ?? []).slice(0, 2);
|
|
236
|
+
const body = segments.length
|
|
237
|
+
? segments
|
|
238
|
+
.map((seg, idx) => ` (${idx + 1}) ${seg.heading ? `${seg.heading}: ` : ''}${seg.content.slice(0, 400)}`)
|
|
239
|
+
.join('\n')
|
|
240
|
+
: doc.content
|
|
241
|
+
? doc.content.slice(0, 600)
|
|
242
|
+
: '';
|
|
243
|
+
snippets.push(`- [${doc.docType}] ${doc.title ?? doc.path ?? doc.id}\n${body}`.trim());
|
|
244
|
+
}
|
|
245
|
+
return snippets.join('\n\n');
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (taskRunId) {
|
|
249
|
+
await this.logTask(taskRunId, `Docdex search failed: ${error?.message ?? error}`, 'docdex');
|
|
250
|
+
}
|
|
251
|
+
return '';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async resolveAgent(agentName) {
|
|
255
|
+
if (!this.routingService || !this.agentService) {
|
|
256
|
+
throw new Error('RoutingService not available for QA routing');
|
|
257
|
+
}
|
|
258
|
+
const resolved = await this.routingService.resolveAgentForCommand({
|
|
259
|
+
workspace: this.workspace,
|
|
260
|
+
commandName: 'qa-tasks',
|
|
261
|
+
overrideAgentSlug: agentName,
|
|
262
|
+
});
|
|
263
|
+
return resolved.agent;
|
|
264
|
+
}
|
|
265
|
+
estimateTokens(text) {
|
|
266
|
+
return Math.max(1, Math.ceil((text?.length ?? 0) / 4));
|
|
267
|
+
}
|
|
268
|
+
extractJsonCandidate(raw) {
|
|
269
|
+
const fenced = raw.match(/```json([\s\S]*?)```/i);
|
|
270
|
+
const candidate = fenced ? fenced[1] : raw;
|
|
271
|
+
const start = candidate.indexOf('{');
|
|
272
|
+
const end = candidate.lastIndexOf('}');
|
|
273
|
+
if (start === -1 || end === -1 || end <= start)
|
|
274
|
+
return undefined;
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(candidate.slice(start, end + 1));
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
normalizeAgentOutput(parsed) {
|
|
283
|
+
if (!parsed || typeof parsed !== 'object')
|
|
284
|
+
return undefined;
|
|
285
|
+
const recommendation = parsed.recommendation;
|
|
286
|
+
if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
|
|
287
|
+
return undefined;
|
|
288
|
+
const followUps = Array.isArray(parsed.follow_up_tasks)
|
|
289
|
+
? parsed.follow_up_tasks
|
|
290
|
+
: Array.isArray(parsed.follow_ups)
|
|
291
|
+
? parsed.follow_ups
|
|
292
|
+
: undefined;
|
|
293
|
+
const failures = Array.isArray(parsed.failures)
|
|
294
|
+
? parsed.failures.map((f) => ({ kind: f.kind, message: f.message ?? String(f), evidence: f.evidence }))
|
|
295
|
+
: undefined;
|
|
296
|
+
return {
|
|
297
|
+
recommendation,
|
|
298
|
+
testedScope: parsed.tested_scope ?? parsed.scope,
|
|
299
|
+
coverageSummary: parsed.coverage_summary ?? parsed.coverage,
|
|
300
|
+
failures,
|
|
301
|
+
followUps,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId) {
|
|
305
|
+
if (!this.agentService) {
|
|
306
|
+
return { recommendation: this.mapOutcome(result) };
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const agent = await this.resolveAgent(agentName);
|
|
310
|
+
const prompts = await this.loadPrompts(agent.id);
|
|
311
|
+
const systemPrompt = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
|
|
312
|
+
const docCtx = await this.gatherDocContext(task.task, taskRunId);
|
|
313
|
+
const acceptance = (task.task.acceptanceCriteria ?? []).map((line) => `- ${line}`).join('\n');
|
|
314
|
+
const prompt = [
|
|
315
|
+
systemPrompt,
|
|
316
|
+
'You are the mcoda QA agent. Interpret the QA execution results and return structured JSON.',
|
|
317
|
+
`Task: ${task.task.key} ${task.task.title}`,
|
|
318
|
+
`Task type: ${task.task.type ?? 'n/a'}, status: ${task.task.status}`,
|
|
319
|
+
task.task.description ? `Task description:\n${task.task.description}` : '',
|
|
320
|
+
`Epic/Story: ${task.task.epicKey ?? task.task.epicId} / ${task.task.storyKey ?? task.task.userStoryId}`,
|
|
321
|
+
acceptance ? `Acceptance criteria:\n${acceptance}` : 'Acceptance criteria: (not provided)',
|
|
322
|
+
`QA profile: ${profile.name} (${profile.runner ?? 'cli'})`,
|
|
323
|
+
`Test command / runner outcome: exit=${result.exitCode} outcome=${result.outcome}`,
|
|
324
|
+
result.stdout ? `Stdout (truncated):\n${result.stdout.slice(0, 3000)}` : '',
|
|
325
|
+
result.stderr ? `Stderr (truncated):\n${result.stderr.slice(0, 3000)}` : '',
|
|
326
|
+
result.artifacts?.length ? `Artifacts:\n${result.artifacts.join('\n')}` : '',
|
|
327
|
+
docCtx ? `Relevant docs (SDS/RFP/OpenAPI):\n${docCtx}` : '',
|
|
328
|
+
[
|
|
329
|
+
'Return strict JSON with keys:',
|
|
330
|
+
'{',
|
|
331
|
+
' "tested_scope": string,',
|
|
332
|
+
' "coverage_summary": string,',
|
|
333
|
+
' "failures": [{ "kind": "functional|contract|perf|security|infra", "message": string, "evidence": string }],',
|
|
334
|
+
' "recommendation": "pass|fix_required|infra_issue|unclear",',
|
|
335
|
+
' "follow_up_tasks": [{ "title": string, "description": string, "type": "bug|qa_followup|chore", "priority": number, "story_points": number, "tags": string[], "related_task_key": string, "epic_key": string, "story_key": string, "doc_links": string[], "evidence_url": string, "artifacts": string[] }]',
|
|
336
|
+
'}',
|
|
337
|
+
'Do not include prose outside the JSON.',
|
|
338
|
+
].join('\n'),
|
|
339
|
+
]
|
|
340
|
+
.filter(Boolean)
|
|
341
|
+
.join('\n\n');
|
|
342
|
+
const separator = "============================================================";
|
|
343
|
+
console.info(separator);
|
|
344
|
+
console.info("[qa-tasks] START OF TASK");
|
|
345
|
+
console.info(`[qa-tasks] Task key: ${task.task.key}`);
|
|
346
|
+
console.info(`[qa-tasks] Title: ${task.task.title ?? '(none)'}`);
|
|
347
|
+
console.info(`[qa-tasks] Description: ${task.task.description ?? '(none)'}`);
|
|
348
|
+
console.info(`[qa-tasks] Story points: ${typeof task.task.storyPoints === 'number' ? task.task.storyPoints : '(none)'}`);
|
|
349
|
+
console.info(`[qa-tasks] Dependencies: ${task.dependencies.keys.length ? task.dependencies.keys.join(', ') : '(none available)'}`);
|
|
350
|
+
if (acceptance)
|
|
351
|
+
console.info(`[qa-tasks] Acceptance criteria:\n${acceptance}`);
|
|
352
|
+
console.info(`[qa-tasks] System prompt used:\n${systemPrompt || '(none)'}`);
|
|
353
|
+
console.info(`[qa-tasks] Task prompt used:\n${prompt}`);
|
|
354
|
+
console.info(separator);
|
|
355
|
+
let output = '';
|
|
356
|
+
let chunkCount = 0;
|
|
357
|
+
if (stream && this.agentService.invokeStream) {
|
|
358
|
+
const gen = await this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
|
|
359
|
+
for await (const chunk of gen) {
|
|
360
|
+
output += chunk.output ?? '';
|
|
361
|
+
chunkCount += 1;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const res = await this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
|
|
366
|
+
output = res.output ?? '';
|
|
367
|
+
}
|
|
368
|
+
const tokensPrompt = this.estimateTokens(prompt);
|
|
369
|
+
const tokensCompletion = this.estimateTokens(output);
|
|
370
|
+
if (!this.dryRunGuard) {
|
|
371
|
+
await this.jobService.recordTokenUsage({
|
|
372
|
+
workspaceId: this.workspace.workspaceId,
|
|
373
|
+
agentId: agent.id,
|
|
374
|
+
modelName: agent.defaultModel,
|
|
375
|
+
jobId,
|
|
376
|
+
taskId: task.task.id,
|
|
377
|
+
commandRunId,
|
|
378
|
+
taskRunId,
|
|
379
|
+
tokensPrompt,
|
|
380
|
+
tokensCompletion,
|
|
381
|
+
tokensTotal: tokensPrompt + tokensCompletion,
|
|
382
|
+
timestamp: new Date().toISOString(),
|
|
383
|
+
metadata: {
|
|
384
|
+
commandName: 'qa-tasks',
|
|
385
|
+
action: 'qa-interpret-results',
|
|
386
|
+
taskKey: task.task.key,
|
|
387
|
+
streaming: stream,
|
|
388
|
+
streamChunks: chunkCount || undefined,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const parsed = this.extractJsonCandidate(output);
|
|
393
|
+
const normalized = this.normalizeAgentOutput(parsed);
|
|
394
|
+
if (normalized) {
|
|
395
|
+
return {
|
|
396
|
+
...normalized,
|
|
397
|
+
rawOutput: output,
|
|
398
|
+
tokensPrompt,
|
|
399
|
+
tokensCompletion,
|
|
400
|
+
agentId: agent.id,
|
|
401
|
+
modelName: agent.defaultModel,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return { recommendation: this.mapOutcome(result), rawOutput: output, tokensPrompt, tokensCompletion, agentId: agent.id, modelName: agent.defaultModel };
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (taskRunId) {
|
|
408
|
+
await this.logTask(taskRunId, `QA agent failed: ${error?.message ?? error}`, 'qa-agent');
|
|
409
|
+
}
|
|
410
|
+
return { recommendation: this.mapOutcome(result) };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async createTaskRun(task, jobId, commandRunId) {
|
|
414
|
+
const startedAt = new Date().toISOString();
|
|
415
|
+
return this.deps.workspaceRepo.createTaskRun({
|
|
416
|
+
taskId: task.id,
|
|
417
|
+
command: 'qa-tasks',
|
|
418
|
+
jobId,
|
|
419
|
+
commandRunId,
|
|
420
|
+
status: 'running',
|
|
421
|
+
startedAt,
|
|
422
|
+
storyPointsAtRun: task.storyPoints ?? null,
|
|
423
|
+
gitBranch: task.vcsBranch ?? null,
|
|
424
|
+
gitBaseBranch: task.vcsBaseBranch ?? null,
|
|
425
|
+
gitCommitSha: task.vcsLastCommitSha ?? null,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
async finishTaskRun(taskRun, status, extra) {
|
|
429
|
+
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
430
|
+
status,
|
|
431
|
+
finishedAt: new Date().toISOString(),
|
|
432
|
+
gitBranch: extra?.gitBranch ?? taskRun.gitBranch,
|
|
433
|
+
gitBaseBranch: extra?.gitBaseBranch ?? taskRun.gitBaseBranch,
|
|
434
|
+
gitCommitSha: extra?.gitCommitSha ?? taskRun.gitCommitSha,
|
|
435
|
+
spPerHourEffective: extra?.spPerHourEffective ?? null,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
async logTask(taskRunId, message, source, details) {
|
|
439
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
440
|
+
taskRunId,
|
|
441
|
+
sequence: Math.floor(Math.random() * 1000000),
|
|
442
|
+
timestamp: new Date().toISOString(),
|
|
443
|
+
source: source ?? 'qa-tasks',
|
|
444
|
+
message,
|
|
445
|
+
details: details ?? undefined,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
async applyStateTransition(task, outcome) {
|
|
449
|
+
const timestamp = { last_qa: new Date().toISOString() };
|
|
450
|
+
if (outcome === 'pass') {
|
|
451
|
+
await this.stateService.markCompleted(task, timestamp);
|
|
452
|
+
}
|
|
453
|
+
else if (outcome === 'fix_required') {
|
|
454
|
+
await this.stateService.returnToInProgress(task, timestamp);
|
|
455
|
+
}
|
|
456
|
+
else if (outcome === 'infra_issue') {
|
|
457
|
+
await this.stateService.markBlocked(task, 'qa_infra_issue');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
buildFollowupSuggestion(task, result, notes) {
|
|
461
|
+
const summary = notes || result.stderr || result.stdout || 'QA failure detected';
|
|
462
|
+
const components = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
|
|
463
|
+
const docLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
|
|
464
|
+
const tests = Array.isArray(task.metadata?.tests) ? task.metadata.tests : [];
|
|
465
|
+
return {
|
|
466
|
+
title: `QA follow-up for ${task.key}`,
|
|
467
|
+
description: `Follow-up created from QA run on ${task.key}.\n\nDetails:\n${summary}`.slice(0, 2000),
|
|
468
|
+
type: 'bug',
|
|
469
|
+
storyPoints: 1,
|
|
470
|
+
priority: 90,
|
|
471
|
+
tags: ['qa', 'qa-followup', ...components],
|
|
472
|
+
components,
|
|
473
|
+
docLinks,
|
|
474
|
+
testName: tests[0],
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
toFollowupSuggestion(task, agentFollow, artifacts) {
|
|
478
|
+
const taskComponents = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
|
|
479
|
+
const taskDocLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
|
|
480
|
+
return {
|
|
481
|
+
title: agentFollow.title ?? `QA follow-up for ${task.key}`,
|
|
482
|
+
description: agentFollow.description,
|
|
483
|
+
type: agentFollow.type ?? 'bug',
|
|
484
|
+
priority: agentFollow.priority ?? 90,
|
|
485
|
+
storyPoints: agentFollow.story_points ?? 1,
|
|
486
|
+
tags: agentFollow.tags,
|
|
487
|
+
relatedTaskKey: agentFollow.related_task_key,
|
|
488
|
+
epicKeyHint: agentFollow.epic_key,
|
|
489
|
+
storyKeyHint: agentFollow.story_key,
|
|
490
|
+
components: agentFollow.components ?? taskComponents,
|
|
491
|
+
docLinks: agentFollow.doc_links ?? taskDocLinks,
|
|
492
|
+
evidenceUrl: agentFollow.evidence_url,
|
|
493
|
+
artifacts: agentFollow.artifacts ?? artifacts,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
async suggestFollowupsFromAgent(task, notes, evidenceUrl, mode, jobId, commandRunId, taskRunId, agentStream = true) {
|
|
497
|
+
if (!this.agentService)
|
|
498
|
+
return [];
|
|
499
|
+
const agent = await this.resolveAgent(undefined);
|
|
500
|
+
const prompts = await this.loadPrompts(agent.id);
|
|
501
|
+
const systemPrompt = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
|
|
502
|
+
const docCtx = await this.gatherDocContext(task.task, taskRunId);
|
|
503
|
+
const prompt = [
|
|
504
|
+
systemPrompt,
|
|
505
|
+
'You are the mcoda QA agent. Given QA notes/evidence, propose structured follow-up tasks as JSON.',
|
|
506
|
+
`Task: ${task.task.key} ${task.task.title}`,
|
|
507
|
+
task.task.description ? `Task description:\n${task.task.description}` : '',
|
|
508
|
+
notes ? `QA notes:\n${notes}` : '',
|
|
509
|
+
evidenceUrl ? `Evidence URL: ${evidenceUrl}` : '',
|
|
510
|
+
docCtx ? `Relevant docs:\n${docCtx}` : '',
|
|
511
|
+
[
|
|
512
|
+
'Return JSON: { "follow_up_tasks": [ { "title": "...", "description": "...", "type": "bug|qa_followup|chore", "priority": number, "story_points": number, "tags": [], "related_task_key": string, "epic_key": string, "story_key": string, "doc_links": [], "evidence_url": string } ] }',
|
|
513
|
+
'No prose outside JSON.',
|
|
514
|
+
].join('\n'),
|
|
515
|
+
]
|
|
516
|
+
.filter(Boolean)
|
|
517
|
+
.join('\n\n');
|
|
518
|
+
let output = '';
|
|
519
|
+
let chunkCount = 0;
|
|
520
|
+
const useStream = agentStream && Boolean(this.agentService?.invokeStream);
|
|
521
|
+
try {
|
|
522
|
+
if (useStream && this.agentService.invokeStream) {
|
|
523
|
+
const gen = await this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
|
|
524
|
+
for await (const chunk of gen) {
|
|
525
|
+
output += chunk.output ?? '';
|
|
526
|
+
chunkCount += 1;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
const res = await this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
|
|
531
|
+
output = res.output ?? '';
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
537
|
+
const tokensPrompt = this.estimateTokens(prompt);
|
|
538
|
+
const tokensCompletion = this.estimateTokens(output);
|
|
539
|
+
if (!this.dryRunGuard) {
|
|
540
|
+
await this.jobService.recordTokenUsage({
|
|
541
|
+
workspaceId: this.workspace.workspaceId,
|
|
542
|
+
agentId: agent.id,
|
|
543
|
+
modelName: agent.defaultModel,
|
|
544
|
+
jobId,
|
|
545
|
+
taskId: task.task.id,
|
|
546
|
+
commandRunId,
|
|
547
|
+
taskRunId,
|
|
548
|
+
tokensPrompt,
|
|
549
|
+
tokensCompletion,
|
|
550
|
+
tokensTotal: tokensPrompt + tokensCompletion,
|
|
551
|
+
timestamp: new Date().toISOString(),
|
|
552
|
+
metadata: {
|
|
553
|
+
commandName: 'qa-tasks',
|
|
554
|
+
action: 'qa-manual-followups',
|
|
555
|
+
taskKey: task.task.key,
|
|
556
|
+
streaming: useStream || undefined,
|
|
557
|
+
streamChunks: chunkCount || undefined,
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
const parsed = this.extractJsonCandidate(output);
|
|
562
|
+
const followUps = Array.isArray(parsed?.follow_up_tasks)
|
|
563
|
+
? parsed.follow_up_tasks
|
|
564
|
+
: Array.isArray(parsed?.followUps)
|
|
565
|
+
? parsed.followUps
|
|
566
|
+
: [];
|
|
567
|
+
return followUps.map((f) => this.toFollowupSuggestion(task.task, f, []));
|
|
568
|
+
}
|
|
569
|
+
async runAuto(task, ctx) {
|
|
570
|
+
const taskRun = await this.createTaskRun(task.task, ctx.jobId, ctx.commandRunId);
|
|
571
|
+
await this.logTask(taskRun.id, 'Starting QA', 'qa-start');
|
|
572
|
+
const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
|
|
573
|
+
if (task.task.status && !allowedStatuses.has(task.task.status)) {
|
|
574
|
+
const message = `Task status ${task.task.status} not allowed for QA`;
|
|
575
|
+
await this.logTask(taskRun.id, message, 'status-gate');
|
|
576
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
577
|
+
if (!this.dryRunGuard) {
|
|
578
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
579
|
+
taskId: task.task.id,
|
|
580
|
+
taskRunId: taskRun.id,
|
|
581
|
+
jobId: ctx.jobId,
|
|
582
|
+
commandRunId: ctx.commandRunId,
|
|
583
|
+
source: 'auto',
|
|
584
|
+
mode: 'auto',
|
|
585
|
+
rawOutcome: 'infra_issue',
|
|
586
|
+
recommendation: 'infra_issue',
|
|
587
|
+
profileName: undefined,
|
|
588
|
+
runner: undefined,
|
|
589
|
+
metadata: { reason: 'status_gating' },
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
|
|
593
|
+
}
|
|
594
|
+
const branchCheck = await this.ensureTaskBranch(task, taskRun.id);
|
|
595
|
+
if (!branchCheck.ok) {
|
|
596
|
+
if (!this.dryRunGuard) {
|
|
597
|
+
await this.applyStateTransition(task.task, 'infra_issue');
|
|
598
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
599
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
600
|
+
taskId: task.task.id,
|
|
601
|
+
taskRunId: taskRun.id,
|
|
602
|
+
jobId: ctx.jobId,
|
|
603
|
+
commandRunId: ctx.commandRunId,
|
|
604
|
+
source: 'auto',
|
|
605
|
+
mode: 'auto',
|
|
606
|
+
rawOutcome: 'infra_issue',
|
|
607
|
+
recommendation: 'infra_issue',
|
|
608
|
+
metadata: { reason: 'vcs_branch_missing', detail: branchCheck.message },
|
|
609
|
+
});
|
|
610
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
611
|
+
taskId: task.task.id,
|
|
612
|
+
taskRunId: taskRun.id,
|
|
613
|
+
jobId: ctx.jobId,
|
|
614
|
+
sourceCommand: 'qa-tasks',
|
|
615
|
+
authorType: 'agent',
|
|
616
|
+
category: 'qa_issue',
|
|
617
|
+
body: `VCS validation failed: ${branchCheck.message ?? 'unknown error'}`,
|
|
618
|
+
createdAt: new Date().toISOString(),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'vcs_branch_missing' };
|
|
622
|
+
}
|
|
623
|
+
let profile;
|
|
624
|
+
try {
|
|
625
|
+
profile = await this.profileService.resolveProfileForTask(task.task, {
|
|
626
|
+
profileName: ctx.request.profileName,
|
|
627
|
+
level: ctx.request.level,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
await this.logTask(taskRun.id, `Profile resolution failed: ${error?.message ?? error}`, 'qa-profile');
|
|
632
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
633
|
+
if (!this.dryRunGuard) {
|
|
634
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
635
|
+
taskId: task.task.id,
|
|
636
|
+
taskRunId: taskRun.id,
|
|
637
|
+
jobId: ctx.jobId,
|
|
638
|
+
commandRunId: ctx.commandRunId,
|
|
639
|
+
source: 'auto',
|
|
640
|
+
mode: 'auto',
|
|
641
|
+
rawOutcome: 'infra_issue',
|
|
642
|
+
recommendation: 'infra_issue',
|
|
643
|
+
metadata: { reason: 'profile_resolution_failed', message: error?.message ?? String(error) },
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'profile_resolution_failed' };
|
|
647
|
+
}
|
|
648
|
+
if (!profile) {
|
|
649
|
+
await this.logTask(taskRun.id, 'No QA profile available', 'qa-profile');
|
|
650
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
651
|
+
if (!this.dryRunGuard) {
|
|
652
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
653
|
+
taskId: task.task.id,
|
|
654
|
+
taskRunId: taskRun.id,
|
|
655
|
+
jobId: ctx.jobId,
|
|
656
|
+
commandRunId: ctx.commandRunId,
|
|
657
|
+
source: 'auto',
|
|
658
|
+
mode: 'auto',
|
|
659
|
+
rawOutcome: 'infra_issue',
|
|
660
|
+
recommendation: 'infra_issue',
|
|
661
|
+
metadata: { reason: 'no_profile' },
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'no_profile' };
|
|
665
|
+
}
|
|
666
|
+
const adapter = this.adapterForProfile(profile);
|
|
667
|
+
if (!adapter) {
|
|
668
|
+
await this.logTask(taskRun.id, 'No QA adapter for profile', 'qa-adapter');
|
|
669
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
670
|
+
if (!this.dryRunGuard) {
|
|
671
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
672
|
+
taskId: task.task.id,
|
|
673
|
+
taskRunId: taskRun.id,
|
|
674
|
+
jobId: ctx.jobId,
|
|
675
|
+
commandRunId: ctx.commandRunId,
|
|
676
|
+
source: 'auto',
|
|
677
|
+
mode: 'auto',
|
|
678
|
+
profileName: profile.name,
|
|
679
|
+
runner: profile.runner,
|
|
680
|
+
rawOutcome: 'infra_issue',
|
|
681
|
+
recommendation: 'infra_issue',
|
|
682
|
+
metadata: { reason: 'no_adapter' },
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', profile: profile.name, runner: profile.runner, notes: 'no_adapter' };
|
|
686
|
+
}
|
|
687
|
+
const qaCtx = {
|
|
688
|
+
workspaceRoot: this.workspace.workspaceRoot,
|
|
689
|
+
jobId: ctx.jobId,
|
|
690
|
+
taskKey: task.task.key,
|
|
691
|
+
env: process.env,
|
|
692
|
+
testCommandOverride: ctx.request.testCommand,
|
|
693
|
+
};
|
|
694
|
+
const ensure = await adapter.ensureInstalled(profile, qaCtx);
|
|
695
|
+
if (!ensure.ok) {
|
|
696
|
+
await this.logTask(taskRun.id, ensure.message ?? 'QA install failed', 'qa-install');
|
|
697
|
+
if (!this.dryRunGuard) {
|
|
698
|
+
await this.applyStateTransition(task.task, 'infra_issue');
|
|
699
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
700
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
701
|
+
taskId: task.task.id,
|
|
702
|
+
taskRunId: taskRun.id,
|
|
703
|
+
jobId: ctx.jobId,
|
|
704
|
+
commandRunId: ctx.commandRunId,
|
|
705
|
+
source: 'auto',
|
|
706
|
+
mode: 'auto',
|
|
707
|
+
profileName: profile.name,
|
|
708
|
+
runner: profile.runner,
|
|
709
|
+
rawOutcome: 'infra_issue',
|
|
710
|
+
recommendation: 'infra_issue',
|
|
711
|
+
metadata: { install: ensure.message, adapter: profile.runner },
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
taskKey: task.task.key,
|
|
716
|
+
outcome: 'infra_issue',
|
|
717
|
+
profile: profile.name,
|
|
718
|
+
runner: profile.runner,
|
|
719
|
+
notes: ensure.message,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
const artifactDir = path.join(this.workspace.workspaceRoot, '.mcoda', 'jobs', ctx.jobId, 'qa', task.task.key);
|
|
723
|
+
await PathHelper.ensureDir(artifactDir);
|
|
724
|
+
const result = await adapter.invoke(profile, { ...qaCtx, artifactDir });
|
|
725
|
+
await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
|
|
726
|
+
exitCode: result.exitCode,
|
|
727
|
+
});
|
|
728
|
+
const interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id);
|
|
729
|
+
const outcome = this.combineOutcome(result, interpretation.recommendation);
|
|
730
|
+
const artifacts = result.artifacts ?? [];
|
|
731
|
+
let qaRun;
|
|
732
|
+
if (!this.dryRunGuard) {
|
|
733
|
+
qaRun = await this.deps.workspaceRepo.createTaskQaRun({
|
|
734
|
+
taskId: task.task.id,
|
|
735
|
+
taskRunId: taskRun.id,
|
|
736
|
+
jobId: ctx.jobId,
|
|
737
|
+
commandRunId: ctx.commandRunId,
|
|
738
|
+
agentId: interpretation.agentId,
|
|
739
|
+
modelName: interpretation.modelName,
|
|
740
|
+
source: 'auto',
|
|
741
|
+
mode: 'auto',
|
|
742
|
+
profileName: profile.name,
|
|
743
|
+
runner: profile.runner,
|
|
744
|
+
rawOutcome: result.outcome,
|
|
745
|
+
recommendation: interpretation.recommendation,
|
|
746
|
+
artifacts,
|
|
747
|
+
rawResult: {
|
|
748
|
+
adapter: result,
|
|
749
|
+
agent: interpretation.rawOutput,
|
|
750
|
+
},
|
|
751
|
+
startedAt: result.startedAt,
|
|
752
|
+
finishedAt: result.finishedAt,
|
|
753
|
+
metadata: {
|
|
754
|
+
tokensPrompt: interpretation.tokensPrompt,
|
|
755
|
+
tokensCompletion: interpretation.tokensCompletion,
|
|
756
|
+
testedScope: interpretation.testedScope,
|
|
757
|
+
coverageSummary: interpretation.coverageSummary,
|
|
758
|
+
failures: interpretation.failures,
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
if (!this.dryRunGuard) {
|
|
763
|
+
await this.applyStateTransition(task.task, outcome);
|
|
764
|
+
await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
|
|
765
|
+
}
|
|
766
|
+
const followups = [];
|
|
767
|
+
if (outcome === 'fix_required' && ctx.request.createFollowupTasks !== 'none') {
|
|
768
|
+
const suggestions = interpretation.followUps?.map((f) => this.toFollowupSuggestion(task.task, f, artifacts)) ?? [];
|
|
769
|
+
if (suggestions.length === 0) {
|
|
770
|
+
suggestions.push(this.buildFollowupSuggestion(task.task, result, ctx.request.notes));
|
|
771
|
+
}
|
|
772
|
+
const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
|
|
773
|
+
for (const suggestion of suggestions) {
|
|
774
|
+
let proceed = ctx.request.createFollowupTasks !== 'prompt';
|
|
775
|
+
if (interactive) {
|
|
776
|
+
const rl = readline.createInterface({ input, output });
|
|
777
|
+
const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
|
|
778
|
+
rl.close();
|
|
779
|
+
proceed = answer.trim().toLowerCase().startsWith('y');
|
|
780
|
+
}
|
|
781
|
+
if (!proceed)
|
|
782
|
+
continue;
|
|
783
|
+
try {
|
|
784
|
+
if (!this.dryRunGuard) {
|
|
785
|
+
const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
|
|
786
|
+
followups.push(created.task.key);
|
|
787
|
+
await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
await this.logTask(taskRun.id, `Failed to create follow-up task: ${error?.message ?? error}`, 'qa-followup');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const bodyLines = [
|
|
796
|
+
`QA outcome: ${outcome}`,
|
|
797
|
+
profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
|
|
798
|
+
interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
|
|
799
|
+
interpretation.failures && interpretation.failures.length
|
|
800
|
+
? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
|
|
801
|
+
: '',
|
|
802
|
+
result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
|
|
803
|
+
result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
|
|
804
|
+
artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
|
|
805
|
+
followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
|
|
806
|
+
].filter(Boolean);
|
|
807
|
+
if (!this.dryRunGuard) {
|
|
808
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
809
|
+
taskId: task.task.id,
|
|
810
|
+
taskRunId: taskRun.id,
|
|
811
|
+
jobId: ctx.jobId,
|
|
812
|
+
sourceCommand: 'qa-tasks',
|
|
813
|
+
authorType: 'agent',
|
|
814
|
+
category: outcome === 'pass' ? 'qa_result' : 'qa_issue',
|
|
815
|
+
body: bodyLines.join('\n\n'),
|
|
816
|
+
createdAt: new Date().toISOString(),
|
|
817
|
+
metadata: {
|
|
818
|
+
...(artifacts.length ? { artifacts } : {}),
|
|
819
|
+
...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
taskKey: task.task.key,
|
|
825
|
+
outcome,
|
|
826
|
+
profile: profile.name,
|
|
827
|
+
runner: profile.runner,
|
|
828
|
+
artifacts,
|
|
829
|
+
followups,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
async runManual(task, ctx) {
|
|
833
|
+
const taskRun = await this.createTaskRun(task.task, ctx.jobId, ctx.commandRunId);
|
|
834
|
+
const result = ctx.request.result ?? 'pass';
|
|
835
|
+
const notes = ctx.request.notes;
|
|
836
|
+
const outcome = result === 'pass' ? 'pass' : result === 'blocked' ? 'infra_issue' : 'fix_required';
|
|
837
|
+
const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
|
|
838
|
+
if (task.task.status && !allowedStatuses.has(task.task.status)) {
|
|
839
|
+
const message = `Task status ${task.task.status} not allowed for manual QA`;
|
|
840
|
+
await this.logTask(taskRun.id, message, 'status-gate');
|
|
841
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
842
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
|
|
843
|
+
}
|
|
844
|
+
if (!ctx.request.dryRun) {
|
|
845
|
+
await this.applyStateTransition(task.task, outcome);
|
|
846
|
+
await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
|
|
847
|
+
}
|
|
848
|
+
const followups = [];
|
|
849
|
+
const artifacts = [];
|
|
850
|
+
if (!ctx.request.dryRun) {
|
|
851
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
852
|
+
taskId: task.task.id,
|
|
853
|
+
taskRunId: taskRun.id,
|
|
854
|
+
jobId: ctx.jobId,
|
|
855
|
+
commandRunId: ctx.commandRunId,
|
|
856
|
+
source: 'manual',
|
|
857
|
+
mode: 'manual',
|
|
858
|
+
rawOutcome: result,
|
|
859
|
+
recommendation: outcome,
|
|
860
|
+
evidenceUrl: ctx.request.evidenceUrl,
|
|
861
|
+
artifacts,
|
|
862
|
+
rawResult: { notes },
|
|
863
|
+
metadata: { notes, evidenceUrl: ctx.request.evidenceUrl },
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (!ctx.request.dryRun && ctx.request.createFollowupTasks !== 'none' && outcome === 'fix_required') {
|
|
867
|
+
const suggestions = [
|
|
868
|
+
{
|
|
869
|
+
title: `Manual QA follow-up for ${task.task.key}`,
|
|
870
|
+
description: notes ?? 'Manual QA reported failure. Please investigate.',
|
|
871
|
+
type: 'bug',
|
|
872
|
+
storyPoints: 1,
|
|
873
|
+
priority: 90,
|
|
874
|
+
tags: ['qa', 'manual'],
|
|
875
|
+
evidenceUrl: ctx.request.evidenceUrl,
|
|
876
|
+
},
|
|
877
|
+
];
|
|
878
|
+
const agentSuggestions = await this.suggestFollowupsFromAgent(task, notes, ctx.request.evidenceUrl, 'manual', ctx.jobId, ctx.commandRunId, taskRun.id);
|
|
879
|
+
if (agentSuggestions.length) {
|
|
880
|
+
suggestions.unshift(...agentSuggestions);
|
|
881
|
+
}
|
|
882
|
+
const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
|
|
883
|
+
for (const suggestion of suggestions) {
|
|
884
|
+
let proceed = ctx.request.createFollowupTasks === 'auto' || ctx.request.createFollowupTasks === undefined;
|
|
885
|
+
if (interactive) {
|
|
886
|
+
const rl = readline.createInterface({ input, output });
|
|
887
|
+
const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
|
|
888
|
+
rl.close();
|
|
889
|
+
proceed = answer.trim().toLowerCase().startsWith('y');
|
|
890
|
+
}
|
|
891
|
+
if (!proceed)
|
|
892
|
+
continue;
|
|
893
|
+
try {
|
|
894
|
+
const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
|
|
895
|
+
followups.push(created.task.key);
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
await this.logTask(taskRun.id, `Follow-up creation failed: ${error?.message ?? error}`, 'qa-followup');
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const body = [
|
|
903
|
+
`Manual QA outcome: ${result}`,
|
|
904
|
+
notes ? `Notes: ${notes}` : '',
|
|
905
|
+
ctx.request.evidenceUrl ? `Evidence: ${ctx.request.evidenceUrl}` : '',
|
|
906
|
+
artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
|
|
907
|
+
followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
|
|
908
|
+
]
|
|
909
|
+
.filter(Boolean)
|
|
910
|
+
.join('\n');
|
|
911
|
+
if (!ctx.request.dryRun) {
|
|
912
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
913
|
+
taskId: task.task.id,
|
|
914
|
+
taskRunId: taskRun.id,
|
|
915
|
+
jobId: ctx.jobId,
|
|
916
|
+
sourceCommand: 'qa-tasks',
|
|
917
|
+
authorType: 'human',
|
|
918
|
+
category: result === 'pass' ? 'qa_result' : 'qa_issue',
|
|
919
|
+
body,
|
|
920
|
+
createdAt: new Date().toISOString(),
|
|
921
|
+
metadata: {
|
|
922
|
+
...(ctx.request.evidenceUrl ? { evidence: ctx.request.evidenceUrl } : {}),
|
|
923
|
+
...(artifacts.length ? { artifacts } : {}),
|
|
924
|
+
},
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
taskKey: task.task.key,
|
|
929
|
+
outcome,
|
|
930
|
+
artifacts,
|
|
931
|
+
followups,
|
|
932
|
+
notes,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
async run(request) {
|
|
936
|
+
const resume = request.resumeJobId ? await this.deps.jobService.getJob(request.resumeJobId) : undefined;
|
|
937
|
+
if (request.resumeJobId && !resume) {
|
|
938
|
+
throw new Error(`Resume requested but job ${request.resumeJobId} not found`);
|
|
939
|
+
}
|
|
940
|
+
const effectiveProject = request.projectKey ?? resume?.payload?.projectKey;
|
|
941
|
+
const effectiveEpic = request.epicKey ?? resume?.payload?.epicKey;
|
|
942
|
+
const effectiveStory = request.storyKey ?? resume?.payload?.storyKey;
|
|
943
|
+
const effectiveTasks = request.taskKeys?.length ? request.taskKeys : resume?.payload?.tasks;
|
|
944
|
+
const effectiveStatus = request.statusFilter ?? resume?.payload?.statusFilter ?? ['ready_to_qa'];
|
|
945
|
+
const selection = await this.selectionService.selectTasks({
|
|
946
|
+
projectKey: effectiveProject,
|
|
947
|
+
epicKey: effectiveEpic,
|
|
948
|
+
storyKey: effectiveStory,
|
|
949
|
+
taskKeys: effectiveTasks,
|
|
950
|
+
statusFilter: effectiveStatus,
|
|
951
|
+
});
|
|
952
|
+
this.dryRunGuard = request.dryRun ?? false;
|
|
953
|
+
if (request.dryRun) {
|
|
954
|
+
const dryResults = [];
|
|
955
|
+
for (const task of selection.ordered) {
|
|
956
|
+
let profile;
|
|
957
|
+
try {
|
|
958
|
+
profile = await this.profileService.resolveProfileForTask(task.task, {
|
|
959
|
+
profileName: request.profileName,
|
|
960
|
+
level: request.level,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
profile = undefined;
|
|
965
|
+
}
|
|
966
|
+
dryResults.push({
|
|
967
|
+
taskKey: task.task.key,
|
|
968
|
+
outcome: profile ? 'unclear' : 'infra_issue',
|
|
969
|
+
profile: profile?.name,
|
|
970
|
+
runner: profile?.runner,
|
|
971
|
+
notes: profile ? 'Dry-run: QA planned' : 'Dry-run: no profile available',
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
jobId: 'dry-run',
|
|
976
|
+
commandRunId: 'dry-run',
|
|
977
|
+
selection,
|
|
978
|
+
results: dryResults,
|
|
979
|
+
warnings: selection.warnings,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
await this.ensureMcoda();
|
|
983
|
+
const completedKeys = new Set();
|
|
984
|
+
const checkpoints = request.resumeJobId ? await this.deps.jobService.readCheckpoints(request.resumeJobId) : [];
|
|
985
|
+
const priorResults = new Map();
|
|
986
|
+
for (const ckpt of checkpoints) {
|
|
987
|
+
if (ckpt.stage?.startsWith('task:')) {
|
|
988
|
+
const parts = ckpt.stage.split(':');
|
|
989
|
+
if (parts[1])
|
|
990
|
+
completedKeys.add(parts[1]);
|
|
991
|
+
}
|
|
992
|
+
if (Array.isArray(ckpt.details?.completedTaskKeys)) {
|
|
993
|
+
for (const key of ckpt.details.completedTaskKeys) {
|
|
994
|
+
completedKeys.add(key);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (ckpt.details?.taskResult && ckpt.details.taskResult.taskKey) {
|
|
998
|
+
priorResults.set(ckpt.details.taskResult.taskKey, ckpt.details.taskResult);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const commandRun = await this.deps.jobService.startCommandRun('qa-tasks', effectiveProject, {
|
|
1002
|
+
taskIds: selection.ordered.map((t) => t.task.key),
|
|
1003
|
+
jobId: resume?.id,
|
|
1004
|
+
});
|
|
1005
|
+
const agentStream = request.agentStream !== false;
|
|
1006
|
+
const job = resume && resume.id
|
|
1007
|
+
? resume
|
|
1008
|
+
: await this.deps.jobService.startJob('qa', commandRun.id, effectiveProject, {
|
|
1009
|
+
commandName: 'qa-tasks',
|
|
1010
|
+
payload: {
|
|
1011
|
+
projectKey: effectiveProject,
|
|
1012
|
+
epicKey: effectiveEpic,
|
|
1013
|
+
storyKey: effectiveStory,
|
|
1014
|
+
tasks: effectiveTasks,
|
|
1015
|
+
statusFilter: effectiveStatus,
|
|
1016
|
+
mode: request.mode ?? 'auto',
|
|
1017
|
+
profile: request.profileName,
|
|
1018
|
+
level: request.level,
|
|
1019
|
+
agent: request.agentName,
|
|
1020
|
+
agentStream,
|
|
1021
|
+
createFollowups: request.createFollowupTasks ?? 'auto',
|
|
1022
|
+
dryRun: request.dryRun ?? false,
|
|
1023
|
+
},
|
|
1024
|
+
totalItems: selection.ordered.length,
|
|
1025
|
+
processedItems: completedKeys.size,
|
|
1026
|
+
});
|
|
1027
|
+
if (resume?.id) {
|
|
1028
|
+
try {
|
|
1029
|
+
const qaRuns = await this.deps.workspaceRepo.listTaskQaRunsForJob(selection.ordered.map((t) => t.task.id), resume.id);
|
|
1030
|
+
for (const run of qaRuns) {
|
|
1031
|
+
const task = selection.ordered.find((t) => t.task.id === run.taskId);
|
|
1032
|
+
if (task && run.recommendation) {
|
|
1033
|
+
completedKeys.add(task.task.key);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
// ignore resume enrichment failures
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const remaining = selection.ordered.filter((t) => !completedKeys.has(t.task.key));
|
|
1042
|
+
// Skip tasks that are already in a terminal QA state for this job (ready_to_qa -> completed/in_progress/blocked)
|
|
1043
|
+
const terminalStatuses = new Set(['completed', 'in_progress', 'blocked']);
|
|
1044
|
+
const skippedTerminal = [];
|
|
1045
|
+
for (const t of remaining) {
|
|
1046
|
+
if (terminalStatuses.has(t.task.status?.toLowerCase?.() ?? '')) {
|
|
1047
|
+
completedKeys.add(t.task.key);
|
|
1048
|
+
skippedTerminal.push({
|
|
1049
|
+
taskKey: t.task.key,
|
|
1050
|
+
outcome: 'pass',
|
|
1051
|
+
notes: `skipped (terminal status ${t.task.status})`,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const filteredRemaining = remaining.filter((t) => !terminalStatuses.has(t.task.status?.toLowerCase?.() ?? ''));
|
|
1056
|
+
await this.deps.jobService.updateJobStatus(job.id, 'running', {
|
|
1057
|
+
totalItems: selection.ordered.length,
|
|
1058
|
+
processedItems: completedKeys.size,
|
|
1059
|
+
});
|
|
1060
|
+
await this.checkpoint(job.id, 'selection', {
|
|
1061
|
+
ordered: selection.ordered.map((t) => t.task.key),
|
|
1062
|
+
blocked: selection.blocked.map((t) => t.task.key),
|
|
1063
|
+
completedTaskKeys: Array.from(completedKeys),
|
|
1064
|
+
});
|
|
1065
|
+
const results = [];
|
|
1066
|
+
for (const task of selection.ordered) {
|
|
1067
|
+
if (completedKeys.has(task.task.key)) {
|
|
1068
|
+
results.push(priorResults.get(task.task.key) ?? { taskKey: task.task.key, outcome: 'pass', notes: 'skipped (resume)' });
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
results.push(...skippedTerminal);
|
|
1072
|
+
try {
|
|
1073
|
+
let processedCount = completedKeys.size;
|
|
1074
|
+
for (const [index, task] of filteredRemaining.entries()) {
|
|
1075
|
+
const mode = request.mode ?? 'auto';
|
|
1076
|
+
if (mode === 'manual') {
|
|
1077
|
+
results.push(await this.runManual(task, { jobId: job.id, commandRunId: commandRun.id, request }));
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
results.push(await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request }));
|
|
1081
|
+
}
|
|
1082
|
+
completedKeys.add(task.task.key);
|
|
1083
|
+
processedCount = completedKeys.size;
|
|
1084
|
+
await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
|
|
1085
|
+
await this.checkpoint(job.id, `task:${task.task.key}:completed`, {
|
|
1086
|
+
processed: processedCount,
|
|
1087
|
+
completedTaskKeys: Array.from(completedKeys),
|
|
1088
|
+
taskResult: results[results.length - 1],
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
const failureCount = results.filter((r) => r.outcome !== 'pass').length;
|
|
1092
|
+
const state = failureCount === 0 ? 'completed' : failureCount === results.length ? 'failed' : 'partial';
|
|
1093
|
+
const errorSummary = failureCount ? `${failureCount} task(s) not passed QA` : undefined;
|
|
1094
|
+
await this.deps.jobService.updateJobStatus(job.id, state, { errorSummary });
|
|
1095
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, state === 'completed' ? 'succeeded' : 'failed', errorSummary);
|
|
1096
|
+
await this.checkpoint(job.id, 'completed', {
|
|
1097
|
+
state,
|
|
1098
|
+
processed: results.length,
|
|
1099
|
+
failures: failureCount,
|
|
1100
|
+
taskResults: results,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1105
|
+
await this.deps.jobService.updateJobStatus(job.id, 'failed', { errorSummary: message });
|
|
1106
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, 'failed', message);
|
|
1107
|
+
throw error;
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
jobId: job.id,
|
|
1111
|
+
commandRunId: commandRun.id,
|
|
1112
|
+
selection,
|
|
1113
|
+
results,
|
|
1114
|
+
warnings: selection.warnings,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
}
|