clinkx 0.2.3 → 1.0.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 (574) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +10 -0
  3. package/README.md +113 -18
  4. package/clinkx-workflows/dist/allowed-roots.d.ts +1 -0
  5. package/clinkx-workflows/dist/allowed-roots.js +5 -0
  6. package/clinkx-workflows/dist/allowed-roots.js.map +1 -0
  7. package/clinkx-workflows/dist/artifacts.d.ts +20 -1
  8. package/clinkx-workflows/dist/artifacts.js +38 -9
  9. package/clinkx-workflows/dist/artifacts.js.map +1 -1
  10. package/clinkx-workflows/dist/backend.d.ts +4 -0
  11. package/clinkx-workflows/dist/citation-check.d.ts +40 -0
  12. package/clinkx-workflows/dist/citation-check.js +348 -0
  13. package/clinkx-workflows/dist/citation-check.js.map +1 -0
  14. package/clinkx-workflows/dist/clink-client.d.ts +2 -1
  15. package/clinkx-workflows/dist/clink-client.js +17 -4
  16. package/clinkx-workflows/dist/clink-client.js.map +1 -1
  17. package/clinkx-workflows/dist/config.d.ts +145 -16
  18. package/clinkx-workflows/dist/config.js +384 -26
  19. package/clinkx-workflows/dist/config.js.map +1 -1
  20. package/clinkx-workflows/dist/contained-file.d.ts +172 -0
  21. package/clinkx-workflows/dist/contained-file.js +483 -0
  22. package/clinkx-workflows/dist/contained-file.js.map +1 -0
  23. package/clinkx-workflows/dist/context-builder-lifecycle-contract.d.ts +416 -0
  24. package/clinkx-workflows/dist/context-builder-lifecycle-contract.js +328 -0
  25. package/clinkx-workflows/dist/context-builder-lifecycle-contract.js.map +1 -0
  26. package/clinkx-workflows/dist/context-builder-lifecycle-router.d.ts +24 -0
  27. package/clinkx-workflows/dist/context-builder-lifecycle-router.js +39 -0
  28. package/clinkx-workflows/dist/context-builder-lifecycle-router.js.map +1 -0
  29. package/clinkx-workflows/dist/context-cache-key.d.ts +53 -0
  30. package/clinkx-workflows/dist/context-cache-key.js +91 -0
  31. package/clinkx-workflows/dist/context-cache-key.js.map +1 -0
  32. package/clinkx-workflows/dist/context-mode-resolver.d.ts +27 -0
  33. package/clinkx-workflows/dist/context-mode-resolver.js +126 -0
  34. package/clinkx-workflows/dist/context-mode-resolver.js.map +1 -0
  35. package/clinkx-workflows/dist/context-preflight.d.ts +184 -0
  36. package/clinkx-workflows/dist/context-preflight.js +91 -0
  37. package/clinkx-workflows/dist/context-preflight.js.map +1 -0
  38. package/clinkx-workflows/dist/council-expander.d.ts +1 -1
  39. package/clinkx-workflows/dist/council-expander.js +2 -1
  40. package/clinkx-workflows/dist/council-expander.js.map +1 -1
  41. package/clinkx-workflows/dist/dashboard/activity-contract.d.ts +165 -0
  42. package/clinkx-workflows/dist/dashboard/activity-contract.js +105 -0
  43. package/clinkx-workflows/dist/dashboard/activity-contract.js.map +1 -0
  44. package/clinkx-workflows/dist/dashboard/activity-lease.d.ts +25 -0
  45. package/clinkx-workflows/dist/dashboard/activity-lease.js +34 -0
  46. package/clinkx-workflows/dist/dashboard/activity-lease.js.map +1 -0
  47. package/clinkx-workflows/dist/dashboard/activity-reader.d.ts +86 -0
  48. package/clinkx-workflows/dist/dashboard/activity-reader.js +480 -0
  49. package/clinkx-workflows/dist/dashboard/activity-reader.js.map +1 -0
  50. package/clinkx-workflows/dist/dashboard/api-routes.d.ts +36 -0
  51. package/clinkx-workflows/dist/dashboard/api-routes.js +1047 -0
  52. package/clinkx-workflows/dist/dashboard/api-routes.js.map +1 -0
  53. package/clinkx-workflows/dist/dashboard/artifact-index.d.ts +37 -0
  54. package/clinkx-workflows/dist/dashboard/artifact-index.js +145 -0
  55. package/clinkx-workflows/dist/dashboard/artifact-index.js.map +1 -0
  56. package/clinkx-workflows/dist/dashboard/auth/challenge-store.d.ts +31 -0
  57. package/clinkx-workflows/dist/dashboard/auth/challenge-store.js +65 -0
  58. package/clinkx-workflows/dist/dashboard/auth/challenge-store.js.map +1 -0
  59. package/clinkx-workflows/dist/dashboard/auth/controller.d.ts +26 -0
  60. package/clinkx-workflows/dist/dashboard/auth/controller.js +596 -0
  61. package/clinkx-workflows/dist/dashboard/auth/controller.js.map +1 -0
  62. package/clinkx-workflows/dist/dashboard/auth/credential-store.d.ts +60 -0
  63. package/clinkx-workflows/dist/dashboard/auth/credential-store.js +776 -0
  64. package/clinkx-workflows/dist/dashboard/auth/credential-store.js.map +1 -0
  65. package/clinkx-workflows/dist/dashboard/auth/enrollment-gate.d.ts +9 -0
  66. package/clinkx-workflows/dist/dashboard/auth/enrollment-gate.js +44 -0
  67. package/clinkx-workflows/dist/dashboard/auth/enrollment-gate.js.map +1 -0
  68. package/clinkx-workflows/dist/dashboard/auth/session-store.d.ts +17 -0
  69. package/clinkx-workflows/dist/dashboard/auth/session-store.js +77 -0
  70. package/clinkx-workflows/dist/dashboard/auth/session-store.js.map +1 -0
  71. package/clinkx-workflows/dist/dashboard/auth/webauthn-service.d.ts +25 -0
  72. package/clinkx-workflows/dist/dashboard/auth/webauthn-service.js +71 -0
  73. package/clinkx-workflows/dist/dashboard/auth/webauthn-service.js.map +1 -0
  74. package/clinkx-workflows/dist/dashboard/context-run-liveness.d.ts +37 -0
  75. package/clinkx-workflows/dist/dashboard/context-run-liveness.js +49 -0
  76. package/clinkx-workflows/dist/dashboard/context-run-liveness.js.map +1 -0
  77. package/clinkx-workflows/dist/dashboard/context-run-reader.d.ts +222 -0
  78. package/clinkx-workflows/dist/dashboard/context-run-reader.js +1178 -0
  79. package/clinkx-workflows/dist/dashboard/context-run-reader.js.map +1 -0
  80. package/clinkx-workflows/dist/dashboard/contracts.d.ts +8936 -0
  81. package/clinkx-workflows/dist/dashboard/contracts.js +1157 -0
  82. package/clinkx-workflows/dist/dashboard/contracts.js.map +1 -0
  83. package/clinkx-workflows/dist/dashboard/generation.d.ts +14 -0
  84. package/clinkx-workflows/dist/dashboard/generation.js +41 -0
  85. package/clinkx-workflows/dist/dashboard/generation.js.map +1 -0
  86. package/clinkx-workflows/dist/dashboard/index.d.ts +28 -0
  87. package/clinkx-workflows/dist/dashboard/index.js +16 -0
  88. package/clinkx-workflows/dist/dashboard/index.js.map +1 -0
  89. package/clinkx-workflows/dist/dashboard/lazy-content.d.ts +101 -0
  90. package/clinkx-workflows/dist/dashboard/lazy-content.js +748 -0
  91. package/clinkx-workflows/dist/dashboard/lazy-content.js.map +1 -0
  92. package/clinkx-workflows/dist/dashboard/progress-report-contract.d.ts +303 -0
  93. package/clinkx-workflows/dist/dashboard/progress-report-contract.js +221 -0
  94. package/clinkx-workflows/dist/dashboard/progress-report-contract.js.map +1 -0
  95. package/clinkx-workflows/dist/dashboard/progress-report-reader.d.ts +50 -0
  96. package/clinkx-workflows/dist/dashboard/progress-report-reader.js +581 -0
  97. package/clinkx-workflows/dist/dashboard/progress-report-reader.js.map +1 -0
  98. package/clinkx-workflows/dist/dashboard/progress-report-status-overlay.d.ts +89 -0
  99. package/clinkx-workflows/dist/dashboard/progress-report-status-overlay.js +71 -0
  100. package/clinkx-workflows/dist/dashboard/progress-report-status-overlay.js.map +1 -0
  101. package/clinkx-workflows/dist/dashboard/progress-report-status-store.d.ts +28 -0
  102. package/clinkx-workflows/dist/dashboard/progress-report-status-store.js +110 -0
  103. package/clinkx-workflows/dist/dashboard/progress-report-status-store.js.map +1 -0
  104. package/clinkx-workflows/dist/dashboard/read-model.d.ts +86 -0
  105. package/clinkx-workflows/dist/dashboard/read-model.js +440 -0
  106. package/clinkx-workflows/dist/dashboard/read-model.js.map +1 -0
  107. package/clinkx-workflows/dist/dashboard/request-admission.d.ts +31 -0
  108. package/clinkx-workflows/dist/dashboard/request-admission.js +126 -0
  109. package/clinkx-workflows/dist/dashboard/request-admission.js.map +1 -0
  110. package/clinkx-workflows/dist/dashboard/scanner.d.ts +75 -0
  111. package/clinkx-workflows/dist/dashboard/scanner.js +1110 -0
  112. package/clinkx-workflows/dist/dashboard/scanner.js.map +1 -0
  113. package/clinkx-workflows/dist/dashboard/server-factory.d.ts +50 -0
  114. package/clinkx-workflows/dist/dashboard/server-factory.js +431 -0
  115. package/clinkx-workflows/dist/dashboard/server-factory.js.map +1 -0
  116. package/clinkx-workflows/dist/dashboard/session-log-contract.d.ts +2611 -0
  117. package/clinkx-workflows/dist/dashboard/session-log-contract.js +480 -0
  118. package/clinkx-workflows/dist/dashboard/session-log-contract.js.map +1 -0
  119. package/clinkx-workflows/dist/dashboard/session-log-identity.d.ts +21 -0
  120. package/clinkx-workflows/dist/dashboard/session-log-identity.js +101 -0
  121. package/clinkx-workflows/dist/dashboard/session-log-identity.js.map +1 -0
  122. package/clinkx-workflows/dist/dashboard/session-log-journal.d.ts +43 -0
  123. package/clinkx-workflows/dist/dashboard/session-log-journal.js +522 -0
  124. package/clinkx-workflows/dist/dashboard/session-log-journal.js.map +1 -0
  125. package/clinkx-workflows/dist/dashboard/session-log-reader.d.ts +61 -0
  126. package/clinkx-workflows/dist/dashboard/session-log-reader.js +378 -0
  127. package/clinkx-workflows/dist/dashboard/session-log-reader.js.map +1 -0
  128. package/clinkx-workflows/dist/dashboard/session-log-redaction.d.ts +6 -0
  129. package/clinkx-workflows/dist/dashboard/session-log-redaction.js +367 -0
  130. package/clinkx-workflows/dist/dashboard/session-log-redaction.js.map +1 -0
  131. package/clinkx-workflows/dist/dashboard/session-log-retention.d.ts +23 -0
  132. package/clinkx-workflows/dist/dashboard/session-log-retention.js +212 -0
  133. package/clinkx-workflows/dist/dashboard/session-log-retention.js.map +1 -0
  134. package/clinkx-workflows/dist/dashboard/static-responses.d.ts +10 -0
  135. package/clinkx-workflows/dist/dashboard/static-responses.js +94 -0
  136. package/clinkx-workflows/dist/dashboard/static-responses.js.map +1 -0
  137. package/clinkx-workflows/dist/dashboard/worker-lock-observer.d.ts +28 -0
  138. package/clinkx-workflows/dist/dashboard/worker-lock-observer.js +241 -0
  139. package/clinkx-workflows/dist/dashboard/worker-lock-observer.js.map +1 -0
  140. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  141. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  142. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  143. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  144. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  145. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  146. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  147. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  148. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  149. package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  150. package/clinkx-workflows/dist/dashboard-ui/assets/index-DOkMXTfB.js +25 -0
  151. package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  152. package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  153. package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  154. package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  155. package/clinkx-workflows/dist/dashboard-ui/assets/style-ChJsn0KD.css +1 -0
  156. package/clinkx-workflows/dist/dashboard-ui/contracts/council-plan-review-input-v1.json +158 -0
  157. package/clinkx-workflows/dist/dashboard-ui/index.html +15 -0
  158. package/clinkx-workflows/dist/dashboard-ui/licenses/IBM-Plex-Mono-OFL.txt +93 -0
  159. package/clinkx-workflows/dist/dashboard-ui/licenses/IBM-Plex-Sans-OFL.txt +93 -0
  160. package/clinkx-workflows/dist/dashboard-ui/licenses/JetBrains-Mono-OFL.txt +93 -0
  161. package/clinkx-workflows/dist/dashboard-ui/licenses/NOTICE.md +32 -0
  162. package/clinkx-workflows/dist/dashboard-ui/theme-init.js +8 -0
  163. package/clinkx-workflows/dist/definition-normalizer.d.ts +19 -0
  164. package/clinkx-workflows/dist/definition-normalizer.js.map +1 -1
  165. package/clinkx-workflows/dist/delegate-validate.d.ts +78 -0
  166. package/clinkx-workflows/dist/delegate-validate.js +97 -0
  167. package/clinkx-workflows/dist/delegate-validate.js.map +1 -0
  168. package/clinkx-workflows/dist/engine.d.ts +141 -0
  169. package/clinkx-workflows/dist/engine.js +882 -28
  170. package/clinkx-workflows/dist/engine.js.map +1 -1
  171. package/clinkx-workflows/dist/errors.d.ts +2 -0
  172. package/clinkx-workflows/dist/errors.js.map +1 -1
  173. package/clinkx-workflows/dist/fingerprint.d.ts +1 -1
  174. package/clinkx-workflows/dist/fingerprint.js +1 -1
  175. package/clinkx-workflows/dist/index.d.ts +19 -0
  176. package/clinkx-workflows/dist/index.js +15 -0
  177. package/clinkx-workflows/dist/index.js.map +1 -1
  178. package/clinkx-workflows/dist/loader.d.ts +9 -3
  179. package/clinkx-workflows/dist/loader.js +92 -21
  180. package/clinkx-workflows/dist/loader.js.map +1 -1
  181. package/clinkx-workflows/dist/plan-review/admission.d.ts +160 -0
  182. package/clinkx-workflows/dist/plan-review/admission.js +290 -0
  183. package/clinkx-workflows/dist/plan-review/admission.js.map +1 -0
  184. package/clinkx-workflows/dist/plan-review/context-policy.d.ts +55 -0
  185. package/clinkx-workflows/dist/plan-review/context-policy.js +146 -0
  186. package/clinkx-workflows/dist/plan-review/context-policy.js.map +1 -0
  187. package/clinkx-workflows/dist/plan-review/contract.d.ts +763 -0
  188. package/clinkx-workflows/dist/plan-review/contract.js +289 -0
  189. package/clinkx-workflows/dist/plan-review/contract.js.map +1 -0
  190. package/clinkx-workflows/dist/plan-review/digest.d.ts +68 -0
  191. package/clinkx-workflows/dist/plan-review/digest.js +241 -0
  192. package/clinkx-workflows/dist/plan-review/digest.js.map +1 -0
  193. package/clinkx-workflows/dist/plan-review/discovery.d.ts +45 -0
  194. package/clinkx-workflows/dist/plan-review/discovery.js +36 -0
  195. package/clinkx-workflows/dist/plan-review/discovery.js.map +1 -0
  196. package/clinkx-workflows/dist/plan-review/execution-contract.d.ts +298 -0
  197. package/clinkx-workflows/dist/plan-review/execution-contract.js +194 -0
  198. package/clinkx-workflows/dist/plan-review/execution-contract.js.map +1 -0
  199. package/clinkx-workflows/dist/plan-review/expander.d.ts +48 -0
  200. package/clinkx-workflows/dist/plan-review/expander.js +243 -0
  201. package/clinkx-workflows/dist/plan-review/expander.js.map +1 -0
  202. package/clinkx-workflows/dist/plan-review/projector.d.ts +126 -0
  203. package/clinkx-workflows/dist/plan-review/projector.js +437 -0
  204. package/clinkx-workflows/dist/plan-review/projector.js.map +1 -0
  205. package/clinkx-workflows/dist/plan-review/publication.d.ts +95 -0
  206. package/clinkx-workflows/dist/plan-review/publication.js +182 -0
  207. package/clinkx-workflows/dist/plan-review/publication.js.map +1 -0
  208. package/clinkx-workflows/dist/plan-review/ranking-reducer.d.ts +34 -0
  209. package/clinkx-workflows/dist/plan-review/ranking-reducer.js +139 -0
  210. package/clinkx-workflows/dist/plan-review/ranking-reducer.js.map +1 -0
  211. package/clinkx-workflows/dist/plan-review/resume.d.ts +38 -0
  212. package/clinkx-workflows/dist/plan-review/resume.js +122 -0
  213. package/clinkx-workflows/dist/plan-review/resume.js.map +1 -0
  214. package/clinkx-workflows/dist/plan-review/rubric.d.ts +7 -0
  215. package/clinkx-workflows/dist/plan-review/rubric.js +24 -0
  216. package/clinkx-workflows/dist/plan-review/rubric.js.map +1 -0
  217. package/clinkx-workflows/dist/plan-review/run-state.d.ts +398 -0
  218. package/clinkx-workflows/dist/plan-review/run-state.js +183 -0
  219. package/clinkx-workflows/dist/plan-review/run-state.js.map +1 -0
  220. package/clinkx-workflows/dist/plan-review/runtime-policy.d.ts +74 -0
  221. package/clinkx-workflows/dist/plan-review/runtime-policy.js +186 -0
  222. package/clinkx-workflows/dist/plan-review/runtime-policy.js.map +1 -0
  223. package/clinkx-workflows/dist/plan-review/snapshot.d.ts +81 -0
  224. package/clinkx-workflows/dist/plan-review/snapshot.js +170 -0
  225. package/clinkx-workflows/dist/plan-review/snapshot.js.map +1 -0
  226. package/clinkx-workflows/dist/plan-review/stage-error.d.ts +19 -0
  227. package/clinkx-workflows/dist/plan-review/stage-error.js +20 -0
  228. package/clinkx-workflows/dist/plan-review/stage-error.js.map +1 -0
  229. package/clinkx-workflows/dist/post-processors/index.d.ts +20 -0
  230. package/clinkx-workflows/dist/post-processors/index.js +21 -0
  231. package/clinkx-workflows/dist/post-processors/index.js.map +1 -0
  232. package/clinkx-workflows/dist/prompt-budget.d.ts +7 -0
  233. package/clinkx-workflows/dist/prompt-budget.js +7 -1
  234. package/clinkx-workflows/dist/prompt-budget.js.map +1 -1
  235. package/clinkx-workflows/dist/reducers/index.js +2 -0
  236. package/clinkx-workflows/dist/reducers/index.js.map +1 -1
  237. package/clinkx-workflows/dist/run-lock-heartbeat.d.ts +2 -0
  238. package/clinkx-workflows/dist/run-lock-heartbeat.js +3 -0
  239. package/clinkx-workflows/dist/run-lock-heartbeat.js.map +1 -0
  240. package/clinkx-workflows/dist/run-summary/cards/council-plan-review.d.ts +34 -0
  241. package/clinkx-workflows/dist/run-summary/cards/council-plan-review.js +74 -0
  242. package/clinkx-workflows/dist/run-summary/cards/council-plan-review.js.map +1 -0
  243. package/clinkx-workflows/dist/run-summary/cards/index.js +5 -0
  244. package/clinkx-workflows/dist/run-summary/cards/index.js.map +1 -1
  245. package/clinkx-workflows/dist/run-summary.d.ts +13 -0
  246. package/clinkx-workflows/dist/run-summary.js +29 -1
  247. package/clinkx-workflows/dist/run-summary.js.map +1 -1
  248. package/clinkx-workflows/dist/schema.d.ts +76 -21
  249. package/clinkx-workflows/dist/schema.js +10 -1
  250. package/clinkx-workflows/dist/schema.js.map +1 -1
  251. package/clinkx-workflows/dist/seed-terms.d.ts +30 -0
  252. package/clinkx-workflows/dist/seed-terms.js +176 -0
  253. package/clinkx-workflows/dist/seed-terms.js.map +1 -0
  254. package/clinkx-workflows/dist/server.d.ts +5 -2
  255. package/clinkx-workflows/dist/server.js +82 -52
  256. package/clinkx-workflows/dist/server.js.map +1 -1
  257. package/clinkx-workflows/dist/shutdown.d.ts +18 -20
  258. package/clinkx-workflows/dist/shutdown.js +43 -61
  259. package/clinkx-workflows/dist/shutdown.js.map +1 -1
  260. package/clinkx-workflows/dist/state-schema.d.ts +38 -0
  261. package/clinkx-workflows/dist/state.d.ts +170 -3
  262. package/clinkx-workflows/dist/state.js +1275 -28
  263. package/clinkx-workflows/dist/state.js.map +1 -1
  264. package/clinkx-workflows/dist/transport.d.ts +15 -0
  265. package/clinkx-workflows/dist/transport.js +19 -10
  266. package/clinkx-workflows/dist/transport.js.map +1 -1
  267. package/clinkx-workflows/dist/types.d.ts +14 -2
  268. package/clinkx-workflows/dist/validators/council.d.ts +318 -85
  269. package/clinkx-workflows/dist/validators/council.js +52 -13
  270. package/clinkx-workflows/dist/validators/council.js.map +1 -1
  271. package/clinkx-workflows/dist/validators/index.d.ts +1 -0
  272. package/clinkx-workflows/dist/validators/index.js +3 -0
  273. package/clinkx-workflows/dist/validators/index.js.map +1 -1
  274. package/clinkx-workflows/dist/validators/plan-review.d.ts +398 -0
  275. package/clinkx-workflows/dist/validators/plan-review.js +316 -0
  276. package/clinkx-workflows/dist/validators/plan-review.js.map +1 -0
  277. package/clinkx-workflows/dist/workflow-receipt.js +188 -4
  278. package/clinkx-workflows/dist/workflow-receipt.js.map +1 -1
  279. package/clinkx-workflows/dist/workflow-tools.d.ts +48 -3
  280. package/clinkx-workflows/dist/workflow-tools.js +1402 -59
  281. package/clinkx-workflows/dist/workflow-tools.js.map +1 -1
  282. package/clinkx-workflows/dist/workspace-signature.d.ts +16 -0
  283. package/clinkx-workflows/dist/workspace-signature.js +388 -0
  284. package/clinkx-workflows/dist/workspace-signature.js.map +1 -0
  285. package/clinkx-workflows/templates/council/answer-specialist.txt +2 -1
  286. package/clinkx-workflows/templates/council/code-review-chairman.txt +13 -0
  287. package/clinkx-workflows/templates/council/code-review-cross-reviewer.txt +4 -1
  288. package/clinkx-workflows/templates/council/code-review-reviewer.txt +19 -3
  289. package/clinkx-workflows/templates/council/debug-debugger.txt +2 -1
  290. package/clinkx-workflows/templates/council/discover-analyst.txt +2 -1
  291. package/clinkx-workflows/templates/council/member.txt +2 -1
  292. package/clinkx-workflows/templates/council/plan-review-chairman.txt +68 -0
  293. package/clinkx-workflows/templates/council/plan-review-cross-reviewer.txt +51 -0
  294. package/clinkx-workflows/templates/council/plan-review-reviewer.txt +60 -0
  295. package/clinkx-workflows/templates/plan-review/chairman.txt +48 -0
  296. package/clinkx-workflows/templates/plan-review/cross-review.txt +52 -0
  297. package/clinkx-workflows/templates/plan-review/ranking.txt +16 -0
  298. package/clinkx-workflows/templates/plan-review/reviewer.txt +57 -0
  299. package/clinkx-workflows/workflows/council-answer.yaml +97 -8
  300. package/clinkx-workflows/workflows/council-code-review.yaml +103 -8
  301. package/clinkx-workflows/workflows/council-debug.yaml +97 -8
  302. package/clinkx-workflows/workflows/council-default.yaml +97 -8
  303. package/clinkx-workflows/workflows/council-discover.yaml +97 -8
  304. package/clinkx-workflows/workflows/council-plan-review.yaml +250 -0
  305. package/clinkx-workflows/workflows/plan-review-receipts.yaml +230 -0
  306. package/conf/adapters/agy.json +49 -0
  307. package/conf/adapters/claude.json +5 -0
  308. package/conf/adapters/codex-app-server.json +44 -0
  309. package/conf/adapters/codex.json +7 -2
  310. package/conf/adapters/glm.json +5 -0
  311. package/conf/adapters/grok.json +60 -0
  312. package/conf/adapters/hapi/agy.json +52 -0
  313. package/conf/adapters/hapi/claude.json +5 -0
  314. package/conf/adapters/hapi/codex.json +7 -2
  315. package/conf/adapters/hapi/glm.json +5 -0
  316. package/conf/prompts/json-codereviewer.txt +1 -0
  317. package/conf/prompts/json.txt +1 -0
  318. package/dist/activity-journal.d.ts +42 -0
  319. package/dist/activity-journal.js +381 -0
  320. package/dist/activity-journal.js.map +1 -0
  321. package/dist/allowed-roots.d.ts +53 -0
  322. package/dist/allowed-roots.js +216 -0
  323. package/dist/allowed-roots.js.map +1 -0
  324. package/dist/codex/activity-session-log.d.ts +14 -0
  325. package/dist/codex/activity-session-log.js +206 -0
  326. package/dist/codex/activity-session-log.js.map +1 -0
  327. package/dist/codex/agent-tools.d.ts +23 -0
  328. package/dist/codex/agent-tools.js +1270 -0
  329. package/dist/codex/agent-tools.js.map +1 -0
  330. package/dist/codex/app-server-client.d.ts +76 -0
  331. package/dist/codex/app-server-client.js +582 -0
  332. package/dist/codex/app-server-client.js.map +1 -0
  333. package/dist/codex/app-server-registry.d.ts +389 -0
  334. package/dist/codex/app-server-registry.js +1407 -0
  335. package/dist/codex/app-server-registry.js.map +1 -0
  336. package/dist/codex/client-types.d.ts +109 -0
  337. package/dist/codex/client-types.js +2 -0
  338. package/dist/codex/client-types.js.map +1 -0
  339. package/dist/codex/protocol.d.ts +5375 -0
  340. package/dist/codex/protocol.js +614 -0
  341. package/dist/codex/protocol.js.map +1 -0
  342. package/dist/codex/session.d.ts +263 -0
  343. package/dist/codex/session.js +466 -0
  344. package/dist/codex/session.js.map +1 -0
  345. package/dist/config.d.ts +185 -19
  346. package/dist/config.js +82 -16
  347. package/dist/config.js.map +1 -1
  348. package/dist/context-builder/anchors.d.ts +12 -0
  349. package/dist/context-builder/anchors.js +22 -0
  350. package/dist/context-builder/anchors.js.map +1 -0
  351. package/dist/context-builder/bundle.d.ts +109 -0
  352. package/dist/context-builder/bundle.js +1498 -0
  353. package/dist/context-builder/bundle.js.map +1 -0
  354. package/dist/context-builder/codemap.d.ts +22 -0
  355. package/dist/context-builder/codemap.js +675 -0
  356. package/dist/context-builder/codemap.js.map +1 -0
  357. package/dist/context-builder/config.d.ts +61 -0
  358. package/dist/context-builder/config.js +216 -0
  359. package/dist/context-builder/config.js.map +1 -0
  360. package/dist/context-builder/context-run-catalog.d.ts +17 -0
  361. package/dist/context-builder/context-run-catalog.js +99 -0
  362. package/dist/context-builder/context-run-catalog.js.map +1 -0
  363. package/dist/context-builder/delegate-packet.d.ts +219 -0
  364. package/dist/context-builder/delegate-packet.js +1190 -0
  365. package/dist/context-builder/delegate-packet.js.map +1 -0
  366. package/dist/context-builder/delegate-reconstruction.d.ts +13 -0
  367. package/dist/context-builder/delegate-reconstruction.js +220 -0
  368. package/dist/context-builder/delegate-reconstruction.js.map +1 -0
  369. package/dist/context-builder/delegate-validation.d.ts +27 -0
  370. package/dist/context-builder/delegate-validation.js +479 -0
  371. package/dist/context-builder/delegate-validation.js.map +1 -0
  372. package/dist/context-builder/delegate.d.ts +26 -0
  373. package/dist/context-builder/delegate.js +134 -0
  374. package/dist/context-builder/delegate.js.map +1 -0
  375. package/dist/context-builder/diagnostics.d.ts +36 -0
  376. package/dist/context-builder/diagnostics.js +107 -0
  377. package/dist/context-builder/diagnostics.js.map +1 -0
  378. package/dist/context-builder/discovery.d.ts +146 -0
  379. package/dist/context-builder/discovery.js +2129 -0
  380. package/dist/context-builder/discovery.js.map +1 -0
  381. package/dist/context-builder/globs.d.ts +14 -0
  382. package/dist/context-builder/globs.js +175 -0
  383. package/dist/context-builder/globs.js.map +1 -0
  384. package/dist/context-builder/handler.d.ts +41 -0
  385. package/dist/context-builder/handler.js +2590 -0
  386. package/dist/context-builder/handler.js.map +1 -0
  387. package/dist/context-builder/lifecycle-contract.d.ts +424 -0
  388. package/dist/context-builder/lifecycle-contract.js +523 -0
  389. package/dist/context-builder/lifecycle-contract.js.map +1 -0
  390. package/dist/context-builder/lifecycle-journal.d.ts +62 -0
  391. package/dist/context-builder/lifecycle-journal.js +88 -0
  392. package/dist/context-builder/lifecycle-journal.js.map +1 -0
  393. package/dist/context-builder/lifecycle-observer.d.ts +102 -0
  394. package/dist/context-builder/lifecycle-observer.js +258 -0
  395. package/dist/context-builder/lifecycle-observer.js.map +1 -0
  396. package/dist/context-builder/lifecycle-reconcile.d.ts +32 -0
  397. package/dist/context-builder/lifecycle-reconcile.js +191 -0
  398. package/dist/context-builder/lifecycle-reconcile.js.map +1 -0
  399. package/dist/context-builder/lifecycle-recovery.d.ts +2 -0
  400. package/dist/context-builder/lifecycle-recovery.js +14 -0
  401. package/dist/context-builder/lifecycle-recovery.js.map +1 -0
  402. package/dist/context-builder/path-signals.d.ts +7 -0
  403. package/dist/context-builder/path-signals.js +70 -0
  404. package/dist/context-builder/path-signals.js.map +1 -0
  405. package/dist/context-builder/paths.d.ts +30 -0
  406. package/dist/context-builder/paths.js +153 -0
  407. package/dist/context-builder/paths.js.map +1 -0
  408. package/dist/context-builder/progress.d.ts +17 -0
  409. package/dist/context-builder/progress.js +93 -0
  410. package/dist/context-builder/progress.js.map +1 -0
  411. package/dist/context-builder/receipt.d.ts +91 -0
  412. package/dist/context-builder/receipt.js +180 -0
  413. package/dist/context-builder/receipt.js.map +1 -0
  414. package/dist/context-builder/refine-handler.d.ts +14 -0
  415. package/dist/context-builder/refine-handler.js +790 -0
  416. package/dist/context-builder/refine-handler.js.map +1 -0
  417. package/dist/context-builder/review-coverage.d.ts +84 -0
  418. package/dist/context-builder/review-coverage.js +437 -0
  419. package/dist/context-builder/review-coverage.js.map +1 -0
  420. package/dist/context-builder/run-id.d.ts +7 -0
  421. package/dist/context-builder/run-id.js +31 -0
  422. package/dist/context-builder/run-id.js.map +1 -0
  423. package/dist/context-builder/schema.d.ts +669 -0
  424. package/dist/context-builder/schema.js +399 -0
  425. package/dist/context-builder/schema.js.map +1 -0
  426. package/dist/context-builder/secrets.d.ts +41 -0
  427. package/dist/context-builder/secrets.js +1651 -0
  428. package/dist/context-builder/secrets.js.map +1 -0
  429. package/dist/context-builder/selection.d.ts +56 -0
  430. package/dist/context-builder/selection.js +1111 -0
  431. package/dist/context-builder/selection.js.map +1 -0
  432. package/dist/context-builder/session-log-identity.d.ts +1 -0
  433. package/dist/context-builder/session-log-identity.js +66 -0
  434. package/dist/context-builder/session-log-identity.js.map +1 -0
  435. package/dist/context-builder/slice-range-math.d.ts +16 -0
  436. package/dist/context-builder/slice-range-math.js +137 -0
  437. package/dist/context-builder/slice-range-math.js.map +1 -0
  438. package/dist/context-builder/status.d.ts +66 -0
  439. package/dist/context-builder/status.js +160 -0
  440. package/dist/context-builder/status.js.map +1 -0
  441. package/dist/context-builder/storage.d.ts +118 -0
  442. package/dist/context-builder/storage.js +1003 -0
  443. package/dist/context-builder/storage.js.map +1 -0
  444. package/dist/context-builder/token-estimate.d.ts +19 -0
  445. package/dist/context-builder/token-estimate.js +90 -0
  446. package/dist/context-builder/token-estimate.js.map +1 -0
  447. package/dist/context-builder/versions.d.ts +29 -0
  448. package/dist/context-builder/versions.js +30 -0
  449. package/dist/context-builder/versions.js.map +1 -0
  450. package/dist/context-builder/workflow-runner.d.ts +69 -0
  451. package/dist/context-builder/workflow-runner.js +1051 -0
  452. package/dist/context-builder/workflow-runner.js.map +1 -0
  453. package/dist/context-builder/write-watchdog.d.ts +14 -0
  454. package/dist/context-builder/write-watchdog.js +38 -0
  455. package/dist/context-builder/write-watchdog.js.map +1 -0
  456. package/dist/dashboard-adapter-catalog.d.ts +9 -0
  457. package/dist/dashboard-adapter-catalog.js +46 -0
  458. package/dist/dashboard-adapter-catalog.js.map +1 -0
  459. package/dist/dashboard-cli.d.ts +79 -0
  460. package/dist/dashboard-cli.js +585 -0
  461. package/dist/dashboard-cli.js.map +1 -0
  462. package/dist/dashboard-companion/client.d.ts +36 -0
  463. package/dist/dashboard-companion/client.js +210 -0
  464. package/dist/dashboard-companion/client.js.map +1 -0
  465. package/dist/dashboard-companion/config.d.ts +8 -0
  466. package/dist/dashboard-companion/config.js +52 -0
  467. package/dist/dashboard-companion/config.js.map +1 -0
  468. package/dist/dashboard-companion/http-proxy.d.ts +10 -0
  469. package/dist/dashboard-companion/http-proxy.js +234 -0
  470. package/dist/dashboard-companion/http-proxy.js.map +1 -0
  471. package/dist/dashboard-companion/protocol.d.ts +80 -0
  472. package/dist/dashboard-companion/protocol.js +455 -0
  473. package/dist/dashboard-companion/protocol.js.map +1 -0
  474. package/dist/dashboard-companion/standalone-assets.d.ts +12 -0
  475. package/dist/dashboard-companion/standalone-assets.js +72 -0
  476. package/dist/dashboard-companion/standalone-assets.js.map +1 -0
  477. package/dist/dashboard-companion/standalone-entry.d.ts +1 -0
  478. package/dist/dashboard-companion/standalone-entry.js +17 -0
  479. package/dist/dashboard-companion/standalone-entry.js.map +1 -0
  480. package/dist/dashboard-gateway/config.d.ts +9 -0
  481. package/dist/dashboard-gateway/config.js +44 -0
  482. package/dist/dashboard-gateway/config.js.map +1 -0
  483. package/dist/dashboard-gateway/index.d.ts +11 -0
  484. package/dist/dashboard-gateway/index.js +755 -0
  485. package/dist/dashboard-gateway/index.js.map +1 -0
  486. package/dist/dashboard-gateway/main.d.ts +1 -0
  487. package/dist/dashboard-gateway/main.js +25 -0
  488. package/dist/dashboard-gateway/main.js.map +1 -0
  489. package/dist/dashboard-pairing-token.d.ts +1 -0
  490. package/dist/dashboard-pairing-token.js +5 -0
  491. package/dist/dashboard-pairing-token.js.map +1 -0
  492. package/dist/elaris/client.d.ts +73 -0
  493. package/dist/elaris/client.js +825 -0
  494. package/dist/elaris/client.js.map +1 -0
  495. package/dist/elaris/config.d.ts +18 -0
  496. package/dist/elaris/config.js +75 -0
  497. package/dist/elaris/config.js.map +1 -0
  498. package/dist/elaris/errors.d.ts +41 -0
  499. package/dist/elaris/errors.js +205 -0
  500. package/dist/elaris/errors.js.map +1 -0
  501. package/dist/elaris/github-repo.d.ts +18 -0
  502. package/dist/elaris/github-repo.js +89 -0
  503. package/dist/elaris/github-repo.js.map +1 -0
  504. package/dist/elaris/index.d.ts +10 -0
  505. package/dist/elaris/index.js +11 -0
  506. package/dist/elaris/index.js.map +1 -0
  507. package/dist/elaris/redact.d.ts +89 -0
  508. package/dist/elaris/redact.js +423 -0
  509. package/dist/elaris/redact.js.map +1 -0
  510. package/dist/elaris/run-id.d.ts +5 -0
  511. package/dist/elaris/run-id.js +20 -0
  512. package/dist/elaris/run-id.js.map +1 -0
  513. package/dist/elaris/status.d.ts +45 -0
  514. package/dist/elaris/status.js +122 -0
  515. package/dist/elaris/status.js.map +1 -0
  516. package/dist/elaris/tool-schemas.d.ts +314 -0
  517. package/dist/elaris/tool-schemas.js +208 -0
  518. package/dist/elaris/tool-schemas.js.map +1 -0
  519. package/dist/elaris/tools.d.ts +73 -0
  520. package/dist/elaris/tools.js +1065 -0
  521. package/dist/elaris/tools.js.map +1 -0
  522. package/dist/elaris/types.d.ts +1541 -0
  523. package/dist/elaris/types.js +252 -0
  524. package/dist/elaris/types.js.map +1 -0
  525. package/dist/errors.d.ts +16 -0
  526. package/dist/errors.js +19 -0
  527. package/dist/errors.js.map +1 -1
  528. package/dist/handler.d.ts +4 -0
  529. package/dist/handler.js +10 -1
  530. package/dist/handler.js.map +1 -1
  531. package/dist/index.d.ts +2 -1
  532. package/dist/index.js +13 -3
  533. package/dist/index.js.map +1 -1
  534. package/dist/local-clink-backend.d.ts +3 -0
  535. package/dist/local-clink-backend.js +4 -0
  536. package/dist/local-clink-backend.js.map +1 -1
  537. package/dist/package-info.d.ts +2 -0
  538. package/dist/package-info.js +18 -0
  539. package/dist/package-info.js.map +1 -0
  540. package/dist/parsers/codex-jsonl.js +48 -3
  541. package/dist/parsers/codex-jsonl.js.map +1 -1
  542. package/dist/parsers/extract.d.ts +10 -1
  543. package/dist/parsers/extract.js +11 -3
  544. package/dist/parsers/extract.js.map +1 -1
  545. package/dist/paths.d.ts +8 -0
  546. package/dist/paths.js +31 -43
  547. package/dist/paths.js.map +1 -1
  548. package/dist/pipeline.d.ts +51 -3
  549. package/dist/pipeline.js +264 -47
  550. package/dist/pipeline.js.map +1 -1
  551. package/dist/run-dir.d.ts +24 -0
  552. package/dist/run-dir.js +201 -2
  553. package/dist/run-dir.js.map +1 -1
  554. package/dist/runner.d.ts +13 -0
  555. package/dist/runner.js +184 -15
  556. package/dist/runner.js.map +1 -1
  557. package/dist/safety.d.ts +4 -4
  558. package/dist/safety.js +4 -4
  559. package/dist/schema.d.ts +7 -7
  560. package/dist/server.d.ts +2 -0
  561. package/dist/server.js +396 -24
  562. package/dist/server.js.map +1 -1
  563. package/dist/session-log-stream.d.ts +18 -0
  564. package/dist/session-log-stream.js +319 -0
  565. package/dist/session-log-stream.js.map +1 -0
  566. package/package.json +47 -5
  567. package/conf/adapters/gemini.json +0 -44
  568. package/conf/adapters/hapi/gemini.json +0 -47
  569. package/dist/artifacts.d.ts +0 -9
  570. package/dist/artifacts.js +0 -24
  571. package/dist/artifacts.js.map +0 -1
  572. package/dist/parsers/gemini-json.d.ts +0 -2
  573. package/dist/parsers/gemini-json.js +0 -72
  574. package/dist/parsers/gemini-json.js.map +0 -1
