factory-factory 0.3.20 → 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 (874) hide show
  1. package/dist/client/assets/{arc-BO0sk6BM.js → arc-BZLkggFv.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BiwrJn01.js → architectureDiagram-3BPJPVTR-0dfKDS01.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-nfqVYcG7.js → blockDiagram-GPEHLZMM-kPnqTUvo.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-gH-CgRkj.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-jiIlm-jU.js → chunk-4BX2VUAB-BzGqW4v8.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-Cv9U9Ak-.js → chunk-55IACEB6-CKYhsw8v.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-CpxJ_7Wx.js → chunk-727SXJPM-D6c3bkpP.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-BL_hJu_8.js → chunk-AQP2D5EJ-Cp_aqtpa.js} +3 -3
  11. package/dist/client/assets/{chunk-FMBD7UC4-U_iAA2we.js → chunk-FMBD7UC4-CkbLLRk5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-CR9rGWpo.js → chunk-ND2GUHAM-BZ894oON.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-BoLv45f5.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-2Q7GC4ZY.js → cose-bilkent-S5V4N54A-CPSnsVY6.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-DaxrK5DK.js → dagre-BM42HDAG-CDuVn0gJ.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-0MAYacdt.js → diagram-2AECGRRQ-DkPx69Lt.js} +2 -2
  19. package/dist/client/assets/{diagram-5GNKFQAL-DW5UxCeI.js → diagram-5GNKFQAL-CchGithI.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-CU3iBH-g.js → diagram-KO2AKTUF-DQ50kQAK.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-CjGOHwD0.js → diagram-LMA3HP47-hKBATxk5.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B-wHM_mJ.js → diagram-OG6HWLK6-Bga7_0RO.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-BkBcMgb_.js → erDiagram-TEJ5UH35-D26at0F2.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-BErHv0-i.js → flowDiagram-I6XJVG4X-Qf-TRw4O.js} +4 -4
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-DxSH6Hll.js → ganttDiagram-6RSMTGT7-CmRredaI.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-BKjk_NzB.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-CI-80hW5.js → ishikawaDiagram-YF4QCWOH-DrCgRttT.js} +3 -3
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-ByKj8dRf.js → journeyDiagram-JHISSGLW-CcyKXKKG.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-DLcf7a9N.js → kanban-definition-UN3LZRKU-CsGSN3QV.js} +1 -1
  33. package/dist/client/assets/{linear-DIZ8op5J.js → linear-DG8wt3yl.js} +1 -1
  34. package/dist/client/assets/{mindmap-definition-RKZ34NQL-BsDpFWdh.js → mindmap-definition-RKZ34NQL-CD6E0vLi.js} +3 -3
  35. package/dist/client/assets/{mobile-baseline-jv_SPi-c.js → mobile-baseline-CCXJGaWY.js} +2 -2
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-C3dEKgHx.js → pieDiagram-4H26LBE5-PIN8tU1a.js} +2 -2
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-H5PdDgog.js → quadrantDiagram-W4KKPZXB-DV6ne1ch.js} +3 -3
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-D3PRRJVb.js → requirementDiagram-4Y6WPE33-Cly9IJv1.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-CIxvoToE.js → sankeyDiagram-5OEKKPKP-D2SFbF9j.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-JOZ0B71g.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-TRj_DMCe.js → terminal-instance-DFzmxfzG.js} +8 -8
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DnI7kBWN.js → timeline-definition-PNZ67QCA-B7ZqNmvc.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-Tk8IfETS.js → vennDiagram-CIIHVFJN-Biz7Z4hM.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-F_38sdTm.js → wardley-L42UT6IY-B-ZjZQUx.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DCqXhcQP.js → wardleyDiagram-YWT4CUSO-C9yV9o-K.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-BGnQjikn.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 +3 -1
  119. package/dist/src/backend/lib/constants.d.ts.map +1 -1
  120. package/dist/src/backend/lib/constants.js +3 -1
  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/file-lock-mutex.d.ts +1 -0
  134. package/dist/src/backend/lib/file-lock-mutex.d.ts.map +1 -1
  135. package/dist/src/backend/lib/file-lock-mutex.js +53 -48
  136. package/dist/src/backend/lib/file-lock-mutex.js.map +1 -1
  137. package/dist/src/backend/lib/file-lock-mutex.test.js +47 -0
  138. package/dist/src/backend/lib/file-lock-mutex.test.js.map +1 -1
  139. package/dist/src/backend/lib/request-trust.d.ts +4 -0
  140. package/dist/src/backend/lib/request-trust.d.ts.map +1 -0
  141. package/dist/src/backend/lib/request-trust.js +116 -0
  142. package/dist/src/backend/lib/request-trust.js.map +1 -0
  143. package/dist/src/backend/lib/request-trust.test.d.ts +2 -0
  144. package/dist/src/backend/lib/request-trust.test.d.ts.map +1 -0
  145. package/dist/src/backend/lib/request-trust.test.js +64 -0
  146. package/dist/src/backend/lib/request-trust.test.js.map +1 -0
  147. package/dist/src/backend/lib/session-model.js +1 -1
  148. package/dist/src/backend/lib/session-model.js.map +1 -1
  149. package/dist/src/backend/lib/shell.d.ts +13 -1
  150. package/dist/src/backend/lib/shell.d.ts.map +1 -1
  151. package/dist/src/backend/lib/shell.js +124 -13
  152. package/dist/src/backend/lib/shell.js.map +1 -1
  153. package/dist/src/backend/lib/shell.test.js +72 -0
  154. package/dist/src/backend/lib/shell.test.js.map +1 -1
  155. package/dist/src/backend/lib/topic-broadcaster.d.ts +18 -0
  156. package/dist/src/backend/lib/topic-broadcaster.d.ts.map +1 -0
  157. package/dist/src/backend/lib/topic-broadcaster.js +56 -0
  158. package/dist/src/backend/lib/topic-broadcaster.js.map +1 -0
  159. package/dist/src/backend/lib/topic-broadcaster.test.d.ts +2 -0
  160. package/dist/src/backend/lib/topic-broadcaster.test.d.ts.map +1 -0
  161. package/dist/src/backend/lib/topic-broadcaster.test.js +126 -0
  162. package/dist/src/backend/lib/topic-broadcaster.test.js.map +1 -0
  163. package/dist/src/backend/lib/websocket-send.d.ts +12 -0
  164. package/dist/src/backend/lib/websocket-send.d.ts.map +1 -0
  165. package/dist/src/backend/lib/websocket-send.js +46 -0
  166. package/dist/src/backend/lib/websocket-send.js.map +1 -0
  167. package/dist/src/backend/lib/websocket-send.test.d.ts +2 -0
  168. package/dist/src/backend/lib/websocket-send.test.d.ts.map +1 -0
  169. package/dist/src/backend/lib/websocket-send.test.js +103 -0
  170. package/dist/src/backend/lib/websocket-send.test.js.map +1 -0
  171. package/dist/src/backend/lib/workspace-derived-state.d.ts +6 -2
  172. package/dist/src/backend/lib/workspace-derived-state.d.ts.map +1 -1
  173. package/dist/src/backend/lib/workspace-derived-state.js +14 -1
  174. package/dist/src/backend/lib/workspace-derived-state.js.map +1 -1
  175. package/dist/src/backend/lib/workspace-derived-state.test.js +12 -0
  176. package/dist/src/backend/lib/workspace-derived-state.test.js.map +1 -1
  177. package/dist/src/backend/middleware/cors.middleware.d.ts.map +1 -1
  178. package/dist/src/backend/middleware/cors.middleware.js +2 -1
  179. package/dist/src/backend/middleware/cors.middleware.js.map +1 -1
  180. package/dist/src/backend/middleware/middleware.test.js +17 -0
  181. package/dist/src/backend/middleware/middleware.test.js.map +1 -1
  182. package/dist/src/backend/orchestration/data-backup.service.test.js +3 -0
  183. package/dist/src/backend/orchestration/data-backup.service.test.js.map +1 -1
  184. package/dist/src/backend/orchestration/domain-bridges.orchestrator.d.ts +4 -2
  185. package/dist/src/backend/orchestration/domain-bridges.orchestrator.d.ts.map +1 -1
  186. package/dist/src/backend/orchestration/domain-bridges.orchestrator.js +64 -23
  187. package/dist/src/backend/orchestration/domain-bridges.orchestrator.js.map +1 -1
  188. package/dist/src/backend/orchestration/domain-bridges.orchestrator.test.js +144 -10
  189. package/dist/src/backend/orchestration/domain-bridges.orchestrator.test.js.map +1 -1
  190. package/dist/src/backend/orchestration/event-collector.orchestrator.d.ts.map +1 -1
  191. package/dist/src/backend/orchestration/event-collector.orchestrator.js +23 -3
  192. package/dist/src/backend/orchestration/event-collector.orchestrator.js.map +1 -1
  193. package/dist/src/backend/orchestration/event-collector.orchestrator.test.js +153 -6
  194. package/dist/src/backend/orchestration/event-collector.orchestrator.test.js.map +1 -1
  195. package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.d.ts +8 -2
  196. package/dist/src/backend/orchestration/reconciliation.service.d.ts.map +1 -0
  197. package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.js +6 -6
  198. package/dist/src/backend/orchestration/reconciliation.service.js.map +1 -0
  199. package/dist/src/backend/orchestration/reconciliation.service.test.d.ts.map +1 -0
  200. package/dist/src/backend/{services/ratchet/service → orchestration}/reconciliation.service.test.js +1 -1
  201. package/dist/src/backend/orchestration/reconciliation.service.test.js.map +1 -0
  202. package/dist/src/backend/orchestration/scheduler.service.d.ts.map +1 -1
  203. package/dist/src/backend/orchestration/scheduler.service.js +9 -1
  204. package/dist/src/backend/orchestration/scheduler.service.js.map +1 -1
  205. package/dist/src/backend/orchestration/scheduler.service.test.js +6 -0
  206. package/dist/src/backend/orchestration/scheduler.service.test.js.map +1 -1
  207. package/dist/src/backend/orchestration/workspace-archive.orchestrator.d.ts +10 -1
  208. package/dist/src/backend/orchestration/workspace-archive.orchestrator.d.ts.map +1 -1
  209. package/dist/src/backend/orchestration/workspace-archive.orchestrator.js +33 -25
  210. package/dist/src/backend/orchestration/workspace-archive.orchestrator.js.map +1 -1
  211. package/dist/src/backend/orchestration/workspace-archive.orchestrator.test.js +8 -1
  212. package/dist/src/backend/orchestration/workspace-archive.orchestrator.test.js.map +1 -1
  213. package/dist/src/backend/orchestration/workspace-children.orchestrator.d.ts +22 -0
  214. package/dist/src/backend/orchestration/workspace-children.orchestrator.d.ts.map +1 -0
  215. package/dist/src/backend/orchestration/workspace-children.orchestrator.js +94 -0
  216. package/dist/src/backend/orchestration/workspace-children.orchestrator.js.map +1 -0
  217. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.d.ts +2 -0
  218. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.d.ts.map +1 -0
  219. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.js +116 -0
  220. package/dist/src/backend/orchestration/workspace-children.orchestrator.test.js.map +1 -0
  221. package/dist/src/backend/orchestration/workspace-init-issue-prompts.d.ts.map +1 -1
  222. package/dist/src/backend/orchestration/workspace-init-issue-prompts.js +3 -174
  223. package/dist/src/backend/orchestration/workspace-init-issue-prompts.js.map +1 -1
  224. package/dist/src/backend/orchestration/workspace-init.orchestrator.d.ts.map +1 -1
  225. package/dist/src/backend/orchestration/workspace-init.orchestrator.js +28 -16
  226. package/dist/src/backend/orchestration/workspace-init.orchestrator.js.map +1 -1
  227. package/dist/src/backend/orchestration/workspace-init.orchestrator.test.js +123 -2
  228. package/dist/src/backend/orchestration/workspace-init.orchestrator.test.js.map +1 -1
  229. package/dist/src/backend/prompts/branch-rename.d.ts.map +1 -1
  230. package/dist/src/backend/prompts/branch-rename.js +20 -13
  231. package/dist/src/backend/prompts/branch-rename.js.map +1 -1
  232. package/dist/src/backend/prompts/branch-rename.test.d.ts +2 -0
  233. package/dist/src/backend/prompts/branch-rename.test.d.ts.map +1 -0
  234. package/dist/src/backend/prompts/branch-rename.test.js +56 -0
  235. package/dist/src/backend/prompts/branch-rename.test.js.map +1 -0
  236. package/dist/src/backend/prompts/ratchet-dispatch.d.ts.map +1 -1
  237. package/dist/src/backend/prompts/ratchet-dispatch.js +28 -6
  238. package/dist/src/backend/prompts/ratchet-dispatch.js.map +1 -1
  239. package/dist/src/backend/prompts/ratchet-dispatch.test.js +55 -0
  240. package/dist/src/backend/prompts/ratchet-dispatch.test.js.map +1 -1
  241. package/dist/src/backend/routers/websocket/chat-connection-registry.d.ts +27 -0
  242. package/dist/src/backend/routers/websocket/chat-connection-registry.d.ts.map +1 -0
  243. package/dist/src/backend/routers/websocket/chat-connection-registry.js +106 -0
  244. package/dist/src/backend/routers/websocket/chat-connection-registry.js.map +1 -0
  245. package/dist/src/backend/routers/websocket/chat-connection-registry.test.d.ts +2 -0
  246. package/dist/src/backend/routers/websocket/chat-connection-registry.test.d.ts.map +1 -0
  247. package/dist/src/backend/routers/websocket/chat-connection-registry.test.js +242 -0
  248. package/dist/src/backend/routers/websocket/chat-connection-registry.test.js.map +1 -0
  249. package/dist/src/backend/routers/websocket/chat.handler.d.ts +2 -5
  250. package/dist/src/backend/routers/websocket/chat.handler.d.ts.map +1 -1
  251. package/dist/src/backend/routers/websocket/chat.handler.js +34 -45
  252. package/dist/src/backend/routers/websocket/chat.handler.js.map +1 -1
  253. package/dist/src/backend/routers/websocket/chat.handler.test.js +86 -43
  254. package/dist/src/backend/routers/websocket/chat.handler.test.js.map +1 -1
  255. package/dist/src/backend/routers/websocket/dev-logs.handler.d.ts +3 -6
  256. package/dist/src/backend/routers/websocket/dev-logs.handler.d.ts.map +1 -1
  257. package/dist/src/backend/routers/websocket/dev-logs.handler.js +12 -68
  258. package/dist/src/backend/routers/websocket/dev-logs.handler.js.map +1 -1
  259. package/dist/src/backend/routers/websocket/dev-logs.handler.test.js +75 -3
  260. package/dist/src/backend/routers/websocket/dev-logs.handler.test.js.map +1 -1
  261. package/dist/src/backend/routers/websocket/message-utils.d.ts +6 -0
  262. package/dist/src/backend/routers/websocket/message-utils.d.ts.map +1 -1
  263. package/dist/src/backend/routers/websocket/message-utils.js +28 -0
  264. package/dist/src/backend/routers/websocket/message-utils.js.map +1 -1
  265. package/dist/src/backend/routers/websocket/message-utils.test.d.ts +2 -0
  266. package/dist/src/backend/routers/websocket/message-utils.test.d.ts.map +1 -0
  267. package/dist/src/backend/routers/websocket/message-utils.test.js +54 -0
  268. package/dist/src/backend/routers/websocket/message-utils.test.js.map +1 -0
  269. package/dist/src/backend/routers/websocket/post-run-logs.handler.d.ts +3 -6
  270. package/dist/src/backend/routers/websocket/post-run-logs.handler.d.ts.map +1 -1
  271. package/dist/src/backend/routers/websocket/post-run-logs.handler.js +12 -57
  272. package/dist/src/backend/routers/websocket/post-run-logs.handler.js.map +1 -1
  273. package/dist/src/backend/routers/websocket/post-run-logs.handler.test.js +76 -12
  274. package/dist/src/backend/routers/websocket/post-run-logs.handler.test.js.map +1 -1
  275. package/dist/src/backend/routers/websocket/push-channel.handler.d.ts +12 -0
  276. package/dist/src/backend/routers/websocket/push-channel.handler.d.ts.map +1 -0
  277. package/dist/src/backend/routers/websocket/push-channel.handler.js +40 -0
  278. package/dist/src/backend/routers/websocket/push-channel.handler.js.map +1 -0
  279. package/dist/src/backend/routers/websocket/setup-terminal.handler.d.ts +1 -4
  280. package/dist/src/backend/routers/websocket/setup-terminal.handler.d.ts.map +1 -1
  281. package/dist/src/backend/routers/websocket/setup-terminal.handler.js +14 -46
  282. package/dist/src/backend/routers/websocket/setup-terminal.handler.js.map +1 -1
  283. package/dist/src/backend/routers/websocket/setup-terminal.handler.test.js +66 -6
  284. package/dist/src/backend/routers/websocket/setup-terminal.handler.test.js.map +1 -1
  285. package/dist/src/backend/routers/websocket/snapshots.handler.d.ts +5 -8
  286. package/dist/src/backend/routers/websocket/snapshots.handler.d.ts.map +1 -1
  287. package/dist/src/backend/routers/websocket/snapshots.handler.js +71 -56
  288. package/dist/src/backend/routers/websocket/snapshots.handler.js.map +1 -1
  289. package/dist/src/backend/routers/websocket/snapshots.handler.test.js +285 -44
  290. package/dist/src/backend/routers/websocket/snapshots.handler.test.js.map +1 -1
  291. package/dist/src/backend/routers/websocket/terminal.handler.d.ts +3 -6
  292. package/dist/src/backend/routers/websocket/terminal.handler.d.ts.map +1 -1
  293. package/dist/src/backend/routers/websocket/terminal.handler.js +111 -101
  294. package/dist/src/backend/routers/websocket/terminal.handler.js.map +1 -1
  295. package/dist/src/backend/routers/websocket/terminal.handler.test.js +256 -16
  296. package/dist/src/backend/routers/websocket/terminal.handler.test.js.map +1 -1
  297. package/dist/src/backend/routers/websocket/upgrade-utils.d.ts +30 -2
  298. package/dist/src/backend/routers/websocket/upgrade-utils.d.ts.map +1 -1
  299. package/dist/src/backend/routers/websocket/upgrade-utils.js +103 -8
  300. package/dist/src/backend/routers/websocket/upgrade-utils.js.map +1 -1
  301. package/dist/src/backend/routers/websocket/upgrade-utils.test.js +316 -3
  302. package/dist/src/backend/routers/websocket/upgrade-utils.test.js.map +1 -1
  303. package/dist/src/backend/routers/websocket/websocket.integration.test.js +10 -17
  304. package/dist/src/backend/routers/websocket/websocket.integration.test.js.map +1 -1
  305. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.d.ts +0 -2
  306. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.d.ts.map +1 -1
  307. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.js +0 -3
  308. package/dist/src/backend/schemas/websocket/setup-terminal-message.schema.js.map +1 -1
  309. package/dist/src/backend/schemas/websocket/terminal-message.schema.d.ts +0 -2
  310. package/dist/src/backend/schemas/websocket/terminal-message.schema.d.ts.map +1 -1
  311. package/dist/src/backend/schemas/websocket/terminal-message.schema.js +0 -1
  312. package/dist/src/backend/schemas/websocket/terminal-message.schema.js.map +1 -1
  313. package/dist/src/backend/server.d.ts.map +1 -1
  314. package/dist/src/backend/server.js +169 -102
  315. package/dist/src/backend/server.js.map +1 -1
  316. package/dist/src/backend/server.upgrade.test.js +217 -24
  317. package/dist/src/backend/server.upgrade.test.js.map +1 -1
  318. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.d.ts.map +1 -1
  319. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.js +12 -2
  320. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.js.map +1 -1
  321. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.test.js +27 -3
  322. package/dist/src/backend/services/auto-iteration/service/auto-iteration.service.test.js.map +1 -1
  323. package/dist/src/backend/services/check-single-writer.test.js +7 -7
  324. package/dist/src/backend/services/check-single-writer.test.js.map +1 -1
  325. package/dist/src/backend/services/config.service.d.ts +1 -0
  326. package/dist/src/backend/services/config.service.d.ts.map +1 -1
  327. package/dist/src/backend/services/config.service.js +20 -5
  328. package/dist/src/backend/services/config.service.js.map +1 -1
  329. package/dist/src/backend/services/config.service.test.js +18 -0
  330. package/dist/src/backend/services/config.service.test.js.map +1 -1
  331. package/dist/src/backend/services/constants.d.ts +1 -1
  332. package/dist/src/backend/services/constants.js +1 -1
  333. package/dist/src/backend/services/constants.js.map +1 -1
  334. package/dist/src/backend/services/env-schemas.d.ts +1 -0
  335. package/dist/src/backend/services/env-schemas.d.ts.map +1 -1
  336. package/dist/src/backend/services/env-schemas.js +1 -0
  337. package/dist/src/backend/services/env-schemas.js.map +1 -1
  338. package/dist/src/backend/services/git-clone.service.d.ts.map +1 -1
  339. package/dist/src/backend/services/git-clone.service.js +19 -3
  340. package/dist/src/backend/services/git-clone.service.js.map +1 -1
  341. package/dist/src/backend/services/git-clone.service.test.js +81 -1
  342. package/dist/src/backend/services/git-clone.service.test.js.map +1 -1
  343. package/dist/src/backend/services/git-ops.service.d.ts.map +1 -1
  344. package/dist/src/backend/services/git-ops.service.js +6 -1
  345. package/dist/src/backend/services/git-ops.service.js.map +1 -1
  346. package/dist/src/backend/services/git-ops.service.test.js +24 -3
  347. package/dist/src/backend/services/git-ops.service.test.js.map +1 -1
  348. package/dist/src/backend/services/github/service/github-cli/errors.d.ts.map +1 -1
  349. package/dist/src/backend/services/github/service/github-cli/errors.js +6 -1
  350. package/dist/src/backend/services/github/service/github-cli/errors.js.map +1 -1
  351. package/dist/src/backend/services/github/service/github-cli/mappers.js +1 -1
  352. package/dist/src/backend/services/github/service/github-cli/mappers.js.map +1 -1
  353. package/dist/src/backend/services/github/service/github-cli/schemas.d.ts +91 -8
  354. package/dist/src/backend/services/github/service/github-cli/schemas.d.ts.map +1 -1
  355. package/dist/src/backend/services/github/service/github-cli/schemas.js +65 -3
  356. package/dist/src/backend/services/github/service/github-cli/schemas.js.map +1 -1
  357. package/dist/src/backend/services/github/service/github-cli/types.d.ts +1 -2
  358. package/dist/src/backend/services/github/service/github-cli/types.d.ts.map +1 -1
  359. package/dist/src/backend/services/github/service/github-cli.service.d.ts +9 -3
  360. package/dist/src/backend/services/github/service/github-cli.service.d.ts.map +1 -1
  361. package/dist/src/backend/services/github/service/github-cli.service.js +287 -65
  362. package/dist/src/backend/services/github/service/github-cli.service.js.map +1 -1
  363. package/dist/src/backend/services/github/service/github-cli.service.test.js +413 -71
  364. package/dist/src/backend/services/github/service/github-cli.service.test.js.map +1 -1
  365. package/dist/src/backend/services/github/service/index.d.ts +2 -0
  366. package/dist/src/backend/services/github/service/index.d.ts.map +1 -1
  367. package/dist/src/backend/services/github/service/index.js +2 -0
  368. package/dist/src/backend/services/github/service/index.js.map +1 -1
  369. package/dist/src/backend/services/github/service/pr-fetch-registry.d.ts +13 -0
  370. package/dist/src/backend/services/github/service/pr-fetch-registry.d.ts.map +1 -0
  371. package/dist/src/backend/services/github/service/pr-fetch-registry.js +34 -0
  372. package/dist/src/backend/services/github/service/pr-fetch-registry.js.map +1 -0
  373. package/dist/src/backend/services/github/service/pr-snapshot.service.d.ts +1 -0
  374. package/dist/src/backend/services/github/service/pr-snapshot.service.d.ts.map +1 -1
  375. package/dist/src/backend/services/github/service/pr-snapshot.service.js +12 -0
  376. package/dist/src/backend/services/github/service/pr-snapshot.service.js.map +1 -1
  377. package/dist/src/backend/services/linear/service/linear-client.service.d.ts.map +1 -1
  378. package/dist/src/backend/services/linear/service/linear-client.service.js +33 -20
  379. package/dist/src/backend/services/linear/service/linear-client.service.js.map +1 -1
  380. package/dist/src/backend/services/linear/service/linear-client.service.test.js +76 -0
  381. package/dist/src/backend/services/linear/service/linear-client.service.test.js.map +1 -1
  382. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.d.ts +13 -1
  383. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.d.ts.map +1 -1
  384. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.js +40 -13
  385. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.js.map +1 -1
  386. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.test.js +58 -0
  387. package/dist/src/backend/services/periodic-task/resources/periodic-task.accessor.test.js.map +1 -1
  388. package/dist/src/backend/services/periodic-task/service/periodic-task.service.d.ts +2 -0
  389. package/dist/src/backend/services/periodic-task/service/periodic-task.service.d.ts.map +1 -1
  390. package/dist/src/backend/services/periodic-task/service/periodic-task.service.js +54 -9
  391. package/dist/src/backend/services/periodic-task/service/periodic-task.service.js.map +1 -1
  392. package/dist/src/backend/services/periodic-task/service/periodic-task.service.test.js +131 -1
  393. package/dist/src/backend/services/periodic-task/service/periodic-task.service.test.js.map +1 -1
  394. package/dist/src/backend/services/port-allocation.service.d.ts +2 -1
  395. package/dist/src/backend/services/port-allocation.service.d.ts.map +1 -1
  396. package/dist/src/backend/services/port-allocation.service.js +12 -4
  397. package/dist/src/backend/services/port-allocation.service.js.map +1 -1
  398. package/dist/src/backend/services/port-allocation.service.test.d.ts +2 -0
  399. package/dist/src/backend/services/port-allocation.service.test.d.ts.map +1 -0
  400. package/dist/src/backend/services/port-allocation.service.test.js +43 -0
  401. package/dist/src/backend/services/port-allocation.service.test.js.map +1 -0
  402. package/dist/src/backend/services/ratchet/service/bridges.d.ts +25 -11
  403. package/dist/src/backend/services/ratchet/service/bridges.d.ts.map +1 -1
  404. package/dist/src/backend/services/ratchet/service/fixer-session.service.d.ts +1 -0
  405. package/dist/src/backend/services/ratchet/service/fixer-session.service.d.ts.map +1 -1
  406. package/dist/src/backend/services/ratchet/service/fixer-session.service.js +9 -2
  407. package/dist/src/backend/services/ratchet/service/fixer-session.service.js.map +1 -1
  408. package/dist/src/backend/services/ratchet/service/fixer-session.service.test.js +25 -0
  409. package/dist/src/backend/services/ratchet/service/fixer-session.service.test.js.map +1 -1
  410. package/dist/src/backend/services/ratchet/service/index.d.ts +1 -2
  411. package/dist/src/backend/services/ratchet/service/index.d.ts.map +1 -1
  412. package/dist/src/backend/services/ratchet/service/index.js +0 -1
  413. package/dist/src/backend/services/ratchet/service/index.js.map +1 -1
  414. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.d.ts +6 -12
  415. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.d.ts.map +1 -1
  416. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.js +52 -56
  417. package/dist/src/backend/services/ratchet/service/ratchet-active-session.helpers.js.map +1 -1
  418. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.d.ts +2 -5
  419. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.d.ts.map +1 -1
  420. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.js +7 -4
  421. package/dist/src/backend/services/ratchet/service/ratchet-decision-logging.helpers.js.map +1 -1
  422. package/dist/src/backend/services/ratchet/service/ratchet-domain-exports.test.js +1 -4
  423. package/dist/src/backend/services/ratchet/service/ratchet-domain-exports.test.js.map +1 -1
  424. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.d.ts +3 -6
  425. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.d.ts.map +1 -1
  426. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.js +145 -22
  427. package/dist/src/backend/services/ratchet/service/ratchet-fixer-dispatch.helpers.js.map +1 -1
  428. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.d.ts +21 -30
  429. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.d.ts.map +1 -1
  430. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.js +96 -31
  431. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.js.map +1 -1
  432. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.test.js +430 -11
  433. package/dist/src/backend/services/ratchet/service/ratchet-pr-state.helpers.test.js.map +1 -1
  434. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.d.ts +11 -0
  435. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.d.ts.map +1 -0
  436. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.js +77 -0
  437. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.js.map +1 -0
  438. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.d.ts +2 -0
  439. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.d.ts.map +1 -0
  440. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.js +66 -0
  441. package/dist/src/backend/services/ratchet/service/ratchet-state-machine.test.js.map +1 -0
  442. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.d.ts +2 -1
  443. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.d.ts.map +1 -1
  444. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.js +35 -9
  445. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.js.map +1 -1
  446. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.test.js +91 -0
  447. package/dist/src/backend/services/ratchet/service/ratchet-workspace-check-coordinator.test.js.map +1 -1
  448. package/dist/src/backend/services/ratchet/service/ratchet.service.d.ts +12 -56
  449. package/dist/src/backend/services/ratchet/service/ratchet.service.d.ts.map +1 -1
  450. package/dist/src/backend/services/ratchet/service/ratchet.service.js +248 -218
  451. package/dist/src/backend/services/ratchet/service/ratchet.service.js.map +1 -1
  452. package/dist/src/backend/services/ratchet/service/ratchet.service.test.js +1520 -404
  453. package/dist/src/backend/services/ratchet/service/ratchet.service.test.js.map +1 -1
  454. package/dist/src/backend/services/ratchet/service/ratchet.types.d.ts +20 -13
  455. package/dist/src/backend/services/ratchet/service/ratchet.types.d.ts.map +1 -1
  456. package/dist/src/backend/services/registry.d.ts +3 -3
  457. package/dist/src/backend/services/registry.d.ts.map +1 -1
  458. package/dist/src/backend/services/registry.js +3 -2
  459. package/dist/src/backend/services/registry.js.map +1 -1
  460. package/dist/src/backend/services/resources.integration.test.js +101 -10
  461. package/dist/src/backend/services/resources.integration.test.js.map +1 -1
  462. package/dist/src/backend/services/run-script/service/run-script.service.d.ts +5 -0
  463. package/dist/src/backend/services/run-script/service/run-script.service.d.ts.map +1 -1
  464. package/dist/src/backend/services/run-script/service/run-script.service.js +130 -33
  465. package/dist/src/backend/services/run-script/service/run-script.service.js.map +1 -1
  466. package/dist/src/backend/services/run-script/service/run-script.service.test.js +276 -24
  467. package/dist/src/backend/services/run-script/service/run-script.service.test.js.map +1 -1
  468. package/dist/src/backend/services/session/resources/agent-session.accessor.test.js +19 -0
  469. package/dist/src/backend/services/session/resources/agent-session.accessor.test.js.map +1 -1
  470. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.d.ts +13 -0
  471. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.d.ts.map +1 -1
  472. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.js +192 -23
  473. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.js.map +1 -1
  474. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.test.js +236 -24
  475. package/dist/src/backend/services/session/service/acp/acp-runtime-manager.test.js.map +1 -1
  476. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.d.ts +11 -0
  477. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.d.ts.map +1 -0
  478. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.js +318 -0
  479. package/dist/src/backend/services/session/service/acp/child-workspace-mcp-server.js.map +1 -0
  480. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-app-server-acp-adapter.test.js +69 -0
  481. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-app-server-acp-adapter.test.js.map +1 -1
  482. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.d.ts +18 -1
  483. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.d.ts.map +1 -1
  484. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.js +54 -6
  485. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.js.map +1 -1
  486. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.test.js +76 -1
  487. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/codex-rpc-client.test.js.map +1 -1
  488. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/session-config-resolver.d.ts.map +1 -1
  489. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/session-config-resolver.js +7 -2
  490. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/session-config-resolver.js.map +1 -1
  491. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/session-config-resolver.test.js +19 -0
  492. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/session-config-resolver.test.js.map +1 -1
  493. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/stream-event-handler.d.ts.map +1 -1
  494. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/stream-event-handler.js +4 -0
  495. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/stream-event-handler.js.map +1 -1
  496. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/stream-event-handler.test.js +54 -0
  497. package/dist/src/backend/services/session/service/acp/codex-app-server-adapter/stream-event-handler.test.js.map +1 -1
  498. package/dist/src/backend/services/session/service/acp/types.d.ts +7 -0
  499. package/dist/src/backend/services/session/service/acp/types.d.ts.map +1 -1
  500. package/dist/src/backend/services/session/service/bridges.d.ts +9 -5
  501. package/dist/src/backend/services/session/service/bridges.d.ts.map +1 -1
  502. package/dist/src/backend/services/session/service/chat/chat-event-forwarder.service.d.ts.map +1 -1
  503. package/dist/src/backend/services/session/service/chat/chat-event-forwarder.service.js +2 -14
  504. package/dist/src/backend/services/session/service/chat/chat-event-forwarder.service.js.map +1 -1
  505. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-processing.d.ts.map +1 -1
  506. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-processing.js +4 -3
  507. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-processing.js.map +1 -1
  508. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-processing.test.js +19 -0
  509. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-processing.test.js.map +1 -1
  510. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-utils.d.ts +1 -0
  511. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-utils.d.ts.map +1 -1
  512. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-utils.js +11 -1
  513. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-utils.js.map +1 -1
  514. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-utils.test.js +3 -0
  515. package/dist/src/backend/services/session/service/chat/chat-message-handlers/attachment-utils.test.js.map +1 -1
  516. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.d.ts.map +1 -1
  517. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.js +256 -51
  518. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.js.map +1 -1
  519. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.test.js +694 -1
  520. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/load-session.handler.test.js.map +1 -1
  521. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.d.ts.map +1 -1
  522. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.js +2 -11
  523. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.js.map +1 -1
  524. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.test.js +5 -18
  525. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/queue-message.handler.test.js.map +1 -1
  526. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.d.ts.map +1 -1
  527. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.js +9 -0
  528. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.js.map +1 -1
  529. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.test.js +34 -0
  530. package/dist/src/backend/services/session/service/chat/chat-message-handlers/handlers/start.handler.test.js.map +1 -1
  531. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.d.ts +11 -0
  532. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.d.ts.map +1 -1
  533. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.js +148 -26
  534. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.js.map +1 -1
  535. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.test.js +339 -1
  536. package/dist/src/backend/services/session/service/chat/chat-message-handlers.service.test.js.map +1 -1
  537. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.d.ts.map +1 -1
  538. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.js +200 -26
  539. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.js.map +1 -1
  540. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.test.js +352 -1
  541. package/dist/src/backend/services/session/service/data/codex-session-history-loader.service.test.js.map +1 -1
  542. package/dist/src/backend/services/session/service/data/session-data.service.d.ts +13 -6
  543. package/dist/src/backend/services/session/service/data/session-data.service.d.ts.map +1 -1
  544. package/dist/src/backend/services/session/service/data/session-data.service.js +2 -2
  545. package/dist/src/backend/services/session/service/data/session-data.service.js.map +1 -1
  546. package/dist/src/backend/services/session/service/data/session-history-loader.service.d.ts.map +1 -1
  547. package/dist/src/backend/services/session/service/data/session-history-loader.service.js +4 -1
  548. package/dist/src/backend/services/session/service/data/session-history-loader.service.js.map +1 -1
  549. package/dist/src/backend/services/session/service/data/session-history-loader.service.test.js +37 -0
  550. package/dist/src/backend/services/session/service/data/session-history-loader.service.test.js.map +1 -1
  551. package/dist/src/backend/services/session/service/index.d.ts +2 -2
  552. package/dist/src/backend/services/session/service/index.d.ts.map +1 -1
  553. package/dist/src/backend/services/session/service/index.js +1 -1
  554. package/dist/src/backend/services/session/service/index.js.map +1 -1
  555. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.d.ts +4 -0
  556. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.d.ts.map +1 -1
  557. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.js +32 -0
  558. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.js.map +1 -1
  559. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.d.ts +2 -0
  560. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.d.ts.map +1 -0
  561. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.js +159 -0
  562. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.slash-commands.test.js.map +1 -0
  563. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.tool-timeout.test.js +45 -9
  564. package/dist/src/backend/services/session/service/lifecycle/acp-event-processor.tool-timeout.test.js.map +1 -1
  565. package/dist/src/backend/services/session/service/lifecycle/session-config-option-helpers.js +1 -1
  566. package/dist/src/backend/services/session/service/lifecycle/session-config-option-helpers.js.map +1 -1
  567. package/dist/src/backend/services/session/service/lifecycle/session.config.service.d.ts +4 -0
  568. package/dist/src/backend/services/session/service/lifecycle/session.config.service.d.ts.map +1 -1
  569. package/dist/src/backend/services/session/service/lifecycle/session.config.service.js +31 -13
  570. package/dist/src/backend/services/session/service/lifecycle/session.config.service.js.map +1 -1
  571. package/dist/src/backend/services/session/service/lifecycle/session.config.service.test.js +36 -0
  572. package/dist/src/backend/services/session/service/lifecycle/session.config.service.test.js.map +1 -1
  573. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.d.ts +23 -3
  574. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.d.ts.map +1 -1
  575. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.js +329 -35
  576. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.js.map +1 -1
  577. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.d.ts +2 -0
  578. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.d.ts.map +1 -0
  579. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.js +822 -0
  580. package/dist/src/backend/services/session/service/lifecycle/session.lifecycle.service.test.js.map +1 -0
  581. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.d.ts +4 -0
  582. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.d.ts.map +1 -1
  583. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.js +13 -0
  584. package/dist/src/backend/services/session/service/lifecycle/session.prompt-builder.js.map +1 -1
  585. package/dist/src/backend/services/session/service/lifecycle/session.service.coverage.test.js +6 -0
  586. package/dist/src/backend/services/session/service/lifecycle/session.service.coverage.test.js.map +1 -1
  587. package/dist/src/backend/services/session/service/lifecycle/session.service.d.ts +12 -6
  588. package/dist/src/backend/services/session/service/lifecycle/session.service.d.ts.map +1 -1
  589. package/dist/src/backend/services/session/service/lifecycle/session.service.js +18 -5
  590. package/dist/src/backend/services/session/service/lifecycle/session.service.js.map +1 -1
  591. package/dist/src/backend/services/session/service/lifecycle/session.service.test.js +190 -18
  592. package/dist/src/backend/services/session/service/lifecycle/session.service.test.js.map +1 -1
  593. package/dist/src/backend/services/session/service/session-domain.service.d.ts +3 -0
  594. package/dist/src/backend/services/session/service/session-domain.service.d.ts.map +1 -1
  595. package/dist/src/backend/services/session/service/session-domain.service.js +30 -0
  596. package/dist/src/backend/services/session/service/session-domain.service.js.map +1 -1
  597. package/dist/src/backend/services/session/service/session-domain.service.test.js +39 -16
  598. package/dist/src/backend/services/session/service/session-domain.service.test.js.map +1 -1
  599. package/dist/src/backend/services/session/service/session-event-bus.d.ts +21 -0
  600. package/dist/src/backend/services/session/service/session-event-bus.d.ts.map +1 -0
  601. package/dist/src/backend/services/session/service/session-event-bus.js +28 -0
  602. package/dist/src/backend/services/session/service/session-event-bus.js.map +1 -0
  603. package/dist/src/backend/services/session/service/store/session-publisher.js +4 -4
  604. package/dist/src/backend/services/session/service/store/session-publisher.js.map +1 -1
  605. package/dist/src/backend/services/session/service/store/session-queue.test.js +1 -0
  606. package/dist/src/backend/services/session/service/store/session-queue.test.js.map +1 -1
  607. package/dist/src/backend/services/session/service/store/session-replay-builder.d.ts.map +1 -1
  608. package/dist/src/backend/services/session/service/store/session-replay-builder.js +25 -3
  609. package/dist/src/backend/services/session/service/store/session-replay-builder.js.map +1 -1
  610. package/dist/src/backend/services/session/service/store/session-replay-builder.test.js +123 -0
  611. package/dist/src/backend/services/session/service/store/session-replay-builder.test.js.map +1 -1
  612. package/dist/src/backend/services/session/service/store/session-runtime-machine.test.js +1 -0
  613. package/dist/src/backend/services/session/service/store/session-runtime-machine.test.js.map +1 -1
  614. package/dist/src/backend/services/session/service/store/session-store-registry.d.ts.map +1 -1
  615. package/dist/src/backend/services/session/service/store/session-store-registry.js +1 -0
  616. package/dist/src/backend/services/session/service/store/session-store-registry.js.map +1 -1
  617. package/dist/src/backend/services/session/service/store/session-store.types.d.ts +7 -0
  618. package/dist/src/backend/services/session/service/store/session-store.types.d.ts.map +1 -1
  619. package/dist/src/backend/services/session/service/store/session-transcript.test.js +1 -0
  620. package/dist/src/backend/services/session/service/store/session-transcript.test.js.map +1 -1
  621. package/dist/src/backend/services/session/service/store/slash-command-cache.service.d.ts.map +1 -1
  622. package/dist/src/backend/services/session/service/store/slash-command-cache.service.js +46 -10
  623. package/dist/src/backend/services/session/service/store/slash-command-cache.service.js.map +1 -1
  624. package/dist/src/backend/services/session/service/store/slash-command-cache.service.test.js +64 -3
  625. package/dist/src/backend/services/session/service/store/slash-command-cache.service.test.js.map +1 -1
  626. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.d.ts +7 -0
  627. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.d.ts.map +1 -0
  628. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.js +94 -0
  629. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.js.map +1 -0
  630. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.d.ts +2 -0
  631. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.d.ts.map +1 -0
  632. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.js +52 -0
  633. package/dist/src/backend/services/session/service/store/slash-command-disk-scanner.test.js.map +1 -0
  634. package/dist/src/backend/services/settings/resources/user-settings.accessor.d.ts +2 -1
  635. package/dist/src/backend/services/settings/resources/user-settings.accessor.d.ts.map +1 -1
  636. package/dist/src/backend/services/settings/resources/user-settings.accessor.js +46 -13
  637. package/dist/src/backend/services/settings/resources/user-settings.accessor.js.map +1 -1
  638. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.d.ts +1 -1
  639. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.d.ts.map +1 -1
  640. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.js +2 -2
  641. package/dist/src/backend/services/terminal/resources/terminal-session.accessor.js.map +1 -1
  642. package/dist/src/backend/services/terminal/service/terminal.service.d.ts +2 -0
  643. package/dist/src/backend/services/terminal/service/terminal.service.d.ts.map +1 -1
  644. package/dist/src/backend/services/terminal/service/terminal.service.js +22 -4
  645. package/dist/src/backend/services/terminal/service/terminal.service.js.map +1 -1
  646. package/dist/src/backend/services/terminal/service/terminal.service.test.js +77 -1
  647. package/dist/src/backend/services/terminal/service/terminal.service.test.js.map +1 -1
  648. package/dist/src/backend/services/workspace/index.d.ts +1 -0
  649. package/dist/src/backend/services/workspace/index.d.ts.map +1 -1
  650. package/dist/src/backend/services/workspace/index.js +1 -0
  651. package/dist/src/backend/services/workspace/index.js.map +1 -1
  652. package/dist/src/backend/services/workspace/resources/project.accessor.d.ts +1 -1
  653. package/dist/src/backend/services/workspace/resources/project.accessor.d.ts.map +1 -1
  654. package/dist/src/backend/services/workspace/resources/project.accessor.js +26 -1
  655. package/dist/src/backend/services/workspace/resources/project.accessor.js.map +1 -1
  656. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.d.ts +19 -0
  657. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.d.ts.map +1 -0
  658. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.js +34 -0
  659. package/dist/src/backend/services/workspace/resources/workspace-notification.accessor.js.map +1 -0
  660. package/dist/src/backend/services/workspace/resources/workspace.accessor.d.ts +26 -4
  661. package/dist/src/backend/services/workspace/resources/workspace.accessor.d.ts.map +1 -1
  662. package/dist/src/backend/services/workspace/resources/workspace.accessor.js +69 -3
  663. package/dist/src/backend/services/workspace/resources/workspace.accessor.js.map +1 -1
  664. package/dist/src/backend/services/workspace/resources/workspace.accessor.test.js +84 -0
  665. package/dist/src/backend/services/workspace/resources/workspace.accessor.test.js.map +1 -1
  666. package/dist/src/backend/services/workspace/service/bridges.d.ts +4 -0
  667. package/dist/src/backend/services/workspace/service/bridges.d.ts.map +1 -1
  668. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.d.ts +2 -2
  669. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.d.ts.map +1 -1
  670. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.js +20 -3
  671. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.js.map +1 -1
  672. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.test.js +30 -0
  673. package/dist/src/backend/services/workspace/service/lifecycle/activity.service.test.js.map +1 -1
  674. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.d.ts +13 -0
  675. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.d.ts.map +1 -1
  676. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.js +55 -2
  677. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.js.map +1 -1
  678. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.test.js +102 -0
  679. package/dist/src/backend/services/workspace/service/lifecycle/creation.service.test.js.map +1 -1
  680. package/dist/src/backend/services/workspace/service/lifecycle/data.service.d.ts +41 -14
  681. package/dist/src/backend/services/workspace/service/lifecycle/data.service.d.ts.map +1 -1
  682. package/dist/src/backend/services/workspace/service/lifecycle/data.service.js +2 -1
  683. package/dist/src/backend/services/workspace/service/lifecycle/data.service.js.map +1 -1
  684. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.d.ts +2 -0
  685. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.d.ts.map +1 -0
  686. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.js +23 -0
  687. package/dist/src/backend/services/workspace/service/lifecycle/data.service.test.js.map +1 -0
  688. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.d.ts +2 -0
  689. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.d.ts.map +1 -1
  690. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.js +42 -25
  691. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.js.map +1 -1
  692. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.test.js +129 -4
  693. package/dist/src/backend/services/workspace/service/lifecycle/state-machine.service.test.js.map +1 -1
  694. package/dist/src/backend/services/workspace/service/query/project-management.service.d.ts +11 -5
  695. package/dist/src/backend/services/workspace/service/query/project-management.service.d.ts.map +1 -1
  696. package/dist/src/backend/services/workspace/service/query/workspace-query.service.d.ts +24 -9
  697. package/dist/src/backend/services/workspace/service/query/workspace-query.service.d.ts.map +1 -1
  698. package/dist/src/backend/services/workspace/service/query/workspace-query.service.js +82 -42
  699. package/dist/src/backend/services/workspace/service/query/workspace-query.service.js.map +1 -1
  700. package/dist/src/backend/services/workspace/service/query/workspace-query.service.test.js +157 -6
  701. package/dist/src/backend/services/workspace/service/query/workspace-query.service.test.js.map +1 -1
  702. package/dist/src/backend/services/workspace/service/state/kanban-state.d.ts +0 -1
  703. package/dist/src/backend/services/workspace/service/state/kanban-state.d.ts.map +1 -1
  704. package/dist/src/backend/services/workspace/service/state/kanban-state.js +1 -7
  705. package/dist/src/backend/services/workspace/service/state/kanban-state.js.map +1 -1
  706. package/dist/src/backend/services/workspace/service/state/kanban-state.test.js +2 -11
  707. package/dist/src/backend/services/workspace/service/state/kanban-state.test.js.map +1 -1
  708. package/dist/src/backend/services/workspace/service/worktree/worktree-init.test.js +1 -1
  709. package/dist/src/backend/services/workspace/service/worktree/worktree-init.test.js.map +1 -1
  710. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.d.ts +1 -1
  711. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.d.ts.map +1 -1
  712. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.js +40 -5
  713. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.js.map +1 -1
  714. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.test.js +57 -6
  715. package/dist/src/backend/services/workspace/service/worktree/worktree-lifecycle.service.test.js.map +1 -1
  716. package/dist/src/backend/services/workspace-snapshot-store.service.d.ts +4 -2
  717. package/dist/src/backend/services/workspace-snapshot-store.service.d.ts.map +1 -1
  718. package/dist/src/backend/services/workspace-snapshot-store.service.js +26 -1
  719. package/dist/src/backend/services/workspace-snapshot-store.service.js.map +1 -1
  720. package/dist/src/backend/services/workspace-snapshot-store.service.test.js +32 -0
  721. package/dist/src/backend/services/workspace-snapshot-store.service.test.js.map +1 -1
  722. package/dist/src/backend/trpc/admin.trpc.d.ts +10 -10
  723. package/dist/src/backend/trpc/github.router.test.js +29 -0
  724. package/dist/src/backend/trpc/github.router.test.js.map +1 -1
  725. package/dist/src/backend/trpc/github.trpc.d.ts +6 -5
  726. package/dist/src/backend/trpc/github.trpc.d.ts.map +1 -1
  727. package/dist/src/backend/trpc/github.trpc.js +26 -1
  728. package/dist/src/backend/trpc/github.trpc.js.map +1 -1
  729. package/dist/src/backend/trpc/index.d.ts +294 -71
  730. package/dist/src/backend/trpc/index.d.ts.map +1 -1
  731. package/dist/src/backend/trpc/periodic-task.trpc.d.ts +5 -5
  732. package/dist/src/backend/trpc/procedures/project-scoped.d.ts +1 -0
  733. package/dist/src/backend/trpc/procedures/project-scoped.d.ts.map +1 -1
  734. package/dist/src/backend/trpc/project.router.test.js +115 -18
  735. package/dist/src/backend/trpc/project.router.test.js.map +1 -1
  736. package/dist/src/backend/trpc/project.trpc.d.ts +27 -9
  737. package/dist/src/backend/trpc/project.trpc.d.ts.map +1 -1
  738. package/dist/src/backend/trpc/project.trpc.js +85 -15
  739. package/dist/src/backend/trpc/project.trpc.js.map +1 -1
  740. package/dist/src/backend/trpc/session.router.test.js +38 -1
  741. package/dist/src/backend/trpc/session.router.test.js.map +1 -1
  742. package/dist/src/backend/trpc/session.trpc.d.ts +34 -19
  743. package/dist/src/backend/trpc/session.trpc.d.ts.map +1 -1
  744. package/dist/src/backend/trpc/session.trpc.js +20 -6
  745. package/dist/src/backend/trpc/session.trpc.js.map +1 -1
  746. package/dist/src/backend/trpc/trpc.d.ts +9 -0
  747. package/dist/src/backend/trpc/trpc.d.ts.map +1 -1
  748. package/dist/src/backend/trpc/trpc.js +39 -1
  749. package/dist/src/backend/trpc/trpc.js.map +1 -1
  750. package/dist/src/backend/trpc/trpc.test.d.ts +2 -0
  751. package/dist/src/backend/trpc/trpc.test.d.ts.map +1 -0
  752. package/dist/src/backend/trpc/trpc.test.js +19 -0
  753. package/dist/src/backend/trpc/trpc.test.js.map +1 -0
  754. package/dist/src/backend/trpc/user-settings.router.test.js +1 -0
  755. package/dist/src/backend/trpc/user-settings.router.test.js.map +1 -1
  756. package/dist/src/backend/trpc/user-settings.trpc.d.ts +2 -2
  757. package/dist/src/backend/trpc/user-settings.trpc.d.ts.map +1 -1
  758. package/dist/src/backend/trpc/user-settings.trpc.js +1 -0
  759. package/dist/src/backend/trpc/user-settings.trpc.js.map +1 -1
  760. package/dist/src/backend/trpc/workspace/files.trpc.d.ts.map +1 -1
  761. package/dist/src/backend/trpc/workspace/files.trpc.js +5 -9
  762. package/dist/src/backend/trpc/workspace/files.trpc.js.map +1 -1
  763. package/dist/src/backend/trpc/workspace/init.router.test.js +24 -2
  764. package/dist/src/backend/trpc/workspace/init.router.test.js.map +1 -1
  765. package/dist/src/backend/trpc/workspace/init.trpc.d.ts +6 -3
  766. package/dist/src/backend/trpc/workspace/init.trpc.d.ts.map +1 -1
  767. package/dist/src/backend/trpc/workspace/init.trpc.js +2 -2
  768. package/dist/src/backend/trpc/workspace/init.trpc.js.map +1 -1
  769. package/dist/src/backend/trpc/workspace/run-script.router.test.js +23 -1
  770. package/dist/src/backend/trpc/workspace/run-script.router.test.js.map +1 -1
  771. package/dist/src/backend/trpc/workspace/run-script.trpc.js +4 -4
  772. package/dist/src/backend/trpc/workspace/run-script.trpc.js.map +1 -1
  773. package/dist/src/backend/trpc/workspace/workspace-helpers.d.ts +11 -5
  774. package/dist/src/backend/trpc/workspace/workspace-helpers.d.ts.map +1 -1
  775. package/dist/src/backend/trpc/workspace.router.test.js +348 -2
  776. package/dist/src/backend/trpc/workspace.router.test.js.map +1 -1
  777. package/dist/src/backend/trpc/workspace.trpc.d.ts +216 -26
  778. package/dist/src/backend/trpc/workspace.trpc.d.ts.map +1 -1
  779. package/dist/src/backend/trpc/workspace.trpc.js +262 -20
  780. package/dist/src/backend/trpc/workspace.trpc.js.map +1 -1
  781. package/dist/src/cli/database-path.d.ts.map +1 -1
  782. package/dist/src/cli/database-path.js +4 -2
  783. package/dist/src/cli/database-path.js.map +1 -1
  784. package/dist/src/cli/database-path.test.js +10 -0
  785. package/dist/src/cli/database-path.test.js.map +1 -1
  786. package/dist/src/cli/serve-env.d.ts.map +1 -1
  787. package/dist/src/cli/serve-env.js +6 -0
  788. package/dist/src/cli/serve-env.js.map +1 -1
  789. package/dist/src/cli/serve-env.test.js +36 -0
  790. package/dist/src/cli/serve-env.test.js.map +1 -1
  791. package/dist/src/shared/acp-protocol/protocol/constants.d.ts +1 -0
  792. package/dist/src/shared/acp-protocol/protocol/constants.d.ts.map +1 -1
  793. package/dist/src/shared/acp-protocol/protocol/constants.js +1 -0
  794. package/dist/src/shared/acp-protocol/protocol/constants.js.map +1 -1
  795. package/dist/src/shared/acp-protocol/protocol/index.d.ts +1 -0
  796. package/dist/src/shared/acp-protocol/protocol/index.d.ts.map +1 -1
  797. package/dist/src/shared/acp-protocol/protocol/index.js +1 -0
  798. package/dist/src/shared/acp-protocol/protocol/index.js.map +1 -1
  799. package/dist/src/shared/acp-protocol/protocol/messages.d.ts +8 -1
  800. package/dist/src/shared/acp-protocol/protocol/messages.d.ts.map +1 -1
  801. package/dist/src/shared/acp-protocol/protocol/messages.js +2 -0
  802. package/dist/src/shared/acp-protocol/protocol/messages.js.map +1 -1
  803. package/dist/src/shared/acp-protocol/protocol/renderer-window.d.ts +3 -0
  804. package/dist/src/shared/acp-protocol/protocol/renderer-window.d.ts.map +1 -0
  805. package/dist/src/shared/acp-protocol/protocol/renderer-window.js +36 -0
  806. package/dist/src/shared/acp-protocol/protocol/renderer-window.js.map +1 -0
  807. package/dist/src/shared/core/enums.d.ts +18 -0
  808. package/dist/src/shared/core/enums.d.ts.map +1 -1
  809. package/dist/src/shared/core/enums.js +15 -0
  810. package/dist/src/shared/core/enums.js.map +1 -1
  811. package/dist/src/shared/core/index.d.ts +1 -1
  812. package/dist/src/shared/core/index.d.ts.map +1 -1
  813. package/dist/src/shared/core/index.js +1 -1
  814. package/dist/src/shared/core/index.js.map +1 -1
  815. package/dist/src/shared/issue-start-prompt.d.ts +14 -0
  816. package/dist/src/shared/issue-start-prompt.d.ts.map +1 -0
  817. package/dist/src/shared/issue-start-prompt.js +208 -0
  818. package/dist/src/shared/issue-start-prompt.js.map +1 -0
  819. package/dist/src/shared/proxy-utils.d.ts +2 -0
  820. package/dist/src/shared/proxy-utils.d.ts.map +1 -1
  821. package/dist/src/shared/proxy-utils.js +20 -2
  822. package/dist/src/shared/proxy-utils.js.map +1 -1
  823. package/dist/src/shared/schemas/auto-iteration.schema.d.ts +2 -2
  824. package/dist/src/shared/schemas/export-data.schema.d.ts +18 -16
  825. package/dist/src/shared/schemas/export-data.schema.d.ts.map +1 -1
  826. package/dist/src/shared/schemas/export-data.schema.js +4 -4
  827. package/dist/src/shared/schemas/export-data.schema.js.map +1 -1
  828. package/dist/src/shared/session-runtime.d.ts +9 -0
  829. package/dist/src/shared/session-runtime.d.ts.map +1 -1
  830. package/dist/src/shared/session-runtime.js +23 -0
  831. package/dist/src/shared/session-runtime.js.map +1 -1
  832. package/dist/src/shared/workspace-notifications.d.ts +10 -0
  833. package/dist/src/shared/workspace-notifications.d.ts.map +1 -0
  834. package/dist/src/shared/workspace-notifications.js +11 -0
  835. package/dist/src/shared/workspace-notifications.js.map +1 -0
  836. package/dist/src/shared/workspace-status-reason.d.ts +26 -0
  837. package/dist/src/shared/workspace-status-reason.d.ts.map +1 -0
  838. package/dist/src/shared/workspace-status-reason.js +84 -0
  839. package/dist/src/shared/workspace-status-reason.js.map +1 -0
  840. package/dist/tsconfig.backend.tsbuildinfo +1 -1
  841. package/package.json +21 -13
  842. package/prisma/migrations/20260622135020_add_child_workspaces/migration.sql +99 -0
  843. package/prisma/migrations/20260622135100_add_child_workspaces/migration.sql +39 -0
  844. package/prisma/migrations/20260622195338_add_notification_direction/migration.sql +22 -0
  845. package/prisma/migrations/20260622220000_add_parent_workspace_id_index/migration.sql +2 -0
  846. package/prisma/migrations/20260715201522_add_ratchet_dispatch_record/migration.sql +125 -0
  847. package/prisma/schema.prisma +43 -1
  848. package/prompts/ratchet/dispatch.md +2 -0
  849. package/dist/client/assets/channel-qE_eugFJ.js +0 -1
  850. package/dist/client/assets/chunk-2J33WTMH-BSA0QyBP.js +0 -1
  851. package/dist/client/assets/classDiagram-4FO5ZUOK-Br7aWOMi.js +0 -1
  852. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-Br7aWOMi.js +0 -1
  853. package/dist/client/assets/index-DHRQmt0z.js +0 -493
  854. package/dist/client/assets/index-p_tQjQfx.css +0 -1
  855. package/dist/client/assets/infoDiagram-5YYISTIA-CPrRPFU2.js +0 -2
  856. package/dist/client/assets/stateDiagram-AJRCARHV-dwx_Dyu7.js +0 -1
  857. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-g56rq9Om.js +0 -1
  858. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.d.ts +0 -16
  859. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.d.ts.map +0 -1
  860. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.js +0 -52
  861. package/dist/src/backend/services/ratchet/service/ratchet-review-poll.helpers.js.map +0 -1
  862. package/dist/src/backend/services/ratchet/service/reconciliation.service.d.ts.map +0 -1
  863. package/dist/src/backend/services/ratchet/service/reconciliation.service.js.map +0 -1
  864. package/dist/src/backend/services/ratchet/service/reconciliation.service.test.d.ts.map +0 -1
  865. package/dist/src/backend/services/ratchet/service/reconciliation.service.test.js.map +0 -1
  866. package/dist/src/backend/services/session/service/chat/chat-connection.service.d.ts +0 -19
  867. package/dist/src/backend/services/session/service/chat/chat-connection.service.d.ts.map +0 -1
  868. package/dist/src/backend/services/session/service/chat/chat-connection.service.js +0 -78
  869. package/dist/src/backend/services/session/service/chat/chat-connection.service.js.map +0 -1
  870. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.d.ts +0 -2
  871. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.d.ts.map +0 -1
  872. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.js +0 -214
  873. package/dist/src/backend/services/session/service/chat/chat-connection.service.test.js.map +0 -1
  874. /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,15 @@ 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(),
