@straiffi/archon 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/dist/client/assets/ActivityView-BgMz3NNT.js +1 -0
  2. package/dist/client/assets/GitHubActionsDialog-CNqWDS3E.js +1 -0
  3. package/dist/client/assets/TestsDialog-Cll7xJe0.js +4 -0
  4. package/dist/client/assets/{architectureDiagram-3BPJPVTR-Do9nbC8v.js → architectureDiagram-3BPJPVTR-BL5dYiul.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-GPEHLZMM-DGGOLHF8.js → blockDiagram-GPEHLZMM-Bi5x2v80.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-AAUBKEIU-Bz7tQybs.js → c4Diagram-AAUBKEIU-esEm54Ay.js} +1 -1
  7. package/dist/client/assets/channel-CqNHF3FD.js +1 -0
  8. package/dist/client/assets/{chunk-2J33WTMH-CJO-GmWJ.js → chunk-2J33WTMH-DACfY8xM.js} +1 -1
  9. package/dist/client/assets/{chunk-3OPIFGDE-DPAVagpC.js → chunk-3OPIFGDE-DZNUiaUs.js} +1 -1
  10. package/dist/client/assets/{chunk-5ZQYHXKU-Cof75Mu7.js → chunk-5ZQYHXKU-ClDfkMAp.js} +1 -1
  11. package/dist/client/assets/{chunk-727SXJPM-COuPqQ8_.js → chunk-727SXJPM-CCwA3GWT.js} +1 -1
  12. package/dist/client/assets/{chunk-AQP2D5EJ-CFj5KabW.js → chunk-AQP2D5EJ-Byl4DRZ9.js} +1 -1
  13. package/dist/client/assets/{chunk-CSCIHK7Q-C4M6w5cT.js → chunk-CSCIHK7Q-CXymZQ7A.js} +1 -1
  14. package/dist/client/assets/{chunk-KSCS5N6A-ByaiwVue.js → chunk-KSCS5N6A-VV3D8g6a.js} +1 -1
  15. package/dist/client/assets/{chunk-L5ZTLDWV-Cx5jeot9.js → chunk-L5ZTLDWV-BL9N5GRI.js} +1 -1
  16. package/dist/client/assets/{chunk-LZXEDZCA-DmGtztud.js → chunk-LZXEDZCA-DKNN-zno.js} +2 -2
  17. package/dist/client/assets/{chunk-ND2GUHAM-i3yuspqL.js → chunk-ND2GUHAM-CHCpQ1yr.js} +1 -1
  18. package/dist/client/assets/{chunk-NZK2D7GU-CQ4E_jP4.js → chunk-NZK2D7GU-B0Dm74mt.js} +1 -1
  19. package/dist/client/assets/{chunk-O5CBEL6O-D2g4VDCd.js → chunk-O5CBEL6O-DAa5z2xG.js} +1 -1
  20. package/dist/client/assets/{chunk-WU5MYG2G-exIu-zQ2.js → chunk-WU5MYG2G-DrVJWKVR.js} +1 -1
  21. package/dist/client/assets/classDiagram-4FO5ZUOK-BjEgQwKg.js +1 -0
  22. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-gGomH-F_.js +1 -0
  23. package/dist/client/assets/{dagre-BM42HDAG-DvF75yLZ.js → dagre-BM42HDAG-B0fsl8mE.js} +1 -1
  24. package/dist/client/assets/{diagram-2AECGRRQ-DFv0HxLL.js → diagram-2AECGRRQ-Cx_VEbSS.js} +1 -1
  25. package/dist/client/assets/{diagram-5GNKFQAL-C_LRir0-.js → diagram-5GNKFQAL-Cv7VVn5r.js} +1 -1
  26. package/dist/client/assets/{diagram-KO2AKTUF-DGZxRDRy.js → diagram-KO2AKTUF-C8Sl5czV.js} +1 -1
  27. package/dist/client/assets/{diagram-LMA3HP47-UXt0uti4.js → diagram-LMA3HP47-C3dZaLz2.js} +1 -1
  28. package/dist/client/assets/{diagram-OG6HWLK6-CFbZS4n5.js → diagram-OG6HWLK6-BAU9DtTr.js} +1 -1
  29. package/dist/client/assets/{erDiagram-TEJ5UH35-CfAwZDdf.js → erDiagram-TEJ5UH35-DpNc-eBD.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cdgi5z3n.js → flowDiagram-I6XJVG4X-DGwTAT1S.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-6RSMTGT7-DOrkFkhu.js → ganttDiagram-6RSMTGT7-MPZhEgzJ.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-cEox0peD.js → gitGraphDiagram-PVQCEYII-CY0tHL5N.js} +1 -1
  33. package/dist/client/assets/index-CJRgmWwY.js +273 -0
  34. package/dist/client/assets/index-mRkJWu70.css +2 -0
  35. package/dist/client/assets/{infoDiagram-5YYISTIA-gm51u2Nn.js → infoDiagram-5YYISTIA-BBZeKjf4.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CjnoOIja.js → ishikawaDiagram-YF4QCWOH-CM_iFrES.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-JHISSGLW-Dlg4DsiO.js → journeyDiagram-JHISSGLW-Do_lY94X.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-UN3LZRKU-BU0XZaSd.js → kanban-definition-UN3LZRKU-brxg3j37.js} +1 -1
  39. package/dist/client/assets/{line-Bert22-_.js → line-PHHbm9rj.js} +1 -1
  40. package/dist/client/assets/{mermaid-parser.core-BvQSGDmL.js → mermaid-parser.core-CLxAkW69.js} +1 -1
  41. package/dist/client/assets/{mermaid.core--qSAHJRj.js → mermaid.core-CebvSmx9.js} +3 -3
  42. package/dist/client/assets/{mindmap-definition-RKZ34NQL-BgFIvWWD.js → mindmap-definition-RKZ34NQL-CX_-ZaJF.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-4H26LBE5-DGcbXD0M.js → pieDiagram-4H26LBE5-BJuHvql_.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-ClkBUeia.js → quadrantDiagram-W4KKPZXB-DiJll7l9.js} +1 -1
  45. package/dist/client/assets/{requirementDiagram-4Y6WPE33-B8CwGToU.js → requirementDiagram-4Y6WPE33-Bg-tXgcj.js} +1 -1
  46. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DTk3jL6B.js → sankeyDiagram-5OEKKPKP-BoK2egfZ.js} +1 -1
  47. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQVrcnKf.js → sequenceDiagram-3UESZ5HK-DcrDWNzv.js} +1 -1
  48. package/dist/client/assets/{stateDiagram-AJRCARHV-DD4yMZPB.js → stateDiagram-AJRCARHV-B995qt1S.js} +1 -1
  49. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Dn8P7Sa1.js +1 -0
  50. package/dist/client/assets/{timeline-definition-PNZ67QCA-BnCBVqcd.js → timeline-definition-PNZ67QCA-DqSVAhqL.js} +1 -1
  51. package/dist/client/assets/{vennDiagram-CIIHVFJN-BDmLMNXS.js → vennDiagram-CIIHVFJN-D9wVraVY.js} +1 -1
  52. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DSLuQiEv.js → wardleyDiagram-YWT4CUSO-CLff9zVd.js} +1 -1
  53. package/dist/client/assets/{xychartDiagram-2RQKCTM6-DAq_LA4r.js → xychartDiagram-2RQKCTM6-BuHUMMH2.js} +1 -1
  54. package/dist/client/index.html +2 -2
  55. package/dist/server/db.js +472 -39
  56. package/dist/server/db.js.map +1 -1
  57. package/dist/server/index.js +1311 -212
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/lib/agent.js +228 -71
  60. package/dist/server/lib/agent.js.map +1 -1
  61. package/dist/server/lib/agentRuns.js +205 -38
  62. package/dist/server/lib/agentRuns.js.map +1 -1
  63. package/dist/server/lib/attachments.js +24 -0
  64. package/dist/server/lib/attachments.js.map +1 -1
  65. package/dist/server/lib/automationProgression.js +464 -0
  66. package/dist/server/lib/automationProgression.js.map +1 -0
  67. package/dist/server/lib/automationReconciliation.js +498 -0
  68. package/dist/server/lib/automationReconciliation.js.map +1 -0
  69. package/dist/server/lib/automationStageDetails.js +232 -0
  70. package/dist/server/lib/automationStageDetails.js.map +1 -0
  71. package/dist/server/lib/buildOpenQuestionOperations.js +238 -0
  72. package/dist/server/lib/buildOpenQuestionOperations.js.map +1 -0
  73. package/dist/server/lib/buildOpenQuestions.js +44 -0
  74. package/dist/server/lib/buildOpenQuestions.js.map +1 -1
  75. package/dist/server/lib/buildSequences.js +130 -46
  76. package/dist/server/lib/buildSequences.js.map +1 -1
  77. package/dist/server/lib/bundleReviewOperations.js +91 -9
  78. package/dist/server/lib/bundleReviewOperations.js.map +1 -1
  79. package/dist/server/lib/chatOperations.js +67 -12
  80. package/dist/server/lib/chatOperations.js.map +1 -1
  81. package/dist/server/lib/executionReconciliation.js +518 -0
  82. package/dist/server/lib/executionReconciliation.js.map +1 -0
  83. package/dist/server/lib/executionRetry.js +694 -0
  84. package/dist/server/lib/executionRetry.js.map +1 -0
  85. package/dist/server/lib/executionRetryContext.js +489 -0
  86. package/dist/server/lib/executionRetryContext.js.map +1 -0
  87. package/dist/server/lib/executionRetryPredicates.js +74 -0
  88. package/dist/server/lib/executionRetryPredicates.js.map +1 -0
  89. package/dist/server/lib/executionRunRetention.js +141 -0
  90. package/dist/server/lib/executionRunRetention.js.map +1 -0
  91. package/dist/server/lib/executionRuns.js +1367 -0
  92. package/dist/server/lib/executionRuns.js.map +1 -0
  93. package/dist/server/lib/executionTimeline.js +45 -0
  94. package/dist/server/lib/executionTimeline.js.map +1 -0
  95. package/dist/server/lib/git.js +385 -6
  96. package/dist/server/lib/git.js.map +1 -1
  97. package/dist/server/lib/integrations/github.js +43 -0
  98. package/dist/server/lib/integrations/github.js.map +1 -1
  99. package/dist/server/lib/integrations/githubActions.js +1 -0
  100. package/dist/server/lib/integrations/githubActions.js.map +1 -1
  101. package/dist/server/lib/integrations/githubActionsSessions.js +337 -114
  102. package/dist/server/lib/integrations/githubActionsSessions.js.map +1 -1
  103. package/dist/server/lib/projectMemorySelections.js +7 -3
  104. package/dist/server/lib/projectMemorySelections.js.map +1 -1
  105. package/dist/server/lib/shutdown.js +2 -2
  106. package/dist/server/lib/shutdown.js.map +1 -1
  107. package/dist/server/lib/specArtifact.js +3 -0
  108. package/dist/server/lib/specArtifact.js.map +1 -1
  109. package/dist/server/lib/specFormatting.js +1 -1
  110. package/dist/server/lib/specFormatting.js.map +1 -1
  111. package/dist/server/lib/specOperations.js +78 -12
  112. package/dist/server/lib/specOperations.js.map +1 -1
  113. package/dist/server/lib/specReviewContext.js +54 -18
  114. package/dist/server/lib/specReviewContext.js.map +1 -1
  115. package/dist/server/lib/specReviewDraft.js +3 -2
  116. package/dist/server/lib/specReviewDraft.js.map +1 -1
  117. package/dist/server/lib/specReviewOperations.js +74 -9
  118. package/dist/server/lib/specReviewOperations.js.map +1 -1
  119. package/dist/server/lib/specReviewValidation.js +217 -0
  120. package/dist/server/lib/specReviewValidation.js.map +1 -0
  121. package/dist/server/lib/specReviews.js +30 -2
  122. package/dist/server/lib/specReviews.js.map +1 -1
  123. package/dist/server/lib/specs.js +18 -1
  124. package/dist/server/lib/specs.js.map +1 -1
  125. package/dist/server/lib/stageSnapshots.js +197 -0
  126. package/dist/server/lib/stageSnapshots.js.map +1 -0
  127. package/dist/server/lib/testSessions.js +160 -18
  128. package/dist/server/lib/testSessions.js.map +1 -1
  129. package/dist/server/lib/ticketAutomation.js +80 -4
  130. package/dist/server/lib/ticketAutomation.js.map +1 -1
  131. package/dist/server/lib/ticketAutomationCommit.js +466 -91
  132. package/dist/server/lib/ticketAutomationCommit.js.map +1 -1
  133. package/dist/server/lib/ticketFollowUpOperations.js +270 -29
  134. package/dist/server/lib/ticketFollowUpOperations.js.map +1 -1
  135. package/dist/server/lib/ticketMessages.js +16 -0
  136. package/dist/server/lib/ticketMessages.js.map +1 -1
  137. package/dist/server/lib/ticketRunOperations.js +51 -6
  138. package/dist/server/lib/ticketRunOperations.js.map +1 -1
  139. package/dist/server/lib/ticketUndo.js +77 -168
  140. package/dist/server/lib/ticketUndo.js.map +1 -1
  141. package/dist/server/lib/ticketWorkflowOperations.js +132 -28
  142. package/dist/server/lib/ticketWorkflowOperations.js.map +1 -1
  143. package/dist/server/lib/tickets.js +41 -27
  144. package/dist/server/lib/tickets.js.map +1 -1
  145. package/dist/server/lib/transcriptRuns.js +3 -3
  146. package/dist/server/lib/transcriptRuns.js.map +1 -1
  147. package/dist/server/lib/turnExecutionErrors.js +12 -0
  148. package/dist/server/lib/turnExecutionErrors.js.map +1 -0
  149. package/dist/server/lib/types.js +58 -1
  150. package/dist/server/lib/types.js.map +1 -1
  151. package/dist/server/workers/build.js +596 -184
  152. package/dist/server/workers/build.js.map +1 -1
  153. package/dist/server/workers/chat.js +269 -132
  154. package/dist/server/workers/chat.js.map +1 -1
  155. package/dist/server/workers/followUp.js +731 -203
  156. package/dist/server/workers/followUp.js.map +1 -1
  157. package/dist/server/workers/plan.js +546 -188
  158. package/dist/server/workers/plan.js.map +1 -1
  159. package/dist/server/workers/planFollowUp.js +240 -75
  160. package/dist/server/workers/planFollowUp.js.map +1 -1
  161. package/dist/server/workers/review.js +790 -297
  162. package/dist/server/workers/review.js.map +1 -1
  163. package/dist/server/workers/specReview.js +212 -46
  164. package/dist/server/workers/specReview.js.map +1 -1
  165. package/dist/server/workers/specSession.js +434 -254
  166. package/dist/server/workers/specSession.js.map +1 -1
  167. package/package.json +1 -1
  168. package/dist/client/assets/GitHubActionsDialog-DAD5REmY.js +0 -1
  169. package/dist/client/assets/TestsDialog-B5NVrroq.js +0 -4
  170. package/dist/client/assets/TicketChangesModal-By-_gTGd.js +0 -1
  171. package/dist/client/assets/channel-Dd5Len0X.js +0 -1
  172. package/dist/client/assets/classDiagram-4FO5ZUOK-B7KUg6r8.js +0 -1
  173. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CQm-z5c4.js +0 -1
  174. package/dist/client/assets/index-BHq6-Eir.js +0 -270
  175. package/dist/client/assets/index-BxTc5qQT.css +0 -2
  176. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-C8yO6POT.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { formatAgentErrorForTranscript, getAgentErrorTranscriptRuns, registerSpecProcessPlaceholder, runSpecAgent, untrackSpecProcessById } from '../lib/agent.js';
