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
@@ -62,6 +62,112 @@ module OpenLanes
62
62
  # @return [String]
63
63
  def run(of = node) = "#{base}:#{of}:run"
64
64
 
65
+ # Pub/sub: `{json}`, one line of this node's {Engine::EventJournal},
66
+ # mirrored after it is fsync'd. The file is the record; a subscriber
67
+ # that misses a message reads the file.
68
+ #
69
+ # @param of [String]
70
+ # @return [String]
71
+ def journal(of = node) = "#{base}:#{of}:journal"
72
+
73
+ # Stream: `{json}`, one `CopilotTurn`: the conversation with this
74
+ # node's copilot (W17), the person's messages and its answers in the
75
+ # order they were said. {Copilot} writes and reads it.
76
+ #
77
+ # @param of [String]
78
+ # @return [String]
79
+ def copilot(of = node) = "#{base}:#{of}:copilot"
80
+
81
+ # Hash: `session`, `model`, `up` and `down`, the copilot's `claude`
82
+ # session id and what it has spent in it. Kept beside {#copilot} so a
83
+ # reloaded page or a restarted server carries on the same conversation
84
+ # (W17: one session, resumed on each message).
85
+ #
86
+ # @param of [String]
87
+ # @return [String]
88
+ def copilot_session(of = node) = "#{base}:#{of}:copilot-session"
89
+
90
+ # Stream: `{json}`, one mailbox message and the plan it belongs to,
91
+ # what `openlanes mail send` publishes after writing `state.json`
92
+ # (plan 003, B3). One stream per checkout; the plan rides in the entry,
93
+ # and each plan reads it through a group of its own ({Keys#mail_group}).
94
+ #
95
+ # @param of [String]
96
+ # @return [String]
97
+ def mail(of = node) = "#{base}:#{of}:mail"
98
+
99
+ # The consumer group one plan drains {#mail} through. Every group sees
100
+ # every entry, so a plan's harness thread acks what it read, its own
101
+ # or not, without taking anything from another plan's.
102
+ #
103
+ # @param plan [#to_s] e.g. "003.00"
104
+ # @return [String]
105
+ def mail_group(plan) = "mail:#{plan}"
106
+
107
+ # Pub/sub channel: one chat's messages, joins, leaves and idle notices
108
+ # (plan 006). Redis delivers a `PUBLISH` to every database's
109
+ # subscribers, so the database is part of every chat channel's name: a
110
+ # chat between nodes on database 13 never reaches a page on database 0.
111
+ #
112
+ # @param id [String] the chat's UUID
113
+ # @param db [Integer] the Redis database the nodes share
114
+ # @return [String]
115
+ def chat_room(id, db:) = "#{chat_base(db)}:room:#{id}"
116
+
117
+ # Pub/sub pattern matching every {#chat_room} on +db+, which each API
118
+ # server subscribes to once, so joining a chat needs no new subscription.
119
+ #
120
+ # @param db [Integer]
121
+ # @return [String]
122
+ def chat_rooms(db:) = "#{chat_base(db).gsub(/[*?\[\]\\]/) { "\\#{it}" }}:room:*"
123
+
124
+ # Pub/sub channel: one machine's chat inbox, where invites to it and
125
+ # their answers arrive. Every API server for that machine listens, so
126
+ # an invite reaches every page open there (D9).
127
+ #
128
+ # @param machine [String] a node id, 24 hex digits of `user@host`
129
+ # @param db [Integer]
130
+ # @return [String]
131
+ def chat_inbox(machine, db:) = "#{chat_base(db)}:machine:#{machine}"
132
+
133
+ # String: how an invite was settled (`accepted`, `declined`, `expired`),
134
+ # `SET NX EX`, so the first answer from any page wins (D9).
135
+ #
136
+ # @param invite [String] the invite's UUID
137
+ # @return [String]
138
+ def chat_answer(invite) = "#{base}:chat:invite:#{invite}"
139
+
140
+ # @param db [Integer]
141
+ # @return [String]
142
+ def chat_base(db) = "#{base}:chat:#{db}"
143
+
144
+ # Pub/sub channel: "look again", one per project, published by
145
+ # whoever wrote something an open page shows ({News}). Every node's
146
+ # engine on the project publishes here, because every page shows every
147
+ # node's board. The database is in the name for the reason it is in
148
+ # {#chat_room}'s: a `PUBLISH` reaches every database's subscribers.
149
+ #
150
+ # @param db [Integer] the Redis database the nodes share
151
+ # @return [String]
152
+ def news(db:) = "#{base}:news:#{db}"
153
+
154
+ # Hash: plan ordinal to that plan's running app, its process statuses
155
+ # and a heartbeat ({Apps::Listing}). One per machine, not per
156
+ # checkout: the app runs in a plan's worktree and the control room in
157
+ # the main checkout, and both are this machine.
158
+ #
159
+ # @param machine [String] a node id, 24 hex digits of `user@host`
160
+ # @return [String]
161
+ def apps(machine = node.split(":").first) = "#{base}:apps:#{machine}"
162
+
163
+ # Stream: `{json}`, one request from the Preview tab's toolbar
164
+ # (restart, stop), read by `openlanes app start`. Not {#commands}: the
165
+ # engine never runs an app, so it would never read them.
166
+ #
167
+ # @param machine [String]
168
+ # @return [String]
169
+ def app_commands(machine = node.split(":").first) = "#{apps(machine)}:commands"
170
+
65
171
  # String: the node name holding a plan, `SET NX EX`.
