@mcoda/core 0.1.8 → 0.1.11

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 (216) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +2 -2
  3. package/dist/api/AgentsApi.d.ts +9 -1
  4. package/dist/api/AgentsApi.d.ts.map +1 -1
  5. package/dist/api/AgentsApi.js +201 -6
  6. package/dist/api/QaTasksApi.d.ts.map +1 -1
  7. package/dist/api/QaTasksApi.js +6 -0
  8. package/dist/api/TasksApi.d.ts.map +1 -1
  9. package/dist/api/TasksApi.js +1 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -0
  13. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  14. package/dist/prompts/PdrPrompts.js +9 -1
  15. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  16. package/dist/prompts/SdsPrompts.js +9 -0
  17. package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
  18. package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
  19. package/dist/services/agents/AgentRatingFormula.js +45 -0
  20. package/dist/services/agents/AgentRatingService.d.ts +60 -0
  21. package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
  22. package/dist/services/agents/AgentRatingService.js +363 -0
  23. package/dist/services/agents/GatewayAgentService.d.ts +11 -0
  24. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  25. package/dist/services/agents/GatewayAgentService.js +525 -84
  26. package/dist/services/agents/GatewayHandoff.d.ts +11 -0
  27. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
  28. package/dist/services/agents/GatewayHandoff.js +141 -0
  29. package/dist/services/agents/RoutingService.d.ts +1 -0
  30. package/dist/services/agents/RoutingService.d.ts.map +1 -1
  31. package/dist/services/agents/RoutingService.js +4 -4
  32. package/dist/services/backlog/BacklogService.d.ts +23 -0
  33. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  34. package/dist/services/backlog/BacklogService.js +62 -7
  35. package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
  36. package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
  37. package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
  38. package/dist/services/backlog/TaskOrderingService.d.ts +17 -4
  39. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  40. package/dist/services/backlog/TaskOrderingService.js +538 -79
  41. package/dist/services/docs/DocInventory.d.ts +11 -0
  42. package/dist/services/docs/DocInventory.d.ts.map +1 -0
  43. package/dist/services/docs/DocInventory.js +230 -0
  44. package/dist/services/docs/DocgenRunContext.d.ts +59 -0
  45. package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
  46. package/dist/services/docs/DocgenRunContext.js +4 -0
  47. package/dist/services/docs/DocsService.d.ts +70 -3
  48. package/dist/services/docs/DocsService.d.ts.map +1 -1
  49. package/dist/services/docs/DocsService.js +1930 -89
  50. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
  51. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
  52. package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
  53. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
  54. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
  55. package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
  56. package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
  57. package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
  58. package/dist/services/docs/patch/DocPatchEngine.js +331 -0
  59. package/dist/services/docs/review/Glossary.d.ts +16 -0
  60. package/dist/services/docs/review/Glossary.d.ts.map +1 -0
  61. package/dist/services/docs/review/Glossary.js +47 -0
  62. package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
  63. package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
  64. package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
  65. package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
  66. package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
  67. package/dist/services/docs/review/ReviewReportSchema.js +47 -0
  68. package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
  69. package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
  70. package/dist/services/docs/review/ReviewTypes.js +94 -0
  71. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
  72. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
  73. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
  74. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
  75. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
  76. package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
  77. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
  78. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
  79. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
  80. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
  81. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
  82. package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
  83. package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
  84. package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
  85. package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
  86. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
  87. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
  88. package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
  89. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
  90. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
  91. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
  92. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
  93. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
  94. package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
  95. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
  96. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
  97. package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
  98. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
  99. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
  100. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
  101. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
  102. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
  103. package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
  104. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
  105. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
  106. package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
  107. package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
  108. package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
  109. package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
  110. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
  111. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
  112. package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
  113. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
  114. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
  115. package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
  116. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
  117. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
  118. package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
  119. package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
  120. package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
  121. package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
  122. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
  123. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
  124. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
  125. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
  126. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
  127. package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
  128. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
  129. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
  130. package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
  131. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
  132. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
  133. package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
  134. package/dist/services/docs/review/glossary.json +47 -0
  135. package/dist/services/estimate/EstimateService.d.ts +2 -0
  136. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  137. package/dist/services/estimate/EstimateService.js +66 -18
  138. package/dist/services/estimate/VelocityService.d.ts +4 -0
  139. package/dist/services/estimate/VelocityService.d.ts.map +1 -1
  140. package/dist/services/estimate/VelocityService.js +179 -36
  141. package/dist/services/estimate/types.d.ts +1 -0
  142. package/dist/services/estimate/types.d.ts.map +1 -1
  143. package/dist/services/execution/GatewayTrioService.d.ts +200 -0
  144. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
  145. package/dist/services/execution/GatewayTrioService.js +2492 -0
  146. package/dist/services/execution/QaApiRunner.d.ts +30 -0
  147. package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
  148. package/dist/services/execution/QaApiRunner.js +881 -0
  149. package/dist/services/execution/QaFollowupService.d.ts +2 -0
  150. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  151. package/dist/services/execution/QaFollowupService.js +9 -2
  152. package/dist/services/execution/QaPlanValidator.d.ts +10 -0
  153. package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
  154. package/dist/services/execution/QaPlanValidator.js +128 -0
  155. package/dist/services/execution/QaProfileService.d.ts +27 -1
  156. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  157. package/dist/services/execution/QaProfileService.js +354 -7
  158. package/dist/services/execution/QaTasksService.d.ts +59 -1
  159. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  160. package/dist/services/execution/QaTasksService.js +3347 -318
  161. package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
  162. package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
  163. package/dist/services/execution/QaTestCommandBuilder.js +495 -0
  164. package/dist/services/execution/TaskSelectionService.d.ts +4 -2
  165. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  166. package/dist/services/execution/TaskSelectionService.js +144 -28
  167. package/dist/services/execution/TaskStateService.d.ts +19 -6
  168. package/dist/services/execution/TaskStateService.d.ts.map +1 -1
  169. package/dist/services/execution/TaskStateService.js +128 -13
  170. package/dist/services/execution/WorkOnTasksService.d.ts +32 -1
  171. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  172. package/dist/services/execution/WorkOnTasksService.js +4667 -722
  173. package/dist/services/jobs/JobInsightsService.d.ts +4 -0
  174. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
  175. package/dist/services/jobs/JobInsightsService.js +51 -5
  176. package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
  177. package/dist/services/jobs/JobResumeService.js +23 -10
  178. package/dist/services/jobs/JobService.d.ts +56 -4
  179. package/dist/services/jobs/JobService.d.ts.map +1 -1
  180. package/dist/services/jobs/JobService.js +232 -1
  181. package/dist/services/openapi/OpenApiService.d.ts +51 -0
  182. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  183. package/dist/services/openapi/OpenApiService.js +953 -106
  184. package/dist/services/planning/CreateTasksService.d.ts +21 -0
  185. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  186. package/dist/services/planning/CreateTasksService.js +569 -31
  187. package/dist/services/planning/RefineTasksService.d.ts +9 -0
  188. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  189. package/dist/services/planning/RefineTasksService.js +409 -59
  190. package/dist/services/review/CodeReviewService.d.ts +18 -0
  191. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  192. package/dist/services/review/CodeReviewService.js +1309 -167
  193. package/dist/services/review/ReviewNormalizer.d.ts +9 -0
  194. package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
  195. package/dist/services/review/ReviewNormalizer.js +147 -0
  196. package/dist/services/shared/AuthErrors.d.ts +3 -0
  197. package/dist/services/shared/AuthErrors.d.ts.map +1 -0
  198. package/dist/services/shared/AuthErrors.js +17 -0
  199. package/dist/services/shared/DocdexGuidance.d.ts +7 -0
  200. package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
  201. package/dist/services/shared/DocdexGuidance.js +12 -0
  202. package/dist/services/shared/ProjectGuidance.d.ts +17 -0
  203. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
  204. package/dist/services/shared/ProjectGuidance.js +78 -0
  205. package/dist/services/system/ToolDenylist.d.ts +13 -0
  206. package/dist/services/system/ToolDenylist.d.ts.map +1 -0
  207. package/dist/services/system/ToolDenylist.js +85 -0
  208. package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
  209. package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
  210. package/dist/services/tasks/TaskCommentFormatter.js +54 -0
  211. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
  212. package/dist/services/telemetry/TelemetryService.js +39 -7
  213. package/dist/workspace/WorkspaceManager.d.ts +26 -0
  214. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  215. package/dist/workspace/WorkspaceManager.js +206 -32
  216. package/package.json +6 -5
@@ -1,14 +1,23 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createRequire } from 'node:module';
7
+ import { execFile, spawn } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { once } from 'node:events';
10
+ import { setTimeout as delay } from 'node:timers/promises';
11
+ import net from 'node:net';
3
12
  import { WorkspaceRepository } from '@mcoda/db';
4
- import { PathHelper } from '@mcoda/shared';
13
+ import { PathHelper, QA_ALLOWED_STATUSES, filterTaskStatuses } from '@mcoda/shared';
5
14
  import { JobService } from '../jobs/JobService.js';
6
15
  import { TaskSelectionService } from './TaskSelectionService.js';
7
16
  import { TaskStateService } from './TaskStateService.js';
8
17
  import { QaProfileService } from './QaProfileService.js';
9
18
  import { QaFollowupService } from './QaFollowupService.js';
10
19
  import { CliQaAdapter } from '@mcoda/integrations/qa/CliQaAdapter.js';
11
- import { ChromiumQaAdapter } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
20
+ import { ChromiumQaAdapter, resolveChromiumBinary } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
12
21
  import { MaestroQaAdapter } from '@mcoda/integrations/qa/MaestroQaAdapter.js';
13
22
  import { VcsClient } from '@mcoda/integrations';
14
23
  import readline from 'node:readline/promises';
@@ -17,13 +26,384 @@ import { AgentService } from '@mcoda/agents';
17
26
  import { GlobalRepository } from '@mcoda/db';
18
27
  import { DocdexClient } from '@mcoda/integrations';
19
28
  import { RoutingService } from '../agents/RoutingService.js';
29
+ import { AgentRatingService } from '../agents/AgentRatingService.js';
30
+ import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from '../shared/ProjectGuidance.js';
31
+ import { buildDocdexUsageGuidance } from '../shared/DocdexGuidance.js';
32
+ import { createTaskCommentSlug, formatTaskCommentBody } from '../tasks/TaskCommentFormatter.js';
33
+ import { AUTH_ERROR_REASON, isAuthErrorMessage } from '../shared/AuthErrors.js';
34
+ import { normalizeQaPlanOutput } from './QaPlanValidator.js';
35
+ import { QaApiRunner } from './QaApiRunner.js';
36
+ import { QaTestCommandBuilder } from './QaTestCommandBuilder.js';
37
+ const execFileAsync = promisify(execFile);
20
38
  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.',
39
+ 'You are the QA agent.',
40
+ buildDocdexUsageGuidance({ contextLabel: 'the QA report', includeHeading: false, includeFallback: true }),
41
+ 'Use docdex snippets to derive acceptance criteria, data contracts, edge cases, and non-functional requirements (performance, accessibility, offline/online assumptions).',
42
+ 'QA policy: always run automated tests. Use browser (Chromium) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage. When test_requirements list unit/component/integration/api, run them in that order using stack-appropriate tools. Prefer tests/all.js or package manager test scripts when no category split is available; do not suggest Jest configs unless the repo explicitly documents them.',
23
43
  ].join('\n');
44
+ const REPO_PROMPTS_DIR = fileURLToPath(new URL('../../../../../prompts/', import.meta.url));
45
+ const resolveRepoPromptPath = (filename) => path.join(REPO_PROMPTS_DIR, filename);
46
+ const QA_TEST_POLICY = 'QA policy: always run automated tests. Use browser (Chromium) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage. When test_requirements list unit/component/integration/api, run them in that order using stack-appropriate tools. Prefer tests/all.js or package manager test scripts when no category split is available; do not suggest Jest configs unless the repo explicitly documents them.';
47
+ const QA_ROUTING_PROMPT = [
48
+ 'You are the mcoda QA routing agent.',
49
+ 'Decide which QA profiles should run for each task in this job.',
50
+ 'Return a QA plan that maps tasks to profiles and action lists (cli/api/browser/stress).',
51
+ 'Only use the provided profile names.',
52
+ 'Always include CLI when tests are available.',
53
+ 'When adding CLI commands, cover functional checks: unit -> component -> integration -> api (when test_requirements exist), then tests/all.js or package.json test script, plus build, lint, and CLI smoke commands where relevant.',
54
+ 'Prefer category scripts (test:unit/test:component/test:integration/test:api) when they exist; otherwise use stack-appropriate test tools.',
55
+ 'UI/front-end tasks must include chromium alongside CLI and include browser actions.',
56
+ 'Only include chromium when the task itself is UI/front-end (ui_task=yes). Do not add chromium just because ui_repo=yes.',
57
+ 'Include at least one light stress action for UI tasks (repeat navigation or submit) when safe.',
58
+ 'Browser actions must use types navigate/click/type/wait_for/snapshot/script; do not emit assertText/assert_text. For text checks, use a script action (optionally preceded by snapshot).',
59
+ 'API/back-end tasks (api_task=yes) must include API requests with sample data/auth when available.',
60
+ 'Only include API requests when api_task=yes or the plan explicitly defines api base_url/requests.',
61
+ 'Do not hardcode ports. If base_url is unknown, omit it and rely on MCODA_QA_API_BASE_URL or detected server ports.',
62
+ 'If unsure, choose a safe minimal set (usually [\"cli\"]) and avoid guessing API endpoints.',
63
+ ].join('\n');
64
+ const QA_ROUTING_OUTPUT_SCHEMA = [
65
+ 'Return strict JSON only with shape:',
66
+ 'Browser action types: navigate/click/type/wait_for/snapshot/script. Use script+expect for text checks; never emit assertText/assert_text.',
67
+ '{',
68
+ ' \"task_profiles\": { \"TASK_KEY\": [\"profile1\", \"profile2\"] },',
69
+ ' \"task_plans\": {',
70
+ ' \"TASK_KEY\": {',
71
+ ' \"profiles\": [\"cli\", \"chromium\"],',
72
+ ' \"cli\": { \"commands\": [\"pnpm test\", \"node tests/all.js\"] },',
73
+ ' \"api\": { \"base_url\": \"http://localhost:<PORT>\", \"requests\": [{ \"method\": \"GET\", \"path\": \"/health\", \"expect\": { \"status\": 200 } }] },',
74
+ ' \"browser\": { \"base_url\": \"http://localhost:<PORT>\", \"actions\": [{ \"type\": \"navigate\", \"url\": \"/\" }, { \"type\": \"snapshot\", \"name\": \"home\" }, { \"type\": \"script\", \"expression\": \"document.body ? document.body.innerText : \\\"\\\"\", \"expect\": \"Welcome\" }] },',
75
+ ' \"stress\": { \"api\": [], \"browser\": [] }',
76
+ ' }',
77
+ ' },',
78
+ ' \"notes\": \"optional\"',
79
+ '}',
80
+ 'No markdown or prose. task_profiles is required; task_plans is optional when actions are unknown.',
81
+ ].join('\n');
82
+ const QA_AGENT_INTERPRETATION_ENV = 'MCODA_QA_AGENT_INTERPRETATION';
83
+ const QA_REQUIRED_DEPS = ['argon2', 'pg', 'ioredis', '@jest/globals'];
84
+ const QA_REQUIRED_ENV = [
85
+ { dep: 'pg', env: 'TEST_DB_URL' },
86
+ { dep: 'ioredis', env: 'TEST_REDIS_URL' },
87
+ ];
24
88
  const DEFAULT_JOB_PROMPT = 'You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.';
25
89
  const DEFAULT_CHARACTER_PROMPT = 'Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.';