70
+ startFetch: vi.fn(),
71
+ registerFetch: vi.fn(),
72
+ cancelFetch: vi.fn(),
58
73
  };
59
74
  const mockSnapshotBridge = {
60
75
  recordCIObservation: vi.fn(),
@@ -67,13 +82,20 @@ describe('ratchet service (state-change + idle dispatch)', () => {
67
82
  unsafeCoerce(ratchetService).isShuttingDown = false;
68
83
  unsafeCoerce(ratchetService).workspaceCheckTimeoutMs =
69
84
  SERVICE_TIMEOUT_MS.ratchetWorkspaceCheck;
70
- unsafeCoerce(ratchetService).reviewPollTrackers.clear();
71
85
  ratchetService.configure({
72
86
  session: mockSessionBridge,
73
87
  github: mockGitHubBridge,
74
88
  snapshot: mockSnapshotBridge,
75
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([]);
76
97
  vi.mocked(mockGitHubBridge.getAuthenticatedUsername).mockResolvedValue(null);
98
+ vi.mocked(mockGitHubBridge.getResolvedReviewCommentIds).mockResolvedValue(new Set());
77
99
  vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
78
100
  vi.mocked(userSettingsAccessor.get).mockResolvedValue({
79
101
  id: 'settings-1',
@@ -98,6 +120,9 @@ describe('ratchet service (state-change + idle dispatch)', () => {
98
120
  });
99
121
  vi.mocked(userSettingsAccessor.getDefaultSessionProvider).mockResolvedValue('CLAUDE');
100
122
  });
123
+ afterEach(() => {
124
+ ratchetService.removeAllListeners();
125
+ });
101
126
  it('checks workspaces and processes each', async () => {
102
127
  vi.mocked(workspaceAccessor.findWithPRsForRatchet).mockResolvedValue([
103
128
  {
@@ -113,6 +138,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
113
138
  ratchetActiveSessionId: null,
114
139
  ratchetLastCiRunId: null,
115
140
  prReviewLastCheckedAt: null,
141
+ ratchetDispatchOutcome: null,
142
+ ratchetDispatchRetryCount: 0,
116
143
  },
117
144
  ]);
118
145
  vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockResolvedValue({
@@ -136,6 +163,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
136
163
  ratchetActiveSessionId: null,
137
164
  ratchetLastCiRunId: null,
138
165
  prReviewLastCheckedAt: null,
166
+ ratchetDispatchOutcome: null,
167
+ ratchetDispatchRetryCount: 0,
139
168
  };
140
169
  const fetchPRStateSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
141
170
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
@@ -147,6 +176,70 @@ describe('ratchet service (state-change + idle dispatch)', () => {
147
176
  action: { type: 'DISABLED', reason: 'Workspace ratcheting disabled' },
148
177
  newState: RatchetState.IDLE,
149
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([]);
150
243
  });
151
244
  it('does not dispatch when workspace is not idle', async () => {
152
245
  const workspace = {
@@ -160,6 +253,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
160
253
  ratchetActiveSessionId: null,
161
254
  ratchetLastCiRunId: null,
162
255
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
256
+ ratchetDispatchOutcome: null,
257
+ ratchetDispatchRetryCount: 0,
163
258
  };
164
259
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
165
260
  ciStatus: CIStatus.SUCCESS,
@@ -209,6 +304,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
209
304
  ratchetActiveSessionId: null,
210
305
  ratchetLastCiRunId: '2026-01-01T00:00:00Z',
211
306
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
307
+ ratchetDispatchOutcome: null,
308
+ ratchetDispatchRetryCount: 0,
212
309
  };
213
310
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
214
311
  ciStatus: CIStatus.FAILURE,
@@ -230,11 +327,64 @@ describe('ratchet service (state-change + idle dispatch)', () => {
230
327
  expect(result).toMatchObject({
231
328
  action: { type: 'TRIGGERED_FIXER' },
232
329
  });
233
- const finalUpdatePayload = vi.mocked(workspaceAccessor.update).mock.calls.at(-1)?.[1];
234
- 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');
235
339
  expect(finalUpdatePayload).not.toHaveProperty('prReviewLastCheckedAt');
236
340
  expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith('ws-change', expect.any(Date));
237
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
+ });
238
388
  it('dispatches when session is running but idle', async () => {
239
389
  const workspace = {
240
390
  id: 'ws-idle-session',
@@ -247,6 +397,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
247
397
  ratchetActiveSessionId: null,
248
398
  ratchetLastCiRunId: '2026-01-01T00:00:00Z',
249
399
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
400
+ ratchetDispatchOutcome: null,
401
+ ratchetDispatchRetryCount: 0,
250
402
  };
251
403
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
252
404
  ciStatus: CIStatus.FAILURE,
@@ -291,6 +443,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
291
443
  ratchetActiveSessionId: null,
292
444
  ratchetLastCiRunId: '2026-01-02T00:00:00Z',
293
445
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
446
+ ratchetDispatchOutcome: null,
447
+ ratchetDispatchRetryCount: 0,
294
448
  };
295
449
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
296
450
  ciStatus: CIStatus.FAILURE,
@@ -325,6 +479,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
325
479
  ratchetActiveSessionId: null,
326
480
  ratchetLastCiRunId: 'ci:SUCCESS|changes-requested:1735776000000',
327
481
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
482
+ ratchetDispatchOutcome: null,
483
+ ratchetDispatchRetryCount: 0,
328
484
  };
329
485
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
330
486
  ciStatus: CIStatus.SUCCESS,
@@ -369,6 +525,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
369
525
  ratchetActiveSessionId: null,
370
526
  ratchetLastCiRunId: 'ci:SUCCESS|changes-requested:old',
371
527
  prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
528
+ ratchetDispatchOutcome: null,
529
+ ratchetDispatchRetryCount: 0,
372
530
  };
373
531
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
374
532
  ciStatus: CIStatus.SUCCESS,
@@ -392,8 +550,56 @@ describe('ratchet service (state-change + idle dispatch)', () => {
392
550
  },
393
551
  });
394
552
  });
553
+ it('dispatches for PR review summary feedback without changes requested', async () => {
554
+ const workspace = {
555
+ id: 'ws-review-summary',
556
+ prUrl: 'https://github.com/example/repo/pull/57',
557
+ prNumber: 57,
558
+ prState: 'OPEN',
559
+ prCiStatus: CIStatus.UNKNOWN,
560
+ ratchetEnabled: true,
561
+ ratchetState: RatchetState.READY,
562
+ ratchetActiveSessionId: null,
563
+ ratchetLastCiRunId: 'ci:SUCCESS|no-changes-requested:old|merge:clean',
564
+ prReviewLastCheckedAt: new Date('2026-01-02T00:00:00Z'),
565
+ ratchetDispatchOutcome: null,
566
+ ratchetDispatchRetryCount: 0,
567
+ };
568
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
569
+ ciStatus: CIStatus.SUCCESS,
570
+ snapshotKey: 'ci:SUCCESS|no-changes-requested:1767315600000|merge:clean',
571
+ hasChangesRequested: false,
572
+ hasMergeConflict: false,
573
+ latestReviewActivityAtMs: new Date('2026-01-02T01:00:00Z').getTime(),
574
+ statusCheckRollup: null,
575
+ prState: 'OPEN',
576
+ prNumber: 57,
577
+ reviewComments: [
578
+ {
579
+ author: 'cubic-dev-ai',
580
+ body: 'Please fix the hydration edge case.',
581
+ path: 'PR review',
582
+ line: null,
583
+ url: 'https://github.com/example/repo/pull/57#pullrequestreview-1',
584
+ },
585
+ ],
586
+ });
587
+ vi.mocked(workspaceAccessor.update).mockResolvedValue({});
588
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
589
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
590
+ status: 'started',
591
+ sessionId: 'ratchet-session',
592
+ promptSent: true,
593
+ });
594
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
595
+ expect(result).toMatchObject({
596
+ action: { type: 'TRIGGERED_FIXER' },
597
+ newState: RatchetState.READY,
598
+ });
599
+ expect(fixerSessionService.acquireAndDispatch).toHaveBeenCalled();
600
+ });
395
601
  it('treats closed PR as IDLE and does not dispatch', () => {
396
- const state = unsafeCoerce(ratchetService).determineRatchetState({
602
+ const state = determineRatchetState({
397
603
  ciStatus: CIStatus.SUCCESS,
398
604
  snapshotKey: '2026-01-02T00:00:00Z',
399
605
  hasChangesRequested: false,
@@ -415,6 +621,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
415
621
  ratchetActiveSessionId: null,
416
622
  ratchetLastCiRunId: null,
417
623
  prReviewLastCheckedAt: null,
624
+ ratchetDispatchOutcome: null,
625
+ ratchetDispatchRetryCount: 0,
418
626
  };
419
627
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
420
628
  ciStatus: CIStatus.SUCCESS,
@@ -452,6 +660,36 @@ describe('ratchet service (state-change + idle dispatch)', () => {
452
660
  });
453
661
  expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
454
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
+ });
455
693
  it('does not dispatch on a clean PR with no new review activity', async () => {
456
694
  const recentCheck = new Date();
457
695
  const workspace = {
@@ -465,6 +703,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
465
703
  ratchetActiveSessionId: null,
466
704
  ratchetLastCiRunId: '2026-01-01T00:00:00Z',
467
705
  prReviewLastCheckedAt: recentCheck,
706
+ ratchetDispatchOutcome: null,
707
+ ratchetDispatchRetryCount: 0,
468
708
  };
469
709
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
470
710
  ciStatus: CIStatus.SUCCESS,
@@ -487,198 +727,453 @@ describe('ratchet service (state-change + idle dispatch)', () => {
487
727
  },
488
728
  });
489
729
  });
490
- it('self-heals when review check timestamp is stale with no active session', async () => {
491
- 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();
492
732
  const workspace = {
493
- id: 'ws-stale-check',
494
- prUrl: 'https://github.com/example/repo/pull/13',
495
- prNumber: 13,
733
+ id: 'ws-disable-race-state',
734
+ prUrl: 'https://github.com/example/repo/pull/41',
735
+ prNumber: 41,
496
736
  prState: 'OPEN',
497
737
  prCiStatus: CIStatus.UNKNOWN,
498
738
  ratchetEnabled: true,
499
739
  ratchetState: RatchetState.READY,
500
740
  ratchetActiveSessionId: null,
501
- ratchetLastCiRunId: '2026-01-01T00:00:00Z',
502
- prReviewLastCheckedAt: staleCheckedAt,
741
+ ratchetLastCiRunId: 'old-snapshot',
742
+ prReviewLastCheckedAt: recentCheck,
743
+ ratchetDispatchOutcome: null,
744
+ ratchetDispatchRetryCount: 0,
503
745
  };
504
746
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
505
747
  ciStatus: CIStatus.SUCCESS,
506
- snapshotKey: '2026-01-03T00:00:00Z',
748
+ snapshotKey: 'new-snapshot',
507
749
  hasChangesRequested: false,
508
- latestReviewActivityAtMs: staleCheckedAt.getTime() - 1000,
750
+ latestReviewActivityAtMs: recentCheck.getTime() - 1000,
509
751
  statusCheckRollup: null,
510
752
  prState: 'OPEN',
511
- 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,
512
759
  });
513
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
514
760
  vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
761
+ const events = [];
762
+ ratchetService.on(RATCHET_STATE_CHANGED, (event) => {
763
+ events.push(event);
764
+ });
515
765
  const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
516
- expect(result).not.toMatchObject({
517
- action: {
518
- type: 'WAITING',
519
- reason: 'PR is clean (green CI and no new review activity)',
520
- },
766
+ expect(result).toMatchObject({
767
+ newState: RatchetState.IDLE,
768
+ action: { type: 'DISABLED', reason: 'Workspace ratcheting disabled' },
521
769
  });
770
+ expect(events).toHaveLength(0);
771
+ ratchetService.removeAllListeners();
522
772
  });
523
- 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();
524
775
  const workspace = {
525
- id: 'ws-stale-active',
526
- prUrl: 'https://github.com/example/repo/pull/9',
527
- prNumber: 9,
776
+ id: 'ws-superseded',
777
+ prUrl: 'https://github.com/example/repo/pull/42',
778
+ prNumber: 42,
528
779
  prState: 'OPEN',
529
780
  prCiStatus: CIStatus.UNKNOWN,
530
781
  ratchetEnabled: true,
531
- ratchetState: RatchetState.CI_FAILED,
532
- ratchetActiveSessionId: 'ratchet-session',
533
- ratchetLastCiRunId: 'previous-snapshot-key',
534
- prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
535
- };
536
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
537
- id: 'ratchet-session',
538
- provider: 'CLAUDE',
539
- status: SessionStatus.RUNNING,
540
- });
541
- vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(false);
542
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
543
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
544
- const action = await unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
545
- expect(action).toBeNull();
546
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
782
+ ratchetState: RatchetState.CI_RUNNING,
547
783
  ratchetActiveSessionId: null,
548
- ratchetLastCiRunId: null,
549
- });
550
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith(workspace.id, null);
551
- });
552
- it('resets dispatch tracking when ratchet session is not found in database', async () => {
553
- const workspace = {
554
- id: 'ws-missing-session',
555
- prUrl: 'https://github.com/example/repo/pull/12',
556
- 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,
557
795
  prState: 'OPEN',
558
- prCiStatus: CIStatus.UNKNOWN,
796
+ prNumber: 42,
797
+ });
798
+ vi.mocked(workspaceAccessor.transitionRatchetStateIfEnabled).mockResolvedValue(false);
799
+ vi.mocked(workspaceAccessor.findById).mockResolvedValue({
800
+ id: 'ws-superseded',
559
801
  ratchetEnabled: true,
560
- ratchetState: RatchetState.CI_FAILED,
561
- ratchetActiveSessionId: 'deleted-session',
562
- ratchetLastCiRunId: 'previous-snapshot-key',
563
- prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
564
- };
565
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
566
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
567
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
568
- const action = await unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
569
- expect(action).toBeNull();
570
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
571
- ratchetActiveSessionId: null,
572
- 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
+ },
573
818
  });
