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
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  require "socket"
4
6
 
5
7
  module OpenLanes
6
8
  module Engine
7
- # The loop. One tick a second: reap what finished and settle it against
8
- # the ledger, poll what is running for its latest line, start what is
9
+ # The loop. A tick every {TICK} seconds: reap what finished and settle it
10
+ # against the ledger, poll what is running for its latest line, start what is
9
11
  # dispatchable, heartbeat the state file, hand the screen a board.
10
12
  #
11
13
  # Nothing here guesses. An agent that finished says so in its document;
@@ -15,8 +17,8 @@ module OpenLanes
15
17
  class Dispatcher
16
18
  # One running invocation. `from` is the state the plan showed when the
17
19
  # agent was picked; `state` is the one it ran under, after any
18
- # `starts_as` rename. History is keyed by `state`: luke picked at ⭐️
19
- # works at 🟡, and keying by ⭐️ would offer him 🟡 afterwards as though
20
+ # `starts_as` rename. History is keyed by `state`: luke picked at ⭐
21
+ # works at 🟡, and keying by ⭐ would offer him 🟡 afterwards as though
20
22
  # he had never been there (the 020.00 regression, run twice over).
21
23
  Job = Struct.new(:key,
22
24
  :task,
@@ -34,7 +36,13 @@ module OpenLanes
34
36
  :state,
35
37
  :frame,
36
38
  :history,
37
- :context) do
39
+ :context,
40
+ :running,
41
+ :rebase,
42
+ :recorded_session,
43
+ :journal,
44
+ :journaled,
45
+ :journal_round) do
38
46
  # @return [Boolean]
39
47
  def finished? = !thread.alive?
40
48
  end
@@ -64,14 +72,29 @@ module OpenLanes
64
72
  # @return [OpenLanes::Plans::Subject]
65
73
  # @!attribute [r] refused
66
74
  # @return [Boolean]
67
- Published = Data.define(:attempt, :subject, :refused)
75
+ Published = Data.define(:attempt, :subject, :refused, :untestable) do
76
+ def initialize(untestable: false, **rest) = super
77
+ end
68
78
 
69
79
  # A blocked plan whose every question has an answer, being drained by
70
80
  # the agent that handles its state, on a thread of its own.
71
- Unblock = Struct.new(:ordinal, :thread, :outcome)
81
+ Unblock = Struct.new(:ordinal, :thread, :outcome, :agent, :rounds)
82
+
83
+ # Who the harness signs its own mail as.
84
+ HARNESS = "openlanes"
72
85
 
73
- # States a plan waits in for a person's answer.
74
- BLOCKED = %i[blocked product_blocked].freeze
86
+ # What a builder is told when its finished work has no way to be tested.
87
+ HOW_TO_TEST_MAIL = <<~MAIL.freeze
88
+ The build is done, but the pull request was not opened: plan.md has no
89
+ `#{Lifecycle::Completion::HOW_TO_TEST}` section, and a pull request nobody can test is not
90
+ ready for review. Add one to plan.md: the commands to run and what a person
91
+ should see, as a list, covering every part of the build and not only yours.
92
+ Change nothing else, then sign Completed again.
93
+ MAIL
94
+
95
+ # States a plan waits in for a person's answer. An answered 💣 is
96
+ # drained like an answered ⭕.
97
+ BLOCKED = %i[blocked product_blocked bomb].freeze
75
98
 
76
99
  # How long a settled row stays on the screen.
77
100
  LINGER = 10
@@ -79,11 +102,26 @@ module OpenLanes
79
102
  # Seconds a kill waits between STOP and SIGKILL.
80
103
  KILL_GRACE = 15
81
104
 
105
+ # Seconds between ticks. The spec promises the page sees an engine
106
+ # change within a second, and the board crosses two waits to get there:
107
+ # this one, then the event stream's wait for a nudge (or, without a
108
+ # subscription, its {Web::Events::POLL} of 0.25 s). At one second a
109
+ # tick, a rename just after one reached the page in up to 1.25 s; half
110
+ # a second leaves the worst case near 0.75 s.
111
+ TICK = 0.5
112
+
82
113
  # Seconds between rescans of the tree while a staying run has nothing
83
114
  # to do. A command from the page rescans at once; this interval is for
84
- # what arrives by other means: a folder edited by hand, `openlanes create`
85
- # typed in another terminal, a `blocked.md` answered in an editor.
86
- RESCAN = 5
115
+ # what arrives by other means: a folder edited by hand, `openlanes
116
+ # create` typed in another terminal, a `blocked.md` answered in an editor.
117
+ RESCAN_SECONDS = 5
118
+
119
+ # {RESCAN_SECONDS} as ticks, which is what the idle count compares.
120
+ RESCAN = (RESCAN_SECONDS / TICK).round
121
+
122
+ # @param seconds [Integer] an interval as a person states it
123
+ # @return [Integer] the same interval in ticks, at least one
124
+ def self.ticks(seconds) = [(seconds / TICK).round, 1].max
87
125
 
88
126
  # Plans with nothing left to do, which an idle staying run reports
89
127
  # reaching rather than ending on. Not `Status#terminal?`:
@@ -96,18 +134,37 @@ module OpenLanes
96
134
  # slop). Anyone else's note is only a note.
97
135
  REVIEW_PHASE = "review"
98
136
 
137
+ # Seconds between passes over the 🏁 plans' pull requests.
138
+ MERGE_POLL = 30
139
+
99
140
  # @param runner [OpenLanes::Engine::Runner]
100
141
  # @param state [OpenLanes::Engine::StateFile, nil] nil under a dry run
101
142
  # @param rounds [Integer, nil] `--rounds`, a cap that only tightens
102
- # @param sleeper [Proc] how a tick waits; the suite passes a no-op
143
+ # @param sleeper [Proc, nil] how a tick waits; the suite passes a no-op.
144
+ # Kept beside +clock+ until 045; nil waits on +clock+
145
+ # @param clock [#now, #sleep] the time source, {OpenLanes::Clock::System}
146
+ # unless a spec moves time itself
103
147
  # @param on_board [Proc, nil] receives a {Board} each tick
104
148
  # @param stay [Boolean] keep running past every fixed point, idle, until
105
149
  # a person stops the run
106
- # @param rescan [Integer] idle ticks, about a second each, between
150
+ # @param rescan [Integer] idle ticks, {TICK} seconds each, between
107
151
  # rescans of the tree while staying
108
- def initialize(runner:, state: nil, rounds: nil, sleeper: ->(seconds) { sleep(seconds) }, on_board: nil,
109
- stay: false, rescan: RESCAN)
152
+ # @param watch [OpenLanes::Engine::PrWatch, nil] the human-mode watch
153
+ # over open pull requests (M4); nil when nobody merges by hand or the
154
+ # run does not stay
155
+ # @param converter [#call, nil] rewrites prose questions into choices
156
+ # (D9); nil, as without OPENROUTER_API_KEY, leaves them as written
157
+ # @param journal [OpenLanes::Engine::EventJournal, nil] the progress
158
+ # journal (S9); nil keeps none
159
+ def initialize(runner:, state: nil, rounds: nil, sleeper: nil, clock: Clock::System, on_board: nil,
160
+ stay: false, rescan: RESCAN, watch: nil, converter: Live.choice_converter, extensions: nil, journal: nil)
110
161
  @runner = runner
162
+ @journal = journal
163
+ @journaled = {}
164
+ @extensions = extensions || Extensions.new(stay:)
165
+ @watch = watch
166
+ @watching = nil
167
+ @waiting_on = {}
111
168
  @stay = stay
112
169
  @rescan = rescan
113
170
  @idle = false
@@ -116,13 +173,16 @@ module OpenLanes
116
173
  @said = nil
117
174
  @state = state
118
175
  @rounds_cap = rounds
119
- @sleeper = sleeper
176
+ @clock = clock
177
+ @tick_lock = Monitor.new
178
+ @sleeper = sleeper || ->(seconds) { clock.sleep(seconds) }
120
179
  @on_board = on_board
121
180
  @running = []
122
181
  @recent = []
123
182
  @attempts = []
124
183
  @history = Hash.new { |h, k| h[k] = [] }
125
184
  @finished_plans = []
185
+ @asked_how_to_test = Set.new
126
186
  @preferred = {}
127
187
  @started_at = UI.monotonic
128
188
  @mutex = Mutex.new
@@ -132,14 +192,39 @@ module OpenLanes
132
192
  @fenced = []
133
193
  @unblocking = []
134
194
  @unblock_tried = {}
195
+ @unblock_failed = {}
196
+ @conversions = Conversions.new(converter:, runner:, find: method(:fresh_by))
197
+ # Agents outside openlanes (W31). A dry run claims nothing, so it
198
+ # asks nothing either.
199
+ @agent_locks = runner.dry_run? ? nil : Engine.agent_locks(runner.tree.dir, own: runner.agents.all.map(&:name))
200
+ @said_outside = {}
201
+ @refused = Set.new
202
+ @locks = PlanLocks.new(agent_locks: @agent_locks)
203
+ @verifying = {}
204
+ @said_verifying = {}
205
+ @said_merging = {}
206
+ @merges_polled = nil
207
+ @finished = carried_over
135
208
  end
136
209
 
137
210
  # @return [Array<OpenLanes::Engine::Dispatcher::Job>]
138
211
  attr_reader :running
139
212
 
213
+ # Mail over Redis, drained once a tick into the plans' `state.json`.
214
+ # {Live::Session#attach} sets it; a run without Redis has none, and
215
+ # loses nothing, because `mail send` wrote the file already.
216
+ #
217
+ # @return [OpenLanes::Live::Mail, nil]
218
+ attr_accessor :mail
219
+
140
220
  # @return [OpenLanes::Engine::Runner]
141
221
  attr_reader :runner
142
222
 
223
+ # Stopped rebases and how often each plan's has failed; see {Rebase}.
224
+ #
225
+ # @return [OpenLanes::Engine::Rebase]
226
+ def rebases = @rebases ||= Rebase.new(agents: @runner.agents, executor: @runner.executor)
227
+
143
228
  # @return [OpenLanes::Engine::StateFile, nil] nil under a dry run
144
229
  def state_file = @state
145
230
 
@@ -148,30 +233,45 @@ module OpenLanes
148
233
 
149
234
  # @return [Array<OpenLanes::Engine::Runner::Attempt>]
150
235
  def run
236
+ journal!(:run_started, jobs: @runner.jobs)
151
237
  resume_stranded
152
238
  loop do
153
239
  tick
154
240
  break if done?
155
241
 
156
- @sleeper.call(1)
242
+ @sleeper.call(TICK)
157
243
  end
244
+ journal!(:run_ended, reason: Execution::Control.quit? ? "quit" : "fixed point")
158
245
  @attempts
159
246
  end
160
247
 
161
248
  # One cycle. Public so the suite can drive it by hand.
162
249
  #
250
+ # One tick at a time: two that overlapped each read the plan as free
251
+ # and handed it to an agent apiece (the stress spec saw one plan
252
+ # started eight times by eight ticks). A `Monitor`, so a hook that
253
+ # ticks from inside a tick does not deadlock on itself.
254
+ #
163
255
  # @return [void]
164
256
  def tick
165
- @before_tick.each { |hook| hook.call(self) }
166
- reap
167
- finish_discards
168
- unblock_answered
169
- poll
170
- # On threads: a kill waits out TERM before KILL, and the board must not.
171
- @running.each { |job| Thread.new { job.handle.kill!(grace: 0, reason: :quit) } } if Execution::Control.overdue?
172
- dispatch unless Execution::Control.quit? || resting?
173
- persist
174
- @on_board&.call(board)
257
+ @tick_lock.synchronize do
258
+ @before_tick.each { |hook| hook.call(self) }
259
+ reap
260
+ finish_discards
261
+ unblock_answered
262
+ convert_questions
263
+ poll
264
+ ask_for_time
265
+ drain_mail
266
+ watch_pull_requests
267
+ settle_verifications
268
+ settle_merges
269
+ # On threads: a kill waits out TERM before KILL, and the board must not.
270
+ @running.each { |job| Thread.new { job.handle.kill!(grace: 0, reason: :quit) } } if Execution::Control.overdue?
271
+ dispatch unless Execution::Control.quit? || resting?
272
+ persist
273
+ @on_board&.call(board)
274
+ end
175
275
  end
176
276
 
