factory-factory 0.3.21 → 0.4.0

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 (789) hide show
  1. package/dist/client/assets/{arc-DMA75yUX.js → arc-BZLkggFv.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-DfVbmuXe.js → architectureDiagram-3BPJPVTR-0dfKDS01.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-BBr1PMaO.js → blockDiagram-GPEHLZMM-kPnqTUvo.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-C6xSY9MY.js → c4Diagram-AAUBKEIU-fGcSJ5RM.js} +1 -1
  5. package/dist/client/assets/channel-76ipWABm.js +1 -0
  6. package/dist/client/assets/chunk-2J33WTMH-nkRXvoLd.js +1 -0
  7. package/dist/client/assets/{chunk-4BX2VUAB-DohmszzB.js → chunk-4BX2VUAB-BzGqW4v8.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-BxtEguq8.js → chunk-55IACEB6-CKYhsw8v.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM--7mPe6NT.js → chunk-727SXJPM-D6c3bkpP.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-CUtRyyX-.js → chunk-AQP2D5EJ-Cp_aqtpa.js} +3 -3
  11. package/dist/client/assets/{chunk-FMBD7UC4-Zvl7moTS.js → chunk-FMBD7UC4-CkbLLRk5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-DHRE6LfZ.js → chunk-ND2GUHAM-BZ894oON.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DCUx43mp.js → chunk-QZHKN3VN-D4NOzC69.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-FuxnCTTL.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-FuxnCTTL.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-CTS7yey1.js → cose-bilkent-S5V4N54A-CPSnsVY6.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-CDvFWhCS.js → dagre-BM42HDAG-CDuVn0gJ.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BP89d-WY.js → diagram-2AECGRRQ-DkPx69Lt.js} +2 -2
  19. package/dist/client/assets/{diagram-5GNKFQAL-BXDVw1Xi.js → diagram-5GNKFQAL-CchGithI.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-DBYI-mqR.js → diagram-KO2AKTUF-DQ50kQAK.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-D359JaJw.js → diagram-LMA3HP47-hKBATxk5.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BMr7mzB4.js → diagram-OG6HWLK6-Bga7_0RO.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-CeCqd6tA.js → erDiagram-TEJ5UH35-D26at0F2.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-ZUKSazw4.js → flowDiagram-I6XJVG4X-Qf-TRw4O.js} +4 -4
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-Cq-q9kPK.js → ganttDiagram-6RSMTGT7-CmRredaI.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zVissJIm.js → gitGraphDiagram-PVQCEYII-QQP7Sl8F.js} +3 -3
  27. package/dist/client/assets/index-Db00yCQO.js +672 -0
  28. package/dist/client/assets/index-kItkrW7R.css +1 -0
  29. package/dist/client/assets/infoDiagram-5YYISTIA-YKWLGpI0.js +2 -0
  30. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-Cux5p5kO.js → ishikawaDiagram-YF4QCWOH-DrCgRttT.js} +3 -3
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-1ke9LkJX.js → journeyDiagram-JHISSGLW-CcyKXKKG.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-DP9dkc_Z.js → kanban-definition-UN3LZRKU-CsGSN3QV.js} +1 -1
  33. package/dist/client/assets/{linear-CyyRtH0c.js → linear-DG8wt3yl.js} +1 -1
  34. package/dist/client/assets/{mindmap-definition-RKZ34NQL-BcU2pjK2.js → mindmap-definition-RKZ34NQL-CD6E0vLi.js} +3 -3
  35. package/dist/client/assets/{mobile-baseline-CSjGr1_M.js → mobile-baseline-CCXJGaWY.js} +2 -2
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-Bl7PuiuI.js → pieDiagram-4H26LBE5-PIN8tU1a.js} +2 -2
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Dx0ckMzC.js → quadrantDiagram-W4KKPZXB-DV6ne1ch.js} +3 -3
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-CXkIn3ft.js → requirementDiagram-4Y6WPE33-Cly9IJv1.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DIjHC-x5.js → sankeyDiagram-5OEKKPKP-D2SFbF9j.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BEdLD9KB.js → sequenceDiagram-3UESZ5HK-BsxZzGc-.js} +4 -4
  41. package/dist/client/assets/stateDiagram-AJRCARHV-DtZoFzqs.js +1 -0
  42. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Dhf9Ohaz.js +1 -0
  43. package/dist/client/assets/{terminal-instance-W5XOlvlg.js → terminal-instance-DFzmxfzG.js} +8 -8
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-CkV6p1W8.js → timeline-definition-PNZ67QCA-B7ZqNmvc.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-ubGCO9Lw.js → vennDiagram-CIIHVFJN-Biz7Z4hM.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-_NZpI_YF.js → wardley-L42UT6IY-B-ZjZQUx.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DMXZwsFF.js → wardleyDiagram-YWT4CUSO-C9yV9o-K.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-T9YSJ2sB.js → xychartDiagram-2RQKCTM6-DuWAbR0h.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/dist/prisma/generated/browser.d.ts +1 -0
  51. package/dist/prisma/generated/browser.d.ts.map +1 -1
  52. package/dist/prisma/generated/client.d.ts +1 -0
  53. package/dist/prisma/generated/client.d.ts.map +1 -1
  54. package/dist/prisma/generated/commonInputTypes.d.ts +60 -0
  55. package/dist/prisma/generated/commonInputTypes.d.ts.map +1 -1
  56. package/dist/prisma/generated/enums.d.ts +12 -0
  57. package/dist/prisma/generated/enums.d.ts.map +1 -1
  58. package/dist/prisma/generated/enums.js +11 -1
  59. package/dist/prisma/generated/enums.js.map +1 -1
  60. package/dist/prisma/generated/internal/class.d.ts +3 -0
  61. package/dist/prisma/generated/internal/class.d.ts.map +1 -1
  62. package/dist/prisma/generated/internal/class.js +4 -4
  63. package/dist/prisma/generated/internal/class.js.map +1 -1
  64. package/dist/prisma/generated/internal/prismaNamespace.d.ts +94 -1
  65. package/dist/prisma/generated/internal/prismaNamespace.d.ts.map +1 -1
  66. package/dist/prisma/generated/internal/prismaNamespace.js +16 -1
  67. package/dist/prisma/generated/internal/prismaNamespace.js.map +1 -1
  68. package/dist/prisma/generated/internal/prismaNamespaceBrowser.d.ts +16 -0
  69. package/dist/prisma/generated/internal/prismaNamespaceBrowser.d.ts.map +1 -1
  70. package/dist/prisma/generated/internal/prismaNamespaceBrowser.js +16 -1
  71. package/dist/prisma/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  72. package/dist/prisma/generated/models/Workspace.d.ts +1412 -92
  73. package/dist/prisma/generated/models/Workspace.d.ts.map +1 -1
  74. package/dist/prisma/generated/models/WorkspaceNotification.d.ts +684 -0
  75. package/dist/prisma/generated/models/WorkspaceNotification.d.ts.map +1 -0
  76. package/dist/prisma/generated/models/WorkspaceNotification.js +2 -0
  77. package/dist/prisma/generated/models/WorkspaceNotification.js.map +1 -0
  78. package/dist/prisma/generated/models.d.ts +1 -0
  79. package/dist/prisma/generated/models.d.ts.map +1 -1
  80. package/dist/prompts/ratchet/dispatch.md +2 -0
  81. package/dist/scripts/check-no-direct-process-env.d.mts.map +1 -1
  82. package/dist/scripts/check-no-direct-process-env.mjs +1 -0
  83. package/dist/scripts/check-no-direct-process-env.mjs.map +1 -1
  84. package/dist/src/backend/app-context.d.ts +1 -2
  85. package/dist/src/backend/app-context.d.ts.map +1 -1
  86. package/dist/src/backend/app-context.js +1 -2
  87. package/dist/src/backend/app-context.js.map +1 -1
  88. package/dist/src/backend/fatal-error-handlers.d.ts +19 -0
  89. package/dist/src/backend/fatal-error-handlers.d.ts.map +1 -0
  90. package/dist/src/backend/fatal-error-handlers.js +37 -0
  91. package/dist/src/backend/fatal-error-handlers.js.map +1 -0
  92. package/dist/src/backend/fatal-error-handlers.test.d.ts +2 -0
  93. package/dist/src/backend/fatal-error-handlers.test.d.ts.map +1 -0
  94. package/dist/src/backend/fatal-error-handlers.test.js +95 -0
  95. package/dist/src/backend/fatal-error-handlers.test.js.map +1 -0
  96. package/dist/src/backend/index.js +2 -15
  97. package/dist/src/backend/index.js.map +1 -1
  98. package/dist/src/backend/interceptors/branch-rename.interceptor.d.ts.map +1 -1
  99. package/dist/src/backend/interceptors/branch-rename.interceptor.js +1 -1
  100. package/dist/src/backend/interceptors/branch-rename.interceptor.js.map +1 -1
  101. package/dist/src/backend/interceptors/branch-rename.interceptor.test.js +7 -6
  102. package/dist/src/backend/interceptors/branch-rename.interceptor.test.js.map +1 -1
  103. package/dist/src/backend/interceptors/conversation-rename.interceptor.d.ts.map +1 -1
  104. package/dist/src/backend/interceptors/conversation-rename.interceptor.js +39 -22
  105. package/dist/src/backend/interceptors/conversation-rename.interceptor.js.map +1 -1
  106. package/dist/src/backend/interceptors/conversation-rename.interceptor.test.js +79 -1
  107. package/dist/src/backend/interceptors/conversation-rename.interceptor.test.js.map +1 -1
  108. package/dist/src/backend/interceptors/pre-pr-rename.interceptor.d.ts.map +1 -1
  109. package/dist/src/backend/interceptors/pre-pr-rename.interceptor.js +1 -2
  110. package/dist/src/backend/interceptors/pre-pr-rename.interceptor.js.map +1 -1
  111. package/dist/src/backend/interceptors/pre-pr-rename.interceptor.test.js +28 -7
  112. package/dist/src/backend/interceptors/pre-pr-rename.interceptor.test.js.map +1 -1
  113. package/dist/src/backend/interceptors/pre-push-rename.interceptor.d.ts.map +1 -1
  114. package/dist/src/backend/interceptors/pre-push-rename.interceptor.js +1 -2
  115. package/dist/src/backend/interceptors/pre-push-rename.interceptor.js.map +1 -1
  116. package/dist/src/backend/interceptors/pre-push-rename.interceptor.test.js +28 -7
  117. package/dist/src/backend/interceptors/pre-push-rename.interceptor.test.js.map +1 -1
  118. package/dist/src/backend/lib/constants.d.ts +2 -0
  119. package/dist/src/backend/lib/constants.d.ts.map +1 -1
  120. package/dist/src/backend/lib/constants.js +2 -0
  121. package/dist/src/backend/lib/constants.js.map +1 -1
  122. package/dist/src/backend/lib/env.d.ts.map +1 -1
  123. package/dist/src/backend/lib/env.js +4 -3
  124. package/dist/src/backend/lib/env.js.map +1 -1
  125. package/dist/src/backend/lib/env.test.js +17 -1
  126. package/dist/src/backend/lib/env.test.js.map +1 -1
  127. package/dist/src/backend/lib/file-helpers.d.ts +7 -0
  128. package/dist/src/backend/lib/file-helpers.d.ts.map +1 -1
  129. package/dist/src/backend/lib/file-helpers.js +65 -15
  130. package/dist/src/backend/lib/file-helpers.js.map +1 -1
  131. package/dist/src/backend/lib/file-helpers.test.js +82 -2
  132. package/dist/src/backend/lib/file-helpers.test.js.map +1 -1
  133. package/dist/src/backend/lib/request-trust.d.ts +4 -0
  134. package/dist/src/backend/lib/request-trust.d.ts.map +1 -0
  135. package/dist/src/backend/lib/request-trust.js +116 -0
  136. package/dist/src/backend/lib/request-trust.js.map +1 -0
  137. package/dist/src/backend/lib/request-trust.test.d.ts +2 -0
  138. package/dist/src/backend/lib/request-trust.test.d.ts.map +1 -0
  139. package/dist/src/backend/lib/request-trust.test.js +64 -0
  140. package/dist/src/backend/lib/request-trust.test.js.map +1 -0
  141. package/dist/src/backend/lib/session-model.js +1 -1
  142. package/dist/src/backend/lib/session-model.js.map +1 -1
  143. package/dist/src/backend/lib/shell.d.ts +13 -1
  144. package/dist/src/backend/lib/shell.d.ts.map +1 -1
  145. package/dist/src/backend/lib/shell.js +124 -13
  146. package/dist/src/backend/lib/shell.js.map +1 -1
  147. package/dist/src/backend/lib/shell.test.js +72 -0
  148. package/dist/src/backend/lib/shell.test.js.map +1 -1
  149. package/dist/src/backend/lib/topic-broadcaster.d.ts +18 -0
  150. package/dist/src/backend/lib/topic-broadcaster.d.ts.map +1 -0
  151. package/dist/src/backend/lib/topic-broadcaster.js +56 -0
  152. package/dist/src/backend/lib/topic-broadcaster.js.map +1 -0
  153. package/dist/src/backend/lib/topic-broadcaster.test.d.ts +2 -0
  154. package/dist/src/backend/lib/topic-broadcaster.test.d.ts.map +1 -0
  155. package/dist/src/backend/lib/topic-broadcaster.test.js +126 -0
  156. package/dist/src/backend/lib/topic-broadcaster.test.js.map +1 -0
  157. package/dist/src/backend/lib/websocket-send.d.ts +12 -0
  158. package/dist/src/backend/lib/websocket-send.d.ts.map +1 -0
  159. package/dist/src/backend/lib/websocket-send.js +46 -0
  160. package/dist/src/backend/lib/websocket-send.js.map +1 -0
  161. package/dist/src/backend/lib/websocket-send.test.d.ts +2 -0
  162. package/dist/src/backend/lib/websocket-send.test.d.ts.map +1 -0
  163. package/dist/src/backend/lib/websocket-send.test.js +103 -0
  164. package/dist/src/backend/lib/websocket-send.test.js.map +1 -0
  165. package/dist/src/backend/middleware/cors.middleware.d.ts.map +1 -1
  166. package/dist/src/backend/middleware/cors.middleware.js +2 -1
  167. package/dist/src/backend/middleware/cors.middleware.js.map +1 -1
  168. package/dist/src/backend/middleware/middleware.test.js +17 -0
  169. package/dist/src/backend/middleware/middleware.test.js.map +1 -1
  170. package/dist/src/backend/orchestration/data-backup.service.test.js +3 -0
  171. package/dist/src/backend/orchestration/data-backup.service.test.js.map +1 -1
  172. package/dist/src/backend/orchestration/domain-bridges.orchestrator.d.ts +2 -1
  173. package/dist/src/backend/orchestration/domain-bridges.orchestrator.d.ts.map +1 -1
  174. package/dist/src/backend/orchestration/domain-bridges.orchestrator.js +57 -21
  175. package/dist/src/backend/orchestration/domain-bridges.orchestrator.js.map +1 -1
  176. package/dist/src/backend/orchestration/domain-bridges.orchestrator.test.js +118 -10
  177. package/dist/src/backend/orchestration/domain-bridges.orchestrator.test.js.map +1 -1
  178. package/dist/src/backend/orchestration/event-collector.orchestrator.d.ts.map +1 -1
  179. package/dist/src/backend/orchestration/event-collector.orchestrator.js +23 -3
  180. package/dist/src/backend/orchestration/event-collector.orchestrator.js.map +1 -1
  181. package/dist/src/backend/orchestration/event-collector.orchestrator.test.js +153 -6
  182. package/dist/src/backend/orchestration/event-collector.orchestrator.test.js.map +1 -1
  183. package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.d.ts +8 -2
  184. package/dist/src/backend/orchestration/reconciliation.service.d.ts.map +1 -0
  185. package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.js +6 -6
  186. package/dist/src/backend/orchestration/reconciliation.service.js.map +1 -0
  187. package/dist/src/backend/orchestration/reconciliation.service.test.d.ts.map +1 -0
  188. package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.test.js +1 -1
  189. package/dist/src/backend/orchestration/reconciliation.service.test.js.map +1 -0
  190. package/dist/src/backend/orchestration/workspace-archive.orchestrator.d.ts +9 -0
  191. package/dist/src/backend/orchestration/workspace-archive.orchestrator.d.ts.map +1 -1
  192. package/dist/src/backend/orchestration/workspace-archive.orchestrator.js +33 -25
  193. package/dist/src/backend/orchestration/workspace-archive.orchestrator.js.map +1 -1
  194. package/dist/src/backend/orchestration/workspace-archive.orchestrator.test.js +8 -1
  195. package/dist/src/backend/orchestration/workspace-archive.orchestrator.test.js.map +1 -1
  196. package/dist/src/backend/orchestration/workspace-children.orchestrator.d.ts +22 -0
  197. package/dist/src/backend/orchestration/workspace-children.orchestrator.d.ts.map +1 -0
  198. package/dist/src/backend/orchestration/workspace-children.orchestrator.js +94 -0
  199. package/dist/src/backend/orchestration/workspace-children.orchestrator.js.map +1 -0
  200. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.d.ts +2 -0
  201. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.d.ts.map +1 -0
  202. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.js +116 -0
  203. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.js.map +1 -0
  204. package/dist/src/backend/orchestration/workspace-init.orchestrator.js +3 -3
  205. package/dist/src/backend/orchestration/workspace-init.orchestrator.js.map +1 -1
  206. package/dist/src/backend/orchestration/workspace-init.orchestrator.test.js +2 -2
  207. package/dist/src/backend/orchestration/workspace-init.orchestrator.test.js.map +1 -1
  208. package/dist/src/backend/prompts/branch-rename.d.ts.map +1 -1
  209. package/dist/src/backend/prompts/branch-rename.js +20 -13
  210. package/dist/src/backend/prompts/branch-rename.js.map +1 -1
  211. package/dist/src/backend/prompts/branch-rename.test.d.ts +2 -0
  212. package/dist/src/backend/prompts/branch-rename.test.d.ts.map +1 -0
  213. package/dist/src/backend/prompts/branch-rename.test.js +56 -0
  214. package/dist/src/backend/prompts/branch-rename.test.js.map +1 -0
  215. package/dist/src/backend/prompts/ratchet-dispatch.d.ts.map +1 -1
  216. package/dist/src/backend/prompts/ratchet-dispatch.js +28 -6
  217. package/dist/src/backend/prompts/ratchet-dispatch.js.map +1 -1
  218. package/dist/src/backend/prompts/ratchet-dispatch.test.js +55 -0
  219. package/dist/src/backend/prompts/ratchet-dispatch.test.js.map +1 -1
  220. package/dist/src/backend/routers/websocket/chat-connection-registry.d.ts +27 -0
  221. package/dist/src/backend/routers/websocket/chat-connection-registry.d.ts.map +1 -0
  222. package/dist/src/backend/routers/websocket/chat-connection-registry.js +106 -0
  223. package/dist/src/backend/routers/websocket/chat-connection-registry.js.map +1 -0
  224. package/dist/src/backend/routers/websocket/chat-connection-registry.test.d.ts +2 -0
  225. package/dist/src/backend/routers/websocket/chat-connection-registry.test.d.ts.map +1 -0
  226. package/dist/src/backend/routers/websocket/chat-connection-registry.test.js +242 -0
  227. package/dist/src/backend/routers/websocket/chat-connection-registry.test.js.map +1 -0
  228. package/dist/src/backend/routers/websocket/chat.handler.d.ts +2 -5
  229. package/dist/src/backend/routers/websocket/chat.handler.d.ts.map +1 -1
  230. package/dist/src/backend/routers/websocket/chat.handler.js +34 -45
  231. package/dist/src/backend/routers/websocket/chat.handler.js.map +1 -1
  232. package/dist/src/backend/routers/websocket/chat.handler.test.js +86 -43
  233. package/dist/src/backend/routers/websocket/chat.handler.test.js.map +1 -1
  234. package/dist/src/backend/routers/websocket/dev-logs.handler.d.ts +3 -6
  235. package/dist/src/backend/routers/websocket/dev-logs.handler.d.ts.map +1 -1
  236. package/dist/src/backend/routers/websocket/dev-logs.handler.js +12 -68
  237. package/dist/src/backend/routers/websocket/dev-logs.handler.js.map +1 -1
  238. package/dist/src/backend/routers/websocket/dev-logs.handler.test.js +75 -3
  239. package/dist/src/backend/routers/websocket/dev-logs.handler.test.js.map +1 -1
  240. package/dist/src/backend/routers/websocket/message-utils.d.ts +6 -0
  241. package/dist/src/backend/routers/websocket/message-utils.d.ts.map +1 -1
  242. package/dist/src/backend/routers/websocket/message-utils.js +28 -0
  243. package/dist/src/backend/routers/websocket/message-utils.js.map +1 -1
  244. package/dist/src/backend/routers/websocket/message-utils.test.d.ts +2 -0
  245. package/dist/src/backend/routers/websocket/message-utils.test.d.ts.map +1 -0
  246. package/dist/src/backend/routers/websocket/message-utils.test.js +54 -0
  247. package/dist/src/backend/routers/websocket/message-utils.test.js.map +1 -0
  248. package/dist/src/backend/routers/websocket/post-run-logs.handler.d.ts +3 -6
  249. package/dist/src/backend/routers/websocket/post-run-logs.handler.d.ts.map +1 -1
  250. package/dist/src/backend/routers/websocket/post-run-logs.handler.js +12 -57
  251. package/dist/src/backend/routers/websocket/post-run-logs.handler.js.map +1 -1
  252. package/dist/src/backend/routers/websocket/post-run-logs.handler.test.js +76 -12
  253. package/dist/src/backend/routers/websocket/post-run-logs.handler.test.js.map +1 -1
  254. package/dist/src/backend/routers/websocket/push-channel.handler.d.ts +12 -0
  255. package/dist/src/backend/routers/websocket/push-channel.handler.d.ts.map +1 -0
  256. package/dist/src/backend/routers/websocket/push-channel.handler.js +40 -0
  257. package/dist/src/backend/routers/websocket/push-channel.handler.js.map +1 -0
  258. package/dist/src/backend/routers/websocket/setup-terminal.handler.d.ts +1 -4
  259. package/dist/src/backend/routers/websocket/setup-terminal.handler.d.ts.map +1 -1
  260. package/dist/src/backend/routers/websocket/setup-terminal.handler.js +14 -46
  261. package/dist/src/backend/routers/websocket/setup-terminal.handler.js.map +1 -1
  262. package/dist/src/backend/routers/websocket/setup-terminal.handler.test.js +66 -6
  263. package/dist/src/backend/routers/websocket/setup-terminal.handler.test.js.map +1 -1
  264. package/dist/src/backend/routers/websocket/snapshots.handler.d.ts +5 -8
  265. package/dist/src/backend/routers/websocket/snapshots.handler.d.ts.map +1 -1
  266. package/dist/src/backend/routers/websocket/snapshots.handler.js +71 -56
  267. package/dist/src/backend/routers/websocket/snapshots.handler.js.map +1 -1
  268. package/dist/src/backend/routers/websocket/snapshots.handler.test.js +285 -44
  269. package/dist/src/backend/routers/websocket/snapshots.handler.test.js.map +1 -1
  270. package/dist/src/backend/routers/websocket/terminal.handler.d.ts +3 -6
  271. package/dist/src/backend/routers/websocket/terminal.handler.d.ts.map +1 -1
  272. package/dist/src/backend/routers/websocket/terminal.handler.js +111 -101
  273. package/dist/src/backend/routers/websocket/terminal.handler.js.map +1 -1
  274. package/dist/src/backend/routers/websocket/terminal.handler.test.js +256 -16
  275. package/dist/src/backend/routers/websocket/terminal.handler.test.js.map +1 -1
  276. package/dist/src/backend/routers/websocket/upgrade-utils.d.ts +30 -2
  277. package/dist/src/backend/routers/websocket/upgrade-utils.d.ts.map +1 -1
  278. package/dist/src/backend/routers/websocket/upgrade-utils.js +103 -8
  279. package/dist/src/backend/routers/websocket/upgrade-utils.js.map +1 -1
  280. package/dist/src/backend/routers/websocket/upgrade-utils.test.js +316 -3
  281. package/dist/src/backend/routers/websocket/upgrade-utils.test.js.map +1 -1
  282. package/dist/src/backend/routers/websocket/websocket.integration.test.js +10 -17
  283. package/dist/src/backend/routers/websocket/websocket.integration.test.js.map +1 -1
  284. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.d.ts +0 -2
  285. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.d.ts.map +1 -1
  286. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.js +0 -3
  287. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.js.map +1 -1
  288. package/dist/src/backend/schemas/websocket/terminal-message.schema.d.ts +0 -2
  289. package/dist/src/backend/schemas/websocket/terminal-message.schema.d.ts.map +1 -1
  290. package/dist/src/backend/schemas/websocket/terminal-message.schema.js +0 -1
  291. package/dist/src/backend/schemas/websocket/terminal-message.schema.js.map +1 -1
  292. package/dist/src/backend/server.d.ts.map +1 -1
  293. package/dist/src/backend/server.js +169 -102
  294. package/dist/src/backend/server.js.map +1 -1
  295. package/dist/src/backend/server.upgrade.test.js +217 -24
  296. package/dist/src/backend/server.upgrade.test.js.map +1 -1
  297. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.d.ts.map +1 -1
  298. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.js +12 -2
  299. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.js.map +1 -1
  300. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.test.js +27 -3
  301. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.test.js.map +1 -1
  302. package/dist/src/backend/services/check-single-writer.test.js +7 -7
  303. package/dist/src/backend/services/check-single-writer.test.js.map +1 -1
  304. package/dist/src/backend/services/config.service.d.ts +1 -0
  305. package/dist/src/backend/services/config.service.d.ts.map +1 -1
  306. package/dist/src/backend/services/config.service.js +20 -5
  307. package/dist/src/backend/services/config.service.js.map +1 -1
  308. package/dist/src/backend/services/config.service.test.js +18 -0
  309. package/dist/src/backend/services/config.service.test.js.map +1 -1
  310. package/dist/src/backend/services/constants.d.ts +1 -1
  311. package/dist/src/backend/services/constants.js +1 -1
  312. package/dist/src/backend/services/constants.js.map +1 -1
  313. package/dist/src/backend/services/env-schemas.d.ts +1 -0
  314. package/dist/src/backend/services/env-schemas.d.ts.map +1 -1
  315. package/dist/src/backend/services/env-schemas.js +1 -0
  316. package/dist/src/backend/services/env-schemas.js.map +1 -1
  317. package/dist/src/backend/services/git-clone.service.d.ts.map +1 -1
  318. package/dist/src/backend/services/git-clone.service.js +19 -3
  319. package/dist/src/backend/services/git-clone.service.js.map +1 -1
  320. package/dist/src/backend/services/git-clone.service.test.js +81 -1
  321. package/dist/src/backend/services/git-clone.service.test.js.map +1 -1
  322. package/dist/src/backend/services/git-ops.service.d.ts.map +1 -1
  323. package/dist/src/backend/services/git-ops.service.js +6 -1
  324. package/dist/src/backend/services/git-ops.service.js.map +1 -1
  325. package/dist/src/backend/services/git-ops.service.test.js +24 -3
  326. package/dist/src/backend/services/git-ops.service.test.js.map +1 -1
  327. package/dist/src/backend/services/github/service/github-cli/errors.d.ts.map +1 -1
  328. package/dist/src/backend/services/github/service/github-cli/errors.js +6 -1
  329. package/dist/src/backend/services/github/service/github-cli/errors.js.map +1 -1
  330. package/dist/src/backend/services/github/service/github-cli/schemas.d.ts +64 -6
  331. package/dist/src/backend/services/github/service/github-cli/schemas.d.ts.map +1 -1
  332. package/dist/src/backend/services/github/service/github-cli/schemas.js +45 -0
  333. package/dist/src/backend/services/github/service/github-cli/schemas.js.map +1 -1
  334. package/dist/src/backend/services/github/service/github-cli.service.d.ts +8 -3
  335. package/dist/src/backend/services/github/service/github-cli.service.d.ts.map +1 -1
  336. package/dist/src/backend/services/github/service/github-cli.service.js +244 -51
  337. package/dist/src/backend/services/github/service/github-cli.service.js.map +1 -1
  338. package/dist/src/backend/services/github/service/github-cli.service.test.js +398 -0
  339. package/dist/src/backend/services/github/service/github-cli.service.test.js.map +1 -1
  340. package/dist/src/backend/services/github/service/index.d.ts +1 -0
  341. package/dist/src/backend/services/github/service/index.d.ts.map +1 -1
  342. package/dist/src/backend/services/github/service/index.js +1 -0
  343. package/dist/src/backend/services/github/service/index.js.map +1 -1
  344. package/dist/src/backend/services/github/service/pr-fetch-registry.d.ts +1 -0
  345. package/dist/src/backend/services/github/service/pr-fetch-registry.d.ts.map +1 -1
  346. package/dist/src/backend/services/github/service/pr-fetch-registry.js +3 -0
  347. package/dist/src/backend/services/github/service/pr-fetch-registry.js.map +1 -1
  348. package/dist/src/backend/services/linear/service/linear-client.service.d.ts.map +1 -1
  349. package/dist/src/backend/services/linear/service/linear-client.service.js +33 -20
  350. package/dist/src/backend/services/linear/service/linear-client.service.js.map +1 -1
  351. package/dist/src/backend/services/linear/service/linear-client.service.test.js +76 -0
  352. package/dist/src/backend/services/linear/service/linear-client.service.test.js.map +1 -1
  353. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.d.ts +13 -1
  354. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.d.ts.map +1 -1
  355. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.js +40 -13
  356. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.js.map +1 -1
  357. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.test.js +58 -0
  358. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.test.js.map +1 -1
  359. package/dist/src/backend/services/periodic-task/service/periodic-task.service.d.ts +2 -0
  360. package/dist/src/backend/services/periodic-task/service/periodic-task.service.d.ts.map +1 -1
  361. package/dist/src/backend/services/periodic-task/service/periodic-task.service.js +54 -9
  362. package/dist/src/backend/services/periodic-task/service/periodic-task.service.js.map +1 -1
  363. package/dist/src/backend/services/periodic-task/service/periodic-task.service.test.js +131 -1
  364. package/dist/src/backend/services/periodic-task/service/periodic-task.service.test.js.map +1 -1
  365. package/dist/src/backend/services/port-allocation.service.d.ts +2 -1
  366. package/dist/src/backend/services/port-allocation.service.d.ts.map +1 -1
  367. package/dist/src/backend/services/port-allocation.service.js +12 -4
  368. package/dist/src/backend/services/port-allocation.service.js.map +1 -1
  369. package/dist/src/backend/services/port-allocation.service.test.d.ts +2 -0
  370. package/dist/src/backend/services/port-allocation.service.test.d.ts.map +1 -0
  371. package/dist/src/backend/services/port-allocation.service.test.js +43 -0
  372. package/dist/src/backend/services/port-allocation.service.test.js.map +1 -0
  373. package/dist/src/backend/services/ratchet/service/bridges.d.ts +18 -11
  374. package/dist/src/backend/services/ratchet/service/bridges.d.ts.map +1 -1
  375. package/dist/src/backend/services/ratchet/service/fixer-session.service.d.ts +1 -0
  376. package/dist/src/backend/services/ratchet/service/fixer-session.service.d.ts.map +1 -1
  377. package/dist/src/backend/services/ratchet/service/fixer-session.service.js +9 -2
  378. package/dist/src/backend/services/ratchet/service/fixer-session.service.js.map +1 -1
  379. package/dist/src/backend/services/ratchet/service/fixer-session.service.test.js +25 -0
  380. package/dist/src/backend/services/ratchet/service/fixer-session.service.test.js.map +1 -1
  381. package/dist/src/backend/services/ratchet/service/index.d.ts +1 -2
  382. package/dist/src/backend/services/ratchet/service/index.d.ts.map +1 -1
  383. package/dist/src/backend/services/ratchet/service/index.js +0 -1
  384. package/dist/src/backend/services/ratchet/service/index.js.map +1 -1
  385. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.d.ts +6 -12
  386. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.d.ts.map +1 -1
  387. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.js +52 -56
  388. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.js.map +1 -1
  389. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.d.ts +2 -5
  390. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.d.ts.map +1 -1
  391. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.js +7 -4
  392. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.js.map +1 -1
  393. package/dist/src/backend/services/ratchet/service/ratchet-domain-exports.test.js +1 -4
  394. package/dist/src/backend/services/ratchet/service/ratchet-domain-exports.test.js.map +1 -1
  395. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.d.ts +3 -6
  396. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.d.ts.map +1 -1
  397. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.js +145 -22
  398. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.js.map +1 -1
  399. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.d.ts +10 -30
  400. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.d.ts.map +1 -1
  401. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.js +60 -30
  402. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.js.map +1 -1
  403. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.test.js +264 -11
  404. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.test.js.map +1 -1
  405. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.d.ts +11 -0
  406. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.d.ts.map +1 -0
  407. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.js +77 -0
  408. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.js.map +1 -0
  409. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.d.ts +2 -0
  410. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.d.ts.map +1 -0
  411. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.js +66 -0
  412. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.js.map +1 -0
  413. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.d.ts +2 -1
  414. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.d.ts.map +1 -1
  415. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.js +35 -9
  416. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.js.map +1 -1
  417. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.test.js +91 -0
  418. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.test.js.map +1 -1
  419. package/dist/src/backend/services/ratchet/service/ratchet.service.d.ts +12 -56
  420. package/dist/src/backend/services/ratchet/service/ratchet.service.d.ts.map +1 -1
  421. package/dist/src/backend/services/ratchet/service/ratchet.service.js +249 -220
  422. package/dist/src/backend/services/ratchet/service/ratchet.service.js.map +1 -1
  423. package/dist/src/backend/services/ratchet/service/ratchet.service.test.js +1520 -453
  424. package/dist/src/backend/services/ratchet/service/ratchet.service.test.js.map +1 -1
  425. package/dist/src/backend/services/ratchet/service/ratchet.types.d.ts +20 -13
  426. package/dist/src/backend/services/ratchet/service/ratchet.types.d.ts.map +1 -1
  427. package/dist/src/backend/services/registry.d.ts +3 -3
  428. package/dist/src/backend/services/registry.d.ts.map +1 -1
  429. package/dist/src/backend/services/registry.js +3 -2
  430. package/dist/src/backend/services/registry.js.map +1 -1
  431. package/dist/src/backend/services/resources.integration.test.js +101 -10
  432. package/dist/src/backend/services/resources.integration.test.js.map +1 -1
  433. package/dist/src/backend/services/run-script/service/run-script.service.d.ts +5 -0
  434. package/dist/src/backend/services/run-script/service/run-script.service.d.ts.map +1 -1
  435. package/dist/src/backend/services/run-script/service/run-script.service.js +130 -33
  436. package/dist/src/backend/services/run-script/service/run-script.service.js.map +1 -1
  437. package/dist/src/backend/services/run-script/service/run-script.service.test.js +276 -24
  438. package/dist/src/backend/services/run-script/service/run-script.service.test.js.map +1 -1
  439. package/dist/src/backend/services/session/resources/agent-session.accessor.test.js +19 -0
  440. package/dist/src/backend/services/session/resources/agent-session.accessor.test.js.map +1 -1
  441. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.d.ts +13 -0
  442. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.d.ts.map +1 -1
  443. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.js +192 -23
  444. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.js.map +1 -1
  445. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.test.js +236 -24
  446. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.test.js.map +1 -1
  447. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.d.ts +11 -0
  448. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.d.ts.map +1 -0
  449. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.js +318 -0
  450. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.js.map +1 -0
  451. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.d.ts +18 -1
  452. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.d.ts.map +1 -1
  453. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.js +54 -6
  454. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.js.map +1 -1
  455. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.test.js +76 -1
  456. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.test.js.map +1 -1
  457. package/dist/src/backend/services/session/service/acp/types.d.ts +7 -0
  458. package/dist/src/backend/services/session/service/acp/types.d.ts.map +1 -1
  459. package/dist/src/backend/services/session/service/bridges.d.ts +9 -5
  460. package/dist/src/backend/services/session/service/bridges.d.ts.map +1 -1
  461. package/dist/src/backend/services/session/service/chat/chat-event-forwarder.service.d.ts.map +1 -1
  462. package/dist/src/backend/services/session/service/chat/chat-event-forwarder.service.js +2 -14
  463. package/dist/src/backend/services/session/service/chat/chat-event-forwarder.service.js.map +1 -1
  464. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.d.ts.map +1 -1
  465. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.js +59 -76
  466. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.js.map +1 -1
  467. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.test.js +204 -1
  468. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.test.js.map +1 -1
  469. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.d.ts.map +1 -1
  470. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.js +2 -11
  471. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.js.map +1 -1
  472. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.test.js +5 -18
  473. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.test.js.map +1 -1
  474. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.d.ts.map +1 -1
  475. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.js +9 -0
  476. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.js.map +1 -1
  477. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.test.js +34 -0
  478. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.test.js.map +1 -1
  479. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.d.ts +11 -0
  480. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.d.ts.map +1 -1
  481. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.js +148 -26
  482. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.js.map +1 -1
  483. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.test.js +339 -1
  484. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.test.js.map +1 -1
  485. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.d.ts.map +1 -1
  486. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.js +200 -26
  487. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.js.map +1 -1
  488. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.test.js +352 -1
  489. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.test.js.map +1 -1
  490. package/dist/src/backend/services/session/service/data/session-data.service.d.ts +9 -2
  491. package/dist/src/backend/services/session/service/data/session-data.service.d.ts.map +1 -1
  492. package/dist/src/backend/services/session/service/data/session-data.service.js +2 -2
  493. package/dist/src/backend/services/session/service/data/session-data.service.js.map +1 -1
  494. package/dist/src/backend/services/session/service/data/session-history-loader.service.d.ts.map +1 -1
  495. package/dist/src/backend/services/session/service/data/session-history-loader.service.js +4 -1
  496. package/dist/src/backend/services/session/service/data/session-history-loader.service.js.map +1 -1
  497. package/dist/src/backend/services/session/service/data/session-history-loader.service.test.js +37 -0
  498. package/dist/src/backend/services/session/service/data/session-history-loader.service.test.js.map +1 -1
  499. package/dist/src/backend/services/session/service/index.d.ts +2 -2
  500. package/dist/src/backend/services/session/service/index.d.ts.map +1 -1
  501. package/dist/src/backend/services/session/service/index.js +1 -1
  502. package/dist/src/backend/services/session/service/index.js.map +1 -1
  503. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.d.ts +2 -0
  504. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.d.ts.map +1 -1
  505. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.js +26 -7
  506. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.js.map +1 -1
  507. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.d.ts +2 -0
  508. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.d.ts.map +1 -0
  509. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.js +159 -0
  510. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.js.map +1 -0
  511. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.d.ts +23 -3
  512. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.d.ts.map +1 -1
  513. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.js +325 -36
  514. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.js.map +1 -1
  515. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.d.ts +2 -0
  516. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.d.ts.map +1 -0
  517. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.js +822 -0
  518. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.js.map +1 -0
  519. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.d.ts +4 -0
  520. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.d.ts.map +1 -1
  521. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.js +13 -0
  522. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.js.map +1 -1
  523. package/dist/src/backend/services/session/service/lifecycle/session.service.coverage.test.js +6 -0
  524. package/dist/src/backend/services/session/service/lifecycle/session.service.coverage.test.js.map +1 -1
  525. package/dist/src/backend/services/session/service/lifecycle/session.service.d.ts +12 -6
  526. package/dist/src/backend/services/session/service/lifecycle/session.service.d.ts.map +1 -1
  527. package/dist/src/backend/services/session/service/lifecycle/session.service.js +18 -5
  528. package/dist/src/backend/services/session/service/lifecycle/session.service.js.map +1 -1
  529. package/dist/src/backend/services/session/service/lifecycle/session.service.test.js +189 -18
  530. package/dist/src/backend/services/session/service/lifecycle/session.service.test.js.map +1 -1
  531. package/dist/src/backend/services/session/service/session-domain.service.d.ts +2 -0
  532. package/dist/src/backend/services/session/service/session-domain.service.d.ts.map +1 -1
  533. package/dist/src/backend/services/session/service/session-domain.service.js +26 -0
  534. package/dist/src/backend/services/session/service/session-domain.service.js.map +1 -1
  535. package/dist/src/backend/services/session/service/session-domain.service.test.js +37 -16
  536. package/dist/src/backend/services/session/service/session-domain.service.test.js.map +1 -1
  537. package/dist/src/backend/services/session/service/session-event-bus.d.ts +21 -0
  538. package/dist/src/backend/services/session/service/session-event-bus.d.ts.map +1 -0
  539. package/dist/src/backend/services/session/service/session-event-bus.js +28 -0
  540. package/dist/src/backend/services/session/service/session-event-bus.js.map +1 -0
  541. package/dist/src/backend/services/session/service/store/session-publisher.js +4 -4
  542. package/dist/src/backend/services/session/service/store/session-publisher.js.map +1 -1
  543. package/dist/src/backend/services/session/service/store/session-queue.test.js +1 -0
  544. package/dist/src/backend/services/session/service/store/session-queue.test.js.map +1 -1
  545. package/dist/src/backend/services/session/service/store/session-replay-builder.d.ts.map +1 -1
  546. package/dist/src/backend/services/session/service/store/session-replay-builder.js +25 -3
  547. package/dist/src/backend/services/session/service/store/session-replay-builder.js.map +1 -1
  548. package/dist/src/backend/services/session/service/store/session-replay-builder.test.js +123 -0
  549. package/dist/src/backend/services/session/service/store/session-replay-builder.test.js.map +1 -1
  550. package/dist/src/backend/services/session/service/store/session-runtime-machine.test.js +1 -0
  551. package/dist/src/backend/services/session/service/store/session-runtime-machine.test.js.map +1 -1
  552. package/dist/src/backend/services/session/service/store/session-store-registry.d.ts.map +1 -1
  553. package/dist/src/backend/services/session/service/store/session-store-registry.js +1 -0
  554. package/dist/src/backend/services/session/service/store/session-store-registry.js.map +1 -1
  555. package/dist/src/backend/services/session/service/store/session-store.types.d.ts +7 -0
  556. package/dist/src/backend/services/session/service/store/session-store.types.d.ts.map +1 -1
  557. package/dist/src/backend/services/session/service/store/session-transcript.test.js +1 -0
  558. package/dist/src/backend/services/session/service/store/session-transcript.test.js.map +1 -1
  559. package/dist/src/backend/services/session/service/store/slash-command-cache.service.d.ts.map +1 -1
  560. package/dist/src/backend/services/session/service/store/slash-command-cache.service.js +46 -10
  561. package/dist/src/backend/services/session/service/store/slash-command-cache.service.js.map +1 -1
  562. package/dist/src/backend/services/session/service/store/slash-command-cache.service.test.js +64 -3
  563. package/dist/src/backend/services/session/service/store/slash-command-cache.service.test.js.map +1 -1
  564. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.d.ts +7 -0
  565. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.d.ts.map +1 -0
  566. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.js +94 -0
  567. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.js.map +1 -0
  568. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.d.ts +2 -0
  569. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.d.ts.map +1 -0
  570. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.js +52 -0
  571. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.js.map +1 -0
  572. package/dist/src/backend/services/settings/resources/user-settings.accessor.d.ts +2 -1
  573. package/dist/src/backend/services/settings/resources/user-settings.accessor.d.ts.map +1 -1
  574. package/dist/src/backend/services/settings/resources/user-settings.accessor.js +46 -13
  575. package/dist/src/backend/services/settings/resources/user-settings.accessor.js.map +1 -1
  576. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.d.ts +1 -1
  577. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.d.ts.map +1 -1
  578. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.js +2 -2
  579. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.js.map +1 -1
  580. package/dist/src/backend/services/terminal/service/terminal.service.d.ts +2 -0
  581. package/dist/src/backend/services/terminal/service/terminal.service.d.ts.map +1 -1
  582. package/dist/src/backend/services/terminal/service/terminal.service.js +22 -4
  583. package/dist/src/backend/services/terminal/service/terminal.service.js.map +1 -1
  584. package/dist/src/backend/services/terminal/service/terminal.service.test.js +77 -1
  585. package/dist/src/backend/services/terminal/service/terminal.service.test.js.map +1 -1
  586. package/dist/src/backend/services/workspace/index.d.ts +1 -0
  587. package/dist/src/backend/services/workspace/index.d.ts.map +1 -1
  588. package/dist/src/backend/services/workspace/index.js +1 -0
  589. package/dist/src/backend/services/workspace/index.js.map +1 -1
  590. package/dist/src/backend/services/workspace/resources/project.accessor.d.ts.map +1 -1
  591. package/dist/src/backend/services/workspace/resources/project.accessor.js +26 -1
  592. package/dist/src/backend/services/workspace/resources/project.accessor.js.map +1 -1
  593. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.d.ts +19 -0
  594. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.d.ts.map +1 -0
  595. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.js +34 -0
  596. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.js.map +1 -0
  597. package/dist/src/backend/services/workspace/resources/workspace.accessor.d.ts +26 -4
  598. package/dist/src/backend/services/workspace/resources/workspace.accessor.d.ts.map +1 -1
  599. package/dist/src/backend/services/workspace/resources/workspace.accessor.js +69 -3
  600. package/dist/src/backend/services/workspace/resources/workspace.accessor.js.map +1 -1
  601. package/dist/src/backend/services/workspace/resources/workspace.accessor.test.js +84 -0
  602. package/dist/src/backend/services/workspace/resources/workspace.accessor.test.js.map +1 -1
  603. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.d.ts +2 -2
  604. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.d.ts.map +1 -1
  605. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.js +20 -3
  606. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.js.map +1 -1
  607. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.test.js +30 -0
  608. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.test.js.map +1 -1
  609. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.d.ts +9 -0
  610. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.d.ts.map +1 -1
  611. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.js +43 -1
  612. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.js.map +1 -1
  613. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.test.js +3 -0
  614. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.test.js.map +1 -1
  615. package/dist/src/backend/services/workspace/service/lifecycle/data.service.d.ts +28 -1
  616. package/dist/src/backend/services/workspace/service/lifecycle/data.service.d.ts.map +1 -1
  617. package/dist/src/backend/services/workspace/service/lifecycle/data.service.js +2 -1
  618. package/dist/src/backend/services/workspace/service/lifecycle/data.service.js.map +1 -1
  619. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.d.ts +2 -0
  620. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.d.ts.map +1 -0
  621. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.js +23 -0
  622. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.js.map +1 -0
  623. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.d.ts +2 -0
  624. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.d.ts.map +1 -1
  625. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.js +42 -25
  626. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.js.map +1 -1
  627. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.test.js +129 -4
  628. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.test.js.map +1 -1
  629. package/dist/src/backend/services/workspace/service/query/project-management.service.d.ts +6 -0
  630. package/dist/src/backend/services/workspace/service/query/project-management.service.d.ts.map +1 -1
  631. package/dist/src/backend/services/workspace/service/query/workspace-query.service.d.ts +13 -1
  632. package/dist/src/backend/services/workspace/service/query/workspace-query.service.d.ts.map +1 -1
  633. package/dist/src/backend/services/workspace/service/query/workspace-query.service.js +65 -40
  634. package/dist/src/backend/services/workspace/service/query/workspace-query.service.js.map +1 -1
  635. package/dist/src/backend/services/workspace/service/query/workspace-query.service.test.js +60 -0
  636. package/dist/src/backend/services/workspace/service/query/workspace-query.service.test.js.map +1 -1
  637. package/dist/src/backend/services/workspace/service/worktree/worktree-init.test.js +1 -1
  638. package/dist/src/backend/services/workspace/service/worktree/worktree-init.test.js.map +1 -1
  639. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.d.ts +1 -1
  640. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.d.ts.map +1 -1
  641. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.js +40 -5
  642. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.js.map +1 -1
  643. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.test.js +57 -6
  644. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.test.js.map +1 -1
  645. package/dist/src/backend/services/workspace-snapshot-store.service.d.ts +2 -1
  646. package/dist/src/backend/services/workspace-snapshot-store.service.d.ts.map +1 -1
  647. package/dist/src/backend/services/workspace-snapshot-store.service.js +15 -1
  648. package/dist/src/backend/services/workspace-snapshot-store.service.js.map +1 -1
  649. package/dist/src/backend/services/workspace-snapshot-store.service.test.js +32 -0
  650. package/dist/src/backend/services/workspace-snapshot-store.service.test.js.map +1 -1
  651. package/dist/src/backend/trpc/admin.trpc.d.ts +6 -6
  652. package/dist/src/backend/trpc/github.router.test.js +29 -0
  653. package/dist/src/backend/trpc/github.router.test.js.map +1 -1
  654. package/dist/src/backend/trpc/github.trpc.d.ts +6 -5
  655. package/dist/src/backend/trpc/github.trpc.d.ts.map +1 -1
  656. package/dist/src/backend/trpc/github.trpc.js +26 -1
  657. package/dist/src/backend/trpc/github.trpc.js.map +1 -1
  658. package/dist/src/backend/trpc/index.d.ts +209 -6
  659. package/dist/src/backend/trpc/index.d.ts.map +1 -1
  660. package/dist/src/backend/trpc/procedures/project-scoped.d.ts +1 -0
  661. package/dist/src/backend/trpc/procedures/project-scoped.d.ts.map +1 -1
  662. package/dist/src/backend/trpc/project.router.test.js +115 -18
  663. package/dist/src/backend/trpc/project.router.test.js.map +1 -1
  664. package/dist/src/backend/trpc/project.trpc.d.ts +6 -0
  665. package/dist/src/backend/trpc/project.trpc.d.ts.map +1 -1
  666. package/dist/src/backend/trpc/project.trpc.js +23 -20
  667. package/dist/src/backend/trpc/project.trpc.js.map +1 -1
  668. package/dist/src/backend/trpc/session.router.test.js +38 -1
  669. package/dist/src/backend/trpc/session.router.test.js.map +1 -1
  670. package/dist/src/backend/trpc/session.trpc.d.ts +15 -0
  671. package/dist/src/backend/trpc/session.trpc.d.ts.map +1 -1
  672. package/dist/src/backend/trpc/session.trpc.js +20 -6
  673. package/dist/src/backend/trpc/session.trpc.js.map +1 -1
  674. package/dist/src/backend/trpc/trpc.d.ts +9 -0
  675. package/dist/src/backend/trpc/trpc.d.ts.map +1 -1
  676. package/dist/src/backend/trpc/trpc.js +39 -1
  677. package/dist/src/backend/trpc/trpc.js.map +1 -1
  678. package/dist/src/backend/trpc/trpc.test.d.ts +2 -0
  679. package/dist/src/backend/trpc/trpc.test.d.ts.map +1 -0
  680. package/dist/src/backend/trpc/trpc.test.js +19 -0
  681. package/dist/src/backend/trpc/trpc.test.js.map +1 -0
  682. package/dist/src/backend/trpc/user-settings.router.test.js +1 -0
  683. package/dist/src/backend/trpc/user-settings.router.test.js.map +1 -1
  684. package/dist/src/backend/trpc/user-settings.trpc.d.ts.map +1 -1
  685. package/dist/src/backend/trpc/user-settings.trpc.js +1 -0
  686. package/dist/src/backend/trpc/user-settings.trpc.js.map +1 -1
  687. package/dist/src/backend/trpc/workspace/files.trpc.d.ts.map +1 -1
  688. package/dist/src/backend/trpc/workspace/files.trpc.js +5 -9
  689. package/dist/src/backend/trpc/workspace/files.trpc.js.map +1 -1
  690. package/dist/src/backend/trpc/workspace/init.router.test.js +24 -2
  691. package/dist/src/backend/trpc/workspace/init.router.test.js.map +1 -1
  692. package/dist/src/backend/trpc/workspace/init.trpc.d.ts +3 -0
  693. package/dist/src/backend/trpc/workspace/init.trpc.d.ts.map +1 -1
  694. package/dist/src/backend/trpc/workspace/init.trpc.js +2 -2
  695. package/dist/src/backend/trpc/workspace/init.trpc.js.map +1 -1
  696. package/dist/src/backend/trpc/workspace/run-script.router.test.js +23 -1
  697. package/dist/src/backend/trpc/workspace/run-script.router.test.js.map +1 -1
  698. package/dist/src/backend/trpc/workspace/run-script.trpc.js +4 -4
  699. package/dist/src/backend/trpc/workspace/run-script.trpc.js.map +1 -1
  700. package/dist/src/backend/trpc/workspace/workspace-helpers.d.ts +6 -0
  701. package/dist/src/backend/trpc/workspace/workspace-helpers.d.ts.map +1 -1
  702. package/dist/src/backend/trpc/workspace.router.test.js +278 -2
  703. package/dist/src/backend/trpc/workspace.router.test.js.map +1 -1
  704. package/dist/src/backend/trpc/workspace.trpc.d.ts +182 -0
  705. package/dist/src/backend/trpc/workspace.trpc.d.ts.map +1 -1
  706. package/dist/src/backend/trpc/workspace.trpc.js +247 -19
  707. package/dist/src/backend/trpc/workspace.trpc.js.map +1 -1
  708. package/dist/src/cli/database-path.d.ts.map +1 -1
  709. package/dist/src/cli/database-path.js +4 -2
  710. package/dist/src/cli/database-path.js.map +1 -1
  711. package/dist/src/cli/database-path.test.js +10 -0
  712. package/dist/src/cli/database-path.test.js.map +1 -1
  713. package/dist/src/cli/serve-env.d.ts.map +1 -1
  714. package/dist/src/cli/serve-env.js +5 -3
  715. package/dist/src/cli/serve-env.js.map +1 -1
  716. package/dist/src/cli/serve-env.test.js +30 -0
  717. package/dist/src/cli/serve-env.test.js.map +1 -1
  718. package/dist/src/shared/acp-protocol/protocol/constants.d.ts +1 -0
  719. package/dist/src/shared/acp-protocol/protocol/constants.d.ts.map +1 -1
  720. package/dist/src/shared/acp-protocol/protocol/constants.js +1 -0
  721. package/dist/src/shared/acp-protocol/protocol/constants.js.map +1 -1
  722. package/dist/src/shared/acp-protocol/protocol/index.d.ts +1 -0
  723. package/dist/src/shared/acp-protocol/protocol/index.d.ts.map +1 -1
  724. package/dist/src/shared/acp-protocol/protocol/index.js +1 -0
  725. package/dist/src/shared/acp-protocol/protocol/index.js.map +1 -1
  726. package/dist/src/shared/acp-protocol/protocol/messages.d.ts +8 -1
  727. package/dist/src/shared/acp-protocol/protocol/messages.d.ts.map +1 -1
  728. package/dist/src/shared/acp-protocol/protocol/messages.js +2 -0
  729. package/dist/src/shared/acp-protocol/protocol/messages.js.map +1 -1
  730. package/dist/src/shared/acp-protocol/protocol/renderer-window.d.ts +3 -0
  731. package/dist/src/shared/acp-protocol/protocol/renderer-window.d.ts.map +1 -0
  732. package/dist/src/shared/acp-protocol/protocol/renderer-window.js +36 -0
  733. package/dist/src/shared/acp-protocol/protocol/renderer-window.js.map +1 -0
  734. package/dist/src/shared/core/enums.d.ts +1 -0
  735. package/dist/src/shared/core/enums.d.ts.map +1 -1
  736. package/dist/src/shared/core/enums.js +1 -0
  737. package/dist/src/shared/core/enums.js.map +1 -1
  738. package/dist/src/shared/issue-start-prompt.d.ts.map +1 -1
  739. package/dist/src/shared/issue-start-prompt.js +38 -3
  740. package/dist/src/shared/issue-start-prompt.js.map +1 -1
  741. package/dist/src/shared/proxy-utils.d.ts +2 -0
  742. package/dist/src/shared/proxy-utils.d.ts.map +1 -1
  743. package/dist/src/shared/proxy-utils.js +20 -2
  744. package/dist/src/shared/proxy-utils.js.map +1 -1
  745. package/dist/src/shared/schemas/export-data.schema.d.ts +14 -12
  746. package/dist/src/shared/schemas/export-data.schema.d.ts.map +1 -1
  747. package/dist/src/shared/session-runtime.d.ts +9 -0
  748. package/dist/src/shared/session-runtime.d.ts.map +1 -1
  749. package/dist/src/shared/session-runtime.js +23 -0
  750. package/dist/src/shared/session-runtime.js.map +1 -1
  751. package/dist/src/shared/workspace-notifications.d.ts +10 -0
  752. package/dist/src/shared/workspace-notifications.d.ts.map +1 -0
  753. package/dist/src/shared/workspace-notifications.js +11 -0
  754. package/dist/src/shared/workspace-notifications.js.map +1 -0
  755. package/dist/tsconfig.backend.tsbuildinfo +1 -1
  756. package/package.json +4 -4
  757. package/prisma/migrations/20260622135020_add_child_workspaces/migration.sql +99 -0
  758. package/prisma/migrations/20260622135100_add_child_workspaces/migration.sql +39 -0
  759. package/prisma/migrations/20260622195338_add_notification_direction/migration.sql +22 -0
  760. package/prisma/migrations/20260622220000_add_parent_workspace_id_index/migration.sql +2 -0
  761. package/prisma/migrations/20260715201522_add_ratchet_dispatch_record/migration.sql +125 -0
  762. package/prisma/schema.prisma +43 -1
  763. package/prompts/ratchet/dispatch.md +2 -0
  764. package/dist/client/assets/channel-DTP0LAbj.js +0 -1
  765. package/dist/client/assets/chunk-2J33WTMH-BfBptE9H.js +0 -1
  766. package/dist/client/assets/classDiagram-4FO5ZUOK-Ctn2iFx-.js +0 -1
  767. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-Ctn2iFx-.js +0 -1
  768. package/dist/client/assets/index-QJUv8N-T.js +0 -662
  769. package/dist/client/assets/index-z2dDRh55.css +0 -1
  770. package/dist/client/assets/infoDiagram-5YYISTIA-EcbEZrdJ.js +0 -2
  771. package/dist/client/assets/stateDiagram-AJRCARHV-JRGDQ0Z0.js +0 -1
  772. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-YFRSfKcY.js +0 -1
  773. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.d.ts +0 -16
  774. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.d.ts.map +0 -1
  775. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.js +0 -52
  776. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.js.map +0 -1
  777. package/dist/src/backend/services/ratchet/service/reconciliation.service.d.ts.map +0 -1
  778. package/dist/src/backend/services/ratchet/service/reconciliation.service.js.map +0 -1
  779. package/dist/src/backend/services/ratchet/service/reconciliation.service.test.d.ts.map +0 -1
  780. package/dist/src/backend/services/ratchet/service/reconciliation.service.test.js.map +0 -1
  781. package/dist/src/backend/services/session/service/chat/chat-connection.service.d.ts +0 -19
  782. package/dist/src/backend/services/session/service/chat/chat-connection.service.d.ts.map +0 -1
  783. package/dist/src/backend/services/session/service/chat/chat-connection.service.js +0 -78
  784. package/dist/src/backend/services/session/service/chat/chat-connection.service.js.map +0 -1
  785. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.d.ts +0 -2
  786. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.d.ts.map +0 -1
  787. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.js +0 -214
  788. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.js.map +0 -1
  789. /package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.test.d.ts +0 -0