574
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith(workspace.id, null);
819
+ expect(events).toHaveLength(0);
820
+ expect(mockSnapshotBridge.recordCIObservation).not.toHaveBeenCalled();
821
+ expect(mockSnapshotBridge.recordReviewCheck).not.toHaveBeenCalled();
575
822
  });
576
- 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 () => {
577
824
  const workspace = {
578
- id: 'ws-idle-active',
579
- prUrl: 'https://github.com/example/repo/pull/11',
580
- prNumber: 11,
825
+ id: 'ws-died-retry',
826
+ prUrl: 'https://github.com/example/repo/pull/13',
827
+ prNumber: 13,
581
828
  prState: 'OPEN',
582
- prCiStatus: CIStatus.UNKNOWN,
829
+ prCiStatus: CIStatus.FAILURE,
583
830
  ratchetEnabled: true,
584
831
  ratchetState: RatchetState.CI_FAILED,
585
- ratchetActiveSessionId: 'ratchet-session',
586
- ratchetLastCiRunId: 'snapshot-key',
832
+ ratchetActiveSessionId: null,
833
+ ratchetLastCiRunId: 'same-snapshot',
587
834
  prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
835
+ ratchetDispatchOutcome: 'DIED',
836
+ ratchetDispatchRetryCount: 1,
588
837
  };
589
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
590
- id: 'ratchet-session',
591
- provider: 'CLAUDE',
592
- 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,
593
846
  });