66
172
  #
67
173
  # @param ordinal [#to_s] e.g. "004.00"
@@ -80,6 +186,14 @@ module OpenLanes
80
186
  BOARD_LENGTH = 100
81
187
  REPLIES_LENGTH = 1000
82
188
 
189
+ # Length of {#copilot}, kept approximately: a conversation the page
190
+ # can scroll back through, not an archive.
191
+ COPILOT_LENGTH = 500
192
+
193
+ # Seconds a message stays in {#mail}. `state.json` is the record; the
194
+ # stream only has to outlast a harness tick, and an hour is generous.
195
+ MAIL_TTL = 3600
196
+
83
197
  # Seconds a snapshot outlives its last refresh.
84
198
  SNAPSHOT_TTL = 30
85
199
 
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # Mail between agents, over Redis as well as through `state.json`
9
+ # (plan 003, B3).
10
+ #
11
+ # `state.json` stays the record: `openlanes mail send` writes it first
12
+ # and publishes here only when Redis answers, so a run without Redis
13
+ # loses nothing. What the stream adds is delivery to the harness thread
14
+ # beside each agent, which drains it once a second (B4) instead of
15
+ # re-reading every plan's file.
16
+ #
17
+ # A stream with a consumer group per plan, not pub/sub: a message
18
+ # published while nobody drains waits for the next drain instead of
19
+ # vanishing. Every publish trims what is older than {Keys::MAIL_TTL}
20
+ # and renews the key's expiry, so the stream never outgrows an hour.
21
+ #
22
+ # Delivery is acked once the drain's block kept it, never before: an
23
+ # entry acked ahead of a failed write would be gone from the stream with
24
+ # only a log line left. An entry not kept stays pending and is offered
25
+ # again on the next drain. An ack says the bytes arrived, nothing more: a message is read when its recipient runs `openlanes mail ack`,
26
+ # and that lands in `state.json`, never here.
27
+ class Mail
28
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
29
+ # @param keys [OpenLanes::Live::Keys]
30
+ # @param now [Proc] wall time, which the trim's cutoff is taken from
31
+ def initialize(store:, keys:, now: -> { Time.now })
32
+ @store = store
33
+ @keys = keys
34
+ @now = now
35
+ end
36
+
37
+ # Publish one message that `state.json` already holds.
38
+ #
39
+ # @param plan [#to_s] the plan's number, e.g. "003.00"
40
+ # @param message [OpenLanes::Plans::Mailbox::Message, Hash]
41
+ # @return [void]
42
+ def publish(plan:, message:)
43
+ payload = JSON.generate("plan" => plan.to_s, "message" => wire(message))
44
+ EventBus.for(@store).transaction do |tx|
45
+ tx.append(@keys.mail, { json: payload })
46
+ tx.xtrim(@keys.mail, minid: cutoff)
47
+ tx.expire(@keys.mail, Keys::MAIL_TTL)
48
+ end
49
+ end
50
+
51
+ # Every message for +plan+ that this plan's group has not had kept
52
+ # yet: first the ones offered before and left pending, then the new.
53
+ # Entries for other plans, and entries that are not messages, are
54
+ # acked in this plan's group straight away, which leaves other plans'
55
+ # groups untouched.
56
+ #
57
+ # With a block, each message is yielded and acked only when the block
58
+ # returns truthy; one it returns false for, or raises on, stays pending
59
+ # for the next drain. Without one, each is acked as it is returned.
60
+ #
61
+ # @param plan [#to_s]
62
+ # @param consumer [String] who is draining, e.g. the agent's name
63
+ # @yieldparam message [Hash] as `state.json` holds it
64
+ # @yieldreturn [Boolean] whether it was kept
65
+ # @return [Array<Hash>] messages as `state.json` holds them, oldest
66
+ # first; with a block, the ones left pending
67
+ def drain(plan:, consumer:)
68
+ group = @keys.mail_group(plan)
69
+ @store.expire(@keys.mail, Keys::MAIL_TTL) if @store.xgroup_create(@keys.mail, group)
70
+ entries = @store.xreadgroup(@keys.mail, group, consumer, id: "0") + @store.xreadgroup(@keys.mail, group, consumer)
71
+ entries.filter_map do |entry|
72
+ message = message_in(entry, plan.to_s)
73
+ kept = message.nil? || !block_given? || yield(message)
74
+ @store.xack(@keys.mail, group, entry.id) if kept
75
+ message if message && (kept ? !block_given? : true)
76
+ end
77
+ end
78
+
79
+ # Which plans have mail in the stream after +cursor+, so a drain can
80
+ # reach a plan with nobody running on it: a worktree's `mail send` has
81
+ # no other way into the main checkout's `state.json`, and the stream
82
+ # forgets it after {Keys::MAIL_TTL}.
83
+ #
84
+ # @param cursor [String] the last stream id already looked at, "0" for
85
+ # all of it
86
+ # @return [Array(Array<String>, String)] the plans, and the new cursor
87
+ def plans_since(cursor)
88
+ plans = []
89
+ loop do
90
+ entries = @store.xread(@keys.mail, id: cursor)
91
+ break if entries.empty?
92
+
93
+ cursor = entries.last.id
94
+ plans.concat(entries.filter_map { plan_in(it) })
95
+ end
96
+ [plans.uniq, cursor]
97
+ end
98
+
99
+ private
100
+
101
+ # The oldest id a publish keeps: {Keys::MAIL_TTL} seconds ago.
102
+ #
103
+ # @return [String]
104
+ def cutoff = "#{((@now.call.to_f - Keys::MAIL_TTL) * 1000).to_i}-0"
105
+
106
+ # @param message [OpenLanes::Plans::Mailbox::Message, Hash]
107
+ # @return [Hash] string keys, `at` in ISO 8601 as `state.json` has it
108
+ def wire(message)
109
+ hash = message.to_h.transform_keys(&:to_s)
110
+ hash.merge("at" => iso8601(hash["at"])).compact
111
+ end
112
+
113
+ # @param at [String, Time, nil]
114
+ # @return [String, nil]
115
+ def iso8601(at)
116
+ return at.iso8601 if at.respond_to?(:iso8601)
117
+
118
+ Time.parse(at.to_s).iso8601
119
+ rescue ArgumentError
120
+ at&.to_s
121
+ end
122
+
123
+ # @param entry [OpenLanes::Live::Store::Entry]
124
+ # @return [String, nil] the plan the entry is for
125
+ def plan_in(entry)
126
+ data = JSON.parse(entry.fields["json"].to_s)
127
+ data["plan"] if data.is_a?(Hash) && data["plan"].is_a?(String)
128
+ rescue JSON::ParserError
129
+ nil
130
+ end
131
+
132
+ # @param entry [OpenLanes::Live::Store::Entry]
133
+ # @param plan [String]
134
+ # @return [Hash, nil] the message, when the entry is one and for +plan+
135
+ def message_in(entry, plan)
136
+ data = JSON.parse(entry.fields["json"].to_s)
137
+ data["message"] if data.is_a?(Hash) && data["plan"] == plan && data["message"].is_a?(Hash)
138
+ rescue JSON::ParserError
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end
@@ -22,6 +22,7 @@ module OpenLanes
22
22
  @groups = Hash.new { |h, k| h[k] = {} }
