@paperclipai/server 2026.817.0 → 2026.818.0-beta.1

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 (548) hide show
  1. package/dist/adapters/hermes-gateway-doc.d.ts +1 -1
  2. package/dist/adapters/hermes-gateway-doc.d.ts.map +1 -1
  3. package/dist/adapters/hermes-gateway-doc.js +1 -1
  4. package/dist/adapters/plugin-loader.d.ts.map +1 -1
  5. package/dist/adapters/plugin-loader.js +3 -2
  6. package/dist/adapters/plugin-loader.js.map +1 -1
  7. package/dist/agent-auth-jwt.d.ts.map +1 -1
  8. package/dist/agent-auth-jwt.js +8 -2
  9. package/dist/agent-auth-jwt.js.map +1 -1
  10. package/dist/app.d.ts +5 -1
  11. package/dist/app.d.ts.map +1 -1
  12. package/dist/app.js +202 -15
  13. package/dist/app.js.map +1 -1
  14. package/dist/built-ins/agents/summarizer/AGENTS.md +2 -2
  15. package/dist/built-ins/agents/summarizer/routines/refresh-stale-summaries.md +1 -0
  16. package/dist/db-errors.d.ts +9 -0
  17. package/dist/db-errors.d.ts.map +1 -1
  18. package/dist/db-errors.js +19 -0
  19. package/dist/db-errors.js.map +1 -1
  20. package/dist/errors.d.ts +1 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +2 -2
  23. package/dist/errors.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +162 -38
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/request-promise-memo.d.ts +6 -0
  28. package/dist/lib/request-promise-memo.d.ts.map +1 -0
  29. package/dist/lib/request-promise-memo.js +25 -0
  30. package/dist/lib/request-promise-memo.js.map +1 -0
  31. package/dist/lib/request-promise-memo.test.d.ts +2 -0
  32. package/dist/lib/request-promise-memo.test.d.ts.map +1 -0
  33. package/dist/lib/request-promise-memo.test.js +74 -0
  34. package/dist/lib/request-promise-memo.test.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +13 -1
  36. package/dist/middleware/auth.d.ts.map +1 -1
  37. package/dist/middleware/auth.js +92 -41
  38. package/dist/middleware/auth.js.map +1 -1
  39. package/dist/middleware/cloud-tenant-actor.test.js +26 -1
  40. package/dist/middleware/cloud-tenant-actor.test.js.map +1 -1
  41. package/dist/middleware/error-handler.d.ts.map +1 -1
  42. package/dist/middleware/error-handler.js +11 -2
  43. package/dist/middleware/error-handler.js.map +1 -1
  44. package/dist/middleware/logger.d.ts.map +1 -1
  45. package/dist/middleware/logger.js +7 -33
  46. package/dist/middleware/logger.js.map +1 -1
  47. package/dist/middleware/private-hostname-guard.d.ts.map +1 -1
  48. package/dist/middleware/private-hostname-guard.js +11 -6
  49. package/dist/middleware/private-hostname-guard.js.map +1 -1
  50. package/dist/middleware/private-json-etag.d.ts +3 -0
  51. package/dist/middleware/private-json-etag.d.ts.map +1 -0
  52. package/dist/middleware/private-json-etag.js +37 -0
  53. package/dist/middleware/private-json-etag.js.map +1 -0
  54. package/dist/middleware/redact-sensitive.d.ts.map +1 -1
  55. package/dist/middleware/redact-sensitive.js +11 -0
  56. package/dist/middleware/redact-sensitive.js.map +1 -1
  57. package/dist/realtime/live-events-ws.d.ts +12 -0
  58. package/dist/realtime/live-events-ws.d.ts.map +1 -1
  59. package/dist/realtime/live-events-ws.js +20 -0
  60. package/dist/realtime/live-events-ws.js.map +1 -1
  61. package/dist/redaction.js +1 -1
  62. package/dist/redaction.js.map +1 -1
  63. package/dist/routes/access.d.ts +2 -0
  64. package/dist/routes/access.d.ts.map +1 -1
  65. package/dist/routes/access.js +21 -10
  66. package/dist/routes/access.js.map +1 -1
  67. package/dist/routes/agents.d.ts +42 -0
  68. package/dist/routes/agents.d.ts.map +1 -1
  69. package/dist/routes/agents.js +875 -8
  70. package/dist/routes/agents.js.map +1 -1
  71. package/dist/routes/companies.d.ts +5 -1
  72. package/dist/routes/companies.d.ts.map +1 -1
  73. package/dist/routes/companies.js +467 -30
  74. package/dist/routes/companies.js.map +1 -1
  75. package/dist/routes/environments.d.ts +12 -3
  76. package/dist/routes/environments.d.ts.map +1 -1
  77. package/dist/routes/environments.js +139 -23
  78. package/dist/routes/environments.js.map +1 -1
  79. package/dist/routes/execution-workspaces.d.ts.map +1 -1
  80. package/dist/routes/execution-workspaces.js +240 -70
  81. package/dist/routes/execution-workspaces.js.map +1 -1
  82. package/dist/routes/file-resources.d.ts +14 -1
  83. package/dist/routes/file-resources.d.ts.map +1 -1
  84. package/dist/routes/file-resources.js +182 -3
  85. package/dist/routes/file-resources.js.map +1 -1
  86. package/dist/routes/index.d.ts +2 -1
  87. package/dist/routes/index.d.ts.map +1 -1
  88. package/dist/routes/index.js +2 -1
  89. package/dist/routes/index.js.map +1 -1
  90. package/dist/routes/instance-settings.d.ts.map +1 -1
  91. package/dist/routes/instance-settings.js +20 -1
  92. package/dist/routes/instance-settings.js.map +1 -1
  93. package/dist/routes/issues.d.ts.map +1 -1
  94. package/dist/routes/issues.js +750 -235
  95. package/dist/routes/issues.js.map +1 -1
  96. package/dist/routes/onboarding-seed.d.ts +27 -0
  97. package/dist/routes/onboarding-seed.d.ts.map +1 -0
  98. package/dist/routes/onboarding-seed.js +56 -0
  99. package/dist/routes/onboarding-seed.js.map +1 -0
  100. package/dist/routes/openapi.d.ts.map +1 -1
  101. package/dist/routes/openapi.js +316 -2
  102. package/dist/routes/openapi.js.map +1 -1
  103. package/dist/routes/secrets.d.ts.map +1 -1
  104. package/dist/routes/secrets.js +11 -1
  105. package/dist/routes/secrets.js.map +1 -1
  106. package/dist/routes/setup-token-route.test.d.ts +2 -0
  107. package/dist/routes/setup-token-route.test.d.ts.map +1 -0
  108. package/dist/routes/setup-token-route.test.js +896 -0
  109. package/dist/routes/setup-token-route.test.js.map +1 -0
  110. package/dist/routes/tool-access.d.ts.map +1 -1
  111. package/dist/routes/tool-access.js +87 -52
  112. package/dist/routes/tool-access.js.map +1 -1
  113. package/dist/routes/workspace-runtime-service-authz.d.ts +11 -1
  114. package/dist/routes/workspace-runtime-service-authz.d.ts.map +1 -1
  115. package/dist/routes/workspace-runtime-service-authz.js +18 -16
  116. package/dist/routes/workspace-runtime-service-authz.js.map +1 -1
  117. package/dist/services/access.d.ts.map +1 -1
  118. package/dist/services/activity.d.ts +1 -1
  119. package/dist/services/agents.d.ts +21 -0
  120. package/dist/services/agents.d.ts.map +1 -1
  121. package/dist/services/agents.js +121 -1
  122. package/dist/services/agents.js.map +1 -1
  123. package/dist/services/attention-resolver-audience.test.d.ts +2 -0
  124. package/dist/services/attention-resolver-audience.test.d.ts.map +1 -0
  125. package/dist/services/attention-resolver-audience.test.js +81 -0
  126. package/dist/services/attention-resolver-audience.test.js.map +1 -0
  127. package/dist/services/attention.d.ts +20 -1
  128. package/dist/services/attention.d.ts.map +1 -1
  129. package/dist/services/attention.js +45 -1
  130. package/dist/services/attention.js.map +1 -1
  131. package/dist/services/authorization.d.ts +2 -2
  132. package/dist/services/authorization.d.ts.map +1 -1
  133. package/dist/services/authorization.js +89 -46
  134. package/dist/services/authorization.js.map +1 -1
  135. package/dist/services/board-auth.d.ts.map +1 -1
  136. package/dist/services/board-auth.js +40 -1
  137. package/dist/services/board-auth.js.map +1 -1
  138. package/dist/services/board-auth.test.d.ts +2 -0
  139. package/dist/services/board-auth.test.d.ts.map +1 -0
  140. package/dist/services/board-auth.test.js +34 -0
  141. package/dist/services/board-auth.test.js.map +1 -0
  142. package/dist/services/built-in-agents.d.ts.map +1 -1
  143. package/dist/services/built-in-agents.js +20 -4
  144. package/dist/services/built-in-agents.js.map +1 -1
  145. package/dist/services/bundled-plugins.d.ts +11 -1
  146. package/dist/services/bundled-plugins.d.ts.map +1 -1
  147. package/dist/services/bundled-plugins.js +46 -1
  148. package/dist/services/bundled-plugins.js.map +1 -1
  149. package/dist/services/codex-device-login-reaper.d.ts +61 -0
  150. package/dist/services/codex-device-login-reaper.d.ts.map +1 -0
  151. package/dist/services/codex-device-login-reaper.js +219 -0
  152. package/dist/services/codex-device-login-reaper.js.map +1 -0
  153. package/dist/services/codex-device-login-service.d.ts +355 -0
  154. package/dist/services/codex-device-login-service.d.ts.map +1 -0
  155. package/dist/services/codex-device-login-service.js +775 -0
  156. package/dist/services/codex-device-login-service.js.map +1 -0
  157. package/dist/services/companies.d.ts.map +1 -1
  158. package/dist/services/companies.js +7 -0
  159. package/dist/services/companies.js.map +1 -1
  160. package/dist/services/company-export-readme.js +1 -1
  161. package/dist/services/company-export-readme.js.map +1 -1
  162. package/dist/services/company-import-transfers.d.ts +55 -0
  163. package/dist/services/company-import-transfers.d.ts.map +1 -0
  164. package/dist/services/company-import-transfers.js +151 -0
  165. package/dist/services/company-import-transfers.js.map +1 -0
  166. package/dist/services/company-portability.d.ts.map +1 -1
  167. package/dist/services/company-portability.js +128 -3
  168. package/dist/services/company-portability.js.map +1 -1
  169. package/dist/services/company-transfer-runs.d.ts +136 -0
  170. package/dist/services/company-transfer-runs.d.ts.map +1 -0
  171. package/dist/services/company-transfer-runs.js +243 -0
  172. package/dist/services/company-transfer-runs.js.map +1 -0
  173. package/dist/services/cross-issue-influence-limit.d.ts +7 -1
  174. package/dist/services/cross-issue-influence-limit.d.ts.map +1 -1
  175. package/dist/services/cross-issue-influence-limit.js.map +1 -1
  176. package/dist/services/environment-config.d.ts +15 -0
  177. package/dist/services/environment-config.d.ts.map +1 -1
  178. package/dist/services/environment-config.js +56 -10
  179. package/dist/services/environment-config.js.map +1 -1
  180. package/dist/services/environment-execution-target.d.ts.map +1 -1
  181. package/dist/services/environment-execution-target.js +61 -11
  182. package/dist/services/environment-execution-target.js.map +1 -1
  183. package/dist/services/environment-run-orchestrator.d.ts +16 -1
  184. package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
  185. package/dist/services/environment-run-orchestrator.js +1 -1
  186. package/dist/services/environment-run-orchestrator.js.map +1 -1
  187. package/dist/services/environment-runtime.d.ts +178 -2
  188. package/dist/services/environment-runtime.d.ts.map +1 -1
  189. package/dist/services/environment-runtime.js +1035 -96
  190. package/dist/services/environment-runtime.js.map +1 -1
  191. package/dist/services/environments.d.ts +75 -0
  192. package/dist/services/environments.d.ts.map +1 -1
  193. package/dist/services/environments.js +199 -8
  194. package/dist/services/environments.js.map +1 -1
  195. package/dist/services/execution-allowlist.d.ts +16 -4
  196. package/dist/services/execution-allowlist.d.ts.map +1 -1
  197. package/dist/services/execution-allowlist.js +35 -16
  198. package/dist/services/execution-allowlist.js.map +1 -1
  199. package/dist/services/execution-allowlist.test.js +33 -0
  200. package/dist/services/execution-allowlist.test.js.map +1 -1
  201. package/dist/services/execution-workspace-policy.d.ts +14 -1
  202. package/dist/services/execution-workspace-policy.d.ts.map +1 -1
  203. package/dist/services/execution-workspace-policy.js +29 -12
  204. package/dist/services/execution-workspace-policy.js.map +1 -1
  205. package/dist/services/execution-workspaces.d.ts +89 -0
  206. package/dist/services/execution-workspaces.d.ts.map +1 -1
  207. package/dist/services/execution-workspaces.js +997 -125
  208. package/dist/services/execution-workspaces.js.map +1 -1
  209. package/dist/services/heartbeat-run-summary.d.ts +1 -0
  210. package/dist/services/heartbeat-run-summary.d.ts.map +1 -1
  211. package/dist/services/heartbeat-run-summary.js +19 -3
  212. package/dist/services/heartbeat-run-summary.js.map +1 -1
  213. package/dist/services/heartbeat.d.ts +18 -2
  214. package/dist/services/heartbeat.d.ts.map +1 -1
  215. package/dist/services/heartbeat.js +369 -16
  216. package/dist/services/heartbeat.js.map +1 -1
  217. package/dist/services/import-write-types.d.ts +12 -0
  218. package/dist/services/import-write-types.d.ts.map +1 -1
  219. package/dist/services/index.d.ts +2 -0
  220. package/dist/services/index.d.ts.map +1 -1
  221. package/dist/services/index.js +2 -0
  222. package/dist/services/index.js.map +1 -1
  223. package/dist/services/instance-settings.d.ts +10 -1
  224. package/dist/services/instance-settings.d.ts.map +1 -1
  225. package/dist/services/instance-settings.js +16 -9
  226. package/dist/services/instance-settings.js.map +1 -1
  227. package/dist/services/issue-review-policy.d.ts +4 -0
  228. package/dist/services/issue-review-policy.d.ts.map +1 -1
  229. package/dist/services/issue-review-policy.js +18 -7
  230. package/dist/services/issue-review-policy.js.map +1 -1
  231. package/dist/services/issue-thread-interaction-resolution.d.ts +92 -0
  232. package/dist/services/issue-thread-interaction-resolution.d.ts.map +1 -0
  233. package/dist/services/issue-thread-interaction-resolution.js +198 -0
  234. package/dist/services/issue-thread-interaction-resolution.js.map +1 -0
  235. package/dist/services/issue-thread-interaction-resolution.test.d.ts +2 -0
  236. package/dist/services/issue-thread-interaction-resolution.test.d.ts.map +1 -0
  237. package/dist/services/issue-thread-interaction-resolution.test.js +152 -0
  238. package/dist/services/issue-thread-interaction-resolution.test.js.map +1 -0
  239. package/dist/services/issue-thread-interactions.d.ts +24 -4
  240. package/dist/services/issue-thread-interactions.d.ts.map +1 -1
  241. package/dist/services/issue-thread-interactions.js +360 -145
  242. package/dist/services/issue-thread-interactions.js.map +1 -1
  243. package/dist/services/issue-thread-interactions.test.js +18 -11
  244. package/dist/services/issue-thread-interactions.test.js.map +1 -1
  245. package/dist/services/issues.d.ts +3 -0
  246. package/dist/services/issues.d.ts.map +1 -1
  247. package/dist/services/issues.js +157 -33
  248. package/dist/services/issues.js.map +1 -1
  249. package/dist/services/local-service-supervisor.d.ts +2 -0
  250. package/dist/services/local-service-supervisor.d.ts.map +1 -1
  251. package/dist/services/local-service-supervisor.js +53 -3
  252. package/dist/services/local-service-supervisor.js.map +1 -1
  253. package/dist/services/managed-environments.d.ts +4 -1
  254. package/dist/services/managed-environments.d.ts.map +1 -1
  255. package/dist/services/managed-environments.js +86 -2
  256. package/dist/services/managed-environments.js.map +1 -1
  257. package/dist/services/managed-environments.test.js +164 -0
  258. package/dist/services/managed-environments.test.js.map +1 -1
  259. package/dist/services/onboarding-greeting.d.ts +7 -0
  260. package/dist/services/onboarding-greeting.d.ts.map +1 -0
  261. package/dist/services/onboarding-greeting.js +27 -0
  262. package/dist/services/onboarding-greeting.js.map +1 -0
  263. package/dist/services/onboarding-greeting.test.d.ts +2 -0
  264. package/dist/services/onboarding-greeting.test.d.ts.map +1 -0
  265. package/dist/services/onboarding-greeting.test.js +32 -0
  266. package/dist/services/onboarding-greeting.test.js.map +1 -0
  267. package/dist/services/onboarding-seed.d.ts +57 -0
  268. package/dist/services/onboarding-seed.d.ts.map +1 -0
  269. package/dist/services/onboarding-seed.js +344 -0
  270. package/dist/services/onboarding-seed.js.map +1 -0
  271. package/dist/services/plan-review-context.d.ts +9 -1
  272. package/dist/services/plan-review-context.d.ts.map +1 -1
  273. package/dist/services/plan-review-context.js +192 -0
  274. package/dist/services/plan-review-context.js.map +1 -1
  275. package/dist/services/plugin-environment-driver.d.ts +46 -0
  276. package/dist/services/plugin-environment-driver.d.ts.map +1 -1
  277. package/dist/services/plugin-environment-driver.js +50 -0
  278. package/dist/services/plugin-environment-driver.js.map +1 -1
  279. package/dist/services/plugin-host-services.d.ts +1 -0
  280. package/dist/services/plugin-host-services.d.ts.map +1 -1
  281. package/dist/services/plugin-host-services.js +51 -28
  282. package/dist/services/plugin-host-services.js.map +1 -1
  283. package/dist/services/plugin-loader.d.ts +10 -1
  284. package/dist/services/plugin-loader.d.ts.map +1 -1
  285. package/dist/services/plugin-loader.js +43 -2
  286. package/dist/services/plugin-loader.js.map +1 -1
  287. package/dist/services/plugin-worker-manager.d.ts +61 -0
  288. package/dist/services/plugin-worker-manager.d.ts.map +1 -1
  289. package/dist/services/plugin-worker-manager.js +248 -1
  290. package/dist/services/plugin-worker-manager.js.map +1 -1
  291. package/dist/services/productivity-review.d.ts.map +1 -1
  292. package/dist/services/productivity-review.js +5 -0
  293. package/dist/services/productivity-review.js.map +1 -1
  294. package/dist/services/recovery/service.d.ts.map +1 -1
  295. package/dist/services/recovery/service.js +5 -1
  296. package/dist/services/recovery/service.js.map +1 -1
  297. package/dist/services/recovery/successful-run-handoff.d.ts +16 -1
  298. package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
  299. package/dist/services/recovery/successful-run-handoff.js +32 -0
  300. package/dist/services/recovery/successful-run-handoff.js.map +1 -1
  301. package/dist/services/recovery/successful-run-handoff.test.js +51 -1
  302. package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
  303. package/dist/services/routines.d.ts.map +1 -1
  304. package/dist/services/routines.js +48 -4
  305. package/dist/services/routines.js.map +1 -1
  306. package/dist/services/runtime-exposure/broker-client.d.ts +88 -0
  307. package/dist/services/runtime-exposure/broker-client.d.ts.map +1 -0
  308. package/dist/services/runtime-exposure/broker-client.js +217 -0
  309. package/dist/services/runtime-exposure/broker-client.js.map +1 -0
  310. package/dist/services/runtime-exposure/broker-client.test.d.ts +2 -0
  311. package/dist/services/runtime-exposure/broker-client.test.d.ts.map +1 -0
  312. package/dist/services/runtime-exposure/broker-client.test.js +185 -0
  313. package/dist/services/runtime-exposure/broker-client.test.js.map +1 -0
  314. package/dist/services/runtime-exposure/exposure-manager.d.ts +135 -0
  315. package/dist/services/runtime-exposure/exposure-manager.d.ts.map +1 -0
  316. package/dist/services/runtime-exposure/exposure-manager.js +247 -0
  317. package/dist/services/runtime-exposure/exposure-manager.js.map +1 -0
  318. package/dist/services/runtime-exposure/exposure-manager.test.d.ts +2 -0
  319. package/dist/services/runtime-exposure/exposure-manager.test.d.ts.map +1 -0
  320. package/dist/services/runtime-exposure/exposure-manager.test.js +384 -0
  321. package/dist/services/runtime-exposure/exposure-manager.test.js.map +1 -0
  322. package/dist/services/runtime-exposure/loopback-listener.d.ts +33 -0
  323. package/dist/services/runtime-exposure/loopback-listener.d.ts.map +1 -0
  324. package/dist/services/runtime-exposure/loopback-listener.js +146 -0
  325. package/dist/services/runtime-exposure/loopback-listener.js.map +1 -0
  326. package/dist/services/runtime-exposure/loopback-listener.test.d.ts +2 -0
  327. package/dist/services/runtime-exposure/loopback-listener.test.d.ts.map +1 -0
  328. package/dist/services/runtime-exposure/loopback-listener.test.js +116 -0
  329. package/dist/services/runtime-exposure/loopback-listener.test.js.map +1 -0
  330. package/dist/services/runtime-exposure/port-pair.d.ts +47 -0
  331. package/dist/services/runtime-exposure/port-pair.d.ts.map +1 -0
  332. package/dist/services/runtime-exposure/port-pair.js +57 -0
  333. package/dist/services/runtime-exposure/port-pair.js.map +1 -0
  334. package/dist/services/runtime-exposure/port-pair.test.d.ts +2 -0
  335. package/dist/services/runtime-exposure/port-pair.test.d.ts.map +1 -0
  336. package/dist/services/runtime-exposure/port-pair.test.js +92 -0
  337. package/dist/services/runtime-exposure/port-pair.test.js.map +1 -0
  338. package/dist/services/runtime-exposure/port-reservation.d.ts +211 -0
  339. package/dist/services/runtime-exposure/port-reservation.d.ts.map +1 -0
  340. package/dist/services/runtime-exposure/port-reservation.js +380 -0
  341. package/dist/services/runtime-exposure/port-reservation.js.map +1 -0
  342. package/dist/services/runtime-exposure/port-reservation.test.d.ts +2 -0
  343. package/dist/services/runtime-exposure/port-reservation.test.d.ts.map +1 -0
  344. package/dist/services/runtime-exposure/port-reservation.test.js +456 -0
  345. package/dist/services/runtime-exposure/port-reservation.test.js.map +1 -0
  346. package/dist/services/runtime-exposure/tailscale-hostname.d.ts +11 -0
  347. package/dist/services/runtime-exposure/tailscale-hostname.d.ts.map +1 -0
  348. package/dist/services/runtime-exposure/tailscale-hostname.js +36 -0
  349. package/dist/services/runtime-exposure/tailscale-hostname.js.map +1 -0
  350. package/dist/services/runtime-exposure/tailscale-hostname.test.d.ts +2 -0
  351. package/dist/services/runtime-exposure/tailscale-hostname.test.d.ts.map +1 -0
  352. package/dist/services/runtime-exposure/tailscale-hostname.test.js +14 -0
  353. package/dist/services/runtime-exposure/tailscale-hostname.test.js.map +1 -0
  354. package/dist/services/sandbox-orphan-cleanup-spool.d.ts +64 -0
  355. package/dist/services/sandbox-orphan-cleanup-spool.d.ts.map +1 -0
  356. package/dist/services/sandbox-orphan-cleanup-spool.js +134 -0
  357. package/dist/services/sandbox-orphan-cleanup-spool.js.map +1 -0
  358. package/dist/services/sandbox-provider-runtime.d.ts +15 -0
  359. package/dist/services/sandbox-provider-runtime.d.ts.map +1 -1
  360. package/dist/services/sandbox-provider-runtime.js +1 -0
  361. package/dist/services/sandbox-provider-runtime.js.map +1 -1
  362. package/dist/services/secrets.d.ts +103 -0
  363. package/dist/services/secrets.d.ts.map +1 -1
  364. package/dist/services/secrets.js +432 -10
  365. package/dist/services/secrets.js.map +1 -1
  366. package/dist/services/setup-token-session.d.ts +630 -0
  367. package/dist/services/setup-token-session.d.ts.map +1 -0
  368. package/dist/services/setup-token-session.js +1005 -0
  369. package/dist/services/setup-token-session.js.map +1 -0
  370. package/dist/services/setup-token-session.test.d.ts +2 -0
  371. package/dist/services/setup-token-session.test.d.ts.map +1 -0
  372. package/dist/services/setup-token-session.test.js +1143 -0
  373. package/dist/services/setup-token-session.test.js.map +1 -0
  374. package/dist/services/setup-token-transport-binding.d.ts +202 -0
  375. package/dist/services/setup-token-transport-binding.d.ts.map +1 -0
  376. package/dist/services/setup-token-transport-binding.js +386 -0
  377. package/dist/services/setup-token-transport-binding.js.map +1 -0
  378. package/dist/services/setup-token-transport-binding.test.d.ts +2 -0
  379. package/dist/services/setup-token-transport-binding.test.d.ts.map +1 -0
  380. package/dist/services/setup-token-transport-binding.test.js +761 -0
  381. package/dist/services/setup-token-transport-binding.test.js.map +1 -0
  382. package/dist/services/summary-slots.d.ts.map +1 -1
  383. package/dist/services/summary-slots.js +40 -6
  384. package/dist/services/summary-slots.js.map +1 -1
  385. package/dist/services/task-watchdogs.js +2 -2
  386. package/dist/services/task-watchdogs.js.map +1 -1
  387. package/dist/services/tool-access-policy.d.ts +1 -1
  388. package/dist/services/tool-access.d.ts.map +1 -1
  389. package/dist/services/tool-access.js +29 -11
  390. package/dist/services/tool-access.js.map +1 -1
  391. package/dist/services/tool-gateway.d.ts +1 -1
  392. package/dist/services/tool-gateway.d.ts.map +1 -1
  393. package/dist/services/tool-gateway.js +56 -5
  394. package/dist/services/tool-gateway.js.map +1 -1
  395. package/dist/services/workspace-file-resources.d.ts +10 -1
  396. package/dist/services/workspace-file-resources.d.ts.map +1 -1
  397. package/dist/services/workspace-file-resources.js +258 -6
  398. package/dist/services/workspace-file-resources.js.map +1 -1
  399. package/dist/services/workspace-git-operation-scheduler.d.ts +135 -0
  400. package/dist/services/workspace-git-operation-scheduler.d.ts.map +1 -0
  401. package/dist/services/workspace-git-operation-scheduler.js +655 -0
  402. package/dist/services/workspace-git-operation-scheduler.js.map +1 -0
  403. package/dist/services/workspace-git-operation-scheduler.test.d.ts +2 -0
  404. package/dist/services/workspace-git-operation-scheduler.test.d.ts.map +1 -0
  405. package/dist/services/workspace-git-operation-scheduler.test.js +367 -0
  406. package/dist/services/workspace-git-operation-scheduler.test.js.map +1 -0
  407. package/dist/services/workspace-operations.d.ts +60 -0
  408. package/dist/services/workspace-operations.d.ts.map +1 -1
  409. package/dist/services/workspace-operations.js +384 -19
  410. package/dist/services/workspace-operations.js.map +1 -1
  411. package/dist/services/workspace-runtime-exposure-backfill.test.d.ts +2 -0
  412. package/dist/services/workspace-runtime-exposure-backfill.test.d.ts.map +1 -0
  413. package/dist/services/workspace-runtime-exposure-backfill.test.js +149 -0
  414. package/dist/services/workspace-runtime-exposure-backfill.test.js.map +1 -0
  415. package/dist/services/workspace-runtime-exposure.test.d.ts +2 -0
  416. package/dist/services/workspace-runtime-exposure.test.d.ts.map +1 -0
  417. package/dist/services/workspace-runtime-exposure.test.js +517 -0
  418. package/dist/services/workspace-runtime-exposure.test.js.map +1 -0
  419. package/dist/services/workspace-runtime-leases.d.ts +90 -0
  420. package/dist/services/workspace-runtime-leases.d.ts.map +1 -0
  421. package/dist/services/workspace-runtime-leases.js +252 -0
  422. package/dist/services/workspace-runtime-leases.js.map +1 -0
  423. package/dist/services/workspace-runtime-read-model.d.ts +12 -0
  424. package/dist/services/workspace-runtime-read-model.d.ts.map +1 -1
  425. package/dist/services/workspace-runtime-read-model.test.js +3 -0
  426. package/dist/services/workspace-runtime-read-model.test.js.map +1 -1
  427. package/dist/services/workspace-runtime-ready-comment.test.js +1 -0
  428. package/dist/services/workspace-runtime-ready-comment.test.js.map +1 -1
  429. package/dist/services/workspace-runtime.d.ts +160 -2
  430. package/dist/services/workspace-runtime.d.ts.map +1 -1
  431. package/dist/services/workspace-runtime.js +1693 -141
  432. package/dist/services/workspace-runtime.js.map +1 -1
  433. package/dist/shutdown.d.ts +24 -0
  434. package/dist/shutdown.d.ts.map +1 -1
  435. package/dist/shutdown.js +37 -0
  436. package/dist/shutdown.js.map +1 -1
  437. package/dist/shutdown.test.js +113 -1
  438. package/dist/shutdown.test.js.map +1 -1
  439. package/dist/startup-banner.js +1 -1
  440. package/dist/startup-banner.js.map +1 -1
  441. package/dist/url-utils.d.ts +26 -0
  442. package/dist/url-utils.d.ts.map +1 -0
  443. package/dist/url-utils.js +59 -0
  444. package/dist/url-utils.js.map +1 -0
  445. package/dist/url-utils.test.d.ts +2 -0
  446. package/dist/url-utils.test.d.ts.map +1 -0
  447. package/dist/url-utils.test.js +55 -0
  448. package/dist/url-utils.test.js.map +1 -0
  449. package/dist/worktree-config.d.ts.map +1 -1
  450. package/dist/worktree-config.js +3 -17
  451. package/dist/worktree-config.js.map +1 -1
  452. package/package.json +20 -20
  453. package/skills/paperclip/SKILL.md +16 -11
  454. package/skills/paperclip/references/api-reference.md +8 -5
  455. package/skills/paperclip-board/SKILL.md +1 -1
  456. package/ui-dist/assets/{abnfDiagram-VRR7QNED-wvSnvD6-.js → abnfDiagram-N423BO3Z-BvgoV-u5.js} +1 -1
  457. package/ui-dist/assets/{arc-Bx0V1JsN.js → arc-BInnUBUU.js} +1 -1
  458. package/ui-dist/assets/architectureDiagram-T3A2C74G-Dg4MziOm.js +36 -0
  459. package/ui-dist/assets/{blockDiagram-677ZJIJ3-BROcLaUk.js → blockDiagram-VBNYF7ZC-D9kzAkLN.js} +1 -1
  460. package/ui-dist/assets/{c4Diagram-LMCZKHZV-D-V3kgju.js → c4Diagram-5PPSVZJV-BwzsgV_0.js} +1 -1
  461. package/ui-dist/assets/channel-qoSYbCn0.js +1 -0
  462. package/ui-dist/assets/{chunk-32BRIVSS-qillH3H6.js → chunk-2GRJ4B5K-D2EQ9vrl.js} +1 -1
  463. package/ui-dist/assets/{chunk-2Q5K7J3B-D19cVnii.js → chunk-2Q5K7J3B-DOA_8fSd.js} +1 -1
  464. package/ui-dist/assets/{chunk-EX3LRPZG-CLUswrZg.js → chunk-5RXB4S5H-CT5ukRiT.js} +1 -1
  465. package/ui-dist/assets/{chunk-5VM5RSS4-Cwlp02Lx.js → chunk-5VM5RSS4-D2gOlkyB.js} +1 -1
  466. package/ui-dist/assets/{chunk-MOJQB5TN-yKciyq6E.js → chunk-6Q2QTUOP-DUb6aG8c.js} +1 -1
  467. package/ui-dist/assets/{chunk-V7JOEXUC-DjJ75g0_.js → chunk-GF5L2VYU-BPabG3Cx.js} +1 -1
  468. package/ui-dist/assets/{chunk-JWPE2WC7-BjepyzEb.js → chunk-JWPE2WC7-scNfhlZz.js} +1 -1
  469. package/ui-dist/assets/{chunk-VR4S4FIN-CiEmy8-R.js → chunk-KBJHAD2P-BcRAzRpq.js} +1 -1
  470. package/ui-dist/assets/{chunk-RYQCIY6F-ed7mQM9j.js → chunk-RYQCIY6F-BiZ0-EGh.js} +1 -1
  471. package/ui-dist/assets/{chunk-XXDRQBXY-BiI83zoO.js → chunk-XXDRQBXY-lJULxFIE.js} +1 -1
  472. package/ui-dist/assets/classDiagram-JCYQIIEL-B24u1REs.js +1 -0
  473. package/ui-dist/assets/classDiagram-v2-OCEON4UE-B24u1REs.js +1 -0
  474. package/ui-dist/assets/{cose-bilkent-JH36ORCC-DIdQf5ta.js → cose-bilkent-JH36ORCC-Do8pe8JB.js} +1 -1
  475. package/ui-dist/assets/{cynefin-VYW2F7L2-B2m9xxh8.js → cynefin-VYW2F7L2-B34jJY1x.js} +1 -1
  476. package/ui-dist/assets/{cynefinDiagram-TSTJHNR4-CGVdB0kX.js → cynefinDiagram-MW4NZA55-DQ8QK8JK.js} +1 -1
  477. package/ui-dist/assets/cytoscape.esm-DShqj__B.js +321 -0
  478. package/ui-dist/assets/{dagre-VKFMJZFB-CskKiZwJ.js → dagre-VZM6K2ZE-B7lSKgxH.js} +1 -1
  479. package/ui-dist/assets/{diagram-OA4YK3LP-D2cn5eIN.js → diagram-7IWD3JNH-B3hLAFGE.js} +1 -1
  480. package/ui-dist/assets/{diagram-FQU43EPY-oa5Zu0XG.js → diagram-B4RE2ZJO-CH9Ro9e0.js} +1 -1
  481. package/ui-dist/assets/{diagram-NH7WQ7WH-fymti5Js.js → diagram-LBJQPF4R-DdPVMii7.js} +1 -1
  482. package/ui-dist/assets/{diagram-G47NLZAW-BWF2ZCsw.js → diagram-Q27KOJAE-f5VXswjc.js} +1 -1
  483. package/ui-dist/assets/diagram-UB23O5K3-DGqxixsR.js +41 -0
  484. package/ui-dist/assets/{ebnfDiagram-CCIWWBDH-CJ1d3D5q.js → ebnfDiagram-BXEA7PRR-C-1klg_w.js} +1 -1
  485. package/ui-dist/assets/{erDiagram-Q63AITRT-BJCPa7kK.js → erDiagram-JOGREHBK-Dt4qK4gv.js} +1 -1
  486. package/ui-dist/assets/{flowDiagram-23GEKE2U-BWjWuz6r.js → flowDiagram-UKHOOZJN-9ZlrTuET.js} +5 -5
  487. package/ui-dist/assets/{ganttDiagram-NO4QXBWP-Dv1KKjoM.js → ganttDiagram-PKOTCBZU-CE3lyvFq.js} +1 -1
  488. package/ui-dist/assets/{gitGraphDiagram-IHSO6WYX-9X7vgL0A.js → gitGraphDiagram-DS77QQ5N-HH2fSgad.js} +1 -1
  489. package/ui-dist/assets/{index-CpaK48ok.js → index-B61ORkIb.js} +1 -1
  490. package/ui-dist/assets/index-BHiUiToL.css +1 -0
  491. package/ui-dist/assets/{index-D2YCrA-D.js → index-BY5ITKmG.js} +1 -1
  492. package/ui-dist/assets/{index-9JMDmXc5.js → index-BbAhzCq8.js} +1 -1
  493. package/ui-dist/assets/{index-C5qHatpp.js → index-Bc9g6q63.js} +1 -1
  494. package/ui-dist/assets/{index-DuI6e5mP.js → index-Bf7tyGxE.js} +1 -1
  495. package/ui-dist/assets/{index-BahRsj0s.js → index-BvFx-4KW.js} +1 -1
  496. package/ui-dist/assets/{index-DKlsEDV7.js → index-C1mpWCcn.js} +1 -1
  497. package/ui-dist/assets/{index-DIXaXZrW.js → index-CHXG4GPL.js} +1 -1
  498. package/ui-dist/assets/{index-DcfO6NoY.js → index-CLu3Tfpv.js} +1 -1
  499. package/ui-dist/assets/{index-Cfz6P0Qq.js → index-CQhaM6qd.js} +1 -1
  500. package/ui-dist/assets/{index-D9d3sl6K.js → index-CVTL3aAL.js} +1 -1
  501. package/ui-dist/assets/{index-BexA3NAl.js → index-CcRTJ93J.js} +1 -1
  502. package/ui-dist/assets/{index-CnBrDwGT.js → index-Cm6iEYPt.js} +247 -223
  503. package/ui-dist/assets/{index-ByGcu-MB.js → index-DQIrrn2L.js} +1 -1
  504. package/ui-dist/assets/{index-DKEzHO7c.js → index-DU4U1fuv.js} +1 -1
  505. package/ui-dist/assets/{index-pqJO8FsV.js → index-DV2FOgyD.js} +1 -1
  506. package/ui-dist/assets/{index-tDuieU3u.js → index-DceuCrr8.js} +1 -1
  507. package/ui-dist/assets/{index-CjN2lm_w.js → index-DlVBBF8o.js} +1 -1
  508. package/ui-dist/assets/{index-0yJtUsFh.js → index-DsdVneqe.js} +1 -1
  509. package/ui-dist/assets/{index-C2a4_IKU.js → index-DvFUXI-Q.js} +1 -1
  510. package/ui-dist/assets/{index-BTMiDMYv.js → index-FHzrJ7iQ.js} +1 -1
  511. package/ui-dist/assets/{index-cUQXJtLK.js → index-diFs0CNC.js} +1 -1
  512. package/ui-dist/assets/{index-C96LHV1A.js → index-hkanj4t6.js} +1 -1
  513. package/ui-dist/assets/{index-rDI1Q4r9.js → index-yRJnh4vx.js} +1 -1
  514. package/ui-dist/assets/{infoDiagram-FWYZ7A6U-CHIhutSb.js → infoDiagram-6WML65LV-2jai2fw-.js} +1 -1
  515. package/ui-dist/assets/{ishikawaDiagram-FXEZZL3T-ChaD7-5_.js → ishikawaDiagram-WSZJBQD7-CUmGhmEb.js} +1 -1
  516. package/ui-dist/assets/{journeyDiagram-5HDEW3XC-M5T2sMgn.js → journeyDiagram-NVQOT4AX-CGQuPDO1.js} +1 -1
  517. package/ui-dist/assets/{kanban-definition-HUTT4EX6-B8H6R_Wc.js → kanban-definition-27J2QSJJ-zgEPcbHl.js} +1 -1
  518. package/ui-dist/assets/{linear-cpPKlVCq.js → linear-BVf5VtLS.js} +1 -1
  519. package/ui-dist/assets/mermaid.core-D2TFkGfI.js +308 -0
  520. package/ui-dist/assets/{mindmap-definition-LN4V7U3C-Bf6POzvm.js → mindmap-definition-FAOFIHXS-xoAQ0Lb_.js} +1 -1
  521. package/ui-dist/assets/{pegDiagram-2B236MQR-BJbGktAV.js → pegDiagram-VL7TDLO6-CBR1PWLr.js} +1 -1
  522. package/ui-dist/assets/{pieDiagram-ENE6RG2P-BmSN9Bio.js → pieDiagram-7S7Q4E2Y-BMPnzK_e.js} +1 -1
  523. package/ui-dist/assets/{quadrantDiagram-ABIIQ3AL-JUpqQCx_.js → quadrantDiagram-CIZ2JOQS-CPtVUPkR.js} +1 -1
  524. package/ui-dist/assets/{railroadDiagram-RFXS5EU6-B8k8T4-2.js → railroadDiagram-AXF67PYL-DC0lsZV1.js} +1 -1
  525. package/ui-dist/assets/{requirementDiagram-TGXJPOKE-b32heq7Q.js → requirementDiagram-LRYGKXZP-BF6k05SB.js} +1 -1
  526. package/ui-dist/assets/{sankeyDiagram-HTMAVEWB-DC3s-xgf.js → sankeyDiagram-W5VNT64P-CRN0ibiP.js} +1 -1
  527. package/ui-dist/assets/{sequenceDiagram-DBY2YBRQ-BsCFyv_E.js → sequenceDiagram-SI44F4Z6-CQtTiPWm.js} +1 -1
  528. package/ui-dist/assets/{sizeCapture-X5ZJPWSS-S7xeCud3.js → sizeCapture-X5ZJPWSS-BdjuGZd1.js} +1 -1
  529. package/ui-dist/assets/{stateDiagram-2N3HPSRC-58O3ofoS.js → stateDiagram-OKZ733FA-B99onxyh.js} +1 -1
  530. package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-bzFlYBZ1.js +1 -0
  531. package/ui-dist/assets/{swimlanes-5IMT3BWC-DFCVG0iF.js → swimlanes-SLNWSIFB-BIByPbrL.js} +2 -2
  532. package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-DgdkBnuK.js +8 -0
  533. package/ui-dist/assets/{timeline-definition-FHXFAJF6-C0c0SkQu.js → timeline-definition-Z64GVDOM-I1NgOcn9.js} +1 -1
  534. package/ui-dist/assets/{vennDiagram-L72KCM5P-B1cEx8GQ.js → vennDiagram-T6HMQDX7-D6t2Njcl.js} +1 -1
  535. package/ui-dist/assets/{wardleyDiagram-EHGQE667-BQhDkOx9.js → wardleyDiagram-T6FBY63Y-BiYKm9gh.js} +1 -1
  536. package/ui-dist/assets/{xychartDiagram-FW5EYKEG-YSQp1N0i.js → xychartDiagram-ELKLHX3M-BhwXKWIa.js} +4 -4
  537. package/ui-dist/index.html +3 -3
  538. package/ui-dist/sw.js +8 -3
  539. package/ui-dist/assets/architectureDiagram-ZJ3FMSHR-BcJt8Ia1.js +0 -36
  540. package/ui-dist/assets/channel-ZRpjyNiZ.js +0 -1
  541. package/ui-dist/assets/classDiagram-OUVF2IWQ-CCzzzCgk.js +0 -1
  542. package/ui-dist/assets/classDiagram-v2-EOCWNBFH-CCzzzCgk.js +0 -1
  543. package/ui-dist/assets/cytoscape.esm-D8joxN9f.js +0 -321
  544. package/ui-dist/assets/diagram-WEI45ONY-M3WOC2tK.js +0 -41
  545. package/ui-dist/assets/index-BqfqfoQX.css +0 -1
  546. package/ui-dist/assets/mermaid.core-MCazc_od.js +0 -308
  547. package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-_VY5dpDG.js +0 -1
  548. package/ui-dist/assets/swimlanesDiagram-G3AALYLV-BJhUjfns.js +0 -8
