@penclipai/server 2026.426.0 → 2026.505.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
  2. package/dist/adapters/builtin-adapter-types.js +3 -0
  3. package/dist/adapters/builtin-adapter-types.js.map +1 -1
  4. package/dist/adapters/index.d.ts +2 -2
  5. package/dist/adapters/index.d.ts.map +1 -1
  6. package/dist/adapters/index.js +1 -1
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/adapters/registry.d.ts +2 -1
  9. package/dist/adapters/registry.d.ts.map +1 -1
  10. package/dist/adapters/registry.js +76 -6
  11. package/dist/adapters/registry.js.map +1 -1
  12. package/dist/adapters/types.d.ts +1 -1
  13. package/dist/adapters/types.d.ts.map +1 -1
  14. package/dist/adapters/utils.d.ts.map +1 -1
  15. package/dist/adapters/utils.js +2 -1
  16. package/dist/adapters/utils.js.map +1 -1
  17. package/dist/attachment-types.d.ts +1 -16
  18. package/dist/attachment-types.d.ts.map +1 -1
  19. package/dist/attachment-types.js +7 -0
  20. package/dist/attachment-types.js.map +1 -1
  21. package/dist/auth/better-auth.d.ts +3 -1
  22. package/dist/auth/better-auth.d.ts.map +1 -1
  23. package/dist/auth/better-auth.js +8 -2
  24. package/dist/auth/better-auth.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +27 -13
  27. package/dist/index.js.map +1 -1
  28. package/dist/middleware/auth.d.ts.map +1 -1
  29. package/dist/middleware/auth.js +143 -2
  30. package/dist/middleware/auth.js.map +1 -1
  31. package/dist/onboarding-assets/ceo/AGENTS.md +1 -1
  32. package/dist/onboarding-assets/ceo/HEARTBEAT.md +5 -5
  33. package/dist/redaction.d.ts.map +1 -1
  34. package/dist/redaction.js +30 -12
  35. package/dist/redaction.js.map +1 -1
  36. package/dist/routes/access.d.ts.map +1 -1
  37. package/dist/routes/access.js +10 -0
  38. package/dist/routes/access.js.map +1 -1
  39. package/dist/routes/activity.d.ts.map +1 -1
  40. package/dist/routes/activity.js +4 -2
  41. package/dist/routes/activity.js.map +1 -1
  42. package/dist/routes/adapters.d.ts.map +1 -1
  43. package/dist/routes/adapters.js +1 -0
  44. package/dist/routes/adapters.js.map +1 -1
  45. package/dist/routes/agents.d.ts.map +1 -1
  46. package/dist/routes/agents.js +317 -56
  47. package/dist/routes/agents.js.map +1 -1
  48. package/dist/routes/costs.d.ts.map +1 -1
  49. package/dist/routes/costs.js +21 -2
  50. package/dist/routes/costs.js.map +1 -1
  51. package/dist/routes/instance-settings.d.ts.map +1 -1
  52. package/dist/routes/instance-settings.js +37 -2
  53. package/dist/routes/instance-settings.js.map +1 -1
  54. package/dist/routes/issue-tree-control.d.ts.map +1 -1
  55. package/dist/routes/issue-tree-control.js +3 -1
  56. package/dist/routes/issue-tree-control.js.map +1 -1
  57. package/dist/routes/issues.d.ts.map +1 -1
  58. package/dist/routes/issues.js +257 -32
  59. package/dist/routes/issues.js.map +1 -1
  60. package/dist/routes/projects.d.ts.map +1 -1
  61. package/dist/routes/projects.js +10 -3
  62. package/dist/routes/projects.js.map +1 -1
  63. package/dist/routes/routines.d.ts.map +1 -1
  64. package/dist/routes/routines.js +6 -1
  65. package/dist/routes/routines.js.map +1 -1
  66. package/dist/routes/workspace-command-authz.d.ts +1 -1
  67. package/dist/routes/workspace-command-authz.d.ts.map +1 -1
  68. package/dist/routes/workspace-command-authz.js +2 -2
  69. package/dist/routes/workspace-command-authz.js.map +1 -1
  70. package/dist/runtime-api.d.ts +4 -0
  71. package/dist/runtime-api.d.ts.map +1 -1
  72. package/dist/runtime-api.js +38 -10
  73. package/dist/runtime-api.js.map +1 -1
  74. package/dist/services/companies.d.ts +6 -0
  75. package/dist/services/companies.d.ts.map +1 -1
  76. package/dist/services/companies.js +1 -0
  77. package/dist/services/companies.js.map +1 -1
  78. package/dist/services/company-portability.d.ts.map +1 -1
  79. package/dist/services/company-portability.js +16 -15
  80. package/dist/services/company-portability.js.map +1 -1
  81. package/dist/services/costs.d.ts +9 -0
  82. package/dist/services/costs.d.ts.map +1 -1
  83. package/dist/services/costs.js +45 -1
  84. package/dist/services/costs.js.map +1 -1
  85. package/dist/services/environment-execution-target.d.ts.map +1 -1
  86. package/dist/services/environment-execution-target.js +7 -13
  87. package/dist/services/environment-execution-target.js.map +1 -1
  88. package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
  89. package/dist/services/environment-run-orchestrator.js +56 -0
  90. package/dist/services/environment-run-orchestrator.js.map +1 -1
  91. package/dist/services/environment-runtime.d.ts +2 -0
  92. package/dist/services/environment-runtime.d.ts.map +1 -1
  93. package/dist/services/environment-runtime.js +80 -39
  94. package/dist/services/environment-runtime.js.map +1 -1
  95. package/dist/services/heartbeat-stop-metadata.d.ts +2 -1
  96. package/dist/services/heartbeat-stop-metadata.d.ts.map +1 -1
  97. package/dist/services/heartbeat-stop-metadata.js +10 -1
  98. package/dist/services/heartbeat-stop-metadata.js.map +1 -1
  99. package/dist/services/heartbeat-stop-metadata.test.js +24 -0
  100. package/dist/services/heartbeat-stop-metadata.test.js.map +1 -1
  101. package/dist/services/heartbeat.d.ts +156 -5
  102. package/dist/services/heartbeat.d.ts.map +1 -1
  103. package/dist/services/heartbeat.js +1384 -112
  104. package/dist/services/heartbeat.js.map +1 -1
  105. package/dist/services/index.d.ts +1 -0
  106. package/dist/services/index.d.ts.map +1 -1
  107. package/dist/services/index.js +1 -0
  108. package/dist/services/index.js.map +1 -1
  109. package/dist/services/instance-settings.d.ts.map +1 -1
  110. package/dist/services/instance-settings.js +4 -1
  111. package/dist/services/instance-settings.js.map +1 -1
  112. package/dist/services/issue-execution-policy.d.ts +56 -1
  113. package/dist/services/issue-execution-policy.d.ts.map +1 -1
  114. package/dist/services/issue-execution-policy.js +400 -2
  115. package/dist/services/issue-execution-policy.js.map +1 -1
  116. package/dist/services/issue-thread-interactions.d.ts +5 -1
  117. package/dist/services/issue-thread-interactions.d.ts.map +1 -1
  118. package/dist/services/issue-thread-interactions.js +44 -1
  119. package/dist/services/issue-thread-interactions.js.map +1 -1
  120. package/dist/services/issue-tree-control.d.ts +1 -0
  121. package/dist/services/issue-tree-control.d.ts.map +1 -1
  122. package/dist/services/issue-tree-control.js +84 -4
  123. package/dist/services/issue-tree-control.js.map +1 -1
  124. package/dist/services/issues.d.ts +10 -1
  125. package/dist/services/issues.d.ts.map +1 -1
  126. package/dist/services/issues.js +452 -48
  127. package/dist/services/issues.js.map +1 -1
  128. package/dist/services/plugin-environment-driver.d.ts +4 -0
  129. package/dist/services/plugin-environment-driver.d.ts.map +1 -1
  130. package/dist/services/plugin-environment-driver.js +18 -1
  131. package/dist/services/plugin-environment-driver.js.map +1 -1
  132. package/dist/services/productivity-review.d.ts +83 -0
  133. package/dist/services/productivity-review.d.ts.map +1 -0
  134. package/dist/services/productivity-review.js +650 -0
  135. package/dist/services/productivity-review.js.map +1 -0
  136. package/dist/services/recovery/index.d.ts +1 -1
  137. package/dist/services/recovery/index.d.ts.map +1 -1
  138. package/dist/services/recovery/index.js +1 -1
  139. package/dist/services/recovery/index.js.map +1 -1
  140. package/dist/services/recovery/issue-graph-liveness.d.ts +13 -1
  141. package/dist/services/recovery/issue-graph-liveness.d.ts.map +1 -1
  142. package/dist/services/recovery/issue-graph-liveness.js +212 -92
  143. package/dist/services/recovery/issue-graph-liveness.js.map +1 -1
  144. package/dist/services/recovery/origins.d.ts +2 -0
  145. package/dist/services/recovery/origins.d.ts.map +1 -1
  146. package/dist/services/recovery/origins.js +4 -0
  147. package/dist/services/recovery/origins.js.map +1 -1
  148. package/dist/services/recovery/run-liveness-continuations.d.ts.map +1 -1
  149. package/dist/services/recovery/run-liveness-continuations.js.map +1 -1
  150. package/dist/services/recovery/service.d.ts +20 -2
  151. package/dist/services/recovery/service.d.ts.map +1 -1
  152. package/dist/services/recovery/service.js +405 -63
  153. package/dist/services/recovery/service.js.map +1 -1
  154. package/dist/services/routines.d.ts +5 -2
  155. package/dist/services/routines.d.ts.map +1 -1
  156. package/dist/services/routines.js +47 -3
  157. package/dist/services/routines.js.map +1 -1
  158. package/dist/worktree-config.d.ts.map +1 -1
  159. package/dist/worktree-config.js +2 -5
  160. package/dist/worktree-config.js.map +1 -1
  161. package/package.json +16 -15
  162. package/skills/diagnose-why-work-stopped/SKILL.md +161 -0
  163. package/skills/paperclip/SKILL.md +37 -26
  164. package/skills/paperclip/references/api-reference.md +6 -2
  165. package/skills/paperclip-converting-plans-to-tasks/SKILL.md +42 -0
  166. package/skills/paperclip-create-agent/SKILL.md +3 -2
  167. package/skills/paperclip-create-agent/references/agent-instruction-templates.md +1 -1
  168. package/skills/paperclip-create-agent/references/api-reference.md +7 -2
  169. package/skills/paperclip-create-agent/references/baseline-role-guide.md +1 -1
  170. package/skills/paperclip-create-agent/references/draft-review-checklist.md +2 -2
  171. package/skills/paperclip-dev/SKILL.md +267 -0
  172. package/skills/terminal-bench-loop/SKILL.md +236 -0
  173. package/ui-dist/assets/{_basePickBy-BRqa7PJ5.js → _basePickBy-BS0Fg_DB.js} +1 -1
  174. package/ui-dist/assets/{_baseUniq-DhE2yrXC.js → _baseUniq-Dtnt_4SE.js} +1 -1
  175. package/ui-dist/assets/{arc-7qnikTQ3.js → arc-BCoOPxh5.js} +1 -1
  176. package/ui-dist/assets/{architectureDiagram-VXUJARFQ-CH0wVUOM.js → architectureDiagram-VXUJARFQ-C6eX2QUo.js} +1 -1
  177. package/ui-dist/assets/{blockDiagram-VD42YOAC-CeeRyJQX.js → blockDiagram-VD42YOAC-aUueUD4B.js} +1 -1
  178. package/ui-dist/assets/browser-ponyfill-BlAfsWm_.js +2 -0
  179. package/ui-dist/assets/{c4Diagram-YG6GDRKO-C_cV0CGo.js → c4Diagram-YG6GDRKO-CfPWRlOF.js} +1 -1
  180. package/ui-dist/assets/channel-ChNSCFJf.js +1 -0
  181. package/ui-dist/assets/{chunk-4BX2VUAB-DQ6pxPVT.js → chunk-4BX2VUAB-BTD1apA4.js} +1 -1
  182. package/ui-dist/assets/{chunk-55IACEB6-L8pS0IoX.js → chunk-55IACEB6-BXXF_ClN.js} +1 -1
  183. package/ui-dist/assets/{chunk-B4BG7PRW-BZKGE88E.js → chunk-B4BG7PRW-hAZeWGP8.js} +1 -1
  184. package/ui-dist/assets/{chunk-DI55MBZ5-CefSoZ_K.js → chunk-DI55MBZ5-cOH3UoEl.js} +1 -1
  185. package/ui-dist/assets/{chunk-FMBD7UC4-Bc3qTTHB.js → chunk-FMBD7UC4-Cu2yZOcl.js} +1 -1
  186. package/ui-dist/assets/{chunk-QN33PNHL-CjWBr5bI.js → chunk-QN33PNHL-0DNN5aRU.js} +1 -1
  187. package/ui-dist/assets/{chunk-QZHKN3VN-C0JUdmmz.js → chunk-QZHKN3VN-B9_bhK2n.js} +1 -1
  188. package/ui-dist/assets/{chunk-TZMSLE5B-D4d4I82z.js → chunk-TZMSLE5B-Cr5xwxio.js} +1 -1
  189. package/ui-dist/assets/classDiagram-2ON5EDUG-4aK1QZU3.js +1 -0
  190. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-4aK1QZU3.js +1 -0
  191. package/ui-dist/assets/clone-C8lk5Qbc.js +1 -0
  192. package/ui-dist/assets/{cose-bilkent-S5V4N54A-B09h9XGZ.js → cose-bilkent-S5V4N54A-6_Dw6gpQ.js} +1 -1
  193. package/ui-dist/assets/{dagre-6UL2VRFP-CA02PXuX.js → dagre-6UL2VRFP-CFBhlh5H.js} +1 -1
  194. package/ui-dist/assets/{diagram-PSM6KHXK-DaT9cnrY.js → diagram-PSM6KHXK-C88ftcah.js} +1 -1
  195. package/ui-dist/assets/{diagram-QEK2KX5R-Drwc3gBw.js → diagram-QEK2KX5R-9EUupcuH.js} +1 -1
  196. package/ui-dist/assets/{diagram-S2PKOQOG-CpsGCaT6.js → diagram-S2PKOQOG-Dsml0wWh.js} +1 -1
  197. package/ui-dist/assets/{erDiagram-Q2GNP2WA-CVkBh9TY.js → erDiagram-Q2GNP2WA-sM-XdfHS.js} +1 -1
  198. package/ui-dist/assets/{flowDiagram-NV44I4VS-De9sXvPR.js → flowDiagram-NV44I4VS-qll7oaoW.js} +1 -1
  199. package/ui-dist/assets/{ganttDiagram-JELNMOA3-CSFa0gXS.js → ganttDiagram-JELNMOA3-VWnJMcjC.js} +1 -1
  200. package/ui-dist/assets/{gitGraphDiagram-V2S2FVAM-DEJaChxa.js → gitGraphDiagram-V2S2FVAM-DFnocrfl.js} +1 -1
  201. package/ui-dist/assets/{graph-D2R4DCtu.js → graph-nq3Qye4Z.js} +1 -1
  202. package/ui-dist/assets/{index-DEG-9CFs.js → index-3Owzaheh.js} +1 -1
  203. package/ui-dist/assets/{index-DHnKx9xX.js → index-B2A-a635.js} +1 -1
  204. package/ui-dist/assets/{index-C1I0SGDm.js → index-BGFrRiqa.js} +1 -1
  205. package/ui-dist/assets/{index-B44EtLRv.js → index-BVC5UhRK.js} +1 -1
  206. package/ui-dist/assets/{index-C_dAXwxT.js → index-BrP1U_Hy.js} +1 -1
  207. package/ui-dist/assets/{index-flZjKn_n.js → index-CXXHGqM8.js} +1 -1
  208. package/ui-dist/assets/{index-ssM_UKPW.js → index-CgyPAauR.js} +1 -1
  209. package/ui-dist/assets/{index-Ct1AraKR.js → index-CksQ4Ytv.js} +1 -1
  210. package/ui-dist/assets/{index-DQ6I_vpd.js → index-CrNzj2vZ.js} +1 -1
  211. package/ui-dist/assets/{index-DzZID5RY.js → index-CxbZBH3M.js} +1 -1
  212. package/ui-dist/assets/{index-Cn6_RRY5.js → index-D-dSSrf-.js} +1 -1
  213. package/ui-dist/assets/{index-CVa2OHgx.js → index-D6uZ_7Vh.js} +1 -1
  214. package/ui-dist/assets/{index-BzjWQd50.js → index-D7JGmxas.js} +1 -1
  215. package/ui-dist/assets/{index-CnT1_9UF.js → index-DDqO9GAq.js} +1 -1
  216. package/ui-dist/assets/index-DEUtmlPm.js +513 -0
  217. package/ui-dist/assets/{index-D2fEhyQg.js → index-DF5RDSoK.js} +1 -1
  218. package/ui-dist/assets/{index-CZGNe8K3.js → index-DfI92epU.js} +1 -1
  219. package/ui-dist/assets/{index-ByamXtyB.js → index-Dukb9MDQ.js} +1 -1
  220. package/ui-dist/assets/index-HP73_6Vr.css +1 -0
  221. package/ui-dist/assets/{index-BJS4rvUh.js → index-NXDTW2n4.js} +1 -1
  222. package/ui-dist/assets/{index-Bad5Hy7e.js → index-SxPPG9ig.js} +1 -1
  223. package/ui-dist/assets/{index-CC51mhhA.js → index-lC4Yz3Gw.js} +1 -1
  224. package/ui-dist/assets/{index-BFzkl36p.js → index-q2RXGI2V.js} +1 -1
  225. package/ui-dist/assets/{index-40icqWwg.js → index-qjfdrS96.js} +1 -1
  226. package/ui-dist/assets/{infoDiagram-HS3SLOUP-CJcjzWkM.js → infoDiagram-HS3SLOUP-CTrK5xoS.js} +1 -1
  227. package/ui-dist/assets/{journeyDiagram-XKPGCS4Q-ByITI00s.js → journeyDiagram-XKPGCS4Q-YFC7FykG.js} +1 -1
  228. package/ui-dist/assets/{kanban-definition-3W4ZIXB7-DvEjKke-.js → kanban-definition-3W4ZIXB7-B3dlyva0.js} +1 -1
  229. package/ui-dist/assets/{layout-CZcd66hi.js → layout-DefunPTK.js} +1 -1
  230. package/ui-dist/assets/{linear-jTUy3iHu.js → linear-CIPvzeMv.js} +1 -1
  231. package/ui-dist/assets/{mermaid.core-DECSZPbJ.js → mermaid.core-zKYhmnnR.js} +4 -4
  232. package/ui-dist/assets/{mindmap-definition-VGOIOE7T-Twtu17_c.js → mindmap-definition-VGOIOE7T-BlU-ebRa.js} +1 -1
  233. package/ui-dist/assets/{pieDiagram-ADFJNKIX-DlbgZ010.js → pieDiagram-ADFJNKIX-Ceto4LXH.js} +1 -1
  234. package/ui-dist/assets/{quadrantDiagram-AYHSOK5B-CMAa3qAT.js → quadrantDiagram-AYHSOK5B-C6M6hkuE.js} +1 -1
  235. package/ui-dist/assets/{requirementDiagram-UZGBJVZJ-CXRTfJOe.js → requirementDiagram-UZGBJVZJ-B-bcG938.js} +1 -1
  236. package/ui-dist/assets/{sankeyDiagram-TZEHDZUN-DeyO4fer.js → sankeyDiagram-TZEHDZUN-CIqty6Qi.js} +1 -1
  237. package/ui-dist/assets/{sequenceDiagram-WL72ISMW-Ch8wlJIL.js → sequenceDiagram-WL72ISMW-CIt2R5tk.js} +1 -1
  238. package/ui-dist/assets/{stateDiagram-FKZM4ZOC-BgL_AAl9.js → stateDiagram-FKZM4ZOC-BC1RFlfg.js} +1 -1
  239. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-Iy6tYSSw.js +1 -0
  240. package/ui-dist/assets/{timeline-definition-IT6M3QCI-D1QWd7TQ.js → timeline-definition-IT6M3QCI-DZqvoU94.js} +1 -1
  241. package/ui-dist/assets/{treemap-GDKQZRPO-B5RkmUv8.js → treemap-GDKQZRPO-CSeKauwA.js} +1 -1
  242. package/ui-dist/assets/{xychartDiagram-PRI3JC2R-WtDhjZfk.js → xychartDiagram-PRI3JC2R-Ut3mCiEd.js} +1 -1
  243. package/ui-dist/index.html +2 -2
  244. package/ui-dist/locales/en/common.json +137 -1
  245. package/ui-dist/locales/zh-CN/common.json +111 -1
  246. package/ui-dist/assets/browser-ponyfill-Ct3hGqsr.js +0 -2
  247. package/ui-dist/assets/channel-pHFjGZL-.js +0 -1
  248. package/ui-dist/assets/classDiagram-2ON5EDUG-X4ZksqXl.js +0 -1
  249. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-X4ZksqXl.js +0 -1
  250. package/ui-dist/assets/clone-DZzimpfG.js +0 -1
  251. package/ui-dist/assets/index-C1oE3J7o.css +0 -1
  252. package/ui-dist/assets/index-fSIlEIHr.js +0 -510
  253. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-gnLzrhSv.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm";