@@ -8,6 +8,11 @@ vi.mock('@/backend/services/workspace', () => ({
8
8
  findWithPRsForRatchet: vi.fn(),
9
9
  findForRatchetById: vi.fn(),
10
10
  update: vi.fn(),
11
+ transitionRatchetStateIfEnabled: vi.fn(),
12
+ settleRatchetIdleWhileDisabled: vi.fn(),
13
+ recordRatchetDispatchIfEnabled: vi.fn(),
14
+ adoptRatchetActiveSessionIfEnabled: vi.fn(),
15
+ recordRatchetSessionEnd: vi.fn(),
11
16
  },
12
17
  }));
13
18
  vi.mock('@/backend/services/session', () => ({
@@ -40,11 +45,15 @@ import { userSettingsAccessor } from '../../../../backend/services/settings/inde
40
45
  import { workspaceAccessor } from '../../../../backend/services/workspace/index.js';
41
46
  import { fixerSessionService } from './fixer-session.service.js';
42
47
  import { RATCHET_STATE_CHANGED, RATCHET_TOGGLED, ratchetService, } from './ratchet.service.js';
48
+ import { buildRatchetingLogContext } from './ratchet-decision-logging.helpers.js';
49
+ import { computeDispatchSnapshotKey, computeLatestReviewActivityAtMs, determineRatchetState, } from './ratchet-pr-state.helpers.js';
43
50
  const mockSessionBridge = {
51
+ findSessionsByWorkspaceId: vi.fn(),
44
52
  isSessionRunning: vi.fn(),
45
53
  isSessionWorking: vi.fn(),
46
54
  stopSession: vi.fn(),
47
55
  startSession: vi.fn(),
56
+ restartSession: vi.fn(),
48
57
  sendSessionMessage: vi.fn(),
49
58
  injectCommittedUserMessage: vi.fn(),
50
59
  };
@@ -52,9 +61,12 @@ const mockGitHubBridge = {
52
61
  extractPRInfo: vi.fn(),
53
62
  getPRFullDetails: vi.fn(),
54
63
  getReviewComments: vi.fn(),
64
+ getResolvedReviewCommentIds: vi.fn(),
55
65
  computeCIStatus: vi.fn(),
56
66
  getAuthenticatedUsername: vi.fn(),
57
67
  fetchAndComputePRState: vi.fn(),
68
+ isRecentlyFetched: vi.fn(),
69
+ isFetchInFlight: vi.fn(),
58
70
  startFetch: vi.fn(),
59
71
  registerFetch: vi.fn(),
60
72
  cancelFetch: vi.fn(),
@@ -70,13 +82,20 @@ describe('ratchet service (state-change + idle dispatch)', () => {
70
82
  unsafeCoerce(ratchetService).isShuttingDown = false;
71
83
  unsafeCoerce(ratchetService).workspaceCheckTimeoutMs =
72
84
  SERVICE_TIMEOUT_MS.ratchetWorkspaceCheck;
73
- unsafeCoerce(ratchetService).reviewPollTrackers.clear();
74
85
  ratchetService.configure({
75
86
  session: mockSessionBridge,
76
87
  github: mockGitHubBridge,
77
88
  snapshot: mockSnapshotBridge,
78
89
  });
90
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(true);
91
+ vi.mocked(workspaceAccessor.settleRatchetIdleWhileDisabled).mockResolvedValue(true);
92
+ vi.mocked(workspaceAccessor.recordRatchetDispatchIfEnabled).mockResolvedValue(true);
93
+ vi.mocked(workspaceAccessor.adoptRatchetActiveSessionIfEnabled).mockResolvedValue(true);
94
+ vi.mocked(workspaceAccessor.recordRatchetSessionEnd).mockResolvedValue(true);
95
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
96
+ vi.mocked(mockSessionBridge.findSessionsByWorkspaceId).mockResolvedValue([]);
79
97
  vi.mocked(mockGitHubBridge.getAuthenticatedUsername).mockResolvedValue(null);
98
+ vi.mocked(mockGitHubBridge.getResolvedReviewCommentIds).mockResolvedValue(new Set());
80
99
  vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
81
100
  vi.mocked(userSettingsAccessor.get).mockResolvedValue({
82
101
  id: 'settings-1',
@@ -101,6 +120,9 @@ describe('ratchet service (state-change + idle dispatch)', () => {
101
120
  });
102
121
  vi.mocked(userSettingsAccessor.getDefaultSessionProvider).mockResolvedValue('CLAUDE');
103
122
  });
123
+ afterEach(() => {
124
+ ratchetService.removeAllListeners();
125
+ });
104
126
  it('checks workspaces and processes each', async () => {
105
127
  vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue([
106
128
  {
@@ -116,6 +138,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
116
138
  ratchetActiveSessionId: null,
117
139
  ratchetLastCiRunId: null,
118
140
  prReviewLastCheckedAt: null,
141
+ ratchetDispatchOutcome: null,
142
+ ratchetDispatchRetryCount: 0,
119
143
  },
120
144
  ]);
121
145
  vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockResolvedValue({
@@ -139,6 +163,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
139
163
  ratchetActiveSessionId: null,
140
164
  ratchetLastCiRunId: null,
141
165
  prReviewLastCheckedAt: null,
166
+ ratchetDispatchOutcome: null,
167
+ ratchetDispatchRetryCount: 0,
142
168
  };
143
169
  const fetchPRStateSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
144
170
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
@@ -150,6 +176,70 @@ describe('ratchet service (state-change + idle dispatch)', () => {
150
176
  action: { type: 'DISABLED', reason: 'Workspace ratcheting disabled' },
151
177
  newState: RatchetState.IDLE,
152
178
  });
179
+ expect(workspaceAccessor.settleRatchetIdleWhileDisabled).toHaveBeenCalledWith('ws-disabled', RatchetState.IDLE);
180
+ });
181
+ it('settles a disabled workspace to IDLE via CAS and emits an accurate fromState', async () => {
182
+ const workspace = {
183
+ id: 'ws-disabled-settle',
184
+ prUrl: 'https://github.com/example/repo/pull/2',
185
+ prNumber: 2,
186
+ prState: 'OPEN',
187
+ prCiStatus: CIStatus.UNKNOWN,
188
+ ratchetEnabled: false,
189
+ ratchetState: RatchetState.CI_FAILED,
190
+ ratchetActiveSessionId: null,
191
+ ratchetLastCiRunId: null,
192
+ prReviewLastCheckedAt: null,
193
+ ratchetDispatchOutcome: null,
194
+ ratchetDispatchRetryCount: 0,
195
+ };
196
+ vi.mocked(workspaceAccessor.settleRatchetIdleWhileDisabled).mockResolvedValue(true);
197
+ const events = [];
198
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
199
+ events.push(event);
200
+ });
201
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
202
+ expect(workspaceAccessor.settleRatchetIdleWhileDisabled).toHaveBeenCalledWith('ws-disabled-settle', RatchetState.CI_FAILED);
203
+ expect(result).toMatchObject({
204
+ previousState: RatchetState.CI_FAILED,
205
+ newState: RatchetState.IDLE,
206
+ action: { type: 'DISABLED' },
207
+ });
208
+ expect(events).toEqual([
209
+ {
210
+ workspaceId: 'ws-disabled-settle',
211
+ fromState: RatchetState.CI_FAILED,
212
+ toState: RatchetState.IDLE,
213
+ },
214
+ ]);
215
+ });
216
+ it('does not emit for a disabled workspace when the settle CAS loses', async () => {
217
+ const workspace = {
218
+ id: 'ws-disabled-settle-race',
219
+ prUrl: 'https://github.com/example/repo/pull/2',
220
+ prNumber: 2,
221
+ prState: 'OPEN',
222
+ prCiStatus: CIStatus.UNKNOWN,
223
+ ratchetEnabled: false,
224
+ ratchetState: RatchetState.CI_FAILED,
225
+ ratchetActiveSessionId: null,
226
+ ratchetLastCiRunId: null,
227
+ prReviewLastCheckedAt: null,
228
+ ratchetDispatchOutcome: null,
229
+ ratchetDispatchRetryCount: 0,
230
+ };
231
+ vi.mocked(workspaceAccessor.settleRatchetIdleWhileDisabled).mockResolvedValue(false);
232
+ const events = [];
233
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
234
+ events.push(event);
235
+ });
236
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
237
+ expect(result).toMatchObject({
238
+ previousState: RatchetState.CI_FAILED,
239
+ newState: RatchetState.CI_FAILED,
240
+ action: { type: 'DISABLED' },
241
+ });
242
+ expect(events).toEqual([]);
153
243
  });
154
244
  it('does not dispatch when workspace is not idle', async () => {
155
245
  const workspace = {
@@ -163,6 +253,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
163
253
  ratchetActiveSessionId: null,
164
254
  ratchetLastCiRunId: null,
165
255
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
256
+ ratchetDispatchOutcome: null,
257
+ ratchetDispatchRetryCount: 0,
166
258
  };
167
259
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
168
260
  ciStatus: CIStatus.SUCCESS,
@@ -212,6 +304,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
212
304
  ratchetActiveSessionId: null,
213
305
  ratchetLastCiRunId: '2026-01-01T00:00:00Z',
214
306
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
307
+ ratchetDispatchOutcome: null,
308
+ ratchetDispatchRetryCount: 0,
215
309
  };
216
310
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
217
311
  ciStatus: CIStatus.FAILURE,
@@ -233,11 +327,64 @@ describe('ratchet service (state-change + idle dispatch)', () => {
233
327
  expect(result).toMatchObject({
234
328
  action: { type: 'TRIGGERED_FIXER' },
235
329
  });
236
- const finalUpdatePayload = vi.mocked(workspaceAccessor.update).mock.calls.at(-1)?.[1];
237
- expect(finalUpdatePayload.ratchetLastCiRunId).toBe('2026-01-02T00:00:00Z');
330
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalledWith('ws-change', {
331
+ sessionId: 'ratchet-session',
332
+ snapshotKey: '2026-01-02T00:00:00Z',
333
+ retryCount: 0,
334
+ });
335
+ const finalUpdatePayload = vi
336
+ .mocked(workspaceAccessor.transitionRatchetStateIfEnabled)
337
+ .mock.calls.at(-1)?.[2];
338
+ expect(finalUpdatePayload).not.toHaveProperty('ratchetLastCiRunId');
238
339
  expect(finalUpdatePayload).not.toHaveProperty('prReviewLastCheckedAt');
239
340
  expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith('ws-change', expect.any(Date));
240
341
  });
342
+ it('stops fixer and returns disabled when active-session recording loses disable race', async () => {
343
+ const workspace = {
344
+ id: 'ws-disable-race-dispatch',
345
+ prUrl: 'https://github.com/example/repo/pull/40',
346
+ prNumber: 40,
347
+ prState: 'OPEN',
348
+ prCiStatus: CIStatus.UNKNOWN,
349
+ ratchetEnabled: true,
350
+ ratchetState: RatchetState.IDLE,
351
+ ratchetActiveSessionId: null,
352
+ ratchetLastCiRunId: 'old-snapshot',
353
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
354
+ ratchetDispatchOutcome: null,
355
+ ratchetDispatchRetryCount: 0,
356
+ };
357
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
358
+ ciStatus: CIStatus.FAILURE,
359
+ snapshotKey: 'new-snapshot',
360
+ hasChangesRequested: false,
361
+ latestReviewActivityAtMs: null,
362
+ statusCheckRollup: null,
363
+ prState: 'OPEN',
364
+ prNumber: 40,
365
+ });
366
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
367
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
368
+ status: 'started',
369
+ sessionId: 'raced-session',
370
+ promptSent: true,
371
+ });
372
+ vi.mocked(workspaceAccessor.recordRatchetDispatchIfEnabled).mockResolvedValue(false);
373
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(false);
374
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
375
+ id: 'ws-disable-race-dispatch',
376
+ ratchetEnabled: false,
377
+ });
378
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
379
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
380
+ expect(result).toMatchObject({
381
+ newState: RatchetState.IDLE,
382
+ action: { type: 'DISABLED', reason: 'Workspace ratcheting disabled' },
383
+ });
384
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalledWith('ws-disable-race-dispatch', { sessionId: 'raced-session', snapshotKey: 'new-snapshot', retryCount: 0 });
385
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('raced-session');
386
+ expect(mockSnapshotBridge.recordReviewCheck).not.toHaveBeenCalled();
387
+ });
241
388
  it('dispatches when session is running but idle', async () => {
242
389
  const workspace = {
243
390
  id: 'ws-idle-session',
@@ -250,6 +397,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
250
397
  ratchetActiveSessionId: null,
251
398
  ratchetLastCiRunId: '2026-01-01T00:00:00Z',
252
399
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
400
+ ratchetDispatchOutcome: null,
401
+ ratchetDispatchRetryCount: 0,
253
402
  };
254
403
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
255
404
  ciStatus: CIStatus.FAILURE,
@@ -294,6 +443,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
294
443
  ratchetActiveSessionId: null,
295
444
  ratchetLastCiRunId: '2026-01-02T00:00:00Z',
296
445
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
446
+ ratchetDispatchOutcome: null,
447
+ ratchetDispatchRetryCount: 0,
297
448
  };
298
449
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
299
450
  ciStatus: CIStatus.FAILURE,
@@ -328,6 +479,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
328
479
  ratchetActiveSessionId: null,
329
480
  ratchetLastCiRunId: 'ci:SUCCESS|changes-requested:1735776000000',
330
481
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
482
+ ratchetDispatchOutcome: null,
483
+ ratchetDispatchRetryCount: 0,
331
484
  };
