openlanes 3.0.1 → 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 (319) 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 +4 -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 +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  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 +7 -0
  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 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  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 +84 -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 +1175 -117
  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 +6 -6
  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 +12 -120
  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 +223 -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 +17 -13
  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 +60 -11
  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 +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -8,7 +8,8 @@ module OpenLanes
8
8
  # The loop itself is {Dispatcher}. This holds what the loop needs about
9
9
  # the run: the tree and its scope, the roster, the executor, how many run
10
10
  # at once, where each plan is checked out, and how a finished plan is
11
- # published. Blocked plans (⭕️ 🅱️) are stepped around, never assigned.
11
+ # published. Blocked plans (⭕ 🛑 💣) are stepped around, never assigned,
12
+ # and while a 💣 stands nothing new is assigned at all (plan 009).
12
13
  class Runner
13
14
  # What one agent did to one plan.
14
15
  Attempt = Data.define(:ordinal,
@@ -33,7 +34,11 @@ module OpenLanes
33
34
  end
34
35
 
35
36
  # One unit of work: an agent, a plan, and the checkout it happens in.
36
- Task = Data.define(:agent, :subject, :root, :checkout, :round) do
37
+ # `refresh` is what bringing the checkout up to date did, nil when
38
+ # nothing was tried (shared isolation, a dry run, a partner already in it).
39
+ Task = Data.define(:agent, :subject, :root, :checkout, :round, :refresh) do
40
+ def initialize(refresh: nil, **rest) = super
41
+
37
42
  # @return [Hash] columns for the log
38
43
  def log_fields
39
44
  { plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
@@ -49,10 +54,24 @@ module OpenLanes
49
54
  # other machines; nil when the run is not live, and every plan is ours
50
55
  # @param stay [Boolean] idle past fixed points until a person stops
51
56
  # the run; see {Dispatcher#done?}
52
- # @param rescan [Integer] seconds between rescans while idle
57
+ # @param rescan [Integer] ticks between rescans while idle
58
+ # @param watch [OpenLanes::Engine::PrWatch, nil] see {Dispatcher#initialize}
59
+ # @param ignore_bombs [Boolean] `--ignore-bombs`: a 💣 halts nothing
60
+ # @param aging_since [Date, nil] `plans.aging_since`, the floor of every
61
+ # blocked plan's clock
62
+ # @param verification [OpenLanes::Engine::Verification::Driver, nil]
63
+ # what a 🧪 plan is run through (plan 014); nil when the run publishes
64
+ # nothing, and an approved plan then finishes at 👀
65
+ # @param journal [OpenLanes::Engine::EventJournal, nil] the progress
66
+ # journal each engine event is appended to; nil keeps none
67
+ # @param merging [OpenLanes::Engine::Merging, nil] what a 🏁 plan's
68
+ # pull requests go through once palpatine has ruled; nil when the
69
+ # run publishes nothing
53
70
  def initialize(tree:, executor:, agents: Agents::Registry.new, isolation: :shared, jobs: 1, worktree: nil,
54
71
  plans: nil, publisher: nil, dry_run: false, rounds: nil, state: nil,
55
- sleeper: ->(seconds) { sleep(seconds) }, on_board: nil, claims: nil, stay: false, rescan: Dispatcher::RESCAN)
72
+ sleeper: ->(seconds) { sleep(seconds) }, on_board: nil, claims: nil, stay: false, rescan: Dispatcher::RESCAN,
73
+ watch: nil, ignore_bombs: false, aging_since: nil, extensions: nil, verification: nil, journal: nil,
74
+ merging: nil)
56
75
  @tree = tree
57
76
  @executor = executor
58
77
  @agents = agents
@@ -68,6 +87,14 @@ module OpenLanes
68
87
  @claims = claims
69
88
  @stay = stay
70
89
  @rescan = rescan
90
+ @watch = watch
91
+ @ignore_bombs = ignore_bombs
92
+ @aging_since = aging_since
93
+ @extensions = extensions
94
+ @verification = verification
95
+ @journal = dry_run ? nil : journal
96
+ @merging = merging
97
+ @would_bomb = []
71
98
  @attempts = []
72
99
  @dropped = []
73
100
  @jobs = isolated? ? jobs : 1
@@ -75,6 +102,29 @@ module OpenLanes
75
102
 
76
103
  attr_reader :jobs, :worktree, :tree, :attempts, :executor, :agents
77
104
 
105
+ # @return [OpenLanes::Engine::Verification::Driver, nil]
106
+ attr_reader :verification
107
+
108
+ # @return [OpenLanes::Engine::Merging, nil]
109
+ attr_reader :merging
110
+
111
+ # A new concurrency limit, from the copilot's `/concurrency N`. Nothing
112
+ # running is stopped: the dispatcher fills only while fewer agents run
113
+ # than the limit, so a lower one takes effect as agents finish.
114
+ #
115
+ # @param count [Integer] at least 1
116
+ # @return [void]
117
+ # @raise [OpenLanes::Error] under `--isolation shared`, where every agent
118
+ # works in one checkout and a second would write over the first
119
+ def jobs=(count)
120
+ raise OpenLanes::Error, "concurrency must be a whole number of at least 1, not #{count.inspect}" unless count.is_a?(Integer) && count >= 1
121
+ if !isolated? && count != 1
122
+ raise OpenLanes::Error, "this run is --isolation shared: one checkout, one agent at a time, so concurrency stays 1"
123
+ end
124
+
125
+ @jobs = count
126
+ end
127
+
78
128
  # @return [Boolean]
79
129
  def isolated? = @isolation == :worktree
80
130
 
@@ -104,6 +154,16 @@ module OpenLanes
104
154
  # @return [void]
105
155
  def drop_plan(ordinal) = @dropped |= [ordinal.to_s]
106
156
 
157
+ # @param ordinal [#to_s]
158
+ # @return [Boolean] whether {#drop_plan} took it out of this run
159
+ def dropped?(ordinal) = @dropped.include?(ordinal.to_s)
160
+
161
+ # Let another machine take the plan: a person stopped it here.
162
+ #
163
+ # @param ordinal [#to_s]
164
+ # @return [void]
165
+ def release(ordinal) = @claims&.release(ordinal)
166
+
107
167
  # @return [Array<OpenLanes::Plans::Subject>]
108
168
  def in_scope = tree.subjects.select { |s| in_scope?(s) }
109
169
 
@@ -132,20 +192,30 @@ module OpenLanes
132
192
  false
133
193
  end
134
194
 
135
- # Take the plan before an agent starts on it. With Redis gone the run
136
- # carries on unclaimed rather than stalling: the claim is an agreement
137
- # between machines, and a machine that cannot reach the others is
138
- # working alone.
195
+ # Take the plan before an agent starts on it. With Redis gone nothing
196
+ # new starts (W31): this machine cannot tell whether another one sharing
197
+ # the Redis already took the plan, and two machines building one spec is
198
+ # the collision the claim exists to prevent. Agents already running keep
199
+ # going. A run with no Redis at all has no claims and works alone.
139
200
  #
140
201
  # @param subject [OpenLanes::Plans::Subject]
141
202
  # @return [Boolean] whether this machine may work it
142
203
  def claim(subject)
143
- @claims.nil? || @claims.claim(subject.feature.ordinal)
204
+ claimed = @claims.nil? || @claims.claim(subject.feature.ordinal)
205
+ @claims_down = nil
206
+ claimed
144
207
  rescue Live::Unavailable => e
145
208
  claims_unavailable(e)
146
- true
209
+ false
147
210
  end
148
211
 
212
+ # Where the plan's checkout lives when the run isolates, whether or not
213
+ # it has been made.
214
+ #
215
+ # @param subject [OpenLanes::Plans::Subject]
216
+ # @return [String, nil]
217
+ def checkout_path(subject) = isolated? ? worktree.path_for(subject.feature) : nil
218
+
149
219
  # Run until nothing is dispatchable, or, staying, until a person stops
150
220
  # it.
151
221
  #
@@ -159,13 +229,99 @@ module OpenLanes
159
229
  sleeper: @sleeper,
160
230
  on_board: @on_board,
161
231
  stay: @stay,
162
- rescan: @rescan)
232
+ rescan: @rescan,
233
+ watch: @watch,
234
+ extensions: @extensions,
235
+ journal: @journal)
163
236
  yield dispatcher if block_given?