177
277
  # A run ends at a fixed point: nothing running and nothing left to
@@ -184,8 +284,13 @@ module OpenLanes
184
284
  # @return [Boolean]
185
285
  def done?
186
286
  return false unless quiet?
287
+ # A quit does not wait for a look: it only reads, and the coverage
288
+ # run under it is a child process killed at exit.
187
289
  return true if Execution::Control.quit?
188
290
  return false if @stay
291
+ # Nor does an ending run return in the middle of one: what it finds
292
+ # is acted on, on the next tick, before the run reports.
293
+ return false if @runner.verification&.busy?
189
294
 
190
295
  next_dispatchable.nil?
191
296
  end
@@ -215,7 +320,24 @@ module OpenLanes
215
320
  # @param seconds [Integer]
216
321
  # @return [void]
217
322
  def extend(key, seconds)
218
- @running.find { |j| j.key == key }&.handle&.extend!(seconds)
323
+ job = @running.find { |j| j.key == key } or return
324
+ job.handle&.extend!(seconds)
325
+ journal!(:extension, plan: job.task.subject.feature.ordinal.to_s, agent: job.task.agent.name, seconds:)
326
+ end
327
+
328
+ # A person's answer to an `X<n>` request (E1). A time choice reaches
329
+ # the agent's clock while that agent still runs on the plan; otherwise
330
+ # the answer is one more round.
331
+ #
332
+ # @param subject [OpenLanes::Plans::Subject]
333
+ # @param number [Integer]
334
+ # @param letter [String]
335
+ # @param by [String]
336
+ # @return [Hash, nil] the request as settled, nil when it already was
337
+ def answer_extension(subject, number, letter, by:)
338
+ request = Plans::PlanState.for(subject.feature.path).extensions.find { it["number"] == number }
339
+ live = request && job("#{subject.feature.ordinal}/#{request["agent"]}")&.handle
340
+ @extensions.answer!(subject.feature.path, number, letter, by:, live:).tap { wake! }
219
341
  end
220
342
 
221
343
  # Something to run at the top of every tick, on the loop thread, where
@@ -271,12 +393,16 @@ module OpenLanes
271
393
  #
272
394
  # @param job [OpenLanes::Engine::Dispatcher::Job]
273
395
  # @return [OpenLanes::Adapters::Base]
274
- def adapter_for(job)
396
+ def adapter_for(job) = adapter_of(job.task.agent, job.task.subject)
397
+
398
+ # @param agent [OpenLanes::Agents::Agent]
399
+ # @param subject [OpenLanes::Plans::Subject]
400
+ # @return [OpenLanes::Adapters::Base]
401
+ def adapter_of(agent, subject)
275
402
  executor = @runner.executor
276
- agent = job.task.agent
277
403
  return Adapters.for(agent.adapter) unless executor.respond_to?(:profile_for)
278
404
 
279
- executor.profile_for(agent, job.task.subject).adapter
405
+ executor.profile_for(agent, subject).adapter
280
406
  end
281
407
 
282
408
  # Hand back the round an interrupted agent lost, so a person's "resume"
@@ -349,12 +475,66 @@ module OpenLanes
349
475
  @fenced |= [ordinal]
350
476
  @runner.drop_plan(ordinal)
351
477
  @running.select { plan_of(it) == ordinal }.each do |job|
352
- UI.log("claim lost; stopping", **job.task.log_fields)
478
+ UI.log("claim lost; stopping", level: :warn, **job.task.log_fields)
353
479
  Thread.new { job.handle.kill!(grace: KILL_GRACE, reason: :fenced) }
354
480
  end
355
481
  end
356
482
  end
357
483
 
484
+ # The copilot's `/stop NNN`: every agent on the plan is told STOP, and
485
+ # the plan leaves this run so the next tick does not start it again.
486
+ # Its claim is let go, so the plan is free for any machine.
487
+ #
488
+ # @param ordinal [#to_s]
489
+ # @return [Array<String>] the keys told STOP
490
+ # @raise [OpenLanes::Error] when the plan is neither running nor in scope
491
+ def stop_plan(ordinal)
492
+ ordinal = ordinal.to_s
493
+ keys = @running.select { plan_of(it) == ordinal }.map(&:key)
494
+ if keys.empty? && !in_run?(ordinal)
495
+ raise OpenLanes::Error, "#{ordinal} is not on this run's board: no agent is working it and it is not in scope"
496
+ end
497
+
498
+ told = keys.select { signal(it, Execution::Control::STOP) }
499
+ @runner.drop_plan(ordinal)
500
+ @runner.release(ordinal)
501
+ told
502
+ end
503
+
504
+ # The copilot's `/restart NNN`, for a lane that lost its agent (a Redis
505
+ # glitch, a killed process): the plan is put back in scope, the rounds
506
+ # it used up here are forgotten, and the next tick dispatches it. A
507
+ # plan with an agent on it already is left alone.
508
+ #
509
+ # @param ordinal [#to_s]
510
+ # @return [void]
511
+ # @raise [OpenLanes::Error] when an agent is working it, or it is not on
512
+ # this node's board
513
+ def restart_plan(ordinal)
514
+ ordinal = ordinal.to_s
515
+ working = @running.select { plan_of(it) == ordinal }.map { it.task.agent.name }
516
+ raise OpenLanes::Error, "#{ordinal} already has an agent: #{working.join(", ")}" unless working.empty?
517
+
518
+ subject = fresh_by(Plans::Ordinal.parse(ordinal))
519
+ unless subject && (in_run?(ordinal) || @runner.dropped?(ordinal)) && !@runner.foreign?(subject)
520
+ raise OpenLanes::Error, "#{ordinal} is not on this node's board"
521
+ end
522
+
523
+ @runner.add_plan(subject.feature.ordinal)
524
+ @mutex.synchronize { @history.delete_if { |(plan, _), _| plan == ordinal } }
525
+ @finished_plans.delete(ordinal)
526
+ wake!
527
+ end
528
+
529
+ # The copilot's `/concurrency N`. Never signals a running agent.
530
+ #
531
+ # @param count [Integer]
532
+ # @return [void]
533
+ # @raise [OpenLanes::Error] as {Runner#jobs=} does
534
+ def concurrency=(count)
535
+ @runner.jobs = count
536
+ end
537
+
358
538
  # @return [OpenLanes::Engine::Board]
359
539
  def board
360
540
  up = @attempts.sum(&:up) + @running.sum { |j| j.up.to_i }
@@ -362,14 +542,16 @@ module OpenLanes
362
542
  Board.new(started_at: @started_at,
363
543
  status: run_status,
364
544
  plans: (@running.map { |j| j.task.subject.feature.ordinal.to_s } + @recent.map { |r| r[:row].ordinal } +
365
- waiting).uniq,
545
+ waiting + @mutex.synchronize { @finished.keys }).uniq,
366
546
  up:,
367
547
  down:,
368
548
  rows: rows,
369
549
  root: @runner.root,
370
550
  running: @running.size,
371
551
  live_up: @running.sum { |j| j.up.to_i },
372
- live_down: @running.sum { |j| j.down.to_i })
552
+ live_down: @running.sum { |j| j.down.to_i },
553
+ waiting_on: @waiting_on.dup,
554
+ jobs: @runner.jobs)
373
555
  end
374
556
 
375
557
  private
@@ -394,13 +576,155 @@ module OpenLanes
394
576
  # @return [String] the plan's ordinal
395
577
  def plan_of(job) = job.task.subject.feature.ordinal.to_s
396
578
 
579
+ # @param ordinal [String]
580
+ # @return [Boolean] whether the plan is in this run's scope now
581
+ def in_run?(ordinal)
582
+ subject = fresh_by(Plans::Ordinal.parse(ordinal))
583
+ !subject.nil? && @runner.in_scope?(subject)
584
+ end
585
+
397
586
  # Plans no agent may be started on and no rename may touch: one being
398
- # discarded or drained, and one another machine holds (W26).
587
+ # discarded or drained, one another machine holds (W26), and one whose
588
+ # `locked-by.pid` another run wrote.
399
589
  #
400
590
  # @return [Array<String>]
401
591
  def untouchable