26
- const MCODA_GITIGNORE_ENTRY = '.mcoda/\n';
90
+ const GATEWAY_PROMPT_MARKERS = [
91
+ 'you are the gateway agent',
92
+ 'return json only',
93
+ 'output json only',
94
+ 'docdexnotes',
95
+ 'fileslikelytouched',
96
+ 'filestocreate',
97
+ 'do not include fields outside the schema',
98
+ ];
99
+ const sanitizeNonGatewayPrompt = (value) => {
100
+ if (!value)
101
+ return undefined;
102
+ const trimmed = value.trim();
103
+ if (!trimmed)
104
+ return undefined;
105
+ const lower = trimmed.toLowerCase();
106
+ if (GATEWAY_PROMPT_MARKERS.some((marker) => lower.includes(marker)))
107
+ return undefined;
108
+ return trimmed;
109
+ };
110
+ const readPromptFile = async (promptPath, fallback) => {
111
+ try {
112
+ const content = await fs.readFile(promptPath, 'utf8');
113
+ const trimmed = content.trim();
114
+ if (trimmed)
115
+ return trimmed;
116
+ }
117
+ catch {
118
+ // fall through to fallback
119
+ }
120
+ return fallback;
121
+ };
122
+ const RUN_ALL_TESTS_MARKER = 'mcoda_run_all_tests_complete';
123
+ const RUN_ALL_TESTS_GUIDANCE = 'Run-all tests did not emit the expected marker. Ensure tests/all.js prints "MCODA_RUN_ALL_TESTS_COMPLETE".';
124
+ const QA_CLEAN_IGNORE_DEFAULTS = ['test-results', 'repo_meta.json', 'logs/', '.docdexignore'];
125
+ const DEFAULT_QA_HOST = '127.0.0.1';
126
+ const QA_HOST_ENV_KEYS = ['HOST', 'BIND_ADDR', 'BIND_ADDRESS', 'LISTEN_HOST', 'VITE_HOST', 'NUXT_HOST'];
127
+ const QA_PORT_ENV_KEYS = ['PORT', 'VITE_PORT', 'NUXT_PORT', 'NEXT_PORT'];
128
+ const QA_SERVER_START_ENV = 'MCODA_QA_START_SERVER';
129
+ const QA_SERVER_TIMEOUT_ENV = 'MCODA_QA_SERVER_TIMEOUT_MS';
130
+ const DEFAULT_QA_SERVER_TIMEOUT_MS = 5000;
131
+ const QA_INSTALL_DEPS_ENV = 'MCODA_QA_INSTALL_DEPS';
132
+ const normalizeSlugList = (input) => {
133
+ if (!Array.isArray(input))
134
+ return [];
135
+ const cleaned = new Set();
136
+ for (const slug of input) {
137
+ if (typeof slug !== 'string')
138
+ continue;
139
+ const trimmed = slug.trim();
140
+ if (trimmed)
141
+ cleaned.add(trimmed);
142
+ }
143
+ return Array.from(cleaned);
144
+ };
145
+ const normalizePath = (value) => value
146
+ .replace(/\\/g, '/')
147
+ .replace(/^\.\//, '')
148
+ .replace(/^\/+/, '');
149
+ const normalizeCleanIgnorePaths = (input) => {
150
+ const normalized = new Set();
151
+ for (const entry of input) {
152
+ if (!entry)
153
+ continue;
154
+ const trimmed = entry.trim();
155
+ if (!trimmed)
156
+ continue;
157
+ normalized.add(normalizePath(trimmed));
158
+ }
159
+ return Array.from(normalized).filter(Boolean);
160
+ };
161
+ const normalizeLineNumber = (value) => {
162
+ if (typeof value === 'number' && Number.isFinite(value)) {
163
+ return Math.max(1, Math.round(value));
164
+ }
165
+ if (typeof value === 'string') {
166
+ const parsed = Number.parseInt(value, 10);
167
+ if (Number.isFinite(parsed))
168
+ return Math.max(1, parsed);
169
+ }
170
+ return undefined;
171
+ };
172
+ const detectApiTask = (task) => {
173
+ const metadata = task.metadata ?? {};
174
+ const files = Array.isArray(metadata.files) ? metadata.files : [];
175
+ const reviewFiles = Array.isArray(metadata.last_review_changed_paths)
176
+ ? metadata.last_review_changed_paths
177
+ : [];
178
+ const combined = [...files, ...reviewFiles].map((file) => String(file).toLowerCase());
179
+ const apiHints = ['/api/', '/routes/', '/controllers/', '/server/', '/backend/', '/services/'];
180
+ if (combined.some((file) => apiHints.some((hint) => file.includes(hint))))
181
+ return true;
182
+ const key = (task.key ?? '').toLowerCase();
183
+ if (key.startsWith('bck-') || key.startsWith('api-'))
184
+ return true;
185
+ const type = String(task.type ?? '').toLowerCase();
186
+ if (type.includes('backend') || type.includes('api'))
187
+ return true;
188
+ const acceptance = Array.isArray(task.acceptanceCriteria)
189
+ ? task.acceptanceCriteria
190
+ : [];
191
+ const text = [task.key, task.title, task.description, task.type, ...acceptance]
192
+ .filter(Boolean)
193
+ .join(' ')
194
+ .toLowerCase();
195
+ if (!text)
196
+ return false;
197
+ const apiPhrases = [
198
+ 'endpoint',
199
+ 'route',
200
+ 'router',
201
+ 'controller',
202
+ 'backend',
203
+ 'server',
204
+ 'openapi',
205
+ 'swagger',
206
+ 'graphql',
207
+ 'rest',
208
+ ];
209
+ return apiPhrases.some((phrase) => text.includes(phrase));
210
+ };
211
+ const applyQaHostDefaults = (env) => {
212
+ const next = { ...env };
213
+ for (const key of QA_HOST_ENV_KEYS) {
214
+ const value = next[key];
215
+ if (!value || value === '0.0.0.0') {
216
+ next[key] = DEFAULT_QA_HOST;
217
+ }
218
+ }
219
+ return next;
220
+ };
221
+ const resolveEnvPort = (env) => {
222
+ for (const key of QA_PORT_ENV_KEYS) {
223
+ const raw = env[key];
224
+ if (!raw)
225
+ continue;
226
+ const parsed = Number.parseInt(raw, 10);
227
+ if (Number.isFinite(parsed) && parsed > 0)
228
+ return parsed;
229
+ }
230
+ return undefined;
231
+ };
232
+ const applyQaPortDefaults = (env, port) => {
233
+ for (const key of QA_PORT_ENV_KEYS) {
234
+ if (!env[key]) {
235
+ env[key] = String(port);
236
+ }
237
+ }
238
+ };
239
+ const resolveUrlPort = (value) => {
240
+ try {
241
+ const url = new URL(value);
242
+ const port = url.port !== ''
243
+ ? Number.parseInt(url.port, 10)
244
+ : url.protocol === 'https:'
245
+ ? 443
246
+ : 80;
247
+ if (!Number.isFinite(port) || port <= 0)
248
+ return undefined;
249
+ return { url, port };
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ };
255
+ const isPortOpen = async (host, port, timeoutMs = 500) => await new Promise((resolve) => {
256
+ const socket = new net.Socket();
257
+ let settled = false;
258
+ const finish = (open) => {
259
+ if (settled)
260
+ return;
261
+ settled = true;
262
+ socket.destroy();
263
+ resolve(open);
264
+ };
265
+ const timer = setTimeout(() => finish(false), timeoutMs);
266
+ socket.once('error', () => {
267
+ clearTimeout(timer);
268
+ finish(false);
269
+ });
270
+ socket.once('connect', () => {
271
+ clearTimeout(timer);
272
+ finish(true);
273
+ });
274
+ socket.connect(port, host);
275
+ });
276
+ const pickFreePort = async (host) => await new Promise((resolve, reject) => {
277
+ const server = net.createServer();
278
+ server.unref();
279
+ server.on('error', reject);
280
+ server.listen(0, host, () => {
281
+ const address = server.address();
282
+ if (typeof address === 'object' && address?.port) {
283
+ const port = address.port;
284
+ server.close(() => resolve(port));
285
+ }
286
+ else {
287
+ server.close(() => reject(new Error('Failed to acquire free port')));
288
+ }
289
+ });
290
+ });
291
+ const normalizeQaUrl = (value) => {
292
+ if (!value)
293
+ return undefined;
294
+ try {
295
+ const url = new URL(value);
296
+ if (url.hostname === '0.0.0.0') {
297
+ url.hostname = DEFAULT_QA_HOST;
298
+ }
299
+ return url.toString().replace(/\/$/, '');
300
+ }
301
+ catch {
302
+ return value;
303
+ }
304
+ };
305
+ const isEnvEnabled = (name) => {
306
+ const raw = process.env[name];
307
+ if (!raw)
308
+ return false;
309
+ const normalized = raw.trim().toLowerCase();
310
+ return !['0', 'false', 'off', 'no'].includes(normalized);
311
+ };
312
+ const shouldAutoStartServer = () => {
313
+ if (process.env[QA_SERVER_START_ENV] === undefined)
314
+ return true;
315
+ return isEnvEnabled(QA_SERVER_START_ENV);
316
+ };
317
+ const resolveServerTimeoutMs = () => {
318
+ const raw = process.env[QA_SERVER_TIMEOUT_ENV];
319
+ if (!raw)
320
+ return DEFAULT_QA_SERVER_TIMEOUT_MS;
321
+ const parsed = Number.parseInt(raw, 10);
322
+ if (Number.isFinite(parsed)) {
323
+ if (parsed <= 0)
324
+ return 0;
325
+ return parsed;
326
+ }
327
+ return DEFAULT_QA_SERVER_TIMEOUT_MS;
328
+ };
329
+ const isLocalBaseUrl = (value) => {
330
+ if (!value)
331
+ return false;
332
+ try {
333
+ const url = new URL(value);
334
+ return ['127.0.0.1', 'localhost'].includes(url.hostname);
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ };
340
+ const parseCommentBody = (body) => {
341
+ const trimmed = (body ?? '').trim();
342
+ if (!trimmed)
343
+ return { message: '(no details provided)' };
344
+ const lines = trimmed.split(/\r?\n/);
345
+ const normalize = (value) => value.trim().toLowerCase();
346
+ const messageIndex = lines.findIndex((line) => normalize(line) === 'message:');
347
+ const suggestedIndex = lines.findIndex((line) => {
348
+ const normalized = normalize(line);
349
+ return normalized === 'suggested_fix:' || normalized === 'suggested fix:';
350
+ });
351
+ if (messageIndex >= 0) {
352
+ const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
353
+ const message = messageLines.join('\n').trim();
354
+ const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
355
+ const suggestedFix = suggestedLines.join('\n').trim();
356
+ return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
357
+ }
358
+ if (suggestedIndex >= 0) {
359
+ const message = lines.slice(0, suggestedIndex).join('\n').trim() || trimmed;
360
+ const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
361
+ const suggestedTail = lines.slice(suggestedIndex + 1).join('\n').trim();
362
+ const suggestedFix = inlineFix || suggestedTail || undefined;
363
+ return { message, suggestedFix };
364
+ }
365
+ return { message: trimmed };
366
+ };
367
+ const buildCommentBacklog = (comments) => {
368
+ if (!comments.length)
369
+ return '';
370
+ const seen = new Set();
371
+ const lines = [];
372
+ const toSingleLine = (value) => value.replace(/\s+/g, ' ').trim();
373
+ for (const comment of comments) {
374
+ const details = parseCommentBody(comment.body);
375
+ const slug = comment.slug?.trim() ||
376
+ createTaskCommentSlug({
377
+ source: comment.sourceCommand ?? 'comment',
378
+ message: details.message || comment.body,
379
+ file: comment.file,
380
+ line: comment.line,
381
+ category: comment.category ?? null,
382
+ });
383
+ const key = slug ??
384
+ `${comment.sourceCommand}:${comment.file ?? ''}:${comment.line ?? ''}:${details.message || comment.body}`;
385
+ if (seen.has(key))
386
+ continue;
387
+ seen.add(key);
388
+ const location = comment.file
389
+ ? `${comment.file}${typeof comment.line === 'number' ? `:${comment.line}` : ''}`
390
+ : '(location not specified)';
391
+ const message = toSingleLine(details.message || comment.body || '(no details provided)');
392
+ lines.push(`- [${slug ?? 'untracked'}] ${location} ${message}`);
393
+ const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
394
+ if (suggestedFix) {
395
+ lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
396
+ }
397
+ }
398
+ return lines.join('\n');
399
+ };
400
+ const formatSlugList = (slugs, limit = 12) => {
401
+ if (!slugs.length)
402
+ return 'none';
403
+ if (slugs.length <= limit)
404
+ return slugs.join(', ');
405
+ return `${slugs.slice(0, limit).join(', ')} (+${slugs.length - limit} more)`;
406
+ };
27
407
  export class QaTasksService {
28
408
  constructor(workspace, deps) {
29
409
  this.workspace = workspace;
@@ -31,7 +411,8 @@ export class QaTasksService {
31
411
  this.dryRunGuard = false;
32
412
  this.selectionService = deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
33
413
  this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
34
- this.profileService = deps.profileService ?? new QaProfileService(workspace.workspaceRoot);
414
+ this.profileService =
415
+ deps.profileService ?? new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
35
416
  this.followupService = deps.followupService ?? new QaFollowupService(deps.workspaceRepo, workspace.workspaceRoot);
36
417
  this.jobService = deps.jobService;
37
418
  this.vcs = deps.vcsClient ?? new VcsClient();
@@ -39,13 +420,16 @@ export class QaTasksService {
39
420
  this.docdex = deps.docdex;
40
421
  this.repo = deps.repo;
41
422
  this.routingService = deps.routingService;
423
+ this.ratingService = deps.ratingService;
42
424
  }
43
425
  static async create(workspace, options = {}) {
44
426
  const repo = await GlobalRepository.create();
45
427
  const agentService = new AgentService(repo);
428
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
46
429
  const docdex = new DocdexClient({
47
430
  workspaceRoot: workspace.workspaceRoot,
48
431
  baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
432
+ repoId: docdexRepoId,
49
433
  });
50
434
  const routingService = await RoutingService.create();
51
435
  const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
@@ -54,7 +438,7 @@ export class QaTasksService {
54
438
  });
55
439
  const selectionService = new TaskSelectionService(workspace, workspaceRepo);
56
440
  const stateService = new TaskStateService(workspaceRepo);
57
- const profileService = new QaProfileService(workspace.workspaceRoot);
441
+ const profileService = new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
58
442
  const followupService = new QaFollowupService(workspaceRepo, workspace.workspaceRoot);
59
443
  const vcsClient = new VcsClient();
60
444
  return new QaTasksService(workspace, {
@@ -90,6 +474,14 @@ export class QaTasksService {
90
474
  await maybeClose(this.docdex);
91
475
  await maybeClose(this.deps.routingService);
92
476
  }
477
+ setDocdexAvailability(available, reason) {
478
+ if (available)
479
+ return;
480
+ const docdex = this.docdex;
481
+ if (docdex && typeof docdex.disable === "function") {
482
+ docdex.disable(reason);
483
+ }
484
+ }
93
485
  async readPromptFiles(paths) {
94
486
  const contents = [];
95
487
  const seen = new Set();
@@ -109,12 +501,62 @@ export class QaTasksService {
109
501
  return contents;
110
502
  }
111
503
  async loadPrompts(agentId) {
112
- const mcodaPromptPath = path.join(this.workspace.workspaceRoot, '.mcoda', 'prompts', 'qa-agent.md');
504
+ const mcodaPromptPath = path.join(this.workspace.mcodaDir, 'prompts', 'qa-agent.md');
113
505
  const workspacePromptPath = path.join(this.workspace.workspaceRoot, 'prompts', 'qa-agent.md');
506
+ const repoPromptPath = resolveRepoPromptPath('qa-agent.md');
507
+ const isStalePrompt = (value) => {
508
+ if (!value)
509
+ return false;
510
+ return (/playwright/i.test(value) ||
511
+ /legacy/i.test(value) ||
512
+ /MCODA_QA_BROWSER_URL/i.test(value) ||
513
+ /http:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}/i.test(value));
514
+ };
114
515
  try {
115
516
  await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
116
- await fs.access(mcodaPromptPath);
117
- console.info(`[qa-tasks] using existing QA prompt at ${mcodaPromptPath}`);
517
+ let existingPrompt;
518
+ try {
519
+ existingPrompt = await fs.readFile(mcodaPromptPath, 'utf8');
520
+ }
521
+ catch {
522
+ existingPrompt = undefined;
523
+ }
524
+ if (existingPrompt && !isStalePrompt(existingPrompt)) {
525
+ console.info(`[qa-tasks] using existing QA prompt at ${mcodaPromptPath}`);
526
+ }
527
+ else {
528
+ if (existingPrompt) {
529
+ console.info(`[qa-tasks] refreshing stale QA prompt at ${mcodaPromptPath}`);
530
+ }
531
+ let sourcePrompt;
532
+ try {
533
+ const workspacePrompt = await fs.readFile(workspacePromptPath, 'utf8');
534
+ if (!isStalePrompt(workspacePrompt)) {
535
+ sourcePrompt = workspacePrompt;
536
+ console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
537
+ }
538
+ }
539
+ catch {
540
+ // ignore workspace prompt
541
+ }
542
+ if (!sourcePrompt) {
543
+ try {
544
+ const repoPrompt = await fs.readFile(repoPromptPath, 'utf8');
545
+ if (!isStalePrompt(repoPrompt)) {
546
+ sourcePrompt = repoPrompt;
547
+ console.info(`[qa-tasks] copied repo QA prompt to ${mcodaPromptPath}`);
548
+ }
549
+ }
550
+ catch {
551
+ // ignore repo prompt
552
+ }
553
+ }
554
+ if (!sourcePrompt) {
555
+ console.info(`[qa-tasks] no QA prompt found at ${workspacePromptPath} or repo prompts; writing default prompt to ${mcodaPromptPath}`);
556
+ sourcePrompt = DEFAULT_QA_PROMPT;
557
+ }
558
+ await fs.writeFile(mcodaPromptPath, sourcePrompt, 'utf8');
559
+ }
118
560
  }
119
561
  catch {
120
562
  try {
@@ -123,25 +565,24 @@ export class QaTasksService {
123
565
  console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
124
566
  }
125
567
  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');
568
+ try {
569
+ await fs.access(repoPromptPath);
570
+ await fs.copyFile(repoPromptPath, mcodaPromptPath);
571
+ console.info(`[qa-tasks] copied repo QA prompt to ${mcodaPromptPath}`);
572
+ }
573
+ catch {
574
+ console.info(`[qa-tasks] no QA prompt found at ${workspacePromptPath} or repo prompts; writing default prompt to ${mcodaPromptPath}`);
575
+ await fs.writeFile(mcodaPromptPath, DEFAULT_QA_PROMPT, 'utf8');
576
+ }
128
577
  }
129
578
  }
130
- const commandPromptFiles = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
131
579
  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
- })();
580
+ const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_QA_PROMPT);
581
+ const commandPrompt = agentPrompts?.commandPrompts?.['qa-tasks']?.trim() || filePrompt;
141
582
  return {
142
- jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
143
- characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
144
- commandPrompt: mergedCommandPrompt || undefined,
583
+ jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
584
+ characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
585
+ commandPrompt: commandPrompt || undefined,
145
586
  };
146
587
  }
147
588
  async checkpoint(jobId, stage, details) {
@@ -151,22 +592,71 @@ export class QaTasksService {
151
592
  details,
152
593
  });
153
594
  }
154
- async ensureTaskBranch(task, taskRunId) {
595
+ async ensureTaskBranch(task, taskRunId, jobId, allowDirty, cleanIgnorePaths) {
155
596
  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);
597
+ const repoRoot = this.workspace.workspaceRoot;
598
+ await this.vcs.ensureRepo(repoRoot);
599
+ if (!allowDirty) {
600
+ const ignorePaths = this.buildCleanIgnorePaths(cleanIgnorePaths);
601
+ if (ignorePaths.length) {
602
+ await this.logTask(taskRunId, `VCS clean ignore paths: ${ignorePaths.join(", ")}`, 'vcs', { ignorePaths });
603
+ }
604
+ await this.vcs.ensureClean(repoRoot, true, ignorePaths);
605
+ }
606
+ let branch = task.task.vcsBranch;
607
+ if (branch) {
608
+ const exists = await this.vcs.branchExists(repoRoot, branch);
160
609
  if (!exists) {
161
- return { ok: false, message: `Task branch ${task.task.vcsBranch} not found` };
610
+ return { ok: false, message: `Task branch ${branch} not found` };
162
611
  }
163
- await this.vcs.checkoutBranch(this.workspace.workspaceRoot, task.task.vcsBranch);
164
612
  }
165
613
  else {
166
614
  const base = this.workspace.config?.branch ?? 'mcoda-dev';
167
- await this.vcs.ensureBaseBranch(this.workspace.workspaceRoot, base);
615
+ await this.vcs.ensureBaseBranch(repoRoot, base);
616
+ branch = base;
168
617
  }
169
- return { ok: true };
618
+ const worktreeRoot = path.join(this.workspace.mcodaDir, 'jobs', jobId, 'qa-worktrees', task.task.key);
619
+ await fs.rm(worktreeRoot, { recursive: true, force: true });
620
+ await PathHelper.ensureDir(path.dirname(worktreeRoot));
621
+ const repoBranch = await this.vcs.currentBranch(repoRoot);
622
+ const preferDetached = repoBranch === branch;
623
+ await this.vcs.addWorktree(repoRoot, worktreeRoot, branch, { detach: preferDetached });
624
+ const reportedBranch = await this.vcs.currentBranch(worktreeRoot);
625
+ let activeBranch = reportedBranch ?? branch;
626
+ if (reportedBranch && reportedBranch !== branch) {
627
+ if (reportedBranch === 'HEAD') {
628
+ await this.logTask(taskRunId, `QA worktree is detached at ${branch}; keeping detached HEAD to avoid branch lock.`, 'vcs', { expected: branch, found: reportedBranch });
629
+ activeBranch = branch;
630
+ }
631
+ else {
632
+ await this.logTask(taskRunId, `QA worktree branch mismatch (${reportedBranch}); switching to ${branch}`, 'vcs', {
633
+ expected: branch,
634
+ found: reportedBranch,
635
+ });
636
+ try {
637
+ await this.vcs.checkoutBranch(worktreeRoot, branch);
638
+ activeBranch = (await this.vcs.currentBranch(worktreeRoot)) ?? branch;
639
+ }
640
+ catch (error) {
641
+ const message = error instanceof Error ? error.message : String(error);
642
+ if (message.includes('already used by worktree')) {
643
+ await this.logTask(taskRunId, `QA worktree branch ${branch} already checked out elsewhere; continuing in detached HEAD.`, 'vcs', { error: message });
644
+ activeBranch = branch;
645
+ }
646
+ else {
647
+ throw error;
648
+ }
649
+ }
650
+ }
651
+ }
652
+ await this.logTask(taskRunId, `QA worktree ready on branch ${activeBranch}`, 'vcs', {
653
+ branch: activeBranch,
654
+ });
655
+ const cleanup = async () => {
656
+ await this.vcs.removeWorktree(repoRoot, worktreeRoot);
657
+ await fs.rm(worktreeRoot, { recursive: true, force: true });
658
+ };
659
+ return { ok: true, workspaceRoot: worktreeRoot, cleanup, branch: activeBranch };
170
660
  }
171
661
  catch (error) {
172
662
  await this.logTask(taskRunId, `VCS check failed: ${error?.message ?? error}`, 'vcs');
@@ -175,16 +665,19 @@ export class QaTasksService {
175
665
  }
176
666
  async ensureMcoda() {
177
667
  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
- }
668
+ }
669
+ buildCleanIgnorePaths(extra) {
670
+ const configPaths = this.workspace.config?.qa?.cleanIgnorePaths ?? [];
671
+ const envPaths = (process.env.MCODA_QA_CLEAN_IGNORE ?? '')
672
+ .split(',')
673
+ .map((entry) => entry.trim())
674
+ .filter(Boolean);
675
+ return normalizeCleanIgnorePaths([
676
+ ...QA_CLEAN_IGNORE_DEFAULTS,
677
+ ...configPaths,
678
+ ...envPaths,
679
+ ...(extra ?? []),
680
+ ]);
188
681
  }
189
682
  adapterForProfile(profile) {
190
683
  const runner = profile?.runner ?? 'cli';
@@ -203,6 +696,226 @@ export class QaTasksService {
203
696
  return 'infra_issue';
204
697
  return 'fix_required';
205
698
  }
699
+ shouldUseAgentInterpretation() {
700
+ if (process.env[QA_AGENT_INTERPRETATION_ENV] === undefined)
701
+ return true;
702
+ return isEnvEnabled(QA_AGENT_INTERPRETATION_ENV);
703
+ }
704
+ buildDeterministicInterpretation(task, profile, result) {
705
+ const recommendation = this.mapOutcome(result);
706
+ const runner = profile.runner ?? 'cli';
707
+ const testedScope = `Ran ${profile.name} (${runner}) QA for ${task.task.key}.`;
708
+ const coverageSummary = recommendation === 'pass'
709
+ ? `Automated QA completed successfully with exit code ${result.exitCode}.`
710
+ : `Automated QA reported outcome ${result.outcome} (exit code ${result.exitCode}). Review logs/artifacts for details.`;
711
+ return {
712
+ recommendation,
713
+ testedScope,
714
+ coverageSummary,
715
+ failures: [],
716
+ followUps: [],
717
+ };
718
+ }
719
+ async buildRunnerFailureMessage(params) {
720
+ const { outcome, result, runs, runSummary, workspaceRoot } = params;
721
+ const artifacts = result.artifacts ?? [];
722
+ const lines = [`QA ${outcome} based on runner output.`];
723
+ if (runSummary)
724
+ lines.push(`Runs:\n${runSummary}`);
725
+ const tail = (text, maxLines = 16, maxChars = 2000) => {
726
+ const trimmed = text.trim();
727
+ if (!trimmed)
728
+ return '';
729
+ const lines = trimmed.split(/\r?\n/);
730
+ const slice = lines.slice(-maxLines).join('\n');
731
+ return slice.length > maxChars ? `${slice.slice(0, maxChars)}…` : slice;
732
+ };
733
+ const resolveArtifactPath = (artifact) => path.resolve(workspaceRoot, artifact);
734
+ const loadJson = async (artifact) => {
735
+ try {
736
+ const raw = await fs.readFile(resolveArtifactPath(artifact), 'utf8');
737
+ return JSON.parse(raw);
738
+ }
739
+ catch {
740
+ return undefined;
741
+ }
742
+ };
743
+ const browserArtifact = artifacts.find((artifact) => artifact.endsWith('browser-actions.json'));
744
+ if (browserArtifact) {
745
+ const payload = await loadJson(browserArtifact);
746
+ const failures = (payload?.actions ?? []).filter((action) => action.ok === false).slice(0, 8);
747
+ if (failures.length) {
748
+ lines.push(`Browser action failures:\n${failures
749
+ .map((action) => `- ${action.index}. ${action.type}: ${action.message ?? 'failed'}`)
750
+ .join('\n')}`);
751
+ }
752
+ }
753
+ const apiArtifact = artifacts.find((artifact) => artifact.endsWith('api-results.json'));
754
+ if (apiArtifact) {
755
+ const payload = await loadJson(apiArtifact);
756
+ const failures = (payload?.results ?? []).filter((item) => item.ok === false).slice(0, 8);
757
+ if (failures.length) {
758
+ lines.push(`API request failures:\n${failures
759
+ .map((item) => {
760
+ const parts = [item.method ?? 'GET', item.url ?? '', item.status ? `status=${item.status}` : ''];
761
+ const detail = item.error ?? item.expectations?.[0];
762
+ return `- ${parts.filter(Boolean).join(' ')}${detail ? ` (${detail})` : ''}`;
763
+ })
764
+ .join('\n')}`);
765
+ }
766
+ }
767
+ const cliFailure = runs.find((run) => run.runner === 'cli' && run.result.outcome !== 'pass');
768
+ const stderrSnippet = cliFailure?.result.stderr ?? result.stderr;
769
+ const stderrTail = stderrSnippet ? tail(stderrSnippet) : '';
770
+ if (stderrTail) {
771
+ lines.push(`Runner stderr (tail):\n${stderrTail}`);
772
+ }
773
+ return lines.join('\n\n');
774
+ }
775
+ async createRunnerFailureComments(params) {
776
+ const { task, taskRunId, jobId, result, runs, workspaceRoot, runSummary } = params;
777
+ const existingSlugs = params.existingSlugs ?? new Set();
778
+ const artifacts = result.artifacts ?? [];
779
+ const resolveArtifactPath = (artifact) => path.resolve(workspaceRoot, artifact);
780
+ const loadJson = async (artifact) => {
781
+ try {
782
+ const raw = await fs.readFile(resolveArtifactPath(artifact), 'utf8');
783
+ return JSON.parse(raw);
784
+ }
785
+ catch {
786
+ return undefined;
787
+ }
788
+ };
789
+ const comments = [];
790
+ const browserArtifact = artifacts.find((artifact) => artifact.endsWith('browser-actions.json'));
791
+ if (browserArtifact) {
792
+ const payload = await loadJson(browserArtifact);
793
+ const failures = (payload?.actions ?? []).filter((action) => action.ok === false).slice(0, 8);
794
+ for (const action of failures) {
795
+ const message = `Browser action ${action.index} (${action.type}) failed${action.message ? `: ${action.message}` : ''}`;
796
+ comments.push({
797
+ message,
798
+ metadata: {
799
+ runner: 'chromium',
800
+ actionIndex: action.index,
801
+ actionType: action.type,
802
+ url: action.url,
803
+ artifact: browserArtifact,
804
+ runSummary,
805
+ },
806
+ });
807
+ }
808
+ }
809
+ const apiArtifact = artifacts.find((artifact) => artifact.endsWith('api-results.json'));
810
+ if (apiArtifact) {
811
+ const payload = await loadJson(apiArtifact);
812
+ const failures = (payload?.results ?? []).filter((item) => item.ok === false).slice(0, 8);
813
+ for (const item of failures) {
814
+ const detail = item.error ?? item.expectations?.[0];
815
+ const parts = [item.method ?? 'GET', item.url ?? '', item.status ? `status=${item.status}` : '']
816
+ .filter(Boolean)
817
+ .join(' ');
818
+ const message = `API ${parts} failed${detail ? `: ${detail}` : ''}`;
819
+ comments.push({
820
+ message,
821
+ metadata: {
822
+ runner: 'api',
823
+ requestId: item.id,
824
+ method: item.method,
825
+ url: item.url,
826
+ status: item.status,
827
+ expectations: item.expectations,
828
+ artifact: apiArtifact,
829
+ runSummary,
830
+ },
831
+ });
832
+ }
833
+ }
834
+ const tail = (text, maxLines = 16, maxChars = 2000) => {
835
+ const trimmed = text.trim();
836
+ if (!trimmed)
837
+ return '';
838
+ const lines = trimmed.split(/\r?\n/);
839
+ const slice = lines.slice(-maxLines).join('\n');
840
+ return slice.length > maxChars ? `${slice.slice(0, maxChars)}…` : slice;
841
+ };
842
+ const cliFailures = runs.filter((run) => run.runner === 'cli' && run.result.outcome !== 'pass');
843
+ for (const run of cliFailures) {
844
+ const stderrTail = tail(run.result.stderr ?? '');
845
+ const message = stderrTail
846
+ ? `CLI QA failed${run.command ? ` (${run.command})` : ''}: ${stderrTail}`
847
+ : `CLI QA failed${run.command ? ` (${run.command})` : ''}.`;
848
+ comments.push({
849
+ message,
850
+ metadata: {
851
+ runner: 'cli',
852
+ command: run.command,
853
+ testCommand: run.testCommand,
854
+ runSummary,
855
+ },
856
+ });
857
+ }
858
+ let created = 0;
859
+ for (const entry of comments) {
860
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message: entry.message, category: 'qa_issue' });
861
+ if (existingSlugs.has(slug))
862
+ continue;
863
+ existingSlugs.add(slug);
864
+ await this.createQaComment({
865
+ task,
866
+ taskRunId,
867
+ jobId,
868
+ message: entry.message,
869
+ category: 'qa_issue',
870
+ status: 'open',
871
+ metadata: entry.metadata,
872
+ });
873
+ created += 1;
874
+ }
875
+ return created;
876
+ }
877
+ resolveRunAllMarkerPolicy() {
878
+ return this.workspace.config?.qa?.runAllMarkerRequired === false ? 'warn' : 'strict';
879
+ }
880
+ adjustOutcomeForSkippedTests(profile, result, testCommand) {
881
+ if ((profile.runner ?? 'cli') !== 'cli')
882
+ return { result };
883
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
884
+ const outputLower = output.toLowerCase();
885
+ const markers = ['no test script configured', 'skipping tests', 'no tests found'];
886
+ if (markers.some((marker) => outputLower.includes(marker))) {
887
+ return { result: { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 } };
888
+ }
889
+ let markerStatus;
890
+ if (testCommand && testCommand.includes('tests/all.js')) {
891
+ const present = outputLower.includes(RUN_ALL_TESTS_MARKER);
892
+ const policy = this.resolveRunAllMarkerPolicy();
893
+ markerStatus = {
894
+ policy,
895
+ present,
896
+ action: present ? 'none' : policy === 'strict' ? 'block' : 'warn',
897
+ };
898
+ if (!present) {
899
+ const passed = result.outcome === 'pass' || result.exitCode === 0;
900
+ if (passed) {
901
+ markerStatus.action = 'warn';
902
+ const warning = `Warning: ${RUN_ALL_TESTS_GUIDANCE}`;
903
+ const stderr = result.stderr?.includes(RUN_ALL_TESTS_GUIDANCE)
904
+ ? result.stderr
905
+ : [result.stderr, warning].filter(Boolean).join('\n');
906
+ return { result: { ...result, stderr }, markerStatus };
907
+ }
908
+ if (policy === 'strict') {
909
+ const stderr = [result.stderr, RUN_ALL_TESTS_GUIDANCE].filter(Boolean).join('\n');
910
+ return {
911
+ result: { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1, stderr },
912
+ markerStatus,
913
+ };
914
+ }
915
+ }
916
+ }
917
+ return { result, markerStatus };
918
+ }
206
919
  combineOutcome(result, recommendation) {
207
920
  const base = this.mapOutcome(result);
208
921
  if (!recommendation)
@@ -217,10 +930,22 @@ export class QaTasksService {
217
930
  return 'unclear';
218
931
  return 'pass';
219
932
  }
220
- async gatherDocContext(task, taskRunId) {
933
+ async gatherDocContext(task, taskRunId, docLinks = []) {
221
934
  if (!this.docdex)
222
935
  return '';
936
+ let openApiIncluded = false;
937
+ const shouldIncludeDocType = (docType) => {
938
+ if (docType.toUpperCase() !== 'OPENAPI')
939
+ return true;
940
+ if (openApiIncluded)
941
+ return false;
942
+ openApiIncluded = true;
943
+ return true;
944
+ };
223
945
  try {
946
+ if (typeof this.docdex?.ensureRepoScope === 'function') {
947
+ await this.docdex.ensureRepoScope();
948
+ }
224
949
  const querySeeds = [task.key, task.title, ...(task.acceptanceCriteria ?? [])]
225
950
  .filter(Boolean)
226
951
  .join(' ')
@@ -231,7 +956,21 @@ export class QaTasksService {
231
956
  query: querySeeds,
232
957
  });
233
958
  const snippets = [];
234
- for (const doc of docs.slice(0, 5)) {
959
+ const resolveDocType = async (doc) => {
960
+ const content = doc.segments?.[0]?.content ?? doc.content ?? '';
961
+ const normalized = normalizeDocType({
962
+ docType: doc.docType,
963
+ path: doc.path,
964
+ title: doc.title,
965
+ content,
966
+ });
967
+ if (normalized.downgraded && taskRunId) {
968
+ await this.logTask(taskRunId, `Docdex docType downgraded from SDS to DOC for ${doc.path ?? doc.title ?? doc.docType ?? 'unknown'}: ${normalized.reason ?? 'not_sds'}`, 'docdex');
969
+ }
970
+ return normalized.docType;
971
+ };
972
+ const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, true));
973
+ for (const doc of filteredDocs.slice(0, 5)) {
235
974
  const segments = (doc.segments ?? []).slice(0, 2);
236
975
  const body = segments.length
237
976
  ? segments
@@ -240,7 +979,47 @@ export class QaTasksService {
240
979
  : doc.content
241
980
  ? doc.content.slice(0, 600)
242
981
  : '';
243
- snippets.push(`- [${doc.docType}] ${doc.title ?? doc.path ?? doc.id}\n${body}`.trim());
982
+ const docType = await resolveDocType(doc);
983
+ if (!shouldIncludeDocType(docType))
984
+ continue;
985
+ snippets.push(`- [${docType}] ${doc.title ?? doc.path ?? doc.id}\n${body}`.trim());
986
+ }
987
+ const normalizeDocLink = (value) => {
988
+ const trimmed = value.trim();
989
+ const stripped = trimmed.replace(/^docdex:/i, '').replace(/^doc:/i, '');
990
+ const candidate = stripped || trimmed;
991
+ const looksLikePath = candidate.includes('/') ||
992
+ candidate.includes('\\') ||
993
+ /\.(md|markdown|txt|rst|yaml|yml|json)$/i.test(candidate);
994
+ return { type: looksLikePath ? 'path' : 'id', ref: candidate };
995
+ };
996
+ for (const link of docLinks) {
997
+ try {
998
+ const { type, ref } = normalizeDocLink(link);
999
+ if (type === 'path' && isDocContextExcluded(ref, true)) {
1000
+ snippets.push(`- [linked:filtered] ${link} — excluded from context`);
1001
+ continue;
1002
+ }
1003
+ let doc = undefined;
1004
+ if (type === 'path' && 'findDocumentByPath' in this.docdex) {
1005
+ doc = await this.docdex.findDocumentByPath(ref);
1006
+ }
1007
+ if (!doc) {
1008
+ doc = await this.docdex.fetchDocumentById(ref);
1009
+ }
1010
+ if (!doc) {
1011
+ snippets.push(`- [linked:missing] ${link} — no docdex entry found`);
1012
+ continue;
1013
+ }
1014
+ const body = (doc.segments?.[0]?.content ?? doc.content ?? '').slice(0, 600);
1015
+ const docType = await resolveDocType(doc);
1016
+ if (!shouldIncludeDocType(docType))
1017
+ continue;
1018
+ snippets.push(`- [linked:${docType}] ${doc.title ?? doc.path ?? doc.id}\n${body}`.trim());
1019
+ }
1020
+ catch (error) {
1021
+ snippets.push(`- [linked:missing] ${link} — ${error?.message ?? error}`);
1022
+ }
244
1023
  }
245
1024
  return snippets.join('\n\n');
246
1025
  }
@@ -262,18 +1041,863 @@ export class QaTasksService {
262
1041
  });
263
1042
  return resolved.agent;
264
1043
  }
265
- estimateTokens(text) {
266
- return Math.max(1, Math.ceil((text?.length ?? 0) / 4));
1044
+ ensureRatingService() {
1045
+ if (!this.ratingService) {
1046
+ if (!this.repo || !this.agentService || !this.routingService) {
1047
+ throw new Error('Agent rating requires routing, agent, and repository services.');
1048
+ }
1049
+ this.ratingService = new AgentRatingService(this.workspace, {
1050
+ workspaceRepo: this.deps.workspaceRepo,
1051
+ globalRepo: this.repo,
1052
+ agentService: this.agentService,
1053
+ routingService: this.routingService,
1054
+ });
1055
+ }
1056
+ return this.ratingService;
1057
+ }
1058
+ resolveTaskComplexity(task) {
1059
+ const metadata = task.metadata ?? {};
1060
+ const metaComplexity = typeof metadata.complexity === 'number' && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
1061
+ const storyPoints = typeof task.storyPoints === 'number' && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
1062
+ const candidate = metaComplexity ?? storyPoints;
1063
+ if (!Number.isFinite(candidate ?? NaN))
1064
+ return undefined;
1065
+ return Math.min(10, Math.max(1, Math.round(candidate)));
1066
+ }
1067
+ estimateTokens(text) {
1068
+ return Math.max(1, Math.ceil((text?.length ?? 0) / 4));
1069
+ }
1070
+ async fileExists(absolutePath) {
1071
+ try {
1072
+ await fs.access(absolutePath);
1073
+ return true;
1074
+ }
1075
+ catch {
1076
+ return false;
1077
+ }
1078
+ }
1079
+ async readPackageJson(root = this.workspace.workspaceRoot) {
1080
+ const pkgPath = path.join(root, 'package.json');
1081
+ try {
1082
+ const raw = await fs.readFile(pkgPath, 'utf8');
1083
+ return JSON.parse(raw);
1084
+ }
1085
+ catch {
1086
+ return undefined;
1087
+ }
1088
+ }
1089
+ async detectPackageManager(root = this.workspace.workspaceRoot) {
1090
+ if (await this.fileExists(path.join(root, 'pnpm-lock.yaml')))
1091
+ return 'pnpm';
1092
+ if (await this.fileExists(path.join(root, 'pnpm-workspace.yaml')))
1093
+ return 'pnpm';
1094
+ if (await this.fileExists(path.join(root, 'yarn.lock')))
1095
+ return 'yarn';
1096
+ if (await this.fileExists(path.join(root, 'package-lock.json')))
1097
+ return 'npm';
1098
+ if (await this.fileExists(path.join(root, 'npm-shrinkwrap.json')))
1099
+ return 'npm';
1100
+ if (await this.fileExists(path.join(root, 'package.json')))
1101
+ return 'npm';
1102
+ return undefined;
1103
+ }
1104
+ async resolveTestCommand(profile, requestTestCommand, workspaceRoot = this.workspace.workspaceRoot) {
1105
+ if (requestTestCommand)
1106
+ return requestTestCommand;
1107
+ if ((profile.runner ?? 'cli') !== 'cli')
1108
+ return undefined;
1109
+ if (profile.test_command)
1110
+ return profile.test_command;
1111
+ if (await this.fileExists(path.join(workspaceRoot, 'tests', 'all.js'))) {
1112
+ return 'node tests/all.js';
1113
+ }
1114
+ const pkg = await this.readPackageJson(workspaceRoot);
1115
+ if (pkg?.scripts?.test) {
1116
+ const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
1117
+ return `${pm} test`;
1118
+ }
1119
+ return undefined;
1120
+ }
1121
+ isCliTask(task) {
1122
+ const metadata = task.metadata ?? {};
1123
+ const files = Array.isArray(metadata.files) ? metadata.files : [];
1124
+ const reviewFiles = Array.isArray(metadata.last_review_changed_paths)
1125
+ ? metadata.last_review_changed_paths
1126
+ : [];
1127
+ const combined = [...files, ...reviewFiles].map((file) => String(file).toLowerCase());
1128
+ const cliHints = ['/cli/', '/packages/cli/', '/bin/', '/cmd/'];
1129
+ if (combined.some((file) => cliHints.some((hint) => file.includes(hint))))
1130
+ return true;
1131
+ const text = [task.key, task.title, task.description, task.type]
1132
+ .filter(Boolean)
1133
+ .join(' ')
1134
+ .toLowerCase();
1135
+ if (!text)
1136
+ return false;
1137
+ return ['cli', 'command', 'terminal'].some((hint) => text.includes(hint));
1138
+ }
1139
+ async findCliBinCommand(workspaceRoot) {
1140
+ const resolveBin = (pkg) => {
1141
+ if (!pkg)
1142
+ return undefined;
1143
+ const bin = pkg.bin;
1144
+ if (typeof bin === 'string')
1145
+ return bin;
1146
+ if (bin && typeof bin === 'object') {
1147
+ const first = Object.values(bin).find((value) => typeof value === 'string');
1148
+ return typeof first === 'string' ? first : undefined;
1149
+ }
1150
+ return undefined;
1151
+ };
1152
+ const rootPkg = await this.readPackageJson(workspaceRoot);
1153
+ let binPath = resolveBin(rootPkg);
1154
+ if (!binPath) {
1155
+ const cliPkgPath = path.join(workspaceRoot, 'packages', 'cli', 'package.json');
1156
+ try {
1157
+ const raw = await fs.readFile(cliPkgPath, 'utf8');
1158
+ const cliPkg = JSON.parse(raw);
1159
+ binPath = resolveBin(cliPkg);
1160
+ if (binPath) {
1161
+ binPath = path.join('packages', 'cli', binPath);
1162
+ }
1163
+ }
1164
+ catch {
1165
+ // ignore
1166
+ }
1167
+ }
1168
+ if (!binPath)
1169
+ return undefined;
1170
+ const normalized = path.isAbsolute(binPath) ? binPath : path.join(workspaceRoot, binPath);
1171
+ if (!(await this.fileExists(normalized)))
1172
+ return undefined;
1173
+ const relative = path.isAbsolute(binPath) ? path.relative(workspaceRoot, binPath) : binPath;
1174
+ return `node ${relative} --help`;
1175
+ }
1176
+ async resolveCliChecklistCommands(params) {
1177
+ const pkg = await this.readPackageJson(params.workspaceRoot);
1178
+ if (!pkg?.scripts)
1179
+ return [];
1180
+ const pm = (await this.detectPackageManager(params.workspaceRoot)) ?? 'npm';
1181
+ const existingLower = params.existing.map((cmd) => cmd.toLowerCase());
1182
+ const addIfScript = (script, matcher) => {
1183
+ if (!pkg.scripts?.[script])
1184
+ return undefined;
1185
+ if (existingLower.some((cmd) => cmd.includes(matcher)))
1186
+ return undefined;
1187
+ return this.buildScriptCommand(pm, script);
1188
+ };
1189
+ const extras = [];
1190
+ const lintCmd = addIfScript('lint', 'lint');
1191
+ if (lintCmd)
1192
+ extras.push(lintCmd);
1193
+ const typecheckCmd = addIfScript('typecheck', 'typecheck') ??
1194
+ addIfScript('type-check', 'type-check') ??
1195
+ addIfScript('tsc', 'tsc');
1196
+ if (typecheckCmd)
1197
+ extras.push(typecheckCmd);
1198
+ const buildCmd = addIfScript('build', 'build');
1199
+ if (buildCmd)
1200
+ extras.push(buildCmd);
1201
+ if (this.isCliTask(params.task)) {
1202
+ const cliSmoke = await this.findCliBinCommand(params.workspaceRoot);
1203
+ if (cliSmoke && !existingLower.some((cmd) => cmd.includes('--help'))) {
1204
+ extras.push(cliSmoke);
1205
+ }
1206
+ }
1207
+ return extras;
1208
+ }
1209
+ softenOptionalNpmScripts(commands) {
1210
+ if (!commands.length)
1211
+ return commands;
1212
+ const scripts = ['lint', 'build'];
1213
+ return commands.map((command) => {
1214
+ let next = command;
1215
+ for (const script of scripts) {
1216
+ const pattern = new RegExp(`\\bnpm\\s+run\\s+${script}\\b(?!\\s+--if-present)`, 'i');
1217
+ next = next.replace(pattern, '$& --if-present');
1218
+ }
1219
+ return next;
1220
+ });
1221
+ }
1222
+ usesCliBrowserTools(commands) {
1223
+ const pattern = /(cypress|puppeteer|selenium|capybara|dusk)/i;
1224
+ return commands.some((command) => pattern.test(command));
1225
+ }
1226
+ ensureCypressChromium(command) {
1227
+ if (!/cypress/i.test(command))
1228
+ return command;
1229
+ const browserMatch = /--browser(\s+|=)(\S+)/i.exec(command);
1230
+ if (browserMatch) {
1231
+ const current = browserMatch[2] ?? "";
1232
+ if (/chromium/i.test(current))
1233
+ return command;
1234
+ return command.replace(browserMatch[0], "--browser chromium");
1235
+ }
1236
+ if (/\bcypress\s+(run|open)\b/i.test(command)) {
1237
+ return `${command} --browser chromium`;
1238
+ }
1239
+ return command;
1240
+ }
1241
+ async applyChromiumForCli(env, commands) {
1242
+ if (!this.usesCliBrowserTools(commands)) {
1243
+ return { ok: true, commands };
1244
+ }
1245
+ const chromiumPath = await resolveChromiumBinary();
1246
+ if (!chromiumPath) {
1247
+ return {
1248
+ ok: false,
1249
+ commands,
1250
+ message: 'Chromium binary not found for CLI browser tests. Install Docdex Chromium (docdex setup or MCODA_QA_CHROMIUM_PATH).',
1251
+ };
1252
+ }
1253
+ env.CHROME_PATH = chromiumPath;
1254
+ env.CHROME_BIN = chromiumPath;
1255
+ env.PUPPETEER_EXECUTABLE_PATH = chromiumPath;
1256
+ env.PUPPETEER_PRODUCT = 'chrome';
1257
+ env.CYPRESS_BROWSER = 'chromium';
1258
+ const updated = commands.map((command) => this.ensureCypressChromium(command));
1259
+ return { ok: true, commands: updated };
1260
+ }
1261
+ buildScriptCommand(pm, script) {
1262
+ if (pm === 'yarn')
1263
+ return `yarn ${script}`;
1264
+ if (pm === 'pnpm')
1265
+ return `pnpm ${script}`;
1266
+ return `npm run ${script}`;
1267
+ }
1268
+ async resolveDevServerCommand(workspaceRoot) {
1269
+ const pkg = await this.readPackageJson(workspaceRoot);
1270
+ if (!pkg?.scripts)
1271
+ return undefined;
1272
+ const script = typeof pkg.scripts.dev === 'string'
1273
+ ? 'dev'
1274
+ : typeof pkg.scripts.start === 'string'
1275
+ ? 'start'
1276
+ : typeof pkg.scripts.serve === 'string'
1277
+ ? 'serve'
1278
+ : undefined;
1279
+ if (!script)
1280
+ return undefined;
1281
+ const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
1282
+ return { script, command: this.buildScriptCommand(pm, script) };
1283
+ }
1284
+ shouldInstallQaDeps() {
1285
+ if (process.env[QA_INSTALL_DEPS_ENV] === undefined)
1286
+ return true;
1287
+ return isEnvEnabled(QA_INSTALL_DEPS_ENV);
1288
+ }
1289
+ async hasFileWithExtension(workspaceRoot, ext) {
1290
+ try {
1291
+ const entries = await fs.readdir(workspaceRoot, { withFileTypes: true });
1292
+ return entries.some((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(ext));
1293
+ }
1294
+ catch {
1295
+ return false;
1296
+ }
1297
+ }
1298
+ async resolveInstallCommands(workspaceRoot) {
1299
+ const commands = [];
1300
+ const pkgPath = path.join(workspaceRoot, 'package.json');
1301
+ if (await this.fileExists(pkgPath)) {
1302
+ const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
1303
+ if (pm === 'pnpm')
1304
+ commands.push('pnpm install');
1305
+ else if (pm === 'yarn')
1306
+ commands.push('yarn install');
1307
+ else
1308
+ commands.push('npm install');
1309
+ }
1310
+ if (await this.fileExists(path.join(workspaceRoot, 'requirements.txt'))) {
1311
+ commands.push('python -m pip install -r requirements.txt');
1312
+ }
1313
+ else if ((await this.fileExists(path.join(workspaceRoot, 'pyproject.toml'))) ||
1314
+ (await this.fileExists(path.join(workspaceRoot, 'setup.py')))) {
1315
+ commands.push('python -m pip install .');
1316
+ }
1317
+ if ((await this.hasFileWithExtension(workspaceRoot, '.sln')) ||
1318
+ (await this.hasFileWithExtension(workspaceRoot, '.csproj'))) {
1319
+ commands.push('dotnet restore');
1320
+ }
1321
+ if (await this.fileExists(path.join(workspaceRoot, 'pom.xml'))) {
1322
+ commands.push('mvn -q -DskipTests dependency:resolve');
1323
+ }
1324
+ else if ((await this.fileExists(path.join(workspaceRoot, 'build.gradle'))) ||
1325
+ (await this.fileExists(path.join(workspaceRoot, 'build.gradle.kts')))) {
1326
+ const gradlew = path.join(workspaceRoot, 'gradlew');
1327
+ if (await this.fileExists(gradlew)) {
1328
+ commands.push('./gradlew --no-daemon dependencies');
1329
+ }
1330
+ else {
1331
+ commands.push('gradle --no-daemon dependencies');
1332
+ }
1333
+ }
1334
+ if (await this.fileExists(path.join(workspaceRoot, 'go.mod'))) {
1335
+ commands.push('go mod download');
1336
+ }
1337
+ if (await this.fileExists(path.join(workspaceRoot, 'composer.json'))) {
1338
+ commands.push('composer install');
1339
+ }
1340
+ if (await this.fileExists(path.join(workspaceRoot, 'Gemfile'))) {
1341
+ commands.push('bundle install');
1342
+ }
1343
+ if (await this.fileExists(path.join(workspaceRoot, 'pubspec.yaml'))) {
1344
+ commands.push('flutter pub get');
1345
+ }
1346
+ if (await this.fileExists(path.join(workspaceRoot, 'Podfile'))) {
1347
+ commands.push('pod install');
1348
+ }
1349
+ return Array.from(new Set(commands));
1350
+ }
1351
+ async runShellCommand(params) {
1352
+ return await new Promise((resolve) => {
1353
+ const child = spawn(params.command, {
1354
+ cwd: params.cwd,
1355
+ env: params.env,
1356
+ shell: true,
1357
+ stdio: ['ignore', 'pipe', 'pipe'],
1358
+ });
1359
+ let stdout = '';
1360
+ let stderr = '';
1361
+ child.stdout?.on('data', (chunk) => {
1362
+ stdout += chunk.toString();
1363
+ });
1364
+ child.stderr?.on('data', (chunk) => {
1365
+ stderr += chunk.toString();
1366
+ });
1367
+ child.once('close', (code) => {
1368
+ const exitCode = typeof code === 'number' ? code : 0;
1369
+ resolve({ ok: exitCode === 0, exitCode, stdout, stderr });
1370
+ });
1371
+ });
1372
+ }
1373
+ async commitInstallChanges(workspaceRoot, message) {
1374
+ const status = await this.vcs.status(workspaceRoot);
1375
+ if (!status.trim())
1376
+ return null;
1377
+ await execFileAsync('git', ['add', '-A'], { cwd: workspaceRoot });
1378
+ const env = {
1379
+ ...process.env,
1380
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? 'mcoda-qa',
1381
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? 'qa@mcoda.local',
1382
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? 'mcoda-qa',
1383
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? 'qa@mcoda.local',
1384
+ };
1385
+ await execFileAsync('git', ['commit', '-m', message, '--no-verify'], { cwd: workspaceRoot, env });
1386
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: workspaceRoot });
1387
+ const trimmed = typeof stdout === 'string' ? stdout.trim() : Buffer.from(stdout).toString('utf8').trim();
1388
+ return trimmed || null;
1389
+ }
1390
+ isBranchInUseError(error) {
1391
+ const message = error instanceof Error ? error.message : String(error ?? '');
1392
+ return /already used by worktree/i.test(message);
1393
+ }
1394
+ buildQaInstallBranch(taskKey, taskRunId, baseBranch) {
1395
+ const suffix = createHash('sha1').update(`${taskKey}:${taskRunId}`).digest('hex').slice(0, 8);
1396
+ const safeKey = taskKey.replace(/[^a-zA-Z0-9._-]+/g, '-');
1397
+ return `mcoda/qa/${safeKey}-${baseBranch}-${suffix}`;
1398
+ }
1399
+ async prepareQaWorkspace(params) {
1400
+ if (!this.shouldInstallQaDeps()) {
1401
+ await this.logTask(params.taskRunId, 'QA dependency install disabled; skipping.', 'qa-install');
1402
+ return { ok: true };
1403
+ }
1404
+ await this.vcs.ensureBaseBranch(params.workspaceRoot, params.baseBranch);
1405
+ const current = await this.vcs.currentBranch(params.workspaceRoot);
1406
+ let installBranch = params.baseBranch;
1407
+ if (current !== params.baseBranch) {
1408
+ try {
1409
+ await this.vcs.checkoutBranch(params.workspaceRoot, params.baseBranch);
1410
+ }
1411
+ catch (error) {
1412
+ if (this.isBranchInUseError(error)) {
1413
+ installBranch = this.buildQaInstallBranch(params.taskKey, params.taskRunId, params.baseBranch);
1414
+ await this.logTask(params.taskRunId, `Base branch ${params.baseBranch} already used by another worktree; using ${installBranch} for QA install.`, 'qa-install');
1415
+ await this.vcs.createOrCheckoutBranch(params.workspaceRoot, installBranch, params.baseBranch);
1416
+ }
1417
+ else {
1418
+ throw error;
1419
+ }
1420
+ }
1421
+ }
1422
+ const commands = await this.resolveInstallCommands(params.workspaceRoot);
1423
+ for (const command of commands) {
1424
+ await this.logTask(params.taskRunId, `Installing deps: ${command}`, 'qa-install');
1425
+ const result = await this.runShellCommand({ command, cwd: params.workspaceRoot });
1426
+ if (!result.ok) {
1427
+ const message = `QA dependency install failed (${command}) with exit ${result.exitCode}.`;
1428
+ await this.logTask(params.taskRunId, message, 'qa-install', {
1429
+ stdout: result.stdout.slice(0, 2000),
1430
+ stderr: result.stderr.slice(0, 2000),
1431
+ });
1432
+ return { ok: false, message };
1433
+ }
1434
+ }
1435
+ const installCommit = await this.commitInstallChanges(params.workspaceRoot, 'chore: qa install dependencies');
1436
+ if (installCommit) {
1437
+ await this.logTask(params.taskRunId, 'Committed QA dependency install changes.', 'qa-install');
1438
+ }
1439
+ if (params.taskBranch && params.taskBranch !== installBranch) {
1440
+ try {
1441
+ await this.vcs.checkoutBranch(params.workspaceRoot, params.taskBranch);
1442
+ }
1443
+ catch (error) {
1444
+ if (this.isBranchInUseError(error)) {
1445
+ await this.logTask(params.taskRunId, `Task branch ${params.taskBranch} already used by another worktree; continuing on ${installBranch}.`, 'qa-install');
1446
+ }
1447
+ else {
1448
+ throw error;
1449
+ }
1450
+ }
1451
+ if (installCommit) {
1452
+ try {
1453
+ await this.vcs.cherryPick(params.workspaceRoot, installCommit);
1454
+ }
1455
+ catch (error) {
1456
+ await this.logTask(params.taskRunId, `Warning: failed to cherry-pick QA install commit onto ${params.taskBranch}.`, 'qa-install', { error: error instanceof Error ? error.message : String(error) });
1457
+ }
1458
+ }
1459
+ }
1460
+ return { ok: true };
1461
+ }
1462
+ async isUrlReachable(url, timeoutMs) {
1463
+ const controller = new AbortController();
1464
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1465
+ try {
1466
+ await fetch(url, { method: 'GET', signal: controller.signal });
1467
+ return true;
1468
+ }
1469
+ catch {
1470
+ return false;
1471
+ }
1472
+ finally {
1473
+ clearTimeout(timer);
1474
+ }
1475
+ }
1476
+ async waitForUrlReady(url, timeoutMs) {
1477
+ if (timeoutMs <= 0)
1478
+ return false;
1479
+ const deadline = Date.now() + timeoutMs;
1480
+ while (Date.now() < deadline) {
1481
+ const ok = await this.isUrlReachable(url, Math.min(2000, timeoutMs));
1482
+ if (ok)
1483
+ return true;
1484
+ await delay(500);
1485
+ }
1486
+ return false;
1487
+ }
1488
+ async startQaServer(params) {
1489
+ const command = await this.resolveDevServerCommand(params.workspaceRoot);
1490
+ if (!command)
1491
+ return undefined;
1492
+ const serverDir = path.join(this.workspace.mcodaDir, 'jobs', params.jobId, 'qa', params.taskKey, 'server');
1493
+ await PathHelper.ensureDir(serverDir);
1494
+ const logPath = path.join(serverDir, 'server.log');
1495
+ const stream = fsSync.createWriteStream(logPath, { flags: 'a' });
1496
+ const env = { ...params.env };
1497
+ try {
1498
+ const url = new URL(params.baseUrl);
1499
+ if (!env.HOST)
1500
+ env.HOST = url.hostname;
1501
+ if (!env.PORT && url.port)
1502
+ env.PORT = url.port;
1503
+ }
1504
+ catch {
1505
+ // ignore invalid base URLs
1506
+ }
1507
+ const child = spawn(command.command, {
1508
+ cwd: params.workspaceRoot,
1509
+ env,
1510
+ shell: true,
1511
+ stdio: ['ignore', 'pipe', 'pipe'],
1512
+ });
1513
+ child.stdout?.pipe(stream);
1514
+ child.stderr?.pipe(stream);
1515
+ child.on('error', (error) => {
1516
+ stream.write(`\n[qa-server] spawn error: ${error instanceof Error ? error.message : String(error)}\n`);
1517
+ });
1518
+ const stop = async () => {
1519
+ if (child.exitCode !== null) {
1520
+ stream.end();
1521
+ return;
1522
+ }
1523
+ child.kill('SIGTERM');
1524
+ try {
1525
+ await Promise.race([once(child, 'exit'), delay(5000)]);
1526
+ }
1527
+ catch {
1528
+ // ignore
1529
+ }
1530
+ if (child.exitCode === null) {
1531
+ child.kill('SIGKILL');
1532
+ }
1533
+ stream.end();
1534
+ };
1535
+ return { baseUrl: params.baseUrl, command: command.command, logPath, process: child, stop };
1536
+ }
1537
+ async checkQaPreflight(testCommand, workspaceRoot = this.workspace.workspaceRoot) {
1538
+ const resolveCommandBinary = (command) => {
1539
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
1540
+ if (!tokens.length)
1541
+ return undefined;
1542
+ let idx = 0;
1543
+ while (idx < tokens.length) {
1544
+ const token = tokens[idx];
1545
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token) && !token.includes("/") && !token.includes("\\")) {
1546
+ idx += 1;
1547
+ continue;
1548
+ }
1549
+ return token.replace(/^['"]|['"]$/g, "");
1550
+ }
1551
+ return undefined;
1552
+ };
1553
+ const commandExists = (command) => {
1554
+ if (!command)
1555
+ return false;
1556
+ if (command.includes("/") || command.includes("\\")) {
1557
+ const resolved = path.isAbsolute(command) ? command : path.resolve(workspaceRoot, command);
1558
+ return fsSync.existsSync(resolved);
1559
+ }
1560
+ const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
1561
+ const extensions = process.platform === "win32"
1562
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
1563
+ : [""];
1564
+ return pathEntries.some((entry) => extensions.some((ext) => fsSync.existsSync(path.join(entry, `${command}${ext}`))));
1565
+ };
1566
+ if (testCommand) {
1567
+ const binary = resolveCommandBinary(testCommand);
1568
+ if (binary && !commandExists(binary)) {
1569
+ return {
1570
+ ok: false,
1571
+ missingDeps: [],
1572
+ missingEnv: [],
1573
+ message: `Missing CLI binary for QA command: ${binary}. Ensure it is installed and on PATH.`,
1574
+ };
1575
+ }
1576
+ }
1577
+ const pkg = await this.readPackageJson(workspaceRoot);
1578
+ const declared = new Set([
1579
+ ...Object.keys(pkg?.dependencies ?? {}),
1580
+ ...Object.keys(pkg?.devDependencies ?? {}),
1581
+ ]);
1582
+ if (declared.size === 0 && !testCommand) {
1583
+ return { ok: true, missingDeps: [], missingEnv: [] };
1584
+ }
1585
+ const usesJest = testCommand?.toLowerCase().includes('jest') ?? false;
1586
+ const depsToCheck = QA_REQUIRED_DEPS.filter((dep) => {
1587
+ if (dep === '@jest/globals') {
1588
+ return usesJest || declared.has('@jest/globals') || declared.has('jest');
1589
+ }
1590
+ return declared.has(dep);
1591
+ });
1592
+ if (depsToCheck.length === 0 && !usesJest) {
1593
+ return { ok: true, missingDeps: [], missingEnv: [] };
1594
+ }
1595
+ const requireFromWorkspace = createRequire(path.join(workspaceRoot, 'package.json'));
1596
+ const missingDeps = depsToCheck.filter((dep) => {
1597
+ try {
1598
+ requireFromWorkspace.resolve(dep);
1599
+ return false;
1600
+ }
1601
+ catch {
1602
+ return true;
1603
+ }
1604
+ });
1605
+ const missingEnv = [];
1606
+ for (const requirement of QA_REQUIRED_ENV) {
1607
+ if (!declared.has(requirement.dep) && !missingDeps.includes(requirement.dep))
1608
+ continue;
1609
+ const value = process.env[requirement.env];
1610
+ if (!value)
1611
+ missingEnv.push(requirement.env);
1612
+ }
1613
+ const messages = [];
1614
+ if (missingDeps.length) {
1615
+ messages.push(`Missing QA dependencies: ${missingDeps.join(', ')}. Install them with your package manager (e.g., pnpm add -D ${missingDeps.join(' ')}).`);
1616
+ }
1617
+ if (missingEnv.length) {
1618
+ messages.push(`Missing QA environment variables: ${missingEnv.join(', ')}. Set them (e.g., in .env.test).`);
1619
+ }
1620
+ return {
1621
+ ok: messages.length === 0,
1622
+ missingDeps,
1623
+ missingEnv,
1624
+ message: messages.join(' '),
1625
+ };
1626
+ }
1627
+ isHttpUrl(value) {
1628
+ if (!value)
1629
+ return false;
1630
+ try {
1631
+ const parsed = new URL(value);
1632
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
1633
+ }
1634
+ catch {
1635
+ return false;
1636
+ }
1637
+ }
1638
+ async loadAvailableProfiles() {
1639
+ const loader = this.profileService?.loadProfiles;
1640
+ if (typeof loader !== 'function')
1641
+ return [];
1642
+ try {
1643
+ const profiles = await loader.call(this.profileService);
1644
+ return Array.isArray(profiles) ? profiles.filter(Boolean) : [];
1645
+ }
1646
+ catch {
1647
+ return [];
1648
+ }
1649
+ }
1650
+ pickDefaultProfile(profiles) {
1651
+ if (!profiles.length)
1652
+ return undefined;
1653
+ const explicitDefault = profiles.find((profile) => profile.default);
1654
+ if (explicitDefault)
1655
+ return explicitDefault;
1656
+ const cliProfile = profiles.find((profile) => profile.name === 'cli' || (profile.runner ?? 'cli') === 'cli');
1657
+ if (cliProfile)
1658
+ return cliProfile;
1659
+ return profiles[0];
1660
+ }
1661
+ async planProfilesWithAgent(tasks, request, ctx) {
1662
+ const plan = new Map();
1663
+ if (request.profileName)
1664
+ return plan;
1665
+ if (!this.agentService)
1666
+ return plan;
1667
+ const profiles = await this.loadAvailableProfiles();
1668
+ if (!profiles.length)
1669
+ return plan;
1670
+ const defaultProfile = this.pickDefaultProfile(profiles);
1671
+ const profileByName = new Map(profiles.map((profile) => [profile.name, profile]));
1672
+ const runnerPlans = new Map();
1673
+ const resolveProfileForRunner = (runner) => {
1674
+ const normalized = runner ?? 'cli';
1675
+ const matches = profiles.filter((profile) => (profile.runner ?? 'cli') === normalized || profile.name === normalized);
1676
+ if (!matches.length)
1677
+ return undefined;
1678
+ const defaults = matches.filter((profile) => profile.default);
1679
+ if (defaults.length === 1)
1680
+ return defaults[0];
1681
+ return matches[0];
1682
+ };
1683
+ const taskKeys = new Set(tasks.map((task) => task.task.key));
1684
+ const agent = await this.resolveAgent(request.agentName);
1685
+ const prompts = await this.loadPrompts(agent.id);
1686
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
1687
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
1688
+ const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt]
1689
+ .filter(Boolean)
1690
+ .join('\n\n');
1691
+ const availableProfiles = profiles
1692
+ .map((profile) => `- ${profile.name} (runner=${profile.runner ?? 'cli'})`)
1693
+ .join('\n');
1694
+ const taskLines = [];
1695
+ for (const task of tasks) {
1696
+ const desc = (task.task.description ?? '').replace(/\s+/g, ' ').trim();
1697
+ const shortDesc = desc ? ` — ${desc.slice(0, 240)}` : '';
1698
+ const metadata = task.task.metadata ?? {};
1699
+ const files = Array.isArray(metadata.files) ? metadata.files : [];
1700
+ const reviewFiles = Array.isArray(metadata.last_review_changed_paths)
1701
+ ? metadata.last_review_changed_paths
1702
+ : [];
1703
+ const combined = [...files, ...reviewFiles].map((file) => String(file)).filter(Boolean);
1704
+ const changedSummary = combined.length > 0 ? ` | changed: ${combined.slice(0, 8).join(', ')}` : '';
1705
+ const apiTask = detectApiTask(task.task);
1706
+ let runnerHint = ` | api_task=${apiTask ? 'yes' : 'no'}`;
1707
+ if (this.profileService && typeof this.profileService.getRunnerPlan === 'function') {
1708
+ const runnerPlan = await this.profileService.getRunnerPlan(task.task);
1709
+ runnerPlans.set(task.task.key, runnerPlan);
1710
+ runnerHint = ` | ui_repo=${runnerPlan.hasWebInterface ? 'yes' : 'no'} ui_task=${runnerPlan.uiTask ? 'yes' : 'no'} mobile_task=${runnerPlan.mobileTask ? 'yes' : 'no'} api_task=${apiTask ? 'yes' : 'no'}`;
1711
+ }
1712
+ taskLines.push(`- ${task.task.key}: ${task.task.title ?? '(untitled)'}${shortDesc}${changedSummary}${runnerHint}`);
1713
+ }
1714
+ const tasksBlock = taskLines.join('\n');
1715
+ const prompt = [
1716
+ systemPrompt,
1717
+ QA_ROUTING_PROMPT,
1718
+ `Available QA profiles:\n${availableProfiles}`,
1719
+ `Tasks:\n${tasksBlock}`,
1720
+ QA_ROUTING_OUTPUT_SCHEMA,
1721
+ ]
1722
+ .filter(Boolean)
1723
+ .join('\n\n');
1724
+ const res = await this.agentService.invoke(agent.id, {
1725
+ input: prompt,
1726
+ metadata: { command: 'qa-tasks', action: 'qa-profile-plan' },
1727
+ });
1728
+ const output = res.output ?? '';
1729
+ const tokensPrompt = this.estimateTokens(prompt);
1730
+ const tokensCompletion = this.estimateTokens(output);
1731
+ if (!this.dryRunGuard) {
1732
+ await this.jobService.recordTokenUsage({
1733
+ workspaceId: this.workspace.workspaceId,
1734
+ agentId: agent.id,
1735
+ modelName: agent.defaultModel,
1736
+ jobId: ctx.jobId ?? 'qa-profile-plan',
1737
+ commandRunId: ctx.commandRunId ?? 'qa-profile-plan',
1738
+ tokensPrompt,
1739
+ tokensCompletion,
1740
+ tokensTotal: tokensPrompt + tokensCompletion,
1741
+ timestamp: new Date().toISOString(),
1742
+ metadata: {
1743
+ commandName: 'qa-tasks',
1744
+ action: 'qa-profile-plan',
1745
+ phase: 'qa-plan',
1746
+ taskCount: tasks.length,
1747
+ },
1748
+ });
1749
+ }
1750
+ const parsed = this.extractJsonCandidate(output);
1751
+ const normalizedPlan = normalizeQaPlanOutput(parsed);
1752
+ if (normalizedPlan.warnings.length) {
1753
+ ctx.warnings?.push(...normalizedPlan.warnings);
1754
+ }
1755
+ const taskProfiles = normalizedPlan.taskProfiles;
1756
+ const taskPlans = normalizedPlan.taskPlans;
1757
+ if (!Object.keys(taskProfiles).length && !Object.keys(taskPlans).length) {
1758
+ ctx.warnings?.push('QA routing agent output invalid; defaulting to CLI profiles.');
1759
+ }
1760
+ this.qaTaskPlans = new Map(Object.entries(taskPlans));
1761
+ for (const task of tasks) {
1762
+ const planEntry = taskPlans[task.task.key];
1763
+ const selection = taskProfiles[task.task.key] ?? planEntry?.profiles ?? [];
1764
+ const rawList = Array.isArray(selection) ? selection : typeof selection === 'string' ? [selection] : [];
1765
+ const resolved = rawList
1766
+ .map((name) => (typeof name === 'string' ? profileByName.get(name) : undefined))
1767
+ .filter(Boolean);
1768
+ let selected = resolved.length ? [...resolved] : [];
1769
+ if (!selected.length && defaultProfile) {
1770
+ selected = [defaultProfile];
1771
+ }
1772
+ const runnerPlan = runnerPlans.get(task.task.key);
1773
+ const allowChromium = Boolean(runnerPlan?.uiTask && runnerPlan?.hasWebInterface);
1774
+ const allowMaestro = Boolean(runnerPlan?.mobileTask);
1775
+ const planBrowserActions = (planEntry?.browser?.actions ?? []).filter(Boolean);
1776
+ const planWantsChromium = rawList.some((name) => String(name).toLowerCase() === 'chromium') ||
1777
+ (planEntry?.profiles ?? []).some((name) => String(name).toLowerCase() === 'chromium') ||
1778
+ planBrowserActions.length > 0;
1779
+ if (planWantsChromium && allowChromium) {
1780
+ const chromiumProfile = resolveProfileForRunner('chromium');
1781
+ if (chromiumProfile && !selected.some((entry) => entry.name === chromiumProfile.name)) {
1782
+ selected.push(chromiumProfile);
1783
+ }
1784
+ }
1785
+ const cliProfile = resolveProfileForRunner('cli');
1786
+ if (cliProfile && !selected.some((entry) => entry.name === cliProfile.name)) {
1787
+ selected.push(cliProfile);
1788
+ }
1789
+ if (runnerPlan) {
1790
+ if (allowChromium) {
1791
+ const chromiumProfile = resolveProfileForRunner('chromium');
1792
+ if (chromiumProfile && !selected.some((entry) => entry.name === chromiumProfile.name)) {
1793
+ selected.push(chromiumProfile);
1794
+ }
1795
+ }
1796
+ if (allowMaestro) {
1797
+ const maestroProfile = resolveProfileForRunner('maestro');
1798
+ if (maestroProfile && !selected.some((entry) => entry.name === maestroProfile.name)) {
1799
+ selected.push(maestroProfile);
1800
+ }
1801
+ }
1802
+ if (!allowChromium) {
1803
+ selected = selected.filter((entry) => (entry.runner ?? 'cli') !== 'chromium' && entry.name !== 'chromium');
1804
+ }
1805
+ if (!allowMaestro) {
1806
+ selected = selected.filter((entry) => (entry.runner ?? 'cli') !== 'maestro' && entry.name !== 'maestro');
1807
+ }
1808
+ }
1809
+ if (selected.length) {
1810
+ plan.set(task.task.key, selected);
1811
+ }
1812
+ }
1813
+ const summary = Object.fromEntries(Array.from(plan.entries()).map(([key, entries]) => [key, entries.map((profile) => profile.name)]));
1814
+ if (ctx.jobId) {
1815
+ await this.checkpoint(ctx.jobId, 'qa-profile-plan', { profiles: summary, notes: normalizedPlan.notes });
1816
+ }
1817
+ const unusedProfileKeys = Object.keys(taskProfiles).filter((key) => !taskKeys.has(key));
1818
+ const unusedPlanKeys = Object.keys(taskPlans).filter((key) => !taskKeys.has(key));
1819
+ const unusedKeys = Array.from(new Set([...unusedProfileKeys, ...unusedPlanKeys]));
1820
+ if (unusedKeys.length) {
1821
+ ctx.warnings?.push(`QA routing agent returned unknown task keys: ${unusedKeys.join(', ')}`);
1822
+ }
1823
+ return plan;
1824
+ }
1825
+ async resolveProfilesForRequest(task, request) {
1826
+ const profileName = request.profileName;
1827
+ if (profileName) {
1828
+ const profile = await this.profileService.resolveProfileForTask(task, {
1829
+ profileName,
1830
+ level: request.level,
1831
+ });
1832
+ return profile ? [profile] : [];
1833
+ }
1834
+ const planned = this.qaProfilePlan?.get(task.key) ?? [];
1835
+ const profiles = [...planned];
1836
+ const seen = new Set(profiles.map((profile) => profile.name));
1837
+ const shouldUseRunnerProfiles = profiles.length === 0;
1838
+ if (shouldUseRunnerProfiles &&
1839
+ this.profileService &&
1840
+ typeof this.profileService.resolveProfilesForTask === 'function') {
1841
+ try {
1842
+ const runnerProfiles = await this.profileService.resolveProfilesForTask(task, {
1843
+ level: request.level,
1844
+ });
1845
+ if (Array.isArray(runnerProfiles)) {
1846
+ for (const profile of runnerProfiles) {
1847
+ if (!profile || seen.has(profile.name))
1848
+ continue;
1849
+ profiles.push(profile);
1850
+ seen.add(profile.name);
1851
+ }
1852
+ }
1853
+ }
1854
+ catch {
1855
+ // ignore runner plan resolution failures and fall back to planned/default
1856
+ }
1857
+ }
1858
+ if (profiles.length)
1859
+ return profiles;
1860
+ const available = await this.loadAvailableProfiles();
1861
+ const fallback = this.pickDefaultProfile(available);
1862
+ return fallback ? [fallback] : [];
1863
+ }
1864
+ isApprovedReviewDecision(decision) {
1865
+ if (!decision)
1866
+ return false;
1867
+ const normalized = decision.toLowerCase();
1868
+ return normalized === 'approve' || normalized === 'info_only';
1869
+ }
1870
+ async shouldSkipQaForNoChanges(task) {
1871
+ const metadata = task.metadata ?? {};
1872
+ const diffEmptyValue = metadata.last_review_diff_empty;
1873
+ const diffEmpty = diffEmptyValue === true ||
1874
+ diffEmptyValue === 'true' ||
1875
+ diffEmptyValue === 1 ||
1876
+ diffEmptyValue === '1';
1877
+ const decision = typeof metadata.last_review_decision === 'string' ? metadata.last_review_decision : undefined;
1878
+ if (diffEmpty && this.isApprovedReviewDecision(decision)) {
1879
+ return {
1880
+ skip: true,
1881
+ decision,
1882
+ reviewId: typeof metadata.last_review_id === 'string' ? metadata.last_review_id : undefined,
1883
+ };
1884
+ }
1885
+ const latestReview = await this.deps.workspaceRepo.getLatestTaskReview(task.id);
1886
+ if (latestReview?.metadata?.diffEmpty === true && this.isApprovedReviewDecision(latestReview.decision)) {
1887
+ return { skip: true, decision: latestReview.decision, reviewId: latestReview.id };
1888
+ }
1889
+ return { skip: false };
267
1890
  }
268
1891
  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)
1892
+ const trimmed = raw.trim();
1893
+ if (!trimmed)
1894
+ return undefined;
1895
+ const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
1896
+ const candidate = fenceMatch ? fenceMatch[1].trim() : trimmed;
1897
+ if (!candidate.startsWith("{") || !candidate.endsWith("}"))
274
1898
  return undefined;
275
1899
  try {
276
- return JSON.parse(candidate.slice(start, end + 1));
1900
+ return JSON.parse(candidate);
277
1901
  }
278
1902
  catch {
279
1903
  return undefined;
@@ -282,34 +1906,132 @@ export class QaTasksService {
282
1906
  normalizeAgentOutput(parsed) {
283
1907
  if (!parsed || typeof parsed !== 'object')
284
1908
  return undefined;
1909
+ const asString = (value) => (typeof value === 'string' ? value.trim() : undefined);
1910
+ const asNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
1911
+ const asLine = (value) => normalizeLineNumber(value);
1912
+ const asFile = (value) => {
1913
+ if (typeof value !== 'string')
1914
+ return undefined;
1915
+ const trimmed = value.trim();
1916
+ return trimmed ? normalizePath(trimmed) : undefined;
1917
+ };
1918
+ const asStringArray = (value) => Array.isArray(value) ? value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) : undefined;
285
1919
  const recommendation = parsed.recommendation;
286
1920
  if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
287
1921
  return undefined;
288
- const followUps = Array.isArray(parsed.follow_up_tasks)
1922
+ const testedScope = asString(parsed.tested_scope ?? parsed.scope);
1923
+ const coverageSummary = asString(parsed.coverage_summary ?? parsed.coverage);
1924
+ const rawFollowUps = Array.isArray(parsed.follow_up_tasks)
289
1925
  ? parsed.follow_up_tasks
290
1926
  : Array.isArray(parsed.follow_ups)
291
1927
  ? parsed.follow_ups
292
1928
  : undefined;
1929
+ const followUps = rawFollowUps
1930
+ ? rawFollowUps.map((item) => ({
1931
+ title: asString(item?.title),
1932
+ description: asString(item?.description),
1933
+ type: asString(item?.type),
1934
+ priority: asNumber(item?.priority),
1935
+ story_points: asNumber(item?.story_points ?? item?.storyPoints),
1936
+ tags: asStringArray(item?.tags),
1937
+ related_task_key: asString(item?.related_task_key ?? item?.relatedTaskKey),
1938
+ epic_key: asString(item?.epic_key ?? item?.epicKey),
1939
+ story_key: asString(item?.story_key ?? item?.storyKey),
1940
+ components: asStringArray(item?.components),
1941
+ doc_links: asStringArray(item?.doc_links ?? item?.docLinks),
1942
+ evidence_url: asString(item?.evidence_url ?? item?.evidenceUrl),
1943
+ artifacts: asStringArray(item?.artifacts),
1944
+ }))
1945
+ : undefined;
293
1946
  const failures = Array.isArray(parsed.failures)
294
- ? parsed.failures.map((f) => ({ kind: f.kind, message: f.message ?? String(f), evidence: f.evidence }))
1947
+ ? parsed.failures.map((f) => ({
1948
+ kind: asString(f?.kind),
1949
+ message: asString(f?.message) ?? String(f),
1950
+ evidence: asString(f?.evidence),
1951
+ file: asFile(f?.file ?? f?.path ?? f?.file_path ?? f?.filePath),
1952
+ line: asLine(f?.line ?? f?.line_number ?? f?.lineNumber),
1953
+ }))
295
1954
  : undefined;
1955
+ const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
1956
+ const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
296
1957
  return {
297
1958
  recommendation,
298
- testedScope: parsed.tested_scope ?? parsed.scope,
299
- coverageSummary: parsed.coverage_summary ?? parsed.coverage,
1959
+ testedScope,
1960
+ coverageSummary,
300
1961
  failures,
301
1962
  followUps,
1963
+ resolvedSlugs,
1964
+ unresolvedSlugs,
302
1965
  };
303
1966
  }
304
- async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId) {
1967
+ validateInterpretation(result, options = {}) {
1968
+ if (typeof result.testedScope !== "string" || !result.testedScope.trim()) {
1969
+ return "tested_scope must be a non-empty string.";
1970
+ }
1971
+ if (typeof result.coverageSummary !== "string" || !result.coverageSummary.trim()) {
1972
+ return "coverage_summary must be a non-empty string.";
1973
+ }
1974
+ if (options.requireCommentSlugs && result.resolvedSlugs === undefined && result.unresolvedSlugs === undefined) {
1975
+ return "resolvedSlugs/unresolvedSlugs required when comment backlog exists.";
1976
+ }
1977
+ if (!result.failures || result.failures.length === 0)
1978
+ return undefined;
1979
+ for (const failure of result.failures) {
1980
+ const file = failure.file?.trim();
1981
+ const line = normalizeLineNumber(failure.line);
1982
+ if (!file || !line) {
1983
+ return "Each QA failure must include file and line.";
1984
+ }
1985
+ failure.file = normalizePath(file);
1986
+ failure.line = line;
1987
+ }
1988
+ return undefined;
1989
+ }
1990
+ async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId, commentBacklog, abortSignal) {
305
1991
  if (!this.agentService) {
306
1992
  return { recommendation: this.mapOutcome(result) };
307
1993
  }
1994
+ const resolveAbortReason = () => {
1995
+ const reason = abortSignal?.reason;
1996
+ if (typeof reason === "string" && reason.trim().length > 0)
1997
+ return reason;
1998
+ if (reason instanceof Error && reason.message)
1999
+ return reason.message;
2000
+ return "qa_tasks_aborted";
2001
+ };
2002
+ const abortIfSignaled = () => {
2003
+ if (abortSignal?.aborted) {
2004
+ throw new Error(resolveAbortReason());
2005
+ }
2006
+ };
2007
+ const withAbort = async (promise) => {
2008
+ if (!abortSignal)
2009
+ return promise;
2010
+ if (abortSignal.aborted) {
2011
+ throw new Error(resolveAbortReason());
2012
+ }
2013
+ return await new Promise((resolve, reject) => {
2014
+ const onAbort = () => reject(new Error(resolveAbortReason()));
2015
+ abortSignal.addEventListener("abort", onAbort, { once: true });
2016
+ promise.then(resolve, reject).finally(() => {
2017
+ abortSignal.removeEventListener("abort", onAbort);
2018
+ });
2019
+ });
2020
+ };
308
2021
  try {
2022
+ abortIfSignaled();
309
2023
  const agent = await this.resolveAgent(agentName);
310
2024
  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);
2025
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
2026
+ if (projectGuidance && taskRunId) {
2027
+ await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
2028
+ }
2029
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
2030
+ const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, QA_TEST_POLICY]
2031
+ .filter(Boolean)
2032
+ .join('\n\n');
2033
+ const docLinks = Array.isArray(task.task.metadata?.doc_links) ? task.task.metadata.doc_links : [];
2034
+ const docCtx = await this.gatherDocContext(task.task, taskRunId, docLinks);
313
2035
  const acceptance = (task.task.acceptanceCriteria ?? []).map((line) => `- ${line}`).join('\n');
314
2036
  const prompt = [
315
2037
  systemPrompt,
@@ -318,7 +2040,8 @@ export class QaTasksService {
318
2040
  `Task type: ${task.task.type ?? 'n/a'}, status: ${task.task.status}`,
319
2041
  task.task.description ? `Task description:\n${task.task.description}` : '',
320
2042
  `Epic/Story: ${task.task.epicKey ?? task.task.epicId} / ${task.task.storyKey ?? task.task.userStoryId}`,
321
- acceptance ? `Acceptance criteria:\n${acceptance}` : 'Acceptance criteria: (not provided)',
2043
+ acceptance ? `Task DoD / acceptance criteria:\n${acceptance}` : 'Task DoD / acceptance criteria: (not provided)',
2044
+ commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : 'Comment backlog: none',
322
2045
  `QA profile: ${profile.name} (${profile.runner ?? 'cli'})`,
323
2046
  `Test command / runner outcome: exit=${result.exitCode} outcome=${result.outcome}`,
324
2047
  result.stdout ? `Stdout (truncated):\n${result.stdout.slice(0, 3000)}` : '',
@@ -328,13 +2051,15 @@ export class QaTasksService {
328
2051
  [
329
2052
  'Return strict JSON with keys:',
330
2053
  '{',
331
- ' "tested_scope": string,',
332
- ' "coverage_summary": string,',
333
- ' "failures": [{ "kind": "functional|contract|perf|security|infra", "message": string, "evidence": string }],',
2054
+ ' "tested_scope": string (single sentence),',
2055
+ ' "coverage_summary": string (single paragraph),',
2056
+ ' "failures": [{ "kind": "functional|contract|perf|security|infra", "message": string, "file": string, "line": number, "evidence": string }],',
334
2057
  ' "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[] }]',
2058
+ ' "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[] }],',
2059
+ ' "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],',
2060
+ ' "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]',
336
2061
  '}',
337
- 'Do not include prose outside the JSON.',
2062
+ 'Do not include prose outside the JSON. No markdown fences or comments. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
338
2063
  ].join('\n'),
339
2064
  ]
340
2065
  .filter(Boolean)
@@ -355,14 +2080,19 @@ export class QaTasksService {
355
2080
  let output = '';
356
2081
  let chunkCount = 0;
357
2082
  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) {
2083
+ const gen = await withAbort(this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } }));
2084
+ while (true) {
2085
+ abortIfSignaled();
2086
+ const { value, done } = await withAbort(gen.next());
2087
+ if (done)
2088
+ break;
2089
+ const chunk = value;
360
2090
  output += chunk.output ?? '';
361
2091
  chunkCount += 1;
362
2092
  }
363
2093
  }
