@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
@@ -4,7 +4,7 @@ import { existsSync } from 'fs';
4
4
  import { createServer } from 'http';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { Server } from 'socket.io';
7
- import { randomUUID } from 'crypto';
7
+ import { createHash, randomUUID } from 'crypto';
8
8
  import cors from 'cors';
9
9
  import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator';
10
10
  import db from './db.js';
@@ -26,10 +26,10 @@ import { browseRepoPath } from './lib/directoryPicker.js';
26
26
  import { resolveDesktopServerHost } from './lib/desktopServerHost.js';
27
27
  import { emitMobileRealtime, getMobileAccessStatus, regenerateMobilePairingChallenge, revokeMobileSession, setMobileAccessStatusChangeListener, startMobileAccess, stopMobileAccess, } from './lib/mobileAccess.js';
28
28
  import { validateDesktopMobileAccessRequest } from './lib/mobileAccessSecurity.js';
29
- import { refreshChatSessionUsageSnapshot, refreshSpecSessionUsageSnapshot, stopAllBuildAgents, stopAllReviewAgents } from './lib/agent.js';
29
+ import { refreshChatSessionUsageSnapshot, refreshSpecSessionUsageSnapshot, stopAllBuildAgents, stopAllReviewAgents, stopBuildAgent, stopReviewAgent } from './lib/agent.js';
30
30
  import { runLightweightPrompt } from './lib/lightweightPrompt.js';
31
31
  import { startBuildChain, stopBuildChain } from './lib/buildChains.js';
32
- import { enqueueBundledBuild, getDependencyOrderedBundleTicketIds, removeTicketFromBuildSequence, stopAllActiveBuildSequences, } from './lib/buildSequences.js';
32
+ import { activateQueuedBuildSequenceTicket, compensateBuildSequenceAcceptance, enqueueBundledBuild, getDependencyOrderedBundleTicketIds, hasActiveSequenceForBundle, removeTicketFromBuildSequence, stopBuildSequence, } from './lib/buildSequences.js';
33
33
  import { prepareTicketForBuild } from './lib/buildFlow.js';
34
34
  import { getBundlePlanMutationBlockerById, shouldBlockPlanForBundleMutationById, } from './lib/bundleActivity.js';
35
35
  import { getDiscoveredModels, normalizeModelId, normalizeTicketModelId } from './lib/models.js';
@@ -45,11 +45,17 @@ import { getProjectFileManifest, ProjectFileContentError, ProjectFileTargetUnava
45
45
  import { updateProjectTargetSelection } from './lib/projectTargets.js';
46
46
  import { exportProjectMemoryArchive, importProjectMemoryArchive } from './lib/projectMemoryTransfer.js';
47
47
  import { buildReviewFindingsResponse, computeDiffSignature, getLatestReviewRunForContext, listReviewFindings } from './lib/reviewFindings.js';
48
- import { createBuildOpenQuestionResolutionBatch, failBuildOpenQuestionBatch, formatBuildOpenQuestionAnswersPrompt, getLatestBuildOpenQuestionBatchStatus, hasActiveBuildOpenQuestionBatch, listAnsweredBuildOpenQuestionsForBatch, listBuildOpenQuestionsForBundle, loadBuildOpenQuestionsByBundleIds, setBuildOpenQuestionBatchStatus, } from './lib/buildOpenQuestions.js';
49
- import { clearAutomationPause, countBlockingFindings, depthAtLeast, getBundleAutomationState, isAutomationDepth, recordAutomationPause, resolveAutomationConfig, } from './lib/ticketAutomation.js';
48
+ import { formatBuildOpenQuestionAnswersPrompt, getLatestBuildOpenQuestionBatchStatus, hasActiveBuildOpenQuestionBatch, listBuildOpenQuestionsForBundle, loadBuildOpenQuestionsByBundleIds, } from './lib/buildOpenQuestions.js';
49
+ import { acceptBuildOpenQuestionResolution } from './lib/buildOpenQuestionOperations.js';
50
+ import { completeAutomationProgression, createAutomationReviewFix, finishAutomationReviewFix, getAutomationProgression, recoverLegacyAutomationProgression, recoverStoppedSideEffectProgression, runAutomationProgression, stopAutomationProgression, waitAutomationProgression } from './lib/automationProgression.js';
51
+ import { reconcileAutomationStage } from './lib/automationReconciliation.js';
52
+ import { getAutomationStageDetails, } from './lib/automationStageDetails.js';
53
+ import { clearAutomationPause, countBlockingFindings, depthAtLeast, getBundleAutomationState, isAutomationDepth, recordAutomationPause, resolveAutomationConfig, upsertBundleAutomationState, } from './lib/ticketAutomation.js';
50
54
  import { runAutomationCommitChain } from './lib/ticketAutomationCommit.js';
51
55
  import { getDiscoveredSkills, normalizeSkillNames } from './lib/skills.js';
52
56
  import { clearAllPreviewProxies } from './lib/previewProxy.js';
57
+ import { createExecutionRun, addExecutionRunLink, addExecutionRunLinks, getExecutionRun, hasActiveBuildExecutionRunForTicket, getGitHubActionsExecutionDetails, hasDirectRetryChild, getTestExecutionDetails, GitHubActionsDispatchConflictError, listExecutionRunLinks, listExecutionRuns, listProjectLifecycleEvents, toCanonicalLifecycleEvent, transitionExecutionRun, withExecutionRunTransaction, } from './lib/executionRuns.js';
58
+ import { cleanupAllExecutionEvidence, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS, resolveExecutionEvidenceRetentionDays, } from './lib/executionRunRetention.js';
53
59
  import { shutdownRealtimeServer } from './lib/shutdown.js';
54
60
  import { resolveClientBuildPaths, shouldServeClientApp, STATIC_CLIENT_DIRECTORY_OPTIONS, STATIC_CLIENT_INDEX_OPTIONS, } from './lib/staticClient.js';
55
61
  import { deleteIntegrationConnection, getGitHubConnectionConfig, getJiraConnectionConfig, listIntegrationConnections, upsertIntegrationConnection, } from './lib/integrations/index.js';
@@ -58,24 +64,25 @@ import { importJiraIssue, JiraApiError, prepareJiraIssueImport, validateJiraConn
58
64
  import { importGitHubIssue, prepareGitHubIssueImport } from './lib/integrations/github-issues.js';
59
65
  import { buildImportedIssueSessionTitle, importProviderHostedIssueImages } from './lib/integrations/importedIssueImages.js';
60
66
  import { dispatchGitHubWorkflow, getGitHubWorkflowDispatchConfig, getGitHubWorkflowRun, getGitHubWorkflowRunJobs, listGitHubWorkflows, listGitHubWorkflowRuns, } from './lib/integrations/githubActions.js';
61
- import { clearAllGitHubActionsSessions, getActiveTrackedRunCount, getTrackedGitHubActionsRuns, trackGitHubActionsRun, } from './lib/integrations/githubActionsSessions.js';
67
+ import { clearAllGitHubActionsSessions, getActiveTrackedRunCount, getTrackedGitHubActionsRuns, startGitHubActionsPolling, trackGitHubActionsRun, } from './lib/integrations/githubActionsSessions.js';
68
+ import { reconcileStartupExecutions } from './lib/executionReconciliation.js';
62
69
  import { PlanningReferenceError, resolveExternalPlanningDescription } from './lib/integrations/planning.js';
63
70
  import { refreshIfNeeded as refreshAssignedIssues, clearCachedAssignedIssues } from './lib/integrations/assignedIssuesCache.js';
64
71
  import { buildCommitMessagePrompt, normalizeGeneratedCommitMessage } from './lib/commitMessage.js';
65
72
  import { BUNDLE_PULL_REQUEST_SYNC_TTL_MS, getBundlePullRequestRecord, hasOpenBundlePullRequest, serializeBundlePullRequest, shouldSyncBundlePullRequest, syncTrackedBundlePullRequestIfNeeded, upsertBundlePullRequest, upsertBundlePullRequestFromGitHub, } from './lib/bundlePullRequests.js';
66
73
  import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listProjects, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, } from './lib/projects.js';
67
74
  import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopTicket } from './lib/run.js';
68
- import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
75
+ import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExist, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
69
76
  import { getBundleBuildBlockerResponse, shouldStartBundleBuildNow, transitionTicketState, } from './lib/ticketWorkflowOperations.js';
70
77
  import { submitPlanTicketFollowUp, submitTicketFollowUp, } from './lib/ticketFollowUpOperations.js';
71
78
  import { canUseBuildControls, formatActiveRunConflict, getTicketRunReadinessError, resumeBuildTicketRun, runProjectTarget, runTicketRun, stopBuildTicketRun, stopReviewTicketRun, stopProjectTargetRun, stopTicketRun, } from './lib/ticketRunOperations.js';
72
79
  import { isTicketAgentSettingsPayload, updateTicketAgentSettings, } from './lib/ticketSettingsOperations.js';
73
- import { resolveBundleReviewOwner, transitionBundleToReview } from './lib/bundleReviewOperations.js';
74
- import { getActiveBundleStageChangeHead, undoTicketStage } from './lib/ticketUndo.js';
80
+ import { rerunTicketReviewOperation, resolveBundleReviewOwner, transitionBundleToReview } from './lib/bundleReviewOperations.js';
81
+ import { clearTicketStageSnapshots, getActiveBundleStageChangeHead, reconcileStageSnapshots, undoTicketStage } from './lib/ticketUndo.js';
75
82
  import { closeAllTerminalSessions, createProjectTerminalSession, createTerminalSessionForWorkspace, destroyTerminalSessionById, registerTerminalSocketHandlers, } from './lib/terminal.js';
76
83
  import { countBundleTickets, createTicketRecord, createBundle, deleteBundle, ensureProjectRootBundle, autoParkTicketIfStale, autoParkSettledBundleTicket, isBundleSettledForAutoPark, clearAutoParkDismissalIfNeeded, assignTicketDoneOrder, assignTicketLaneOrder, getBundle, getBundleByName, getBundleByBranch, getBundleRepresentativeTicketContext, isProjectRootBundle, getTicket, isBundledTicket, listTicketsInRunContext, listBundles, listBoardTicketEnrichment, listTickets, resolveTicketBranch, resolveTicketTool, updateBundleAutomationConfig, updateBundleIdentity, } from './lib/tickets.js';
77
84
  import { createTicketMessage, upsertTicketDescriptionMessage } from './lib/ticketMessages.js';
78
- import { listAgentRunsForParent } from './lib/agentRuns.js';
85
+ import { listAgentRunsForExecutionRun, listAgentRunsForParent, terminalizeAgentRunsForExecutionRun } from './lib/agentRuns.js';
79
86
  import { HttpError } from './lib/httpError.js';
80
87
  import { parseIncomingExternalSource } from './lib/externalSource.js';
81
88
  import { materializeTicketsWithDependencies } from './lib/ticketBatch.js';
@@ -84,9 +91,8 @@ import { buildSpecImplementationDraft, buildVisualizationAppendix } from './lib/
84
91
  import { exportSpecToDocs, checkSpecExportCollision } from './lib/specExport.js';
85
92
  import { createTicketDependency, deleteTicketDependenciesForTicket, deleteTicketDependency, getTicketDependency, isTicketRunSetupReady, listTicketDependencies, validateCreateTicketDependency, } from './lib/ticketDependencies.js';
86
93
  import { startBuild } from './workers/build.js';
87
- import { startFollowUp } from './workers/followUp.js';
88
- import { expandPlanningTicketOutline, generatePlanningTicketOutline, generatePlanText, prepareLinkedPlanningResearch, startPlan, } from './workers/plan.js';
89
- import { startReview } from './workers/review.js';
94
+ import { expandPlanningTicketOutline, generatePlanningTicketOutline, generatePlanText, prepareLinkedPlanningResearch, startPlan, stopAllPlanAgents, } from './workers/plan.js';
95
+ import { EXECUTION_RUN_KINDS, validateWorkflowContext } from './lib/types.js';
90
96
  const getErrorMessage = (error) => {
91
97
  return error instanceof Error ? error.message : String(error);
92
98
  };
@@ -134,6 +140,87 @@ const parseMultiValueQuery = (value, allowedValues, fieldName) => {
134
140
  values: entries.filter((entry, index, values) => values.indexOf(entry) === index),
135
141
  };
136
142
  };