332
485
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
333
486
  ciStatus: CIStatus.SUCCESS,
@@ -372,6 +525,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
372
525
  ratchetActiveSessionId: null,
373
526
  ratchetLastCiRunId: 'ci:SUCCESS|changes-requested:old',
374
527
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
528
+ ratchetDispatchOutcome: null,
529
+ ratchetDispatchRetryCount: 0,
375
530
  };
376
531
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
377
532
  ciStatus: CIStatus.SUCCESS,
@@ -407,6 +562,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
407
562
  ratchetActiveSessionId: null,
408
563
  ratchetLastCiRunId: 'ci:SUCCESS|no-changes-requested:old|merge:clean',
409
564
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
565
+ ratchetDispatchOutcome: null,
566
+ ratchetDispatchRetryCount: 0,
410
567
  };
411
568
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
412
569
  ciStatus: CIStatus.SUCCESS,
@@ -442,7 +599,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
442
599
  expect(fixerSessionService.acquireAndDispatch).toHaveBeenCalled();
443
600
  });
444
601
  it('treats closed PR as IDLE and does not dispatch', () => {
445
- const state = unsafeCoerce(ratchetService).determineRatchetState({
602
+ const state = determineRatchetState({
446
603
  ciStatus: CIStatus.SUCCESS,
447
604
  snapshotKey: '2026-01-02T00:00:00Z',
448
605
  hasChangesRequested: false,
@@ -464,6 +621,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
464
621
  ratchetActiveSessionId: null,
465
622
  ratchetLastCiRunId: null,
466
623
  prReviewLastCheckedAt: null,
624
+ ratchetDispatchOutcome: null,
625
+ ratchetDispatchRetryCount: 0,
467
626
  };
468
627
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
469
628
  ciStatus: CIStatus.SUCCESS,
@@ -501,6 +660,36 @@ describe('ratchet service (state-change + idle dispatch)', () => {
501
660
  });
502
661
  expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
503
662
  });
663
+ it('routes an aborted prompt-failure path through orphan cleanup', async () => {
664
+ const controller = new AbortController();
665
+ const timeoutError = new Error('Workspace check timed out');
666
+ let finishPointerClear;
667
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
668
+ status: 'started',
669
+ sessionId: 'aborted-prompt-session',
670
+ promptSent: false,
671
+ });
672
+ vi.mocked(workspaceAccessor.update).mockImplementation(() => new Promise((resolve) => {
673
+ finishPointerClear = () => resolve({});
674
+ }));
675
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
676
+ const trigger = unsafeCoerce(ratchetService).triggerFixer({
677
+ id: 'ws-aborted-prompt',
678
+ prUrl: 'https://github.com/example/repo/pull/7',
679
+ }, {
680
+ ciStatus: CIStatus.FAILURE,
681
+ snapshotKey: 'failed:7',
682
+ prNumber: 7,
683
+ reviewComments: [],
684
+ hasMergeConflict: false,
685
+ }, 0, controller.signal);
686
+ await vi.waitFor(() => expect(workspaceAccessor.update).toHaveBeenCalled());
687
+ controller.abort(timeoutError);
688
+ finishPointerClear();
689
+ await expect(trigger).rejects.toBe(timeoutError);
690
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith('ws-aborted-prompt', 'aborted-prompt-session', 'COMPLETED');
691
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('aborted-prompt-session');
692
+ });
504
693
  it('does not dispatch on a clean PR with no new review activity', async () => {
505
694
  const recentCheck = new Date();
506
695
  const workspace = {
@@ -514,6 +703,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
514
703
  ratchetActiveSessionId: null,
515
704
  ratchetLastCiRunId: '2026-01-01T00:00:00Z',
516
705
  prReviewLastCheckedAt: recentCheck,
706
+ ratchetDispatchOutcome: null,
707
+ ratchetDispatchRetryCount: 0,
517
708
  };
518
709
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
519
710
  ciStatus: CIStatus.SUCCESS,
@@ -536,216 +727,473 @@ describe('ratchet service (state-change + idle dispatch)', () => {
536
727
  },
537
728
  });
538
729
  });
