smol-symphony 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (716) 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/src/bin/symphony.js +30 -0
  11. package/dist/src/bin/symphony.js.map +1 -0
  12. package/dist/src/core/actions/context.js +109 -0
  13. package/dist/src/core/actions/context.js.map +1 -0
  14. package/dist/{actions/parsing.js → src/core/actions/parse.js} +33 -114
  15. package/dist/src/core/actions/parse.js.map +1 -0
  16. package/dist/src/core/actions/plan.js +197 -0
  17. package/dist/src/core/actions/plan.js.map +1 -0
  18. package/dist/src/core/actions/predicates.js +111 -0
  19. package/dist/src/core/actions/predicates.js.map +1 -0
  20. package/dist/src/core/actions/run-fold.js +248 -0
  21. package/dist/src/core/actions/run-fold.js.map +1 -0
  22. package/dist/src/core/actions/template.js +118 -0
  23. package/dist/src/core/actions/template.js.map +1 -0
  24. package/dist/src/core/cli/args.js +116 -0
  25. package/dist/src/core/cli/args.js.map +1 -0
  26. package/dist/src/core/coerce.js +75 -0
  27. package/dist/src/core/coerce.js.map +1 -0
  28. package/dist/src/core/credential/account-id.js +20 -0
  29. package/dist/src/core/credential/account-id.js.map +1 -0
  30. package/dist/src/core/credential/adapter-config.js +136 -0
  31. package/dist/src/core/credential/adapter-config.js.map +1 -0
  32. package/dist/src/core/credential/availability.js +98 -0
  33. package/dist/src/core/credential/availability.js.map +1 -0
  34. package/dist/src/core/credential/extract.js +228 -0
  35. package/dist/src/core/credential/extract.js.map +1 -0
  36. package/dist/src/core/credential/fake-creds.js +171 -0
  37. package/dist/src/core/credential/fake-creds.js.map +1 -0
  38. package/dist/src/core/credential/identity.js +125 -0
  39. package/dist/src/core/credential/identity.js.map +1 -0
  40. package/dist/src/core/credential/shape.js +230 -0
  41. package/dist/src/core/credential/shape.js.map +1 -0
  42. package/dist/src/core/credential/strings.js +15 -0
  43. package/dist/src/core/credential/strings.js.map +1 -0
  44. package/dist/src/core/doctor/checks.js +303 -0
  45. package/dist/src/core/doctor/checks.js.map +1 -0
  46. package/dist/src/core/git/result.js +107 -0
  47. package/dist/src/core/git/result.js.map +1 -0
  48. package/dist/src/core/http/decisions.js +225 -0
  49. package/dist/src/core/http/decisions.js.map +1 -0
  50. package/dist/{http.js → src/core/http/render.js} +472 -738
  51. package/dist/src/core/http/render.js.map +1 -0
  52. package/dist/{http-handlers.js → src/core/http/routes.js} +52 -87
  53. package/dist/src/core/http/routes.js.map +1 -0
  54. package/dist/src/core/http/views.js +181 -0
  55. package/dist/src/core/http/views.js.map +1 -0
  56. package/dist/src/core/image/managed-image.js +95 -0
  57. package/dist/src/core/image/managed-image.js.map +1 -0
  58. package/dist/src/core/issue/file.js +149 -0
  59. package/dist/src/core/issue/file.js.map +1 -0
  60. package/dist/src/core/issue/parse.js +210 -0
  61. package/dist/src/core/issue/parse.js.map +1 -0
  62. package/dist/src/core/mcp/dispatch.js +239 -0
  63. package/dist/src/core/mcp/dispatch.js.map +1 -0
  64. package/dist/src/core/mcp/post-move.js +92 -0
  65. package/dist/src/core/mcp/post-move.js.map +1 -0
  66. package/dist/src/core/mcp/protocol.js +293 -0
  67. package/dist/src/core/mcp/protocol.js.map +1 -0
  68. package/dist/src/core/mcp/url.js +162 -0
  69. package/dist/src/core/mcp/url.js.map +1 -0
  70. package/dist/src/core/path.js +63 -0
  71. package/dist/src/core/path.js.map +1 -0
  72. package/dist/src/core/reconcile/image-decide.js +48 -0
  73. package/dist/src/core/reconcile/image-decide.js.map +1 -0
  74. package/dist/src/core/reconcile/ledger.js +142 -0
  75. package/dist/src/core/reconcile/ledger.js.map +1 -0
  76. package/dist/src/core/reconcile/pr-classify.js +62 -0
  77. package/dist/src/core/reconcile/pr-classify.js.map +1 -0
  78. package/dist/{reconciler → src/core/reconcile}/pr-decide.js +25 -12
  79. package/dist/src/core/reconcile/pr-decide.js.map +1 -0
  80. package/dist/src/core/reconcile/pr-loop.js +161 -0
  81. package/dist/src/core/reconcile/pr-loop.js.map +1 -0
  82. package/dist/src/core/reconcile/pr-notes.js +35 -0
  83. package/dist/src/core/reconcile/pr-notes.js.map +1 -0
  84. package/dist/src/core/reconcile/vm-decide.js +70 -0
  85. package/dist/src/core/reconcile/vm-decide.js.map +1 -0
  86. package/dist/src/core/reconcile/vm-reap.js +207 -0
  87. package/dist/src/core/reconcile/vm-reap.js.map +1 -0
  88. package/dist/src/core/reconcile/workspace-decide.js +162 -0
  89. package/dist/src/core/reconcile/workspace-decide.js.map +1 -0
  90. package/dist/src/core/runlog/summary.js +231 -0
  91. package/dist/src/core/runlog/summary.js.map +1 -0
  92. package/dist/src/core/runner/dispatch-config.js +95 -0
  93. package/dist/src/core/runner/dispatch-config.js.map +1 -0
  94. package/dist/src/core/runner/injection.js +61 -0
  95. package/dist/src/core/runner/injection.js.map +1 -0
  96. package/dist/src/core/runner/mise.js +210 -0
  97. package/dist/src/core/runner/mise.js.map +1 -0
  98. package/dist/src/core/runner/prompt.js +720 -0
  99. package/dist/src/core/runner/prompt.js.map +1 -0
  100. package/dist/src/core/runner/turn.js +242 -0
  101. package/dist/src/core/runner/turn.js.map +1 -0
  102. package/dist/src/core/runner/vm-plan.js +390 -0
  103. package/dist/src/core/runner/vm-plan.js.map +1 -0
  104. package/dist/src/core/schedule/admission.js +123 -0
  105. package/dist/src/core/schedule/admission.js.map +1 -0
  106. package/dist/src/core/schedule/circuit-breaker.js +111 -0
  107. package/dist/src/core/schedule/circuit-breaker.js.map +1 -0
  108. package/dist/src/core/schedule/eligibility.js +83 -0
  109. package/dist/src/core/schedule/eligibility.js.map +1 -0
  110. package/dist/src/core/schedule/reconcile-issue.js +82 -0
  111. package/dist/src/core/schedule/reconcile-issue.js.map +1 -0
  112. package/dist/src/core/schedule/retry.js +96 -0
  113. package/dist/src/core/schedule/retry.js.map +1 -0
  114. package/dist/src/core/schedule/sleep-cycle.js +133 -0
  115. package/dist/src/core/schedule/sleep-cycle.js.map +1 -0
  116. package/dist/src/core/schedule/slots.js +124 -0
  117. package/dist/src/core/schedule/slots.js.map +1 -0
  118. package/dist/src/core/schedule/tick.js +553 -0
  119. package/dist/src/core/schedule/tick.js.map +1 -0
  120. package/dist/src/core/schedule/token-fold.js +181 -0
  121. package/dist/src/core/schedule/token-fold.js.map +1 -0
  122. package/dist/src/core/state-resolve.js +86 -0
  123. package/dist/src/core/state-resolve.js.map +1 -0
  124. package/dist/src/core/vm-guards.js +278 -0
  125. package/dist/src/core/vm-guards.js.map +1 -0
  126. package/dist/src/core/workflow/derive.js +107 -0
  127. package/dist/src/core/workflow/derive.js.map +1 -0
  128. package/dist/src/core/workflow/parse.js +687 -0
  129. package/dist/src/core/workflow/parse.js.map +1 -0
  130. package/dist/src/core/workflow/prompt-probe.js +78 -0
  131. package/dist/src/core/workflow/prompt-probe.js.map +1 -0
  132. package/dist/src/core/workflow/validate.js +189 -0
  133. package/dist/src/core/workflow/validate.js.map +1 -0
  134. package/dist/src/core/workspace-key.js +19 -0
  135. package/dist/src/core/workspace-key.js.map +1 -0
  136. package/dist/src/shell/actions-runner.js +356 -0
  137. package/dist/src/shell/actions-runner.js.map +1 -0
  138. package/dist/src/shell/adapter/adapter-registry.js +45 -0
  139. package/dist/src/shell/adapter/adapter-registry.js.map +1 -0
  140. package/dist/src/shell/adapter/clock-random.js +96 -0
  141. package/dist/src/shell/adapter/clock-random.js.map +1 -0
  142. package/dist/src/shell/adapter/gondolin-dispatch-helpers.js +158 -0
  143. package/dist/src/shell/adapter/gondolin-dispatch-helpers.js.map +1 -0
  144. package/dist/src/shell/adapter/gondolin-dispatch.js +385 -0
  145. package/dist/src/shell/adapter/gondolin-dispatch.js.map +1 -0
  146. package/dist/src/shell/adapter/gondolin-image-converter.js +233 -0
  147. package/dist/src/shell/adapter/gondolin-image-converter.js.map +1 -0
  148. package/dist/src/shell/adapter/gondolin-image-fetch.js +180 -0
  149. package/dist/src/shell/adapter/gondolin-image-fetch.js.map +1 -0
  150. package/dist/src/shell/adapter/launcher-asset.js +57 -0
  151. package/dist/src/shell/adapter/launcher-asset.js.map +1 -0
  152. package/dist/src/shell/adapter/mise-config-asset.js +65 -0
  153. package/dist/src/shell/adapter/mise-config-asset.js.map +1 -0
  154. package/dist/src/shell/adapter/workflow-loader.js +304 -0
  155. package/dist/src/shell/adapter/workflow-loader.js.map +1 -0
  156. package/dist/src/shell/cli/doctor.js +268 -0
  157. package/dist/src/shell/cli/doctor.js.map +1 -0
  158. package/dist/src/shell/effect-interpreter-families.js +314 -0
  159. package/dist/src/shell/effect-interpreter-families.js.map +1 -0
  160. package/dist/src/shell/effect-interpreter.js +29 -0
  161. package/dist/src/shell/effect-interpreter.js.map +1 -0
  162. package/dist/src/shell/interp/acp-frame.js +137 -0
  163. package/dist/src/shell/interp/acp-frame.js.map +1 -0
  164. package/dist/src/shell/interp/acp-ws-conn.js +320 -0
  165. package/dist/src/shell/interp/acp-ws-conn.js.map +1 -0
  166. package/dist/src/shell/interp/acp-ws-frames.js +159 -0
  167. package/dist/src/shell/interp/acp-ws-frames.js.map +1 -0
  168. package/dist/src/shell/interp/acp-ws.js +197 -0
  169. package/dist/src/shell/interp/acp-ws.js.map +1 -0
  170. package/dist/src/shell/interp/acp.js +319 -0
  171. package/dist/src/shell/interp/acp.js.map +1 -0
  172. package/dist/src/shell/interp/credential-defaults.js +128 -0
  173. package/dist/src/shell/interp/credential-defaults.js.map +1 -0
  174. package/dist/src/shell/interp/credential-hooks.js +149 -0
  175. package/dist/src/shell/interp/credential-hooks.js.map +1 -0
  176. package/dist/src/shell/interp/credential-registry.js +226 -0
  177. package/dist/src/shell/interp/credential-registry.js.map +1 -0
  178. package/dist/src/shell/interp/credential.js +103 -0
  179. package/dist/src/shell/interp/credential.js.map +1 -0
  180. package/dist/src/shell/interp/gh.js +163 -0
  181. package/dist/src/shell/interp/gh.js.map +1 -0
  182. package/dist/src/shell/interp/git.js +28 -0
  183. package/dist/src/shell/interp/git.js.map +1 -0
  184. package/dist/src/shell/interp/log.js +213 -0
  185. package/dist/src/shell/interp/log.js.map +1 -0
  186. package/dist/src/shell/interp/process.js +178 -0
  187. package/dist/src/shell/interp/process.js.map +1 -0
  188. package/dist/src/shell/interp/runlog.js +193 -0
  189. package/dist/src/shell/interp/runlog.js.map +1 -0
  190. package/dist/src/shell/interp/timer.js +64 -0
  191. package/dist/src/shell/interp/timer.js.map +1 -0
  192. package/dist/src/shell/interp/tracker-disk.js +99 -0
  193. package/dist/src/shell/interp/tracker-disk.js.map +1 -0
  194. package/dist/src/shell/interp/tracker-parse.js +71 -0
  195. package/dist/src/shell/interp/tracker-parse.js.map +1 -0
  196. package/dist/src/shell/interp/tracker-scan.js +238 -0
  197. package/dist/src/shell/interp/tracker-scan.js.map +1 -0
  198. package/dist/src/shell/interp/tracker-write.js +91 -0
  199. package/dist/src/shell/interp/tracker-write.js.map +1 -0
  200. package/dist/src/shell/interp/tracker.js +41 -0
  201. package/dist/src/shell/interp/tracker.js.map +1 -0
  202. package/dist/src/shell/interp/tty.js +48 -0
  203. package/dist/src/shell/interp/tty.js.map +1 -0
  204. package/dist/src/shell/interp/vm.js +199 -0
  205. package/dist/src/shell/interp/vm.js.map +1 -0
  206. package/dist/src/shell/interp/workspace.js +310 -0
  207. package/dist/src/shell/interp/workspace.js.map +1 -0
  208. package/dist/src/shell/main-acp.js +78 -0
  209. package/dist/src/shell/main-acp.js.map +1 -0
  210. package/dist/src/shell/main-adapters.js +222 -0
  211. package/dist/src/shell/main-adapters.js.map +1 -0
  212. package/dist/src/shell/main-credential.js +122 -0
  213. package/dist/src/shell/main-credential.js.map +1 -0
  214. package/dist/src/shell/main-doctor.js +22 -0
  215. package/dist/src/shell/main-doctor.js.map +1 -0
  216. package/dist/src/shell/main-entry.js +46 -0
  217. package/dist/src/shell/main-entry.js.map +1 -0
  218. package/dist/src/shell/main-http-csrf.js +45 -0
  219. package/dist/src/shell/main-http-csrf.js.map +1 -0
  220. package/dist/src/shell/main-http-handler.js +389 -0
  221. package/dist/src/shell/main-http-handler.js.map +1 -0
  222. package/dist/src/shell/main-http-mcp.js +122 -0
  223. package/dist/src/shell/main-http-mcp.js.map +1 -0
  224. package/dist/src/shell/main-http-views.js +253 -0
  225. package/dist/src/shell/main-http-views.js.map +1 -0
  226. package/dist/src/shell/main-http.js +76 -0
  227. package/dist/src/shell/main-http.js.map +1 -0
  228. package/dist/src/shell/main-loops.js +130 -0
  229. package/dist/src/shell/main-loops.js.map +1 -0
  230. package/dist/src/shell/main-mcp.js +129 -0
  231. package/dist/src/shell/main-mcp.js.map +1 -0
  232. package/dist/src/shell/main-orchestrator.js +120 -0
  233. package/dist/src/shell/main-orchestrator.js.map +1 -0
  234. package/dist/src/shell/main-preflight.js +43 -0
  235. package/dist/src/shell/main-preflight.js.map +1 -0
  236. package/dist/src/shell/main-reconcilers-helpers.js +244 -0
  237. package/dist/src/shell/main-reconcilers-helpers.js.map +1 -0
  238. package/dist/src/shell/main-reconcilers-pr.js +148 -0
  239. package/dist/src/shell/main-reconcilers-pr.js.map +1 -0
  240. package/dist/src/shell/main-reconcilers.js +225 -0
  241. package/dist/src/shell/main-reconcilers.js.map +1 -0
  242. package/dist/src/shell/main-runner.js +355 -0
  243. package/dist/src/shell/main-runner.js.map +1 -0
  244. package/dist/src/shell/main-scaffold.js +116 -0
  245. package/dist/src/shell/main-scaffold.js.map +1 -0
  246. package/dist/src/shell/main-shutdown.js +115 -0
  247. package/dist/src/shell/main-shutdown.js.map +1 -0
  248. package/dist/src/shell/main-startup.js +48 -0
  249. package/dist/src/shell/main-startup.js.map +1 -0
  250. package/dist/src/shell/main-substrates.js +43 -0
  251. package/dist/src/shell/main-substrates.js.map +1 -0
  252. package/dist/src/shell/main.js +385 -0
  253. package/dist/src/shell/main.js.map +1 -0
  254. package/dist/src/shell/orchestrator-feedback.js +69 -0
  255. package/dist/src/shell/orchestrator-feedback.js.map +1 -0
  256. package/dist/src/shell/orchestrator-image.js +167 -0
  257. package/dist/src/shell/orchestrator-image.js.map +1 -0
  258. package/dist/src/shell/orchestrator-loop.js +468 -0
  259. package/dist/src/shell/orchestrator-loop.js.map +1 -0
  260. package/dist/src/shell/orchestrator-reconcile.js +36 -0
  261. package/dist/src/shell/orchestrator-reconcile.js.map +1 -0
  262. package/dist/src/shell/reconciler-loop.js +228 -0
  263. package/dist/src/shell/reconciler-loop.js.map +1 -0
  264. package/dist/src/shell/runner-loop-turn.js +301 -0
  265. package/dist/src/shell/runner-loop-turn.js.map +1 -0
  266. package/dist/src/shell/runner-loop.js +338 -0
  267. package/dist/src/shell/runner-loop.js.map +1 -0
  268. package/dist/src/shell/server/http.js +208 -0
  269. package/dist/src/shell/server/http.js.map +1 -0
  270. package/dist/src/shell/server/mcp-runtime-effects.js +237 -0
  271. package/dist/src/shell/server/mcp-runtime-effects.js.map +1 -0
  272. package/dist/src/shell/server/mcp-runtime.js +99 -0
  273. package/dist/src/shell/server/mcp-runtime.js.map +1 -0
  274. package/dist/src/shell/workspace-key.js +14 -0
  275. package/dist/src/shell/workspace-key.js.map +1 -0
  276. package/dist/src/types/acp.js +8 -0
  277. package/dist/src/types/acp.js.map +1 -0
  278. package/dist/src/types/actions/plan.js +6 -0
  279. package/dist/src/types/actions/plan.js.map +1 -0
  280. package/dist/src/types/actions/predicates.js +6 -0
  281. package/dist/src/types/actions/predicates.js.map +1 -0
  282. package/dist/src/types/actions/run-fold.js +8 -0
  283. package/dist/src/types/actions/run-fold.js.map +1 -0
  284. package/dist/src/types/actions.js +7 -0
  285. package/dist/src/types/actions.js.map +1 -0
  286. package/dist/src/types/adapter/clock-random.js +4 -0
  287. package/dist/src/types/adapter/clock-random.js.map +1 -0
  288. package/dist/src/types/adapter/gondolin-image-converter.js +5 -0
  289. package/dist/src/types/adapter/gondolin-image-converter.js.map +1 -0
  290. package/dist/src/types/adapter/gondolin-image-fetch.js +5 -0
  291. package/dist/src/types/adapter/gondolin-image-fetch.js.map +1 -0
  292. package/dist/src/types/adapter/workflow-loader.js +4 -0
  293. package/dist/src/types/adapter/workflow-loader.js.map +1 -0
  294. package/dist/src/types/cli/args.js +8 -0
  295. package/dist/src/types/cli/args.js.map +1 -0
  296. package/dist/src/types/config.js +8 -0
  297. package/dist/src/types/config.js.map +1 -0
  298. package/dist/src/types/credential-interp.js +6 -0
  299. package/dist/src/types/credential-interp.js.map +1 -0
  300. package/dist/src/types/credentials.js +10 -0
  301. package/dist/src/types/credentials.js.map +1 -0
  302. package/dist/src/types/doctor.js +7 -0
  303. package/dist/src/types/doctor.js.map +1 -0
  304. package/dist/src/types/domain.js +7 -0
  305. package/dist/src/types/domain.js.map +1 -0
  306. package/dist/src/types/effect.js +15 -0
  307. package/dist/src/types/effect.js.map +1 -0
  308. package/dist/src/types/errors.js +39 -0
  309. package/dist/src/types/errors.js.map +1 -0
  310. package/dist/src/types/http/decisions.js +6 -0
  311. package/dist/src/types/http/decisions.js.map +1 -0
  312. package/dist/src/types/http/render.js +10 -0
  313. package/dist/src/types/http/render.js.map +1 -0
  314. package/dist/src/types/http/views.js +6 -0
  315. package/dist/src/types/http/views.js.map +1 -0
  316. package/dist/src/types/http.js +9 -0
  317. package/dist/src/types/http.js.map +1 -0
  318. package/dist/src/types/image/managed-image.js +7 -0
  319. package/dist/src/types/image/managed-image.js.map +1 -0
  320. package/dist/src/types/interp/effect-interpreter.js +8 -0
  321. package/dist/src/types/interp/effect-interpreter.js.map +1 -0
  322. package/dist/src/types/interp/tracker.js +7 -0
  323. package/dist/src/types/interp/tracker.js.map +1 -0
  324. package/dist/src/types/issue/file.js +6 -0
  325. package/dist/src/types/issue/file.js.map +1 -0
  326. package/dist/src/types/issue/parse.js +8 -0
  327. package/dist/src/types/issue/parse.js.map +1 -0
  328. package/dist/src/types/main-acp.js +13 -0
  329. package/dist/src/types/main-acp.js.map +1 -0
  330. package/dist/src/types/main-adapters.js +5 -0
  331. package/dist/src/types/main-adapters.js.map +1 -0
  332. package/dist/src/types/main-credential.js +21 -0
  333. package/dist/src/types/main-credential.js.map +1 -0
  334. package/dist/src/types/main-doctor.js +6 -0
  335. package/dist/src/types/main-doctor.js.map +1 -0
  336. package/dist/src/types/main-http-handler.js +12 -0
  337. package/dist/src/types/main-http-handler.js.map +1 -0
  338. package/dist/src/types/main-http.js +5 -0
  339. package/dist/src/types/main-http.js.map +1 -0
  340. package/dist/src/types/main-loops.js +5 -0
  341. package/dist/src/types/main-loops.js.map +1 -0
  342. package/dist/src/types/main-mcp.js +12 -0
  343. package/dist/src/types/main-mcp.js.map +1 -0
  344. package/dist/src/types/main-orchestrator.js +5 -0
  345. package/dist/src/types/main-orchestrator.js.map +1 -0
  346. package/dist/src/types/main-reconcilers.js +11 -0
  347. package/dist/src/types/main-reconcilers.js.map +1 -0
  348. package/dist/src/types/main-runner.js +13 -0
  349. package/dist/src/types/main-runner.js.map +1 -0
  350. package/dist/src/types/main-startup.js +5 -0
  351. package/dist/src/types/main-startup.js.map +1 -0
  352. package/dist/src/types/main-substrates.js +5 -0
  353. package/dist/src/types/main-substrates.js.map +1 -0
  354. package/dist/src/types/mcp/dispatch.js +4 -0
  355. package/dist/src/types/mcp/dispatch.js.map +1 -0
  356. package/dist/src/types/mcp/post-move.js +7 -0
  357. package/dist/src/types/mcp/post-move.js.map +1 -0
  358. package/dist/src/types/mcp.js +9 -0
  359. package/dist/src/types/mcp.js.map +1 -0
  360. package/dist/src/types/ports.js +12 -0
  361. package/dist/src/types/ports.js.map +1 -0
  362. package/dist/src/types/reconcile/image-decide.js +5 -0
  363. package/dist/src/types/reconcile/image-decide.js.map +1 -0
  364. package/dist/src/types/reconcile/ledger.js +7 -0
  365. package/dist/src/types/reconcile/ledger.js.map +1 -0
  366. package/dist/src/types/reconcile/pr-loop.js +8 -0
  367. package/dist/src/types/reconcile/pr-loop.js.map +1 -0
  368. package/dist/src/types/reconcile/vm-reap.js +8 -0
  369. package/dist/src/types/reconcile/vm-reap.js.map +1 -0
  370. package/dist/src/types/reconcile/workspace-decide.js +7 -0
  371. package/dist/src/types/reconcile/workspace-decide.js.map +1 -0
  372. package/dist/src/types/reconcile.js +9 -0
  373. package/dist/src/types/reconcile.js.map +1 -0
  374. package/dist/src/types/runlog.js +7 -0
  375. package/dist/src/types/runlog.js.map +1 -0
  376. package/dist/src/types/runner/actions-runner.js +12 -0
  377. package/dist/src/types/runner/actions-runner.js.map +1 -0
  378. package/dist/src/types/runner/gondolin-dispatch.js +5 -0
  379. package/dist/src/types/runner/gondolin-dispatch.js.map +1 -0
  380. package/dist/src/types/runner/injection.js +6 -0
  381. package/dist/src/types/runner/injection.js.map +1 -0
  382. package/dist/src/types/runner/runner-loop.js +5 -0
  383. package/dist/src/types/runner/runner-loop.js.map +1 -0
  384. package/dist/src/types/runner/turn.js +4 -0
  385. package/dist/src/types/runner/turn.js.map +1 -0
  386. package/dist/src/types/runner/vm-plan.js +4 -0
  387. package/dist/src/types/runner/vm-plan.js.map +1 -0
  388. package/dist/src/types/runtime.js +9 -0
  389. package/dist/src/types/runtime.js.map +1 -0
  390. package/dist/src/types/schedule/admission.js +7 -0
  391. package/dist/src/types/schedule/admission.js.map +1 -0
  392. package/dist/src/types/schedule/circuit-breaker.js +2 -0
  393. package/dist/src/types/schedule/circuit-breaker.js.map +1 -0
  394. package/dist/src/types/schedule/eligibility.js +9 -0
  395. package/dist/src/types/schedule/eligibility.js.map +1 -0
  396. package/dist/src/types/schedule/orchestrator-loop.js +10 -0
  397. package/dist/src/types/schedule/orchestrator-loop.js.map +1 -0
  398. package/dist/src/types/schedule/sleep-cycle.js +4 -0
  399. package/dist/src/types/schedule/sleep-cycle.js.map +1 -0
  400. package/dist/src/types/schedule/slots.js +8 -0
  401. package/dist/src/types/schedule/slots.js.map +1 -0
  402. package/dist/src/types/schedule/tick.js +9 -0
  403. package/dist/src/types/schedule/tick.js.map +1 -0
  404. package/dist/src/types/server/mcp-runtime.js +8 -0
  405. package/dist/src/types/server/mcp-runtime.js.map +1 -0
  406. package/dist/src/types/workflow/parse.js +4 -0
  407. package/dist/src/types/workflow/parse.js.map +1 -0
  408. package/dist/tests/core/account-id.test.js +35 -0
  409. package/dist/tests/core/account-id.test.js.map +1 -0
  410. package/dist/tests/core/actions-parse.test.js +176 -0
  411. package/dist/tests/core/actions-parse.test.js.map +1 -0
  412. package/dist/tests/core/adapter-config.test.js +133 -0
  413. package/dist/tests/core/adapter-config.test.js.map +1 -0
  414. package/dist/tests/core/admission.test.js +215 -0
  415. package/dist/tests/core/admission.test.js.map +1 -0
  416. package/dist/tests/core/args.test.js +132 -0
  417. package/dist/tests/core/args.test.js.map +1 -0
  418. package/dist/tests/core/availability.test.js +62 -0
  419. package/dist/tests/core/availability.test.js.map +1 -0
  420. package/dist/tests/core/checks.test.js +395 -0
  421. package/dist/tests/core/checks.test.js.map +1 -0
  422. package/dist/tests/core/circuit-breaker.test.js +172 -0
  423. package/dist/tests/core/circuit-breaker.test.js.map +1 -0
  424. package/dist/tests/core/coerce.test.js +87 -0
  425. package/dist/tests/core/coerce.test.js.map +1 -0
  426. package/dist/tests/core/context.test.js +228 -0
  427. package/dist/tests/core/context.test.js.map +1 -0
  428. package/dist/tests/core/decisions.test.js +310 -0
  429. package/dist/tests/core/decisions.test.js.map +1 -0
  430. package/dist/tests/core/derive.test.js +205 -0
  431. package/dist/tests/core/derive.test.js.map +1 -0
  432. package/dist/tests/core/dispatch-config.test.js +164 -0
  433. package/dist/tests/core/dispatch-config.test.js.map +1 -0
  434. package/dist/tests/core/dispatch.test.js +302 -0
  435. package/dist/tests/core/dispatch.test.js.map +1 -0
  436. package/dist/tests/core/eligibility.test.js +163 -0
  437. package/dist/tests/core/eligibility.test.js.map +1 -0
  438. package/dist/tests/core/extract.test.js +139 -0
  439. package/dist/tests/core/extract.test.js.map +1 -0
  440. package/dist/tests/core/fake-creds.test.js +134 -0
  441. package/dist/tests/core/fake-creds.test.js.map +1 -0
  442. package/dist/tests/core/file.test.js +197 -0
  443. package/dist/tests/core/file.test.js.map +1 -0
  444. package/dist/tests/core/git-result.test.js +113 -0
  445. package/dist/tests/core/git-result.test.js.map +1 -0
  446. package/dist/tests/core/identity.test.js +180 -0
  447. package/dist/tests/core/identity.test.js.map +1 -0
  448. package/dist/tests/core/image-decide.test.js +59 -0
  449. package/dist/tests/core/image-decide.test.js.map +1 -0
  450. package/dist/tests/core/injection.test.js +163 -0
  451. package/dist/tests/core/injection.test.js.map +1 -0
  452. package/dist/tests/core/ledger.test.js +218 -0
  453. package/dist/tests/core/ledger.test.js.map +1 -0
  454. package/dist/tests/core/managed-image.test.js +68 -0
  455. package/dist/tests/core/managed-image.test.js.map +1 -0
  456. package/dist/tests/core/mise.test.js +138 -0
  457. package/dist/tests/core/mise.test.js.map +1 -0
  458. package/dist/tests/core/parse.test.js +174 -0
  459. package/dist/tests/core/parse.test.js.map +1 -0
  460. package/dist/tests/core/path.test.js +50 -0
  461. package/dist/tests/core/path.test.js.map +1 -0
  462. package/dist/tests/core/plan.test.js +218 -0
  463. package/dist/tests/core/plan.test.js.map +1 -0
  464. package/dist/tests/core/post-move.test.js +162 -0
  465. package/dist/tests/core/post-move.test.js.map +1 -0
  466. package/dist/tests/core/pr-classify.test.js +117 -0
  467. package/dist/tests/core/pr-classify.test.js.map +1 -0
  468. package/dist/tests/core/pr-decide.test.js +298 -0
  469. package/dist/tests/core/pr-decide.test.js.map +1 -0
  470. package/dist/tests/core/pr-loop.test.js +301 -0
  471. package/dist/tests/core/pr-loop.test.js.map +1 -0
  472. package/dist/tests/core/pr-notes.test.js +165 -0
  473. package/dist/tests/core/pr-notes.test.js.map +1 -0
  474. package/dist/tests/core/predicates.test.js +154 -0
  475. package/dist/tests/core/predicates.test.js.map +1 -0
  476. package/dist/tests/core/prompt.test.js +189 -0
  477. package/dist/tests/core/prompt.test.js.map +1 -0
  478. package/dist/tests/core/protocol.test.js +195 -0
  479. package/dist/tests/core/protocol.test.js.map +1 -0
  480. package/dist/tests/core/reconcile-issue.test.js +116 -0
  481. package/dist/tests/core/reconcile-issue.test.js.map +1 -0
  482. package/dist/tests/core/render.test.js +549 -0
  483. package/dist/tests/core/render.test.js.map +1 -0
  484. package/dist/tests/core/retry.test.js +186 -0
  485. package/dist/tests/core/retry.test.js.map +1 -0
  486. package/dist/tests/core/routes.test.js +247 -0
  487. package/dist/tests/core/routes.test.js.map +1 -0
  488. package/dist/tests/core/run-fold.test.js +299 -0
  489. package/dist/tests/core/run-fold.test.js.map +1 -0
  490. package/dist/tests/core/shape.test.js +185 -0
  491. package/dist/tests/core/shape.test.js.map +1 -0
  492. package/dist/tests/core/sleep-cycle.test.js +150 -0
  493. package/dist/tests/core/sleep-cycle.test.js.map +1 -0
  494. package/dist/tests/core/slots.test.js +201 -0
  495. package/dist/tests/core/slots.test.js.map +1 -0
  496. package/dist/tests/core/state-resolve.test.js +80 -0
  497. package/dist/tests/core/state-resolve.test.js.map +1 -0
  498. package/dist/tests/core/summary.test.js +200 -0
  499. package/dist/tests/core/summary.test.js.map +1 -0
  500. package/dist/tests/core/template.test.js +116 -0
  501. package/dist/tests/core/template.test.js.map +1 -0
  502. package/dist/tests/core/tick.test.js +558 -0
  503. package/dist/tests/core/tick.test.js.map +1 -0
  504. package/dist/tests/core/token-fold.test.js +176 -0
  505. package/dist/tests/core/token-fold.test.js.map +1 -0
  506. package/dist/tests/core/turn.test.js +388 -0
  507. package/dist/tests/core/turn.test.js.map +1 -0
  508. package/dist/tests/core/url.test.js +118 -0
  509. package/dist/tests/core/url.test.js.map +1 -0
  510. package/dist/tests/core/validate.test.js +247 -0
  511. package/dist/tests/core/validate.test.js.map +1 -0
  512. package/dist/tests/core/views.test.js +252 -0
  513. package/dist/tests/core/views.test.js.map +1 -0
  514. package/dist/tests/core/vm-decide.test.js +110 -0
  515. package/dist/tests/core/vm-decide.test.js.map +1 -0
  516. package/dist/tests/core/vm-guards.test.js +153 -0
  517. package/dist/tests/core/vm-guards.test.js.map +1 -0
  518. package/dist/tests/core/vm-plan.test.js +332 -0
  519. package/dist/tests/core/vm-plan.test.js.map +1 -0
  520. package/dist/tests/core/vm-reap.test.js +196 -0
  521. package/dist/tests/core/vm-reap.test.js.map +1 -0
  522. package/dist/tests/core/workflow-parse.test.js +493 -0
  523. package/dist/tests/core/workflow-parse.test.js.map +1 -0
  524. package/dist/tests/core/workspace-decide.test.js +236 -0
  525. package/dist/tests/core/workspace-decide.test.js.map +1 -0
  526. package/dist/tests/helpers/fixtures.js +167 -0
  527. package/dist/tests/helpers/fixtures.js.map +1 -0
  528. package/dist/tests/shell/acp-substrate.test.js +101 -0
  529. package/dist/tests/shell/acp-substrate.test.js.map +1 -0
  530. package/dist/tests/shell/actions-runner-push.test.js +203 -0
  531. package/dist/tests/shell/actions-runner-push.test.js.map +1 -0
  532. package/dist/tests/shell/credential-hooks.test.js +36 -0
  533. package/dist/tests/shell/credential-hooks.test.js.map +1 -0
  534. package/dist/tests/shell/credential-registry.test.js +165 -0
  535. package/dist/tests/shell/credential-registry.test.js.map +1 -0
  536. package/dist/tests/shell/credential-substrate.test.js +179 -0
  537. package/dist/tests/shell/credential-substrate.test.js.map +1 -0
  538. package/dist/tests/shell/dockerfile-mise-pin.test.js +51 -0
  539. package/dist/tests/shell/dockerfile-mise-pin.test.js.map +1 -0
  540. package/dist/tests/shell/doctor.test.js +101 -0
  541. package/dist/tests/shell/doctor.test.js.map +1 -0
  542. package/dist/tests/shell/effect-vm-create.test.js +52 -0
  543. package/dist/tests/shell/effect-vm-create.test.js.map +1 -0
  544. package/dist/tests/shell/gh-port.test.js +63 -0
  545. package/dist/tests/shell/gh-port.test.js.map +1 -0
  546. package/dist/tests/shell/gondolin-dispatch-guard.test.js +144 -0
  547. package/dist/tests/shell/gondolin-dispatch-guard.test.js.map +1 -0
  548. package/dist/tests/shell/gondolin-dispatch-shquote.test.js +168 -0
  549. package/dist/tests/shell/gondolin-dispatch-shquote.test.js.map +1 -0
  550. package/dist/tests/shell/gondolin-image-converter.test.js +208 -0
  551. package/dist/tests/shell/gondolin-image-converter.test.js.map +1 -0
  552. package/dist/tests/shell/gondolin-image-fetch.test.js +93 -0
  553. package/dist/tests/shell/gondolin-image-fetch.test.js.map +1 -0
  554. package/dist/tests/shell/http-handler.test.js +608 -0
  555. package/dist/tests/shell/http-handler.test.js.map +1 -0
  556. package/dist/tests/shell/http-server.test.js +53 -0
  557. package/dist/tests/shell/http-server.test.js.map +1 -0
  558. package/dist/tests/shell/mcp-runtime.test.js +366 -0
  559. package/dist/tests/shell/mcp-runtime.test.js.map +1 -0
  560. package/dist/tests/shell/mise-config-asset.test.js +87 -0
  561. package/dist/tests/shell/mise-config-asset.test.js.map +1 -0
  562. package/dist/tests/shell/orchestrator-loop.test.js +583 -0
  563. package/dist/tests/shell/orchestrator-loop.test.js.map +1 -0
  564. package/dist/tests/shell/reconciler-passes.test.js +314 -0
  565. package/dist/tests/shell/reconciler-passes.test.js.map +1 -0
  566. package/dist/tests/shell/runner-loop-turn.test.js +97 -0
  567. package/dist/tests/shell/runner-loop-turn.test.js.map +1 -0
  568. package/dist/tests/shell/runner-slice.test.js +536 -0
  569. package/dist/tests/shell/runner-slice.test.js.map +1 -0
  570. package/dist/tests/shell/scaffold.test.js +65 -0
  571. package/dist/tests/shell/scaffold.test.js.map +1 -0
  572. package/dist/tests/shell/tick-config.test.js +83 -0
  573. package/dist/tests/shell/tick-config.test.js.map +1 -0
  574. package/dist/tests/shell/tracker-parse-dates.test.js +44 -0
  575. package/dist/tests/shell/tracker-parse-dates.test.js.map +1 -0
  576. package/dist/tests/shell/tracker-write-issue.test.js +154 -0
  577. package/dist/tests/shell/tracker-write-issue.test.js.map +1 -0
  578. package/dist/tests/shell/workflow-prompt-split.test.js +208 -0
  579. package/dist/tests/shell/workflow-prompt-split.test.js.map +1 -0
  580. package/dist/tests/shell/workspace-live-config.test.js +140 -0
  581. package/dist/tests/shell/workspace-live-config.test.js.map +1 -0
  582. package/package.json +21 -9
  583. package/patches/@earendil-works+gondolin+0.12.0.patch +173 -0
  584. package/prompts/Reflect.md +91 -0
  585. package/prompts/Review.md +97 -0
  586. package/prompts/Todo.md +96 -0
  587. package/prompts/_footer.md +41 -0
  588. package/prompts/_preamble.md +42 -0
  589. package/prompts-minimal/Todo.md +26 -0
  590. package/scripts/postinstall.mjs +63 -0
  591. package/scripts/vm-agent.mjs +312 -90
  592. package/WORKFLOW.md +0 -744
  593. package/dist/acp-bridge.js +0 -324
  594. package/dist/acp-bridge.js.map +0 -1
  595. package/dist/actions/cache.js +0 -191
  596. package/dist/actions/cache.js.map +0 -1
  597. package/dist/actions/effects.js +0 -41
  598. package/dist/actions/effects.js.map +0 -1
  599. package/dist/actions/executor.js +0 -570
  600. package/dist/actions/executor.js.map +0 -1
  601. package/dist/actions/index.js +0 -13
  602. package/dist/actions/index.js.map +0 -1
  603. package/dist/actions/parsing.js.map +0 -1
  604. package/dist/actions/predicate-env.js +0 -27
  605. package/dist/actions/predicate-env.js.map +0 -1
  606. package/dist/actions/predicates.js +0 -49
  607. package/dist/actions/predicates.js.map +0 -1
  608. package/dist/actions/templating.js +0 -66
  609. package/dist/actions/templating.js.map +0 -1
  610. package/dist/actions/types.js +0 -15
  611. package/dist/actions/types.js.map +0 -1
  612. package/dist/agent/acp.js +0 -473
  613. package/dist/agent/acp.js.map +0 -1
  614. package/dist/agent/adapter-names.js +0 -159
  615. package/dist/agent/adapter-names.js.map +0 -1
  616. package/dist/agent/adapters.js +0 -511
  617. package/dist/agent/adapters.js.map +0 -1
  618. package/dist/agent/credential-extractors.js +0 -342
  619. package/dist/agent/credential-extractors.js.map +0 -1
  620. package/dist/agent/credential-secrets.js +0 -628
  621. package/dist/agent/credential-secrets.js.map +0 -1
  622. package/dist/agent/credential-ticker.js +0 -57
  623. package/dist/agent/credential-ticker.js.map +0 -1
  624. package/dist/agent/gondolin-creds-staging.js +0 -356
  625. package/dist/agent/gondolin-creds-staging.js.map +0 -1
  626. package/dist/agent/gondolin-dispatch.js +0 -375
  627. package/dist/agent/gondolin-dispatch.js.map +0 -1
  628. package/dist/agent/gondolin.js +0 -124
  629. package/dist/agent/gondolin.js.map +0 -1
  630. package/dist/agent/runner-decisions.js +0 -134
  631. package/dist/agent/runner-decisions.js.map +0 -1
  632. package/dist/agent/runner.js +0 -1456
  633. package/dist/agent/runner.js.map +0 -1
  634. package/dist/agent/tool-call-summary.js +0 -102
  635. package/dist/agent/tool-call-summary.js.map +0 -1
  636. package/dist/agent/vm-acp-mapping.js +0 -73
  637. package/dist/agent/vm-acp-mapping.js.map +0 -1
  638. package/dist/agent/vm-guards.js +0 -262
  639. package/dist/agent/vm-guards.js.map +0 -1
  640. package/dist/agent/vm-port.js +0 -22
  641. package/dist/agent/vm-port.js.map +0 -1
  642. package/dist/agent/vm-process-registry.js +0 -79
  643. package/dist/agent/vm-process-registry.js.map +0 -1
  644. package/dist/bin/cli-args.js +0 -105
  645. package/dist/bin/cli-args.js.map +0 -1
  646. package/dist/bin/symphony.js +0 -794
  647. package/dist/bin/symphony.js.map +0 -1
  648. package/dist/errors.js +0 -15
  649. package/dist/errors.js.map +0 -1
  650. package/dist/http-disk.js +0 -135
  651. package/dist/http-disk.js.map +0 -1
  652. package/dist/http-handlers.js.map +0 -1
  653. package/dist/http.js.map +0 -1
  654. package/dist/issues.js +0 -178
  655. package/dist/issues.js.map +0 -1
  656. package/dist/logging.js +0 -203
  657. package/dist/logging.js.map +0 -1
  658. package/dist/mcp.js +0 -706
  659. package/dist/mcp.js.map +0 -1
  660. package/dist/memory.js +0 -85
  661. package/dist/memory.js.map +0 -1
  662. package/dist/orchestrator-decisions.js +0 -331
  663. package/dist/orchestrator-decisions.js.map +0 -1
  664. package/dist/orchestrator.js +0 -1569
  665. package/dist/orchestrator.js.map +0 -1
  666. package/dist/prompt.js +0 -65
  667. package/dist/prompt.js.map +0 -1
  668. package/dist/reconciler/cache.js +0 -65
  669. package/dist/reconciler/cache.js.map +0 -1
  670. package/dist/reconciler/index.js +0 -448
  671. package/dist/reconciler/index.js.map +0 -1
  672. package/dist/reconciler/ledger.js +0 -131
  673. package/dist/reconciler/ledger.js.map +0 -1
  674. package/dist/reconciler/pr-adapters.js +0 -174
  675. package/dist/reconciler/pr-adapters.js.map +0 -1
  676. package/dist/reconciler/pr-decide.js.map +0 -1
  677. package/dist/reconciler/pr.js +0 -422
  678. package/dist/reconciler/pr.js.map +0 -1
  679. package/dist/reconciler/types.js +0 -12
  680. package/dist/reconciler/types.js.map +0 -1
  681. package/dist/reconciler/vm.js +0 -243
  682. package/dist/reconciler/vm.js.map +0 -1
  683. package/dist/reconciler/workspace-defaults.js +0 -83
  684. package/dist/reconciler/workspace-defaults.js.map +0 -1
  685. package/dist/reconciler/workspace.js +0 -272
  686. package/dist/reconciler/workspace.js.map +0 -1
  687. package/dist/runlog.js +0 -403
  688. package/dist/runlog.js.map +0 -1
  689. package/dist/scaffold.js +0 -165
  690. package/dist/scaffold.js.map +0 -1
  691. package/dist/trackers/local.js +0 -445
  692. package/dist/trackers/local.js.map +0 -1
  693. package/dist/trackers/types.js +0 -10
  694. package/dist/trackers/types.js.map +0 -1
  695. package/dist/types.js +0 -3
  696. package/dist/types.js.map +0 -1
  697. package/dist/util/clock.js +0 -12
  698. package/dist/util/clock.js.map +0 -1
  699. package/dist/util/crypto.js +0 -25
  700. package/dist/util/crypto.js.map +0 -1
  701. package/dist/util/frontmatter.js +0 -70
  702. package/dist/util/frontmatter.js.map +0 -1
  703. package/dist/util/fs-issues.js +0 -22
  704. package/dist/util/fs-issues.js.map +0 -1
  705. package/dist/util/process.js +0 -152
  706. package/dist/util/process.js.map +0 -1
  707. package/dist/util/workspace-key.js +0 -10
  708. package/dist/util/workspace-key.js.map +0 -1
  709. package/dist/workflow-loader.js +0 -147
  710. package/dist/workflow-loader.js.map +0 -1
  711. package/dist/workflow.js +0 -822
  712. package/dist/workflow.js.map +0 -1
  713. package/dist/workspace-types.js +0 -8
  714. package/dist/workspace-types.js.map +0 -1
  715. package/dist/workspace.js +0 -443
  716. package/dist/workspace.js.map +0 -1
