@mcoda/core 0.1.9 → 0.1.12

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 (204) hide show
  1. package/README.md +2 -2
  2. package/dist/api/AgentsApi.d.ts +1 -0
  3. package/dist/api/AgentsApi.d.ts.map +1 -1
  4. package/dist/api/AgentsApi.js +136 -11
  5. package/dist/api/QaTasksApi.d.ts.map +1 -1
  6. package/dist/api/QaTasksApi.js +4 -0
  7. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  8. package/dist/prompts/PdrPrompts.js +6 -0
  9. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  10. package/dist/prompts/SdsPrompts.js +7 -0
  11. package/dist/services/agents/AgentRatingService.d.ts +19 -0
  12. package/dist/services/agents/AgentRatingService.d.ts.map +1 -1
  13. package/dist/services/agents/AgentRatingService.js +66 -2
  14. package/dist/services/agents/GatewayAgentService.d.ts +8 -0
  15. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  16. package/dist/services/agents/GatewayAgentService.js +462 -65
  17. package/dist/services/agents/GatewayHandoff.d.ts +5 -1
  18. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -1
  19. package/dist/services/agents/GatewayHandoff.js +65 -32
  20. package/dist/services/agents/RoutingService.d.ts +1 -0
  21. package/dist/services/agents/RoutingService.d.ts.map +1 -1
  22. package/dist/services/agents/RoutingService.js +4 -4
  23. package/dist/services/backlog/BacklogService.d.ts +23 -0
  24. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  25. package/dist/services/backlog/BacklogService.js +62 -7
  26. package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
  27. package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
  28. package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
  29. package/dist/services/backlog/TaskOrderingService.d.ts +16 -4
  30. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  31. package/dist/services/backlog/TaskOrderingService.js +529 -73
  32. package/dist/services/docs/DocInventory.d.ts +11 -0
  33. package/dist/services/docs/DocInventory.d.ts.map +1 -0
  34. package/dist/services/docs/DocInventory.js +230 -0
  35. package/dist/services/docs/DocgenRunContext.d.ts +59 -0
  36. package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
  37. package/dist/services/docs/DocgenRunContext.js +4 -0
  38. package/dist/services/docs/DocsService.d.ts +59 -2
  39. package/dist/services/docs/DocsService.d.ts.map +1 -1
  40. package/dist/services/docs/DocsService.js +1701 -48
  41. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
  42. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
  43. package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
  44. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
  45. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
  46. package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
  47. package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
  48. package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
  49. package/dist/services/docs/patch/DocPatchEngine.js +331 -0
  50. package/dist/services/docs/review/Glossary.d.ts +16 -0
  51. package/dist/services/docs/review/Glossary.d.ts.map +1 -0
  52. package/dist/services/docs/review/Glossary.js +47 -0
  53. package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
  54. package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
  55. package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
  56. package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
  57. package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
  58. package/dist/services/docs/review/ReviewReportSchema.js +47 -0
  59. package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
  60. package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
  61. package/dist/services/docs/review/ReviewTypes.js +94 -0
  62. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
  63. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
  64. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
  65. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
  66. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
  67. package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
  68. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
  69. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
  70. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
  71. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
  72. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
  73. package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
  74. package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
  75. package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
  76. package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
  77. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
  78. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
  79. package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
  80. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
  81. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
  82. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
  83. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
  84. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
  85. package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
  86. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
  87. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
  88. package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
  89. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
  90. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
  91. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
  92. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
  93. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
  94. package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
  95. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
  96. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
  97. package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
  98. package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
  99. package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
  100. package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
  101. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
  102. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
  103. package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
  104. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
  105. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
  106. package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
  107. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
  108. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
  109. package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
  110. package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
  111. package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
  112. package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
  113. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
  114. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
  115. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
  116. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
  117. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
  118. package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
  119. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
  120. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
  121. package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
  122. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
  123. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
  124. package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
  125. package/dist/services/docs/review/glossary.json +47 -0
  126. package/dist/services/estimate/EstimateService.d.ts +2 -0
  127. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  128. package/dist/services/estimate/EstimateService.js +66 -18
  129. package/dist/services/estimate/VelocityService.d.ts +4 -0
  130. package/dist/services/estimate/VelocityService.d.ts.map +1 -1
  131. package/dist/services/estimate/VelocityService.js +179 -36
  132. package/dist/services/estimate/types.d.ts +1 -0
  133. package/dist/services/estimate/types.d.ts.map +1 -1
  134. package/dist/services/execution/GatewayTrioService.d.ts +71 -4
  135. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -1
  136. package/dist/services/execution/GatewayTrioService.js +1695 -328
  137. package/dist/services/execution/QaApiRunner.d.ts +30 -0
  138. package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
  139. package/dist/services/execution/QaApiRunner.js +881 -0
  140. package/dist/services/execution/QaFollowupService.d.ts +1 -0
  141. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  142. package/dist/services/execution/QaFollowupService.js +8 -2
  143. package/dist/services/execution/QaPlanValidator.d.ts +10 -0
  144. package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
  145. package/dist/services/execution/QaPlanValidator.js +128 -0
  146. package/dist/services/execution/QaProfileService.d.ts +21 -1
  147. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  148. package/dist/services/execution/QaProfileService.js +214 -29
  149. package/dist/services/execution/QaTasksService.d.ts +41 -1
  150. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  151. package/dist/services/execution/QaTasksService.js +2851 -500
  152. package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
  153. package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
  154. package/dist/services/execution/QaTestCommandBuilder.js +495 -0
  155. package/dist/services/execution/TaskSelectionService.d.ts +4 -2
  156. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  157. package/dist/services/execution/TaskSelectionService.js +144 -28
  158. package/dist/services/execution/TaskStateService.d.ts +19 -6
  159. package/dist/services/execution/TaskStateService.d.ts.map +1 -1
  160. package/dist/services/execution/TaskStateService.js +128 -13
  161. package/dist/services/execution/WorkOnTasksService.d.ts +19 -2
  162. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  163. package/dist/services/execution/WorkOnTasksService.js +3913 -1225
  164. package/dist/services/jobs/JobInsightsService.d.ts +4 -0
  165. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
  166. package/dist/services/jobs/JobInsightsService.js +51 -5
  167. package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
  168. package/dist/services/jobs/JobResumeService.js +23 -10
  169. package/dist/services/jobs/JobService.d.ts +56 -4
  170. package/dist/services/jobs/JobService.d.ts.map +1 -1
  171. package/dist/services/jobs/JobService.js +232 -1
  172. package/dist/services/openapi/OpenApiService.d.ts +41 -0
  173. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  174. package/dist/services/openapi/OpenApiService.js +889 -98
  175. package/dist/services/planning/CreateTasksService.d.ts +15 -0
  176. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  177. package/dist/services/planning/CreateTasksService.js +311 -6
  178. package/dist/services/planning/RefineTasksService.d.ts +4 -0
  179. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  180. package/dist/services/planning/RefineTasksService.js +225 -24
  181. package/dist/services/review/CodeReviewService.d.ts +4 -0
  182. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  183. package/dist/services/review/CodeReviewService.js +778 -232
  184. package/dist/services/review/ReviewNormalizer.d.ts +9 -0
  185. package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
  186. package/dist/services/review/ReviewNormalizer.js +147 -0
  187. package/dist/services/shared/AuthErrors.d.ts +3 -0
  188. package/dist/services/shared/AuthErrors.d.ts.map +1 -0
  189. package/dist/services/shared/AuthErrors.js +17 -0
  190. package/dist/services/shared/DocdexGuidance.d.ts +7 -0
  191. package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
  192. package/dist/services/shared/DocdexGuidance.js +12 -0
  193. package/dist/services/shared/ProjectGuidance.d.ts +12 -1
  194. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
  195. package/dist/services/shared/ProjectGuidance.js +64 -7
  196. package/dist/services/system/ToolDenylist.d.ts +13 -0
  197. package/dist/services/system/ToolDenylist.d.ts.map +1 -0
  198. package/dist/services/system/ToolDenylist.js +85 -0
  199. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
  200. package/dist/services/telemetry/TelemetryService.js +39 -7
  201. package/dist/workspace/WorkspaceManager.d.ts +22 -0
  202. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  203. package/dist/workspace/WorkspaceManager.js +203 -32
  204. package/package.json +6 -5