594
- vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
595
- vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
596
- vi.mocked(mockSessionBridge.stopSession).mockResolvedValue();
597
847
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
598
- const action = await unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
599
- expect(action).toBeNull();
600
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
601
- 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,
602
862
  });
603
- expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
604
- expect(mockSnapshotBridge.recordReviewCheck).not.toHaveBeenCalled();
605
863
  });
606
- 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');
607
866
  const workspace = {
608
- id: 'ws-clean-active',
609
- prUrl: 'https://github.com/example/repo/pull/13',
610
- prNumber: 13,
867
+ id: 'ws-died-clean',
868
+ prUrl: 'https://github.com/example/repo/pull/14',
869
+ prNumber: 14,
611
870
  prState: 'OPEN',
612
871
  prCiStatus: CIStatus.SUCCESS,
613
872
  ratchetEnabled: true,
614
873
  ratchetState: RatchetState.READY,
615
- ratchetActiveSessionId: 'ratchet-session',
874
+ ratchetActiveSessionId: null,
616
875
  ratchetLastCiRunId: 'clean-snapshot',
617
- prReviewLastCheckedAt: new Date('2026-01-01T00:00:00Z'),
876
+ prReviewLastCheckedAt: checkedAt,
877
+ ratchetDispatchOutcome: 'DIED',
878
+ ratchetDispatchRetryCount: 0,
618
879
  };
