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,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # What the engine does with a 🏁 Completed plan once palpatine has ruled
6
+ # (decisions P1 to P5).
7
+ #
8
+ # palpatine only writes `plan.md` and signs. Everything that leaves the
9
+ # machine happens here: labelling each `ready-to-merge` pull request
10
+ # `auto-merged` or `merge-manually`, merging the `auto` ones one at a
11
+ # time under `merge.by: auto`, and rebasing the siblings left open onto
12
+ # `main` after each merge. A rebase that raises leaves that sibling for a
13
+ # person, and every other open sibling is still rebased or left in the
14
+ # same pass, rather than retrying the merge on old green CI. A branch
15
+ # GitHub still reports as `BEHIND` is never merged. Every step is asked of
16
+ # GitHub as it stands, so {#advance} may be called on every poll and does
17
+ # nothing twice. That is also how a sibling waits for CI on its rebased
18
+ # head, and how a person's merge of a `merge-manually` pull request is
19
+ # noticed in either mode.
20
+ #
21
+ # The plan reaches ✅ through {PrOutcomes}, once nothing is left open,
22
+ # exactly as when a person merges the last pull request by hand.
23
+ class Merging
24
+ # The phase an agent definition declares to be the risk assessor. Its
25
+ # Completed note carries the verdicts.
26
+ PHASE = "completion"
27
+
28
+ # The two verdicts palpatine may give.
29
+ VERDICTS = %w[auto manual].freeze
30
+
31
+ # One verdict, with or without the pull request it is for.
32
+ VERDICT = /(auto|manual)(?:\s+#?(\d+))?/i
33
+
34
+ # The only notes read at all: `auto`, `manual #13`, `auto #12, manual #13`,
35
+ # with at most one bare verdict, first. Matched against the whole note,
36
+ # because a scan found the `auto` in "do not auto-merge" and would have
37
+ # merged the plan.
38
+ NOTE = /\A\s*#{VERDICT}(?:\s*,\s*(?:auto|manual)\s+#?\d+)*\s*\z/i
39
+
40
+ # How long a rebased sibling's zero checks still mean "CI has not
41
+ # registered on the new head yet", as {Verification::Outcome::NO_CHECKS_GRACE}
42
+ # does for the verification commit.
43
+ NO_CHECKS_GRACE = Verification::Outcome::NO_CHECKS_GRACE
44
+
45
+ # Who signs the comment carrying a rebase conflict.
46
+ SENDER = "openlanes"
47
+
48
+ # What one call did, for the log.
49
+ #
50
+ # @!attribute [r] note
51
+ # @return [String]
52
+ # @!attribute [r] to
53
+ # @return [Symbol, nil] the state the folder moved to, nil when it stayed
54
+ # @!attribute [r] merged
55
+ # @return [Boolean] whether the engine merged a pull request in this pass
56
+ Result = Data.define(:note, :to, :merged) do
57
+ def initialize(to: nil, merged: false, **rest) = super
58
+ end
59
+
60
+ class << self
61
+ # The verdict for each pull request, read off palpatine's note. A
62
+ # bare verdict covers every pull request it does not name; a pull
63
+ # request the note says nothing about, or a note that is missing or
64
+ # not in the {NOTE} shape, is `manual` (P4): a wrong `manual` costs a
65
+ # person ten minutes, a wrong `auto` can ship an outage.
66
+ #
67
+ # @param note [String, nil]
68
+ # @param numbers [Array<Integer>] the `ready-to-merge` pull requests
69
+ # @return [Hash{Integer => String}]
70
+ def verdicts(note, numbers)
71
+ return numbers.to_h { [it, "manual"] } unless readable?(note)
72
+
73
+ found = note.to_s.scan(VERDICT).map { |word, number| [word.downcase, number&.to_i] }
74
+ named = found.select(&:last).to_h { |word, number| [number, word] }
75
+ bare = found.find { |_, number| number.nil? }&.first || "manual"
76
+ numbers.to_h { |n| [n, named.fetch(n, bare)] }
77
+ end
78
+
79
+ # @param note [String, nil]
80
+ # @return [Boolean] whether the note is in the {NOTE} shape
81
+ def readable?(note) = note.to_s.match?(NOTE)
82
+ end
83
+
84
+ # @param github [Vcs::GitHub]
85
+ # @param outcomes [PrOutcomes] records merges and rejections, and moves the folder
86
+ # @param merge_by [#call, Symbol] {Config#merger}, read live
87
+ # @param worktree [Vcs::Worktree, nil] where a plan's own branch is checked out
88
+ # @param publisher [Vcs::Publisher, nil] pushes a rebased branch, with the lease
89
+ # @param clock [#call]
90
+ # @param held [#call, nil] path -> who holds an alock lock in it, or nil;
91
+ # a merged plan's checkout somebody holds is not retired
92
+ def initialize(github:, outcomes:, merge_by:, worktree: nil, publisher: nil, clock: -> { Clock::System.now }, held: nil)
93
+ @github = github
94
+ @outcomes = outcomes
95
+ @merge_by = merge_by
96
+ @worktree = worktree
97
+ @publisher = publisher
98
+ @clock = clock
99
+ @held = held
100
+ end
101
+
102
+ # palpatine signed, or its run ended without a signature: keep the
103
+ # verdicts for this visit to 🏁, then act on them.
104
+ #
105
+ # @param subject [Plans::Subject] at 🏁
106
+ # @param note [String, nil] palpatine's Completed note; nil reads as `manual` throughout
107
+ # @return [Result]
108
+ def assess(subject, note)
109
+ numbers = subject.pull_requests.select(&:open?).map { number_of(it) }
110
+ unless note.nil? || self.class.readable?(note)
111
+ UI.log("palpatine's note is not a verdict list, so every pull request is manual: #{note.inspect}",
112
+ level: :warn,
113
+ plan: subject.feature.ordinal.to_s)
114
+ end
115
+ Plans::PlanState.for(subject.feature.path).assess!(self.class.verdicts(note, numbers), at: @clock.call)
116
+ advance(subject)
117
+ end
118
+
119
+ # @param subject [Plans::Subject]
120
+ # @return [Boolean] whether palpatine has ruled on this visit to 🏁, so
121
+ # nobody is to start it again
122
+ def assessed?(subject) = subject.status.key == :completed && !record(subject).nil?
123
+
124
+ # One pass over a 🏁 plan's pull requests. At most one merge per pass:
125
+ # the siblings are rebased after it, and each must show green CI on
126
+ # its new head before it merges in turn.
127
+ #
128
+ # @param subject [Plans::Subject] at 🏁, assessed
129
+ # @return [Result]
130
+ def advance(subject)
131
+ record = record(subject) or return Result.new(note: "palpatine has not ruled yet")
132
+
133
+ notes = []
134
+ subject.pull_requests.select(&:open?).each do |pull|
135
+ step = step(subject, pull, record)
136
+ next unless step
137
+
138
+ notes << step.note
139
+ return Result.new(note: notes.join("; "), to: step.to, merged: step.merged) if step.to || step.merged
140
+ end
141
+ Result.new(note: notes.empty? ? "waiting on every pull request" : notes.join("; "))
142
+ end
143
+
144
+ # The plans whose checkouts wait on a rebase after a merge.
145
+ #
146
+ # @return [Array<String>] ordinals
147
+ def pending_rebases = queued.keys
148
+
149
+ # Rebase every queued checkout no agent is running in onto `main`, and
150
+ # push the ones that moved. A busy checkout stays queued, so its
151
+ # `HEAD` moves only between runs, before its next boundary snapshot.
152
+ # A conflict is aborted and left for that plan's next round, whose own
153
+ # pre-run rebase hands it to bb8; uncommitted work is refused by
154
+ # {Vcs::Refresh} and never touched.
155
+ #
156
+ # @param busy [Array<String>] ordinals with an agent running
157
+ # @return [Array<String>] one line per checkout handled, for the log
158
+ def rebase_queued(busy:)
159
+ (queued.keys - busy).map do |ordinal|
160
+ other = queued.delete(ordinal)
161
+ rebase_checkout(other)
162
+ rescue OpenLanes::Error => e
163
+ "#{ordinal} not rebased onto main: #{e.message.lines.first.to_s.strip}"
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ # @param other [Plans::Subject]
170
+ # @return [String]
171
+ def rebase_checkout(other)
172
+ feature = other.feature
173
+ checkout = @worktree.checkout_for(feature)
174
+ refresh = Vcs::Refresh.new(path: checkout.path)
175
+ outcome = refresh.call
176
+ ordinal = feature.ordinal.to_s
177
+ if outcome.conflicted?
178
+ refresh.abort!("conflicts after a merge to main")
179
+ return "#{ordinal} conflicts with main in #{outcome.files.join(", ")}; its next round resolves it"
180
+ end
181
+ return "#{ordinal} not rebased: #{outcome.reason}" unless outcome.done?
182
+ return "#{ordinal} already on main" if outcome.kind == :current
183
+ # A branch with no pull request was never published, and a rebase
184
+ # is no reason to publish it.
185
+ return "#{ordinal} rebased onto main" unless other.pull_requests.any?(&:open?)
186
+
187
+ @publisher.pushed!(checkout, "HEAD")
188
+ "#{ordinal} rebased onto main"
189
+ end
190
+
191
+ # One pull request, one step along its machine.
192
+ #
193
+ # @return [Result, nil] nil when there is nothing to do for it now
194
+ def step(subject, pull, record)
195
+ number = number_of(pull)
196
+ repo = Vcs::GitHub.repo_of(pull.url)
197
+ live = @github.pull_request(pull.url || number.to_s)
198
+ return settled_by_someone(subject, number, live[:state], live:) unless live[:open]
199
+
200
+ machine = Plans::PullRequestMachine.new(pull, live[:labels])
201
+ return nil unless machine.key == :ready_to_merge
202
+
203
+ verdict = record.dig("verdicts", number.to_s) || "manual"
204
+ return leave(machine, number, repo, "palpatine said manual") if verdict == "manual"
205
+ return leave(machine, number, repo, "merge.by is #{merge_by}") unless merge_by == :auto
206
+
207
+ merge(subject, machine, number, repo, record, live[:head], merge_state: live[:merge_state])
208
+ end
209
+
210
+ # Merged or closed on GitHub, by the engine a moment ago or by a person
211
+ # at any time, in either mode.
212
+ #
213
+ # @param state [String] as {Vcs::GitHub.state_label} words it
214
+ # @param live [Hash, nil] the pull request as {Vcs::GitHub#pull_request} read it
215
+ # @param ref [String, nil] how to read it when +live+ is nil
216
+ # @return [Result]
217
+ def settled_by_someone(subject, number, state, live: nil, ref: nil)
218
+ kind = state.to_s.match?(/merged/i) ? :merged : :closed
219
+ applied = @outcomes.apply(PrWatch::Outcome.new(plan: subject.feature.ordinal.to_s, pr: number, kind:))
220
+ retire(subject, number, live || ref) if kind == :merged
221
+ Result.new(note: "PR ##{number} #{kind}", to: applied&.to)
222
+ end
223
+
224
+ # A merged pull request ends its plan's worktree, once it is the
225
+ # plan's last open one: a sibling still open is rebased in that
226
+ # checkout after this merge. {Vcs::Worktree#retire} decides whether
227
+ # each checkout's work really landed; this only says when to ask.
228
+ # Never fatal: a checkout left behind costs disk, and a merge that
229
+ # failed over one would cost the plan.
230
+ #
231
+ # @param pull [Hash, String, nil] the merged pull request as read, or a ref to read it by
232
+ # @return [void]
233
+ def retire(subject, number, pull)
234
+ return if @worktree.nil? || pull.nil?
235
+ return if fresh(subject).pull_requests.any? { it.open? && number_of(it) != number }
236
+
237
+ ordinal = subject.feature.ordinal.to_s
238
+ live = pull.is_a?(Hash) ? pull : @github.pull_request(pull)
239
+ return if live[:merge_commit].to_s.empty?
240
+
241
+ @worktree.checkouts_of(subject.feature).each do |path|
242
+ UI.log(@worktree.retire(path, merge_commit: live[:merge_commit], base: live[:base], held: @held).to_s, plan: ordinal)
243
+ end
244
+ rescue OpenLanes::Error => e
245
+ UI.log("worktree not retired: #{e.message.lines.first.to_s.strip}", level: :warn, plan: ordinal)
246
+ end
247
+
248
+ # @param comment [Boolean] post +why+ on the pull request, so a person
249
+ # looking at it sees why the engine stepped away
250
+ # @return [Result]
251
+ def leave(machine, number, repo, why, comment: false)
252
+ machine.fire!(:leave_for_a_person)
253
+ @github.swap_label(number:,
254
+ repo:,
255
+ add: Plans::ReviewLabel::MERGE_MANUALLY,
256
+ remove: Plans::ReviewLabel.displaced_by(Plans::ReviewLabel::MERGE_MANUALLY))
257
+ @github.comment(number:, repo:, body: why) if comment
258
+ Result.new(note: "PR ##{number} #{Plans::ReviewLabel::MERGE_MANUALLY}: #{why}")
259
+ end
260
+
261
+ # Merge once CI is green on the head as it stands, then rebase the
262
+ # siblings. A merge GitHub refuses, a conflict among the reasons, is
263
+ # left for a person rather than retried every poll. So is one with no
264
+ # CI at all: nobody watches this merge, and no checks is no evidence.
265
+ # The merge names the head read before the checks, so a push in
266
+ # between makes GitHub refuse it rather than merge a head CI never saw.
267
+ # A branch still behind `main` is the same: CI on that head never saw
268
+ # the commit just merged, so a person rebases it.
269
+ #
270
+ # @param head [String, nil] the head commit, as read before the checks
271
+ # @param merge_state [String, nil] GitHub's `mergeStateStatus`
272
+ # @return [Result, nil]
273
+ def merge(subject, machine, number, repo, record, head, merge_state: nil)
274
+ ci = Vcs::CiGate.verdict(@github.checks(number:, repo:))
275
+ ci = :registering if ci == :none && registering?(record, number)
276
+ return Result.new(note: "PR ##{number} waits on CI before it merges") if %i[pending registering].include?(ci)
277
+ return reject(subject, number, repo, "CI is red on PR ##{number} after its rebase onto main.") if ci == :red
278
+ return leave(machine, number, repo, "no CI checks ran") if ci == :none
279
+ return leave(machine, number, repo, "its head commit could not be read") if head.to_s.empty?
280
+ return leave(machine, number, repo, "its branch is behind main", comment: true) if behind?(merge_state)
281
+ return leave(machine, number, repo, "it does not merge clean onto main", comment: true) if dirty?(merge_state)
282
+ return Result.new(note: "PR ##{number} waits on GitHub to work out whether it merges clean") if unknown?(merge_state)
283
+
284
+ main = main_ci(repo)
285
+ return Result.new(note: "PR ##{number} waits on CI on main, which is #{main}") unless main == :green
286
+
287
+ begin
288
+ @github.merge(number:, repo:, head:)
289
+ rescue OpenLanes::Error => e
290
+ return leave(machine, number, repo, e.message)
291
+ end
292
+ queue_other_plans(subject)
293
+ machine.fire!(:merge_automatically)
294
+ @github.swap_label(number:,
295
+ repo:,
296
+ add: Plans::ReviewLabel::AUTO_MERGED,
297
+ remove: Plans::ReviewLabel.displaced_by(Plans::ReviewLabel::AUTO_MERGED))
298
+ merged = settled_by_someone(subject, number, "Merged", ref: repo ? "https://github.com/#{repo}/pull/#{number}" : number.to_s)
299
+ done = Result.new(note: "PR ##{number} #{Plans::ReviewLabel::AUTO_MERGED}", to: merged.to, merged: true)
300
+ return done if merged.to
301
+
302
+ rebased = rebase_siblings(fresh(subject), number)
303
+ rebased ? rebased.with(note: "#{done.note}; #{rebased.note}", merged: true) : done
304
+ end
305
+
306
+ # Every pull request of the plan still open, onto `main` (P5). The
307
+ # plan's own branch is rebased in its worktree and pushed with the
308
+ # lease; a sibling from any other branch is rebased on GitHub's side.
309
+ # A conflict still sends the plan to 🔴. Anything else that raises
310
+ # (a refused push, a GitHub update that did not finish) leaves that
311
+ # sibling for a person, and the walk continues: returning from the
312
+ # first raise used to skip later siblings, which kept `ready-to-merge`
313
+ # and the old green CI. GitHub's `BEHIND` is silent unless the base
314
+ # requires branches to be up to date, so each sibling must be rebased
315
+ # or left in this pass.
316
+ #
317
+ # @return [Result, nil] the rejection a conflict caused, or the leaves a
318
+ # rebase that raised caused; nil when every sibling rebased
319
+ def rebase_siblings(subject, merged)
320
+ notes = []
321
+ subject.pull_requests.select(&:open?).each do |pull|
322
+ number = number_of(pull)
323
+ repo = Vcs::GitHub.repo_of(pull.url)
324
+ why = rebase(subject, pull, number, repo)
325
+ return reject(subject, number, repo, why) if why
326
+
327
+ Plans::PlanState.for(subject.feature.path).rebased!(number, at: @clock.call)
328
+ rescue OpenLanes::Error => e
329
+ UI.log("rebase after merging ##{merged} not finished: #{e.message}",
330
+ level: :warn,
331
+ plan: subject.feature.ordinal.to_s)
332
+ notes << leave_unrebased(pull, number, repo, e.message).note
333
+ end
334
+ notes.empty? ? nil : Result.new(note: notes.join("; "))
335
+ end
336
+
337
+ # @return [Result]
338
+ def leave_unrebased(pull, number, repo, error)
339
+ live = @github.pull_request(pull.url || number.to_s)
340
+ machine = Plans::PullRequestMachine.new(pull, live[:labels])
341
+ leave(machine, number, repo, "rebase onto main failed: #{error}", comment: true)
342
+ end
343
+
344
+ # @param status [String, nil] GitHub's `mergeStateStatus`
345
+ # @return [Boolean]
346
+ def behind?(status) = status.to_s.upcase == "BEHIND"
347
+
348
+ # @param status [String, nil] GitHub's `mergeStateStatus`
349
+ # @return [Boolean] whether the merge would conflict
350
+ def dirty?(status) = status.to_s.upcase == "DIRTY"
351
+
352
+ # @param status [String, nil] GitHub's `mergeStateStatus`
353
+ # @return [Boolean] whether GitHub is still computing it
354
+ def unknown?(status) = status.to_s.upcase == "UNKNOWN"
355
+
356
+ # CI on `main` (R5). No checks at all reads as `:none`, which holds
357
+ # the merge like red does: nobody watches it, and no checks is no
358
+ # evidence `main` builds.
359
+ #
360
+ # @return [Symbol] as {Vcs::CiGate.verdict}
361
+ def main_ci(repo) = Vcs::CiGate.verdict(@github.branch_checks(branch: "main", repo:))
362
+
363
+ # Every other plan whose checkout exists, to be rebased onto `main`
364
+ # once no agent is running in it (R5). Never done here: a rebase moves
365
+ # `HEAD`, and an agent's after-check would blame the agent for it.
366
+ #
367
+ # @return [void]
368
+ def queue_other_plans(subject)
369
+ return if @worktree.nil? || @publisher.nil?
370
+
371
+ Plans::Tree.new(dir: File.dirname(subject.feature.path)).subjects.each do |other|
372
+ next if other.feature.ordinal == subject.feature.ordinal || other.status.terminal?
373
+ next unless File.directory?(@worktree.path_for(other.feature))
374
+
375
+ queued[other.feature.ordinal.to_s] = other
376
+ end
377
+ end
378
+
379
+ # @return [Hash{String => Plans::Subject}] checkouts waiting on a rebase, by plan
380
+ def queued = @queued ||= {}
381
+
382
+ # @return [String, nil] why the rebase failed, nil when it went through
383
+ def rebase(subject, pull, number, repo)
384
+ checkout = own_checkout(subject, pull) or return github_rebase(number, repo)
385
+
386
+ refresh = Vcs::Refresh.new(path: checkout.path)
387
+ outcome = refresh.call
388
+ if outcome.conflicted?
389
+ refresh.abort!("conflicts after a sibling merged")
390
+ return "PR ##{number} no longer rebases onto #{outcome.base}: conflicts in #{outcome.files.join(", ")}."
391
+ end
392
+ # Skipped or refused locally (a dirty tree, no remote): GitHub can
393
+ # still rebase the branch it holds, and says so if it conflicts.
394
+ return github_rebase(number, repo) unless outcome.done?
395
+
396
+ @publisher.pushed!(checkout, "HEAD") unless outcome.kind == :current
397
+ nil
398
+ end
399
+
400
+ # The plan's own checkout, when it already exists and builds this
401
+ # pull request. Never made here: a poll that minted a worktree per
402
+ # 🏁 plan would leave one behind for every plan a person merges.
403
+ #
404
+ # @return [Vcs::Worktree::Checkout, nil]
405
+ def own_checkout(subject, pull)
406
+ return nil if @worktree.nil? || @publisher.nil? || !File.directory?(@worktree.path_for(subject.feature))
407
+
408
+ checkout = @worktree.checkout_for(subject.feature)
409
+ @publisher.branch_mismatch(pull, checkout) ? nil : checkout
410
+ end
411
+
412
+ # @return [String, nil]
413
+ def github_rebase(number, repo)
414
+ @github.update_branch(number:, repo:)
415
+ nil
416
+ rescue OpenLanes::Error => e
417
+ "PR ##{number} no longer rebases onto main: #{e.message}"
418
+ end
419
+
420
+ # A conflict or red CI after a rebase: `review-rejected` on the pull
421
+ # request, and the plan to 🔴 with the reason mailed to luke and rey,
422
+ # through {PrOutcomes} exactly as a person's rejection is.
423
+ #
424
+ # @return [Result]
425
+ def reject(subject, number, repo, why)
426
+ @github.swap_label(number:,
427
+ repo:,
428
+ add: Plans::ReviewLabel::REJECTED,
429
+ remove: Plans::ReviewLabel.displaced_by(Plans::ReviewLabel::REJECTED))
430
+ @github.comment(number:, repo:, body: why)
431
+ comments = [{ author: SENDER, url: "", body: why }]
432
+ applied = @outcomes.apply(PrWatch::Outcome.new(plan: subject.feature.ordinal.to_s, pr: number, kind: :rejected, comments:))
433
+ Result.new(note: "PR ##{number} back to the builders: #{why}", to: applied&.to)
434
+ end
435
+
436
+ # Whether a rebased sibling's push is recent enough that CI may not
437
+ # have registered on its new head yet.
438
+ #
439
+ # @return [Boolean]
440
+ def registering?(record, number)
441
+ at = record.dig("rebased", number.to_s) or return false
442
+
443
+ @clock.call - Time.iso8601(at) < NO_CHECKS_GRACE
444
+ end
445
+
446
+ # @return [Hash, nil]
447
+ def record(subject) = Plans::PlanState.for(subject.feature.path).merge_risk
448
+
449
+ # @return [Symbol]
450
+ def merge_by = @merge_by.respond_to?(:call) ? @merge_by.call : @merge_by
451
+
452
+ # @return [Integer]
453
+ def number_of(pull) = Vcs::GitHub.number_of(pull.url || pull.number.to_s)
454
+
455
+ # @return [Plans::Subject]
456
+ def fresh(subject) = Plans::Tree.new(dir: File.dirname(subject.feature.path)).find(subject.feature.ordinal.to_s) || subject
457
+ end
458
+ end
459
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # What a run says on the terminal while it works: one plain line per
6
+ # event, appended, never redrawn.
7
+ #
8
+ # It is a {UI.tap_log} tap, so it hears exactly what the progress log
9
+ # file hears (an agent started or finished, a plan changed state, a
10
+ # command came from the page, the reason the engine idles) and nothing
11
+ # else. The file keeps its fixed columns for `awk`; the terminal gets
12
+ # only the fields a line actually has. Echoing the file's format there
13
+ # is what drew an idle line as a row of empty columns,
14
+ # `[13:39:10 | | … | 83054 | ] idle: …`.
15
+ #
16
+ # Nothing here moves the cursor or colours anything. A line is written
17
+ # whole, under a lock, with any carriage return or escape sequence in any
18
+ # field (an agent's note is free text, a page command's plan is unchecked)
19
+ # taken out first, so two threads logging at once
20
+ # cannot splice their lines, and no message can rewrite the one above.
21
+ # The same lines serve a terminal, a pipe and a CI log alike.
22
+ #
23
+ # @example
24
+ # narrator = Narrator.new
25
+ # UI.tap_log(narrator)
26
+ # UI.log("started", plan: "003.00", status: "⭐ Planned", agent: "yoda-writer", round: "01")
27
+ # # 13:39:10 003.00 ⭐ Planned yoda-writer r01 started
28
+ class Narrator
29
+ # Between the parts of a line. Two spaces read as columns without
30
+ # holding open the ones a line does not have.
31
+ GAP = " "
32
+
33
+ # A carriage return, an escape sequence, or any other control
34
+ # character but a tab: what would let one message overwrite another.
35
+ CONTROL = %r{\e\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0a-\x1f\x7f]}
36
+
37
+ # @param stream [#puts, nil] where the lines go; nil means whatever
38
+ # `$stderr` is when a line is written, so a spec swapping it is heard
39
+ # @param clock [Proc] wall time, injectable for a fixed timestamp
40
+ def initialize(stream: nil, clock: -> { Clock::System.now })
41
+ @stream = stream
42
+ @clock = clock
43
+ @lock = Mutex.new
44
+ @last = nil
45
+ end
46
+
47
+ # The tap itself. A line identical to the previous one is dropped, so
48
+ # whatever repeats itself tick after tick (an idle reason, a store
49
+ # that keeps refusing) is said once per change rather than once a
50
+ # second.
51
+ #
52
+ # @param message [String]
53
+ # @param fields [Hash] the log line's fields: plan, status, agent, round,
54
+ # and `to:` when the event moved the plan
55
+ # @return [void]
56
+ def call(message, fields = {})
57
+ text = render(message, **fields)
58
+ @lock.synchronize do
59
+ next if text == @last
60
+
61
+ @last = text
62
+ # Unpainted, so a terminal gets the same bytes as a pipe or a CI log.
63
+ (@stream || $stderr).puts("#{@clock.call.strftime(ProgressLog::TIME_FORMAT)}#{GAP}#{text}")
64
+ end
65
+ rescue StandardError
66
+ # A tap runs on the logging thread and must never take it down;
67
+ # a terminal that went away is no reason to stop the agents.
68
+ nil
69
+ end
70
+
71
+ # @return [Proc] for {UI.tap_log}
72
+ def to_proc = method(:call).to_proc
73
+
74
+ # One event, without its timestamp.
75
+ #
76
+ # @param message [String]
77
+ # @param plan [String, nil]
78
+ # @param status [String, nil] emoji and label
79
+ # @param agent [String, nil]
80
+ # @param round [String, nil] e.g. "01"
81
+ # @param to [String, nil] the state the plan moved to, emoji and label
82
+ # @return [String] e.g. `003.00 ⭐ Planned yoda-writer r01 finished → 🟡 Building`
83
+ def render(message, plan: nil, status: nil, agent: nil, round: nil, to: nil, **)
84
+ # Every field, not just the message: a page command's plan and agent
85
+ # are logged before anything checks that they name real ones.
86
+ plan, status, agent, round = [plan, status, agent, round].map { clean(it) if it }
87
+ said = clean(message)
88
+ said = "#{said} → #{self.class.status_cell(clean(to))}" if to
89
+ [[plan, (self.class.status_cell(status) if status)].compact.join(" "), [agent, ("r#{round}" if round)].compact.join(" "), said]
90
+ .reject(&:empty?).join(GAP)
91
+ end
92
+
93
+ # The emoji fitted to two cells and followed by two spaces, then the
94
+ # label, exactly as the progress log draws it.
95
+ #
96
+ # @param status [String] emoji and label, e.g. "⭐ Planned"
97
+ # @return [String] e.g. "⭐ Planned"
98
+ def self.status_cell(status) = ProgressLog.status_cell(status).to_s
99
+
100
+ private
101
+
102
+ # @param text [Object]
103
+ # @return [String] on one line, with nothing that moves the cursor
104
+ def clean(text) = text.to_s.gsub(CONTROL, " ").squeeze(" ").strip
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # The dispatcher's `locked-by.pid` files, counted per plan. Both halves of
6
+ # a pair work in one folder and so share one lock file; removing it when
7
+ # the first of them finished left the partner running in an unlocked
8
+ # folder, which a separate `resync dirs` was then free to rename. The
9
+ # file goes when the last job holding the plan does.
10
+ #
11
+ # The plan's alock (W31) follows the same count. It is taken first, by
12
+ # {#reserve}, on the folder as it is named before the job starts: the
13
+ # start may rename the folder, and a refusal found after the rename would
14
+ # leave a folder renamed under somebody else's work. {#hold} moves it to
15
+ # the new name.
16
+ class PlanLocks
17
+ # Raised when an agent outside openlanes holds the plan.
18
+ class Refused < Error; end
19
+
20
+ # @param agent_locks [AgentLocks, nil] nil leaves alock out
21
+ def initialize(agent_locks: nil)
22
+ @agent_locks = agent_locks
23
+ @mutex = Mutex.new
24
+ @held = {}
25
+ @reserved = {}
26
+ end
27
+
28
+ # Take the plan's alock before its first job starts. A partner joining
29
+ # a running plan already has it.
30
+ #
31
+ # @param ordinal [String]
32
+ # @param dir [String] the plan folder, before any rename
33
+ # @param intent [String] said to whoever reads the lock
34
+ # @return [void]
35
+ # @raise [Refused] when somebody else holds the folder
36
+ def reserve(ordinal, dir, intent)
37
+ @mutex.synchronize do
38
+ next if @held.key?(ordinal) || @reserved.key?(ordinal) || @agent_locks.nil?
39
+ raise Refused, "#{File.basename(dir)} is held in alock" unless @agent_locks.acquire(dir, intent)
40
+
41
+ @reserved[ordinal] = dir
42
+ end
43
+ end
44
+
45
+ # @param ordinal [String]
46
+ # @param dir [String] the plan folder, as the job about to start sees it
47
+ # @return [void]
48
+ def hold(ordinal, dir)
49
+ @mutex.synchronize do
50
+ path, count = @held.fetch(ordinal) { [@reserved.fetch(ordinal, dir), 0] }
51
+ # A folder renamed since the first hold carried its lock along;
52
+ # writing at the new path again costs nothing and is never wrong.
53
+ begin
54
+ Plans::Lock.write(dir) if count.zero? || path != dir
55
+ rescue Plans::Lock::Held
56
+ drop_reservation(ordinal) if count.zero?
57
+ raise
58
+ end
59
+ move_alock(path, dir) if path != dir
60
+ @reserved.delete(ordinal)
61
+ @held[ordinal] = [dir, count + 1]
62
+ end
63
+ end
64
+
65
+ # Give back a reservation whose job never started.
66
+ #
67
+ # @param ordinal [String]
68
+ # @return [void]
69
+ def unreserve(ordinal) = @mutex.synchronize { drop_reservation(ordinal) }
70
+
71
+ # @param ordinal [String]
72
+ # @return [void]
73
+ def release(ordinal)
74
+ @mutex.synchronize do
75
+ path, count = @held[ordinal]
76
+ next unless path
77
+
78
+ if count > 1
79
+ @held[ordinal] = [path, count - 1]
80
+ else
81
+ @held.delete(ordinal)
82
+ Plans::Lock.remove(path)
83
+ @agent_locks&.release(path)
84
+ end
85
+ end
86
+ end
87
+
88
+ # @param ordinal [String]
89
+ # @return [Integer] how many running jobs hold it
90
+ def count(ordinal) = @mutex.synchronize { @held.fetch(ordinal, [nil, 0]).last }
91
+
92
+ private
93
+
94
+ # @return [void]
95
+ def drop_reservation(ordinal)
96
+ dir = @reserved.delete(ordinal) or return
97
+ @agent_locks&.release(dir)
98
+ end
99
+
100
+ # The folder was renamed under its lock. The new name is taken before
101
+ # the old is given back, so the plan is never left unheld; a refusal
102
+ # here means somebody claimed a name they could only have guessed, and
103
+ # is said rather than stopping a job that already started.
104
+ #
105
+ # @return [void]
106
+ def move_alock(from, to)
107
+ return unless @agent_locks
108
+
109
+ UI.log("alock not moved to #{File.basename(to)}: refused", level: :warn) unless @agent_locks.acquire(to, "openlanes")
110
+ @agent_locks.release(from)
111
+ end
112
+ end
113
+ end
114
+ end