364
2094
  else {
365
- const res = await this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
2095
+ const res = await withAbort(this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } }));
366
2096
  output = res.output ?? '';
367
2097
  }
368
2098
  const tokensPrompt = this.estimateTokens(prompt);
@@ -383,6 +2113,8 @@ export class QaTasksService {
383
2113
  metadata: {
384
2114
  commandName: 'qa-tasks',
385
2115
  action: 'qa-interpret-results',
2116
+ phase: 'qa-interpret',
2117
+ attempt: 1,
386
2118
  taskKey: task.task.key,
387
2119
  streaming: stream,
388
2120
  streamChunks: chunkCount || undefined,
@@ -390,7 +2122,15 @@ export class QaTasksService {
390
2122
  });
391
2123
  }
392
2124
  const parsed = this.extractJsonCandidate(output);
393
- const normalized = this.normalizeAgentOutput(parsed);
2125
+ let normalized = this.normalizeAgentOutput(parsed);
2126
+ const requireCommentSlugs = Boolean(commentBacklog && commentBacklog.trim());
2127
+ let validationError = normalized ? this.validateInterpretation(normalized, { requireCommentSlugs }) : undefined;
2128
+ if (normalized && validationError) {
2129
+ if (taskRunId) {
2130
+ await this.logTask(taskRunId, `QA agent output missing required fields (${validationError}); retrying once.`, 'qa-agent');
2131
+ }
2132
+ normalized = undefined;
2133
+ }
394
2134
  if (normalized) {
395
2135
  return {
396
2136
  ...normalized,
@@ -401,15 +2141,243 @@ export class QaTasksService {
401
2141
  modelName: agent.defaultModel,
402
2142
  };
403
2143
  }
404
- return { recommendation: this.mapOutcome(result), rawOutput: output, tokensPrompt, tokensCompletion, agentId: agent.id, modelName: agent.defaultModel };
2144
+ const retryPrompt = `${prompt}\n\nReturn STRICT JSON only. Do not include prose, markdown fences, or comments.`;
2145
+ let retryOutput = "";
2146
+ if (stream && this.agentService.invokeStream) {
2147
+ const gen = await withAbort(this.agentService.invokeStream(agent.id, { input: retryPrompt, metadata: { command: 'qa-tasks' } }));
2148
+ while (true) {
2149
+ abortIfSignaled();
2150
+ const { value, done } = await withAbort(gen.next());
2151
+ if (done)
2152
+ break;
2153
+ const chunk = value;
2154
+ retryOutput += chunk.output ?? '';
2155
+ }
2156
+ }
2157
+ else {
2158
+ const res = await withAbort(this.agentService.invoke(agent.id, { input: retryPrompt, metadata: { command: 'qa-tasks' } }));
2159
+ retryOutput = res.output ?? '';
2160
+ }
2161
+ const retryTokensPrompt = this.estimateTokens(retryPrompt);
2162
+ const retryTokensCompletion = this.estimateTokens(retryOutput);
2163
+ if (!this.dryRunGuard) {
2164
+ await this.jobService.recordTokenUsage({
2165
+ workspaceId: this.workspace.workspaceId,
2166
+ agentId: agent.id,
2167
+ modelName: agent.defaultModel,
2168
+ jobId,
2169
+ taskId: task.task.id,
2170
+ commandRunId,
2171
+ taskRunId,
2172
+ tokensPrompt: retryTokensPrompt,
2173
+ tokensCompletion: retryTokensCompletion,
2174
+ tokensTotal: retryTokensPrompt + retryTokensCompletion,
2175
+ timestamp: new Date().toISOString(),
2176
+ metadata: {
2177
+ commandName: 'qa-tasks',
2178
+ action: 'qa-interpret-retry',
2179
+ phase: 'qa-interpret-retry',
2180
+ attempt: 2,
2181
+ taskKey: task.task.key,
2182
+ },
2183
+ });
2184
+ }
2185
+ const retryParsed = this.extractJsonCandidate(retryOutput);
2186
+ let retryNormalized = this.normalizeAgentOutput(retryParsed);
2187
+ validationError = retryNormalized ? this.validateInterpretation(retryNormalized, { requireCommentSlugs }) : undefined;
2188
+ if (retryNormalized && validationError) {
2189
+ retryNormalized = undefined;
2190
+ }
2191
+ if (retryNormalized) {
2192
+ return {
2193
+ ...retryNormalized,
2194
+ rawOutput: retryOutput,
2195
+ tokensPrompt: tokensPrompt + retryTokensPrompt,
2196
+ tokensCompletion: tokensCompletion + retryTokensCompletion,
2197
+ agentId: agent.id,
2198
+ modelName: agent.defaultModel,
2199
+ };
2200
+ }
2201
+ if (taskRunId) {
2202
+ const message = validationError
2203
+ ? `QA agent output missing required fields (${validationError}); falling back to QA outcome.`
2204
+ : "QA agent returned invalid JSON after retry; falling back to QA outcome.";
2205
+ await this.logTask(taskRunId, message, "qa-agent");
2206
+ }
2207
+ return {
2208
+ recommendation: 'unclear',
2209
+ rawOutput: retryOutput || output,
2210
+ tokensPrompt: tokensPrompt + retryTokensPrompt,
2211
+ tokensCompletion: tokensCompletion + retryTokensCompletion,
2212
+ agentId: agent.id,
2213
+ modelName: agent.defaultModel,
2214
+ invalidJson: true,
2215
+ };
405
2216
  }
