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
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Lifecycle
5
+ # `openlanes unblock NNN…` without the command: resolve the plans named,
6
+ # refuse the ones that cannot be drained, say what each is waiting on,
7
+ # hand the rest to the draining agent, and report what moved.
8
+ #
9
+ # Deliberately not part of `run`. ⭕ and 🛑 are {Plans::StateMachine::SETTLED},
10
+ # so the loop never offers a blocked plan to anybody, and that is the
11
+ # property that makes the state mean anything: the plan waits for a human,
12
+ # and no agent quietly decides otherwise. Answers arriving is not a fact
13
+ # the tool can observe, so a human typing the command *is* the signal.
14
+ class UnblockWorkflow
15
+ include UI
16
+ include Dry::Monads[:result]
17
+
18
+ # The agent that knows the shape of `blocked.md`.
19
+ DEFAULT_AGENT = "lando-broker"
20
+
21
+ # What each token turned out to be, for whoever has to fix it.
22
+ PROBLEMS = {
23
+ missing: "no plan of that number",
24
+ not_blocked: "no blocked.md, so there is nothing to drain"
25
+ }.freeze
26
+
27
+ # @param tree [OpenLanes::Plans::Tree]
28
+ # @param agent [String] the name of the agent that drains
29
+ # @param root [String, nil] repository root (default: the .plans parent)
30
+ # @param dry_run [Boolean] invoke no agent, change nothing
31
+ # @param quiet [Boolean] draw nothing on STDERR
32
+ def initialize(tree:, agent: DEFAULT_AGENT, root: nil, dry_run: false, quiet: false)
33
+ @tree = tree
34
+ @agent = agent
35
+ @root = root
36
+ @dry_run = dry_run
37
+ @quiet = quiet
38
+ end
39
+
40
+ # The deliverable rows go to STDOUT and the progress to STDERR as the
41
+ # work happens; the answer is the exit status the command ends with.
42
+ #
43
+ # @param tokens [Array<String>] the plans named: NNN or NNN.MM
44
+ # @return [Dry::Monads::Result] Success(Integer) the exit status, or
45
+ # Failure({Refusal}) when no agent has the name given
46
+ def call(tokens)
47
+ agent = OpenLanes::Agents.registry.find(@agent) or return Failure(unknown_agent)
48
+
49
+ unblocker = Lifecycle.unblocker(tree: @tree,
50
+ agent:,
51
+ root: @root,
52
+ commit: !@dry_run,
53
+ executor: Execution.executor(root: @root || File.dirname(@tree.dir), dry_run: @dry_run))
54
+ targets = unblocker.resolve(tokens)
55
+ refused(targets)
56
+ subjects = targets.select(&:drainable?).map(&:subject)
57
+ return Success(worst(targets, [])) if subjects.empty?
58
+
59
+ credentials unless @dry_run || @quiet
60
+ preflight(subjects)
61
+
62
+ outcomes = Execution.on_interrupt { unblocker.call(subjects) }
63
+ outcomes.each { |outcome| report(outcome) }
64
+ footer(outcomes) unless @quiet
65
+ Success(worst(targets, outcomes))
66
+ end
67
+
68
+ private
69
+
70
+ # @return [OpenLanes::Refusal]
71
+ def unknown_agent
72
+ known = OpenLanes::Agents.registry.all.map(&:name).join(", ")
73
+ Refusal.new(message: "No agent called #{@agent}.\n\nKnown: #{known}", status: 65)
74
+ end
75
+
76
+ # @return [void]
77
+ def credentials
78
+ notice = Execution.credentials_notice
79
+ warn(notice) if notice
80
+ end
81
+
82
+ # A number that names no folder, or a folder that was never stopped, is
83
+ # refused rather than skipped. Whoever typed this believes an answer has
84
+ # arrived; running against a folder nobody blocked and saying nothing is
85
+ # how you come back later to a plan nobody touched and no record of why.
86
+ #
87
+ # Every bad token is reported, not just the first, because stopping at
88
+ # the first one hides the rest of the answer.
89
+ #
90
+ # @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
91
+ # @return [void]
92
+ def refused(targets)
93
+ problems = targets.reject(&:drainable?)
94
+ return if problems.empty?
95
+
96
+ problems.each do |target|
97
+ state = target.subject ? target.subject.status.key : "unknown"
98
+ puts "#{target.token}\t#{state}\t0 open\t#{PROBLEMS.fetch(target.problem)}"
99
+ end
100
+ return if @quiet
101
+
102
+ error("#{problems.size} of the plans named cannot be drained:\n\n" +
103
+ problems.map { |t| " #{t.token} — #{PROBLEMS.fetch(t.problem)}" }.join("\n") +
104
+ "\n\nThe tree holds: #{@tree.ordinals.join(", ")}")
105
+ end
106
+
107
+ # What each folder is waiting on, said BEFORE the agent is invoked.
108
+ #
109
+ # A real run hands the folder to an agent that can take a quarter of an
110
+ # hour, and until it came back the terminal used to show nothing at
111
+ # all: a run that had found nothing to do and a run still working
112
+ # looked exactly alike.
113
+ #
114
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
115
+ # @return [void]
116
+ def preflight(subjects)
117
+ return if @quiet
118
+
119
+ subjects.each do |subject|
120
+ questions = Unblocker.questions(subject)
121
+ answered = questions.count(&:answered)
122
+ say("#{paint(subject.feature.ordinal.to_s, :bright_black)} #{subject.status.emoji} " \
123
+ "#{subject.feature.title} — #{summary(questions.size, answered)}")
124
+ detail(subject, questions)
125
+ end
126
+ end
127
+
128
+ # @param open [Integer]
129
+ # @param answered [Integer]
130
+ # @return [String]
131
+ def summary(open, answered)
132
+ return "nothing open" if open.zero?
133
+
134
+ "#{open} open, #{answered.zero? ? "none answered yet" : "#{answered} with an answer waiting"}"
135
+ end
136
+
137
+ # @param subject [OpenLanes::Plans::Subject]
138
+ # @param questions [Array<OpenLanes::Lifecycle::Unblocker::Question>]
139
+ # @return [void]
140
+ def detail(subject, questions)
141
+ if subject.unreadable_block?
142
+ say(" #{paint("blocked.md names no `## B<n>` question, so nothing here can be drained", :red)}",
143
+ bullet: " ")
144
+ say(" #{paint("Number each open question `## B1`, `## B2`, and each answer `## A1`, `## A2`.", :yellow)}",
145
+ bullet: " ")
146
+ elsif questions.empty?
147
+ say(" #{paint("nothing left open", :green)}", bullet: " ")
148
+ else
149
+ questions.each { |question| say(" #{paint(question.to_s, :yellow)}", bullet: " ") }
150
+ end
151
+ end
152
+
153
+ # One plan, after the fact: the deliverable line on STDOUT, and what
154
+ # changed on STDERR.
155
+ #
156
+ # @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
157
+ # @return [void]
158
+ def report(outcome)
159
+ moved = movement(outcome)
160
+ puts "#{outcome.ordinal}\t#{outcome.ok ? outcome.subject.status.key : "failed"}\t" \
161
+ "#{outcome.after.size} open\t#{moved}\t#{outcome.note}"
162
+ # A dry run changed nothing, so the preflight above it is still the
163
+ # whole truth. Printing the same lines again reads as a second pass
164
+ # that found the same thing, which is not what happened.
165
+ return if @quiet || @dry_run
166
+
167
+ say("#{paint(outcome.ordinal.to_s, :bright_black)} #{outcome.subject.status.emoji} " \
168
+ "#{outcome.subject.feature.title} — #{paint(moved, outcome.ok ? :green : :red)}")
169
+ detail(outcome.subject, outcome.after)
170
+ end
171
+
172
+ # What the file says happened, rather than what the agent claims. The
173
+ # questions are counted off disk on both sides of the run.
174
+ #
175
+ # @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
176
+ # @return [String]
177
+ def movement(outcome)
178
+ return "failed" unless outcome.ok
179
+ return "not attempted" if @dry_run
180
+ return "blocked.md retired" if outcome.cleared?
181
+ return "nothing folded" if outcome.folded.empty?
182
+
183
+ "folded #{outcome.folded.map { |n| "B#{n}" }.join(", ")}"
184
+ end
185
+
186
+ # @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
187
+ # @return [void]
188
+ def footer(outcomes)
189
+ if @dry_run
190
+ warn("Dry run: no agent was invoked, and #{outcomes.size} " \
191
+ "plan#{"s" unless outcomes.size == 1} #{outcomes.size == 1 ? "is" : "are"} unchanged.\n" \
192
+ "Re-run without --dry-run to fold in whatever has been answered.")
193
+ return
194
+ end
195
+
196
+ failed = outcomes.reject(&:ok)
197
+ return error(failed.map { |o| "#{o.ordinal}: #{o.note}" }.join("\n")) unless failed.empty?
198
+
199
+ success(tally(outcomes))
200
+ end
201
+
202
+ # @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
203
+ # @return [String]
204
+ def tally(outcomes)
205
+ cleared = outcomes.count(&:cleared?)
206
+ folded = outcomes.sum { |o| o.folded.size }
207
+ waiting = outcomes.sum { |o| o.after.size }
208
+ text = "#{folded} question#{"s" unless folded == 1} folded in · " \
209
+ "#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
210
+ "#{waiting} still waiting on a human."
211
+ return text unless folded.zero?
212
+
213
+ "#{text}\n\nNothing moved. An answer has to be written into blocked.md as its own " \
214
+ "`## A<n>` section, answering the `## B<n>` of the same number, before " \
215
+ "there is anything to fold."
216
+ end
217
+
218
+ # The most serious thing that happened, as an exit status. A tree that
219
+ # was partly drained still exits non-zero when part of it could not be.
220
+ #
221
+ # @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
222
+ # @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
223
+ # @return [Integer]
224
+ def worst(targets, outcomes)
225
+ problems = targets.map(&:problem)
226
+ return 66 if problems.include?(:missing)
227
+ return 65 if problems.include?(:not_blocked)
228
+ return 1 unless outcomes.all?(&:ok)
229
+
230
+ 0
231
+ end
232
+ end
233
+ end
234
+ end
@@ -118,7 +118,7 @@ module OpenLanes
118
118
  # is as much a thing the user needs told about as a folder that drained, and