@@ -3,23 +3,120 @@ import { createHash } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
6
+ import { and, asc, desc, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
7
7
  import { executionWorkspaces, heartbeatRuns, issueWorkProducts, issues, projects, projectWorkspaces, workspaceRuntimeServices, } from "@paperclipai/db";
8
8
  import { deriveProjectUrlKey, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT } from "@paperclipai/shared";
9
9
  import { conflict, notFound, unprocessable } from "../errors.js";
10
+ import { logger } from "../middleware/logger.js";
10
11
  import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, } from "./issue-execution-policy.js";
11
12
  import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
12
13
  import { issueRecoveryActionService } from "./issue-recovery-actions.js";
13
14
  import { logActivity } from "./activity-log.js";
14
15
  import { createPullRequestMergeDetailsResolver, extractGitHubPullRequestReferences, setBoundedPullRequestCacheEntry, } from "./github-pull-request-merge.js";
15
16
  import { visibleIssueCondition } from "./issue-visibility.js";
17
+ import { createGitRemoteAuthProvider } from "./git-credentials.js";
16
18
  import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
19
+ import { workspaceGitOperationScheduler } from "./workspace-git-operation-scheduler.js";
17
20
  import { listCurrentRuntimeServicesForExecutionWorkspaces, listCurrentRuntimeServicesForProjectWorkspaces, selectConfiguredRuntimeServiceRows, } from "./workspace-runtime-read-model.js";