23
23
  @last_ms = 0
24
24
  @last_seq = 0
25
+ @subscribers = []
25
26
  end
26
27
 
27
28
  # @return [String] what {Live.mask} shows for it
@@ -125,6 +126,17 @@ module OpenLanes
125
126
  # @return [Integer]
126
127
  def xlen(key) = locked { (live(key) || []).size }
127
128
 
129
+ # @return [Integer] how many were removed
130
+ def xtrim(key, minid:)
131
+ locked do
132
+ stream = live(key) or next 0
133
+ floor = id_of(minid.include?("-") ? minid : "#{minid}-0")
134
+ before = stream.size
135
+ stream.reject! { (id_of(it.id) <=> floor).negative? }
136
+ before - stream.size
137
+ end
138
+ end
139
+
128
140
  # @return [Boolean] whether the group is new
129
141
  def xgroup_create(key, group, from: "0")
130
142
  locked do
@@ -140,7 +152,7 @@ module OpenLanes
140
152
  # @return [Array<Store::Entry>]
141
153
  def xreadgroup(key, group, consumer, id: ">", count: 100)
142
154
  locked do
143
- state = @groups[key][group] or raise Unavailable, "NOGROUP #{group} on #{key}"
155
+ state = @groups[key][group] or raise GroupMissing, "NOGROUP #{group} on #{key}"
144
156
  stream = live(key) || []
