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
@@ -126,6 +126,17 @@ module OpenLanes
126
126
  # @return [Integer]
127
127
  def xlen(key) = locked { (live(key) || []).size }
128
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
+
129
140
  # @return [Boolean] whether the group is new
130
141
  def xgroup_create(key, group, from: "0")
131
142
  locked do
@@ -141,7 +152,7 @@ module OpenLanes
141
152
  # @return [Array<Store::Entry>]
142
153
  def xreadgroup(key, group, consumer, id: ">", count: 100)
143
154
  locked do
144
- state = @groups[key][group] or raise Unavailable, "NOGROUP #{group} on #{key}"
155
+ state = @groups[key][group] or raise GroupMissing, "NOGROUP #{group} on #{key}"
145
156
  stream = live(key) || []
146
157
  if id == ">"
147
158
  fresh = stream.select { |e| (id_of(e.id) <=> state[:last]).positive? }.first(count)
@@ -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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # What the engine says once Redis is back: how long it was gone, how
9
+ # that was measured, and which copy of the run state won.
10
+ #
11
+ # The duration prefers this node's last heartbeat in Redis, because it
12
+ # is the last moment Redis provably had the engine's word. A heartbeat
13
+ # far older than the first failure is not that: it is what an RDB
14
+ # rollback restores, and measuring from it would overstate the outage by
15
+ # however long the snapshot was stale. Then the engine's own monotonic
16
+ # window is the answer, and `measured_by` says so.
17
+ class OutageReport
18
+ # Seconds a heartbeat may precede the first failure and still count.
19
+ # The publisher beats every tick, so a live one is a few seconds old.
20
+ FRESH = 15
21
+
22
+ # @param store [#hget]
23
+ # @param keys [OpenLanes::Live::Keys]
24
+ # @param node [OpenLanes::Live::Node]
25
+ # @param window [OpenLanes::Live::Outage::Window]
26
+ # @param reconciled_from [Symbol, nil] `:redis`, `:file` or nil, from
27
+ # {RunRecord#reconcile}
28
+ def initialize(store:, keys:, node:, window:, reconciled_from:)
29
+ @store = store
30
+ @keys = keys
31
+ @node = node
32
+ @window = window
33
+ @reconciled_from = reconciled_from
34
+ end
35
+
36
+ # The `run.outage` wire shape (plan 022, shared with the page).
37
+ #
38
+ # @return [Hash]
39
+ def to_h
40
+ { started_at: iso(started_at),
41
+ ended_at: iso(@window.ended_at),
42
+ seconds: seconds,
43
+ measured_by: measured_by,
44
+ reconciled_from: @reconciled_from&.to_s }
45
+ end
46
+
47
+ # @return [String] one log line
48
+ def line
49
+ from = @reconciled_from ? "run state from #{@reconciled_from}" : "no run state to reconcile"
50
+ "Redis is back after #{seconds}s (by #{measured_by}), #{from}; resuming"
51
+ end
52
+
53
+ # @return [Integer]
54
+ def seconds
55
+ (heartbeat ? @window.ended_at - heartbeat : @window.seconds).round
56
+ end
57
+
58
+ # @return [String] `"heartbeat"` or `"clock"`
59
+ def measured_by = heartbeat ? "heartbeat" : "clock"
60
+
61
+ private
62
+
63
+ # @return [Time]
64
+ def started_at = heartbeat || @window.started_at
65
+
66
+ # This node's heartbeat, when it is from just before the first failure.
67
+ #
68
+ # @return [Time, nil]
69
+ def heartbeat
70
+ return @heartbeat if defined?(@heartbeat)
71
+
72
+ @heartbeat = read_heartbeat
73
+ end
74
+
75
+ # @return [Time, nil]
76
+ def read_heartbeat
77
+ json = @store.hget(@keys.nodes, @node.name) or return nil
78
+ at = Time.iso8601(JSON.parse(json)["heartbeat_at"].to_s)
79
+ at if at <= @window.started_at && @window.started_at - at <= FRESH
80
+ rescue JSON::ParserError, ArgumentError, Unavailable
81
+ nil
82
+ end
83
+
84
+ # @param time [Time]
85
+ # @return [String]
86
+ def iso(time) = time.utc.iso8601
87
+ end
88
+ end
89
+ end
@@ -60,6 +60,12 @@ module OpenLanes
60
60
  @on_lost = block