402
- busy = @running.map { plan_of(it) } + @discarding.keys + @unblocking.map(&:ordinal)
403
- busy + @runner.tree.subjects.select { @runner.foreign?(it) }.map { it.feature.ordinal.to_s }
592
+ busy = @running.map { plan_of(it) } + @discarding.keys + @unblocking.map(&:ordinal) + verifying_now
593
+ busy + @runner.tree.subjects.select { @runner.foreign?(it) || locked_elsewhere?(it) || held_outside?(it) }
594
+ .map { it.feature.ordinal.to_s }
595
+ end
596
+
597
+ # Plans whose CI and coverage are being looked at off the loop's
598
+ # thread. Untouchable until the look is acted on: a builder started
599
+ # in the checkout the suite is running in, or a person's rejection
600
+ # renaming the folder under it, would leave the look acting on a plan
601
+ # that is no longer the one it read.
602
+ #
603
+ # @return [Array<String>]
604
+ def verifying_now = @runner.verification&.looking || []
605
+
606
+ # The Redis claim covers machines that share one; the lock file covers
607
+ # a second run on this machine, or one that never reached Redis.
608
+ # Without this check such a run renamed the folder at start, wrote its
609
+ # own lock over the holder's, and deleted it when its agent finished.
610
+ # This run's own lock is not a reason to stay out: a pair shares it.
611
+ #
612
+ # @param subject [OpenLanes::Plans::Subject]
613
+ # @return [Boolean]
614
+ def locked_elsewhere?(subject) = Plans::Lock.read(subject.feature.path)&.held_elsewhere? || false
615
+
616
+ # An agent outside openlanes holds the plan's folder or its worktree in
617
+ # alock (W31). Said once per holder, not on every tick it stays held.
618
+ #
619
+ # @param subject [OpenLanes::Plans::Subject]
620
+ # @return [Boolean]
621
+ def held_outside?(subject)
622
+ held = @agent_locks&.held_elsewhere(subject.feature.path, checkout: @runner.checkout_path(subject))
623
+ ordinal = subject.feature.ordinal.to_s
624
+ if held && @said_outside[ordinal] != held
625
+ UI.log("held outside openlanes by #{held}; stepping around it", plan: ordinal)
626
+ end
627
+ @said_outside[ordinal] = held
628
+ !held.nil?
629
+ end
630
+
631
+ # ---- pull requests a person owes (M4) ---------------------------------
632
+
633
+ # The `gh` call runs on a thread of its own, so a slow GitHub never
634
+ # stretches the one-second tick; its outcomes are applied on the first
635
+ # tick after it returns, here, where every other rename happens.
636
+ #
637
+ # @return [void]
638
+ def watch_pull_requests
639
+ return unless @watch
640
+
641
+ if @watching && !@watching.alive?
642
+ settle_watch(@watching.value)
643
+ @watching = nil
644
+ end
645
+ return if @watching || !@watch.due?
646
+
647
+ subjects = @runner.in_scope.reject { untouchable.include?(it.feature.ordinal.to_s) }
648
+ @watching = Thread.new do
649
+ @watch.poll(subjects)
650
+ # StandardError, not only OpenLanes::Error: `#value` re-raises whatever
651
+ # ended the thread, so a stray JSON::ParserError or Errno from `gh`
652
+ # would otherwise end the whole run on the next tick.
653
+ rescue StandardError => e
654
+ UI.log("pull requests unwatched this time: #{e.message.lines.first.to_s.strip}", level: :warn)
655
+ nil
656
+ end
657
+ end
658
+
659
+ # @param outcomes [Array<OpenLanes::Engine::PrWatch::Outcome>, nil] nil when the poll failed
660
+ # @return [void]
661
+ def settle_watch(outcomes)
662
+ return if outcomes.nil?
663
+
664
+ @waiting_on = outcomes.select { it.kind == :waiting }.group_by(&:plan).transform_values { it.map(&:pr) }
665
+ # Every agent defined, not this run's roster: the mail waits in the
666
+ # plan folder for whichever run next starts the builders, and a run
667
+ # typed with `--agent hansolo-reviewer` must not lose it.
668
+ effects = PrOutcomes.new(tree_dir: @runner.tree.dir, agents: Agents::Registry.new(dir: @runner.agents.dir))
669
+ outcomes.each do |outcome|
670
+ next if outcome.kind == :waiting || untouchable.include?(outcome.plan)
671
+
672
+ applied = effects.apply(outcome) or next
673
+ @finished_plans.delete(applied.plan) if applied.to
674
+ keep_finished(applied.plan)
675
+ UI.log("#{applied.plan}: #{applied.note}#{" → #{Plans::STATUS_BY_KEY.fetch(applied.to)}" if applied.to}")
676
+ wake!
677
+ end
678
+ @state&.waiting_on = @waiting_on
679
+ end
680
+
681
+ # ---- finished plans ---------------------------------------------------
682
+
683
+ # The one plan that finished last before this engine started, so a
684
+ # restarted board is not empty of what it just did, and not crowded
685
+ # with everything it ever did either.
686
+ #
687
+ # @return [Hash{String => OpenLanes::Plans::PlanState::Finished}]
688
+ def carried_over
689
+ latest = @runner.tree.subjects.reject { @runner.foreign?(it) }.filter_map { |subject|
690
+ finished = Plans::PlanState.for(subject.feature.path).finished
691
+ [subject.feature.ordinal.to_s, finished] if finished
692
+ }.max_by { |_, finished| finished.at }
693
+ latest ? [latest].to_h : {}
694
+ end
695
+
696
+ # Record an approval or a merge, and keep the plan on the board for the
697
+ # rest of the run. A dry run writes nothing, but still shows it.
698
+ #
699
+ # @param subject [OpenLanes::Plans::Subject]
700
+ # @param kind [String] one of {Plans::PlanState::FINISH_KINDS}
701
+ # @return [void]
702
+ def finish(subject, kind)
703
+ at = @clock.now
704
+ kept = @runner.dry_run? ? Plans::PlanState::Finished.new(kind:, at:) : Plans::PlanState.for(subject.feature.path).finish!(kind, at:)
705
+ @mutex.synchronize { @finished[subject.feature.ordinal.to_s] = kept }
706
+ end
707
+
708
+ # Read back what {PrOutcomes} wrote: a merge joins the board's finished
709
+ # plans, a rejection leaves them. Read rather than told, so the watch's
710
+ # one record of it is the only one.
711
+ #
712
+ # @param ordinal [String]
713
+ # @return [void]
714
+ def keep_finished(ordinal)
715
+ subject = fresh_by(Plans::Ordinal.parse(ordinal)) or return
716
+ finished = Plans::PlanState.for(subject.feature.path).finished
717
+ @mutex.synchronize { finished ? @finished[ordinal] = finished : @finished.delete(ordinal) }
718
+ end
719
+
720
+ # An agent is starting on the plan, so it is not finished any more: the
721
+ # board would otherwise show an approved row over a rebuild.
722
+ #
723
+ # @param subject [OpenLanes::Plans::Subject]
724
+ # @return [void]
725
+ def unfinish(subject)
726
+ @mutex.synchronize { @finished.delete(subject.feature.ordinal.to_s) }
727
+ Plans::PlanState.for(subject.feature.path).unfinish! unless @runner.dry_run?
404
728
  end
405
729
 
406
730
  # ---- discarding -------------------------------------------------------
@@ -439,13 +763,14 @@ module OpenLanes
439
763
  # that handles its state, the way `openlanes unblock` hands it over
440
764
  # (W10): the page's answer is what a person typing that command used
441
765
  # to be. Each answer set is tried once; a failed drain waits for
442
- # `blocked.md` to change. Drains finished since the last tick are
766
+ # `blocked.md` to change. An answer the broker would not take goes back
767
+ # to the page ({#reopen}). Drains finished since the last tick are
443
768
  # settled first, so the plan is picked up again in this run.
444
769
  #
445
770
  # @return [void]
446
771
  def unblock_answered
447
772
  @unblocking.reject { it.thread.alive? }.each { settle_unblock(it) }
448
- return if @runner.dry_run? || Execution::Control.quit?
773
+ return if @runner.dry_run? || Execution::Control.quit? || Execution::Control.paused?
449
774
 
450
775
  blocked = @runner.in_scope.select { BLOCKED.include?(it.status.key) }
451
776
  return if blocked.empty?
@@ -467,10 +792,20 @@ module OpenLanes
467
792
  next unless @runner.claim(subject)
468
793
 
469
794
  @unblock_tried[ordinal] = answers
795
+ @unblock_failed.delete(ordinal)
470
796
  start_unblock(agent, subject)
471
797
  end
472
798
  end
473
799
 
800
+ # Prose questions go to the model off the tick thread; what it wrote
801
+ # comes back on a later tick (D9). A quitting or dry run applies what
802
+ # already came back and sends nothing new.
803
+ #
804
+ # @return [void]
805
+ def convert_questions
806
+ @conversions.call(busy: untouchable, start: !(@runner.dry_run? || Execution::Control.quit?))
807
+ end
808
+
474
809
  # @param agent [OpenLanes::Agents::Agent]
475
810
  # @param subject [OpenLanes::Plans::Subject]
476
811
  # @return [void]
@@ -480,7 +815,9 @@ module OpenLanes
480
815
  executor: @runner.executor,
481
816
  commit: true)
482
817
  UI.log("unblocking: every question answered", plan: subject.feature.ordinal.to_s, agent: agent.name)
483
- unblock = Unblock.new(ordinal: subject.feature.ordinal.to_s)
818
+ unblock = Unblock.new(ordinal: subject.feature.ordinal.to_s,
819
+ agent: agent.name,
820
+ rounds: Plans::PlanState.for(subject.feature.path).journal(agent.name).size)
484
821
  unblock.thread = Thread.new do
485
822
  Thread.current.report_on_exception = false
486
823
  unblock.outcome = begin
@@ -502,9 +839,12 @@ module OpenLanes
502
839
  outcome = unblock.outcome
503
840
  if outcome.is_a?(Exception) || !outcome.ok
504
841
  note = outcome.is_a?(Exception) ? outcome.message : outcome.note
505
- return UI.log("unblock failed: #{note}", plan: unblock.ordinal)
842
+ @unblock_failed[unblock.ordinal] = note
843
+ return UI.log("unblock failed: #{note}", level: :warn, plan: unblock.ordinal)
506
844
  end
507
845
 
846
+ @unblock_failed.delete(unblock.ordinal)
847
+ reopen(unblock, outcome) unless outcome.waiting.empty?
508
848
  @mutex.synchronize do
509
849
  @history.each { |(ordinal, _), entries| entries.reject! { it[:status] == "Blocked" } if ordinal == unblock.ordinal }
510
850
  end
@@ -514,13 +854,53 @@ module OpenLanes
514
854
  plan: unblock.ordinal)
515
855
  end
516
856
 
857
+ # A broker that ran cleanly and left an answer under its question did
858
+ # not accept it: it asks for a), b) or c) and was told something else.
859
+ # Left there, the page counts the question answered and hides it, and
860
+ # the dispatcher never retries an answer set it already tried, so the
861
+ # plan waits on a person who is never asked. The answer comes out and
862
+ # the question goes back on the page, with what was said and why the
863
+ # broker would not take it.
864
+ #
865
+ # @param unblock [OpenLanes::Engine::Dispatcher::Unblock]
866
+ # @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
867
+ # @return [void]
868
+ def reopen(unblock, outcome)
869
+ path = File.join(outcome.subject.feature.path, "blocked.md")
870
+ text = File.read(path)
871
+ answers = Plans::BlockedFile.answers(text)
872
+ why = refusal(unblock, outcome.subject)
873
+ outcome.waiting.each do |question|
874
+ said = Plans::Journal.clip(answers[question.number].to_s.gsub(/<!--.*?-->|^Answered by .*$/, "").split.join(" "))
875
+ text = Plans::BlockedFile.reject(text, question.number, "> **Answer not accepted:** #{said}\n>\n> #{why}") || text
876
+ UI.log("answer to B#{question.number} not accepted: #{why}", level: :warn, plan: unblock.ordinal)
877
+ end
878
+ File.write(path, text)
879
+ rescue SystemCallError => e
880
+ UI.log("could not reopen the unaccepted answers: #{e.message}", level: :warn, plan: unblock.ordinal)
881
+ end
882
+
883
+ # What the broker wrote in its journal during this drain, last line
884
+ # first, since that is where it says what it did with the answers.
885
+ #
886
+ # @param unblock [OpenLanes::Engine::Dispatcher::Unblock]
887
+ # @param subject [OpenLanes::Plans::Subject]
888
+ # @return [String]
889
+ def refusal(unblock, subject)
890
+ rounds = Plans::PlanState.for(subject.feature.path).journal(unblock.agent).drop(unblock.rounds.to_i)
891
+ said = rounds.flat_map(&:entries).last&.text
892
+ said ? "#{unblock.agent}: #{Plans::Journal.clip(said)}" : "#{unblock.agent} left it unfolded and said nothing"
893
+ rescue OpenLanes::Error, JSON::ParserError
894
+ "#{unblock.agent} left it unfolded"
895
+ end
896
+
517
897
  # @param job [OpenLanes::Engine::Dispatcher::Job]
518
898
  # @return [OpenLanes::Engine::Board::Row]
519
899
  def row_for(job)
520
900
  task = job.task
521
901
  job.frame = job.frame.to_i + 1
522
902
  Board::Row.new(key: job.key,
523
- at: Time.now,
903
+ at: @clock.now,
524
904
  ordinal: task.subject.feature.ordinal.to_s,
525
905
  file: job.file || task.agent.ledger.first.to_s,
526
906
  agent: task.agent.name,
@@ -541,7 +921,9 @@ module OpenLanes
541
921
  history: job.history || [],
542
922
  context: job.context,
543
923
  since: job.started_at,
544
- session: job.handle.session)
924
+ session: job.handle.session,
925
+ unacked: Board::Row.unacked_in(task.subject.feature.path),
926
+ running: job.running || [])
545
927
  end
546
928
 
547
929
  # @param task [OpenLanes::Engine::Runner::Task]
@@ -574,13 +956,18 @@ module OpenLanes
574
956
 
575
957
  # ---- starting ---------------------------------------------------------
576
958
 
577
- # Started stages a dead run left behind get the harness's own line and a
578
- # place in history, so eligibility treats them as an Interrupted round.
959
+ # Started stages a dead run left behind get the harness's own line, and
960
+ # the agent starts again from its resume note without spending a round:
961
+ # the harness died, not the agent. Asking a person to keep or discard
962
+ # the work got the same answer every time, so nobody is asked.
579
963
  #
580
964
  # Two records can say a stage was stranded: the run's own state file,
581
- # and the plan's `state.json`. The second is what survives a run whose
582
- # state file was lost, or a plan carried to another checkout. A stage
583
- # either names is resumed once.
965
+ # which holds the pid and host of every stage it started, and the
966
+ # plan's `state.json`, whose stages carry a `run` only when an older
967
+ # release wrote them. The pid and host are about one machine, so new
968
+ # stages no longer put them in a committed file; the second check stays
969
+ # so a crash under the old layout still resumes. A stage either names is
970
+ # resumed once.
584
971
  #
585
972
  # @return [void]
586
973
  def resume_stranded
@@ -613,22 +1000,40 @@ module OpenLanes
613
1000
  def interrupt_stranded(subject, name, round)
614
1001
  agent = @runner.agents.find(name) or return nil
615
1002
  ordinal = subject.feature.ordinal.to_s
1003
+ harness_resume(subject, agent, round, "harness died")
616
1004
  write_interrupted(subject, agent, round, "harness died")
617
- remember(ordinal, agent.name, subject.status.key, "Interrupted", round)
1005
+ Plans::Mailbox.new(dir: subject.feature.path).append(from: "openlanes",
1006
+ to: agent.name,
1007
+ body: "RESUME: the harness died during your round #{round}; carry on from your last resume note")
618
1008
  @state&.record(ordinal, agent: agent.name, round:, status: "Interrupted", exit: "harness died")
619
- stage!(subject, agent, round, status: "Interrupted", note: "harness died", finished_at: Time.now.iso8601)
1009
+ stage!(subject, agent, round, status: "Interrupted", note: "harness died", finished_at: @clock.now.iso8601)
620
1010
  [ordinal, agent.name, round]
621
1011
  end
622
1012
 
623
1013
  # @return [void]
624
1014
  def dispatch
1015
+ ask_for_rounds
1016
+ @refused.clear
625
1017
  started = 0
626
1018
  while @running.size < @runner.jobs && (pair = next_dispatchable)