145
157
  if id == ">"
146
158
  fresh = stream.select { |e| (id_of(e.id) <=> state[:last]).positive? }.first(count)
@@ -193,6 +205,29 @@ module OpenLanes
193
205
  # @return [Array<String>]
194
206
  def scan(prefix) = locked { @data.keys.select { |k| k.start_with?(prefix) && !live(k).nil? }.sort }
195
207
 
208
+ # ---- pub/sub ----------------------------------------------------------
209
+
210
+ # @return [Integer] how many subscriptions received it
211
+ def publish(channel, message)
212
+ receivers = locked { @subscribers.dup }
213
+ receivers.sum do |subscriber|
214
+ subscriber.deliver(channel, message.to_s)
215
+ subscriber.matches(channel)
216
+ end
217
+ end
218
+
219
+ # @return [OpenLanes::Live::MemorySubscriber]
220
+ def subscriber(channels: [], patterns: [])
221
+ MemorySubscriber.new(store: self, channels:, patterns:).tap { |s| locked { @subscribers << s } }
222
+ end
223
+
224
+ # @param subscriber [OpenLanes::Live::MemorySubscriber]
225
+ # @return [void]
226
+ def unsubscribe(subscriber) = locked { @subscribers.delete(subscriber) }
227
+
228
+ # @return [Integer] always 0: one process, one keyspace
229
+ def db = 0
230
+
196
231
  # ---- transactions -----------------------------------------------------
197
232
 