61
61
  end
62
62
 
63
+ # {OutageReport#to_h} of the outage this run last recovered from,
64
+ # published as `run.outage` from the next snapshot on.
65
+ #
66
+ # @return [Hash, nil]
67
+ attr_accessor :outage
68
+
63
69
  # @return [OpenLanes::Live::Meter]
64
70
  attr_reader :meter
65
71
 
@@ -77,11 +83,12 @@ module OpenLanes
77
83
  @meter.record(board.rows, now:)
78
84
  snapshot = build(board, now)
79
85
  json = JSON.generate(snapshot)
80
- @store.multi do |tx|
86
+ EventBus.for(@store).transaction do |tx|
81
87
  tx.set(@keys.snapshot, json, ex: Keys::SNAPSHOT_TTL)
82
- tx.xadd(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
88
+ tx.append(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
83
89
  tx.hset(@keys.nodes, @node.name, JSON.generate(@node.wire(heartbeat_at: now)))
84
90
  end
91
+ nudge(snapshot)
85
92
  lost = @claims&.renew || []
86
93
  unless lost.empty?
87
94
  UI.log("claims lost to another machine: #{lost.join(", ")}; stopping their agents")
@@ -153,9 +160,42 @@ module OpenLanes
153
160
  claims: @claims,
154
161
  run_state: @run_state,
155
162
  meter: @meter,
163
+ # The URL the engine publishes through, not one read back from the
164
+ # environment: the page's status bar says which Redis the claims and
165
+ # run state really live in, and only the store that wrote them knows.
166
+ redis_url: @store.url,
167
+ outage: @outage,
156
168
  log:).to_h
157
169
  end
158
170
 
171
+ # Fields that move with the clock alone: the sequence number, the
172
+ # snapshot's time, this node's heartbeat, and how long each plan has
173
+ # been worked on. A car's `elapsed` is left in on purpose: while an
174
+ # agent runs, its seconds counter is what the page is there to show.
175
+ #
176
+ # @param snapshot [Hash]
177
+ # @return [Hash] what the page would see change
178
+ def content(snapshot)
179
+ snapshot.except(:seq, :at)
180
+ .merge(node: snapshot[:node].except(:heartbeat_at),
181
+ plans: Array(snapshot[:plans]).map { it.except(:elapsed) })
182
+ end
183
+
184
+ # Nudges the open pages ({News}) when this tick's snapshot says
185
+ # something the last one did not. The snapshot is still written every
186
+ # tick, because its expiry is how a node is known to be alive; only
187
+ # the nudge waits for news, so an engine with nothing to do is quiet.
188
+ #
189
+ # @param snapshot [Hash]
190
+ # @return [void]
191
+ def nudge(snapshot)
192
+ now = content(snapshot)
193
+ return if now == @published
194
+
195
+ News.publish(store: @store, keys: @keys, kind: "board")
196
+ @published = now
197
+ end
198
+
159
199
  # One more than the last published, even across an engine restart:
160
200
  # the API server reads a change of `seq` as news, and a restart that
161
201
  # counted from one again would look like the past.
@@ -50,7 +50,7 @@ module OpenLanes
50
50
  @state_file = dispatcher.state_file
51
51
  redis = read_redis
52
52
  file = @state_file&.live
53
- newer = [redis, file].compact.max_by { it["seq"].to_i }
53
+ newer = newest(redis, file)
54
54
  # A copy that is missing is behind, unless both are: then there is
55
55
  # nothing to keep until a person changes something.
56
56
  @in_redis = redis ? redis["seq"].to_i : (newer && -1) || 0
@@ -62,6 +62,37 @@ module OpenLanes
62
62
  ordinal, agent = key.split("/", 2)
63
63
  dispatcher.prefer_model(ordinal, agent, model)
64
64
  end
65
+ restore_concurrency(dispatcher)
66
+ end
67
+
68
+ # A `/concurrency` set before the restart holds after it (plan 020.00).
69
+ # One this run cannot take (a shared checkout runs one agent) is said
70
+ # once and dropped, rather than stopping the engine from starting.
71
+ #
72
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
73
+ # @return [void]
74
+ def restore_concurrency(dispatcher)
75
+ limit = @run_state.concurrency or return
76
+ dispatcher.concurrency = limit
77
+ rescue OpenLanes::Error => e
78
+ UI.log("concurrency #{limit} from before the restart not kept: #{e.message}")
79
+ end
80
+
81
+ # After Redis comes back: which copy is the newer, by the same `seq`
82
+ # rule as {#restore}. An RDB rollback leaves Redis behind or empty and
83
+ # the file wins. The engine's own state kept going through the outage,
84
+ # so nothing is restored from either; both marks are reset to what is
85
+ # really there, so the next {#flush} rewrites whichever copy is behind.
86
+ #
87
+ # @return [Symbol, nil] `:redis` or `:file`, or nil with neither copy
88
+ def reconcile
89
+ @warned = false
90
+ redis = read_redis
91
+ file = @state_file&.live
92
+ @in_redis = redis ? redis["seq"].to_i : -1
93
+ @in_file = file ? file["seq"].to_i : -1
94
+ newer = newest(redis, file) or return nil
95
+ newer.equal?(redis) ? :redis : :file
65
96
  end
66
97
 
67
98
  # Bring both copies up to the state's `seq`. The file is written by the
@@ -98,6 +129,13 @@ module OpenLanes
98
129
 
99
130
  private
100
131
 
132
+ # The copy with the higher `seq`; Redis on a tie, being listed first.
133
+ #
134
+ # @param redis [Hash, nil]
135
+ # @param file [Hash, nil]
136
+ # @return [Hash, nil]
137
+ def newest(redis, file) = [redis, file].compact.max_by { it["seq"].to_i }
138
+
101
139
  # @return [void]
102
140
  def mirror
103
141
  return unless @state_file && @in_file != @run_state.seq
@@ -9,7 +9,8 @@ module OpenLanes
9
9
  # Held in the engine, and kept by {RunRecord} in Redis and in
10
10
  # `.plans/openlanes-state.json` (W6), so an engine that restarts still
11
11
  # knows them. Every change moves {#seq} on by one, which is how the
12
- # record tells which of its two copies is newer. A model choice also
12
+ # record tells which of its two copies is newer. So does the concurrency
13
+ # a person set with `/concurrency` (plan 020.00). A model choice also
13
14
  # lives in the executor, which is what applies it; this copy is what the
14
15
  # page is shown and what a restart hands back to the executor.
15
16
  class RunState
@@ -18,9 +19,24 @@ module OpenLanes
18
19
  @held_questions = []
19
20
  @held_cars = []
20
21
  @models = {}
22
+ @concurrency = nil
21
23
  @seq = 0
22
24
  end
23
25
 
26
+ # @return [Integer, nil] the limit `/concurrency` set; nil when nobody did
27
+ def concurrency = @mutex.synchronize { @concurrency }
28
+
29
+ # @param limit [Integer]
30
+ # @return [void]
31
+ def concurrency=(limit)
32
+ @mutex.synchronize do
33
+ next if @concurrency == limit
34
+
35
+ @concurrency = limit
36
+ @seq += 1
37
+ end
38
+ end
39
+
24
40
  # @param id [String] `"<ordinal>/B<n>"`
25
41
  # @param held [Boolean]
26
42
  # @return [void]
@@ -67,7 +83,7 @@ module OpenLanes
67
83
  def to_h
68
84
  @mutex.synchronize do
69
85
  { "seq" => @seq, "held_questions" => @held_questions.dup, "held_cars" => @held_cars.dup,
70
- "models" => @models.dup }
86
+ "models" => @models.dup }.merge(@concurrency ? { "concurrency" => @concurrency } : {})
71
87
  end
