openlanes 3.0.0 → 3.1.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 (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,776 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # `openlanes run` without the command: narrow the roster and the plans,
6
+ # check the pairing makes sense, set the run up (isolation, log,
7
+ # state file, live board, control room), build the {Runner}, run it to a
8
+ # fixed point, and report what it did.
9
+ #
10
+ # Progress, the startup box and the closing report draw on STDERR and the
11
+ # attempts list prints on STDOUT, as the run happens. The answer is the
12
+ # exit status the command ends with.
13
+ class RunWorkflow
14
+ include UI
15
+ include Dry::Monads[:result]
16
+ include Dry::Monads::Do.for(:call)
17
+
18
+ # `--prompt` shorter than this that names an existing file is read.
19
+ PROMPT_PATH_LIMIT = 80
20
+
21
+ # @param tree [OpenLanes::Plans::Tree]
22
+ # @param origin [String] the directory the command was typed in, which
23
+ # a path typed into `--prompt` is relative to
24
+ # @param options [Hash] the `run` flags as dry-cli parsed them, over
25
+ # `.openlanes.yml`
26
+ def initialize(tree:, origin: Dir.pwd, **options)
27
+ @tree = tree
28
+ @origin = origin
29
+ @options = options
30
+ @root = options[:root] || File.dirname(tree.dir)
31
+ @dry_run = options.fetch(:dry_run, false)
32
+ @quiet = options.fetch(:quiet, false)
33
+ @startup = []
34
+ end
35
+
36
+ # Option precedence, quietest to loudest: the built-in default, then
37
+ # `.openlanes.yml` (`jobs`, and the `commands.run` section), then the
38
+ # flag actually typed. {CLI::Base.default_params} folded the file in.
39
+ #
40
+ # @return [Dry::Monads::Result] Success(Integer) the exit status, or
41
+ # Failure({Refusal}) for flags that contradict each other or the tree
42
+ def call
43
+ yield prompt_needs_agent
44
+ agents = yield roster
45
+ plans = yield scope
46
+ yield assignable(agents, plans) if @options[:agent]
47
+ yield isolatable
48
+ yield logging
49
+ migrated = yield labels_migrated
50
+
51
+ prepare(plans, migrated)
52
+ live = yield live_for(agents)
53
+ web = yield web_for(live)
54
+ runner = runner_for(agents, plans, live, web)
55
+ startup_box
56
+
57
+ started = UI.monotonic
58
+ attempts = run(runner, live, web)
59
+ Success(finish(runner, attempts, seconds: UI.monotonic - started))
60
+ end
61
+
62
+ private
63
+
64
+ # @param message [String]
65
+ # @param status [Integer]
66
+ # @return [Dry::Monads::Result::Failure]
67
+ def refusal(message, status) = Failure(Refusal.new(message:, status:))
68
+
69
+ # @return [Dry::Monads::Result]
70
+ def prompt_needs_agent
71
+ return Success(nil) unless @options[:prompt] && !@options[:agent]
72
+
73
+ refusal("--prompt only works with --agent: it speaks to one agent, and without " \
74
+ "that restriction every agent in the round would hear it.",
75
+ 64)
76
+ end
77
+
78
+ # `--agent` restricts the roster for assignments, not just chaining, or a
79
+ # flag like `--prompt` aimed at one agent leaks to the whole round.
80
+ # Each `--skip` name is checked against the full roster, so a misspelled
81
+ # one is a refusal rather than a silent no-op — the same reasoning
82
+ # `--plan` applies to numbers.
83
+ #
84
+ # @return [Dry::Monads::Result] Success(OpenLanes::Agents::Registry)
85
+ def roster
86
+ all = OpenLanes::Agents.registry
87
+ only = @options[:agent]
88
+ skips = @options[:skip].to_s.split(",").map(&:strip).reject(&:empty?)
89
+
90
+ ([only].compact + skips).each { |name| all.find(name) or return refusal(unknown_agent(all, name), 65) }
91
+ if only && skips.include?(only)
92
+ return refusal("--agent #{only} and --skip #{only} contradict each other: " \
93
+ "one asks for only this agent, the other for anything but.",
94
+ 64)
95
+ end
96
+
97
+ agents = only ? all.only(only) : all
98
+ Success(skips.empty? ? agents : agents.without(*skips))
99
+ end
100
+
101
+ # @param all [OpenLanes::Agents::Registry]
102
+ # @param name [String]
103
+ # @return [String]
104
+ def unknown_agent(all, name) = "No agent called #{name}.\n\nKnown: #{all.all.map(&:name).join(", ")}"
105
+
106
+ # `--plan` names the whole point of scoping: a skill that just minted
107
+ # N folders hands off exactly those, not the tree. A typo'd or already-
108
+ # settled number silently running the whole tree instead is the failure
109
+ # this exists to prevent, so an unknown one is refused rather than
110
+ # dropped.
111
+ #
112
+ # @return [Dry::Monads::Result] Success(Array<OpenLanes::Plans::Ordinal>), or Success(nil) for all
113
+ def scope
114
+ return Success(nil) unless @options[:plan]
115
+
116
+ known = @tree.subjects.map { |s| s.feature.ordinal }
117
+ ordinals = @options[:plan].split(",").map(&:strip).reject(&:empty?).map do |token|
118
+ ordinal = Plans::Ordinal.parse(token)
119
+ unless ordinal && known.include?(ordinal)
120
+ return refusal("No plan #{token} in #{@tree.dir}.\n\nKnown: #{known.join(", ")}", 66)
121
+ end
122
+
123
+ ordinal
124
+ end
125
+ Success(ordinals)
126
+ end
127
+
128
+ # `--agent` narrowing the round to nobody used to exit 0 in silence —
129
+ # "0 plans addressed", green box, no hint that the agent named simply
130
+ # does not handle any in-scope plan's state. Someone who typed both an
131
+ # agent and a plan meant them to meet, so a pairing that cannot happen
132
+ # is refused up front with the agent that would actually take each plan.
133
+ #
134
+ # @param agents [OpenLanes::Agents::Registry] already narrowed to the one agent
135
+ # @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
136
+ # @return [Dry::Monads::Result]
137
+ def assignable(agents, plans)
138
+ name = @options[:agent]
139
+ agent = agents.find(name)
140
+ active = @tree.subjects.select do |s|
141
+ (plans.nil? || plans.include?(s.feature.ordinal)) && !Plans::StateMachine::SETTLED.include?(s.status.key)
142
+ end
143
+ return Success(nil) if active.empty? || active.any? { |s| agent.handles?(s.status) }
144
+
145
+ refusal("#{name} handles #{handled(agent).join(", ")}, and no plan in scope is there:\n\n" \
146
+ "#{takers(active).join("\n")}\n\nName the agent that takes these states, or drop --agent.",
147
+ 65)
148
+ end
149
+
150
+ # @param agent [OpenLanes::Agents::Agent]
151
+ # @return [Array<String>] the states it handles, as a person reads them
152
+ def handled(agent)
153
+ agent.handles.map { |k| OpenLanes::Plans::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
154
+ end
155
+
156
+ # @param active [Array<OpenLanes::Plans::Subject>]
157
+ # @return [Array<String>] each plan's state, and who would take it
158
+ def takers(active)
159
+ roster = OpenLanes::Agents.registry
160
+ active.map do |s|
161
+ names = OpenLanes::Agents::Routing.filter(roster.for_status(s.status), s).map(&:name)
162
+ verb = names.size == 1 ? "takes" : "take"
163
+ " #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
164
+ "#{" - #{names.join(" and ")} #{verb} it" unless names.empty?}"
165
+ end
166
+ end
167
+
168
+ # @return [Symbol] :worktree or :shared
169
+ def isolation = @options.fetch(:isolation, "worktree").to_sym
170
+
171
+ # @return [Dry::Monads::Result]
172
+ def isolatable
173
+ return Success(nil) unless isolation == :worktree && !OpenLanes::Vcs.worktree(root: @root).repository?
174
+
175
+ refusal("#{@root} is not a git repository, so plans cannot be isolated.\n\n" \
176
+ "Run with --isolation shared to work in one tree, serially.",
177
+ 66)
178
+ end
179
+
180
+ # Opens the log every event of the run is written to, the one the
181
+ # control room's server will write to as well (it is handed the same
182
+ # settings through {Web::Launcher#env}). Logged as `engine`, so its
183
+ # lines and the server's tell themselves apart.
184
+ #
185
+ # The project root rather than `--root`: `--root` is where agents
186
+ # work, and the log belongs with the `.plans` it reports on.
187
+ #
188
+ # @return [Dry::Monads::Result]
189
+ def logging
190
+ settings = Log.setup(Log::Settings.from(@options, root: File.dirname(@tree.dir)), name: "engine")
191
+ Log.logger.info("run starting", root: @root, dry_run: @dry_run)
192
+ Success(settings)
193
+ rescue Log::Invalid => e
194
+ refusal(e.message, 64)
195
+ end
196
+
197
+ # The warnings that must land before the first agent,
198
+ # the state file, and a clean control file.
199
+ #
200
+ # @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
201
+ # @param migrated [OpenLanes::Vcs::LabelMigration::Outcome, nil] what
202
+ # {#labels_migrated} did, reported beside the sync
203
+ # @return [void]
204
+ def prepare(plans, migrated = nil)
205
+ startup("Log", UI.log_path)
206
+ credentials unless @dry_run || @quiet
207
+ spec_warning(plans) unless @quiet
208
+ sync_labels(migrated) if publishing?
209
+
210
+ # The state file and the locks live in the runtime state directory,
211
+ # outside the repository; what is left to ignore is the temp siblings
212
+ # plan documents are written to and renamed over. Editing somebody's
213
+ # .gitignore is announced, the once it happens.
214
+ if !@dry_run && StateFile.ensure_ignored!(@root) && !@quiet
215
+ info("Added #{StateFile::IGNORE.join(" and ")} to .gitignore: plan documents are written through these.")
216
+ end
217
+
218
+ # Control files from a previous run would otherwise be read as orders
219
+ # to this one.
220
+ Execution::Control.reset!
221
+ end
222
+
223
+ # M3's review labels moved onto R3's (R3), before anything else of the
224
+ # run: chewie's verification reads `ready-to-merge` as verified and
225
+ # skips CI and coverage, so a run that started with the migration still
226
+ # owed would trust an M3 approval as a verification, and could put a
227
+ # real `ready-to-merge` on that the next start's migration takes back
228
+ # off. So a publishing run that cannot migrate does not start. A dry
229
+ # run labels nothing and only warns.
230
+ #
231
+ # @return [Dry::Monads::Result] Success(Outcome or nil), or a refusal
232
+ def labels_migrated
233
+ return Success(nil) unless publishing?
234
+
235
+ Success(Vcs.label_migration(root: @root).call(dry_run: @dry_run))
236
+ rescue OpenLanes::Error => e
237
+ if @dry_run
238
+ warn("Review labels not migrated: #{e.message}") unless @quiet
239
+ return Success(nil)
240
+ end
241
+
242
+ refusal("Review labels not migrated: #{e.message}\n\n" \
243
+ "Until they are, an M3 ready-to-merge on an open pull request would read as chewie's " \
244
+ "verification. Start again once gh can reach the repository, or run with --no-git-push.",
245
+ 69)
246
+ end
247
+
248
+ # @return [Boolean] whether this run opens and labels pull requests;
249
+ # the same test {#publisher_for} applies, without its warning
250
+ def publishing? = isolation == :worktree && @options[:git_push] != false
251
+
252
+ # Every review label on the repository, in R3's colours, before any
253
+ # pull request is labelled (R3). Once per run. A `gh` that cannot be
254
+ # reached is warned about rather than refused: labelling creates a
255
+ # missing label on first use anyway, so the run loses colours, not work.
256
+ # Against `--root`'s repository, the one the publisher opens pull
257
+ # requests on, not whichever checkout the engine was started from.
258
+ #
259
+ # @param migrated [OpenLanes::Vcs::LabelMigration::Outcome, nil]
260
+ # @return [void]
261
+ def sync_labels(migrated = nil)
262
+ changes = Vcs.label_sync(root: @root).call(dry_run: @dry_run).select(&:write?)
263
+ lines = [migrated&.to_s, *changes].compact.map { " #{it}" }
264
+ return if lines.empty? || @quiet
265
+
266
+ lead = @dry_run ? "Dry run: would sync review labels" : "Synced review labels"
267
+ info("#{lead}:\n#{lines.join("\n")}")
268
+ rescue OpenLanes::Error => e
269
+ warn("Review labels not synced: #{e.message}") unless @quiet
270
+ end
271
+
272
+ # @return [void]
273
+ def credentials
274
+ notice = Execution.credentials_notice
275
+ warn(notice) if notice
276
+ end
277
+
278
+ # A spec.md whose frontmatter does not parse routes as if it had none:
279
+ # lane full, every override dropped. An eval agent's unquoted backtick
280
+ # sent a quick-lane rename to research that way, and said nothing.
281
+ # {Plans::Spec#problems} already knew; say it before agents are assigned.
282
+ #
283
+ # @param plans [Array<OpenLanes::Plans::Ordinal>, nil] the --plan ordinals, nil for all
284
+ # @return [void]
285
+ def spec_warning(plans)
286
+ lines = @tree.subjects.filter_map do |subject|
287
+ ordinal = subject.feature.ordinal
288
+ next if plans && !plans.include?(ordinal)
289
+
290
+ problems = Plans::Spec.for(subject).problems
291
+ "#{ordinal}: #{problems.join("; ")}" unless problems.empty?
292
+ end
293
+ return if lines.empty?
294
+
295
+ warn("spec.md frontmatter ignored in part, so these plans route on defaults:\n#{lines.join("\n")}")
296
+ end
297
+
298
+ # The live board (W6, W13, W26), when this run has one: not on a dry
299
+ # run, which must leave nothing behind, and not without Redis, where
300
+ # the run goes on exactly as it did before there was a page. Either
301
+ # way it is said once, so a page that never updates is not a mystery.
302
+ #
303
+ # @param agents [OpenLanes::Agents::Registry]
304
+ # @return [Dry::Monads::Result] Success(OpenLanes::Live::Session or nil)
305
+ def live_for(agents)
306
+ if @dry_run
307
+ warn("Dry run: the live board is off, so nothing is published to Redis and no plan is claimed.") unless @quiet
308
+ return Success(nil)
309
+ end
310
+
311
+ config = Config.current
312
+ store = Live.connect(Live.redis_url(option: @options[:redis_url], config:))
313
+ Success(Live.session(store:, root: @root, tree: @tree, agents:, project: config.project).tap do |session|
314
+ startup("Live board", session.described)
315
+ end)
316
+ rescue Live::Unavailable => e
317
+ without_live(e.message)
318
+ end
319
+
320
+ # A live board this run cannot do without: `run` goes on headless.
321
+ #
322
+ # @param message [String] why there is no board
323
+ # @return [Dry::Monads::Result] Success(nil)
324
+ def without_live(message)
325
+ unless @quiet
326
+ warn("#{message}\n\nThe run goes on without the live board: no page, no commands from it, and no " \
327
+ "plan claims shared with other machines. Start redis-server, or pass --redis-url.")
328
+ end
329
+ Success(nil)
330
+ end
331
+
332
+ # The control room (W1, W7): `openlanes web` as its own process beside
333
+ # the engine, reading the board this run publishes. Only with a live
334
+ # board to read, so a dry run, a run without Redis and `--no-web` all
335
+ # stay headless. A server that does not come up is reported and left
336
+ # out; the run is the agents' work, and the page is a view of it.
337
+ #
338
+ # @param live [OpenLanes::Live::Session, nil]
339
+ # @return [Dry::Monads::Result] Success(OpenLanes::Web::Launcher or nil)
340
+ def web_for(live)
341
+ return Success(nil) if live.nil? || @options[:web] == false
342
+
343
+ port = Integer(@options[:port] || OpenLanes::Web::DEFAULT_PORT)
344
+ listen = @options[:listen] || OpenLanes::Web::Listen.parse(nil)
345
+ web = OpenLanes::Web.launcher(root: @root,
346
+ plans_dir: @tree.dir,
347
+ port:,
348
+ redis_url: live.store.url,
349
+ listen:,
350
+ credentials: @options[:credentials]).start
351
+ unless web.ready?
352
+ web.stop
353
+ return without_web(web, live)
354
+ end
355
+
356
+ startup("Control room", web.port == port ? web.url : "#{web.url} (#{port} was taken)")
357
+ startup("Also on", "#{(listen.urls(web.port) - [web.url]).join(", ")}, behind a login") if listen.public?
358
+ OpenLanes::Web.open(browser_url(web)) unless @options[:open] == false
359
+ Success(web)
360
+ end
361
+
362
+ # @param web [OpenLanes::Web::Launcher] one that did not answer
363
+ # @param _live [OpenLanes::Live::Session]
364
+ # @return [Dry::Monads::Result] Success(nil)
365
+ def without_web(web, _live)
366
+ warn("The control room did not answer on #{web.url}, so the run goes on without it. Its log: #{web.log}") unless @quiet
367
+ Success(nil)
368
+ end
369
+
370
+ # Where the browser is sent once the control room answers.
371
+ #
372
+ # @param web [OpenLanes::Web::Launcher]
373
+ # @return [String]
374
+ def browser_url(web) = web.url
375
+
376
+ # @param agents [OpenLanes::Agents::Registry]
377
+ # @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
378
+ # @param live [OpenLanes::Live::Session, nil]
379
+ # @param web [OpenLanes::Web::Launcher, nil]
380
+ # @return [OpenLanes::Engine::Runner]
381
+ def runner_for(agents, plans, live, web)
382
+ worktree = (OpenLanes::Vcs.worktree(root: @root) if isolation == :worktree)
383
+ publisher = publisher_for(@root, isolation)
384
+ Engine.runner(
385
+ tree: @tree,
386
+ agents:,
387
+ isolation:,
388
+ jobs: (@options[:jobs] || UI.default_jobs).to_i,
389
+ plans:,
390
+ state: Engine.state_file(@tree),
391
+ worktree:,
392
+ rounds: @options[:rounds]&.to_i,
393
+ executor: executor,
394
+ dry_run: @dry_run,
395
+ publisher:,
396
+ verification: verification_for(publisher, worktree),
397
+ merging: merging_for(publisher, worktree),
398
+ on_board: live&.on_board,
399
+ claims: live&.claims,
400
+ ignore_bombs: @options.fetch(:ignore_bombs, false),
401
+ aging_since: Config.current.aging_since,
402
+ journal: (journal_for(live) unless @dry_run),
403
+ **staying(web, live)
404
+ )
405
+ end
406
+
407
+ # The run's progress journal (S9), under the runtime directory and
408
+ # never under `.plans/`, mirrored to the live bus when there is one.
409
+ #
410
+ # @param live [OpenLanes::Live::Session, nil]
411
+ # @return [OpenLanes::Engine::EventJournal]
412
+ def journal_for(live)
413
+ return EventJournal.new(root: @root) unless live
414
+
415
+ EventJournal.new(root: @root, node: live.keys.node, bus: Live::EventBus.for(live.store), channel: live.keys.journal)
416
+ end
417
+
418
+ # @return [OpenLanes::Execution::Executor]
419
+ def executor
420
+ Execution.executor(root: @root,
421
+ timeout: @options[:timeout]&.to_i,
422
+ dry_run: @dry_run,
423
+ instructions: instructions_from(@options[:prompt]),
424
+ model: @options[:model],
425
+ max_tokens: @options[:max_tokens]&.to_i,
426
+ lean: !@options[:user_config])
427
+ end
428
+
429
+ # `--prompt` takes the instructions themselves or a file holding them.
430
+ # A short argument naming an existing file is read; anything else is
431
+ # the text, so a sentence that happens to match a filename in the
432
+ # project is not silently swapped for that file once it is long enough
433
+ # to be a sentence.
434
+ #
435
+ # @param prompt [String, nil]
436
+ # @return [String, nil]
437
+ def instructions_from(prompt)
438
+ path = prompt && File.expand_path(prompt, @origin)
439
+ return prompt unless prompt && prompt.length < PROMPT_PATH_LIMIT && File.file?(path)
440
+
441
+ File.read(path)
442
+ end
443
+
444
+ # The one thing this harness does that leaves the machine — pushing a
445
+ # branch and opening its pull request — so it takes one more thing to
446
+ # turn on than everything else here: not being a dry run is not enough,
447
+ # isolation has to actually give each plan a branch of its own too.
448
+ #
449
+ # `nil` is how {Runner} is told to leave a finished worktree alone; it
450
+ # is what `--no-git-push` asks for, and what a shared tree gets
451
+ # by construction, since there is no separate branch there to push.
452
+ #
453
+ # @param root [String]
454
+ # @param isolation [Symbol]
455
+ # @return [OpenLanes::Vcs::Publisher, nil]
456
+ def publisher_for(root, isolation)
457
+ if isolation != :worktree || @options[:git_push] == false
458
+ unless @quiet
459
+ warn("No pull request will be opened (#{isolation == :worktree ? "--no-git-push" : "--isolation shared"}), " \
460
+ "so a finished build stays at 🟡 Building:\n🟢 Ready for Review needs an open pull request, " \
461
+ "and hansolo-reviewer reviews one.")
462
+ end
463
+ return nil
464
+ end
465
+
466
+ Vcs.publisher(root:, dry_run: @dry_run)
467
+ end
468
+
469
+ # Verification (plan 014) commits, comments and labels, so it runs
470
+ # only where the build was published: a plan never pushed has no pull
471
+ # request to verify, and a dry run changes nothing.
472
+ #
473
+ # @param publisher [OpenLanes::Vcs::Publisher, nil]
474
+ # @param worktree [OpenLanes::Vcs::Worktree, nil]
475
+ # @return [OpenLanes::Engine::Verification::Driver, nil]
476
+ def verification_for(publisher, worktree)
477
+ return nil if publisher.nil? || worktree.nil? || @dry_run
478
+
479
+ github = Vcs.github
480
+ config = Config.current
481
+ outcome = Verification.outcome(root: @root,
482
+ publisher:,
483
+ github:,
484
+ outcomes: PrOutcomes.new(tree_dir: @tree.dir, agents: Agents::Registry.new),
485
+ agents: Agents::Registry.new,
486
+ # Read live, like `quality:` below: U3 asks the quality mid-run, and
487
+ # an answer of mvp lowers the cap to 1 as soon as it is given.
488
+ max_rejections: -> { Config.current.max_rejections })
489
+ Verification::Driver.new(outcome:, worktree:, app: Apps.definition(config), quality: -> { Config.current.desired_codebase_quality })
490
+ end
491
+
492
+ # Merging (P1 to P5) labels, merges and rebases on GitHub, so it runs
493
+ # only where verification does: a plan never published has no pull
494
+ # request to merge, and a dry run changes nothing.
495
+ #
496
+ # @param publisher [OpenLanes::Vcs::Publisher, nil]
497
+ # @param worktree [OpenLanes::Vcs::Worktree, nil]
498
+ # @return [OpenLanes::Engine::Merging, nil]
499
+ def merging_for(publisher, worktree)
500
+ return nil if publisher.nil? || worktree.nil? || @dry_run
501
+
502
+ Merging.new(github: Vcs.github,
503
+ outcomes: PrOutcomes.new(tree_dir: @tree.dir, agents: Agents::Registry.new),
504
+ # Read live, as the quality is: a person may switch `merge.by`
505
+ # while a plan waits at 🏁.
506
+ merge_by: -> { Config.current.merger },
507
+ worktree:,
508
+ publisher:,
509
+ # A merged plan's checkout with somebody's lock in it stays.
510
+ held: ->(path) { Engine.agent_locks(@tree.dir)&.held_in(path)&.to_s })
511
+ end
512
+
513
+ # What the runner is told about idling past its fixed points. With its
514
+ # control room up the engine stays, since the page can answer a blocked
515
+ # plan or create the next one at any moment, and a run that had ended
516
+ # would leave the page talking to nobody. `--stay` asks the same of a
517
+ # headless run whose page `openlanes web` serves apart, as mprocs does:
518
+ # the commands travel through the live board, not the server. Otherwise
519
+ # headless, or with `--until-settled`, it ends at the first fixed point.
520
+ #
521
+ # A staying run is also the one that watches the pull requests a person
522
+ # merges (M4): it is still up when they do. A run that ends cannot wait
523
+ # for a merge, so it names the pull requests it leaves unwatched instead.
524
+ #
525
+ # @param web [OpenLanes::Web::Launcher, nil] the control room, when it came up
526
+ # @param live [OpenLanes::Live::Session, nil] the board a page apart would read
527
+ # @return [Hash] `stay:`, `rescan:` and `watch:` for {Runner}, or nothing
528
+ def staying(web, live = nil)
529
+ stay_unheeded(live)
530
+ return {} if @options[:until_settled]
531
+ return {} unless web || (live && @options[:stay])
532
+
533
+ @stays = if web
534
+ "The engine stays up with its control room: Stop All Local or Ctrl-C ends it."
535
+ else
536
+ "The engine stays up for the control room `openlanes web` serves: Stop All Local or Ctrl-C ends it."
537
+ end
538
+ { stay: true, rescan: Dispatcher::RESCAN, watch: watch }
539
+ end
540
+
541
+ # A `--stay` this run cannot keep, said once on STDERR. Dropped in
542
+ # silence, mprocs showed an engine that had ended at its first fixed
543
+ # point and nothing said why. A run without Redis goes on headless
544
+ # rather than refusing (see {#without_live}), so this warns the same
545
+ # way instead of exiting 69 as `start` does.
546
+ #
547
+ # @param live [OpenLanes::Live::Session, nil]
548
+ # @return [void]
549
+ def stay_unheeded(live)
550
+ return if @quiet || !@options[:stay]
551
+
552
+ if @options[:until_settled]
553
+ warn("--stay is ignored with --until-settled: the run ends at the first fixed point.")
554
+ elsif live.nil?
555
+ warn("--stay needs the live board (Redis) a separate page reads, and this run has none, " \
556
+ "so it ends at the first fixed point.")
557
+ end
558
+ end
559
+
560
+ # The pull request watch a staying run carries, in human mode only: when
561
+ # agents merge, nobody else's merge is worth waiting for. Kept apart from
562
+ # {#staying} so {StartWorkflow}, which overrides that, cannot leave it behind.
563
+ #
564
+ # @return [OpenLanes::Engine::PrWatch, nil]
565
+ def watch
566
+ PrWatch.new(github: Vcs.github) if Config.current.human_merges? && !@dry_run
567
+ end
568
+
569
+ # The open pull requests a run that does not stay leaves for a person,
570
+ # logged so the next `openlanes start` is known to be the one to watch them.
571
+ #
572
+ # @param runner [OpenLanes::Engine::Runner]
573
+ # @return [void]
574
+ def unwatched(runner)
575
+ return if @stays || !Config.current.human_merges?
576
+
577
+ open = runner.in_scope.flat_map { |s| s.pull_requests.select(&:open?).map { "#{s.feature.ordinal} ##{it.number}" } }
578
+ return if open.empty?
579
+
580
+ UI.log("not watching #{open.join(", ")}: openlanes start watches pull requests until they merge")
581
+ end
582
+
583
+ # One line of the startup box, kept until {#startup_box} draws it.
584
+ #
585
+ # @param label [String]
586
+ # @param value [String, Array<String>] parts a narrow box may break between
587
+ # @return [void]
588
+ def startup(label, value) = @startup << [label, value]
589
+
590
+ # Where the run writes, what it publishes to, and where its page is:
591
+ # one Info box, drawn once all three are known. A line that does not
592
+ # apply (no Redis, no control room) is left out, not drawn empty.
593
+ #
594
+ # @return [void]
595
+ def startup_box
596
+ return if @quiet || @startup.empty?
597
+
598
+ info([UI.labelled(@startup), @stays].compact.join("\n\n"))
599
+ end
600
+
601
+ # The terminal hears the run as it happens, one appended line per
602
+ # event, whether or not a control room is up: the page is the board,
603
+ # and the terminal is its log. Nothing here redraws, so nothing can
604
+ # draw over a box or over another line.
605
+ #
606
+ # @param runner [OpenLanes::Engine::Runner]
607
+ # @param live [OpenLanes::Live::Session, nil]
608
+ # @param web [OpenLanes::Web::Launcher, nil]
609
+ # @return [Array<OpenLanes::Engine::Runner::Attempt>]
610
+ def run(runner, live, web)
611
+ narrator = UI.tap_log(Narrator.new) unless @quiet
612
+ Execution.on_interrupt(signals:) { runner.call { |dispatcher| live&.attach(dispatcher) } }
613
+ ensure
614
+ UI.untap_log(narrator) if narrator
615
+ live&.close
616
+ web&.stop
617
+ end
618
+
619
+ # Signals that stop the run gracefully, the way the first Ctrl-C does.
620
+ # TERM too: a run with its control room stays up like a service, and a
621
+ # service manager stops a service that way.
622
+ #
623
+ # @return [Array<String>]
624
+ def signals = %w[INT TERM]
625
+
626
+ # The closing report. A halted run that exited 0 would read to a
627
+ # script as a tree with nothing left to do, so a 💣 makes it 1 (N9).
628
+ #
629
+ # @param runner [OpenLanes::Engine::Runner]
630
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
631
+ # @param seconds [Float] wall clock for the whole loop
632
+ # @return [Integer] the exit status: 1 when any attempt failed, or
633
+ # when a 💣 halted the run
634
+ def finish(runner, attempts, seconds:)
635
+ unwatched(runner)
636
+ report(runner, attempts, seconds:)
637
+ bombs = runner.halted_by
638
+ bomb_report(bombs)
639
+ status = failures(attempts).empty? && bombs.empty? ? 0 : 1
640
+ Log.logger.public_send(status.zero? ? :info : :warn,
641
+ "run finished",
642
+ attempts: attempts.size,
643
+ failed: failures(attempts).size,
644
+ seconds: seconds.round(1),
645
+ exit: status)
646
+ status
647
+ end
648
+
649
+ # Named on STDERR even under --quiet: it is why the run exits 1.
650
+ #
651
+ # @param bombs [Array<OpenLanes::Plans::Subject>]
652
+ # @return [void]
653
+ def bomb_report(bombs)
654
+ return if bombs.empty?
655
+
656
+ names = bombs.map { |b| " #{b.feature.ordinal} #{b.feature.title} — see blocked.md" }.join("\n")
657
+ error("Halted: #{bombs.size} 💣 plan#{"s" unless bombs.size == 1} other plans wait on, " \
658
+ "blocked too long:\n#{names}\n\n" \
659
+ "Answer blocked.md, resume anyway from the control room, or re-run with --ignore-bombs.")
660
+ end
661
+
662
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
663
+ # @return [Array<OpenLanes::Engine::Runner::Attempt>]
664
+ def failures(attempts) = attempts.reject(&:ok)
665
+
666
+ # One line per attempt: the plan, who ran, which of that agent's rounds
667
+ # it was, what moved, and the note. There is no round header because
668
+ # the loop has no rounds of its own: each agent counts its own per
669
+ # plan, so two agents on one plan can be on different numbers.
670
+ #
671
+ # @param runner [OpenLanes::Engine::Runner]
672
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
673
+ # @param seconds [Float]
674
+ # @return [void]
675
+ def report(runner, attempts, seconds:)
676
+ # What the run cost, on STDOUT with the attempts it belongs to, so a run
677
+ # redirected to a file keeps its bill. A dry run spent nothing and gets
678
+ # none of this.
679
+ puts_report(attempts, (Engine.tally(attempts:, seconds:).render unless @dry_run))
680
+ return if @quiet
681
+
682
+ if Execution::Control.quit?
683
+ warn("Stopped from the keyboard — q. Unfinished plans keep their state; " \
684
+ "re-run to resume where they stand.")
685
+ end
686
+
687
+ summarize(runner, attempts)
688
+ held_report(runner.held)
689
+ blocked_report(runner.blocked)
690
+ end
691
+
692
+ # @param runner [OpenLanes::Engine::Runner]
693
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
694
+ # @return [void]
695
+ def summarize(runner, attempts)
696
+ failed = failures(attempts)
697
+ summary = ["#{attempts.size} invocation#{"s" unless attempts.size == 1}", "#{attempts.count(&:advanced?)} advanced"]
698
+ summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
699
+ summary << "#{runner.blocked.size} blocked" unless runner.blocked.empty?
700
+ summary << "#{failed.size} failed" unless failed.empty?
701
+
702
+ if @dry_run
703
+ warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run without --dry-run.")
704
+ elsif failed.empty?
705
+ success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
706
+ else
707
+ error("#{summary.join(" · ")}\n\n#{failed.map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
708
+ end
709
+ end
710
+
711
+ # Plans the dependency gate held back, and why, so a run that stops at a
712
+ # fixed point with work left says what it is waiting for.
713
+ #
714
+ # @param held [Hash{String => String}] ordinal => reason
715
+ # @return [void]
716
+ def held_report(held)
717
+ return if held.empty?
718
+
719
+ warn("#{held.size} plan#{"s" unless held.size == 1} held by dependencies:\n" +
720
+ held.map { |ordinal, why| " #{ordinal} #{why}" }.join("\n"))
721
+ end
722
+
723
+ # @param blocked [Array<OpenLanes::Plans::Subject>]
724
+ # @return [void]
725
+ def blocked_report(blocked)
726
+ return if blocked.empty?
727
+
728
+ warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
729
+ blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
730
+ "\n\nWrite the answers into blocked.md, then: openlanes unblock " \
731
+ "#{blocked.map { |b| b.feature.ordinal }.join(",")}")
732
+ end
733
+
734
+ # The attempts and the bill, one blank line before each part that has
735
+ # anything in it. `puts []` prints an empty line, so a run with no
736
+ # attempts used to open its report with two blank lines and a third
737
+ # before the tally.
738
+ #
739
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
740
+ # @param tally [String, nil] nil prints none
741
+ # @return [void]
742
+ def puts_report(attempts, tally)
743
+ lines = attempt_lines(attempts)
744
+ puts("", *lines) unless lines.empty?
745
+ puts("", tally) if tally
746
+ end
747
+
748
+ # The attempts list, padded into columns. Widths come from the rows
749
+ # themselves, so the columns are as narrow as the run allows and still
750
+ # square; tabs put every column on the next multiple of eight, so one
751
+ # agent's name a character longer pushed a whole column a stop right.
752
+ #
753
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
754
+ # @return [Array<String>] one line per attempt, indented by two
755
+ def attempt_lines(attempts)
756
+ rows = attempts.map { |a| [a.ordinal.to_s, a.agent.to_s, "[R:#{a.round}]", mark(a), a.note.to_s] }
757
+ widths = rows.transpose.map { |column| column.map { |cell| UI.display_width(cell) }.max.to_i }
758
+ rows.map do |cells|
759
+ # The note is last and is left as it is: padding the final column
760
+ # only adds trailing whitespace a copy-paste then carries.
761
+ last = cells.size - 1
762
+ " #{cells.each_with_index.map { |cell, i| i == last ? cell : UI.fit(cell, widths[i] + 2) }.join}"
763
+ end
764
+ end
765
+
766
+ # @param attempt [OpenLanes::Engine::Runner::Attempt]
767
+ # @return [String] what the attempt did to its plan's state
768
+ def mark(attempt)
769
+ return "FAIL" unless attempt.ok
770
+ return "#{attempt.from} -> #{attempt.to}" if attempt.advanced?
771
+
772
+ "no change"
773
+ end
774
+ end
775
+ end
776
+ end