619
- const prStateInfo = {
880
+ vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
620
881
  ciStatus: CIStatus.SUCCESS,
621
882
  snapshotKey: 'clean-snapshot',
622
883
  hasChangesRequested: false,
623
- latestReviewActivityAtMs: null,
884
+ latestReviewActivityAtMs: checkedAt.getTime() - 1000,
624
885
  statusCheckRollup: null,
625
886
  prState: 'OPEN',
626
- prNumber: 13,
887
+ prNumber: 14,
627
888
  reviewComments: [],
628
- };
629
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
630
- id: 'ratchet-session',
631
- provider: 'CLAUDE',
632
- status: SessionStatus.RUNNING,
633
889
  });
634
- vi.mocked(mockSessionBridge.isSessionRunning).mockReturnValue(true);
635
- vi.mocked(mockSessionBridge.isSessionWorking).mockReturnValue(false);
636
- vi.mocked(mockSessionBridge.stopSession).mockResolvedValue();
637
890
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
638
- const result = await unsafeCoerce(ratchetService).collectRatchetingActivityChecks(workspace, prStateInfo, true, false);
639
- expect(result).toEqual({
640
- activeRatchetSession: null,
641
- hasOtherActiveSession: false,
891
+ vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
892
+ vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
893
+ status: 'started',
894
+ sessionId: 'retry-session',
895
+ promptSent: true,
642
896
  });