627
- # Lost to another machine between the check and the claim: the next
628
- # pass sees the plan as foreign and steps around it.
629
- next unless @runner.claim(pair.last)
1019
+ # Lost to another machine between the check and the claim, or Redis
1020
+ # cannot say who holds it (W31). Either way the rest of this pass goes
1021
+ # on without the plan: with Redis gone `foreign?` answers false, so
1022
+ # without remembering the refusal `next_dispatchable` hands the same
1023
+ # plan back and the tick never returns.
1024
+ unless @runner.claim(pair.last)
1025
+ @refused << pair.last.feature.ordinal.to_s
1026
+ next
1027
+ end
1028
+
1029
+ started_now = start(*pair)
1030
+ # Lost to an agent outside openlanes (W31): the rest of this pass
1031
+ # goes on without that plan, and the next listing shows its holder.
1032
+ next if started_now.nil?
1033
+ # Lost the lock file to another run on this machine: the next tick
1034
+ # reads its lock and steps around the plan.
1035
+ break unless started_now
630
1036
 
631
- start(*pair)
632
1037
  started += 1
633
1038
  end
634
1039
  settle_idle(started) if @stay
@@ -643,7 +1048,7 @@ module OpenLanes
643
1048
 
644
1049
  # Whether an idle staying run skips this tick's dispatch. Dispatching
645
1050
  # reloads the tree and runs `resync dirs` over it, which is cheap once
646
- # but not once a second for hours with nothing to find, so an idle run
1051
+ # but not twice a second for hours with nothing to find, so an idle run
647
1052
  # looks again every {RESCAN} ticks, or at once when woken. Commands,
648
1053
  # drains and the board still run every tick; only the scan waits.
649
1054
  #
@@ -676,15 +1081,32 @@ module OpenLanes
676
1081
  # @param subjects [Array<OpenLanes::Plans::Subject>] the plans in scope
677
1082
  # @return [String] what an idle run is waiting for
678
1083
  def idle_reason(subjects)
679
- return "idle: no plans yet; create one from the page or with openlanes create" if subjects.empty?
1084
+ # Finished plans move into archive/ (N3), so an empty top level after
1085
+ # a run that shipped everything is "finished", not "no plans yet".
1086
+ if subjects.empty? && @runner.tree.everything.reload.subjects.empty?
1087
+ return "idle: no plans yet; create one from the page or with openlanes create"
1088
+ end
680
1089
  if subjects.all? { FINISHED.include?(it.status.key) }
681
1090
  return "idle: every plan is finished; create the next one from the page"
682
1091
  end
683
1092
 
684
- blocked = waiting
685
- return "idle: waiting for answers on #{blocked.join(", ")}" unless blocked.empty?
686
-
687
- "idle: nothing to do"
1093
+ bombs = @runner.halted_by.map { it.feature.ordinal.to_s }
1094
+ return "idle: halted by 💣 #{bombs.join(", ")}; answer, or resume anyway from the page" unless bombs.empty?
1095
+
1096
+ # Every reason, not the first: a run idle on answers and on a held
1097
+ # plan named only the answers, and the held plan looked forgotten.
1098
+ reasons = []
1099
+ failed = waiting.select { @unblock_failed.key?(it) }
1100
+ blocked = waiting - failed
1101
+ reasons << "waiting for answers on #{blocked.join(", ")}" unless blocked.empty?
1102
+ unless failed.empty?
1103
+ reasons << "unblock failed on #{failed.map { "#{it} (#{@unblock_failed[it]})" }.join(", ")}; " \
1104
+ "edit blocked.md to try again"
1105
+ end
1106
+ reasons.concat(@verifying.map { |plan, note| "#{plan} #{note}" })
1107
+ held = @runner.held(@runner.dependencies(except: untouchable))
1108
+ reasons << "held by dependencies: #{held.map { |o, why| "#{o} #{why}" }.join("; ")}" unless held.empty?
1109
+ reasons.empty? ? "idle: nothing to do" : "idle: #{reasons.join("; ")}"
688
1110
  end
689
1111
 
690
1112
  # Blocked plans in scope that this machine may answer for: on the board
@@ -702,15 +1124,37 @@ module OpenLanes
702
1124
  # @return [Array(OpenLanes::Agents::Agent, OpenLanes::Plans::Subject), nil]
703
1125
  def next_dispatchable
704
1126
  reconcile
705
- @runner.in_scope.each do |subject|
1127
+ # A 💣 stops new work, not work under way: agents already running
1128
+ # finish, and their plans settle, while nothing else starts.
1129
+ return nil if @runner.halted?
1130
+ # Redis lost (W32): the agents under way are paused, and nobody new
1131
+ # starts until it answers, since no claim could be checked.
1132
+ return nil if Execution::Control.paused?
1133
+
1134
+ @dependencies = @runner.dependencies(except: untouchable)
1135
+ blockers_first(@runner.in_scope).each do |subject|
1136
+ working = @running.select { plan_of(it) == subject.feature.ordinal.to_s }.map { it.task.agent }
706
1137
  # After {#reconcile} this is the folder's name. On a dry run, which
707
- # renames nothing, it is what the name would have become.
708
- state = Lifecycle::Lanes.target(subject) || Lifecycle::Resync::Dirs.target(subject)
1138
+ # renames nothing, it is what the name would have become. A plan an
1139
+ # agent is in right now is never reconciled, so its contents are
1140
+ # not asked: see {#partners_only}.
1141
+ state = working.empty? ? @runner.effective_status(subject) : subject.status
709
1142
  next if Plans::StateMachine::SETTLED.include?(state.key)
710
1143
  next if @finished_plans.include?(subject.feature.ordinal.to_s)
1144
+ # Verified and waiting on CI: the engine's to settle, not chewie's.
1145
+ next if @runner.verification&.awaiting_ci?(subject)
1146
+ # palpatine ruled on this visit to 🏁: the merges are the engine's.
1147
+ next if @runner.merging&.assessed?(subject)
711
1148
  next if @runner.foreign?(subject) || @discarding.key?(subject.feature.ordinal.to_s)
712
-
713
- candidates = Agents::Routing.filter(@runner.agents.for_status(state), subject)
1149
+ next if held?(subject)
1150
+ next if locked_elsewhere?(subject)
1151
+ next if held_outside?(subject) || @refused.include?(subject.feature.ordinal.to_s)
1152
+ # A stopped rebase holds the checkout until the harness has
1153
+ # continued or aborted it; a partner started now would build on
1154
+ # conflict markers.
1155
+ next if @running.any? { plan_of(it) == subject.feature.ordinal.to_s && it.rebase }
1156
+
1157
+ candidates = partners_only(Agents::Routing.filter(@runner.agents.for_status(state), subject), working, state)
714
1158
  preferred = @preferred[subject.feature.ordinal.to_s]
715
1159
  candidates = candidates.sort_by { |a| a.name == preferred ? 0 : 1 } if preferred
716
1160
  agent = candidates.find { |a| eligible?(a, subject) }
@@ -719,21 +1163,66 @@ module OpenLanes
719
1163
  nil
720
1164
  end
721
1165
 
1166
+ # The dependency gate: a plan whose `depends_on:` names anything not yet
1167
+ # ✅, or which sits on a cycle, is not started this tick. Kept out of
1168
+ # {#next_dispatchable} so the rule can be read, and changed, alone.
1169
+ #
1170
+ # @param subject [OpenLanes::Plans::Subject]
1171
+ # @return [Boolean]
1172
+ def held?(subject) = !@dependencies.reason(subject.feature.ordinal).nil?
1173
+
1174
+ # Plans that others wait for go first, so the work that unblocks the
1175
+ # most is started soonest. Stable: plans nobody waits for keep the
1176
+ # tree's order among themselves, and so do the blockers.
1177
+ #
1178
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
1179
+ # @return [Array<OpenLanes::Plans::Subject>]
1180
+ def blockers_first(subjects)
1181
+ deps = @dependencies || @runner.dependencies
1182
+ subjects.each_with_index.sort_by { |s, i| [deps.blocks_of(s.feature.ordinal).empty? ? 1 : 0, i] }.map(&:first)
1183
+ end
1184
+
1185
+ # One agent at a time on a plan, except a pair working the same state.
1186
+ #
1187
+ # A plan with an agent in it is judged by its folder's name, never by
1188
+ # what its contents would justify. Asking the contents is how the
1189
+ # minesweeper trial ran its whole chain inside ⚪ New: leah wrote her
1190
+ # `## Research` chapter, the next tick saw a Researched plan and started
1191
+ # yoda beside her, and leah's own Completed then found a partner running
1192
+ # and held the folder where it was. Every later agent repeated it, so
1193
+ # the folder never moved and luke and rey were building on a plan whose
1194
+ # name still said nobody had researched it.
1195
+ #
1196
+ # @param candidates [Array<OpenLanes::Agents::Agent>]
1197
+ # @param working [Array<OpenLanes::Agents::Agent>] already on the plan
1198
+ # @param state [OpenLanes::Plans::Status] the folder's state
1199
+ # @return [Array<OpenLanes::Agents::Agent>]
1200
+ def partners_only(candidates, working, state)
1201
+ return candidates if working.empty?
1202
+
1203
+ team = @runner.agents.team_for(state).map(&:name)
1204
+ return [] unless working.all? { team.include?(it.name) }
1205
+
1206
+ candidates.select { team.include?(it.name) }
1207
+ end
1208
+
722
1209
  # A folder's name can lag its contents: a run killed before its rename,
723
1210
  # a plan.md written by hand, a resync nobody ran. So the names are
724
1211
  # reconciled before every dispatch, the way `resync dirs` would, and a
725
- # ⚪️ folder that already holds a plan goes to the pair under its honest
1212
+ # ⚪ folder that already holds a plan goes to the pair under its honest
726
1213
  # name rather than to leah-researcher under its stale one. Plans an
727
1214
  # agent is working in right now are left alone: the prompt names the
728
1215
  # folder by path, and moving it under a running agent would strand
729
1216
  # every write it makes afterwards. Nor is a plan another machine holds
730
- # touched: its name is that machine's to change.
1217
+ # touched: its name is that machine's to change. Then the blocked plans
1218
+ # age (plan 009), so a 💣 is in place before anyone is assigned.
731
1219
  #
732
1220
  # @return [void]
733
1221
  def reconcile
734
1222
  busy = untouchable
735
1223
  Lifecycle::Resync::Dirs.new(tree: @runner.tree.reload, except: busy).call(commit: !@runner.dry_run?)
736
1224
  fast_forward(busy) unless @runner.dry_run?
1225
+ @runner.age!(except: busy)
737
1226
  end
738
1227
 
739
1228
  # Plans whose lane skips research, rewriting or planning are moved past
@@ -762,19 +1251,39 @@ module OpenLanes
762
1251
  return true if past.empty?
763
1252
  return false if %w[Completed Blocked].include?(past.last[:status])
764
1253
 
765
- past.size < rounds_for(agent)
1254
+ past.size < rounds_for(agent) + @extensions.rounds(subject.feature.path, agent.name, subject.status.key)
766
1255
  end
767
1256
 
768
1257
  # @param agent [OpenLanes::Agents::Agent]
769
1258
  # @param subject [OpenLanes::Plans::Subject]
770
- # @return [void]
1259
+ # @return [Boolean, nil] false when another run took the plan's lock
1260
+ # first, nil when an agent outside openlanes holds the plan
771
1261
  def start(agent, subject)
772
1262
  ordinal = subject.feature.ordinal.to_s
773
1263
  round = @history[[ordinal, agent.name]].count { |h| h[:state] == subject.status.key } + 1
774
1264
  from = subject.status.key
1265
+ # Before the rename, so a plan somebody outside openlanes holds is
1266
+ # left exactly as they had it (W31).
1267
+ return nil unless reserve(ordinal, subject, agent)
1268
+
775
1269
  subject = rename_on_start(subject, agent) unless @runner.dry_run?
776
- task = @runner.prepare(agent, subject, round)
1270
+ # The lock says the folder is in use, so nothing renames it while the
1271
+ # agent holds its path. It is taken before anything is recorded as
1272
+ # started, since another run can win it between the check and here,
1273
+ # and before the thread, so the next tick already sees it; it goes
1274
+ # when the last agent in the plan does, however that agent went.
1275
+ return false unless lock(ordinal, subject)
1276
+
1277
+ unfinish(subject)
1278
+
1279
+ release_owed = true
1280
+ # A checkout another agent is working in is never moved under it.
1281
+ alone = @running.none? { plan_of(it) == ordinal }
1282
+ task = @runner.prepare(agent, subject, round, refresh: alone, hold: rebases.hold(ordinal))
777
1283
  handle = Execution::Executor::Handle.new