539
- it('self-heals when review check timestamp is stale with no active session', async () => {
540
- const staleCheckedAt = new Date(Date.now() - 15 * 60_000);
730
+ it('returns disabled when stale check-state update loses disable race', async () => {
731
+ const recentCheck = new Date();
541
732
  const workspace = {
542
- id: 'ws-stale-check',
543
- prUrl: 'https://github.com/example/repo/pull/13',
544
- prNumber: 13,
733
+ id: 'ws-disable-race-state',
734
+ prUrl: 'https://github.com/example/repo/pull/41',
735
+ prNumber: 41,
545
736
  prState: 'OPEN',
546
737
  prCiStatus: CIStatus.UNKNOWN,
547
738
  ratchetEnabled: true,
548
739
  ratchetState: RatchetState.READY,
549
740
  ratchetActiveSessionId: null,
550
- ratchetLastCiRunId: '2026-01-01T00:00:00Z',
551
- prReviewLastCheckedAt: staleCheckedAt,
741
+ ratchetLastCiRunId: 'old-snapshot',
742
+ prReviewLastCheckedAt: recentCheck,
743
+ ratchetDispatchOutcome: null,
744
+ ratchetDispatchRetryCount: 0,
552
745
  };
553
746
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
554
747
  ciStatus: CIStatus.SUCCESS,
555
- snapshotKey: '2026-01-03T00:00:00Z',
748
+ snapshotKey: 'new-snapshot',
556
749
  hasChangesRequested: false,
557
- latestReviewActivityAtMs: staleCheckedAt.getTime() - 1000,
750
+ latestReviewActivityAtMs: recentCheck.getTime() - 1000,
558
751
  statusCheckRollup: null,
559
752
  prState: 'OPEN',
560
- prNumber: 13,
753
+ prNumber: 41,
754
+ });
755
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(false);
756
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
757
+ id: 'ws-disable-race-state',
758
+ ratchetEnabled: false,
561
759
  });
562
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
563
760
  vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
761
+ const events = [];
762
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
763
+ events.push(event);
764
+ });
564
765
  const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
565
- expect(result).not.toMatchObject({
566
- action: {
567
- type: 'WAITING',
568
- reason: 'PR is clean (green CI and no new review activity)',
569
- },
766
+ expect(result).toMatchObject({
767
+ newState: RatchetState.IDLE,
768
+ action: { type: 'DISABLED', reason: 'Workspace ratcheting disabled' },
570
769
  });
770
+ expect(events).toHaveLength(0);
771
+ ratchetService.removeAllListeners();
571
772
  });
572
- it('resets dispatch tracking when ratchet session process is not running', async () => {
773
+ it('reports a concurrent state change without emitting when the check loses the CAS while still enabled', async () => {
774
+ const recentCheck = new Date();
573
775
  const workspace = {
574
- id: 'ws-stale-active',
575
- prUrl: 'https://github.com/example/repo/pull/9',
576
- prNumber: 9,
776
+ id: 'ws-superseded',
777
+ prUrl: 'https://github.com/example/repo/pull/42',
778
+ prNumber: 42,
577
779
  prState: 'OPEN',
578
780
  prCiStatus: CIStatus.UNKNOWN,
579
781
  ratchetEnabled: true,
580
- ratchetState: RatchetState.CI_FAILED,
581
- ratchetActiveSessionId: 'ratchet-session',
582
- ratchetLastCiRunId: 'previous-snapshot-key',
583
- prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
584
- };
585
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
586
- id: 'ratchet-session',
587
- provider: 'CLAUDE',
588
- status: SessionStatus.RUNNING,
589
- });
590
- vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(false);
591
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
592
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
593
- const action = await unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
594
- expect(action).toBeNull();
595
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
782
+ ratchetState: RatchetState.CI_RUNNING,
596
783
  ratchetActiveSessionId: null,
597
- ratchetLastCiRunId: null,
598
- });
599
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith(workspace.id, null);
600
- });
601
- it('resets dispatch tracking when ratchet session is not found in database', async () => {
602
- const workspace = {
603
- id: 'ws-missing-session',
604
- prUrl: 'https://github.com/example/repo/pull/12',
605
- prNumber: 12,
784
+ ratchetLastCiRunId: 'old-snapshot',
785
+ prReviewLastCheckedAt: recentCheck,
786
+ ratchetDispatchOutcome: null,
787
+ ratchetDispatchRetryCount: 0,
788
+ };
789
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
790
+ ciStatus: CIStatus.SUCCESS,
791
+ snapshotKey: 'new-snapshot',
792
+ hasChangesRequested: false,
793
+ latestReviewActivityAtMs: recentCheck.getTime() - 1000,
794
+ statusCheckRollup: null,
606
795
  prState: 'OPEN',
607
- prCiStatus: CIStatus.UNKNOWN,
796
+ prNumber: 42,
797
+ });
798
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(false);
799
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
800
+ id: 'ws-superseded',
608
801
  ratchetEnabled: true,
609
- ratchetState: RatchetState.CI_FAILED,
610
- ratchetActiveSessionId: 'deleted-session',
611
- ratchetLastCiRunId: 'previous-snapshot-key',
612
- prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
613
- };
614
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
615
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
616
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
617
- const action = await unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
618
- expect(action).toBeNull();
619
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
620
- ratchetActiveSessionId: null,
621
- ratchetLastCiRunId: null,
802
+ ratchetState: RatchetState.IDLE,
803
+ });
804
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
805
+ const events = [];
806
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
807
+ events.push(event);
808
+ });
809
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
810
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).toHaveBeenCalledWith('ws-superseded', RatchetState.CI_RUNNING, expect.objectContaining({ ratchetState: RatchetState.READY }));
811
+ expect(result).toMatchObject({
812
+ previousState: RatchetState.CI_RUNNING,
813
+ newState: RatchetState.CI_RUNNING,
814
+ action: {
815
+ type: 'WAITING',
816
+ reason: 'Ratchet state changed concurrently during this check; re-evaluating next cycle',
817
+ },
622
818
  });
623
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith(workspace.id, null);
819
+ expect(events).toHaveLength(0);
820
+ expect(mockSnapshotBridge.recordCIObservation).not.toHaveBeenCalled();
821
+ expect(mockSnapshotBridge.recordReviewCheck).not.toHaveBeenCalled();
624
822
  });
625
- it('does not reset dispatch tracking when ratchet session completed normally', async () => {
823
+ it('redispatches with an incremented retry count when the previous fixer died', async () => {
626
824
  const workspace = {
627
- id: 'ws-idle-active',
628
- prUrl: 'https://github.com/example/repo/pull/11',
629
- prNumber: 11,
825
+ id: 'ws-died-retry',
826
+ prUrl: 'https://github.com/example/repo/pull/13',
827
+ prNumber: 13,
630
828
  prState: 'OPEN',
631
- prCiStatus: CIStatus.UNKNOWN,
829
+ prCiStatus: CIStatus.FAILURE,
632
830
  ratchetEnabled: true,
633
831
  ratchetState: RatchetState.CI_FAILED,
634
- ratchetActiveSessionId: 'ratchet-session',
635
- ratchetLastCiRunId: 'snapshot-key',
832
+ ratchetActiveSessionId: null,
833
+ ratchetLastCiRunId: 'same-snapshot',
636
834
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
835
+ ratchetDispatchOutcome: 'DIED',
836
+ ratchetDispatchRetryCount: 1,
637
837
  };
638
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
639
- id: 'ratchet-session',
640
- provider: 'CLAUDE',
641
- status: SessionStatus.RUNNING,
838
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
839
+ ciStatus: CIStatus.FAILURE,
840
+ snapshotKey: 'same-snapshot',
841
+ hasChangesRequested: false,
842
+ latestReviewActivityAtMs: null,
843
+ statusCheckRollup: null,
844
+ prState: 'OPEN',
845
+ prNumber: 13,
642
846
  });
643
- vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
644
- vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
645
- vi.mocked(mockSessionBridge.stopSession).mockResolvedValue();
646
847
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
647
- const action = await unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
648
- expect(action).toBeNull();
649
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
650
- ratchetActiveSessionId: null,
848
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
849
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
850
+ status: 'started',
851
+ sessionId: 'retry-session',
852
+ promptSent: true,
853
+ });
854
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
855
+ expect(result).toMatchObject({
856
+ action: { type: 'TRIGGERED_FIXER' },
857
+ });
858
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalledWith('ws-died-retry', {
859
+ sessionId: 'retry-session',
860
+ snapshotKey: 'same-snapshot',
861
+ retryCount: 2,
651
862
  });
652
- expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
653
- expect(mockSnapshotBridge.recordReviewCheck).not.toHaveBeenCalled();
654
863
  });