@@ -1,1456 +0,0 @@
1
- // Agent Runner (SPEC §6.2): workspace + prompt + ACP session, with continuation turns up
2
- // to agent.max_turns. The ACP adapter (claude-agent-acp / codex-acp / opencode acp) runs
3
- // inside a per-issue Gondolin VM. The host workspace directory is volume-mounted into
4
- // the VM at the same absolute path so cwd values are consistent.
5
- import { setTimeout as delay } from 'node:timers/promises';
6
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
7
- import os from 'node:os';
8
- import path from 'node:path';
9
- import { WorkspaceManager, fetchBaseInWorkspace, resolveBaseBranch, resolveGithubRepo, sanitizeWorkspaceKey, } from '../workspace.js';
10
- import { renderPrompt } from '../prompt.js';
11
- import { SYMPHONY_VM_PREFIX } from './vm-port.js';
12
- import { GondolinDispatcher, } from './gondolin-dispatch.js';
13
- import { MCP_GUEST_BASE_URL } from './vm-acp-mapping.js';
14
- import { stripCredentialEnv } from './vm-guards.js';
15
- import { AcpClient } from './acp.js';
16
- import { ADAPTERS, isKnownAdapter, } from './adapters.js';
17
- import { activeStateNames } from '../issues.js';
18
- import { withIssue } from '../logging.js';
19
- import { resolveActionsForState } from '../workflow.js';
20
- import { parseFrontMatterLenient } from '../util/frontmatter.js';
21
- import { runActions, toActionsSnapshot, } from '../actions/index.js';
22
- import { defaultPredicateEnv } from '../actions/predicate-env.js';
23
- import { classifyTurnOutcome, computeForwardedEnv, decideAttemptOutcome, decideTurnContinuation, deriveActionContext, selectPromptKind, } from './runner-decisions.js';
24
- const CONTINUATION_PROMPT_WITH_MCP = 'Continue working on the same issue. Pick up where the prior turn left off and proceed with the next concrete action. If the work is fully complete, summarize what changed and call the symphony.transition tool to hand off to the next state.';
25
- const CONTINUATION_PROMPT_NO_MCP = 'Continue working on the same issue. Pick up where the prior turn left off and proceed with the next concrete action. If the work is fully complete, summarize what changed and stop.';
26
- function continuationPrompt(mcpEnabled) {
27
- return mcpEnabled ? CONTINUATION_PROMPT_WITH_MCP : CONTINUATION_PROMPT_NO_MCP;
28
- }
29
- // Source of truth for "which state's cleanup actions should fire."
30
- // Prefers the running-entry's current issue state (the runner may have moved
31
- // it during the attempt, e.g. via a typed-action reroute), otherwise falls
32
- // back to the issue snapshot the attempt was launched with.
33
- function resolveCleanupState(issue, runningEntry) {
34
- return runningEntry?.issue.state ?? issue.state;
35
- }
36
- // Stage the SYMPHONY_* env vars + temp body file consumed by the Done-state
37
- // `actions:` block (push_branch + create_pr_if_missing). Reads the current
38
- // issue file from <tracker_root>/<state>/<identifier>.md so any transition
39
- // notes appended by `symphony.transition` ride through into the PR body; falls
40
- // back to the in-memory description if the file isn't reachable (no tracker
41
- // root pinned, or read failure). Returns the env map and a cleanup closure the
42
- // caller MUST run after the actions complete, so the temp file is removed
43
- // promptly.
44
- async function stageActionContextEnv(entry) {
45
- const issue = entry.issue;
46
- const ident = entry.identifier;
47
- const branch = `agent/${ident}`;
48
- let body = issue.description ?? '';
49
- if (entry.tracker_root_at_dispatch) {
50
- const issuePath = path.join(entry.tracker_root_at_dispatch, issue.state, `${ident}.md`);
51
- try {
52
- const text = await readFile(issuePath, 'utf8');
53
- body = parseFrontMatterLenient(text).body;
54
- }
55
- catch {
56
- // Fall back to the dispatch-time description; the action still fires, it
57
- // just won't see notes the agent appended during the run.
58
- }
59
- }
60
- const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'symphony-pr-body-'));
61
- const bodyFile = path.join(tmpDir, 'body.md');
62
- await writeFile(bodyFile, body, 'utf8');
63
- const cleanup = async () => {
64
- try {
65
- await rm(tmpDir, { recursive: true, force: true });
66
- }
67
- catch {
68
- // Best-effort cleanup; tmp dir is in $TMPDIR and the OS will reclaim it eventually.
69
- }
70
- };
71
- const title = issue.title.trim();
72
- // Use the base branch pinned at dispatch time (`base_branch_at_dispatch`), the
73
- // same value the workspace was cloned/fetched against, so a WORKFLOW.md reload
74
- // mid-run can't open a PR against a base the agent never rebased onto.
75
- const env = {
76
- SYMPHONY_ISSUE_ID: issue.id,
77
- SYMPHONY_BRANCH: branch,
78
- SYMPHONY_BASE_BRANCH: entry.base_branch_at_dispatch,
79
- SYMPHONY_PR_TITLE: title.length > 0 ? `${issue.id}: ${title}` : issue.id,
80
- SYMPHONY_PR_BODY_FILE: bodyFile,
81
- };
82
- return { env, cleanup };
83
- }
84
- function buildSteeringReplyPrompt(question, context, reply) {
85
- const ctxBlock = context && context.length > 0 ? `\n\nContext you provided:\n${context}` : '';
86
- return [
87
- 'The human operator has responded to your steering request.',
88
- '',
89
- 'Your question was:',
90
- question,
91
- ctxBlock,
92
- '',
93
- 'The human responded:',
94
- reply,
95
- '',
96
- 'Continue work on the issue, taking the human response into account. If the work is fully complete, call symphony.transition to hand off to the next state. If you need to ask another question, call symphony.request_human_steering again.',
97
- ]
98
- .join('\n')
99
- .replace(/\n{3,}/g, '\n\n');
100
- }
101
- /**
102
- * Fixed guest paths for the eval/debug read-only mounts. Hardcoded (not
103
- * configurable) so the prompt body can reference them by literal path. Kept
104
- * as exports for tests and any future operator-facing surface that needs to
105
- * mention them.
106
- */
107
- export const EVAL_MODE_ISSUES_GUEST_PATH = '/symphony/issues';
108
- export const EVAL_MODE_LOGS_GUEST_PATH = '/symphony/logs';
109
- /**
110
- * Resolve effective adapter/model/max_turns for an issue's current state. Per-state
111
- * overrides declared under `states.<name>` win; otherwise the workflow-level
112
- * `acp.adapter` / `acp.model` / `agent.max_turns` defaults apply.
113
- *
114
- * Throws when `state` is not declared in `cfg.states`. The orchestrator should never
115
- * dispatch an issue whose state is not declared (validateDispatch + reconciliation both
116
- * gate on that), but defense in depth: returning a silent fallback here would mask a
117
- * tracker/workflow drift bug as a confusing default-adapter run.
118
- */
119
- export function resolveDispatchConfig(cfg, state) {
120
- const states = cfg.states;
121
- // Case-insensitive lookup matches the rest of symphony (eligibility, reconciliation,
122
- // local-tracker state directories all compare lowercase). A workflow that declares
123
- // `Todo` and a tracker file living under `todo/` still resolves correctly.
124
- let key = null;
125
- if (Object.prototype.hasOwnProperty.call(states, state)) {
126
- key = state;
127
- }
128
- else {
129
- const lower = state.toLowerCase();
130
- for (const name of Object.keys(states)) {
131
- if (name.toLowerCase() === lower) {
132
- key = name;
133
- break;
134
- }
135
- }
136
- }
137
- if (key === null) {
138
- const declared = Object.keys(states).join(', ');
139
- throw new Error(`resolveDispatchConfig: state "${state}" is not declared in workflow states (declared: ${declared.length > 0 ? declared : '<none>'})`);
140
- }
141
- const s = states[key];
142
- const adapter = (s.adapter ?? cfg.acp.adapter);
143
- // Distinguish "not overridden" (undefined) from "explicitly null" (means: use adapter
144
- // default). Only fall back to workflow-level acp.model when the state did not declare
145
- // a model key at all; an explicit null in the state config means the operator wants
146
- // the adapter's own default for this state.
147
- const model = s.model === undefined ? cfg.acp.model : s.model;
148
- const effort = s.effort === undefined ? cfg.acp.effort : s.effort;
149
- const max_turns = s.max_turns ?? cfg.agent.max_turns;
150
- const eval_mode = s.eval_mode === true;
151
- return { adapter, model, effort, max_turns, eval_mode };
152
- }
153
- /**
154
- * Derive the extra read-only bind mounts the eval/debug mode contributes for
155
- * a single dispatch. Returns an empty list when the state did not opt in or
156
- * when neither symphony state root is configured (defense in depth — the
157
- * local tracker always sets `tracker.root` and the loader always sets
158
- * `logs.root`, but a hand-built ServiceConfig in tests might not).
159
- *
160
- * Pure so tests can assert the mount shape without spinning up the runner.
161
- * The host paths are absolute (the loader normalizes both roots), and the
162
- * guest paths are the fixed `EVAL_MODE_*` constants so the prompt body can
163
- * reference them by literal path.
164
- */
165
- export function buildEvalModeMounts(cfg, resolved) {
166
- if (!resolved.eval_mode)
167
- return [];
168
- const mounts = [];
169
- const trackerRoot = cfg.tracker.root;
170
- if (trackerRoot && trackerRoot.length > 0) {
171
- mounts.push({ host: trackerRoot, guest: EVAL_MODE_ISSUES_GUEST_PATH, readonly: true });
172
- }
173
- const logsRoot = cfg.logs.root;
174
- if (logsRoot && logsRoot.length > 0) {
175
- mounts.push({ host: logsRoot, guest: EVAL_MODE_LOGS_GUEST_PATH, readonly: true });
176
- }
177
- return mounts;
178
- }
179
- export class AgentRunner {
180
- cfg;
181
- workflow;
182
- workspaces;
183
- tracker;
184
- vmClient;
185
- events;
186
- mcp;
187
- acpBridge;
188
- followupSink;
189
- actionSnapshotSink;
190
- credentialRegistry;
191
- adapterHooks;
192
- gondolinVmConfig;
193
- constructor(cfg, workflow, workspaces, tracker,
194
- /**
195
- * Gondolin VM-substrate client (the in-process VM backend). Each
196
- * dispatch creates a per-issue VM through a per-attempt `GondolinDispatcher`
197
- * built over this client + the shared credential registry.
198
- */
199
- vmClient, events, mcp = null,
200
- /**
201
- * Host-side TCP bridge the in-VM agent dials back to for ACP traffic. The
202
- * exec channel is now just the process tether + stderr tap; required at
203
- * runtime — runAttempt fails fast if absent.
204
- */
205
- acpBridge = null,
206
- /**
207
- * Sink for `propose_followup` actions (issue 36). Wired to the
208
- * orchestrator's tracker in production; nullable for tests that don't
209
- * exercise the action. Same shape as the MCP `propose_issue` tool's
210
- * tracker-side write.
211
- */
212
- followupSink = null,
213
- /**
214
- * Sink for per-attempt action ledgers surfaced on Snapshot (issue 36 AC5).
215
- * Nullable so tests that don't exercise the snapshot surface can pass
216
- * undefined.
217
- */
218
- actionSnapshotSink = null,
219
- /**
220
- * Shared host credential registry (Gondolin secret-substitution model,
221
- * replaces the credential proxy). Owns every live per-VM `secretManager`;
222
- * the dispatcher registers a manager per dispatch, the ticker fans fresh
223
- * tokens out to all of them on rotation. Required — runAttempt fails fast
224
- * if absent.
225
- */
226
- credentialRegistry = null,
227
- /**
228
- * Per-adapter Gondolin hooks config (allowlist + token-shaped placeholder
229
- * secret + request/response hooks). Built once at composition time from
230
- * `buildAdapterCredentialSpecs`; the dispatcher passes the selected adapter's
231
- * entry straight into `createHttpHooks`. Required — runAttempt fails fast
232
- * if the dispatched adapter is missing.
233
- */
234
- adapterHooks = null,
235
- /**
236
- * Static Gondolin VM shape (image ref + cpus/mem). Resolved from config at
237
- * composition time. Required.
238
- */
239
- gondolinVmConfig = null) {
240
- this.cfg = cfg;
241
- this.workflow = workflow;
242
- this.workspaces = workspaces;
243
- this.tracker = tracker;
244
- this.vmClient = vmClient;
245
- this.events = events;
246
- this.mcp = mcp;
247
- this.acpBridge = acpBridge;
248
- this.followupSink = followupSink;
249
- this.actionSnapshotSink = actionSnapshotSink;
250
- this.credentialRegistry = credentialRegistry;
251
- this.adapterHooks = adapterHooks;
252
- this.gondolinVmConfig = gondolinVmConfig;
253
- }
254
- setAcpBridge(bridge) {
255
- this.acpBridge = bridge;
256
- }
257
- updateConfig(cfg, workflow) {
258
- this.cfg = cfg;
259
- this.workflow = workflow;
260
- }
261
- setMcpRegistry(mcp) {
262
- this.mcp = mcp;
263
- }
264
- vmNameFor(issue) {
265
- return `${SYMPHONY_VM_PREFIX}${sanitizeWorkspaceKey(issue.identifier)}`.toLowerCase();
266
- }
267
- /**
268
- * Resolve the action templating context from the staged `extraEnv` map and
269
- * the running entry. Pass-through to the pure `deriveActionContext` helper
270
- * so the env fallback chain stays out of the imperative-shell complexity
271
- * budget; the shell only adapts `RunningEntry` to the helper's input shape.
272
- * The repo comes from the entry's dispatch-time pin (`github_repo_at_dispatch`),
273
- * NOT live config, so a WORKFLOW.md reload mid-run can't retarget `$repo` away
274
- * from the repo the workspace `origin` was set up against.
275
- */
276
- buildActionContext(entry, workspacePath, extraEnv) {
277
- return deriveActionContext({
278
- identifier: entry.identifier,
279
- workspacePath,
280
- issueId: entry.issue.id,
281
- issueTitle: entry.issue.title ?? '',
282
- issueDescription: entry.issue.description,
283
- repoEnv: entry.github_repo_at_dispatch ?? undefined,
284
- extraEnv,
285
- });
286
- }
287
- /**
288
- * Construct a `RunInVmExecutor` bound to a specific per-issue VM. Each
289
- * invocation execs a fresh one-shot process against the dispatch's live
290
- * `VmHandle`, with stdin closed, stdout/stderr drained into the per-issue
291
- * run log, and a timeout that aborts the exec on overrun. The workspace is
292
- * VFS-mounted at the same host path inside the VM (the dispatcher declares
293
- * that mount at bring-up), so `workdir` is identical on both sides — no path
294
- * translation needed.
295
- *
296
- * Wraps the `VmHandle.exec` contract rather than reaching into the VmClient
297
- * from the actions module so the actions package stays free of
298
- * `node:child_process`/VM imports; the dependency inversion lets tests pass
299
- * `hostRunInVm` without touching the VM substrate.
300
- */
301
- buildVmRunInVm(vm, runLog) {
302
- return ({ name, cmd, env, workdir, timeoutMs, onStdout, onStderr }) => new Promise((resolve) => {
303
- const stream = vm.exec({
304
- command: cmd,
305
- workdir,
306
- env,
307
- // The local timer below kills the exec at the deadline; passing
308
- // timeoutMs to the VM exec lets the substrate enforce the same bound.
309
- timeoutMs,
310
- });
311
- // No stdin: run_in_vm is one-shot exec; the action's `cmd` is the
312
- // full command line. Closing stdin tells the in-VM process its
313
- // input stream is at EOF so it never blocks waiting for input.
314
- try {
315
- stream.stdin.end();
316
- }
317
- catch {
318
- /* idempotent on already-ended stream */
319
- }
320
- let stdout = '';
321
- let stderr = '';
322
- let timedOut = false;
323
- const limit = 65_536;
324
- stream.stdout.setEncoding('utf8');
325
- stream.stderr.setEncoding('utf8');
326
- stream.stdout.on('data', (chunk) => {
327
- stdout += chunk;
328
- if (stdout.length > limit)
329
- stdout = stdout.slice(0, limit);
330
- onStdout?.(chunk);
331
- runLog?.record({ channel: 'hook', hook: `run_in_vm:${name}`, stream: 'stdout', text: chunk });
332
- });
333
- stream.stderr.on('data', (chunk) => {
334
- stderr += chunk;
335
- if (stderr.length > limit)
336
- stderr = stderr.slice(0, limit);
337
- onStderr?.(chunk);
338
- runLog?.record({ channel: 'hook', hook: `run_in_vm:${name}`, stream: 'stderr', text: chunk });
339
- });
340
- const timer = setTimeout(() => {
341
- timedOut = true;
342
- try {
343
- stream.kill();
344
- }
345
- catch {
346
- /* idempotent */
347
- }
348
- }, timeoutMs);
349
- stream.exit
350
- .then(({ code }) => {
351
- clearTimeout(timer);
352
- resolve({
353
- // Gondolin reports the abort/exit as a numeric signal; the
354
- // run-log surface only cares about exit_code + timed_out, so the
355
- // numeric signal is folded into `null` rather than mistyped.
356
- exit_code: code,
357
- signal: null,
358
- timed_out: timedOut,
359
- stdout,
360
- stderr,
361
- });
362
- })
363
- .catch((err) => {
364
- clearTimeout(timer);
365
- resolve({
366
- exit_code: null,
367
- signal: null,
368
- timed_out: timedOut,
369
- stdout,
370
- stderr: stderr + `\n${err.message}`,
371
- });
372
- });
373
- });
374
- }
375
- /**
376
- * Drive the typed action executor for a state's `actions:` block. Reroutes
377
- * the issue when an action returns `route_to` (today: `merge` on conflict);
378
- * the workspace and `agent/<id>` branch are preserved so the operator who
379
- * picks up the issue in the routed state can resolve it.
380
- *
381
- * Returns the underlying `ActionExecResult` so the caller can distinguish
382
- * "rerouted (treat as success — the agent's work is done; the issue lives
383
- * on in the conflict state)" from "non-routed failure (the cleanup pass
384
- * itself failed — the attempt must report `ok: false` so the orchestrator
385
- * retries or surfaces the error)." A void return here is what let a failed
386
- * `push_branch` / `create_pr_if_missing` look like a successful attempt
387
- * in the prior implementation.
388
- */
389
- async runStateActions(stateName, actions, entry, workspacePath, extraEnv, capture, runInVm) {
390
- const ctx = this.buildActionContext(entry, workspacePath, extraEnv);
391
- const snapshotId = `actions:${stateName}`;
392
- const logger = withIssue({ issue_id: entry.issue_id, issue_identifier: entry.identifier });
393
- logger.info('running state actions', {
394
- state: stateName,
395
- action_count: actions.length,
396
- });
397
- const result = await runActions(actions, {
398
- workspacePath,
399
- ctx,
400
- capture: capture ?? undefined,
401
- followupSink: this.followupSink ?? undefined,
402
- runInVm: runInVm ?? undefined,
403
- predicateEnv: defaultPredicateEnv,
404
- snapshotId,
405
- now: () => Date.now(),
406
- });
407
- // Surface on snapshot regardless of outcome; the dashboard shows the
408
- // full ledger including in-progress / error states.
409
- this.actionSnapshotSink?.recordActionResult(snapshotId, {
410
- id: snapshotId,
411
- ready: result.ok,
412
- desired_hash: null,
413
- last_error: result.actions.find((a) => a.state === 'error')?.error ?? null,
414
- actions: result.actions,
415
- });
416
- if (result.route_to) {
417
- logger.warn('state action requested reroute', {
418
- state: stateName,
419
- target_state: result.route_to,
420
- reason: result.reason,
421
- });
422
- await this.rerouteEntryAction(entry, stateName, result.route_to, result.reason);
423
- }
424
- else if (!result.ok) {
425
- logger.warn('state actions failed', {
426
- state: stateName,
427
- reason: result.reason,
428
- });
429
- }
430
- return result;
431
- }
432
- /**
433
- * Move `entry`'s tracker file into `targetState` and append a diagnostic
434
- * note. Used by `runStateActions` when an action returns a route_to (e.g.
435
- * `merge`'s on_conflict).
436
- */
437
- async rerouteEntryAction(entry, fromState, targetState, reason) {
438
- if (!this.tracker.moveIssueToState) {
439
- entry.cleanup_workspace_on_exit = false;
440
- return;
441
- }
442
- const notes = [
443
- `**Action rerouted** to \`${targetState}\` from \`${fromState}\`.`,
444
- '',
445
- `**Reason:** ${reason ?? 'unknown'}`,
446
- '',
447
- `**Workspace and \`agent/${entry.identifier}\` branch are preserved** for resolution.`,
448
- ].join('\n');
449
- try {
450
- await this.tracker.moveIssueToState(entry.issue_id, targetState, {
451
- fromRoot: entry.tracker_root_at_dispatch ?? undefined,
452
- fromState,
453
- notes,
454
- actor: entry.resolved_actor,
455
- });
456
- }
457
- catch {
458
- entry.cleanup_workspace_on_exit = false;
459
- return;
460
- }
461
- entry.cleanup_workspace_on_exit = false;
462
- entry.issue.state = targetState;
463
- // Record the conflict reroute so the run-summary reducer can surface it as
464
- // PR-autopilot rebase churn (issue 123). `rerouted` distinguishes it from a
465
- // normal handoff so it never counts as a review rejection.
466
- entry.last_transition = {
467
- from_state: fromState,
468
- to_state: targetState,
469
- notes,
470
- actor: entry.resolved_actor,
471
- terminal: false,
472
- rerouted: true,
473
- };
474
- }
475
- /**
476
- * Cleanup: run the per-state `actions:` block (push_branch +
477
- * create_pr_if_missing on Done is the canonical example). Stages the
478
- * SYMPHONY_* env vars + temp body file the action context consumes via
479
- * `deriveActionContext` (so $pr_body_file points at the post-transition
480
- * body), and tears them down via `finally`. Returns the non-routed action
481
- * failure reason, or null when the cleanup succeeded / rerouted / was
482
- * skipped (no actions declared or no running entry).
483
- *
484
- * The Done-state push + PR-create handoff is a typed `actions:` block
485
- * (push_branch + create_pr_if_missing) — there is no shell-hook arm here.
486
- */
487
- async runCleanupActions(issue, cleanupState, runningEntry, workspacePath, vm, hookCapture, runLog) {
488
- const logger = withIssue({ issue_id: issue.id, issue_identifier: issue.identifier });
489
- const cleanupActions = resolveActionsForState(this.cfg, cleanupState);
490
- if (!runningEntry || !cleanupActions || cleanupActions.length === 0)
491
- return null;
492
- let staged;
493
- try {
494
- const built = await stageActionContextEnv(runningEntry);
495
- staged = { extraEnv: built.env, cleanup: built.cleanup };
496
- }
497
- catch (err) {
498
- logger.warn('action-context env staging failed; running actions without SYMPHONY_PR_* vars', {
499
- error: err.message,
500
- });
501
- staged = { extraEnv: undefined, cleanup: async () => undefined };
502
- }
503
- try {
504
- // run_in_vm goes through the per-issue VM's exec channel. The VM is still
505
- // alive here — teardown of the dispatch happens after the cleanup actions
506
- // run. A null handle (the dispatch never came up) skips run_in_vm.
507
- const runInVm = vm
508
- ? this.buildVmRunInVm(vm, runLog)
509
- : undefined;
510
- const actionResult = await this.runStateActions(cleanupState, cleanupActions, runningEntry, workspacePath, staged.extraEnv, hookCapture('actions'), runInVm);
511
- if (!actionResult.ok && !actionResult.route_to) {
512
- return actionResult.reason ?? 'unknown';
513
- }
514
- return null;
515
- }
516
- finally {
517
- await staged.cleanup();
518
- }
519
- }
520
- /**
521
- * Per-attempt context assembled once the VM is up and the bridge is registered.
522
- * Everything `tearDownSession` needs to unwind cleanly lives here so post-VM
523
- * failure paths share one teardown contract. `acpSocket` is `null` until the
524
- * in-VM agent dials back; teardown checks for null before destroying.
525
- */
526
- static STDERR_RING_LIMIT = 240;
527
- static STDERR_LOG_LIMIT = 500;
528
- static STEERING_PREVIEW_LIMIT = 240;
529
- async runAttempt(issue, attempt, cancelSignal, runningEntry, runLog) {
530
- const logger = withIssue({ issue_id: issue.id, issue_identifier: issue.identifier });
531
- try {
532
- return await this.runAttemptCore(issue, attempt, cancelSignal, runningEntry, runLog, logger);
533
- }
534
- catch (err) {
535
- if (err instanceof PhaseFailure)
536
- return err.attemptResult;
537
- throw err;
538
- }
539
- }
540
- /**
541
- * Phase pipeline that backs `runAttempt`. Each `unwrap(...)` either yields
542
- * the phase's success value or throws `PhaseFailure` (caught by `runAttempt`
543
- * and converted to a `RunAttemptResult`). This keeps the orchestrator under
544
- * the imperative-shell budget while preserving the strict ordering of the
545
- * original 535-line method.
546
- */
547
- async runAttemptCore(issue, attempt, cancelSignal, runningEntry, runLog, logger) {
548
- const resolved = this.unwrap(this.resolveAttemptDispatch(issue, logger));
549
- const hookCapture = this.makeHookCapture(runLog);
550
- // Use the repo/base pinned on the entry at dispatch time so workspace setup,
551
- // the pre-dispatch fetch, and the Done action context all agree even if
552
- // WORKFLOW.md is reloaded mid-run. Fall back to live config only when no
553
- // entry was supplied (defensive — production always passes one).
554
- const setupSnapshot = {
555
- githubRepo: runningEntry?.github_repo_at_dispatch ?? resolveGithubRepo(this.cfg.workspace.github_repo),
556
- baseBranch: runningEntry?.base_branch_at_dispatch ?? resolveBaseBranch(this.cfg.workspace.base_branch),
557
- };
558
- const ws = this.unwrap(await this.setupWorkspace(issue, setupSnapshot, runLog, logger));
559
- const adapter = this.unwrap(this.prepareAdapterRuntime(resolved, logger));
560
- const bridge = this.unwrap(this.validateAcpBridge(logger));
561
- const vm = this.unwrap(await this.bringUpVmAndExec({
562
- issue,
563
- resolved,
564
- workspacePath: ws.workspace.path,
565
- adapter,
566
- runLog,
567
- logger,
568
- }));
569
- const ctx = {
570
- issue,
571
- runningEntry,
572
- workspacePath: ws.workspace.path,
573
- vm: vm.vm,
574
- bridgeReg: vm.bridgeReg,
575
- exec: vm.exec,
576
- teardownDispatch: vm.teardownDispatch,
577
- acpSocket: null,
578
- hookCapture,
579
- runLog,
580
- logger,
581
- };
582
- // Once dispatch() has succeeded the VM + the per-VM secret registration are
583
- // live and ONLY this ctx's teardown can release them. `unwrap` throws a
584
- // `PhaseFailure` straight up the call stack, so a post-dispatch step that
585
- // returns early/throws BEFORE `tearDownSession` runs would strand the VM +
586
- // the secret manager. Guard the whole post-dispatch path: on any throw, tear
587
- // the dispatch down before propagating (idempotent — the dispatcher's
588
- // teardown() guards re-entry, so the normal success path's tearDownSession is
589
- // never double-counted).
590
- return this.runSessionOrTeardown(ctx, {
591
- resolved,
592
- cancelSignal,
593
- attempt,
594
- acpReachUrl: bridge.acpReachUrl,
595
- });
596
- }
597
- /**
598
- * Drive the post-dispatch path (bridge connect → session init → turn loop →
599
- * teardown) with a teardown safety net: any throw before the normal
600
- * `tearDownSession` runs still releases the Gondolin handle (close VM +
601
- * deregister the secret manager) so a post-dispatch failure cannot leak the VM.
602
- * The dispatch teardown is idempotent, so re-running it from the catch after an
603
- * inner `tearDownSession` already fired is a no-op.
604
- */
605
- async runSessionOrTeardown(ctx, args) {
606
- try {
607
- const session = this.unwrap(await this.connectBridgeAndInitSession({
608
- ctx,
609
- resolved: args.resolved,
610
- cancelSignal: args.cancelSignal,
611
- acpReachUrl: args.acpReachUrl,
612
- }));
613
- const activeStates = new Set(activeStateNames(this.cfg.states).map((s) => s.toLowerCase()));
614
- const loopRes = await this.runTurnLoop({
615
- ctx,
616
- client: session.client,
617
- resolved: args.resolved,
618
- cancelSignal: args.cancelSignal,
619
- attempt: args.attempt,
620
- activeStates,
621
- });
622
- clearInterval(session.cancelCheckTimer);
623
- const tearReason = loopRes.kind === 'mid_failure' ? loopRes.cleanupReason : loopRes.lastReason;
624
- const nonRouted = await this.tearDownSession(ctx, tearReason);
625
- return composeAttemptResult({ loopRes, sessionId: session.sessionId, nonRouted });
626
- }
627
- catch (err) {
628
- // A post-dispatch step threw without unwinding the dispatch (e.g. an
629
- // unexpected throw inside session init that bypassed its own teardown).
630
- // Release the VM + secret registration before re-propagating.
631
- await this.teardownDispatch(ctx, 'post_dispatch_failure');
632
- throw err;
633
- }
634
- }
635
- /** Convert a PhaseResult into either the success value or a thrown PhaseFailure. */
636
- unwrap(res) {
637
- if (!res.ok)
638
- throw new PhaseFailure(res.result);
639
- return res.value;
640
- }
641
- // -------------------------------------------------------------------------
642
- // Phase 1: dispatch resolution
643
- // -------------------------------------------------------------------------
644
- /**
645
- * Pin adapter/model/max_turns once at attempt start. Every downstream read
646
- * goes through `resolved`, not the live `this.cfg.acp.*` — that way a
647
- * workflow reload mid-attempt cannot redirect the adapter or change the
648
- * budget. Fails fast on unknown adapter (defense in depth — validateDispatch
649
- * + per-state validation should have caught it earlier).
650
- */
651
- resolveAttemptDispatch(issue, logger) {
652
- let resolved;
653
- try {
654
- resolved = resolveDispatchConfig(this.cfg, issue.state);
655
- }
656
- catch (err) {
657
- logger.error('dispatch resolution failed', {
658
- error: err.message,
659
- state: issue.state,
660
- });
661
- return failPhase('dispatch resolution error');
662
- }
663
- if (!isKnownAdapter(resolved.adapter)) {
664
- logger.error('unknown acp adapter for state', {
665
- adapter: resolved.adapter,
666
- state: issue.state,
667
- });
668
- return failPhase('unknown acp adapter');
669
- }
670
- return { ok: true, value: resolved };
671
- }
672
- /**
673
- * Build the per-issue capture closure used by the terminal-state `actions:`
674
- * block to mirror its stdout/stderr into the run log's `hook` channel
675
- * (`hook: "actions"`). Returns `undefined` when no run log was provided
676
- * (production always wires one in; tests may not).
677
- */
678
- makeHookCapture(runLog) {
679
- return (hook) => runLog
680
- ? {
681
- onChunk: (stream, text) => runLog.record({ channel: 'hook', hook, stream, text }),
682
- onResult: (r) => runLog.record({
683
- channel: 'hook',
684
- hook,
685
- kind: 'result',
686
- exit_code: r.exit_code,
687
- signal: r.signal,
688
- timed_out: r.timed_out,
689
- }),
690
- }
691
- : undefined;
692
- }
693
- // -------------------------------------------------------------------------
694
- // Phase 2: workspace setup (ensureFor + base fetch)
695
- // -------------------------------------------------------------------------
696
- async setupWorkspace(issue, setup, runLog, logger) {
697
- let workspace;
698
- try {
699
- workspace = await this.workspaces.ensureFor(issue.identifier, setup);
700
- }
701
- catch (err) {
702
- logger.error('workspace error', { error: err.message });
703
- return failPhase('workspace error');
704
- }
705
- const fetchRes = await this.fetchBaseBranch(workspace.path, setup.baseBranch, runLog, logger);
706
- if (!fetchRes.ok)
707
- return fetchRes;
708
- return { ok: true, value: { workspace } };
709
- }
710
- /**
711
- * Issue 101: a fresh `origin/<base>` is a dispatch precondition. The host
712
- * fetches it before every dispatch (fresh OR re-dispatch) so the agent's
713
- * first step — `git rebase origin/<base>` — runs against a current ref.
714
- * Skipped cleanly in local-only mode (no `origin` configured) — the source
715
- * repo's local `<base>` is the only truth there. `baseBranch` is the
716
- * dispatch-time pinned value (NOT live config) so the fetch matches the base
717
- * the Done action context renders.
718
- */
719
- async fetchBaseBranch(workspacePath, baseBranch, runLog, logger) {
720
- const fetchResult = await fetchBaseInWorkspace(workspacePath, baseBranch);
721
- if (!fetchResult.ok) {
722
- logger.error('pre-dispatch base fetch failed; aborting attempt', {
723
- base_branch: baseBranch,
724
- error: fetchResult.diagnostic,
725
- });
726
- runLog?.system('pre_dispatch_base_fetch_failed', {
727
- base_branch: baseBranch,
728
- error: fetchResult.diagnostic,
729
- });
730
- return failPhase('pre-dispatch base fetch failed');
731
- }
732
- if (!fetchResult.skipped) {
733
- runLog?.system('pre_dispatch_base_fetch_ok', { base_branch: baseBranch });
734
- }
735
- return { ok: true, value: undefined };
736
- }
737
- // -------------------------------------------------------------------------
738
- // Phase 3: adapter runtime preparation (credentials + injections)
739
- // -------------------------------------------------------------------------
740
- /**
741
- * Apply model/effort runtime injections and seal the adapter runtime the
742
- * Gondolin dispatcher launches. Pre-handshake failures unwind through the
743
- * normal phase pipeline.
744
- *
745
- * Credentials never enter the VM: the dispatcher stages per-adapter FAKE
746
- * native creds (placeholders only) and registers a per-VM `secretManager`
747
- * that substitutes the real token at egress (Gondolin secret-substitution).
748
- * There is no credential proxy and no base-URL injection — the in-VM client
749
- * dials its REAL upstream in native mode with the placeholder bearer. So this
750
- * phase produces only the model/effort knobs (env vars, extra argv, and the
751
- * non-secret staged files like claude's `settings.json` effortLevel), which
752
- * ride through the dispatch options.
753
- */
754
- prepareAdapterRuntime(resolved, logger) {
755
- const profile = ADAPTERS[resolved.adapter];
756
- const injectedRes = this.applyRuntimeInjectionsOrFail(profile, resolved, logger);
757
- if (!injectedRes.ok)
758
- return injectedRes;
759
- return {
760
- ok: true,
761
- value: {
762
- profile,
763
- adapterBin: profile.binary[0],
764
- effectiveAdapterArgs: [...profile.binary.slice(1), ...injectedRes.value.runtimeArgs],
765
- runtimeEnv: injectedRes.value.runtimeEnv,
766
- // Model/effort staged files (claude effortLevel settings.json) carry
767
- // their content in-memory, so they are passed straight to the dispatcher
768
- // as guest writes — no workspace staging + in-VM `cp` preamble.
769
- extraGuestFiles: injectedRes.value.runtimeExtraFiles,
770
- },
771
- };
772
- }
773
- applyRuntimeInjectionsOrFail(profile, resolved, logger) {
774
- try {
775
- const injected = this.applyRuntimeInjections(profile, resolved);
776
- return { ok: true, value: injected };
777
- }
778
- catch (err) {
779
- logger.error('runtime injection staging failed', { adapter: profile.id, error: err.message });
780
- return failPhase('runtime injection staging error');
781
- }
782
- }
783
- /**
784
- * Compose the model + effort injections through the three orthogonal
785
- * channels the adapter profile declares: env vars (claude-agent-acp's
786
- * ANTHROPIC_MODEL), extra argv (codex-acp's `-c model=...`), and staged
787
- * files (claude-agent-acp's settings.json for `effortLevel`). Pure: the
788
- * staged-file content is known in-memory and converted directly to guest
789
- * writes (no FS staging).
790
- */
791
- applyRuntimeInjections(profile, resolved) {
792
- const acc = { runtimeEnv: {}, runtimeArgs: [], runtimeExtraFiles: [] };
793
- if (resolved.model) {
794
- this.applyModelInjection(profile.modelInjection(resolved.model), acc);
795
- }
796
- if (resolved.effort && profile.effortInjection) {
797
- this.applyModelInjection(profile.effortInjection(resolved.effort), acc);
798
- }
799
- return acc;
800
- }
801
- /** Fold one injection into the accumulator (env / args / guest files). */
802
- applyModelInjection(inj, acc) {
803
- if (inj.env) {
804
- for (const [k, v] of Object.entries(inj.env))
805
- acc.runtimeEnv[k] = v;
806
- }
807
- if (inj.extraArgs)
808
- acc.runtimeArgs.push(...inj.extraArgs);
809
- if (inj.stagedFiles) {
810
- for (const f of inj.stagedFiles) {
811
- acc.runtimeExtraFiles.push({ guestPath: f.guestPath, content: f.content, mode: 0o600 });
812
- }
813
- }
814
- }
815
- // -------------------------------------------------------------------------
816
- // Phase 4: bridge presence + reach URL
817
- // -------------------------------------------------------------------------
818
- /**
819
- * The TCP bridge is mandatory — without it there is no transport for ACP
820
- * frames. Returns the reach URL the in-VM agent will dial back to,
821
- * preferring the explicit `reach_url` override over the host/port derived
822
- * from the bridge's bound port.
823
- */
824
- validateAcpBridge(logger) {
825
- if (!this.acpBridge) {
826
- logger.error('acp bridge is not configured', {});
827
- return failPhase('acp bridge unavailable');
828
- }
829
- const port = this.acpBridge.port() ?? this.cfg.acp.bridge.bind_port;
830
- const acpReachUrl = this.cfg.acp.bridge.reach_url ?? `tcp://${this.cfg.acp.bridge.reach_host}:${port}`;
831
- return { ok: true, value: { acpReachUrl } };
832
- }
833
- // -------------------------------------------------------------------------
834
- // Phase 5: VM bring-up + bridge register + exec stream
835
- // -------------------------------------------------------------------------
836
- /**
837
- * Bring up the per-issue VM, register with the ACP bridge, and start the
838
- * in-VM proxy via Gondolin exec. VM start happens BEFORE bridge register so a
839
- * `register()` synchronous throw cannot leave us with a half-staged
840
- * registration whose `accepted` promise has no `.catch` attached yet
841
- * (Node ≥ 15 crashes on unhandled rejections).
842
- */
843
- async bringUpVmAndExec(args) {
844
- const dispatcherRes = this.buildDispatcherOrFail(args.resolved, args.logger);
845
- if (!dispatcherRes.ok)
846
- return dispatcherRes;
847
- const bridgePort = this.acpBridge.port() ?? this.cfg.acp.bridge.bind_port;
848
- const bridgeHost = this.cfg.acp.bridge.reach_host;
849
- // Register the bridge FIRST so the dispatch launch env carries the bearer.
850
- // `register()` attaches its own internal `.catch`, so a cancel before the
851
- // caller's `await accepted` cannot escalate to an unhandled rejection.
852
- const regRes = this.registerBridgeOrFail(args.issue, args.logger);
853
- if (!regRes.ok)
854
- return regRes;
855
- const bridgeReg = regRes.value.bridgeReg;
856
- try {
857
- const handle = await dispatcherRes.value.dispatch(this.buildDispatchOptions({ ...args, bridgeHost, bridgePort, bridgeReg }));
858
- return {
859
- ok: true,
860
- value: { vm: handle.vm, exec: handle.exec, bridgeReg, teardownDispatch: handle.teardown },
861
- };
862
- }
863
- catch (err) {
864
- // The dispatch failed mid-bring-up. Cancel the bridge registration (the VM,
865
- // if it came up, is torn down by the dispatcher's own error path); the
866
- // reconciler GC converges any session that leaked past close().
867
- bridgeReg.cancel('gondolin_dispatch_failed');
868
- args.logger.error('gondolin dispatch failed', { error: err.message });
869
- return failPhase('gondolin dispatch error');
870
- }
871
- }
872
- /**
873
- * Build the per-dispatch `GondolinDispatcher` for the resolved adapter. The
874
- * VM client, credential registry, and Gondolin VM shape are injected once at
875
- * composition time; the per-adapter hooks config (allowlist + placeholder
876
- * secret + request/response hooks) selects the credential routing. Fails fast
877
- * if any collaborator is unwired (the composition root guarantees them).
878
- */
879
- buildDispatcherOrFail(resolved, logger) {
880
- if (!this.credentialRegistry || !this.adapterHooks || !this.gondolinVmConfig) {
881
- logger.error('gondolin dispatch collaborators are not wired', {
882
- registry: this.credentialRegistry !== null,
883
- hooks: this.adapterHooks !== null,
884
- vmConfig: this.gondolinVmConfig !== null,
885
- });
886
- return failPhase('gondolin dispatch unavailable');
887
- }
888
- const hooks = this.adapterHooks[resolved.adapter];
889
- if (!hooks) {
890
- logger.error('no gondolin hooks config for adapter', { adapter: resolved.adapter });
891
- return failPhase('gondolin adapter hooks unavailable');
892
- }
893
- return {
894
- ok: true,
895
- value: new GondolinDispatcher(this.vmClient, this.credentialRegistry, hooks, this.gondolinVmConfig),
896
- };
897
- }
898
- /**
899
- * Assemble the `GondolinDispatchOptions` for a dispatch. Mounts (workspace RW
900
- * + configured volumes + eval-mode RO) are validated by the Phase 3 guard
901
- * inside the dispatcher; the forwarded boot env is stripped of all credential
902
- * vars there too. The adapter bin/args/runtime-env + the non-secret runtime
903
- * files (effort settings.json) ride through; the bridge host/port + bearer
904
- * become the dispatch's ACP wiring.
905
- */
906
- buildDispatchOptions(args) {
907
- return {
908
- identifier: sanitizeWorkspaceKey(args.issue.identifier).toLowerCase(),
909
- mounts: this.buildVmMounts(args.workspacePath, args.resolved),
910
- env: this.buildForwardedEnv(),
911
- workdir: args.workspacePath,
912
- bridgeHost: args.bridgeHost,
913
- bridgePort: args.bridgePort,
914
- mcp: this.mcpDispatchTarget(),
915
- acpToken: args.bridgeReg.token,
916
- adapterBin: args.adapter.adapterBin,
917
- adapterArgs: args.adapter.effectiveAdapterArgs,
918
- runtimeEnv: args.adapter.runtimeEnv,
919
- extraGuestFiles: args.adapter.extraGuestFiles,
920
- opencodeModel: args.resolved.model,
921
- onStderr: (chunk) => this.onAgentStderr(chunk, args.issue, args.runLog, args.logger),
922
- };
923
- }
924
- /**
925
- * The host MCP endpoint to tunnel into the guest via `tcp.hosts`, or undefined
926
- * when MCP is disabled, the HTTP server hasn't bound a port, or an
927
- * `explicit_host_url` already points the guest at a directly-reachable URL.
928
- * MUST mirror `setupMcpForAttempt`'s synthetic-vs-explicit decision so the guest
929
- * MCP URL and the tunnel that backs it stay consistent — under Gondolin the guest
930
- * cannot reach the host loopback directly, so a missing tunnel means the agent
931
- * can never reach `symphony.transition`.
932
- */
933
- mcpDispatchTarget() {
934
- if (!this.cfg.mcp.enabled || this.cfg.mcp.explicit_host_url || !this.mcp)
935
- return undefined;
936
- const port = this.mcp.getEffectivePort();
937
- return port === null ? undefined : { host: this.cfg.mcp.host, port };
938
- }
939
- buildVmMounts(workspacePath, resolved) {
940
- const mounts = [
941
- { host: workspacePath, guest: workspacePath, readonly: false },
942
- ];
943
- for (const v of this.cfg.gondolin.volumes) {
944
- mounts.push({ host: v.host, guest: v.guest, readonly: v.readonly });
945
- }
946
- for (const m of buildEvalModeMounts(this.cfg, resolved)) {
947
- mounts.push(m);
948
- }
949
- return mounts;
950
- }
951
- /**
952
- * Forward the configured `gondolin.forward_env` vars into the VM boot env, then
953
- * STRIP every credential-bearing var via `stripCredentialEnv` (defense in depth).
954
- * `forward_env` can name a real cred var (e.g. `OPENAI_API_KEY`), so this strip
955
- * makes "no real token reaches the guest boot env" obvious AT THE SOURCE rather
956
- * than only inside the dispatcher. The dispatcher's `buildCreateVmOptions`
957
- * re-applies the SAME `stripCredentialEnv` as the enforcement chokepoint — the
958
- * double-strip is idempotent (`stripCredentialEnv` is a pure filter), so the two
959
- * layers compose without surprise. The guest holds only the placeholder bearer
960
- * Gondolin substitutes at egress (the host-only-refresh invariant). No per-adapter
961
- * omit is needed here (the strip is uniform + strictly stronger).
962
- */
963
- buildForwardedEnv() {
964
- return stripCredentialEnv(computeForwardedEnv(this.cfg.gondolin.forward_env, undefined, (k) => process.env[k]));
965
- }
966
- registerBridgeOrFail(issue, logger) {
967
- try {
968
- const bridgeReg = this.acpBridge.register(issue.id, issue.identifier);
969
- return { ok: true, value: { bridgeReg } };
970
- }
971
- catch (err) {
972
- logger.error('acp bridge register failed', { error: err.message });
973
- return failPhase('acp bridge register failed');
974
- }
975
- }
976
- /**
977
- * The diagnostic-stderr sink the dispatcher pipes the in-VM agent's stderr to.
978
- * Mirrors the old `attachStderrTap`: records to the run log, surfaces a
979
- * truncated `agent_stderr` runtime event, and logs at info. Wired before the
980
- * bridge handshake (the dispatcher taps stderr at launch) so a pre-connect
981
- * crash still surfaces.
982
- */
983
- onAgentStderr(chunk, issue, runLog, logger) {
984
- runLog?.record({ channel: 'stderr', text: chunk });
985
- const text = chunk.trim();
986
- if (text.length === 0)
987
- return;
988
- const truncated = text.length > AgentRunner.STDERR_RING_LIMIT
989
- ? text.slice(0, AgentRunner.STDERR_RING_LIMIT) + '…'
990
- : text;
991
- this.events.onRuntimeEvent(issue.id, {
992
- at: new Date().toISOString(),
993
- event: 'agent_stderr',
994
- message: truncated,
995
- });
996
- logger.info('agent stderr', { text: text.slice(0, AgentRunner.STDERR_LOG_LIMIT) });
997
- }
998
- // -------------------------------------------------------------------------
999
- // Phase 6: bridge connect + MCP setup + AcpClient + initSession
1000
- // -------------------------------------------------------------------------
1001
- async connectBridgeAndInitSession(args) {
1002
- const connRes = await this.waitForBridgeAccept(args.ctx, args.acpReachUrl);
1003
- if (!connRes.ok) {
1004
- await this.tearDownSession(args.ctx, 'acp_bridge_connect_failed');
1005
- return connRes;
1006
- }
1007
- args.ctx.acpSocket = connRes.value.acpSocket;
1008
- const clientRef = { current: null };
1009
- const cancelCheckTimer = this.startCancelTimer(args.ctx, args.cancelSignal, clientRef);
1010
- const mcpRes = this.setupMcpForAttempt(args.ctx);
1011
- if (!mcpRes.ok) {
1012
- await this.cancelAndTearDown(args.ctx, cancelCheckTimer, mcpRes.cleanupReason);
1013
- return { ok: false, result: mcpRes.result };
1014
- }
1015
- const client = this.buildAcpClient(args.ctx, mcpRes.value.mcpServers);
1016
- clientRef.current = client;
1017
- const sessRes = await this.initAcpSession(args.ctx, client, args.resolved);
1018
- if (!sessRes.ok) {
1019
- await this.cancelAndTearDown(args.ctx, cancelCheckTimer, 'init_failed');
1020
- return sessRes;
1021
- }
1022
- this.emitSessionStarted(args.ctx, sessRes.value.sessionId);
1023
- return { ok: true, value: { client, sessionId: sessRes.value.sessionId, cancelCheckTimer } };
1024
- }
1025
- async cancelAndTearDown(ctx, timer, reason) {
1026
- clearInterval(timer);
1027
- await this.tearDownSession(ctx, reason);
1028
- }
1029
- buildAcpClient(ctx, mcpServers) {
1030
- return new AcpClient({
1031
- stdin: ctx.acpSocket,
1032
- stdout: ctx.acpSocket,
1033
- stderr: ctx.exec.stderr,
1034
- cwd: ctx.workspacePath,
1035
- readTimeoutMs: this.cfg.acp.read_timeout_ms,
1036
- promptTimeoutMs: this.cfg.acp.prompt_timeout_ms,
1037
- onEvent: (event) => this.events.onRuntimeEvent(ctx.issue.id, event),
1038
- onTokenUsage: (u) => this.events.onTokenUsage(ctx.issue.id, u),
1039
- mcpServers,
1040
- runLog: ctx.runLog,
1041
- });
1042
- }
1043
- emitSessionStarted(ctx, sessionId) {
1044
- this.events.onSessionStarted?.({
1045
- issueId: ctx.issue.id,
1046
- sessionId,
1047
- threadId: sessionId,
1048
- pid: ctx.exec.pid ? String(ctx.exec.pid) : null,
1049
- });
1050
- }
1051
- /**
1052
- * Race the bridge handshake against a configured connect timeout. A stuck
1053
- * VM or misconfigured `reach_host` would otherwise hang the attempt until
1054
- * the orchestrator's stall timer fires much later.
1055
- */
1056
- async waitForBridgeAccept(ctx, acpReachUrl) {
1057
- try {
1058
- const acpSocket = await Promise.race([
1059
- ctx.bridgeReg.accepted,
1060
- new Promise((_, reject) => setTimeout(() => reject(new Error('acp bridge: in-VM agent did not connect in time')), this.cfg.acp.bridge.connect_timeout_ms)),
1061
- ]);
1062
- ctx.runLog?.system('acp_bridge_connected', { reach_url: acpReachUrl });
1063
- return { ok: true, value: { acpSocket } };
1064
- }
1065
- catch (err) {
1066
- ctx.logger.error('acp bridge connect timeout', { error: err.message });
1067
- ctx.runLog?.system('acp_bridge_failed', { error: err.message });
1068
- return failPhase('acp bridge connect failed');
1069
- }
1070
- }
1071
- /**
1072
- * Start the periodic cancel check. The polite path is `client.cancel()`
1073
- * (session/cancel over ACP); the belt-and-braces path is `forceClose()` to
1074
- * unwind a stuck `runPrompt()` plus `execStream.kill()` and socket destroy
1075
- * to break the transport. `clientRef.current` may briefly be null between
1076
- * timer start and AcpClient construction; the `?.` keeps that race safe.
1077
- */
1078
- startCancelTimer(ctx, cancelSignal, clientRef) {
1079
- const onCancel = () => {
1080
- if (!cancelSignal.cancelled)
1081
- return;
1082
- const c = clientRef.current;
1083
- c?.cancel().catch(() => undefined);
1084
- c?.forceClose('cancel_requested');
1085
- try {
1086
- ctx.exec.kill();
1087
- }
1088
- catch { /* idempotent */ }
1089
- if (ctx.acpSocket && !ctx.acpSocket.destroyed) {
1090
- try {
1091
- ctx.acpSocket.destroy();
1092
- }
1093
- catch { /* idempotent */ }
1094
- }
1095
- };
1096
- return setInterval(onCancel, 500);
1097
- }
1098
- /**
1099
- * Wire the MCP registry servers list for AcpClient. MCP is mandatory for
1100
- * symphony operations (`transition`, `request_human_steering`); fail fast
1101
- * if the registry or the reachable URL is missing.
1102
- */
1103
- setupMcpForAttempt(ctx) {
1104
- const mcpServers = [];
1105
- if (!this.cfg.mcp.enabled || !ctx.runningEntry) {
1106
- return { ok: true, value: { mcpServers } };
1107
- }
1108
- if (!this.mcp) {
1109
- ctx.logger.error('mcp is required but no registry is wired into the runner', {});
1110
- return {
1111
- ok: false,
1112
- result: { ok: false, reason: 'mcp required but registry unavailable', threadId: null, turnsCompleted: 0 },
1113
- cleanupReason: 'mcp_registry_unavailable',
1114
- };
1115
- }
1116
- // Under Gondolin the guest can't reach the host loopback directly, so when no
1117
- // `explicit_host_url` is configured the agent dials the fixed synthetic MCP host
1118
- // (`MCP_GUEST_BASE_URL`) that `mcpDispatchTarget()` tunnelled via `tcp.hosts`.
1119
- // The condition MUST match `mcpDispatchTarget()` exactly (enabled, no explicit
1120
- // URL, port bound) so the advertised URL is always backed by a live tunnel.
1121
- const useSyntheticGuestHost = !this.cfg.mcp.explicit_host_url && this.mcp.getEffectivePort() !== null;
1122
- const url = this.mcp.buildUrl(ctx.runningEntry.identifier, { host: this.cfg.mcp.host, explicit_host_url: this.cfg.mcp.explicit_host_url }, useSyntheticGuestHost ? MCP_GUEST_BASE_URL : undefined);
1123
- if (!url) {
1124
- ctx.logger.error('mcp is required but no reachable URL is configured', {
1125
- host: this.cfg.mcp.host,
1126
- explicit_host_url: this.cfg.mcp.explicit_host_url,
1127
- });
1128
- return {
1129
- ok: false,
1130
- result: {
1131
- ok: false,
1132
- reason: 'mcp required but URL unavailable (start the HTTP server or set mcp.host_url)',
1133
- threadId: null,
1134
- turnsCompleted: 0,
1135
- },
1136
- cleanupReason: 'mcp_url_unavailable',
1137
- };
1138
- }
1139
- const token = this.mcp.activate(ctx.runningEntry);
1140
- mcpServers.push({
1141
- type: 'http',
1142
- name: 'symphony',
1143
- url,
1144
- headers: [{ name: 'Authorization', value: `Bearer ${token}` }],
1145
- });
1146
- ctx.logger.debug('mcp registered', { url });
1147
- return { ok: true, value: { mcpServers } };
1148
- }
1149
- async initAcpSession(ctx, client, resolved) {
1150
- try {
1151
- const sess = await client.initSession();
1152
- return { ok: true, value: { sessionId: sess.sessionId } };
1153
- }
1154
- catch (err) {
1155
- ctx.logger.error('acp init failed', {
1156
- error: err.message,
1157
- adapter: resolved.adapter,
1158
- });
1159
- this.events.onRuntimeEvent(ctx.issue.id, {
1160
- at: new Date().toISOString(),
1161
- event: 'startup_failed',
1162
- message: err.message,
1163
- });
1164
- return failPhase('agent session startup error');
1165
- }
1166
- }
1167
- // -------------------------------------------------------------------------
1168
- // Phase 7: autonomous turn loop
1169
- // -------------------------------------------------------------------------
1170
- /**
1171
- * Drive the ACP loop. Runs as long as the agent keeps engaging — only
1172
- * autonomous turns count against max_turns; steering-reply turns are free
1173
- * because the human is in the loop.
1174
- */
1175
- async runTurnLoop(args) {
1176
- const state = {
1177
- turnsCompleted: 0,
1178
- autonomousTurns: 0,
1179
- lastReason: 'unknown',
1180
- agentFailure: null,
1181
- currentIssue: args.ctx.issue,
1182
- pendingSteering: null,
1183
- firstTurn: true,
1184
- };
1185
- while (true) {
1186
- const iter = await this.runTurnIteration({ ...args, state });
1187
- if (iter.kind === 'mid_failure') {
1188
- return { kind: 'mid_failure', publicReason: iter.publicReason, cleanupReason: iter.cleanupReason, turnsCompleted: state.turnsCompleted };
1189
- }
1190
- if (iter.kind === 'break')
1191
- break;
1192
- }
1193
- return { kind: 'done', lastReason: state.lastReason, agentFailure: state.agentFailure, turnsCompleted: state.turnsCompleted };
1194
- }
1195
- async runTurnIteration(args) {
1196
- const { ctx, client, resolved, cancelSignal, attempt, activeStates, state } = args;
1197
- if (cancelSignal.cancelled) {
1198
- state.lastReason = 'cancelled_by_reconciliation';
1199
- return { kind: 'break' };
1200
- }
1201
- const promptRes = await this.prepareTurnPrompt(state, attempt, ctx.logger);
1202
- if (promptRes.kind === 'mid_failure')
1203
- return promptRes;
1204
- const isSteeringReply = state.pendingSteering !== null;
1205
- state.pendingSteering = null;
1206
- state.firstTurn = false;
1207
- this.events.onTurn(ctx.issue.id, state.turnsCompleted + 1);
1208
- const outcome = await client.runPrompt(promptRes.prompt);
1209
- const turnRes = this.applyTurnOutcome(state, outcome, ctx.runningEntry, isSteeringReply);
1210
- if (turnRes.kind === 'break')
1211
- return turnRes;
1212
- return await this.handlePostTurnFlow({ ctx, cancelSignal, activeStates, resolved, state });
1213
- }
1214
- async prepareTurnPrompt(state, attempt, logger) {
1215
- try {
1216
- const prompt = await this.composeTurnPrompt(state, attempt);
1217
- return { kind: 'ok', prompt };
1218
- }
1219
- catch (err) {
1220
- logger.error('prompt rendering failed', { error: err.message });
1221
- return { kind: 'mid_failure', publicReason: 'prompt error', cleanupReason: 'prompt_error' };
1222
- }
1223
- }
1224
- /**
1225
- * Render the prompt for the next iteration via the pure `selectPromptKind`
1226
- * helper. Steering replies trump everything (the human is in the loop);
1227
- * the first turn gets the full template; later autonomous turns get the
1228
- * bare continuation prompt.
1229
- */
1230
- async composeTurnPrompt(state, attempt) {
1231
- const kind = selectPromptKind({
1232
- pendingSteering: state.pendingSteering !== null,
1233
- firstTurn: state.firstTurn,
1234
- });
1235
- if (kind === 'steering') {
1236
- const ps = state.pendingSteering;
1237
- return buildSteeringReplyPrompt(ps.question, ps.context, ps.reply);
1238
- }
1239
- if (kind === 'initial') {
1240
- return renderPrompt({
1241
- template: this.workflow.prompt_template,
1242
- issue: state.currentIssue,
1243
- attempt,
1244
- });
1245
- }
1246
- return continuationPrompt(this.cfg.mcp.enabled);
1247
- }
1248
- /**
1249
- * Classify the runPrompt outcome (delegated to pure `classifyTurnOutcome`)
1250
- * and update the turn counters. Returns the next loop control: `break`
1251
- * collapses agent failure / agent_transitioned into a single break signal
1252
- * with state already populated; `continue` falls through to post-turn flow.
1253
- */
1254
- applyTurnOutcome(state, outcome, runningEntry, isSteeringReply) {
1255
- const cls = classifyTurnOutcome({
1256
- outcomeReason: outcome.reason,
1257
- outcomeMessage: outcome.message,
1258
- transitioned: runningEntry?.transitioned === true,
1259
- });
1260
- if (cls.kind === 'agent_failure') {
1261
- state.agentFailure = cls.agentFailure;
1262
- state.lastReason = cls.reason;
1263
- return { kind: 'break' };
1264
- }
1265
- if (cls.kind === 'agent_transitioned') {
1266
- state.lastReason = 'agent_transitioned';
1267
- return { kind: 'break' };
1268
- }
1269
- state.turnsCompleted++;
1270
- if (!isSteeringReply)
1271
- state.autonomousTurns++;
1272
- return { kind: 'continue' };
1273
- }
1274
- /**
1275
- * Post-turn flow: tool-driven exit > steering pause > tracker refresh +
1276
- * continuation decision. The tracker-refresh branch is in its own helper
1277
- * so the orchestrator stays under the shell complexity / statement budget;
1278
- * the pure `decideTurnContinuation` and `handleSteeringRequest` carry the
1279
- * decision-heavy work.
1280
- */
1281
- async handlePostTurnFlow(args) {
1282
- const { ctx, cancelSignal, activeStates, resolved, state } = args;
1283
- if (ctx.runningEntry?.transitioned) {
1284
- state.lastReason = 'agent_transitioned';
1285
- return { kind: 'break' };
1286
- }
1287
- if (ctx.runningEntry?.steering_requested && this.mcp) {
1288
- return await this.handleSteeringBranch(ctx, ctx.runningEntry, cancelSignal, state);
1289
- }
1290
- return await this.refreshAndDecideContinuation({ ctx, activeStates, resolved, state });
1291
- }
1292
- async handleSteeringBranch(ctx, entry, cancelSignal, state) {
1293
- const steer = await this.handleSteeringRequest(ctx, entry, cancelSignal);
1294
- if (steer.kind === 'cancelled') {
1295
- state.lastReason = 'cancelled_while_awaiting_steering';
1296
- return { kind: 'break' };
1297
- }
1298
- state.pendingSteering = steer.pendingSteering;
1299
- return { kind: 'continue' };
1300
- }
1301
- async refreshAndDecideContinuation(args) {
1302
- const { ctx, activeStates, resolved, state } = args;
1303
- let refreshed;
1304
- try {
1305
- refreshed = await this.tracker.fetchIssueStatesByIds([ctx.issue.id]);
1306
- }
1307
- catch (err) {
1308
- ctx.logger.error('issue state refresh failed', { error: err.message });
1309
- return { kind: 'mid_failure', publicReason: 'issue state refresh error', cleanupReason: 'issue_state_refresh_failed' };
1310
- }
1311
- const found = refreshed[0] ?? null;
1312
- const cont = decideTurnContinuation({
1313
- refreshedIssue: found,
1314
- activeStates,
1315
- autonomousTurns: state.autonomousTurns,
1316
- maxTurns: resolved.max_turns,
1317
- });
1318
- if (cont.kind === 'break') {
1319
- if (found)
1320
- state.currentIssue = found;
1321
- state.lastReason = cont.reason;
1322
- return { kind: 'break' };
1323
- }
1324
- state.currentIssue = found;
1325
- await delay(25);
1326
- return { kind: 'continue' };
1327
- }
1328
- /**
1329
- * Park the autonomous loop on a pending steering request. The wait does
1330
- * not count against max_turns; cancellation breaks via the registry's
1331
- * cancel-aware resolver (which resolves null when `cancelSignal.cancelled`
1332
- * flips).
1333
- */
1334
- async handleSteeringRequest(ctx, entry, cancelSignal) {
1335
- const question = entry.steering_question ?? '';
1336
- const context = entry.steering_context;
1337
- const limit = AgentRunner.STEERING_PREVIEW_LIMIT;
1338
- this.events.onRuntimeEvent(ctx.issue.id, {
1339
- at: new Date().toISOString(),
1340
- event: 'awaiting_human_steering',
1341
- message: question.length > limit ? question.slice(0, limit) + '…' : question,
1342
- });
1343
- const reply = await this.mcp.awaitSteeringReply(ctx.issue.identifier, cancelSignal);
1344
- if (reply === null)
1345
- return { kind: 'cancelled' };
1346
- entry.steering_requested = false;
1347
- entry.steering_question = null;
1348
- entry.steering_context = null;
1349
- this.events.onRuntimeEvent(ctx.issue.id, {
1350
- at: new Date().toISOString(),
1351
- event: 'human_steering_received',
1352
- message: reply.length > limit ? reply.slice(0, limit) + '…' : reply,
1353
- });
1354
- return { kind: 'received', pendingSteering: { question, context, reply } };
1355
- }
1356
- // -------------------------------------------------------------------------
1357
- // Phase 8: session teardown (consolidates the old `cleanup(reason)` closure)
1358
- // -------------------------------------------------------------------------
1359
- /**
1360
- * Unwind a session: cancel the bridge registration, destroy the socket and
1361
- * kill the launch exec, deactivate MCP, run the per-state `actions:` block
1362
- * (which may `run_in_vm` against the still-live VM), THEN tear down the
1363
- * Gondolin dispatch — close the VM and deregister the per-VM secret manager.
1364
- * The dispatch owns the VM lifecycle now (Gondolin), so teardown is synchronous
1365
- * here rather than deferred to the reconciler.
1366
- *
1367
- * Returns the non-routed action failure reason, or null when the cleanup
1368
- * succeeded or routed (so the caller can fold it into `decideAttemptOutcome`).
1369
- */
1370
- async tearDownSession(ctx, reason) {
1371
- this.detachSession(ctx, reason);
1372
- await this.awaitExecExit(ctx.exec);
1373
- this.deactivateMcpForEntry(ctx.runningEntry);
1374
- ctx.logger.debug('agent runner cleanup', { reason });
1375
- const nonRouted = await this.runCleanupActions(ctx.issue, resolveCleanupState(ctx.issue, ctx.runningEntry), ctx.runningEntry, ctx.workspacePath,
1376
- // run_in_vm cleanup execs into the live dispatch VM before it is closed below.
1377
- ctx.vm, ctx.hookCapture, ctx.runLog);
1378
- await this.teardownDispatch(ctx, reason);
1379
- return nonRouted;
1380
- }
1381
- detachSession(ctx, reason) {
1382
- ctx.bridgeReg.cancel(reason);
1383
- try {
1384
- if (ctx.acpSocket && !ctx.acpSocket.destroyed)
1385
- ctx.acpSocket.destroy();
1386
- }
1387
- catch { /* ignore */ }
1388
- try {
1389
- ctx.exec.kill();
1390
- }
1391
- catch { /* ignore */ }
1392
- }
1393
- async awaitExecExit(exec) {
1394
- try {
1395
- await exec.exit;
1396
- }
1397
- catch { /* ignore */ }
1398
- }
1399
- deactivateMcpForEntry(entry) {
1400
- if (this.mcp && entry)
1401
- this.mcp.deactivate(entry.identifier);
1402
- }
1403
- /**
1404
- * Close the dispatch's VM + deregister its secret manager (idempotent — the
1405
- * dispatcher's `teardown()` guards re-entry). Runs after the cleanup actions
1406
- * so `run_in_vm` still sees a live VM. A null teardown (the dispatch never
1407
- * came up) is a no-op.
1408
- */
1409
- async teardownDispatch(ctx, reason) {
1410
- if (!ctx.teardownDispatch)
1411
- return;
1412
- ctx.runLog?.system('vm_teardown', { reason });
1413
- try {
1414
- await ctx.teardownDispatch();
1415
- }
1416
- catch (err) {
1417
- ctx.logger.warn('gondolin dispatch teardown threw', { error: err.message });
1418
- }
1419
- }
1420
- }
1421
- class PhaseFailure extends Error {
1422
- attemptResult;
1423
- constructor(attemptResult) {
1424
- super(`phase_failure: ${attemptResult.reason}`);
1425
- this.attemptResult = attemptResult;
1426
- this.name = 'PhaseFailure';
1427
- }
1428
- }
1429
- function failPhase(reason) {
1430
- return { ok: false, result: { ok: false, reason, threadId: null, turnsCompleted: 0 } };
1431
- }
1432
- /**
1433
- * Compose the final RunAttemptResult from the turn-loop outcome + teardown
1434
- * action ledger. `mid_failure` (prompt render error, tracker refresh error)
1435
- * gets surfaced verbatim; the happy path delegates to `decideAttemptOutcome`
1436
- * which encodes the agentFailure > non-routed action failure > success
1437
- * precedence.
1438
- */
1439
- function composeAttemptResult(input) {
1440
- if (input.loopRes.kind === 'mid_failure') {
1441
- return {
1442
- ok: false,
1443
- reason: input.loopRes.publicReason,
1444
- threadId: input.sessionId,
1445
- turnsCompleted: input.loopRes.turnsCompleted,
1446
- };
1447
- }
1448
- return decideAttemptOutcome({
1449
- agentFailure: input.loopRes.agentFailure,
1450
- nonRoutedActionFailureReason: input.nonRouted,
1451
- lastReason: input.loopRes.lastReason,
1452
- sessionId: input.sessionId,
1453
- turnsCompleted: input.loopRes.turnsCompleted,
1454
- });
1455
- }
1456
- //# sourceMappingURL=runner.js.map