164
237
  @attempts = dispatcher.run
165
238
  end
166
239
 
167
240
  # @return [Array<OpenLanes::Plans::Subject>]
168
- def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
241
+ def blocked = in_scope.select { |s| Dispatcher::BLOCKED.include?(s.status.key) }
242
+
243
+ # Age the blocked plans in scope (plan 009): bomb the ones others wait
244
+ # on, defer the ones nobody does. A dry run fires nothing, and
245
+ # remembers what it would have bombed so it halts as a real run would.
246
+ #
247
+ # @param except [Array<String>] ordinals an agent or a drain is in
248
+ # @param now [Time]
249
+ # @return [OpenLanes::Lifecycle::Aging::Result]
250
+ def age!(except: [], now: Clock::System.now)
251
+ subjects = in_scope.reject { except.include?(it.feature.ordinal.to_s) || foreign?(it) }
252
+ result = Lifecycle.age(tree:, subjects:, since: @aging_since, now:, commit: !dry_run?)
253
+ @would_bomb = result.bombed if dry_run?
254
+ result
255
+ end
256
+
257
+ # Plans in scope that are 💣, or on a dry run would have become one.
258
+ #
259
+ # @return [Array<OpenLanes::Plans::Subject>]
260
+ def bombs
261
+ in_scope.select { it.status.key == :bomb || @would_bomb.include?(it.feature.ordinal.to_s) }
262
+ end
263
+
264
+ # The bombs that stop new work right now: none under `--ignore-bombs`,
265
+ # and none whose halt a person lifted from the page until it lapses.
266
+ #
267
+ # @param now [Time]
268
+ # @return [Array<OpenLanes::Plans::Subject>]
269
+ def halted_by(now: Clock::System.now)
270
+ return [] if @ignore_bombs
271
+
272
+ bombs.reject { Plans::PlanState.for(it.feature.path).resumed?(now:) }
273
+ end
274
+
275
+ # @return [Boolean] whether a 💣 keeps every new agent from starting
276
+ def halted? = !halted_by.empty?
277
+
278
+ # Plans in scope the dependency gate holds back, with the reason: a
279
+ # dependency that is not done yet, or a cycle that no order can break.
280
+ # A plan with no `depends_on:` key is never held, nor is one already
281
+ # parked, whose own state says more than its dependencies do.
282
+ #
283
+ # @param deps [OpenLanes::Plans::Dependencies] the graph to ask; the
284
+ # dispatcher passes the one it gated with, so both say the same thing
285
+ # @return [Hash{String => String}] ordinal => "waiting on 034.00" or "cycle: ..."
286
+ def held(deps = dependencies)
287
+ in_scope.each_with_object({}) do |subject, out|
288
+ next if Plans::StateMachine::SETTLED.include?(subject.status.key)
289
+
290
+ ordinal = subject.feature.ordinal.to_s
291
+ reason = deps.reason(ordinal) and out[ordinal] = reason
292
+ end
293
+ end
294
+
295
+ # The dependency graph judged by the states this run acts on. A real run
296
+ # has reconciled every folder name before it asks, so the names are the
297
+ # states. A dry run renames nothing, so it judges each plan by the state
298
+ # reconciliation would give it, as {Dispatcher} does when it picks an
299
+ # agent; otherwise an In Review prerequisite whose pull requests have
300
+ # all merged would read as done to one and open to the other.
301
+ #
302
+ # @param except [Array<String>] ordinals reconciliation leaves alone
303
+ # (an agent is in them), judged by their folder's name; plans another
304
+ # machine holds are always left alone
305
+ # @return [OpenLanes::Plans::Dependencies]
306
+ def dependencies(except: [])
307
+ return tree.dependencies unless dry_run?
308
+
309
+ leave = except.map(&:to_s)
310
+ # Every plan, archived ones included, as {Plans::Tree#dependencies}
311
+ # does: a shipped prerequisite sits in `archive/`.
312
+ Plans::Dependencies.new(tree.everything,
313
+ status_of: lambda { |subject|
314
+ untouched = leave.include?(subject.feature.ordinal.to_s) || foreign?(subject)
315
+ untouched ? subject.status : effective_status(subject)
316
+ })
317
+ end
318
+
319
+ # The state a plan's contents justify, which reconciliation would rename
320
+ # its folder to: its lane's skip first, then what `resync dirs` reads.
321
+ #
322
+ # @param subject [OpenLanes::Plans::Subject]
323
+ # @return [OpenLanes::Plans::Status]
324
+ def effective_status(subject) = Lifecycle::Lanes.target(subject) || Lifecycle::Resync::Dirs.target(subject)
169
325
 