406
2217
  catch (error) {
2218
+ const message = error?.message ?? String(error);
407
2219
  if (taskRunId) {
408
- await this.logTask(taskRunId, `QA agent failed: ${error?.message ?? error}`, 'qa-agent');
2220
+ await this.logTask(taskRunId, `QA agent failed: ${message}`, 'qa-agent');
2221
+ }
2222
+ if (isAuthErrorMessage(message)) {
2223
+ throw error;
409
2224
  }
410
2225
  return { recommendation: this.mapOutcome(result) };
411
2226
  }
412
2227
  }
2228
+ async loadCommentContext(taskId) {
2229
+ const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
2230
+ sourceCommands: ['code-review', 'qa-tasks'],
2231
+ limit: 50,
2232
+ });
2233
+ const unresolved = comments.filter((comment) => !comment.resolvedAt);
2234
+ return { comments, unresolved };
2235
+ }
2236
+ resolveFailureSlug(failure) {
2237
+ const message = (failure.message ?? '').trim() || 'QA issue';
2238
+ return createTaskCommentSlug({
2239
+ source: 'qa-tasks',
2240
+ message,
2241
+ category: failure.kind ?? 'qa_issue',
2242
+ file: failure.file,
2243
+ line: failure.line,
2244
+ });
2245
+ }
2246
+ async applyCommentResolutions(params) {
2247
+ const existingBySlug = new Map();
2248
+ const openBySlug = new Set();
2249
+ const resolvedBySlug = new Set();
2250
+ for (const comment of params.existingComments) {
2251
+ if (!comment.slug)
2252
+ continue;
2253
+ if (!existingBySlug.has(comment.slug)) {
2254
+ existingBySlug.set(comment.slug, comment);
2255
+ }
2256
+ if (comment.resolvedAt) {
2257
+ resolvedBySlug.add(comment.slug);
2258
+ }
2259
+ else {
2260
+ openBySlug.add(comment.slug);
2261
+ }
2262
+ }
2263
+ const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined);
2264
+ const resolvedSet = new Set(resolvedSlugs);
2265
+ const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
2266
+ const failureSlugs = [];
2267
+ for (const failure of params.failures ?? []) {
2268
+ const slug = this.resolveFailureSlug(failure);
2269
+ failureSlugs.push(slug);
2270
+ if (!resolvedSet.has(slug)) {
2271
+ unresolvedSet.add(slug);
2272
+ }
2273
+ }
2274
+ for (const slug of resolvedSet) {
2275
+ unresolvedSet.delete(slug);
2276
+ }
2277
+ const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
2278
+ const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
2279
+ if (!this.dryRunGuard) {
2280
+ for (const slug of toResolve) {
2281
+ await this.deps.workspaceRepo.resolveTaskComment({
2282
+ taskId: params.task.id,
2283
+ slug,
2284
+ resolvedAt: new Date().toISOString(),
2285
+ resolvedBy: params.agentId ?? null,
2286
+ });
2287
+ }
2288
+ for (const slug of toReopen) {
2289
+ await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
2290
+ }
2291
+ }
2292
+ const createdSlugs = new Set();
2293
+ for (const failure of params.failures ?? []) {
2294
+ const slug = this.resolveFailureSlug(failure);
2295
+ if (existingBySlug.has(slug) || createdSlugs.has(slug))
2296
+ continue;
2297
+ const baseMessage = (failure.message ?? '').trim() || '(no details provided)';
2298
+ const message = failure.evidence ? `${baseMessage}\nEvidence: ${failure.evidence}` : baseMessage;
2299
+ const body = formatTaskCommentBody({
2300
+ slug,
2301
+ source: 'qa-tasks',
2302
+ message,
2303
+ status: 'open',
2304
+ category: failure.kind ?? 'qa_issue',
2305
+ file: failure.file ?? null,
2306
+ line: failure.line ?? null,
2307
+ });
2308
+ if (!this.dryRunGuard) {
2309
+ await this.deps.workspaceRepo.createTaskComment({
2310
+ taskId: params.task.id,
2311
+ taskRunId: params.taskRunId,
2312
+ jobId: params.jobId,
2313
+ sourceCommand: 'qa-tasks',
2314
+ authorType: 'agent',
2315
+ authorAgentId: params.agentId ?? null,
2316
+ category: failure.kind ?? 'qa_issue',
2317
+ slug,
2318
+ status: 'open',
2319
+ file: failure.file ?? null,
2320
+ line: failure.line ?? null,
2321
+ pathHint: failure.file ?? null,
2322
+ body,
2323
+ createdAt: new Date().toISOString(),
2324
+ metadata: {
2325
+ kind: failure.kind,
2326
+ evidence: failure.evidence,
2327
+ },
2328
+ });
2329
+ }
2330
+ createdSlugs.add(slug);
2331
+ }
2332
+ const openSet = new Set(openBySlug);
2333
+ for (const slug of unresolvedSet) {
2334
+ openSet.add(slug);
2335
+ }
2336
+ for (const slug of resolvedSet) {
2337
+ openSet.delete(slug);
2338
+ }
2339
+ if ((resolvedSlugs.length || toReopen.length || unresolvedSet.size) && !this.dryRunGuard) {
2340
+ const resolutionMessage = [
2341
+ `Resolved slugs: ${formatSlugList(toResolve)}`,
2342
+ `Reopened slugs: ${formatSlugList(toReopen)}`,
2343
+ `Open slugs: ${formatSlugList(Array.from(openSet))}`,
2344
+ ].join('\n');
2345
+ const resolutionSlug = createTaskCommentSlug({
2346
+ source: 'qa-tasks',
2347
+ message: resolutionMessage,
2348
+ category: 'comment_resolution',
2349
+ });
2350
+ const resolutionBody = formatTaskCommentBody({
2351
+ slug: resolutionSlug,
2352
+ source: 'qa-tasks',
2353
+ message: resolutionMessage,
2354
+ status: 'resolved',
2355
+ category: 'comment_resolution',
2356
+ });
2357
+ const createdAt = new Date().toISOString();
2358
+ await this.deps.workspaceRepo.createTaskComment({
2359
+ taskId: params.task.id,
2360
+ taskRunId: params.taskRunId,
2361
+ jobId: params.jobId,
2362
+ sourceCommand: 'qa-tasks',
2363
+ authorType: 'agent',
2364
+ authorAgentId: params.agentId ?? null,
2365
+ category: 'comment_resolution',
2366
+ slug: resolutionSlug,
2367
+ status: 'resolved',
2368
+ body: resolutionBody,
2369
+ createdAt,
2370
+ resolvedAt: createdAt,
2371
+ resolvedBy: params.agentId ?? null,
2372
+ metadata: {
2373
+ resolvedSlugs: toResolve,
2374
+ reopenedSlugs: toReopen,
2375
+ openSlugs: Array.from(openSet),
2376
+ },
2377
+ });
2378
+ }
2379
+ return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
2380
+ }
413
2381
  async createTaskRun(task, jobId, commandRunId) {
414
2382
  const startedAt = new Date().toISOString();
415
2383
  return this.deps.workspaceRepo.createTaskRun({
@@ -445,16 +2413,48 @@ export class QaTasksService {
445
2413
  details: details ?? undefined,
446
2414
  });
447
2415
  }
448
- async applyStateTransition(task, outcome) {
449
- const timestamp = { last_qa: new Date().toISOString() };
450
- if (outcome === 'pass') {
451
- await this.stateService.markCompleted(task, timestamp);
2416
+ async createQaComment(params) {
2417
+ const status = params.status ?? (params.category === 'qa_result' ? 'resolved' : 'open');
2418
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message: params.message, category: params.category });
2419
+ const body = formatTaskCommentBody({
2420
+ slug,
2421
+ source: 'qa-tasks',
2422
+ message: params.message,
2423
+ status,
2424
+ category: params.category,
2425
+ });
2426
+ const createdAt = new Date().toISOString();
2427
+ await this.deps.workspaceRepo.createTaskComment({
2428
+ taskId: params.task.id,
2429
+ taskRunId: params.taskRunId,
2430
+ jobId: params.jobId,
2431
+ sourceCommand: 'qa-tasks',
2432
+ authorType: params.authorType ?? 'agent',
2433
+ authorAgentId: params.authorAgentId ?? null,
2434
+ category: params.category,
2435
+ slug,
2436
+ status,
2437
+ body,
2438
+ createdAt,
2439
+ resolvedAt: status === 'resolved' ? createdAt : null,
2440
+ resolvedBy: status === 'resolved' ? params.authorAgentId ?? null : null,
2441
+ metadata: params.metadata ?? undefined,
2442
+ });
2443
+ }
2444
+ async applyStateTransition(task, outcome, context, metadataPatch) {
2445
+ const baseMetadata = {
2446
+ last_qa: new Date().toISOString(),
2447
+ last_qa_outcome: outcome,
2448
+ };
2449
+ if (outcome !== 'pass') {
2450
+ baseMetadata.qa_failure_reason = `qa_${outcome}`;
452
2451
  }
453
- else if (outcome === 'fix_required') {
454
- await this.stateService.returnToInProgress(task, timestamp);
2452
+ const mergedMetadata = metadataPatch ? { ...baseMetadata, ...metadataPatch } : baseMetadata;
2453
+ if (outcome === 'pass') {
2454
+ await this.stateService.markCompleted(task, mergedMetadata, context);
455
2455
  }
456
- else if (outcome === 'infra_issue') {
457
- await this.stateService.markBlocked(task, 'qa_infra_issue');
2456
+ else {
2457
+ await this.stateService.markNotStarted(task, mergedMetadata, context);
458
2458
  }
459
2459
  }
460
2460
  buildFollowupSuggestion(task, result, notes) {
@@ -474,6 +2474,38 @@ export class QaTasksService {
474
2474
  testName: tests[0],
475
2475
  };
476
2476
  }
2477
+ buildManualQaFollowup(task, rawOutput) {
2478
+ const summary = rawOutput ? rawOutput.slice(0, 1000) : 'QA agent returned invalid JSON after retry.';
2479
+ const components = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
2480
+ const docLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
2481
+ const tests = Array.isArray(task.metadata?.tests) ? task.metadata.tests : [];
2482
+ return {
2483
+ title: `Manual QA follow-up for ${task.key}`,
2484
+ description: `QA agent returned invalid JSON after retry. Manual QA required.\n\nRaw output:\n${summary}`.slice(0, 2000),
2485
+ type: 'qa_followup',
2486
+ storyPoints: 1,
2487
+ priority: 90,
2488
+ tags: ['qa', 'manual', ...components],
2489
+ components,
2490
+ docLinks,
2491
+ testName: tests[0],
2492
+ };
2493
+ }
2494
+ buildFollowupSlug(task, suggestion) {
2495
+ const seedParts = [
2496
+ task.key,
2497
+ suggestion.title ?? '',
2498
+ suggestion.description ?? '',
2499
+ suggestion.type ?? '',
2500
+ suggestion.testName ?? '',
2501
+ suggestion.evidenceUrl ?? '',
2502
+ ...(suggestion.tags ?? []),
2503
+ ...(suggestion.components ?? []),
2504
+ ];
2505
+ const seed = seedParts.join('|').toLowerCase();
2506
+ const digest = createHash('sha1').update(seed).digest('hex').slice(0, 12);
2507
+ return `qa-followup-${task.key}-${digest}`;
2508
+ }
477
2509
  toFollowupSuggestion(task, agentFollow, artifacts) {
478
2510
  const taskComponents = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
479
2511
  const taskDocLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
@@ -498,8 +2530,14 @@ export class QaTasksService {
498
2530
  return [];
499
2531
  const agent = await this.resolveAgent(undefined);
500
2532
  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);
2533
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
2534
+ if (projectGuidance && taskRunId) {
2535
+ await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
2536
+ }
2537
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
2538
+ const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
2539
+ const docLinks = Array.isArray(task.task.metadata?.doc_links) ? task.task.metadata.doc_links : [];
2540
+ const docCtx = await this.gatherDocContext(task.task, taskRunId, docLinks);
503
2541
  const prompt = [
504
2542
  systemPrompt,
505
2543
  'You are the mcoda QA agent. Given QA notes/evidence, propose structured follow-up tasks as JSON.',
@@ -531,7 +2569,11 @@ export class QaTasksService {
531
2569
  output = res.output ?? '';
532
2570
  }
533
2571
  }
534
- catch {
2572
+ catch (error) {
2573
+ const message = error?.message ?? String(error);
2574
+ if (isAuthErrorMessage(message)) {
2575
+ throw error;
2576
+ }
535
2577
  return [];
536
2578
  }
537
2579
  const tokensPrompt = this.estimateTokens(prompt);
@@ -568,6 +2610,12 @@ export class QaTasksService {
568
2610
  }
569
2611
  async runAuto(task, ctx) {
570
2612
  const taskRun = await this.createTaskRun(task.task, ctx.jobId, ctx.commandRunId);
2613
+ const statusContextBase = {
2614
+ commandName: 'qa-tasks',
2615
+ jobId: ctx.jobId,
2616
+ taskRunId: taskRun.id,
2617
+ metadata: { lane: 'qa' },
2618
+ };
571
2619
  await this.logTask(taskRun.id, 'Starting QA', 'qa-start');
572
2620
  const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
573
2621
  if (task.task.status && !allowedStatuses.has(task.task.status)) {
@@ -588,252 +2636,1043 @@ export class QaTasksService {
588
2636
  runner: undefined,
589
2637
  metadata: { reason: 'status_gating' },
590
2638
  });
2639
+ await this.createQaComment({
2640
+ task: task.task,
2641
+ taskRunId: taskRun.id,
2642
+ jobId: ctx.jobId,
2643
+ message,
2644
+ category: 'qa_issue',
2645
+ status: 'open',
2646
+ metadata: {
2647
+ reason: 'status_gating',
2648
+ taskStatus: task.task.status,
2649
+ allowedStatuses: Array.from(allowedStatuses),
2650
+ },
2651
+ });
2652
+ }
2653
+ return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
2654
+ }
2655
+ const skipReview = await this.shouldSkipQaForNoChanges(task.task);
2656
+ if (skipReview.skip) {
2657
+ const message = 'QA skipped: code review reported no code changes to validate.';
2658
+ await this.logTask(taskRun.id, message, 'qa-skip', { reason: 'review_no_changes', decision: skipReview.decision });
2659
+ if (!this.dryRunGuard) {
2660
+ await this.applyStateTransition(task.task, 'pass', statusContextBase);
2661
+ await this.finishTaskRun(taskRun, 'succeeded');
2662
+ await this.deps.workspaceRepo.createTaskQaRun({
2663
+ taskId: task.task.id,
2664
+ taskRunId: taskRun.id,
2665
+ jobId: ctx.jobId,
2666
+ commandRunId: ctx.commandRunId,
2667
+ source: 'auto',
2668
+ mode: 'auto',
2669
+ rawOutcome: 'pass',
2670
+ recommendation: 'pass',
2671
+ profileName: undefined,
2672
+ runner: undefined,
2673
+ metadata: { reason: 'review_no_changes', decision: skipReview.decision, reviewId: skipReview.reviewId },
2674
+ });
2675
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_result' });
2676
+ const body = formatTaskCommentBody({
2677
+ slug,
2678
+ source: 'qa-tasks',
2679
+ message,
2680
+ status: 'resolved',
2681
+ category: 'qa_result',
2682
+ });
2683
+ await this.deps.workspaceRepo.createTaskComment({
2684
+ taskId: task.task.id,
2685
+ taskRunId: taskRun.id,
2686
+ jobId: ctx.jobId,
2687
+ sourceCommand: 'qa-tasks',
2688
+ authorType: 'agent',
2689
+ category: 'qa_result',
2690
+ slug,
2691
+ status: 'resolved',
2692
+ body,
2693
+ createdAt: new Date().toISOString(),
2694
+ resolvedAt: new Date().toISOString(),
2695
+ });
2696
+ }
2697
+ return { taskKey: task.task.key, outcome: 'pass', notes: 'review_no_changes' };
2698
+ }
2699
+ const branchCheck = await this.ensureTaskBranch(task, taskRun.id, ctx.jobId, ctx.request.allowDirty ?? false, ctx.request.cleanIgnorePaths);
2700
+ if (!branchCheck.ok) {
2701
+ if (!this.dryRunGuard) {
2702
+ await this.applyStateTransition(task.task, 'infra_issue', statusContextBase);
2703
+ await this.finishTaskRun(taskRun, 'failed');
2704
+ await this.deps.workspaceRepo.createTaskQaRun({
2705
+ taskId: task.task.id,
2706
+ taskRunId: taskRun.id,
2707
+ jobId: ctx.jobId,
2708
+ commandRunId: ctx.commandRunId,
2709
+ source: 'auto',
2710
+ mode: 'auto',
2711
+ rawOutcome: 'infra_issue',
2712
+ recommendation: 'infra_issue',
2713
+ metadata: { reason: 'vcs_branch_missing', detail: branchCheck.message },
2714
+ });
2715
+ const message = `VCS validation failed: ${branchCheck.message ?? 'unknown error'}`;
2716
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_issue' });
2717
+ const body = formatTaskCommentBody({
2718
+ slug,
2719
+ source: 'qa-tasks',
2720
+ message,
2721
+ status: 'open',
2722
+ category: 'qa_issue',
2723
+ });
2724
+ await this.deps.workspaceRepo.createTaskComment({
2725
+ taskId: task.task.id,
2726
+ taskRunId: taskRun.id,
2727
+ jobId: ctx.jobId,
2728
+ sourceCommand: 'qa-tasks',
2729
+ authorType: 'agent',
2730
+ category: 'qa_issue',
2731
+ slug,
2732
+ status: 'open',
2733
+ body,
2734
+ createdAt: new Date().toISOString(),
2735
+ });
2736
+ }
2737
+ return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'vcs_branch_missing' };
2738
+ }
2739
+ const qaWorkspaceRoot = branchCheck.workspaceRoot ?? this.workspace.workspaceRoot;
2740
+ const cleanupWorktree = branchCheck.cleanup;
2741
+ let serverHandle;
2742
+ const baseBranch = this.workspace.config?.branch ?? 'mcoda-dev';
2743
+ const taskBranch = branchCheck.branch ?? baseBranch;
2744
+ let qaPrepared = false;
2745
+ const ensureQaPrepared = async () => {
2746
+ if (qaPrepared)
2747
+ return { ok: true };
2748
+ qaPrepared = true;
2749
+ return await this.prepareQaWorkspace({
2750
+ workspaceRoot: qaWorkspaceRoot,
2751
+ taskRunId: taskRun.id,
2752
+ baseBranch,
2753
+ taskBranch,
2754
+ taskKey: task.task.key,
2755
+ });
2756
+ };
2757
+ try {
2758
+ const prep = await ensureQaPrepared();
2759
+ if (!prep.ok) {
2760
+ const message = prep.message ?? 'QA dependency install failed.';
2761
+ await this.logTask(taskRun.id, message, 'qa-install');
2762
+ await this.finishTaskRun(taskRun, 'failed');
2763
+ if (!this.dryRunGuard) {
2764
+ await this.applyStateTransition(task.task, 'infra_issue', statusContextBase, {
2765
+ qa_failure_reason: 'qa_dependency_install_failed',
2766
+ });
2767
+ await this.deps.workspaceRepo.createTaskQaRun({
2768
+ taskId: task.task.id,
2769
+ taskRunId: taskRun.id,
2770
+ jobId: ctx.jobId,
2771
+ commandRunId: ctx.commandRunId,
2772
+ source: 'auto',
2773
+ mode: 'auto',
2774
+ rawOutcome: 'infra_issue',
2775
+ recommendation: 'infra_issue',
2776
+ metadata: { reason: 'qa_dependency_install_failed', message },
2777
+ });
2778
+ await this.createQaComment({
2779
+ task: task.task,
2780
+ taskRunId: taskRun.id,
2781
+ jobId: ctx.jobId,
2782
+ message,
2783
+ category: 'qa_issue',
2784
+ status: 'open',
2785
+ metadata: { reason: 'qa_dependency_install_failed' },
2786
+ });
2787
+ }
2788
+ return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'qa_dependency_install_failed' };
2789
+ }
2790
+ let profiles = [];
2791
+ try {
2792
+ profiles = await this.resolveProfilesForRequest(task.task, ctx.request);
2793
+ }
2794
+ catch (error) {
2795
+ await this.logTask(taskRun.id, `Profile resolution failed: ${error?.message ?? error}`, 'qa-profile');
2796
+ await this.finishTaskRun(taskRun, 'failed');
2797
+ if (!this.dryRunGuard) {
2798
+ await this.applyStateTransition(task.task, 'infra_issue', statusContextBase, {
2799
+ qa_failure_reason: 'qa_profile_resolution_failed',
2800
+ });
2801
+ await this.deps.workspaceRepo.createTaskQaRun({
2802
+ taskId: task.task.id,
2803
+ taskRunId: taskRun.id,
2804
+ jobId: ctx.jobId,
2805
+ commandRunId: ctx.commandRunId,
2806
+ source: 'auto',
2807
+ mode: 'auto',
2808
+ rawOutcome: 'infra_issue',
2809
+ recommendation: 'infra_issue',
2810
+ metadata: { reason: 'profile_resolution_failed', message: error?.message ?? String(error) },
2811
+ });
2812
+ await this.createQaComment({
2813
+ task: task.task,
2814
+ taskRunId: taskRun.id,
2815
+ jobId: ctx.jobId,
2816
+ message: `QA profile resolution failed: ${error?.message ?? String(error)}`,
2817
+ category: 'qa_issue',
2818
+ status: 'open',
2819
+ metadata: { reason: 'profile_resolution_failed' },
2820
+ });
2821
+ }
2822
+ return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'profile_resolution_failed' };
2823
+ }
2824
+ if (!profiles.length) {
2825
+ await this.logTask(taskRun.id, 'No QA profile available', 'qa-profile');
2826
+ await this.finishTaskRun(taskRun, 'failed');
2827
+ if (!this.dryRunGuard) {
2828
+ await this.applyStateTransition(task.task, 'infra_issue', statusContextBase, { qa_failure_reason: 'qa_no_profile' });
2829
+ await this.deps.workspaceRepo.createTaskQaRun({
2830
+ taskId: task.task.id,
2831
+ taskRunId: taskRun.id,
2832
+ jobId: ctx.jobId,
2833
+ commandRunId: ctx.commandRunId,
2834
+ source: 'auto',
2835
+ mode: 'auto',
2836
+ rawOutcome: 'infra_issue',
2837
+ recommendation: 'infra_issue',
2838
+ metadata: { reason: 'no_profile' },
2839
+ });
2840
+ await this.createQaComment({
2841
+ task: task.task,
2842
+ taskRunId: taskRun.id,
2843
+ jobId: ctx.jobId,
2844
+ message: 'QA profile selection returned no profiles. Add or configure QA profiles.',
2845
+ category: 'qa_issue',
2846
+ status: 'open',
2847
+ metadata: { reason: 'no_profile' },
2848
+ });
2849
+ }
2850
+ return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'no_profile' };
2851
+ }
2852
+ const taskPlan = this.qaTaskPlans?.get(task.task.key);
2853
+ const requestCommand = ctx.request.testCommand;
2854
+ const requestCommandIsUrl = this.isHttpUrl(requestCommand);
2855
+ const cliOverride = requestCommandIsUrl ? undefined : requestCommand;
2856
+ const browserOverride = requestCommandIsUrl ? normalizeQaUrl(requestCommand) : undefined;
2857
+ const qaEnv = applyQaHostDefaults(process.env);
2858
+ const apiRunner = new QaApiRunner(qaWorkspaceRoot);
2859
+ const explicitBaseUrl = normalizeQaUrl(qaEnv.MCODA_QA_API_BASE_URL ?? qaEnv.MCODA_API_BASE_URL ?? qaEnv.API_BASE_URL ?? qaEnv.BASE_URL);
2860
+ if (explicitBaseUrl && !qaEnv.MCODA_QA_API_BASE_URL) {
2861
+ qaEnv.MCODA_QA_API_BASE_URL = explicitBaseUrl;
2862
+ }
2863
+ const hasOpenApiSpec = await apiRunner.hasOpenApiSpec();
2864
+ const browserActions = [...(taskPlan?.browser?.actions ?? [])];
2865
+ const browserStressEntries = taskPlan?.stress?.browser ?? [];
2866
+ const browserStressConfigured = browserStressEntries.length;
2867
+ if (browserStressConfigured) {
2868
+ for (const stress of browserStressEntries) {
2869
+ if (stress?.type !== 'repeat' || !stress.action)
2870
+ continue;
2871
+ const count = Math.max(1, Math.round(stress.count ?? 1));
2872
+ for (let index = 0; index < count; index += 1) {
2873
+ browserActions.push({ ...stress.action });
2874
+ }
2875
+ }
2876
+ }
2877
+ const wantsChromium = browserActions.length > 0 || profiles.some((profile) => (profile.runner ?? 'cli') === 'chromium');
2878
+ const wantsCli = profiles.some((profile) => (profile.runner ?? 'cli') === 'cli');
2879
+ const explicitProfileName = ctx.request.profileName?.toLowerCase();
2880
+ const explicitApi = explicitProfileName === 'api';
2881
+ const apiProbeEnabled = Boolean(taskPlan?.api || detectApiTask(task.task) || explicitApi);
2882
+ let apiProbeRequests;
2883
+ if (taskPlan?.api?.requests?.length) {
2884
+ apiProbeRequests = [...taskPlan.api.requests];
2885
+ }
2886
+ else if (apiProbeEnabled || hasOpenApiSpec) {
2887
+ apiProbeRequests = await apiRunner.suggestDefaultRequests();
2888
+ }
2889
+ let resolvedApiBaseUrl;
2890
+ const resolveApiBaseUrl = async () => {
2891
+ if (resolvedApiBaseUrl !== undefined)
2892
+ return resolvedApiBaseUrl;
2893
+ if (explicitBaseUrl) {
2894
+ resolvedApiBaseUrl = explicitBaseUrl;
2895
+ return resolvedApiBaseUrl;
2896
+ }
2897
+ const inferred = await apiRunner.resolveBaseUrl({
2898
+ planBaseUrl: taskPlan?.api?.base_url,
2899
+ planBrowserBaseUrl: taskPlan?.browser?.base_url,
2900
+ env: qaEnv,
2901
+ probeRequests: apiProbeRequests,
2902
+ });
2903
+ const normalized = inferred ? normalizeQaUrl(inferred) : undefined;
2904
+ resolvedApiBaseUrl = normalized;
2905
+ return resolvedApiBaseUrl;
2906
+ };
2907
+ let allocatedBaseUrl;
2908
+ const allocateLocalBaseUrl = async (reason, options = {}) => {
2909
+ if (allocatedBaseUrl)
2910
+ return allocatedBaseUrl;
2911
+ const host = qaEnv.MCODA_QA_HOST ?? DEFAULT_QA_HOST;
2912
+ const envPort = options.ignoreEnvPort ? undefined : resolveEnvPort(qaEnv);
2913
+ const port = envPort ?? (await pickFreePort(host));
2914
+ applyQaPortDefaults(qaEnv, port);
2915
+ const baseUrl = `http://${host}:${port}`;
2916
+ qaEnv.MCODA_QA_API_BASE_URL = baseUrl;
2917
+ allocatedBaseUrl = baseUrl;
2918
+ await this.logTask(taskRun.id, `QA base URL set to ${baseUrl} (${reason}).`, 'qa-server', {
2919
+ baseUrl,
2920
+ reason,
2921
+ });
2922
+ return baseUrl;
2923
+ };
2924
+ let browserBaseUrl = normalizeQaUrl(browserOverride ?? taskPlan?.browser?.base_url ?? taskPlan?.api?.base_url);
2925
+ if (wantsChromium && !browserBaseUrl) {
2926
+ browserBaseUrl = (await resolveApiBaseUrl()) ?? (await allocateLocalBaseUrl('browser'));
2927
+ }
2928
+ if (wantsCli) {
2929
+ const baseUrlCandidate = browserBaseUrl ?? (await resolveApiBaseUrl());
2930
+ if (baseUrlCandidate && isLocalBaseUrl(baseUrlCandidate)) {
2931
+ const parsed = resolveUrlPort(baseUrlCandidate);
2932
+ if (parsed) {
2933
+ const envPort = resolveEnvPort(qaEnv);
2934
+ let desiredPort = envPort ?? parsed.port;
2935
+ let adjusted = false;
2936
+ let adjustReason;
2937
+ if (envPort && envPort !== parsed.port) {
2938
+ adjusted = true;
2939
+ adjustReason = 'env';
2940
+ }
2941
+ else {
2942
+ const probeOk = apiProbeRequests?.length
2943
+ ? await apiRunner.probeBaseUrl(baseUrlCandidate, apiProbeRequests)
2944
+ : undefined;
2945
+ const open = await isPortOpen(parsed.url.hostname, parsed.port);
2946
+ if (open && (!apiProbeRequests?.length || !probeOk)) {
2947
+ desiredPort = await pickFreePort(parsed.url.hostname);
2948
+ adjusted = true;
2949
+ adjustReason = 'in_use';
2950
+ }
2951
+ }
2952
+ if (desiredPort !== parsed.port) {
2953
+ parsed.url.port = String(desiredPort);
2954
+ adjusted = true;
2955
+ adjustReason = adjustReason ?? 'env';
2956
+ }
2957
+ applyQaPortDefaults(qaEnv, desiredPort);
2958
+ if (adjusted) {
2959
+ const adjustedBaseUrl = parsed.url.toString().replace(/\/$/, '');
2960
+ if (browserBaseUrl)
2961
+ browserBaseUrl = adjustedBaseUrl;
2962
+ qaEnv.MCODA_QA_API_BASE_URL = adjustedBaseUrl;
2963
+ const reasonLabel = adjustReason === 'env' ? 'env override' : 'port in use';
2964
+ await this.logTask(taskRun.id, `QA port ${parsed.port} -> ${desiredPort} (${reasonLabel}).`, 'qa-server', {
2965
+ baseUrl: baseUrlCandidate,
2966
+ adjustedBaseUrl,
2967
+ reason: adjustReason ?? 'unknown',
2968
+ });
2969
+ }
2970
+ }
2971
+ }
2972
+ }
2973
+ const adjustBaseUrlForPortConflict = async (baseUrl, reason, probeRequests) => {
2974
+ if (!baseUrl)
2975
+ return baseUrl;
2976
+ if (explicitBaseUrl)
2977
+ return baseUrl;
2978
+ if (reason === 'browser' && !probeRequests?.length)
2979
+ return baseUrl;
2980
+ if (!isLocalBaseUrl(baseUrl))
2981
+ return baseUrl;
2982
+ const parsed = resolveUrlPort(baseUrl);
2983
+ if (!parsed)
2984
+ return baseUrl;
2985
+ const open = await isPortOpen(parsed.url.hostname, parsed.port);
2986
+ if (!open)
2987
+ return baseUrl;
2988
+ if (probeRequests?.length) {
2989
+ const probeOk = await apiRunner.probeBaseUrl(baseUrl, probeRequests);
2990
+ if (probeOk)
2991
+ return baseUrl;
2992
+ }
2993
+ const adjustedBaseUrl = await allocateLocalBaseUrl(reason, { ignoreEnvPort: true });
2994
+ const reasonLabel = probeRequests?.length ? 'probe_mismatch' : 'port_in_use';
2995
+ await this.logTask(taskRun.id, `QA ${reason} base URL ${baseUrl} rejected (${reasonLabel}); using ${adjustedBaseUrl}.`, 'qa-server', { baseUrl, adjustedBaseUrl, reason: reasonLabel });
2996
+ return adjustedBaseUrl;
2997
+ };
2998
+ if (wantsChromium) {
2999
+ browserBaseUrl = await adjustBaseUrlForPortConflict(browserBaseUrl, 'browser', apiProbeRequests);
3000
+ }
3001
+ if (wantsChromium && browserActions.length === 0 && browserBaseUrl) {
3002
+ browserActions.push({ type: 'navigate', url: '/' }, {
3003
+ type: 'script',
3004
+ expression: "document.body ? 'ok' : ''",
3005
+ expect: 'ok',
3006
+ }, { type: 'snapshot', name: 'home' });
3007
+ }
3008
+ if (wantsChromium && browserActions.length > 0 && !browserStressConfigured) {
3009
+ const stressSeed = browserActions[0];
3010
+ browserActions.push({ ...stressSeed }, { ...stressSeed });
3011
+ }
3012
+ const baseCtx = {
3013
+ workspaceRoot: qaWorkspaceRoot,
3014
+ jobId: ctx.jobId,
3015
+ taskKey: task.task.key,
3016
+ env: qaEnv,
3017
+ };
3018
+ let serverBaseUrl;
3019
+ const serverTimeoutMs = resolveServerTimeoutMs();
3020
+ const ensureServerReady = async (baseUrl, reason, options = {}) => {
3021
+ if (!baseUrl)
3022
+ return { ok: true };
3023
+ if (!isLocalBaseUrl(baseUrl))
3024
+ return { ok: true };
3025
+ const reachable = await this.isUrlReachable(baseUrl, 1500);
3026
+ if (reachable)
3027
+ return { ok: true };
3028
+ if (!shouldAutoStartServer()) {
3029
+ const message = `QA ${reason} base URL ${baseUrl} is not reachable and auto-start is disabled.`;
3030
+ await this.logTask(taskRun.id, message, 'qa-server');
3031
+ return { ok: true, message };
3032
+ }
3033
+ if (serverHandle) {
3034
+ const message = `QA server already started for ${serverBaseUrl ?? 'unknown'}; ${baseUrl} is still unreachable.`;
3035
+ await this.logTask(taskRun.id, message, 'qa-server');
3036
+ return options.allowFailure ? { ok: true, message } : { ok: false, message };
3037
+ }
3038
+ const prep = await ensureQaPrepared();
3039
+ if (!prep.ok) {
3040
+ const message = prep.message ?? 'QA dependency install failed.';
3041
+ await this.logTask(taskRun.id, message, 'qa-install');
3042
+ return options.allowFailure ? { ok: true, message } : { ok: false, message };
3043
+ }
3044
+ const handle = await this.startQaServer({
3045
+ workspaceRoot: qaWorkspaceRoot,
3046
+ baseUrl,
3047
+ env: qaEnv,
3048
+ jobId: ctx.jobId,
3049
+ taskKey: task.task.key,
3050
+ });
3051
+ if (!handle) {
3052
+ const message = `QA ${reason} base URL ${baseUrl} is unreachable and no dev server script (dev/start/serve) was found.`;
3053
+ await this.logTask(taskRun.id, message, 'qa-server');
3054
+ return options.allowFailure ? { ok: true, message } : { ok: false, message };
3055
+ }
3056
+ serverHandle = handle;
3057
+ serverBaseUrl = baseUrl;
3058
+ await this.logTask(taskRun.id, `Starting QA server: ${handle.command}`, 'qa-server', {
3059
+ baseUrl,
3060
+ logPath: handle.logPath,
3061
+ });
3062
+ if (serverTimeoutMs <= 0) {
3063
+ const message = `QA server wait disabled; continuing without readiness check for ${baseUrl}.`;
3064
+ await this.logTask(taskRun.id, message, 'qa-server', { baseUrl, timeoutMs: serverTimeoutMs });
3065
+ console.info(`[qa-tasks] ${message}`);
3066
+ return { ok: true, message };
3067
+ }
3068
+ console.info(`[qa-tasks] waiting for QA server at ${baseUrl} (timeout ${serverTimeoutMs}ms).`);
3069
+ const ready = await this.waitForUrlReady(baseUrl, serverTimeoutMs);
3070
+ if (!ready) {
3071
+ const message = `QA server did not become ready at ${baseUrl} after ${serverTimeoutMs}ms.`;
3072
+ await this.logTask(taskRun.id, message, 'qa-server', { baseUrl, timeoutMs: serverTimeoutMs });
3073
+ return options.allowFailure ? { ok: true, message } : { ok: false, message };
3074
+ }
3075
+ return { ok: true };
3076
+ };
3077
+ const runs = [];
3078
+ const runSummaries = [];
3079
+ const artifactSet = new Set();
3080
+ const buildInfraResult = (message) => {
3081
+ const now = new Date().toISOString();
3082
+ return {
3083
+ outcome: 'infra_issue',
3084
+ exitCode: null,
3085
+ stdout: '',
3086
+ stderr: message,
3087
+ artifacts: [],
3088
+ startedAt: now,
3089
+ finishedAt: now,
3090
+ };
3091
+ };
3092
+ for (const profile of profiles) {
3093
+ const runner = profile.runner ?? 'cli';
3094
+ await this.logTask(taskRun.id, `Running QA profile ${profile.name} (${runner})`, 'qa-profile');
3095
+ const adapter = this.adapterForProfile(profile);
3096
+ if (!adapter) {
3097
+ const message = `No QA adapter for profile ${profile.name} (${runner})`;
3098
+ await this.logTask(taskRun.id, message, 'qa-adapter');
3099
+ runs.push({ profile, runner, result: buildInfraResult(message) });
3100
+ runSummaries.push(`- ${profile.name} (${runner}) infra_issue (no_adapter)`);
3101
+ continue;
3102
+ }
3103
+ let testCommand = undefined;
3104
+ let cliCommands = [];
3105
+ if (runner === 'cli') {
3106
+ const planCommands = (taskPlan?.cli?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean);
3107
+ const commandBuilder = new QaTestCommandBuilder(qaWorkspaceRoot);
3108
+ const commandPlan = await commandBuilder.build({
3109
+ task: task.task,
3110
+ planCommands,
3111
+ cliOverride,
3112
+ profileCommand: profile.test_command,
3113
+ });
3114
+ const dedupedCommands = new Set();
3115
+ cliCommands = commandPlan.commands.filter((cmd) => {
3116
+ const normalized = cmd.trim();
3117
+ if (!normalized)
3118
+ return false;
3119
+ if (dedupedCommands.has(normalized))
3120
+ return false;
3121
+ dedupedCommands.add(normalized);
3122
+ return true;
3123
+ });
3124
+ if (!cliOverride) {
3125
+ const checklist = await this.resolveCliChecklistCommands({
3126
+ workspaceRoot: qaWorkspaceRoot,
3127
+ task: task.task,
3128
+ existing: cliCommands,
3129
+ });
3130
+ if (checklist.length) {
3131
+ cliCommands = [...cliCommands, ...checklist];
3132
+ }
3133
+ }
3134
+ cliCommands = this.softenOptionalNpmScripts(cliCommands);
3135
+ const chromiumPrep = await this.applyChromiumForCli(qaEnv, cliCommands);
3136
+ if (!chromiumPrep.ok) {
3137
+ const message = chromiumPrep.message ?? 'Chromium preflight failed.';
3138
+ await this.logTask(taskRun.id, message, 'qa-preflight');
3139
+ const failedCommand = cliCommands.length ? cliCommands.join(' && ') : undefined;
3140
+ runs.push({ profile, runner, testCommand: failedCommand, result: buildInfraResult(message) });
3141
+ runSummaries.push(`- ${profile.name} (${runner}) infra_issue (chromium_preflight)`);
3142
+ continue;
3143
+ }
3144
+ cliCommands = chromiumPrep.commands;
3145
+ testCommand = cliCommands.length ? cliCommands.join(' && ') : undefined;
3146
+ const preflight = await this.checkQaPreflight(testCommand, qaWorkspaceRoot);
3147
+ if (!preflight.ok) {
3148
+ const message = preflight.message ?? 'QA preflight failed.';
3149
+ await this.logTask(taskRun.id, message, 'qa-preflight', {
3150
+ missingDeps: preflight.missingDeps,
3151
+ missingEnv: preflight.missingEnv,
3152
+ });
3153
+ runs.push({ profile, runner, testCommand, result: buildInfraResult(message) });
3154
+ runSummaries.push(`- ${profile.name} (${runner}) infra_issue (preflight)`);
3155
+ continue;
3156
+ }
3157
+ }
3158
+ const runCtx = {
3159
+ ...baseCtx,
3160
+ commands: runner === 'cli' && cliCommands.length > 1 ? cliCommands : undefined,
3161
+ testCommandOverride: runner === 'cli'
3162
+ ? cliCommands.length === 1
3163
+ ? cliCommands[0]
3164
+ : undefined
3165
+ : undefined,
3166
+ browserActions: runner === 'chromium' && browserActions.length ? browserActions : undefined,
3167
+ browserBaseUrl: runner === 'chromium' ? browserBaseUrl : undefined,
3168
+ };
3169
+ let ensure;
3170
+ try {
3171
+ ensure = await adapter.ensureInstalled(profile, runCtx);
3172
+ }
3173
+ catch (error) {
3174
+ ensure = { ok: false, message: error?.message ?? String(error) };
3175
+ }
3176
+ if (!ensure.ok) {
3177
+ const installMessage = ensure.message ?? 'QA install failed';
3178
+ const guidance = profile.runner === 'chromium'
3179
+ ? 'Install Docdex Chromium (docdex setup or MCODA_QA_CHROMIUM_PATH).'
3180
+ : undefined;
3181
+ const installLower = installMessage.toLowerCase();
3182
+ const alreadyGuided = installLower.includes('chromium') ||
3183
+ installLower.includes('docdex') ||
3184
+ installLower.includes('test_command');
3185
+ const installMessageWithGuidance = guidance && !alreadyGuided ? `${installMessage} ${guidance}` : installMessage;
3186
+ await this.logTask(taskRun.id, installMessageWithGuidance, 'qa-install');
3187
+ runs.push({ profile, runner, testCommand, result: buildInfraResult(installMessageWithGuidance) });
3188
+ runSummaries.push(`- ${profile.name} (${runner}) infra_issue (install)`);
3189
+ continue;
3190
+ }
3191
+ if (runner === 'chromium') {
3192
+ const serverCheck = await ensureServerReady(browserBaseUrl, 'browser', { allowFailure: true });
3193
+ if (!serverCheck.ok) {
3194
+ const message = serverCheck.message ?? `QA server not ready for ${browserBaseUrl ?? 'browser QA'}.`;
3195
+ runs.push({ profile, runner, testCommand, result: buildInfraResult(message) });
3196
+ runSummaries.push(`- ${profile.name} (${runner}) infra_issue (server_unavailable)`);
3197
+ continue;
3198
+ }
3199
+ }
3200
+ const profileDir = profile.name.replace(/[\\/]/g, '_');
3201
+ const artifactDir = path.join(this.workspace.mcodaDir, 'jobs', ctx.jobId, 'qa', task.task.key, profileDir);
3202
+ await PathHelper.ensureDir(artifactDir);
3203
+ const runEnv = { ...runCtx.env };
3204
+ let result = await adapter.invoke(profile, { ...runCtx, env: runEnv, artifactDir });
3205
+ let markerStatus;
3206
+ if (runner === 'cli') {
3207
+ const adjusted = this.adjustOutcomeForSkippedTests(profile, result, testCommand);
3208
+ result = adjusted.result;
3209
+ markerStatus = adjusted.markerStatus;
3210
+ if (markerStatus) {
3211
+ const statusLabel = markerStatus.present ? 'present' : 'missing';
3212
+ await this.logTask(taskRun.id, `Run-all marker ${statusLabel} for ${profile.name} (policy=${markerStatus.policy}, action=${markerStatus.action}).`, 'qa-marker', {
3213
+ policy: markerStatus.policy,
3214
+ status: statusLabel,
3215
+ action: markerStatus.action,
3216
+ marker: RUN_ALL_TESTS_MARKER,
3217
+ profile: profile.name,
3218
+ });
3219
+ }
3220
+ }
3221
+ const command = runner === 'chromium'
3222
+ ? browserBaseUrl ?? profile.test_command
3223
+ : cliCommands.length > 1
3224
+ ? cliCommands.join(' && ')
3225
+ : cliCommands[0] ?? testCommand ?? profile.test_command;
3226
+ runs.push({
3227
+ profile,
3228
+ runner,
3229
+ testCommand,
3230
+ command,
3231
+ result,
3232
+ markerStatus,
3233
+ });
3234
+ for (const artifact of result.artifacts ?? []) {
3235
+ artifactSet.add(artifact);
3236
+ }
3237
+ runSummaries.push(`- ${profile.name} (${runner}) outcome=${result.outcome} exit=${result.exitCode ?? 'null'}${command ? ` cmd=${command}` : ''}`);
591
3238
  }
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(),
3239
+ let apiRequests = [...(taskPlan?.api?.requests ?? [])];
3240
+ if (taskPlan?.stress?.api?.length) {
3241
+ for (const stress of taskPlan.stress.api) {
3242
+ if (stress?.type !== 'burst' || !stress.request)
3243
+ continue;
3244
+ const count = Math.max(1, Math.round(stress.count ?? 1));
3245
+ for (let index = 0; index < count; index += 1) {
3246
+ apiRequests.push({
3247
+ ...stress.request,
3248
+ id: stress.request.id ? `${stress.request.id}-${index + 1}` : `stress-${index + 1}`,
3249
+ });
3250
+ }
3251
+ }
3252
+ }
3253
+ const allowApiRunner = !explicitProfileName || explicitApi;
3254
+ const apiPlanRequested = taskPlan?.api !== undefined;
3255
+ const apiTaskHint = detectApiTask(task.task);
3256
+ if (allowApiRunner) {
3257
+ const shouldRunApiFallback = apiRequests.length === 0 && (apiPlanRequested || apiTaskHint || explicitApi);
3258
+ if (shouldRunApiFallback) {
3259
+ if (hasOpenApiSpec || apiPlanRequested || apiTaskHint || explicitApi) {
3260
+ apiRequests = await apiRunner.suggestDefaultRequests();
3261
+ }
3262
+ }
3263
+ }
3264
+ if (allowApiRunner && apiRequests.length) {
3265
+ let baseUrl = await apiRunner.resolveBaseUrl({
3266
+ planBaseUrl: taskPlan?.api?.base_url,
3267
+ planBrowserBaseUrl: taskPlan?.browser?.base_url,
3268
+ env: baseCtx.env,
3269
+ probeRequests: apiRequests,
619
3270
  });
3271
+ if (!baseUrl && shouldAutoStartServer()) {
3272
+ baseUrl = await allocateLocalBaseUrl('api');
3273
+ }
3274
+ if (baseUrl) {
3275
+ baseUrl = await adjustBaseUrlForPortConflict(baseUrl, 'api', apiRequests);
3276
+ }
3277
+ if (!baseUrl) {
3278
+ const message = shouldAutoStartServer()
3279
+ ? 'QA API base URL could not be resolved.'
3280
+ : 'QA API base URL is missing and auto-start is disabled.';
3281
+ const apiProfile = { name: 'api', runner: 'api' };
3282
+ runs.push({ profile: apiProfile, runner: 'api', command: 'unknown', result: buildInfraResult(message) });
3283
+ runSummaries.push(`- api (api) infra_issue (no_base_url)`);
3284
+ await this.logTask(taskRun.id, message, 'qa-server');
3285
+ }
3286
+ else {
3287
+ const apiProfile = { name: 'api', runner: 'api' };
3288
+ const serverCheck = await ensureServerReady(baseUrl, 'api');
3289
+ if (!serverCheck.ok) {
3290
+ const message = serverCheck.message ?? `QA server not ready for ${baseUrl}.`;
3291
+ const apiResult = buildInfraResult(message);
3292
+ runs.push({
3293
+ profile: apiProfile,
3294
+ runner: 'api',
3295
+ command: `${baseUrl} (${apiRequests.length} requests)`,
3296
+ result: apiResult,
3297
+ });
3298
+ runSummaries.push(`- api (api) infra_issue (server_unavailable) cmd=${baseUrl}`);
3299
+ }
3300
+ else {
3301
+ const apiArtifactDir = path.join(this.workspace.mcodaDir, 'jobs', ctx.jobId, 'qa', task.task.key, 'api');
3302
+ await PathHelper.ensureDir(apiArtifactDir);
3303
+ const apiResult = await apiRunner.run({
3304
+ baseUrl,
3305
+ requests: apiRequests,
3306
+ env: baseCtx.env,
3307
+ artifactDir: apiArtifactDir,
3308
+ });
3309
+ runs.push({
3310
+ profile: apiProfile,
3311
+ runner: 'api',
3312
+ command: `${baseUrl} (${apiRequests.length} requests)`,
3313
+ result: apiResult,
3314
+ });
3315
+ for (const artifact of apiResult.artifacts ?? []) {
3316
+ artifactSet.add(artifact);
3317
+ }
3318
+ runSummaries.push(`- api (api) outcome=${apiResult.outcome} exit=${apiResult.exitCode ?? 'null'} cmd=${baseUrl}`);
3319
+ }
3320
+ }
620
3321
  }
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,
3322
+ else if (!allowApiRunner && (apiRequests.length || apiPlanRequested || apiTaskHint)) {
3323
+ await this.logTask(taskRun.id, `Skipping API checks because profile=${ctx.request.profileName} was explicitly requested.`, 'qa-api', { profile: ctx.request.profileName });
3324
+ }
3325
+ if (!runs.length) {
3326
+ await this.logTask(taskRun.id, 'No QA runs executed', 'qa-adapter');
3327
+ await this.finishTaskRun(taskRun, 'failed');
3328
+ if (!this.dryRunGuard) {
3329
+ await this.deps.workspaceRepo.createTaskQaRun({
3330
+ taskId: task.task.id,
3331
+ taskRunId: taskRun.id,
3332
+ jobId: ctx.jobId,
3333
+ commandRunId: ctx.commandRunId,
3334
+ source: 'auto',
3335
+ mode: 'auto',
3336
+ rawOutcome: 'infra_issue',
3337
+ recommendation: 'infra_issue',
3338
+ metadata: { reason: 'no_runs' },
3339
+ });
3340
+ }
3341
+ return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'no_runs' };
3342
+ }
3343
+ const combinedOutcome = runs.some((run) => run.result.outcome === 'infra_issue')
3344
+ ? 'infra_issue'
3345
+ : runs.some((run) => run.result.outcome === 'fail')
3346
+ ? 'fail'
3347
+ : 'pass';
3348
+ const combinedExitCode = combinedOutcome === 'infra_issue'
3349
+ ? null
3350
+ : combinedOutcome === 'pass'
3351
+ ? 0
3352
+ : runs.find((run) => typeof run.result.exitCode === 'number' && run.result.exitCode !== 0)?.result.exitCode ?? 1;
3353
+ const combineOutput = (field) => runs
3354
+ .map((run) => {
3355
+ const header = `=== ${run.profile.name} (${run.runner}) outcome=${run.result.outcome} exit=${run.result.exitCode ?? 'null'}${run.command ? ` cmd=${run.command}` : ''} ===`;
3356
+ const body = field === 'stdout' ? run.result.stdout : run.result.stderr;
3357
+ return [header, body].filter(Boolean).join('\n');
3358
+ })
3359
+ .join('\n\n');
3360
+ const startedAt = runs.reduce((min, run) => (run.result.startedAt < min ? run.result.startedAt : min), runs[0].result.startedAt);
3361
+ const finishedAt = runs.reduce((max, run) => (run.result.finishedAt > max ? run.result.finishedAt : max), runs[0].result.finishedAt);
3362
+ const result = {
3363
+ outcome: combinedOutcome,
3364
+ exitCode: combinedExitCode,
3365
+ stdout: combineOutput('stdout'),
3366
+ stderr: combineOutput('stderr'),
3367
+ artifacts: Array.from(artifactSet),
3368
+ startedAt,
3369
+ finishedAt,
3370
+ };
3371
+ const profile = runs.length === 1 ? runs[0].profile : { name: 'auto', runner: 'multi' };
3372
+ const runSummary = runSummaries.length ? runSummaries.join('\n') : undefined;
3373
+ await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
3374
+ exitCode: result.exitCode,
3375
+ runs: runs.length,
628
3376
  });
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
- });
3377
+ const commentContext = await this.loadCommentContext(task.task.id);
3378
+ const commentBacklog = buildCommentBacklog(commentContext.unresolved);
3379
+ let interpretation;
3380
+ try {
3381
+ if (this.shouldUseAgentInterpretation()) {
3382
+ interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id, commentBacklog, ctx.request.abortSignal);
3383
+ }
3384
+ else {
3385
+ interpretation = this.buildDeterministicInterpretation(task, profile, result);
3386
+ if (taskRun.id) {
3387
+ await this.logTask(taskRun.id, 'QA agent interpretation disabled; using runner outcome only.', 'qa-agent');
3388
+ }
3389
+ }
645
3390
  }
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
- });
3391
+ catch (error) {
3392
+ const message = error?.message ?? String(error);
3393
+ if (isAuthErrorMessage(message) && !this.dryRunGuard) {
3394
+ await this.stateService.markFailed(task.task, AUTH_ERROR_REASON, statusContextBase);
3395
+ await this.finishTaskRun(taskRun, 'failed');
3396
+ }
3397
+ throw error;
663
3398
  }
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');
3399
+ const invalidJson = interpretation.invalidJson === true;
3400
+ const outcome = invalidJson ? 'unclear' : this.combineOutcome(result, interpretation.recommendation);
3401
+ const artifacts = result.artifacts ?? [];
3402
+ const commentResolution = await this.applyCommentResolutions({
3403
+ task: task.task,
3404
+ taskRunId: taskRun.id,
3405
+ jobId: ctx.jobId,
3406
+ agentId: interpretation.agentId,
3407
+ failures: interpretation.failures ?? [],
3408
+ resolvedSlugs: interpretation.resolvedSlugs,
3409
+ unresolvedSlugs: interpretation.unresolvedSlugs,
3410
+ existingComments: commentContext.comments,
3411
+ });
3412
+ let qaRun;
670
3413
  if (!this.dryRunGuard) {
671
- await this.deps.workspaceRepo.createTaskQaRun({
3414
+ qaRun = await this.deps.workspaceRepo.createTaskQaRun({
672
3415
  taskId: task.task.id,
673
3416
  taskRunId: taskRun.id,
674
3417
  jobId: ctx.jobId,
675
3418
  commandRunId: ctx.commandRunId,
3419
+ agentId: interpretation.agentId,
3420
+ modelName: interpretation.modelName,
676
3421
  source: 'auto',
677
3422
  mode: 'auto',
678
3423
  profileName: profile.name,
679
3424
  runner: profile.runner,
680
- rawOutcome: 'infra_issue',
681
- recommendation: 'infra_issue',
682
- metadata: { reason: 'no_adapter' },
3425
+ rawOutcome: result.outcome,
3426
+ recommendation: interpretation.recommendation,
3427
+ artifacts,
3428
+ rawResult: {
3429
+ adapter: result,
3430
+ adapterRuns: runs.map((run) => ({
3431
+ profile: run.profile.name,
3432
+ runner: run.runner,
3433
+ command: run.command,
3434
+ testCommand: run.testCommand,
3435
+ result: run.result,
3436
+ })),
3437
+ agent: interpretation.rawOutput,
3438
+ },
3439
+ startedAt: result.startedAt,
3440
+ finishedAt: result.finishedAt,
3441
+ metadata: {
3442
+ tokensPrompt: interpretation.tokensPrompt,
3443
+ tokensCompletion: interpretation.tokensCompletion,
3444
+ testedScope: interpretation.testedScope,
3445
+ coverageSummary: interpretation.coverageSummary,
3446
+ failures: interpretation.failures,
3447
+ invalidJson: interpretation.invalidJson ?? false,
3448
+ runSummary,
3449
+ runProfiles: runs.map((run) => run.profile.name),
3450
+ runCount: runs.length,
3451
+ },
683
3452
  });
684
3453
  }
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
3454
  if (!this.dryRunGuard) {
698
- await this.applyStateTransition(task.task, 'infra_issue');
699
- await this.finishTaskRun(taskRun, 'failed');
700
- await this.deps.workspaceRepo.createTaskQaRun({
3455
+ const statusContext = {
3456
+ ...statusContextBase,
3457
+ agentId: interpretation.agentId ?? undefined,
3458
+ };
3459
+ await this.applyStateTransition(task.task, outcome, statusContext, invalidJson ? { qa_failure_reason: 'qa_invalid_output', qa_invalid_output: true } : undefined);
3460
+ await this.finishTaskRun(taskRun, invalidJson ? 'failed' : outcome === 'pass' ? 'succeeded' : 'failed');
3461
+ }
3462
+ const existingSlugs = new Set(commentContext.comments.map((comment) => comment.slug).filter((slug) => Boolean(slug)));
3463
+ const fallbackFailures = outcome !== 'pass' && (!interpretation.failures || interpretation.failures.length === 0);
3464
+ if (!this.dryRunGuard && fallbackFailures) {
3465
+ let created = 0;
3466
+ try {
3467
+ created = await this.createRunnerFailureComments({
3468
+ task: task.task,
3469
+ taskRunId: taskRun.id,
3470
+ jobId: ctx.jobId,
3471
+ outcome,
3472
+ result,
3473
+ runs,
3474
+ workspaceRoot: qaWorkspaceRoot,
3475
+ runSummary,
3476
+ existingSlugs,
3477
+ });
3478
+ }
3479
+ catch {
3480
+ // fall through to summary comment
3481
+ }
3482
+ if (created === 0) {
3483
+ let message = `QA ${outcome} based on runner output. Review QA logs/artifacts for details.`;
3484
+ try {
3485
+ message = await this.buildRunnerFailureMessage({
3486
+ outcome,
3487
+ result,
3488
+ runs,
3489
+ runSummary,
3490
+ workspaceRoot: qaWorkspaceRoot,
3491
+ });
3492
+ }
3493
+ catch {
3494
+ // fallback to default message if summary build fails
3495
+ }
3496
+ await this.createQaComment({
3497
+ task: task.task,
3498
+ taskRunId: taskRun.id,
3499
+ jobId: ctx.jobId,
3500
+ message,
3501
+ category: 'qa_issue',
3502
+ status: 'open',
3503
+ metadata: { reason: 'qa_runner_failure', outcome, runSummary },
3504
+ });
3505
+ }
3506
+ }
3507
+ const followups = [];
3508
+ const wantsFollowups = ctx.request.createFollowupTasks !== 'none';
3509
+ const needsManualFollowup = interpretation.invalidJson === true;
3510
+ if ((outcome === 'fix_required' || needsManualFollowup) && wantsFollowups) {
3511
+ const suggestions = interpretation.followUps?.map((f) => this.toFollowupSuggestion(task.task, f, artifacts)) ?? [];
3512
+ if (needsManualFollowup) {
3513
+ suggestions.unshift(this.buildManualQaFollowup(task.task, interpretation.rawOutput));
3514
+ }
3515
+ else if (suggestions.length === 0) {
3516
+ suggestions.push(this.buildFollowupSuggestion(task.task, result, ctx.request.notes));
3517
+ }
3518
+ const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
3519
+ for (const suggestion of suggestions) {
3520
+ const followupSlug = this.buildFollowupSlug(task.task, suggestion);
3521
+ const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
3522
+ if (existing.length) {
3523
+ await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
3524
+ continue;
3525
+ }
3526
+ let proceed = ctx.request.createFollowupTasks !== 'prompt';
3527
+ if (interactive) {
3528
+ const rl = readline.createInterface({ input, output });
3529
+ const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
3530
+ rl.close();
3531
+ proceed = answer.trim().toLowerCase().startsWith('y');
3532
+ }
3533
+ if (!proceed)
3534
+ continue;
3535
+ try {
3536
+ if (!this.dryRunGuard) {
3537
+ const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
3538
+ followups.push(created.task.key);
3539
+ await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
3540
+ }
3541
+ }
3542
+ catch (error) {
3543
+ await this.logTask(taskRun.id, `Failed to create follow-up task: ${error?.message ?? error}`, 'qa-followup');
3544
+ }
3545
+ }
3546
+ }
3547
+ const bodyLines = [
3548
+ `QA outcome: ${outcome}`,
3549
+ outcome === 'unclear'
3550
+ ? 'QA outcome unclear: provide missing acceptance criteria, reproduction steps, and expected behavior.'
3551
+ : '',
3552
+ profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
3553
+ runSummary ? `Runs:\n${runSummary}` : '',
3554
+ interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
3555
+ interpretation.failures && interpretation.failures.length
3556
+ ? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
3557
+ : '',
3558
+ commentResolution
3559
+ ? `Comment slugs: resolved ${commentResolution.resolved.length}, reopened ${commentResolution.reopened.length}, open ${commentResolution.open.length}`
3560
+ : '',
3561
+ interpretation.invalidJson
3562
+ ? 'QA agent output invalid; task needs follow-up (qa_invalid_output).'
3563
+ : '',
3564
+ interpretation.invalidJson && interpretation.rawOutput
3565
+ ? `QA agent output (invalid JSON):\n${interpretation.rawOutput.slice(0, 4000)}`
3566
+ : '',
3567
+ result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
3568
+ result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
3569
+ artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
3570
+ followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
3571
+ ].filter(Boolean);
3572
+ if (!this.dryRunGuard) {
3573
+ const category = outcome === 'pass' ? 'qa_result' : 'qa_issue';
3574
+ const summaryMessage = bodyLines.join('\n\n');
3575
+ const summarySlug = createTaskCommentSlug({
3576
+ source: 'qa-tasks',
3577
+ message: summaryMessage,
3578
+ category,
3579
+ });
3580
+ const status = outcome === 'pass' ? 'resolved' : 'open';
3581
+ const summaryBody = formatTaskCommentBody({
3582
+ slug: summarySlug,
3583
+ source: 'qa-tasks',
3584
+ message: summaryMessage,
3585
+ status,
3586
+ category,
3587
+ });
3588
+ const createdAt = new Date().toISOString();
3589
+ await this.deps.workspaceRepo.createTaskComment({
701
3590
  taskId: task.task.id,
702
3591
  taskRunId: taskRun.id,
703
3592
  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 },
3593
+ sourceCommand: 'qa-tasks',
3594
+ authorType: 'agent',
3595
+ authorAgentId: interpretation.agentId ?? null,
3596
+ category,
3597
+ slug: summarySlug,
3598
+ status,
3599
+ body: summaryBody,
3600
+ createdAt,
3601
+ resolvedAt: status === 'resolved' ? createdAt : null,
3602
+ resolvedBy: status === 'resolved' ? interpretation.agentId ?? null : null,
3603
+ metadata: {
3604
+ ...(artifacts.length ? { artifacts } : {}),
3605
+ ...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
3606
+ },
712
3607
  });
713
3608
  }
3609
+ const ratingTokens = (interpretation.tokensPrompt ?? 0) + (interpretation.tokensCompletion ?? 0);
3610
+ if (ctx.request.rateAgents && interpretation.agentId && ratingTokens > 0) {
3611
+ try {
3612
+ const ratingService = this.ensureRatingService();
3613
+ await ratingService.rate({
3614
+ workspace: this.workspace,
3615
+ agentId: interpretation.agentId,
3616
+ commandName: 'qa-tasks',
3617
+ jobId: ctx.jobId,
3618
+ commandRunId: ctx.commandRunId,
3619
+ taskId: task.task.id,
3620
+ taskKey: task.task.key,
3621
+ discipline: task.task.type ?? undefined,
3622
+ complexity: this.resolveTaskComplexity(task.task),
3623
+ });
3624
+ }
3625
+ catch (error) {
3626
+ const message = `Agent rating failed for ${task.task.key}: ${error instanceof Error ? error.message : String(error)}`;
3627
+ ctx.warnings?.push(message);
3628
+ try {
3629
+ await this.logTask(taskRun.id, message, 'rating');
3630
+ }
3631
+ catch {
3632
+ /* ignore rating log failures */
3633
+ }
3634
+ }
3635
+ }
714
3636
  return {
715
3637
  taskKey: task.task.key,
716
- outcome: 'infra_issue',
3638
+ outcome,
717
3639
  profile: profile.name,
718
3640
  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
3641
  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');
3642
+ followups,
3643
+ };
765
3644
  }
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');
3645
+ finally {
3646
+ if (serverHandle) {
3647
+ try {
3648
+ await serverHandle.stop();
3649
+ await this.logTask(taskRun.id, 'QA server stopped.', 'qa-server');
780
3650
  }
781
- if (!proceed)
782
- continue;
3651
+ catch (error) {
3652
+ await this.logTask(taskRun.id, `QA server shutdown failed: ${error?.message ?? error}`, 'qa-server');
3653
+ }
3654
+ }
3655
+ if (cleanupWorktree) {
783
3656
  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
- }
3657
+ await cleanupWorktree();
789
3658
  }
790
3659
  catch (error) {
791
- await this.logTask(taskRun.id, `Failed to create follow-up task: ${error?.message ?? error}`, 'qa-followup');
3660
+ await this.logTask(taskRun.id, `QA cleanup failed: ${error?.message ?? error}`, 'qa-cleanup');
792
3661
  }
793
3662
  }
794
3663
  }
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
3664
  }