1284
+ # A round a person gave more time to (E1) starts with it.
1285
+ bonus = @runner.dry_run? ? 0 : @extensions.take_seconds(subject.feature.path, agent.name, subject.status.key)
1286
+ handle.extend!(bonus) if bonus.positive?
778
1287
  job = Job.new(key: "#{ordinal}/#{agent.name}",
779
1288
  task:,
780
1289
  handle:,
@@ -785,6 +1294,12 @@ module OpenLanes
785
1294
  down: 0,
786
1295
  subagents: 0,
787
1296
  frame: 0)
1297
+ if task.refresh&.conflicted?
1298
+ start_rebase(job, ordinal)
1299
+ release_owed = false
1300
+ return true
1301
+ end
1302
+ job.message = task.refresh.summary if task.refresh&.kind == :pending
788
1303
  if agent.advances_to && Plans::STATUS_BY_KEY.key?(agent.advances_to)
789
1304
  successor = Agents::Routing.filter(@runner.agents.for_status(Plans::STATUS_BY_KEY.fetch(agent.advances_to)),
790
1305
  subject).first&.name
@@ -794,14 +1309,18 @@ module OpenLanes
794
1309
  # never start is not a partner.
795
1310
  team = @runner.agents.team_for(Lifecycle::Resync::Dirs.target(subject))
796
1311
  partners = Agents::Routing.filter(team, subject) - [agent]
1312
+ # Asked before this round's own stage is written, which would
1313
+ # otherwise be the latest one it finds.
1314
+ resume = resumption_for(task, from)
797
1315
  remember(ordinal, agent.name, job.state, "Started", round)
798
1316
  stage!(subject,
799
1317
  agent,
800
1318
  round,
1319
+ reset: true,
801
1320
  status: "Started",
802
1321
  from: from.to_s,
803
- started_at: Time.now.iso8601,
804
- run: { pid: Process.pid, host: Socket.gethostname },
1322
+ started_at: @clock.now.iso8601,
1323
+ cwd: task.root,
805
1324
  **profile_fields(agent, subject))
806
1325
  @state&.record(ordinal,
807
1326
  agent: agent.name,
@@ -810,26 +1329,107 @@ module OpenLanes
810
1329
  state: from.to_s,
811
1330
  model: model_for(agent, subject),
812
1331
  file: agent.ledger.first,
813
- started_at: Time.now.iso8601)
1332
+ started_at: @clock.now.iso8601)
1333
+ journal!(:plan_assigned, plan: ordinal, agent: agent.name, round:)
814
1334
  UI.log("started", **task.log_fields)
815
1335
  job.thread = Thread.new do
816
1336
  Thread.current.report_on_exception = false
817
1337
  job.result = begin
818
- @runner.executor.call(agent, subject, root: task.root, round:, successor:, handle:, partners:) { |progress|
1338
+ invoke = lambda {
1339
+ @runner.executor.call(agent,
1340
+ subject,
1341
+ root: task.root,
1342
+ round:,
1343
+ successor:,
1344
+ handle:,
1345
+ partners:,
1346
+ resume:) { |progress|
1347
+ job.up = progress.up
1348
+ job.down = progress.down
1349
+ job.subagents = progress.subagents
1350
+ job.running = progress.running
1351
+ job.context = progress.context
1352
+ job.message = progress.message || progress.activity
1353
+ job.history = Board::Row.remember(job.history || [], job.message)
1354
+ job.journal = progress.journal
1355
+ }
1356
+ }
1357
+ verifier?(agent) ? @runner.verification.around(task, subject, &invoke) : invoke.call
1358
+ rescue StandardError => e
1359
+ e
1360
+ ensure
1361
+ @locks.release(ordinal) unless @runner.dry_run?
1362
+ end
1363
+ end
1364
+ release_owed = false
1365
+ @running << job
1366
+ true
1367
+ rescue StandardError
1368
+ # Failed before the thread that releases it existed.
1369
+ @locks.release(ordinal) if release_owed && !@runner.dry_run?
1370
+ @locks.unreserve(ordinal)
1371
+ raise
1372
+ end
1373
+
1374
+ # A job that resolves a stopped rebase rather than working the plan
1375
+ # (D10). Nothing is signed or remembered for it: no round of the
1376
+ # agent's is spent, and plan work starts on the next tick, on the
1377
+ # rebased branch, or on the old one if the rebase was aborted.
1378
+ #
1379
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
1380
+ # @param ordinal [String]
1381
+ # @return [void]
1382
+ def start_rebase(job, ordinal)
1383
+ stopped = job.task.refresh
1384
+ job.rebase = stopped
1385
+ job.message = "rebasing onto #{stopped.base}: #{stopped.files.size} conflicted file#{"s" unless stopped.files.size == 1}"
1386
+ UI.log("started rebase resolution", **job.task.log_fields)
1387
+ job.thread = Thread.new do
1388
+ Thread.current.report_on_exception = false
1389
+ job.result = begin
1390
+ rebases.resolve(job.task, handle: job.handle) { |progress|
819
1391
  job.up = progress.up
820
1392
  job.down = progress.down
821
- job.subagents = progress.subagents
822
- job.context = progress.context
823
- job.message = progress.message || progress.activity
1393
+ job.message = "rebasing: #{progress.message || progress.activity}"
824
1394
  job.history = Board::Row.remember(job.history || [], job.message)
825
1395
  }
826
1396
  rescue StandardError => e
827
1397
  e
1398
+ ensure
1399
+ @locks.release(ordinal)
828
1400
  end
829
1401
  end
830
1402
  @running << job
831
1403
  end
832
1404
 
1405
+ # The plan's alock, taken before its first job starts. Refused, the
1406
+ # plan's Redis claim goes back too, so another machine may take it.
1407
+ #
1408
+ # @param ordinal [String]
1409
+ # @param subject [OpenLanes::Plans::Subject]
1410
+ # @param agent [OpenLanes::Agents::Agent]
1411
+ # @return [Boolean] whether the job may start
1412
+ def reserve(ordinal, subject, agent)
1413
+ @locks.reserve(ordinal, subject.feature.path, "openlanes: #{agent.name} on #{ordinal}")
1414
+ true
1415
+ rescue PlanLocks::Refused => e
1416
+ UI.log("not started: #{e.message}", level: :warn, plan: ordinal, agent: agent.name)
1417
+ @runner.release(ordinal)
1418
+ @refused << ordinal
1419
+ false
1420
+ end
1421
+
1422
+ # @param ordinal [String]
1423
+ # @param subject [OpenLanes::Plans::Subject]
1424
+ # @return [Boolean] whether this run now holds the plan's lock
1425
+ def lock(ordinal, subject)
1426
+ @locks.hold(ordinal, subject.feature.path) unless @runner.dry_run?
1427
+ true
1428
+ rescue Plans::Lock::Held => e
1429
+ UI.log("lock lost: #{e.message}", level: :warn, plan: ordinal)
1430
+ false
1431
+ end
1432
+
833
1433
  # `starts_as`: the folder moves the moment the agent is dispatched, where
834
1434
  # the topology allows, so luke's start is what makes a plan 🟡.
835
1435
  #
@@ -842,6 +1442,71 @@ module OpenLanes
842
1442
  Plans::Tree.new(dir: @runner.tree.dir).find(subject.feature.ordinal) || subject
843
1443
  end
844
1444
 
1445
+ # ---- more time, more rounds (E1) ---------------------------------------
1446
+
1447
+ # Each running agent near the end of its clock asks for more, and is
1448
+ # given it once on its own. Read only in the last {Extensions::ASK_AT}
1449
+ # seconds, so the folder is looked up then and not on every tick.
1450
+ #
1451
+ # @return [void]
1452
+ def ask_for_time
1453
+ return if @runner.dry_run?
1454
+
1455
+ @running.each do |job|
1456
+ left = job.handle.remaining
1457
+ next unless left && left <= Extensions::ASK_AT
1458
+
1459
+ subject = fresh(job.task) or next
1460
+ @extensions.watch(job, subject.feature.path)
1461
+ end
1462
+ end
1463
+
1464
+ # Agents that used every round they had on a plan's state without
1465
+ # finishing ask for another. Before the dispatch, so a round granted
1466
+ # on its own starts in the same pass.
1467
+ #
1468
+ # @return [void]
1469
+ def ask_for_rounds
1470
+ return if @runner.dry_run? || @runner.halted?
1471
+
1472
+ busy = untouchable
1473
+ # Fresh from disk: a round started on a plan renames it without
1474
+ # touching the cached tree, and an exhausted Building agent read
1475
+ # against a stale Planned would never be asked about.
1476
+ @runner.tree.reload
1477
+ @runner.in_scope.each do |subject|
1478
+ next if busy.include?(subject.feature.ordinal.to_s) || Plans::StateMachine::SETTLED.include?(subject.status.key)
1479
+
1480
+ Agents::Routing.filter(@runner.agents.for_status(subject.status), subject).each do |agent|
1481
+ last = out_of_rounds(agent, subject) or next
1482
+ @extensions.exhausted(subject.feature.path, agent.name, subject.status.key, last[:round])
1483
+ end
1484
+ end
1485
+ end
1486
+
1487
+ # @param agent [OpenLanes::Agents::Agent]
1488
+ # @param subject [OpenLanes::Plans::Subject]
1489
+ # @return [Hash, nil] the agent's last round on the plan's state, when it was its last allowed one
1490
+ def out_of_rounds(agent, subject)
1491
+ past = @history[[subject.feature.ordinal.to_s, agent.name]].select { |h| h[:state] == subject.status.key }
1492
+ return nil if past.empty? || %w[Completed Blocked Started].include?(past.last[:status])
1493
+ return nil if past.size < rounds_for(agent) + @extensions.rounds(subject.feature.path, agent.name, subject.status.key)
1494
+
1495
+ past.last
1496
+ end
1497
+
1498
+ # A round that has ended needs no more time, however it ended: its open
1499
+ # request, if it asked, is closed rather than left on the page.
1500
+ #
1501
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
1502
+ # @return [void]
1503
+ def close_extension(job)
1504
+ return if @runner.dry_run?
1505
+
1506
+ subject = fresh(job.task) or return
1507
+ @extensions.close!(subject.feature.path, job.task.agent.name, job.state)
1508
+ end
1509
+
845
1510
  # ---- polling ----------------------------------------------------------
846
1511
 
847
1512
  # @return [void]
@@ -851,6 +1516,9 @@ module OpenLanes
851
1516
  entry = Plans::Ledger.last_for(reading, job.task.agent.name)
852
1517
  job.file = entry&.file || job.file
853
1518
  job.status = entry&.status
1519
+ record_session(job)
1520
+ record_journal(job)
1521
+ journal_progress(job)
854
1522
  @state&.record(job.task.subject.feature.ordinal.to_s,
855
1523
  agent: job.task.agent.name,
856
1524
  round: job.task.round,
@@ -863,6 +1531,49 @@ module OpenLanes
863
1531
  end
864
1532
  end
865
1533
 
1534
+ # Messages published to Redis, kept in their plan's `state.json`
1535
+ # unless that file already has them. Every plan with an agent running
1536
+ # is drained, and so is every plan the stream has mail for, running or
1537
+ # not: a worktree's `mail send` reaches the main checkout only through
1538
+ # the stream, which forgets it after an hour. A plan stays on the list
1539
+ # until nothing of its mail is left pending.
1540
+ #
1541
+ # A message is acked once it is kept, or once it proves it never can
1542
+ # be (no body, no single sender), since retrying an invalid message
1543
+ # every second for an hour would only repeat the log line. One whose
1544
+ # plan this checkout cannot find stays pending and is tried next tick.
1545
+ #
1546
+ # Only delivery: nothing here reads whether a message was acked by its
1547
+ # reader, and no assignment turns on it (D3). Redis going away mid-run
1548
+ # costs the drain, never the tick.
1549
+ #
1550
+ # @return [void]
1551
+ def drain_mail
1552
+ return if @mail.nil? || @runner.dry_run?
1553
+
1554
+ found, @mail_cursor = @mail.plans_since(@mail_cursor || "0")
1555
+ @mail_plans = (@mail_plans || Set.new) | found
1556
+ plans = @running.map { plan_of(it).to_s } | @mail_plans.to_a
1557
+ plans.each do |ordinal|
1558
+ pending = @mail.drain(plan: ordinal, consumer: "harness") { |message| keep_mail(ordinal, message) }
1559
+ @mail_plans.delete(ordinal) if pending.empty?
1560
+ end
1561
+ rescue Live::Unavailable, SystemCallError => e
1562
+ UI.log("mail not drained: #{e.message}", level: :warn)
1563
+ end
1564
+
1565
+ # @param ordinal [String]
1566
+ # @param message [Hash]
1567
+ # @return [Boolean] whether the stream may forget it
1568
+ def keep_mail(ordinal, message)
1569
+ subject = fresh_by(ordinal) or return false
1570
+ Plans.mailbox(subject.feature.path).deliver(message)
1571
+ true
1572
+ rescue OpenLanes::Error => e
1573
+ UI.log("mail not kept: #{e.message}", level: :warn, plan: ordinal)
1574
+ true
1575
+ end
1576
+
866
1577
  # @param task [OpenLanes::Engine::Runner::Task]
867
1578
  # @return [OpenLanes::Plans::Ledger::Reading]
868
1579
  def read_ledger(task)
@@ -876,20 +1587,32 @@ module OpenLanes
876
1587
  def reap
877
1588
  @running.select(&:finished?).each do |job|
878
1589
  @running.delete(job)
1590
+ # What it printed since the last tick, before anything is made of
1591
+ # its outcome: a killed round keeps every line it got out.
1592
+ record_journal(job)
1593
+ journal_progress(job)
879
1594
  attempt = settle(job)
1595
+ journal_end(job, attempt)
880
1596
  # A requeue, or a compaction that stopped the agent and then could
881
1597
  # not happen, hands the round back: the stop was a person's choice.
882
1598
  requeued = @requeued.delete(job.key)
883
1599
  forget_round(job) if requeued || job.handle.compaction&.restart
884
1600
  @attempts << attempt
1601
+ close_extension(job)
885
1602
  row = row_for(job)
886
1603
  @recent << { at: UI.monotonic, row: row.with(state: attempt.ok ? :done : :failed,
887
1604
  message: attempt.note,
888
1605
  history: Board::Row.remember(row.history, attempt.note),
889
1606
  remaining: nil,
890
1607
  phase: nil,
1608
+ running: [],
891
1609
  bold: !attempt.ok) }
892
- UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}", **job.task.log_fields)
1610
+ # `to:` names the state the plan moved to, on the terminal and in the log.
1611
+ moved = Plans::STATUS_BY_KEY[attempt.to]&.to_s if attempt.advanced?
1612
+ UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}",
1613
+ level: attempt.ok ? :info : :warn,
1614
+ **job.task.log_fields,
1615
+ **{ to: moved }.compact)
893
1616
  end