643
- expect(workspaceAccessor.update).toHaveBeenCalledWith(workspace.id, {
644
- ratchetActiveSessionId: null,
897
+ const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
898
+ expect(result).toMatchObject({
899
+ action: { type: 'TRIGGERED_FIXER' },
645
900
  });
646
- expect(mockSessionBridge.stopSession).toHaveBeenCalledWith('ratchet-session');
647
- expect(agentSessionAccessor.findByWorkspaceId).not.toHaveBeenCalled();
901
+ expect(workspaceAccessor.recordRatchetDispatchIfEnabled).toHaveBeenCalledWith('ws-died-clean', {
902
+ sessionId: 'retry-session',
903
+ snapshotKey: 'clean-snapshot',
904
+ retryCount: 1,
905
+ });
906
+ });
907
+ it('stops retrying a died fixer once the retry budget is exhausted', async () => {
908
+ const workspace = {
909
+ id: 'ws-died-exhausted',
910
+ prUrl: 'https://github.com/example/repo/pull/15',
911
+ prNumber: 15,
912
+ prState: 'OPEN',
913
+ prCiStatus: CIStatus.FAILURE,
914
+ ratchetEnabled: true,
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');
648
1140
  });
649
- it('decides to trigger fixer when context is actionable', () => {
650
- const decision = unsafeCoerce(ratchetService).decideRatchetAction({
651
- workspace: { ratchetEnabled: true },
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 },
652
1144
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
653
1145
  isCleanPrWithNoNewReviewActivity: false,
654
- activeRatchetSession: null,
1146
+ activeFixerCheck: { kind: 'none' },
1147
+ dispatchOutcome: null,
1148
+ dispatchRetryCount: 0,
655
1149
  hasStateChangedSinceLastDispatch: true,
656
- hasOtherActiveSession: false,
657
1150
  });
658
- expect(decision).toEqual({ type: 'TRIGGER_FIXER' });
1151
+ expect(decision).toEqual({ type: 'TRIGGER_FIXER', retryCount: 0 });
659
1152
  });
660
- it('waits when CI is not in terminal state (PENDING)', () => {
661
- const decision = unsafeCoerce(ratchetService).decideRatchetAction({
662
- workspace: { ratchetEnabled: true },
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 },
663
1156
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.PENDING },
664
1157
  isCleanPrWithNoNewReviewActivity: false,
665
- activeRatchetSession: null,
1158
+ activeFixerCheck: { kind: 'none' },
1159
+ dispatchOutcome: null,
1160
+ dispatchRetryCount: 0,
666
1161
  hasStateChangedSinceLastDispatch: true,
667
- hasOtherActiveSession: false,
668
1162
  });
669
1163
  expect(decision).toEqual({
670
1164
  type: 'RETURN_ACTION',
671
1165
  action: { type: 'WAITING', reason: 'Waiting for CI to complete (not in terminal state)' },
672
1166
  });
673
1167
  });
674
- it('waits when CI is not in terminal state (UNKNOWN)', () => {
675
- const decision = unsafeCoerce(ratchetService).decideRatchetAction({
676
- workspace: { ratchetEnabled: true },
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 },
677
1171
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.UNKNOWN },
678
1172
  isCleanPrWithNoNewReviewActivity: false,
679
- activeRatchetSession: null,
1173
+ activeFixerCheck: { kind: 'none' },
1174
+ dispatchOutcome: null,
1175
+ dispatchRetryCount: 0,
680
1176
  hasStateChangedSinceLastDispatch: true,
681
- hasOtherActiveSession: false,
682
1177
  });
683
1178
  expect(decision).toEqual({
684
1179
  type: 'RETURN_ACTION',
@@ -697,6 +1192,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
697
1192
  ratchetActiveSessionId: null,
698
1193
  ratchetLastCiRunId: 'prev-snapshot',
699
1194
  prReviewLastCheckedAt: null,
1195
+ ratchetDispatchOutcome: null,
1196
+ ratchetDispatchRetryCount: 0,
700
1197
  };
701
1198
  const prStateInfo = {
702
1199
  ciStatus: CIStatus.SUCCESS,
@@ -707,11 +1204,19 @@ describe('ratchet service (state-change + idle dispatch)', () => {
707
1204
  prState: 'OPEN',
708
1205
  prNumber: 10,
709
1206
  };
710
- const logContext = unsafeCoerce(ratchetService).buildRatchetingLogContext(workspace, RatchetState.READY, RatchetState.READY, { type: 'WAITING', reason: 'noop' }, prStateInfo, 10, { hasNewReviewActivitySinceLastDispatch: true });
711
- 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);
712
1217
  });
