@straiffi/archon 1.7.2 → 1.7.3

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 (164) hide show
  1. package/dist/client/assets/ActivityView-DTEenQas.js +1 -0
  2. package/dist/client/assets/GitHubActionsDialog-BKWHonUS.js +1 -0
  3. package/dist/client/assets/TestsDialog-cBaoffCs.js +4 -0
  4. package/dist/client/assets/{architectureDiagram-3BPJPVTR-Do9nbC8v.js → architectureDiagram-3BPJPVTR-BzzGotac.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-GPEHLZMM-DGGOLHF8.js → blockDiagram-GPEHLZMM-DIcqLHKg.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-AAUBKEIU-Bz7tQybs.js → c4Diagram-AAUBKEIU-Hc1Sqmhs.js} +1 -1
  7. package/dist/client/assets/channel-Cym17waa.js +1 -0
  8. package/dist/client/assets/{chunk-2J33WTMH-CJO-GmWJ.js → chunk-2J33WTMH-CWSwjsgk.js} +1 -1
  9. package/dist/client/assets/{chunk-3OPIFGDE-DPAVagpC.js → chunk-3OPIFGDE-BnX3DljN.js} +1 -1
  10. package/dist/client/assets/{chunk-5ZQYHXKU-Cof75Mu7.js → chunk-5ZQYHXKU-EyyJlI-d.js} +1 -1
  11. package/dist/client/assets/{chunk-727SXJPM-COuPqQ8_.js → chunk-727SXJPM-Ci6CfxWj.js} +1 -1
  12. package/dist/client/assets/{chunk-AQP2D5EJ-CFj5KabW.js → chunk-AQP2D5EJ-RKZNsJTm.js} +1 -1
  13. package/dist/client/assets/{chunk-CSCIHK7Q-C4M6w5cT.js → chunk-CSCIHK7Q-DBf0Euoj.js} +1 -1
  14. package/dist/client/assets/{chunk-KSCS5N6A-ByaiwVue.js → chunk-KSCS5N6A-BmLQI_2S.js} +1 -1
  15. package/dist/client/assets/{chunk-L5ZTLDWV-Cx5jeot9.js → chunk-L5ZTLDWV-DKwVAb8G.js} +1 -1
  16. package/dist/client/assets/{chunk-LZXEDZCA-DmGtztud.js → chunk-LZXEDZCA-axotKkbz.js} +2 -2
  17. package/dist/client/assets/{chunk-ND2GUHAM-i3yuspqL.js → chunk-ND2GUHAM-BBlJ4nXC.js} +1 -1
  18. package/dist/client/assets/{chunk-NZK2D7GU-CQ4E_jP4.js → chunk-NZK2D7GU-BKJGoZZt.js} +1 -1
  19. package/dist/client/assets/{chunk-O5CBEL6O-D2g4VDCd.js → chunk-O5CBEL6O-C-Ghigo1.js} +1 -1
  20. package/dist/client/assets/{chunk-WU5MYG2G-exIu-zQ2.js → chunk-WU5MYG2G-CCzVWYcd.js} +1 -1
  21. package/dist/client/assets/classDiagram-4FO5ZUOK-BcigvvIC.js +1 -0
  22. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-3AdSuE0N.js +1 -0
  23. package/dist/client/assets/{dagre-BM42HDAG-DvF75yLZ.js → dagre-BM42HDAG-rwIdPcjI.js} +1 -1
  24. package/dist/client/assets/{diagram-2AECGRRQ-DFv0HxLL.js → diagram-2AECGRRQ-DJWxOQsg.js} +1 -1
  25. package/dist/client/assets/{diagram-5GNKFQAL-C_LRir0-.js → diagram-5GNKFQAL-C-mQeFtd.js} +1 -1
  26. package/dist/client/assets/{diagram-KO2AKTUF-DGZxRDRy.js → diagram-KO2AKTUF-BAu6u4sR.js} +1 -1
  27. package/dist/client/assets/{diagram-LMA3HP47-UXt0uti4.js → diagram-LMA3HP47-U6CbvBn0.js} +1 -1
  28. package/dist/client/assets/{diagram-OG6HWLK6-CFbZS4n5.js → diagram-OG6HWLK6-DNTwFcs1.js} +1 -1
  29. package/dist/client/assets/{erDiagram-TEJ5UH35-CfAwZDdf.js → erDiagram-TEJ5UH35-DaHX6Xw5.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cdgi5z3n.js → flowDiagram-I6XJVG4X-B9hKqL2l.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-6RSMTGT7-DOrkFkhu.js → ganttDiagram-6RSMTGT7-BQ1HPHVO.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-cEox0peD.js → gitGraphDiagram-PVQCEYII-BBRcsmMK.js} +1 -1
  33. package/dist/client/assets/index-CEMaJCPS.js +273 -0
  34. package/dist/client/assets/index-lWftq65U.css +2 -0
  35. package/dist/client/assets/{infoDiagram-5YYISTIA-gm51u2Nn.js → infoDiagram-5YYISTIA-DUoNiSGC.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CjnoOIja.js → ishikawaDiagram-YF4QCWOH-DJN1y2w8.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-JHISSGLW-Dlg4DsiO.js → journeyDiagram-JHISSGLW-BxLLMT70.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-UN3LZRKU-BU0XZaSd.js → kanban-definition-UN3LZRKU-BdCPistJ.js} +1 -1
  39. package/dist/client/assets/{line-Bert22-_.js → line-4mIgXsZd.js} +1 -1
  40. package/dist/client/assets/{mermaid-parser.core-BvQSGDmL.js → mermaid-parser.core-Pu1QGWJ-.js} +1 -1
  41. package/dist/client/assets/{mermaid.core--qSAHJRj.js → mermaid.core-DnH9DrgZ.js} +3 -3
  42. package/dist/client/assets/{mindmap-definition-RKZ34NQL-BgFIvWWD.js → mindmap-definition-RKZ34NQL-DqE4ba3C.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-4H26LBE5-DGcbXD0M.js → pieDiagram-4H26LBE5-Crjy9ejf.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-ClkBUeia.js → quadrantDiagram-W4KKPZXB-BUSZ2VB7.js} +1 -1
  45. package/dist/client/assets/{requirementDiagram-4Y6WPE33-B8CwGToU.js → requirementDiagram-4Y6WPE33-CBhFKEoV.js} +1 -1
  46. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DTk3jL6B.js → sankeyDiagram-5OEKKPKP-CgdbY41v.js} +1 -1
  47. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQVrcnKf.js → sequenceDiagram-3UESZ5HK-hkp_H7AE.js} +1 -1
  48. package/dist/client/assets/{stateDiagram-AJRCARHV-DD4yMZPB.js → stateDiagram-AJRCARHV-C6x45RWp.js} +1 -1
  49. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-DVzPjRPN.js +1 -0
  50. package/dist/client/assets/{timeline-definition-PNZ67QCA-BnCBVqcd.js → timeline-definition-PNZ67QCA-CjnZWttQ.js} +1 -1
  51. package/dist/client/assets/{vennDiagram-CIIHVFJN-BDmLMNXS.js → vennDiagram-CIIHVFJN-DU5ZyYju.js} +1 -1
  52. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DSLuQiEv.js → wardleyDiagram-YWT4CUSO-CaCEnCB0.js} +1 -1
  53. package/dist/client/assets/{xychartDiagram-2RQKCTM6-DAq_LA4r.js → xychartDiagram-2RQKCTM6-Ce3xa37U.js} +1 -1
  54. package/dist/client/index.html +2 -2
  55. package/dist/server/db.js +471 -39
  56. package/dist/server/db.js.map +1 -1
  57. package/dist/server/index.js +1256 -214
  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/automationProgression.js +464 -0
  64. package/dist/server/lib/automationProgression.js.map +1 -0
  65. package/dist/server/lib/automationReconciliation.js +498 -0
  66. package/dist/server/lib/automationReconciliation.js.map +1 -0
  67. package/dist/server/lib/automationStageDetails.js +232 -0
  68. package/dist/server/lib/automationStageDetails.js.map +1 -0
  69. package/dist/server/lib/buildOpenQuestionOperations.js +223 -0
  70. package/dist/server/lib/buildOpenQuestionOperations.js.map +1 -0
  71. package/dist/server/lib/buildOpenQuestions.js +44 -0
  72. package/dist/server/lib/buildOpenQuestions.js.map +1 -1
  73. package/dist/server/lib/buildSequences.js +130 -46
  74. package/dist/server/lib/buildSequences.js.map +1 -1
  75. package/dist/server/lib/bundleReviewOperations.js +82 -9
  76. package/dist/server/lib/bundleReviewOperations.js.map +1 -1
  77. package/dist/server/lib/chatOperations.js +31 -0
  78. package/dist/server/lib/chatOperations.js.map +1 -1
  79. package/dist/server/lib/executionReconciliation.js +518 -0
  80. package/dist/server/lib/executionReconciliation.js.map +1 -0
  81. package/dist/server/lib/executionRunRetention.js +141 -0
  82. package/dist/server/lib/executionRunRetention.js.map +1 -0
  83. package/dist/server/lib/executionRuns.js +1264 -0
  84. package/dist/server/lib/executionRuns.js.map +1 -0
  85. package/dist/server/lib/git.js +385 -6
  86. package/dist/server/lib/git.js.map +1 -1
  87. package/dist/server/lib/integrations/github.js +43 -0
  88. package/dist/server/lib/integrations/github.js.map +1 -1
  89. package/dist/server/lib/integrations/githubActions.js +1 -0
  90. package/dist/server/lib/integrations/githubActions.js.map +1 -1
  91. package/dist/server/lib/integrations/githubActionsSessions.js +337 -114
  92. package/dist/server/lib/integrations/githubActionsSessions.js.map +1 -1
  93. package/dist/server/lib/projectMemorySelections.js +7 -3
  94. package/dist/server/lib/projectMemorySelections.js.map +1 -1
  95. package/dist/server/lib/shutdown.js +2 -2
  96. package/dist/server/lib/shutdown.js.map +1 -1
  97. package/dist/server/lib/specArtifact.js +3 -0
  98. package/dist/server/lib/specArtifact.js.map +1 -1
  99. package/dist/server/lib/specFormatting.js +1 -1
  100. package/dist/server/lib/specFormatting.js.map +1 -1
  101. package/dist/server/lib/specOperations.js +31 -0
  102. package/dist/server/lib/specOperations.js.map +1 -1
  103. package/dist/server/lib/specReviewContext.js +44 -14
  104. package/dist/server/lib/specReviewContext.js.map +1 -1
  105. package/dist/server/lib/specReviewDraft.js +3 -2
  106. package/dist/server/lib/specReviewDraft.js.map +1 -1
  107. package/dist/server/lib/specReviewOperations.js +44 -7
  108. package/dist/server/lib/specReviewOperations.js.map +1 -1
  109. package/dist/server/lib/specReviewValidation.js +217 -0
  110. package/dist/server/lib/specReviewValidation.js.map +1 -0
  111. package/dist/server/lib/specReviews.js +30 -2
  112. package/dist/server/lib/specReviews.js.map +1 -1
  113. package/dist/server/lib/specs.js +18 -1
  114. package/dist/server/lib/specs.js.map +1 -1
  115. package/dist/server/lib/stageSnapshots.js +197 -0
  116. package/dist/server/lib/stageSnapshots.js.map +1 -0
  117. package/dist/server/lib/testSessions.js +164 -19
  118. package/dist/server/lib/testSessions.js.map +1 -1
  119. package/dist/server/lib/ticketAutomation.js +80 -4
  120. package/dist/server/lib/ticketAutomation.js.map +1 -1
  121. package/dist/server/lib/ticketAutomationCommit.js +466 -91
  122. package/dist/server/lib/ticketAutomationCommit.js.map +1 -1
  123. package/dist/server/lib/ticketFollowUpOperations.js +214 -20
  124. package/dist/server/lib/ticketFollowUpOperations.js.map +1 -1
  125. package/dist/server/lib/ticketRunOperations.js +51 -6
  126. package/dist/server/lib/ticketRunOperations.js.map +1 -1
  127. package/dist/server/lib/ticketUndo.js +77 -168
  128. package/dist/server/lib/ticketUndo.js.map +1 -1
  129. package/dist/server/lib/ticketWorkflowOperations.js +132 -28
  130. package/dist/server/lib/ticketWorkflowOperations.js.map +1 -1
  131. package/dist/server/lib/tickets.js +41 -27
  132. package/dist/server/lib/tickets.js.map +1 -1
  133. package/dist/server/lib/transcriptRuns.js +3 -3
  134. package/dist/server/lib/transcriptRuns.js.map +1 -1
  135. package/dist/server/lib/turnExecutionErrors.js +12 -0
  136. package/dist/server/lib/turnExecutionErrors.js.map +1 -0
  137. package/dist/server/lib/types.js +57 -1
  138. package/dist/server/lib/types.js.map +1 -1
  139. package/dist/server/workers/build.js +596 -184
  140. package/dist/server/workers/build.js.map +1 -1
  141. package/dist/server/workers/chat.js +261 -130
  142. package/dist/server/workers/chat.js.map +1 -1
  143. package/dist/server/workers/followUp.js +716 -203
  144. package/dist/server/workers/followUp.js.map +1 -1
  145. package/dist/server/workers/plan.js +527 -188
  146. package/dist/server/workers/plan.js.map +1 -1
  147. package/dist/server/workers/planFollowUp.js +231 -69
  148. package/dist/server/workers/planFollowUp.js.map +1 -1
  149. package/dist/server/workers/review.js +790 -297
  150. package/dist/server/workers/review.js.map +1 -1
  151. package/dist/server/workers/specReview.js +211 -45
  152. package/dist/server/workers/specReview.js.map +1 -1
  153. package/dist/server/workers/specSession.js +425 -252
  154. package/dist/server/workers/specSession.js.map +1 -1
  155. package/package.json +1 -1
  156. package/dist/client/assets/GitHubActionsDialog-DAD5REmY.js +0 -1
  157. package/dist/client/assets/TestsDialog-B5NVrroq.js +0 -4
  158. package/dist/client/assets/TicketChangesModal-By-_gTGd.js +0 -1
  159. package/dist/client/assets/channel-Dd5Len0X.js +0 -1
  160. package/dist/client/assets/classDiagram-4FO5ZUOK-B7KUg6r8.js +0 -1
  161. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CQm-z5c4.js +0 -1
  162. package/dist/client/assets/index-BHq6-Eir.js +0 -270
  163. package/dist/client/assets/index-BxTc5qQT.css +0 -2
  164. 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, 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, }) => {
316
375
  const specSession = getSpecSession(specSessionId, { includeMessages: true });
317
376
  if (!specSession) {
318
377
  return false;
@@ -337,6 +396,16 @@ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments =
337
396
  kind: 'prompt',
338
397
  content: trimmedInstruction,
339
398
  });