72
88
  end
73
89
 
@@ -80,6 +96,7 @@ module OpenLanes
80
96
  @held_questions = Array(data["held_questions"]).map(&:to_s)
81
97
  @held_cars = Array(data["held_cars"]).map(&:to_s)
82
98
  @models = (data["models"].is_a?(Hash) ? data["models"] : {}).to_h { |k, v| [k.to_s, v.to_s] }
99
+ @concurrency = data["concurrency"].is_a?(Integer) && data["concurrency"].positive? ? data["concurrency"] : nil
83
100
  @seq = data["seq"].to_i
84
101
  end
85
102
  self
@@ -19,13 +19,16 @@ module OpenLanes
19
19
  # @param node [OpenLanes::Live::Node]
20
20
  # @param repo [String]
21
21
  # @param clock [Proc] wall time
22
+ # @param outage [OpenLanes::Live::Outage] what pauses the run while
23
+ # Redis is gone; a spec hands in one on its own clock
22
24
  def initialize(store:, root:, tree:, agents:, project: nil, node: Node.current(root:), repo: Repo.for(root),
23
- clock: -> { Time.now })
25
+ clock: -> { Time.now }, outage: Outage.new(store:, limit: Config.current.outage_limit, **Config.current.redis_backoff))
24
26
  @store = store