119
119
  # stopping at the first bad one hides the rest of the report.
120
120
  #
121
- # The gate is `blocked.md` itself, not the folder's emoji. ⭕️ is *derived*
121
+ # The gate is `blocked.md` itself, not the folder's emoji. ⭕ is *derived*
122
122
  # from the file, so gating on the emoji made the tool circular: a
123
123
  # `blocked.md` whose questions are not numbered `## B<n>` never earns the
124
124
  # emoji, so `unblock` refused it, so the file could never drain and the
@@ -187,11 +187,85 @@ module OpenLanes
187
187
  # @param progress [OpenLanes::UI::Line]
188
188
  # @return [Array(Boolean, String)]
189
189
  def invoke(subject, progress)
190
- @executor.call(agent, subject, root:, &progress)
190
+ journal_answers(subject)
191
+ forward = progress.to_proc
192
+ kept = { written: 0, number: nil }
193
+ @executor.call(agent, subject, root:) do |update|
194
+ record_journal(subject, update, kept)
195
+ forward.call(update)
196
+ end
191
197
  rescue StandardError => e
192
198
  [false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
193
199
  end
194
200
 
201
+ # The broker's own journal lines (M2), written as they arrive, the way
202
+ # {Engine::Dispatcher} writes every other agent's: its prompt asks it to
203
+ # keep one like any agent's, and neither the run loop nor `openlanes
204
+ # unblock` hands its progress to the dispatcher. As they arrive, so a
205
+ # broker that fails or is killed keeps what it said.
206
+ #
207
+ # A journal that cannot be written costs the record, never the drain;
208
+ # the lines stay unwritten for the next update to try again.
209
+ #
210
+ # @param subject [OpenLanes::Plans::Subject]
211
+ # @param update [Object] whatever the executor reports
212
+ # @param kept [Hash] how many lines are written, and the journal round
213
+ # @return [void]
214
+ def record_journal(subject, update, kept)
215
+ lines = update.respond_to?(:journal) ? Array(update.journal) : []
216
+ return if !applying? || lines.size <= kept[:written]
217
+
218
+ entries, rejected = lines[kept[:written]..].partition { it.is_a?(Plans::Journal::Entry) }
219
+ unless entries.empty?
220
+ kept[:number], refused = Plans::PlanState.for(subject.feature.path)
221
+ .journal!(agent: agent.name,
222
+ entries:,
223
+ number: kept[:number],
224
+ state: subject.feature.status)
225
+ rejected += refused
226
+ end
227
+ kept[:written] = lines.size
228
+ rejected.each { UI.log("journal line ignored, #{it.reason}: #{it.line[0, 160]}", level: :warn, **log_fields(subject)) }
229
+ rescue SystemCallError, OpenLanes::Error => e
230
+ UI.log("journal not written yet: #{e.message}", level: :warn, **log_fields(subject))
231
+ end
232
+
233
+ # Every answered question goes into the journal of the agent that asked
234
+ # it (M3), B1 with A1, before the broker folds it in and deletes it:
235
+ # afterwards the words are gone from `blocked.md`, and the asker's next
236
+ # round would know only that the plan was unblocked, not what it was
237
+ # told. Here, rather than where the page writes an answer, because an
238
+ # answer typed into the file by hand is drained the same way.
239
+ #
240
+ # A journal that cannot be written costs the record, never the drain.
241
+ #
242
+ # @param subject [OpenLanes::Plans::Subject]
243
+ # @return [void]
244
+ def journal_answers(subject)
245
+ return unless applying?
246
+
247
+ state = Plans::PlanState.for(subject.feature.path)
248
+ asker = state.blocked_by or return
249
+ text = subject.read("blocked.md").to_s
250
+ bodies = Plans::BlockedFile.bodies(text)
251
+ answers = Plans::BlockedFile.answers(text).transform_values { plain(it) }
252
+ self.class.questions(subject).each do |question|
253
+ answer = answers[question.number]
254
+ next if answer.nil? || answer.empty?
255
+
256
+ state.answer!(agent: asker,
257
+ ref: "B#{question.number}",
258
+ question: plain("#{question.heading} #{bodies[question.number]}"),
259
+ answer:)
260
+ end
261
+ rescue SystemCallError, OpenLanes::Error => e
262
+ UI.log("journal not written: #{e.message}", level: :warn, **log_fields(subject))
263
+ end
264
+
265
+ # @param text [String] a block's body
266
+ # @return [String] one line, without the page's command markers
267
+ def plain(text) = Plans::Journal.clip(text.gsub(/<!--.*?-->/m, "").split.join(" "))
268
+
195
269
  # The same pass the run loop makes after every round, for the same reason:
196
270
  # a drained folder is only *named* differently once something reads the
197
271
  # file the agent just deleted.
@@ -3,7 +3,8 @@
3
3
  module OpenLanes
4
4
  # Operations that change which plans exist or what they are called:
5
5
  # minting a folder, drafting its brief, adopting an orphan pull request,
6
- # reconciling names with contents, and draining answered questions.
6
+ # reconciling names with contents, draining answered questions, and
7
+ # aging the ones nobody answers.
7
8
  module Lifecycle
8
9
  module_function
9
10
 
@@ -12,6 +13,10 @@ module OpenLanes
12
13
  # @return [Dry::Monads::Result] Success(path) or Failure(message)
13
14
  def create(dir:, **) = Creator.new(dir:).create(**)
14
15
 
16
+ # @param options [Hash] passed to {CreateWorkflow#initialize}
17
+ # @return [OpenLanes::Lifecycle::CreateWorkflow] `openlanes create`, without the command
18
+ def create_plan(**) = CreateWorkflow.new(**)
19
+
15
20
  # @param tree [OpenLanes::Plans::Tree]
16
21
  # @return [OpenLanes::Lifecycle::Resync::Dirs]
17
22
  def resync_dirs(tree:, **) = Resync::Dirs.new(tree:, **)
@@ -19,10 +24,41 @@ module OpenLanes
19
24
  # @return [OpenLanes::Lifecycle::Resync::Prs]
20
25
  def resync_prs(**) = Resync::Prs.new(**)
21
26
 
27
+ # `resync prs` as one step: retitle (and adopt) on GitHub, or say why not.
28
+ #
29
+ # @param commit [Boolean] false previews and changes nothing
30
+ # @param options [Hash] passed to {Resync::Prs#initialize}
31
+ # @return [Dry::Monads::Result] Success(Array<Resync::Prs::Change>), or
32
+ # Failure({Refusal}) with status 69 when GitHub refused, or 73 when an
33
+ # orphan would be adopted in a linked worktree, where no plan is minted
34
+ def retitle_prs(commit:, **)
35
+ Dry::Monads::Result::Success.new(Resync::Prs.new(**).call(commit:))
36
+ rescue Allocator::Refused => e
37
+ Dry::Monads::Result::Failure.new(Refusal.new(message: e.message, status: 73))
38
+ rescue OpenLanes::Error => e
39
+ Dry::Monads::Result::Failure.new(Refusal.new(message: e.message, status: 69))
40
+ end
41
+
22
42
  # @return [OpenLanes::Lifecycle::Brief]
23
43
  def brief(**) = Brief.new(**)
24
44
 
45
+ # @param options [Hash] passed to {UnblockWorkflow#initialize}
46
+ # @return [OpenLanes::Lifecycle::UnblockWorkflow] `openlanes unblock`, without the command
47
+ def unblock(**) = UnblockWorkflow.new(**)
48
+
25
49
  # @return [OpenLanes::Lifecycle::Unblocker]
26
50
  def unblocker(**) = Unblocker.new(**)
51
+
52
+ # Bomb or defer the blocked plans past their limits (plan 009).
53
+ #
54
+ # @param tree [OpenLanes::Plans::Tree]
55
+ # @param subjects [Array<OpenLanes::Plans::Subject>, nil] nil for every plan
56
+ # @param since [Date, nil] `plans.aging_since`
57
+ # @param now [Time]
58
+ # @param commit [Boolean] false reports and changes nothing
59
+ # @return [OpenLanes::Lifecycle::Aging::Result]
60
+ def age(tree:, subjects: nil, since: nil, now: Time.now, commit: true)
61
+ Aging.new(tree:, since:, now:).call(subjects:, commit:)
62
+ end
27
63
  end
28
64
  end
@@ -12,7 +12,7 @@ module OpenLanes
12
12
  # contract, and the name we would prefer if the team happens to have one.
13
13
  # {Push} resolves the name first and falls back to the type.
14
14
  #
15
- # Several states have no Linear equivalent at all — ⭕️ Technical Block is
15
+ # Several states have no Linear equivalent at all — ⭕ Technical Block is
16
16
  # a reason, not a position in a workflow — so they map to the nearest
17
17
  # position and carry a label saying which reason.
18
18
  #
@@ -50,11 +50,14 @@ module OpenLanes
50
50
  building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
51
51
  ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
52
52
  in_review: Placement.new(type: "started", name: "In Review", labels: []),
53
+ verifying: Placement.new(type: "started", name: "In Review", labels: []),
54
+ completed: Placement.new(type: "started", name: "In Review", labels: %w[ready-to-merge]),
53
55
  rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
54
56
  approved: Placement.new(type: "completed", name: "Done", labels: []),
55
57
  deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
56
58
  blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
57
59
  product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
60
+ bomb: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked bomb]),
58
61
  deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