399
+ addExecutionRunLink({
400
+ projectId: executionContext.projectId,
401
+ runId: executionContext.runId,
402
+ link: {
403
+ entityType: 'spec_message',
404
+ entityId: userMessageId,
405
+ relationship: 'trigger',
406
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
407
+ },
408
+ });
340
409
  if (attachments.length > 0) {
341
410
  linkSpecAttachmentsToMessage({
342
411
  specMessageId: userMessageId,
@@ -363,7 +432,6 @@ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments =
363
432
  }
364
433
  beginSpecSessionThinkingRun(specSessionId);
365
434
  updateSpecSessionStatus(specSessionId, 'running', null, null);
366
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
367
435
  // Register a cancellation token before any async work so an interrupt during
368
436
  // the pre-response window (prepareLinkedPlanningResearch) is accepted rather
369
437
  // than returning a 409. runSpecAgent will overwrite this entry with the real
@@ -371,279 +439,384 @@ export const startSpecSessionTurn = ({ specSessionId, instruction, attachments =
371
439
  // stopRequested below and finalizes to 'stopped' without starting the agent.
372
440
  const specCancellation = registerSpecProcessPlaceholder(specSessionId);
373
441
  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,
442
+ let queuedJob;
443
+ try {
444
+ queuedJob = enqueue(jobKey, async () => {
445
+ let stagedAgentAttachmentsDirectoryPath = null;
446
+ let mainRunId = null;
447
+ try {
448
+ transitionExecutionRun({
449
+ projectId: executionContext.projectId,
450
+ runId: executionContext.runId,
451
+ status: 'running',
452
+ domainStatus: 'running',
453
+ eventType: 'spec_turn_started',
454
+ }, io);
455
+ const mainRunIndex = getNextAgentRunIndex(executionContext.runId);
456
+ mainRunId = createAgentRun({
457
+ executionRunId: executionContext.runId,
458
+ parentType: 'spec',
459
+ parentId: specSessionId,
460
+ label: 'Main agent',
461
+ role: 'main',
462
+ });
463
+ const latestSession = getSpecSession(specSessionId);
464
+ if (!latestSession) {
465
+ throw new Error('Spec session no longer exists');
466
+ }
467
+ // The prior snapshot (if any) is read once and reused both to guide the
468
+ // prompt (don't redraw established artifacts) and to carry its mermaid/
469
+ // mockup forward when the current turn omits them. No new snapshot is
470
+ // created mid-run, so this reference stays valid through the done path.
471
+ const previousSnapshot = latestSession.latest_snapshot_id
472
+ ? getSpecSnapshot(latestSession.latest_snapshot_id)
473
+ : null;
474
+ const promptContext = latestSession.title === getDefaultSpecSessionTitle()
475
+ ? null
476
+ : latestSession.title;
477
+ const projectMemory = getProjectMemoryPromptBlock(project.id, {
478
+ flow: 'spec',
479
+ stage: 'plan',
480
+ title: trimmedInstruction || 'Attached images only',
481
+ description: promptContext,
482
+ currentInstruction: trimmedInstruction,
483
+ invocation: { parentType: 'spec', parentId: specSessionId, agentRunId: mainRunId },
484
+ });
485
+ const cwd = project.repo_path;
486
+ const linkedResearch = trimmedInstruction
487
+ ? await prepareLinkedPlanningResearch(trimmedInstruction, {
488
+ projectId: project.id,
489
+ tool: resolvedTool,
490
+ model: resolvedModel,
491
+ variant: resolvedVariant,
492
+ skills: resolvedSkills,
493
+ executionContext,
494
+ cancellationState: specCancellation,
495
+ agentRunParent: { parentType: 'spec', parentId: specSessionId, io },
496
+ })
497
+ : [];
498
+ // Honor an interrupt that arrived during the pre-response window. There
499
+ // is no child to kill and no partial response to save; transition straight
500
+ // to 'stopped' (worker-owned, mirroring how Chat finalizes after a stop).
501
+ if (specCancellation.stopRequested) {
502
+ untrackSpecProcessById(specSessionId);
503
+ finalizeAgentRun(mainRunId, 'stopped', null);
504
+ updateSpecSessionStatus(specSessionId, 'stopped', null, null);
505
+ emitUpdatedSpecSession(specSessionId, io);
506
+ transitionExecutionRun({
507
+ projectId: executionContext.projectId,
508
+ runId: executionContext.runId,
509
+ status: 'cancelled',
510
+ domainStatus: 'stopped',
511
+ eventType: 'spec_turn_cancelled',
512
+ }, io);
513
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
514
+ return;
515
+ }
516
+ const linkedResearchContext = buildLinkedResearchContext(project, linkedResearch);
517
+ const stagedAgentAttachments = resolvedTool === 'opencode'
518
+ ? stageSpecAttachmentsForAgent({
519
+ specSessionId,
520
+ cwd,
521
+ attachments,
522
+ })
523
+ : { directoryPath: null, files: [] };
524
+ stagedAgentAttachmentsDirectoryPath = stagedAgentAttachments.directoryPath;
525
+ const specPrompt = buildSpecPrompt({
526
+ projectName: project.name,
527
+ projectPath: cwd,
528
+ instruction: trimmedInstruction,
529
+ hasAttachments: attachments.length > 0,
530
+ projectMemory,
531
+ memoryEnabled: Boolean(project.memory_enabled),
532
+ previousSnapshot,
533
+ linkedResearchContext,
534
+ mode: resolveSpecModeValue(latestSession.mode),
535
+ });
536
+ const specRunResult = await runSpecAgent({
537
+ specSessionId,
403
538
  tool: resolvedTool,
539
+ prompt: specPrompt,
540
+ cwd,
541
+ files: stagedAgentAttachments.files,
542
+ io,
404
543
  model: resolvedModel,
405
544
  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()
545
+ resumeSessionId: resolvedTool === 'claude'
546
+ ? latestSession.claude_session_id
547
+ : latestSession.opencode_session_id,
548
+ executionContext,
549
+ mainRun: { id: mainRunId, label: 'Main agent', role: 'main', runIndex: mainRunIndex },
550
+ });
551
+ if (specRunResult.completionStatus === 'stopped') {
552
+ const interruptedSession = getSpecSession(specSessionId);
553
+ const interruptedResponse = interruptedSession?.streaming_response?.trim()
554
+ || specRunResult.finalResponse?.trim()
555
+ || getTranscriptContentFallback(specRunResult.transcriptRuns)
556
+ || '';
557
+ if (resolvedTool === 'opencode' && interruptedSession?.title === getDefaultSpecSessionTitle()) {
558
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
559
+ }
560
+ if (interruptedResponse) {
561
+ const messageId = createSpecMessage({
562
+ specSessionId,
563
+ role: 'assistant',
564
+ kind: 'final_response',
565
+ content: interruptedResponse,
566
+ responseTiming: specRunResult.responseTiming,
567
+ transcriptRuns: specRunResult.transcriptRuns,
568
+ });
569
+ if (messageId) {
570
+ bindSpecSessionThinkingMessage(specSessionId, messageId);
571
+ addExecutionRunLink({
572
+ projectId: executionContext.projectId,
573
+ runId: executionContext.runId,
574
+ link: {
575
+ entityType: 'spec_message',
576
+ entityId: messageId,
577
+ relationship: 'result',
578
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
579
+ },
580
+ });
581
+ }
582
+ }
583
+ updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
584
+ updateSpecSessionStatus(specSessionId, 'stopped', interruptedSession?.agent_log ?? null, null);
585
+ emitUpdatedSpecSession(specSessionId, io);
586
+ finalizeAgentRun(mainRunId, 'stopped', interruptedResponse || null);
587
+ transitionExecutionRun({
588
+ projectId: executionContext.projectId,
589
+ runId: executionContext.runId,
590
+ status: 'cancelled',
591
+ domainStatus: 'stopped',
592
+ eventType: 'spec_turn_cancelled',
593
+ }, io);
594
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
595
+ return;
596
+ }
597
+ // Done path: process the completed response.
598
+ const latestCompletedSession = getSpecSession(specSessionId);
599
+ const streamedAssistantMessage = latestCompletedSession?.streaming_response?.trim() ?? '';
600
+ const rawResponse = typeof specRunResult.finalResponse === 'string'
601
+ ? specRunResult.finalResponse
602
+ : '';
603
+ // Resolve the session mode at the moment the turn completed. NULL/legacy
604
+ // values normalize to 'standard'. The worker's per-mode output branch is
605
+ // decided here, not from a re-read after the artifact path, so an
606
+ // auto-finalize mode flip does not change how THIS turn's output parses.
607
+ const sessionMode = resolveSpecModeValue(latestCompletedSession?.mode);
608
+ // Grill mode: try the grill-question extractor first. If a payload is
609
+ // returned, store it as a 'grill-question' assistant message and skip the
610
+ // snapshot. If no payload is returned, fall through to the standard
611
+ // artifact path (the agent signaled it has no more questions and is
612
+ // emitting the full plan — this is the auto-finalize path).
613
+ if (sessionMode === 'grill' && rawResponse) {
614
+ const grillResult = extractGrillQuestionFromOutput(rawResponse);
615
+ if (grillResult.grillQuestion) {
616
+ const cleanedGrillText = grillResult.cleanedOutput.trim()
617
+ || grillResult.grillQuestion.question
618
+ || streamedAssistantMessage.trim();
619
+ const grillMessageId = createSpecMessage({
620
+ specSessionId,
621
+ role: 'assistant',
622
+ kind: 'grill-question',
623
+ content: cleanedGrillText,
624
+ metadata: { grill_question: grillResult.grillQuestion },
625
+ responseTiming: specRunResult.responseTiming,
626
+ transcriptRuns: specRunResult.transcriptRuns,
627
+ });
628
+ if (grillMessageId) {
629
+ bindSpecSessionThinkingMessage(specSessionId, grillMessageId);
630
+ addExecutionRunLink({
631
+ projectId: executionContext.projectId,
632
+ runId: executionContext.runId,
633
+ link: {
634
+ entityType: 'spec_message',
635
+ entityId: grillMessageId,
636
+ relationship: 'result',
637
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
638
+ },
639
+ });
640
+ }
641
+ if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
642
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
643
+ }
644
+ updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
645
+ updateSpecSessionStatus(specSessionId, 'done', getSpecSession(specSessionId)?.agent_log ?? null, null);
646
+ emitUpdatedSpecSession(specSessionId, io);
647
+ finalizeAgentRun(mainRunId, 'done', cleanedGrillText);
648
+ transitionExecutionRun({
649
+ projectId: executionContext.projectId,
650
+ runId: executionContext.runId,
651
+ status: 'completed',
652
+ domainStatus: 'done',
653
+ eventType: 'spec_turn_completed',
654
+ }, io);
655
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
656
+ return;
657
+ }
658
+ }
659
+ const artifactResult = rawResponse
660
+ ? extractSpecArtifactFromOutput(rawResponse)
661
+ : { cleanedOutput: '', artifact: null, error: null };
662
+ const parsedResponse = extractProjectMemorySuggestionsFromOutput(artifactResult.cleanedOutput);
663
+ if (project.memory_enabled && parsedResponse.suggestions && parsedResponse.suggestions.length > 0) {
664
+ persistSpecSessionProjectMemorySuggestions(project.id, specSessionId, parsedResponse.suggestions);
665
+ }
666
+ const cleanedPlanText = stripMermaidBlocks(parsedResponse.cleanedOutput).trim()
667
+ || stripMermaidBlocks(streamedAssistantMessage).trim();
668
+ let assistantMessage = cleanedPlanText
455
669
  || getTranscriptContentFallback(specRunResult.transcriptRuns)
456
- || '';
457
- if (resolvedTool === 'opencode' && interruptedSession?.title === getDefaultSpecSessionTitle()) {
458
- updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
670
+ || (attachments.length > 0 ? ATTACHMENT_ONLY_EMPTY_RESPONSE_FALLBACK : '');
671
+ // Create snapshot from artifact if present. Presence is populated
672
+ // alongside artifact by the extractor, so the two are non-null together.
673
+ if (artifactResult.artifact && artifactResult.presence) {
674
+ // A block re-emitted this turn overrides the previous snapshot's value;
675
+ // an omitted block is carried forward so established artifacts don't
676
+ // disappear when a follow-up turn omits the diagram/mockup.
677
+ const { mermaid: mermaidBlock, mockup: mockupBlock, fileRelationGraph: fileRelationGraphBlock } = resolveCarriedForwardArtifacts({
678
+ rawResponse,
679
+ previousMermaid: previousSnapshot?.mermaid ?? null,
680
+ previousMockup: previousSnapshot?.mockup ?? null,
681
+ previousFileRelationGraph: previousSnapshot?.file_relation_graph ?? null,
682
+ });
683
+ // Optional structured array fields use the same carry-forward contract:
684
+ // a field the LLM emitted this turn (including an explicit empty array)
685
+ // overrides the prior snapshot; a field the LLM omitted is carried
686
+ // forward unchanged so follow-up turns never silently drop information.
687
+ const carriedFields = resolveCarriedForwardStructuredFields({
688
+ artifact: artifactResult.artifact,
689
+ presence: artifactResult.presence,
690
+ previous: previousSnapshot
691
+ ? {
692
+ assumptions: previousSnapshot.assumptions,
693
+ openQuestions: previousSnapshot.open_questions,
694
+ likelyFiles: previousSnapshot.likely_files,
695
+ dependencies: previousSnapshot.dependencies,
696
+ }
697
+ : null,
698
+ });
699
+ const snapshotId = createSpecSnapshot({
700
+ specSessionId,
701
+ summary: artifactResult.artifact.summary,
702
+ assumptions: carriedFields.assumptions,
703
+ openQuestions: carriedFields.openQuestions,
704
+ likelyFiles: carriedFields.likelyFiles,
705
+ tickets: artifactResult.artifact.tickets,
706
+ dependencies: carriedFields.dependencies,
707
+ mermaid: mermaidBlock,
708
+ mockup: mockupBlock,
709
+ fileRelationGraph: fileRelationGraphBlock,
710
+ planText: cleanedPlanText,
711
+ });
712
+ updateSpecSessionLatestSnapshot(specSessionId, snapshotId);
713
+ addExecutionRunLink({
714
+ projectId: executionContext.projectId,
715
+ runId: executionContext.runId,
716
+ link: {
717
+ entityType: 'spec_snapshot',
718
+ entityId: snapshotId,
719
+ relationship: 'result',
720
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
721
+ },
722
+ });
723
+ // Auto-finalize: when an artifact is emitted during grill mode, the
724
+ // agent has signaled that it has no remaining questions. Flip the
725
+ // session mode back to 'standard' so subsequent turns run the standard
726
+ // prompt. This runs inside the same queued job that wrote the snapshot,
727
+ // so it cannot race with a concurrent user-initiated mode change.
728
+ if (sessionMode === 'grill') {
729
+ updateSpecSessionMode(specSessionId, 'standard');
730
+ }
459
731
  }
460
- if (interruptedResponse) {
732
+ // Append artifact error note to the visible message if extraction failed.
733
+ // Suppress the "no block found" note — it is normal for Q&A turns where the
734
+ // agent intentionally omitted the artifact because the specification did not change.
735
+ if (artifactResult.error && assistantMessage && artifactResult.error !== 'No spec artifact block found in planner response') {
736
+ 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.]`;
737
+ }
738
+ if (assistantMessage) {
461
739
  const messageId = createSpecMessage({
462
740
  specSessionId,
463
741
  role: 'assistant',
464
742
  kind: 'final_response',
465
- content: interruptedResponse,
743
+ content: assistantMessage,
466
744
  responseTiming: specRunResult.responseTiming,
467
745
  transcriptRuns: specRunResult.transcriptRuns,
468
746
  });
469
747
  if (messageId) {
470
748
  bindSpecSessionThinkingMessage(specSessionId, messageId);
749
+ addExecutionRunLink({
750
+ projectId: executionContext.projectId,
751
+ runId: executionContext.runId,
752
+ link: {
753
+ entityType: 'spec_message',
754
+ entityId: messageId,
755
+ relationship: 'result',
756
+ expectedParent: { entityType: 'spec_session', entityId: specSessionId },
757
+ },
758
+ });
471
759
  }
472
760
  }
473
- if (specRunResult.transcriptRuns) {
474
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
761
+ if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
762
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
475
763
  }
476
764
  updateSpecSessionUsageSnapshot(specSessionId, specRunResult.usageSnapshot);
477
- updateSpecSessionStatus(specSessionId, 'stopped', interruptedSession?.agent_log ?? null, null);
765
+ updateSpecSessionStatus(specSessionId, 'done', getSpecSession(specSessionId)?.agent_log ?? null, null);
478
766
  emitUpdatedSpecSession(specSessionId, io);
479
- return;
767
+ finalizeAgentRun(mainRunId, 'done', assistantMessage || rawResponse || null);
768
+ transitionExecutionRun({
769
+ projectId: executionContext.projectId,
770
+ runId: executionContext.runId,
771
+ status: 'completed',
772
+ domainStatus: 'done',
773
+ eventType: 'spec_turn_completed',
774
+ }, io);
775
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
480
776
  }
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);
777
+ catch (error) {
778
+ if (specCancellation.stopRequested) {
779
+ untrackSpecProcessById(specSessionId);
780
+ if (mainRunId) {
781
+ finalizeAgentRun(mainRunId, 'stopped', null);
517
782
  }
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);
783
+ updateSpecSessionStatus(specSessionId, 'stopped', null, null);
523
784
  emitUpdatedSpecSession(specSessionId, io);
785
+ transitionExecutionRun({
786
+ projectId: executionContext.projectId,
787
+ runId: executionContext.runId,
788
+ status: 'cancelled',
789
+ domainStatus: 'stopped',
790
+ eventType: 'spec_turn_cancelled',
791
+ }, io);
792
+ emitAgentRunsUpdated(io, 'spec', specSessionId);
524
793
  return;
525
794
  }
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);
795
+ const latestSession = getSpecSession(specSessionId);
796
+ if (resolvedTool === 'opencode' && latestSession?.title === getDefaultSpecSessionTitle()) {
797
+ updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
607
798
  }
799
+ failSpecTurn({ specSessionId, executionContext, mainRunId, error, io });
608
800
  }
609
- if (specRunResult.transcriptRuns) {
610
- clearAndEmitAgentRunsForParent(io, 'spec', specSessionId);
611
- }
612
- if (resolvedTool === 'opencode' && latestCompletedSession?.title === getDefaultSpecSessionTitle()) {
613
- updateSpecSessionTitle(specSessionId, deriveSpecSessionTitle(trimmedInstruction));
801
+ finally {
802
+ deleteStagedSpecAttachments(stagedAgentAttachmentsDirectoryPath);
614
803
  }
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);
804
+ });
805
+ }
806
+ catch (error) {
807
+ untrackSpecProcessById(specSessionId);
808
+ const failureMessage = getErrorMessage(error);
809
+ updateSpecSessionStatus(specSessionId, 'error', failureMessage, null);
810
+ emitUpdatedSpecSession(specSessionId, io);
811
+ throw new TurnStartError('queue_failed', failureMessage);
812
+ }
813
+ queuedJob.catch(error => {
814
+ try {
815
+ failSpecTurn({ specSessionId, executionContext, mainRunId: null, error, io });
640
816
  }
641
- finally {
642
- deleteStagedSpecAttachments(stagedAgentAttachmentsDirectoryPath);
817
+ catch {
818
+ // Startup reconciliation remains the final repair boundary for database failures.
643
819
  }
644
- }).catch(error => {
645
- updateSpecSessionStatus(specSessionId, 'error', getErrorMessage(error), null);
646
- emitUpdatedSpecSession(specSessionId, io);
647
820
  });
648
821
  return true;
649
822
  };