832
3665
  async runManual(task, ctx) {
833
3666
  const taskRun = await this.createTaskRun(task.task, ctx.jobId, ctx.commandRunId);
3667
+ const statusContext = {
3668
+ commandName: 'qa-tasks',
3669
+ jobId: ctx.jobId,
3670
+ taskRunId: taskRun.id,
3671
+ metadata: { lane: 'qa' },
3672
+ };
834
3673
  const result = ctx.request.result ?? 'pass';
835
3674
  const notes = ctx.request.notes;
836
- const outcome = result === 'pass' ? 'pass' : result === 'blocked' ? 'infra_issue' : 'fix_required';
3675
+ const outcome = result === 'pass' ? 'pass' : 'fix_required';
837
3676
  const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
838
3677
  if (task.task.status && !allowedStatuses.has(task.task.status)) {
839
3678
  const message = `Task status ${task.task.status} not allowed for manual QA`;
@@ -842,7 +3681,7 @@ export class QaTasksService {
842
3681
  return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
843
3682
  }
844
3683
  if (!ctx.request.dryRun) {
845
- await this.applyStateTransition(task.task, outcome);
3684
+ await this.applyStateTransition(task.task, outcome, statusContext);
846
3685
  await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
847
3686
  }
848
3687
  const followups = [];
@@ -875,12 +3714,20 @@ export class QaTasksService {
875
3714
  evidenceUrl: ctx.request.evidenceUrl,
876
3715
  },
877
3716
  ];
878
- const agentSuggestions = await this.suggestFollowupsFromAgent(task, notes, ctx.request.evidenceUrl, 'manual', ctx.jobId, ctx.commandRunId, taskRun.id);
3717
+ const agentSuggestions = this.shouldUseAgentInterpretation()
3718
+ ? await this.suggestFollowupsFromAgent(task, notes, ctx.request.evidenceUrl, 'manual', ctx.jobId, ctx.commandRunId, taskRun.id)
3719
+ : [];
879
3720
  if (agentSuggestions.length) {
880
3721
  suggestions.unshift(...agentSuggestions);
881
3722
  }
882
3723
  const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
883
3724
  for (const suggestion of suggestions) {
3725
+ const followupSlug = this.buildFollowupSlug(task.task, suggestion);
3726
+ const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
3727
+ if (existing.length) {
3728
+ await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
3729
+ continue;
3730
+ }
884
3731
  let proceed = ctx.request.createFollowupTasks === 'auto' || ctx.request.createFollowupTasks === undefined;
885
3732
  if (interactive) {
886
3733
  const rl = readline.createInterface({ input, output });
@@ -891,7 +3738,7 @@ export class QaTasksService {
891
3738
  if (!proceed)
892
3739
  continue;
893
3740
  try {
894
- const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
3741
+ const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
895
3742
  followups.push(created.task.key);
896
3743
  }
897
3744
  catch (error) {
@@ -909,15 +3756,30 @@ export class QaTasksService {
909
3756
  .filter(Boolean)
910
3757
  .join('\n');
911
3758
  if (!ctx.request.dryRun) {
3759
+ const category = result === 'pass' ? 'qa_result' : 'qa_issue';
3760
+ const status = result === 'pass' ? 'resolved' : 'open';
3761
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message: body, category });
3762
+ const formattedBody = formatTaskCommentBody({
3763
+ slug,
3764
+ source: 'qa-tasks',
3765
+ message: body,
3766
+ status,
3767
+ category,
3768
+ });
3769
+ const createdAt = new Date().toISOString();
912
3770
  await this.deps.workspaceRepo.createTaskComment({
913
3771
  taskId: task.task.id,
914
3772
  taskRunId: taskRun.id,
915
3773
  jobId: ctx.jobId,
916
3774
  sourceCommand: 'qa-tasks',
917
3775
  authorType: 'human',
918
- category: result === 'pass' ? 'qa_result' : 'qa_issue',
919
- body,
920
- createdAt: new Date().toISOString(),
3776
+ category,
3777
+ slug,
3778
+ status,
3779
+ body: formattedBody,
3780
+ createdAt,
3781
+ resolvedAt: status === 'resolved' ? createdAt : null,
3782
+ resolvedBy: status === 'resolved' ? 'human' : null,
921
3783
  metadata: {
922
3784
  ...(ctx.request.evidenceUrl ? { evidence: ctx.request.evidenceUrl } : {}),
923
3785
  ...(artifacts.length ? { artifacts } : {}),
@@ -933,6 +3795,8 @@ export class QaTasksService {
933
3795
  };
934
3796
  }
935
3797
  async run(request) {
3798
+ this.qaProfilePlan = undefined;
3799
+ this.qaTaskPlans = undefined;
936
3800
  const resume = request.resumeJobId ? await this.deps.jobService.getJob(request.resumeJobId) : undefined;
937
3801
  if (request.resumeJobId && !resume) {
938
3802
  throw new Error(`Resume requested but job ${request.resumeJobId} not found`);
@@ -942,33 +3806,67 @@ export class QaTasksService {
942
3806
  const effectiveStory = request.storyKey ?? resume?.payload?.storyKey;
943
3807
  const effectiveTasks = request.taskKeys?.length ? request.taskKeys : resume?.payload?.tasks;
944
3808
  const effectiveStatus = request.statusFilter ?? resume?.payload?.statusFilter ?? ['ready_to_qa'];
3809
+ const effectiveLimit = request.limit ?? resume?.payload?.limit;
3810
+ const ignoreStatusFilter = Boolean(effectiveTasks?.length) || request.ignoreStatusFilter === true;
3811
+ const { filtered: statusFilter, rejected } = filterTaskStatuses(ignoreStatusFilter ? [] : effectiveStatus, QA_ALLOWED_STATUSES, QA_ALLOWED_STATUSES);
945
3812
  const selection = await this.selectionService.selectTasks({
946
3813
  projectKey: effectiveProject,
947
3814
  epicKey: effectiveEpic,
948
3815
  storyKey: effectiveStory,
949
3816
  taskKeys: effectiveTasks,
950
- statusFilter: effectiveStatus,
3817
+ statusFilter,
3818
+ limit: effectiveLimit,
3819
+ ignoreDependencies: true,
3820
+ ignoreStatusFilter,
951
3821
  });
3822
+ if (rejected.length > 0 && !ignoreStatusFilter) {
3823
+ selection.warnings.push(`qa-tasks ignores unsupported statuses: ${rejected.join(", ")}. Allowed: ${QA_ALLOWED_STATUSES.join(", ")}.`);
3824
+ }
3825
+ const abortSignal = request.abortSignal;
3826
+ const resolveAbortReason = () => {
3827
+ const reason = abortSignal?.reason;
3828
+ if (typeof reason === "string" && reason.trim().length > 0)
3829
+ return reason;
3830
+ if (reason instanceof Error && reason.message)
3831
+ return reason.message;
3832
+ return "qa_tasks_aborted";
3833
+ };
3834
+ const abortIfSignaled = () => {
3835
+ if (abortSignal?.aborted) {
3836
+ throw new Error(resolveAbortReason());
3837
+ }
3838
+ };
3839
+ const mode = request.mode ?? 'auto';
952
3840
  this.dryRunGuard = request.dryRun ?? false;
953
3841
  if (request.dryRun) {
954
3842
  const dryResults = [];
3843
+ if (mode !== 'manual') {
3844
+ this.qaProfilePlan = await this.planProfilesWithAgent(selection.ordered, request, {
3845
+ warnings: selection.warnings,
3846
+ });
3847
+ }
3848
+ else {
3849
+ this.qaProfilePlan = new Map();
3850
+ }
955
3851
  for (const task of selection.ordered) {
956
- let profile;
3852
+ abortIfSignaled();
3853
+ let profiles = [];
957
3854
  try {
958
- profile = await this.profileService.resolveProfileForTask(task.task, {
959
- profileName: request.profileName,
960
- level: request.level,
961
- });
3855
+ profiles = await this.resolveProfilesForRequest(task.task, request);
962
3856
  }
963
3857
  catch {
964
- profile = undefined;
3858
+ profiles = [];
965
3859
  }
3860
+ const profile = profiles[0];
3861
+ const profileNames = profiles.map((entry) => entry.name);
966
3862
  dryResults.push({
967
3863
  taskKey: task.task.key,
968
3864
  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',
3865
+ profile: profileNames.length > 1 ? 'auto' : profile?.name,
3866
+ runner: profileNames.length > 1 ? 'multi' : profile?.runner,
3867
+ notes: profile
3868
+ ? `Dry-run: QA planned${profileNames.length > 1 ? ` (${profileNames.join(', ')})` : ''}`
3869
+ : 'Dry-run: no profile available',
972
3870
  });
973
3871
  }
974
3872
  return {
@@ -1012,8 +3910,9 @@ export class QaTasksService {
1012
3910
  epicKey: effectiveEpic,
1013
3911
  storyKey: effectiveStory,
1014
3912
  tasks: effectiveTasks,
1015
- statusFilter: effectiveStatus,
1016
- mode: request.mode ?? 'auto',
3913
+ statusFilter,
3914
+ limit: effectiveLimit,
3915
+ mode,
1017
3916
  profile: request.profileName,
1018
3917
  level: request.level,
1019
3918
  agent: request.agentName,
@@ -1024,6 +3923,16 @@ export class QaTasksService {
1024
3923
  totalItems: selection.ordered.length,
1025
3924
  processedItems: completedKeys.size,
1026
3925
  });
3926
+ if (mode !== 'manual') {
3927
+ this.qaProfilePlan = await this.planProfilesWithAgent(selection.ordered, request, {
3928
+ jobId: job.id,
3929
+ commandRunId: commandRun.id,
3930
+ warnings: selection.warnings,
3931
+ });
3932
+ }
3933
+ else {
3934
+ this.qaProfilePlan = new Map();
3935
+ }
1027
3936
  if (resume?.id) {
1028
3937
  try {
1029
3938
  const qaRuns = await this.deps.workspaceRepo.listTaskQaRunsForJob(selection.ordered.map((t) => t.task.id), resume.id);
@@ -1039,8 +3948,8 @@ export class QaTasksService {
1039
3948
  }
1040
3949
  }
1041
3950
  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']);
3951
+ // Skip tasks that are already in a terminal QA state for this job (ready_to_qa -> completed/in_progress/failed)
3952
+ const terminalStatuses = new Set(['completed', 'in_progress', 'failed']);
1044
3953
  const skippedTerminal = [];
1045
3954
  for (const t of remaining) {
1046
3955
  if (terminalStatuses.has(t.task.status?.toLowerCase?.() ?? '')) {
@@ -1059,10 +3968,60 @@ export class QaTasksService {
1059
3968
  });
1060
3969
  await this.checkpoint(job.id, 'selection', {
1061
3970
  ordered: selection.ordered.map((t) => t.task.key),
1062
- blocked: selection.blocked.map((t) => t.task.key),
1063
3971
  completedTaskKeys: Array.from(completedKeys),
1064
3972
  });
3973
+ const warnings = [...selection.warnings];
1065
3974
  const results = [];
3975
+ let abortRemainingReason = null;
3976
+ const formatSessionId = (iso) => {
3977
+ const date = new Date(iso);
3978
+ const pad = (value) => String(value).padStart(2, '0');
3979
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
3980
+ };
3981
+ const formatDuration = (ms) => {
3982
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
3983
+ const seconds = totalSeconds % 60;
3984
+ const minutesTotal = Math.floor(totalSeconds / 60);
3985
+ const minutes = minutesTotal % 60;
3986
+ const hours = Math.floor(minutesTotal / 60);
3987
+ if (hours > 0)
3988
+ return `${hours}H ${minutes}M ${seconds}S`;
3989
+ return `${minutes}M ${seconds}S`;
3990
+ };
3991
+ const emitLine = (line) => {
3992
+ console.info(line);
3993
+ };
3994
+ const emitBlank = () => emitLine('');
3995
+ const emitQaStart = (details) => {
3996
+ emitLine('╭──────────────────────────────────────────────────────────╮');
3997
+ emitLine('│ START OF QA TASK │');
3998
+ emitLine('╰──────────────────────────────────────────────────────────╯');
3999
+ emitLine(` [🪪] QA Task ID: ${details.taskKey}`);
4000
+ emitLine(` [👹] Alias: ${details.alias}`);
4001
+ emitLine(` [ℹ️] Summary: ${details.summary}`);
4002
+ emitLine(` [🤖] Agent: ${details.agent}`);
4003
+ emitLine(` [🕹️] Provider: ${details.provider}`);
4004
+ emitLine(` [🧩] Step: qa`);
4005
+ emitLine(` [🧪] Mode: ${details.mode}`);
4006
+ emitLine(` [📁] Workdir: ${details.workdir}`);
4007
+ emitLine(` [🔑] Session: ${details.sessionId}`);
4008
+ emitLine(` [🕒] Started: ${details.startedAt}`);
4009
+ emitBlank();
4010
+ emitLine(' ░░░░░ START OF QA TASK ░░░░░');
4011
+ emitBlank();
4012
+ };
4013
+ const emitQaEnd = (details) => {
4014
+ emitLine('╭──────────────────────────────────────────────────────────╮');
4015
+ emitLine('│ END OF QA TASK │');
4016
+ emitLine('╰──────────────────────────────────────────────────────────╯');
4017
+ emitLine(` 🧪 QA TASK ${details.taskKey} | 📜 STATUS ${details.statusLabel} | 🧭 OUTCOME ${details.outcome} | ⌛ TIME ${formatDuration(details.elapsedMs)}`);
4018
+ emitLine(` [🕒] Started: ${details.startedAt}`);
4019
+ emitLine(` [🕒] Ended: ${details.endedAt}`);
4020
+ emitLine(` [🧰] Profile: ${details.profile ?? 'n/a'} (${details.runner ?? 'n/a'})`);
4021
+ emitBlank();
4022
+ emitLine(' ░░░░░ END OF QA TASK ░░░░░');
4023
+ emitBlank();
4024
+ };
1066
4025
  for (const task of selection.ordered) {
1067
4026
  if (completedKeys.has(task.task.key)) {
1068
4027
  results.push(priorResults.get(task.task.key) ?? { taskKey: task.task.key, outcome: 'pass', notes: 'skipped (resume)' });
@@ -1072,13 +4031,74 @@ export class QaTasksService {
1072
4031
  try {
1073
4032
  let processedCount = completedKeys.size;
1074
4033
  for (const [index, task] of filteredRemaining.entries()) {
4034
+ if (abortRemainingReason)
4035
+ break;
4036
+ abortIfSignaled();
1075
4037
  const mode = request.mode ?? 'auto';
1076
- if (mode === 'manual') {
1077
- results.push(await this.runManual(task, { jobId: job.id, commandRunId: commandRun.id, request }));
4038
+ const startedAt = new Date().toISOString();
4039
+ const taskStartMs = Date.now();
4040
+ const sessionId = formatSessionId(startedAt);
4041
+ const qaAgentLabel = request.agentName ?? '(auto)';
4042
+ emitQaStart({
4043
+ taskKey: task.task.key,
4044
+ alias: `QA task ${task.task.key}`,
4045
+ summary: task.task.title ?? task.task.description ?? '(none)',
4046
+ agent: qaAgentLabel,
4047
+ provider: qaAgentLabel === '(auto)' ? 'routing' : 'qa',
4048
+ mode,
4049
+ workdir: this.workspace.workspaceRoot,
4050
+ sessionId,
4051
+ startedAt,
4052
+ });
4053
+ let result;
4054
+ try {
4055
+ if (mode === 'manual') {
4056
+ result = await this.runManual(task, { jobId: job.id, commandRunId: commandRun.id, request });
4057
+ }
4058
+ else {
4059
+ result = await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request, warnings });
4060
+ }
1078
4061
  }
1079
- else {
1080
- results.push(await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request }));
4062
+ catch (error) {
4063
+ const message = error instanceof Error ? error.message : String(error);
4064
+ emitQaEnd({
4065
+ taskKey: task.task.key,
4066
+ statusLabel: 'FAILED',
4067
+ outcome: message,
4068
+ profile: undefined,
4069
+ runner: undefined,
4070
+ elapsedMs: Date.now() - taskStartMs,
4071
+ startedAt,
4072
+ endedAt: new Date().toISOString(),
4073
+ });
4074
+ if (isAuthErrorMessage(message)) {
4075
+ abortRemainingReason = message;
4076
+ warnings.push(`Auth/rate limit error detected; stopping after ${task.task.key}. ${message}`);
4077
+ results.push({ taskKey: task.task.key, outcome: 'infra_issue', notes: AUTH_ERROR_REASON });
4078
+ completedKeys.add(task.task.key);
4079
+ processedCount = completedKeys.size;
4080
+ await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
4081
+ await this.checkpoint(job.id, `task:${task.task.key}:aborted`, {
4082
+ processed: processedCount,
4083
+ completedTaskKeys: Array.from(completedKeys),
4084
+ taskResult: results[results.length - 1],
4085
+ });
4086
+ break;
4087
+ }
4088
+ throw error;
1081
4089
  }
4090
+ results.push(result);
4091
+ const statusLabel = result.outcome === 'pass' ? 'COMPLETED' : result.outcome === 'infra_issue' ? 'BLOCKED' : 'FAILED';
4092
+ emitQaEnd({
4093
+ taskKey: task.task.key,
4094
+ statusLabel,
4095
+ outcome: result.outcome,
4096
+ profile: result.profile,
4097
+ runner: result.runner,
4098
+ elapsedMs: Date.now() - taskStartMs,
4099
+ startedAt,
4100
+ endedAt: new Date().toISOString(),
4101
+ });
1082
4102
  completedKeys.add(task.task.key);
1083
4103
  processedCount = completedKeys.size;
1084
4104
  await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
@@ -1088,9 +4108,18 @@ export class QaTasksService {
1088
4108
  taskResult: results[results.length - 1],
1089
4109
  });
1090
4110
  }
4111
+ if (abortRemainingReason) {
4112
+ warnings.push(`Stopped remaining tasks due to auth/rate limit: ${abortRemainingReason}`);
4113
+ }
1091
4114
  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;
4115
+ const state = abortRemainingReason
4116
+ ? 'failed'
4117
+ : failureCount === 0
4118
+ ? 'completed'
4119
+ : failureCount === results.length
4120
+ ? 'failed'
4121
+ : 'partial';
4122
+ const errorSummary = abortRemainingReason ?? (failureCount ? `${failureCount} task(s) not passed QA` : undefined);
1094
4123
  await this.deps.jobService.updateJobStatus(job.id, state, { errorSummary });
1095
4124
  await this.deps.jobService.finishCommandRun(commandRun.id, state === 'completed' ? 'succeeded' : 'failed', errorSummary);
1096
4125
  await this.checkpoint(job.id, 'completed', {
@@ -1111,7 +4140,7 @@ export class QaTasksService {
1111
4140
  commandRunId: commandRun.id,
1112
4141
  selection,
1113
4142
  results,
1114
- warnings: selection.warnings,
4143
+ warnings,
1115
4144
  };
1116
4145
  }
1117
4146
  }