143
+ const EXECUTION_RUN_STATUSES = ['queued', 'running', 'waiting', 'completed', 'failed', 'cancelled', 'interrupted'];
144
+ const parseLifecycleListQuery = (query) => {
145
+ const kind = parseMultiValueQuery(query.kind, EXECUTION_RUN_KINDS, 'kind');
146
+ if ('error' in kind) {
147
+ return kind;
148
+ }
149
+ if (kind.values.length > 1) {
150
+ return { error: 'kind must include at most one value' };
151
+ }
152
+ const status = parseMultiValueQuery(query.status, EXECUTION_RUN_STATUSES, 'status');
153
+ if ('error' in status) {
154
+ return status;
155
+ }
156
+ if (query.cursor != null && typeof query.cursor !== 'string') {
157
+ return { error: 'cursor must be a string' };
158
+ }
159
+ const limit = query.limit == null || query.limit === '' ? 50 : Number(query.limit);
160
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) {
161
+ return { error: 'limit must be an integer between 1 and 200' };
162
+ }
163
+ return {
164
+ kind: kind.values[0],
165
+ statuses: status.values,
166
+ cursor: typeof query.cursor === 'string' && query.cursor !== '' ? query.cursor : null,
167
+ limit,
168
+ };
169
+ };
170
+ const serializeTestExecutionDetail = (details, retryEligibility) => {
171
+ if (!details) {
172
+ return { kind: 'test', available: false, retry_eligibility: retryEligibility };
173
+ }
174
+ return {
175
+ kind: 'test',
176
+ available: true,
177
+ command_id: details.command_id,
178
+ command_name: details.command_name,
179
+ command_runner: details.command_runner,
180
+ target_kind: details.target_kind,
181
+ target_bundle_id: details.target_bundle_id,
182
+ target_label: details.target_label,
183
+ ticket_id: details.ticket_id,
184
+ selection: details.selection,
185
+ summary: details.summary,
186
+ exit_code: details.exit_code,
187
+ duration_ms: details.duration_ms,
188
+ output: details.output,
189
+ output_truncated: details.output_truncated,
190
+ output_retention_state: details.output_retention_state,
191
+ output_expired_at: details.output_expired_at,
192
+ output_byte_count: details.output_byte_count,
193
+ retry_eligibility: retryEligibility,
194
+ };
195
+ };
196
+ const serializeGitHubActionsExecutionDetail = (details) => {
197
+ if (!details) {
198
+ return { kind: 'github_actions', available: false };
199
+ }
200
+ return {
201
+ kind: 'github_actions',
202
+ available: true,
203
+ workflow_id: details.workflow_id,
204
+ workflow_name: details.workflow_name,
205
+ ref: details.ref,
206
+ input_names: details.input_names,
207
+ provider_run_id: details.provider_run_id,
208
+ provider_status: details.provider_status,
209
+ conclusion: details.conclusion,
210
+ html_url: details.html_url,
211
+ head_sha: details.head_sha,
212
+ head_branch: details.head_branch,
213
+ provider_created_at: details.provider_created_at,
214
+ provider_updated_at: details.provider_updated_at,
215
+ provider_started_at: details.provider_started_at,
216
+ dispatched_at: details.dispatched_at,
217
+ last_synced_at: details.last_synced_at,
218
+ jobs: details.jobs,
219
+ jobs_retention_state: details.jobs_retention_state,
220
+ jobs_expired_at: details.jobs_expired_at,
221
+ jobs_count: details.jobs_count,
222
+ };
223
+ };
137
224
  const formatWorktreeDeleteError = (target, branch, error) => {
138
225
  return `Unable to delete the ${target} worktree for branch "${branch}": ${getErrorMessage(error)}`;
139
226
  };
@@ -183,21 +270,13 @@ const getBundlePlanBlockerError = () => {
183
270
  return 'AI planning is blocked because this bundle worktree is currently being modified.';
184
271
  };