713
1218
  it('ignores review activity authored by the authenticated user', () => {
714
- const latestActivity = unsafeCoerce(ratchetService).computeLatestReviewActivityAtMs({
1219
+ const latestActivity = computeLatestReviewActivityAtMs({
715
1220
  reviews: [
716
1221
  { submittedAt: '2026-01-02T00:00:00Z', author: { login: 'ratchet-bot' } },
717
1222
  { submittedAt: '2026-01-01T00:00:00Z', author: { login: 'reviewer' } },
@@ -721,7 +1226,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
721
1226
  expect(latestActivity).toBe(new Date('2026-01-01T02:00:00Z').getTime());
722
1227
  });
723
1228
  describe('determineRatchetState', () => {
724
- const callDetermineRatchetState = (pr) => unsafeCoerce(ratchetService).determineRatchetState(pr);
1229
+ const callDetermineRatchetState = (pr) => determineRatchetState(pr);
725
1230
  it('returns MERGED for merged PR', () => {
726
1231
  expect(callDetermineRatchetState({
727
1232
  ciStatus: CIStatus.SUCCESS,
@@ -780,7 +1285,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
780
1285
  });
781
1286
  });
782
1287
  describe('computeDispatchSnapshotKey', () => {
783
- 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);
784
1289
  it('includes CI status in key for non-failure states', () => {
785
1290
  const key = callComputeSnapshotKey(CIStatus.SUCCESS, false, null, null);
786
1291
  expect(key).toContain('ci:SUCCESS');
@@ -824,7 +1329,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
824
1329
  });
825
1330
  });
826
1331
  describe('computeLatestReviewActivityAtMs edge cases', () => {
827
- const callCompute = (prDetails, reviewComments, authenticatedUsername) => unsafeCoerce(ratchetService).computeLatestReviewActivityAtMs(prDetails, reviewComments, authenticatedUsername);
1332
+ const callCompute = (prDetails, reviewComments, authenticatedUsername) => computeLatestReviewActivityAtMs(prDetails, reviewComments, authenticatedUsername);
828
1333
  it('returns null when there are no reviews or comments', () => {
829
1334
  expect(callCompute({ reviews: [], comments: [] }, [], null)).toBeNull();
830
1335
  });
@@ -867,6 +1372,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
867
1372
  ratchetActiveSessionId: null,
868
1373
  ratchetLastCiRunId: null,
869
1374
  prReviewLastCheckedAt: null,
1375
+ ratchetDispatchOutcome: null,
1376
+ ratchetDispatchRetryCount: 0,
870
1377
  };
871
1378
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(null);
872
1379
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
@@ -876,6 +1383,27 @@ describe('ratchet service (state-change + idle dispatch)', () => {
876
1383
  newState: RatchetState.IDLE,
877
1384
  });
878
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
+ });
879
1407
  it('returns WAITING when shutting down', async () => {
880
1408
  unsafeCoerce(ratchetService).isShuttingDown = true;
881
1409
  const workspace = {
@@ -887,6 +1415,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
887
1415
  ratchetActiveSessionId: null,
888
1416
  ratchetLastCiRunId: null,
889
1417
  prReviewLastCheckedAt: null,
1418
+ ratchetDispatchOutcome: null,
1419
+ ratchetDispatchRetryCount: 0,
890
1420
  };
891
1421
  const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
892
1422
  expect(result).toMatchObject({
@@ -905,6 +1435,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
905
1435
  ratchetActiveSessionId: null,
906
1436
  ratchetLastCiRunId: null,
907
1437
  prReviewLastCheckedAt: null,
1438
+ ratchetDispatchOutcome: null,
1439
+ ratchetDispatchRetryCount: 0,
908
1440
  };
909
1441
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockRejectedValue(new Error('Unexpected explosion'));
910
1442
  const result = await unsafeCoerce(ratchetService).processWorkspace(workspace);
@@ -942,6 +1474,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
942
1474
  ratchetActiveSessionId: null,
943
1475
  ratchetLastCiRunId: null,
944
1476
  prReviewLastCheckedAt: null,
1477
+ ratchetDispatchOutcome: null,
1478
+ ratchetDispatchRetryCount: 0,
945
1479
  },
946
1480
  {
947
1481
  id: 'ws-fast',
@@ -956,11 +1490,14 @@ describe('ratchet service (state-change + idle dispatch)', () => {
956
1490
  ratchetActiveSessionId: null,
957
1491
  ratchetLastCiRunId: null,
958
1492
  prReviewLastCheckedAt: null,
1493
+ ratchetDispatchOutcome: null,
1494
+ ratchetDispatchRetryCount: 0,
959
1495
  },
960
1496
  ]);
961
- vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation((workspace) => {
1497
+ vi.spyOn(unsafeCoerce(ratchetService), 'processWorkspace').mockImplementation((workspace, _opts, signal) => {
962
1498
  if (workspace.id === 'ws-timeout') {
963
- return new Promise(() => {
1499
+ return new Promise((_resolve, reject) => {
1500
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
964
1501
  });
965
1502
  }
966
1503
  return Promise.resolve({
@@ -986,6 +1523,125 @@ describe('ratchet service (state-change + idle dispatch)', () => {
986
1523
  }),
987
1524
  ]));
988
1525
  });
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,
1532
+ prState: 'OPEN',
1533
+ prCiStatus: CIStatus.UNKNOWN,
1534
+ defaultSessionProvider: 'WORKSPACE_DEFAULT',
1535
+ ratchetSessionProvider: 'WORKSPACE_DEFAULT',
1536
+ ratchetEnabled: true,
1537
+ ratchetState: RatchetState.IDLE,
1538
+ ratchetActiveSessionId: null,
1539
+ ratchetLastCiRunId: null,
1540
+ prReviewLastCheckedAt: null,
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
+ });
989
1645
  });
990
1646
  describe('shutdown behavior', () => {
991
1647
  it('stops immediately while monitor loop is sleeping', async () => {
@@ -1003,33 +1659,314 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1003
1659
  vi.useRealTimers();
1004
1660
  });
1005
1661
  });
1006
- describe('checkWorkspaceById', () => {
1007
- it('returns null when shutting down', async () => {
1008
- unsafeCoerce(ratchetService).isShuttingDown = true;
1009
- const result = await ratchetService.checkWorkspaceById('ws-1');
1010
- expect(result).toBeNull();
1011
- });
1012
- it('returns null when workspace not found', async () => {
1013
- unsafeCoerce(ratchetService).isShuttingDown = false;
1014
- vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(null);
1015
- const result = await ratchetService.checkWorkspaceById('ws-nonexistent');
1016
- expect(result).toBeNull();
1662
+ describe('markPrClosed', () => {
1663
+ afterEach(() => {
1664
+ ratchetService.removeAllListeners();
1017
1665
  });
1018
- it('deduplicates concurrent checks for the same workspace', async () => {
1019
- unsafeCoerce(ratchetService).isShuttingDown = false;
1020
- const workspace = {
1021
- id: 'ws-1',
1022
- prUrl: 'https://github.com/example/repo/pull/1',
1023
- prNumber: 1,
1024
- prState: 'OPEN',
1025
- prCiStatus: CIStatus.UNKNOWN,
1026
- defaultSessionProvider: 'WORKSPACE_DEFAULT',
1027
- ratchetSessionProvider: 'WORKSPACE_DEFAULT',
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',
1028
1764
  ratchetEnabled: true,
1029
1765
  ratchetState: RatchetState.IDLE,
1030
1766
  ratchetActiveSessionId: null,
1031
1767
  ratchetLastCiRunId: null,
1032
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,
1033
1970
  };
1034
1971
  vi.mocked(workspaceAccessor.findForRatchetById).mockResolvedValue(workspace);
1035
1972
  let releaseCheck;
@@ -1050,7 +1987,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1050
1987
  });
1051
1988
  const first = ratchetService.checkWorkspaceById('ws-1');
1052
1989
  const second = ratchetService.checkWorkspaceById('ws-1');
1053
- await Promise.resolve();
1990
+ await vi.waitFor(() => expect(processWorkspaceSpy).toHaveBeenCalledTimes(1));
1054
1991
  expect(processWorkspaceSpy).toHaveBeenCalledTimes(1);
1055
1992
  releaseCheck();
1056
1993
  const [firstResult, secondResult] = await Promise.all([first, second]);
@@ -1058,311 +1995,416 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1058
1995
  expect(secondResult).toEqual(result);
1059
1996
  expect(processWorkspaceSpy).toHaveBeenCalledTimes(1);
1060
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
+ });
1061
2137
  });
1062
2138
  describe('decideRatchetAction edge cases', () => {
1063
2139
  const callDecide = (context) => unsafeCoerce(ratchetService).decideRatchetAction(context);
1064
- it('returns COMPLETED for merged PR', () => {
1065
- expect(callDecide({
1066
- 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 },
1067
2149
  prStateInfo: { prState: 'MERGED', ciStatus: CIStatus.SUCCESS },
1068
2150
  isCleanPrWithNoNewReviewActivity: false,
1069
- activeRatchetSession: null,
2151
+ activeFixerCheck: { kind: 'none' },
2152
+ dispatchOutcome: null,
2153
+ dispatchRetryCount: 0,
1070
2154
  hasStateChangedSinceLastDispatch: true,
1071
- hasOtherActiveSession: false,
1072
2155
  })).toEqual({ type: 'RETURN_ACTION', action: { type: 'COMPLETED' } });
1073
2156
  });
1074
- it('returns WAITING for non-open PR', () => {
1075
- const result = callDecide({
1076
- workspace: { ratchetEnabled: true },
2157
+ it('returns WAITING for non-open PR', async () => {
2158
+ const result = (await callDecide({
2159
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1077
2160
  prStateInfo: { prState: 'CLOSED', ciStatus: CIStatus.SUCCESS },
1078
2161
  isCleanPrWithNoNewReviewActivity: false,
1079
- activeRatchetSession: null,
2162
+ activeFixerCheck: { kind: 'none' },
2163
+ dispatchOutcome: null,
2164
+ dispatchRetryCount: 0,
1080
2165
  hasStateChangedSinceLastDispatch: true,
1081
- hasOtherActiveSession: false,
1082
- });
2166
+ }));
1083
2167
  expect(result.action?.reason).toBe('PR is not open');
1084
2168
  });
1085
- it('returns active session when one exists', () => {
2169
+ it('returns active session when one exists', async () => {
1086
2170
  const activeSession = { type: 'FIXER_ACTIVE', sessionId: 's-1' };
1087
- const result = callDecide({
1088
- workspace: { ratchetEnabled: true },
2171
+ const result = (await callDecide({
2172
+ workspace: { id: 'ws-decide', ratchetEnabled: true },
1089
2173
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
1090
2174
  isCleanPrWithNoNewReviewActivity: false,
1091
- activeRatchetSession: activeSession,
2175
+ activeFixerCheck: { kind: 'active', action: activeSession },
2176
+ dispatchOutcome: 'RUNNING',
2177
+ dispatchRetryCount: 0,
1092
2178
  hasStateChangedSinceLastDispatch: true,
1093
- hasOtherActiveSession: false,
1094
- });
2179
+ }));
1095
2180
  expect(result.action).toEqual(activeSession);
1096
2181
  });
1097
- it('returns WAITING when other active session blocks dispatch', () => {
1098
- const result = callDecide({
1099
- 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 },
1100
2186
  prStateInfo: { prState: 'OPEN', ciStatus: CIStatus.FAILURE },
1101
2187
  isCleanPrWithNoNewReviewActivity: false,
1102
- activeRatchetSession: null,
2188
+ activeFixerCheck: { kind: 'none' },
2189
+ dispatchOutcome: null,
2190
+ dispatchRetryCount: 0,
1103
2191
  hasStateChangedSinceLastDispatch: true,
1104
- hasOtherActiveSession: true,
1105
- });
2192
+ }));
1106
2193
  expect(result.action?.reason).toBe('Workspace has another working session');
1107
2194
  });
1108
- it('returns WAITING when there are no CI failures or PR review comments', () => {
1109
- const result = callDecide({
1110
- 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 },
1111
2198
  prStateInfo: {
1112
2199
  prState: 'OPEN',
1113
2200
  ciStatus: CIStatus.SUCCESS,
1114
2201
  reviewComments: [],
1115
2202
  },
1116
2203
  isCleanPrWithNoNewReviewActivity: false,
1117
- activeRatchetSession: null,
2204
+ activeFixerCheck: { kind: 'none' },
2205
+ dispatchOutcome: null,
2206
+ dispatchRetryCount: 0,
1118
2207
  hasStateChangedSinceLastDispatch: true,
1119
- hasOtherActiveSession: false,
1120
- });
2208
+ }));
1121
2209
  expect(result.action?.reason).toBe('No CI failures or PR review comments to address');
1122
2210
  });
1123
- });
1124
- describe('getActiveRatchetSession edge cases', () => {
1125
- const callGetActiveRatchetSession = (workspace) => unsafeCoerce(ratchetService).getActiveRatchetSession(workspace);
1126
- it('returns null when ratchetActiveSessionId is null', async () => {
1127
- const result = await callGetActiveRatchetSession({
1128
- id: 'ws-1',
1129
- 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,
1130
2232
  });
1131
- expect(result).toBeNull();
2233
+ expect(result).toEqual({ type: 'TRIGGER_FIXER', retryCount: 3 });
1132
2234
  });
1133
- it('returns null and resets dispatch tracking when session DB record is missing', async () => {
1134
- vi.mocked(agentSessionAccessor.findById).mockResolvedValue(null);
1135
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1136
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
1137
- const result = await callGetActiveRatchetSession({
1138
- id: 'ws-2',
1139
- ratchetActiveSessionId: 'gone-session',
1140
- });
1141
- expect(result).toBeNull();
1142
- 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',
1143
2266
  ratchetActiveSessionId: null,
1144
- ratchetLastCiRunId: null,
1145
2267
  });
1146
- expect(mockSnapshotBridge.recordReviewCheck).toHaveBeenCalledWith('ws-2', null);
2268
+ expect(result).toEqual({ kind: 'none' });
1147
2269
  });
1148
- 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 () => {
1149
2271
  vi.mocked(agentSessionAccessor.findById).mockResolvedValue({
1150
2272
  id: 'completed-session',
1151
2273
  provider: 'CLAUDE',
1152
2274
  status: SessionStatus.IDLE,
1153
2275
  });
1154
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1155
- vi.mocked(mockSnapshotBridge.recordReviewCheck).mockResolvedValue();
1156
- const result = await callGetActiveRatchetSession({
2276
+ const result = await callCheckActiveFixerSession({
1157
2277
  id: 'ws-3',
1158
2278
  ratchetActiveSessionId: 'completed-session',
1159
2279
  });
1160
- expect(result).toBeNull();
1161
- expect(workspaceAccessor.update).toHaveBeenCalledWith('ws-3', {
1162
- ratchetActiveSessionId: null,
1163
- ratchetLastCiRunId: null,
1164
- });
1165
- 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');
1166
2282
  });
1167
- });
1168
- describe('review comment polling', () => {
1169
- const cleanWorkspace = {
1170
- id: 'ws-poll',
1171
- prUrl: 'https://github.com/example/repo/pull/100',
1172
- prNumber: 100,
1173
- prState: 'OPEN',
1174
- prCiStatus: CIStatus.UNKNOWN,
1175
- ratchetEnabled: true,
1176
- ratchetState: RatchetState.READY,
1177
- ratchetActiveSessionId: null,
1178
- ratchetLastCiRunId: 'old-snapshot',
1179
- prReviewLastCheckedAt: new Date(),
1180
- };
1181
- const cleanPrState = {
1182
- ciStatus: CIStatus.SUCCESS,
1183
- snapshotKey: 'new-snapshot',
1184
- hasChangesRequested: false,
1185
- latestReviewActivityAtMs: new Date('2026-01-01T00:00:00Z').getTime(),
1186
- statusCheckRollup: null,
1187
- prState: 'OPEN',
1188
- prNumber: 100,
1189
- reviewComments: [],
1190
- };
1191
- const prStateWithComments = {
1192
- ...cleanPrState,
1193
- snapshotKey: 'snapshot-with-comments',
1194
- hasChangesRequested: true,
1195
- latestReviewActivityAtMs: new Date('2026-01-03T00:00:00Z').getTime(),
1196
- reviewComments: [
1197
- {
1198
- author: 'reviewer',
1199
- body: 'Please update this line',
1200
- path: 'src/file.ts',
1201
- line: 21,
1202
- url: 'https://github.com/example/repo/pull/100#discussion_r2',
1203
- },
1204
- ],
1205
- };
1206
- const getTrackers = () => unsafeCoerce(ratchetService).reviewPollTrackers;
1207
- const callProcessWorkspace = (workspace) => unsafeCoerce(ratchetService).processWorkspace(workspace);
1208
- it('creates tracker when PR is first seen as clean with state change', async () => {
1209
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(cleanPrState);
1210
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1211
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1212
- const result = await callProcessWorkspace(cleanWorkspace);
1213
- expect(result.action.type).toBe('WAITING');
1214
- expect(getTrackers().has('ws-poll')).toBe(true);
1215
- expect(getTrackers().get('ws-poll')?.snapshotKey).toBe('new-snapshot');
1216
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1217
- });
1218
- it('creates tracker even when state has not changed (continuous polling)', async () => {
1219
- const workspace = { ...cleanWorkspace, ratchetLastCiRunId: 'new-snapshot' };
1220
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(cleanPrState);
1221
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1222
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1223
- await callProcessWorkspace(workspace);
1224
- expect(getTrackers().has('ws-poll')).toBe(true);
1225
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1226
- });
1227
- it('skips re-poll when not enough time has elapsed', async () => {
1228
- getTrackers().set('ws-poll', {
1229
- snapshotKey: 'new-snapshot',
1230
- lastPolledAt: Date.now(),
1231
- pollCount: 0,
1232
- });
1233
- const fetchSpy = vi
1234
- .spyOn(unsafeCoerce(ratchetService), 'fetchPRState')
1235
- .mockResolvedValue(cleanPrState);
1236
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1237
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1238
- await callProcessWorkspace(cleanWorkspace);
1239
- expect(fetchSpy).toHaveBeenCalledTimes(1);
1240
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1241
- });
1242
- it('re-polls and finds comments when time has elapsed', async () => {
1243
- getTrackers().set('ws-poll', {
1244
- snapshotKey: 'new-snapshot',
1245
- lastPolledAt: Date.now() - 3 * 60_000,
1246
- pollCount: 0,
1247
- });
1248
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1249
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1250
- fetchSpy.mockResolvedValueOnce(prStateWithComments);
1251
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1252
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1253
- vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1254
- status: 'started',
1255
- sessionId: 'ratchet-poll-session',
1256
- 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,
1257
2288
  });
1258
- const result = await callProcessWorkspace(cleanWorkspace);
1259
- expect(fetchSpy).toHaveBeenCalledTimes(2);
1260
- expect(result.action.type).toBe('TRIGGERED_FIXER');
1261
- expect(getTrackers().has('ws-poll')).toBe(false);
1262
- });
1263
- it('increments pollCount when re-poll still finds clean PR', async () => {
1264
- getTrackers().set('ws-poll', {
1265
- snapshotKey: 'new-snapshot',
1266
- lastPolledAt: Date.now() - 3 * 60_000,
1267
- pollCount: 0,
2289
+ const result = await callCheckActiveFixerSession({
2290
+ id: 'ws-4',
2291
+ ratchetActiveSessionId: 'failed-session',
1268
2292
  });
1269
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1270
- fetchSpy.mockResolvedValue(cleanPrState);
1271
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1272
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1273
- await callProcessWorkspace(cleanWorkspace);
1274
- expect(fetchSpy).toHaveBeenCalledTimes(2);
1275
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(1);
1276
- });
1277
- it('continues polling after many cycles without exhausting', async () => {
1278
- getTrackers().set('ws-poll', {
1279
- snapshotKey: 'new-snapshot',
1280
- lastPolledAt: Date.now() - 3 * 60_000,
1281
- pollCount: 50,
1282
- });
1283
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1284
- fetchSpy.mockResolvedValue(cleanPrState);
1285
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1286
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1287
- await callProcessWorkspace(cleanWorkspace);
1288
- expect(fetchSpy).toHaveBeenCalledTimes(2);
1289
- 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');
1290
2295
  });
1291
- it('resets tracker when snapshotKey changes', async () => {
1292
- getTrackers().set('ws-poll', {
1293
- snapshotKey: 'different-snapshot',
1294
- lastPolledAt: Date.now() - 10 * 60_000,
1295
- 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,
1296
2301
  });
1297
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(cleanPrState);
1298
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1299
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1300
- await callProcessWorkspace(cleanWorkspace);
1301
- const tracker = getTrackers().get('ws-poll');
1302
- expect(tracker?.snapshotKey).toBe('new-snapshot');
1303
- expect(tracker?.pollCount).toBe(0);
1304
- });
1305
- it('cleans up tracker when decision is not clean PR', async () => {
1306
- getTrackers().set('ws-poll', {
1307
- snapshotKey: 'new-snapshot',
1308
- lastPolledAt: Date.now(),
1309
- pollCount: 1,
1310
- });
1311
- const failedPrState = {
1312
- ...cleanPrState,
1313
- ciStatus: CIStatus.FAILURE,
1314
- snapshotKey: 'failed-snapshot',
1315
- };
1316
- vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(failedPrState);
1317
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1318
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1319
- vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1320
- status: 'started',
1321
- sessionId: 'fixer',
1322
- 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',
1323
2308
  });
1324
- await callProcessWorkspace(cleanWorkspace);
1325
- 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');
1326
2312
  });
1327
- it('continues to next cycle when fetchPRState returns null during re-poll', async () => {
1328
- getTrackers().set('ws-poll', {
1329
- snapshotKey: 'new-snapshot',
1330
- lastPolledAt: Date.now() - 3 * 60_000,
1331
- 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,
1332
2318
  });
1333
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1334
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1335
- fetchSpy.mockResolvedValueOnce(null);
1336
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1337
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1338
- const result = await callProcessWorkspace(cleanWorkspace);
1339
- expect(result.action.type).toBe('WAITING');
1340
- expect(getTrackers().get('ws-poll')?.pollCount).toBe(0);
1341
- });
1342
- it('skips re-poll when shutting down', async () => {
1343
- getTrackers().set('ws-poll', {
1344
- snapshotKey: 'new-snapshot',
1345
- lastPolledAt: Date.now() - 3 * 60_000,
1346
- 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',
1347
2324
  });
1348
- unsafeCoerce(ratchetService).isShuttingDown = false;
1349
- const fetchSpy = vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState');
1350
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1351
- fetchSpy.mockResolvedValueOnce(cleanPrState);
1352
- vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1353
- vi.mocked(agentSessionAccessor.findByWorkspaceId).mockResolvedValue([]);
1354
- const handlePollSpy = vi.spyOn(unsafeCoerce(ratchetService), 'handleReviewCommentPoll');
1355
- handlePollSpy.mockImplementationOnce(async (...args) => {
1356
- unsafeCoerce(ratchetService).isShuttingDown = true;
1357
- handlePollSpy.mockRestore();
1358
- return await unsafeCoerce(ratchetService).handleReviewCommentPoll(...args);
2325
+ expect(result).toEqual({
2326
+ kind: 'active',
2327
+ action: { type: 'FIXER_ACTIVE', sessionId: 'working-session' },
1359
2328
  });
1360
- const result = await callProcessWorkspace(cleanWorkspace);
1361
- expect(result.action.type).toBe('WAITING');
1362
- 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();
1363
2348
  });
1364
2349
  });
1365
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
+ });
1366
2408
  it('handles acquireAndDispatch throwing an error', async () => {
1367
2409
  const workspace = {
1368
2410
  id: 'ws-trigger-fail',
@@ -1373,12 +2415,14 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1373
2415
  ratchetActiveSessionId: null,
1374
2416
  ratchetLastCiRunId: null,
1375
2417
  prReviewLastCheckedAt: null,
2418
+ ratchetDispatchOutcome: null,
2419
+ ratchetDispatchRetryCount: 0,
1376
2420
  };
1377
2421
  vi.mocked(fixerSessionService.acquireAndDispatch).mockRejectedValue(new Error('Session creation failed'));
1378
2422
  const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
1379
2423
  ciStatus: CIStatus.FAILURE,
1380
2424
  prNumber: 20,
1381
- });
2425
+ }, 0);
1382
2426
  expect(result).toMatchObject({
1383
2427
  type: 'ERROR',
1384
2428
  error: 'Session creation failed',
@@ -1394,6 +2438,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1394
2438
  ratchetActiveSessionId: null,
1395
2439
  ratchetLastCiRunId: null,
1396
2440
  prReviewLastCheckedAt: null,
2441
+ ratchetDispatchOutcome: null,
2442
+ ratchetDispatchRetryCount: 0,
1397
2443
  };
1398
2444
  vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1399
2445
  status: 'already_active',
@@ -1403,11 +2449,42 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1403
2449
  const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
1404
2450
  ciStatus: CIStatus.FAILURE,
1405
2451
  prNumber: 21,
1406
- });
2452
+ }, 0);
1407
2453
  expect(result).toMatchObject({
1408
2454
  type: 'FIXER_ACTIVE',
1409
2455
  sessionId: 'existing-session',
1410
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');
1411
2488
  });