170
326
  # @return [Boolean]
171
327
  def settled? = in_scope.all? { |s| Plans::StateMachine::SETTLED.include?(s.status.key) }
@@ -176,12 +332,20 @@ module OpenLanes
176
332
  # is the one checked out where the run was typed, which is where a dry
177
333
  # run of that plan is typed.
178
334
  #
335
+ # A worktree is brought up to date with the default branch first (see
336
+ # {Vcs::Refresh}), here rather than on the agent's thread, so no partner
337
+ # can start in the same checkout while git is rewriting it.
338
+ #
339
+ # @param refresh [Boolean] false when another agent is already working
340
+ # in the plan's checkout, which must not move under it
341
+ # @param hold [String, nil] see {Vcs::Refresh#call}
179
342
  # @return [OpenLanes::Engine::Runner::Task]
180
- def prepare(agent, subject, round)
343
+ def prepare(agent, subject, round, refresh: true, hold: nil)
181
344
  return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) if !isolated? || dry_run?
182
345
 
183
346
  checkout = worktree.checkout_for(subject.feature)
184
- Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
347
+ outcome = worktree.refresh(checkout, hold:) if refresh
348
+ Task.new(agent:, subject:, root: checkout.path, checkout:, round:, refresh: outcome)
185
349
  end
186
350
 