2
- import { agents, agentWakeupRequests, companies, heartbeatRunEvents, heartbeatRunWatchdogDecisions, heartbeatRuns, issueRelations, issues, } from "@penclipai/db";
2
+ import { DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "@penclipai/shared";
3
+ import { agents, agentWakeupRequests, approvals, companies, heartbeatRunEvents, heartbeatRunWatchdogDecisions, heartbeatRuns, issueApprovals, issueRelations, issueThreadInteractions, issues, } from "@penclipai/db";
3
4
  import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js";
4
5
  import { runningProcesses } from "../../adapters/index.js";
5
6
  import { forbidden, notFound } from "../../errors.js";
@@ -12,12 +13,11 @@ import { instanceSettingsService } from "../instance-settings.js";
12
13
  import { issueTreeControlService } from "../issue-tree-control.js";
13
14
  import { issueService } from "../issues.js";
14
15
  import { getRunLogStore } from "../run-log-store.js";
15
- import { RECOVERY_ORIGIN_KINDS, buildIssueGraphLivenessLeafKey, parseIssueGraphLivenessIncidentKey, } from "./origins.js";
16
+ import { RECOVERY_ORIGIN_KINDS, buildIssueGraphLivenessLeafKey, isStrandedIssueRecoveryOriginKind, parseIssueGraphLivenessIncidentKey, } from "./origins.js";
16
17
  import { classifyIssueGraphLiveness, } from "./issue-graph-liveness.js";
17
18
  import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
18
19
  const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
19
20
  const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"];
20
- const ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_MIN_STALE_MS = 24 * 60 * 60 * 1000;
21
21
  export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000;
22
22
  export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000;
23
23
  export const ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS = 30 * 60 * 1000;
@@ -31,23 +31,9 @@ function readNonEmptyString(value) {
31
31
  function summarizeRunFailureForIssueComment(run) {
32
32
  if (!run)
33
33
  return null;
34
- const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null;
35
- const rawError = readNonEmptyString(run.error)?.trim() ?? null;
36
- const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/);
37
- const firstLine = rawError
38
- ?.split(/\r?\n/)
39
- .map((line) => line.trim())
40
- .find(Boolean) ?? null;
41
- const summarySource = apiMessageMatch?.[1] ?? firstLine;
42
- const summary = summarySource && summarySource.length > 240
43
- ? `${summarySource.slice(0, 237)}...`
44
- : summarySource;
45
- if (errorCode && summary)
46
- return ` Latest retry failure: \`${errorCode}\` - ${summary}.`;
47
- if (errorCode)
48
- return ` Latest retry failure: \`${errorCode}\`.`;
49
- if (summary)
50
- return ` Latest retry failure: ${summary}.`;
34
+ if (readNonEmptyString(run.error) || readNonEmptyString(run.errorCode)) {
35
+ return " Latest retry failure details were withheld from the issue thread; inspect the linked run for evidence.";
36
+ }
51
37
  return null;
52
38
  }
53
39
  function didAutomaticRecoveryFail(latestRun, expectedRetryReason) {
@@ -105,6 +91,29 @@ function formatIssueLinksForComment(relations) {
105
91
  function isAgentInvokable(agent) {
106
92
  return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
107
93
  }
94
+ function isStrandedIssueRecoveryIssue(issue) {
95
+ return isStrandedIssueRecoveryOriginKind(issue.originKind);
96
+ }
97
+ function isUnsuccessfulTerminalIssueRun(latestRun) {
98
+ return Boolean(latestRun &&
99
+ UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES.includes(latestRun.status));
100
+ }
101
+ function isSuccessfulInProgressContinuationRun(latestRun) {
102
+ return latestRun?.status === "succeeded";
103
+ }
104
+ function isProductiveContinuationRun(latestRun) {
105
+ return latestRun?.status === "succeeded" &&
106
+ (latestRun.livenessState === "advanced" ||
107
+ latestRun.livenessState === "completed" ||
108
+ latestRun.livenessState === "blocked" ||
109
+ latestRun.livenessState === "needs_followup");
110
+ }
111
+ function isRepeatedProductiveContinuationRecovery(latestRun) {
112
+ const latestContext = parseObject(latestRun.contextSnapshot);
113
+ return readNonEmptyString(latestContext.retryReason) === "issue_continuation_needed" &&
114
+ readNonEmptyString(latestContext.source) === "issue.productive_terminal_continuation_recovery" &&
115
+ isProductiveContinuationRun(latestRun);
116
+ }
108
117
  function parseLivenessIncidentKey(incidentKey) {
109
118
  if (!incidentKey)
110
119
  return null;
@@ -202,6 +211,7 @@ export function recoveryService(db, deps) {
202
211
  error: heartbeatRuns.error,
203
212
  errorCode: heartbeatRuns.errorCode,
204
213
  contextSnapshot: heartbeatRuns.contextSnapshot,
214
+ livenessState: heartbeatRuns.livenessState,
205
215
  })
206
216
  .from(heartbeatRuns)
207
217
  .where(and(eq(heartbeatRuns.companyId, companyId), sql `${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`))
@@ -226,6 +236,14 @@ export function recoveryService(db, deps) {
226
236
  ]);
227
237
  return Boolean(run || deferredWake);
228
238
  }
239
+ async function hasQueuedIssueWake(companyId, issueId) {
240
+ return db
241
+ .select({ id: agentWakeupRequests.id })
242
+ .from(agentWakeupRequests)
243
+ .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, "queued"), sql `${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`))
244
+ .limit(1)
245
+ .then((rows) => Boolean(rows[0]));
246
+ }
229
247
  async function enqueueStrandedIssueRecovery(input) {
230
248
  const queued = await deps.enqueueWakeup(input.agentId, {
231
249
  source: "automation",
@@ -259,6 +277,32 @@ export function recoveryService(db, deps) {
259
277
  }
260
278
  return queued;
261
279
  }
280
+ async function enqueueInitialAssignedTodoDispatch(issue, agentId) {
281
+ return deps.enqueueWakeup(agentId, {
282
+ source: "assignment",
283
+ triggerDetail: "system",
284
+ reason: "issue_assigned",
285
+ payload: {
286
+ issueId: issue.id,
287
+ mutation: "assigned_todo_liveness_dispatch",
288
+ },
289
+ requestedByActorType: "system",
290
+ requestedByActorId: null,
291
+ contextSnapshot: {
292
+ issueId: issue.id,
293
+ taskId: issue.id,
294
+ wakeReason: "issue_assigned",
295
+ source: "issue.assigned_todo_liveness_dispatch",
296
+ },
297
+ });
298
+ }
299
+ async function isInvocationBudgetBlocked(issue, agentId) {
300
+ const budgetBlock = await budgets.getInvocationBlock(issue.companyId, agentId, {
301
+ issueId: issue.id,
302
+ projectId: issue.projectId,
303
+ });
304
+ return Boolean(budgetBlock);
305
+ }
262
306
  async function reconcileUnassignedBlockingIssues() {
263
307
  const candidates = await db
264
308
  .select({
@@ -603,6 +647,14 @@ export function recoveryService(db, deps) {
603
647
  (maybe.constraint === "issues_active_stale_run_evaluation_uq" ||
604
648
  typeof maybe.message === "string" && maybe.message.includes("issues_active_stale_run_evaluation_uq"));
605
649
  }
650
+ function isUniqueStrandedIssueRecoveryConflict(error) {
651
+ if (!error || typeof error !== "object")
652
+ return false;
653
+ const maybe = error;
654
+ return maybe.code === "23505" &&
655
+ (maybe.constraint === "issues_active_stranded_issue_recovery_uq" ||
656
+ typeof maybe.message === "string" && maybe.message.includes("issues_active_stranded_issue_recovery_uq"));
657
+ }
606
658
  async function ensureSourceIssueBlockedByStaleEvaluation(input) {
607
659
  if (!input.sourceIssue || ["done", "cancelled"].includes(input.sourceIssue.status))
608
660
  return false;
@@ -990,6 +1042,8 @@ export function recoveryService(db, deps) {
990
1042
  ].join("\n");
991
1043
  }
992
1044
  async function ensureStrandedIssueRecoveryIssue(input) {
1045
+ if (isStrandedIssueRecoveryIssue(input.issue))
1046
+ return null;
993
1047
  const existing = await findOpenStrandedIssueRecoveryIssue(input.issue.companyId, input.issue.id);
994
1048
  if (existing)
995
1049
  return existing;
@@ -997,32 +1051,43 @@ export function recoveryService(db, deps) {
997
1051
  if (!ownerAgentId)
998
1052
  return null;
999
1053
  const prefix = await getCompanyIssuePrefix(input.issue.companyId);
1000
- const recovery = await issuesSvc.create(input.issue.companyId, {
1001
- title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
1002
- description: buildStrandedIssueRecoveryDescription({
1003
- issue: input.issue,
1004
- latestRun: input.latestRun,
1005
- previousStatus: input.previousStatus,
1006
- prefix,
1007
- }),
1008
- status: "todo",
1009
- priority: input.issue.priority,
1010
- parentId: input.issue.id,
1011
- projectId: input.issue.projectId,
1012
- goalId: input.issue.goalId,
1013
- assigneeAgentId: ownerAgentId,
1014
- originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
1015
- originId: input.issue.id,
1016
- originRunId: input.latestRun?.id ?? null,
1017
- originFingerprint: [
1018
- STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
1019
- input.issue.companyId,
1020
- input.issue.id,
1021
- input.latestRun?.id ?? "no-run",
1022
- ].join(":"),
1023
- billingCode: input.issue.billingCode,
1024
- inheritExecutionWorkspaceFromIssueId: input.issue.id,
1025
- });
1054
+ let recovery;
1055
+ try {
1056
+ recovery = await issuesSvc.create(input.issue.companyId, {
1057
+ title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
1058
+ description: buildStrandedIssueRecoveryDescription({
1059
+ issue: input.issue,
1060
+ latestRun: input.latestRun,
1061
+ previousStatus: input.previousStatus,
1062
+ prefix,
1063
+ }),
1064
+ status: "todo",
1065
+ priority: input.issue.priority,
1066
+ parentId: input.issue.id,
1067
+ projectId: input.issue.projectId,
1068
+ goalId: input.issue.goalId,
1069
+ assigneeAgentId: ownerAgentId,
1070
+ originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
1071
+ originId: input.issue.id,
1072
+ originRunId: input.latestRun?.id ?? null,
1073
+ originFingerprint: [
1074
+ STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
1075
+ input.issue.companyId,
1076
+ input.issue.id,
1077
+ input.latestRun?.id ?? "no-run",
1078
+ ].join(":"),
1079
+ billingCode: input.issue.billingCode,
1080
+ inheritExecutionWorkspaceFromIssueId: input.issue.id,
1081
+ });
1082
+ }
1083
+ catch (error) {
1084
+ if (!isUniqueStrandedIssueRecoveryConflict(error))
1085
+ throw error;
1086
+ const raced = await findOpenStrandedIssueRecoveryIssue(input.issue.companyId, input.issue.id);
1087
+ if (!raced)
1088
+ throw error;
1089
+ return raced;
1090
+ }
1026
1091
  await deps.enqueueWakeup(ownerAgentId, {
1027
1092
  source: "assignment",
1028
1093
  triggerDetail: "system",
@@ -1045,6 +1110,60 @@ export function recoveryService(db, deps) {
1045
1110
  });
1046
1111
  return recovery;
1047
1112
  }
1113
+ function buildRecoveryIssueInPlaceEscalationComment(input) {
1114
+ const runLink = input.latestRun
1115
+ ? runUiLink({ id: input.latestRun.id, agentId: input.latestRun.agentId }, input.prefix)
1116
+ : "none";
1117
+ const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "none";
1118
+ const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
1119
+ return [
1120
+ "Paperclip stopped automatic stranded-work recovery for this recovery issue.",
1121
+ "",
1122
+ `- Recovery issue: ${issueUiLink({ identifier: input.issue.identifier, id: input.issue.id }, input.prefix)}`,
1123
+ `- Previous status: \`${input.previousStatus}\``,
1124
+ `- Latest run: ${runLink}`,
1125
+ `- Latest run status: \`${input.latestRun?.status ?? "unknown"}\``,
1126
+ `- Retry reason: \`${retryReason}\``,
1127
+ failureSummary ? `- Failure: ${failureSummary.trim()}` : "- Failure: none recorded",
1128
+ "- Guard: recovery issues do not create nested `stranded_issue_recovery` issues.",
1129
+ "",
1130
+ "Next action: the current recovery owner should inspect the failed run evidence, restore a live execution path or record the manual resolution, then move this recovery issue out of `blocked`.",
1131
+ ].join("\n");
1132
+ }
1133
+ async function escalateStrandedRecoveryIssueInPlace(input) {
1134
+ const updated = await issuesSvc.update(input.issue.id, { status: "blocked" });
1135
+ if (!updated)
1136
+ return null;
1137
+ const prefix = await getCompanyIssuePrefix(input.issue.companyId);
1138
+ await issuesSvc.addComment(input.issue.id, buildRecoveryIssueInPlaceEscalationComment({
1139
+ issue: input.issue,
1140
+ previousStatus: input.previousStatus,
1141
+ latestRun: input.latestRun,
1142
+ prefix,
1143
+ }), {});
1144
+ await logActivity(db, {
1145
+ companyId: input.issue.companyId,
1146
+ actorType: "system",
1147
+ actorId: "system",
1148
+ agentId: null,
1149
+ runId: null,
1150
+ action: "issue.updated",
1151
+ entityType: "issue",
1152
+ entityId: input.issue.id,
1153
+ details: {
1154
+ identifier: input.issue.identifier,
1155
+ status: "blocked",
1156
+ previousStatus: input.previousStatus,
1157
+ source: "recovery.reconcile_stranded_recovery_issue",
1158
+ latestRunId: input.latestRun?.id ?? null,
1159
+ latestRunStatus: input.latestRun?.status ?? null,
1160
+ latestRunErrorCode: input.latestRun?.errorCode ?? null,
1161
+ originKind: input.issue.originKind,
1162
+ originId: input.issue.originId,
1163
+ },
1164
+ });
1165
+ return updated;
1166
+ }
1048
1167
  async function existingBlockerIssueIds(companyId, issueId) {
1049
1168
  return db
1050
1169
  .select({ blockerIssueId: issueRelations.issueId })
@@ -1061,6 +1180,13 @@ export function recoveryService(db, deps) {
1061
1180
  .then((rows) => rows.map((row) => row.blockerIssueId));
1062
1181
  }
1063
1182
  async function escalateStrandedAssignedIssue(input) {
1183
+ if (isStrandedIssueRecoveryIssue(input.issue)) {
1184
+ return escalateStrandedRecoveryIssueInPlace({
1185
+ issue: input.issue,
1186
+ previousStatus: input.previousStatus,
1187
+ latestRun: input.latestRun,
1188
+ });
1189
+ }
1064
1190
  const recoveryIssue = await ensureStrandedIssueRecoveryIssue({
1065
1191
  issue: input.issue,
1066
1192
  previousStatus: input.previousStatus,
@@ -1118,8 +1244,11 @@ export function recoveryService(db, deps) {
1118
1244
  .from(issues)
1119
1245
  .where(and(isNull(issues.assigneeUserId), inArray(issues.status, ["todo", "in_progress"]), sql `${issues.assigneeAgentId} is not null`));
1120
1246
  const result = {
1247
+ assignmentDispatched: 0,
1121
1248
  dispatchRequeued: 0,
1122
1249
  continuationRequeued: 0,
1250
+ productiveContinuationObserved: 0,
1251
+ successfulContinuationObserved: 0,
1123
1252
  orphanBlockersAssigned: 0,
1124
1253
  escalated: 0,
1125
1254
  skipped: 0,
@@ -1145,8 +1274,42 @@ export function recoveryService(db, deps) {
1145
1274
  continue;
1146
1275
  }
1147
1276
  const latestRun = await getLatestIssueRun(issue.companyId, issue.id);
1277
+ if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) {
1278
+ const updated = await escalateStrandedRecoveryIssueInPlace({
1279
+ issue,
1280
+ previousStatus: issue.status,
1281
+ latestRun,
1282
+ });
1283
+ if (updated) {
1284
+ result.escalated += 1;
1285
+ result.issueIds.push(issue.id);
1286
+ }
1287
+ else {
1288
+ result.skipped += 1;
1289
+ }
1290
+ continue;
1291
+ }
1148
1292
  if (issue.status === "todo") {
1149
- if (!latestRun || latestRun.status === "succeeded") {
1293
+ if (!latestRun) {
1294
+ if (await hasQueuedIssueWake(issue.companyId, issue.id)) {
1295
+ result.skipped += 1;
1296
+ continue;
1297
+ }
1298
+ if (await isInvocationBudgetBlocked(issue, agentId)) {
1299
+ result.skipped += 1;
1300
+ continue;
1301
+ }
1302
+ const queued = await enqueueInitialAssignedTodoDispatch(issue, agentId);
1303
+ if (queued) {
1304
+ result.assignmentDispatched += 1;
1305
+ result.issueIds.push(issue.id);
1306
+ }
1307
+ else {
1308
+ result.skipped += 1;
1309
+ }
1310
+ continue;
1311
+ }
1312
+ if (latestRun.status === "succeeded") {
1150
1313
  result.skipped += 1;
1151
1314
  continue;
1152
1315
  }
@@ -1169,6 +1332,10 @@ export function recoveryService(db, deps) {
1169
1332
  }
1170
1333
  continue;
1171
1334
  }
1335
+ if (await isInvocationBudgetBlocked(issue, agentId)) {
1336
+ result.skipped += 1;
1337
+ continue;
1338
+ }
1172
1339
  const queued = await enqueueStrandedIssueRecovery({
1173
1340
  issueId: issue.id,
1174
1341
  agentId,
@@ -1190,6 +1357,51 @@ export function recoveryService(db, deps) {
1190
1357
  result.skipped += 1;
1191
1358
  continue;
1192
1359
  }
1360
+ if (isSuccessfulInProgressContinuationRun(latestRun)) {
1361
+ const successfulRun = latestRun;
1362
+ if (!isProductiveContinuationRun(successfulRun)) {
1363
+ result.successfulContinuationObserved += 1;
1364
+ result.skipped += 1;
1365
+ continue;
1366
+ }
1367
+ if (isRepeatedProductiveContinuationRecovery(successfulRun)) {
1368
+ const updated = await escalateStrandedAssignedIssue({
1369
+ issue,
1370
+ previousStatus: "in_progress",
1371
+ latestRun: successfulRun,
1372
+ comment: "Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry " +
1373
+ "made progress, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
1374
+ });
1375
+ if (updated) {
1376
+ result.escalated += 1;
1377
+ result.issueIds.push(issue.id);
1378
+ }
1379
+ else {
1380
+ result.skipped += 1;
1381
+ }
1382
+ continue;
1383
+ }
1384
+ if (await isInvocationBudgetBlocked(issue, agentId)) {
1385
+ result.skipped += 1;
1386
+ continue;
1387
+ }
1388
+ const queued = await enqueueStrandedIssueRecovery({
1389
+ issueId: issue.id,
1390
+ agentId,
1391
+ reason: "issue_continuation_needed",
1392
+ retryReason: "issue_continuation_needed",
1393
+ source: "issue.productive_terminal_continuation_recovery",
1394
+ retryOfRunId: successfulRun.id,
1395
+ });
1396
+ if (queued) {
1397
+ result.continuationRequeued += 1;
1398
+ result.issueIds.push(issue.id);
1399
+ }
1400
+ else {
1401
+ result.skipped += 1;
1402
+ }
1403
+ continue;
1404
+ }
1193
1405
  if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) {
1194
1406
  const failureSummary = summarizeRunFailureForIssueComment(latestRun);
1195
1407
  const updated = await escalateStrandedAssignedIssue({
@@ -1209,6 +1421,10 @@ export function recoveryService(db, deps) {
1209
1421
  }
1210
1422
  continue;
1211
1423
  }
1424
+ if (await isInvocationBudgetBlocked(issue, agentId)) {
1425
+ result.skipped += 1;
1426
+ continue;
1427
+ }
1212
1428
  const queued = await enqueueStrandedIssueRecovery({
1213
1429
  issueId: issue.id,
1214
1430
  agentId,
@@ -1232,7 +1448,7 @@ export function recoveryService(db, deps) {
1232
1448
  return result;
1233
1449
  }
1234
1450
  async function collectIssueGraphLivenessFindings() {
1235
- const [issueRows, relationRows, agentRows, activeRunRows, activeIssueRunRows, wakeRows] = await Promise.all([
1451
+ const [issueRows, relationRows, agentRows, activeRunRows, activeIssueRunRows, wakeRows, interactionRows, approvalRows, recoveryIssueRows,] = await Promise.all([
1236
1452
  db
1237
1453
  .select({
1238
1454
  id: issues.id,
@@ -1247,7 +1463,10 @@ export function recoveryService(db, deps) {
1247
1463
  assigneeUserId: issues.assigneeUserId,
1248
1464
  createdByAgentId: issues.createdByAgentId,
1249
1465
  createdByUserId: issues.createdByUserId,
1466
+ executionPolicy: issues.executionPolicy,
1250
1467
  executionState: issues.executionState,
1468
+ monitorNextCheckAt: issues.monitorNextCheckAt,
1469
+ monitorAttemptCount: issues.monitorAttemptCount,
1251
1470
  })
1252
1471
  .from(issues)
1253
1472
  .where(and(isNull(issues.hiddenAt), notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]))),
@@ -1298,7 +1517,43 @@ export function recoveryService(db, deps) {
1298
1517
  })
1299
1518
  .from(agentWakeupRequests)
1300
1519
  .where(inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"])),
1520
+ db
1521
+ .select({
1522
+ companyId: issueThreadInteractions.companyId,
1523
+ issueId: issueThreadInteractions.issueId,
1524
+ status: issueThreadInteractions.status,
1525
+ })
1526
+ .from(issueThreadInteractions)
1527
+ .where(eq(issueThreadInteractions.status, "pending")),
1528
+ db
1529
+ .select({
1530
+ companyId: issueApprovals.companyId,
1531
+ issueId: issueApprovals.issueId,
1532
+ status: approvals.status,
1533
+ })
1534
+ .from(issueApprovals)
1535
+ .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
1536
+ .where(inArray(approvals.status, ["pending", "revision_requested"])),
1537
+ db
1538
+ .select({
1539
+ companyId: issues.companyId,
1540
+ id: issues.id,
1541
+ status: issues.status,
1542
+ originId: issues.originId,
1543
+ })
1544
+ .from(issues)
1545
+ .where(and(isNull(issues.hiddenAt), eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND), notInArray(issues.status, ["done", "cancelled"]))),
1301
1546
  ]);
1547
+ const openRecoveryIssues = recoveryIssueRows.flatMap((row) => {
1548
+ const issueId = readNonEmptyString(row.originId);
1549
+ if (!issueId)
1550
+ return [];
1551
+ return [{
1552
+ companyId: row.companyId,
1553
+ issueId,
1554
+ status: row.status,
1555
+ }];
1556
+ });
1302
1557
  return classifyIssueGraphLiveness({
1303
1558
  issues: issueRows,
1304
1559
  relations: relationRows,
@@ -1320,6 +1575,10 @@ export function recoveryService(db, deps) {
1320
1575
  status: row.status,
1321
1576
  issueId: issueIdFromWakePayload(row.payload),
1322
1577
  })),
1578
+ pendingInteractions: interactionRows,
1579
+ pendingApprovals: approvalRows,
1580
+ openRecoveryIssues,
1581
+ now: new Date(),
1323
1582
  });
1324
1583
  }
1325
1584
  async function findOpenLivenessEscalation(companyId, incidentKey) {
@@ -1422,18 +1681,90 @@ export function recoveryService(db, deps) {
1422
1681
  }
1423
1682
  return result;
1424
1683
  }
1425
- async function isLivenessFindingOldEnoughForAutoRecovery(finding, now = new Date()) {
1426
- const issueIds = [...new Set(finding.dependencyPath.map((entry) => entry.issueId))];
1684
+ function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw) {
1685
+ const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS));
1686
+ return Math.min(MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, Math.max(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, numeric));
1687
+ }
1688
+ function livenessDependencyIssueKey(companyId, issueId) {
1689
+ return `${companyId}:${issueId}`;
1690
+ }
1691
+ async function loadLivenessDependencyUpdatedAtByIssue(findings) {
1692
+ const issueIds = [
1693
+ ...new Set(findings.flatMap((finding) => finding.dependencyPath.map((entry) => entry.issueId))),
1694
+ ];
1427
1695
  if (issueIds.length === 0)
1428
- return false;
1696
+ return new Map();
1429
1697
  const rows = await db
1430
- .select({ id: issues.id, updatedAt: issues.updatedAt })
1698
+ .select({ id: issues.id, companyId: issues.companyId, updatedAt: issues.updatedAt })
1431
1699
  .from(issues)
1432
- .where(and(eq(issues.companyId, finding.companyId), inArray(issues.id, issueIds)));
1433
- if (rows.length !== issueIds.length)
1434
- return false;
1435
- const latestUpdatedAt = rows.reduce((latest, row) => row.updatedAt > latest ? row.updatedAt : latest, rows[0].updatedAt);
1436
- return now.getTime() - latestUpdatedAt.getTime() >= ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_MIN_STALE_MS;
1700
+ .where(inArray(issues.id, issueIds));
1701
+ return new Map(rows.map((row) => [
1702
+ livenessDependencyIssueKey(row.companyId, row.id),
1703
+ row.updatedAt,
1704
+ ]));
1705
+ }
1706
+ function latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey) {
1707
+ const dependencyIssueIds = [...new Set(finding.dependencyPath.map((entry) => entry.issueId))];
1708
+ if (dependencyIssueIds.length === 0)
1709
+ return null;
1710
+ const timestamps = dependencyIssueIds.map((issueId) => updatedAtByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId)) ?? null);
1711
+ if (timestamps.some((timestamp) => !timestamp))
1712
+ return null;
1713
+ const [firstTimestamp, ...remainingTimestamps] = timestamps;
1714
+ return remainingTimestamps.reduce((latest, updatedAt) => updatedAt > latest ? updatedAt : latest, firstTimestamp);
1715
+ }
1716
+ function isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, updatedAtByIssueKey) {
1717
+ const latestUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey);
1718
+ return Boolean(latestUpdatedAt && latestUpdatedAt >= cutoff);
1719
+ }
1720
+ async function buildIssueGraphLivenessAutoRecoveryPreview(opts) {
1721
+ const now = opts?.now ?? new Date();
1722
+ const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(opts?.lookbackHours);
1723
+ const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
1724
+ const findings = await collectIssueGraphLivenessFindings();
1725
+ const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
1726
+ const issueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))];
1727
+ const recoveryRows = issueIds.length > 0
1728
+ ? await db
1729
+ .select({ id: issues.id, identifier: issues.identifier, title: issues.title })
1730
+ .from(issues)
1731
+ .where(inArray(issues.id, issueIds))
1732
+ : [];
1733
+ const recoveryById = new Map(recoveryRows.map((row) => [row.id, row]));
1734
+ const items = [];
1735
+ let skippedOutsideLookback = 0;
1736
+ for (const finding of findings) {
1737
+ const latestDependencyUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey);
1738
+ if (!latestDependencyUpdatedAt || latestDependencyUpdatedAt < cutoff) {
1739
+ skippedOutsideLookback += 1;
1740
+ continue;
1741
+ }
1742
+ const recoveryIssue = recoveryById.get(finding.recoveryIssueId);
1743
+ items.push({
1744
+ issueId: finding.issueId,
1745
+ identifier: finding.identifier,
1746
+ title: finding.dependencyPath[0]?.title ?? finding.identifier ?? finding.issueId,
1747
+ state: finding.state,
1748
+ severity: finding.severity,
1749
+ reason: finding.reason,
1750
+ recoveryIssueId: finding.recoveryIssueId,
1751
+ recoveryIdentifier: recoveryIssue?.identifier ?? null,
1752
+ recoveryTitle: recoveryIssue?.title ?? null,
1753
+ recommendedOwnerAgentId: finding.recommendedOwnerAgentId,
1754
+ incidentKey: finding.incidentKey,
1755
+ latestDependencyUpdatedAt: latestDependencyUpdatedAt.toISOString(),
1756
+ dependencyPath: finding.dependencyPath,
1757
+ });
1758
+ }
1759
+ return {
1760
+ lookbackHours,
1761
+ cutoff: cutoff.toISOString(),
1762
+ generatedAt: now.toISOString(),
1763
+ findings: findings.length,
1764
+ recoverableFindings: items.length,
1765
+ skippedOutsideLookback,
1766
+ items,
1767
+ };
1437
1768
  }
1438
1769
  async function resolveEscalationOwnerAgentId(finding, issue) {
1439
1770
  const detailedCandidates = finding.recommendedOwnerCandidates.length > 0
@@ -1482,10 +1813,15 @@ export function recoveryService(db, deps) {
1482
1813
  async function ensureIssueBlockedByEscalation(input) {
1483
1814
  const blockerIds = await existingBlockerIssueIds(input.issue.companyId, input.issue.id);
1484
1815
  const nextBlockerIds = [...new Set([...blockerIds, input.escalationIssueId])];
1816
+ const isAlreadyBlockedByEscalation = blockerIds.includes(input.escalationIssueId);
1817
+ const isAlreadyBlocked = input.issue.status === "blocked";
1818
+ if (isAlreadyBlockedByEscalation && isAlreadyBlocked) {
1819
+ return input.issue;
1820
+ }
1485
1821
  const update = {
1486
1822
  blockedByIssueIds: nextBlockerIds,
1487
1823
  };
1488
- if (input.issue.status !== "blocked") {
1824
+ if (!isAlreadyBlocked) {
1489
1825
  update.status = "blocked";
1490
1826
  }
1491
1827
  const updated = await issuesSvc.update(input.issue.id, update);
@@ -1667,16 +2003,22 @@ export function recoveryService(db, deps) {
1667
2003
  async function reconcileIssueGraphLiveness(opts) {
1668
2004
  const findings = await collectIssueGraphLivenessFindings();
1669
2005
  const experimentalSettings = await instanceSettings.getExperimental();
1670
- const autoRecoveryEnabled = asBoolean(experimentalSettings.enableIssueGraphLivenessAutoRecovery, false);
2006
+ const autoRecoveryEnabled = asBoolean(experimentalSettings.enableIssueGraphLivenessAutoRecovery, true) || opts?.force === true;
2007
+ const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(opts?.lookbackHours ?? experimentalSettings.issueGraphLivenessAutoRecoveryLookbackHours);
2008
+ const now = new Date();
2009
+ const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
1671
2010
  const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings);
2011
+ const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
1672
2012
  const result = {
1673
2013
  findings: findings.length,
1674
2014
  autoRecoveryEnabled,
2015
+ lookbackHours,
2016
+ cutoff: cutoff.toISOString(),
1675
2017
  escalationsCreated: 0,
1676
2018
  existingEscalations: 0,
1677
2019
  skipped: 0,
1678
2020
  skippedAutoRecoveryDisabled: 0,
1679
- skippedAutoRecoveryTooYoung: 0,
2021
+ skippedOutsideLookback: 0,
1680
2022
  obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired,
1681
2023
  obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped,
1682
2024
  obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved,
@@ -1688,10 +2030,9 @@ export function recoveryService(db, deps) {
1688
2030
  result.skippedAutoRecoveryDisabled = findings.length;
1689
2031
  return result;
1690
2032
  }
1691
- const now = new Date();
1692
2033
  for (const finding of findings) {
1693
- if (!await isLivenessFindingOldEnoughForAutoRecovery(finding, now)) {
1694
- result.skippedAutoRecoveryTooYoung += 1;
2034
+ if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, updatedAtByIssueKey)) {
2035
+ result.skippedOutsideLookback += 1;
1695
2036
  result.skipped += 1;
1696
2037
  continue;
1697
2038
  }
@@ -1724,6 +2065,7 @@ export function recoveryService(db, deps) {
1724
2065
  recordWatchdogDecision,
1725
2066
  scanSilentActiveRuns,
1726
2067
  reconcileStrandedAssignedIssues,
2068
+ buildIssueGraphLivenessAutoRecoveryPreview,
1727
2069
  reconcileIssueGraphLiveness,
1728
2070
  readRecoveryTimerIntervalMs,
1729
2071
  };