@@ -1,15 +1,23 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
3
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';
4
12
  import { WorkspaceRepository } from '@mcoda/db';
5
- import { PathHelper } from '@mcoda/shared';
13
+ import { PathHelper, QA_ALLOWED_STATUSES, filterTaskStatuses } from '@mcoda/shared';
6
14
  import { JobService } from '../jobs/JobService.js';
7
15
  import { TaskSelectionService } from './TaskSelectionService.js';
8
16
  import { TaskStateService } from './TaskStateService.js';
9
17
  import { QaProfileService } from './QaProfileService.js';
10
18
  import { QaFollowupService } from './QaFollowupService.js';
11
19
  import { CliQaAdapter } from '@mcoda/integrations/qa/CliQaAdapter.js';
12
- import { ChromiumQaAdapter } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
20
+ import { ChromiumQaAdapter, resolveChromiumBinary } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
13
21
  import { MaestroQaAdapter } from '@mcoda/integrations/qa/MaestroQaAdapter.js';
14
22
  import { VcsClient } from '@mcoda/integrations';
15
23
  import readline from 'node:readline/promises';
@@ -19,18 +27,108 @@ import { GlobalRepository } from '@mcoda/db';
19
27
  import { DocdexClient } from '@mcoda/integrations';
20
28
  import { RoutingService } from '../agents/RoutingService.js';
21
29
  import { AgentRatingService } from '../agents/AgentRatingService.js';
22
- import { loadProjectGuidance } from '../shared/ProjectGuidance.js';
30
+ import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from '../shared/ProjectGuidance.js';
31
+ import { buildDocdexUsageGuidance } from '../shared/DocdexGuidance.js';
23
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);
24
38
  const DEFAULT_QA_PROMPT = [
25
- '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.',
26
- '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.',
27
- 'QA policy: always run automated tests. Use browser (Playwright) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage.',
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.',
28
43
  ].join('\n');
29
- const QA_TEST_POLICY = 'QA policy: always run automated tests. Use browser (Playwright) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage.';
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
+ ];
30
88
  const DEFAULT_JOB_PROMPT = 'You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.';
31
89
  const DEFAULT_CHARACTER_PROMPT = 'Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.';
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
+ };
32
122
  const RUN_ALL_TESTS_MARKER = 'mcoda_run_all_tests_complete';