198
233
  # @yieldparam tx [Store::Transaction]
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # {Subscriber}'s methods over {MemoryStore#publish}: a queue in this
6
+ # process, matched by exact channel or by Redis-style glob.
7
+ class MemorySubscriber
8
+ # @param store [OpenLanes::Live::MemoryStore]
9
+ # @param channels [Array<String>]
10
+ # @param patterns [Array<String>]
11
+ def initialize(store:, channels: [], patterns: [])
12
+ @store = store
13
+ @channels = channels
14
+ @patterns = patterns
15
+ @queue = Thread::Queue.new
16
+ end
17
+
18
+ # @param channel [String]
19
+ # @return [Integer] how many of its subscriptions match, as Redis counts receivers
20
+ def matches(channel)
21
+ @channels.count(channel) + @patterns.count { File.fnmatch?(it, channel) }
22
+ end
23
+
24
+ # @param channel [String]
25
+ # @param message [String]
26
+ # @return [void]
27
+ def deliver(channel, message) = matches(channel).times { @queue << [channel, message] }
28
+
29
+ # @param timeout [Float]
30
+ # @return [Array(String, String), nil]
31
+ def next_message(timeout) = @queue.pop(timeout: [timeout, 0].max)
32
+
33
+ # @return [void]
34
+ def close
35
+ @store.unsubscribe(self)
36
+ @queue.close
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # The nudge that tells every open page's event stream to look again
8
+ # ({Keys#news}). It carries no data: whoever wrote something the page
9
+ # shows writes it first, then publishes `{"kind": ...}`, and the stream
10
+ # re-reads Redis as it always has. The data stays in its keys and
11
+ # streams, so a nudge lost to a dropped connection costs latency, never
12
+ # correctness, and the stream's cursors work exactly as before.
13
+ #
14
+ # Who publishes, and why each one does:
15
+ #
16
+ # - `board`: {Publisher}, on the Dispatcher's tick, only when the
17
+ # snapshot changed apart from its clock, so an idle engine is quiet.
18
+ # - `reply`: {Commands}, on the Dispatcher's tick, for each reply.
19
+ # - `copilot`: {Copilot}, in the API server, for each turn written.
20
+ # - `app`: {Apps::Runner}, when a process changes state or the app stops.
21
+ # Its once-a-second heartbeat does not: nothing on the page moves.
22
+ # - `chat`: {Chat}, after a step handed a page something to show.
23
+ # Its events live in this process, not in Redis, so the nudge is the
24
+ # only way a stream blocked on Redis can hear of them.
25
+ # - `stop`: {Web::Server#stop}, so open streams end now rather than at
26
+ # their next ping.
27
+ #
28
+ # The `--demo` engine publishes `board` and `reply` the way the real one does.
29
+ module News
30
+ # Every kind a nudge may name. The stream does not branch on them; they
31
+ # are there for whoever watches the channel with `redis-cli SUBSCRIBE`.
32
+ KINDS = %w[board reply copilot app chat stop].freeze
33
+
34
+ module_function
35
+
36
+ # Nudge every stream following +keys+' project on +store+'s database.
37
+ # Best effort: the write it announces is already in Redis, and a
38
+ # stream that misses the nudge still finds it at its next ping.
39
+ #
40
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
41
+ # @param keys [OpenLanes::Live::Keys]
42
+ # @param kind [String] one of {KINDS}
43
+ # @return [Integer] how many subscriptions heard it, 0 when Redis did not answer
44
+ def publish(store:, keys:, kind:)
45
+ bus = EventBus.for(store)
46
+ bus.publish(keys.news(db: bus.db), JSON.generate(kind:))
47
+ rescue Unavailable
48
+ 0
49
+ end
50
+
51
+ # A subscription of its own to the nudges, for one event stream. The
52
+ # caller closes it: each is a Redis connection.
53
+ #
54
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
55
+ # @param keys [OpenLanes::Live::Keys]
56
+ # @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
57
+ # @raise [OpenLanes::Live::Unavailable]
58
+ def subscribe(store:, keys:) = EventBus.for(store).then { it.subscribe(channels: [keys.news(db: it.db)]) }
59
+ end
60
+ end
61
+ end
@@ -46,7 +46,27 @@ module OpenLanes
46
46
  ID_LENGTH = 24
47
47
  CHECKOUT_LENGTH = 12
48
48
 
49
+ # A declared `user@host`: one of each, no spaces, no second `@`.
50
+ IDENTITY = /\A(?<user>[^@\s]+)@(?<host>[^@\s]+)\z/
51
+
52
+ # A declared identity that is not `user@host`.
53
+ class Invalid < ArgumentError; end
54
+
49
55
  class << self
56
+ # This checkout as a machine of another name: `openlanes web --demo
57
+ # --identity demo@second-machine` registers as a second machine on the
58
+ # same Redis and repository, through the same digest every process
59
+ # is already named by, so nothing reads a second naming scheme.
60
+ #
61
+ # @param identity [String] `user@host`
62
+ # @param root [String] the checkout
63
+ # @return [OpenLanes::Live::Node]
64
+ # @raise [Invalid]
65
+ def declared(identity, root:, **)
66
+ match = IDENTITY.match(identity.to_s.strip) or raise Invalid, "an identity is user@host, not #{identity.inspect}"
67
+ current(root:, user: match[:user], host: match[:host], **)
68
+ end
69
+
50
70
  # @param root [String] the checkout
51
71
  # @param user [String]
