smol-symphony 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (540) hide show
  1. package/AGENTS.md +41 -22
  2. package/DESIGN.md +494 -273
  3. package/README.md +109 -57
  4. package/SPEC.md +33 -24
  5. package/WORKFLOW.minimal.yaml +34 -0
  6. package/{WORKFLOW.template.md → WORKFLOW.template.yaml} +409 -256
  7. package/WORKFLOW.yaml +487 -0
  8. package/assets/skills/symphony-issues/SKILL.md +136 -0
  9. package/assets/symphony-mise.system.toml +68 -0
  10. package/dist/bin/symphony.js +22 -786
  11. package/dist/bin/symphony.js.map +1 -1
  12. package/dist/core/actions/context.js +109 -0
  13. package/dist/core/actions/context.js.map +1 -0
  14. package/dist/{actions/parsing.js → core/actions/parse.js} +33 -114
  15. package/dist/core/actions/parse.js.map +1 -0
  16. package/dist/core/actions/plan.js +197 -0
  17. package/dist/core/actions/plan.js.map +1 -0
  18. package/dist/core/actions/predicates.js +111 -0
  19. package/dist/core/actions/predicates.js.map +1 -0
  20. package/dist/core/actions/run-fold.js +248 -0
  21. package/dist/core/actions/run-fold.js.map +1 -0
  22. package/dist/core/actions/template.js +118 -0
  23. package/dist/core/actions/template.js.map +1 -0
  24. package/dist/core/cli/args.js +116 -0
  25. package/dist/core/cli/args.js.map +1 -0
  26. package/dist/core/coerce.js +75 -0
  27. package/dist/core/coerce.js.map +1 -0
  28. package/dist/core/credential/account-id.js +20 -0
  29. package/dist/core/credential/account-id.js.map +1 -0
  30. package/dist/core/credential/adapter-config.js +136 -0
  31. package/dist/core/credential/adapter-config.js.map +1 -0
  32. package/dist/core/credential/availability.js +98 -0
  33. package/dist/core/credential/availability.js.map +1 -0
  34. package/dist/core/credential/extract.js +228 -0
  35. package/dist/core/credential/extract.js.map +1 -0
  36. package/dist/core/credential/fake-creds.js +171 -0
  37. package/dist/core/credential/fake-creds.js.map +1 -0
  38. package/dist/core/credential/identity.js +125 -0
  39. package/dist/core/credential/identity.js.map +1 -0
  40. package/dist/core/credential/shape.js +230 -0
  41. package/dist/core/credential/shape.js.map +1 -0
  42. package/dist/core/credential/strings.js +15 -0
  43. package/dist/core/credential/strings.js.map +1 -0
  44. package/dist/core/doctor/checks.js +303 -0
  45. package/dist/core/doctor/checks.js.map +1 -0
  46. package/dist/core/git/result.js +107 -0
  47. package/dist/core/git/result.js.map +1 -0
  48. package/dist/core/http/decisions.js +225 -0
  49. package/dist/core/http/decisions.js.map +1 -0
  50. package/dist/{http.js → core/http/render.js} +472 -738
  51. package/dist/core/http/render.js.map +1 -0
  52. package/dist/{http-handlers.js → core/http/routes.js} +52 -87
  53. package/dist/core/http/routes.js.map +1 -0
  54. package/dist/core/http/views.js +181 -0
  55. package/dist/core/http/views.js.map +1 -0
  56. package/dist/core/image/managed-image.js +95 -0
  57. package/dist/core/image/managed-image.js.map +1 -0
  58. package/dist/core/issue/file.js +149 -0
  59. package/dist/core/issue/file.js.map +1 -0
  60. package/dist/core/issue/parse.js +210 -0
  61. package/dist/core/issue/parse.js.map +1 -0
  62. package/dist/core/mcp/dispatch.js +239 -0
  63. package/dist/core/mcp/dispatch.js.map +1 -0
  64. package/dist/core/mcp/post-move.js +92 -0
  65. package/dist/core/mcp/post-move.js.map +1 -0
  66. package/dist/core/mcp/protocol.js +293 -0
  67. package/dist/core/mcp/protocol.js.map +1 -0
  68. package/dist/core/mcp/url.js +162 -0
  69. package/dist/core/mcp/url.js.map +1 -0
  70. package/dist/core/path.js +63 -0
  71. package/dist/core/path.js.map +1 -0
  72. package/dist/core/reconcile/image-decide.js +48 -0
  73. package/dist/core/reconcile/image-decide.js.map +1 -0
  74. package/dist/core/reconcile/ledger.js +142 -0
  75. package/dist/core/reconcile/ledger.js.map +1 -0
  76. package/dist/core/reconcile/pr-classify.js +62 -0
  77. package/dist/core/reconcile/pr-classify.js.map +1 -0
  78. package/dist/{reconciler → core/reconcile}/pr-decide.js +25 -12
  79. package/dist/core/reconcile/pr-decide.js.map +1 -0
  80. package/dist/core/reconcile/pr-loop.js +161 -0
  81. package/dist/core/reconcile/pr-loop.js.map +1 -0
  82. package/dist/core/reconcile/pr-notes.js +35 -0
  83. package/dist/core/reconcile/pr-notes.js.map +1 -0
  84. package/dist/core/reconcile/vm-decide.js +70 -0
  85. package/dist/core/reconcile/vm-decide.js.map +1 -0
  86. package/dist/core/reconcile/vm-reap.js +207 -0
  87. package/dist/core/reconcile/vm-reap.js.map +1 -0
  88. package/dist/core/reconcile/workspace-decide.js +162 -0
  89. package/dist/core/reconcile/workspace-decide.js.map +1 -0
  90. package/dist/core/runlog/summary.js +231 -0
  91. package/dist/core/runlog/summary.js.map +1 -0
  92. package/dist/core/runner/dispatch-config.js +95 -0
  93. package/dist/core/runner/dispatch-config.js.map +1 -0
  94. package/dist/core/runner/injection.js +61 -0
  95. package/dist/core/runner/injection.js.map +1 -0
  96. package/dist/core/runner/mise.js +210 -0
  97. package/dist/core/runner/mise.js.map +1 -0
  98. package/dist/core/runner/prompt.js +720 -0
  99. package/dist/core/runner/prompt.js.map +1 -0
  100. package/dist/core/runner/turn.js +242 -0
  101. package/dist/core/runner/turn.js.map +1 -0
  102. package/dist/core/runner/vm-plan.js +390 -0
  103. package/dist/core/runner/vm-plan.js.map +1 -0
  104. package/dist/core/schedule/admission.js +123 -0
  105. package/dist/core/schedule/admission.js.map +1 -0
  106. package/dist/core/schedule/circuit-breaker.js +111 -0
  107. package/dist/core/schedule/circuit-breaker.js.map +1 -0
  108. package/dist/core/schedule/eligibility.js +83 -0
  109. package/dist/core/schedule/eligibility.js.map +1 -0
  110. package/dist/core/schedule/reconcile-issue.js +82 -0
  111. package/dist/core/schedule/reconcile-issue.js.map +1 -0
  112. package/dist/core/schedule/retry.js +96 -0
  113. package/dist/core/schedule/retry.js.map +1 -0
  114. package/dist/core/schedule/sleep-cycle.js +133 -0
  115. package/dist/core/schedule/sleep-cycle.js.map +1 -0
  116. package/dist/core/schedule/slots.js +124 -0
  117. package/dist/core/schedule/slots.js.map +1 -0
  118. package/dist/core/schedule/tick.js +553 -0
  119. package/dist/core/schedule/tick.js.map +1 -0
  120. package/dist/core/schedule/token-fold.js +181 -0
  121. package/dist/core/schedule/token-fold.js.map +1 -0
  122. package/dist/core/state-resolve.js +86 -0
  123. package/dist/core/state-resolve.js.map +1 -0
  124. package/dist/core/vm-guards.js +278 -0
  125. package/dist/core/vm-guards.js.map +1 -0
  126. package/dist/core/workflow/derive.js +107 -0
  127. package/dist/core/workflow/derive.js.map +1 -0
  128. package/dist/core/workflow/parse.js +687 -0
  129. package/dist/core/workflow/parse.js.map +1 -0
  130. package/dist/core/workflow/prompt-probe.js +78 -0
  131. package/dist/core/workflow/prompt-probe.js.map +1 -0
  132. package/dist/core/workflow/validate.js +189 -0
  133. package/dist/core/workflow/validate.js.map +1 -0
  134. package/dist/core/workspace-key.js +19 -0
  135. package/dist/core/workspace-key.js.map +1 -0
  136. package/dist/shell/actions-runner.js +356 -0
  137. package/dist/shell/actions-runner.js.map +1 -0
  138. package/dist/shell/adapter/adapter-registry.js +45 -0
  139. package/dist/shell/adapter/adapter-registry.js.map +1 -0
  140. package/dist/shell/adapter/clock-random.js +96 -0
  141. package/dist/shell/adapter/clock-random.js.map +1 -0
  142. package/dist/shell/adapter/gondolin-dispatch-helpers.js +158 -0
  143. package/dist/shell/adapter/gondolin-dispatch-helpers.js.map +1 -0
  144. package/dist/shell/adapter/gondolin-dispatch.js +385 -0
  145. package/dist/shell/adapter/gondolin-dispatch.js.map +1 -0
  146. package/dist/shell/adapter/gondolin-image-converter.js +233 -0
  147. package/dist/shell/adapter/gondolin-image-converter.js.map +1 -0
  148. package/dist/shell/adapter/gondolin-image-fetch.js +180 -0
  149. package/dist/shell/adapter/gondolin-image-fetch.js.map +1 -0
  150. package/dist/shell/adapter/launcher-asset.js +57 -0
  151. package/dist/shell/adapter/launcher-asset.js.map +1 -0
  152. package/dist/shell/adapter/mise-config-asset.js +65 -0
  153. package/dist/shell/adapter/mise-config-asset.js.map +1 -0
  154. package/dist/shell/adapter/workflow-loader.js +304 -0
  155. package/dist/shell/adapter/workflow-loader.js.map +1 -0
  156. package/dist/shell/cli/doctor.js +268 -0
  157. package/dist/shell/cli/doctor.js.map +1 -0
  158. package/dist/shell/effect-interpreter-families.js +314 -0
  159. package/dist/shell/effect-interpreter-families.js.map +1 -0
  160. package/dist/shell/effect-interpreter.js +29 -0
  161. package/dist/shell/effect-interpreter.js.map +1 -0
  162. package/dist/shell/interp/acp-frame.js +137 -0
  163. package/dist/shell/interp/acp-frame.js.map +1 -0
  164. package/dist/shell/interp/acp-ws-conn.js +320 -0
  165. package/dist/shell/interp/acp-ws-conn.js.map +1 -0
  166. package/dist/shell/interp/acp-ws-frames.js +159 -0
  167. package/dist/shell/interp/acp-ws-frames.js.map +1 -0
  168. package/dist/shell/interp/acp-ws.js +197 -0
  169. package/dist/shell/interp/acp-ws.js.map +1 -0
  170. package/dist/shell/interp/acp.js +319 -0
  171. package/dist/shell/interp/acp.js.map +1 -0
  172. package/dist/shell/interp/credential-defaults.js +128 -0
  173. package/dist/shell/interp/credential-defaults.js.map +1 -0
  174. package/dist/shell/interp/credential-hooks.js +149 -0
  175. package/dist/shell/interp/credential-hooks.js.map +1 -0
  176. package/dist/shell/interp/credential-registry.js +226 -0
  177. package/dist/shell/interp/credential-registry.js.map +1 -0
  178. package/dist/shell/interp/credential.js +103 -0
  179. package/dist/shell/interp/credential.js.map +1 -0
  180. package/dist/shell/interp/gh.js +163 -0
  181. package/dist/shell/interp/gh.js.map +1 -0
  182. package/dist/shell/interp/git.js +28 -0
  183. package/dist/shell/interp/git.js.map +1 -0
  184. package/dist/shell/interp/log.js +213 -0
  185. package/dist/shell/interp/log.js.map +1 -0
  186. package/dist/shell/interp/process.js +178 -0
  187. package/dist/shell/interp/process.js.map +1 -0
  188. package/dist/shell/interp/runlog.js +193 -0
  189. package/dist/shell/interp/runlog.js.map +1 -0
  190. package/dist/shell/interp/timer.js +64 -0
  191. package/dist/shell/interp/timer.js.map +1 -0
  192. package/dist/shell/interp/tracker-disk.js +99 -0
  193. package/dist/shell/interp/tracker-disk.js.map +1 -0
  194. package/dist/shell/interp/tracker-parse.js +71 -0
  195. package/dist/shell/interp/tracker-parse.js.map +1 -0
  196. package/dist/shell/interp/tracker-scan.js +238 -0
  197. package/dist/shell/interp/tracker-scan.js.map +1 -0
  198. package/dist/shell/interp/tracker-write.js +91 -0
  199. package/dist/shell/interp/tracker-write.js.map +1 -0
  200. package/dist/shell/interp/tracker.js +41 -0
  201. package/dist/shell/interp/tracker.js.map +1 -0
  202. package/dist/shell/interp/tty.js +48 -0
  203. package/dist/shell/interp/tty.js.map +1 -0
  204. package/dist/shell/interp/vm.js +199 -0
  205. package/dist/shell/interp/vm.js.map +1 -0
  206. package/dist/shell/interp/workspace.js +310 -0
  207. package/dist/shell/interp/workspace.js.map +1 -0
  208. package/dist/shell/main-acp.js +78 -0
  209. package/dist/shell/main-acp.js.map +1 -0
  210. package/dist/shell/main-adapters.js +222 -0
  211. package/dist/shell/main-adapters.js.map +1 -0
  212. package/dist/shell/main-credential.js +122 -0
  213. package/dist/shell/main-credential.js.map +1 -0
  214. package/dist/shell/main-doctor.js +22 -0
  215. package/dist/shell/main-doctor.js.map +1 -0
  216. package/dist/shell/main-entry.js +46 -0
  217. package/dist/shell/main-entry.js.map +1 -0
  218. package/dist/shell/main-http-csrf.js +45 -0
  219. package/dist/shell/main-http-csrf.js.map +1 -0
  220. package/dist/shell/main-http-handler.js +389 -0
  221. package/dist/shell/main-http-handler.js.map +1 -0
  222. package/dist/shell/main-http-mcp.js +122 -0
  223. package/dist/shell/main-http-mcp.js.map +1 -0
  224. package/dist/shell/main-http-views.js +253 -0
  225. package/dist/shell/main-http-views.js.map +1 -0
  226. package/dist/shell/main-http.js +76 -0
  227. package/dist/shell/main-http.js.map +1 -0
  228. package/dist/shell/main-loops.js +130 -0
  229. package/dist/shell/main-loops.js.map +1 -0
  230. package/dist/shell/main-mcp.js +129 -0
  231. package/dist/shell/main-mcp.js.map +1 -0
  232. package/dist/shell/main-orchestrator.js +120 -0
  233. package/dist/shell/main-orchestrator.js.map +1 -0
  234. package/dist/shell/main-preflight.js +43 -0
  235. package/dist/shell/main-preflight.js.map +1 -0
  236. package/dist/shell/main-reconcilers-helpers.js +244 -0
  237. package/dist/shell/main-reconcilers-helpers.js.map +1 -0
  238. package/dist/shell/main-reconcilers-pr.js +148 -0
  239. package/dist/shell/main-reconcilers-pr.js.map +1 -0
  240. package/dist/shell/main-reconcilers.js +225 -0
  241. package/dist/shell/main-reconcilers.js.map +1 -0
  242. package/dist/shell/main-runner.js +355 -0
  243. package/dist/shell/main-runner.js.map +1 -0
  244. package/dist/shell/main-scaffold.js +116 -0
  245. package/dist/shell/main-scaffold.js.map +1 -0
  246. package/dist/shell/main-shutdown.js +115 -0
  247. package/dist/shell/main-shutdown.js.map +1 -0
  248. package/dist/shell/main-startup.js +48 -0
  249. package/dist/shell/main-startup.js.map +1 -0
  250. package/dist/shell/main-substrates.js +43 -0
  251. package/dist/shell/main-substrates.js.map +1 -0
  252. package/dist/shell/main.js +385 -0
  253. package/dist/shell/main.js.map +1 -0
  254. package/dist/shell/orchestrator-feedback.js +69 -0
  255. package/dist/shell/orchestrator-feedback.js.map +1 -0
  256. package/dist/shell/orchestrator-image.js +167 -0
  257. package/dist/shell/orchestrator-image.js.map +1 -0
  258. package/dist/shell/orchestrator-loop.js +468 -0
  259. package/dist/shell/orchestrator-loop.js.map +1 -0
  260. package/dist/shell/orchestrator-reconcile.js +36 -0
  261. package/dist/shell/orchestrator-reconcile.js.map +1 -0
  262. package/dist/shell/reconciler-loop.js +228 -0
  263. package/dist/shell/reconciler-loop.js.map +1 -0
  264. package/dist/shell/runner-loop-turn.js +301 -0
  265. package/dist/shell/runner-loop-turn.js.map +1 -0
  266. package/dist/shell/runner-loop.js +338 -0
  267. package/dist/shell/runner-loop.js.map +1 -0
  268. package/dist/shell/server/http.js +208 -0
  269. package/dist/shell/server/http.js.map +1 -0
  270. package/dist/shell/server/mcp-runtime-effects.js +237 -0
  271. package/dist/shell/server/mcp-runtime-effects.js.map +1 -0
  272. package/dist/shell/server/mcp-runtime.js +99 -0
  273. package/dist/shell/server/mcp-runtime.js.map +1 -0
  274. package/dist/shell/workspace-key.js +14 -0
  275. package/dist/shell/workspace-key.js.map +1 -0
  276. package/dist/types/acp.js +8 -0
  277. package/dist/types/acp.js.map +1 -0
  278. package/dist/types/actions/plan.js +6 -0
  279. package/dist/types/actions/plan.js.map +1 -0
  280. package/dist/types/actions/predicates.js +6 -0
  281. package/dist/types/actions/predicates.js.map +1 -0
  282. package/dist/types/actions/run-fold.js +8 -0
  283. package/dist/types/actions/run-fold.js.map +1 -0
  284. package/dist/types/actions.js +7 -0
  285. package/dist/types/actions.js.map +1 -0
  286. package/dist/types/adapter/clock-random.js +4 -0
  287. package/dist/types/adapter/clock-random.js.map +1 -0
  288. package/dist/types/adapter/gondolin-image-converter.js +5 -0
  289. package/dist/types/adapter/gondolin-image-converter.js.map +1 -0
  290. package/dist/types/adapter/gondolin-image-fetch.js +5 -0
  291. package/dist/types/adapter/gondolin-image-fetch.js.map +1 -0
  292. package/dist/types/adapter/workflow-loader.js +4 -0
  293. package/dist/types/adapter/workflow-loader.js.map +1 -0
  294. package/dist/types/cli/args.js +8 -0
  295. package/dist/types/cli/args.js.map +1 -0
  296. package/dist/types/config.js +8 -0
  297. package/dist/types/config.js.map +1 -0
  298. package/dist/types/credential-interp.js +6 -0
  299. package/dist/types/credential-interp.js.map +1 -0
  300. package/dist/types/credentials.js +10 -0
  301. package/dist/types/credentials.js.map +1 -0
  302. package/dist/types/doctor.js +7 -0
  303. package/dist/types/doctor.js.map +1 -0
  304. package/dist/types/domain.js +7 -0
  305. package/dist/types/domain.js.map +1 -0
  306. package/dist/types/effect.js +15 -0
  307. package/dist/types/effect.js.map +1 -0
  308. package/dist/types/errors.js +39 -0
  309. package/dist/types/errors.js.map +1 -0
  310. package/dist/types/http/decisions.js +6 -0
  311. package/dist/types/http/decisions.js.map +1 -0
  312. package/dist/types/http/render.js +10 -0
  313. package/dist/types/http/render.js.map +1 -0
  314. package/dist/types/http/views.js +6 -0
  315. package/dist/types/http/views.js.map +1 -0
  316. package/dist/types/http.js +9 -0
  317. package/dist/types/http.js.map +1 -0
  318. package/dist/types/image/managed-image.js +7 -0
  319. package/dist/types/image/managed-image.js.map +1 -0
  320. package/dist/types/interp/effect-interpreter.js +8 -0
  321. package/dist/types/interp/effect-interpreter.js.map +1 -0
  322. package/dist/types/interp/tracker.js +7 -0
  323. package/dist/types/interp/tracker.js.map +1 -0
  324. package/dist/types/issue/file.js +6 -0
  325. package/dist/types/issue/file.js.map +1 -0
  326. package/dist/types/issue/parse.js +8 -0
  327. package/dist/types/issue/parse.js.map +1 -0
  328. package/dist/types/main-acp.js +13 -0
  329. package/dist/types/main-acp.js.map +1 -0
  330. package/dist/types/main-adapters.js +5 -0
  331. package/dist/types/main-adapters.js.map +1 -0
  332. package/dist/types/main-credential.js +21 -0
  333. package/dist/types/main-credential.js.map +1 -0
  334. package/dist/types/main-doctor.js +6 -0
  335. package/dist/types/main-doctor.js.map +1 -0
  336. package/dist/types/main-http-handler.js +12 -0
  337. package/dist/types/main-http-handler.js.map +1 -0
  338. package/dist/types/main-http.js +5 -0
  339. package/dist/types/main-http.js.map +1 -0
  340. package/dist/types/main-loops.js +5 -0
  341. package/dist/types/main-loops.js.map +1 -0
  342. package/dist/types/main-mcp.js +12 -0
  343. package/dist/types/main-mcp.js.map +1 -0
  344. package/dist/types/main-orchestrator.js +5 -0
  345. package/dist/types/main-orchestrator.js.map +1 -0
  346. package/dist/types/main-reconcilers.js +11 -0
  347. package/dist/types/main-reconcilers.js.map +1 -0
  348. package/dist/types/main-runner.js +13 -0
  349. package/dist/types/main-runner.js.map +1 -0
  350. package/dist/types/main-startup.js +5 -0
  351. package/dist/types/main-startup.js.map +1 -0
  352. package/dist/types/main-substrates.js +5 -0
  353. package/dist/types/main-substrates.js.map +1 -0
  354. package/dist/types/mcp/dispatch.js +4 -0
  355. package/dist/types/mcp/dispatch.js.map +1 -0
  356. package/dist/types/mcp/post-move.js +7 -0
  357. package/dist/types/mcp/post-move.js.map +1 -0
  358. package/dist/types/mcp.js +9 -0
  359. package/dist/types/mcp.js.map +1 -0
  360. package/dist/types/ports.js +12 -0
  361. package/dist/types/ports.js.map +1 -0
  362. package/dist/types/reconcile/image-decide.js +5 -0
  363. package/dist/types/reconcile/image-decide.js.map +1 -0
  364. package/dist/types/reconcile/ledger.js +7 -0
  365. package/dist/types/reconcile/ledger.js.map +1 -0
  366. package/dist/types/reconcile/pr-loop.js +8 -0
  367. package/dist/types/reconcile/pr-loop.js.map +1 -0
  368. package/dist/types/reconcile/vm-reap.js +8 -0
  369. package/dist/types/reconcile/vm-reap.js.map +1 -0
  370. package/dist/types/reconcile/workspace-decide.js +7 -0
  371. package/dist/types/reconcile/workspace-decide.js.map +1 -0
  372. package/dist/types/reconcile.js +9 -0
  373. package/dist/types/reconcile.js.map +1 -0
  374. package/dist/types/runlog.js +7 -0
  375. package/dist/types/runlog.js.map +1 -0
  376. package/dist/types/runner/actions-runner.js +12 -0
  377. package/dist/types/runner/actions-runner.js.map +1 -0
  378. package/dist/types/runner/gondolin-dispatch.js +5 -0
  379. package/dist/types/runner/gondolin-dispatch.js.map +1 -0
  380. package/dist/types/runner/injection.js +6 -0
  381. package/dist/types/runner/injection.js.map +1 -0
  382. package/dist/types/runner/runner-loop.js +5 -0
  383. package/dist/types/runner/runner-loop.js.map +1 -0
  384. package/dist/types/runner/turn.js +4 -0
  385. package/dist/types/runner/turn.js.map +1 -0
  386. package/dist/types/runner/vm-plan.js +4 -0
  387. package/dist/types/runner/vm-plan.js.map +1 -0
  388. package/dist/types/runtime.js +9 -0
  389. package/dist/types/runtime.js.map +1 -0
  390. package/dist/types/schedule/admission.js +7 -0
  391. package/dist/types/schedule/admission.js.map +1 -0
  392. package/dist/types/schedule/circuit-breaker.js +2 -0
  393. package/dist/types/schedule/circuit-breaker.js.map +1 -0
  394. package/dist/types/schedule/eligibility.js +9 -0
  395. package/dist/types/schedule/eligibility.js.map +1 -0
  396. package/dist/types/schedule/orchestrator-loop.js +10 -0
  397. package/dist/types/schedule/orchestrator-loop.js.map +1 -0
  398. package/dist/types/schedule/sleep-cycle.js +4 -0
  399. package/dist/types/schedule/sleep-cycle.js.map +1 -0
  400. package/dist/types/schedule/slots.js +8 -0
  401. package/dist/types/schedule/slots.js.map +1 -0
  402. package/dist/types/schedule/tick.js +9 -0
  403. package/dist/types/schedule/tick.js.map +1 -0
  404. package/dist/types/server/mcp-runtime.js +8 -0
  405. package/dist/types/server/mcp-runtime.js.map +1 -0
  406. package/dist/types/workflow/parse.js +4 -0
  407. package/dist/types/workflow/parse.js.map +1 -0
  408. package/package.json +22 -10
  409. package/patches/@earendil-works+gondolin+0.12.0.patch +173 -0
  410. package/prompts/Reflect.md +91 -0
  411. package/prompts/Review.md +97 -0
  412. package/prompts/Todo.md +96 -0
  413. package/prompts/_footer.md +41 -0
  414. package/prompts/_preamble.md +42 -0
  415. package/prompts-minimal/Todo.md +26 -0
  416. package/scripts/postinstall.mjs +63 -0
  417. package/scripts/vm-agent.mjs +312 -90
  418. package/WORKFLOW.md +0 -744
  419. package/dist/acp-bridge.js +0 -324
  420. package/dist/acp-bridge.js.map +0 -1
  421. package/dist/actions/cache.js +0 -191
  422. package/dist/actions/cache.js.map +0 -1
  423. package/dist/actions/effects.js +0 -41
  424. package/dist/actions/effects.js.map +0 -1
  425. package/dist/actions/executor.js +0 -570
  426. package/dist/actions/executor.js.map +0 -1
  427. package/dist/actions/index.js +0 -13
  428. package/dist/actions/index.js.map +0 -1
  429. package/dist/actions/parsing.js.map +0 -1
  430. package/dist/actions/predicate-env.js +0 -27
  431. package/dist/actions/predicate-env.js.map +0 -1
  432. package/dist/actions/predicates.js +0 -49
  433. package/dist/actions/predicates.js.map +0 -1
  434. package/dist/actions/templating.js +0 -66
  435. package/dist/actions/templating.js.map +0 -1
  436. package/dist/actions/types.js +0 -15
  437. package/dist/actions/types.js.map +0 -1
  438. package/dist/agent/acp.js +0 -473
  439. package/dist/agent/acp.js.map +0 -1
  440. package/dist/agent/adapter-names.js +0 -159
  441. package/dist/agent/adapter-names.js.map +0 -1
  442. package/dist/agent/adapters.js +0 -511
  443. package/dist/agent/adapters.js.map +0 -1
  444. package/dist/agent/credential-extractors.js +0 -342
  445. package/dist/agent/credential-extractors.js.map +0 -1
  446. package/dist/agent/credential-secrets.js +0 -628
  447. package/dist/agent/credential-secrets.js.map +0 -1
  448. package/dist/agent/credential-ticker.js +0 -57
  449. package/dist/agent/credential-ticker.js.map +0 -1
  450. package/dist/agent/gondolin-creds-staging.js +0 -356
  451. package/dist/agent/gondolin-creds-staging.js.map +0 -1
  452. package/dist/agent/gondolin-dispatch.js +0 -375
  453. package/dist/agent/gondolin-dispatch.js.map +0 -1
  454. package/dist/agent/gondolin.js +0 -124
  455. package/dist/agent/gondolin.js.map +0 -1
  456. package/dist/agent/runner-decisions.js +0 -134
  457. package/dist/agent/runner-decisions.js.map +0 -1
  458. package/dist/agent/runner.js +0 -1456
  459. package/dist/agent/runner.js.map +0 -1
  460. package/dist/agent/tool-call-summary.js +0 -102
  461. package/dist/agent/tool-call-summary.js.map +0 -1
  462. package/dist/agent/vm-acp-mapping.js +0 -73
  463. package/dist/agent/vm-acp-mapping.js.map +0 -1
  464. package/dist/agent/vm-guards.js +0 -262
  465. package/dist/agent/vm-guards.js.map +0 -1
  466. package/dist/agent/vm-port.js +0 -22
  467. package/dist/agent/vm-port.js.map +0 -1
  468. package/dist/agent/vm-process-registry.js +0 -79
  469. package/dist/agent/vm-process-registry.js.map +0 -1
  470. package/dist/bin/cli-args.js +0 -105
  471. package/dist/bin/cli-args.js.map +0 -1
  472. package/dist/errors.js +0 -15
  473. package/dist/errors.js.map +0 -1
  474. package/dist/http-disk.js +0 -135
  475. package/dist/http-disk.js.map +0 -1
  476. package/dist/http-handlers.js.map +0 -1
  477. package/dist/http.js.map +0 -1
  478. package/dist/issues.js +0 -178
  479. package/dist/issues.js.map +0 -1
  480. package/dist/logging.js +0 -203
  481. package/dist/logging.js.map +0 -1
  482. package/dist/mcp.js +0 -706
  483. package/dist/mcp.js.map +0 -1
  484. package/dist/memory.js +0 -85
  485. package/dist/memory.js.map +0 -1
  486. package/dist/orchestrator-decisions.js +0 -331
  487. package/dist/orchestrator-decisions.js.map +0 -1
  488. package/dist/orchestrator.js +0 -1569
  489. package/dist/orchestrator.js.map +0 -1
  490. package/dist/prompt.js +0 -65
  491. package/dist/prompt.js.map +0 -1
  492. package/dist/reconciler/cache.js +0 -65
  493. package/dist/reconciler/cache.js.map +0 -1
  494. package/dist/reconciler/index.js +0 -448
  495. package/dist/reconciler/index.js.map +0 -1
  496. package/dist/reconciler/ledger.js +0 -131
  497. package/dist/reconciler/ledger.js.map +0 -1
  498. package/dist/reconciler/pr-adapters.js +0 -174
  499. package/dist/reconciler/pr-adapters.js.map +0 -1
  500. package/dist/reconciler/pr-decide.js.map +0 -1
  501. package/dist/reconciler/pr.js +0 -422
  502. package/dist/reconciler/pr.js.map +0 -1
  503. package/dist/reconciler/types.js +0 -12
  504. package/dist/reconciler/types.js.map +0 -1
  505. package/dist/reconciler/vm.js +0 -243
  506. package/dist/reconciler/vm.js.map +0 -1
  507. package/dist/reconciler/workspace-defaults.js +0 -83
  508. package/dist/reconciler/workspace-defaults.js.map +0 -1
  509. package/dist/reconciler/workspace.js +0 -272
  510. package/dist/reconciler/workspace.js.map +0 -1
  511. package/dist/runlog.js +0 -403
  512. package/dist/runlog.js.map +0 -1
  513. package/dist/scaffold.js +0 -165
  514. package/dist/scaffold.js.map +0 -1
  515. package/dist/trackers/local.js +0 -445
  516. package/dist/trackers/local.js.map +0 -1
  517. package/dist/trackers/types.js +0 -10
  518. package/dist/trackers/types.js.map +0 -1
  519. package/dist/types.js +0 -3
  520. package/dist/types.js.map +0 -1
  521. package/dist/util/clock.js +0 -12
  522. package/dist/util/clock.js.map +0 -1
  523. package/dist/util/crypto.js +0 -25
  524. package/dist/util/crypto.js.map +0 -1
  525. package/dist/util/frontmatter.js +0 -70
  526. package/dist/util/frontmatter.js.map +0 -1
  527. package/dist/util/fs-issues.js +0 -22
  528. package/dist/util/fs-issues.js.map +0 -1
  529. package/dist/util/process.js +0 -152
  530. package/dist/util/process.js.map +0 -1
  531. package/dist/util/workspace-key.js +0 -10
  532. package/dist/util/workspace-key.js.map +0 -1
  533. package/dist/workflow-loader.js +0 -147
  534. package/dist/workflow-loader.js.map +0 -1
  535. package/dist/workflow.js +0 -822
  536. package/dist/workflow.js.map +0 -1
  537. package/dist/workspace-types.js +0 -8
  538. package/dist/workspace-types.js.map +0 -1
  539. package/dist/workspace.js +0 -443
  540. package/dist/workspace.js.map +0 -1