187
351
  # Push a finished build and record the pull request it opened in the
@@ -208,7 +372,14 @@ module OpenLanes
208
372
 
209
373
  # @param error [OpenLanes::Live::Unavailable]
210
374
  # @return [void]
211
- def claims_unavailable(error) = UI.log("claims unchecked: #{error.message}")
375
+ # Said once per outage: every plan asks on every tick, and the same
376
+ # line a hundred times buries the moment Redis went away.
377
+ def claims_unavailable(error)
378
+ return if @claims_down == error.message
379
+
380
+ @claims_down = error.message
381
+ UI.log("claims unchecked, starting nothing new until Redis answers: #{error.message}", level: :warn)
382
+ end
212
383
 
213
384
  # @return [void]
214
385
  def record_pull_request(path, publication)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # `openlanes start` without the command: {RunWorkflow} as a long-lived
6
+ # service. It stays up past every fixed point, rescanning the tree, and
7
+ # treats a missing live board or control room as a refusal rather than a
8
+ # reason to go on headless, since the page is the only way in.
9
+ class StartWorkflow < RunWorkflow
10
+ # Exit status when Redis or the control room is not there to serve.
11
+ UNAVAILABLE = 69
12
+
13
+ # @param rescan [Integer] seconds between rescans of .plans while idle
14
+ # @param options [Hash] as for {RunWorkflow#initialize}
15
+ def initialize(rescan:, **)
16
+ super(**)
17
+ @rescan = rescan
18
+ end
19
+
20
+ private
21
+
22
+ # Always stays, at the rescan asked for, and watches the pull requests
23
+ # a person merges the way a staying `run` does. `--rescan` is seconds
24
+ # and the dispatcher counts ticks, so a 5 passed through unconverted
25
+ # would rescan every 2.5 s.
26
+ #
27
+ # @param _web [OpenLanes::Web::Launcher]
28
+ # @return [Hash]
29
+ def staying(_web, _live = nil) = { stay: true, rescan: Dispatcher.ticks(@rescan), watch: watch }
30
+
31
+ # @param message [String]
32
+ # @return [Dry::Monads::Result::Failure]
33
+ def without_live(message)
34
+ refusal("#{message}\n\nopenlanes start needs Redis: its control room is the only way to answer the " \
35
+ "agents' questions. Start redis-server, or pass --redis-url. " \
36
+ "`openlanes run` goes on without it, headless.",
37
+ UNAVAILABLE)
38
+ end
39
+
40
+ # @param web [OpenLanes::Web::Launcher]
41
+ # @param live [OpenLanes::Live::Session]
42
+ # @return [Dry::Monads::Result::Failure]
43
+ def without_web(web, live)
44
+ live.close
45
+ refusal("The control room did not answer on #{web.url}, and openlanes start is no use without it. " \
46
+ "Its log: #{web.log}",
47
+ UNAVAILABLE)
48
+ end
49
+
50
+ # A project with nothing left to do opens on the idea dialog (W19).
51
+ #
52
+ # @param web [OpenLanes::Web::Launcher]
53
+ # @return [String]
54
+ def browser_url(web)
55
+ open = @tree.subjects.reject { Dispatcher::FINISHED.include?(it.status.key) }
56
+ open.empty? ? "#{web.url}#start" : web.url
57
+ end
58
+
59
+ # What the service did, and exit 0: stopping it on purpose is not a
60
+ # failure, and the failed attempts it lived through are listed above
61
+ # the box.
62
+ #
63
+ # @return [Integer] 0
64
+ def finish(runner, attempts, seconds:)
65
+ puts_report(attempts, Engine.tally(attempts:, seconds:).render)
66
+ unless @quiet
67
+ blocked = runner.blocked.map { it.feature.ordinal }
68
+ lines = ["Stopped. Unfinished plans keep their state; openlanes start picks them up again."]
69
+ lines << "Still waiting for answers: #{blocked.join(", ")}" unless blocked.empty?
70
+ success("#{attempts.size} invocation#{"s" unless attempts.size == 1}, " \
71
+ "#{attempts.count(&:advanced?)} advanced\n\n#{lines.join("\n")}")
72
+ end
73
+ 0
74
+ end
75
+ end
76
+ end
77
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "socket"
5
6
 