59
62
  retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
60
63
  discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
@@ -55,7 +55,7 @@ module OpenLanes
55
55
  # the moment the work moves.
56
56
  GLYPH_NOTE = /\s*\((?:[^\w\s(][^)]*)\)\s*\z/
57
57
 
58
- # The same thing without the brackets: `… over the law plane ☢️ deferred`.
58
+ # The same thing without the brackets: `… over the law plane 💤 deferred`.
59
59
  TRAILING_GLYPH = /\s*[\u{2190}-\u{2BFF}\u{1F000}-\u{1FAFF}][^\n]*\z/
60
60
 
61
61
  # A plan number the author typed into the heading as well. The issue for
@@ -95,6 +95,7 @@ module OpenLanes
95
95
  @outgoing = {}
96
96
  @beat = nil
97
97
  @running = false
98
+ @unavailable = nil
98
99
  end
99
100
 
100
101
  # @return [String]
@@ -109,12 +110,7 @@ module OpenLanes
109
110
  @running = true
110
111
  @thread = Thread.new do
111
112
  while @running
112
- begin
113
- step(POLL)
114
- rescue Unavailable
115
- close_subscriber
116
- sleep 1
117
- end
113
+ listen(POLL) or sleep 1
118
114
  end
119
115
  end
120
116
  self