2
- import { clearAndEmitAgentRunsForParent } from '../lib/agentRuns.js';
2
+ import { createAgentRun, emitAgentRunsUpdated, finalizeAgentRun, getNextAgentRunIndex, terminalizeAgentRunsForExecutionRun } from '../lib/agentRuns.js';
3
3
  import { deleteStagedSpecAttachments, linkSpecAttachmentsToMessage, stageSpecAttachmentsForAgent } from '../lib/attachments.js';
4
4
  import { createSpecMessage } from '../lib/specMessages.js';
5
5
  import { beginSpecSessionThinkingRun, bindSpecSessionThinkingMessage } from '../lib/thinkingState.js';
@@ -12,6 +12,8 @@ import { persistSpecSessionProjectMemorySuggestions } from '../lib/projectMemory
12
12
  import { extractProjectMemorySuggestionsFromOutput, getProjectMemorySuggestionPromptRules, } from '../lib/projectMemorySuggestions.js';
13
13
  import { enqueue, isRunning } from '../lib/queue.js';
14
14
  import { getProjectById } from '../lib/projects.js';
15
+ import { addExecutionRunLink, transitionExecutionRun } from '../lib/executionRuns.js';
16
+ import { TurnStartError } from '../lib/turnExecutionErrors.js';
15
17
  import { buildLinkedResearchContext, prepareLinkedPlanningResearch } from './plan.js';