6
7
  module OpenLanes
7
8
  module Engine
@@ -11,58 +12,72 @@ module OpenLanes
11
12
  # The ledger inside each document is the record humans read and the
12
13
  # dispatcher acts on; this is the index of it, plus what the ledger cannot
13
14
  # hold: which run wrote what, process ids, tokens, and whether the run
14
- # that wrote a `Started` is still alive. It sits in `.plans/` rather than
15
- # the system temp dir because a reboot must not lose it, and it is
16
- # gitignored because it is about one machine's run, not the plan.
15
+ # that wrote a `Started` is still alive. It sits in the runtime state
16
+ # directory ({OpenLanes::Runtime}) rather than the system temp dir because
17
+ # a reboot must not lose it, and outside `.plans/` because it is about one
18
+ # machine's run, not the plan: in `.plans/` every `git status` and every
19
+ # snapshot of the tree had to learn to ignore it.
17
20
  class StateFile
18
21
  FILENAME = "openlanes-state.json"
19
22
 
20
- # Where it used to live, one directory down. Read by {#load} when the
21
- # current path holds nothing, so a run started under the old layout is
22
- # picked up rather than silently restarted from zero. Nothing writes
23
- # here any more and nothing deletes it: the file left behind is the
24
- # only record a half-finished run has.
23
+ # The oldest place it lived, under `.plans/tmp/`. Read by {#load} after
24
+ # `.plans/` itself, so a run started under either old layout is picked
25
+ # up rather than silently restarted from zero. Nothing writes to either
26
+ # any more and nothing deletes them: the file left behind is the only
27
+ # record a half-finished run has.
25
28
  LEGACY_DIRNAME = "tmp"