655
- it('cleans up completed ratchet sessions before skipping clean PRs', async () => {
864
+ it('redispatches a died fixer even when the PR is otherwise clean', async () => {
865
+ const checkedAt = new Date('2026-01-02T00:00:00Z');
656
866
  const workspace = {
657
- id: 'ws-clean-active',
658
- prUrl: 'https://github.com/example/repo/pull/13',
659
- prNumber: 13,
867
+ id: 'ws-died-clean',
868
+ prUrl: 'https://github.com/example/repo/pull/14',
869
+ prNumber: 14,
660
870
  prState: 'OPEN',
661
871
  prCiStatus: CIStatus.SUCCESS,
662
872
  ratchetEnabled: true,
663
873
  ratchetState: RatchetState.READY,
664
- ratchetActiveSessionId: 'ratchet-session',
874
+ ratchetActiveSessionId: null,
665
875
  ratchetLastCiRunId: 'clean-snapshot',
666
- prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
876
+ prReviewLastCheckedAt: checkedAt,
877
+ ratchetDispatchOutcome: 'DIED',
878
+ ratchetDispatchRetryCount: 0,
667
879
  };
668
- const prStateInfo = {
880
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
669
881
  ciStatus: CIStatus.SUCCESS,
670
882
  snapshotKey: 'clean-snapshot',
671
883
  hasChangesRequested: false,
672
- latestReviewActivityAtMs: null,
884
+ latestReviewActivityAtMs: checkedAt.getTime() - 1000,
673
885
  statusCheckRollup: null,
674
886
  prState: 'OPEN',
675
- prNumber: 13,
887
+ prNumber: 14,
676
888
  reviewComments: [],
677
- };
678
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
679
- id: 'ratchet-session',
680
- provider: 'CLAUDE',
681
- status: SessionStatus.RUNNING,
682
889
  });
683
- vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
684
- vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
685
- vi.mocked(mockSessionBridge.stopSession).mockResolvedValue();
686
890
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
687
- const result = await unsafeCoerce(ratchetService).collectRatchetingActivityChecks(workspace, prStateInfo, true, false);
688
- expect(result).toEqual({
689
- activeRatchetSession: null,
690
- hasOtherActiveSession: false,
691
- });
692
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
693
- ratchetActiveSessionId: null,
694
- });
695
- expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
696
- expect(agentSessionAccessor.findByWorkspaceId).not.toHaveBeenCalled();
697
- });
698
- it('decides to trigger fixer when context is actionable', () => {
699
- const decision = unsafeCoerce(ratchetService).decideRatchetAction({
700
- workspace: { ratchetEnabled: true },
701
- prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
702
- isCleanPrWithNoNewReviewActivity: false,
703
- activeRatchetSession: null,
704
- hasStateChangedSinceLastDispatch: true,
705
- hasOtherActiveSession: false,
706
- });
707
- expect(decision).toEqual({ type: 'TRIGGER_FIXER' });
708
- });
709
- it('waits when CI is not in terminal state (PENDING)', () => {
710
- const decision = unsafeCoerce(ratchetService).decideRatchetAction({
711
- workspace: { ratchetEnabled: true },
712
- prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.PENDING },
713
- isCleanPrWithNoNewReviewActivity: false,
714
- activeRatchetSession: null,
715
- hasStateChangedSinceLastDispatch: true,
716
- hasOtherActiveSession: false,
717
- });
718
- expect(decision).toEqual({
719
- type: 'RETURN_ACTION',
720
- action: { type: 'WAITING', reason: 'Waiting for CI to complete (not in terminal state)' },
891
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
892
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
893
+ status: 'started',
894
+ sessionId: 'retry-session',
895
+ promptSent: true,
721
896
  });
722
- });
723
- it('waits when CI is not in terminal state (UNKNOWN)', () => {
724
- const decision = unsafeCoerce(ratchetService).decideRatchetAction({
725
- workspace: { ratchetEnabled: true },
726
- prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.UNKNOWN },
727
- isCleanPrWithNoNewReviewActivity: false,
728
- activeRatchetSession: null,
729
- hasStateChangedSinceLastDispatch: true,
730
- hasOtherActiveSession: false,
897
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
898
+ expect(result).toMatchObject({
899
+ action: { type: 'TRIGGERED_FIXER' },
731
900
  });
732
- expect(decision).toEqual({
733
- type: 'RETURN_ACTION',
734
- action: { type: 'WAITING', reason: 'Waiting for CI to complete (not in terminal state)' },
901
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalledWith('ws-died-clean', {
902
+ sessionId: 'retry-session',
903
+ snapshotKey: 'clean-snapshot',
904
+ retryCount: 1,
735
905
  });
736
906
  });
737
- it('prefers precomputed review-activity diagnostics from decision context', () => {
907
+ it('stops retrying a died fixer once the retry budget is exhausted', async () => {
738
908
  const workspace = {
739
- id: 'ws-log-diag',
740
- prUrl: 'https://github.com/example/repo/pull/10',
741
- prNumber: 10,
909
+ id: 'ws-died-exhausted',
910
+ prUrl: 'https://github.com/example/repo/pull/15',
911
+ prNumber: 15,
742
912
  prState: 'OPEN',
743
- prCiStatus: CIStatus.UNKNOWN,
913
+ prCiStatus: CIStatus.FAILURE,
744
914
  ratchetEnabled: true,
745
- ratchetState: RatchetState.READY,
915
+ ratchetState: RatchetState.CI_FAILED,
916
+ ratchetActiveSessionId: null,
917
+ ratchetLastCiRunId: 'same-snapshot',
918
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
919
+ ratchetDispatchOutcome: 'DIED',
920
+ ratchetDispatchRetryCount: 3,
921
+ };
922
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
923
+ ciStatus: CIStatus.FAILURE,
924
+ snapshotKey: 'same-snapshot',
925
+ hasChangesRequested: false,
926
+ latestReviewActivityAtMs: null,
927
+ statusCheckRollup: null,
928
+ prState: 'OPEN',
929
+ prNumber: 15,
930
+ });
931
+ vi.mocked(workspaceAccessor.update).mockResolvedValue({});
932
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
933
+ expect(result).toMatchObject({
934
+ action: {
935
+ type: 'WAITING',
936
+ reason: 'Fixer died 4 times for this PR state; waiting for PR state to change',
937
+ },
938
+ });
939
+ expect(fixerSessionService.acquireAndDispatch).not.toHaveBeenCalled();
940
+ });
941
+ it('resets the retry count when the PR state changes after a death', async () => {
942
+ const workspace = {
943
+ id: 'ws-died-changed',
944
+ prUrl: 'https://github.com/example/repo/pull/16',
945
+ prNumber: 16,
946
+ prState: 'OPEN',
947
+ prCiStatus: CIStatus.FAILURE,
948
+ ratchetEnabled: true,
949
+ ratchetState: RatchetState.CI_FAILED,
950
+ ratchetActiveSessionId: null,
951
+ ratchetLastCiRunId: 'old-snapshot',
952
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
953
+ ratchetDispatchOutcome: 'DIED',
954
+ ratchetDispatchRetryCount: 3,
955
+ };
956
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
957
+ ciStatus: CIStatus.FAILURE,
958
+ snapshotKey: 'new-snapshot',
959
+ hasChangesRequested: false,
960
+ latestReviewActivityAtMs: null,
961
+ statusCheckRollup: null,
962
+ prState: 'OPEN',
963
+ prNumber: 16,
964
+ });
965
+ vi.mocked(workspaceAccessor.update).mockResolvedValue({});
966
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
967
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
968
+ status: 'started',
969
+ sessionId: 'fresh-session',
970
+ promptSent: true,
971
+ });
972
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
973
+ expect(result).toMatchObject({
974
+ action: { type: 'TRIGGERED_FIXER' },
975
+ });
976
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalledWith('ws-died-changed', { sessionId: 'fresh-session', snapshotKey: 'new-snapshot', retryCount: 0 });
977
+ });
978
+ it('does not redispatch when the previous fixer completed and PR state is unchanged', async () => {
979
+ const workspace = {
980
+ id: 'ws-completed-unchanged',
981
+ prUrl: 'https://github.com/example/repo/pull/17',
982
+ prNumber: 17,
983
+ prState: 'OPEN',
984
+ prCiStatus: CIStatus.FAILURE,
985
+ ratchetEnabled: true,
986
+ ratchetState: RatchetState.CI_FAILED,
987
+ ratchetActiveSessionId: null,
988
+ ratchetLastCiRunId: 'same-snapshot',
989
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
990
+ ratchetDispatchOutcome: 'COMPLETED',
991
+ ratchetDispatchRetryCount: 2,
992
+ };
993
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
994
+ ciStatus: CIStatus.FAILURE,
995
+ snapshotKey: 'same-snapshot',
996
+ hasChangesRequested: false,
997
+ latestReviewActivityAtMs: null,
998
+ statusCheckRollup: null,
999
+ prState: 'OPEN',
1000
+ prNumber: 17,
1001
+ });
1002
+ vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1003
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
1004
+ expect(result).toMatchObject({
1005
+ action: {
1006
+ type: 'WAITING',
1007
+ reason: 'PR state unchanged since last ratchet dispatch',
1008
+ },
1009
+ });
1010
+ expect(fixerSessionService.acquireAndDispatch).not.toHaveBeenCalled();
1011
+ });
1012
+ it('settles the dispatch as DIED when ratchet session process is not running', async () => {
1013
+ const workspace = {
1014
+ id: 'ws-stale-active',
1015
+ prUrl: 'https://github.com/example/repo/pull/9',
1016
+ prNumber: 9,
1017
+ prState: 'OPEN',
1018
+ prCiStatus: CIStatus.UNKNOWN,
1019
+ ratchetEnabled: true,
1020
+ ratchetState: RatchetState.CI_FAILED,
1021
+ ratchetActiveSessionId: 'ratchet-session',
1022
+ ratchetLastCiRunId: 'previous-snapshot-key',
1023
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
1024
+ ratchetDispatchOutcome: 'RUNNING',
1025
+ ratchetDispatchRetryCount: 0,
1026
+ };
1027
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
1028
+ id: 'ratchet-session',
1029
+ provider: 'CLAUDE',
1030
+ status: SessionStatus.RUNNING,
1031
+ });
1032
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(false);
1033
+ const result = await unsafeCoerce(ratchetService).checkActiveFixerSession(workspace);
1034
+ expect(result).toEqual({ kind: 'settled', outcome: 'DIED' });
1035
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith(workspace.id, 'ratchet-session', 'DIED');
1036
+ });
1037
+ it('settles the dispatch as DIED when ratchet session is not found in database', async () => {
1038
+ const workspace = {
1039
+ id: 'ws-missing-session',
1040
+ prUrl: 'https://github.com/example/repo/pull/12',
1041
+ prNumber: 12,
1042
+ prState: 'OPEN',
1043
+ prCiStatus: CIStatus.UNKNOWN,
1044
+ ratchetEnabled: true,
1045
+ ratchetState: RatchetState.CI_FAILED,
1046
+ ratchetActiveSessionId: 'deleted-session',
1047
+ ratchetLastCiRunId: 'previous-snapshot-key',
1048
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
1049
+ ratchetDispatchOutcome: 'RUNNING',
1050
+ ratchetDispatchRetryCount: 0,
1051
+ };
1052
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
1053
+ const result = await unsafeCoerce(ratchetService).checkActiveFixerSession(workspace);
1054
+ expect(result).toEqual({ kind: 'settled', outcome: 'DIED' });
1055
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith(workspace.id, 'deleted-session', 'DIED');
1056
+ });
1057
+ it('reports a concurrent end when another path already settled the dispatch', async () => {
1058
+ const workspace = {
1059
+ id: 'ws-concurrent-end',
1060
+ prUrl: 'https://github.com/example/repo/pull/12',
1061
+ prNumber: 12,
1062
+ prState: 'OPEN',
1063
+ prCiStatus: CIStatus.UNKNOWN,
1064
+ ratchetEnabled: true,
1065
+ ratchetState: RatchetState.CI_FAILED,
1066
+ ratchetActiveSessionId: 'finished-session',
1067
+ ratchetLastCiRunId: 'previous-snapshot-key',
1068
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
1069
+ ratchetDispatchOutcome: 'RUNNING',
1070
+ ratchetDispatchRetryCount: 0,
1071
+ };
1072
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
1073
+ vi.mocked(workspaceAccessor.recordRatchetSessionEnd).mockResolvedValue(false);
1074
+ const result = await unsafeCoerce(ratchetService).checkActiveFixerSession(workspace);
1075
+ expect(result).toEqual({ kind: 'ended_concurrently' });
1076
+ });
1077
+ it('waits for the next cycle when the fixer ends concurrently mid-check', async () => {
1078
+ const workspace = {
1079
+ id: 'ws-concurrent-wait',
1080
+ prUrl: 'https://github.com/example/repo/pull/12',
1081
+ prNumber: 12,
1082
+ prState: 'OPEN',
1083
+ prCiStatus: CIStatus.UNKNOWN,
1084
+ ratchetEnabled: true,
1085
+ ratchetState: RatchetState.CI_FAILED,
1086
+ ratchetActiveSessionId: 'finished-session',
1087
+ ratchetLastCiRunId: 'previous-snapshot-key',
1088
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
1089
+ ratchetDispatchOutcome: 'RUNNING',
1090
+ ratchetDispatchRetryCount: 0,
1091
+ };
1092
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
1093
+ ciStatus: CIStatus.FAILURE,
1094
+ snapshotKey: 'new-snapshot',
1095
+ hasChangesRequested: false,
1096
+ latestReviewActivityAtMs: null,
1097
+ statusCheckRollup: null,
1098
+ prState: 'OPEN',
1099
+ prNumber: 12,
1100
+ });
1101
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
1102
+ vi.mocked(workspaceAccessor.recordRatchetSessionEnd).mockResolvedValue(false);
1103
+ vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1104
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
1105
+ expect(result).toMatchObject({
1106
+ action: {
1107
+ type: 'WAITING',
1108
+ reason: 'Fixer session ended during this check; re-evaluating next cycle',
1109
+ },
1110
+ });
1111
+ expect(fixerSessionService.acquireAndDispatch).not.toHaveBeenCalled();
1112
+ });
1113
+ it('settles the dispatch as COMPLETED when ratchet session finished its work', async () => {
1114
+ const workspace = {
1115
+ id: 'ws-idle-active',
1116
+ prUrl: 'https://github.com/example/repo/pull/11',
1117
+ prNumber: 11,
1118
+ prState: 'OPEN',
1119
+ prCiStatus: CIStatus.UNKNOWN,
1120
+ ratchetEnabled: true,
1121
+ ratchetState: RatchetState.CI_FAILED,
1122
+ ratchetActiveSessionId: 'ratchet-session',
1123
+ ratchetLastCiRunId: 'snapshot-key',
1124
+ prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
1125
+ ratchetDispatchOutcome: 'RUNNING',
1126
+ ratchetDispatchRetryCount: 0,
1127
+ };
1128
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
1129
+ id: 'ratchet-session',
1130
+ provider: 'CLAUDE',
1131
+ status: SessionStatus.RUNNING,
1132
+ });
1133
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
1134
+ vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
1135
+ vi.mocked(mockSessionBridge.stopSession).mockResolvedValue();
1136
+ const result = await unsafeCoerce(ratchetService).checkActiveFixerSession(workspace);
1137
+ expect(result).toEqual({ kind: 'settled', outcome: 'COMPLETED' });
1138
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith(workspace.id, 'ratchet-session', 'COMPLETED');
1139
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
1140
+ });
1141
+ it('decides to trigger fixer when context is actionable', async () => {
1142
+ const decision = await unsafeCoerce(ratchetService).decideRatchetAction({
1143
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1144
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
1145
+ isCleanPrWithNoNewReviewActivity: false,
1146
+ activeFixerCheck: { kind: 'none' },
1147
+ dispatchOutcome: null,
1148
+ dispatchRetryCount: 0,
1149
+ hasStateChangedSinceLastDispatch: true,
1150
+ });
1151
+ expect(decision).toEqual({ type: 'TRIGGER_FIXER', retryCount: 0 });
1152
+ });
1153
+ it('waits when CI is not in terminal state (PENDING)', async () => {
1154
+ const decision = await unsafeCoerce(ratchetService).decideRatchetAction({
1155
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1156
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.PENDING },
1157
+ isCleanPrWithNoNewReviewActivity: false,
1158
+ activeFixerCheck: { kind: 'none' },
1159
+ dispatchOutcome: null,
1160
+ dispatchRetryCount: 0,
1161
+ hasStateChangedSinceLastDispatch: true,
1162
+ });
1163
+ expect(decision).toEqual({
1164
+ type: 'RETURN_ACTION',
1165
+ action: { type: 'WAITING', reason: 'Waiting for CI to complete (not in terminal state)' },
1166
+ });
1167
+ });
1168
+ it('waits when CI is not in terminal state (UNKNOWN)', async () => {
1169
+ const decision = await unsafeCoerce(ratchetService).decideRatchetAction({
1170
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1171
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.UNKNOWN },
1172
+ isCleanPrWithNoNewReviewActivity: false,
1173
+ activeFixerCheck: { kind: 'none' },
1174
+ dispatchOutcome: null,
1175
+ dispatchRetryCount: 0,
1176
+ hasStateChangedSinceLastDispatch: true,
1177
+ });
1178
+ expect(decision).toEqual({
1179
+ type: 'RETURN_ACTION',
1180
+ action: { type: 'WAITING', reason: 'Waiting for CI to complete (not in terminal state)' },
1181
+ });
1182
+ });
1183
+ it('prefers precomputed review-activity diagnostics from decision context', () => {
1184
+ const workspace = {
1185
+ id: 'ws-log-diag',
1186
+ prUrl: 'https://github.com/example/repo/pull/10',
1187
+ prNumber: 10,
1188
+ prState: 'OPEN',
1189
+ prCiStatus: CIStatus.UNKNOWN,
1190
+ ratchetEnabled: true,
1191
+ ratchetState: RatchetState.READY,
746
1192
  ratchetActiveSessionId: null,
747
1193
  ratchetLastCiRunId: 'prev-snapshot',
748
1194
  prReviewLastCheckedAt: null,
1195
+ ratchetDispatchOutcome: null,
1196
+ ratchetDispatchRetryCount: 0,
749
1197
  };
750
1198
  const prStateInfo = {
751
1199
  ciStatus: CIStatus.SUCCESS,
@@ -756,11 +1204,19 @@ describe('ratchet service (state-change + idle dispatch)', () => {
756
1204
  prState: 'OPEN',
757
1205
  prNumber: 10,
758
1206
  };
759
- const logContext = unsafeCoerce(ratchetService).buildRatchetingLogContext(workspace, RatchetState.READY, RatchetState.READY, { type: 'WAITING', reason: 'noop' }, prStateInfo, 10, { hasNewReviewActivitySinceLastDispatch: true });
760
- expect(logContext.reviewTimestampComparison.hasNewReviewActivitySinceLastDispatch).toBe(true);
1207
+ const logContext = buildRatchetingLogContext({
1208
+ workspace: workspace,
1209
+ previousState: RatchetState.READY,
1210
+ newState: RatchetState.READY,
1211
+ action: { type: 'WAITING', reason: 'noop' },
1212
+ prStateInfo: prStateInfo,
1213
+ prNumber: 10,
1214
+ decisionContext: { hasNewReviewActivitySinceLastDispatch: true },
1215
+ });
1216
+ expect(logContext.reviewTimestampComparison?.hasNewReviewActivitySinceLastDispatch).toBe(true);
761
1217
  });
762
1218
  it('ignores review activity authored by the authenticated user', () => {
763
- const latestActivity = unsafeCoerce(ratchetService).computeLatestReviewActivityAtMs({
1219
+ const latestActivity = computeLatestReviewActivityAtMs({
764
1220
  reviews: [
765
1221
  { submittedAt: '2026-01-02T00:00:00Z', author: { login: 'ratchet-bot' } },
766
1222
  { submittedAt: '2026-01-01T00:00:00Z', author: { login: 'reviewer' } },
@@ -770,7 +1226,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
770
1226
  expect(latestActivity).toBe(new Date('2026-01-01T02:00:00Z').getTime());
771
1227
  });
772
1228
  describe('determineRatchetState', () => {
773
- const callDetermineRatchetState = (pr) => unsafeCoerce(ratchetService).determineRatchetState(pr);
1229
+ const callDetermineRatchetState = (pr) => determineRatchetState(pr);
774
1230
  it('returns MERGED for merged PR', () => {
775
1231
  expect(callDetermineRatchetState({
776
1232
  ciStatus: CIStatus.SUCCESS,
@@ -829,7 +1285,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
829
1285
  });
830
1286
  });
831
1287
  describe('computeDispatchSnapshotKey', () => {
832
- const callComputeSnapshotKey = (ciStatus, hasChangesRequested, latestReviewActivityAtMs, statusChecks) => unsafeCoerce(ratchetService).computeDispatchSnapshotKey(ciStatus, hasChangesRequested, latestReviewActivityAtMs, statusChecks);
1288
+ const callComputeSnapshotKey = (ciStatus, hasChangesRequested, latestReviewActivityAtMs, statusChecks) => computeDispatchSnapshotKey(ciStatus, hasChangesRequested, latestReviewActivityAtMs, statusChecks);
833
1289
  it('includes CI status in key for non-failure states', () => {
834
1290
  const key = callComputeSnapshotKey(CIStatus.SUCCESS, false, null, null);
835
1291
  expect(key).toContain('ci:SUCCESS');
@@ -873,7 +1329,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
873
1329
  });
874
1330
  });
875
1331
  describe('computeLatestReviewActivityAtMs edge cases', () => {
876
- const callCompute = (prDetails, reviewComments, authenticatedUsername) => unsafeCoerce(ratchetService).computeLatestReviewActivityAtMs(prDetails, reviewComments, authenticatedUsername);
1332
+ const callCompute = (prDetails, reviewComments, authenticatedUsername) => computeLatestReviewActivityAtMs(prDetails, reviewComments, authenticatedUsername);
877
1333
  it('returns null when there are no reviews or comments', () => {
878
1334
  expect(callCompute({ reviews: [], comments: [] }, [], null)).toBeNull();
879
1335
  });
@@ -916,6 +1372,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
916
1372
  ratchetActiveSessionId: null,
917
1373
  ratchetLastCiRunId: null,
918
1374
  prReviewLastCheckedAt: null,
1375
+ ratchetDispatchOutcome: null,
1376
+ ratchetDispatchRetryCount: 0,
919
1377
  };
920
1378
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(null);
921
1379
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
@@ -925,6 +1383,27 @@ describe('ratchet service (state-change + idle dispatch)', () => {
925
1383
  newState: RatchetState.IDLE,
926
1384
  });
927
1385
  });
1386
+ it('returns WAITING action when fetchPRState skips a recent fetch', async () => {
1387
+ const workspace = {
1388
+ id: 'ws-recent-fetch',
1389
+ prUrl: 'https://github.com/example/repo/pull/99',
1390
+ prNumber: 99,
1391
+ ratchetEnabled: true,
1392
+ ratchetState: RatchetState.IDLE,
1393
+ ratchetActiveSessionId: null,
1394
+ ratchetLastCiRunId: null,
1395
+ prReviewLastCheckedAt: null,
1396
+ ratchetDispatchOutcome: null,
1397
+ ratchetDispatchRetryCount: 0,
1398
+ };
1399
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({ skipped: true, reason: 'recently_fetched' });
1400
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
1401
+ expect(result).toMatchObject({
1402
+ action: { type: 'WAITING', reason: 'recently_fetched' },
1403
+ newState: RatchetState.IDLE,
1404
+ });
1405
+ expect(workspaceAccessor.update).not.toHaveBeenCalled();
1406
+ });
928
1407
  it('returns WAITING when shutting down', async () => {
929
1408
  unsafeCoerce(ratchetService).isShuttingDown = true;
930
1409
  const workspace = {
@@ -936,6 +1415,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
936
1415
  ratchetActiveSessionId: null,
937
1416
  ratchetLastCiRunId: null,
938
1417
  prReviewLastCheckedAt: null,
1418
+ ratchetDispatchOutcome: null,
1419
+ ratchetDispatchRetryCount: 0,
939
1420
  };
940
1421
  const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
941
1422
  expect(result).toMatchObject({
@@ -954,6 +1435,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
954
1435
  ratchetActiveSessionId: null,
955
1436
  ratchetLastCiRunId: null,
956
1437
  prReviewLastCheckedAt: null,
1438
+ ratchetDispatchOutcome: null,
1439
+ ratchetDispatchRetryCount: 0,
957
1440
  };
958
1441
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockRejectedValue(new Error('Unexpected explosion'));
959
1442
  const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
@@ -991,6 +1474,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
991
1474
  ratchetActiveSessionId: null,
992
1475
  ratchetLastCiRunId: null,
993
1476
  prReviewLastCheckedAt: null,
1477
+ ratchetDispatchOutcome: null,
1478
+ ratchetDispatchRetryCount: 0,
994
1479
  },
995
1480
  {
996
1481
  id: 'ws-fast',
@@ -1005,11 +1490,14 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1005
1490
  ratchetActiveSessionId: null,
1006
1491
  ratchetLastCiRunId: null,
1007
1492
  prReviewLastCheckedAt: null,
1493
+ ratchetDispatchOutcome: null,
1494
+ ratchetDispatchRetryCount: 0,
1008
1495
  },
1009
1496
  ]);
1010
- vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation((workspace) => {
1497
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation((workspace, _opts, signal) => {
1011
1498
  if (workspace.id === 'ws-timeout') {
1012
- return new Promise(() => {
1499
+ return new Promise((_resolve, reject) => {
1500
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
1013
1501
  });
1014
1502
  }
1015
1503
  return Promise.resolve({
@@ -1035,41 +1523,12 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1035
1523
  }),
1036
1524
  ]));
1037
1525
  });
1038
- });
1039
- describe('shutdown behavior', () => {
1040
- it('stops immediately while monitor loop is sleeping', async () => {
1041
- vi.useFakeTimers();
1042
- vi.spyOn(ratchetService, 'checkAllWorkspaces').mockResolvedValue({
1043
- checked: 0,
1044
- stateChanges: 0,
1045
- actionsTriggered: 0,
1046
- results: [],
1047
- });
1048
- ratchetService.start();
1049
- await Promise.resolve();
1050
- await Promise.resolve();
1051
- await expect(ratchetService.stop()).resolves.toBeUndefined();
1052
- vi.useRealTimers();
1053
- });
1054
- });
1055
- describe('checkWorkspaceById', () => {
1056
- it('returns null when shutting down', async () => {
1057
- unsafeCoerce(ratchetService).isShuttingDown = true;
1058
- const result = await ratchetService.checkWorkspaceById('ws-1');
1059
- expect(result).toBeNull();
1060
- });
1061
- it('returns null when workspace not found', async () => {
1062
- unsafeCoerce(ratchetService).isShuttingDown = false;
1063
- vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(null);
1064
- const result = await ratchetService.checkWorkspaceById('ws-nonexistent');
1065
- expect(result).toBeNull();
1066
- });
1067
- it('deduplicates concurrent checks for the same workspace', async () => {
1068
- unsafeCoerce(ratchetService).isShuttingDown = false;
1069
- const workspace = {
1070
- id: 'ws-1',
1071
- prUrl: 'https://github.com/example/repo/pull/1',
1072
- prNumber: 1,
1526
+ it('does not count concurrency queue wait toward the workspace timeout', async () => {
1527
+ unsafeCoerce(ratchetService).workspaceCheckTimeoutMs = 5;
1528
+ const workspaces = Array.from({ length: 4 }, (_, index) => ({
1529
+ id: `ws-queued-timeout-${index}`,
1530
+ prUrl: `https://github.com/example/repo/pull/${index + 1}`,
1531
+ prNumber: index + 1,
1073
1532
  prState: 'OPEN',
1074
1533
  prCiStatus: CIStatus.UNKNOWN,
1075
1534
  defaultSessionProvider: 'WORKSPACE_DEFAULT',
@@ -1079,13 +1538,442 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1079
1538
  ratchetActiveSessionId: null,
1080
1539
  ratchetLastCiRunId: null,
1081
1540
  prReviewLastCheckedAt: null,
1082
- };
1083
- vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1084
- let releaseCheck;
1085
- const checkBarrier = new Promise((resolve) => {
1086
- releaseCheck = () => resolve();
1087
- });
1088
- const result = {
1541
+ ratchetDispatchOutcome: null,
1542
+ ratchetDispatchRetryCount: 0,
1543
+ }));
1544
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue(workspaces);
1545
+ const processWorkspaceSpy = vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace');
1546
+ processWorkspaceSpy.mockImplementation((workspace, _opts, signal) => {
1547
+ if (workspace.id !== 'ws-queued-timeout-3') {
1548
+ return new Promise((_resolve, reject) => {
1549
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
1550
+ });
1551
+ }
1552
+ return Promise.resolve({
1553
+ workspaceId: workspace.id,
1554
+ previousState: RatchetState.IDLE,
1555
+ newState: RatchetState.IDLE,
1556
+ action: { type: 'WAITING', reason: 'ran after queue' },
1557
+ });
1558
+ });
1559
+ const result = await ratchetService.checkAllWorkspaces();
1560
+ expect(processWorkspaceSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'ws-queued-timeout-3' }), undefined, expect.any(AbortSignal), expect.any(Function));
1561
+ expect(result.results[3]).toMatchObject({
1562
+ workspaceId: 'ws-queued-timeout-3',
1563
+ action: { type: 'WAITING', reason: 'ran after queue' },
1564
+ });
1565
+ });
1566
+ it('lets a committed dispatch finish instead of reporting a timeout error', async () => {
1567
+ unsafeCoerce(ratchetService).workspaceCheckTimeoutMs = 5;
1568
+ const workspace = {
1569
+ id: 'ws-committed-dispatch',
1570
+ prUrl: 'https://github.com/example/repo/pull/1',
1571
+ prNumber: 1,
1572
+ ratchetEnabled: true,
1573
+ ratchetState: RatchetState.IDLE,
1574
+ };
1575
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue([workspace]);
1576
+ let finishCommit;
1577
+ const commitBarrier = new Promise((resolve) => {
1578
+ finishCommit = resolve;
1579
+ });
1580
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation(async (workspaceArg, _opts, signal, commitSideEffects) => {
1581
+ commitSideEffects();
1582
+ await commitBarrier;
1583
+ signal.throwIfAborted();
1584
+ return {
1585
+ workspaceId: workspaceArg.id,
1586
+ previousState: RatchetState.IDLE,
1587
+ newState: RatchetState.IDLE,
1588
+ action: { type: 'TRIGGERED_FIXER', sessionId: 'session-1', promptSent: true },
1589
+ };
1590
+ });
1591
+ const resultPromise = ratchetService.checkAllWorkspaces();
1592
+ await new Promise((resolve) => setTimeout(resolve, 10));
1593
+ finishCommit();
1594
+ const result = await resultPromise;
1595
+ expect(result.results[0]).toMatchObject({
1596
+ workspaceId: workspace.id,
1597
+ action: { type: 'TRIGGERED_FIXER', sessionId: 'session-1' },
1598
+ });
1599
+ });
1600
+ it('runs at most three workspace checks concurrently', async () => {
1601
+ const workspaces = Array.from({ length: 7 }, (_, index) => ({
1602
+ id: `ws-${index}`,
1603
+ prUrl: `https://github.com/example/repo/pull/${index + 1}`,
1604
+ prNumber: index + 1,
1605
+ prState: 'OPEN',
1606
+ prCiStatus: CIStatus.UNKNOWN,
1607
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
1608
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1609
+ ratchetEnabled: true,
1610
+ ratchetState: RatchetState.IDLE,
1611
+ ratchetActiveSessionId: null,
1612
+ ratchetLastCiRunId: null,
1613
+ prReviewLastCheckedAt: null,
1614
+ ratchetDispatchOutcome: null,
1615
+ ratchetDispatchRetryCount: 0,
1616
+ }));
1617
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue(workspaces);
1618
+ let active = 0;
1619
+ let maximumActive = 0;
1620
+ let releaseChecks;
1621
+ const checkBarrier = new Promise((resolve) => {
1622
+ releaseChecks = resolve;
1623
+ });
1624
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation(async (workspace) => {
1625
+ active += 1;
1626
+ maximumActive = Math.max(maximumActive, active);
1627
+ await checkBarrier;
1628
+ active -= 1;
1629
+ return {
1630
+ workspaceId: workspace.id,
1631
+ previousState: RatchetState.IDLE,
1632
+ newState: RatchetState.IDLE,
1633
+ action: { type: 'WAITING', reason: 'noop' },
1634
+ };
1635
+ });
1636
+ const resultPromise = ratchetService.checkAllWorkspaces();
1637
+ await vi.waitFor(() => expect(active).toBe(3));
1638
+ expect(maximumActive).toBe(3);
1639
+ releaseChecks();
1640
+ const result = await resultPromise;
1641
+ expect(maximumActive).toBe(3);
1642
+ expect(result.checked).toBe(7);
1643
+ expect(result.results).toHaveLength(7);
1644
+ });
1645
+ });
1646
+ describe('shutdown behavior', () => {
1647
+ it('stops immediately while monitor loop is sleeping', async () => {
1648
+ vi.useFakeTimers();
1649
+ vi.spyOn(ratchetService, 'checkAllWorkspaces').mockResolvedValue({
1650
+ checked: 0,
1651
+ stateChanges: 0,
1652
+ actionsTriggered: 0,
1653
+ results: [],
1654
+ });
1655
+ ratchetService.start();
1656
+ await Promise.resolve();
1657
+ await Promise.resolve();
1658
+ await expect(ratchetService.stop()).resolves.toBeUndefined();
1659
+ vi.useRealTimers();
1660
+ });
1661
+ });
1662
+ describe('markPrClosed', () => {
1663
+ afterEach(() => {
1664
+ ratchetService.removeAllListeners();
1665
+ });
1666
+ it('resets ratchet state to IDLE via CAS on fromState and emits a state change', async () => {
1667
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
1668
+ id: 'ws-closed',
1669
+ ratchetEnabled: true,
1670
+ ratchetState: RatchetState.CI_FAILED,
1671
+ });
1672
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(true);
1673
+ const stateEvents = [];
1674
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
1675
+ stateEvents.push(event);
1676
+ });
1677
+ await ratchetService.markPrClosed('ws-closed');
1678
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).toHaveBeenCalledWith('ws-closed', RatchetState.CI_FAILED, {
1679
+ ratchetState: RatchetState.IDLE,
1680
+ ratchetLastCheckedAt: expect.any(Date),
1681
+ });
1682
+ expect(stateEvents).toEqual([
1683
+ {
1684
+ workspaceId: 'ws-closed',
1685
+ fromState: RatchetState.CI_FAILED,
1686
+ toState: RatchetState.IDLE,
1687
+ },
1688
+ ]);
1689
+ });
1690
+ it('emits the fromState that actually won the compare-and-swap after losing a race', async () => {
1691
+ vi.mocked(workspaceAccessor.findById)
1692
+ .mockResolvedValueOnce({
1693
+ id: 'ws-closed',
1694
+ ratchetEnabled: true,
1695
+ ratchetState: RatchetState.CI_FAILED,
1696
+ })
1697
+ .mockResolvedValueOnce({
1698
+ id: 'ws-closed',
1699
+ ratchetEnabled: true,
1700
+ ratchetState: RatchetState.CI_RUNNING,
1701
+ });
1702
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled)
1703
+ .mockResolvedValueOnce(false)
1704
+ .mockResolvedValueOnce(true);
1705
+ const stateEvents = [];
1706
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
1707
+ stateEvents.push(event);
1708
+ });
1709
+ await ratchetService.markPrClosed('ws-closed');
1710
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).toHaveBeenNthCalledWith(1, 'ws-closed', RatchetState.CI_FAILED, expect.objectContaining({ ratchetState: RatchetState.IDLE }));
1711
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).toHaveBeenNthCalledWith(2, 'ws-closed', RatchetState.CI_RUNNING, expect.objectContaining({ ratchetState: RatchetState.IDLE }));
1712
+ expect(stateEvents).toEqual([
1713
+ {
1714
+ workspaceId: 'ws-closed',
1715
+ fromState: RatchetState.CI_RUNNING,
1716
+ toState: RatchetState.IDLE,
1717
+ },
1718
+ ]);
1719
+ });
1720
+ it('is a no-op when ratchet state is already IDLE', async () => {
1721
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
1722
+ id: 'ws-closed',
1723
+ ratchetEnabled: true,
1724
+ ratchetState: RatchetState.IDLE,
1725
+ });
1726
+ await ratchetService.markPrClosed('ws-closed');
1727
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).not.toHaveBeenCalled();
1728
+ });
1729
+ it('does not emit when ratcheting was disabled concurrently', async () => {
1730
+ vi.mocked(workspaceAccessor.findById)
1731
+ .mockResolvedValueOnce({
1732
+ id: 'ws-closed',
1733
+ ratchetEnabled: true,
1734
+ ratchetState: RatchetState.CI_FAILED,
1735
+ })
1736
+ .mockResolvedValueOnce({
1737
+ id: 'ws-closed',
1738
+ ratchetEnabled: false,
1739
+ ratchetState: RatchetState.IDLE,
1740
+ });
1741
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(false);
1742
+ const stateEvents = [];
1743
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
1744
+ stateEvents.push(event);
1745
+ });
1746
+ await ratchetService.markPrClosed('ws-closed');
1747
+ expect(stateEvents).toEqual([]);
1748
+ });
1749
+ it('is a no-op when workspace is not found', async () => {
1750
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue(null);
1751
+ await ratchetService.markPrClosed('ws-missing');
1752
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).not.toHaveBeenCalled();
1753
+ });
1754
+ it('does not continue to side effects after a timed-out await completes', async () => {
1755
+ unsafeCoerce(ratchetService).workspaceCheckTimeoutMs = 5;
1756
+ const workspace = {
1757
+ id: 'ws-zombie',
1758
+ prUrl: 'https://github.com/example/repo/pull/1',
1759
+ prNumber: 1,
1760
+ prState: 'OPEN',
1761
+ prCiStatus: CIStatus.FAILURE,
1762
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
1763
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1764
+ ratchetEnabled: true,
1765
+ ratchetState: RatchetState.IDLE,
1766
+ ratchetActiveSessionId: null,
1767
+ ratchetLastCiRunId: null,
1768
+ prReviewLastCheckedAt: null,
1769
+ ratchetDispatchOutcome: null,
1770
+ ratchetDispatchRetryCount: 0,
1771
+ };
1772
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue([workspace]);
1773
+ let releaseFetch;
1774
+ const fetchBarrier = new Promise((resolve) => {
1775
+ releaseFetch = resolve;
1776
+ });
1777
+ const finishSpy = vi.spyOn(unsafeCoerce(ratchetService), 'finishRatchetCheck');
1778
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockImplementation(async () => {
1779
+ await fetchBarrier;
1780
+ return {
1781
+ ciStatus: CIStatus.FAILURE,
1782
+ snapshotKey: 'failed:1',
1783
+ hasChangesRequested: false,
1784
+ hasMergeConflict: false,
1785
+ latestReviewActivityAtMs: null,
1786
+ statusCheckRollup: [],
1787
+ prState: 'OPEN',
1788
+ prNumber: 1,
1789
+ reviewComments: [],
1790
+ };
1791
+ });
1792
+ const result = await ratchetService.checkAllWorkspaces();
1793
+ expect(result.results[0]?.action.type).toBe('ERROR');
1794
+ releaseFetch();
1795
+ await new Promise((resolve) => setTimeout(resolve, 0));
1796
+ expect(finishSpy).not.toHaveBeenCalled();
1797
+ expect(workspaceAccessor.transitionRatchetStateIfEnabled).not.toHaveBeenCalled();
1798
+ });
1799
+ });
1800
+ describe('checkWorkspaceById', () => {
1801
+ it('returns null when shutting down', async () => {
1802
+ unsafeCoerce(ratchetService).isShuttingDown = true;
1803
+ const result = await ratchetService.checkWorkspaceById('ws-1');
1804
+ expect(result).toBeNull();
1805
+ });
1806
+ it('returns null when workspace not found', async () => {
1807
+ unsafeCoerce(ratchetService).isShuttingDown = false;
1808
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(null);
1809
+ const result = await ratchetService.checkWorkspaceById('ws-nonexistent');
1810
+ expect(result).toBeNull();
1811
+ });
1812
+ it('skips the PR fetch when the workspace was recently fetched', async () => {
1813
+ const workspace = {
1814
+ id: 'ws-cooldown',
1815
+ prUrl: 'https://github.com/example/repo/pull/9',
1816
+ prNumber: 9,
1817
+ prState: 'OPEN',
1818
+ prCiStatus: CIStatus.UNKNOWN,
1819
+ ratchetEnabled: true,
1820
+ ratchetState: RatchetState.CI_RUNNING,
1821
+ ratchetActiveSessionId: null,
1822
+ ratchetLastCiRunId: null,
1823
+ prReviewLastCheckedAt: null,
1824
+ ratchetDispatchOutcome: null,
1825
+ ratchetDispatchRetryCount: 0,
1826
+ };
1827
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1828
+ vi.mocked(mockGitHubBridge.extractPRInfo).mockReturnValue({
1829
+ owner: 'example',
1830
+ repo: 'repo',
1831
+ number: 9,
1832
+ });
1833
+ vi.mocked(mockGitHubBridge.isRecentlyFetched).mockReturnValue(true);
1834
+ const result = await ratchetService.checkWorkspaceById('ws-cooldown');
1835
+ expect(result?.action).toEqual({ type: 'WAITING', reason: 'recently_fetched' });
1836
+ expect(mockGitHubBridge.getPRFullDetails).not.toHaveBeenCalled();
1837
+ });
1838
+ it('bypasses the PR fetch cooldown when requested by an event-driven check', async () => {
1839
+ const workspace = {
1840
+ id: 'ws-bypass',
1841
+ prUrl: 'https://github.com/example/repo/pull/9',
1842
+ prNumber: 9,
1843
+ prState: 'OPEN',
1844
+ prCiStatus: CIStatus.UNKNOWN,
1845
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
1846
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1847
+ ratchetEnabled: true,
1848
+ ratchetState: RatchetState.CI_RUNNING,
1849
+ ratchetActiveSessionId: null,
1850
+ ratchetLastCiRunId: null,
1851
+ prReviewLastCheckedAt: null,
1852
+ ratchetDispatchOutcome: null,
1853
+ ratchetDispatchRetryCount: 0,
1854
+ };
1855
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1856
+ vi.mocked(mockGitHubBridge.extractPRInfo).mockReturnValue({
1857
+ owner: 'example',
1858
+ repo: 'repo',
1859
+ number: 9,
1860
+ });
1861
+ vi.mocked(mockGitHubBridge.isRecentlyFetched).mockReturnValue(true);
1862
+ vi.mocked(mockGitHubBridge.getPRFullDetails).mockResolvedValue({
1863
+ state: 'OPEN',
1864
+ number: 9,
1865
+ url: 'https://github.com/example/repo/pull/9',
1866
+ reviewDecision: null,
1867
+ mergeStateStatus: 'CLEAN',
1868
+ reviews: [],
1869
+ comments: [],
1870
+ statusCheckRollup: null,
1871
+ });
1872
+ vi.mocked(mockGitHubBridge.getReviewComments).mockResolvedValue([]);
1873
+ vi.mocked(mockGitHubBridge.computeCIStatus).mockReturnValue(CIStatus.SUCCESS);
1874
+ const result = await ratchetService.checkWorkspaceById('ws-bypass', {
1875
+ bypassPrFetchCooldown: true,
1876
+ });
1877
+ expect(mockGitHubBridge.getPRFullDetails).toHaveBeenCalledWith('example/repo', 9, expect.any(AbortSignal));
1878
+ expect(result?.newState).toBe(RatchetState.READY);
1879
+ expect(result?.action).not.toEqual({ type: 'WAITING', reason: 'recently_fetched' });
1880
+ });
1881
+ it('reruns a bypassed check once when the first attempt was dedup-skipped', async () => {
1882
+ const workspace = {
1883
+ id: 'ws-rerun',
1884
+ prUrl: 'https://github.com/example/repo/pull/9',
1885
+ prNumber: 9,
1886
+ prState: 'OPEN',
1887
+ prCiStatus: CIStatus.UNKNOWN,
1888
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
1889
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1890
+ ratchetEnabled: true,
1891
+ ratchetState: RatchetState.CI_RUNNING,
1892
+ ratchetActiveSessionId: null,
1893
+ ratchetLastCiRunId: null,
1894
+ prReviewLastCheckedAt: null,
1895
+ ratchetDispatchOutcome: null,
1896
+ ratchetDispatchRetryCount: 0,
1897
+ };
1898
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1899
+ vi.mocked(mockGitHubBridge.extractPRInfo).mockReturnValue({
1900
+ owner: 'example',
1901
+ repo: 'repo',
1902
+ number: 9,
1903
+ });
1904
+ vi.mocked(mockGitHubBridge.isFetchInFlight).mockReturnValueOnce(true).mockReturnValue(false);
1905
+ vi.mocked(mockGitHubBridge.getPRFullDetails).mockResolvedValue({
1906
+ state: 'OPEN',
1907
+ number: 9,
1908
+ url: 'https://github.com/example/repo/pull/9',
1909
+ reviewDecision: null,
1910
+ mergeStateStatus: 'CLEAN',
1911
+ reviews: [],
1912
+ comments: [],
1913
+ statusCheckRollup: null,
1914
+ });
1915
+ vi.mocked(mockGitHubBridge.getReviewComments).mockResolvedValue([]);
1916
+ vi.mocked(mockGitHubBridge.computeCIStatus).mockReturnValue(CIStatus.SUCCESS);
1917
+ const result = await ratchetService.checkWorkspaceById('ws-rerun', {
1918
+ bypassPrFetchCooldown: true,
1919
+ });
1920
+ expect(mockGitHubBridge.getPRFullDetails).toHaveBeenCalledTimes(1);
1921
+ expect(result?.newState).toBe(RatchetState.READY);
1922
+ expect(result?.action).not.toEqual({ type: 'WAITING', reason: 'recently_fetched' });
1923
+ });
1924
+ it('does not rerun a bypassed check more than once', async () => {
1925
+ const workspace = {
1926
+ id: 'ws-rerun-cap',
1927
+ prUrl: 'https://github.com/example/repo/pull/9',
1928
+ prNumber: 9,
1929
+ prState: 'OPEN',
1930
+ prCiStatus: CIStatus.UNKNOWN,
1931
+ ratchetEnabled: true,
1932
+ ratchetState: RatchetState.CI_RUNNING,
1933
+ ratchetActiveSessionId: null,
1934
+ ratchetLastCiRunId: null,
1935
+ prReviewLastCheckedAt: null,
1936
+ ratchetDispatchOutcome: null,
1937
+ ratchetDispatchRetryCount: 0,
1938
+ };
1939
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1940
+ vi.mocked(mockGitHubBridge.extractPRInfo).mockReturnValue({
1941
+ owner: 'example',
1942
+ repo: 'repo',
1943
+ number: 9,
1944
+ });
1945
+ vi.mocked(mockGitHubBridge.isFetchInFlight).mockReturnValue(true);
1946
+ const result = await ratchetService.checkWorkspaceById('ws-rerun-cap', {
1947
+ bypassPrFetchCooldown: true,
1948
+ });
1949
+ expect(result?.action).toEqual({ type: 'WAITING', reason: 'recently_fetched' });
1950
+ expect(mockGitHubBridge.isFetchInFlight).toHaveBeenCalledTimes(2);
1951
+ expect(mockGitHubBridge.getPRFullDetails).not.toHaveBeenCalled();
1952
+ });
1953
+ it('deduplicates concurrent checks for the same workspace', async () => {
1954
+ unsafeCoerce(ratchetService).isShuttingDown = false;
1955
+ const workspace = {
1956
+ id: 'ws-1',
1957
+ prUrl: 'https://github.com/example/repo/pull/1',
1958
+ prNumber: 1,
1959
+ prState: 'OPEN',
1960
+ prCiStatus: CIStatus.UNKNOWN,
1961
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
1962
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1963
+ ratchetEnabled: true,
1964
+ ratchetState: RatchetState.IDLE,
1965
+ ratchetActiveSessionId: null,
1966
+ ratchetLastCiRunId: null,
1967
+ prReviewLastCheckedAt: null,
1968
+ ratchetDispatchOutcome: null,
1969
+ ratchetDispatchRetryCount: 0,
1970
+ };
1971
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1972
+ let releaseCheck;
1973
+ const checkBarrier = new Promise((resolve) => {
1974
+ releaseCheck = () => resolve();
1975
+ });
1976
+ const result = {
1089
1977
  workspaceId: 'ws-1',
1090
1978
  previousState: RatchetState.IDLE,
1091
1979
  newState: RatchetState.IDLE,
@@ -1099,7 +1987,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1099
1987
  });
1100
1988
  const first = ratchetService.checkWorkspaceById('ws-1');
1101
1989
  const second = ratchetService.checkWorkspaceById('ws-1');
1102
- await Promise.resolve();
1990
+ await vi.waitFor(() => expect(processWorkspaceSpy).toHaveBeenCalledTimes(1));
1103
1991
  expect(processWorkspaceSpy).toHaveBeenCalledTimes(1);
1104
1992
  releaseCheck();
1105
1993
  const [firstResult, secondResult] = await Promise.all([first, second]);
@@ -1107,311 +1995,416 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1107
1995
  expect(secondResult).toEqual(result);
1108
1996
  expect(processWorkspaceSpy).toHaveBeenCalledTimes(1);
1109
1997
  });
1998
+ it('deduplicates a same-workspace check before it enters the concurrency queue', async () => {
1999
+ const makeWorkspace = (id) => ({
2000
+ id,
2001
+ prUrl: `https://github.com/example/repo/pull/${id}`,
2002
+ prNumber: 1,
2003
+ ratchetEnabled: true,
2004
+ ratchetState: RatchetState.IDLE,
2005
+ });
2006
+ const workspaces = [
2007
+ makeWorkspace('blocker-1'),
2008
+ makeWorkspace('blocker-2'),
2009
+ makeWorkspace('blocker-3'),
2010
+ makeWorkspace('queued-target'),
2011
+ ];
2012
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue(workspaces);
2013
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspaces[3]);
2014
+ let releaseBlockers;
2015
+ const blockerBarrier = new Promise((resolve) => {
2016
+ releaseBlockers = resolve;
2017
+ });
2018
+ let activeBlockers = 0;
2019
+ let targetRuns = 0;
2020
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation(async (workspace) => {
2021
+ if (workspace.id.startsWith('blocker-')) {
2022
+ activeBlockers += 1;
2023
+ await blockerBarrier;
2024
+ }
2025
+ else {
2026
+ targetRuns += 1;
2027
+ }
2028
+ return {
2029
+ workspaceId: workspace.id,
2030
+ previousState: RatchetState.IDLE,
2031
+ newState: RatchetState.IDLE,
2032
+ action: { type: 'WAITING', reason: 'noop' },
2033
+ };
2034
+ });
2035
+ const batch = ratchetService.checkAllWorkspaces();
2036
+ await vi.waitFor(() => expect(activeBlockers).toBe(3));
2037
+ const direct = ratchetService.checkWorkspaceById('queued-target');
2038
+ await Promise.resolve();
2039
+ expect(targetRuns).toBe(0);
2040
+ releaseBlockers();
2041
+ const [batchResult, directResult] = await Promise.all([batch, direct]);
2042
+ expect(targetRuns).toBe(1);
2043
+ expect(directResult).toEqual(batchResult.results[3]);
2044
+ });
2045
+ it('keeps a limiter slot occupied until a timed-out runner finishes cleanup', async () => {
2046
+ unsafeCoerce(ratchetService).workspaceCheckTimeoutMs = 5;
2047
+ const makeWorkspace = (id) => ({
2048
+ id,
2049
+ prUrl: `https://github.com/example/repo/pull/${id}`,
2050
+ prNumber: 1,
2051
+ ratchetEnabled: true,
2052
+ ratchetState: RatchetState.IDLE,
2053
+ });
2054
+ const workspaces = [
2055
+ makeWorkspace('timed-out-1'),
2056
+ makeWorkspace('timed-out-2'),
2057
+ makeWorkspace('timed-out-3'),
2058
+ makeWorkspace('after-timeouts'),
2059
+ ];
2060
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue(workspaces);
2061
+ let finishCleanup;
2062
+ const cleanupBarrier = new Promise((resolve) => {
2063
+ finishCleanup = resolve;
2064
+ });
2065
+ let activeTimedOutChecks = 0;
2066
+ let targetRuns = 0;
2067
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation(async (workspace, _opts, signal) => {
2068
+ if (workspace.id.startsWith('timed-out-')) {
2069
+ activeTimedOutChecks += 1;
2070
+ await new Promise((resolve) => {
2071
+ signal.addEventListener('abort', () => resolve(), { once: true });
2072
+ });
2073
+ await cleanupBarrier;
2074
+ signal.throwIfAborted();
2075
+ }
2076
+ targetRuns += workspace.id === 'after-timeouts' ? 1 : 0;
2077
+ return {
2078
+ workspaceId: workspace.id,
2079
+ previousState: RatchetState.IDLE,
2080
+ newState: RatchetState.IDLE,
2081
+ action: { type: 'WAITING', reason: 'noop' },
2082
+ };
2083
+ });
2084
+ const batch = ratchetService.checkAllWorkspaces();
2085
+ await vi.waitFor(() => expect(activeTimedOutChecks).toBe(3));
2086
+ await new Promise((resolve) => setTimeout(resolve, 10));
2087
+ expect(targetRuns).toBe(0);
2088
+ finishCleanup();
2089
+ await batch;
2090
+ expect(targetRuns).toBe(1);
2091
+ });
2092
+ it('runs a direct workspace check without waiting for the batch limiter', async () => {
2093
+ const makeWorkspace = (id) => ({
2094
+ id,
2095
+ prUrl: `https://github.com/example/repo/pull/${id}`,
2096
+ prNumber: 1,
2097
+ ratchetEnabled: true,
2098
+ ratchetState: RatchetState.IDLE,
2099
+ });
2100
+ const batchWorkspaces = [
2101
+ makeWorkspace('batch-blocker-1'),
2102
+ makeWorkspace('batch-blocker-2'),
2103
+ makeWorkspace('batch-blocker-3'),
2104
+ ];
2105
+ const directWorkspace = makeWorkspace('direct-target');
2106
+ vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue(batchWorkspaces);
2107
+ vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(directWorkspace);
2108
+ let releaseBatch;
2109
+ const batchBarrier = new Promise((resolve) => {
2110
+ releaseBatch = resolve;
2111
+ });
2112
+ let activeBatchChecks = 0;
2113
+ let directRuns = 0;
2114
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation(async (workspace) => {
2115
+ if (workspace.id.startsWith('batch-blocker-')) {
2116
+ activeBatchChecks += 1;
2117
+ await batchBarrier;
2118
+ }
2119
+ else {
2120
+ directRuns += 1;
2121
+ }
2122
+ return {
2123
+ workspaceId: workspace.id,
2124
+ previousState: RatchetState.IDLE,
2125
+ newState: RatchetState.IDLE,
2126
+ action: { type: 'WAITING', reason: 'noop' },
2127
+ };
2128
+ });
2129
+ const batch = ratchetService.checkAllWorkspaces();
2130
+ await vi.waitFor(() => expect(activeBatchChecks).toBe(3));
2131
+ const directResult = await ratchetService.checkWorkspaceById(directWorkspace.id);
2132
+ expect(directRuns).toBe(1);
2133
+ expect(directResult?.workspaceId).toBe(directWorkspace.id);
2134
+ releaseBatch();
2135
+ await batch;
2136
+ });
1110
2137
  });