894
1617
  end
895
1618
 
@@ -901,7 +1624,12 @@ module OpenLanes
901
1624
  ordinal = task.subject.feature.ordinal.to_s
902
1625
  result = job.result
903
1626
  base = attempt_for(job)
1627
+ # Before anything reads the ledger: a resolution signs nothing, so
1628
+ # whatever the ledger holds is the plan's previous round, not this one.
1629
+ return base.with(ok: false, note: "rebase: #{result.message}") if job.rebase && result.is_a?(Exception)
1630
+ return base if job.rebase
904
1631
  return crashed(job, base, result) if result.is_a?(Exception)
1632
+ return app_did_not_start(job, base, result) if result.is_a?(Verification::AppSession::Failed)
905
1633
  return base.with(note: result.note) if @runner.dry_run?
906
1634
 
907
1635
  reading = read_ledger(task)
@@ -911,9 +1639,14 @@ module OpenLanes
911
1639
 
912
1640
  attempt = if (why = stopped_for(ordinal))
913
1641
  stopped_by_harness(job, base, why)
1642
+ elsif assessor?(agent) && !(result.ok && entry&.completed?)
1643
+ # No verdict is `manual` (P4), never a second try at `auto`.
1644
+ write_interrupted(fresh(task), agent, task.round, reason_for(result)) if fresh(task) && result.killed
1645
+ assessed(job, base, nil)
914
1646
  elsif result.killed || (result.ok && (entry.nil? || entry.status == "Started"))
915
1647
  verify_and_sign(job, base, reason_for(result))
916
1648
  elsif !result.ok
1649
+ fresh(task)&.then { harness_resume(it, agent, task.round, result.note, phrase: job.message) }
917
1650
  remember(ordinal, agent.name, job.state, "Interrupted", task.round)
918
1651
  base.with(ok: false, note: result.note)
919
1652
  elsif entry.completed?
@@ -926,24 +1659,29 @@ module OpenLanes
926
1659
  end
927
1660
  attempt = attempt.with(note: "#{attempt.note}; #{problems.join(", ")}") unless problems.empty?
928
1661
  attempt = attempt.with(to: fresh(task)&.status&.key || attempt.to)
1662
+ # One outcome, written to both records. The engine's state file once
1663
+ # defaulted a failed round to Completed while state.json said
1664
+ # Interrupted, so the page and the plan disagreed about the same round.
1665
+ outcome = attempt.status || (attempt.ok ? "Completed" : "Interrupted")
929
1666
  if (settled = fresh(task))
930
1667
  stage!(settled,
931
1668
  agent,
932
1669
  task.round,
933
- status: attempt.status || (attempt.ok ? "Completed" : "Interrupted"),
1670
+ status: outcome,
934
1671
  to: attempt.to.to_s,
935
- finished_at: Time.now.iso8601,
1672
+ finished_at: @clock.now.iso8601,
936
1673
  up: attempt.up.to_i,
937
1674
  down: attempt.down.to_i,
938
1675
  seconds: attempt.seconds.to_f.round(1),
939
- note: attempt.note.to_s)
1676
+ note: attempt.note.to_s,
1677
+ session: job.handle&.session)
940
1678
  end
941
1679
  @state&.record(ordinal,
942
1680
  agent: agent.name,
943
1681
  round: task.round,
944
- status: attempt.status || "Completed",
1682
+ status: outcome,
945
1683
  exit: attempt.ok ? "ok" : attempt.note,
946
- ended_at: Time.now.iso8601,
1684
+ ended_at: @clock.now.iso8601,
947
1685
  up: attempt.up,
948
1686
  down: attempt.down)
949
1687
  attempt
@@ -965,7 +1703,10 @@ module OpenLanes
965
1703
  def stopped_by_harness(job, base, why)
966
1704
  task = job.task
967
1705
  subject = fresh(task)
968
- write_interrupted(subject, task.agent, task.round, why) if subject
1706
+ if subject
1707
+ harness_resume(subject, task.agent, task.round, why, phrase: job.message)
1708
+ write_interrupted(subject, task.agent, task.round, why)
1709
+ end
969
1710
  remember(plan_of(job), task.agent.name, job.state, "Interrupted", task.round)
970
1711
  base.with(ok: false, status: "Interrupted", note: why)
971
1712
  end
@@ -989,45 +1730,71 @@ module OpenLanes
989
1730
 
990
1731
  # The agent stopped without saying it finished. If the state it was
991
1732
  # advancing to is justified by what is on disk, the work happened and
992
- # the harness signs for it, in bold, saying why it had to.
1733
+ # the harness signs for it in state.json, saying why it had to.
993
1734
  #
994
1735
  # @return [OpenLanes::Engine::Runner::Attempt]
995
1736
  def verify_and_sign(job, base, reason)
996
1737
  task = job.task
997
1738
  agent = task.agent
998
1739
  subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
1740
+ # Read before the harness's own Interrupted line lands, which would
1741
+ # otherwise be the "last ledger line" the resume record quotes.
1742
+ last = last_line(subject, agent)
999
1743
  write_interrupted(subject, agent, task.round, reason)
1000
1744
  target = agent.advances_to
1001
1745
  if target && subject.machine.may?(target)
1002
1746
  successor = @runner.agents.for_status(Plans::STATUS_BY_KEY.fetch(target)).first&.name
1003
- lines = [Plans::Ledger.render(Plans::Ledger::Entry.new(at: Time.now,
1004
- agent: agent.name,
1005
- status: "Completed",
1006
- round: task.round,
1007
- note: "signed by harness: work verified on disk",
1008
- file: "",
1009
- line: 0))]
1010
- lines << Plans::Ledger.render_handoff(Plans::Ledger::Handoff.new(at: Time.now, next: successor, file: "", line: 0)) if successor
1011
- Plans::Ledger.append(File.join(subject.feature.path, agent.ledger.first), *lines)
1747
+ Plans::PlanState.for(subject.feature.path).sign!(agent: agent.name,
1748
+ round: task.round,
1749
+ status: "Completed",
1750
+ note: "signed by harness: work verified on disk",
1751
+ next_agent: successor)
1012
1752
  entry = Plans::Ledger.last_for(read_ledger(task), agent.name)
1013
1753
  complete(job, base.with(note: "#{reason}; signed by harness"), entry, read_ledger(task))
1014
1754
  else
1755
+ harness_resume(subject, agent, task.round, reason, last:, phrase: job.message)
1015
1756
  remember(subject.feature.ordinal.to_s, agent.name, job.state, "Interrupted", task.round)
1016
- base.with(ok: false, status: "Interrupted", note: "#{reason}; #{retry_note(agent, Plans::Ledger::Entry.new(at: Time.now, agent: agent.name, status: "Interrupted", round: task.round, file: "", line: 0))}")
1757
+ base.with(ok: false, status: "Interrupted", note: "#{reason}; #{retry_note(agent, Plans::Ledger::Entry.new(at: @clock.now, agent: agent.name, status: "Interrupted", round: task.round, file: "", line: 0))}")
1017
1758
  end
1018
1759
  end
1019
1760
 
1761
+ # The resume record the agent did not leave: killed when its grace ran
1762
+ # out, dead, or stopped by the harness. Only what the harness saw, its
1763
+ # last ledger line and its last transcript phrase, because that is all
1764
+ # it knows; an agent's own record of this round or a later one stays,
1765
+ # which {Plans::LastKnownState#supersedes?} decides under the plan lock.
1766
+ # Pids, tokens and exit codes stay in `openlanes-state.json`.
1767
+ #
1768
+ # @param subject [OpenLanes::Plans::Subject]
1769
+ # @param agent [OpenLanes::Agents::Agent]
1770
+ # @param round [Integer]
1771
+ # @param reason [String] why the agent stopped
1772
+ # @param last [OpenLanes::Plans::Ledger::Entry, nil] read before the
1773
+ # harness wrote its own Interrupted line
1774
+ # @param phrase [String, nil] the transcript's last phrase
1775
+ # @return [void]
1776
+ def harness_resume(subject, agent, round, reason, last: last_line(subject, agent), phrase: nil)
1777
+ return if @runner.dry_run?
1778
+
1779
+ seen = "last ledger line: #{last.status}, round #{last.round}#{": #{last.note}" if last.note}" if last
1780
+ note = [reason, seen, ("last seen: #{phrase}" if phrase && !phrase.empty?)].compact.join("; ")
1781
+ entry = Plans::LastKnownState.new(source: "harness", round:, status: "Interrupted", note:)
1782
+ Plans::PlanState.for(subject.feature.path).record_last_known(agent.name, entry)
1783
+ rescue SystemCallError, OpenLanes::Error => e
1784
+ UI.log("resume record not written: #{e.message}", level: :warn, plan: subject.feature.ordinal.to_s)
1785
+ end
1786
+
1787
+ # @param subject [OpenLanes::Plans::Subject]
1788
+ # @param agent [OpenLanes::Agents::Agent]
1789
+ # @return [OpenLanes::Plans::Ledger::Entry, nil]
1790
+ def last_line(subject, agent) = Plans::Ledger.last_for(Plans::Ledger.read(subject.feature.path, agent.ledger), agent.name)
1791
+
1792
+ # The harness signs in `state.json`, as the agents do through
1793
+ # `openlanes state sign`; nothing it writes lands in the plan's documents.
1794
+ #
1020
1795
  # @return [void]
1021
1796
  def write_interrupted(subject, agent, round, reason)