@@ -135,7 +131,8 @@ module OpenLanes
135
131
  end
136
132
 
137
133
  # Handle whatever has arrived, waiting up to +timeout+ for the first,
138
- # then expire, prune and heartbeat.
134
+ # then expire, prune and heartbeat, and nudge the event streams when a
135
+ # page was handed something to show.
139
136
  #
140
137
  # @param timeout [Float]
141
138
  # @return [void]
@@ -147,8 +144,31 @@ module OpenLanes
147
144
  message = sub.next_message(0)
148
145
  end
149
146
  housekeep
147
+ nudge
150
148
  end
151
149
 
150
+ # {#step}, kept from raising when Redis refuses the subscription (an ACL
151
+ # without `PSUBSCRIBE` answers `NOPERM`) or drops the connection. The
152
+ # board does not need the chat, so the server carries on serving it:
153
+ # one warning per outage, `unavailable` in {#state} until a step works
154
+ # again, and a fresh subscription on the next try.
155
+ #
156
+ # @param timeout [Float]
157
+ # @return [Boolean] whether the step went through
158
+ def listen(timeout = 0)
159
+ step(timeout)
160
+ @lock.synchronize { @unavailable = nil }
161
+ true
162
+ rescue Unavailable => e
163
+ close_subscriber
164
+ first = @lock.synchronize { @unavailable.nil?.tap { @unavailable = e.message } }
165
+ warn_unavailable(e.message) if first
166
+ false
167
+ end
168
+
169
+ # @return [String, nil] why the chat cannot listen, while it cannot
170
+ def unavailable = @lock.synchronize { @unavailable }
171
+
152
172
  # ---- the event stream -------------------------------------------------