16
18
  import { extractGrillQuestionFromOutput, extractSpecArtifactFromOutput, resolveCarriedForwardArtifacts, resolveCarriedForwardStructuredFields, stripMermaidBlocks } from '../lib/specArtifact.js';
17
19
  import { IMPLEMENTATION_BODY_DEDUPLICATION_RULES, IMPLEMENTATION_DETAIL_RULES, IMPLEMENTATION_READY_DESCRIPTION_REQUIREMENTS, IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE, } from '../lib/ticketDescriptionContract.js';
@@ -39,7 +41,7 @@ const formatPreviousSpecStateBlock = (snapshot) => {
39
41
  if (snapshot.likely_files.length > 0) {
40
42
  lines.push('Likely files:');
41
43
  for (const file of snapshot.likely_files) {
42
- lines.push(`- ${file.path} — ${file.reason}`);
44
+ lines.push(`- [${file.is_new ? 'new' : 'existing'}] ${file.path} — ${file.reason}`);
43
45
  }
44
46
  }
45
47
  if (snapshot.assumptions.length > 0) {
@@ -124,7 +126,7 @@ const buildStandardModeTail = ({ existingMermaid, existingMockup, existingFileRe
124
126
  '',
125
127
  '4. A machine-readable artifact block with the spec structured data, using exactly these markers:',
126
128
  ' <archon-spec-artifact>',
127
- ' {"summary":"short plan summary","likely_files":[{"path":"src/foo.ts","reason":"Add bar helper"},{"path":"src/consumer.ts","reason":"Call the new bar helper"}],"tickets":[{"key":"add-foo-bar","title":"Add bar helper to foo module","description":"## Problem Statement\\nThe foo module is missing the `bar` helper, blocking downstream consumers from validating inputs consistently.\\n\\n## Acceptance Criteria\\n- [ ] A `bar(input)` function is exported from `src/foo.ts`.\\n- [ ] Invalid inputs throw a typed error with a clear message.\\n- [ ] All existing callers of foo continue to work unchanged.\\n\\n## Implementation Approach\\n1. Add `bar` to `src/foo.ts` following the existing `baz` helper signature pattern.\\n2. Reuse the project existing validation utilities instead of inlining checks.\\n3. Export `bar` from the module barrel file.\\n\\n## Edge Cases and Error Handling\\n- Empty string inputs should throw the same typed error as other invalid inputs.\\n- Preserve current behavior for `undefined`/`null` (do not coerce).\\n\\n## Constraints and Things to Avoid\\n- Do not introduce a new dependency.\\n- Do not change the public signature of existing exports."},{"key":"use-bar-in-consumer","title":"Call bar from consumer module","description":"## Problem Statement\\nThe consumer module duplicates input validation that `bar` now provides.\\n\\n## Acceptance Criteria\\n- [ ] Consumer calls `bar` instead of its own inline validation.\\n- [ ] Existing consumer tests still pass.\\n\\n## Implementation Approach\\n1. Replace the inline check in consumer with a call to `bar`.\\n2. Remove the now-unused validation helper from consumer.\\n\\n## Edge Cases and Error Handling\\n- Consumer must handle the typed errors thrown by `bar`.\\n\\n## Constraints and Things to Avoid\\n- Do not change the consumer public API."}],"dependencies":[{"blocker_key":"add-foo-bar","dependent_key":"use-bar-in-consumer"}]}',
129
+ ' {"summary":"short plan summary","likely_files":[{"path":"src/foo.ts","reason":"Add bar helper","is_new":false},{"path":"src/newHelper.ts","reason":"Provide the new shared helper","is_new":true}],"tickets":[{"key":"add-foo-bar","title":"Add bar helper to foo module","description":"## Problem Statement\\nThe foo module is missing the `bar` helper, blocking downstream consumers from validating inputs consistently.\\n\\n## Acceptance Criteria\\n- [ ] A `bar(input)` function is exported from `src/foo.ts`.\\n- [ ] Invalid inputs throw a typed error with a clear message.\\n- [ ] All existing callers of foo continue to work unchanged.\\n\\n## Implementation Approach\\n1. Add `bar` to `src/foo.ts` following the existing `baz` helper signature pattern.\\n2. Reuse the project existing validation utilities instead of inlining checks.\\n3. Export `bar` from the module barrel file.\\n\\n## Edge Cases and Error Handling\\n- Empty string inputs should throw the same typed error as other invalid inputs.\\n- Preserve current behavior for `undefined`/`null` (do not coerce).\\n\\n## Constraints and Things to Avoid\\n- Do not introduce a new dependency.\\n- Do not change the public signature of existing exports."},{"key":"use-bar-in-consumer","title":"Call bar from consumer module","description":"## Problem Statement\\nThe consumer module duplicates input validation that `bar` now provides.\\n\\n## Acceptance Criteria\\n- [ ] Consumer calls `bar` instead of its own inline validation.\\n- [ ] Existing consumer tests still pass.\\n\\n## Implementation Approach\\n1. Replace the inline check in consumer with a call to `bar`.\\n2. Remove the now-unused validation helper from consumer.\\n\\n## Edge Cases and Error Handling\\n- Consumer must handle the typed errors thrown by `bar`.\\n\\n## Constraints and Things to Avoid\\n- Do not change the consumer public API."}],"dependencies":[{"blocker_key":"add-foo-bar","dependent_key":"use-bar-in-consumer"}]}',
128
130
  ' </archon-spec-artifact>',
129
131
  '',
130
132
  'The artifact represents the complete current state of the spec. On follow-up turns, re-emit every field',
@@ -135,9 +137,11 @@ const buildStandardModeTail = ({ existingMermaid, existingMockup, existingFileRe
135
137
  ' what this turn did, what was emitted, or what changed; the summary describes the plan itself.',
136
138
  '- `assumptions` — array of assumptions that affect the plan.',
137
139
  '- `open_questions` — array of unresolved questions needing clarification.',
138
- '- `likely_files` — array of {"path": "...", "reason": "..."} objects for files you expect to create or',
139
- ' modify. If your human-readable text includes a table of files to change, include the same files here.',
140
- ' The `reason` should describe what change is needed, not just repeat the path.',
140
+ '- `likely_files` — array of {"path": "...", "reason": "...", "is_new": boolean} objects for files you',
141
+ ' expect to create or modify. Inspect the repository before classifying each path: set `is_new` to true',
142
+ ' only when the path does not exist and the implementation will create it; use false for an existing file',
143
+ ' that will be modified. If your human-readable text includes a table of files to change, include the same',
144
+ ' files here. The `reason` should describe what change is needed, not just repeat the path.',
141
145
  '- `tickets` (required) — proposed board tickets with stable kebab-case keys (always at least one).',
142
146
  ' Each ticket will later be implemented by a separate build agent that has no access to this planning',
143
147
  ' conversation, so every ticket `description` must be self-contained and implementation-ready.',
@@ -312,7 +316,62 @@ const emitUpdatedSpecSession = (specSessionId, io) => {
312
316
  io.emit('spec-session:updated', specSession);
313
317
  }
314
318
  };
315
- export const startSpecSessionTurn = ({ specSessionId, instruction, attachments = [], tool, model, variant, skills = [], io, }) => {
319
+ const failSpecTurn = ({ specSessionId, executionContext, existingTriggerMessageId, providerContinuation, conversationContext, priorSnapshotId, turnMode, mainRunId, error, io, }) => {
320
+ const failureMessage = getErrorMessage(error);
321
+ untrackSpecProcessById(specSessionId);
322
+ try {
323
+ if (mainRunId) {
324
+ finalizeAgentRun(mainRunId, 'error', null, failureMessage);
325
+ }
326
+ terminalizeAgentRunsForExecutionRun(executionContext.runId, 'error', failureMessage);
327
+ }
328
+ catch {
329
+ // Preserve the top-level execution even if detail finalization fails.
330
+ }
331
+ try {
332
+ const errorContent = formatAgentErrorForTranscript(error);
333
+ if (errorContent) {
334
+ const errorMessageId = createSpecMessage({
335
+ specSessionId,
336
+ role: 'assistant',
337
+ kind: 'error',
338
+ content: errorContent,
339
+ transcriptRuns: getAgentErrorTranscriptRuns(error),
340
+ });
341
+ bindSpecSessionThinkingMessage(specSessionId, errorMessageId);
342
+ addExecutionRunLink({
343
+ projectId: executionContext.projectId,
344
+ runId: executionContext.runId,
345
+ link: {
346
+ entityType: 'spec_message',
347
+ entityId: errorMessageId,
348
+ relationship: 'result',
349
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
350
+ },
351
+ });
352
+ }
353
+ }
354
+ catch {
355
+ // Terminal state must not depend on secondary error-message persistence.
356
+ }
357
+ try {
358
+ updateSpecSessionStatus(specSessionId, 'error', failureMessage, null);
359
+ emitUpdatedSpecSession(specSessionId, io);
360
+ }
361
+ catch {
362
+ // Preserve the top-level execution even if domain projection fails.
363
+ }
364
+ transitionExecutionRun({
365
+ projectId: executionContext.projectId,
366
+ runId: executionContext.runId,
367
+ status: 'failed',
368
+ domainStatus: 'error',
369
+ failure: { code: 'spec_turn_failed', message: failureMessage },
370
+ eventType: 'spec_turn_failed',
371
+ }, io);
372
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
373
+ };
374
+ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments = [], tool, model, variant, skills = [], io, executionContext, existingTriggerMessageId, providerContinuation, conversationContext, priorSnapshotId, turnMode, }) => {
316
375
  const specSession = getSpecSession(specSessionId, { includeMessages: true });
317
376
  if (!specSession) {
318
377
  return false;
@@ -331,13 +390,25 @@ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments =
331
390
  if (specSession.agent_status === 'running' || isRunning(jobKey)) {
332
391
  return false;
333
392
  }
334
- const userMessageId = createSpecMessage({
393
+ const userMessageId = existingTriggerMessageId ?? createSpecMessage({
335
394
  specSessionId,
336
395
  role: 'user',
337
396
  kind: 'prompt',
338
397
  content: trimmedInstruction,
339
398
  });
340
- if (attachments.length > 0) {
399
+ if (!existingTriggerMessageId) {
400
+ addExecutionRunLink({
401
+ projectId: executionContext.projectId,
402
+ runId: executionContext.runId,
403
+ link: {
404
+ entityType: 'spec_message',
405
+ entityId: userMessageId,
406
+ relationship: 'trigger',
407
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
408
+ },
409
+ });
410
+ }
411
+ if (!existingTriggerMessageId && attachments.length > 0) {
341
412
  linkSpecAttachmentsToMessage({
342
413
  specMessageId: userMessageId,
343
414
  attachments,
@@ -363,7 +434,6 @@ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments =
363
434
  }
364
435
  beginSpecSessionThinkingRun(specSessionId);
365
436
  updateSpecSessionStatus(specSessionId, 'running', null, null);
366
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
367
437
  // Register a cancellation token before any async work so an interrupt during
368
438
  // the pre-response window (prepareLinkedPlanningResearch) is accepted rather
369
439
  // than returning a 409. runSpecAgent will overwrite this entry with the real
@@ -371,279 +441,389 @@ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments =
371
441
  // stopRequested below and finalizes to 'stopped' without starting the agent.
372
442
  const specCancellation = registerSpecProcessPlaceholder(specSessionId);
373
443
  emitUpdatedSpecSession(specSessionId, io);
374
- enqueue(jobKey, async () => {
375
- let stagedAgentAttachmentsDirectoryPath = null;
376
- try {
377
- const latestSession = getSpecSession(specSessionId);
378
- if (!latestSession) {
379
- return;
380
- }
381
- // The prior snapshot (if any) is read once and reused both to guide the
382
- // prompt (don't redraw established artifacts) and to carry its mermaid/
383
- // mockup forward when the current turn omits them. No new snapshot is
384
- // created mid-run, so this reference stays valid through the done path.
385
- const previousSnapshot = latestSession.latest_snapshot_id
386
- ? getSpecSnapshot(latestSession.latest_snapshot_id)
387
- : null;
388
- const promptContext = latestSession.title === getDefaultSpecSessionTitle()
389
- ? null
390
- : latestSession.title;
391
- const projectMemory = getProjectMemoryPromptBlock(project.id, {
392
- flow: 'spec',
393
- stage: 'plan',
394
- title: trimmedInstruction || 'Attached images only',
395
- description: promptContext,
396
- currentInstruction: trimmedInstruction,
397
- invocation: { parentType: 'spec', parentId: specSessionId },
398
- });
399
- const cwd = project.repo_path;
400
- const linkedResearch = trimmedInstruction
401
- ? await prepareLinkedPlanningResearch(trimmedInstruction, {
402
- projectId: project.id,
444
+ let queuedJob;
445
+ try {
446
+ queuedJob = enqueue(jobKey, async () => {
447
+ let stagedAgentAttachmentsDirectoryPath = null;
448
+ let mainRunId = null;
449
+ try {
450
+ transitionExecutionRun({
451
+ projectId: executionContext.projectId,
452
+ runId: executionContext.runId,
453
+ status: 'running',
454
+ domainStatus: 'running',
455
+ eventType: 'spec_turn_started',
456
+ }, io);
457
+ const mainRunIndex = getNextAgentRunIndex(executionContext.runId);
458
+ mainRunId = createAgentRun({
459
+ executionRunId: executionContext.runId,
460
+ parentType: 'spec',
461
+ parentId: specSessionId,
462
+ label: 'Main agent',
463
+ role: 'main',
464
+ });
465
+ const latestSession = getSpecSession(specSessionId);
466
+ if (!latestSession) {
467
+ throw new Error('Spec session no longer exists');
468
+ }
469
+ // The prior snapshot (if any) is read once and reused both to guide the
470
+ // prompt (don't redraw established artifacts) and to carry its mermaid/
471
+ // mockup forward when the current turn omits them. No new snapshot is
472
+ // created mid-run, so this reference stays valid through the done path.
473
+ const resolvedPriorSnapshotId = priorSnapshotId === undefined ? latestSession.latest_snapshot_id : priorSnapshotId;
474
+ const previousSnapshot = resolvedPriorSnapshotId
475
+ ? getSpecSnapshot(resolvedPriorSnapshotId)
476
+ : null;
477
+ const promptContext = latestSession.title === getDefaultSpecSessionTitle()
478
+ ? null
479
+ : latestSession.title;
480
+ const projectMemory = getProjectMemoryPromptBlock(project.id, {
481
+ flow: 'spec',
482
+ stage: 'plan',
483
+ title: trimmedInstruction || 'Attached images only',
484
+ description: promptContext,
485
+ currentInstruction: trimmedInstruction,
486
+ invocation: { parentType: 'spec', parentId: specSessionId, agentRunId: mainRunId },
487
+ });
488
+ const cwd = project.repo_path;
489
+ const linkedResearch = trimmedInstruction
490
+ ? await prepareLinkedPlanningResearch(trimmedInstruction, {
491
+ projectId: project.id,
492
+ tool: resolvedTool,
493
+ model: resolvedModel,
494
+ variant: resolvedVariant,
495
+ skills: resolvedSkills,
496
+ executionContext,
497
+ cancellationState: specCancellation,
498
+ agentRunParent: { parentType: 'spec', parentId: specSessionId, io },
499
+ })
500
+ : [];
501
+ // Honor an interrupt that arrived during the pre-response window. There
502
+ // is no child to kill and no partial response to save; transition straight
503
+ // to 'stopped' (worker-owned, mirroring how Chat finalizes after a stop).
504
+ if (specCancellation.stopRequested) {
505
+ untrackSpecProcessById(specSessionId);
506
+ finalizeAgentRun(mainRunId, 'stopped', null);
507
+ updateSpecSessionStatus(specSessionId, 'stopped', null, null);
508
+ emitUpdatedSpecSession(specSessionId, io);
509
+ transitionExecutionRun({
510
+ projectId: executionContext.projectId,
511
+ runId: executionContext.runId,
512
+ status: 'cancelled',
513
+ domainStatus: 'stopped',
514
+ eventType: 'spec_turn_cancelled',
515
+ }, io);
516
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
517
+ return;
518
+ }
519
+ const linkedResearchContext = buildLinkedResearchContext(project, linkedResearch);
520
+ const stagedAgentAttachments = resolvedTool === 'opencode'
521
+ ? stageSpecAttachmentsForAgent({
522
+ specSessionId,
523
+ cwd,
524
+ attachments,
525
+ })
526
+ : { directoryPath: null, files: [] };
527
+ stagedAgentAttachmentsDirectoryPath = stagedAgentAttachments.directoryPath;
528
+ const specPrompt = buildSpecPrompt({
529
+ projectName: project.name,
530
+ projectPath: cwd,
531
+ instruction: conversationContext
532
+ ? `<conversation_before_retry>\n${conversationContext}\n</conversation_before_retry>\n\n${trimmedInstruction}`
533
+ : trimmedInstruction,
534
+ hasAttachments: attachments.length > 0,
535
+ projectMemory,
536
+ memoryEnabled: Boolean(project.memory_enabled),
537
+ previousSnapshot,
538
+ linkedResearchContext,
539
+ mode: turnMode ?? resolveSpecModeValue(latestSession.mode),
540
+ });
541
+ const specRunResult = await runSpecAgent({
542
+ specSessionId,
403
543
  tool: resolvedTool,
544
+ prompt: specPrompt,
545
+ cwd,
546
+ files: stagedAgentAttachments.files,
547
+ io,
404
548
  model: resolvedModel,
405
549
  variant: resolvedVariant,
406
- skills: resolvedSkills,
407
- })
408
- : [];
409
- // Honor an interrupt that arrived during the pre-response window. There
410
- // is no child to kill and no partial response to save; transition straight
411
- // to 'stopped' (worker-owned, mirroring how Chat finalizes after a stop).
412
- if (specCancellation.stopRequested) {
413
- untrackSpecProcessById(specSessionId);
414
- updateSpecSessionStatus(specSessionId, 'stopped', null, null);
415
- emitUpdatedSpecSession(specSessionId, io);
416
- return;
417
- }
418
- const linkedResearchContext = buildLinkedResearchContext(project, linkedResearch);
419
- const stagedAgentAttachments = resolvedTool === 'opencode'
420
- ? stageSpecAttachmentsForAgent({
421
- specSessionId,
422
- cwd,
423
- attachments,
424
- })
425
- : { directoryPath: null, files: [] };
426
- stagedAgentAttachmentsDirectoryPath = stagedAgentAttachments.directoryPath;
427
- const specPrompt = buildSpecPrompt({
428
- projectName: project.name,
429
- projectPath: cwd,
430
- instruction: trimmedInstruction,
431
- hasAttachments: attachments.length > 0,
432
- projectMemory,
433
- memoryEnabled: Boolean(project.memory_enabled),
434
- previousSnapshot,
435
- linkedResearchContext,
436
- mode: resolveSpecModeValue(latestSession.mode),
437
- });
438
- const specRunResult = await runSpecAgent({
439
- specSessionId,
440
- tool: resolvedTool,
441
- prompt: specPrompt,
442
- cwd,
443
- files: stagedAgentAttachments.files,
444
- io,
445
- model: resolvedModel,
446
- variant: resolvedVariant,
447
- resumeSessionId: resolvedTool === 'claude'
448
- ? latestSession.claude_session_id
449
- : latestSession.opencode_session_id,
450
- });
451
- if (specRunResult.completionStatus === 'stopped') {
452
- const interruptedSession = getSpecSession(specSessionId);
453
- const interruptedResponse = interruptedSession?.streaming_response?.trim()
454
- || specRunResult.finalResponse?.trim()
550
+ resumeSessionId: providerContinuation === 'fresh'
551
+ ? null
552
+ : resolvedTool === 'claude'
553
+ ? latestSession.claude_session_id
554
+ : latestSession.opencode_session_id,
555
+ executionContext,
556
+ mainRun: { id: mainRunId, label: 'Main agent', role: 'main', runIndex: mainRunIndex },
557
+ });
558
+ if (specRunResult.completionStatus === 'stopped') {
559
+ const interruptedSession = getSpecSession(specSessionId);
560
+ const interruptedResponse = interruptedSession?.streaming_response?.trim()
561
+ || specRunResult.finalResponse?.trim()
562
+ || getTranscriptContentFallback(specRunResult.transcriptRuns)
563
+ || '';
564
+ if (resolvedTool === 'opencode' && interruptedSession?.title === getDefaultSpecSessionTitle()) {
565
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
566
+ }
567
+ if (interruptedResponse) {
568
+ const messageId = createSpecMessage({
569
+ specSessionId,
570
+ role: 'assistant',
571
+ kind: 'final_response',
572
+ content: interruptedResponse,
573
+ responseTiming: specRunResult.responseTiming,
574
+ transcriptRuns: specRunResult.transcriptRuns,
575
+ });
576
+ if (messageId) {
577
+ bindSpecSessionThinkingMessage(specSessionId, messageId);
578
+ addExecutionRunLink({
579
+ projectId: executionContext.projectId,
580
+ runId: executionContext.runId,
581
+ link: {
582
+ entityType: 'spec_message',
583
+ entityId: messageId,
584
+ relationship: 'result',
585
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
586
+ },
587
+ });
588
+ }
589
+ }
590
+ updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
591
+ updateSpecSessionStatus(specSessionId, 'stopped', interruptedSession?.agent_log ?? null, null);
592
+ emitUpdatedSpecSession(specSessionId, io);
593
+ finalizeAgentRun(mainRunId, 'stopped', interruptedResponse || null);
594
+ transitionExecutionRun({
595
+ projectId: executionContext.projectId,
596
+ runId: executionContext.runId,
597
+ status: 'cancelled',
598
+ domainStatus: 'stopped',
599
+ eventType: 'spec_turn_cancelled',
600
+ }, io);
601
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
602
+ return;
603
+ }
604
+ // Done path: process the completed response.
605
+ const latestCompletedSession = getSpecSession(specSessionId);
606
+ const streamedAssistantMessage = latestCompletedSession?.streaming_response?.trim() ?? '';
607
+ const rawResponse = typeof specRunResult.finalResponse === 'string'
608
+ ? specRunResult.finalResponse
609
+ : '';
610
+ // Resolve the session mode at the moment the turn completed. NULL/legacy
611
+ // values normalize to 'standard'. The worker's per-mode output branch is
612
+ // decided here, not from a re-read after the artifact path, so an
613
+ // auto-finalize mode flip does not change how THIS turn's output parses.
614
+ const sessionMode = turnMode ?? resolveSpecModeValue(latestCompletedSession?.mode);
615
+ // Grill mode: try the grill-question extractor first. If a payload is
616
+ // returned, store it as a 'grill-question' assistant message and skip the
617
+ // snapshot. If no payload is returned, fall through to the standard
618
+ // artifact path (the agent signaled it has no more questions and is
619
+ // emitting the full plan — this is the auto-finalize path).
620
+ if (sessionMode === 'grill' && rawResponse) {
621
+ const grillResult = extractGrillQuestionFromOutput(rawResponse);
622
+ if (grillResult.grillQuestion) {
623
+ const cleanedGrillText = grillResult.cleanedOutput.trim()
624
+ || grillResult.grillQuestion.question
625
+ || streamedAssistantMessage.trim();
626
+ const grillMessageId = createSpecMessage({
627
+ specSessionId,
628
+ role: 'assistant',
629
+ kind: 'grill-question',
630
+ content: cleanedGrillText,
631
+ metadata: { grill_question: grillResult.grillQuestion },
632
+ responseTiming: specRunResult.responseTiming,
633
+ transcriptRuns: specRunResult.transcriptRuns,
634
+ });
635
+ if (grillMessageId) {
636
+ bindSpecSessionThinkingMessage(specSessionId, grillMessageId);
637
+ addExecutionRunLink({
638
+ projectId: executionContext.projectId,
639
+ runId: executionContext.runId,
640
+ link: {
641
+ entityType: 'spec_message',
642
+ entityId: grillMessageId,
643
+ relationship: 'result',
644
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
645
+ },
646
+ });
647
+ }
648
+ if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
649
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
650
+ }
651
+ updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
652
+ updateSpecSessionStatus(specSessionId, 'done', getSpecSession(specSessionId)?.agent_log ?? null, null);
653
+ emitUpdatedSpecSession(specSessionId, io);
654
+ finalizeAgentRun(mainRunId, 'done', cleanedGrillText);
655
+ transitionExecutionRun({
656
+ projectId: executionContext.projectId,
657
+ runId: executionContext.runId,
658
+ status: 'completed',
659
+ domainStatus: 'done',
660
+ eventType: 'spec_turn_completed',
661
+ }, io);
662
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
663
+ return;
664
+ }
665
+ }
666
+ const artifactResult = rawResponse
667
+ ? extractSpecArtifactFromOutput(rawResponse)
668
+ : { cleanedOutput: '', artifact: null, error: null };
669
+ const parsedResponse = extractProjectMemorySuggestionsFromOutput(artifactResult.cleanedOutput);
670
+ if (project.memory_enabled && parsedResponse.suggestions && parsedResponse.suggestions.length > 0) {
671
+ persistSpecSessionProjectMemorySuggestions(project.id, specSessionId, parsedResponse.suggestions);
672
+ }
673
+ const cleanedPlanText = stripMermaidBlocks(parsedResponse.cleanedOutput).trim()
674
+ || stripMermaidBlocks(streamedAssistantMessage).trim();
675
+ let assistantMessage = cleanedPlanText
455
676
  || getTranscriptContentFallback(specRunResult.transcriptRuns)
456
- || '';
457
- if (resolvedTool === 'opencode' && interruptedSession?.title === getDefaultSpecSessionTitle()) {
458
- updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
677
+ || (attachments.length > 0 ? ATTACHMENT_ONLY_EMPTY_RESPONSE_FALLBACK : '');
678
+ // Create snapshot from artifact if present. Presence is populated
679
+ // alongside artifact by the extractor, so the two are non-null together.
680
+ if (artifactResult.artifact && artifactResult.presence) {
681
+ // A block re-emitted this turn overrides the previous snapshot's value;
682
+ // an omitted block is carried forward so established artifacts don't
683
+ // disappear when a follow-up turn omits the diagram/mockup.
684
+ const { mermaid: mermaidBlock, mockup: mockupBlock, fileRelationGraph: fileRelationGraphBlock } = resolveCarriedForwardArtifacts({
685
+ rawResponse,
686
+ previousMermaid: previousSnapshot?.mermaid ?? null,
687
+ previousMockup: previousSnapshot?.mockup ?? null,
688
+ previousFileRelationGraph: previousSnapshot?.file_relation_graph ?? null,
689
+ });
690
+ // Optional structured array fields use the same carry-forward contract:
691
+ // a field the LLM emitted this turn (including an explicit empty array)
692
+ // overrides the prior snapshot; a field the LLM omitted is carried
693
+ // forward unchanged so follow-up turns never silently drop information.
694
+ const carriedFields = resolveCarriedForwardStructuredFields({
695
+ artifact: artifactResult.artifact,
696
+ presence: artifactResult.presence,
697
+ previous: previousSnapshot
698
+ ? {
699
+ assumptions: previousSnapshot.assumptions,
700
+ openQuestions: previousSnapshot.open_questions,
701
+ likelyFiles: previousSnapshot.likely_files,
702
+ dependencies: previousSnapshot.dependencies,
703
+ }
704
+ : null,
705
+ });
706
+ const snapshotId = createSpecSnapshot({
707
+ specSessionId,
708
+ summary: artifactResult.artifact.summary,
709
+ assumptions: carriedFields.assumptions,
710
+ openQuestions: carriedFields.openQuestions,
711
+ likelyFiles: carriedFields.likelyFiles,
712
+ tickets: artifactResult.artifact.tickets,
713
+ dependencies: carriedFields.dependencies,
714
+ mermaid: mermaidBlock,
715
+ mockup: mockupBlock,
716
+ fileRelationGraph: fileRelationGraphBlock,
717
+ planText: cleanedPlanText,
718
+ });
719
+ updateSpecSessionLatestSnapshot(specSessionId, snapshotId);
720
+ addExecutionRunLink({
721
+ projectId: executionContext.projectId,
722
+ runId: executionContext.runId,
723
+ link: {
724
+ entityType: 'spec_snapshot',
725
+ entityId: snapshotId,
726
+ relationship: 'result',
727
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
728
+ },
729
+ });
730
+ // Auto-finalize: when an artifact is emitted during grill mode, the
731
+ // agent has signaled that it has no remaining questions. Flip the
732
+ // session mode back to 'standard' so subsequent turns run the standard
733
+ // prompt. This runs inside the same queued job that wrote the snapshot,
734
+ // so it cannot race with a concurrent user-initiated mode change.
735
+ if (sessionMode === 'grill') {
736
+ updateSpecSessionMode(specSessionId, 'standard');
737
+ }
459
738
  }
460
- if (interruptedResponse) {
739
+ // Append artifact error note to the visible message if extraction failed.
740
+ // Suppress the "no block found" note — it is normal for Q&A turns where the
741
+ // agent intentionally omitted the artifact because the specification did not change.
742
+ if (artifactResult.error && assistantMessage && artifactResult.error !== 'No spec artifact block found in planner response') {
743
+ assistantMessage += `\n\n[Note: ${artifactResult.error}. No specification snapshot was saved. You can try refining the spec or asking the planner to regenerate the artifact.]`;
744
+ }
745
+ if (assistantMessage) {
461
746
  const messageId = createSpecMessage({
462
747
  specSessionId,
463
748
  role: 'assistant',
464
749
  kind: 'final_response',
465
- content: interruptedResponse,
750
+ content: assistantMessage,
466
751
  responseTiming: specRunResult.responseTiming,
467
752
  transcriptRuns: specRunResult.transcriptRuns,
468
753
  });
469
754
  if (messageId) {
470
755
  bindSpecSessionThinkingMessage(specSessionId, messageId);
756
+ addExecutionRunLink({
757
+ projectId: executionContext.projectId,
758
+ runId: executionContext.runId,
759
+ link: {
760
+ entityType: 'spec_message',
761
+ entityId: messageId,
762
+ relationship: 'result',
763
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
764
+ },
765
+ });
471
766
  }
472
767
  }
473
- if (specRunResult.transcriptRuns) {
474
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
768
+ if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
769
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
475
770
  }
476
771
  updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
477
- updateSpecSessionStatus(specSessionId, 'stopped', interruptedSession?.agent_log ?? null, null);
772
+ updateSpecSessionStatus(specSessionId, 'done', getSpecSession(specSessionId)?.agent_log ?? null, null);
478
773
  emitUpdatedSpecSession(specSessionId, io);
479
- return;
774
+ finalizeAgentRun(mainRunId, 'done', assistantMessage || rawResponse || null);
775
+ transitionExecutionRun({
776
+ projectId: executionContext.projectId,
777
+ runId: executionContext.runId,
778
+ status: 'completed',
779
+ domainStatus: 'done',
780
+ eventType: 'spec_turn_completed',
781
+ }, io);
782
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
480
783
  }
481
- // Done path: process the completed response.
482
- const latestCompletedSession = getSpecSession(specSessionId);
483
- const streamedAssistantMessage = latestCompletedSession?.streaming_response?.trim() ?? '';
484
- const rawResponse = typeof specRunResult.finalResponse === 'string'
485
- ? specRunResult.finalResponse
486
- : '';
487
- // Resolve the session mode at the moment the turn completed. NULL/legacy
488
- // values normalize to 'standard'. The worker's per-mode output branch is
489
- // decided here, not from a re-read after the artifact path, so an
490
- // auto-finalize mode flip does not change how THIS turn's output parses.
491
- const sessionMode = resolveSpecModeValue(latestCompletedSession?.mode);
492
- // Grill mode: try the grill-question extractor first. If a payload is
493
- // returned, store it as a 'grill-question' assistant message and skip the
494
- // snapshot. If no payload is returned, fall through to the standard
495
- // artifact path (the agent signaled it has no more questions and is
496
- // emitting the full plan — this is the auto-finalize path).
497
- if (sessionMode === 'grill' && rawResponse) {
498
- const grillResult = extractGrillQuestionFromOutput(rawResponse);
499
- if (grillResult.grillQuestion) {
500
- const cleanedGrillText = grillResult.cleanedOutput.trim()
501
- || grillResult.grillQuestion.question
502
- || streamedAssistantMessage.trim();
503
- const grillMessageId = createSpecMessage({
504
- specSessionId,
505
- role: 'assistant',
506
- kind: 'grill-question',
507
- content: cleanedGrillText,
508
- metadata: { grill_question: grillResult.grillQuestion },
509
- responseTiming: specRunResult.responseTiming,
510
- transcriptRuns: specRunResult.transcriptRuns,
511
- });
512
- if (grillMessageId) {
513
- bindSpecSessionThinkingMessage(specSessionId, grillMessageId);
514
- }
515
- if (specRunResult.transcriptRuns) {
516
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
784
+ catch (error) {
785
+ if (specCancellation.stopRequested) {
786
+ untrackSpecProcessById(specSessionId);
787
+ if (mainRunId) {
788
+ finalizeAgentRun(mainRunId, 'stopped', null);
517
789
  }
518
- if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
519
- updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
520
- }
521
- updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
522
- updateSpecSessionStatus(specSessionId, 'done', getSpecSession(specSessionId)?.agent_log ?? null, null);
790
+ updateSpecSessionStatus(specSessionId, 'stopped', null, null);
523
791
  emitUpdatedSpecSession(specSessionId, io);
792
+ transitionExecutionRun({
793
+ projectId: executionContext.projectId,
794
+ runId: executionContext.runId,
795
+ status: 'cancelled',
796
+ domainStatus: 'stopped',
797
+ eventType: 'spec_turn_cancelled',
798
+ }, io);
799
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
524
800
  return;
525
801
  }
526
- }
527
- const artifactResult = rawResponse
528
- ? extractSpecArtifactFromOutput(rawResponse)
529
- : { cleanedOutput: '', artifact: null, error: null };
530
- const parsedResponse = extractProjectMemorySuggestionsFromOutput(artifactResult.cleanedOutput);
531
- if (project.memory_enabled && parsedResponse.suggestions && parsedResponse.suggestions.length > 0) {
532
- persistSpecSessionProjectMemorySuggestions(project.id, specSessionId, parsedResponse.suggestions);
533
- }
534
- const cleanedPlanText = stripMermaidBlocks(parsedResponse.cleanedOutput).trim()
535
- || stripMermaidBlocks(streamedAssistantMessage).trim();
536
- let assistantMessage = cleanedPlanText
537
- || getTranscriptContentFallback(specRunResult.transcriptRuns)
538
- || (attachments.length > 0 ? ATTACHMENT_ONLY_EMPTY_RESPONSE_FALLBACK : '');
539
- // Create snapshot from artifact if present. Presence is populated
540
- // alongside artifact by the extractor, so the two are non-null together.
541
- if (artifactResult.artifact && artifactResult.presence) {
542
- // A block re-emitted this turn overrides the previous snapshot's value;
543
- // an omitted block is carried forward so established artifacts don't
544
- // disappear when a follow-up turn omits the diagram/mockup.
545
- const { mermaid: mermaidBlock, mockup: mockupBlock, fileRelationGraph: fileRelationGraphBlock } = resolveCarriedForwardArtifacts({
546
- rawResponse,
547
- previousMermaid: previousSnapshot?.mermaid ?? null,
548
- previousMockup: previousSnapshot?.mockup ?? null,
549
- previousFileRelationGraph: previousSnapshot?.file_relation_graph ?? null,
550
- });
551
- // Optional structured array fields use the same carry-forward contract:
552
- // a field the LLM emitted this turn (including an explicit empty array)
553
- // overrides the prior snapshot; a field the LLM omitted is carried
554
- // forward unchanged so follow-up turns never silently drop information.
555
- const carriedFields = resolveCarriedForwardStructuredFields({
556
- artifact: artifactResult.artifact,
557
- presence: artifactResult.presence,
558
- previous: previousSnapshot
559
- ? {
560
- assumptions: previousSnapshot.assumptions,
561
- openQuestions: previousSnapshot.open_questions,
562
- likelyFiles: previousSnapshot.likely_files,
563
- dependencies: previousSnapshot.dependencies,
564
- }
565
- : null,
566
- });
567
- const snapshotId = createSpecSnapshot({
568
- specSessionId,
569
- summary: artifactResult.artifact.summary,
570
- assumptions: carriedFields.assumptions,
571
- openQuestions: carriedFields.openQuestions,
572
- likelyFiles: carriedFields.likelyFiles,
573
- tickets: artifactResult.artifact.tickets,
574
- dependencies: carriedFields.dependencies,
575
- mermaid: mermaidBlock,
576
- mockup: mockupBlock,
577
- fileRelationGraph: fileRelationGraphBlock,
578
- planText: cleanedPlanText,
579
- });
580
- updateSpecSessionLatestSnapshot(specSessionId, snapshotId);
581
- // Auto-finalize: when an artifact is emitted during grill mode, the
582
- // agent has signaled that it has no remaining questions. Flip the
583
- // session mode back to 'standard' so subsequent turns run the standard
584
- // prompt. This runs inside the same queued job that wrote the snapshot,
585
- // so it cannot race with a concurrent user-initiated mode change.
586
- if (sessionMode === 'grill') {
587
- updateSpecSessionMode(specSessionId, 'standard');
588
- }
589
- }
590
- // Append artifact error note to the visible message if extraction failed.
591
- // Suppress the "no block found" note — it is normal for Q&A turns where the
592
- // agent intentionally omitted the artifact because the specification did not change.
593
- if (artifactResult.error && assistantMessage && artifactResult.error !== 'No spec artifact block found in planner response') {
594
- assistantMessage += `\n\n[Note: ${artifactResult.error}. No specification snapshot was saved. You can try refining the spec or asking the planner to regenerate the artifact.]`;
595
- }
596
- if (assistantMessage) {
597
- const messageId = createSpecMessage({
598
- specSessionId,
599
- role: 'assistant',
600
- kind: 'final_response',
601
- content: assistantMessage,
602
- responseTiming: specRunResult.responseTiming,
603
- transcriptRuns: specRunResult.transcriptRuns,
604
- });
605
- if (messageId) {
606
- bindSpecSessionThinkingMessage(specSessionId, messageId);
802
+ const latestSession = getSpecSession(specSessionId);
803
+ if (resolvedTool === 'opencode' && latestSession?.title === getDefaultSpecSessionTitle()) {
804
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
607
805
  }
806
+ failSpecTurn({ specSessionId, executionContext, mainRunId, error, io });
608
807
  }
609
- if (specRunResult.transcriptRuns) {
610
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
611
- }
612
- if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
613
- updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
808
+ finally {
809
+ deleteStagedSpecAttachments(stagedAgentAttachmentsDirectoryPath);
614
810
  }
615
- updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
616
- updateSpecSessionStatus(specSessionId, 'done', getSpecSession(specSessionId)?.agent_log ?? null, null);
617
- emitUpdatedSpecSession(specSessionId, io);
618
- }
619
- catch (error) {
620
- const transcriptRuns = getAgentErrorTranscriptRuns(error);
621
- const latestSession = getSpecSession(specSessionId);
622
- if (resolvedTool === 'opencode' && latestSession?.title === getDefaultSpecSessionTitle()) {
623
- updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
624
- }
625
- const errorContent = formatAgentErrorForTranscript(error);
626
- if (errorContent) {
627
- createSpecMessage({
628
- specSessionId,
629
- role: 'assistant',
630
- kind: 'error',
631
- content: errorContent,
632
- transcriptRuns,
633
- });
634
- }
635
- if (transcriptRuns) {
636
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
637
- }
638
- updateSpecSessionStatus(specSessionId, 'error', getErrorMessage(error), null);
639
- emitUpdatedSpecSession(specSessionId, io);
811
+ });
812
+ }
813
+ catch (error) {
814
+ untrackSpecProcessById(specSessionId);
815
+ const failureMessage = getErrorMessage(error);
816
+ updateSpecSessionStatus(specSessionId, 'error', failureMessage, null);
817
+ emitUpdatedSpecSession(specSessionId, io);
818
+ throw new TurnStartError('queue_failed', failureMessage);
819
+ }
820
+ queuedJob.catch(error => {
821
+ try {
822
+ failSpecTurn({ specSessionId, executionContext, mainRunId: null, error, io });
640
823
  }
641
- finally {
642
- deleteStagedSpecAttachments(stagedAgentAttachmentsDirectoryPath);
824
+ catch {
825
+ // Startup reconciliation remains the final repair boundary for database failures.
643
826
  }
644
- }).catch(error => {
645
- updateSpecSessionStatus(specSessionId, 'error', getErrorMessage(error), null);
646
- emitUpdatedSpecSession(specSessionId, io);
647
827
  });
648
828
  return true;
649
829
  };