1022
- line = Plans::Ledger.render(Plans::Ledger::Entry.new(at: Time.now,
1023
- agent: agent.name,
1024
- status: "Interrupted",
1025
- round:,
1026
- note: reason,
1027
- file: "",
1028
- line: 0,
1029
- bold: true))
1030
- Plans::Ledger.append(File.join(subject.feature.path, agent.ledger.first), line)
1797
+ Plans::PlanState.for(subject.feature.path).sign!(agent: agent.name, round:, status: "Interrupted", note: reason)
1031
1798
  end
1032
1799
 
1033
1800
  # A Completed entry: publish if the move is to 🟢, then move the folder,
@@ -1044,14 +1811,16 @@ module OpenLanes
1044
1811
  # Only a reviewer rules. A builder that signed `Completed (approved)`
1045
1812
  # once had its own work taken as approved: the plan was parked as
1046
1813
  # finished at 🟡, never reviewed and never published.
1047
- verdict = verdict_of(entry) if agent.phase.to_s == REVIEW_PHASE
1814
+ return assessed(job, base, entry.note) if assessor?(agent)
1815
+
1816
+ verdict = verdict_of(entry) if [REVIEW_PHASE, Verification::PHASE].include?(agent.phase.to_s)
1817
+ return verified(job, base, subject, verdict) if agent.phase.to_s == Verification::PHASE && @runner.verification
1818
+
1048
1819
  partner_running = @running.any? { |j| j.task.subject.feature.ordinal.to_s == ordinal }
1049
1820
  target = target_for(agent, verdict, partner_running)
1050
1821
 
1051
- if verdict == :approved
1052
- @finished_plans << ordinal
1053
- return base.with(status: "Completed", note: "#{base.note}; approved, awaiting merge")
1054
- end
1822
+ return approved(base, subject, ordinal) if verdict == :approved
1823
+ return built_locally(base.with(status: "Completed"), ordinal) if verdict == :verified
1055
1824
 
1056
1825
  attempt = base.with(status: "Completed")
1057
1826
  return built_locally(attempt, ordinal) if local_only_review?(target, subject)
@@ -1059,11 +1828,12 @@ module OpenLanes
1059
1828
  if target