@@ -1,1569 +0,0 @@
1
- // Orchestrator. Owns the single-authority runtime state and drives the
2
- // poll-and-dispatch tick, retries, reconciliation, and worker exit handling.
3
- import { deriveArmRouting, derivePrRouting, validateDispatch, WorkflowError, } from './workflow.js';
4
- import { validateDispatchIo } from './workflow-loader.js';
5
- import { writeIssueFile, pickHoldingState } from './issues.js';
6
- import { resolveDispatchConfig } from './agent/runner.js';
7
- import { codexCredentialAvailable, codexMissingCredentialMessage, hostClaudeCredentialPath, hostCodexCredentialPath, hostOpencodeCredentialPath, isKnownAdapter, opencodeCredentialAvailable, opencodeMissingCredentialMessage, } from './agent/adapter-names.js';
8
- import { accessSync, constants as fsConstants, readFileSync } from 'node:fs';
9
- import { activeStateNames, terminalStateNames } from './issues.js';
10
- import { buildIssueDetailDto, classifyPrIntent, computeEligibilityReason, decideCircuitBreaker, decideExitRetry, decideReconcileForIssue, decideRetryAfterIneligible, decideSleepCycleArm, requiredAdapterIds, resolveActorString, sleepCycleArmNotes, } from './orchestrator-decisions.js';
11
- import { resolveGithubRepo } from './workspace.js';
12
- import { withIssue, log } from './logging.js';
13
- import { openRunLog } from './runlog.js';
14
- import { defaultMemProbe, computeMemoryAdmission } from './memory.js';
15
- import { runProcess } from './util/process.js';
16
- const CONTINUATION_DELAY_MS = 1_000;
17
- const FAILURE_BASE_MS = 10_000;
18
- // Actor stamped into the notes header when the orchestrator (not an agent)
19
- // auto-arms the reflection issue, so the move is attributable on the dashboard
20
- // and in the issue body.
21
- const SLEEP_CYCLE_ACTOR = 'symphony/sleep-cycle';
22
- /**
23
- * Resolve the base branch the autopilot should rebase against. Mirrors the
24
- * canonical workspace-setup contract — the `SYMPHONY_BASE_BRANCH` env wins,
25
- * else the parsed `workspace.base_branch` (which defaults to `main`).
26
- */
27
- function baseBranchName(configBaseBranch) {
28
- const env = process.env.SYMPHONY_BASE_BRANCH;
29
- if (env && env.length > 0)
30
- return env;
31
- return configBaseBranch;
32
- }
33
- export class Orchestrator {
34
- cfg;
35
- workflowDef;
36
- workflowSrc;
37
- tracker;
38
- workspaces;
39
- runner;
40
- memProbe;
41
- reconciler;
42
- running = new Map();
43
- claimed = new Set();
44
- retryAttempts = new Map();
45
- // Per-issue circuit-breaker streak (issue 128): the last abnormal-exit reason
46
- // (normalized) and how many consecutive attempts failed with it. Updated on
47
- // every worker exit; cleared on a clean exit, on trip, and on claim release.
48
- // In-memory only — a process restart resets the streak, but the *trip itself*
49
- // is restart-safe because it physically moves the issue out of the active set.
50
- circuitBreakers = new Map();
51
- // Sleep-cycle auto-arm (issue 125). Count of terminal-state transitions
52
- // observed since the reflection issue was last armed; the idle and
53
- // done-threshold triggers both read it, and it resets to 0 on each arm. The
54
- // in-flight guard stops two overlapping ticks from both firing the async
55
- // Dormant → Reflect move. In-memory only — a process restart resets the
56
- // streak (consistent with `circuitBreakers`).
57
- doneSinceReflect = 0;
58
- armingReflection = false;
59
- completed = new Set();
60
- // Per-state ledger of the most-recent action-list execution. Surfaced via
61
- // `snapshot.reconciler.resources` so the dashboard can render "Done.actions:
62
- // push_branch ok, create_pr_if_missing in_progress" without a separate
63
- // first-class surface for action state (issue 36 AC5).
64
- lastActionResults = new Map();
65
- // Per-issue JSONL run log. Opened lazily on first dispatch for an issue, kept open across
66
- // retries so the file is one chronological stream per issue, and closed only when the
67
- // issue finally unwinds (terminal cleanup, claim release without redispatch, or stop()).
68
- runLogs = new Map();
69
- // Set of issue ids whose terminal cleanup (workspaces.remove) is still in
70
- // flight. Used by closeRunLog to defer the close until the terminal-state
71
- // actions capture has stopped writing; otherwise the retry-timer's "claim
72
- // released" close fires ~1s after worker exit (before the actions finish)
73
- // and we'd lose the action output lines in the JSONL log.
74
- cleanupInFlight = new Set();
75
- sessionTotals = {
76
- input_tokens: 0,
77
- output_tokens: 0,
78
- total_tokens: 0,
79
- seconds_running: 0,
80
- };
81
- rateLimits = null;
82
- tickTimer = null;
83
- stopped = false;
84
- refreshRequested = false;
85
- // Latest dispatch validation error, if any (operator-visible).
86
- lastValidationError = null;
87
- // Optional callback used to propagate reloaded config to components that hold their own
88
- // tracker/runner/workspace state (so prompt body, per-state actions, gondolin config, etc.,
89
- // take effect on the next dispatch).
90
- onConfigReloaded;
91
- // Last clamp-active state observed by availableGlobalSlots. Used to log
92
- // transitions (clamp_active true→false or false→true) at info level without
93
- // spamming the log every tick while the cap stays clamped.
94
- memoryClampActive = false;
95
- constructor(cfg, workflowDef, workflowSrc, tracker, workspaces, runner,
96
- // Memory probe used by the admission cap (issue 27). Defaults to reading
97
- // /proc/meminfo synchronously; tests inject a stub that returns a controlled
98
- // mem_available_mib so the clamp behavior is deterministic.
99
- memProbe = defaultMemProbe,
100
- // Reconciler (issue 32, 33) — owns managed external resources: the
101
- // symphony-VM lifecycle reaper, the workspace janitor, and PR autopilot.
102
- // Optional so tests that don't exercise reconciliation don't have to
103
- // construct one; when absent, `Snapshot.reconciler` is null and stray VM
104
- // reaping is skipped. Production wiring in bin/symphony.ts always passes
105
- // one in.
106
- reconciler = null) {
107
- this.cfg = cfg;
108
- this.workflowDef = workflowDef;
109
- this.workflowSrc = workflowSrc;
110
- this.tracker = tracker;
111
- this.workspaces = workspaces;
112
- this.runner = runner;
113
- this.memProbe = memProbe;
114
- this.reconciler = reconciler;
115
- workflowSrc.onChange((next) => {
116
- if ('error' in next) {
117
- this.lastValidationError = next.error.message;
118
- log.warn('workflow reload error', { error: next.error.message });
119
- return;
120
- }
121
- this.cfg = next.config;
122
- this.workflowDef = next.definition;
123
- this.lastValidationError = null;
124
- this.onConfigReloaded?.(next.config, next.definition);
125
- // Issue 32: a config-watcher change is one of the reconciler's declared
126
- // triggers. Re-binding the resource set picks up new managed-resource
127
- // config (e.g. `gondolin.*` VM settings).
128
- this.reconciler?.updateConfig(next.config);
129
- log.info('runtime config reloaded', {
130
- poll_interval_ms: next.config.polling.interval_ms,
131
- max_concurrent_agents: next.config.agent.max_concurrent_agents,
132
- });
133
- });
134
- }
135
- /** Register a callback invoked after every successful workflow reload. */
136
- setOnConfigReloaded(cb) {
137
- this.onConfigReloaded = cb;
138
- }
139
- async start() {
140
- const validation = validateDispatch(this.cfg) ?? validateDispatchIo(this.cfg);
141
- if (validation) {
142
- log.error('startup validation failed', { error: validation });
143
- throw new WorkflowError('workflow_parse_error', validation);
144
- }
145
- await this.assertAdapterCredentials();
146
- await this.runStartupReconcile();
147
- this.scheduleTick(0);
148
- }
149
- /**
150
- * Fail fast when symphony will dispatch to an adapter whose host credential
151
- * (substituted into the outbound request at Gondolin egress) is missing.
152
- * Per-state overrides can change the adapter, so the set is the union of
153
- * `cfg.acp.adapter` and every distinct `states.<name>.adapter`. claude needs
154
- * `~/.claude/.credentials.json`; codex needs either a `~/.codex/auth.json`
155
- * token or an `OPENAI_API_KEY` env var; opencode needs either a
156
- * `github-copilot` token in `~/.local/share/opencode/auth.json` or a
157
- * COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN env var. A missing credential
158
- * surfaces here as a clear startup error rather than an opaque per-request
159
- * egress failure mid-dispatch.
160
- */
161
- async assertAdapterCredentials() {
162
- const ids = requiredAdapterIds(this.cfg, isKnownAdapter);
163
- if (ids.has('claude'))
164
- this.assertClaudeCredential();
165
- if (ids.has('codex'))
166
- this.assertCodexCredential();
167
- if (ids.has('opencode'))
168
- this.assertOpencodeCredential();
169
- }
170
- assertClaudeCredential() {
171
- const credPath = hostClaudeCredentialPath();
172
- try {
173
- accessSync(credPath, fsConstants.R_OK);
174
- }
175
- catch (err) {
176
- const msg = `adapter "claude" requires a host credential at ${credPath}, but it is missing or unreadable: ${err.message}`;
177
- log.error('startup credential check failed', { adapter: 'claude', error: msg });
178
- throw new WorkflowError('missing_host_credential', msg);
179
- }
180
- }
181
- assertCodexCredential() {
182
- let authText = null;
183
- try {
184
- authText = readFileSync(hostCodexCredentialPath(), 'utf8');
185
- }
186
- catch {
187
- authText = null;
188
- }
189
- if (codexCredentialAvailable(authText, process.env))
190
- return;
191
- const msg = codexMissingCredentialMessage();
192
- log.error('startup credential check failed', { adapter: 'codex', error: msg });
193
- throw new WorkflowError('missing_host_credential', msg);
194
- }
195
- assertOpencodeCredential() {
196
- let authText = null;
197
- try {
198
- authText = readFileSync(hostOpencodeCredentialPath(), 'utf8');
199
- }
200
- catch {
201
- authText = null;
202
- }
203
- if (opencodeCredentialAvailable(authText, process.env))
204
- return;
205
- const msg = opencodeMissingCredentialMessage();
206
- log.error('startup credential check failed', { adapter: 'opencode', error: msg });
207
- throw new WorkflowError('missing_host_credential', msg);
208
- }
209
- /**
210
- * Initial workspace + VM reap and the first reconcile pass (issues 32-34).
211
- * The `running` map is empty here, so the janitors converge to "remove
212
- * anything orphaned by the previous process" and the bake (if any) starts
213
- * before the first dispatch.
214
- */
215
- async runStartupReconcile() {
216
- if (!this.reconciler)
217
- return;
218
- await this.reconciler.reapWorkspaces();
219
- await this.reconciler.reapVms();
220
- this.reconciler.start();
221
- void this.reconciler.reconcile().catch((err) => log.warn('initial reconcile pass failed', { error: err.message }));
222
- }
223
- async stop() {
224
- this.stopped = true;
225
- if (this.tickTimer) {
226
- clearTimeout(this.tickTimer);
227
- this.tickTimer = null;
228
- }
229
- if (this.reconciler) {
230
- await this.reconciler.stop().catch(() => undefined);
231
- }
232
- for (const e of this.retryAttempts.values())
233
- clearTimeout(e.timer_handle);
234
- this.retryAttempts.clear();
235
- // Signal cancel on all running entries.
236
- for (const e of this.running.values())
237
- e.cancel();
238
- this.running.clear();
239
- this.claimed.clear();
240
- this.circuitBreakers.clear();
241
- // Drain every open run log so the JSONL files are flushed before exit.
242
- const closures = [];
243
- for (const [issueId, rl] of this.runLogs) {
244
- rl.system('runlog_closed', { reason: 'orchestrator_stopped' });
245
- closures.push(rl.close());
246
- this.runLogs.delete(issueId);
247
- }
248
- await Promise.all(closures);
249
- // VM teardown lives in the reconciler `vm` resource (issue 52). stop() does NOT
250
- // wait for in-flight workers to unwind before returning — the bin script then
251
- // exits the process, which abruptly ends the per-VM Gondolin runners but can
252
- // leave their session sockets behind. Without this backstop, every SIGTERM
253
- // during an active run can leak one VM per running entry, and over enough
254
- // operator restarts the host OOMs (issue 26). `running` is cleared above,
255
- // so the reaper's intended set is ∅ and every `symphony-*` VM (live session +
256
- // any orphaned socket) gets torn down.
257
- if (this.reconciler) {
258
- await this.reconciler.reapVms();
259
- }
260
- }
261
- /**
262
- * Operator trigger for an immediate reconcile pass. Used by `symphony reconcile
263
- * --force` (which invalidates the cache first via `force: true`) and by any
264
- * future dashboard button that wants to re-evaluate the resource DAG without
265
- * waiting for the backstop tick.
266
- */
267
- async triggerReconcile(opts = {}) {
268
- if (!this.reconciler)
269
- return;
270
- await this.reconciler.reconcile(opts);
271
- }
272
- /** Operator trigger for an immediate poll cycle (§9.5 /refresh). */
273
- triggerRefresh() {
274
- if (this.refreshRequested)
275
- return { queued: true, coalesced: true };
276
- this.refreshRequested = true;
277
- if (this.tickTimer)
278
- clearTimeout(this.tickTimer);
279
- this.tickTimer = setTimeout(() => void this.tick(), 0);
280
- return { queued: true, coalesced: false };
281
- }
282
- scheduleTick(delayMs) {
283
- if (this.stopped)
284
- return;
285
- if (this.tickTimer)
286
- clearTimeout(this.tickTimer);
287
- this.tickTimer = setTimeout(() => void this.tick(), delayMs);
288
- }
289
- async tick() {
290
- if (this.stopped)
291
- return;
292
- this.refreshRequested = false;
293
- await this.reconcileSafely();
294
- if (!this.applyDispatchValidation())
295
- return;
296
- const fetched = await this.fetchCandidatesForTick();
297
- if (!fetched)
298
- return;
299
- if (this.gatedOnReconciler(fetched.issues.length))
300
- return;
301
- this.dispatchSorted(this.sortForDispatch(fetched.issues), fetched.root);
302
- this.maybeArmSleepCycle(fetched.issues.length === 0);
303
- this.scheduleTick(this.cfg.polling.interval_ms);
304
- }
305
- async reconcileSafely() {
306
- try {
307
- await this.reconcile();
308
- }
309
- catch (err) {
310
- log.warn('reconcile error', { error: err.message });
311
- }
312
- }
313
- /**
314
- * Run dispatch validation. Returns true to continue dispatch, false when the
315
- * config is invalid (the tick is rescheduled and the caller must return).
316
- */
317
- applyDispatchValidation() {
318
- const validation = validateDispatch(this.cfg) ?? validateDispatchIo(this.cfg);
319
- if (validation) {
320
- this.lastValidationError = validation;
321
- log.warn('dispatch validation failed; skipping dispatch', { error: validation });
322
- this.scheduleTick(this.cfg.polling.interval_ms);
323
- return false;
324
- }
325
- this.lastValidationError = null;
326
- return true;
327
- }
328
- /**
329
- * Atomic fetch: the tracker returns the issues AND the root it used during
330
- * the scan. That's the snapshot we pin onto each RunningEntry, so a workflow
331
- * reload that races the dispatch loop can't cause `transition` to operate
332
- * against a different tracker root than where the issue lives. Returns
333
- * null on tracker error (tick is rescheduled, caller must return).
334
- */
335
- async fetchCandidatesForTick() {
336
- try {
337
- const r = await this.tracker.fetchCandidateIssues();
338
- return { issues: r.issues, root: r.root };
339
- }
340
- catch (err) {
341
- log.warn('candidate fetch failed', { error: err.message });
342
- this.scheduleTick(this.cfg.polling.interval_ms);
343
- return null;
344
- }
345
- }
346
- /**
347
- * Reconciler gate (issue 32): refuse to dispatch any issue whose
348
- * prerequisites haven't converged. When the gate is closed we kick a
349
- * reconcile pass so the loop self-corrects on the next poll instead of
350
- * waiting on the slower backstop tick. Returns true when dispatch must
351
- * be skipped (caller must return).
352
- */
353
- gatedOnReconciler(candidateCount) {
354
- if (!this.reconciler || this.reconciler.dispatchReady())
355
- return false;
356
- log.debug('dispatch gated on reconciler', { candidate_count: candidateCount });
357
- void this.reconciler.reconcile().catch((err) => log.debug('gated-reconcile failed', { error: err.message }));
358
- this.scheduleTick(this.cfg.polling.interval_ms);
359
- return true;
360
- }
361
- dispatchSorted(sorted, snapshotTrackerRoot) {
362
- for (const issue of sorted) {
363
- if (this.availableGlobalSlots() <= 0)
364
- break;
365
- if (!this.isEligible(issue))
366
- continue;
367
- void this.dispatchIssue(issue, null, { trackerRoot: snapshotTrackerRoot });
368
- }
369
- }
370
- /** Stall detection + tracker state refresh for running issues. */
371
- async reconcile() {
372
- this.detectStalls();
373
- await this.refreshTrackerStates();
374
- }
375
- detectStalls() {
376
- if (this.cfg.acp.stall_timeout_ms <= 0)
377
- return;
378
- const now = Date.now();
379
- for (const [issueId, entry] of this.running) {
380
- // Skip stall detection for issues awaiting human steering: the agent is
381
- // intentionally paused while the human composes a reply, and the wait can
382
- // legitimately exceed stall_timeout_ms. The cancel signal still applies
383
- // (the runner's awaitSteeringReply respects it) for non-stall reasons like
384
- // terminal-state transitions or operator-initiated cancels.
385
- if (entry.steering_requested)
386
- continue;
387
- const ref = entry.last_event_at ?? entry.started_at;
388
- const elapsed = now - Date.parse(ref);
389
- if (Number.isFinite(elapsed) && elapsed > this.cfg.acp.stall_timeout_ms) {
390
- log.warn('stall detected', {
391
- issue_id: issueId,
392
- issue_identifier: entry.identifier,
393
- elapsed_ms: elapsed,
394
- });
395
- this.terminateRunning(issueId, false, `stalled after ${elapsed}ms`);
396
- }
397
- }
398
- }
399
- async refreshTrackerStates() {
400
- const ids = [...this.running.keys()];
401
- if (ids.length === 0)
402
- return;
403
- let refreshed;
404
- try {
405
- refreshed = await this.tracker.fetchIssueStatesByIds(ids);
406
- }
407
- catch (err) {
408
- log.debug('state refresh failed; keep workers running', { error: err.message });
409
- return;
410
- }
411
- const byId = new Map(refreshed.map((i) => [i.id, i]));
412
- for (const id of ids)
413
- this.applyReconcileAction(id, byId.get(id));
414
- }
415
- applyReconcileAction(id, fresh) {
416
- const decision = decideReconcileForIssue(fresh, this.cfg.states);
417
- if (decision.kind === 'terminate') {
418
- this.terminateRunning(id, decision.cleanup, decision.reason);
419
- }
420
- else if (decision.kind === 'refresh' && fresh) {
421
- const entry = this.running.get(id);
422
- if (entry)
423
- entry.issue = fresh;
424
- }
425
- }
426
- terminateRunning(issueId, cleanupWorkspace, reason) {
427
- const entry = this.running.get(issueId);
428
- if (!entry)
429
- return;
430
- if (cleanupWorkspace)
431
- entry.cleanup_workspace_on_exit = true;
432
- entry.cancel();
433
- this.runLogs.get(issueId)?.system('reconciliation_terminating', {
434
- reason,
435
- cleanup_workspace: cleanupWorkspace,
436
- });
437
- log.info('reconciliation terminating run', {
438
- issue_id: issueId,
439
- issue_identifier: entry.identifier,
440
- reason,
441
- cleanup_workspace: cleanupWorkspace,
442
- });
443
- }
444
- /** Candidate eligibility. */
445
- isEligible(issue) {
446
- return this.eligibilityReason(issue, /*ignoreOwnClaim*/ false) === null;
447
- }
448
- // Returns null when eligible, otherwise a short reason string. The `ignoreOwnClaim`
449
- // form is used by the retry path so the issue's own claim/retry entry does not block
450
- // its own redispatch.
451
- eligibilityReason(issue, ignoreOwnClaim) {
452
- return computeEligibilityReason(issue, ignoreOwnClaim, this.eligibilitySnapshot());
453
- }
454
- eligibilitySnapshot() {
455
- return {
456
- active: new Set(activeStateNames(this.cfg.states).map((s) => s.toLowerCase())),
457
- terminal: new Set(terminalStateNames(this.cfg.states).map((s) => s.toLowerCase())),
458
- running: new Set(this.running.keys()),
459
- claimed: this.claimed,
460
- perStateSlot: (state) => this.hasPerStateSlot(state),
461
- };
462
- }
463
- availableGlobalSlots() {
464
- // Pending continuations hold their slot. The continuation is the
465
- // post-transition resume of an issue that just normal-exited (e.g.
466
- // Todo→Review handoff); without this, a tick firing inside the 1s
467
- // continuation window can dispatch a brand-new Todo and steal the slot
468
- // the just-transitioned issue is about to reclaim, leaving it requeued
469
- // with "no available orchestrator slots" until something else finishes.
470
- // Failure-backoff retries do NOT hold slots: the orchestrator is free
471
- // to run other work during the exponential-backoff window.
472
- let pendingContinuations = 0;
473
- for (const r of this.retryAttempts.values()) {
474
- if (r.kind === 'continuation')
475
- pendingContinuations++;
476
- }
477
- const admission = this.computeAdmission();
478
- // Log a single line when the memory clamp transitions in or out of "active." This
479
- // gives the operator the "why isn't this dispatching" signal in the log without
480
- // spamming every tick while memory stays low.
481
- if (admission.clamp_active !== this.memoryClampActive) {
482
- this.memoryClampActive = admission.clamp_active;
483
- if (admission.clamp_active) {
484
- log.info('memory admission clamping concurrency', {
485
- static_cap: admission.static_cap,
486
- effective_cap: admission.effective_cap,
487
- mem_available_mib: admission.mem_available_mib,
488
- reserve_mib: admission.reserve_mib,
489
- per_vm_mib: admission.per_vm_mib,
490
- });
491
- }
492
- else {
493
- log.info('memory admission cleared; full static cap available', {
494
- static_cap: admission.static_cap,
495
- mem_available_mib: admission.mem_available_mib,
496
- });
497
- }
498
- }
499
- return Math.max(0, admission.effective_cap - this.running.size - pendingContinuations);
500
- }
501
- /**
502
- * Compute the current memory-admission snapshot. Reads `/proc/meminfo` via the injected
503
- * probe (default reads the real file; tests inject a stub). Pure with respect to the
504
- * orchestrator state — just folds running count + config + probe reading into the
505
- * dynamic cap. Snapshot endpoint and slot accounting both call through here so they
506
- * never desync.
507
- */
508
- computeAdmission() {
509
- const staticCap = this.cfg.agent.max_concurrent_agents;
510
- const reserveMib = this.cfg.agent.host_memory_reserve_mib;
511
- const perVmMib = this.cfg.gondolin.mem_mib;
512
- const enabled = this.cfg.agent.memory_admission_enabled;
513
- const probe = enabled
514
- ? this.memProbe()
515
- : { mem_available_mib: null, supported: false };
516
- const { effective_cap, admission_room, clamp_active } = computeMemoryAdmission({
517
- enabled,
518
- static_cap: staticCap,
519
- running: this.running.size,
520
- probe,
521
- reserve_mib: reserveMib,
522
- per_vm_mib: perVmMib,
523
- });
524
- return {
525
- enabled,
526
- probe_supported: probe.supported,
527
- mem_available_mib: probe.mem_available_mib,
528
- reserve_mib: reserveMib,
529
- per_vm_mib: perVmMib,
530
- static_cap: staticCap,
531
- effective_cap,
532
- admission_room,
533
- clamp_active,
534
- };
535
- }
536
- /**
537
- * Resolve a state's per-state concurrency cap (`states.<name>.max_concurrent`)
538
- * with a case-insensitive name lookup, mirroring how issue states arrive from
539
- * the tracker in arbitrary case. Returns undefined when the state is unknown
540
- * or declares no cap.
541
- */
542
- perStateConcurrencyCap(stateName) {
543
- const lower = stateName.toLowerCase();
544
- for (const [name, sc] of Object.entries(this.cfg.states)) {
545
- if (name.toLowerCase() === lower)
546
- return sc.max_concurrent;
547
- }
548
- return undefined;
549
- }
550
- /** Per-state slot accounting using current running entries. */
551
- hasPerStateSlot(stateName) {
552
- // Per-state concurrency lives on the state (issue 137): read
553
- // `states.<name>.max_concurrent` (case-insensitively). Undefined → no
554
- // per-state cap, so only the global ceiling applies.
555
- const cap = this.perStateConcurrencyCap(stateName);
556
- if (!cap)
557
- return this.availableGlobalSlots() > 0;
558
- let inState = 0;
559
- for (const e of this.running.values()) {
560
- if (e.issue.state.toLowerCase() === stateName.toLowerCase())
561
- inState++;
562
- }
563
- // Mirror the global rule for per-state caps: a pending continuation whose
564
- // target state matches counts against the state's cap, so the resuming
565
- // worker is guaranteed a slot when its timer fires.
566
- for (const r of this.retryAttempts.values()) {
567
- if (r.kind === 'continuation' && r.target_state.toLowerCase() === stateName.toLowerCase()) {
568
- inState++;
569
- }
570
- }
571
- return inState < cap && this.availableGlobalSlots() > 0;
572
- }
573
- /** Sort: priority ASC (null last), then created_at ASC, then identifier. */
574
- sortForDispatch(issues) {
575
- return [...issues].sort((a, b) => {
576
- const pa = a.priority ?? Number.POSITIVE_INFINITY;
577
- const pb = b.priority ?? Number.POSITIVE_INFINITY;
578
- if (pa !== pb)
579
- return pa - pb;
580
- const ca = a.created_at ? Date.parse(a.created_at) : Number.POSITIVE_INFINITY;
581
- const cb = b.created_at ? Date.parse(b.created_at) : Number.POSITIVE_INFINITY;
582
- if (Number.isFinite(ca) && Number.isFinite(cb) && ca !== cb)
583
- return ca - cb;
584
- return a.identifier.localeCompare(b.identifier);
585
- });
586
- }
587
- /** Dispatch one issue. */
588
- async dispatchIssue(issue, attempt, snapshot) {
589
- if (this.running.has(issue.id))
590
- return;
591
- this.claimed.add(issue.id);
592
- this.retryAttempts.delete(issue.id);
593
- const cancel = { cancelled: false };
594
- const startedAt = new Date().toISOString();
595
- const workspacePath = this.workspaces.workspacePathFor(issue.identifier);
596
- // Snapshot tracker.root BEFORE workspace setup or Gondolin VM
597
- // bring-up. A WORKFLOW.md reload during that window (or even between
598
- // fetchCandidateIssues returning and this iteration of the dispatch loop)
599
- // can mutate the live tracker config; pinning here closes that window.
600
- // When the caller supplies a snapshot (the tick/retry path does — it
601
- // captured at the fetch atomically), prefer that value; the optional
602
- // fallback reads the live config for completeness.
603
- const trackerRootAtDispatch = snapshot?.trackerRoot ?? (this.tracker.currentRoot ? this.tracker.currentRoot() : null);
604
- // Pin the effective repo + base branch at dispatch time (same window as
605
- // tracker.root above) so a mid-run WORKFLOW.md reload can't drift the Done
606
- // action context away from the values the workspace was set up with.
607
- const githubRepoAtDispatch = resolveGithubRepo(this.cfg.workspace.github_repo);
608
- const baseBranchAtDispatch = baseBranchName(this.cfg.workspace.base_branch);
609
- // Resolve "<adapter>/<model or 'default'>" at dispatch time and pin it on
610
- // the entry. The MCP transition tool stamps this into the notes-block
611
- // header the next agent reads in `issue.description`. The helper folds any
612
- // per-state override on top of the workflow defaults; an unknown state
613
- // falls back to workflow defaults so an older test harness without a
614
- // states map still produces a non-null actor string.
615
- const resolvedActor = resolveActorString(this.cfg.states, this.cfg.acp.adapter, this.cfg.acp.model, issue.state);
616
- const entry = {
617
- issue_id: issue.id,
618
- identifier: issue.identifier,
619
- issue,
620
- session_id: null,
621
- thread_id: null,
622
- turn_id: null,
623
- adapter_pid: null,
624
- last_event: null,
625
- last_event_at: null,
626
- last_message: null,
627
- input_tokens: 0,
628
- output_tokens: 0,
629
- total_tokens: 0,
630
- last_reported_input_tokens: 0,
631
- last_reported_output_tokens: 0,
632
- last_reported_total_tokens: 0,
633
- turn_count: 0,
634
- retry_attempt: attempt,
635
- started_at: startedAt,
636
- workspace_path: workspacePath,
637
- cancel: () => {
638
- cancel.cancelled = true;
639
- },
640
- recent_events: [],
641
- last_error: null,
642
- cleanup_workspace_on_exit: false,
643
- mcp_token: null,
644
- tracker_root_at_dispatch: trackerRootAtDispatch,
645
- github_repo_at_dispatch: githubRepoAtDispatch,
646
- base_branch_at_dispatch: baseBranchAtDispatch,
647
- resolved_actor: resolvedActor,
648
- transitioned: false,
649
- steering_requested: false,
650
- steering_question: null,
651
- steering_context: null,
652
- last_transition: null,
653
- };
654
- this.running.set(issue.id, entry);
655
- const logger = withIssue({ issue_id: issue.id, issue_identifier: issue.identifier });
656
- const runLog = this.ensureRunLog(issue.id, issue.identifier);
657
- if (runLog) {
658
- runLog.setAttempt(attempt ?? 0);
659
- runLog.system('attempt_started', {
660
- attempt: attempt ?? 0,
661
- issue_state: issue.state,
662
- issue_title: issue.title,
663
- workspace_path: workspacePath,
664
- tracker_root: trackerRootAtDispatch,
665
- // Pin the per-state turn budget so the run-summary reducer can report
666
- // turns-used-vs-budget without re-resolving config (issue 123).
667
- max_turns: this.resolveStateMaxTurns(issue.state),
668
- });
669
- }
670
- logger.info('agent attempt started', { attempt });
671
- void this.runWorker(issue, attempt, entry, cancel, runLog);
672
- }
673
- /**
674
- * Open (or return the existing) per-issue run log. Returns `undefined` only when log file
675
- * opening throws — symphony should keep running even if logs can't be persisted, so the
676
- * runner sees `undefined` and behaves exactly as before.
677
- *
678
- * `issueId` (tracker primary key) is stamped on every line and is the map key so the
679
- * lifecycle survives identifier collisions or renames; `identifier` derives the filename.
680
- */
681
- ensureRunLog(issueId, identifier) {
682
- const existing = this.runLogs.get(issueId);
683
- if (existing)
684
- return existing;
685
- try {
686
- const rl = openRunLog(this.cfg.logs.root, issueId, identifier);
687
- this.runLogs.set(issueId, rl);
688
- return rl;
689
- }
690
- catch (err) {
691
- log.warn('runlog open failed; continuing without run log', {
692
- issue_id: issueId,
693
- issue_identifier: identifier,
694
- error: err.message,
695
- });
696
- return undefined;
697
- }
698
- }
699
- /**
700
- * Resolve the per-state turn budget for the run log's `attempt_started`
701
- * event. Returns null on any resolution failure (unknown state) — the
702
- * summary reducer treats a null budget as "unknown", never an error.
703
- */
704
- resolveStateMaxTurns(state) {
705
- try {
706
- return resolveDispatchConfig(this.cfg, state).max_turns;
707
- }
708
- catch {
709
- return null;
710
- }
711
- }
712
- /**
713
- * Record the end-of-attempt lifecycle events: the `transition` the agent (or
714
- * an action reroute) performed during this attempt, if any, followed by
715
- * `attempt_ended`. Both feed the run-summary reducer (issue 123); recording
716
- * the transition here — once per attempt, off the hot path — is what makes
717
- * the state path, rejection notes, and terminal outcome reconstructable.
718
- */
719
- recordAttemptEnd(runLog, entry, ok, reason, turnsCompleted) {
720
- if (entry.last_transition)
721
- runLog?.system('transition', { ...entry.last_transition });
722
- runLog?.system('attempt_ended', { ok, reason, turns_completed: turnsCompleted });
723
- }
724
- closeRunLog(issueId, fields, opts = {}) {
725
- // If a terminal cleanup (`workspaces.remove`) is mid-flight for this issue, the
726
- // terminal-state actions capture may still be writing to the run log. Closing the
727
- // log here would truncate those lines on disk. Defer until the cleanup's .finally
728
- // fires the close with `viaCleanup: true`.
729
- if (!opts.viaCleanup && this.cleanupInFlight.has(issueId))
730
- return;
731
- const rl = this.runLogs.get(issueId);
732
- if (!rl)
733
- return;
734
- if (fields)
735
- rl.system('runlog_closed', fields);
736
- // Emit the compact per-issue run summary (issue 123) at the terminal unwind,
737
- // when the lifecycle accumulator holds the full trajectory. Pure over
738
- // in-memory state, so it precedes (and does not depend on) the stream flush.
739
- rl.writeSummary();
740
- this.runLogs.delete(issueId);
741
- void rl.close();
742
- }
743
- async runWorker(issue, attempt, entry, cancelSignal, runLog) {
744
- const logger = withIssue({ issue_id: issue.id, issue_identifier: issue.identifier });
745
- let ok = false;
746
- let reason = 'unknown';
747
- let turnsCompleted = 0;
748
- try {
749
- const result = await this.runner.runAttempt(issue, attempt, cancelSignal, entry, runLog);
750
- ok = result.ok;
751
- reason = result.reason;
752
- turnsCompleted = result.turnsCompleted;
753
- if (result.threadId)
754
- entry.thread_id = result.threadId;
755
- entry.turn_count = result.turnsCompleted;
756
- }
757
- catch (err) {
758
- ok = false;
759
- reason = err.message;
760
- logger.error('worker threw', { error: reason });
761
- }
762
- this.recordAttemptEnd(runLog, entry, ok, reason, turnsCompleted);
763
- this.onWorkerExit(issue.id, ok, reason, entry);
764
- }
765
- /** on_worker_exit */
766
- onWorkerExit(issueId, normal, reason, entry) {
767
- this.running.delete(issueId);
768
- // Issue 52: the reconciler `vm` resource is the sole owner of VM teardown.
769
- // Every worker exit — clean or not — kicks the reaper so the intended set
770
- // (now excluding this issue) converges in a single pass.
771
- if (this.reconciler && !this.stopped) {
772
- void this.reconciler.reapVms().catch((err) => log.debug('post-exit vm reap failed', { error: err.message }));
773
- }
774
- const elapsedMs = Date.now() - Date.parse(entry.started_at);
775
- if (Number.isFinite(elapsedMs))
776
- this.sessionTotals.seconds_running += elapsedMs / 1000;
777
- const logger = withIssue({ issue_id: issueId, issue_identifier: entry.identifier });
778
- if (entry.cleanup_workspace_on_exit)
779
- this.scheduleWorkspaceCleanup(issueId, entry, logger);
780
- // If the service was stopped while this worker was unwinding, do not schedule
781
- // a new retry — that would leave a live timer behind even though stop() was called.
782
- if (this.stopped) {
783
- this.claimed.delete(issueId);
784
- this.circuitBreakers.delete(issueId);
785
- return;
786
- }
787
- if (normal)
788
- this.completed.add(issueId);
789
- this.recordSleepCycleProgress(entry);
790
- // Circuit breaker (issue 128): a deterministically-failing dispatch (same
791
- // reason every attempt) would otherwise retry forever under backoff. Trip
792
- // after the configured streak and route the issue to a holding state
793
- // instead of scheduling another retry.
794
- if (this.updateCircuitBreaker(issueId, normal, reason, entry))
795
- return;
796
- const plan = decideExitRetry({
797
- normal,
798
- reason,
799
- priorAttempt: entry.retry_attempt,
800
- targetState: entry.issue.state,
801
- continuationDelayMs: CONTINUATION_DELAY_MS,
802
- failureBaseMs: FAILURE_BASE_MS,
803
- maxBackoffMs: this.cfg.agent.max_retry_backoff_ms,
804
- });
805
- if (normal) {
806
- logger.info('worker exited (normal)', { reason });
807
- }
808
- else {
809
- logger.warn('worker exited (abnormal)', {
810
- reason,
811
- next_attempt: plan.attempt,
812
- delay_ms: plan.delayMs,
813
- });
814
- }
815
- this.scheduleRetry(issueId, { identifier: entry.identifier, ...plan });
816
- }
817
- /**
818
- * Fold this exit into the per-issue circuit-breaker streak (issue 128) and
819
- * return true when the breaker tripped — the caller must then NOT schedule a
820
- * retry. A clean exit clears the streak; an abnormal exit either records the
821
- * (normalized) failure or, on reaching `agent.circuit_breaker_threshold`
822
- * consecutive identical failures, trips and fires the holding-state route.
823
- * The pure `decideCircuitBreaker` owns the counting; this shell just persists
824
- * the streak and dispatches the side effect.
825
- */
826
- updateCircuitBreaker(issueId, normal, reason, entry) {
827
- const decision = decideCircuitBreaker({
828
- normal,
829
- reason,
830
- prior: this.circuitBreakers.get(issueId) ?? null,
831
- threshold: this.cfg.agent.circuit_breaker_threshold,
832
- });
833
- if (decision.kind === 'continue') {
834
- this.circuitBreakers.set(issueId, {
835
- normalizedReason: decision.normalizedReason,
836
- count: decision.count,
837
- });
838
- return false;
839
- }
840
- this.circuitBreakers.delete(issueId);
841
- if (decision.kind === 'trip') {
842
- void this.tripCircuitBreaker(issueId, entry, reason, decision.count);
843
- return true;
844
- }
845
- return false;
846
- }
847
- /**
848
- * Stop retrying a circuit-broken issue and move it into a holding state so a
849
- * human sees "stuck on identical failure" on the dashboard rather than a
850
- * silent multi-hour loop. The move is restart-safe (the file leaves the
851
- * active set on disk, so the loop cannot resume on the next process start).
852
- * If routing fails — no `holding` state declared, or the tracker can't write
853
- * — we keep the issue's dispatch claim so the tick's `already claimed` gate
854
- * still halts the loop for this session, and log loudly.
855
- */
856
- async tripCircuitBreaker(issueId, entry, reason, count) {
857
- const logger = withIssue({ issue_id: issueId, issue_identifier: entry.identifier });
858
- this.runLogs.get(issueId)?.system('circuit_breaker_tripped', {
859
- reason,
860
- consecutive_failures: count,
861
- });
862
- logger.error('circuit breaker tripped; halting retries', { reason, consecutive_failures: count });
863
- let holdingState;
864
- try {
865
- holdingState = pickHoldingState(this.cfg.states);
866
- }
867
- catch {
868
- logger.error('no holding state declared; retaining claim to halt the retry loop', { reason });
869
- return;
870
- }
871
- const moved = await this.routeToHolding(issueId, entry, holdingState, reason, count);
872
- if (moved) {
873
- this.claimed.delete(issueId);
874
- this.closeRunLog(issueId, { reason: 'circuit_breaker_tripped' });
875
- }
876
- }
877
- /**
878
- * Move `entry`'s tracker file into `holdingState`, appending a diagnostic
879
- * note (rendered into the issue body before the rename) so the operator sees
880
- * why it stopped. Returns false when the tracker can't perform the move so
881
- * the caller can fall back to retaining the claim. Modelled on the runner's
882
- * action-reroute path; the orchestrator owns this move because it is
883
- * state-machine behavior, not repo-local glue.
884
- */
885
- async routeToHolding(issueId, entry, holdingState, reason, count) {
886
- if (!this.tracker.moveIssueToState)
887
- return false;
888
- const notes = [
889
- `**Circuit breaker tripped** — routed to \`${holdingState}\` for human inspection.`,
890
- '',
891
- `Symphony stopped retrying after **${count} consecutive attempts failed with the same error**, to avoid an unbounded dispatch loop (issue 128).`,
892
- '',
893
- `**Last failure reason:** ${reason}`,
894
- '',
895
- `Resolve the underlying cause, then move the issue back into an active state to resume dispatch.`,
896
- ].join('\n');
897
- try {
898
- await this.tracker.moveIssueToState(issueId, holdingState, {
899
- fromRoot: entry.tracker_root_at_dispatch ?? undefined,
900
- fromState: entry.issue.state,
901
- notes,
902
- actor: entry.resolved_actor,
903
- });
904
- return true;
905
- }
906
- catch (err) {
907
- withIssue({ issue_id: issueId, issue_identifier: entry.identifier }).error('circuit breaker route to holding failed; retaining claim', { error: err.message });
908
- return false;
909
- }
910
- }
911
- /**
912
- * Sleep-cycle auto-arm (issue 125): fold a finished attempt into the
913
- * terminal-transition counter the triggers read. Only the work the reflector
914
- * mines counts — a transition into a `role: terminal` state (Done/Cancelled)
915
- * — and the reflection issue's own moves (it can only go to its holding
916
- * dormant state) are excluded so a reflection run never counts toward arming
917
- * the next one. No-op when the block is disabled.
918
- */
919
- recordSleepCycleProgress(entry) {
920
- const arm = deriveArmRouting(this.cfg.states);
921
- if (!arm.issue)
922
- return;
923
- if (entry.issue_id === arm.issue || entry.identifier === arm.issue)
924
- return;
925
- if (entry.last_transition?.terminal)
926
- this.doneSinceReflect += 1;
927
- }
928
- /**
929
- * Sleep-cycle auto-arm (issue 125): decide — purely — whether to arm the
930
- * reflection cycle this tick, and fire the async Dormant → Reflect move when
931
- * a trigger fires. `noActiveCandidates` is true when this poll surfaced no
932
- * active-state issues; combined with empty running/claimed/retry sets that is
933
- * the orchestrator-idle signal. The in-flight guard prevents two overlapping
934
- * ticks from both launching the move.
935
- */
936
- maybeArmSleepCycle(noActiveCandidates) {
937
- const arm = deriveArmRouting(this.cfg.states);
938
- if (!arm.armState || this.armingReflection)
939
- return;
940
- const idle = noActiveCandidates &&
941
- this.running.size === 0 &&
942
- this.claimed.size === 0 &&
943
- this.retryAttempts.size === 0;
944
- const trigger = decideSleepCycleArm({
945
- enabled: true,
946
- issueId: arm.issue,
947
- armOnIdle: arm.onIdle,
948
- armAfterDone: arm.afterTerminal,
949
- doneSinceReflect: this.doneSinceReflect,
950
- idle,
951
- });
952
- if (!trigger)
953
- return;
954
- this.armingReflection = true;
955
- void this.armReflection(arm, trigger).finally(() => {
956
- this.armingReflection = false;
957
- });
958
- }
959
- /**
960
- * Move the reflection issue from its dormant (holding) state into the active
961
- * reflect state. Guarded so it is a no-op unless the issue currently lives in
962
- * `dormant_state` (so we never yank it out of Reflect mid-run or relocate it
963
- * from some other state), and the counter only resets on a successful move so
964
- * a failed arm doesn't silently discard the accumulated terminal count.
965
- */
966
- async armReflection(arm, trigger) {
967
- if (!this.tracker.moveIssueToState || !arm.issue || !arm.from || !arm.armState)
968
- return;
969
- const current = await this.fetchReflectionIssue(arm.issue);
970
- if (!current)
971
- return;
972
- if (current.state.toLowerCase() !== arm.from.toLowerCase())
973
- return;
974
- if (this.running.has(current.id) || this.claimed.has(current.id))
975
- return;
976
- const count = this.doneSinceReflect;
977
- try {
978
- await this.tracker.moveIssueToState(current.id, arm.armState, {
979
- fromState: arm.from,
980
- notes: sleepCycleArmNotes(trigger, count, arm.afterTerminal),
981
- actor: SLEEP_CYCLE_ACTOR,
982
- });
983
- this.doneSinceReflect = 0;
984
- log.info('sleep cycle armed reflection', {
985
- issue_id: current.id,
986
- trigger,
987
- done_since_reflect: count,
988
- reflect_state: arm.armState,
989
- });
990
- }
991
- catch (err) {
992
- log.warn('sleep cycle arm failed', {
993
- issue_id: current.id,
994
- error: err.message,
995
- });
996
- }
997
- }
998
- /** Look up the reflection issue's current tracker state; undefined on miss/error. */
999
- async fetchReflectionIssue(issueId) {
1000
- try {
1001
- const found = await this.tracker.fetchIssueStatesByIds([issueId]);
1002
- return found.find((i) => i.id === issueId || i.identifier === issueId);
1003
- }
1004
- catch (err) {
1005
- log.debug('sleep cycle reflection-issue lookup failed', {
1006
- issue_id: issueId,
1007
- error: err.message,
1008
- });
1009
- return undefined;
1010
- }
1011
- }
1012
- /**
1013
- * Workspace removal deferred until the worker has fully unwound (including the
1014
- * terminal-state `actions:` block) so we never delete the dir while the agent
1015
- * is still inside it.
1016
- */
1017
- scheduleWorkspaceCleanup(issueId, entry, logger) {
1018
- this.cleanupInFlight.add(issueId);
1019
- this.workspaces
1020
- .remove(entry.identifier)
1021
- .catch((err) => logger.warn('workspace removal failed', { error: err.message }))
1022
- .finally(() => {
1023
- this.cleanupInFlight.delete(issueId);
1024
- this.closeRunLog(issueId, { reason: 'cleanup_on_exit' }, { viaCleanup: true });
1025
- });
1026
- }
1027
- /** Retry queue. */
1028
- scheduleRetry(issueId, sched) {
1029
- if (this.stopped)
1030
- return;
1031
- const existing = this.retryAttempts.get(issueId);
1032
- if (existing)
1033
- clearTimeout(existing.timer_handle);
1034
- const dueAt = Date.now() + sched.delayMs;
1035
- const handle = setTimeout(() => void this.onRetryTimer(issueId), sched.delayMs);
1036
- this.retryAttempts.set(issueId, {
1037
- issue_id: issueId,
1038
- identifier: sched.identifier,
1039
- attempt: sched.attempt,
1040
- due_at_ms: dueAt,
1041
- timer_handle: handle,
1042
- error: sched.error,
1043
- kind: sched.kind,
1044
- target_state: sched.target_state,
1045
- });
1046
- this.claimed.add(issueId);
1047
- }
1048
- /** on_retry_timer */
1049
- async onRetryTimer(issueId) {
1050
- if (this.stopped)
1051
- return;
1052
- const entry = this.retryAttempts.get(issueId);
1053
- if (!entry)
1054
- return;
1055
- this.retryAttempts.delete(issueId);
1056
- const fetched = await this.fetchRetryCandidates(issueId, entry);
1057
- if (!fetched)
1058
- return;
1059
- const issue = fetched.issues.find((i) => i.id === issueId);
1060
- if (!issue) {
1061
- this.releaseRetryClaim(issueId, entry.identifier, 'not_in_candidates');
1062
- return;
1063
- }
1064
- const reason = this.eligibilityReason(issue, true);
1065
- if (reason !== null) {
1066
- this.handleRetryIneligible(issue, entry, reason);
1067
- return;
1068
- }
1069
- void this.dispatchIssue(issue, entry.attempt, { trackerRoot: fetched.root });
1070
- }
1071
- /**
1072
- * Tracker poll for the retry timer. Returns the snapshot or null when the
1073
- * fetch failed (a failure-shaped retry is rescheduled internally so the
1074
- * caller can just bail).
1075
- */
1076
- async fetchRetryCandidates(issueId, entry) {
1077
- try {
1078
- const r = await this.tracker.fetchCandidateIssues();
1079
- return { issues: r.issues, root: r.root };
1080
- }
1081
- catch (err) {
1082
- log.debug('retry poll failed', {
1083
- issue_id: issueId,
1084
- issue_identifier: entry.identifier,
1085
- error: err.message,
1086
- });
1087
- this.scheduleRetry(issueId, {
1088
- identifier: entry.identifier,
1089
- attempt: entry.attempt + 1,
1090
- delayMs: Math.min(FAILURE_BASE_MS * Math.pow(2, entry.attempt), this.cfg.agent.max_retry_backoff_ms),
1091
- error: 'retry poll failed',
1092
- kind: 'failure',
1093
- target_state: entry.target_state,
1094
- });
1095
- return null;
1096
- }
1097
- }
1098
- /**
1099
- * Re-applied candidate eligibility came back non-null. The pure
1100
- * `decideRetryAfterIneligible` picks between rescheduling (only `no
1101
- * per-state slot`, which is genuine contention) and releasing the claim
1102
- * (everything else: blocker, missing fields, non-active state).
1103
- */
1104
- handleRetryIneligible(issue, entry, reason) {
1105
- const action = decideRetryAfterIneligible({
1106
- reason,
1107
- priorAttempt: entry.attempt,
1108
- targetState: issue.state,
1109
- failureBaseMs: FAILURE_BASE_MS,
1110
- maxBackoffMs: this.cfg.agent.max_retry_backoff_ms,
1111
- });
1112
- if (action.kind === 'release') {
1113
- this.releaseRetryClaim(issue.id, entry.identifier, `ineligible:${reason}`, reason);
1114
- return;
1115
- }
1116
- this.scheduleRetry(issue.id, { identifier: issue.identifier, ...action.plan });
1117
- }
1118
- releaseRetryClaim(issueId, identifier, closeTag, ineligibleReason) {
1119
- this.claimed.delete(issueId);
1120
- this.circuitBreakers.delete(issueId);
1121
- log.info(ineligibleReason
1122
- ? 'retry releasing claim (ineligible)'
1123
- : 'retry releasing claim (not in candidates)', { issue_id: issueId, issue_identifier: identifier, ...(ineligibleReason ? { reason: ineligibleReason } : {}) });
1124
- this.closeRunLog(issueId, { reason: `claim_released_${closeTag}` });
1125
- }
1126
- /**
1127
- * Implements {@link IntendedVmProvider}. Returns the set of `symphony-*` VM
1128
- * names the orchestrator currently intends to keep alive — one per running
1129
- * dispatch. Used by the reconciler's vm resource to compute the orphan set
1130
- * to reap. `running.set` happens BEFORE the runner calls `createVm`, so a VM
1131
- * that exists in Gondolin's session registry as part of an in-flight create
1132
- * is always already represented
1133
- * here. The reaper sees it as intended and leaves it alone, closing the
1134
- * "creating-but-not-yet-active" race the issue body calls out.
1135
- */
1136
- intendedVmNames() {
1137
- const out = new Set();
1138
- for (const entry of this.running.values()) {
1139
- out.add(this.runner.vmNameFor(entry.issue));
1140
- }
1141
- return out;
1142
- }
1143
- /**
1144
- * Implements {@link WorkspaceIntendedProvider}. Returns the map of
1145
- * identifier → state the reconciler should preserve workspaces for. Two
1146
- * sources are unioned:
1147
- *
1148
- * • Tracker view: every issue file in a non-terminal state. Anything
1149
- * terminal (Done, Cancelled) is fair game for removal — this replaces
1150
- * the old `startupTerminalCleanup` sweep with a continuous pass.
1151
- * • In-flight allocations: running entries plus claimed/pending retries.
1152
- * The window between dispatch claiming an issue and the tracker
1153
- * reflecting it is brief but real; without this, a fresh dispatch's
1154
- * workspace could be reaped seconds after creation.
1155
- *
1156
- * The state value is carried so the reconciler's `create` callback can apply
1157
- * the merge-state guard (it skips eager recreation of a workspace whose issue
1158
- * sits in the autopilot's merge state).
1159
- *
1160
- * Tracker errors propagate. Catching them here would cause an empty set
1161
- * to be returned, which the reconciler would treat as authoritative and
1162
- * reap every workspace — the regression this contract closes. The
1163
- * resource's `reconcile()` catches the throw and leaves on-disk state
1164
- * untouched until the next pass.
1165
- *
1166
- * Mirrors `intendedVmNames()` in shape so the reconciler's race-condition
1167
- * reasoning is the same across both janitors.
1168
- */
1169
- async activeIdentifiers() {
1170
- const out = new Map();
1171
- const nonTerminal = [];
1172
- for (const [name, cfg] of Object.entries(this.cfg.states)) {
1173
- if (cfg.role !== 'terminal')
1174
- nonTerminal.push(name);
1175
- }
1176
- const issues = await this.tracker.fetchIssuesByStates(nonTerminal);
1177
- for (const i of issues)
1178
- out.set(i.identifier, i.state);
1179
- // Issue 38/139: when the PR engine is enabled, the merge-state issues'
1180
- // workspaces are owned by the pr resource (it rebases inside them and cleans
1181
- // them up post-merge). Include those identifiers in the desired set so the
1182
- // workspace janitor doesn't reap a workspace the pr resource is actively
1183
- // driving. The merge state is derived by scanning states for `pr.auto_merge`
1184
- // (no named-string sibling block). The orchestrator's `createWorkspace`
1185
- // callback declines to eagerly recreate a missing merge-state workspace — so
1186
- // adding it here is safe: the workspace either already exists from the
1187
- // dispatch that ran the issue into the merge state, or it doesn't and the
1188
- // autopilot just skips the rebase step for that PR.
1189
- if (this.cfg.pr.enabled) {
1190
- const mergeState = derivePrRouting(this.cfg.states).mergeState;
1191
- if (mergeState) {
1192
- const mergeIssues = await this.tracker.fetchIssuesByStates([mergeState]);
1193
- for (const i of mergeIssues)
1194
- out.set(i.identifier, i.state);
1195
- }
1196
- }
1197
- return out;
1198
- }
1199
- /**
1200
- * Identifiers the orchestrator has claimed for dispatch but the tracker may
1201
- * not yet reflect as active, with the state the eager workspace create should
1202
- * see (used for the merge-state guard). Running entries carry the state the
1203
- * dispatch was claimed from (`issue.state`); pending retries carry their
1204
- * `target_state` (where the next attempt will run).
1205
- */
1206
- inFlightIdentifiers() {
1207
- const out = new Map();
1208
- for (const e of this.running.values())
1209
- out.set(e.identifier, e.issue.state);
1210
- for (const r of this.retryAttempts.values())
1211
- out.set(r.identifier, r.target_state);
1212
- return out;
1213
- }
1214
- /**
1215
- * Implements {@link ProposeFollowupSink} for the action executor's
1216
- * `propose_followup` action (issue 36). Same tracker shape as the MCP
1217
- * `propose_issue` tool — file lands in the first declared `holding` state,
1218
- * with `proposed_by` set to the parent issue's identifier. Uses the live
1219
- * tracker root (passed-in parent identifier is the canonical attribution).
1220
- */
1221
- async proposeFollowup(input) {
1222
- const root = this.tracker.currentRoot ? this.tracker.currentRoot() : this.cfg.tracker.root;
1223
- if (!root) {
1224
- throw new Error('tracker root not available; cannot file propose_followup');
1225
- }
1226
- const landingState = pickHoldingState(this.cfg.states);
1227
- const result = await writeIssueFile({
1228
- trackerRoot: root,
1229
- state: landingState,
1230
- title: input.title,
1231
- description: input.description ?? '',
1232
- priority: input.priority ?? null,
1233
- labels: input.labels ?? [],
1234
- now: () => Date.now(),
1235
- extra_front_matter: {
1236
- proposed_by: input.parent_identifier,
1237
- proposed_at: new Date().toISOString(),
1238
- },
1239
- });
1240
- log.info('action propose_followup', {
1241
- proposed_by: input.parent_identifier,
1242
- identifier: result.identifier,
1243
- state: result.state,
1244
- });
1245
- return { identifier: result.identifier };
1246
- }
1247
- /**
1248
- * Receive a per-attempt action ledger from the runner's cleanup pass. The
1249
- * snapshot is keyed by state so the dashboard can render "Done.actions:
1250
- * push_branch ok, create_pr_if_missing rate-limited, retrying" without the
1251
- * orchestrator having to know about specific action kinds.
1252
- *
1253
- * `id` should follow the `actions:<StateName>` convention so it sorts
1254
- * predictably next to reconciler-resource rows in
1255
- * `snapshot.reconciler.resources`.
1256
- */
1257
- recordActionResult(id, snapshot) {
1258
- this.lastActionResults.set(id, snapshot);
1259
- }
1260
- /**
1261
- * Workspace removal callback the reconciler invokes for stale dirs (issue
1262
- * 34). Defers to `WorkspaceManager.remove` (a best-effort `rm -rf`).
1263
- * Failures are logged at warn (the reconciler's action ledger also records
1264
- * them).
1265
- */
1266
- async removeWorkspace(identifier) {
1267
- await this.workspaces.remove(identifier);
1268
- }
1269
- /**
1270
- * Workspace create callback the reconciler invokes for non-terminal issues
1271
- * whose dirs are not yet on disk (issue 34). Delegates to
1272
- * `WorkspaceManager.ensureFor` so the same canonical clone+branch+remote
1273
- * setup the dispatch path runs also fires here.
1274
- *
1275
- * The intended-set provider supplies the state alongside the identifier so
1276
- * the merge-state guard below can fire; the `null` fallback is defensive —
1277
- * production callers always pass a state.
1278
- *
1279
- * Race with the runner's dispatch-time `ensureFor` is handled inside
1280
- * `WorkspaceManager` via a per-identifier in-flight promise lock: both
1281
- * callers coalesce into one setup pass, so the canonical setup runs exactly
1282
- * once whether the reconciler or the runner wins the race.
1283
- */
1284
- async createWorkspace(identifier, state) {
1285
- // Issue 38: refuse to eagerly recreate a missing workspace for an issue
1286
- // in the autopilot's merge state. Those workspaces only exist as
1287
- // leftovers from a prior dispatch; recreating one from scratch would
1288
- // miss the agent's local commits (the agent's branch is on the remote,
1289
- // but a fresh clone would still need a separate fetch to pick it up).
1290
- // Operators who genuinely want a recreated workspace can cancel the
1291
- // issue (Cancelled triggers the close path + normal cleanup) and refile.
1292
- if (this.cfg.pr.enabled && state !== null) {
1293
- const mergeState = derivePrRouting(this.cfg.states).mergeState;
1294
- if (mergeState && state.toLowerCase() === mergeState.toLowerCase()) {
1295
- return;
1296
- }
1297
- }
1298
- await this.workspaces.ensureFor(identifier);
1299
- }
1300
- /**
1301
- * Implements {@link PrIntendedProvider} (issue 38). Returns the set of
1302
- * terminal-state issues the PR autopilot should manage:
1303
- *
1304
- * • Issues in the configured `merge_state` (default `Done`) become
1305
- * `kind: 'merge'` intents. The autopilot rebases them on
1306
- * `origin/<base>` and arms GitHub auto-merge.
1307
- * • Issues in the configured `close_state` (default `Cancelled`) become
1308
- * `kind: 'close'` intents. The autopilot closes the PR without merge
1309
- * and best-effort-deletes the remote branch. The workspace is NOT
1310
- * supplied — Cancelled cleanup goes through the orchestrator's
1311
- * standard terminal path.
1312
- *
1313
- * Both queries hit the tracker; failures bubble (the pr resource catches
1314
- * and surfaces in last_error so a transient tracker hiccup doesn't blank
1315
- * the autopilot's intended set).
1316
- *
1317
- * When `pr.enabled` is false this method is never invoked (the reconciler
1318
- * skips its pr pass entirely), but the early return keeps the public surface
1319
- * idempotent. The merge/close targets are derived by scanning states for the
1320
- * per-state `pr:` field (issue 139), not read from named strings.
1321
- */
1322
- async prIntended() {
1323
- if (!this.cfg.pr.enabled)
1324
- return [];
1325
- const { mergeState, closeState } = derivePrRouting(this.cfg.states);
1326
- if (!mergeState && !closeState)
1327
- return [];
1328
- const baseBranch = baseBranchName(this.cfg.workspace.base_branch);
1329
- const fetchStates = [mergeState, closeState].filter((s) => s !== null);
1330
- const issues = await this.tracker.fetchIssuesByStates(fetchStates);
1331
- const out = [];
1332
- for (const issue of issues) {
1333
- const intent = classifyPrIntent({
1334
- issue,
1335
- mergeState: mergeState ?? '',
1336
- closeState,
1337
- baseBranch,
1338
- mergeWorkspacePath: this.workspaces.workspacePathFor(issue.identifier),
1339
- });
1340
- if (intent)
1341
- out.push(intent);
1342
- }
1343
- return out;
1344
- }
1345
- /**
1346
- * Tracker-side transition the PR autopilot uses to route a conflict-rebasing
1347
- * issue back into the implementing state (or, after exceeding the attempt
1348
- * limit, into the holding state). Same shape as the MCP transition tool —
1349
- * the tracker handles atomic notes-append + cross-directory rename.
1350
- *
1351
- * No workspace flag is touched here: the target state's `role` decides
1352
- * cleanup at the transition's own level (active/holding never trigger
1353
- * cleanup), and the workspace was preserved across the move into the merge
1354
- * state in the first place because the PR engine suppresses the terminal
1355
- * cleanup for that target. See {@link McpRegistry.performTransition} for the
1356
- * role-driven rule.
1357
- */
1358
- async routeIssueForAutopilot(input) {
1359
- if (!this.tracker.moveIssueToState) {
1360
- throw new Error('tracker does not support state transitions');
1361
- }
1362
- // The tracker file's `id` may diverge from the identifier when the file
1363
- // sets an explicit front-matter `id`. Resolve via a candidate scan so the
1364
- // tracker can find the right file even with that aliasing.
1365
- let issueId = null;
1366
- try {
1367
- const candidates = await this.tracker.fetchIssuesByStates([input.fromState]);
1368
- const match = candidates.find((c) => c.identifier === input.identifier);
1369
- if (match)
1370
- issueId = match.id;
1371
- }
1372
- catch {
1373
- // Fall through to identifier fallback below.
1374
- }
1375
- if (issueId === null)
1376
- issueId = input.identifier;
1377
- await this.tracker.moveIssueToState(issueId, input.toState, {
1378
- fromState: input.fromState,
1379
- notes: input.notes,
1380
- actor: input.actor,
1381
- });
1382
- }
1383
- /**
1384
- * Implements {@link BaseRefProvider}. Returns the configured base branch
1385
- * name AND its current SHA in the source repo (workflow_dir by default).
1386
- * Returns null when the SHA can't be resolved (no `.git`, base branch
1387
- * missing, etc.) — drift detection skips the pass.
1388
- *
1389
- * Why both fields: the reconciler's drift check compares the workspace's
1390
- * own copy of `<branch>` (frozen at clone time) against this SHA. Returning
1391
- * the branch name keeps the source-of-truth in one place; the inspector
1392
- * uses it to run `git rev-parse <branch>` inside the workspace.
1393
- *
1394
- * The base branch (`workspace.base_branch`, or the `SYMPHONY_BASE_BRANCH` env
1395
- * override, default `main`) is resolved the same way the dispatch-time clone
1396
- * does, so the drift check compares against the same ref the workspace was
1397
- * originally cloned from.
1398
- */
1399
- async currentBaseRef() {
1400
- const branch = process.env.SYMPHONY_BASE_BRANCH && process.env.SYMPHONY_BASE_BRANCH.length > 0
1401
- ? process.env.SYMPHONY_BASE_BRANCH
1402
- : this.cfg.workspace.base_branch;
1403
- const sourceRepo = process.env.SYMPHONY_SOURCE_REPO && process.env.SYMPHONY_SOURCE_REPO.length > 0
1404
- ? process.env.SYMPHONY_SOURCE_REPO
1405
- : this.cfg.workflow_dir;
1406
- const r = await runProcess('git', ['rev-parse', branch], { cwd: sourceRepo });
1407
- if (r.exit_code !== 0)
1408
- return null;
1409
- const sha = r.stdout.trim();
1410
- return sha.length > 0 ? { branch, sha } : null;
1411
- }
1412
- // Public callbacks the runner uses to feed events back.
1413
- reportTokenUsage(issueId, usage) {
1414
- const e = this.running.get(issueId);
1415
- if (!e)
1416
- return;
1417
- // §9.4: prefer absolute totals; track deltas to avoid double-counting.
1418
- const dIn = Math.max(0, usage.input_tokens - e.last_reported_input_tokens);
1419
- const dOut = Math.max(0, usage.output_tokens - e.last_reported_output_tokens);
1420
- const dTot = Math.max(0, usage.total_tokens - e.last_reported_total_tokens);
1421
- e.input_tokens = usage.input_tokens;
1422
- e.output_tokens = usage.output_tokens;
1423
- e.total_tokens = usage.total_tokens;
1424
- e.last_reported_input_tokens = usage.input_tokens;
1425
- e.last_reported_output_tokens = usage.output_tokens;
1426
- e.last_reported_total_tokens = usage.total_tokens;
1427
- this.sessionTotals.input_tokens += dIn;
1428
- this.sessionTotals.output_tokens += dOut;
1429
- this.sessionTotals.total_tokens += dTot;
1430
- }
1431
- reportRateLimits(_issueId, snapshot) {
1432
- this.rateLimits = snapshot;
1433
- }
1434
- reportRuntimeEvent(issueId, ev) {
1435
- const e = this.running.get(issueId);
1436
- if (!e)
1437
- return;
1438
- e.last_event = ev.event;
1439
- e.last_event_at = ev.at;
1440
- e.last_message = ev.message;
1441
- e.recent_events.push(ev);
1442
- if (e.recent_events.length > 50)
1443
- e.recent_events.shift();
1444
- }
1445
- reportSessionStarted(issueId, info) {
1446
- const e = this.running.get(issueId);
1447
- if (!e)
1448
- return;
1449
- e.session_id = info.sessionId;
1450
- e.thread_id = info.threadId;
1451
- e.adapter_pid = info.pid;
1452
- }
1453
- reportTurnStarted(issueId, turnNumber) {
1454
- const e = this.running.get(issueId);
1455
- if (!e)
1456
- return;
1457
- e.turn_count = turnNumber;
1458
- }
1459
- /** §9.3 snapshot. */
1460
- snapshot() {
1461
- const generatedAt = new Date().toISOString();
1462
- const liveExtraSeconds = [...this.running.values()]
1463
- .map((e) => (Date.now() - Date.parse(e.started_at)) / 1000)
1464
- .reduce((a, b) => a + b, 0);
1465
- return {
1466
- generated_at: generatedAt,
1467
- counts: { running: this.running.size, retrying: this.retryAttempts.size },
1468
- running: [...this.running.values()].map((e) => ({
1469
- issue_id: e.issue_id,
1470
- issue_identifier: e.identifier,
1471
- issue_title: e.issue.title ?? '',
1472
- issue_body: e.issue.description ?? '',
1473
- state: e.issue.state,
1474
- session_id: e.session_id,
1475
- turn_count: e.turn_count,
1476
- last_event: e.last_event,
1477
- last_message: e.last_message,
1478
- started_at: e.started_at,
1479
- last_event_at: e.last_event_at,
1480
- tokens: {
1481
- input_tokens: e.input_tokens,
1482
- output_tokens: e.output_tokens,
1483
- total_tokens: e.total_tokens,
1484
- },
1485
- steering_requested: e.steering_requested,
1486
- steering_question: e.steering_question,
1487
- steering_context: e.steering_context,
1488
- transitioned: e.transitioned,
1489
- })),
1490
- retrying: [...this.retryAttempts.values()].map((r) => ({
1491
- issue_id: r.issue_id,
1492
- issue_identifier: r.identifier,
1493
- attempt: r.attempt,
1494
- due_at: new Date(r.due_at_ms).toISOString(),
1495
- error: r.error,
1496
- kind: r.kind,
1497
- })),
1498
- session_totals: {
1499
- ...this.sessionTotals,
1500
- seconds_running: this.sessionTotals.seconds_running + liveExtraSeconds,
1501
- },
1502
- rate_limits: this.rateLimits,
1503
- memory_admission: this.computeAdmission(),
1504
- reconciler: this.buildReconcilerSnapshot(),
1505
- };
1506
- }
1507
- /**
1508
- * Combine the reconciler's resource snapshot with the most recent action
1509
- * results so the dashboard sees both surfaces under one `reconciler.resources`
1510
- * list. When neither side has anything to report (no reconciler wired AND no
1511
- * actions ever ran), the field is null to preserve the existing test
1512
- * harness's "no reconciler" shape.
1513
- */
1514
- buildReconcilerSnapshot() {
1515
- const base = this.reconciler ? this.reconciler.snapshot() : null;
1516
- if (!base && this.lastActionResults.size === 0)
1517
- return null;
1518
- const resources = base ? [...base.resources] : [];
1519
- for (const snap of this.lastActionResults.values())
1520
- resources.push(snap);
1521
- return { resources };
1522
- }
1523
- /** Issue-detail view used by the HTTP /api/v1/<identifier> endpoint. */
1524
- detailByIdentifier(identifier) {
1525
- const entry = this.findRunningByIdentifier(identifier);
1526
- const retry = this.findRetryByIdentifier(identifier);
1527
- return buildIssueDetailDto(identifier, entry
1528
- ? {
1529
- issue_id: entry.issue_id,
1530
- identifier: entry.identifier,
1531
- workspace_path: entry.workspace_path,
1532
- session_id: entry.session_id,
1533
- turn_count: entry.turn_count,
1534
- state: entry.issue.state,
1535
- started_at: entry.started_at,
1536
- last_event: entry.last_event,
1537
- last_message: entry.last_message,
1538
- last_event_at: entry.last_event_at,
1539
- input_tokens: entry.input_tokens,
1540
- output_tokens: entry.output_tokens,
1541
- total_tokens: entry.total_tokens,
1542
- recent_events: entry.recent_events,
1543
- last_error: entry.last_error,
1544
- }
1545
- : null, retry
1546
- ? {
1547
- issue_id: retry.issue_id,
1548
- identifier: retry.identifier,
1549
- attempt: retry.attempt,
1550
- due_at_ms: retry.due_at_ms,
1551
- error: retry.error,
1552
- kind: retry.kind,
1553
- }
1554
- : null);
1555
- }
1556
- findRunningByIdentifier(identifier) {
1557
- for (const e of this.running.values())
1558
- if (e.identifier === identifier)
1559
- return e;
1560
- return null;
1561
- }
1562
- findRetryByIdentifier(identifier) {
1563
- for (const r of this.retryAttempts.values())
1564
- if (r.identifier === identifier)
1565
- return r;
1566
- return null;
1567
- }
1568
- }
1569
- //# sourceMappingURL=orchestrator.js.map