26
29
 
27
- # The lines {.ensure_ignored!} adds. The second covers the sibling
28
- # {#save} writes and renames over the first — brief, but `.plans/` is a
29
- # directory whose other contents are committed, so a `git status` run
30
- # during that instant must not offer it.
31
- IGNORE = ["#{OpenLanes::PLANS_DIR}/#{FILENAME}", "#{OpenLanes::PLANS_DIR}/#{FILENAME}.*.tmp"].freeze
30
+ # The line {.ensure_ignored!} adds: the `<name>.<pid>.tmp` siblings
31
+ # `state.json` and `blocked.md` are written to and renamed over, inside
32
+ # plan folders whose other contents are committed. Brief, but a
33
+ # `git status` during that instant must not offer one.
34
+ IGNORE = ["#{OpenLanes::PLANS_DIR}/**/*.tmp"].freeze
35
+
36
+ # A literal path git can be asked about for the {IGNORE} line.
37
+ PROBE = "#{OpenLanes::PLANS_DIR}/000.00/state.json.1.2.tmp".freeze
32
38
 
33
39
  # @param tree [OpenLanes::Plans::Tree]
34
- # @return [String]
35
- def self.for(tree) = File.join(tree.dir, FILENAME)
40
+ # @return [String] in the runtime state directory, keyed by the tree's root
41
+ def self.for(tree) = Runtime.state_file(root: File.dirname(File.expand_path(tree.dir)))
36
42
 
37
43
  # @param tree [OpenLanes::Plans::Tree]
38
- # @return [String] where {LEGACY_DIRNAME} put it
39
- def self.legacy_for(tree) = File.join(tree.dir, LEGACY_DIRNAME, FILENAME)
44
+ # @return [Array<String>] where older releases wrote it, newest first
45
+ def self.legacy_for(tree) = [File.join(tree.dir, FILENAME), File.join(tree.dir, LEGACY_DIRNAME, FILENAME)]
40
46
 
41
- # Add the ignore lines once. Editing somebody's `.gitignore` is a thing
42
- # to announce, which is why this returns whether it did.
43
- #
44
- # Only the first line is asked about: git answers for a literal path,
45
- # and the second is a glob no path equals. They are written together,
46
- # so one being ignored means both are.
47
+ # Add the ignore line once. Editing somebody's `.gitignore` is a thing
48
+ # to announce, which is why this returns whether it did. Lines an older
49
+ # release wrote are left where they are: removing a line is editing
50
+ # something this method did not write.
47
51
  #
48
52
  # @param root [String] repository root
49
53
  # @return [Boolean] true when lines were added
50
54
  def self.ensure_ignored!(root)
51
55
  return false unless system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
52
- return false if system("git", "-C", root, "check-ignore", "-q", IGNORE.first, out: File::NULL, err: File::NULL)
56
+
57
+ missing = ignored?(root, PROBE) ? [] : IGNORE
58
+ return false if missing.empty?
53
59
 