1060
1829
  attempt = attempt.with(note: "#{base.note}#{" (#{entry.note})" if entry.note}")
1061
1830
  published = publish_for_review(task, target, subject, attempt)
1831
+ return untestable(job, published.attempt, subject) if published.untestable
1062
1832
  return unpublished(published, ordinal) if published.refused
1063
1833
 
1064
1834
  attempt = published.attempt
1065
1835
  subject = published.subject
1066
- return attempt.with(ok: false, note: "ledger says Completed but #{Plans::STATUS_BY_KEY.fetch(target)} is not justified: #{Plans::STATUS_BY_KEY.fetch(target).violation(subject)}") unless subject.machine.may?(target)
1836
+ return unjustified(job, attempt, subject, target) unless subject.machine.may?(target)
1067
1837
 
1068
1838
  subject.machine.promote!(target)
1069
1839
  attempt = attempt.with(to: target)
@@ -1074,6 +1844,22 @@ module OpenLanes
1074
1844
  attempt
1075
1845
  end
1076
1846
 
1847
+ # The agent signed Completed, but what it left does not justify the
1848
+ # state it advances to. The folder stays, its `next:` is not honoured,
1849
+ # and the round is recorded as Interrupted; once the agent's rounds run
1850
+ # out, Needs you asks for more of them (E1). Taking the
1851
+ # signature at its word instead parked the plan: nobody else handles
1852
+ # the state it is still in, and its own agent counted itself done.
1853
+ #
1854
+ # @return [OpenLanes::Engine::Runner::Attempt]
1855
+ def unjustified(job, attempt, subject, target)
1856
+ status = Plans::STATUS_BY_KEY.fetch(target)
1857
+ remember(plan_of(job), job.task.agent.name, job.state, "Interrupted", job.task.round)
1858
+ attempt.with(ok: false,
1859
+ status: "Interrupted",
1860
+ note: "ledger says Completed but #{status} is not justified: #{status.violation(subject)}")
1861
+ end
1862
+
1077
1863
  # A build finished in a run that opens no pull request: 🟢 needs one,
1078
1864
  # so the folder cannot move, and failing the attempt would only start
1079
1865
  # the builder again on work that is done. Only the missing pull request
@@ -1101,11 +1887,146 @@ module OpenLanes
1101
1887
  attempt.with(note: "#{attempt.note}; built locally, no pull request in this run, so it waits for one")
1102
1888
  end
1103
1889
 
1890
+ # ---- merging (P1 to P5) ------------------------------------------------
1891
+
1892
+ # @param agent [OpenLanes::Agents::Agent]
1893
+ # @return [Boolean] whether this agent's note carries merge verdicts
1894
+ def assessor?(agent) = agent.phase.to_s == Merging::PHASE && !@runner.merging.nil?
1895
+
1896
+ # palpatine finished, or stopped without a verdict. The engine labels,
1897
+ # merges and rebases from here; the folder moves only to ✅ or 🔴.
1898
+ #
1899
+ # @param note [String, nil] the Completed note; nil reads as `manual`
1900
+ # @return [OpenLanes::Engine::Runner::Attempt]
1901
+ def assessed(job, base, note)
1902
+ task = job.task
1903
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
1904
+ remember(plan_of(job), task.agent.name, job.state, "Completed", task.round)
1905
+ result = @runner.merging.assess(subject, note)
1906
+ wake!
1907
+ base.with(status: "Completed", to: result.to || job.state, note: "#{base.note}; #{result.note}")
1908
+ rescue OpenLanes::Error => e
1909
+ base.with(ok: false, status: "Completed", note: "#{base.note}; merge not settled: #{e.message}")
1910
+ end
1911
+
1912
+ # One pass over every assessed 🏁 plan, at most once per
1913
+ # {MERGE_POLL} seconds: GitHub is asked three times per pull request.
1914
+ #
1915
+ # @return [void]
1916
+ def settle_merges
1917
+ merging = @runner.merging or return
1918
+ return if @runner.dry_run?
1919
+
1920
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1921
+ return if @merges_polled && now - @merges_polled < MERGE_POLL
1922
+
1923
+ @merges_polled = now
1924
+ busy = untouchable
1925
+ @runner.in_scope.each do |subject|
1926
+ ordinal = subject.feature.ordinal.to_s
1927
+ next if busy.include?(ordinal) || !merging.assessed?(subject)
1928
+
1929
+ result = merging.advance(subject)
1930
+ next if @said_merging[ordinal] == result.note && !result.to
1931
+
1932
+ @said_merging[ordinal] = result.note
1933
+ @finished_plans.delete(ordinal) if result.to
1934
+ UI.log(result.note, plan: ordinal)
1935
+ wake! if result.to || result.merged
1936
+ rescue OpenLanes::Error => e
1937
+ UI.log("merge not settled: #{e.message.lines.first.to_s.strip}", level: :warn, plan: ordinal)
1938
+ end
1939
+ # Read again: a merge above may have queued checkouts, and an agent
1940
+ # started since the first read must keep its HEAD (R5).
1941
+ merging.rebase_queued(busy: untouchable).each { UI.log(it) }
1942
+ end
1943
+
1944
+ # ---- verification (plan 014) -----------------------------------------
1945
+
1946
+ # @param agent [OpenLanes::Agents::Agent]
1947
+ # @return [Boolean] whether this agent's run is wrapped in the plan's app
1948
+ def verifier?(agent) = agent.phase.to_s == Verification::PHASE && !@runner.verification.nil? && !@runner.dry_run?
1949
+
1950
+ # hansolo approved. Into 🧪 when the run verifies and the pull request
1951
+ # carries a verdict that goes on to verification; otherwise the plan is
1952
+ # finished at 👀, as it was before chewie existed.
1953
+ #
1954
+ # @return [OpenLanes::Engine::Runner::Attempt]
1955
+ def approved(base, subject, ordinal)
1956
+ said = @runner.verification&.hand_off(subject)
1957
+ now = fresh_by(ordinal)&.status&.key
1958
+ return base.with(status: "Completed", to: now, note: "#{base.note}; approved, #{said}") if %i[verifying rejected].include?(now)
1959
+
1960
+ @finished_plans << ordinal
1961
+ finish(subject, "approved")
1962
+ base.with(status: "Completed", note: "#{base.note}; approved, awaiting merge#{"; #{said}" if said}")
1963
+ end
1964
+
1965
+ # chewie signed. The engine commits, comments and labels from its
1966
+ # report; the folder moves only on a rejection.
1967
+ #
1968
+ # @return [OpenLanes::Engine::Runner::Attempt]
1969
+ def verified(job, base, subject, verdict)
1970
+ driver = @runner.verification
1971
+ result = case verdict
1972
+ when :rejected then driver.reject(job.task, subject)
1973
+ when :verified then driver.pass(job.task, subject)
1974
+ end
1975
+ return base.with(ok: false, status: "Completed", note: "#{base.note}; signed neither verified nor rejected") unless verdict
1976
+ return base.with(ok: false, status: "Completed", note: "#{base.note}; no open pull request to verify") unless result
1977
+
1978
+ wake!
1979
+ base.with(status: "Completed", to: result.to || job.state, note: "#{base.note}; #{result.note}")
1980
+ end
1981
+
1982
+ # The app did not come up, so chewie never ran: rejected all the same
1983
+ # (M1), with the failing process's last lines.
1984
+ #
1985
+ # @param result [OpenLanes::Engine::Verification::AppSession::Failed]
1986
+ # @return [OpenLanes::Engine::Runner::Attempt]
1987
+ def app_did_not_start(job, base, result)
1988
+ task = job.task
1989
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
1990
+ remember(plan_of(job), task.agent.name, job.state, "Completed", task.round)
1991
+ rejected = @runner.verification.reject(task, subject, why: result.to_markdown)
1992
+ note = rejected&.note || "no open pull request to reject"
1993
+ base.with(ok: !rejected.nil?, status: "Completed", to: rejected&.to || job.state, note: "#{result.headline}; #{note}")
1994
+ end
1995
+
1996
+ # What the last look at CI found for the verified plans, logged, and
1997
+ # the plans it moved woken for. A rejection hands the plan back to the
1998
+ # builders; `ready-to-merge` leaves it at 🧪 for a person, or plan
1999
+ # 033's merge, to finish.
2000
+ #
2001
+ # @return [void]
2002
+ def settle_verifications
2003
+ driver = @runner.verification or return
2004
+ return if @runner.dry_run?
2005
+
2006
+ # The plans being looked at are untouchable to everything but the
2007
+ # look itself, which is about to be acted on.
2008
+ busy = untouchable - driver.looking
2009
+ subjects = @runner.in_scope.reject { busy.include?(it.feature.ordinal.to_s) }
2010
+ driver.poll(subjects).each do |settled|
2011
+ result = settled.result
2012
+ @verifying[settled.plan] = result.note if result.kind == :waiting
2013
+ next if result.kind == :waiting && @said_verifying[settled.plan] == result.note
2014
+
2015
+ @said_verifying[settled.plan] = result.note
2016
+ @verifying.delete(settled.plan) unless result.kind == :waiting
2017
+ @finished_plans.delete(settled.plan) if result.to
2018
+ UI.log(result.note, level: result.kind == :rejected ? :warn : :info, plan: settled.plan)
2019
+ wake!
2020
+ end
2021
+ end
2022
+
1104
2023
  # @param entry [OpenLanes::Plans::Ledger::Entry]
1105
- # @return [Symbol, nil] :rejected, :approved, :slop, :product or nil
2024
+ # @return [Symbol, nil] :rejected, :verified, :approved, :slop, :product or nil
1106
2025
  def verdict_of(entry)
1107
2026
  note = entry.note.to_s.downcase
1108
- return :rejected if note.include?("reject")
2027
+ # Anchored, as the rejection count is: "nothing to reject" approves.
2028
+ return :rejected if note.strip.match?(Verification::Outcome::REJECTION)
2029
+ return :verified if note.include?("verified")
1109
2030
  return :approved if note.include?("approv")
1110
2031
  return :slop if note.include?("slop") || note.include?("rewrite")
1111
2032
  return :product if note.include?("product")
@@ -1147,15 +2068,47 @@ module OpenLanes
1147
2068
 
1148
2069
  publication = @runner.publish(task, subject)
1149
2070
  if publication&.refusal
1150
- Published.new(attempt: attempt.with(ok: false, note: "#{attempt.note}; publish refused: #{publication.refusal}"), subject:, refused: true)
2071
+ Published.new(attempt: attempt.with(ok: false, note: "#{attempt.note}; publish refused: #{publication.refusal}"),
2072
+ subject:,
2073
+ refused: true,
2074
+ untestable: publication.untestable?)
1151
2075
  elsif publication&.published?
2076
+ # Once per refusal, not once per run: a later round that loses the
2077
+ # section again is owed another mail rather than a silent Interrupted.
2078
+ @mutex.synchronize { @asked_how_to_test.delete(task.subject.feature.ordinal.to_s) }
1152
2079
  verb = publication.updated ? "pushed to" : "opened"
1153
- Published.new(attempt: attempt.with(note: "#{attempt.note}; #{verb} #{publication.url}"), subject: fresh(task) || subject, refused: false)
2080
+ note = "#{attempt.note}; #{verb} #{publication.url}"
2081
+ note += "; #{Plans::ReviewLabel::INITIAL} not applied: #{publication.label_error}" if publication.label_error
2082
+ Published.new(attempt: attempt.with(note:), subject: fresh(task) || subject, refused: false)
1154
2083
  else
1155
2084
  unchanged
1156
2085
  end
1157
2086
  end
1158
2087
 
2088
+ # The builders finished, but plan.md says nothing about how to test the
2089
+ # result. That is work an agent can do, so the builder is mailed what is
2090
+ # missing and handed its round back, once, to write it. A second refusal
2091
+ # is Interrupted and spends a round, as any failed round does. Parking the plan
2092
+ # as finished, as a rejected push is, left 021 at 🟡 with nothing on the
2093
+ # page saying why.
2094
+ #
2095
+ # @param job [Job]
2096
+ # @param attempt [OpenLanes::Engine::Runner::Attempt]
2097
+ # @param subject [OpenLanes::Plans::Subject]
2098
+ # @return [OpenLanes::Engine::Runner::Attempt]
2099
+ def untestable(job, attempt, subject)
2100
+ agent = job.task.agent
2101
+ ordinal = plan_of(job)
2102
+ if @mutex.synchronize { @asked_how_to_test.add?(ordinal) }
2103
+ forget_round(job)
2104
+ Plans::Mailbox.new(dir: subject.feature.path).append(from: HARNESS, to: agent.name, body: HOW_TO_TEST_MAIL)
2105
+ return attempt.with(status: "Interrupted", note: "#{attempt.note}; #{agent.name} was asked to add it")
2106
+ end
2107
+
2108
+ remember(ordinal, agent.name, job.state, "Interrupted", job.task.round)
2109
+ attempt.with(status: "Interrupted", note: "#{attempt.note}; still missing after #{agent.name} was asked")
2110
+ end
2111
+
1159
2112
  # A refused publish leaves the plan where it was. Running the builder
1160
2113
  # again would redo finished work to hit the same rejected push, so the
1161
2114
  # plan is done for this run and the attempt says why.
@@ -1191,6 +2144,7 @@ module OpenLanes
1191
2144
  subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
1192
2145
  remember(subject.feature.ordinal.to_s, task.agent.name, job.state, "Blocked", task.round)
1193
2146
  target = verdict_of(entry) == :product ? :product_blocked : :blocked
2147
+ journal!(:question, plan: subject.feature.ordinal.to_s, agent: task.agent.name, note: target.to_s)
1194
2148
  if subject.machine.may?(target)
1195
2149
  subject.machine.promote!(target)
1196
2150
  base.with(to: target, status: "Blocked", note: "blocked; see blocked.md")
@@ -1201,6 +2155,7 @@ module OpenLanes
1201
2155
 
1202
2156
  # @return [OpenLanes::Engine::Runner::Attempt]
1203
2157
  def crashed(job, base, error)
2158
+ fresh(job.task)&.then { harness_resume(it, job.task.agent, base.round, error.message.lines.first.to_s.strip, phrase: job.message) }
1204
2159
  remember(base.ordinal, base.agent, job.state, "Interrupted", base.round)
1205
2160
  base.with(ok: false, note: error.message.lines.first.to_s.strip)
1206
2161
  end
@@ -1241,7 +2196,92 @@ module OpenLanes
1241
2196
 
1242
2197
  Plans::PlanState.for(subject.feature.path).stage!(agent: agent.name, round:, **fields)
1243
2198
  rescue SystemCallError, OpenLanes::Error => e
1244
- UI.log("state.json not written: #{e.message}", plan: subject.feature.ordinal.to_s)
2199
+ UI.log("state.json not written: #{e.message}", level: :warn, plan: subject.feature.ordinal.to_s)
2200
+ end
2201
+
2202
+ # The session this round carries on (M1): the previous round's, when
2203
+ # its CLI can resume one, the plan has not moved since, and it ran in
2204
+ # this round's checkout. Either way the log says which and why, since
2205
+ # an agent that suddenly knows or forgets everything is otherwise a
2206
+ # mystery.
2207
+ #
2208
+ # @param task [OpenLanes::Engine::Runner::Task]
2209
+ # @param state [Symbol] the state the plan was in when dispatched
2210
+ # @return [String, nil] the session id to resume, nil to start fresh
2211
+ def resumption_for(task, state)
2212
+ return nil if @runner.dry_run?
2213
+
2214
+ adapter = adapter_of(task.agent, task.subject)
2215
+ unless adapter.resumes?
2216
+ UI.log("fresh session: #{adapter.name} cannot resume one", **task.log_fields)
2217
+ return nil
2218
+ end
2219
+
2220
+ decision = Plans::PlanState.for(task.subject.feature.path)
2221
+ .resumption(agent: task.agent.name, state:, root: task.root)
2222
+ UI.log(decision.resume? ? "resuming session #{decision.session}: #{decision.reason}" : "fresh session: #{decision.reason}",
2223
+ **task.log_fields)
2224
+ decision.session
2225
+ rescue OpenLanes::Error => e
2226
+ UI.log("fresh session: state.json unreadable: #{e.message}", level: :warn, **task.log_fields)
2227
+ nil
2228
+ end
2229
+
2230
+ # A round's session id goes into its stage the moment the stream names
2231
+ # it, not only when the round settles: a person resuming an agent by
2232
+ # hand is most often doing it while the round is still running, or
2233
+ # after the harness died under it.
2234
+ #
2235
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
2236
+ # @return [void]
2237
+ def record_session(job)
2238
+ session = job.handle&.session
2239
+ return if session.nil? || job.recorded_session == session || job.rebase
2240
+
2241
+ subject = fresh(job.task) or return
2242
+ stage!(subject, job.task.agent, job.task.round, session:)
2243
+ job.recorded_session = session
2244
+ end
2245
+
2246
+ # The journal lines (M2) the agent printed since the last look, into
2247
+ # the plan's `state.json`, as they arrive rather than when the round
2248
+ # ends, so a round that is killed keeps what it already said. The
2249
+ # executor's thread only collects them; writing happens here, on the
2250
+ # loop's, against the folder as it is named now.
2251
+ #
2252
+ # A line that is not a valid entry is dropped and said so in the log:
2253
+ # the agent believes it remembered it, and only the log can tell a
2254
+ # person it did not.
2255
+ #
2256
+ # The place in the list moves on only once the lines are written. A
2257
+ # folder not found this tick, or a `state.json` that cannot be written
2258
+ # right now, leaves them for the next tick to try again, rather than a
2259
+ # hole in the journal the agent believes it kept.
2260
+ #
2261
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
2262
+ # @return [void]
2263
+ def record_journal(job)
2264
+ lines = job.journal || []
2265
+ return if job.rebase || @runner.dry_run? || lines.size <= job.journaled.to_i
2266
+
2267
+ fields = job.task.log_fields
2268
+ entries, rejected = lines[job.journaled.to_i..].partition { it.is_a?(Plans::Journal::Entry) }
2269
+ unless entries.empty?
2270
+ subject = fresh(job.task) or
2271
+ return UI.log("journal not written yet: plan folder not found", level: :warn, **fields)
2272
+
2273
+ job.journal_round, refused = Plans::PlanState.for(subject.feature.path)
2274
+ .journal!(agent: job.task.agent.name,
2275
+ entries:,
2276
+ number: job.journal_round,
2277
+ round: job.task.round,
2278
+ state: job.from)
2279
+ rejected += refused
2280
+ end
2281
+ job.journaled = lines.size
2282
+ rejected.each { UI.log("journal line ignored, #{it.reason}: #{it.line[0, 160]}", level: :warn, **fields) }
2283
+ rescue SystemCallError, OpenLanes::Error => e
2284
+ UI.log("journal not written yet: #{e.message}", level: :warn, **job.task.log_fields)
1245
2285
  end
1246
2286
 
1247
2287
  # @return [Hash] adapter, model and effort, when the executor can say
@@ -1285,6 +2325,79 @@ module OpenLanes
1285
2325
  @finished_plans.delete(ordinal)
1286
2326
  end
1287
2327
 
2328
+ # One event in the progress journal, when the run keeps one. A disk
2329
+ # that refuses the write is reported, not raised: the agents already
2330
+ # running are worth more than the record of them.
2331
+ #
2332
+ # @return [void]
2333
+ def journal!(kind, **fields)
2334
+ @journal&.append(kind, **fields)
2335
+ rescue SystemCallError, IOError => e
2336
+ UI.log("journal write failed: #{e.class}: #{e.message}", level: :warn)
2337
+ end
2338
+
2339
+ # The agent's pid and start time the first time it is seen, its
2340
+ # session id once its stream names it, and the trace's size whenever
2341
+ # it grew: the in-flight picture a replay rebuilds.
2342
+ #
2343
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
2344
+ # @return [void]
2345
+ def journal_progress(job)
2346
+ return unless @journal
2347
+
2348
+ handle = job.handle
2349
+ pid = handle&.pid or return
2350
+ seen = (@journaled[job.key] ||= {})
2351
+ who = { plan: job.task.subject.feature.ordinal.to_s, agent: job.task.agent.name }
2352
+ unless seen[:pid] == pid
2353
+ seen.replace(pid:, session: handle.session, offset: 0)
2354
+ journal!(:agent_started,
2355
+ **who,
2356
+ round: job.task.round,
2357
+ pid:,
2358
+ started_at: Execution::ProcessStart.of(pid)&.utc&.iso8601,
2359
+ session_id: handle.session,
2360
+ output_file: handle.trace)
2361
+ end
2362
+ if handle.session && seen[:session] != handle.session
2363
+ seen[:session] = handle.session
2364
+ journal!(:agent_session, **who, session_id: handle.session)
2365
+ end
2366
+ offset = handle.trace && File.exist?(handle.trace) ? File.size(handle.trace) : 0
2367
+ return unless offset > seen[:offset]
2368
+
2369
+ seen[:offset] = offset
2370
+ journal!(:agent_offset, **who, offset:)
2371
+ end
2372
+
2373
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
2374
+ # @param attempt [OpenLanes::Engine::Runner::Attempt]
2375
+ # @return [void]
2376
+ def journal_end(job, attempt)
2377
+ return unless @journal
2378
+
2379
+ who = { plan: job.task.subject.feature.ordinal.to_s, agent: job.task.agent.name }
2380
+ journal!(:agent_ended, **who, exit: attempt.ok ? 0 : 1, note: attempt.note.to_s) if @journaled.delete(job.key)
2381
+ return unless attempt.advanced?
2382
+
2383
+ journal!(:transition, plan: who[:plan], from: attempt.from.to_s, to: attempt.to.to_s, event: event_between(attempt.from, attempt.to))
2384
+ end
2385
+
2386
+ # The aasm event that moved +from+ to +to+. A destination names one
2387
+ # forward event, but a retreat lands on the same states by `step_back`,
2388
+ # so the edge, not the destination alone, says which fired.
2389
+ #
2390
+ # @param from [Symbol, #to_sym]
2391
+ # @param to [Symbol, #to_sym]
2392
+ # @return [String]
2393
+ def event_between(from, to)
2394
+ from = from.to_sym
2395
+ to = to.to_sym
2396
+ return "step_back" unless Plans::StateMachine.inbound.fetch(to, []).include?(from)
2397
+
2398
+ Plans::StateMachine.event_for.fetch(to).to_s
2399
+ end
2400
+
1288
2401
  # @return [void]
1289
2402
  def persist
1290
2403
  return unless @state