153
173
 
154
174
  # A page's event stream opened.
@@ -184,7 +204,8 @@ module OpenLanes
184
204
  # ---- what the page asks -----------------------------------------------
185
205
 
186
206
  # `ChatState`: the chat this page is in, the invites open to this
187
- # machine, and the ones this page is waiting on.
207
+ # machine, the ones this page is waiting on, and, while Redis refuses
208
+ # the subscription, why the chat is unavailable.
188
209
  #
189
210
  # @param page [String]
190
211
  # @return [Hash]
@@ -194,7 +215,8 @@ module OpenLanes
194
215
  chat = me.chat && { id: me.chat, me: participant(me), participants: roster(me.chat) }
195
216
  { chat:,
196
217
  invites: @incoming.values.map { it[:invite] },
197
- outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) } }
218
+ outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) },
219
+ unavailable: @unavailable }
198
220
  end
199
221
  end
200
222
 
@@ -533,6 +555,20 @@ module OpenLanes
533
555
  def push(page, event)
534
556
  page.events << event
535
557
  page.events.shift(page.events.size - BACKLOG) if page.events.size > BACKLOG
558
+ @pushed = true
559
+ end
560
+
561
+ # A page's events wait in this process, where an event stream blocked
562
+ # on {Keys#news} cannot see them, so a step that queued any says so on
563
+ # that channel: one nudge per step, however many it queued. Nearly
564
+ # every push happens inside a step, on the listening thread; the one
565
+ # that does not (an invite nobody was there to hear, expired on the
566
+ # request's own thread) is nudged by the next step, {POLL} later.
567
+ #
568
+ # @return [void]
569
+ def nudge
570
+ pushed = @lock.synchronize { @pushed.tap { @pushed = false } }
571
+ News.publish(store:, keys: @keys, kind: "chat") if pushed
536
572
  end