185
272
  const deleteTicketRecord = async (ticket) => {
186
- if (ticket.agent_status === 'running') {
273
+ if (ticket.agent_status === 'running'
274
+ || (ticket.project_id && hasActiveBuildExecutionRunForTicket(ticket.project_id, ticket.id))) {
187
275
  return { ok: false, status: 409, error: 'Cannot delete a ticket while the agent is running' };
188
276
  }
189
277
  if (isRunning(ticket)) {
190
278
  await stopTicket(ticket);
191
279
  }
192
- let nextBuildTicketId = null;
193
- let affectedTicketIds = [];
194
- if (ticket.state === 'build') {
195
- const sequenceRemoval = removeTicketFromBuildSequence(ticket.id);
196
- if (sequenceRemoval.ok) {
197
- nextBuildTicketId = sequenceRemoval.nextTicketId;
198
- affectedTicketIds = sequenceRemoval.affectedTicketIds;
199
- }
200
- }
201
280
  const branch = resolveTicketBranch(ticket);
202
281
  if (!isBundledTicket(ticket) && branch) {
203
282
  const project = getEffectiveProject(ticket);
@@ -216,7 +295,24 @@ const deleteTicketRecord = async (ticket) => {
216
295
  }
217
296
  const bundledProject = isBundledTicket(ticket) ? getEffectiveProject(ticket) : null;
218
297
  const deletedBundleId = ticket.worktree_bundle_id;
298
+ let nextBuildTicketId = null;
299
+ let nextBuildSequenceId = null;
300
+ let affectedTicketIds = [];
301
+ if (ticket.state === 'build') {
302
+ const sequenceRemoval = removeTicketFromBuildSequence(ticket.id);
303
+ if (sequenceRemoval.ok) {
304
+ nextBuildTicketId = sequenceRemoval.nextTicketId;
305
+ nextBuildSequenceId = sequenceRemoval.sequenceId;
306
+ affectedTicketIds = sequenceRemoval.affectedTicketIds;
307
+ }
308
+ }
219
309
  db.prepare('DELETE FROM tickets WHERE id = ?').run(ticket.id);
310
+ try {
311
+ clearTicketStageSnapshots(ticket.id);
312
+ }
313
+ catch (error) {
314
+ console.warn(`[ticket-delete] Unable to remove stage snapshots for ticket ${ticket.id}`, error);
315
+ }
220
316
  if (bundledProject && deletedBundleId) {
221
317
  try {
222
318
  cleanupBundleIfEmpty(deletedBundleId, bundledProject);
@@ -230,7 +326,7 @@ const deleteTicketRecord = async (ticket) => {
230
326
  }
231
327
  }
232
328
  io.emit('ticket:deleted', { id: ticket.id });
233
- return { ok: true, nextBuildTicketId, affectedTicketIds };
329
+ return { ok: true, nextBuildTicketId, nextBuildSequenceId, affectedTicketIds };
234
330
  };
235
331
  const emitUpdatedTickets = (ticketIds) => {
236
332
  for (const ticketId of ticketIds) {
@@ -240,6 +336,41 @@ const emitUpdatedTickets = (ticketIds) => {
240
336
  }
241
337
  }
242
338
  };
339
+ const scheduleBuildAfterTicketDeletion = (result) => {
340
+ if (!result.nextBuildTicketId || !result.nextBuildSequenceId) {
341
+ return;
342
+ }
343
+ const nextTicket = getTicket(result.nextBuildTicketId);
344
+ if (!nextTicket) {
345
+ stopBuildSequence(result.nextBuildTicketId);
346
+ return;
347
+ }
348
+ const scheduling = startBuild(nextTicket, io, {
349
+ sequenceId: result.nextBuildSequenceId,
350
+ initiation: { initiator: 'automation' },
351
+ accept: () => {
352
+ const transition = prepareTicketForBuild(nextTicket.id);
353
+ if (!transition.ok) {
354
+ throw new Error(transition.error);
355
+ }
356
+ activateQueuedBuildSequenceTicket(result.nextBuildSequenceId, transition.ticket.id);
357
+ return {
358
+ ticket: transition.ticket,
359
+ sequenceId: result.nextBuildSequenceId,
360
+ compensate: () => {
361
+ db.prepare(`
362
+ UPDATE build_sequence_tickets SET status = 'queued', started_at = NULL
363
+ WHERE build_sequence_id = ? AND ticket_id = ? AND status = 'active'
364
+ `).run(result.nextBuildSequenceId, transition.ticket.id);
365
+ },
366
+ };
367
+ },
368
+ });
369
+ if (!scheduling.accepted) {
370
+ const stopped = stopBuildSequence(nextTicket.id);
371
+ emitUpdatedTickets(stopped.affectedTicketIds);
372
+ }
373
+ };
243
374
  const CORS_ORIGINS = parseCorsOrigins(process.env.CORS_ORIGIN);
244
375
  const CORS_ORIGIN_RESOLVER = createCorsOriginResolver(CORS_ORIGINS);
245
376
  const app = express();
@@ -523,16 +654,15 @@ const asProjectPayload = (body) => {
523
654
  const isWorktreeReady = (branch, project) => {
524
655
  return existsSync(resolveWorktreePath(branch, project));
525
656
  };
526
- // Any ticket left in 'running' on startup had its job lost when the server stopped
527
- db.prepare("UPDATE tickets SET agent_status = 'error' WHERE agent_status = 'running'").run();
528
- // Same recovery for spec and chat sessions: the in-memory process maps are wiped
529
- // on restart, so any session still marked 'running' is orphaned and would
530
- // otherwise be permanently uninterruptible (the stop endpoint returns 409).
531
- db.prepare("UPDATE spec_sessions SET agent_status = 'error' WHERE agent_status = 'running'").run();
532
- db.prepare("UPDATE chat_sessions SET agent_status = 'error' WHERE agent_status = 'running'").run();
533
- // Live run rows belong to in-memory child processes and are never resumable after restart.
534
- db.prepare('DELETE FROM agent_runs').run();
535
- stopAllActiveBuildSequences();
657
+ try {
658
+ const reconciliation = reconcileStageSnapshots();
659
+ if (reconciliation.removedRecords > 0 || reconciliation.removedDirectories > 0) {
660
+ console.info('[stage-snapshots] Reconciled stale undo data', reconciliation);
661
+ }
662
+ }
663
+ catch (error) {
664
+ console.warn('[stage-snapshots] Unable to reconcile stale undo data', error);
665
+ }
536
666
  const getRequestedProjectId = (req) => {
537
667
  if (typeof req.query.project_id === 'string' && req.query.project_id !== '') {
538
668
  return req.query.project_id;
@@ -586,6 +716,9 @@ const resolveTicketDiffWorkspace = (ticket) => {
586
716
  return { error: 'Ticket project no longer exists', status: 400 };
587
717
  }
588
718
  const bundle = ticket.worktree_bundle_id ? getBundle(ticket.worktree_bundle_id, project.id) : null;
719
+ if (ticket.worktree_bundle_id && !bundle) {
720
+ return { error: 'Selected bundle target no longer exists', status: 409 };
721
+ }
589
722
  if (bundle && isProjectRootBundle(bundle)) {
590
723
  const branchState = getProjectBranch(project, { includeDiffStats: false });
591
724
  if (branchState.status !== 'ready' || !branchState.branch) {
@@ -598,7 +731,7 @@ const resolveTicketDiffWorkspace = (ticket) => {
598
731
  kind: 'project_root',
599
732
  };
600
733
  }
601
- const branch = resolveTicketBranch(ticket);
734
+ const branch = bundle?.branch ?? resolveTicketBranch(ticket);
602
735
  if (!branch || !isWorktreeReady(branch, project)) {
603
736
  return { error: 'Worktree not ready yet', status: 400 };
604
737
  }
@@ -620,6 +753,9 @@ const resolveTicketDiffWorkspaceFromContext = (ticket) => {
620
753
  if (!project) {
621
754
  return { error: 'Ticket project no longer exists', status: 400 };
622
755
  }
756
+ if (ticket.worktree_bundle_id && !ticket.bundle_kind) {
757
+ return { error: 'Selected bundle target no longer exists', status: 409 };
758
+ }
623
759
  if (ticket.bundle_kind === 'project_root') {
624
760
  const branchState = getProjectBranch(project, { includeDiffStats: false });
625
761
  if (branchState.status !== 'ready' || !branchState.branch) {
@@ -632,7 +768,7 @@ const resolveTicketDiffWorkspaceFromContext = (ticket) => {
632
768
  kind: 'project_root',
633
769
  };
634
770
  }
635
- const branch = resolveTicketRouteBranch(ticket);
771
+ const branch = ticket.worktree_bundle_id ? ticket.bundle_branch : ticket.branch;
636
772
  if (!branch || !isWorktreeReady(branch, project)) {
637
773
  return { error: 'Worktree not ready yet', status: 400 };
638
774
  }
@@ -811,6 +947,63 @@ const parseTestTargetSelection = (value) => {
811
947
  }
812
948
  return null;
813
949
  };
950
+ const unavailableTestRetry = (reason, status, message) => ({
951
+ eligibility: { eligible: false, reason },
952
+ error: { status, message },
953
+ });
954
+ const resolveTestRetry = (project, executionRun, details) => {
955
+ if (!executionRun || executionRun.kind !== 'test' || !details) {
956
+ return unavailableTestRetry('details_unavailable', 404, 'Test execution run not found');
957
+ }
958
+ if (!['failed', 'cancelled', 'interrupted'].includes(executionRun.status)) {
959
+ return unavailableTestRetry('run_not_terminal', 409, 'Only failed, cancelled, or interrupted test runs can be retried.');
960
+ }
961
+ if (hasDirectRetryChild(executionRun.id)) {
962
+ return unavailableTestRetry('retry_already_created', 409, 'A retry has already been created for this test run.');
963
+ }
964
+ if (!details.command_id) {
965
+ return unavailableTestRetry('command_unidentifiable', 409, 'The original test command is no longer identifiable.');
966
+ }
967
+ const command = findProjectTestCommand(project, details.command_id);
968
+ if (!command) {
969
+ return unavailableTestRetry('command_missing', 404, 'Test command not found');
970
+ }
971
+ const selection = parseTestTargetSelection(details.selection);
972
+ if (!selection) {
973
+ return unavailableTestRetry('selection_invalid', 409, 'The original test selection is no longer valid.');
974
+ }
975
+ let workspace;
976
+ if (details.ticket_id) {
977
+ const ticket = getTicket(details.ticket_id);
978
+ if (!ticket || ticket.project_id !== project.id) {
979
+ return unavailableTestRetry('target_missing', 404, 'Ticket not found');
980
+ }
981
+ workspace = resolveTicketTestWorkspace(ticket);
982
+ }
983
+ else if (details.target_kind === 'repo_root' || details.target_kind === 'bundle') {
984
+ workspace = resolveExplicitProjectTestWorkspace(project, details.target_kind, details.target_bundle_id);
985
+ }
986
+ else {
987
+ return unavailableTestRetry('target_invalid', 409, 'The original test target is no longer valid.');
988
+ }
989
+ if ('error' in workspace) {
990
+ return unavailableTestRetry('target_unavailable', workspace.status, workspace.error);
991
+ }
992
+ if (!isTestSelectionSupported(command, selection, workspace.cwd)) {
993
+ return unavailableTestRetry('selection_unsupported', 409, 'The selected test command only supports running the full suite.');
994
+ }
995
+ if (selection.kind !== 'all' && !doesTestFileExist(workspace.cwd, selection.path, command)) {
996
+ return unavailableTestRetry('test_file_missing', 404, 'Selected test file no longer exists in this target workspace.');
997
+ }
998
+ return {
999
+ eligibility: { eligible: true, reason: null },
1000
+ details,
1001
+ executionRun,
1002
+ command,
1003
+ selection,
1004
+ workspace,
1005
+ };
1006
+ };
814
1007
  const resolveProjectFileSuggestionWorkspace = (project, options = {}) => {
815
1008
  const ticketId = options.ticketId?.trim() || null;
816
1009
  if (ticketId) {
@@ -1421,25 +1614,15 @@ const submitBundleFollowUpResponse = (projectId, bundleId, payload, broadcaster)
1421
1614
  if (!targetTicket) {
1422
1615
  return { ok: false, status: 409, error: 'No non-plan tickets are available for follow-up in this bundle' };
1423
1616
  }
1424
- const effectiveProject = getEffectiveProject(targetTicket);
1425
- if (!effectiveProject) {
1426
- return { ok: false, status: 400, error: 'Ticket project no longer exists' };
1427
- }
1428
- const branch = resolveTicketBranch(targetTicket);
1429
- if (!branch || targetTicket.has_worktree === false || !isWorktreeReady(branch, effectiveProject)) {
1430
- return { ok: false, status: 400, error: 'Worktree not ready yet' };
1431
- }
1432
- const started = startFollowUp(targetTicket, broadcaster, instruction, mode);
1433
- if (!started) {
1434
- return { ok: false, status: 409, error: 'Ticket is already running' };
1435
- }
1436
- createTicketMessage({
1617
+ const submitted = submitTicketFollowUp({
1437
1618
  ticketId: targetTicket.id,
1438
- role: 'user',
1439
- kind: 'follow_up',
1440
- content: instruction,
1441
- origin: payload?.origin === 'changes' ? 'changes' : null,
1619
+ requestedProjectId: project.id,
1620
+ payload: { ...payload, instruction, mode },
1621
+ broadcaster: broadcaster,
1442
1622
  });
1623
+ if (!submitted.ok) {
1624
+ return submitted;
1625
+ }
1443
1626
  return {
1444
1627
  ok: true,
1445
1628
  status: 200,
@@ -1462,8 +1645,11 @@ const rerunTicketReview = (ticketId) => {
1462
1645
  if (!ticket.can_re_review) {
1463
1646
  return { ok: false, status: 409, error: 'Review can only be re-run after a follow-up finishes' };
1464
1647
  }
1465
- startReview(ticket, io);
1466
- return { ok: true, ticket: getTicket(ticket.id) ?? ticket };
1648
+ const scheduling = rerunTicketReviewOperation(ticket, io);
1649
+ if (!scheduling.ok) {
1650
+ return { ok: false, status: scheduling.status, error: scheduling.error };
1651
+ }
1652
+ return { ok: true, ticket: scheduling.value ?? ticket };
1467
1653
  };
1468
1654
  const reviewBundleTickets = (projectId, bundleId) => {
1469
1655
  const project = getProjectById(projectId);
@@ -1534,19 +1720,81 @@ const buildBundleTickets = (projectId, bundleId, broadcaster) => {
1534
1720
  }
1535
1721
  }
1536
1722
  const movedTickets = [];
1537
- for (const planTicket of orderedPlanTickets) {
1538
- const buildTransition = prepareTicketForBuild(planTicket.id, {
1539
- ignoredBlockerTicketIds: bundleTicketIds,
1540
- });
1541
- if (!buildTransition.ok) {
1542
- return {
1543
- ok: false,
1544
- status: buildTransition.status,
1545
- error: buildTransition.error,
1546
- ...(buildTransition.blockers ? { details: { blockers: buildTransition.blockers } } : {}),
1547
- };
1723
+ const ticketSnapshots = orderedPlanTickets.map(ticket => db.prepare(`
1724
+ SELECT id, project_id, state, tool, agent_status, agent_log, streaming_response, branch,
1725
+ build_completed_at, run_setup_status, auto_park_dismissed_at, planning_context_json, lane_order
1726
+ FROM tickets
1727
+ WHERE id = ? AND project_id = ?
1728
+ `).get(ticket.id, project.id));
1729
+ const firstPlanTicket = orderedPlanTickets[0];
1730
+ if (!firstPlanTicket) {
1731
+ return { ok: false, status: 409, error: 'No plan tickets are available to build in this bundle' };
1732
+ }
1733
+ const acceptBundleBuild = () => {
1734
+ let activeSequenceId = null;
1735
+ for (const [index, planTicket] of orderedPlanTickets.entries()) {
1736
+ const buildTransition = prepareTicketForBuild(planTicket.id, {
1737
+ ignoredBlockerTicketIds: bundleTicketIds,
1738
+ });
1739
+ if (!buildTransition.ok) {
1740
+ throw new Error(buildTransition.error);
1741
+ }
1742
+ movedTickets.push(buildTransition.ticket);
1743
+ const queuedBuild = enqueueBundledBuild({
1744
+ ticketId: buildTransition.ticket.id,
1745
+ projectId: project.id,
1746
+ worktreeBundleId: bundle.id,
1747
+ activate: index === 0,
1748
+ });
1749
+ if (index === 0) {
1750
+ activeSequenceId = queuedBuild.sequenceId;
1751
+ }
1752
+ }
1753
+ return {
1754
+ ticket: movedTickets[0],
1755
+ sequenceId: activeSequenceId,
1756
+ ...(activeSequenceId
1757
+ ? {
1758
+ compensate: () => {
1759
+ compensateBuildSequenceAcceptance(activeSequenceId, movedTickets.map(ticket => ticket.id));
1760
+ for (const snapshot of ticketSnapshots) {
1761
+ db.prepare(`
1762
+ UPDATE tickets
1763
+ SET state = ?, tool = ?, agent_status = ?, agent_log = ?, streaming_response = ?, branch = ?,
1764
+ build_completed_at = ?, run_setup_status = ?, auto_park_dismissed_at = ?,
1765
+ planning_context_json = ?, lane_order = ?, updated_at = CURRENT_TIMESTAMP
1766
+ WHERE id = ? AND project_id = ?
1767
+ `).run(snapshot.state, snapshot.tool, snapshot.agent_status, snapshot.agent_log, snapshot.streaming_response, snapshot.branch, snapshot.build_completed_at, snapshot.run_setup_status, snapshot.auto_park_dismissed_at, snapshot.planning_context_json, snapshot.lane_order, snapshot.id, snapshot.project_id);
1768
+ }
1769
+ },
1770
+ }
1771
+ : {}),
1772
+ };
1773
+ };
1774
+ if (hasActiveSequenceForBundle(project.id, bundle.id)) {
1775
+ try {
1776
+ db.transaction(acceptBundleBuild)();
1777
+ }
1778
+ catch (error) {
1779
+ return { ok: false, status: 409, error: error instanceof Error ? error.message : String(error) };
1548
1780
  }
1549
- movedTickets.push(buildTransition.ticket);
1781
+ emitUpdatedTickets(movedTickets.map(ticket => ticket.id));
1782
+ return {
1783
+ ok: true,
1784
+ status: 200,
1785
+ value: serializeBundleDetails(project, bundle, { target_ticket_id: movedTickets[0]?.id ?? null }),
1786
+ };
1787
+ }
1788
+ const scheduling = startBuild(firstPlanTicket, broadcaster, { accept: acceptBundleBuild });
1789
+ if (scheduling === undefined) {
1790
+ acceptBundleBuild();
1791
+ }
1792
+ else if (!scheduling.accepted) {
1793
+ return {
1794
+ ok: false,
1795
+ status: scheduling.reason === 'queue_failed' ? 503 : 409,
1796
+ error: scheduling.error,
1797
+ };
1550
1798
  }
1551
1799
  emitUpdatedTickets(movedTickets.map(ticket => ticket.id));
1552
1800
  const details = serializeBundleDetails(project, bundle, {
@@ -1556,22 +1804,6 @@ const buildBundleTickets = (projectId, bundleId, broadcaster) => {
1556
1804
  ok: true,
1557
1805
  status: 200,
1558
1806
  value: details,
1559
- afterResponse: () => {
1560
- for (const [index, movedTicket] of movedTickets.entries()) {
1561
- if (!movedTicket.project_id || !movedTicket.worktree_bundle_id) {
1562
- continue;
1563
- }
1564
- const queuedBuild = enqueueBundledBuild({
1565
- ticketId: movedTicket.id,
1566
- projectId: movedTicket.project_id,
1567
- worktreeBundleId: movedTicket.worktree_bundle_id,
1568
- activate: index === 0 && shouldStartBundleBuildNow(movedTicket),
1569
- });
1570
- if (queuedBuild.shouldStartNow) {
1571
- startBuild(movedTicket, broadcaster);
1572
- }
1573
- }
1574
- },
1575
1807
  };
1576
1808
  };
1577
1809
  const getCurrentReviewWorkspaceSignature = (ticket) => {
@@ -1912,14 +2144,14 @@ const validateExpandedPlanningAgentDraft = (outline, draft) => {
1912
2144
  }
1913
2145
  return draft;
1914
2146
  };
1915
- const createPlanningAgentTickets = db.transaction(({ draft, initiativePrompt, projectId, model, variant, skills, tool, worktreeBundleId, }) => {
2147
+ const createPlanningAgentTickets = db.transaction(({ draft, initiativePrompt, projectId, model, variant, skills, worktreeBundleId, }) => {
1916
2148
  const { createdTickets, createdDependencies, ticketIdsByKey } = materializeTicketsWithDependencies({
1917
2149
  options: {
1918
2150
  projectId,
1919
2151
  model,
1920
2152
  variant,
1921
2153
  skills,
1922
- tool: tool ?? 'opencode',
2154
+ tool: null,
1923
2155
  worktreeBundleId,
1924
2156
  agentStatus: 'done',
1925
2157
  },
@@ -2761,6 +2993,7 @@ app.post('/projects/:id/spec-sessions/:sessionId/snapshots/:snapshotId/review/st
2761
2993
  projectId: req.params.id,
2762
2994
  specSessionId: req.params.sessionId,
2763
2995
  snapshotId: req.params.snapshotId,
2996
+ broadcaster: io,
2764
2997
  });
2765
2998
  if (!result.ok) {
2766
2999
  return res.status(result.status).json({ error: result.error });
@@ -3470,7 +3703,6 @@ app.post('/bundles/:id/build', (req, res) => {
3470
3703
  });
3471
3704
  }
3472
3705
  res.status(buildResult.status).json(buildResult.value);
3473
- buildResult.afterResponse?.();
3474
3706
  });
3475
3707
  app.post('/bundles/:id/review', (req, res) => {
3476
3708
  const result = getRequiredProject(req);
@@ -3512,33 +3744,51 @@ app.post('/bundles/:id/build-open-questions/resolve', (req, res) => {
3512
3744
  if (resolutions.some(resolution => resolution === null || !resolution.questionId)) {
3513
3745
  return res.status(400).json({ error: 'Each resolution requires a question_id' });
3514
3746
  }
3515
- const buildTickets = listBundleSliceTickets(result.project.id, bundle.id, 'build');
3516
- if (buildTickets.length === 0
3517
- || buildTickets.some(ticket => ticket.agent_status !== 'done' && ticket.agent_status !== 'stopped')) {
3518
- return res.status(409).json({ error: 'All build tickets in the bundle must be done or stopped before resolving questions' });
3519
- }
3520
- const created = createBuildOpenQuestionResolutionBatch({
3747
+ const accepted = acceptBuildOpenQuestionResolution({
3521
3748
  projectId: result.project.id,
3522
3749
  bundleId: bundle.id,
3523
3750
  resolutions: resolutions.filter((resolution) => resolution !== null),
3751
+ broadcaster: io,
3524
3752
  });
3525
- if (!created.ok) {
3526
- return res.status(409).json({
3527
- error: created.error,
3528
- ...(created.code ? { code: created.code } : {}),
3753
+ if (!accepted.ok) {
3754
+ return res.status(accepted.status).json({
3755
+ error: accepted.error,
3756
+ ...(accepted.code ? { code: accepted.code } : {}),
3529
3757
  });
3530
3758
  }
3531
- const answeredQuestions = listAnsweredBuildOpenQuestionsForBatch(created.batch.id);
3532
- if (answeredQuestions.length === 0) {
3533
- clearAutomationPause(result.project.id, bundle.id);
3759
+ if (accepted.kind === 'dismissed') {
3534
3760
  const automationConfig = resolveAutomationConfig(bundle, result.project);
3535
3761
  const reviewResult = depthAtLeast(automationConfig.depth, 'auto_review')
3536
- ? transitionBundleToReview(result.project.id, bundle.id, io, { automatic: true })
3762
+ ? transitionBundleToReview(result.project.id, bundle.id, io, {
3763
+ automatic: true,
3764
+ initiation: {
3765
+ causalParentRunId: accepted.progressionRunId,
3766
+ workflowContext: validateWorkflowContext({ build_open_question_batch_id: accepted.batchId }),
3767
+ },
3768
+ })
3537
3769
  : null;
3770
+ if (!reviewResult) {
3771
+ completeAutomationProgression({
3772
+ projectId: result.project.id,
3773
+ bundleId: bundle.id,
3774
+ progressionRunId: accepted.progressionRunId,
3775
+ broadcaster: io,
3776
+ });
3777
+ }
3538
3778
  for (const ticket of listDetailedBundleTickets(result.project.id, bundle.id)) {
3539
3779
  io.emit('ticket:updated', ticket);
3540
3780
  }
3541
3781
  if (reviewResult && !reviewResult.ok) {
3782
+ waitAutomationProgression({
3783
+ projectId: result.project.id,
3784
+ bundleId: bundle.id,
3785
+ progressionRunId: accepted.progressionRunId,
3786
+ pauseCode: 'review',
3787
+ pauseReason: reviewResult.error,
3788
+ reasonCode: 'stage_failed',
3789
+ pausedStageRunId: null,
3790
+ broadcaster: io,
3791
+ });
3542
3792
  return res.status(reviewResult.status).json({ error: reviewResult.error });
3543
3793
  }
3544
3794
  return res.json(serializeBundleDetails(result.project, bundle, {
@@ -3546,35 +3796,11 @@ app.post('/bundles/:id/build-open-questions/resolve', (req, res) => {
3546
3796
  target_ticket_id: reviewResult?.value?.id,
3547
3797
  }));
3548
3798
  }
3549
- const targetTicketSource = resolveBundleTargetTicket(buildTickets);
3550
- const targetTicket = targetTicketSource ? getTicket(targetTicketSource.id) : null;
3551
- if (!targetTicket) {
3552
- failBuildOpenQuestionBatch(created.batch.id, 'No completed Build ticket is available for the follow-up');
3553
- recordAutomationPause(result.project.id, bundle.id, 'open_questions', 'Build open question follow-up could not be started');
3554
- return res.status(409).json({ error: 'No completed Build ticket is available for the follow-up' });
3555
- }
3556
- const instruction = [
3557
- 'Apply this complete batch of answered Build requirements in one bundle-scoped pass. Preserve the grouping by originating ticket and do not work on dismissed questions. Treat every submitted decision as authoritative and do not return an answered or dismissed batch question as open.',
3558
- formatBuildOpenQuestionAnswersPrompt(answeredQuestions),
3559
- ].join('\n\n');
3560
- setBuildOpenQuestionBatchStatus(created.batch.id, 'running');
3561
- clearAutomationPause(result.project.id, bundle.id);
3562
- const started = startFollowUp(targetTicket, io, instruction, 'build', {
3563
- buildOpenQuestionBatchId: created.batch.id,
3564
- });
3565
- if (!started) {
3566
- failBuildOpenQuestionBatch(created.batch.id, 'Build follow-up could not be started');
3567
- recordAutomationPause(result.project.id, bundle.id, 'open_questions', 'Build open question follow-up could not be started');
3568
- for (const ticket of listDetailedBundleTickets(result.project.id, bundle.id)) {
3569
- io.emit('ticket:updated', ticket);
3570
- }
3571
- return res.status(409).json({ error: 'Build follow-up could not be started' });
3572
- }
3573
3799
  for (const ticket of listDetailedBundleTickets(result.project.id, bundle.id)) {
3574
3800
  io.emit('ticket:updated', ticket);
3575
3801
  }
3576
- return res.status(202).json(serializeBundleDetails(result.project, bundle, {
3577
- target_ticket_id: targetTicket.id,
3802
+ return res.status(accepted.status).json(serializeBundleDetails(result.project, bundle, {
3803
+ target_ticket_id: accepted.targetTicket.id,
3578
3804
  }));
3579
3805
  });
3580
3806
  app.post('/bundles/:id/undo-review', (req, res) => {
@@ -3652,6 +3878,12 @@ app.post('/bundles/:id/stop', async (req, res) => {
3652
3878
  if (!targetTicket) {
3653
3879
  return res.status(404).json({ error: 'Bundle tickets not found' });
3654
3880
  }
3881
+ await stopAutomationProgression({
3882
+ projectId: result.project.id,
3883
+ bundleId: bundle.id,
3884
+ broadcaster: io,
3885
+ stopLocalWorker: worker => (worker.kind === 'review' ? stopReviewAgent(worker.ticket_id) : stopBuildAgent(worker.ticket_id)),
3886
+ });
3655
3887
  await stopTicket(targetTicket);
3656
3888
  emitRunStateForContext(targetTicket, false);
3657
3889
  return res.json(serializeBundleDetails(result.project, bundle));
@@ -3667,6 +3899,27 @@ app.post('/bundles/:id/follow-up', (req, res) => {
3667
3899
  }
3668
3900
  return res.status(response.status).json(response.value);
3669
3901
  });
3902
+ const AUTOMATION_SIDE_EFFECT_KINDS = new Set(['automation_commit', 'automation_push', 'automation_pull_request']);
3903
+ const AUTOMATION_WORKER_PAUSE_KINDS = new Set(['build', 'review', 'build_follow_up', 'automation_review_fix']);
3904
+ const pausedRunBelongsToProgression = (projectId, bundleId, progressionRunId, pausedRunId) => {
3905
+ const pausedRun = getExecutionRun(projectId, pausedRunId);
3906
+ const progression = getExecutionRun(projectId, progressionRunId);
3907
+ if (!pausedRun || !progression || progression.kind !== 'automation_progression') {
3908
+ return false;
3909
+ }
3910
+ const ownsBundle = listExecutionRunLinks(projectId, pausedRun.id).some(link => (link.entity_type === 'bundle'
3911
+ && link.entity_id === bundleId
3912
+ && (link.relationship === 'primary' || link.relationship === 'context')));
3913
+ if (!ownsBundle) {
3914
+ return false;
3915
+ }
3916
+ if (pausedRun.parent_run_id === progressionRunId) {
3917
+ return true;
3918
+ }
3919
+ const parent = pausedRun.parent_run_id ? getExecutionRun(projectId, pausedRun.parent_run_id) : null;
3920
+ return parent?.kind === 'automation_review_fix' && parent.parent_run_id === progressionRunId
3921
+ || progression.parent_run_id === pausedRun.id;
3922
+ };
3670
3923
  app.post('/bundles/:id/resume-automation', async (req, res) => {
3671
3924
  const result = getRequiredProject(req);
3672
3925
  if ('error' in result) {
@@ -3689,11 +3942,124 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
3689
3942
  && listBuildOpenQuestionsForBundle(result.project.id, bundle.id).length > 0) {
3690
3943
  return res.status(409).json({ error: 'Resolve or dismiss all Build open questions before resuming automation' });
3691
3944
  }
3945
+ let progressionRunId = bundleState.automation_progression_run_id;
3946
+ if (!progressionRunId) {
3947
+ try {
3948
+ progressionRunId = (bundleState.automation_paused_stage_run_id
3949
+ ? recoverStoppedSideEffectProgression({
3950
+ projectId: result.project.id,
3951
+ bundleId: bundle.id,
3952
+ broadcaster: io,
3953
+ })
3954
+ : recoverLegacyAutomationProgression({
3955
+ projectId: result.project.id,
3956
+ bundleId: bundle.id,
3957
+ broadcaster: io,
3958
+ })).runId;
3959
+ }
3960
+ catch (error) {
3961
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
3962
+ }
3963
+ }
3964
+ try {
3965
+ if (getAutomationProgression(result.project.id, bundle.id)?.id !== progressionRunId) {
3966
+ return res.status(409).json({ error: 'Automation progression ownership is invalid' });
3967
+ }
3968
+ }
3969
+ catch (error) {
3970
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
3971
+ }
3972
+ let sideEffectResume = null;
3973
+ let pausedRunKind = null;
3974
+ if (bundleState.automation_paused_stage_run_id) {
3975
+ const stageRunId = bundleState.automation_paused_stage_run_id;
3976
+ const stage = getExecutionRun(result.project.id, stageRunId);
3977
+ pausedRunKind = stage?.kind ?? null;
3978
+ if (!stage
3979
+ || (!AUTOMATION_SIDE_EFFECT_KINDS.has(stage.kind) && !AUTOMATION_WORKER_PAUSE_KINDS.has(stage.kind))
3980
+ || !pausedRunBelongsToProgression(result.project.id, bundle.id, progressionRunId, stageRunId)) {
3981
+ return res.status(409).json({ error: 'The paused automation stage does not belong to the active progression' });
3982
+ }
3983
+ if (AUTOMATION_SIDE_EFFECT_KINDS.has(stage.kind)) {
3984
+ let reconciled;
3985
+ try {
3986
+ reconciled = await reconcileAutomationStage({
3987
+ projectId: result.project.id,
3988
+ bundleId: bundle.id,
3989
+ progressionRunId,
3990
+ stageRunId,
3991
+ });
3992
+ }
3993
+ catch (error) {
3994
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
3995
+ }
3996
+ if (reconciled.domainStatus === 'ambiguous') {
3997
+ return res.status(409).json({ error: 'The paused automation side effect is ambiguous and cannot be resumed safely.' });
3998
+ }
3999
+ const details = getAutomationStageDetails(result.project.id, stage.id);
4000
+ if (!details || details.stage_kind !== stage.kind) {
4001
+ return res.status(409).json({ error: 'The paused automation side effect has no complete persisted identity.' });
4002
+ }
4003
+ if (reconciled.status === 'completed' && reconciled.domainStatus === 'reconciled_completed') {
4004
+ if (stage.kind === 'automation_commit') {
4005
+ const commitResult = details.result;
4006
+ const expectedHeadSha = commitResult && ('commitSha' in commitResult ? commitResult.commitSha : commitResult.headSha);
4007
+ if (!expectedHeadSha) {
4008
+ return res.status(409).json({ error: 'The reconciled commit has no persisted HEAD identity.' });
4009
+ }
4010
+ sideEffectResume = { stageKind: stage.kind, expectedHeadSha };
4011
+ }
4012
+ else if (stage.kind === 'automation_push') {
4013
+ const pushResult = details.result;
4014
+ if (!pushResult?.remoteSha) {
4015
+ return res.status(409).json({ error: 'The reconciled push has no persisted remote identity.' });
4016
+ }
4017
+ sideEffectResume = { stageKind: stage.kind, expectedHeadSha: pushResult.remoteSha };
4018
+ }
4019
+ else {
4020
+ sideEffectResume = { stageKind: stage.kind };
4021
+ }
4022
+ }
4023
+ else if (reconciled.status === 'interrupted' && reconciled.domainStatus === 'reconciled_absent') {
4024
+ if (stage.kind === 'automation_push') {
4025
+ const pushIntent = details.intent;
4026
+ sideEffectResume = { stageKind: stage.kind, retryOfRunId: stage.id, expectedHeadSha: pushIntent.targetLocalSha };
4027
+ }
4028
+ else if (stage.kind === 'automation_pull_request') {
4029
+ const pullRequestIntent = details.intent;
4030
+ sideEffectResume = { stageKind: stage.kind, retryOfRunId: stage.id, expectedHeadSha: pullRequestIntent.headSha };
4031
+ }
4032
+ else {
4033
+ sideEffectResume = { stageKind: stage.kind, retryOfRunId: stage.id };
4034
+ }
4035
+ }
4036
+ else {
4037
+ return res.status(409).json({ error: 'The paused automation side effect has no safely resumable outcome.' });
4038
+ }
4039
+ }
4040
+ }
4041
+ if (liveAutomationConfig.depth === 'manual') {
4042
+ completeAutomationProgression({
4043
+ projectId: result.project.id,
4044
+ bundleId: bundle.id,
4045
+ progressionRunId,
4046
+ domainStatus: 'manual_boundary_reached',
4047
+ broadcaster: io,
4048
+ });
4049
+ for (const ticket of listDetailedBundleTickets(result.project.id, bundle.id)) {
4050
+ io.emit('ticket:updated', ticket);
4051
+ }
4052
+ return res.json(serializeBundleDetails(result.project, bundle));
4053
+ }
3692
4054
  const reviewOwnerSource = bundleState.automation_paused_code === 'open_questions'
3693
4055
  ? listBundleSliceTickets(result.project.id, bundle.id, 'build')
3694
4056
  .find(ticket => ticket.agent_status === 'done') ?? null
3695
4057
  : resolveBundleReviewOwner(result.project.id, bundle.id)
3696
4058
  ?? listBundleSliceTickets(result.project.id, bundle.id, 'review')[0]
4059
+ ?? (bundleState.automation_paused_code === 'review' && (pausedRunKind === 'build' || pausedRunKind === 'build_follow_up')
4060
+ ? listBundleSliceTickets(result.project.id, bundle.id, 'build')
4061
+ .find(ticket => ticket.agent_status === 'done') ?? null
4062
+ : null)
3697
4063
  ?? null;
3698
4064
  if (!reviewOwnerSource) {
3699
4065
  return res.status(409).json({ error: 'No review ticket to resume from' });
@@ -3707,6 +4073,49 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
3707
4073
  return res.status(400).json({ error: 'Ticket project no longer exists' });
3708
4074
  }
3709
4075
  const automationConfig = resolveAutomationConfig(owner.bundle ?? null, project);
4076
+ const restoreWaitingProgression = () => {
4077
+ waitAutomationProgression({
4078
+ projectId: result.project.id,
4079
+ bundleId: bundle.id,
4080
+ progressionRunId,
4081
+ pauseCode: bundleState.automation_paused_code ?? 'review',
4082
+ pauseReason: bundleState.automation_paused_reason ?? 'Automation paused',
4083
+ reasonCode: bundleState.automation_pause_reason_code ?? 'manual_transition',
4084
+ pausedStageRunId: bundleState.automation_paused_stage_run_id,
4085
+ broadcaster: io,
4086
+ });
4087
+ };
4088
+ const acceptResumedReview = () => {
4089
+ runAutomationProgression({
4090
+ projectId: result.project.id,
4091
+ bundleId: bundle.id,
4092
+ progressionRunId,
4093
+ broadcaster: io,
4094
+ });
4095
+ return {
4096
+ ticket: owner,
4097
+ compensate: restoreWaitingProgression,
4098
+ };
4099
+ };
4100
+ if (sideEffectResume) {
4101
+ try {
4102
+ runAutomationProgression({
4103
+ projectId: result.project.id,
4104
+ bundleId: bundle.id,
4105
+ progressionRunId,
4106
+ broadcaster: io,
4107
+ });
4108
+ await runAutomationCommitChain(owner, project, owner.bundle ?? null, automationConfig, io, progressionRunId, sideEffectResume);
4109
+ }
4110
+ catch (error) {
4111
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4112
+ }
4113
+ const resumedOwner = getTicket(owner.id);
4114
+ if (resumedOwner) {
4115
+ io.emit('ticket:updated', resumedOwner);
4116
+ }
4117
+ return res.json(serializeBundleDetails(result.project, bundle));
4118
+ }
3710
4119
  if (owner.state === 'review' && owner.agent_status === 'running') {
3711
4120
  return res.status(409).json({ error: 'Review already running' });
3712
4121
  }
@@ -3723,24 +4132,131 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
3723
4132
  && automationConfig.reviewFixEnabled
3724
4133
  && blockingCount > 0
3725
4134
  && iteration < automationConfig.reviewMaxIterations) {
3726
- clearAutomationPause(result.project.id, bundle.id);
3727
- startFollowUp(owner, io, `Fix the code review findings at or above "${automationConfig.reviewSeverity}" severity. Address each blocking finding directly and minimally. Do not introduce unrelated changes, reformatting, or refactors. Do not commit.`, 'build');
4135
+ let reviewFixRunId = null;
4136
+ let followUpResult;
4137
+ const previousIteration = bundleState.review_iteration_count ?? 0;
4138
+ const previousFixLoopActive = bundleState.fix_loop_active;
4139
+ try {
4140
+ const progression = getAutomationProgression(result.project.id, bundle.id);
4141
+ if (!progression || progression.id !== progressionRunId) {
4142
+ return res.status(409).json({ error: 'Automation progression ownership is invalid' });
4143
+ }
4144
+ const workflowContext = validateWorkflowContext(progression.metadata);
4145
+ runAutomationProgression({
4146
+ projectId: result.project.id,
4147
+ bundleId: bundle.id,
4148
+ progressionRunId,
4149
+ broadcaster: io,
4150
+ });
4151
+ upsertBundleAutomationState(result.project.id, bundle.id, {
4152
+ review_iteration_count: iteration + 1,
4153
+ fix_loop_active: 1,
4154
+ });
4155
+ const reviewFix = createAutomationReviewFix({
4156
+ projectId: result.project.id,
4157
+ bundleId: bundle.id,
4158
+ ticketId: owner.id,
4159
+ ticketTitle: owner.title,
4160
+ progressionRunId,
4161
+ workflowContext,
4162
+ broadcaster: io,
4163
+ });
4164
+ reviewFixRunId = reviewFix.runId;
4165
+ followUpResult = submitTicketFollowUp({
4166
+ ticketId: owner.id,
4167
+ requestedProjectId: result.project.id,
4168
+ payload: {
4169
+ instruction: `Fix the code review findings at or above "${automationConfig.reviewSeverity}" severity. Address each blocking finding directly and minimally. Do not introduce unrelated changes, reformatting, or refactors. Do not commit.`,
4170
+ mode: 'build',
4171
+ },
4172
+ broadcaster: io,
4173
+ initiation: {
4174
+ initiator: 'automation',
4175
+ causalParentRunId: reviewFix.runId,
4176
+ workflowContext,
4177
+ },
4178
+ });
4179
+ }
4180
+ catch (error) {
4181
+ upsertBundleAutomationState(result.project.id, bundle.id, {
4182
+ review_iteration_count: previousIteration,
4183
+ fix_loop_active: previousFixLoopActive,
4184
+ });
4185
+ try {
4186
+ if (reviewFixRunId) {
4187
+ finishAutomationReviewFix({
4188
+ projectId: result.project.id,
4189
+ reviewFixRunId,
4190
+ failedMessage: error instanceof Error ? error.message : String(error),
4191
+ broadcaster: io,
4192
+ });
4193
+ }
4194
+ }
4195
+ finally {
4196
+ restoreWaitingProgression();
4197
+ }
4198
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4199
+ }
4200
+ if (!followUpResult.ok) {
4201
+ upsertBundleAutomationState(result.project.id, bundle.id, {
4202
+ review_iteration_count: previousIteration,
4203
+ fix_loop_active: previousFixLoopActive,
4204
+ });
4205
+ if (reviewFixRunId) {
4206
+ finishAutomationReviewFix({
4207
+ projectId: result.project.id,
4208
+ reviewFixRunId,
4209
+ failedMessage: followUpResult.error,
4210
+ broadcaster: io,
4211
+ });
4212
+ waitAutomationProgression({
4213
+ projectId: result.project.id,
4214
+ bundleId: bundle.id,
4215
+ progressionRunId,
4216
+ pauseCode: 'review',
4217
+ pauseReason: `fix follow-up could not start: ${followUpResult.error}`,
4218
+ reasonCode: 'stage_failed',
4219
+ pausedStageRunId: reviewFixRunId,
4220
+ broadcaster: io,
4221
+ });
4222
+ }
4223
+ return res.status(followUpResult.status).json({ error: followUpResult.error });
4224
+ }
3728
4225
  }
3729
4226
  else if (depthAtLeast(automationConfig.depth, 'auto_commit')) {
3730
- clearAutomationPause(result.project.id, bundle.id);
3731
- await runAutomationCommitChain(owner, project, owner.bundle ?? null, automationConfig, io);
4227
+ try {
4228
+ runAutomationProgression({
4229
+ projectId: result.project.id,
4230
+ bundleId: bundle.id,
4231
+ progressionRunId,
4232
+ broadcaster: io,
4233
+ });
4234
+ await runAutomationCommitChain(owner, project, owner.bundle ?? null, automationConfig, io, progressionRunId);
4235
+ }
4236
+ catch (error) {
4237
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4238
+ }
3732
4239
  }
3733
4240
  else {
3734
- clearAutomationPause(result.project.id, bundle.id);
3735
- startReview(owner, io);
4241
+ const reviewResult = rerunTicketReviewOperation(owner, io, {
4242
+ automatic: true,
4243
+ initiation: { causalParentRunId: progressionRunId },
4244
+ accept: acceptResumedReview,
4245
+ });
4246
+ if (!reviewResult.ok) {
4247
+ return res.status(reviewResult.status).json({ error: reviewResult.error });
4248
+ }
3736
4249
  }
3737
4250
  }
3738
4251
  else if (owner.state === 'build' && owner.agent_status === 'done') {
3739
- const reviewResult = reviewBundleTickets(result.project.id, bundle.id);
4252
+ const reviewResult = transitionBundleToReview(result.project.id, bundle.id, io, {
4253
+ automatic: true,
4254
+ initiation: { causalParentRunId: progressionRunId },
4255
+ accept: acceptResumedReview,
4256
+ });
3740
4257
  if (!reviewResult.ok) {
3741
4258
  return res.status(reviewResult.status).json({ error: reviewResult.error });
3742
4259
  }
3743
- clearAutomationPause(result.project.id, bundle.id);
3744
4260
  }
3745
4261
  else {
3746
4262
  return res.status(409).json({ error: 'Ticket is not in a resumable state' });
@@ -3751,7 +4267,7 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
3751
4267
  }
3752
4268
  return res.json(serializeBundleDetails(result.project, bundle));
3753
4269
  });
3754
- app.post('/bundles/:id/dismiss-automation-pause', (req, res) => {
4270
+ app.post('/bundles/:id/dismiss-automation-pause', async (req, res) => {
3755
4271
  const result = getRequiredProject(req);
3756
4272
  if ('error' in result) {
3757
4273
  return res.status(400).json({ error: result.error });
@@ -3772,7 +4288,68 @@ app.post('/bundles/:id/dismiss-automation-pause', (req, res) => {
3772
4288
  && listBuildOpenQuestionsForBundle(result.project.id, bundle.id).length > 0) {
3773
4289
  return res.status(409).json({ error: 'Resolve or dismiss all Build open questions before dismissing this pause' });
3774
4290
  }
3775
- clearAutomationPause(result.project.id, bundle.id);
4291
+ let progressionRunId = bundleState.automation_progression_run_id;
4292
+ if (!progressionRunId) {
4293
+ try {
4294
+ progressionRunId = (bundleState.automation_paused_stage_run_id
4295
+ ? recoverStoppedSideEffectProgression({
4296
+ projectId: result.project.id,
4297
+ bundleId: bundle.id,
4298
+ broadcaster: io,
4299
+ })
4300
+ : recoverLegacyAutomationProgression({
4301
+ projectId: result.project.id,
4302
+ bundleId: bundle.id,
4303
+ broadcaster: io,
4304
+ })).runId;
4305
+ }
4306
+ catch (error) {
4307
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4308
+ }
4309
+ }
4310
+ try {
4311
+ if (getAutomationProgression(result.project.id, bundle.id)?.id !== progressionRunId) {
4312
+ return res.status(409).json({ error: 'Automation progression ownership is invalid' });
4313
+ }
4314
+ }
4315
+ catch (error) {
4316
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4317
+ }
4318
+ if (bundleState.automation_paused_stage_run_id) {
4319
+ const stage = getExecutionRun(result.project.id, bundleState.automation_paused_stage_run_id);
4320
+ if (!stage
4321
+ || (!AUTOMATION_SIDE_EFFECT_KINDS.has(stage.kind) && !AUTOMATION_WORKER_PAUSE_KINDS.has(stage.kind))
4322
+ || !pausedRunBelongsToProgression(result.project.id, bundle.id, progressionRunId, stage.id)) {
4323
+ return res.status(409).json({ error: 'The paused automation stage does not belong to the active progression' });
4324
+ }
4325
+ if (AUTOMATION_SIDE_EFFECT_KINDS.has(stage.kind)) {
4326
+ let reconciled;
4327
+ try {
4328
+ reconciled = await reconcileAutomationStage({
4329
+ projectId: result.project.id,
4330
+ bundleId: bundle.id,
4331
+ progressionRunId,
4332
+ stageRunId: stage.id,
4333
+ });
4334
+ }
4335
+ catch (error) {
4336
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4337
+ }
4338
+ if (reconciled.domainStatus === 'ambiguous') {
4339
+ return res.status(409).json({ error: 'The paused automation side effect is ambiguous and cannot be dismissed safely.' });
4340
+ }
4341
+ if (!(reconciled.status === 'completed' && reconciled.domainStatus === 'reconciled_completed')
4342
+ && !(reconciled.status === 'interrupted' && reconciled.domainStatus === 'reconciled_absent')) {
4343
+ return res.status(409).json({ error: 'The paused automation side effect has no safely dismissible outcome.' });
4344
+ }
4345
+ }
4346
+ }
4347
+ await stopAutomationProgression({
4348
+ projectId: result.project.id,
4349
+ bundleId: bundle.id,
4350
+ broadcaster: io,
4351
+ stopLocalWorker: worker => (worker.kind === 'review' ? stopReviewAgent(worker.ticket_id) : stopBuildAgent(worker.ticket_id)),
4352
+ });
3776
4353
  for (const ticket of listDetailedBundleTickets(result.project.id, bundle.id)) {
3777
4354
  io.emit('ticket:updated', ticket);
3778
4355
  }
@@ -4415,20 +4992,84 @@ app.post('/integrations/github/actions/workflows/:id/dispatch', async (req, res)
4415
4992
  }
4416
4993
  }
4417
4994
  console.info(`[integrations] github actions dispatch project=${result.project.id} workflow=${workflowId} ref=${ref}`);
4995
+ const inputNames = Object.keys(inputs).sort();
4996
+ const inputFingerprint = inputNames.length > 0
4997
+ ? createHash('sha256').update(JSON.stringify(inputNames.map(name => [name, inputs[name]]))).digest('hex')
4998
+ : null;
4999
+ const dispatchedAt = new Date().toISOString();
5000
+ const workflowName = asTrimmedString(req.body?.workflowName)?.slice(0, 500) || `Workflow ${workflowId}`;
5001
+ const workflowUrl = asTrimmedString(req.body?.workflowUrl)?.slice(0, 2_000) || null;
5002
+ let executionRun;
5003
+ try {
5004
+ executionRun = createExecutionRun({
5005
+ projectId: result.project.id,
5006
+ kind: 'github_actions',
5007
+ domainStatus: 'dispatching',
5008
+ sourceReference: {
5009
+ kind: 'github_workflow',
5010
+ id: String(workflowId),
5011
+ label: workflowName,
5012
+ url: workflowUrl,
5013
+ },
5014
+ eventType: 'github_actions_dispatch_requested',
5015
+ eventMessage: 'GitHub Actions workflow dispatch was requested.',
5016
+ eventMetadata: { workflow_id: workflowId, ref, input_names: inputNames },
5017
+ githubActionsDetails: {
5018
+ workflowId,
5019
+ workflowName,
5020
+ ref,
5021
+ inputNames,
5022
+ inputFingerprint,
5023
+ dispatchedAt,
5024
+ },
5025
+ }, io);
5026
+ }
5027
+ catch (error) {
5028
+ if (error instanceof GitHubActionsDispatchConflictError) {
5029
+ return res.status(409).json({
5030
+ error: error.message,
5031
+ execution_run_id: error.executionRunId,
5032
+ });
5033
+ }
5034
+ throw error;
5035
+ }
4418
5036
  try {
4419
- const dispatchedAt = Date.now();
4420
5037
  await dispatchGitHubWorkflow(githubConfig, workflowId, { ref, inputs });
5038
+ transitionExecutionRun({
5039
+ projectId: result.project.id,
5040
+ runId: executionRun.id,
5041
+ status: 'waiting',
5042
+ domainStatus: 'resolving',
5043
+ eventType: 'github_actions_dispatch_accepted',
5044
+ eventMessage: 'GitHub accepted the workflow dispatch request.',
5045
+ eventMetadata: { workflow_id: workflowId, ref },
5046
+ }, io);
4421
5047
  const trackedRun = trackGitHubActionsRun({
4422
5048
  io,
5049
+ executionRunId: executionRun.id,
4423
5050
  projectId: result.project.id,
4424
- workflowId,
4425
5051
  config: githubConfig,
4426
- dispatchedAt,
4427
5052
  });
4428
- return res.status(202).json({ tracking_key: trackedRun.tracking_key, dispatched: true });
5053
+ return res.status(202).json({
5054
+ tracking_key: trackedRun.tracking_key,
5055
+ execution_run_id: executionRun.id,
5056
+ dispatched: true,
5057
+ });
4429
5058
  }
4430
5059
  catch (error) {
4431
5060
  console.warn(`[integrations] github actions dispatch failed project=${result.project.id} workflow=${workflowId}`, error);
5061
+ transitionExecutionRun({
5062
+ projectId: result.project.id,
5063
+ runId: executionRun.id,
5064
+ status: 'failed',
5065
+ domainStatus: 'dispatch_failed',
5066
+ failure: {
5067
+ code: error instanceof GitHubApiError ? `github_actions_dispatch_http_${error.status}` : 'github_actions_dispatch_failed',
5068
+ message: error instanceof GitHubApiError ? error.publicMessage : 'Unable to dispatch the workflow right now.',
5069
+ },
5070
+ eventType: 'github_actions_dispatch_failed',
5071
+ eventMessage: error instanceof GitHubApiError ? error.publicMessage : 'Unable to dispatch the workflow right now.',
5072
+ }, io);
4432
5073
  if (error instanceof GitHubApiError) {
4433
5074
  return res.status(error.status).json({ error: error.publicMessage });
4434
5075
  }
@@ -4636,6 +5277,8 @@ app.post('/planning-agent', async (req, res) => {
4636
5277
  ensureProjectRootBundle(project.id);
4637
5278
  const trimmedDescription = description.trim();
4638
5279
  let autoCreatedBundle = null;
5280
+ let planningExecutionRun = null;
5281
+ let materializationCommitted = false;
4639
5282
  try {
4640
5283
  const resolvedDescription = await resolveExternalPlanningDescription(project.id, trimmedDescription);
4641
5284
  const requestedBundleSelection = resolveRequestedBundleSelection(req.body, project.id);
@@ -4670,13 +5313,38 @@ app.post('/planning-agent', async (req, res) => {
4670
5313
  });
4671
5314
  }
4672
5315
  }
4673
- const linkedResearch = await prepareLinkedPlanningResearch(resolvedDescription, plannerOptions);
4674
- const outline = normalizePlanningAgentOutline(await generatePlanningTicketOutline(resolvedDescription, {
5316
+ planningExecutionRun = createExecutionRun({
5317
+ projectId: project.id,
5318
+ kind: 'planning_agent',
5319
+ domainStatus: 'queued',
5320
+ sourceReference: { kind: 'project', id: project.id, label: project.name },
5321
+ eventType: 'planning_agent_created',
5322
+ }, io);
5323
+ const executionContext = {
5324
+ runId: planningExecutionRun.id,
5325
+ correlationId: planningExecutionRun.correlation_id,
5326
+ projectId: planningExecutionRun.project_id,
5327
+ kind: planningExecutionRun.kind,
5328
+ };
5329
+ const executionPlannerOptions = {
4675
5330
  ...plannerOptions,
5331
+ executionContext,
5332
+ agentRunParent: { parentType: 'planning', parentId: project.id, io },
5333
+ };
5334
+ transitionExecutionRun({
5335
+ projectId: project.id,
5336
+ runId: planningExecutionRun.id,
5337
+ status: 'running',
5338
+ domainStatus: 'running',
5339
+ eventType: 'planning_agent_started',
5340
+ }, io);
5341
+ const linkedResearch = await prepareLinkedPlanningResearch(resolvedDescription, executionPlannerOptions);
5342
+ const outline = normalizePlanningAgentOutline(await generatePlanningTicketOutline(resolvedDescription, {
5343
+ ...executionPlannerOptions,
4676
5344
  linkedResearch,
4677
5345
  }));
4678
5346
  const draft = validateExpandedPlanningAgentDraft(outline, normalizePlanningAgentDraft(await expandPlanningTicketOutline(resolvedDescription, outline, {
4679
- ...plannerOptions,
5347
+ ...executionPlannerOptions,
4680
5348
  linkedResearch,
4681
5349
  })));
4682
5350
  const created = createPlanningAgentTickets({
@@ -4686,18 +5354,57 @@ app.post('/planning-agent', async (req, res) => {
4686
5354
  model: tool === 'opencode' && typeof model === 'string' ? normalizeModelId(model) || null : null,
4687
5355
  variant: tool === 'opencode' && typeof variant === 'string' && variant.trim() ? variant.trim() : null,
4688
5356
  skills: resolveRequestedSkills(req.body?.skills),
4689
- tool,
4690
5357
  worktreeBundleId: bundleSelection.bundleId,
4691
5358
  });
5359
+ materializationCommitted = true;
5360
+ addExecutionRunLinks({
5361
+ projectId: project.id,
5362
+ runId: planningExecutionRun.id,
5363
+ links: created.tickets.map(ticket => ({
5364
+ entityType: 'ticket',
5365
+ entityId: ticket.id,
5366
+ relationship: 'result',
5367
+ })),
5368
+ });
4692
5369
  for (const ticket of created.tickets) {
4693
5370
  io.emit('ticket:created', ticket);
4694
5371
  }
4695
5372
  for (const dependency of created.dependencies) {
4696
5373
  io.emit('ticket-dependency:created', dependency);
4697
5374
  }
5375
+ transitionExecutionRun({
5376
+ projectId: project.id,
5377
+ runId: planningExecutionRun.id,
5378
+ status: 'completed',
5379
+ domainStatus: 'done',
5380
+ eventType: 'planning_agent_completed',
5381
+ }, io);
4698
5382
  return res.status(201).json(created);
4699
5383
  }
4700
5384
  catch (error) {
5385
+ if (planningExecutionRun) {
5386
+ const rawFailureMessage = getErrorMessage(error) || 'Planning agent failed';
5387
+ const failureMessage = (materializationCommitted
5388
+ ? `Planning tickets were created, but execution result reconciliation failed. Reconcile run ${planningExecutionRun.id}: ${rawFailureMessage}`
5389
+ : rawFailureMessage).slice(0, 4_000);
5390
+ try {
5391
+ terminalizeAgentRunsForExecutionRun(planningExecutionRun.id, 'error', failureMessage);
5392
+ }
5393
+ catch {
5394
+ // Preserve the top-level execution failure when child detail finalization fails.
5395
+ }
5396
+ transitionExecutionRun({
5397
+ projectId: project.id,
5398
+ runId: planningExecutionRun.id,
5399
+ status: 'failed',
5400
+ domainStatus: 'error',
5401
+ failure: {
5402
+ code: materializationCommitted ? 'planning_agent_result_reconciliation_failed' : 'planning_agent_failed',
5403
+ message: failureMessage,
5404
+ },
5405
+ eventType: 'planning_agent_failed',
5406
+ }, io);
5407
+ }
4701
5408
  if (autoCreatedBundle && countBundleTickets(autoCreatedBundle.id, project.id) === 0) {
4702
5409
  deleteBundle(autoCreatedBundle.id, project.id);
4703
5410
  }
@@ -5987,7 +6694,14 @@ app.delete('/projects/:id', (req, res) => {
5987
6694
  error: 'Cannot delete a project with existing tickets, bundles, or worktrees',
5988
6695
  });
5989
6696
  }
5990
- db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
6697
+ db.transaction(() => {
6698
+ db.prepare(`
6699
+ UPDATE execution_runs
6700
+ SET parent_run_id = NULL, retry_of_run_id = NULL
6701
+ WHERE project_id = ?
6702
+ `).run(req.params.id);
6703
+ db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
6704
+ })();
5991
6705
  res.status(204).end();
5992
6706
  });
5993
6707
  app.post('/tickets', (req, res) => {
@@ -6260,11 +6974,14 @@ app.post('/tickets/delete-parked', async (req, res) => {
6260
6974
  deleted_ticket_ids: [],
6261
6975
  failures: [],
6262
6976
  };
6977
+ const sequenceContinuations = [];
6263
6978
  for (const ticket of parkedTickets) {
6264
6979
  const deleteResult = await deleteTicketRecord(ticket);
6265
6980
  if (deleteResult.ok) {
6266
6981
  summary.deleted_count += 1;
6267
6982
  summary.deleted_ticket_ids.push(ticket.id);
6983
+ emitUpdatedTickets(deleteResult.affectedTicketIds ?? []);
6984
+ sequenceContinuations.push(deleteResult);
6268
6985
  continue;
6269
6986
  }
6270
6987
  summary.failed_count += 1;
@@ -6274,6 +6991,11 @@ app.post('/tickets/delete-parked', async (req, res) => {
6274
6991
  error: deleteResult.error,
6275
6992
  });
6276
6993
  }
6994
+ for (const continuation of sequenceContinuations) {
6995
+ if (continuation.nextBuildTicketId && getTicket(continuation.nextBuildTicketId)) {
6996
+ scheduleBuildAfterTicketDeletion(continuation);
6997
+ }
6998
+ }
6277
6999
  return res.json(summary);
6278
7000
  });
6279
7001
  app.post('/tickets/:id/build-chain', (req, res) => {
@@ -6287,22 +7009,43 @@ app.post('/tickets/:id/build-chain', (req, res) => {
6287
7009
  if (!existing.project_id) {
6288
7010
  return res.status(400).json({ error: 'Ticket project no longer exists' });
6289
7011
  }
6290
- const chainStart = startBuildChain(existing.id, existing.project_id);
6291
- if (!chainStart.ok) {
6292
- return res.status(chainStart.status).json({ error: chainStart.error });
6293
- }
6294
- const buildTransition = prepareTicketForBuild(chainStart.activeTicketId);
6295
- if (!buildTransition.ok) {
6296
- stopBuildChain(chainStart.activeTicketId);
6297
- return res.status(buildTransition.status).json({
6298
- error: buildTransition.error,
6299
- ...(buildTransition.blockers ? { blockers: buildTransition.blockers } : {}),
7012
+ const validation = prepareTicketForBuild(existing.id, { updateState: false });
7013
+ if (!validation.ok) {
7014
+ return res.status(validation.status).json({
7015
+ error: validation.error,
7016
+ ...(validation.blockers ? { blockers: validation.blockers } : {}),
6300
7017
  });
6301
7018
  }
6302
- emitUpdatedTickets(chainStart.affectedTicketIds);
6303
- io.emit('ticket:updated', buildTransition.ticket);
6304
- res.json(buildTransition.ticket);
6305
- startBuild(buildTransition.ticket, io);
7019
+ let affectedTicketIds = [];
7020
+ let activeTicketId = existing.id;
7021
+ const acceptChain = () => {
7022
+ const chainStart = startBuildChain(existing.id, existing.project_id);
7023
+ if (!chainStart.ok) {
7024
+ throw new Error(chainStart.error);
7025
+ }
7026
+ affectedTicketIds = chainStart.affectedTicketIds;
7027
+ activeTicketId = chainStart.activeTicketId;
7028
+ const buildTransition = prepareTicketForBuild(chainStart.activeTicketId);
7029
+ if (!buildTransition.ok) {
7030
+ throw new Error(buildTransition.error);
7031
+ }
7032
+ return {
7033
+ ticket: buildTransition.ticket,
7034
+ sequenceId: chainStart.sequenceId,
7035
+ compensate: () => compensateBuildSequenceAcceptance(chainStart.sequenceId, chainStart.affectedTicketIds),
7036
+ };
7037
+ };
7038
+ const scheduling = startBuild(existing, io, { accept: acceptChain });
7039
+ if (scheduling === undefined) {
7040
+ acceptChain();
7041
+ }
7042
+ else if (!scheduling.accepted) {
7043
+ return res.status(scheduling.reason === 'queue_failed' ? 503 : 409).json({ error: scheduling.error });
7044
+ }
7045
+ emitUpdatedTickets(affectedTicketIds);
7046
+ const updated = getTicket(activeTicketId);
7047
+ io.emit('ticket:updated', updated);
7048
+ return res.json(updated);
6306
7049
  });
6307
7050
  app.post('/tickets/:id/stop-build-chain', (req, res) => {
6308
7051
  const existing = getTicket(req.params.id);
@@ -6336,31 +7079,152 @@ app.post('/tickets/:id/retry', (req, res) => {
6336
7079
  && hasActiveBuildOpenQuestionBatch(ticket.project_id, ticket.worktree_bundle_id)) {
6337
7080
  return res.status(409).json({ error: 'Build open question resolution is still in progress' });
6338
7081
  }
6339
- db.prepare('UPDATE tickets SET agent_status = NULL, agent_log = NULL, agent_message_id = NULL, previous_agent_log = NULL, previous_agent_message_id = NULL, streaming_response = NULL, build_completed_at = NULL, auto_park_dismissed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.params.id);
6340
- const updated = getTicket(req.params.id);
6341
- io.emit('ticket:updated', updated);
6342
- res.json(updated);
6343
- if (ticket.state === 'build' && updated) {
6344
- if (updated.project_id && updated.worktree_bundle_id) {
6345
- const buildBlocker = getBundleBuildBlockerResponse(updated);
7082
+ if (ticket.state === 'build') {
7083
+ if (ticket.project_id && ticket.worktree_bundle_id) {
7084
+ const buildBlocker = getBundleBuildBlockerResponse(ticket);
6346
7085
  if (buildBlocker) {
6347
- return;
7086
+ return res.status(409).json({ error: buildBlocker.error, blocker_ticket_id: buildBlocker.blocker_ticket_id });
6348
7087
  }
6349
- const queuedBuild = enqueueBundledBuild({
6350
- ticketId: updated.id,
6351
- projectId: updated.project_id,
6352
- worktreeBundleId: updated.worktree_bundle_id,
6353
- activate: shouldStartBundleBuildNow(updated),
6354
- });
6355
- if (!queuedBuild.shouldStartNow) {
6356
- return;
7088
+ }
7089
+ const retrySource = ticket.project_id
7090
+ ? db.prepare(`
7091
+ SELECT er.id
7092
+ FROM execution_runs er
7093
+ INNER JOIN execution_run_links link
7094
+ ON link.execution_run_id = er.id
7095
+ AND link.project_id = er.project_id
7096
+ AND link.relationship = 'primary'
7097
+ AND link.entity_type = 'ticket'
7098
+ WHERE er.project_id = ?
7099
+ AND er.kind = 'build'
7100
+ AND er.status IN ('failed', 'interrupted')
7101
+ AND link.entity_id = ?
7102
+ ORDER BY er.created_at DESC, er.id DESC
7103
+ LIMIT 1
7104
+ `).get(ticket.project_id, ticket.id)
7105
+ : undefined;
7106
+ const acceptRetry = () => {
7107
+ db.prepare('UPDATE tickets SET agent_status = NULL, agent_log = NULL, agent_message_id = NULL, previous_agent_log = NULL, previous_agent_message_id = NULL, streaming_response = NULL, build_completed_at = NULL, auto_park_dismissed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.params.id);
7108
+ const updated = getTicket(req.params.id);
7109
+ if (!updated) {
7110
+ throw new Error('Not found');
7111
+ }
7112
+ let sequenceId = null;
7113
+ if (updated.project_id && updated.worktree_bundle_id) {
7114
+ sequenceId = enqueueBundledBuild({
7115
+ ticketId: updated.id,
7116
+ projectId: updated.project_id,
7117
+ worktreeBundleId: updated.worktree_bundle_id,
7118
+ activate: shouldStartBundleBuildNow(updated),
7119
+ }).sequenceId;
7120
+ }
7121
+ return {
7122
+ ticket: updated,
7123
+ sequenceId,
7124
+ ...(sequenceId ? { compensate: () => compensateBuildSequenceAcceptance(sequenceId, [updated.id]) } : {}),
7125
+ };
7126
+ };
7127
+ if (ticket.project_id && ticket.worktree_bundle_id && hasActiveSequenceForBundle(ticket.project_id, ticket.worktree_bundle_id)) {
7128
+ try {
7129
+ withExecutionRunTransaction(transaction => {
7130
+ const acceptance = acceptRetry();
7131
+ if (!acceptance.sequenceId) {
7132
+ throw new Error('Build sequence no longer exists');
7133
+ }
7134
+ transaction.createExecutionRun({
7135
+ projectId: ticket.project_id,
7136
+ kind: 'build',
7137
+ domainStatus: 'queued',
7138
+ sourceReference: { kind: 'ticket', id: acceptance.ticket.id, label: acceptance.ticket.title },
7139
+ links: [
7140
+ { entityType: 'bundle', entityId: ticket.worktree_bundle_id, relationship: 'context' },
7141
+ { entityType: 'build_sequence', entityId: acceptance.sequenceId, relationship: 'context' },
7142
+ ],
7143
+ initiatedBy: 'user',
7144
+ retryOfRunId: retrySource?.id ?? null,
7145
+ eventType: 'build_created',
7146
+ });
7147
+ }, io);
7148
+ }
7149
+ catch (error) {
7150
+ return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
6357
7151
  }
7152
+ const updated = getTicket(req.params.id);
7153
+ io.emit('ticket:updated', updated);
7154
+ return res.json(updated);
7155
+ }
7156
+ const scheduling = startBuild(ticket, io, {
7157
+ initiation: { initiator: 'user', retrySourceRunId: retrySource?.id ?? null },
7158
+ accept: acceptRetry,
7159
+ });
7160
+ if (scheduling === undefined) {
7161
+ acceptRetry();
7162
+ }
7163
+ else if (!scheduling.accepted) {
7164
+ return res.status(scheduling.reason === 'queue_failed' ? 503 : 409).json({ error: scheduling.error });
6358
7165
  }
6359
- startBuild(updated, io);
7166
+ const updated = getTicket(req.params.id);
7167
+ io.emit('ticket:updated', updated);
7168
+ return res.json(updated);
6360
7169
  }
6361
- if (ticket.state === 'review' && updated) {
6362
- startReview(updated, io);
7170
+ const reviewRetrySource = ticket.state === 'review' && ticket.project_id
7171
+ ? db.prepare(`
7172
+ SELECT er.id
7173
+ FROM execution_runs er
7174
+ JOIN execution_run_links link
7175
+ ON link.execution_run_id = er.id
7176
+ AND link.relationship = 'primary'
7177
+ AND link.entity_type = 'ticket'
7178
+ WHERE er.project_id = ?
7179
+ AND er.kind = 'review'
7180
+ AND er.status IN ('failed', 'interrupted')
7181
+ AND link.entity_id = ?
7182
+ ORDER BY er.created_at DESC, er.id DESC
7183
+ LIMIT 1
7184
+ `).get(ticket.project_id, ticket.id)
7185
+ : undefined;
7186
+ if (ticket.state === 'review') {
7187
+ const reviewSnapshot = {
7188
+ agent_status: ticket.agent_status,
7189
+ agent_log: ticket.agent_log,
7190
+ agent_message_id: ticket.agent_message_id,
7191
+ previous_agent_log: ticket.previous_agent_log,
7192
+ previous_agent_message_id: ticket.previous_agent_message_id,
7193
+ streaming_response: ticket.streaming_response,
7194
+ build_completed_at: ticket.build_completed_at,
7195
+ auto_park_dismissed_at: ticket.auto_park_dismissed_at,
7196
+ };
7197
+ const reviewResult = rerunTicketReviewOperation(ticket, io, {
7198
+ initiation: { retrySourceRunId: reviewRetrySource?.id ?? null },
7199
+ accept: () => {
7200
+ db.prepare('UPDATE tickets SET agent_status = NULL, agent_log = NULL, agent_message_id = NULL, previous_agent_log = NULL, previous_agent_message_id = NULL, streaming_response = NULL, build_completed_at = NULL, auto_park_dismissed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.params.id);
7201
+ const acceptedTicket = getTicket(req.params.id);
7202
+ if (!acceptedTicket) {
7203
+ throw new Error('Not found');
7204
+ }
7205
+ return {
7206
+ ticket: acceptedTicket,
7207
+ compensate: () => {
7208
+ db.prepare(`
7209
+ UPDATE tickets SET agent_status = ?, agent_log = ?, agent_message_id = ?, previous_agent_log = ?,
7210
+ previous_agent_message_id = ?, streaming_response = ?, build_completed_at = ?, auto_park_dismissed_at = ?,
7211
+ updated_at = CURRENT_TIMESTAMP WHERE id = ?
7212
+ `).run(reviewSnapshot.agent_status, reviewSnapshot.agent_log, reviewSnapshot.agent_message_id, reviewSnapshot.previous_agent_log, reviewSnapshot.previous_agent_message_id, reviewSnapshot.streaming_response, reviewSnapshot.build_completed_at, reviewSnapshot.auto_park_dismissed_at, ticket.id);
7213
+ },
7214
+ };
7215
+ },
7216
+ });
7217
+ if (!reviewResult.ok) {
7218
+ return res.status(reviewResult.status).json({ error: reviewResult.error });
7219
+ }
7220
+ const updated = getTicket(req.params.id);
7221
+ io.emit('ticket:updated', updated);
7222
+ return res.json(updated);
6363
7223
  }
7224
+ db.prepare('UPDATE tickets SET agent_status = NULL, agent_log = NULL, agent_message_id = NULL, previous_agent_log = NULL, previous_agent_message_id = NULL, streaming_response = NULL, auto_park_dismissed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.params.id);
7225
+ const updated = getTicket(req.params.id);
7226
+ io.emit('ticket:updated', updated);
7227
+ return res.json(updated);
6364
7228
  });
6365
7229
  app.post('/tickets/:id/review', (req, res) => {
6366
7230
  const ticket = getTicket(req.params.id);
@@ -6664,6 +7528,111 @@ app.post('/projects/:id/test-sessions', (req, res) => {
6664
7528
  targetLabel: workspace.target_label,
6665
7529
  }));
6666
7530
  });
7531
+ app.get('/projects/:id/execution-runs', (req, res) => {
7532
+ const project = getProjectById(req.params.id);
7533
+ if (!project) {
7534
+ return res.status(404).json({ error: 'Not found' });
7535
+ }
7536
+ const query = parseLifecycleListQuery(req.query);
7537
+ if ('error' in query) {
7538
+ return res.status(400).json({ error: query.error });
7539
+ }
7540
+ try {
7541
+ return res.json(listExecutionRuns({
7542
+ projectId: project.id,
7543
+ kind: query.kind,
7544
+ statuses: query.statuses,
7545
+ cursor: query.cursor,
7546
+ limit: query.limit,
7547
+ }));
7548
+ }
7549
+ catch (error) {
7550
+ if (error instanceof Error && error.message === 'Invalid execution run cursor') {
7551
+ return res.status(400).json({ error: 'cursor must be a valid execution run cursor' });
7552
+ }
7553
+ throw error;
7554
+ }
7555
+ });
7556
+ app.get('/projects/:id/lifecycle-events', (req, res) => {
7557
+ const project = getProjectById(req.params.id);
7558
+ if (!project) {
7559
+ return res.status(404).json({ error: 'Not found' });
7560
+ }
7561
+ const query = parseLifecycleListQuery(req.query);
7562
+ if ('error' in query) {
7563
+ return res.status(400).json({ error: query.error });
7564
+ }
7565
+ try {
7566
+ const page = listProjectLifecycleEvents({
7567
+ projectId: project.id,
7568
+ kind: query.kind,
7569
+ statuses: query.statuses,
7570
+ cursor: query.cursor,
7571
+ limit: query.limit,
7572
+ });
7573
+ return res.json({
7574
+ ...page,
7575
+ events: page.events.map(toCanonicalLifecycleEvent),
7576
+ });
7577
+ }
7578
+ catch (error) {
7579
+ if (error instanceof Error && error.message === 'Invalid lifecycle event cursor') {
7580
+ return res.status(400).json({ error: 'cursor must be a valid lifecycle event cursor' });
7581
+ }
7582
+ throw error;
7583
+ }
7584
+ });
7585
+ app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7586
+ const project = getProjectById(req.params.id);
7587
+ if (!project) {
7588
+ return res.status(404).json({ error: 'Not found' });
7589
+ }
7590
+ const run = getExecutionRun(project.id, req.params.runId);
7591
+ if (!run) {
7592
+ return res.status(404).json({ error: 'Execution run not found' });
7593
+ }
7594
+ const testDetails = run.kind === 'test' ? getTestExecutionDetails(project.id, run.id) : null;
7595
+ const details = run.kind === 'test'
7596
+ ? serializeTestExecutionDetail(testDetails, resolveTestRetry(project, run, testDetails).eligibility)
7597
+ : run.kind === 'github_actions'
7598
+ ? serializeGitHubActionsExecutionDetail(getGitHubActionsExecutionDetails(project.id, run.id))
7599
+ : { kind: run.kind, available: true };
7600
+ const links = listExecutionRunLinks(project.id, run.id);
7601
+ const agentRuns = listAgentRunsForExecutionRun(run.id);
7602
+ return res.json({ run, links, details, agent_runs: agentRuns });
7603
+ });
7604
+ app.post('/projects/:id/execution-runs/:runId/retry', async (req, res) => {
7605
+ const project = getProjectById(req.params.id);
7606
+ if (!project) {
7607
+ return res.status(404).json({ error: 'Not found' });
7608
+ }
7609
+ const executionRun = getExecutionRun(project.id, req.params.runId);
7610
+ const details = getTestExecutionDetails(project.id, req.params.runId);
7611
+ const retry = resolveTestRetry(project, executionRun, details);
7612
+ if ('error' in retry) {
7613
+ return res.status(retry.error.status).json({ error: retry.error.message });
7614
+ }
7615
+ const session = createTestSession({
7616
+ projectId: project.id,
7617
+ ticketId: retry.details.ticket_id,
7618
+ targetKind: retry.workspace.target_kind,
7619
+ targetBundleId: retry.workspace.target_bundle_id,
7620
+ cwd: retry.workspace.cwd,
7621
+ targetLabel: retry.workspace.target_label,
7622
+ });
7623
+ try {
7624
+ return res.status(201).json(await startTestSessionRun(session.id, project, {
7625
+ command: retry.command,
7626
+ selection: retry.selection,
7627
+ retryOfRunId: retry.executionRun.id,
7628
+ deleteOnComplete: true,
7629
+ }, io));
7630
+ }
7631
+ catch (error) {
7632
+ await deleteTestSession(session.id, io).catch(() => undefined);
7633
+ return res.status(409).json({ error: getErrorMessage(error) });
7634
+ }
7635
+ });
6667
7636
  app.post('/tickets/:id/test-sessions', (req, res) => {
6668
7637
  const ticket = getTicket(req.params.id);
6669
7638
  if (!ticket) {
@@ -6766,7 +7735,7 @@ app.post('/test-sessions/:id/stop', async (req, res) => {
6766
7735
  }
6767
7736
  });
6768
7737
  app.delete('/test-sessions/:id', async (req, res) => {
6769
- const deleted = await deleteTestSession(req.params.id, io);
7738
+ const deleted = await deleteTestSession(req.params.id, io, { purgeExecutionRun: true });
6770
7739
  if (!deleted) {
6771
7740
  return res.status(404).json({ error: 'Not found' });
6772
7741
  }
@@ -6919,8 +7888,14 @@ app.post('/tickets/:id/plan', (req, res) => {
6919
7888
  });
6920
7889
  }
6921
7890
  }
6922
- startPlan(ticket, io, { branch });
6923
- res.status(202).json({ ok: true });
7891
+ const scheduling = startPlan(ticket, io, {
7892
+ branch,
7893
+ });
7894
+ if (!scheduling.accepted) {
7895
+ const status = scheduling.reason === 'queue_failed' ? 503 : 409;
7896
+ return res.status(status).json({ error: scheduling.error });
7897
+ }
7898
+ return res.status(202).json({ ok: true, execution_run_id: scheduling.executionContext.runId });
6924
7899
  });
6925
7900
  app.delete('/tickets/:id', async (req, res) => {
6926
7901
  const ticket = getTicket(req.params.id);
@@ -6937,12 +7912,7 @@ app.delete('/tickets/:id', async (req, res) => {
6937
7912
  if (result.affectedTicketIds) {
6938
7913
  emitUpdatedTickets(result.affectedTicketIds);
6939
7914
  }
6940
- if (result.nextBuildTicketId) {
6941
- const nextTicket = getTicket(result.nextBuildTicketId);
6942
- if (nextTicket) {
6943
- startBuild(nextTicket, io);
6944
- }
6945
- }
7915
+ scheduleBuildAfterTicketDeletion(result);
6946
7916
  res.status(204).end();
6947
7917
  });
6948
7918
  const clientBuild = resolveClientBuildPaths(import.meta.url);
@@ -6969,6 +7939,33 @@ io.on('connection', (socket) => {
6969
7939
  registerTerminalSocketHandlers(io, socket);
6970
7940
  });
6971
7941
  const DEFAULT_PORT = 3001;
7942
+ let executionEvidenceCleanupTimer = null;
7943
+ let startServerPromise = null;
7944
+ let shutdownServerPromise = null;
7945
+ let shutdownRequested = false;
7946
+ const throwIfShutdownRequested = () => {
7947
+ if (shutdownRequested) {
7948
+ throw new Error('Server startup was cancelled by shutdown');
7949
+ }
7950
+ };
7951
+ const runExecutionEvidenceCleanup = () => {
7952
+ try {
7953
+ const result = cleanupAllExecutionEvidence({ retentionDays: resolveExecutionEvidenceRetentionDays() });
7954
+ if (result.total > 0) {
7955
+ console.log(`Expired execution evidence for ${result.total} run(s): ${result.test_output} test output, ${result.github_jobs} GitHub job snapshot(s)`);
7956
+ }
7957
+ }
7958
+ catch (error) {
7959
+ console.error('Failed to clean up expired execution evidence', error);
7960
+ }
7961
+ };
7962
+ const startExecutionEvidenceCleanupTimer = () => {
7963
+ if (executionEvidenceCleanupTimer) {
7964
+ return;
7965
+ }
7966
+ executionEvidenceCleanupTimer = setInterval(runExecutionEvidenceCleanup, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS);
7967
+ executionEvidenceCleanupTimer.unref();
7968
+ };
6972
7969
  const resolveStartResult = (host) => {
6973
7970
  const address = httpServer.address();
6974
7971
  const { displayHost } = resolveDesktopServerHost(host);
@@ -6986,38 +7983,83 @@ const resolveStartResult = (host) => {
6986
7983
  };
6987
7984
  };
6988
7985
  export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
6989
- return new Promise((resolve, reject) => {
6990
- if (httpServer.listening) {
6991
- resolve(resolveStartResult(host));
6992
- return;
7986
+ if (shutdownRequested) {
7987
+ return Promise.reject(new Error('Server startup was cancelled by shutdown'));
7988
+ }
7989
+ if (httpServer.listening) {
7990
+ return Promise.resolve(resolveStartResult(host));
7991
+ }
7992
+ if (startServerPromise) {
7993
+ return startServerPromise;
7994
+ }
7995
+ startServerPromise = (async () => {
7996
+ await reconcileStartupExecutions({ getGitHubConfig: getGitHubConnectionConfig });
7997
+ throwIfShutdownRequested();
7998
+ startGitHubActionsPolling(io);
7999
+ try {
8000
+ throwIfShutdownRequested();
8001
+ await new Promise((resolve, reject) => {
8002
+ const handleError = (error) => {
8003
+ httpServer.off('error', handleError);
8004
+ reject(error);
8005
+ };
8006
+ const handleListening = () => {
8007
+ httpServer.off('error', handleError);
8008
+ resolve();
8009
+ };
8010
+ httpServer.once('error', handleError);
8011
+ const { bindHost } = resolveDesktopServerHost(host);
8012
+ httpServer.listen(port, bindHost, handleListening);
8013
+ });
6993
8014
  }
6994
- const handleError = (error) => {
6995
- httpServer.off('error', handleError);
6996
- reject(error);
6997
- };
6998
- const handleListening = () => {
6999
- httpServer.off('error', handleError);
7000
- resolve(resolveStartResult(host));
7001
- };
7002
- httpServer.once('error', handleError);
7003
- const { bindHost } = resolveDesktopServerHost(host);
7004
- httpServer.listen(port, bindHost, handleListening);
8015
+ catch (error) {
8016
+ await clearAllGitHubActionsSessions();
8017
+ throw error;
8018
+ }
8019
+ throwIfShutdownRequested();
8020
+ runExecutionEvidenceCleanup();
8021
+ throwIfShutdownRequested();
8022
+ startExecutionEvidenceCleanupTimer();
8023
+ return resolveStartResult(host);
8024
+ })().finally(() => {
8025
+ startServerPromise = null;
7005
8026
  });
8027
+ return startServerPromise;
7006
8028
  };
7007
8029
  export const shutdownServer = async (signal) => {
7008
- await stopMobileAccess();
7009
- await clearAllTestSessions(io);
7010
- await clearAllGitHubActionsSessions();
7011
- await shutdownRealtimeServer({
7012
- signal,
7013
- io,
7014
- httpServer,
7015
- closeAllTerminalSessions,
7016
- clearAllPreviewProxies,
7017
- stopAllBuildAgents,
7018
- stopAllReviewAgents,
7019
- stopAllTickets,
8030
+ if (shutdownServerPromise) {
8031
+ return shutdownServerPromise;
8032
+ }
8033
+ shutdownRequested = true;
8034
+ const pendingStartup = startServerPromise;
8035
+ shutdownServerPromise = (async () => {
8036
+ await Promise.all([
8037
+ pendingStartup?.catch(() => undefined),
8038
+ stopAllPlanAgents(),
8039
+ ]);
8040
+ if (executionEvidenceCleanupTimer) {
8041
+ clearInterval(executionEvidenceCleanupTimer);
8042
+ executionEvidenceCleanupTimer = null;
8043
+ }
8044
+ await stopMobileAccess();
8045
+ await clearAllTestSessions(io);
8046
+ await clearAllGitHubActionsSessions();
8047
+ await shutdownRealtimeServer({
8048
+ signal,
8049
+ io,
8050
+ httpServer,
8051
+ closeAllTerminalSessions,
8052
+ clearAllPreviewProxies,
8053
+ stopAllBuildAgents,
8054
+ stopAllReviewAgents,
8055
+ stopAllPlanAgents,
8056
+ stopAllTickets,
8057
+ });
8058
+ })().finally(() => {
8059
+ shutdownRequested = false;
8060
+ shutdownServerPromise = null;
7020
8061
  });
8062
+ return shutdownServerPromise;
7021
8063
  };
7022
8064
  export const registerShutdownHandlers = () => {
7023
8065
  const createHandler = (signal) => {