33
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';
34
132
  const normalizeSlugList = (input) => {
35
133
  if (!Array.isArray(input))
36
134
  return [];
@@ -44,6 +142,201 @@ const normalizeSlugList = (input) => {
44
142
  }
45
143
  return Array.from(cleaned);
46
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
+ };
47
340
  const parseCommentBody = (body) => {
48
341
  const trimmed = (body ?? '').trim();
49
342
  if (!trimmed)
@@ -78,8 +371,15 @@ const buildCommentBacklog = (comments) => {
78
371
  const lines = [];
79
372
  const toSingleLine = (value) => value.replace(/\s+/g, ' ').trim();
80
373
  for (const comment of comments) {
81
- const slug = comment.slug?.trim() || undefined;
82
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
+ });
83
383
  const key = slug ??
84
384
  `${comment.sourceCommand}:${comment.file ?? ''}:${comment.line ?? ''}:${details.message || comment.body}`;
85
385
  if (seen.has(key))
@@ -104,7 +404,6 @@ const formatSlugList = (slugs, limit = 12) => {
104
404
  return slugs.join(', ');
105
405
  return `${slugs.slice(0, limit).join(', ')} (+${slugs.length - limit} more)`;
106
406
  };
107
- const MCODA_GITIGNORE_ENTRY = '.mcoda/\n';
108
407
  export class QaTasksService {
109
408
  constructor(workspace, deps) {
110
409
  this.workspace = workspace;
@@ -112,7 +411,8 @@ export class QaTasksService {
112
411
  this.dryRunGuard = false;
113
412
  this.selectionService = deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
114
413
  this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
115
- this.profileService = deps.profileService ?? new QaProfileService(workspace.workspaceRoot);
414
+ this.profileService =
415
+ deps.profileService ?? new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
116
416
  this.followupService = deps.followupService ?? new QaFollowupService(deps.workspaceRepo, workspace.workspaceRoot);
117
417
  this.jobService = deps.jobService;
118
418
  this.vcs = deps.vcsClient ?? new VcsClient();
@@ -125,9 +425,11 @@ export class QaTasksService {
125
425
  static async create(workspace, options = {}) {
126
426
  const repo = await GlobalRepository.create();
127
427
  const agentService = new AgentService(repo);
428
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
128
429
  const docdex = new DocdexClient({
129
430
  workspaceRoot: workspace.workspaceRoot,
130
431
  baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
432
+ repoId: docdexRepoId,
131
433
  });
132
434
  const routingService = await RoutingService.create();
133
435
  const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
@@ -136,7 +438,7 @@ export class QaTasksService {
136
438
  });
137
439
  const selectionService = new TaskSelectionService(workspace, workspaceRepo);
138
440
  const stateService = new TaskStateService(workspaceRepo);
139
- const profileService = new QaProfileService(workspace.workspaceRoot);
441
+ const profileService = new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
140
442
  const followupService = new QaFollowupService(workspaceRepo, workspace.workspaceRoot);
141
443
  const vcsClient = new VcsClient();
142
444
  return new QaTasksService(workspace, {
@@ -172,6 +474,14 @@ export class QaTasksService {
172
474
  await maybeClose(this.docdex);
173
475
  await maybeClose(this.deps.routingService);
174
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
+ }
175
485
  async readPromptFiles(paths) {
176
486
  const contents = [];
177
487
  const seen = new Set();
@@ -191,12 +501,62 @@ export class QaTasksService {
191
501
  return contents;
192
502
  }
193
503
  async loadPrompts(agentId) {
194
- const mcodaPromptPath = path.join(this.workspace.workspaceRoot, '.mcoda', 'prompts', 'qa-agent.md');
504
+ const mcodaPromptPath = path.join(this.workspace.mcodaDir, 'prompts', 'qa-agent.md');
195
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
+ };
196
515
  try {
197
516
  await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
198
- await fs.access(mcodaPromptPath);
199
- 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
+ }
200
560
  }
201
561
  catch {
202
562
  try {
@@ -205,25 +565,24 @@ export class QaTasksService {
205
565
  console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
206
566
  }
207
567
  catch {
208
- console.info(`[qa-tasks] no QA prompt found at ${workspacePromptPath}; writing default prompt to ${mcodaPromptPath}`);
209
- 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
+ }
210
577
  }
211
578
  }
212
- const commandPromptFiles = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
213
579
  const agentPrompts = this.agentService && 'getPrompts' in this.agentService ? await this.agentService.getPrompts(agentId) : undefined;
214
- const mergedCommandPrompt = (() => {
215
- const parts = [...commandPromptFiles];
216
- if (agentPrompts?.commandPrompts?.['qa-tasks']) {
217
- parts.push(agentPrompts.commandPrompts['qa-tasks']);
218
- }
219
- if (!parts.length)
220
- parts.push(DEFAULT_QA_PROMPT);
221
- return parts.filter(Boolean).join('\n\n');
222
- })();
580
+ const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_QA_PROMPT);
581
+ const commandPrompt = agentPrompts?.commandPrompts?.['qa-tasks']?.trim() || filePrompt;
223
582
  return {
224
- jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
225
- characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
226
- commandPrompt: mergedCommandPrompt || undefined,
583
+ jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
584
+ characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
585
+ commandPrompt: commandPrompt || undefined,
227
586
  };
228
587
  }
229
588
  async checkpoint(jobId, stage, details) {
@@ -233,24 +592,71 @@ export class QaTasksService {
233
592
  details,
234
593
  });
235
594
  }
236
- async ensureTaskBranch(task, taskRunId, allowDirty) {
595
+ async ensureTaskBranch(task, taskRunId, jobId, allowDirty, cleanIgnorePaths) {
237
596
  try {
238
- await this.vcs.ensureRepo(this.workspace.workspaceRoot);
597
+ const repoRoot = this.workspace.workspaceRoot;
598
+ await this.vcs.ensureRepo(repoRoot);
239
599
  if (!allowDirty) {
240
- await this.vcs.ensureClean(this.workspace.workspaceRoot, true, ["test-results", "playwright-report"]);
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);
241
605
  }
242
- if (task.task.vcsBranch) {
243
- const exists = await this.vcs.branchExists(this.workspace.workspaceRoot, task.task.vcsBranch);
606
+ let branch = task.task.vcsBranch;
607
+ if (branch) {
608
+ const exists = await this.vcs.branchExists(repoRoot, branch);
244
609
  if (!exists) {
245
- return { ok: false, message: `Task branch ${task.task.vcsBranch} not found` };
610
+ return { ok: false, message: `Task branch ${branch} not found` };
246
611
  }
247
- await this.vcs.checkoutBranch(this.workspace.workspaceRoot, task.task.vcsBranch);
248
612
  }
249
613
  else {
250
614
  const base = this.workspace.config?.branch ?? 'mcoda-dev';
251
- await this.vcs.ensureBaseBranch(this.workspace.workspaceRoot, base);
615
+ await this.vcs.ensureBaseBranch(repoRoot, base);
616
+ branch = base;
252
617
  }
253
- 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 };
254
660
  }
255
661
  catch (error) {
256
662
  await this.logTask(taskRunId, `VCS check failed: ${error?.message ?? error}`, 'vcs');
@@ -259,16 +665,19 @@ export class QaTasksService {
259
665
  }
260
666
  async ensureMcoda() {
261
667
  await PathHelper.ensureDir(this.workspace.mcodaDir);
262
- const gitignorePath = `${this.workspace.workspaceRoot}/.gitignore`;
263
- try {
264
- const content = await fs.readFile(gitignorePath, 'utf8');
265
- if (!content.includes('.mcoda/')) {
266
- await fs.writeFile(gitignorePath, `${content.trimEnd()}\n${MCODA_GITIGNORE_ENTRY}`, 'utf8');
267
- }
268
- }
269
- catch {
270
- await fs.writeFile(gitignorePath, MCODA_GITIGNORE_ENTRY, 'utf8');
271
- }
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
+ ]);
272
681
  }
273
682
  adapterForProfile(profile) {
274
683
  const runner = profile?.runner ?? 'cli';
@@ -287,22 +696,225 @@ export class QaTasksService {
287
696
  return 'infra_issue';
288
697
  return 'fix_required';
289
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
+ }
290
880
  adjustOutcomeForSkippedTests(profile, result, testCommand) {
291
881
  if ((profile.runner ?? 'cli') !== 'cli')
292
- return result;
882
+ return { result };
293
883
  const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
294
884
  const outputLower = output.toLowerCase();
295
885
  const markers = ['no test script configured', 'skipping tests', 'no tests found'];
296
886
  if (markers.some((marker) => outputLower.includes(marker))) {
297
- return { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 };
887
+ return { result: { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 } };
298
888
  }
889
+ let markerStatus;
299
890
  if (testCommand && testCommand.includes('tests/all.js')) {
300
- if (!outputLower.includes(RUN_ALL_TESTS_MARKER)) {
301
- const stderr = [result.stderr, RUN_ALL_TESTS_GUIDANCE].filter(Boolean).join('\n');
302
- return { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1, stderr };
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
+ }
303
915
  }
304
916
  }
305
- return result;
917
+ return { result, markerStatus };
306
918
  }
307
919
  combineOutcome(result, recommendation) {
308
920
  const base = this.mapOutcome(result);
@@ -318,10 +930,22 @@ export class QaTasksService {
318
930
  return 'unclear';
319
931
  return 'pass';
320
932
  }
321
- async gatherDocContext(task, taskRunId) {
933
+ async gatherDocContext(task, taskRunId, docLinks = []) {
322
934
  if (!this.docdex)
323
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
+ };
324
945
  try {
946
+ if (typeof this.docdex?.ensureRepoScope === 'function') {
947
+ await this.docdex.ensureRepoScope();
948
+ }
325
949
  const querySeeds = [task.key, task.title, ...(task.acceptanceCriteria ?? [])]
326
950
  .filter(Boolean)
327
951
  .join(' ')
@@ -332,7 +956,21 @@ export class QaTasksService {
332
956
  query: querySeeds,
333
957
  });
334
958
  const snippets = [];
335
- 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)) {
336
974
  const segments = (doc.segments ?? []).slice(0, 2);
337
975
  const body = segments.length
338
976
  ? segments
@@ -341,7 +979,47 @@ export class QaTasksService {
341
979
  : doc.content
342
980
  ? doc.content.slice(0, 600)
343
981
  : '';
344
- 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
+ }
345
1023
  }
346
1024
  return snippets.join('\n\n');
347
1025
  }
@@ -398,8 +1076,8 @@ export class QaTasksService {
398
1076
  return false;
399
1077
  }
400
1078
  }
401
- async readPackageJson() {
402
- const pkgPath = path.join(this.workspace.workspaceRoot, 'package.json');
1079
+ async readPackageJson(root = this.workspace.workspaceRoot) {
1080
+ const pkgPath = path.join(root, 'package.json');
403
1081
  try {
404
1082
  const raw = await fs.readFile(pkgPath, 'utf8');
405
1083
  return JSON.parse(raw);
@@ -408,8 +1086,7 @@ export class QaTasksService {
408
1086
  return undefined;
409
1087
  }
410
1088
  }
411
- async detectPackageManager() {
412
- const root = this.workspace.workspaceRoot;
1089
+ async detectPackageManager(root = this.workspace.workspaceRoot) {
413
1090
  if (await this.fileExists(path.join(root, 'pnpm-lock.yaml')))
414
1091
  return 'pnpm';
415
1092
  if (await this.fileExists(path.join(root, 'pnpm-workspace.yaml')))
@@ -424,143 +1101,928 @@ export class QaTasksService {
424
1101
  return 'npm';
425
1102
  return undefined;
426
1103
  }
427
- async resolveTestCommand(profile, requestTestCommand) {
1104
+ async resolveTestCommand(profile, requestTestCommand, workspaceRoot = this.workspace.workspaceRoot) {
428
1105
  if (requestTestCommand)
429
1106
  return requestTestCommand;
430
1107
  if ((profile.runner ?? 'cli') !== 'cli')
431
1108
  return undefined;
432
1109
  if (profile.test_command)
433
1110
  return profile.test_command;
434
- if (await this.fileExists(path.join(this.workspace.workspaceRoot, 'tests', 'all.js'))) {
1111
+ if (await this.fileExists(path.join(workspaceRoot, 'tests', 'all.js'))) {
435
1112
  return 'node tests/all.js';
436
1113
  }
437
- const pkg = await this.readPackageJson();
1114
+ const pkg = await this.readPackageJson(workspaceRoot);
438
1115
  if (pkg?.scripts?.test) {
439
- const pm = (await this.detectPackageManager()) ?? 'npm';
1116
+ const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
440
1117
  return `${pm} test`;
441
1118
  }
442
1119
  return undefined;
443
1120
  }
444
- extractJsonCandidate(raw) {
445
- const fenced = raw.match(/```json([\s\S]*?)```/i);
446
- const candidate = fenced ? fenced[1] : raw;
447
- const start = candidate.indexOf('{');
448
- const end = candidate.lastIndexOf('}');
449
- if (start === -1 || end === -1 || end <= start)
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
+ }
450
1150
  return undefined;
451
- const slice = candidate.slice(start, end + 1);
452
- try {
453
- return JSON.parse(slice);
454
- }
455
- catch {
456
- const cleanedLines = slice
457
- .split(/\r?\n/)
458
- .filter((line) => {
459
- const trimmed = line.trim();
460
- if (!trimmed)
461
- return true;
462
- if (trimmed.startsWith("{") ||
463
- trimmed.startsWith("}") ||
464
- trimmed.startsWith("[") ||
465
- trimmed.startsWith("]") ||
466
- trimmed.startsWith("\"")) {
467
- return true;
468
- }
469
- return false;
470
- })
471
- .join("\n")
472
- .replace(/,\s*([}\]])/g, "$1");
473
- const withQuotedKeys = cleanedLines.replace(/([{,]\s*)([A-Za-z0-9_]+)\s*:/g, '$1"$2":');
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');
474
1156
  try {
475
- return JSON.parse(withQuotedKeys);
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
+ }
476
1163
  }
477
1164
  catch {
478
- return undefined;
1165
+ // ignore
479
1166
  }
480
1167
  }
481
- }
482
- normalizeAgentOutput(parsed) {
483
- if (!parsed || typeof parsed !== 'object')
1168
+ if (!binPath)
484
1169
  return undefined;
485
- const asString = (value) => (typeof value === 'string' ? value.trim() : undefined);
486
- const asNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
487
- const asStringArray = (value) => Array.isArray(value) ? value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) : undefined;
488
- const recommendation = parsed.recommendation;
489
- if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
1170
+ const normalized = path.isAbsolute(binPath) ? binPath : path.join(workspaceRoot, binPath);
1171
+ if (!(await this.fileExists(normalized)))
490
1172
  return undefined;
491
- const rawFollowUps = Array.isArray(parsed.follow_up_tasks)
492
- ? parsed.follow_up_tasks
493
- : Array.isArray(parsed.follow_ups)
494
- ? parsed.follow_ups
495
- : undefined;
496
- const followUps = rawFollowUps
497
- ? rawFollowUps.map((item) => ({
498
- title: asString(item?.title),
499
- description: asString(item?.description),
500
- type: asString(item?.type),
501
- priority: asNumber(item?.priority),
502
- story_points: asNumber(item?.story_points ?? item?.storyPoints),
503
- tags: asStringArray(item?.tags),
504
- related_task_key: asString(item?.related_task_key ?? item?.relatedTaskKey),
505
- epic_key: asString(item?.epic_key ?? item?.epicKey),
506
- story_key: asString(item?.story_key ?? item?.storyKey),
507
- components: asStringArray(item?.components),
508
- doc_links: asStringArray(item?.doc_links ?? item?.docLinks),
509
- evidence_url: asString(item?.evidence_url ?? item?.evidenceUrl),
510
- artifacts: asStringArray(item?.artifacts),
511
- }))
512
- : undefined;
513
- const failures = Array.isArray(parsed.failures)
514
- ? parsed.failures.map((f) => ({ kind: f.kind, message: f.message ?? String(f), evidence: f.evidence }))
515
- : undefined;
516
- const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
517
- const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
518
- return {
519
- recommendation,
520
- testedScope: parsed.tested_scope ?? parsed.scope,
521
- coverageSummary: parsed.coverage_summary ?? parsed.coverage,
522
- failures,
523
- followUps,
524
- resolvedSlugs,
525
- unresolvedSlugs,
526
- };
1173
+ const relative = path.isAbsolute(binPath) ? path.relative(workspaceRoot, binPath) : binPath;
1174
+ return `node ${relative} --help`;
527
1175
  }
528
- async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId, commentBacklog, abortSignal) {
529
- if (!this.agentService) {
530
- return { recommendation: this.mapOutcome(result) };
531
- }
532
- const resolveAbortReason = () => {
533
- const reason = abortSignal?.reason;
534
- if (typeof reason === "string" && reason.trim().length > 0)
535
- return reason;
536
- if (reason instanceof Error && reason.message)
537
- return reason.message;
538
- return "qa_tasks_aborted";
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);
539
1188
  };
540
- const abortIfSignaled = () => {
541
- if (abortSignal?.aborted) {
542
- throw new Error(resolveAbortReason());
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);
543
1205
  }
544
- };
545
- const withAbort = async (promise) => {
546
- if (!abortSignal)
547
- return promise;
548
- if (abortSignal.aborted) {
549
- throw new Error(resolveAbortReason());
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');
550
1218
  }
551
- return await new Promise((resolve, reject) => {
552
- const onAbort = () => reject(new Error(resolveAbortReason()));
553
- abortSignal.addEventListener("abort", onAbort, { once: true });
554
- promise.then(resolve, reject).finally(() => {
555
- abortSignal.removeEventListener("abort", onAbort);
556
- });
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 };
1890
+ }
1891
+ extractJsonCandidate(raw) {
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("}"))
1898
+ return undefined;
1899
+ try {
1900
+ return JSON.parse(candidate);
1901
+ }
1902
+ catch {
1903
+ return undefined;
1904
+ }
1905
+ }
1906
+ normalizeAgentOutput(parsed) {
1907
+ if (!parsed || typeof parsed !== 'object')
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;
1919
+ const recommendation = parsed.recommendation;
1920
+ if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
1921
+ return undefined;
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)
1925
+ ? parsed.follow_up_tasks
1926
+ : Array.isArray(parsed.follow_ups)
1927
+ ? parsed.follow_ups
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;
1946
+ const failures = Array.isArray(parsed.failures)
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
+ }))
1954
+ : undefined;
1955
+ const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
1956
+ const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
1957
+ return {
1958
+ recommendation,
1959
+ testedScope,
1960
+ coverageSummary,
1961
+ failures,
1962
+ followUps,
1963
+ resolvedSlugs,
1964
+ unresolvedSlugs,
1965
+ };
1966
+ }
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) {
1991
+ if (!this.agentService) {
1992
+ return { recommendation: this.mapOutcome(result) };
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
+ });
557
2019
  });