537
573
 
538
574
  # @return [Hash] `ChatParticipant`
@@ -586,10 +622,23 @@ module OpenLanes
586
622
  end
587
623
 
588
624
  # @return [Integer] receivers
589
- def publish(channel, data) = store.publish(channel, JSON.generate(data))
625
+ def publish(channel, data) = EventBus.for(store).publish(channel, JSON.generate(data))
590
626
 
591
627
  # @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
592
- def subscriber = @subscriber ||= store.subscriber(channels: [inbox], patterns: [@keys.chat_rooms(db:)])
628
+ def subscriber = @subscriber ||= EventBus.for(store).subscribe(channels: [inbox], patterns: [@keys.chat_rooms(db:)])
629
+
630
+ # {UI.log} alone reaches only a log file or a tap, and `openlanes web`
631
+ # sets up neither, so there the one warning an outage gets would be
632
+ # dropped and nobody would learn why the chat went dark. With nothing
633
+ # listening it goes to STDERR instead.
634
+ #
635
+ # @param reason [String]
636
+ # @return [void]
637
+ def warn_unavailable(reason)
638
+ message = "chat unavailable: #{reason}"
639
+ UI.log(message)
640
+ UI.line(message, bullet: "!") unless UI.log_path || UI.log_taps.any?
641
+ end
593
642
 
594
643
  # @return [void]
595
644
  def close_subscriber