18
21
  const execFileAsync = promisify(execFile);
19
22
  const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
20
23
  const WORKSPACE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence";
21
24
  const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
22
25
  export const ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON = "issue_terminal";
26
+ // The reopen-failure reason kept on the row when a rebuild does not finish. The
27
+ // value is sanitized: it never contains a repository URL, a host path, or git
28
+ // output.
29
+ export const EXECUTION_WORKSPACE_REOPEN_FAILED_REASON = "reopen_failed";
30
+ // How long the terminal reaper waits before it reclaims a stranded reopen-pending
31
+ // flag. A reopen sets the flag while the source issue is still terminal. The
32
+ // consuming request clears the flag within seconds when the request ends. If the
33
+ // server exits first, or every clear retry fails, the flag stays set and both the
34
+ // reaper and the archive route skip the workspace forever. After this grace the
35
+ // reaper reclaims the flag, but only when no run still owns it. The reaper checks
36
+ // for a live consuming run first, so a request that outruns this grace keeps its
37
+ // fence. A run has a heartbeat row the reaper can see. An HTTP consuming request
38
+ // has none, so the route re-stamps the flag on an interval below this grace, and
39
+ // the fresh timestamp keeps the fence. The grace is a backstop for a flag whose
40
+ // consumer is gone, not a hard deadline on the request.
41
+ export const STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS = 5 * 60 * 1000;
42
+ // The metadata key that holds the workspace lifecycle generation. The generation
43
+ // is a monotonic integer. Every archive and every reopen increases it by one. A
44
+ // destructive cleanup captures the generation it archived at, then re-reads the
45
+ // generation under the lifecycle lock immediately before it deletes the worktree.
46
+ // A reopen that ran in between raises the generation, so the stale cleanup finds
47
+ // a mismatch and does nothing. This fences a queued or in-flight cleanup against
48
+ // a workspace that a reopen already restored.
49
+ export const EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY = "lifecycleGeneration";
50
+ export function readExecutionWorkspaceLifecycleGeneration(metadata) {
51
+ const raw = metadata?.[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY];
52
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : 0;
53
+ }
54
+ // Return a metadata object with the lifecycle generation increased by one. The
55
+ // caller keeps every other metadata key.
56
+ export function bumpExecutionWorkspaceLifecycleGeneration(metadata) {
57
+ return {
58
+ ...(metadata ?? {}),
59
+ [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: readExecutionWorkspaceLifecycleGeneration(metadata) + 1,
60
+ };
61
+ }
62
+ function isClosedExecutionWorkspaceStatus(status) {
63
+ return status === "archived" || status === "cleanup_failed";
64
+ }
65
+ // The metadata key that marks a workspace as reopened for a source issue that is
66
+ // still terminal. A reopen sets this flag in the same write that publishes the
67
+ // row as active. The source issue is still terminal at that moment, because the
68
+ // route changes the issue out of the terminal state only after the reopen
69
+ // returns. Both destructive paths (the terminal reaper and the archive route)
70
+ // exclude a flagged workspace, so neither one archives and destroys a worktree
71
+ // that a reopen rebuilt while the caller has not yet consumed it. The reaper
72
+ // clears the flag on a later pass once the source issue leaves the terminal
73
+ // state, so a normal terminal cycle can reap the workspace again.
74
+ export const EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY = "reopenPendingConsumption";
75
+ // The metadata key that holds the time a reopen set the reopen-pending flag. The
76
+ // terminal reaper reads this timestamp to tell a fresh, in-flight reopen from a
77
+ // stranded flag whose consumer never cleared it. A reopen writes this key in the
78
+ // same write that sets the flag, and every clear removes both keys together.
79
+ export const EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY = "reopenPendingConsumptionSince";
80
+ export function metadataHasReopenPendingConsumption(metadata) {
81
+ return metadata?.[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY] === true;
82
+ }
83
+ // Read the time a reopen set the reopen-pending flag. Return null when the flag
84
+ // carries no valid timestamp. The terminal reaper uses this to tell a fresh,
85
+ // in-flight reopen from a stranded flag whose consumer never cleared it.
86
+ export function readMetadataReopenPendingConsumptionSince(metadata) {
87
+ const raw = metadata?.[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY];
88
+ if (typeof raw !== "string")
89
+ return null;
90
+ const parsed = new Date(raw);
91
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
92
+ }
93
+ // Return a metadata object with the reopen-pending flag set. The caller keeps
94
+ // every other metadata key. The `at` timestamp records when the reopen set the
95
+ // flag, so the terminal reaper can reclaim a stranded flag after a grace period.
96
+ export function setMetadataReopenPendingConsumption(metadata, at) {
97
+ return {
98
+ ...(metadata ?? {}),
99
+ [EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY]: true,
100
+ [EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY]: at.toISOString(),
101
+ };
102
+ }
103
+ // Return a metadata object with the reopen-pending flag removed. The caller
104
+ // keeps every other metadata key. The function removes the flag and its
105
+ // timestamp together, so no orphan timestamp survives a clear.
106
+ export function clearMetadataReopenPendingConsumption(metadata) {
107
+ const next = { ...(metadata ?? {}) };
108
+ delete next[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY];
109
+ delete next[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY];
110
+ return next;
111
+ }
112
+ // Acquire the per-workspace, transaction-scoped Postgres advisory lock. Postgres
113
+ // releases the lock when the transaction that holds `tx` commits or rolls back.
114
+ // Both the reopen path and the destructive cleanup path acquire the same lock,
115
+ // so they never run against the same workspace at the same time, even on
116
+ // different server processes.
117
+ async function acquireExecutionWorkspaceLifecycleLock(tx, workspaceId) {
118
+ await tx.execute(sql `select pg_advisory_xact_lock(hashtextextended(${`execution_workspace_lifecycle:${workspaceId}`}, 0))`);
119
+ }
23
120
  function parseGitHubRepository(repoUrl) {
24
121
  if (!repoUrl)
25
122
  return null;
@@ -102,6 +199,15 @@ async function pathExists(value) {
102
199
  async function runGit(args, cwd) {
103
200
  return await execFileAsync("git", ["-C", cwd, ...args], { cwd });
104
201
  }
202
+ async function runExpensiveGitStatus(input) {
203
+ return workspaceGitOperationScheduler.run({
204
+ workspacePath: input.cwd,
205
+ args: input.args,
206
+ operation: input.operation,
207
+ fairnessKeys: input.fairnessKeys,
208
+ cacheTtlMs: 0,
209
+ });
210
+ }
105
211
  async function readGitStdout(args, cwd) {
106
212
  const output = await runGit(args, cwd);
107
213
  return output.stdout.trim() || null;
@@ -197,7 +303,15 @@ async function inspectExecutionWorkspaceBranchForReconcile(workspace) {
197
303
  if (!toBranch) {
198
304
  throw unprocessable("Execution workspace is detached; Paperclip cannot reconcile it to a branch name");
199
305
  }
200
- const status = await runGit(["status", "--porcelain", "--untracked-files=all"], worktreePath)
306
+ const status = await runExpensiveGitStatus({
307
+ args: ["status", "--porcelain", "--untracked-files=all"],
308
+ cwd: worktreePath,
309
+ operation: "execution_workspaces.branch_reconcile_status",
310
+ fairnessKeys: [
311
+ `workspace:${workspace.id}`,
312
+ ...(workspace.sourceIssueId ? [`issue:${workspace.sourceIssueId}`] : []),
313
+ ],
314
+ })
201
315
  .then((output) => output.stdout)
202
316
  .catch(() => null);
203
317
  const statusLines = status === null
@@ -395,11 +509,11 @@ async function inspectGitCloseReadiness(workspace) {
395
509
  const expectsGitInspection = workspace.providerType === "git_worktree" ||
396
510
  Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath);
397
511
  if (!expectsGitInspection) {
398
- return { git: null, warnings };
512
+ return { git: null, warnings, statusInspectionSucceeded: true };
399
513
  }
400
514
  if (!workspacePath) {
401
515
  warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close.");
402
- return { git: null, warnings };
516
+ return { git: null, warnings, statusInspectionSucceeded: false };
403
517
  }
404
518
  if (!(await pathExists(workspacePath))) {
405
519
  warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`);
@@ -419,6 +533,7 @@ async function inspectGitCloseReadiness(workspace) {
419
533
  createdByRuntime,
420
534
  },
421
535
  warnings,
536
+ statusInspectionSucceeded: true,
422
537
  };
423
538
  }
424
539
  let repoRoot = null;
@@ -439,9 +554,19 @@ async function inspectGitCloseReadiness(workspace) {
439
554
  }
440
555
  let dirtyEntryCount = 0;
441
556
  let untrackedEntryCount = 0;
557
+ let statusInspectionSucceeded = false;
442
558
  if (repoRoot) {
443
559
  try {
444
- const statusOutput = (await runGit(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath)).stdout;
560
+ const statusOutput = (await runExpensiveGitStatus({
561
+ args: ["status", "--porcelain=v1", "--untracked-files=all"],
562
+ cwd: workspacePath,
563
+ operation: "execution_workspaces.close_readiness_status",
564
+ fairnessKeys: [
565
+ `company:${workspace.companyId}`,
566
+ `workspace:${workspace.id}`,
567
+ ...(workspace.sourceIssueId ? [`issue:${workspace.sourceIssueId}`] : []),
568
+ ],
569
+ })).stdout;
445
570
  for (const line of statusOutput.split(/\r?\n/)) {
446
571
  if (!line)
447
572
  continue;
@@ -451,6 +576,7 @@ async function inspectGitCloseReadiness(workspace) {
451
576
  }
452
577
  dirtyEntryCount += 1;
453
578
  }
579
+ statusInspectionSucceeded = true;
454
580
  }
455
581
  catch (error) {
456
582
  warnings.push(`Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`);
@@ -499,6 +625,7 @@ async function inspectGitCloseReadiness(workspace) {
499
625
  createdByRuntime,
500
626
  },
501
627
  warnings,
628
+ statusInspectionSucceeded,
502
629
  };
503
630
  }
504
631
  export function readExecutionWorkspaceConfig(metadata) {
@@ -605,6 +732,7 @@ function toRuntimeService(row) {
605
732
  stoppedAt: row.stoppedAt ?? null,
606
733
  stopPolicy: row.stopPolicy ?? null,
607
734
  healthStatus: row.healthStatus,
735
+ exposure: row.exposure ?? null,
608
736
  configIndex: row.configIndex ?? null,
609
737
  createdAt: row.createdAt,
610
738
  updatedAt: row.updatedAt,
@@ -674,6 +802,7 @@ function toWorkspaceOverviewPrimaryService(service) {
674
802
  url: service.url,
675
803
  port: service.port,
676
804
  healthStatus: service.healthStatus,
805
+ exposure: service.exposure ?? null,
677
806
  updatedAt: service.updatedAt,
678
807
  };
679
808
  }
@@ -744,6 +873,28 @@ export function executionWorkspaceService(db, opts = {}) {
744
873
  const now = opts.now ?? (() => new Date());
745
874
  const pullRequestStateCache = new Map();
746
875
  const pullRequestStateCacheTtlMs = 5 * 60 * 1000;
876
+ // The terminal-workspace reaper scans the candidate set in fixed-size pages.
877
+ // It keeps this keyset cursor between sweeps so each sweep continues after the
878
+ // previous page. A skipped candidate keeps its updatedAt, so a cursor is
879
+ // required: without it the query re-selects the oldest ineligible rows every
880
+ // sweep and starves eligible rows behind them. The cursor resets to the start
881
+ // when a sweep reaches the end of the candidate set.
882
+ let terminalSweepCursor = null;
883
+ // The reaper freezes an upper bound on updatedAt at the start of each
884
+ // rotation. The scan only reads candidates at or below the bound, so a steady
885
+ // stream of newer candidates cannot keep every page full and stop the cursor
886
+ // from ever reaching the end. A frozen set is finite, so the cursor always
887
+ // reaches a short page and resets, and older candidates that became eligible
888
+ // are revisited on the next rotation. The next rotation captures a new bound,
889
+ // so candidates updated after the previous bound enter the scan then.
890
+ let terminalSweepBoundary = null;
891
+ // The scheduler starts a sweep on each tick and does not wait for the previous
892
+ // sweep to finish. A sweep that outlasts the tick interval overlaps the next
893
+ // sweep. Both sweeps share the cursor and the boundary above. Interleaved
894
+ // reads and writes can leave a non-null cursor with a null boundary, which
895
+ // removes the upper bound and makes the scan chase newer churn again. This
896
+ // flag lets only one sweep run at a time, so one sweep owns the shared state.
897
+ let terminalSweepInProgress = false;
747
898
  async function listWorkspaceIssueTree(workspace) {
748
899
  if (!workspace.sourceIssueId)
749
900
  return [];
@@ -865,6 +1016,9 @@ export function executionWorkspaceService(db, opts = {}) {
865
1016
  readGitStdout(["rev-parse", "HEAD"], workspacePath).catch(() => null),
866
1017
  readGitStdout(["symbolic-ref", "--quiet", "--short", "HEAD"], workspacePath).catch(() => null),
867
1018
  ]);
1019
+ if (!current.statusInspectionSucceeded) {
1020
+ throw new Error("Refusing terminal workspace cleanup because the git status could not be verified");
1021
+ }
868
1022
  if (!current.git?.repoRoot
869
1023
  || current.git.hasDirtyTrackedFiles
870
1024
  || current.git.hasUntrackedFiles
@@ -900,7 +1054,141 @@ export function executionWorkspaceService(db, opts = {}) {
900
1054
  .limit(1);
901
1055
  return active.length > 0;
902
1056
  }
903
- async function cleanupTerminalWorkspace(workspace, expectedHeadSha) {
1057
+ // The single generation-fenced gateway for a terminal-workspace write. It owns
1058
+ // the whole guarded step. It acquires the per-workspace lifecycle lock, it
1059
+ // re-reads the fresh row, and it compares the current lifecycle generation to
1060
+ // the generation the caller captured. It runs the caller's write body only
1061
+ // when the current generation still equals `expectedGeneration` and the fresh
1062
+ // row still passes the caller's `isWriteTarget` guard. Otherwise it emits the
1063
+ // optional skip log and returns the caller's skip value. Every fenced
1064
+ // terminal-workspace write routes through this function, so no other function
1065
+ // re-derives the lock, the fresh read, or the generation compare.
1066
+ async function fenceLifecycleGenerationWrite(input) {
1067
+ return db.transaction(async (tx) => {
1068
+ await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId);
1069
+ const row = await tx
1070
+ .select({ status: executionWorkspaces.status, metadata: executionWorkspaces.metadata })
1071
+ .from(executionWorkspaces)
1072
+ .where(eq(executionWorkspaces.id, input.workspaceId))
1073
+ .then((rows) => rows[0] ?? null);
1074
+ const fresh = row
1075
+ ? { status: row.status, metadata: row.metadata }
1076
+ : null;
1077
+ const currentGeneration = fresh ? readExecutionWorkspaceLifecycleGeneration(fresh.metadata) : null;
1078
+ if (!fresh
1079
+ || currentGeneration !== input.expectedGeneration
1080
+ || !input.isWriteTarget(fresh)) {
1081
+ if (input.skipLog) {
1082
+ logger.info({
1083
+ event: input.skipLog.event,
1084
+ reason: "reopened",
1085
+ executionWorkspaceId: input.workspaceId,
1086
+ capturedGeneration: input.expectedGeneration,
1087
+ currentGeneration,
1088
+ currentStatus: fresh?.status ?? null,
1089
+ }, input.skipLog.message);
1090
+ }
1091
+ return input.onSkip();
1092
+ }
1093
+ return input.write({ tx, fresh });
1094
+ });
1095
+ }
1096
+ // Clear the reopen-pending flag through the lifecycle gateway. Every caller
1097
+ // presents the lifecycle generation that owns the fence it wants to clear. The
1098
+ // gateway removes the flag only when the current generation still equals
1099
+ // `expectedGeneration`. A newer reopen raises the generation and re-sets the
1100
+ // flag, so its fence has a different owner. This check stops a stale actor (a
1101
+ // delayed response cleanup, a retry, or an aged reaper snapshot) from clearing
1102
+ // a newer reopen's live fence.
1103
+ //
1104
+ // A caller that reclaims a stranded flag passes `requireStaleSinceBefore`. The
1105
+ // write-target guard then re-reads the flag timestamp from the fresh row and
1106
+ // clears the flag only when that timestamp is still older than the cutoff. This
1107
+ // re-confirms the strand decision against the live row instead of an aged
1108
+ // snapshot.
1109
+ async function clearReopenPendingConsumptionUnderLock(workspaceId, options) {
1110
+ return fenceLifecycleGenerationWrite({
1111
+ workspaceId,
1112
+ expectedGeneration: options.expectedGeneration,
1113
+ isWriteTarget: (fresh) => {
1114
+ if (!metadataHasReopenPendingConsumption(fresh.metadata))
1115
+ return false;
1116
+ if (options.requireStaleSinceBefore) {
1117
+ const freshSince = readMetadataReopenPendingConsumptionSince(fresh.metadata);
1118
+ const stillStale = freshSince === null
1119
+ || freshSince.getTime() <= options.requireStaleSinceBefore.getTime();
1120
+ // The live row shows a fresh flag, so the consumer is still in flight.
1121
+ if (!stillStale)
1122
+ return false;
1123
+ }
1124
+ return true;
1125
+ },
1126
+ onSkip: () => false,
1127
+ write: async ({ tx, fresh }) => {
1128
+ await tx
1129
+ .update(executionWorkspaces)
1130
+ .set({
1131
+ metadata: clearMetadataReopenPendingConsumption(fresh.metadata),
1132
+ updatedAt: new Date(),
1133
+ })
1134
+ .where(eq(executionWorkspaces.id, workspaceId));
1135
+ return true;
1136
+ },
1137
+ });
1138
+ }
1139
+ // Re-stamp the reopen-pending timestamp for a workspace whose consuming request
1140
+ // is still in flight. The request that reopens and consumes the worktree is an
1141
+ // HTTP request, not a heartbeat run, so `workspaceHasActiveRun` cannot see it.
1142
+ // Without a refresh, a request that runs longer than the stale grace period lets
1143
+ // the terminal reaper clear the live fence, and a later sweep archives and
1144
+ // destroys the worktree under the request. The consuming route calls this on an
1145
+ // interval shorter than the grace period, so the flag never looks stale while the
1146
+ // request lives. The refresh runs under the lifecycle lock and re-stamps the
1147
+ // timestamp only while the flag is still set and the current generation still
1148
+ // equals `expectedGeneration`, so it never revives a cleared flag and never
1149
+ // refreshes a newer reopen's fence. It returns true when it re-stamped the flag.
1150
+ async function refreshReopenPendingConsumptionUnderLock(workspaceId, options) {
1151
+ return fenceLifecycleGenerationWrite({
1152
+ workspaceId,
1153
+ expectedGeneration: options.expectedGeneration,
1154
+ // A newer reopen or an archive raised the generation. The gateway then
1155
+ // skips, so this refresh never re-stamps another owner's fence.
1156
+ isWriteTarget: (fresh) => metadataHasReopenPendingConsumption(fresh.metadata),
1157
+ onSkip: () => false,
1158
+ write: async ({ tx, fresh }) => {
1159
+ await tx
1160
+ .update(executionWorkspaces)
1161
+ .set({
1162
+ metadata: setMetadataReopenPendingConsumption(fresh.metadata, now()),
1163
+ updatedAt: new Date(),
1164
+ })
1165
+ .where(eq(executionWorkspaces.id, workspaceId));
1166
+ return true;
1167
+ },
1168
+ });
1169
+ }
1170
+ async function cleanupTerminalWorkspace(workspace, expectedHeadSha, capturedGeneration) {
1171
+ // The gateway holds the per-workspace lifecycle lock across the destructive
1172
+ // actions. A reopen takes the same lock, so a reopen cannot rebuild the
1173
+ // worktree while this cleanup runs, and this cleanup cannot delete a worktree
1174
+ // that a reopen already restored. The advisory lock (not a row FOR UPDATE)
1175
+ // gives the exclusion, so the cleanup body can still update the same row on
1176
+ // the pooled connection without a self-block. A reopen restored this
1177
+ // workspace after it was archived when the guard fails, so the cleanup skips
1178
+ // and does not destroy the rebuilt worktree.
1179
+ return fenceLifecycleGenerationWrite({
1180
+ workspaceId: workspace.id,
1181
+ expectedGeneration: capturedGeneration,
1182
+ isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
1183
+ skipLog: {
1184
+ event: "execution_workspace.cleanup_skipped",
1185
+ message: "execution workspace cleanup skipped because it was reopened",
1186
+ },
1187
+ onSkip: () => ({ cleaned: false, warnings: [], skippedReopened: true }),
1188
+ write: () => runTerminalWorkspaceCleanup(workspace, expectedHeadSha),
1189
+ });
1190
+ }
1191
+ async function runTerminalWorkspaceCleanup(workspace, expectedHeadSha) {
904
1192
  const [{ acquireGitWorktreeCleanupLock, cleanupExecutionWorkspaceArtifacts, stopRuntimeServicesForExecutionWorkspace, }, { workspaceOperationService },] = await Promise.all([
905
1193
  import("./workspace-runtime.js"),
906
1194
  import("./workspace-operations.js"),
@@ -974,6 +1262,41 @@ export function executionWorkspaceService(db, opts = {}) {
974
1262
  await cleanupLock?.release();
975
1263
  }
976
1264
  }
1265
+ // Write the cleanup-failed status under the per-workspace lifecycle lock, but
1266
+ // only while the row is still closed at the generation the reaper captured. The
1267
+ // reaper calls this from its catch handler after a cleanup threw. The cleanup
1268
+ // transaction already rolled back and released the lock, so a reopen can run in
1269
+ // the window before this write. A reopen raises the generation and restores the
1270
+ // row to active. A later archive lowers the row back to closed but keeps the
1271
+ // higher generation. The generation compare then skips this write, so stale
1272
+ // cleanup-failure state never lands on a newer archive lifecycle. The function
1273
+ // returns true when it wrote the status, or false when the fence skipped it.
1274
+ async function markTerminalCleanupFailedFenced(input) {
1275
+ // A reopen restored the row after the cleanup threw when the guard fails. The
1276
+ // gateway then skips, so the stale cleanup-failure status never overwrites the
1277
+ // newer lifecycle state.
1278
+ return fenceLifecycleGenerationWrite({
1279
+ workspaceId: input.workspaceId,
1280
+ expectedGeneration: input.capturedGeneration,
1281
+ isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
1282
+ skipLog: {
1283
+ event: "execution_workspace.cleanup_failed_write_skipped",
1284
+ message: "execution workspace cleanup-failure write skipped because it was reopened",
1285
+ },
1286
+ onSkip: () => false,
1287
+ write: async ({ tx }) => {
1288
+ await tx
1289
+ .update(executionWorkspaces)
1290
+ .set({
1291
+ status: "cleanup_failed",
1292
+ cleanupReason: input.cleanupReason,
1293
+ updatedAt: now(),
1294
+ })
1295
+ .where(eq(executionWorkspaces.id, input.workspaceId));
1296
+ return true;
1297
+ },
1298
+ });
1299
+ }
977
1300
  function buildListConditions(companyId, filters) {
978
1301
  const conditions = [eq(executionWorkspaces.companyId, companyId)];
979
1302
  if (filters?.projectId)
@@ -1352,10 +1675,13 @@ export function executionWorkspaceService(db, opts = {}) {
1352
1675
  : null;
1353
1676
  const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices);
1354
1677
  const config = readExecutionWorkspaceConfig(workspace.metadata ?? null);
1355
- const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace);
1678
+ const { git, warnings: gitWarnings, statusInspectionSucceeded, } = await inspectGitCloseReadiness(executionWorkspace);
1356
1679
  const { deliveryState } = await assessDelivery(workspace, git);
1357
1680
  const warnings = [...gitWarnings];
1358
1681
  const blockingReasons = [];
1682
+ if (!statusInspectionSucceeded) {
1683
+ blockingReasons.push("Paperclip could not verify the workspace git status. Retry before destructive cleanup.");
1684
+ }
1359
1685
  const isSharedWorkspace = executionWorkspace.mode === "shared_workspace";
1360
1686
  const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd);
1361
1687
  const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null;
@@ -1514,137 +1840,275 @@ export function executionWorkspaceService(db, opts = {}) {
1514
1840
  };
1515
1841
  },
1516
1842
  sweepTerminalWorkspaces: async (limit = 50) => {
1517
- const candidates = await db
1518
- .select()
1519
- .from(executionWorkspaces)
1520
- .where(and(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]), isNull(executionWorkspaces.closedAt), sql `${executionWorkspaces.sourceIssueId} IS NOT NULL`))
1521
- .orderBy(asc(executionWorkspaces.updatedAt), asc(executionWorkspaces.id))
1522
- .limit(limit);
1523
- const result = {
1524
- checked: candidates.length,
1525
- eligible: 0,
1526
- archived: 0,
1527
- cleanupFailed: 0,
1528
- skippedActiveRun: 0,
1529
- skippedNonTerminalTree: 0,
1530
- skippedUndelivered: 0,
1531
- skippedRace: 0,
1532
- };
1533
- for (const workspace of candidates) {
1534
- const executionWorkspace = toExecutionWorkspace(workspace);
1535
- const { git } = await inspectGitCloseReadiness(executionWorkspace);
1536
- const assessment = await assessDelivery(workspace, git);
1537
- if (!assessment.sourceIssueTerminal || !assessment.subtreeTerminal) {
1538
- result.skippedNonTerminalTree += 1;
1539
- continue;
1540
- }
1541
- if (assessment.workspaceDirty) {
1542
- result.skippedUndelivered += 1;
1543
- continue;
1843
+ // Skip this sweep while another sweep runs. A concurrent sweep would share
1844
+ // the cursor and the boundary and could corrupt the rotation state. A
1845
+ // skipped tick is safe: the next tick runs the sweep with intact state.
1846
+ if (terminalSweepInProgress) {
1847
+ return {
1848
+ checked: 0,
1849
+ eligible: 0,
1850
+ archived: 0,
1851
+ cleanupFailed: 0,
1852
+ skippedActiveRun: 0,
1853
+ skippedNonTerminalTree: 0,
1854
+ skippedUndelivered: 0,
1855
+ skippedRace: 0,
1856
+ skippedReopened: 0,
1857
+ clearedStaleReopenPending: 0,
1858
+ };
1859
+ }
1860
+ terminalSweepInProgress = true;
1861
+ try {
1862
+ const baseCandidateFilter = and(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]), isNull(executionWorkspaces.closedAt), sql `${executionWorkspaces.sourceIssueId} IS NOT NULL`);
1863
+ // Continue the scan after the previous sweep's last row. The keyset
1864
+ // predicate uses the same (updatedAt, id) order as the query, so each
1865
+ // sweep advances past the rows it already inspected instead of re-reading
1866
+ // the oldest ineligible candidates.
1867
+ const cursor = terminalSweepCursor;
1868
+ // Freeze an upper bound on updatedAt at the start of each rotation. Without
1869
+ // a bound, a steady stream of newer candidates keeps every page full, so
1870
+ // the cursor never reaches the end and never resets, and older candidates
1871
+ // that became eligible are never revisited. The frozen bound makes the
1872
+ // rotation cover a finite set, so the cursor always reaches a short page.
1873
+ if (!cursor) {
1874
+ terminalSweepBoundary = now();
1544
1875
  }
1545
- if (assessment.deliveryState !== "merged_via_pr"
1546
- && assessment.deliveryState !== "merged_by_ancestry") {
1547
- result.skippedUndelivered += 1;
1548
- continue;
1876
+ const boundary = terminalSweepBoundary;
1877
+ const boundaryFilter = boundary
1878
+ ? lte(executionWorkspaces.updatedAt, boundary)
1879
+ : undefined;
1880
+ const cursorFilter = cursor
1881
+ ? or(gt(executionWorkspaces.updatedAt, cursor.updatedAt), and(eq(executionWorkspaces.updatedAt, cursor.updatedAt), gt(executionWorkspaces.id, cursor.id)))
1882
+ : undefined;
1883
+ const scanFilter = and(baseCandidateFilter, boundaryFilter, cursorFilter);
1884
+ const candidates = await db
1885
+ .select()
1886
+ .from(executionWorkspaces)
1887
+ .where(scanFilter)
1888
+ .orderBy(asc(executionWorkspaces.updatedAt), asc(executionWorkspaces.id))
1889
+ .limit(limit);
1890
+ // Advance the cursor to this page's last row. A short page means the scan
1891
+ // reached the end of the bounded candidate set, so reset the cursor and
1892
+ // the bound to start a new rotation on the next sweep.
1893
+ if (candidates.length < limit) {
1894
+ terminalSweepCursor = null;
1895
+ terminalSweepBoundary = null;
1549
1896
  }
1550
- if (await workspaceHasActiveRun(workspace)) {
1551
- result.skippedActiveRun += 1;
1552
- continue;
1897
+ else {
1898
+ const lastCandidate = candidates[candidates.length - 1];
1899
+ terminalSweepCursor = { updatedAt: lastCandidate.updatedAt, id: lastCandidate.id };
1553
1900
  }
1554
- result.eligible += 1;
1555
- const closedAt = now();
1556
- const archived = await db
1557
- .update(executionWorkspaces)
1558
- .set({
1559
- status: "archived",
1560
- closedAt,
1561
- cleanupEligibleAt: workspace.cleanupEligibleAt ?? closedAt,
1562
- cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
1563
- updatedAt: closedAt,
1564
- })
1565
- .where(and(eq(executionWorkspaces.id, workspace.id), eq(executionWorkspaces.companyId, workspace.companyId), inArray(executionWorkspaces.status, ["active", "idle", "in_review"]), isNull(executionWorkspaces.closedAt), sql `EXISTS (
1566
- SELECT 1
1567
- FROM ${issues} source_issue
1568
- WHERE source_issue.company_id = ${workspace.companyId}
1569
- AND source_issue.id = ${workspace.sourceIssueId}
1570
- AND source_issue.status IN ('done', 'cancelled')
1571
- )`, sql `NOT EXISTS (
1572
- SELECT 1
1573
- FROM ${issues} linked_issue
1574
- JOIN ${heartbeatRuns} live_run
1575
- ON live_run.id = linked_issue.checkout_run_id
1576
- OR live_run.id = linked_issue.execution_run_id
1577
- WHERE linked_issue.company_id = ${workspace.companyId}
1578
- AND (
1579
- linked_issue.execution_workspace_id = ${workspace.id}
1580
- OR linked_issue.id = ${workspace.sourceIssueId}
1901
+ const result = {
1902
+ checked: candidates.length,
1903
+ eligible: 0,
1904
+ archived: 0,
1905
+ cleanupFailed: 0,
1906
+ skippedActiveRun: 0,
1907
+ skippedNonTerminalTree: 0,
1908
+ skippedUndelivered: 0,
1909
+ skippedRace: 0,
1910
+ skippedReopened: 0,
1911
+ clearedStaleReopenPending: 0,
1912
+ };
1913
+ for (const workspace of candidates) {
1914
+ const executionWorkspace = toExecutionWorkspace(workspace);
1915
+ const { git, statusInspectionSucceeded } = await inspectGitCloseReadiness(executionWorkspace);
1916
+ if (!statusInspectionSucceeded) {
1917
+ result.skippedUndelivered += 1;
1918
+ continue;
1919
+ }
1920
+ const assessment = await assessDelivery(workspace, git);
1921
+ const reopenPending = metadataHasReopenPendingConsumption(workspace.metadata);
1922
+ if (!assessment.sourceIssueTerminal || !assessment.subtreeTerminal) {
1923
+ if (reopenPending) {
1924
+ // The source issue left the terminal state, so the reopen transition
1925
+ // committed. Clear the reopen-pending flag under the lifecycle lock so
1926
+ // a later terminal cycle can archive the workspace again. Pass the
1927
+ // generation from this snapshot, so the clear skips a newer reopen that
1928
+ // raised the generation and installed its own fence after this read.
1929
+ await clearReopenPendingConsumptionUnderLock(workspace.id, {
1930
+ expectedGeneration: readExecutionWorkspaceLifecycleGeneration(workspace.metadata),
1931
+ });
1932
+ }
1933
+ result.skippedNonTerminalTree += 1;
1934
+ continue;
1935
+ }
1936
+ if (assessment.workspaceDirty) {
1937
+ result.skippedUndelivered += 1;
1938
+ continue;
1939
+ }
1940
+ if (assessment.deliveryState !== "merged_via_pr"
1941
+ && assessment.deliveryState !== "merged_by_ancestry") {
1942
+ result.skippedUndelivered += 1;
1943
+ continue;
1944
+ }
1945
+ if (reopenPending) {
1946
+ const pendingSince = readMetadataReopenPendingConsumptionSince(workspace.metadata);
1947
+ const staleBefore = new Date(now().getTime() - STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS);
1948
+ const stranded = pendingSince === null
1949
+ || pendingSince.getTime() <= staleBefore.getTime();
1950
+ if (!stranded) {
1951
+ // A reopen published this workspace as active while the source issue
1952
+ // is still terminal, and the consuming request is still in flight. Do
1953
+ // not archive it now. The authoritative check is the NOT reopenPending
1954
+ // predicate in the archive statement below; this early skip avoids the
1955
+ // work when the snapshot already shows the flag.
1956
+ result.skippedReopened += 1;
1957
+ continue;
1958
+ }
1959
+ // The flag is older than the grace period, but age alone does not prove
1960
+ // the consuming request ended. An authorized request can run longer than
1961
+ // the grace period. A live consuming run still owns the fence, so keep it
1962
+ // and skip. This stops the sweep from clearing the fence of a slow request
1963
+ // that a later sweep would then archive and destroy under the request.
1964
+ if (await workspaceHasActiveRun(workspace)) {
1965
+ result.skippedReopened += 1;
1966
+ continue;
1967
+ }
1968
+ // The reopen-pending flag outlived its consumption window and no run owns
1969
+ // it. The consuming request ended without moving the issue out of the
1970
+ // terminal state, or the server exited before it cleared the flag. Clear
1971
+ // the stranded flag under the lifecycle lock so a later sweep can reclaim
1972
+ // the workspace. Keep the row active, so a retried resume can still reuse
1973
+ // the rebuilt worktree. Pass this snapshot's generation and re-confirm
1974
+ // staleness against the fresh row under the lock, so a newer reopen that
1975
+ // raised the generation or refreshed the timestamp keeps its live fence.
1976
+ const cleared = await clearReopenPendingConsumptionUnderLock(workspace.id, {
1977
+ expectedGeneration: readExecutionWorkspaceLifecycleGeneration(workspace.metadata),
1978
+ requireStaleSinceBefore: staleBefore,
1979
+ });
1980
+ if (cleared) {
1981
+ result.clearedStaleReopenPending += 1;
1982
+ logger.info({
1983
+ event: "execution_workspace.reopen",
1984
+ outcome: "stale_reopen_pending_cleared",
1985
+ executionWorkspaceId: workspace.id,
1986
+ sourceIssueId: workspace.sourceIssueId,
1987
+ pendingSince: pendingSince?.toISOString() ?? null,
1988
+ }, "cleared a stranded reopen-pending flag on a terminal workspace");
1989
+ }
1990
+ continue;
1991
+ }
1992
+ if (await workspaceHasActiveRun(workspace)) {
1993
+ result.skippedActiveRun += 1;
1994
+ continue;
1995
+ }
1996
+ result.eligible += 1;
1997
+ const closedAt = now();
1998
+ // Raise the lifecycle generation on archive. The cleanup below captures
1999
+ // this generation and re-checks it before it deletes the worktree, so a
2000
+ // reopen that runs in between fences the cleanup off.
2001
+ const archivedMetadata = bumpExecutionWorkspaceLifecycleGeneration(workspace.metadata);
2002
+ // Take the per-workspace lifecycle lock before the archive decision, so a
2003
+ // concurrent reopen cannot publish an active row between the predicate
2004
+ // checks and the archive write. The archive statement re-checks the
2005
+ // status, the terminal predicates, and the reopen-pending flag under the
2006
+ // lock, so it never archives a workspace that a reopen just restored.
2007
+ const archived = await db.transaction(async (tx) => {
2008
+ await acquireExecutionWorkspaceLifecycleLock(tx, workspace.id);
2009
+ return tx
2010
+ .update(executionWorkspaces)
2011
+ .set({
2012
+ status: "archived",
2013
+ closedAt,
2014
+ cleanupEligibleAt: workspace.cleanupEligibleAt ?? closedAt,
2015
+ cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
2016
+ metadata: archivedMetadata,
2017
+ updatedAt: closedAt,
2018
+ })
2019
+ .where(and(eq(executionWorkspaces.id, workspace.id), eq(executionWorkspaces.companyId, workspace.companyId), inArray(executionWorkspaces.status, ["active", "idle", "in_review"]), isNull(executionWorkspaces.closedAt), sql `(${executionWorkspaces.metadata} ->> ${EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY}) IS DISTINCT FROM 'true'`, sql `EXISTS (
2020
+ SELECT 1
2021
+ FROM ${issues} source_issue
2022
+ WHERE source_issue.company_id = ${workspace.companyId}
2023
+ AND source_issue.id = ${workspace.sourceIssueId}
2024
+ AND source_issue.status IN ('done', 'cancelled')
2025
+ )`, sql `NOT EXISTS (
2026
+ SELECT 1
2027
+ FROM ${issues} linked_issue
2028
+ JOIN ${heartbeatRuns} live_run
2029
+ ON live_run.id = linked_issue.checkout_run_id
2030
+ OR live_run.id = linked_issue.execution_run_id
2031
+ WHERE linked_issue.company_id = ${workspace.companyId}
2032
+ AND (
2033
+ linked_issue.execution_workspace_id = ${workspace.id}
2034
+ OR linked_issue.id = ${workspace.sourceIssueId}
2035
+ )
2036
+ AND live_run.company_id = ${workspace.companyId}
2037
+ AND live_run.status IN ('queued', 'running')
2038
+ )`, sql `NOT EXISTS (
2039
+ WITH RECURSIVE issue_tree(id, status) AS (
2040
+ SELECT root.id, root.status
2041
+ FROM ${issues} root
2042
+ WHERE root.company_id = ${workspace.companyId}
2043
+ AND root.id = ${workspace.sourceIssueId}
2044
+ UNION ALL
2045
+ SELECT child.id, child.status
2046
+ FROM ${issues} child
2047
+ JOIN issue_tree parent ON child.parent_id = parent.id
2048
+ WHERE child.company_id = ${workspace.companyId}
1581
2049
  )
1582
- AND live_run.company_id = ${workspace.companyId}
1583
- AND live_run.status IN ('queued', 'running')
1584
- )`, sql `NOT EXISTS (
1585
- WITH RECURSIVE issue_tree(id, status) AS (
1586
- SELECT root.id, root.status
1587
- FROM ${issues} root
1588
- WHERE root.company_id = ${workspace.companyId}
1589
- AND root.id = ${workspace.sourceIssueId}
1590
- UNION ALL
1591
- SELECT child.id, child.status
1592
- FROM ${issues} child
1593
- JOIN issue_tree parent ON child.parent_id = parent.id
1594
- WHERE child.company_id = ${workspace.companyId}
1595
- )
1596
- SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled')
1597
- )`))
1598
- .returning()
1599
- .then((rows) => rows[0] ?? null);
1600
- if (!archived) {
1601
- result.skippedRace += 1;
1602
- continue;
1603
- }
1604
- await logActivity(db, {
1605
- companyId: archived.companyId,
1606
- actorType: "system",
1607
- actorId: "workspace_terminality_reaper",
1608
- action: "execution_workspace.issue_terminal_archived",
1609
- entityType: "execution_workspace",
1610
- entityId: archived.id,
1611
- details: {
1612
- sourceIssueId: archived.sourceIssueId,
1613
- deliveryState: assessment.deliveryState,
1614
- cleanupEligibleAt: archived.cleanupEligibleAt?.toISOString() ?? null,
1615
- cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
1616
- },
1617
- });
1618
- try {
1619
- const cleanup = await cleanupTerminalWorkspace(archived, assessment.workspaceHeadSha);
1620
- if (!cleanup.cleaned)
1621
- result.cleanupFailed += 1;
1622
- else
1623
- result.archived += 1;
1624
- }
1625
- catch (error) {
1626
- result.cleanupFailed += 1;
1627
- const failure = error instanceof Error ? error.message : String(error);
1628
- await db
1629
- .update(executionWorkspaces)
1630
- .set({
1631
- status: "cleanup_failed",
1632
- cleanupReason: `${ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON} | ${failure}`,
1633
- updatedAt: now(),
1634
- })
1635
- .where(eq(executionWorkspaces.id, archived.id));
2050
+ SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled')
2051
+ )`))
2052
+ .returning()
2053
+ .then((rows) => rows[0] ?? null);
2054
+ });
2055
+ if (!archived) {
2056
+ result.skippedRace += 1;
2057
+ continue;
2058
+ }
1636
2059
  await logActivity(db, {
1637
2060
  companyId: archived.companyId,
1638
2061
  actorType: "system",
1639
2062
  actorId: "workspace_terminality_reaper",
1640
- action: "execution_workspace.issue_terminal_cleanup_failed",
2063
+ action: "execution_workspace.issue_terminal_archived",
1641
2064
  entityType: "execution_workspace",
1642
2065
  entityId: archived.id,
1643
- details: { sourceIssueId: archived.sourceIssueId, failure },
2066
+ details: {
2067
+ sourceIssueId: archived.sourceIssueId,
2068
+ deliveryState: assessment.deliveryState,
2069
+ cleanupEligibleAt: archived.cleanupEligibleAt?.toISOString() ?? null,
2070
+ cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
2071
+ },
1644
2072
  });
2073
+ const capturedGeneration = readExecutionWorkspaceLifecycleGeneration(archived.metadata);
2074
+ try {
2075
+ const cleanup = await cleanupTerminalWorkspace(archived, assessment.workspaceHeadSha, capturedGeneration);
2076
+ if (cleanup.skippedReopened)
2077
+ result.skippedReopened += 1;
2078
+ else if (!cleanup.cleaned)
2079
+ result.cleanupFailed += 1;
2080
+ else
2081
+ result.archived += 1;
2082
+ }
2083
+ catch (error) {
2084
+ result.cleanupFailed += 1;
2085
+ const failure = error instanceof Error ? error.message : String(error);
2086
+ // Mark cleanup_failed only while the row is still closed at the
2087
+ // generation this sweep captured. A reopen that raced after the failure
2088
+ // raises the generation and restores the row; a later archive keeps the
2089
+ // higher generation. The fenced write then skips, so stale
2090
+ // cleanup-failure state never lands on a newer archive lifecycle.
2091
+ await markTerminalCleanupFailedFenced({
2092
+ workspaceId: archived.id,
2093
+ capturedGeneration,
2094
+ cleanupReason: `${ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON} | ${failure}`,
2095
+ });
2096
+ await logActivity(db, {
2097
+ companyId: archived.companyId,
2098
+ actorType: "system",
2099
+ actorId: "workspace_terminality_reaper",
2100
+ action: "execution_workspace.issue_terminal_cleanup_failed",
2101
+ entityType: "execution_workspace",
2102
+ entityId: archived.id,
2103
+ details: { sourceIssueId: archived.sourceIssueId, failure },
2104
+ });
2105
+ }
1645
2106
  }
2107
+ return result;
2108
+ }
2109
+ finally {
2110
+ terminalSweepInProgress = false;
1646
2111
  }
1647
- return result;
1648
2112
  },
1649
2113
  create: async (data) => {
1650
2114
  const row = await db
@@ -1663,6 +2127,414 @@ export function executionWorkspaceService(db, opts = {}) {
1663
2127
  .then((rows) => rows[0] ?? null);
1664
2128
  return row ? toExecutionWorkspace(row) : null;
1665
2129
  },
2130
+ // Reopen one closed isolated execution workspace so an authorized issue can
2131
+ // use it again. The caller must first authorize the request on the issue.
2132
+ // The whole operation runs under the per-workspace lifecycle lock: it reads
2133
+ // the row in the issue scope, rebuilds the worktree, and only then publishes
2134
+ // the row as "active". A rebuild failure keeps the row closed and returns an
2135
+ // error, so the caller never dispatches a run against a broken workspace.
2136
+ reopenClosedIsolatedExecutionWorkspaceForIssue: async (input) => {
2137
+ const { issue, actor } = input;
2138
+ // Bind the workspace to the issue company and project. A null project on
2139
+ // the issue must match a null project on the row (IS NOT DISTINCT FROM).
2140
+ const projectIdCondition = issue.projectId == null
2141
+ ? sql `${executionWorkspaces.projectId} IS NULL`
2142
+ : eq(executionWorkspaces.projectId, issue.projectId);
2143
+ return db.transaction(async (tx) => {
2144
+ await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId);
2145
+ const row = await tx
2146
+ .select()
2147
+ .from(executionWorkspaces)
2148
+ .where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, issue.companyId), projectIdCondition, eq(executionWorkspaces.mode, "isolated_workspace")))
2149
+ .then((rows) => rows[0] ?? null);
2150
+ if (!row) {
2151
+ // Wrong company, wrong project, wrong mode, or missing. Fail closed and
2152
+ // disclose no workspace detail.
2153
+ return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" };
2154
+ }
2155
+ if (!isClosedExecutionWorkspaceStatus(row.status)) {
2156
+ // A concurrent reopen already restored the row. Report success without a
2157
+ // second rebuild so the caller continues normally. The other request
2158
+ // owns the reopen-pending flag, so return its generation and let the
2159
+ // caller skip the consumption guard.
2160
+ return {
2161
+ ok: true,
2162
+ reopened: false,
2163
+ workspace: toExecutionWorkspace(row),
2164
+ generation: readExecutionWorkspaceLifecycleGeneration(row.metadata),
2165
+ };
2166
+ }
2167
+ const [{ ensurePersistedExecutionWorkspaceAvailable }, { workspaceOperationService }, { ensureManagedProjectWorkspace },] = await Promise.all([
2168
+ import("./workspace-runtime.js"),
2169
+ import("./workspace-operations.js"),
2170
+ // heartbeat.js imports this module, so a static import creates a
2171
+ // cycle. Load ensureManagedProjectWorkspace dynamically instead.
2172
+ import("./heartbeat.js"),
2173
+ ]);
2174
+ const [projectWorkspace, projectPolicy] = await Promise.all([
2175
+ row.projectWorkspaceId
2176
+ ? db
2177
+ .select({ cwd: projectWorkspaces.cwd })
2178
+ .from(projectWorkspaces)
2179
+ .where(and(eq(projectWorkspaces.companyId, row.companyId), eq(projectWorkspaces.id, row.projectWorkspaceId)))
2180
+ .then((rows) => rows[0] ?? null)
2181
+ : null,
2182
+ db
2183
+ .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
2184
+ .from(projects)
2185
+ .where(and(eq(projects.companyId, row.companyId), eq(projects.id, row.projectId)))
2186
+ .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)),
2187
+ ]);
2188
+ // Resolve the base checkout that the rebuild spawns git in. A
2189
+ // local-folder project stores its base path in projectWorkspaces.cwd.
2190
+ // A managed_checkout project stores null there, so resolve its live
2191
+ // managed checkout instead. Never use row.cwd for a git_worktree
2192
+ // rebuild: row.cwd is the archived worktree path, which the reaper
2193
+ // already removed from disk. A spawn in that missing directory fails
2194
+ // with "spawn git ENOENT" and hides the real cause.
2195
+ let resolvedBaseCwd = projectWorkspace?.cwd ?? null;
2196
+ if (resolvedBaseCwd == null && row.strategyType === "git_worktree" && row.projectId) {
2197
+ const managedWorkspace = await ensureManagedProjectWorkspace({
2198
+ companyId: row.companyId,
2199
+ projectId: row.projectId,
2200
+ repoUrl: row.repoUrl,
2201
+ resolveGitAuth: createGitRemoteAuthProvider(db, row.companyId, {
2202
+ issueId: row.sourceIssueId ?? issue.id,
2203
+ }),
2204
+ });
2205
+ resolvedBaseCwd = managedWorkspace.cwd;
2206
+ }
2207
+ const config = readExecutionWorkspaceConfig(row.metadata);
2208
+ const nextGeneration = readExecutionWorkspaceLifecycleGeneration(row.metadata) + 1;
2209
+ const nextMetadata = bumpExecutionWorkspaceLifecycleGeneration(row.metadata);
2210
+ const recorder = workspaceOperationService(db).createRecorder({
2211
+ companyId: row.companyId,
2212
+ executionWorkspaceId: row.id,
2213
+ });
2214
+ let rebuildError = null;
2215
+ try {
2216
+ const realized = await ensurePersistedExecutionWorkspaceAvailable({
2217
+ db: tx,
2218
+ base: {
2219
+ baseCwd: resolvedBaseCwd ?? row.cwd ?? "",
2220
+ source: "task_session",
2221
+ projectId: row.projectId,
2222
+ workspaceId: row.projectWorkspaceId,
2223
+ repoUrl: row.repoUrl,
2224
+ repoRef: row.baseRef,
2225
+ },
2226
+ workspace: {
2227
+ id: row.id,
2228
+ mode: row.mode,
2229
+ strategyType: row.strategyType,
2230
+ cwd: row.cwd,
2231
+ providerRef: row.providerRef,
2232
+ projectId: row.projectId,
2233
+ projectWorkspaceId: row.projectWorkspaceId,
2234
+ repoUrl: row.repoUrl,
2235
+ baseRef: row.baseRef,
2236
+ branchName: row.branchName,
2237
+ metadata: row.metadata,
2238
+ config: {
2239
+ ...config,
2240
+ provisionCommand: config?.provisionCommand
2241
+ ?? projectPolicy?.workspaceStrategy?.provisionCommand
2242
+ ?? null,
2243
+ },
2244
+ },
2245
+ issue: row.sourceIssueId
2246
+ ? { id: row.sourceIssueId, identifier: null, title: row.name }
2247
+ : null,
2248
+ agent: {
2249
+ id: actor.agentId ?? null,
2250
+ name: actor.actorType === "user" ? "Board" : "Agent",
2251
+ companyId: row.companyId,
2252
+ },
2253
+ recorder,
2254
+ });
2255
+ if (!realized) {
2256
+ rebuildError = "Execution workspace could not be rebuilt";
2257
+ }
2258
+ }
2259
+ catch (error) {
2260
+ rebuildError = error instanceof Error ? error.message : String(error);
2261
+ }
2262
+ if (rebuildError) {
2263
+ // The rebuild failed. Keep the row closed and retryable. Raise the
2264
+ // generation so a queued cleanup that captured the old generation does
2265
+ // nothing. Clear cleanupEligibleAt so the reaper does not destroy the
2266
+ // half-built worktree while a later reopen retries.
2267
+ await tx
2268
+ .update(executionWorkspaces)
2269
+ .set({
2270
+ cleanupReason: EXECUTION_WORKSPACE_REOPEN_FAILED_REASON,
2271
+ cleanupEligibleAt: null,
2272
+ metadata: nextMetadata,
2273
+ updatedAt: new Date(),
2274
+ })
2275
+ .where(eq(executionWorkspaces.id, row.id));
2276
+ // The server log carries the underlying cause for diagnosis. The audit
2277
+ // event and the returned message stay free of repo URLs, host paths,
2278
+ // and git output.
2279
+ logger.warn({
2280
+ event: "execution_workspace.reopen",
2281
+ outcome: "rebuild_failed",
2282
+ executionWorkspaceId: row.id,
2283
+ issueId: issue.id,
2284
+ companyId: row.companyId,
2285
+ actorType: actor.actorType,
2286
+ actorAgentId: actor.agentId ?? null,
2287
+ generation: nextGeneration,
2288
+ error: rebuildError,
2289
+ }, "execution workspace reopen rebuild failed");
2290
+ await logActivity(tx, {
2291
+ companyId: row.companyId,
2292
+ actorType: actor.actorType === "user" ? "user" : "agent",
2293
+ actorId: actor.agentId ?? "system",
2294
+ agentId: actor.actorType === "user" ? null : actor.agentId,
2295
+ action: "execution_workspace.reopen_failed",
2296
+ entityType: "execution_workspace",
2297
+ entityId: row.id,
2298
+ details: {
2299
+ issueId: issue.id,
2300
+ outcome: "rebuild_failed",
2301
+ generation: nextGeneration,
2302
+ },
2303
+ });
2304
+ return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
2305
+ }
2306
+ // The rebuild succeeded. Publish the row as active in one write, and clear
2307
+ // the closed markers. Set the reopen-pending flag in the same write. The
2308
+ // source issue is still terminal at this point, because the route changes
2309
+ // the issue out of the terminal state only after this reopen returns. The
2310
+ // flag stops the terminal reaper and the archive route from archiving and
2311
+ // destroying the rebuilt worktree in that window.
2312
+ const activeMetadata = setMetadataReopenPendingConsumption(nextMetadata, now());
2313
+ const activeRow = await tx
2314
+ .update(executionWorkspaces)
2315
+ .set({
2316
+ status: "active",
2317
+ closedAt: null,
2318
+ cleanupReason: null,
2319
+ cleanupEligibleAt: null,
2320
+ metadata: activeMetadata,
2321
+ lastUsedAt: new Date(),
2322
+ updatedAt: new Date(),
2323
+ })
2324
+ .where(eq(executionWorkspaces.id, row.id))
2325
+ .returning()
2326
+ .then((rows) => rows[0] ?? null);
2327
+ if (!activeRow) {
2328
+ return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
2329
+ }
2330
+ logger.info({
2331
+ event: "execution_workspace.reopen",
2332
+ outcome: "reopened",
2333
+ executionWorkspaceId: row.id,
2334
+ issueId: issue.id,
2335
+ companyId: row.companyId,
2336
+ actorType: actor.actorType,
2337
+ actorAgentId: actor.agentId ?? null,
2338
+ generation: nextGeneration,
2339
+ }, "execution workspace reopened");
2340
+ await logActivity(tx, {
2341
+ companyId: row.companyId,
2342
+ actorType: actor.actorType === "user" ? "user" : "agent",
2343
+ actorId: actor.agentId ?? "system",
2344
+ agentId: actor.actorType === "user" ? null : actor.agentId,
2345
+ action: "execution_workspace.reopened",
2346
+ entityType: "execution_workspace",
2347
+ entityId: row.id,
2348
+ details: {
2349
+ issueId: issue.id,
2350
+ outcome: "reopened",
2351
+ generation: nextGeneration,
2352
+ },
2353
+ });
2354
+ return { ok: true, reopened: true, workspace: toExecutionWorkspace(activeRow), generation: nextGeneration };
2355
+ });
2356
+ },
2357
+ // Clear the reopen-pending flag after a caller failed to consume a reopened
2358
+ // workspace. A reopen publishes the rebuilt worktree as active and sets the
2359
+ // flag while the source issue is still terminal. The route then moves the
2360
+ // issue out of the terminal state, and the terminal reaper clears the flag
2361
+ // once it observes the non-terminal issue. If that move never lands (the
2362
+ // route mutation returns null, throws, or leaves the issue terminal), the
2363
+ // issue stays terminal and the flag stays set. The terminal reaper and the
2364
+ // archive route both skip a reopen-pending row, so the rebuilt worktree leaks
2365
+ // and no path can reclaim it. This method clears the flag under the lifecycle
2366
+ // lock, so the reaper can archive and reclaim the worktree. It keeps the row
2367
+ // active, so a retried resume can still reuse the rebuilt worktree. The
2368
+ // method is idempotent: it does nothing when the flag is already clear.
2369
+ // Re-stamp the reopen-pending timestamp while a consuming request is still in
2370
+ // flight. The consuming route calls this on an interval shorter than the stale
2371
+ // grace period, so the terminal reaper never treats the live fence as stranded.
2372
+ // The refresh runs only while the flag is still set and the generation still
2373
+ // matches `expectedGeneration`, so it never revives a cleared flag and never
2374
+ // refreshes a newer reopen's fence. It returns { refreshed } so the caller can
2375
+ // stop the interval once the fence is no longer its own.
2376
+ refreshReopenPendingConsumption: async (input) => {
2377
+ const refreshed = await refreshReopenPendingConsumptionUnderLock(input.workspaceId, {
2378
+ expectedGeneration: input.expectedGeneration,
2379
+ });
2380
+ return { refreshed };
2381
+ },
2382
+ clearReopenPendingConsumptionForUnconsumedReopen: async (input) => {
2383
+ const cleared = await clearReopenPendingConsumptionUnderLock(input.workspaceId, {
2384
+ expectedGeneration: input.expectedGeneration,
2385
+ });
2386
+ if (cleared) {
2387
+ logger.info({
2388
+ event: "execution_workspace.reopen",
2389
+ outcome: "unconsumed_reopen_cleared",
2390
+ executionWorkspaceId: input.workspaceId,
2391
+ issueId: input.issue.id,
2392
+ companyId: input.issue.companyId,
2393
+ actorType: input.actor.actorType,
2394
+ actorAgentId: input.actor.agentId ?? null,
2395
+ }, "execution workspace reopen-pending flag cleared after an unconsumed reopen");
2396
+ await logActivity(db, {
2397
+ companyId: input.issue.companyId,
2398
+ actorType: input.actor.actorType === "user" ? "user" : "agent",
2399
+ actorId: input.actor.agentId ?? "system",
2400
+ agentId: input.actor.actorType === "user" ? null : input.actor.agentId,
2401
+ action: "execution_workspace.reopen_unconsumed",
2402
+ entityType: "execution_workspace",
2403
+ entityId: input.workspaceId,
2404
+ details: {
2405
+ issueId: input.issue.id,
2406
+ outcome: "unconsumed_reopen_cleared",
2407
+ },
2408
+ });
2409
+ }
2410
+ return { cleared };
2411
+ },
2412
+ // Archive one workspace under the per-workspace lifecycle lock. The archive
2413
+ // route calls this so the transition to archived and the destruction fence
2414
+ // both run under the same lock as a reopen. The lock stops a concurrent
2415
+ // reopen from publishing an active row between the status re-check and the
2416
+ // archive write. The archive runs only while the row is still open (not
2417
+ // already archived by a race) and clears the reopen-pending flag.
2418
+ //
2419
+ // The method refuses to archive a row that carries the reopen-pending flag.
2420
+ // A reopen sets that flag when it publishes a rebuilt worktree as active
2421
+ // while the source issue is still terminal. The method returns a distinct
2422
+ // "reopen_pending" outcome for that row. It does not clear the flag and does
2423
+ // not archive. The route maps that outcome to HTTP 409 and returns before any
2424
+ // destructive cleanup, so the archive control never removes a rebuilt
2425
+ // worktree during the reopen consumption window.
2426
+ archiveWorkspaceUnderLifecycleLock: async (input) => {
2427
+ return db.transaction(async (tx) => {
2428
+ await acquireExecutionWorkspaceLifecycleLock(tx, input.id);
2429
+ const fresh = await tx
2430
+ .select()
2431
+ .from(executionWorkspaces)
2432
+ .where(eq(executionWorkspaces.id, input.id))
2433
+ .then((rows) => rows[0] ?? null);
2434
+ if (!fresh || isClosedExecutionWorkspaceStatus(fresh.status)) {
2435
+ // The row is missing or already closed by a concurrent path. Do not
2436
+ // archive again.
2437
+ return null;
2438
+ }
2439
+ if (metadataHasReopenPendingConsumption(fresh.metadata)) {
2440
+ // A reopen published this row as active while its source issue is still
2441
+ // terminal. A caller will consume the rebuilt worktree. Refuse the
2442
+ // archive and keep the flag, so the destructive path never removes the
2443
+ // rebuilt worktree. The route maps this to HTTP 409.
2444
+ return { outcome: "reopen_pending" };
2445
+ }
2446
+ const baseMetadata = input.patch.metadata
2447
+ ?? fresh.metadata;
2448
+ const archiveMetadata = clearMetadataReopenPendingConsumption(bumpExecutionWorkspaceLifecycleGeneration(baseMetadata));
2449
+ const archived = await tx
2450
+ .update(executionWorkspaces)
2451
+ .set({
2452
+ ...input.patch,
2453
+ status: "archived",
2454
+ closedAt: input.closedAt,
2455
+ cleanupReason: null,
2456
+ metadata: archiveMetadata,
2457
+ updatedAt: new Date(),
2458
+ })
2459
+ .where(and(eq(executionWorkspaces.id, input.id),
2460
+ // Defense in depth: never archive a reopen-pending row even if the
2461
+ // flag appears between the read above and this write. The lifecycle
2462
+ // lock already serializes reopen and archive, so this predicate only
2463
+ // adds a second, authoritative guard at the write.
2464
+ sql `(${executionWorkspaces.metadata} ->> ${EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY}) IS DISTINCT FROM 'true'`))
2465
+ .returning()
2466
+ .then((rows) => rows[0] ?? null);
2467
+ if (!archived)
2468
+ return null;
2469
+ return {
2470
+ outcome: "archived",
2471
+ workspace: toExecutionWorkspace(archived),
2472
+ capturedGeneration: readExecutionWorkspaceLifecycleGeneration(archiveMetadata),
2473
+ };
2474
+ });
2475
+ },
2476
+ // Apply the terminal cleanup outcome to a workspace row through the lifecycle
2477
+ // gateway. The archive route calls this after the destruction fence ran, to
2478
+ // record cleanup warnings and, when the destroy failed, the cleanup_failed
2479
+ // status. A reopen that raced after the fence returned restores the row to an
2480
+ // open status and raises the generation. The gateway re-reads the fresh row
2481
+ // under the lock and writes only while the row is still closed at
2482
+ // `capturedGeneration`. So a stale cleanup patch never overwrites the closedAt,
2483
+ // the cleanup reason, or the status of a freshly rebuilt worktree. The method
2484
+ // returns the updated row, or null when the guard skipped the write.
2485
+ applyClosedWorkspaceCleanupOutcome: async (input) => {
2486
+ // A reopen restored the row after the destruction fence returned when the
2487
+ // guard fails. The gateway then skips, so the write never overwrites the
2488
+ // newly active lifecycle state.
2489
+ return fenceLifecycleGenerationWrite({
2490
+ workspaceId: input.id,
2491
+ expectedGeneration: input.capturedGeneration,
2492
+ isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
2493
+ onSkip: () => null,
2494
+ write: async ({ tx }) => {
2495
+ const row = await tx
2496
+ .update(executionWorkspaces)
2497
+ .set({
2498
+ closedAt: input.closedAt,
2499
+ cleanupReason: input.cleanupReason,
2500
+ ...(input.markCleanupFailed ? { status: "cleanup_failed" } : {}),
2501
+ updatedAt: new Date(),
2502
+ })
2503
+ .where(eq(executionWorkspaces.id, input.id))
2504
+ .returning()
2505
+ .then((rows) => rows[0] ?? null);
2506
+ return row ? toExecutionWorkspace(row) : null;
2507
+ },
2508
+ });
2509
+ },
2510
+ // Read the lifecycle generation of a workspace row. The archive route captures
2511
+ // this before it destroys, so it can hand the value to the destruction fence.
2512
+ readLifecycleGeneration: async (id) => {
2513
+ const row = await db
2514
+ .select({ metadata: executionWorkspaces.metadata })
2515
+ .from(executionWorkspaces)
2516
+ .where(eq(executionWorkspaces.id, id))
2517
+ .then((rows) => rows[0] ?? null);
2518
+ return row ? readExecutionWorkspaceLifecycleGeneration(row.metadata) : null;
2519
+ },
2520
+ // Run a destructive workspace cleanup through the lifecycle gateway. The
2521
+ // caller passes the generation it captured at archive time. If a reopen raised
2522
+ // the generation or restored the row to an open status, the gateway skips the
2523
+ // destroy callback, so a cleanup never deletes a worktree that a reopen
2524
+ // rebuilt.
2525
+ fenceClosedWorkspaceDestruction: async (input) => {
2526
+ return fenceLifecycleGenerationWrite({
2527
+ workspaceId: input.workspaceId,
2528
+ expectedGeneration: input.capturedGeneration,
2529
+ isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
2530
+ skipLog: {
2531
+ event: "execution_workspace.cleanup_skipped",
2532
+ message: "execution workspace cleanup skipped because it was reopened",
2533
+ },
2534
+ onSkip: () => ({ skippedReopened: true }),
2535
+ write: async () => ({ skippedReopened: false, result: await input.destroy() }),
2536
+ });
2537
+ },
1666
2538
  reconcileExecutionWorkspaceBranch: async (id, input) => {
1667
2539
  const existingRow = await db
1668
2540
  .select()