25
27
  @node = node
26
28
  @keys = Keys.new(repo:, node: node.name)
27
29
  @run_state = RunState.new
28
30
  @record = RunRecord.new(store:, keys: @keys, run_state: @run_state)
31
+ @outage = outage
29
32
  @claims = Claims.new(store:, keys: @keys)
30
33
  @publisher = Publisher.new(store:,
31
34
  keys: @keys,
@@ -69,20 +72,45 @@ module OpenLanes
69
72
  # @return [OpenLanes::Live::RunRecord]
70
73
  attr_reader :record
71
74
 
75
+ # @return [OpenLanes::Live::Outage]
76
+ attr_reader :outage
77
+
72
78
  # @return [Proc] the runner's `on_board:`
73
79
  def on_board = @publisher.to_proc
74
80
 
75
81
  # Wire the run in: the run state a previous engine left is taken back
76
- # on the first tick, commands are applied at the top of each tick, and
77
- # the log is heard from now on.
82
+ # on the first tick, commands are applied at the top of each tick, mail
83
+ # published to Redis is drained into the plans' `state.json`, and the
84
+ # log is heard from now on.
78
85
  #
79
86
  # @param dispatcher [OpenLanes::Engine::Dispatcher]
80
87
  # @return [void]
81
88
  def attach(dispatcher)
82
89
  @publisher.listen
83
90
  @publisher.on_lost { |ordinals| dispatcher.fence(ordinals) }
91
+ # First, so a tick that finds Redis gone pauses before anything else
92
+ # on it asks Redis for something (W32).
93
+ dispatcher.before_tick(&@outage)
84
94
  dispatcher.before_tick(&@record)
85
95
  dispatcher.before_tick(&@commands)
96
+ dispatcher.mail = Mail.new(store: @store, keys: @keys)
97
+ @outage.on_resume { recover(it) }
98
+ end
99
+
100
+ # Redis answers again: say how long it was gone and which copy of the
101
+ # run state won, and publish that as `run.outage`.
102
+ #
103
+ # @param window [OpenLanes::Live::Outage::Window]
104
+ # @return [OpenLanes::Live::OutageReport]
105
+ def recover(window)
106
+ report = OutageReport.new(store: @store,
107
+ keys: @keys,
108
+ node: @node,
109
+ window:,
110
+ reconciled_from: @record.reconcile)
111
+ UI.log(report.line)
112
+ @publisher.outage = report.to_h
113
+ report
86
114
  end
87
115
 
88
116
  # The run is over: the run state is written down, claims go back, and