@@ -1,15 +1,27 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
- import { readFileSync } from "node:fs";
4
- import { mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { constants as fsConstants, readFileSync } from "node:fs";
4
+ import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { dirname, basename, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { hostname } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { WorkflowError } from "./errors.js";
9
9
  import { buildFidelityMetadata, } from "./fidelity.js";
10
10
  import { fingerprintsMatch, generateFingerprint, generateWorkflowFingerprint, } from "./fingerprint.js";
11
- import { DEFAULT_RETAIN_CANCELLED_DAYS, DEFAULT_RETAIN_DEBUG_DAYS, DEFAULT_RETAIN_FAILED_DAYS, deriveAllowedRoots, getArtifactsDir, getChildConfigPath, getChildMaxConcurrent, getChildMaxOutputBytes, getChildMaxPromptBytes, getChildMaxResponseChars, getLogLevel, getRetentionConfig, getRunsDir, getRunStateDir, getStateDir, resolveWorkspaceRoot, } from "./config.js";
11
+ import { parseWorkflowContext, } from "./context-preflight.js";
12
+ import { isLegalContextBuilderLifecycleTransition, parseContextBuilderLifecycleStateV1, } from "./context-builder-lifecycle-contract.js";
13
+ import { PlanReviewRunStateSchema, } from "./plan-review/run-state.js";
14
+ import { CouncilPlanReviewErrorSchema, createPlanReviewPublicationError, serializePlanReviewResult, writeSerializedPlanReviewResultSidecar, } from "./plan-review/publication.js";
15
+ import { isPlanReviewPublicationEligible, projectPlanReviewRun, } from "./plan-review/projector.js";
16
+ import { isPlanReviewRunState } from "./plan-review/admission.js";
17
+ import { restorePlanReviewRun } from "./plan-review/resume.js";
18
+ import { PlanReviewStageError } from "./plan-review/stage-error.js";
19
+ import { DEFAULT_RETAIN_ASYNC_DAYS, DEFAULT_RETAIN_CANCELLED_DAYS, DEFAULT_RETAIN_DEBUG_DAYS, DEFAULT_RETAIN_FAILED_DAYS, DEFAULT_RETAIN_SUCCEEDED_DAYS, deriveAllowedRoots, getArtifactsDir, getChildConfigPath, getChildMaxConcurrent, getChildMaxOutputBytes, getChildMaxPromptBytes, getChildMaxResponseChars, getLogLevel, getRetentionConfig, getRunsDir, getRunStateDir, getStateDir, resolveWorkspaceRoot, } from "./config.js";
12
20
  import { logger } from "./logger.js";
21
+ import { resolveOpenedDescriptorPath } from "./contained-file.js";
22
+ import { deriveWorkflowRunGeneration } from "./dashboard/generation.js";
23
+ import { removeSessionLogGeneration } from "./dashboard/session-log-retention.js";
24
+ import { createSessionLogGeneration, readSessionLogGeneration, } from "./dashboard/session-log-identity.js";
13
25
  const STATE_FILENAME = "state.json";
14
26
  const STATE_TMP_FILENAME = "state.json.tmp";
15
27
  const WORKFLOW_SNAPSHOT_FILENAME = "workflow.snapshot.json";
@@ -17,8 +29,162 @@ const INPUTS_SNAPSHOT_FILENAME = "inputs.snapshot.json";
17
29
  const CHILD_CONFIG_DIRNAME = "child-config";
18
30
  const CHILD_CONFIG_SNAPSHOT_FILENAME = "snapshot.json";
19
31
  const RUN_LOCK_FILENAME = "run.lock";
32
+ /**
33
+ * Producer-side refresh cadence for a held `run.lock`'s mtime. Mirrors
34
+ * `dashboard/auth/credential-store.ts`'s LOCK_HEARTBEAT_MS.
35
+ */
36
+ const RUN_LOCK_HEARTBEAT_MS = 10_000;
37
+ /**
38
+ * Reader-side staleness floor the shipped cadence is designed against; the
39
+ * dashboard observer derives its own threshold from the declared interval.
40
+ * Re-exported for tests and cross-module documentation only — nothing in this
41
+ * module consults it, because run-lock CONTROL decisions
42
+ * (`classifyRunLockSync`, `reapRunCancelled`, pruning) must never depend on
43
+ * mtime freshness.
44
+ */
45
+ export { RUN_LOCK_HEARTBEAT_STALE_FLOOR_MS } from "./run-lock-heartbeat.js";
46
+ /** Same cap as the dashboard observer's contained-read `maxBytes`. */
47
+ const RUN_LOCK_MAX_BYTES = 4_096;
20
48
  const OUTPUT_INLINE_THRESHOLD_BYTES = 10 * 1024;
49
+ /**
50
+ * Cap for error strings persisted inline in state.json (run-level `error` and
51
+ * per-call `errorMessage`). Unlike call outputs (which spill to sidecar files
52
+ * via persistOutput), errors always live inline, and provider stderr/exception
53
+ * chains can be arbitrarily large — keep a bounded head plus an explicit
54
+ * truncation marker recording how many bytes were cut.
55
+ */
56
+ const MAX_PERSISTED_ERROR_BYTES = 16 * 1024;
21
57
  const DAY_MS = 24 * 60 * 60 * 1000;
58
+ const STALE_RUNNING_RECOVERY_ERROR = "Recovered stale running run during resume";
59
+ /**
60
+ * Run-level error written by reconcileStaleRunDirectories. Deliberately
61
+ * distinct from STALE_RUNNING_RECOVERY_ERROR (the resume-path recovery string)
62
+ * so the two reconcile paths remain distinguishable in persisted state.
63
+ */
64
+ export const STALE_RUN_RECONCILE_ERROR = "Reconciled stale running run: no live worker process";
65
+ const STALE_RUN_RECONCILE_CALL_ERROR = "Reconciled: worker terminated before call completion";
66
+ function resolvePlanReviewChairmanCallId(definition) {
67
+ const stage = definition.stages.find((entry) => entry.id === "chairman");
68
+ const callId = stage?.calls[0]?.id;
69
+ if (callId === undefined || stage?.calls.length !== 1) {
70
+ throw new Error("Persisted plan-review workflow definition must have exactly one chairman call");
71
+ }
72
+ return callId;
73
+ }
74
+ async function readPersistedPlanReviewChairmanCallId(runDir, state) {
75
+ const workflowSnapshotPath = join(runDir, state.workflowSnapshotPath ?? WORKFLOW_SNAPSHOT_FILENAME);
76
+ await verifySnapshotHash(workflowSnapshotPath, state.workflowSnapshotHash, "workflow snapshot");
77
+ const definition = await readJsonFile(workflowSnapshotPath);
78
+ return resolvePlanReviewChairmanCallId(definition);
79
+ }
80
+ function withPlanReviewPublication(state, publication) {
81
+ const nextState = structuredClone(state);
82
+ nextState.plan_review_state = PlanReviewRunStateSchema.parse({
83
+ ...nextState.plan_review_state,
84
+ publication,
85
+ });
86
+ return nextState;
87
+ }
88
+ /**
89
+ * C-9 chain-internal finalizer. It never calls a public store writer or
90
+ * enqueueWrite. The committed marker is persisted on the pre-terminal state,
91
+ * after the sidecar and before the caller commits the terminal state.
92
+ */
93
+ const finalizePlanReviewTerminalState = async ({ runDir, previousState, intendedState, intent, chairmanCallId, serializedResult, }) => {
94
+ const existing = previousState.plan_review_state?.publication;
95
+ if (existing?.committed === true) {
96
+ return { state: withPlanReviewPublication(intendedState, existing) };
97
+ }
98
+ if (!isPlanReviewPublicationEligible(previousState)) {
99
+ return { state: intendedState };
100
+ }
101
+ let restored;
102
+ try {
103
+ const resolvedChairmanCallId = chairmanCallId ??
104
+ await readPersistedPlanReviewChairmanCallId(runDir, previousState);
105
+ restored = await restorePlanReviewRun(runDir, previousState.plan_review_state, resolvedChairmanCallId);
106
+ }
107
+ catch (err) {
108
+ const detail = err instanceof Error ? err.message : String(err);
109
+ const stageError = err instanceof PlanReviewStageError
110
+ ? err
111
+ : new PlanReviewStageError("execution_contract", detail, "internal_error");
112
+ const finalStatus = intent.status === "succeeded" ? "failed" : intent.status;
113
+ const failed = structuredClone(intendedState);
114
+ failed.status = finalStatus;
115
+ if (intent.status === "succeeded") {
116
+ failed.error = `Plan-review persisted-state verification failed: ${detail}`;
117
+ }
118
+ failed.plan_review_error = intent.planReviewError ??
119
+ CouncilPlanReviewErrorSchema.parse({
120
+ schema_version: "council-plan-review-error-v1",
121
+ run_id: failed.runId,
122
+ execution_status: finalStatus,
123
+ stage: stageError.stage,
124
+ error: {
125
+ reason: stageError.reason,
126
+ detail: detail.slice(0, 2_048),
127
+ },
128
+ });
129
+ return {
130
+ state: failed,
131
+ ...(intent.status === "succeeded"
132
+ ? { throwAfterCommit: stageError }
133
+ : {}),
134
+ };
135
+ }
136
+ try {
137
+ const projected = serializedResult ?? serializePlanReviewResult(await projectPlanReviewRun({
138
+ runDir,
139
+ state: previousState,
140
+ contract: restored.contract,
141
+ execution_contract_digest: restored.execution_contract_digest,
142
+ }));
143
+ const marker = await writeSerializedPlanReviewResultSidecar(runDir, projected);
144
+ // Durable crash boundary: a process death after this write still leaves a
145
+ // contained, marker-addressable result even though terminal intent has not
146
+ // yet been committed.
147
+ const intermediate = withPlanReviewPublication(previousState, marker);
148
+ validateStrictStateSubtrees(intermediate, join(runDir, STATE_FILENAME));
149
+ await writeJsonAtomic(join(runDir, STATE_FILENAME), intermediate);
150
+ return { state: withPlanReviewPublication(intendedState, marker) };
151
+ }
152
+ catch (err) {
153
+ const detail = err instanceof Error ? err.message : String(err);
154
+ const finalStatus = intent.status === "succeeded" ? "failed" : intent.status;
155
+ const failed = withPlanReviewPublication(intendedState, { committed: false });
156
+ failed.status = finalStatus;
157
+ if (intent.status === "succeeded") {
158
+ failed.error = `Plan-review publication failed: ${detail}`;
159
+ }
160
+ failed.plan_review_error = createPlanReviewPublicationError(failed.runId, finalStatus, err instanceof PlanReviewStageError ? err.reason : "internal_error", detail);
161
+ return {
162
+ state: failed,
163
+ ...(intent.status === "succeeded"
164
+ ? {
165
+ throwAfterCommit: new PlanReviewStageError("publication", failed.error, failed.plan_review_error.error.reason),
166
+ }
167
+ : {}),
168
+ };
169
+ }
170
+ };
171
+ class RunLockOwnershipUnknownError extends Error {
172
+ constructor(lockPath, detail) {
173
+ super(`Run lock ownership is unknown at ${lockPath}; refusing to reclaim it: ${detail}`);
174
+ this.name = "RunLockOwnershipUnknownError";
175
+ }
176
+ }
177
+ /**
178
+ * Constructed ONLY after a complete, valid lock record has been read and its
179
+ * token proves the lock is no longer ours. Never for open/stat/read/parse/type/
180
+ * size errors — those stay "ordinary" and must not revoke unlink authority.
181
+ */
182
+ class RunLockOwnershipLostError extends Error {
183
+ constructor(lockPath, detail) {
184
+ super(`Run lock ownership was lost at ${lockPath}: ${detail}`);
185
+ this.name = "RunLockOwnershipLostError";
186
+ }
187
+ }
22
188
  export function classifyRunDirectory(runDirPath) {
23
189
  try {
24
190
  const state = readRunStateSync(runDirPath);
@@ -34,8 +200,15 @@ export function classifyRunDirectory(runDirPath) {
34
200
  return "failed";
35
201
  case "cancelled":
36
202
  return "cancelled";
37
- case "succeeded":
38
- return state.debugKeepArtifacts === true ? "debug_kept" : "unknown";
203
+ case "succeeded": {
204
+ if ((state.debugKeepArtifacts !== undefined && typeof state.debugKeepArtifacts !== "boolean") ||
205
+ (state.async !== undefined && typeof state.async !== "boolean")) {
206
+ return "unknown";
207
+ }
208
+ return state.debugKeepArtifacts === true
209
+ ? "debug_kept"
210
+ : (state.async === true ? "succeeded_async" : "succeeded_sync");
211
+ }
39
212
  case "running":
40
213
  case "pending":
41
214
  return "running_or_pending";
@@ -91,9 +264,40 @@ export async function pruneRunDirectories(runsRootDir, options) {
91
264
  result.skipped += 1;
92
265
  continue;
93
266
  }
267
+ const identity = await readSessionLogGeneration(runDirPath);
268
+ let sessionLogGeneration;
269
+ if (identity.kind === "available") {
270
+ sessionLogGeneration = identity.sessionLogGeneration;
271
+ }
272
+ else if (identity.kind === "legacy") {
273
+ try {
274
+ sessionLogGeneration = await admitWorkflowRunGeneration(runsRootDir, runDirPath, entry.name);
275
+ }
276
+ catch {
277
+ logger.warn({ run_id: entry.name }, "legacy workflow session-log generation could not be admitted before parent pruning");
278
+ }
279
+ }
280
+ else {
281
+ logger.warn({ run_id: entry.name }, "workflow session-log identity was unsafe before parent pruning");
282
+ }
94
283
  await rm(runDirPath, { recursive: true, force: true });
95
284
  pruned = true;
96
285
  result.pruned += 1;
286
+ if (sessionLogGeneration !== undefined) {
287
+ try {
288
+ const cleanup = await removeSessionLogGeneration({
289
+ rootDir: join(dirname(resolve(runsRootDir)), "session-logs"),
290
+ family: "workflow",
291
+ generation: sessionLogGeneration,
292
+ });
293
+ if (cleanup.kind === "unsafe" || cleanup.kind === "unavailable") {
294
+ logger.warn({ run_id: entry.name, cleanup: cleanup.kind }, "workflow parent pruned but exact session-log cleanup was unavailable");
295
+ }
296
+ }
297
+ catch {
298
+ logger.warn({ run_id: entry.name }, "workflow parent pruned but exact session-log cleanup failed");
299
+ }
300
+ }
97
301
  logger.info({
98
302
  run_dir: runDirPath,
99
303
  classification,
@@ -115,8 +319,64 @@ export async function pruneRunDirectories(runsRootDir, options) {
115
319
  logger.info({ runs_root_dir: runsRootDir, ...result }, "workflow run directory pruning complete");
116
320
  return result;
117
321
  }
322
+ function isTerminalRunStatus(status) {
323
+ return status === "succeeded" || status === "failed" || status === "cancelled";
324
+ }
325
+ function isPlanReviewRun(state) {
326
+ // Presence-based typed detection (see isPlanReviewRunState): legacy runs
327
+ // persisted under the pre-rename name "council-plan-review" keep routing
328
+ // through the plan-review terminal-intent protocol, while runs of the
329
+ // GENERIC council-plan-review workflow never do.
330
+ return isPlanReviewRunState(state);
331
+ }
332
+ function isStaleRunningRecoveryCheckpoint(state) {
333
+ return state.status === "failed" &&
334
+ state.failureClass === "transient" &&
335
+ state.error === STALE_RUNNING_RECOVERY_ERROR &&
336
+ state.plan_review_error === undefined &&
337
+ state.stages.every((stage) => stage.calls.every((call) => call.state !== "running"));
338
+ }
339
+ function canReplaceStaleRunningRecovery(state, intent) {
340
+ return isStaleRunningRecoveryCheckpoint(state) &&
341
+ intent.status === "failed" &&
342
+ intent.planReviewError?.stage === "execution_contract";
343
+ }
344
+ function applyPlanReviewTerminalIntent(state, intent) {
345
+ const replacingRecovery = canReplaceStaleRunningRecovery(state, intent);
346
+ if (isTerminalRunStatus(state.status) && !replacingRecovery) {
347
+ return undefined;
348
+ }
349
+ const nextState = structuredClone(state);
350
+ nextState.status = intent.status;
351
+ nextState.completedAt = intent.completedAt;
352
+ nextState.error = intent.error;
353
+ if (intent.replaceFailureClass || replacingRecovery) {
354
+ nextState.failureClass = intent.failureClass;
355
+ }
356
+ if (intent.planReviewError === undefined) {
357
+ nextState.plan_review_error = undefined;
358
+ }
359
+ else {
360
+ const planReviewError = CouncilPlanReviewErrorSchema.parse(intent.planReviewError);
361
+ if (planReviewError.run_id !== state.runId) {
362
+ throw new Error(`plan_review_error.run_id ${planReviewError.run_id} does not match run ${state.runId}`);
363
+ }
364
+ if (planReviewError.execution_status !== intent.status) {
365
+ throw new Error(`plan_review_error.execution_status ${planReviewError.execution_status} does not match terminal intent ${intent.status}`);
366
+ }
367
+ nextState.plan_review_error = planReviewError;
368
+ }
369
+ return nextState;
370
+ }
118
371
  export class WorkflowStateStore {
119
372
  runDir;
373
+ /** Portable journal identity; absent only for a legacy or failed-open run. */
374
+ sessionLogGeneration;
375
+ /**
376
+ * Warn-only duplicate-replay signal detected at create(); undefined on
377
+ * resume and when no permanently-failed identical sibling was found.
378
+ */
379
+ duplicateOfFailedRun;
120
380
  definition;
121
381
  variables;
122
382
  hooks;
@@ -126,22 +386,49 @@ export class WorkflowStateStore {
126
386
  debugKeepArtifacts;
127
387
  writeChain = Promise.resolve();
128
388
  lockHeld = true;
389
+ planReviewTerminalResult;
390
+ lockToken;
391
+ heartbeat;
392
+ heartbeatInFlight;
393
+ heartbeatError;
129
394
  constructor(options) {
130
395
  this.runDir = options.runDir;
396
+ this.sessionLogGeneration = options.sessionLogGeneration;
397
+ this.duplicateOfFailedRun = options.duplicateOfFailedRun;
131
398
  this.definition = options.definition;
132
399
  this.variables = options.variables;
133
400
  this.state = options.state;
134
401
  this.resume = options.resume;
135
402
  this.debugKeepArtifacts = options.debugKeepArtifacts;
136
403
  this.lockPath = join(options.runDir, RUN_LOCK_FILENAME);
404
+ this.lockToken = options.lockToken;
405
+ this.startHeartbeat();
137
406
  this.hooks = {
138
407
  onRunStateChange: async (event) => {
139
408
  await this.enqueueWrite(async () => {
409
+ const cappedError = capPersistedError(event.error);
410
+ if (isPlanReviewRun(this.state) && isTerminalRunStatus(event.state)) {
411
+ await this.finalizePlanReviewTerminalIntent({
412
+ status: event.state,
413
+ completedAt: event.completedAt,
414
+ error: cappedError,
415
+ failureClass: event.failureClass,
416
+ replaceFailureClass: true,
417
+ planReviewError: event.planReviewError,
418
+ });
419
+ return;
420
+ }
421
+ if (isPlanReviewRun(this.state) &&
422
+ isTerminalRunStatus(this.state.status) &&
423
+ !(event.state === "running" &&
424
+ isStaleRunningRecoveryCheckpoint(this.state))) {
425
+ return;
426
+ }
140
427
  const nextState = structuredClone(this.state);
141
428
  nextState.status = event.state;
142
429
  nextState.startedAt = event.startedAt ?? nextState.startedAt;
143
430
  nextState.completedAt = event.completedAt;
144
- nextState.error = event.error;
431
+ nextState.error = cappedError;
145
432
  nextState.failureClass = event.failureClass;
146
433
  this.state = nextState;
147
434
  await this.writeState();
@@ -166,10 +453,13 @@ export class WorkflowStateStore {
166
453
  const callEntry = getCallEntry(nextState, event.stageId, event.callId);
167
454
  callEntry.state = event.state;
168
455
  callEntry.retryCount = event.retryCount;
456
+ if (event.state === "running") {
457
+ callEntry.attemptsConsumed = Math.max(callEntry.attemptsConsumed ?? 0, event.attempt);
458
+ }
169
459
  callEntry.startedAt = event.startedAt;
170
460
  callEntry.completedAt = event.completedAt;
171
461
  callEntry.durationMs = event.durationMs;
172
- callEntry.errorMessage = event.error;
462
+ callEntry.errorMessage = capPersistedError(event.error);
173
463
  callEntry.failureClass = event.failureClass;
174
464
  callEntry.childDebugMetadata = event.childDebugMetadata;
175
465
  callEntry.contextProducing = callEntry.contextProducing ?? false;
@@ -205,10 +495,19 @@ export class WorkflowStateStore {
205
495
  const recycled = await prepareRunDirForCreate(runDir, options.runId);
206
496
  let lockHeld = false;
207
497
  try {
208
- await acquireRunLock(runDir);
498
+ const lock = await acquireRunLock(runDir, { declareHeartbeat: true });
209
499
  lockHeld = true;
500
+ const sessionLogGeneration = await createSessionLogGeneration(runDir);
210
501
  const workflowSnapshotHash = await writeJsonSnapshot(join(runDir, WORKFLOW_SNAPSHOT_FILENAME), options.definition);
211
502
  const inputsSnapshotHash = await writeJsonSnapshot(join(runDir, INPUTS_SNAPSHOT_FILENAME), options.variables);
503
+ const duplicateOfFailedRun = await detectDuplicateReplay(runDir, workflowSnapshotHash, inputsSnapshotHash);
504
+ if (duplicateOfFailedRun !== undefined) {
505
+ logger.warn({
506
+ run_id: options.runId,
507
+ duplicate_of_run_id: duplicateOfFailedRun.runId,
508
+ duplicate_completed_at: duplicateOfFailedRun.completedAt,
509
+ }, "run replays workflow and inputs identical to a permanently-failed prior run");
510
+ }
212
511
  const childConfigPath = getChildConfigPath();
213
512
  const resumable = childConfigPath != null;
214
513
  const fingerprintData = resumable
@@ -242,15 +541,21 @@ export class WorkflowStateStore {
242
541
  callId: call.id,
243
542
  state: "pending",
244
543
  retryCount: 0,
544
+ attemptsConsumed: 0,
245
545
  contextProducing: isContextProducing(options.definition, stage.id, call.id),
246
546
  })),
247
547
  })),
248
548
  variables: options.variables,
549
+ initialContextPolicy: options.initialContextPolicy,
249
550
  debugKeepArtifacts,
551
+ async: options.async ?? false,
250
552
  };
251
553
  await writeJsonAtomic(join(runDir, STATE_FILENAME), state);
252
- if (recycled) {
554
+ if (recycled.recycled) {
253
555
  await rm(`${runDir}.recycled`, { recursive: true, force: true }).catch(() => { });
556
+ if (recycled.retiredSessionLogGeneration !== undefined) {
557
+ await cleanupWorkflowSessionLogGeneration(dirname(runDir), options.runId, recycled.retiredSessionLogGeneration);
558
+ }
254
559
  }
255
560
  return new WorkflowStateStore({
256
561
  runDir,
@@ -259,13 +564,16 @@ export class WorkflowStateStore {
259
564
  state,
260
565
  resume: {},
261
566
  debugKeepArtifacts,
567
+ lockToken: lock.token,
568
+ sessionLogGeneration,
569
+ duplicateOfFailedRun,
262
570
  });
263
571
  }
264
572
  catch (error) {
265
573
  if (lockHeld) {
266
574
  await safeUnlink(join(runDir, RUN_LOCK_FILENAME));
267
575
  }
268
- if (recycled) {
576
+ if (recycled.recycled) {
269
577
  try {
270
578
  await rm(runDir, { recursive: true, force: true });
271
579
  await rename(`${runDir}.recycled`, runDir);
@@ -283,9 +591,19 @@ export class WorkflowStateStore {
283
591
  static async resume(options) {
284
592
  const runDir = options.runDir ?? getRunStateDir(options.runId);
285
593
  const debugKeepArtifacts = options.debugKeepArtifacts ?? false;
286
- await acquireRunLock(runDir);
594
+ const lock = await acquireRunLock(runDir, { declareHeartbeat: true });
287
595
  try {
288
596
  const state = await readRunState(runDir);
597
+ const identity = await readSessionLogGeneration(runDir);
598
+ if (identity.kind === "unavailable") {
599
+ throw new Error(`Run ${options.runId} has an unsafe session-log identity`);
600
+ }
601
+ if (isPlanReviewRun(state) &&
602
+ (state.status === "failed" ||
603
+ state.status === "cancelled" ||
604
+ state.status === "succeeded")) {
605
+ throw new Error(`Run ${options.runId} is terminal (${state.status}) and cannot be resumed; start a new run instead.`);
606
+ }
289
607
  if (!state.resumable) {
290
608
  throw new Error(`Run ${options.runId} is non-resumable because it was started from ambient child config`);
291
609
  }
@@ -361,6 +679,10 @@ export class WorkflowStateStore {
361
679
  state: recovered,
362
680
  resume,
363
681
  debugKeepArtifacts,
682
+ lockToken: lock.token,
683
+ ...(identity.kind === "available"
684
+ ? { sessionLogGeneration: identity.sessionLogGeneration }
685
+ : {}),
364
686
  });
365
687
  }
366
688
  catch (error) {
@@ -371,8 +693,54 @@ export class WorkflowStateStore {
371
693
  get runState() {
372
694
  return structuredClone(this.state);
373
695
  }
696
+ /**
697
+ * Open a new generic resume attempt before any context preflight or provider
698
+ * setup. Provider-call attempt counters remain untouched; only the run-level
699
+ * terminal envelope is cleared so a pre-dispatch failure/cancellation can be
700
+ * persisted as the outcome of this resume attempt.
701
+ *
702
+ * Plan-review stale-run recovery has its own terminal-intent protocol and is
703
+ * deliberately left unchanged here.
704
+ */
705
+ async beginResumeAttempt() {
706
+ await this.enqueueWrite(async () => {
707
+ if (isPlanReviewRun(this.state))
708
+ return;
709
+ const nextState = structuredClone(this.state);
710
+ if (isTerminalRunStatus(this.state.status)) {
711
+ nextState.previousTerminal = {
712
+ status: this.state.status,
713
+ ...(this.state.completedAt === undefined
714
+ ? {}
715
+ : { completedAt: this.state.completedAt }),
716
+ ...(this.state.error === undefined ? {} : { error: this.state.error }),
717
+ ...(this.state.failureClass === undefined
718
+ ? {}
719
+ : { failureClass: this.state.failureClass }),
720
+ };
721
+ }
722
+ nextState.status = "pending";
723
+ nextState.completedAt = undefined;
724
+ nextState.error = undefined;
725
+ nextState.failureClass = undefined;
726
+ this.state = nextState;
727
+ await this.writeState();
728
+ });
729
+ }
374
730
  async markCancelled(reason = "Workflow cancelled") {
375
731
  await this.enqueueWrite(async () => {
732
+ if (isPlanReviewRun(this.state)) {
733
+ await this.finalizePlanReviewTerminalIntent({
734
+ status: "cancelled",
735
+ completedAt: new Date().toISOString(),
736
+ error: reason,
737
+ replaceFailureClass: false,
738
+ });
739
+ return;
740
+ }
741
+ if (this.state.status !== "pending" && this.state.status !== "running") {
742
+ return;
743
+ }
376
744
  const nextState = structuredClone(this.state);
377
745
  nextState.status = "cancelled";
378
746
  nextState.completedAt = new Date().toISOString();
@@ -381,14 +749,308 @@ export class WorkflowStateStore {
381
749
  await this.writeState();
382
750
  });
383
751
  }
752
+ /**
753
+ * Idempotently mark the run failed. Only applies when the current status is
754
+ * non-terminal (pending/running) so it never clobbers a richer engine-emitted
755
+ * terminal status (succeeded/failed/cancelled).
756
+ */
757
+ async markFailed(error, failureClass, planReviewError) {
758
+ const cappedError = capPersistedError(error);
759
+ await this.enqueueWrite(async () => {
760
+ if (isPlanReviewRun(this.state)) {
761
+ await this.finalizePlanReviewTerminalIntent({
762
+ status: "failed",
763
+ completedAt: new Date().toISOString(),
764
+ error: cappedError,
765
+ failureClass,
766
+ replaceFailureClass: failureClass !== undefined,
767
+ planReviewError,
768
+ });
769
+ return;
770
+ }
771
+ if (this.state.status !== "pending" && this.state.status !== "running") {
772
+ return;
773
+ }
774
+ const nextState = structuredClone(this.state);
775
+ nextState.status = "failed";
776
+ nextState.completedAt = new Date().toISOString();
777
+ nextState.error = cappedError;
778
+ if (failureClass !== undefined) {
779
+ nextState.failureClass = failureClass;
780
+ }
781
+ this.state = nextState;
782
+ await this.writeState();
783
+ });
784
+ }
384
785
  async flush() {
385
786
  await this.writeChain;
386
787
  }
788
+ async commitPreflightContext({ variables, workflow_context, }) {
789
+ await this.enqueueWrite(async () => {
790
+ const lifecycle = this.state.context_builder_lifecycle;
791
+ if (lifecycle !== undefined) {
792
+ if ((lifecycle.status !== "succeeded" && lifecycle.status !== "partial") ||
793
+ lifecycle.phase !== "complete" ||
794
+ lifecycle.artifact_run_id !== workflow_context.context_builder.source_run_id) {
795
+ throw new Error("workflow_context cannot commit before a matching successful lifecycle completes");
796
+ }
797
+ }
798
+ const committedWorkflowContext = lifecycle === undefined
799
+ ? workflow_context
800
+ : {
801
+ ...workflow_context,
802
+ context_builder: {
803
+ ...workflow_context.context_builder,
804
+ lifecycle: structuredClone(lifecycle),
805
+ },
806
+ };
807
+ const inputsSnapshotPath = this.state.inputsSnapshotPath ?? INPUTS_SNAPSHOT_FILENAME;
808
+ const inputsSnapshotHash = await writeJsonSnapshot(join(this.runDir, inputsSnapshotPath), variables);
809
+ const nextState = structuredClone(this.state);
810
+ nextState.inputsSnapshotPath = inputsSnapshotPath;
811
+ nextState.inputsSnapshotHash = inputsSnapshotHash;
812
+ nextState.variables = variables;
813
+ nextState.workflow_context = structuredClone(committedWorkflowContext);
814
+ this.variables = variables;
815
+ this.state = nextState;
816
+ await this.writeState();
817
+ try {
818
+ await writeJsonAtomic(join(getStateDir(), "workflow-contexts", `${this.state.runId}.json`), committedWorkflowContext);
819
+ }
820
+ catch (error) {
821
+ logger.warn({ runId: this.state.runId, err: error }, "failed to write workflow context provenance");
822
+ }
823
+ });
824
+ }
825
+ async observeContextBuilderLifecycleTransition(transition) {
826
+ const previous = transition.previous === null
827
+ ? null
828
+ : parseContextBuilderLifecycleStateV1(transition.previous);
829
+ const snapshot = parseContextBuilderLifecycleStateV1(transition.snapshot);
830
+ if (snapshot.operation_kind !== "workflow_preflight") {
831
+ throw new Error("workflow state accepts only workflow_preflight lifecycle snapshots");
832
+ }
833
+ if (previous !== null && !isLegalContextBuilderLifecycleTransition(previous, snapshot)) {
834
+ throw new Error("illegal workflow context-builder lifecycle transition");
835
+ }
836
+ await this.enqueueWrite(async () => {
837
+ const current = this.state.context_builder_lifecycle;
838
+ if (previous !== null) {
839
+ if (current === undefined || !lifecycleSnapshotsEqual(current, previous)) {
840
+ throw new Error("workflow context-builder lifecycle predecessor mismatch");
841
+ }
842
+ }
843
+ const nextState = structuredClone(this.state);
844
+ nextState.context_builder_lifecycle = structuredClone(snapshot);
845
+ this.state = nextState;
846
+ await this.writeState();
847
+ });
848
+ }
849
+ /**
850
+ * Persist the plan-review aux subtree (A-5).
851
+ *
852
+ * Called immediately after `create()` and BEFORE any seat can be dispatched.
853
+ * It cannot be a `create()` argument in the runtime path: the subtree binds
854
+ * the canonical snapshot's hash, and the snapshot can only be materialized
855
+ * into a run directory that `create()` itself makes — and the plan requires a
856
+ * snapshot/digest failure to be a POST-state `failed` run, not a pre-state
857
+ * reject. `create()` still validates+persists the subtree when a caller
858
+ * already has one (e.g. a rebuild), so both entry points are strict.
859
+ */
860
+ async commitPlanReviewState(state) {
861
+ const validated = PlanReviewRunStateSchema.parse(state);
862
+ await this.enqueueWrite(async () => {
863
+ const nextState = structuredClone(this.state);
864
+ nextState.plan_review_state = validated;
865
+ this.state = nextState;
866
+ await this.writeState();
867
+ });
868
+ }
869
+ /**
870
+ * Record that the plan-review context policy was actually enforced at the
871
+ * preflight seam, together with the post-filtered copied paths (A-7).
872
+ *
873
+ * Committed before stage dispatch so a resumed run replays the same enforced
874
+ * exclusion instead of re-deriving it from caller variables.
875
+ */
876
+ async commitPlanReviewContextProvenance(provenance) {
877
+ await this.enqueueWrite(async () => {
878
+ const current = this.state.plan_review_state;
879
+ if (current === undefined) {
880
+ return;
881
+ }
882
+ const nextState = structuredClone(this.state);
883
+ nextState.plan_review_state = PlanReviewRunStateSchema.parse({
884
+ ...current,
885
+ context_provenance: provenance,
886
+ });
887
+ this.state = nextState;
888
+ await this.writeState();
889
+ });
890
+ }
891
+ /** Persisted-state terminal projection used only by the engine's opt-in policy. */
892
+ async projectPlanReviewTerminalResult() {
893
+ if (this.planReviewTerminalResult !== undefined) {
894
+ return this.planReviewTerminalResult;
895
+ }
896
+ const state = this.runState;
897
+ const restored = await restorePlanReviewRun(this.runDir, state.plan_review_state, resolvePlanReviewChairmanCallId(this.definition));
898
+ const envelope = await projectPlanReviewRun({
899
+ runDir: this.runDir,
900
+ state,
901
+ contract: restored.contract,
902
+ execution_contract_digest: restored.execution_contract_digest,
903
+ });
904
+ this.planReviewTerminalResult = serializePlanReviewResult(envelope);
905
+ return this.planReviewTerminalResult;
906
+ }
907
+ /**
908
+ * Refresh this store's own `run.lock` mtime so a reader that cannot verify our
909
+ * PID namespace (a containerized or bind-mounted dashboard) has portable
910
+ * liveness evidence. Observation only — nothing in this module reads the
911
+ * mtime, and no control path consults it.
912
+ */
913
+ startHeartbeat() {
914
+ this.heartbeat = setInterval(() => {
915
+ // Single-flight: never overlap beats. Skip this tick if we no longer hold
916
+ // the lock, a prior beat latched an error, or a beat is still running. The
917
+ // callback must NOT await — a slow fstat/read must not stall the interval.
918
+ if (!this.lockHeld ||
919
+ this.heartbeatError != null ||
920
+ this.heartbeatInFlight != null) {
921
+ return;
922
+ }
923
+ const beat = this.beatRunLock()
924
+ .catch((error) => {
925
+ // Losing liveness REPORTING must never lose the run: latch and log, and
926
+ // never let this reach enqueueWrite. This deliberately diverges from
927
+ // credential-store's assertHealthy(), where the lock protects a shared
928
+ // file; here it only decorates a dashboard observation.
929
+ if (error instanceof RunLockOwnershipLostError) {
930
+ // PROVEN ownership loss (a complete valid record whose token is not
931
+ // ours): revoke unlink authority BEFORE latching/logging so close()
932
+ // cannot delete a lock a different owner now holds.
933
+ this.lockHeld = false;
934
+ }
935
+ // ENOENT / EACCES / EIO / ELOOP / non-regular-file / oversized /
936
+ // malformed JSON / invalid record / short read stay "ordinary" and DO
937
+ // NOT clear lockHeld. That is safe ONLY under the confirmed
938
+ // SINGLE-PRODUCER topology: no other writer holds this exact lock, so a
939
+ // transient read failure is not evidence of a foreign owner and ENOENT
940
+ // is our own recycled/removed path, not a takeover. A future
941
+ // multi-producer change MUST revisit this asymmetry.
942
+ //
943
+ // Latching is PERMANENT for this run: a single ordinary transient
944
+ // failure (e.g. a rejected utimes / EIO) halts THIS run's heartbeat
945
+ // with NO retry — retry was deliberately omitted as over-engineering.
946
+ // A foreign-host dashboard may therefore age a live-but-transiently-
947
+ // erroring run to `orphaned` and never self-correct until the run ends.
948
+ // That is fail-safe: it only stops a liveness DECORATION, never mutates
949
+ // run state and never fails the run.
950
+ this.heartbeatError = error instanceof Error ? error : new Error(String(error));
951
+ logger.warn({ run_dir: this.runDir, err: this.heartbeatError.message }, "workflow run lock heartbeat stopped");
952
+ })
953
+ .finally(() => {
954
+ // Identity guard: only an obsolete completion of the CURRENT in-flight
955
+ // beat clears the slot; a newer promise is never clobbered.
956
+ if (this.heartbeatInFlight === beat) {
957
+ this.heartbeatInFlight = undefined;
958
+ }
959
+ });
960
+ this.heartbeatInFlight = beat;
961
+ }, RUN_LOCK_HEARTBEAT_MS);
962
+ this.heartbeat.unref();
963
+ }
964
+ /**
965
+ * One descriptor-bound heartbeat beat: open the lock O_NOFOLLOW read-only,
966
+ * fstat + bounded positional read through the SAME handle, verify it is still
967
+ * our token, then utimes that still-open handle. Throws
968
+ * `RunLockOwnershipLostError` only on a complete valid record whose token
969
+ * differs; every other failure is an ordinary Error.
970
+ */
971
+ async beatRunLock() {
972
+ if (!this.lockHeld || this.heartbeatError != null)
973
+ return;
974
+ const handle = await open(this.lockPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
975
+ let propagating;
976
+ try {
977
+ const stats = await handle.stat();
978
+ if (!stats.isFile()) {
979
+ throw new Error(`run.lock at ${this.lockPath} is not a regular file`);
980
+ }
981
+ if (stats.size > RUN_LOCK_MAX_BYTES) {
982
+ throw new Error(`run.lock at ${this.lockPath} exceeds ${RUN_LOCK_MAX_BYTES} bytes`);
983
+ }
984
+ const size = Number(stats.size);
985
+ const buffer = Buffer.allocUnsafe(size);
986
+ let offset = 0;
987
+ while (offset < size) {
988
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
989
+ if (bytesRead === 0) {
990
+ throw new Error(`run.lock at ${this.lockPath} ended prematurely after ${offset} of ${size} bytes`);
991
+ }
992
+ offset += bytesRead;
993
+ }
994
+ const value = JSON.parse(buffer.toString("utf-8"));
995
+ if (!isRunLockRecord(value)) {
996
+ throw new Error(`run.lock at ${this.lockPath} is not a valid lock record`);
997
+ }
998
+ if (typeof value.token !== "string") {
999
+ // A present-but-mistyped token is a corrupt/invalid record, NOT proven
1000
+ // ownership loss: treat it as an ordinary heartbeat error so it does not
1001
+ // revoke unlink authority. Under single-producer this producer always
1002
+ // writes a string token; this only guards hostile/corrupt lock bodies.
1003
+ throw new Error(`run.lock token is not a string at ${this.lockPath}`);
1004
+ }
1005
+ if (value.token !== this.lockToken) {
1006
+ throw new RunLockOwnershipLostError(this.lockPath, "lock token no longer matches this store");
1007
+ }
1008
+ const now = new Date();
1009
+ await handle.utimes(now, now);
1010
+ }
1011
+ catch (error) {
1012
+ propagating = error;
1013
+ throw error;
1014
+ }
1015
+ finally {
1016
+ try {
1017
+ await handle.close();
1018
+ }
1019
+ catch (closeError) {
1020
+ // Swallow a close failure only when another error is already
1021
+ // propagating; otherwise surface it.
1022
+ if (propagating === undefined)
1023
+ throw closeError;
1024
+ }
1025
+ }
1026
+ }
1027
+ async stopHeartbeat() {
1028
+ if (this.heartbeat !== undefined) {
1029
+ clearInterval(this.heartbeat);
1030
+ this.heartbeat = undefined;
1031
+ }
1032
+ // At most one beat is ever active. Await it, swallowing the already-latched
1033
+ // failure; do NOT loop.
1034
+ const inFlight = this.heartbeatInFlight;
1035
+ if (inFlight !== undefined) {
1036
+ await inFlight.catch(() => { });
1037
+ }
1038
+ }
387
1039
  async close() {
388
1040
  await this.flush();
1041
+ // Ordering is load-bearing in both directions. Not before flush(): draining
1042
+ // the write chain can take time, and a lock aging while the run is provably
1043
+ // alive reads `orphaned` to a foreign host. Not after the unlink: a beat
1044
+ // racing the unlink would utimes a deleted — or recycled — path.
1045
+ await this.stopHeartbeat();
389
1046
  if (this.state.status === "succeeded" && !this.debugKeepArtifacts) {
390
- this.lockHeld = false;
391
- await rm(this.runDir, { recursive: true, force: true });
1047
+ // Successful runs remain observable until their retention window expires.
1048
+ // Release ownership so classification/pruning and immediate run-ID recycling
1049
+ // can safely operate on both synchronous and asynchronous successes.
1050
+ if (this.lockHeld) {
1051
+ await safeUnlink(this.lockPath);
1052
+ this.lockHeld = false;
1053
+ }
392
1054
  return;
393
1055
  }
394
1056
  if (this.lockHeld) {
@@ -401,12 +1063,34 @@ export class WorkflowStateStore {
401
1063
  const nextState = structuredClone(this.state);
402
1064
  const callEntry = getCallEntry(nextState, event.stageId, event.callId);
403
1065
  callEntry.retryCount = event.nextRetryCount;
404
- callEntry.errorMessage = event.error;
1066
+ callEntry.errorMessage = capPersistedError(event.error);
405
1067
  callEntry.failureClass = event.failureClass;
406
1068
  this.state = nextState;
407
1069
  await this.writeState();
408
1070
  });
409
1071
  }
1072
+ /** Runs inside the caller's existing queue task; never enqueue from here. */
1073
+ async finalizePlanReviewTerminalIntent(intent) {
1074
+ const previousState = this.state;
1075
+ const intended = applyPlanReviewTerminalIntent(previousState, intent);
1076
+ if (intended === undefined) {
1077
+ return;
1078
+ }
1079
+ const finalized = await finalizePlanReviewTerminalState({
1080
+ runDir: this.runDir,
1081
+ previousState,
1082
+ intendedState: intended,
1083
+ intent,
1084
+ chairmanCallId: resolvePlanReviewChairmanCallId(this.definition),
1085
+ serializedResult: this.planReviewTerminalResult,
1086
+ });
1087
+ validateStrictStateSubtrees(finalized.state, join(this.runDir, STATE_FILENAME));
1088
+ this.state = finalized.state;
1089
+ await this.writeState();
1090
+ if (finalized.throwAfterCommit !== undefined) {
1091
+ throw finalized.throwAfterCommit;
1092
+ }
1093
+ }
410
1094
  async enqueueWrite(task) {
411
1095
  const next = this.writeChain.then(task, task);
412
1096
  this.writeChain = next.catch(() => { });
@@ -420,7 +1104,7 @@ async function prepareRunDirForCreate(runDir, runId) {
420
1104
  await mkdir(dirname(runDir), { recursive: true });
421
1105
  try {
422
1106
  await mkdir(runDir);
423
- return false;
1107
+ return { recycled: false };
424
1108
  }
425
1109
  catch (error) {
426
1110
  if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
@@ -428,7 +1112,7 @@ async function prepareRunDirForCreate(runDir, runId) {
428
1112
  }
429
1113
  }
430
1114
  const recycled = await recycleSucceededRunDir(runDir);
431
- if (recycled) {
1115
+ if (recycled.recycled) {
432
1116
  const recycledPath = `${runDir}.recycled`;
433
1117
  try {
434
1118
  await mkdir(runDir);
@@ -445,7 +1129,7 @@ async function prepareRunDirForCreate(runDir, runId) {
445
1129
  }
446
1130
  throw error;
447
1131
  }
448
- return true;
1132
+ return recycled;
449
1133
  }
450
1134
  throw duplicateRunDirError(runId);
451
1135
  }
@@ -453,6 +1137,14 @@ async function pruneRunDirectoriesBeforeCreate(runDir) {
453
1137
  const runsRoot = getRunsDir();
454
1138
  if (dirname(runDir) !== runsRoot)
455
1139
  return;
1140
+ // Reconcile before pruning so a stale running run terminalized here becomes
1141
+ // prunable by a later create once past its retention window.
1142
+ try {
1143
+ await reconcileStaleRunDirectories(runsRoot);
1144
+ }
1145
+ catch (error) {
1146
+ logger.warn({ runs_root_dir: runsRoot, err: error instanceof Error ? error.message : String(error) }, "stale workflow run reconcile failed before create");
1147
+ }
456
1148
  try {
457
1149
  await pruneRunDirectories(runsRoot, getRetentionConfig());
458
1150
  }
@@ -460,11 +1152,89 @@ async function pruneRunDirectoriesBeforeCreate(runDir) {
460
1152
  logger.warn({ runs_root_dir: runsRoot, err: error instanceof Error ? error.message : String(error) }, "workflow run directory pruning failed before create");
461
1153
  }
462
1154
  }
1155
+ /**
1156
+ * Bounds the per-create duplicate-replay scan: at most this many sibling run
1157
+ * directories have their persisted state read. Best-effort by design — a runs
1158
+ * root larger than the cap simply yields a possibly-incomplete signal.
1159
+ */
1160
+ const MAX_DUPLICATE_REPLAY_SCAN_DIRS = 1_000;
1161
+ /**
1162
+ * Warn-only duplicate-replay detection (failure investigation F6): find a
1163
+ * sibling run whose persisted `workflowSnapshotHash` AND `inputsSnapshotHash`
1164
+ * both equal the run being created, and whose status is terminal `failed`
1165
+ * with `failureClass:"permanent"`. Cancelled runs and transient failures are
1166
+ * deliberately excluded; with multiple matches the most recent `completedAt`
1167
+ * wins.
1168
+ *
1169
+ * WARN-ONLY, never a gate: identical hashes do NOT imply an identical outcome.
1170
+ * The hashes cover only the workflow definition and resolved input variables —
1171
+ * not workspace content, which may have changed since the prior run — and
1172
+ * providers are nondeterministic, so an exact replay can legitimately succeed.
1173
+ * This function therefore never throws and never blocks creation: any scan or
1174
+ * per-sibling read error degrades silently (or with a warn log) to "no
1175
+ * signal", and admission, mutex, cap, and state-creation behavior are
1176
+ * unchanged.
1177
+ */
1178
+ async function detectDuplicateReplay(runDir, workflowSnapshotHash, inputsSnapshotHash) {
1179
+ try {
1180
+ const runsRoot = dirname(runDir);
1181
+ const selfName = basename(runDir);
1182
+ let entries;
1183
+ try {
1184
+ entries = await readdir(runsRoot, { withFileTypes: true });
1185
+ }
1186
+ catch {
1187
+ return undefined;
1188
+ }
1189
+ let best;
1190
+ let bestCompletedAtMs = Number.NEGATIVE_INFINITY;
1191
+ let examined = 0;
1192
+ for (const entry of entries) {
1193
+ if (!entry.isDirectory() || entry.name === selfName)
1194
+ continue;
1195
+ if (examined >= MAX_DUPLICATE_REPLAY_SCAN_DIRS)
1196
+ break;
1197
+ examined += 1;
1198
+ let state;
1199
+ try {
1200
+ state = readRunStateSync(join(runsRoot, entry.name));
1201
+ }
1202
+ catch {
1203
+ // Unreadable siblings are skipped silently — mid-write, corrupt, or
1204
+ // foreign directories must never affect the new run.
1205
+ continue;
1206
+ }
1207
+ if (state.status !== "failed" ||
1208
+ state.failureClass !== "permanent" ||
1209
+ state.workflowSnapshotHash !== workflowSnapshotHash ||
1210
+ state.inputsSnapshotHash !== inputsSnapshotHash) {
1211
+ continue;
1212
+ }
1213
+ const parsedCompletedAt = state.completedAt !== undefined
1214
+ ? Date.parse(state.completedAt)
1215
+ : Number.NaN;
1216
+ const orderKey = Number.isFinite(parsedCompletedAt)
1217
+ ? parsedCompletedAt
1218
+ : Number.NEGATIVE_INFINITY;
1219
+ if (best === undefined || orderKey > bestCompletedAtMs) {
1220
+ best = { runId: state.runId, completedAt: state.completedAt };
1221
+ bestCompletedAtMs = orderKey;
1222
+ }
1223
+ }
1224
+ return best;
1225
+ }
1226
+ catch (error) {
1227
+ logger.warn({ run_dir: runDir, err: error instanceof Error ? error.message : String(error) }, "duplicate-replay scan failed; proceeding without signal");
1228
+ return undefined;
1229
+ }
1230
+ }
463
1231
  function normalizePruneOptions(options) {
464
1232
  return {
465
1233
  retainFailedDays: normalizeRetentionDays(options?.retainFailedDays, DEFAULT_RETAIN_FAILED_DAYS),
466
1234
  retainCancelledDays: normalizeRetentionDays(options?.retainCancelledDays, DEFAULT_RETAIN_CANCELLED_DAYS),
467
1235
  retainDebugDays: normalizeRetentionDays(options?.retainDebugDays, DEFAULT_RETAIN_DEBUG_DAYS),
1236
+ retainAsyncDays: normalizeRetentionDays(options?.retainAsyncDays, DEFAULT_RETAIN_ASYNC_DAYS),
1237
+ retainSucceededDays: normalizeRetentionDays(options?.retainSucceededDays, DEFAULT_RETAIN_SUCCEEDED_DAYS),
468
1238
  };
469
1239
  }
470
1240
  function normalizeRetentionDays(value, fallback) {
@@ -478,6 +1248,10 @@ function retentionDaysForClassification(classification, options) {
478
1248
  return options.retainCancelledDays;
479
1249
  case "debug_kept":
480
1250
  return options.retainDebugDays;
1251
+ case "succeeded_async":
1252
+ return options.retainAsyncDays;
1253
+ case "succeeded_sync":
1254
+ return options.retainSucceededDays;
481
1255
  case "active_locked":
482
1256
  case "running_or_pending":
483
1257
  case "unknown":
@@ -493,6 +1267,44 @@ async function tryAcquirePruneLock(runDirPath) {
493
1267
  return false;
494
1268
  }
495
1269
  }
1270
+ async function admitWorkflowRunGeneration(runsRootDir, runDirPath, runId) {
1271
+ const requestedRoot = resolve(runsRootDir);
1272
+ const requestedRun = resolve(runDirPath);
1273
+ if (requestedRun !== join(requestedRoot, runId)) {
1274
+ throw new Error("workflow run identity is not contained");
1275
+ }
1276
+ const rootLinked = await lstat(requestedRoot);
1277
+ if (rootLinked.isSymbolicLink() || !rootLinked.isDirectory()) {
1278
+ throw new Error("workflow runs root identity is unsafe");
1279
+ }
1280
+ const canonicalRoot = await realpath(requestedRoot);
1281
+ const canonicalRun = join(canonicalRoot, runId);
1282
+ const flags = fsConstants.O_RDONLY |
1283
+ (fsConstants.O_DIRECTORY ?? 0) |
1284
+ (fsConstants.O_NOFOLLOW ?? 0);
1285
+ const rootHandle = await open(canonicalRoot, flags);
1286
+ let runHandle;
1287
+ try {
1288
+ runHandle = await open(canonicalRun, flags);
1289
+ const rootStats = await rootHandle.stat({ bigint: true });
1290
+ const runStats = await runHandle.stat({ bigint: true });
1291
+ if (!rootStats.isDirectory() ||
1292
+ !runStats.isDirectory() ||
1293
+ rootStats.nlink === 0n ||
1294
+ runStats.nlink === 0n ||
1295
+ BigInt(rootLinked.dev) !== rootStats.dev ||
1296
+ BigInt(rootLinked.ino) !== rootStats.ino ||
1297
+ await resolveOpenedDescriptorPath(rootHandle.fd, canonicalRoot) !== canonicalRoot ||
1298
+ await resolveOpenedDescriptorPath(runHandle.fd, canonicalRun) !== canonicalRun) {
1299
+ throw new Error("workflow run identity changed during admission");
1300
+ }
1301
+ return deriveWorkflowRunGeneration(runId, rootStats, runStats);
1302
+ }
1303
+ finally {
1304
+ await runHandle?.close().catch(() => undefined);
1305
+ await rootHandle.close().catch(() => undefined);
1306
+ }
1307
+ }
496
1308
  async function getRunDirectoryAgeMs(runDirPath) {
497
1309
  const completedAt = await readRunCompletedAt(runDirPath);
498
1310
  if (completedAt != null) {
@@ -522,8 +1334,85 @@ function readRunStateSync(runDir) {
522
1334
  if (state.state_version !== 1) {
523
1335
  throw new Error(`Unsupported state_version ${String(state.state_version)} in ${statePath}`);
524
1336
  }
1337
+ validateStrictStateSubtrees(state, statePath);
525
1338
  return state;
526
1339
  }
1340
+ /**
1341
+ * Strictly validate optional state subtrees that are resume or lifecycle
1342
+ * authorities. Older state_version:1 files may omit either subtree.
1343
+ *
1344
+ * The rest of RunState is still validated only by `state_version`. These are
1345
+ * exceptions because resume derives execution bindings from plan_review_state,
1346
+ * while lifecycle is a strict bounded H0 contract and must never acquire
1347
+ * arbitrary producer fields.
1348
+ */
1349
+ function validateStrictStateSubtrees(state, statePath) {
1350
+ if (state.workflow_context !== undefined) {
1351
+ try {
1352
+ state = {
1353
+ ...state,
1354
+ workflow_context: parseWorkflowContext(state.workflow_context),
1355
+ };
1356
+ }
1357
+ catch (error) {
1358
+ throw new Error(`Invalid workflow_context in ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
1359
+ }
1360
+ }
1361
+ let parsedLifecycle;
1362
+ if (state.context_builder_lifecycle !== undefined) {
1363
+ try {
1364
+ parsedLifecycle = parseContextBuilderLifecycleStateV1(state.context_builder_lifecycle);
1365
+ }
1366
+ catch (error) {
1367
+ throw new Error(`Invalid context_builder_lifecycle in ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
1368
+ }
1369
+ }
1370
+ const nestedLifecycle = state.workflow_context?.context_builder.lifecycle;
1371
+ let parsedNestedLifecycle;
1372
+ if (nestedLifecycle !== undefined) {
1373
+ try {
1374
+ parsedNestedLifecycle = parseContextBuilderLifecycleStateV1(nestedLifecycle);
1375
+ }
1376
+ catch (error) {
1377
+ throw new Error(`Invalid workflow_context.context_builder.lifecycle in ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
1378
+ }
1379
+ }
1380
+ if (state.workflow_context !== undefined) {
1381
+ const sourceRunId = state.workflow_context.context_builder.source_run_id;
1382
+ for (const lifecycle of [parsedLifecycle, parsedNestedLifecycle]) {
1383
+ if (lifecycle !== undefined &&
1384
+ ((lifecycle.status !== "succeeded" && lifecycle.status !== "partial") ||
1385
+ lifecycle.phase !== "complete" ||
1386
+ lifecycle.artifact_run_id !== sourceRunId)) {
1387
+ throw new Error(`Inconsistent workflow context lifecycle in ${statePath}`);
1388
+ }
1389
+ }
1390
+ if (parsedLifecycle !== undefined &&
1391
+ parsedNestedLifecycle !== undefined &&
1392
+ !lifecycleSnapshotsEqual(parsedLifecycle, parsedNestedLifecycle)) {
1393
+ throw new Error(`Inconsistent workflow context lifecycle in ${statePath}`);
1394
+ }
1395
+ }
1396
+ if (state.plan_review_state !== undefined) {
1397
+ const parsed = PlanReviewRunStateSchema.safeParse(state.plan_review_state);
1398
+ if (!parsed.success) {
1399
+ throw new Error(`Invalid plan_review_state in ${statePath}: ${parsed.error.issues
1400
+ .map((issue) => `${issue.path.join(".") || "plan_review_state"}: ${issue.message}`)
1401
+ .join("; ")}`);
1402
+ }
1403
+ }
1404
+ if (state.plan_review_error !== undefined) {
1405
+ const parsed = CouncilPlanReviewErrorSchema.safeParse(state.plan_review_error);
1406
+ if (!parsed.success) {
1407
+ throw new Error(`Invalid plan_review_error in ${statePath}: ${parsed.error.issues
1408
+ .map((issue) => `${issue.path.join(".") || "plan_review_error"}: ${issue.message}`)
1409
+ .join("; ")}`);
1410
+ }
1411
+ }
1412
+ }
1413
+ function lifecycleSnapshotsEqual(left, right) {
1414
+ return JSON.stringify(left) === JSON.stringify(right);
1415
+ }
527
1416
  function classifyRunLockSync(lockPath) {
528
1417
  try {
529
1418
  const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
@@ -540,11 +1429,275 @@ function classifyRunLockSync(lockPath) {
540
1429
  return code === "ENOENT" ? "stale_or_absent" : "unknown";
541
1430
  }
542
1431
  }
1432
+ export async function readRunStateForStatus(runId) {
1433
+ const runDir = getRunStateDir(runId);
1434
+ let state;
1435
+ try {
1436
+ state = await readRunState(runDir);
1437
+ }
1438
+ catch (error) {
1439
+ const code = error.code;
1440
+ if (code === "ENOENT") {
1441
+ return null;
1442
+ }
1443
+ throw error;
1444
+ }
1445
+ const lockStatus = classifyRunLockSync(join(runDir, RUN_LOCK_FILENAME));
1446
+ return {
1447
+ state,
1448
+ lock: lockStatus,
1449
+ };
1450
+ }
1451
+ /**
1452
+ * Reap a run that has no live worker (orphaned/stale/prior-process) by writing a
1453
+ * terminal "cancelled" state and unlinking its lock. Used by cancel_workflow when
1454
+ * the run is not in this process's in-memory cancel registry.
1455
+ *
1456
+ * The run.lock is the single source of truth for ownership: this acquires the
1457
+ * lock itself (reusing acquireRunLock, which atomically creates the lock and
1458
+ * reclaims a stale same-host/dead-PID lock) BEFORE mutating, closing the TOCTOU
1459
+ * where a concurrent resume_workflow could grab the lock between a caller's
1460
+ * status read and the reap write.
1461
+ *
1462
+ * Returns:
1463
+ * - "locked" — a LIVE lock blocks acquisition (held by a running worker, or a
1464
+ * same-process blocking run); nothing is mutated.
1465
+ * - "ownership_unknown" — the lock is malformed/unreadable; nothing is mutated
1466
+ * or reclaimed.
1467
+ * - "skipped" — lock acquired but the run is already terminal (not
1468
+ * pending/running); the lock is released and nothing is mutated.
1469
+ * The caller re-reads to report already_terminal.
1470
+ * - "cancelled" — lock acquired and run was pending/running; terminal
1471
+ * "cancelled" state is written and the lock removed.
1472
+ *
1473
+ * NOTE: This intentionally does NOT use WorkflowStateStore.resume() (which
1474
+ * recovers + re-locks the run — the wrong intent here).
1475
+ */
1476
+ export async function reapRunCancelled(runId) {
1477
+ const runDir = getRunStateDir(runId);
1478
+ try {
1479
+ await acquireRunLock(runDir);
1480
+ }
1481
+ catch (error) {
1482
+ if (error instanceof RunLockOwnershipUnknownError) {
1483
+ return "ownership_unknown";
1484
+ }
1485
+ // A live lock holder (running worker or same-process blocking run) prevents
1486
+ // acquisition; a truly stale/dead lock would have been reclaimed instead.
1487
+ return "locked";
1488
+ }
1489
+ const lockPath = join(runDir, RUN_LOCK_FILENAME);
1490
+ let lockHeld = true;
1491
+ try {
1492
+ const state = await readRunState(runDir);
1493
+ if (state.status !== "pending" && state.status !== "running") {
1494
+ return "skipped";
1495
+ }
1496
+ let nextState = structuredClone(state);
1497
+ if (isPlanReviewRun(state)) {
1498
+ const intent = {
1499
+ status: "cancelled",
1500
+ completedAt: new Date().toISOString(),
1501
+ error: "Reaped: no live worker process",
1502
+ replaceFailureClass: false,
1503
+ };
1504
+ const intended = applyPlanReviewTerminalIntent(state, intent);
1505
+ if (intended === undefined) {
1506
+ return "skipped";
1507
+ }
1508
+ const finalized = await finalizePlanReviewTerminalState({
1509
+ runDir,
1510
+ previousState: state,
1511
+ intendedState: intended,
1512
+ intent,
1513
+ });
1514
+ nextState = finalized.state;
1515
+ validateStrictStateSubtrees(nextState, join(runDir, STATE_FILENAME));
1516
+ }
1517
+ else {
1518
+ nextState.status = "cancelled";
1519
+ nextState.completedAt = new Date().toISOString();
1520
+ nextState.error = "Reaped: no live worker process";
1521
+ }
1522
+ await writeJsonAtomic(join(runDir, STATE_FILENAME), nextState);
1523
+ await safeUnlink(lockPath);
1524
+ lockHeld = false;
1525
+ return "cancelled";
1526
+ }
1527
+ finally {
1528
+ if (lockHeld) {
1529
+ await safeUnlink(lockPath);
1530
+ }
1531
+ }
1532
+ }
1533
+ /**
1534
+ * Terminalize run directories frozen as running/pending with no live worker.
1535
+ *
1536
+ * A worker that dies between state writes (e.g. during a retry backoff delay,
1537
+ * where applyRetryUpdate has already persisted errorMessage/failureClass while
1538
+ * the call state stays "running") leaves the run persisted as `running`
1539
+ * forever: the only other reconcile lives in WorkflowStateStore.resume(),
1540
+ * which non-resumable runs never reach, and pruning skips running_or_pending
1541
+ * directories. This scan converts such runs to terminal `failed` so status
1542
+ * projection and retention can move on. No new persisted status is introduced;
1543
+ * STALE_RUN_RECONCILE_ERROR is the distinct terminal representation.
1544
+ *
1545
+ * Liveness authority is classifyRunLockSync's same-host PID probe only:
1546
+ * a run is touched solely when its lock is "stale_or_absent" (missing lock or
1547
+ * same-host dead PID). "held" (live PID, or ANY foreign-host lock) and
1548
+ * "unknown" (malformed lock) are skipped entirely, and no decision here
1549
+ * consults heartbeat mtime freshness. TOCTOU-safety mirrors reapRunCancelled:
1550
+ * acquire the run.lock (no heartbeat declaration, like other reap
1551
+ * acquisitions), re-read state under the lock, re-verify non-terminal, write
1552
+ * once, then release. Idempotent: reconciled runs are terminal `failed` and
1553
+ * count as skipped_terminal on re-scan.
1554
+ */
1555
+ export async function reconcileStaleRunDirectories(runsRootDir) {
1556
+ const result = {
1557
+ scanned: 0,
1558
+ reconciled: 0,
1559
+ skipped_held: 0,
1560
+ skipped_terminal: 0,
1561
+ errors: 0,
1562
+ };
1563
+ let entries;
1564
+ try {
1565
+ entries = await readdir(runsRootDir, { withFileTypes: true });
1566
+ }
1567
+ catch (error) {
1568
+ const code = error.code;
1569
+ if (code !== "ENOENT") {
1570
+ result.errors += 1;
1571
+ logger.warn({ runs_root_dir: runsRootDir, err: error instanceof Error ? error.message : String(error) }, "failed to read workflow runs directory for stale-run reconcile");
1572
+ }
1573
+ return result;
1574
+ }
1575
+ for (const entry of entries) {
1576
+ if (!entry.isDirectory()) {
1577
+ continue;
1578
+ }
1579
+ const runDirPath = join(runsRootDir, entry.name);
1580
+ result.scanned += 1;
1581
+ let state;
1582
+ try {
1583
+ state = readRunStateSync(runDirPath);
1584
+ }
1585
+ catch (error) {
1586
+ result.errors += 1;
1587
+ logger.warn({ run_dir: runDirPath, err: error instanceof Error ? error.message : String(error) }, "skipping run with unreadable state during stale-run reconcile");
1588
+ continue;
1589
+ }
1590
+ if (state.status !== "running" && state.status !== "pending") {
1591
+ result.skipped_terminal += 1;
1592
+ continue;
1593
+ }
1594
+ const lockStatus = classifyRunLockSync(join(runDirPath, RUN_LOCK_FILENAME));
1595
+ if (lockStatus !== "stale_or_absent") {
1596
+ // "held" (live same-host PID or any foreign-host lock) and "unknown"
1597
+ // (malformed lock) are both untouchable — never infer liveness beyond
1598
+ // the same-host PID probe.
1599
+ result.skipped_held += 1;
1600
+ continue;
1601
+ }
1602
+ try {
1603
+ const outcome = await reconcileStaleRun(runDirPath);
1604
+ result[outcome] += 1;
1605
+ }
1606
+ catch (error) {
1607
+ result.errors += 1;
1608
+ logger.warn({ run_dir: runDirPath, err: error instanceof Error ? error.message : String(error) }, "failed to reconcile stale workflow run directory");
1609
+ }
1610
+ }
1611
+ logger.info({ runs_root_dir: runsRootDir, ...result }, "stale workflow run reconcile complete");
1612
+ return result;
1613
+ }
1614
+ /**
1615
+ * Terminalize one pre-screened stale run under its own lock. See
1616
+ * reconcileStaleRunDirectories for the authority and safety model.
1617
+ */
1618
+ async function reconcileStaleRun(runDir) {
1619
+ try {
1620
+ await acquireRunLock(runDir);
1621
+ }
1622
+ catch {
1623
+ // A live lock holder appeared between the pre-screen and acquisition, or
1624
+ // ownership is unknown; either way nothing is mutated.
1625
+ return "skipped_held";
1626
+ }
1627
+ const lockPath = join(runDir, RUN_LOCK_FILENAME);
1628
+ try {
1629
+ const state = await readRunState(runDir);
1630
+ if (state.status !== "pending" && state.status !== "running") {
1631
+ return "skipped_terminal";
1632
+ }
1633
+ const reconciledAt = new Date().toISOString();
1634
+ let nextState = structuredClone(state);
1635
+ for (const stage of nextState.stages) {
1636
+ for (const call of stage.calls) {
1637
+ if (call.state === "running") {
1638
+ call.state = "failed";
1639
+ call.completedAt = reconciledAt;
1640
+ // Preserve an already-recorded error verbatim: a retry-scheduled
1641
+ // "permanent" classification must stay permanent (deliberately NOT
1642
+ // resume()'s downgrade-to-transient recovery behavior).
1643
+ if (call.errorMessage === undefined) {
1644
+ call.errorMessage = STALE_RUN_RECONCILE_CALL_ERROR;
1645
+ }
1646
+ if (call.failureClass === undefined) {
1647
+ call.failureClass = "transient";
1648
+ }
1649
+ }
1650
+ }
1651
+ stage.state = deriveStageState(stage.calls);
1652
+ stage.completedAt = deriveStageCompletedAt(stage.calls);
1653
+ }
1654
+ if (isPlanReviewRun(state)) {
1655
+ const intent = {
1656
+ status: "failed",
1657
+ completedAt: reconciledAt,
1658
+ error: STALE_RUN_RECONCILE_ERROR,
1659
+ failureClass: "transient",
1660
+ replaceFailureClass: true,
1661
+ };
1662
+ const intended = applyPlanReviewTerminalIntent(nextState, intent);
1663
+ if (intended === undefined) {
1664
+ return "skipped_terminal";
1665
+ }
1666
+ const finalized = await finalizePlanReviewTerminalState({
1667
+ runDir,
1668
+ previousState: state,
1669
+ intendedState: intended,
1670
+ intent,
1671
+ });
1672
+ nextState = finalized.state;
1673
+ validateStrictStateSubtrees(nextState, join(runDir, STATE_FILENAME));
1674
+ }
1675
+ else {
1676
+ nextState.status = "failed";
1677
+ nextState.completedAt = reconciledAt;
1678
+ nextState.error = STALE_RUN_RECONCILE_ERROR;
1679
+ nextState.failureClass = "transient";
1680
+ }
1681
+ await writeJsonAtomic(join(runDir, STATE_FILENAME), nextState);
1682
+ logger.info({ run_dir: runDir, run_id: state.runId }, "reconciled stale running workflow run to terminal failed");
1683
+ return "reconciled";
1684
+ }
1685
+ finally {
1686
+ await safeUnlink(lockPath);
1687
+ }
1688
+ }
543
1689
  function isRunLockRecord(value) {
544
1690
  const record = asRecord(value);
545
- return Number.isSafeInteger(record["pid"]) &&
546
- typeof record["hostname"] === "string" &&
547
- typeof record["startedAt"] === "string";
1691
+ const pid = record["pid"];
1692
+ const host = record["hostname"];
1693
+ const startedAt = record["startedAt"];
1694
+ return typeof pid === "number" &&
1695
+ Number.isSafeInteger(pid) &&
1696
+ pid > 0 &&
1697
+ typeof host === "string" &&
1698
+ host.length > 0 &&
1699
+ typeof startedAt === "string" &&
1700
+ Number.isFinite(Date.parse(startedAt));
548
1701
  }
549
1702
  function duplicateRunDirError(runId) {
550
1703
  return new WorkflowError(`Run directory for run_id "${runId}" already exists. Use a unique run_id for new runs. If this is a failed/cancelled resumable run, try resume_workflow.`);
@@ -557,19 +1710,40 @@ async function recycleSucceededRunDir(runDir) {
557
1710
  lockHeld = true;
558
1711
  }
559
1712
  catch {
560
- return false;
1713
+ return { recycled: false };
561
1714
  }
562
1715
  try {
563
1716
  const state = await readRunState(runDir);
564
1717
  if (state.status !== "succeeded") {
565
- return false;
1718
+ return { recycled: false };
1719
+ }
1720
+ const identity = await readSessionLogGeneration(runDir);
1721
+ let retiredSessionLogGeneration;
1722
+ if (identity.kind === "available") {
1723
+ retiredSessionLogGeneration = identity.sessionLogGeneration;
1724
+ }
1725
+ else if (identity.kind === "legacy") {
1726
+ try {
1727
+ retiredSessionLogGeneration = await admitWorkflowRunGeneration(dirname(runDir), runDir, basename(runDir));
1728
+ }
1729
+ catch {
1730
+ logger.warn({ run_id: basename(runDir) }, "legacy recycled workflow session-log generation could not be admitted");
1731
+ }
1732
+ }
1733
+ else {
1734
+ logger.warn({ run_id: basename(runDir) }, "recycled workflow session-log identity was unsafe");
566
1735
  }
567
1736
  await rename(runDir, `${runDir}.recycled`);
568
1737
  lockHeld = false;
569
- return true;
1738
+ return {
1739
+ recycled: true,
1740
+ ...(retiredSessionLogGeneration === undefined
1741
+ ? {}
1742
+ : { retiredSessionLogGeneration }),
1743
+ };
570
1744
  }
571
1745
  catch {
572
- return false;
1746
+ return { recycled: false };
573
1747
  }
574
1748
  finally {
575
1749
  if (lockHeld) {
@@ -577,6 +1751,21 @@ async function recycleSucceededRunDir(runDir) {
577
1751
  }
578
1752
  }
579
1753
  }
1754
+ async function cleanupWorkflowSessionLogGeneration(runsRoot, runId, sessionLogGeneration) {
1755
+ try {
1756
+ const cleanup = await removeSessionLogGeneration({
1757
+ rootDir: join(dirname(resolve(runsRoot)), "session-logs"),
1758
+ family: "workflow",
1759
+ generation: sessionLogGeneration,
1760
+ });
1761
+ if (cleanup.kind === "unsafe" || cleanup.kind === "unavailable") {
1762
+ logger.warn({ run_id: runId, cleanup: cleanup.kind }, "recycled workflow parent removed but exact session-log cleanup was unavailable");
1763
+ }
1764
+ }
1765
+ catch {
1766
+ logger.warn({ run_id: runId }, "recycled workflow parent removed but exact session-log cleanup failed");
1767
+ }
1768
+ }
580
1769
  function getCallEntry(state, stageId, callId) {
581
1770
  const stage = state.stages.find((entry) => entry.stageId === stageId);
582
1771
  if (stage == null) {
@@ -610,6 +1799,27 @@ function deriveStageCompletedAt(calls) {
610
1799
  .sort();
611
1800
  return completedValues.length > 0 ? completedValues[completedValues.length - 1] : undefined;
612
1801
  }
1802
+ function capPersistedError(message) {
1803
+ if (message === undefined) {
1804
+ return undefined;
1805
+ }
1806
+ const encoded = Buffer.from(message, "utf-8");
1807
+ if (encoded.length <= MAX_PERSISTED_ERROR_BYTES) {
1808
+ return message;
1809
+ }
1810
+ let end = MAX_PERSISTED_ERROR_BYTES;
1811
+ // Never split a multi-byte UTF-8 sequence: back off over continuation bytes
1812
+ // so the kept head decodes cleanly.
1813
+ while (end > 0) {
1814
+ const boundaryByte = encoded[end];
1815
+ if (boundaryByte === undefined || (boundaryByte & 0b1100_0000) !== 0b1000_0000) {
1816
+ break;
1817
+ }
1818
+ end -= 1;
1819
+ }
1820
+ const omitted = encoded.length - end;
1821
+ return `${encoded.subarray(0, end).toString("utf-8")} …[truncated ${omitted} bytes]`;
1822
+ }
613
1823
  async function persistOutput(runDir, stageId, callId, outputText) {
614
1824
  const sizeBytes = Buffer.byteLength(outputText, "utf-8");
615
1825
  if (sizeBytes <= OUTPUT_INLINE_THRESHOLD_BYTES) {
@@ -629,6 +1839,7 @@ async function buildResumeState(definition, state, runDir) {
629
1839
  const completedCalls = new Map();
630
1840
  const preparedInputs = new Map();
631
1841
  const failedCalls = new Map();
1842
+ const nextRetryCounts = new Map();
632
1843
  const firstIncompleteStageIndex = definition.stages.findIndex((stage) => {
633
1844
  const persistedStage = state.stages.find((entry) => entry.stageId === stage.id);
634
1845
  return !persistedStage?.calls.every((call) => call.state === "succeeded");
@@ -653,6 +1864,7 @@ async function buildResumeState(definition, state, runDir) {
653
1864
  stageId: stage.id,
654
1865
  callId: call.id,
655
1866
  error: entry.errorMessage,
1867
+ terminal: true,
656
1868
  });
657
1869
  }
658
1870
  }
@@ -670,10 +1882,16 @@ async function buildResumeState(definition, state, runDir) {
670
1882
  stageId: stage.id,
671
1883
  callId: call.id,
672
1884
  error: entry.errorMessage,
1885
+ // Only terminal failures are persisted as `failed`; retryable
1886
+ // intermediate attempts remain `running` with attemptsConsumed.
1887
+ // Never redispatch a persisted failed call after process restart.
1888
+ terminal: true,
673
1889
  });
674
1890
  }
675
1891
  if (entry?.materializedInputPath != null) {
676
1892
  preparedInputs.set(`${stage.id}.${call.id}`, await readJsonFile(join(runDir, entry.materializedInputPath)));
1893
+ nextRetryCounts.set(`${stage.id}.${call.id}`, entry.attemptsConsumed ??
1894
+ (entry.state === "running" ? entry.retryCount + 1 : entry.retryCount));
677
1895
  }
678
1896
  }
679
1897
  }
@@ -682,6 +1900,7 @@ async function buildResumeState(definition, state, runDir) {
682
1900
  completedCalls,
683
1901
  preparedInputs,
684
1902
  failedCalls,
1903
+ nextRetryCounts,
685
1904
  };
686
1905
  }
687
1906
  async function rehydrateExecutedCall(runDir, entry) {
@@ -878,6 +2097,7 @@ async function readRunState(runDir) {
878
2097
  if (state.state_version !== 1) {
879
2098
  throw new Error(`Unsupported state_version ${String(state.state_version)} in ${statePath}`);
880
2099
  }
2100
+ validateStrictStateSubtrees(state, statePath);
881
2101
  return state;
882
2102
  }
883
2103
  async function verifySnapshotHash(filePath, expectedHash, label) {
@@ -913,12 +2133,25 @@ function hashContent(content) {
913
2133
  async function readJsonFile(filePath) {
914
2134
  return JSON.parse(await readFile(filePath, "utf-8"));
915
2135
  }
916
- async function acquireRunLock(runDir) {
2136
+ /**
2137
+ * Atomically create `run.lock`, reclaiming a same-host dead-PID lock.
2138
+ *
2139
+ * `declareHeartbeat` is deliberately opt-in: only a caller that goes on to hold
2140
+ * the lock behind a live `WorkflowStateStore` (create/resume) beats it. The
2141
+ * prune/reap/recycle callers hold the lock briefly with no beating owner, so
2142
+ * declaring a heartbeat there would publish a capability claim nobody honors —
2143
+ * and a foreign-host reader would then age it into a false `orphaned`.
2144
+ */
2145
+ async function acquireRunLock(runDir, options = {}) {
917
2146
  const lockPath = join(runDir, RUN_LOCK_FILENAME);
918
2147
  const record = {
919
2148
  pid: process.pid,
920
2149
  hostname: hostname(),
921
2150
  startedAt: new Date().toISOString(),
2151
+ token: randomBytes(16).toString("base64url"),
2152
+ ...(options.declareHeartbeat === true
2153
+ ? { heartbeatIntervalMs: RUN_LOCK_HEARTBEAT_MS }
2154
+ : {}),
922
2155
  };
923
2156
  for (let attempt = 0; attempt < 2; attempt += 1) {
924
2157
  try {
@@ -929,13 +2162,26 @@ async function acquireRunLock(runDir) {
929
2162
  finally {
930
2163
  await handle.close();
931
2164
  }
932
- return;
2165
+ return record;
933
2166
  }
934
2167
  catch (error) {
935
2168
  if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
936
2169
  throw error;
937
2170
  }
938
- const existing = await readJsonFile(lockPath);
2171
+ let existingValue;
2172
+ try {
2173
+ existingValue = await readJsonFile(lockPath);
2174
+ }
2175
+ catch (readError) {
2176
+ if (readError.code === "ENOENT") {
2177
+ continue;
2178
+ }
2179
+ throw new RunLockOwnershipUnknownError(lockPath, readError instanceof Error ? readError.message : String(readError));
2180
+ }
2181
+ if (!isRunLockRecord(existingValue)) {
2182
+ throw new RunLockOwnershipUnknownError(lockPath, "record must contain a positive integer pid, non-empty hostname, and valid startedAt timestamp");
2183
+ }
2184
+ const existing = existingValue;
939
2185
  if (existing.hostname === hostname() && !isPidLive(existing.pid)) {
940
2186
  await unlink(lockPath);
941
2187
  continue;
@@ -943,6 +2189,7 @@ async function acquireRunLock(runDir) {
943
2189
  throw new Error(`Run lock is held for ${runDir} by pid ${String(existing.pid)} on ${existing.hostname} since ${existing.startedAt}`);
944
2190
  }
945
2191
  }
2192
+ throw new Error(`Failed to acquire run lock for ${runDir} after 2 attempts; lock ownership was not established`);
946
2193
  }
947
2194
  function isPidLive(pid) {
948
2195
  try {