54
60
  path = File.join(root, ".gitignore")
55
61
  existing = File.file?(path) ? File.read(path) : ""
56
62
  glue = existing.empty? || existing.end_with?("\n") ? "" : "\n"
57
- File.write(path, "#{existing}#{glue}#{IGNORE.join("\n")}\n")
63
+ File.write(path, "#{existing}#{glue}#{missing.join("\n")}\n")
58
64
  true
59
65
  end
60
66
 
67
+ # @param root [String]
68
+ # @param path [String] relative to +root+
69
+ # @return [Boolean]
70
+ def self.ignored?(root, path) = system("git", "-C", root, "check-ignore", "-q", path, out: File::NULL, err: File::NULL)
71
+ private_class_method :ignored?
72
+
61
73
  # @param path [String]
62
74
  # @param pid [Integer] this run's process id
63
- def initialize(path:, pid: Process.pid)
75
+ # @param legacy [Array<String>, nil] older locations {#load} falls back
76
+ # to; by default the two beside and under +path+'s directory
77
+ def initialize(path:, pid: Process.pid, legacy: nil)
64
78
  @path = path
65
79
  @pid = pid
80
+ @legacy = legacy
66
81
  @data = { "run" => {}, "plans" => {} }
67
82
  @mutex = Mutex.new
68
83
  end
@@ -70,11 +85,11 @@ module OpenLanes
70
85
  # @return [String]
71
86
  attr_reader :path
72
87
 
73
- # Where a run started before the file moved up out of {LEGACY_DIRNAME}
74
- # would have written. {#load} falls back to it; {#save} never uses it.
88
+ # Where a run started under an older layout would have written.
89
+ # {#load} falls back to them in order; {#save} never uses them.
75
90
  #
76
- # @return [String]
77
- def legacy_path = File.join(File.dirname(path), LEGACY_DIRNAME, File.basename(path))
91
+ # @return [Array<String>]
92
+ def legacy_paths = @legacy || [File.join(File.dirname(path), LEGACY_DIRNAME, File.basename(path))]
78
93
 
79
94
  # Reads the current path, or the legacy one when the current path holds
80
95
  # nothing. A run interrupted under the old layout is otherwise invisible
@@ -84,7 +99,7 @@ module OpenLanes
84
99
  # @return [self]
85
100
  def load
86
101
  @mutex.synchronize do
87
- source = [path, legacy_path].find { |candidate| File.file?(candidate) }
102
+ source = [path, *legacy_paths].find { |candidate| File.file?(candidate) }
88
103
  @data = JSON.parse(File.read(source)) if source
89
104
  @data = { "run" => {}, "plans" => {} } unless @data.is_a?(Hash) && @data["plans"].is_a?(Hash)
90
105
  end
@@ -106,18 +121,24 @@ module OpenLanes
106
121
  end
107
122
  end
108
123
 
124
+ # Also forgets the previous run's `waiting_on`. A live pid only says some
125
+ # run is up, not that it watches: a plain `run`, or one with `merge.by:
126
+ # auto`, never writes the map, so the last watch's entries would read as
127
+ # current for as long as this run lasted.
128
+ #
109
129
  # @param root [String]
110
130
  # @return [void]
111
131
  def begin_run!(root:)
112
132
  @mutex.synchronize do
113
- @data["run"] = { "pid" => @pid, "root" => root, "started_at" => Time.now.iso8601,
114
- "heartbeat_at" => Time.now.iso8601 }
133
+ @data.delete("waiting_on")
134
+ @data["run"] = { "pid" => @pid, "host" => Socket.gethostname, "root" => root,
135
+ "started_at" => Clock::System.now.iso8601, "heartbeat_at" => Clock::System.now.iso8601 }
115
136
  end
116
137
  end
117
138
 
118
139
  # @return [void]
119
140
  def heartbeat!