558
2020
  };
559
2021
  try {
560
2022
  abortIfSignaled();
561
2023
  const agent = await this.resolveAgent(agentName);
562
2024
  const prompts = await this.loadPrompts(agent.id);
563
- const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
2025
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
564
2026
  if (projectGuidance && taskRunId) {
565
2027
  await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
566
2028
  }
@@ -568,7 +2030,8 @@ export class QaTasksService {
568
2030
  const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, QA_TEST_POLICY]
569
2031
  .filter(Boolean)
570
2032
  .join('\n\n');
571
- const docCtx = await this.gatherDocContext(task.task, taskRunId);
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);
572
2035
  const acceptance = (task.task.acceptanceCriteria ?? []).map((line) => `- ${line}`).join('\n');
573
2036
  const prompt = [
574
2037
  systemPrompt,
@@ -577,7 +2040,7 @@ export class QaTasksService {
577
2040
  `Task type: ${task.task.type ?? 'n/a'}, status: ${task.task.status}`,
578
2041
  task.task.description ? `Task description:\n${task.task.description}` : '',
579
2042
  `Epic/Story: ${task.task.epicKey ?? task.task.epicId} / ${task.task.storyKey ?? task.task.userStoryId}`,
580
- acceptance ? `Acceptance criteria:\n${acceptance}` : 'Acceptance criteria: (not provided)',
2043
+ acceptance ? `Task DoD / acceptance criteria:\n${acceptance}` : 'Task DoD / acceptance criteria: (not provided)',
581
2044
  commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : 'Comment backlog: none',
582
2045
  `QA profile: ${profile.name} (${profile.runner ?? 'cli'})`,
583
2046
  `Test command / runner outcome: exit=${result.exitCode} outcome=${result.outcome}`,
@@ -588,15 +2051,15 @@ export class QaTasksService {
588
2051
  [
589
2052
  'Return strict JSON with keys:',
590
2053
  '{',
591
- ' "tested_scope": string,',
592
- ' "coverage_summary": string,',
593
- ' "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 }],',
594
2057
  ' "recommendation": "pass|fix_required|infra_issue|unclear",',
595
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[] }],',
596
2059
  ' "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],',
597
2060
  ' "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]',
598
2061
  '}',
599
- 'Do not include prose outside the JSON. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
2062
+ 'Do not include prose outside the JSON. No markdown fences or comments. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
600
2063
  ].join('\n'),
601
2064
  ]