1111
2138
  describe('decideRatchetAction edge cases', () => {
1112
2139
  const callDecide = (context) => unsafeCoerce(ratchetService).decideRatchetAction(context);
1113
- it('returns COMPLETED for merged PR', () => {
1114
- expect(callDecide({
1115
- workspace: { ratchetEnabled: true },
2140
+ const arrangeOtherWorkingSession = () => {
2141
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([
2142
+ { id: 'other-session' },
2143
+ ]);
2144
+ vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(true);
2145
+ };
2146
+ it('returns COMPLETED for merged PR', async () => {
2147
+ expect(await callDecide({
2148
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1116
2149
  prStateInfo: { prState: 'MERGED', ciStatus: CIStatus.SUCCESS },
1117
2150
  isCleanPrWithNoNewReviewActivity: false,
1118
- activeRatchetSession: null,
2151
+ activeFixerCheck: { kind: 'none' },
2152
+ dispatchOutcome: null,
2153
+ dispatchRetryCount: 0,
1119
2154
  hasStateChangedSinceLastDispatch: true,
1120
- hasOtherActiveSession: false,
1121
2155
  })).toEqual({ type: 'RETURN_ACTION', action: { type: 'COMPLETED' } });
1122
2156
  });
1123
- it('returns WAITING for non-open PR', () => {
1124
- const result = callDecide({
1125
- workspace: { ratchetEnabled: true },
2157
+ it('returns WAITING for non-open PR', async () => {
2158
+ const result = (await callDecide({
2159
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1126
2160
  prStateInfo: { prState: 'CLOSED', ciStatus: CIStatus.SUCCESS },
1127
2161
  isCleanPrWithNoNewReviewActivity: false,
1128
- activeRatchetSession: null,
2162
+ activeFixerCheck: { kind: 'none' },
2163
+ dispatchOutcome: null,
2164
+ dispatchRetryCount: 0,
1129
2165
  hasStateChangedSinceLastDispatch: true,
1130
- hasOtherActiveSession: false,
1131
- });
2166
+ }));
1132
2167
  expect(result.action?.reason).toBe('PR is not open');
1133
2168
  });
1134
- it('returns active session when one exists', () => {
2169
+ it('returns active session when one exists', async () => {
1135
2170
  const activeSession = { type: 'FIXER_ACTIVE', sessionId: 's-1' };
1136
- const result = callDecide({
1137
- workspace: { ratchetEnabled: true },
2171
+ const result = (await callDecide({
2172
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1138
2173
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
1139
2174
  isCleanPrWithNoNewReviewActivity: false,
1140
- activeRatchetSession: activeSession,
2175
+ activeFixerCheck: { kind: 'active', action: activeSession },
2176
+ dispatchOutcome: 'RUNNING',
2177
+ dispatchRetryCount: 0,
1141
2178
  hasStateChangedSinceLastDispatch: true,
1142
- hasOtherActiveSession: false,
1143
- });
2179
+ }));
1144
2180
  expect(result.action).toEqual(activeSession);
1145
2181
  });
1146
- it('returns WAITING when other active session blocks dispatch', () => {
1147
- const result = callDecide({
1148
- workspace: { ratchetEnabled: true },
2182
+ it('returns WAITING when other active session blocks dispatch', async () => {
2183
+ arrangeOtherWorkingSession();
2184
+ const result = (await callDecide({
2185
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1149
2186
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
1150
2187
  isCleanPrWithNoNewReviewActivity: false,
1151
- activeRatchetSession: null,
2188
+ activeFixerCheck: { kind: 'none' },
2189
+ dispatchOutcome: null,
2190
+ dispatchRetryCount: 0,
1152
2191
  hasStateChangedSinceLastDispatch: true,
1153
- hasOtherActiveSession: true,
1154
- });
2192
+ }));
1155
2193
  expect(result.action?.reason).toBe('Workspace has another working session');
1156
2194
  });
1157
- it('returns WAITING when there are no CI failures or PR review comments', () => {
1158
- const result = callDecide({
1159
- workspace: { ratchetEnabled: true },
2195
+ it('returns WAITING when there are no CI failures or PR review comments', async () => {
2196
+ const result = (await callDecide({
2197
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1160
2198
  prStateInfo: {
1161
2199
  prState: 'OPEN',
1162
2200
  ciStatus: CIStatus.SUCCESS,
1163
2201
  reviewComments: [],
1164
2202
  },
1165
2203
  isCleanPrWithNoNewReviewActivity: false,
1166
- activeRatchetSession: null,
2204
+ activeFixerCheck: { kind: 'none' },
2205
+ dispatchOutcome: null,
2206
+ dispatchRetryCount: 0,
1167
2207
  hasStateChangedSinceLastDispatch: true,
1168
- hasOtherActiveSession: false,
1169
- });
2208
+ }));
1170
2209
  expect(result.action?.reason).toBe('No CI failures or PR review comments to address');
1171
2210
  });
1172
- });
1173
- describe('getActiveRatchetSession edge cases', () => {
1174
- const callGetActiveRatchetSession = (workspace) => unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
1175
- it('returns null when ratchetActiveSessionId is null', async () => {
1176
- const result = await callGetActiveRatchetSession({
1177
- id: 'ws-1',
1178
- ratchetActiveSessionId: null,
2211
+ it('waits when the fixer ended concurrently during the check', async () => {
2212
+ const result = (await callDecide({
2213
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
2214
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
2215
+ isCleanPrWithNoNewReviewActivity: false,
2216
+ activeFixerCheck: { kind: 'ended_concurrently' },
2217
+ dispatchOutcome: 'COMPLETED',
2218
+ dispatchRetryCount: 0,
2219
+ hasStateChangedSinceLastDispatch: false,
2220
+ }));
2221
+ expect(result.action?.reason).toBe('Fixer session ended during this check; re-evaluating next cycle');
2222
+ });
2223
+ it('retries a died fixer ahead of the unchanged-state gate', async () => {
2224
+ const result = await callDecide({
2225
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
2226
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
2227
+ isCleanPrWithNoNewReviewActivity: false,
2228
+ activeFixerCheck: { kind: 'settled', outcome: 'DIED' },
2229
+ dispatchOutcome: 'DIED',
2230
+ dispatchRetryCount: 2,
2231
+ hasStateChangedSinceLastDispatch: false,
1179
2232
  });
1180
- expect(result).toBeNull();
2233
+ expect(result).toEqual({ type: 'TRIGGER_FIXER', retryCount: 3 });
1181
2234
  });
1182
- it('returns null and resets dispatch tracking when session DB record is missing', async () => {
1183
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
1184
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1185
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
1186
- const result = await callGetActiveRatchetSession({
1187
- id: 'ws-2',
1188
- ratchetActiveSessionId: 'gone-session',
1189
- });
1190
- expect(result).toBeNull();
1191
- expect(workspaceAccessor.update).toHaveBeenCalledWith('ws-2', {
2235
+ it('does not retry a died fixer while another session is working', async () => {
2236
+ arrangeOtherWorkingSession();
2237
+ const result = (await callDecide({
2238
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
2239
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
2240
+ isCleanPrWithNoNewReviewActivity: false,
2241
+ activeFixerCheck: { kind: 'settled', outcome: 'DIED' },
2242
+ dispatchOutcome: 'DIED',
2243
+ dispatchRetryCount: 0,
2244
+ hasStateChangedSinceLastDispatch: false,
2245
+ }));
2246
+ expect(result.action?.reason).toBe('Workspace has another working session');
2247
+ });
2248
+ it('gives up on a died fixer once the retry budget is spent', async () => {
2249
+ const result = (await callDecide({
2250
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
2251
+ prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
2252
+ isCleanPrWithNoNewReviewActivity: false,
2253
+ activeFixerCheck: { kind: 'none' },
2254
+ dispatchOutcome: 'DIED',
2255
+ dispatchRetryCount: 3,
2256
+ hasStateChangedSinceLastDispatch: false,
2257
+ }));
2258
+ expect(result.action?.reason).toBe('Fixer died 4 times for this PR state; waiting for PR state to change');
2259
+ });
2260
+ });
2261
+ describe('checkActiveFixerSession edge cases', () => {
2262
+ const callCheckActiveFixerSession = (workspace) => unsafeCoerce(ratchetService).checkActiveFixerSession(workspace);
2263
+ it('returns none when ratchetActiveSessionId is null', async () => {
2264
+ const result = await callCheckActiveFixerSession({
2265
+ id: 'ws-1',
1192
2266
  ratchetActiveSessionId: null,
1193
- ratchetLastCiRunId: null,
1194
2267
  });
1195
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith('ws-2', null);
2268
+ expect(result).toEqual({ kind: 'none' });
1196
2269
  });
1197
- it('returns null and resets dispatch tracking when session is not RUNNING', async () => {
2270
+ it('settles a non-RUNNING session as COMPLETED when its status is not FAILED', async () => {
1198
2271
  vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
1199
2272
  id: 'completed-session',
1200
2273
  provider: 'CLAUDE',
1201
2274
  status: SessionStatus.IDLE,
1202
2275
  });
1203
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1204
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
1205
- const result = await callGetActiveRatchetSession({
2276
+ const result = await callCheckActiveFixerSession({
1206
2277
  id: 'ws-3',
1207
2278
  ratchetActiveSessionId: 'completed-session',
1208
2279
  });
1209
- expect(result).toBeNull();
1210
- expect(workspaceAccessor.update).toHaveBeenCalledWith('ws-3', {
1211
- ratchetActiveSessionId: null,
1212
- ratchetLastCiRunId: null,
1213
- });
1214
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith('ws-3', null);
2280
+ expect(result).toEqual({ kind: 'settled', outcome: 'COMPLETED' });
2281
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith('ws-3', 'completed-session', 'COMPLETED');
1215
2282
  });
1216
- });
1217
- describe('review comment polling', () => {
1218
- const cleanWorkspace = {
1219
- id: 'ws-poll',
1220
- prUrl: 'https://github.com/example/repo/pull/100',
1221
- prNumber: 100,
1222
- prState: 'OPEN',
1223
- prCiStatus: CIStatus.UNKNOWN,
1224
- ratchetEnabled: true,
1225
- ratchetState: RatchetState.READY,
1226
- ratchetActiveSessionId: null,
1227
- ratchetLastCiRunId: 'old-snapshot',
1228
- prReviewLastCheckedAt: new Date(),
1229
- };
1230
- const cleanPrState = {
1231
- ciStatus: CIStatus.SUCCESS,
1232
- snapshotKey: 'new-snapshot',
1233
- hasChangesRequested: false,
1234
- latestReviewActivityAtMs: new Date('2026-01-01T00:00:00Z').getTime(),
1235
- statusCheckRollup: null,
1236
- prState: 'OPEN',
1237
- prNumber: 100,
1238
- reviewComments: [],
1239
- };
1240
- const prStateWithComments = {
1241
- ...cleanPrState,
1242
- snapshotKey: 'snapshot-with-comments',
1243
- hasChangesRequested: true,
1244
- latestReviewActivityAtMs: new Date('2026-01-03T00:00:00Z').getTime(),
1245
- reviewComments: [
1246
- {
1247
- author: 'reviewer',
1248
- body: 'Please update this line',
1249
- path: 'src/file.ts',
1250
- line: 21,
1251
- url: 'https://github.com/example/repo/pull/100#discussion_r2',
1252
- },
1253
- ],
1254
- };
1255
- const getTrackers = () => unsafeCoerce(ratchetService).reviewPollTrackers;
1256
- const callProcessWorkspace = (workspace) => unsafeCoerce(ratchetService).processWorkspace(workspace);
1257
- it('creates tracker when PR is first seen as clean with state change', async () => {
1258
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(cleanPrState);
1259
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1260
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1261
- const result = await callProcessWorkspace(cleanWorkspace);
1262
- expect(result.action.type).toBe('WAITING');
1263
- expect(getTrackers().has('ws-poll')).toBe(true);
1264
- expect(getTrackers().get('ws-poll')?.snapshotKey).toBe('new-snapshot');
1265
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1266
- });
1267
- it('creates tracker even when state has not changed (continuous polling)', async () => {
1268
- const workspace = { ...cleanWorkspace, ratchetLastCiRunId: 'new-snapshot' };
1269
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(cleanPrState);
1270
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1271
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1272
- await callProcessWorkspace(workspace);
1273
- expect(getTrackers().has('ws-poll')).toBe(true);
1274
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1275
- });
1276
- it('skips re-poll when not enough time has elapsed', async () => {
1277
- getTrackers().set('ws-poll', {
1278
- snapshotKey: 'new-snapshot',
1279
- lastPolledAt: Date.now(),
1280
- pollCount: 0,
1281
- });
1282
- const fetchSpy = vi
1283
- .spyOn(unsafeCoerce(ratchetService), 'fetchPRState')
1284
- .mockResolvedValue(cleanPrState);
1285
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1286
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1287
- await callProcessWorkspace(cleanWorkspace);
1288
- expect(fetchSpy).toHaveBeenCalledTimes(1);
1289
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1290
- });
1291
- it('re-polls and finds comments when time has elapsed', async () => {
1292
- getTrackers().set('ws-poll', {
1293
- snapshotKey: 'new-snapshot',
1294
- lastPolledAt: Date.now() - 3 * 60_000,
1295
- pollCount: 0,
1296
- });
1297
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1298
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1299
- fetchSpy.mockResolvedValueOnce(prStateWithComments);
1300
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1301
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1302
- vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1303
- status: 'started',
1304
- sessionId: 'ratchet-poll-session',
1305
- promptSent: true,
2283
+ it('settles a FAILED session as DIED', async () => {
2284
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
2285
+ id: 'failed-session',
2286
+ provider: 'CLAUDE',
2287
+ status: SessionStatus.FAILED,
1306
2288
  });
1307
- const result = await callProcessWorkspace(cleanWorkspace);
1308
- expect(fetchSpy).toHaveBeenCalledTimes(2);
1309
- expect(result.action.type).toBe('TRIGGERED_FIXER');
1310
- expect(getTrackers().has('ws-poll')).toBe(false);
1311
- });
1312
- it('increments pollCount when re-poll still finds clean PR', async () => {
1313
- getTrackers().set('ws-poll', {
1314
- snapshotKey: 'new-snapshot',
1315
- lastPolledAt: Date.now() - 3 * 60_000,
1316
- pollCount: 0,
2289
+ const result = await callCheckActiveFixerSession({
2290
+ id: 'ws-4',
2291
+ ratchetActiveSessionId: 'failed-session',
1317
2292
  });
1318
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1319
- fetchSpy.mockResolvedValue(cleanPrState);
1320
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1321
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1322
- await callProcessWorkspace(cleanWorkspace);
1323
- expect(fetchSpy).toHaveBeenCalledTimes(2);
1324
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(1);
1325
- });
1326
- it('continues polling after many cycles without exhausting', async () => {
1327
- getTrackers().set('ws-poll', {
1328
- snapshotKey: 'new-snapshot',
1329
- lastPolledAt: Date.now() - 3 * 60_000,
1330
- pollCount: 50,
1331
- });
1332
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1333
- fetchSpy.mockResolvedValue(cleanPrState);
1334
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1335
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1336
- await callProcessWorkspace(cleanWorkspace);
1337
- expect(fetchSpy).toHaveBeenCalledTimes(2);
1338
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(51);
2293
+ expect(result).toEqual({ kind: 'settled', outcome: 'DIED' });
2294
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith('ws-4', 'failed-session', 'DIED');
1339
2295
  });
1340
- it('resets tracker when snapshotKey changes', async () => {
1341
- getTrackers().set('ws-poll', {
1342
- snapshotKey: 'different-snapshot',
1343
- lastPolledAt: Date.now() - 10 * 60_000,
1344
- pollCount: 3,
2296
+ it('settles a provider-mismatched session as DIED and stops it', async () => {
2297
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
2298
+ id: 'codex-session',
2299
+ provider: 'CODEX',
2300
+ status: SessionStatus.RUNNING,
1345
2301
  });
1346
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(cleanPrState);
1347
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1348
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1349
- await callProcessWorkspace(cleanWorkspace);
1350
- const tracker = getTrackers().get('ws-poll');
1351
- expect(tracker?.snapshotKey).toBe('new-snapshot');
1352
- expect(tracker?.pollCount).toBe(0);
1353
- });
1354
- it('cleans up tracker when decision is not clean PR', async () => {
1355
- getTrackers().set('ws-poll', {
1356
- snapshotKey: 'new-snapshot',
1357
- lastPolledAt: Date.now(),
1358
- pollCount: 1,
1359
- });
1360
- const failedPrState = {
1361
- ...cleanPrState,
1362
- ciStatus: CIStatus.FAILURE,
1363
- snapshotKey: 'failed-snapshot',
1364
- };
1365
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(failedPrState);
1366
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1367
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1368
- vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1369
- status: 'started',
1370
- sessionId: 'fixer',
1371
- promptSent: true,
2302
+ vi.mocked(mockSessionBridge.stopSession).mockResolvedValue();
2303
+ const result = await callCheckActiveFixerSession({
2304
+ id: 'ws-5',
2305
+ ratchetActiveSessionId: 'codex-session',
2306
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
2307
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1372
2308
  });
1373
- await callProcessWorkspace(cleanWorkspace);
1374
- expect(getTrackers().has('ws-poll')).toBe(false);
2309
+ expect(result).toEqual({ kind: 'settled', outcome: 'DIED' });
2310
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith('ws-5', 'codex-session', 'DIED');
2311
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('codex-session');
1375
2312
  });
1376
- it('continues to next cycle when fetchPRState returns null during re-poll', async () => {
1377
- getTrackers().set('ws-poll', {
1378
- snapshotKey: 'new-snapshot',
1379
- lastPolledAt: Date.now() - 3 * 60_000,
1380
- pollCount: 0,
2313
+ it('returns the active fixer when the session is working', async () => {
2314
+ vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
2315
+ id: 'working-session',
2316
+ provider: 'CLAUDE',
2317
+ status: SessionStatus.RUNNING,
1381
2318
  });
1382
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1383
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1384
- fetchSpy.mockResolvedValueOnce(null);
1385
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1386
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1387
- const result = await callProcessWorkspace(cleanWorkspace);
1388
- expect(result.action.type).toBe('WAITING');
1389
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1390
- });
1391
- it('skips re-poll when shutting down', async () => {
1392
- getTrackers().set('ws-poll', {
1393
- snapshotKey: 'new-snapshot',
1394
- lastPolledAt: Date.now() - 3 * 60_000,
1395
- pollCount: 0,
2319
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
2320
+ vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(true);
2321
+ const result = await callCheckActiveFixerSession({
2322
+ id: 'ws-6',
2323
+ ratchetActiveSessionId: 'working-session',
1396
2324
  });
1397
- unsafeCoerce(ratchetService).isShuttingDown = false;
1398
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1399
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1400
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1401
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1402
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1403
- const handlePollSpy = vi.spyOn(unsafeCoerce(ratchetService), 'handleReviewCommentPoll');
1404
- handlePollSpy.mockImplementationOnce(async (...args) => {
1405
- unsafeCoerce(ratchetService).isShuttingDown = true;
1406
- handlePollSpy.mockRestore();
1407
- return await unsafeCoerce(ratchetService).handleReviewCommentPoll(...args);
2325
+ expect(result).toEqual({
2326
+ kind: 'active',
2327
+ action: { type: 'FIXER_ACTIVE', sessionId: 'working-session' },
1408
2328
  });
1409
- const result = await callProcessWorkspace(cleanWorkspace);
1410
- expect(result.action.type).toBe('WAITING');
1411
- expect(fetchSpy).toHaveBeenCalledTimes(1);
2329
+ expect(workspaceAccessor.recordRatchetSessionEnd).not.toHaveBeenCalled();
2330
+ });
2331
+ it('does not settle or stop a fixer after cancellation during session lookup', async () => {
2332
+ const controller = new AbortController();
2333
+ const timeoutError = new Error('Workspace check timed out');
2334
+ let finishLookup;
2335
+ vi.mocked(agentSessionAccessor.findById).mockImplementation(() => new Promise((resolve) => {
2336
+ finishLookup = resolve;
2337
+ }));
2338
+ const check = unsafeCoerce(ratchetService).checkActiveFixerSession({
2339
+ id: 'ws-cancelled-active-check',
2340
+ ratchetActiveSessionId: 'session-1',
2341
+ }, controller.signal);
2342
+ await vi.waitFor(() => expect(agentSessionAccessor.findById).toHaveBeenCalled());
2343
+ controller.abort(timeoutError);
2344
+ finishLookup(null);
2345
+ await expect(check).rejects.toBe(timeoutError);
2346
+ expect(workspaceAccessor.recordRatchetSessionEnd).not.toHaveBeenCalled();
2347
+ expect(mockSessionBridge.stopSession).not.toHaveBeenCalled();
1412
2348
  });
1413
2349
  });
1414
2350
  describe('triggerFixer error handling', () => {
2351
+ it('does not clean up a fixer after its dispatch record is persisted', async () => {
2352
+ const controller = new AbortController();
2353
+ const timeoutError = new Error('Workspace check timed out');
2354
+ let finishRecord;
2355
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
2356
+ status: 'started',
2357
+ sessionId: 'cancelled-session',
2358
+ promptSent: true,
2359
+ });
2360
+ vi.mocked(workspaceAccessor.recordRatchetDispatchIfEnabled).mockImplementation(() => new Promise((resolve) => {
2361
+ finishRecord = resolve;
2362
+ }));
2363
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
2364
+ const commitSideEffects = vi.fn();
2365
+ const trigger = unsafeCoerce(ratchetService).triggerFixer({
2366
+ id: 'ws-cancelled-dispatch',
2367
+ prUrl: 'https://github.com/example/repo/pull/20',
2368
+ }, {
2369
+ ciStatus: CIStatus.FAILURE,
2370
+ snapshotKey: 'failed:20',
2371
+ prNumber: 20,
2372
+ reviewComments: [],
2373
+ hasMergeConflict: false,
2374
+ }, 0, controller.signal, commitSideEffects);
2375
+ await vi.waitFor(() => expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalled());
2376
+ controller.abort(timeoutError);
2377
+ finishRecord(true);
2378
+ await expect(trigger).rejects.toBe(timeoutError);
2379
+ expect(commitSideEffects).toHaveBeenCalledTimes(1);
2380
+ expect(workspaceAccessor.recordRatchetSessionEnd).not.toHaveBeenCalled();
2381
+ expect(mockSessionBridge.stopSession).not.toHaveBeenCalled();
2382
+ });
2383
+ it('cleans up a started fixer when dispatch persistence fails', async () => {
2384
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
2385
+ status: 'started',
2386
+ sessionId: 'unrecorded-session',
2387
+ promptSent: true,
2388
+ });
2389
+ vi.mocked(workspaceAccessor.recordRatchetDispatchIfEnabled).mockRejectedValue(new Error('dispatch record write failed'));
2390
+ vi.mocked(workspaceAccessor.recordRatchetSessionEnd).mockRejectedValue(new Error('dispatch cleanup write failed'));
2391
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
2392
+ const commitSideEffects = vi.fn();
2393
+ const result = await unsafeCoerce(ratchetService).triggerFixer({
2394
+ id: 'ws-unrecorded-dispatch',
2395
+ prUrl: 'https://github.com/example/repo/pull/20',
2396
+ }, {
2397
+ ciStatus: CIStatus.FAILURE,
2398
+ snapshotKey: 'failed:20',
2399
+ prNumber: 20,
2400
+ reviewComments: [],
2401
+ hasMergeConflict: false,
2402
+ }, 0, new AbortController().signal, commitSideEffects);
2403
+ expect(result).toMatchObject({ type: 'ERROR', error: 'dispatch record write failed' });
2404
+ expect(commitSideEffects).toHaveBeenCalledTimes(1);
2405
+ expect(workspaceAccessor.recordRatchetSessionEnd).toHaveBeenCalledWith('ws-unrecorded-dispatch', 'unrecorded-session', 'COMPLETED');
2406
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('unrecorded-session');
2407
+ });
1415
2408
  it('handles acquireAndDispatch throwing an error', async () => {
1416
2409
  const workspace = {
1417
2410
  id: 'ws-trigger-fail',
@@ -1422,12 +2415,14 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1422
2415
  ratchetActiveSessionId: null,
1423
2416
  ratchetLastCiRunId: null,
1424
2417
  prReviewLastCheckedAt: null,
2418
+ ratchetDispatchOutcome: null,
2419
+ ratchetDispatchRetryCount: 0,
1425
2420
  };
1426
2421
  vi.mocked(fixerSessionService.acquireAndDispatch).mockRejectedValue(new Error('Session creation failed'));
1427
2422
  const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
1428
2423
  ciStatus: CIStatus.FAILURE,
1429
2424
  prNumber: 20,
1430
- });
2425
+ }, 0);
1431
2426
  expect(result).toMatchObject({
1432
2427
  type: 'ERROR',
1433
2428
  error: 'Session creation failed',
@@ -1443,6 +2438,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1443
2438
  ratchetActiveSessionId: null,
1444
2439
  ratchetLastCiRunId: null,
1445
2440
  prReviewLastCheckedAt: null,
2441
+ ratchetDispatchOutcome: null,
2442
+ ratchetDispatchRetryCount: 0,
1446
2443
  };
1447
2444
  vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1448
2445
  status: 'already_active',
@@ -1452,11 +2449,42 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1452
2449
  const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
1453
2450
  ciStatus: CIStatus.FAILURE,
1454
2451
  prNumber: 21,
1455
- });
2452
+ }, 0);
1456
2453
  expect(result).toMatchObject({
1457
2454
  type: 'FIXER_ACTIVE',
1458
2455
  sessionId: 'existing-session',
1459
2456
  });
2457
+ expect(workspaceAccessor.adoptRatchetActiveSessionIfEnabled).toHaveBeenCalledWith('ws-already-active', 'existing-session');
2458
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).not.toHaveBeenCalled();
2459
+ });
2460
+ it('stops already-active fixer when active-session recording loses disable race', async () => {
2461
+ const workspace = {
2462
+ id: 'ws-already-active-disabled',
2463
+ prUrl: 'https://github.com/example/repo/pull/24',
2464
+ prNumber: 24,
2465
+ ratchetEnabled: true,
2466
+ ratchetState: RatchetState.CI_FAILED,
2467
+ ratchetActiveSessionId: null,
2468
+ ratchetLastCiRunId: null,
2469
+ prReviewLastCheckedAt: null,
2470
+ ratchetDispatchOutcome: null,
2471
+ ratchetDispatchRetryCount: 0,
2472
+ };
2473
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
2474
+ status: 'already_active',
2475
+ sessionId: 'existing-session',
2476
+ });
2477
+ vi.mocked(workspaceAccessor.adoptRatchetActiveSessionIfEnabled).mockResolvedValue(false);
2478
+ vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
2479
+ const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
2480
+ ciStatus: CIStatus.FAILURE,
2481
+ prNumber: 24,
2482
+ }, 0);
2483
+ expect(result).toMatchObject({
2484
+ type: 'DISABLED',
2485
+ reason: 'Workspace ratcheting disabled',
2486
+ });
2487
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('existing-session');
1460
2488
  });
1461
2489
  it('handles skipped result from acquireAndDispatch', async () => {
1462
2490
  const workspace = {
@@ -1468,6 +2496,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1468
2496
  ratchetActiveSessionId: null,
1469
2497
  ratchetLastCiRunId: null,
1470
2498
  prReviewLastCheckedAt: null,
2499
+ ratchetDispatchOutcome: null,
2500
+ ratchetDispatchRetryCount: 0,
1471
2501
  };
1472
2502
  vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1473
2503
  status: 'skipped',
@@ -1476,7 +2506,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1476
2506
  const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
1477
2507
  ciStatus: CIStatus.FAILURE,
1478
2508
  prNumber: 22,
1479
- });
2509
+ }, 0);
1480
2510
  expect(result).toMatchObject({
1481
2511
  type: 'ERROR',
1482
2512
  error: 'No worktree path',
@@ -1534,6 +2564,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1534
2564
  ratchetState: RatchetState.IDLE,
1535
2565
  ratchetActiveSessionId: null,
1536
2566
  ratchetLastCiRunId: null,
2567
+ ratchetDispatchOutcome: null,
2568
+ ratchetDispatchRetryCount: 0,
1537
2569
  });
1538
2570
  expect(stateEvents).toEqual([
1539
2571
  {
@@ -1550,6 +2582,31 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1550
2582
  },
1551
2583
  ]);
1552
2584
  });
2585
+ it('stops ratchet workflow sessions found after disabling workspace', async () => {
2586
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
2587
+ id: 'ws-disable-raced-session',
2588
+ ratchetState: RatchetState.CI_RUNNING,
2589
+ ratchetActiveSessionId: null,
2590
+ });
2591
+ vi.mocked(workspaceAccessor.update).mockResolvedValue({});
2592
+ vi.mocked(mockSessionBridge.findSessionsByWorkspaceId).mockResolvedValue([
2593
+ {
2594
+ id: 'raced-ratchet-session',
2595
+ workflow: 'ratchet',
2596
+ status: SessionStatus.RUNNING,
2597
+ },
2598
+ {
2599
+ id: 'manual-session',
2600
+ workflow: 'default',
2601
+ status: SessionStatus.RUNNING,
2602
+ },
2603
+ ]);
2604
+ vi.mocked(mockSessionBridge.isSessionRunning).mockImplementation((sessionId) => sessionId === 'raced-ratchet-session');
2605
+ await ratchetService.setWorkspaceRatcheting('ws-disable-raced-session', false);
2606
+ expect(mockSessionBridge.findSessionsByWorkspaceId).toHaveBeenCalledWith('ws-disable-raced-session');
2607
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledTimes(1);
2608
+ expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('raced-ratchet-session');
2609
+ });
1553
2610
  });
1554
2611
  describe('event emission', () => {
1555
2612
  afterEach(() => {
@@ -1565,6 +2622,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1565
2622
  ratchetActiveSessionId: null,
1566
2623
  ratchetLastCiRunId: null,
1567
2624
  prReviewLastCheckedAt: null,
2625
+ ratchetDispatchOutcome: null,
2626
+ ratchetDispatchRetryCount: 0,
1568
2627
  };
1569
2628
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1570
2629
  const events = [];
@@ -1589,6 +2648,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1589
2648
  ratchetActiveSessionId: null,
1590
2649
  ratchetLastCiRunId: null,
1591
2650
  prReviewLastCheckedAt: null,
2651
+ ratchetDispatchOutcome: null,
2652
+ ratchetDispatchRetryCount: 0,
1592
2653
  };
1593
2654
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1594
2655
  const events = [];
@@ -1608,6 +2669,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1608
2669
  ratchetActiveSessionId: null,
1609
2670
  ratchetLastCiRunId: null,
1610
2671
  prReviewLastCheckedAt: null,
2672
+ ratchetDispatchOutcome: null,
2673
+ ratchetDispatchRetryCount: 0,
1611
2674
  };
1612
2675
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
1613
2676
  ciStatus: CIStatus.FAILURE,
@@ -1648,6 +2711,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1648
2711
  ratchetActiveSessionId: null,
1649
2712
  ratchetLastCiRunId: null,
1650
2713
  prReviewLastCheckedAt: null,
2714
+ ratchetDispatchOutcome: null,
2715
+ ratchetDispatchRetryCount: 0,
1651
2716
  };
1652
2717
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
1653
2718
  ciStatus: CIStatus.FAILURE,
@@ -1682,6 +2747,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1682
2747
  ratchetActiveSessionId: null,
1683
2748
  ratchetLastCiRunId: null,
1684
2749
  prReviewLastCheckedAt: null,
2750
+ ratchetDispatchOutcome: null,
2751
+ ratchetDispatchRetryCount: 0,
1685
2752
  };
1686
2753
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(null);
1687
2754
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});