120
- @mutex.synchronize { @data["run"]["heartbeat_at"] = Time.now.iso8601 }
141
+ @mutex.synchronize { @data["run"]["heartbeat_at"] = Clock::System.now.iso8601 }
121
142
  end
122
143
 
123
144
  # @param ordinal [String]
@@ -152,6 +173,42 @@ module OpenLanes
152
173
  @mutex.synchronize { @data["live"] = data }
153
174
  end
154
175
 
176
+ # The Redis outage under way (W32): when it began, when the agents were
177
+ # paused, how many times Redis was asked since. Nil when there is none.
178
+ #
179
+ # @return [Hash, nil]
180
+ def outage = @mutex.synchronize { @data["outage"]&.dup }
181
+
182
+ # @param record [Hash, nil] written by the next {#save}
183
+ # @return [void]
184
+ def outage=(record)
185
+ @mutex.synchronize { record ? @data["outage"] = record : @data.delete("outage") }
186
+ end
187
+
188
+ # Outages that ended, resumed or given up on, oldest first.
189
+ #
190
+ # @return [Array<Hash>]
191
+ def outages = @mutex.synchronize { (@data["outages"] || []).map(&:dup) }
192
+
193
+ # @param records [Array<Hash>]
194
+ # @return [void]
195
+ def outages=(records)
196
+ @mutex.synchronize { @data["outages"] = records }
197
+ end
198
+
199
+ # Pull requests open and waiting on a person, by plan, as the
200
+ # human-mode watch last saw them. Here so `openlanes list-plans`, a
201
+ # process of its own, can say who owes what while the engine runs.
202
+ #
203
+ # @return [Hash{String => Array<Integer>}]
204
+ def waiting_on = @mutex.synchronize { (@data["waiting_on"] || {}).transform_values(&:dup) }
205
+
206
+ # @param data [Hash{String => Array<Integer>}] written by the next {#save}
207
+ # @return [void]
208
+ def waiting_on=(data)
209
+ @mutex.synchronize { @data["waiting_on"] = data.transform_keys(&:to_s) }
210
+ end
211
+
155
212
  # @param ordinal [String]
156
213
  # @return [Array<Hash>]
157
214
  def stages(ordinal) = @mutex.synchronize { (@data.dig("plans", ordinal.to_s, "stages") || []).map(&:dup) }
@@ -159,11 +216,21 @@ module OpenLanes
159
216
  # @return [Array<String>] every ordinal with a stage recorded
160
217
  def plans = @mutex.synchronize { @data["plans"].keys }
161
218
 
219
+ # @return [Boolean] whether a run wrote this file and is still alive
220
+ def running? = !@data.dig("run", "pid").nil? && !previous_run_dead?
221
+
222
+ # A run recorded on another host is never provably dead: its pid names
223
+ # a process on a machine this one cannot see. A record with no `host`
224
+ # predates the field and keeps the local check.
225
+ #
162
226
  # @return [Boolean] whether the run that last wrote this file is gone
163
227
  def previous_run_dead?
164
228
  previous = @data.dig("run", "pid")
165
229
  return false if previous.nil? || previous == @pid
166
230
 
231
+ host = @data.dig("run", "host")
232
+ return false if host && host != Socket.gethostname
233
+
167
234
  Process.kill(0, previous)
168
235
  false
169
236
  rescue Errno::ESRCH, Errno::EPERM
@@ -4,7 +4,7 @@ module OpenLanes
4
4
  module Engine
5
5
  # What a `run` cost, once it is over.
6
6
  #
7
- # The loop reports movement — which plan went from ⭐️ to 🟡, which agent
7
+ # The loop reports movement — which plan went from ⭐ to 🟡, which agent
8
8
  # failed and why — and says nothing about the bill. A round of five agents
9
9
  # against a large repository spends several million tokens and the better
10
10
  # part of an hour, and until this existed the only record of that was a