602
2065
  .filter(Boolean)
@@ -650,6 +2113,8 @@ export class QaTasksService {
650
2113
  metadata: {
651
2114
  commandName: 'qa-tasks',
652
2115
  action: 'qa-interpret-results',
2116
+ phase: 'qa-interpret',
2117
+ attempt: 1,
653
2118
  taskKey: task.task.key,
654
2119
  streaming: stream,
655
2120
  streamChunks: chunkCount || undefined,
@@ -657,7 +2122,15 @@ export class QaTasksService {
657
2122
  });
658
2123
  }
659
2124
  const parsed = this.extractJsonCandidate(output);
660
- 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
+ }
661
2134
  if (normalized) {
662
2135
  return {
663
2136
  ...normalized,
@@ -703,12 +2176,18 @@ export class QaTasksService {
703
2176
  metadata: {
704
2177
  commandName: 'qa-tasks',
705
2178
  action: 'qa-interpret-retry',
2179
+ phase: 'qa-interpret-retry',
2180
+ attempt: 2,
706
2181
  taskKey: task.task.key,
707
2182
  },
708
2183
  });
709
2184
  }
710
2185
  const retryParsed = this.extractJsonCandidate(retryOutput);
711
- const retryNormalized = this.normalizeAgentOutput(retryParsed);
2186
+ let retryNormalized = this.normalizeAgentOutput(retryParsed);
2187
+ validationError = retryNormalized ? this.validateInterpretation(retryNormalized, { requireCommentSlugs }) : undefined;
2188
+ if (retryNormalized && validationError) {
2189
+ retryNormalized = undefined;
2190
+ }
712
2191
  if (retryNormalized) {
713
2192
  return {
714
2193
  ...retryNormalized,
@@ -720,7 +2199,10 @@ export class QaTasksService {
720
2199
  };
721
2200
  }
722
2201
  if (taskRunId) {
723
- await this.logTask(taskRunId, "QA agent returned invalid JSON after retry; falling back to QA outcome.", "qa-agent");
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");
724
2206
  }
725
2207
  return {
726
2208
  recommendation: 'unclear',
@@ -733,8 +2215,12 @@ export class QaTasksService {
733
2215
  };
734
2216
  }
735
2217
  catch (error) {
2218
+ const message = error?.message ?? String(error);
736
2219
  if (taskRunId) {
737
- 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;
738
2224
  }
739
2225
  return { recommendation: this.mapOutcome(result) };
740
2226
  }
@@ -753,6 +2239,8 @@ export class QaTasksService {
753
2239
  source: 'qa-tasks',
754
2240
  message,
755
2241
  category: failure.kind ?? 'qa_issue',
2242
+ file: failure.file,
2243
+ line: failure.line,
756
2244
  });
757
2245
  }
758
2246
  async applyCommentResolutions(params) {
@@ -814,6 +2302,8 @@ export class QaTasksService {
814
2302
  message,
815
2303
  status: 'open',
816
2304
  category: failure.kind ?? 'qa_issue',
2305
+ file: failure.file ?? null,
2306
+ line: failure.line ?? null,
817
2307
  });