1412
2489
  it('handles skipped result from acquireAndDispatch', async () => {
1413
2490
  const workspace = {
@@ -1419,6 +2496,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1419
2496
  ratchetActiveSessionId: null,
1420
2497
  ratchetLastCiRunId: null,
1421
2498
  prReviewLastCheckedAt: null,
2499
+ ratchetDispatchOutcome: null,
2500
+ ratchetDispatchRetryCount: 0,
1422
2501
  };
1423
2502
  vi.mocked(fixerSessionService.acquireAndDispatch).mockResolvedValue({
1424
2503
  status: 'skipped',
@@ -1427,7 +2506,7 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1427
2506
  const result = await unsafeCoerce(ratchetService).triggerFixer(workspace, {
1428
2507
  ciStatus: CIStatus.FAILURE,
1429
2508
  prNumber: 22,
1430
- });
2509
+ }, 0);
1431
2510
  expect(result).toMatchObject({
1432
2511
  type: 'ERROR',
1433
2512
  error: 'No worktree path',
@@ -1485,6 +2564,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1485
2564
  ratchetState: RatchetState.IDLE,
1486
2565
  ratchetActiveSessionId: null,
1487
2566
  ratchetLastCiRunId: null,
2567
+ ratchetDispatchOutcome: null,
2568
+ ratchetDispatchRetryCount: 0,
1488
2569
  });
1489
2570
  expect(stateEvents).toEqual([
1490
2571
  {
@@ -1501,6 +2582,31 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1501
2582
  },
1502
2583
  ]);
1503
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
+ });
1504
2610
  });
1505
2611
  describe('event emission', () => {
1506
2612
  afterEach(() => {
@@ -1516,6 +2622,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1516
2622
  ratchetActiveSessionId: null,
1517
2623
  ratchetLastCiRunId: null,
1518
2624
  prReviewLastCheckedAt: null,
2625
+ ratchetDispatchOutcome: null,
2626
+ ratchetDispatchRetryCount: 0,
1519
2627
  };
1520
2628
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1521
2629
  const events = [];
@@ -1540,6 +2648,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1540
2648
  ratchetActiveSessionId: null,
1541
2649
  ratchetLastCiRunId: null,
1542
2650
  prReviewLastCheckedAt: null,
2651
+ ratchetDispatchOutcome: null,
2652
+ ratchetDispatchRetryCount: 0,
1543
2653
  };
1544
2654
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});
1545
2655
  const events = [];
@@ -1559,6 +2669,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1559
2669
  ratchetActiveSessionId: null,
1560
2670
  ratchetLastCiRunId: null,
1561
2671
  prReviewLastCheckedAt: null,
2672
+ ratchetDispatchOutcome: null,
2673
+ ratchetDispatchRetryCount: 0,
1562
2674
  };
1563
2675
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
1564
2676
  ciStatus: CIStatus.FAILURE,
@@ -1599,6 +2711,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1599
2711
  ratchetActiveSessionId: null,
1600
2712
  ratchetLastCiRunId: null,
1601
2713
  prReviewLastCheckedAt: null,
2714
+ ratchetDispatchOutcome: null,
2715
+ ratchetDispatchRetryCount: 0,
1602
2716
  };
1603
2717
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue({
1604
2718
  ciStatus: CIStatus.FAILURE,
@@ -1633,6 +2747,8 @@ describe('ratchet service (state-change + idle dispatch)', () => {
1633
2747
  ratchetActiveSessionId: null,
1634
2748
  ratchetLastCiRunId: null,
1635
2749
  prReviewLastCheckedAt: null,
2750
+ ratchetDispatchOutcome: null,
2751
+ ratchetDispatchRetryCount: 0,
1636
2752
  };
1637
2753
  vi.spyOn(unsafeCoerce(ratchetService), 'fetchPRState').mockResolvedValue(null);
1638
2754
  vi.mocked(workspaceAccessor.update).mockResolvedValue({});