52
72
  # @param host [String]
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # What the engine does when its Redis stops answering (W32).
6
+ #
7
+ # A blip is not an outage: Redis that is back within {GRACE} seconds
8
+ # changes nothing. Past that, every running agent is paused through its
9
+ # control file ({Execution::Control::PAUSE}), nothing new starts, and the
10
+ # outage goes into the run's state file, the record that survives Redis.
11
+ # Redis is then asked again after 1, 1, 2, 3, 5, 8 … seconds, the
12
+ # Fibonacci numbers scaled by `redis_backoff.initial` and capped at
13
+ # `redis_backoff.cap` ({MAX_GAP} by default), so a short outage is noticed
14
+ # quickly, a long one is not hammered, and a Redis that comes back is
15
+ # never left unnoticed for longer than the cap. With full jitter (the
16
+ # default) each gap is drawn uniformly below that, so engines sharing one
17
+ # Redis do not all ask in the same second. When it answers, the agents
18
+ # carry on, their clocks having stood still meanwhile. When it has not answered for
19
+ # `redis_outage_hours` (an hour by default), the run shuts down the way
20
+ # `q` does, and says why.
21
+ #
22
+ # Run on the dispatcher's tick, ahead of everything that needs Redis.
23
+ class Outage
24
+ # Seconds Redis may be unreachable before the agents are paused.
25
+ GRACE = 10
26
+
27
+ # Seconds between pings while Redis answers.
28
+ PING_EVERY = 1
29
+
30
+ # The longest gap between two asks while paused, in seconds. Without
31
+ # it the Fibonacci gaps pass 16 minutes within fifteen asks, and a Redis
32
+ # back after 17 minutes would leave every agent paused for 10 more.
33
+ MAX_GAP = 30
34
+
35
+ # How many past outages the state file keeps.
36
+ HISTORY = 20
37
+
38
+ # One outage that ended in Redis answering: wall-clock bounds, and the
39
+ # length by the monotonic clock, which a changed system time cannot
40
+ # skew. {OutageReport} turns it into `run.outage` (plan 022).
41
+ Window = Data.define(:started_at, :ended_at, :seconds)
42
+
43
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
44
+ # @param limit [Numeric] seconds paused before the run gives up
45
+ # @param clock [#call] monotonic seconds
46
+ # @param wall [#call] wall time, for the state file
47
+ # @param initial [Numeric] seconds before the first ask; the Fibonacci gaps are its multiples
48
+ # @param cap [Numeric] the longest gap between two asks
49
+ # @param jitter [Boolean] full jitter: each gap is drawn from [0, gap)
50
+ # @param random [#rand] the jitter's source; a spec hands in a fixed one
51
+ def initialize(store:, limit:, clock: -> { UI.monotonic }, wall: -> { Time.now },
52
+ initial: 1, cap: MAX_GAP, jitter: true, random: Random.new)
53
+ @store = store
54
+ @limit = limit
55
+ @initial = initial
56
+ @cap = cap
57
+ @jitter = jitter
58
+ @random = random
59
+ @clock = clock
60
+ @wall = wall
61
+ @state = :up
62
+ @down_since = nil
63
+ @last_ping = nil
64
+ @resumed = []
65
+ end
66
+
67
+ # @return [Symbol] :up, :paused or :gave_up
68
+ attr_reader :state
69
+
70
+ # One tick.
71
+ #
72
+ # @param dispatcher [OpenLanes::Engine::Dispatcher] for its state file
73
+ # @return [void]
74
+ def call(dispatcher)
75
+ @state_file = dispatcher.state_file
76
+ now = @clock.call
77
+ case @state
78
+ when :up then watch(now)
79
+ when :paused then probe(now)
80
+ end
81
+ end
82
+
83
+ # @return [Boolean] whether agents are paused for this outage
84
+ def paused? = @state == :paused
85
+
86
+ # @return [Proc] {#call}, for `Dispatcher#before_tick`
87
+ def to_proc = method(:call).to_proc
88
+
89
+ # Something to run once Redis answers again, after the agents resume.
90
+ #
91
+ # @yieldparam window [Window]
92
+ # @return [void]
93
+ def on_resume(&hook) = @resumed << hook
94
+
95
+ # @return [Window, nil] the last outage that ended in Redis answering
96
+ attr_reader :window
97
+
98
+ private
99
+
100
+ # @return [void]
101
+ def watch(now)
102
+ return if @last_ping && now - @last_ping < PING_EVERY
103
+
104
+ @last_ping = now
105
+ if answers?
106
+ @down_since = nil
107
+ else
108
+ @down_since ||= now
109
+ pause(now) if now - @down_since >= GRACE
110
+ end
111
+ end
112
+
113
+ # @return [void]
114
+ def pause(now)
115
+ @state = :paused
116
+ @delays = fibonacci
117
+ @attempts = 0
118
+ @next_probe = next_probe(now)
119
+ @record = { "since" => (@wall.call - (now - @down_since)).iso8601, "paused_at" => @wall.call.iso8601,
120
+ "limit_seconds" => @limit.round, "attempts" => 0 }
121
+ UI.log("Redis has not answered for #{GRACE}s: pausing every agent, starting nothing new, " \
122
+ "and giving up after #{hours(@limit)}",
123
+ level: :warn)
124
+ Execution::Control.pause!
125
+ save
126
+ end
127
+
128
+ # @return [void]
129
+ def probe(now)
130
+ return if now < @next_probe
131
+
132
+ @attempts += 1
133
+ @record["attempts"] = @attempts
134
+ @record["last_attempt_at"] = @wall.call.iso8601
135
+ if answers?
136
+ resume(now)
137
+ elsif now - @down_since >= @limit
138
+ give_up(now)
139
+ else
140
+ @next_probe = next_probe(now)
141
+ save
142
+ end
143
+ end
144
+
145
+ # @return [void]
146
+ def resume(now)
147
+ @state = :up
148
+ @record["resumed_at"] = @wall.call.iso8601
149
+ @record["seconds"] = (now - @down_since).round
150
+ UI.log("Redis answers again after #{(now - @down_since).round}s and #{@attempts} attempts: resuming every agent")
151
+ Execution::Control.resume!
152
+ @window = Window.new(started_at: @wall.call - (now - @down_since),
153
+ ended_at: @wall.call,
154
+ seconds: now - @down_since)
155
+ @down_since = nil
156
+ save(over: true)
157
+ @resumed.each { |hook| hook.call(@window) }
158
+ end
159
+
160
+ # @return [void]
161
+ def give_up(now)
162
+ @state = :gave_up
163
+ @record["gave_up_at"] = @wall.call.iso8601
164
+ @record["seconds"] = (now - @down_since).round
165
+ UI.log("Redis has not answered for #{hours(@limit)}: shutting the run down", level: :error)
166
+ Execution::Control.quit!
167
+ save(over: true)
168
+ end
169
+
170
+ # The next Fibonacci gap, scaled, capped, jittered, and never past the
171
+ # limit: the last ask is the one at the limit.
172
+ #
173
+ # @return [Float]
174
+ def next_probe(now) = [now + gap, @down_since + @limit].min
175
+
176
+ # @return [Float] seconds to the next ask
177
+ def gap
178
+ gap = [@delays.next * @initial, @cap].min.to_f
179
+ @jitter ? gap * @random.rand : gap
180
+ end
181
+
182
+ # @return [Boolean]
183
+ def answers?
184
+ @store.ping == true
185
+ rescue Unavailable, SystemCallError
186
+ false
187
+ end
188
+
189
+ # 1, 1, 2, 3, 5, 8, …
190
+ #
191
+ # @return [Enumerator]
192
+ def fibonacci
193
+ Enumerator.new do |out|
194
+ a, b = 1, 1
195
+ loop do
196
+ out << a
197
+ a, b = b, a + b
198
+ end
199
+ end
200
+ end
201
+
202
+ # The current outage, or, once it is over, into the history. Saved at
203
+ # once: this is the record a person reads to learn why agents stood
204
+ # still, and the run may not live to save it later.
205
+ #
206
+ # @param over [Boolean]
207
+ # @return [void]
208
+ def save(over: false)
209
+ return unless @state_file
210
+
211
+ @state_file.outage = over ? nil : @record
212
+ @state_file.outages = (@state_file.outages + [@record]).last(HISTORY) if over
213
+ @state_file.save
214
+ rescue SystemCallError => e
215
+ UI.log("outage not recorded: #{e.message}", level: :warn)
216
+ end
217
+
218
+ # @return [String]
219
+ def hours(seconds)
220
+ hours = seconds / 3600.0
221
+ hours == hours.round ? "#{hours.round} hour#{"s" unless hours.round == 1}" : "#{hours.round(2)} hours"
222
+ end
223
+ end
224
+ end
225
+ end