818
2308
  if (!this.dryRunGuard) {
819
2309
  await this.deps.workspaceRepo.createTaskComment({
@@ -826,6 +2316,9 @@ export class QaTasksService {
826
2316
  category: failure.kind ?? 'qa_issue',
827
2317
  slug,
828
2318
  status: 'open',
2319
+ file: failure.file ?? null,
2320
+ line: failure.line ?? null,
2321
+ pathHint: failure.file ?? null,
829
2322
  body,
830
2323
  createdAt: new Date().toISOString(),
831
2324
  metadata: {
@@ -920,19 +2413,48 @@ export class QaTasksService {
920
2413
  details: details ?? undefined,
921
2414
  });
922
2415
  }
923
- async applyStateTransition(task, outcome) {
924
- const timestamp = { last_qa: new Date().toISOString() };
925
- if (outcome === 'pass') {
926
- await this.stateService.markCompleted(task, timestamp);
927
- }
928
- else if (outcome === 'fix_required') {
929
- await this.stateService.returnToInProgress(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}`;
930
2451
  }
931
- else if (outcome === 'infra_issue') {
932
- await this.stateService.markBlocked(task, 'qa_infra_issue');
2452
+ const mergedMetadata = metadataPatch ? { ...baseMetadata, ...metadataPatch } : baseMetadata;
2453
+ if (outcome === 'pass') {
2454
+ await this.stateService.markCompleted(task, mergedMetadata, context);
933
2455
  }
934
- else if (outcome === 'unclear') {
935
- await this.stateService.markBlocked(task, 'qa_unclear');
2456
+ else {
2457
+ await this.stateService.markNotStarted(task, mergedMetadata, context);
936
2458
  }
937
2459
  }
938
2460
  buildFollowupSuggestion(task, result, notes) {
@@ -1008,13 +2530,14 @@ export class QaTasksService {
1008
2530
  return [];
1009
2531
  const agent = await this.resolveAgent(undefined);
1010
2532
  const prompts = await this.loadPrompts(agent.id);
1011
- const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
2533
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
1012
2534
  if (projectGuidance && taskRunId) {
1013
2535
  await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
1014
2536
  }
1015
2537
  const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
1016
2538
  const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
1017
- const docCtx = await this.gatherDocContext(task.task, taskRunId);
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);
1018
2541
  const prompt = [
1019
2542
  systemPrompt,
1020
2543
  'You are the mcoda QA agent. Given QA notes/evidence, propose structured follow-up tasks as JSON.',
@@ -1046,7 +2569,11 @@ export class QaTasksService {
1046
2569
  output = res.output ?? '';
1047
2570
  }
1048
2571
  }
1049
- catch {
2572
+ catch (error) {
2573
+ const message = error?.message ?? String(error);
2574
+ if (isAuthErrorMessage(message)) {
2575
+ throw error;
2576
+ }
1050
2577
  return [];
1051
2578
  }
1052
2579
  const tokensPrompt = this.estimateTokens(prompt);
@@ -1083,6 +2610,12 @@ export class QaTasksService {
1083
2610
  }
1084
2611
  async runAuto(task, ctx) {
1085
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
+ };
1086
2619
  await this.logTask(taskRun.id, 'Starting QA', 'qa-start');
1087
2620
  const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
1088
2621
  if (task.task.status && !allowedStatuses.has(task.task.status)) {
@@ -1103,13 +2636,70 @@ export class QaTasksService {
1103
2636
  runner: undefined,
1104
2637
  metadata: { reason: 'status_gating' },
1105
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
+ });
1106
2652
  }
1107
2653
  return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
1108
2654
  }
1109
- const branchCheck = await this.ensureTaskBranch(task, taskRun.id, ctx.request.allowDirty ?? false);
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);
1110
2700
  if (!branchCheck.ok) {
1111
2701
  if (!this.dryRunGuard) {
1112
- await this.applyStateTransition(task.task, 'infra_issue');
2702
+ await this.applyStateTransition(task.task, 'infra_issue', statusContextBase);
1113
2703
  await this.finishTaskRun(taskRun, 'failed');
1114
2704
  await this.deps.workspaceRepo.createTaskQaRun({
1115
2705
  taskId: task.task.id,
@@ -1144,336 +2734,945 @@ export class QaTasksService {
1144
2734
  createdAt: new Date().toISOString(),
1145
2735
  });
1146
2736
  }
1147
- return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'vcs_branch_missing' };
1148
- }
1149
- let profile;
1150
- try {
1151
- profile = await this.profileService.resolveProfileForTask(task.task, {
1152
- profileName: ctx.request.profileName,
1153
- level: ctx.request.level,
1154
- });
1155
- }
1156
- catch (error) {
1157
- await this.logTask(taskRun.id, `Profile resolution failed: ${error?.message ?? error}`, 'qa-profile');
1158
- await this.finishTaskRun(taskRun, 'failed');
1159
- if (!this.dryRunGuard) {
1160
- await this.deps.workspaceRepo.createTaskQaRun({
1161
- taskId: task.task.id,
1162
- taskRunId: taskRun.id,
1163
- jobId: ctx.jobId,
1164
- commandRunId: ctx.commandRunId,
1165
- source: 'auto',
1166
- mode: 'auto',
1167
- rawOutcome: 'infra_issue',
1168
- recommendation: 'infra_issue',
1169
- metadata: { reason: 'profile_resolution_failed', message: error?.message ?? String(error) },
1170
- });
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}` : ''}`);
3238
+ }
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
+ }
1171
3252
  }
1172
- return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'profile_resolution_failed' };
1173
- }
1174
- if (!profile) {
1175
- await this.logTask(taskRun.id, 'No QA profile available', 'qa-profile');
1176
- await this.finishTaskRun(taskRun, 'failed');
1177
- if (!this.dryRunGuard) {
1178
- await this.deps.workspaceRepo.createTaskQaRun({
1179
- taskId: task.task.id,
1180
- taskRunId: taskRun.id,
1181
- jobId: ctx.jobId,
1182
- commandRunId: ctx.commandRunId,
1183
- source: 'auto',
1184
- mode: 'auto',
1185
- rawOutcome: 'infra_issue',
1186
- recommendation: 'infra_issue',
1187
- metadata: { reason: 'no_profile' },
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,
1188
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
+ }
1189
3321
  }
1190
- return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'no_profile' };
1191
- }
1192
- const adapter = this.adapterForProfile(profile);
1193
- if (!adapter) {
1194
- await this.logTask(taskRun.id, 'No QA adapter for profile', 'qa-adapter');
1195
- await this.finishTaskRun(taskRun, 'failed');
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,
3376
+ });
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
+ }
3390
+ }
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;
3398
+ }
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;
1196
3413
  if (!this.dryRunGuard) {
1197
- await this.deps.workspaceRepo.createTaskQaRun({
3414
+ qaRun = await this.deps.workspaceRepo.createTaskQaRun({
1198
3415
  taskId: task.task.id,
1199
3416
  taskRunId: taskRun.id,
1200
3417
  jobId: ctx.jobId,
1201
3418
  commandRunId: ctx.commandRunId,
3419
+ agentId: interpretation.agentId,
3420
+ modelName: interpretation.modelName,
1202
3421
  source: 'auto',
1203
3422
  mode: 'auto',
1204
3423
  profileName: profile.name,
1205
3424
  runner: profile.runner,
1206
- rawOutcome: 'infra_issue',
1207
- recommendation: 'infra_issue',
1208
- 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
+ },
1209
3452
  });
1210
3453
  }
1211
- return { taskKey: task.task.key, outcome: 'infra_issue', profile: profile.name, runner: profile.runner, notes: 'no_adapter' };
1212
- }
1213
- const testCommand = await this.resolveTestCommand(profile, ctx.request.testCommand);
1214
- const qaCtx = {
1215
- workspaceRoot: this.workspace.workspaceRoot,
1216
- jobId: ctx.jobId,
1217
- taskKey: task.task.key,
1218
- env: process.env,
1219
- testCommandOverride: testCommand,
1220
- };
1221
- const ensure = await adapter.ensureInstalled(profile, qaCtx);
1222
- if (!ensure.ok) {
1223
- const guidance = 'Run "docdex setup" to install Playwright and at least one browser.';
1224
- const installMessage = ensure.message ?? 'QA install failed';
1225
- const installMessageWithGuidance = installMessage.includes('docdex setup')
1226
- ? installMessage
1227
- : `${installMessage} ${guidance}`;
1228
- await this.logTask(taskRun.id, installMessageWithGuidance, 'qa-install');
1229
3454
  if (!this.dryRunGuard) {
1230
- await this.applyStateTransition(task.task, 'infra_issue');
1231
- await this.finishTaskRun(taskRun, 'failed');
1232
- await this.deps.workspaceRepo.createTaskQaRun({
1233
- taskId: task.task.id,
1234
- taskRunId: taskRun.id,
1235
- jobId: ctx.jobId,
1236
- commandRunId: ctx.commandRunId,
1237
- source: 'auto',
1238
- mode: 'auto',
1239
- profileName: profile.name,
1240
- runner: profile.runner,
1241
- rawOutcome: 'infra_issue',
1242
- recommendation: 'infra_issue',
1243
- metadata: { install: installMessageWithGuidance, adapter: profile.runner },
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,
1244
3579
  });
1245
- const message = `QA infra issue: ${installMessageWithGuidance}`;
1246
- const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_issue' });
1247
- const body = formatTaskCommentBody({
1248
- slug,
3580
+ const status = outcome === 'pass' ? 'resolved' : 'open';
3581
+ const summaryBody = formatTaskCommentBody({
3582
+ slug: summarySlug,
1249
3583
  source: 'qa-tasks',
1250
- message,
1251
- status: 'open',
1252
- category: 'qa_issue',
3584
+ message: summaryMessage,
3585
+ status,
3586
+ category,
1253
3587
  });
3588
+ const createdAt = new Date().toISOString();
1254
3589
  await this.deps.workspaceRepo.createTaskComment({
1255
3590
  taskId: task.task.id,
1256
3591
  taskRunId: taskRun.id,
1257
3592
  jobId: ctx.jobId,
1258
3593
  sourceCommand: 'qa-tasks',
1259
3594
  authorType: 'agent',
1260
- category: 'qa_issue',
1261
- slug,
1262
- status: 'open',
1263
- body,
1264
- createdAt: new Date().toISOString(),
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
+ },
1265
3607
  });
1266
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
+ }
1267
3636
  return {
1268
3637
  taskKey: task.task.key,
1269
- outcome: 'infra_issue',
3638
+ outcome,
1270
3639
  profile: profile.name,
1271
3640
  runner: profile.runner,
1272
- notes: installMessageWithGuidance,
1273
- };
1274
- }
1275
- const artifactDir = path.join(this.workspace.workspaceRoot, '.mcoda', 'jobs', ctx.jobId, 'qa', task.task.key);
1276
- await PathHelper.ensureDir(artifactDir);
1277
- const qaEnv = { ...qaCtx.env };
1278
- const browsersPath = ensure.details?.playwrightBrowsersPath;
1279
- if (typeof browsersPath === 'string' && browsersPath.trim().length > 0) {
1280
- qaEnv.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
1281
- }
1282
- let result = await adapter.invoke(profile, { ...qaCtx, env: qaEnv, artifactDir });
1283
- result = this.adjustOutcomeForSkippedTests(profile, result, testCommand);
1284
- await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
1285
- exitCode: result.exitCode,
1286
- });
1287
- const commentContext = await this.loadCommentContext(task.task.id);
1288
- const commentBacklog = buildCommentBacklog(commentContext.unresolved);
1289
- const interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id, commentBacklog, ctx.request.abortSignal);
1290
- const outcome = this.combineOutcome(result, interpretation.recommendation);
1291
- const artifacts = result.artifacts ?? [];
1292
- const commentResolution = await this.applyCommentResolutions({
1293
- task: task.task,
1294
- taskRunId: taskRun.id,
1295
- jobId: ctx.jobId,
1296
- agentId: interpretation.agentId,
1297
- failures: interpretation.failures ?? [],
1298
- resolvedSlugs: interpretation.resolvedSlugs,
1299
- unresolvedSlugs: interpretation.unresolvedSlugs,
1300
- existingComments: commentContext.comments,
1301
- });
1302
- let qaRun;
1303
- if (!this.dryRunGuard) {
1304
- qaRun = await this.deps.workspaceRepo.createTaskQaRun({
1305
- taskId: task.task.id,
1306
- taskRunId: taskRun.id,
1307
- jobId: ctx.jobId,
1308
- commandRunId: ctx.commandRunId,
1309
- agentId: interpretation.agentId,
1310
- modelName: interpretation.modelName,
1311
- source: 'auto',
1312
- mode: 'auto',
1313
- profileName: profile.name,
1314
- runner: profile.runner,
1315
- rawOutcome: result.outcome,
1316
- recommendation: interpretation.recommendation,
1317
3641
  artifacts,
1318
- rawResult: {
1319
- adapter: result,
1320
- agent: interpretation.rawOutput,
1321
- },
1322
- startedAt: result.startedAt,
1323
- finishedAt: result.finishedAt,
1324
- metadata: {
1325
- tokensPrompt: interpretation.tokensPrompt,
1326
- tokensCompletion: interpretation.tokensCompletion,
1327
- testedScope: interpretation.testedScope,
1328
- coverageSummary: interpretation.coverageSummary,
1329
- failures: interpretation.failures,
1330
- invalidJson: interpretation.invalidJson ?? false,
1331
- },
1332
- });
1333
- }
1334
- if (!this.dryRunGuard) {
1335
- await this.applyStateTransition(task.task, outcome);
1336
- await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
3642
+ followups,
3643
+ };
1337
3644
  }
1338
- const followups = [];
1339
- const wantsFollowups = ctx.request.createFollowupTasks !== 'none';
1340
- const needsManualFollowup = interpretation.invalidJson === true;
1341
- if ((outcome === 'fix_required' || needsManualFollowup) && wantsFollowups) {
1342
- const suggestions = interpretation.followUps?.map((f) => this.toFollowupSuggestion(task.task, f, artifacts)) ?? [];
1343
- if (needsManualFollowup) {
1344
- suggestions.unshift(this.buildManualQaFollowup(task.task, interpretation.rawOutput));
1345
- }
1346
- else if (suggestions.length === 0) {
1347
- suggestions.push(this.buildFollowupSuggestion(task.task, result, ctx.request.notes));
1348
- }
1349
- const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
1350
- for (const suggestion of suggestions) {
1351
- const followupSlug = this.buildFollowupSlug(task.task, suggestion);
1352
- const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
1353
- if (existing.length) {
1354
- await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
1355
- continue;
1356
- }
1357
- let proceed = ctx.request.createFollowupTasks !== 'prompt';
1358
- if (interactive) {
1359
- const rl = readline.createInterface({ input, output });
1360
- const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
1361
- rl.close();
1362
- proceed = answer.trim().toLowerCase().startsWith('y');
1363
- }
1364
- if (!proceed)
1365
- continue;
3645
+ finally {
3646
+ if (serverHandle) {
1366
3647
  try {
1367
- if (!this.dryRunGuard) {
1368
- const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
1369
- followups.push(created.task.key);
1370
- await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
1371
- }
3648
+ await serverHandle.stop();
3649
+ await this.logTask(taskRun.id, 'QA server stopped.', 'qa-server');
1372
3650
  }
1373
3651
  catch (error) {
1374
- await this.logTask(taskRun.id, `Failed to create follow-up task: ${error?.message ?? error}`, 'qa-followup');
1375
- }
1376
- }
1377
- }
1378
- const bodyLines = [
1379
- `QA outcome: ${outcome}`,
1380
- outcome === 'unclear'
1381
- ? 'QA outcome unclear: provide missing acceptance criteria, reproduction steps, and expected behavior.'
1382
- : '',
1383
- profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
1384
- interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
1385
- interpretation.failures && interpretation.failures.length
1386
- ? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
1387
- : '',
1388
- commentResolution
1389
- ? `Comment slugs: resolved ${commentResolution.resolved.length}, reopened ${commentResolution.reopened.length}, open ${commentResolution.open.length}`
1390
- : '',
1391
- interpretation.invalidJson && interpretation.rawOutput
1392
- ? `QA agent output (invalid JSON):\n${interpretation.rawOutput.slice(0, 4000)}`
1393
- : '',
1394
- result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
1395
- result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
1396
- artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
1397
- followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
1398
- ].filter(Boolean);
1399
- if (!this.dryRunGuard) {
1400
- const category = outcome === 'pass' ? 'qa_result' : 'qa_issue';
1401
- const summaryMessage = bodyLines.join('\n\n');
1402
- const summarySlug = createTaskCommentSlug({
1403
- source: 'qa-tasks',
1404
- message: summaryMessage,
1405
- category,
1406
- });
1407
- const status = outcome === 'pass' ? 'resolved' : 'open';
1408
- const summaryBody = formatTaskCommentBody({
1409
- slug: summarySlug,
1410
- source: 'qa-tasks',
1411
- message: summaryMessage,
1412
- status,
1413
- category,
1414
- });
1415
- const createdAt = new Date().toISOString();
1416
- await this.deps.workspaceRepo.createTaskComment({
1417
- taskId: task.task.id,
1418
- taskRunId: taskRun.id,
1419
- jobId: ctx.jobId,
1420
- sourceCommand: 'qa-tasks',
1421
- authorType: 'agent',
1422
- authorAgentId: interpretation.agentId ?? null,
1423
- category,
1424
- slug: summarySlug,
1425
- status,
1426
- body: summaryBody,
1427
- createdAt,
1428
- resolvedAt: status === 'resolved' ? createdAt : null,
1429
- resolvedBy: status === 'resolved' ? interpretation.agentId ?? null : null,
1430
- metadata: {
1431
- ...(artifacts.length ? { artifacts } : {}),
1432
- ...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
1433
- },
1434
- });
1435
- }
1436
- const ratingTokens = (interpretation.tokensPrompt ?? 0) + (interpretation.tokensCompletion ?? 0);
1437
- if (ctx.request.rateAgents && interpretation.agentId && ratingTokens > 0) {
1438
- try {
1439
- const ratingService = this.ensureRatingService();
1440
- await ratingService.rate({
1441
- workspace: this.workspace,
1442
- agentId: interpretation.agentId,
1443
- commandName: 'qa-tasks',
1444
- jobId: ctx.jobId,
1445
- commandRunId: ctx.commandRunId,
1446
- taskId: task.task.id,
1447
- taskKey: task.task.key,
1448
- discipline: task.task.type ?? undefined,
1449
- complexity: this.resolveTaskComplexity(task.task),
1450
- });
3652
+ await this.logTask(taskRun.id, `QA server shutdown failed: ${error?.message ?? error}`, 'qa-server');
3653
+ }
1451
3654
  }
1452
- catch (error) {
1453
- const message = `Agent rating failed for ${task.task.key}: ${error instanceof Error ? error.message : String(error)}`;
1454
- ctx.warnings?.push(message);
3655
+ if (cleanupWorktree) {
1455
3656
  try {
1456
- await this.logTask(taskRun.id, message, 'rating');
3657
+ await cleanupWorktree();
1457
3658
  }
1458
- catch {
1459
- /* ignore rating log failures */
3659
+ catch (error) {
3660
+ await this.logTask(taskRun.id, `QA cleanup failed: ${error?.message ?? error}`, 'qa-cleanup');
1460
3661
  }
1461
3662
  }
1462
3663
  }
1463
- return {
1464
- taskKey: task.task.key,
1465
- outcome,
1466
- profile: profile.name,
1467
- runner: profile.runner,
1468
- artifacts,
1469
- followups,
1470
- };
1471
3664
  }
1472
3665
  async runManual(task, ctx) {
1473
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
+ };
1474
3673
  const result = ctx.request.result ?? 'pass';
1475
3674
  const notes = ctx.request.notes;
1476
- const outcome = result === 'pass' ? 'pass' : result === 'blocked' ? 'infra_issue' : 'fix_required';
3675
+ const outcome = result === 'pass' ? 'pass' : 'fix_required';
1477
3676
  const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
1478
3677
  if (task.task.status && !allowedStatuses.has(task.task.status)) {
1479
3678
  const message = `Task status ${task.task.status} not allowed for manual QA`;
@@ -1482,7 +3681,7 @@ export class QaTasksService {
1482
3681
  return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
1483
3682
  }
1484
3683
  if (!ctx.request.dryRun) {
1485
- await this.applyStateTransition(task.task, outcome);
3684
+ await this.applyStateTransition(task.task, outcome, statusContext);
1486
3685
  await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
1487
3686
  }
1488
3687
  const followups = [];
@@ -1515,7 +3714,9 @@ export class QaTasksService {
1515
3714
  evidenceUrl: ctx.request.evidenceUrl,
1516
3715
  },
1517
3716
  ];
1518
- 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
+ : [];
1519
3720
  if (agentSuggestions.length) {
1520
3721
  suggestions.unshift(...agentSuggestions);
1521
3722
  }
@@ -1594,6 +3795,8 @@ export class QaTasksService {
1594
3795
  };
1595
3796
  }
1596
3797
  async run(request) {
3798
+ this.qaProfilePlan = undefined;
3799
+ this.qaTaskPlans = undefined;
1597
3800
  const resume = request.resumeJobId ? await this.deps.jobService.getJob(request.resumeJobId) : undefined;
1598
3801
  if (request.resumeJobId && !resume) {
1599
3802
  throw new Error(`Resume requested but job ${request.resumeJobId} not found`);
@@ -1603,13 +3806,22 @@ export class QaTasksService {
1603
3806
  const effectiveStory = request.storyKey ?? resume?.payload?.storyKey;
1604
3807
  const effectiveTasks = request.taskKeys?.length ? request.taskKeys : resume?.payload?.tasks;
1605
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);
1606
3812
  const selection = await this.selectionService.selectTasks({
1607
3813
  projectKey: effectiveProject,
1608
3814
  epicKey: effectiveEpic,
1609
3815
  storyKey: effectiveStory,
1610
3816
  taskKeys: effectiveTasks,
1611
- statusFilter: effectiveStatus,
3817
+ statusFilter,
3818
+ limit: effectiveLimit,
3819
+ ignoreDependencies: true,
3820
+ ignoreStatusFilter,
1612
3821
  });
3822
+ if (rejected.length > 0 && !ignoreStatusFilter) {
3823
+ selection.warnings.push(`qa-tasks ignores unsupported statuses: ${rejected.join(", ")}. Allowed: ${QA_ALLOWED_STATUSES.join(", ")}.`);
3824
+ }
1613
3825
  const abortSignal = request.abortSignal;
1614
3826
  const resolveAbortReason = () => {
1615
3827
  const reason = abortSignal?.reason;
@@ -1624,27 +3836,37 @@ export class QaTasksService {
1624
3836
  throw new Error(resolveAbortReason());
1625
3837
  }
1626
3838
  };
3839
+ const mode = request.mode ?? 'auto';
1627
3840
  this.dryRunGuard = request.dryRun ?? false;
1628
3841
  if (request.dryRun) {
1629
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
+ }
1630
3851
  for (const task of selection.ordered) {
1631
3852
  abortIfSignaled();
1632
- let profile;
3853
+ let profiles = [];
1633
3854
  try {
1634
- profile = await this.profileService.resolveProfileForTask(task.task, {
1635
- profileName: request.profileName,
1636
- level: request.level,
1637
- });
3855
+ profiles = await this.resolveProfilesForRequest(task.task, request);
1638
3856
  }
1639
3857
  catch {
1640
- profile = undefined;
3858
+ profiles = [];
1641
3859
  }
3860
+ const profile = profiles[0];
3861
+ const profileNames = profiles.map((entry) => entry.name);
1642
3862
  dryResults.push({
1643
3863
  taskKey: task.task.key,
1644
3864
  outcome: profile ? 'unclear' : 'infra_issue',
1645
- profile: profile?.name,
1646
- runner: profile?.runner,
1647
- 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',
1648
3870
  });
1649
3871
  }
1650
3872
  return {
@@ -1688,8 +3910,9 @@ export class QaTasksService {
1688
3910
  epicKey: effectiveEpic,
1689
3911
  storyKey: effectiveStory,
1690
3912
  tasks: effectiveTasks,
1691
- statusFilter: effectiveStatus,
1692
- mode: request.mode ?? 'auto',
3913
+ statusFilter,
3914
+ limit: effectiveLimit,
3915
+ mode,
1693
3916
  profile: request.profileName,
1694
3917
  level: request.level,
1695
3918
  agent: request.agentName,
@@ -1700,6 +3923,16 @@ export class QaTasksService {
1700
3923
  totalItems: selection.ordered.length,
1701
3924
  processedItems: completedKeys.size,
1702
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
+ }
1703
3936
  if (resume?.id) {
1704
3937
  try {
1705
3938
  const qaRuns = await this.deps.workspaceRepo.listTaskQaRunsForJob(selection.ordered.map((t) => t.task.id), resume.id);
@@ -1715,8 +3948,8 @@ export class QaTasksService {
1715
3948
  }
1716
3949
  }
1717
3950
  const remaining = selection.ordered.filter((t) => !completedKeys.has(t.task.key));
1718
- // Skip tasks that are already in a terminal QA state for this job (ready_to_qa -> completed/in_progress/blocked)
1719
- 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']);
1720
3953
  const skippedTerminal = [];
1721
3954
  for (const t of remaining) {
1722
3955
  if (terminalStatuses.has(t.task.status?.toLowerCase?.() ?? '')) {
@@ -1735,11 +3968,60 @@ export class QaTasksService {
1735
3968
  });
1736
3969
  await this.checkpoint(job.id, 'selection', {
1737
3970
  ordered: selection.ordered.map((t) => t.task.key),
1738
- blocked: selection.blocked.map((t) => t.task.key),
1739
3971
  completedTaskKeys: Array.from(completedKeys),
1740
3972
  });
1741
3973
  const warnings = [...selection.warnings];
1742
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
+ };
1743
4025
  for (const task of selection.ordered) {
1744
4026
  if (completedKeys.has(task.task.key)) {
1745
4027
  results.push(priorResults.get(task.task.key) ?? { taskKey: task.task.key, outcome: 'pass', notes: 'skipped (resume)' });
@@ -1749,14 +4031,74 @@ export class QaTasksService {
1749
4031
  try {
1750
4032
  let processedCount = completedKeys.size;
1751
4033
  for (const [index, task] of filteredRemaining.entries()) {
4034
+ if (abortRemainingReason)
4035
+ break;
1752
4036
  abortIfSignaled();
1753
4037
  const mode = request.mode ?? 'auto';
1754
- if (mode === 'manual') {
1755
- 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
+ }
1756
4061
  }
1757
- else {
1758
- results.push(await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request, warnings }));
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;
1759
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
+ });
1760
4102
  completedKeys.add(task.task.key);
1761
4103
  processedCount = completedKeys.size;
1762
4104
  await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
@@ -1766,9 +4108,18 @@ export class QaTasksService {
1766
4108
  taskResult: results[results.length - 1],
1767
4109
  });
1768
4110
  }
4111
+ if (abortRemainingReason) {
4112
+ warnings.push(`Stopped remaining tasks due to auth/rate limit: ${abortRemainingReason}`);
4113
+ }
1769
4114
  const failureCount = results.filter((r) => r.outcome !== 'pass').length;
1770
- const state = failureCount === 0 ? 'completed' : failureCount === results.length ? 'failed' : 'partial';
1771
- 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);
1772
4123
  await this.deps.jobService.updateJobStatus(job.id, state, { errorSummary });
1773
4124
  await this.deps.jobService.finishCommandRun(commandRun.id, state === 'completed' ? 'succeeded' : 'failed', errorSummary);
1774
4125
  await this.checkpoint(job.id, 'completed', {