openlanes 3.0.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 (466) hide show
  1. checksums.yaml +7 -0
  2. data/agents/hansolo-reviewer.md +59 -0
  3. data/agents/lando-broker.md +70 -0
  4. data/agents/leah-researcher.md +60 -0
  5. data/agents/luke-backend.md +73 -0
  6. data/agents/palpatine-planner.md +61 -0
  7. data/agents/r2d2-mechanic.md +57 -0
  8. data/agents/rey-frontend.md +68 -0
  9. data/agents/yoda-writer.md +67 -0
  10. data/bin/factory-e2e +157 -0
  11. data/bin/plan-number +164 -0
  12. data/bin/setup +26 -0
  13. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
  14. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
  15. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
  16. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
  17. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
  18. data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
  19. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
  20. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
  21. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
  22. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
  23. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
  24. data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
  25. data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
  26. data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
  27. data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
  28. data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
  29. data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
  30. data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
  31. data/evals/cases/lando-broker/unblock-fast.yml +44 -0
  32. data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
  33. data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
  34. data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
  35. data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
  36. data/evals/cases/leah-researcher/research-deep.yml +51 -0
  37. data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
  38. data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
  39. data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
  40. data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
  41. data/evals/cases/leah-researcher/research-fast.yml +55 -0
  42. data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
  43. data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
  44. data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
  45. data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
  46. data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
  47. data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
  48. data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
  49. data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
  50. data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
  51. data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
  52. data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
  53. data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
  54. data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
  55. data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
  56. data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
  57. data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
  58. data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
  59. data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
  60. data/evals/cases/luke-backend/build-medium.yml +68 -0
  61. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
  62. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
  63. data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
  64. data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
  65. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
  66. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
  67. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
  68. data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
  69. data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
  70. data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
  71. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
  72. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
  73. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
  74. data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
  75. data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
  76. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
  77. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
  78. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
  79. data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
  80. data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
  81. data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
  82. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
  83. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
  84. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
  85. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
  86. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
  87. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
  88. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
  89. data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
  90. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
  91. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
  92. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
  93. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
  94. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
  95. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
  96. data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
  97. data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
  98. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
  99. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
  100. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
  101. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
  102. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
  103. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
  104. data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
  105. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
  106. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
  107. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
  108. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
  109. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
  110. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
  111. data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
  112. data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
  113. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
  114. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
  115. data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
  116. data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
  117. data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
  118. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
  119. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
  120. data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
  121. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
  122. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
  123. data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
  124. data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
  125. data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
  126. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
  127. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
  128. data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
  129. data/evals/cases/rey-frontend/ui-medium.yml +55 -0
  130. data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
  131. data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
  132. data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
  133. data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
  134. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
  135. data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
  136. data/evals/cases/yoda-writer/spec-medium.yml +40 -0
  137. data/exe/depot +1 -0
  138. data/exe/lanes +1 -0
  139. data/exe/openlanes +32 -0
  140. data/lib/openlanes/adapters/base.rb +69 -0
  141. data/lib/openlanes/adapters/claude.rb +124 -0
  142. data/lib/openlanes/adapters/codex.rb +33 -0
  143. data/lib/openlanes/adapters/model.rb +37 -0
  144. data/lib/openlanes/adapters/pi.rb +22 -0
  145. data/lib/openlanes/adapters/stream.rb +196 -0
  146. data/lib/openlanes/adapters.rb +92 -0
  147. data/lib/openlanes/agents/agent.rb +103 -0
  148. data/lib/openlanes/agents/profile.rb +57 -0
  149. data/lib/openlanes/agents/registry.rb +151 -0
  150. data/lib/openlanes/agents/roster.rb +103 -0
  151. data/lib/openlanes/agents/routing.rb +88 -0
  152. data/lib/openlanes/agents.rb +20 -0
  153. data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
  154. data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
  155. data/lib/openlanes/cli/base.rb +263 -0
  156. data/lib/openlanes/cli/create/create.rb +354 -0
  157. data/lib/openlanes/cli/docs/docs.rb +40 -0
  158. data/lib/openlanes/cli/eval/eval.rb +110 -0
  159. data/lib/openlanes/cli/index/index.rb +40 -0
  160. data/lib/openlanes/cli/init/init.rb +145 -0
  161. data/lib/openlanes/cli/linear/linear.rb +36 -0
  162. data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
  163. data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
  164. data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
  165. data/lib/openlanes/cli/mail/mail.rb +31 -0
  166. data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
  167. data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
  168. data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
  169. data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
  170. data/lib/openlanes/cli/run/run.rb +544 -0
  171. data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
  172. data/lib/openlanes/cli/start/start.rb +110 -0
  173. data/lib/openlanes/cli/state/state.rb +26 -0
  174. data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
  175. data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
  176. data/lib/openlanes/cli/states/states.rb +15 -0
  177. data/lib/openlanes/cli/unblock/unblock.rb +235 -0
  178. data/lib/openlanes/cli/version/version.rb +13 -0
  179. data/lib/openlanes/cli/web/web.rb +100 -0
  180. data/lib/openlanes/cli/worktree/worktree.rb +85 -0
  181. data/lib/openlanes/cli.rb +162 -0
  182. data/lib/openlanes/engine/board.rb +104 -0
  183. data/lib/openlanes/engine/dispatcher.rb +1297 -0
  184. data/lib/openlanes/engine/progress_log.rb +125 -0
  185. data/lib/openlanes/engine/runner.rb +224 -0
  186. data/lib/openlanes/engine/state_file.rb +190 -0
  187. data/lib/openlanes/engine/tally.rb +177 -0
  188. data/lib/openlanes/engine.rb +19 -0
  189. data/lib/openlanes/evals/case.rb +191 -0
  190. data/lib/openlanes/evals/changes.rb +67 -0
  191. data/lib/openlanes/evals/check.rb +224 -0
  192. data/lib/openlanes/evals/evidence.rb +71 -0
  193. data/lib/openlanes/evals/live.rb +140 -0
  194. data/lib/openlanes/evals/recording.rb +38 -0
  195. data/lib/openlanes/evals/run.rb +34 -0
  196. data/lib/openlanes/evals/score.rb +24 -0
  197. data/lib/openlanes/evals/scorer.rb +42 -0
  198. data/lib/openlanes/evals/verdict.rb +18 -0
  199. data/lib/openlanes/evals.rb +56 -0
  200. data/lib/openlanes/execution/checks.rb +73 -0
  201. data/lib/openlanes/execution/child.rb +228 -0
  202. data/lib/openlanes/execution/clock.rb +121 -0
  203. data/lib/openlanes/execution/control.rb +194 -0
  204. data/lib/openlanes/execution/executor.rb +849 -0
  205. data/lib/openlanes/execution/prompt.rb +455 -0
  206. data/lib/openlanes/execution/transcript.rb +531 -0
  207. data/lib/openlanes/execution.rb +19 -0
  208. data/lib/openlanes/launcher.rb +69 -0
  209. data/lib/openlanes/lifecycle/adoption.rb +197 -0
  210. data/lib/openlanes/lifecycle/brief.rb +288 -0
  211. data/lib/openlanes/lifecycle/completion.rb +58 -0
  212. data/lib/openlanes/lifecycle/creator.rb +126 -0
  213. data/lib/openlanes/lifecycle/lanes.rb +74 -0
  214. data/lib/openlanes/lifecycle/resync.rb +428 -0
  215. data/lib/openlanes/lifecycle/unblocker.rb +236 -0
  216. data/lib/openlanes/lifecycle.rb +28 -0
  217. data/lib/openlanes/linear/api.rb +224 -0
  218. data/lib/openlanes/linear/attribution.rb +185 -0
  219. data/lib/openlanes/linear/fuzzy.rb +68 -0
  220. data/lib/openlanes/linear/import.rb +314 -0
  221. data/lib/openlanes/linear/issue.rb +187 -0
  222. data/lib/openlanes/linear/mapping.rb +116 -0
  223. data/lib/openlanes/linear/push.rb +197 -0
  224. data/lib/openlanes/linear/survey.rb +174 -0
  225. data/lib/openlanes/linear/unit.rb +274 -0
  226. data/lib/openlanes/linear.rb +41 -0
  227. data/lib/openlanes/live/claims.rb +71 -0
  228. data/lib/openlanes/live/command.rb +122 -0
  229. data/lib/openlanes/live/commands.rb +505 -0
  230. data/lib/openlanes/live/keys.rb +93 -0
  231. data/lib/openlanes/live/memory_store.rb +265 -0
  232. data/lib/openlanes/live/meter.rb +51 -0
  233. data/lib/openlanes/live/node.rb +88 -0
  234. data/lib/openlanes/live/publisher.rb +179 -0
  235. data/lib/openlanes/live/repo.rb +60 -0
  236. data/lib/openlanes/live/run_record.rb +133 -0
  237. data/lib/openlanes/live/run_state.rb +101 -0
  238. data/lib/openlanes/live/session.rb +107 -0
  239. data/lib/openlanes/live/snapshot.rb +322 -0
  240. data/lib/openlanes/live/stations.rb +67 -0
  241. data/lib/openlanes/live/store.rb +277 -0
  242. data/lib/openlanes/live.rb +109 -0
  243. data/lib/openlanes/plans/dev_work.rb +56 -0
  244. data/lib/openlanes/plans/feature.rb +131 -0
  245. data/lib/openlanes/plans/index.rb +208 -0
  246. data/lib/openlanes/plans/ledger.rb +255 -0
  247. data/lib/openlanes/plans/mailbox.rb +164 -0
  248. data/lib/openlanes/plans/ordinal.rb +92 -0
  249. data/lib/openlanes/plans/plan_state.rb +291 -0
  250. data/lib/openlanes/plans/pull_request.rb +29 -0
  251. data/lib/openlanes/plans/pull_requests.rb +229 -0
  252. data/lib/openlanes/plans/spec.rb +213 -0
  253. data/lib/openlanes/plans/state_machine.rb +433 -0
  254. data/lib/openlanes/plans/status.rb +365 -0
  255. data/lib/openlanes/plans/subject.rb +133 -0
  256. data/lib/openlanes/plans/tree.rb +79 -0
  257. data/lib/openlanes/plans.rb +39 -0
  258. data/lib/openlanes/presentation/diagram.rb +149 -0
  259. data/lib/openlanes/presentation/documentation.rb +438 -0
  260. data/lib/openlanes/presentation/reporter.rb +181 -0
  261. data/lib/openlanes/presentation/viewer.rb +62 -0
  262. data/lib/openlanes/presentation.rb +23 -0
  263. data/lib/openlanes/skills/installer.rb +68 -0
  264. data/lib/openlanes/skills/skill.rb +60 -0
  265. data/lib/openlanes/skills.rb +25 -0
  266. data/lib/openlanes/support/config.rb +217 -0
  267. data/lib/openlanes/support/frontmatter.rb +38 -0
  268. data/lib/openlanes/support/markdown.rb +56 -0
  269. data/lib/openlanes/support/project.rb +79 -0
  270. data/lib/openlanes/support/ui.rb +671 -0
  271. data/lib/openlanes/vcs/github.rb +171 -0
  272. data/lib/openlanes/vcs/publisher.rb +221 -0
  273. data/lib/openlanes/vcs/worktree.rb +248 -0
  274. data/lib/openlanes/vcs.rb +19 -0
  275. data/lib/openlanes/version.rb +10 -0
  276. data/lib/openlanes/web/app.rb +176 -0
  277. data/lib/openlanes/web/context.rb +66 -0
  278. data/lib/openlanes/web/demo.rb +169 -0
  279. data/lib/openlanes/web/demo_board.rb +286 -0
  280. data/lib/openlanes/web/events.rb +104 -0
  281. data/lib/openlanes/web/launcher.rb +143 -0
  282. data/lib/openlanes/web/server.rb +77 -0
  283. data/lib/openlanes/web/stores.rb +36 -0
  284. data/lib/openlanes/web.rb +92 -0
  285. data/lib/openlanes.rb +151 -0
  286. data/openlanes.gemspec +87 -0
  287. data/schemas/plan-state.schema.json +101 -0
  288. data/skills/openlanes/SKILL.md +48 -0
  289. data/skills/openlanes-documents/SKILL.md +61 -0
  290. data/skills/openlanes-documents/references/blocked-template.md +49 -0
  291. data/skills/openlanes-documents/references/closing-templates.md +59 -0
  292. data/skills/openlanes-documents/references/plan-template.md +61 -0
  293. data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
  294. data/skills/openlanes-documents/references/spec-template.md +45 -0
  295. data/skills/openlanes-run/SKILL.md +90 -0
  296. data/skills/openlanes-setup/SKILL.md +64 -0
  297. data/skills/openlanes-spec/SKILL.md +90 -0
  298. data/skills/openlanes-status/SKILL.md +32 -0
  299. data/skills/openlanes-sync/SKILL.md +22 -0
  300. data/web/dist/assets/apl-B4CMkyY2.js +2 -0
  301. data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
  302. data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
  303. data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
  304. data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  305. data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  306. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  307. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  308. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  309. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  310. data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
  311. data/web/dist/assets/clike-B9uivgTg.js +2 -0
  312. data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
  313. data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
  314. data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
  315. data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
  316. data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
  317. data/web/dist/assets/crystal-SjHAIU92.js +2 -0
  318. data/web/dist/assets/css-BnMrqG3P.js +2 -0
  319. data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
  320. data/web/dist/assets/d-pRatUO7H.js +2 -0
  321. data/web/dist/assets/diff-DbItnlRl.js +2 -0
  322. data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
  323. data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
  324. data/web/dist/assets/dylan-DwRh75JA.js +2 -0
  325. data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
  326. data/web/dist/assets/ecl-Cabwm37j.js +2 -0
  327. data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
  328. data/web/dist/assets/elm-vLlmbW-K.js +2 -0
  329. data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
  330. data/web/dist/assets/factor-kuTfRLto.js +2 -0
  331. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  332. data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
  333. data/web/dist/assets/forth-Ffai-XNe.js +2 -0
  334. data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
  335. data/web/dist/assets/gas-Bneqetm1.js +2 -0
  336. data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
  337. data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
  338. data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
  339. data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
  340. data/web/dist/assets/http-DBlCnlav.js +2 -0
  341. data/web/dist/assets/idl-BEugSyMb.js +2 -0
  342. data/web/dist/assets/index-2xPC-eHt.js +2 -0
  343. data/web/dist/assets/index-8dNhRmiO.js +8 -0
  344. data/web/dist/assets/index-B7-O9ujQ.js +2 -0
  345. data/web/dist/assets/index-BdR34cXC.js +2 -0
  346. data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
  347. data/web/dist/assets/index-Byyxbh_z.js +4 -0
  348. data/web/dist/assets/index-C0VmkrO-.js +2 -0
  349. data/web/dist/assets/index-CI74IkWP.js +198 -0
  350. data/web/dist/assets/index-CYxcbpRM.js +3 -0
  351. data/web/dist/assets/index-D0rmJN3h.js +2 -0
  352. data/web/dist/assets/index-DB4QwuYn.js +2 -0
  353. data/web/dist/assets/index-DDI7zoAS.js +2 -0
  354. data/web/dist/assets/index-DGLUuEXi.js +2 -0
  355. data/web/dist/assets/index-OR-odsl9.js +2 -0
  356. data/web/dist/assets/index-P3vSkiU-.js +2 -0
  357. data/web/dist/assets/index-a4EHR-u4.css +1 -0
  358. data/web/dist/assets/index-mRV4XhJZ.js +2 -0
  359. data/web/dist/assets/index-popk9wbW.js +2 -0
  360. data/web/dist/assets/index-uM88Njdj.js +2 -0
  361. data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
  362. data/web/dist/assets/julia-DuME0IfC.js +2 -0
  363. data/web/dist/assets/livescript-BwQOo05w.js +2 -0
  364. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  365. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  366. data/web/dist/assets/lua-VAEuO923.js +2 -0
  367. data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
  368. data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
  369. data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
  370. data/web/dist/assets/mllike-CXdrOF99.js +2 -0
  371. data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
  372. data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
  373. data/web/dist/assets/mumps-BT43cFF4.js +2 -0
  374. data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
  375. data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
  376. data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
  377. data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
  378. data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
  379. data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
  380. data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
  381. data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
  382. data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
  383. data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
  384. data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
  385. data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
  386. data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
  387. data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
  388. data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
  389. data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
  390. data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  391. data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
  392. data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  393. data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
  394. data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
  395. data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
  396. data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
  397. data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
  398. data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
  399. data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
  400. data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
  401. data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
  402. data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
  403. data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
  404. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
  405. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
  406. data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
  407. data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  408. data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
  409. data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
  410. data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
  411. data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
  412. data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
  413. data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
  414. data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
  415. data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
  416. data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
  417. data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
  418. data/web/dist/assets/oz-BzwKVEFT.js +2 -0
  419. data/web/dist/assets/pascal--L3eBynH.js +2 -0
  420. data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
  421. data/web/dist/assets/pig-CevX1Tat.js +2 -0
  422. data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
  423. data/web/dist/assets/properties-C78fOPTZ.js +2 -0
  424. data/web/dist/assets/protobuf-ChK-085T.js +2 -0
  425. data/web/dist/assets/pug-DeIclll2.js +2 -0
  426. data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
  427. data/web/dist/assets/python-BuPzkPfP.js +2 -0
  428. data/web/dist/assets/q-pXgVlZs6.js +2 -0
  429. data/web/dist/assets/r-B6wPVr8A.js +2 -0
  430. data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
  431. data/web/dist/assets/ruby-BK5aLJev.js +2 -0
  432. data/web/dist/assets/sas-B4kiWyti.js +2 -0
  433. data/web/dist/assets/scheme-C41bIUwD.js +2 -0
  434. data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
  435. data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
  436. data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
  437. data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
  438. data/web/dist/assets/solr-DehyRSwq.js +2 -0
  439. data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
  440. data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
  441. data/web/dist/assets/sql-D0XecflT.js +2 -0
  442. data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
  443. data/web/dist/assets/stylus-B533Al4x.js +2 -0
  444. data/web/dist/assets/swift-BzpIVaGY.js +2 -0
  445. data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
  446. data/web/dist/assets/textile-CnDTJFAw.js +2 -0
  447. data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
  448. data/web/dist/assets/tiki-DGYXhP31.js +2 -0
  449. data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
  450. data/web/dist/assets/troff-wAsdV37c.js +2 -0
  451. data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
  452. data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
  453. data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
  454. data/web/dist/assets/vb-CmGdzxic.js +2 -0
  455. data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
  456. data/web/dist/assets/velocity-D8B20fx6.js +2 -0
  457. data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
  458. data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
  459. data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
  460. data/web/dist/assets/xquery-DzFWVndE.js +2 -0
  461. data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
  462. data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
  463. data/web/dist/index.html +14 -0
  464. data/web/dist/vite.svg +1 -0
  465. data/web/src/api/fixtures/board.json +512 -0
  466. metadata +907 -0
@@ -0,0 +1,1297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module OpenLanes
6
+ 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
+ # dispatchable, heartbeat the state file, hand the screen a board.
10
+ #
11
+ # Nothing here guesses. An agent that finished says so in its document;
12
+ # the state machine says whether the folder may move; the state file says
13
+ # what an earlier run left behind. When the three disagree the row goes
14
+ # red with the reason, and nobody is re-run to paper over it.
15
+ class Dispatcher
16
+ # One running invocation. `from` is the state the plan showed when the
17
+ # 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
+ # he had never been there (the 020.00 regression, run twice over).
21
+ Job = Struct.new(:key,
22
+ :task,
23
+ :thread,
24
+ :handle,
25
+ :started_at,
26
+ :file,
27
+ :status,
28
+ :message,
29
+ :up,
30
+ :down,
31
+ :subagents,
32
+ :result,
33
+ :from,
34
+ :state,
35
+ :frame,
36
+ :history,
37
+ :context) do
38
+ # @return [Boolean]
39
+ def finished? = !thread.alive?
40
+ end
41
+
42
+ # A plan a person is throwing back (W9): its agents stopping, then its
43
+ # worktree reset and its folder moved back one state. {Live::Commands}
44
+ # holds on to it and replies once {#finished?}.
45
+ #
46
+ # @!attribute [r] ordinal
47
+ # @return [String]
48
+ # @!attribute [r] status
49
+ # @return [String, nil] "done" or "refused", once it is over
50
+ # @!attribute [r] reason
51
+ # @return [String, nil]
52
+ Discard = Struct.new(:ordinal, :status, :reason) do
53
+ # @return [Boolean]
54
+ def finished? = !status.nil?
55
+ end
56
+
57
+ # What publishing did for one Completed build: the attempt with the
58
+ # pull request noted, the plan read again so the row the publisher
59
+ # recorded is in it, and whether the publisher refused.
60
+ #
61
+ # @!attribute [r] attempt
62
+ # @return [OpenLanes::Engine::Runner::Attempt]
63
+ # @!attribute [r] subject
64
+ # @return [OpenLanes::Plans::Subject]
65
+ # @!attribute [r] refused
66
+ # @return [Boolean]
67
+ Published = Data.define(:attempt, :subject, :refused)
68
+
69
+ # A blocked plan whose every question has an answer, being drained by
70
+ # the agent that handles its state, on a thread of its own.
71
+ Unblock = Struct.new(:ordinal, :thread, :outcome)
72
+
73
+ # States a plan waits in for a person's answer.
74
+ BLOCKED = %i[blocked product_blocked].freeze
75
+
76
+ # How long a settled row stays on the screen.
77
+ LINGER = 10
78
+
79
+ # Seconds a kill waits between STOP and SIGKILL.
80
+ KILL_GRACE = 15
81
+
82
+ # Seconds between rescans of the tree while a staying run has nothing
83
+ # 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
87
+
88
+ # Plans with nothing left to do, which an idle staying run reports
89
+ # reaching rather than ending on. Not `Status#terminal?`:
90
+ # only ❌ has no way out, while ✅ and 😎 can still roll back, and a
91
+ # project whose plans all merged is finished in every sense a person
92
+ # means. The page's backlog draws the same line.
93
+ FINISHED = Live::Snapshot::FINISHED
94
+
95
+ # The phase whose Completed notes are verdicts (approved, rejected,
96
+ # slop). Anyone else's note is only a note.
97
+ REVIEW_PHASE = "review"
98
+
99
+ # @param runner [OpenLanes::Engine::Runner]
100
+ # @param state [OpenLanes::Engine::StateFile, nil] nil under a dry run
101
+ # @param rounds [Integer, nil] `--rounds`, a cap that only tightens
102
+ # @param sleeper [Proc] how a tick waits; the suite passes a no-op
103
+ # @param on_board [Proc, nil] receives a {Board} each tick
104
+ # @param stay [Boolean] keep running past every fixed point, idle, until
105
+ # a person stops the run
106
+ # @param rescan [Integer] idle ticks, about a second each, between
107
+ # 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)
110
+ @runner = runner
111
+ @stay = stay
112
+ @rescan = rescan
113
+ @idle = false
114
+ @idle_ticks = 0
115
+ @wake = false
116
+ @said = nil
117
+ @state = state
118
+ @rounds_cap = rounds
119
+ @sleeper = sleeper
120
+ @on_board = on_board
121
+ @running = []
122
+ @recent = []
123
+ @attempts = []
124
+ @history = Hash.new { |h, k| h[k] = [] }
125
+ @finished_plans = []
126
+ @preferred = {}
127
+ @started_at = UI.monotonic
128
+ @mutex = Mutex.new
129
+ @before_tick = []
130
+ @requeued = []
131
+ @discarding = {}
132
+ @fenced = []
133
+ @unblocking = []
134
+ @unblock_tried = {}
135
+ end
136
+
137
+ # @return [Array<OpenLanes::Engine::Dispatcher::Job>]
138
+ attr_reader :running
139
+
140
+ # @return [OpenLanes::Engine::Runner]
141
+ attr_reader :runner
142
+
143
+ # @return [OpenLanes::Engine::StateFile, nil] nil under a dry run
144
+ def state_file = @state
145
+
146
+ # @return [Array<OpenLanes::Engine::Runner::Attempt>]
147
+ attr_reader :attempts
148
+
149
+ # @return [Array<OpenLanes::Engine::Runner::Attempt>]
150
+ def run
151
+ resume_stranded
152
+ loop do
153
+ tick
154
+ break if done?
155
+
156
+ @sleeper.call(1)
157
+ end
158
+ @attempts
159
+ end
160
+
161
+ # One cycle. Public so the suite can drive it by hand.
162
+ #
163
+ # @return [void]
164
+ 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)
175
+ end
176
+
177
+ # A run ends at a fixed point: nothing running and nothing left to
178
+ # start. A staying run (the engine with its control room) never ends on
179
+ # its own, not even once every plan is {FINISHED}: the page can create
180
+ # the next plan at any moment, and a run that had quit would leave it
181
+ # nowhere to go. Only a quit ends it (Stop All Local, Ctrl-C, SIGTERM),
182
+ # once the agents have gone.
183
+ #
184
+ # @return [Boolean]
185
+ def done?
186
+ return false unless quiet?
187
+ return true if Execution::Control.quit?
188
+ return false if @stay
189
+
190
+ next_dispatchable.nil?
191
+ end
192
+
193
+ # @return [Boolean] whether this run idles past its fixed points
194
+ def stay? = @stay
195
+
196
+ # Rescan on the next tick rather than at the end of the interval: a
197
+ # command was applied, and what it changed should be acted on now.
198
+ #
199
+ # @return [void]
200
+ def wake! = @wake = true
201
+
202
+ # Kill from a view: STOP, {KILL_GRACE} seconds, SIGKILL, on a thread
203
+ # so the tick is not held for fifteen seconds.
204
+ #
205
+ # @param key [String] a row's key
206
+ # @return [void]
207
+ def kill(key)
208
+ job = @running.find { |j| j.key == key } or return
209
+ Thread.new { job.handle.kill!(grace: KILL_GRACE) }
210
+ end
211
+
212
+ # More time, from a view.
213
+ #
214
+ # @param key [String]
215
+ # @param seconds [Integer]
216
+ # @return [void]
217
+ def extend(key, seconds)
218
+ @running.find { |j| j.key == key }&.handle&.extend!(seconds)
219
+ end
220
+
221
+ # Something to run at the top of every tick, on the loop thread, where
222
+ # {#running} may be read and changed safely: the live run applies the
223
+ # page's commands here.
224
+ #
225
+ # @yieldparam dispatcher [OpenLanes::Engine::Dispatcher]
226
+ # @return [void]
227
+ def before_tick(&hook) = @before_tick << hook
228
+
229
+ # @param key [String] a row's key, `"<ordinal>/<agent>"`
230
+ # @return [OpenLanes::Engine::Dispatcher::Job, nil]
231
+ def job(key) = @running.find { |j| j.key == key }
232
+
233
+ # One control word into one running agent's control file.
234
+ #
235
+ # @param key [String]
236
+ # @param word [String] one of {Execution::Control}'s words
237
+ # @return [Boolean] whether there was a running agent to tell
238
+ def signal(key, word)
239
+ control = job(key)&.handle&.control or return false
240
+ Execution::Control.write(control, word)
241
+ true
242
+ end
243
+
244
+ # Stop the agent with a resume note and give it its round back, so it
245
+ # is started again, on whatever model is now preferred, as soon as it
246
+ # has stopped. The agent is told INTERRUPT, which is the word that
247
+ # makes it leave the note.
248
+ #
249
+ # @param key [String]
250
+ # @return [Boolean] whether there was a running agent to restart
251
+ def requeue(key)
252
+ signal(key, Execution::Control::INTERRUPT) or return false
253
+ @requeued |= [key]
254
+ true
255
+ end
256
+
257
+ # Compact one running agent's session and carry it on (W28): stopped
258
+ # with INTERRUPT, its session compacted with `--resume`, then resumed
259
+ # under the same handle, so the board shows one car carrying on. The
260
+ # executor does the work, on the agent's own thread.
261
+ #
262
+ # @param key [String]
263
+ # @param model [String, nil] what the session carries on in; nil keeps
264
+ # the model it was on
265
+ # @return [OpenLanes::Execution::Executor::Compaction, nil] answered once
266
+ # the continuation has started; nil when nothing by that key is running
267
+ def compact(key, model: nil) = job(key)&.handle&.compact!(model:)
268
+
269
+ # The CLI a running agent was started on, which is what decides whether
270
+ # its session can be resumed.
271
+ #
272
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
273
+ # @return [OpenLanes::Adapters::Base]
274
+ def adapter_for(job)
275
+ executor = @runner.executor
276
+ agent = job.task.agent
277
+ return Adapters.for(agent.adapter) unless executor.respond_to?(:profile_for)
278
+
279
+ executor.profile_for(agent, job.task.subject).adapter
280
+ end
281
+
282
+ # Hand back the round an interrupted agent lost, so a person's "resume"
283
+ # starts it again even when it had no rounds left.
284
+ #
285
+ # @param ordinal [#to_s]
286
+ # @param agent [String]
287
+ # @return [Boolean] whether there was an interrupted round to hand back
288
+ def resume(ordinal, agent)
289
+ @mutex.synchronize do
290
+ entries = @history[[ordinal.to_s, agent.to_s]]
291
+ last = entries.rindex { |h| h[:status] == "Interrupted" } or next false
292
+ entries.delete_at(last)
293
+ true
294
+ end
295
+ end
296
+
297
+ # The model one agent gets on one plan from its next invocation (W24).
298
+ #
299
+ # @param ordinal [#to_s]
300
+ # @param agent [String]
301
+ # @param model [String, nil] nil forgets the choice
302
+ # @return [Boolean] whether the executor can honour it
303
+ def prefer_model(ordinal, agent, model)
304
+ executor = @runner.executor
305
+ return false unless executor.respond_to?(:prefer_model)
306
+
307
+ executor.prefer_model(ordinal, agent, model)
308
+ @models&.delete([ordinal.to_s, agent.to_s])
309
+ true
310
+ end
311
+
312
+ # Throw a plan's work away (W9): every agent on it is stopped, each with
313
+ # {KILL_GRACE} seconds to finish the line it is writing, and once the
314
+ # last is gone the plan's worktree is reset and its folder sent back
315
+ # one state. Plan-wide, because a pair shares one worktree and a reset
316
+ # takes both halves. What can be refused is refused here, before any
317
+ # agent is stopped.
318
+ #
319
+ # @param ordinal [#to_s]
320
+ # @return [OpenLanes::Engine::Dispatcher::Discard] finished on a later tick
321
+ # @raise [OpenLanes::Error] with the reason it cannot be done
322
+ def discard(ordinal)
323
+ ordinal = ordinal.to_s
324
+ return @discarding[ordinal] if @discarding.key?(ordinal)
325
+
326
+ if !@runner.isolated? || @runner.dry_run?
327
+ raise OpenLanes::Error,
328
+ "discard resets the plan's own worktree, and this run has none: under --isolation shared " \
329
+ "or a dry run every agent works in the main checkout, which a reset would wipe. " \
330
+ "Use stop on each agent instead."
331
+ end
332
+ subject = fresh_by(Plans::Ordinal.parse(ordinal)) or raise OpenLanes::Error, "there is no plan #{ordinal}"
333
+ refusal = subject.machine.retreat_refusal and raise OpenLanes::Error, "#{ordinal} cannot go back: #{refusal}"
334
+
335
+ @running.select { plan_of(it) == ordinal }.each do |job|
336
+ Thread.new { job.handle.kill!(grace: KILL_GRACE, reason: :discard) }
337
+ end
338
+ @discarding[ordinal] = Discard.new(ordinal:)
339
+ end
340
+
341
+ # Plans whose claim another machine now holds (W26): their agents are
342
+ # stopped, each with {KILL_GRACE} seconds, the plans leave this run,
343
+ # and nothing this node does afterwards signs or renames for them.
344
+ #
345
+ # @param ordinals [Array<String>]
346
+ # @return [void]
347
+ def fence(ordinals)
348
+ ordinals.map(&:to_s).each do |ordinal|
349
+ @fenced |= [ordinal]
350
+ @runner.drop_plan(ordinal)
351
+ @running.select { plan_of(it) == ordinal }.each do |job|
352
+ UI.log("claim lost; stopping", **job.task.log_fields)
353
+ Thread.new { job.handle.kill!(grace: KILL_GRACE, reason: :fenced) }
354
+ end
355
+ end
356
+ end
357
+
358
+ # @return [OpenLanes::Engine::Board]
359
+ def board
360
+ up = @attempts.sum(&:up) + @running.sum { |j| j.up.to_i }
361
+ down = @attempts.sum(&:down) + @running.sum { |j| j.down.to_i }
362
+ Board.new(started_at: @started_at,
363
+ status: run_status,
364
+ plans: (@running.map { |j| j.task.subject.feature.ordinal.to_s } + @recent.map { |r| r[:row].ordinal } +
365
+ waiting).uniq,
366
+ up:,
367
+ down:,
368
+ rows: rows,
369
+ root: @runner.root,
370
+ running: @running.size,
371
+ live_up: @running.sum { |j| j.up.to_i },
372
+ live_down: @running.sum { |j| j.down.to_i })
373
+ end
374
+
375
+ private
376
+
377
+ # @return [Symbol]
378
+ def run_status
379
+ return :quitting if Execution::Control.quit?
380
+ return :wrapping_up if @running.any? { |j| %i[wrap_up stopped].include?(j.handle.phase) }
381
+ return :idle if @idle && quiet?
382
+
383
+ :running
384
+ end
385
+
386
+ # @return [Array<OpenLanes::Engine::Board::Row>]
387
+ def rows
388
+ now = UI.monotonic
389
+ @recent.reject! { |r| now - r[:at] > LINGER }
390
+ @running.map { |job| row_for(job) } + @recent.map { |r| r[:row] }
391
+ end
392
+
393
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
394
+ # @return [String] the plan's ordinal
395
+ def plan_of(job) = job.task.subject.feature.ordinal.to_s
396
+
397
+ # Plans no agent may be started on and no rename may touch: one being
398
+ # discarded or drained, and one another machine holds (W26).
399
+ #
400
+ # @return [Array<String>]
401
+ 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 }
404
+ end
405
+
406
+ # ---- discarding -------------------------------------------------------
407
+
408
+ # Discards whose agents have all gone: the worktree is reset and the
409
+ # folder moved back. A finished one stays one tick, for
410
+ # {Live::Commands} to reply from, and is dropped on the next.
411
+ #
412
+ # @return [void]
413
+ def finish_discards
414
+ @discarding.delete_if { |_, discard| discard.finished? }
415
+ @discarding.each_value do |discard|
416
+ next if @running.any? { plan_of(it) == discard.ordinal }
417
+
418
+ discard.status, discard.reason = throw_back(discard.ordinal)
419
+ end
420
+ end
421
+
422
+ # @param ordinal [String]
423
+ # @return [Array(String, String)] status and reason
424
+ def throw_back(ordinal)
425
+ subject = fresh_by(Plans::Ordinal.parse(ordinal)) or return ["refused", "#{ordinal}'s folder vanished"]
426
+ reset = @runner.worktree.reset(subject.feature)
427
+ from = subject.status
428
+ to = subject.machine.retreat!
429
+ @finished_plans.delete(ordinal)
430
+ UI.log("discarded: #{from} -> #{to}", plan: ordinal)
431
+ ["done", "#{ordinal} #{reset ? "reset" : "had no worktree to reset"}, and went back from #{from} to #{to}"]
432
+ rescue OpenLanes::Error => e
433
+ ["refused", "#{ordinal}'s agents were stopped, but: #{e.message}"]
434
+ end
435
+
436
+ # ---- unblocking -------------------------------------------------------
437
+
438
+ # A blocked plan whose questions are all answered goes to the agent
439
+ # that handles its state, the way `openlanes unblock` hands it over
440
+ # (W10): the page's answer is what a person typing that command used
441
+ # 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
443
+ # settled first, so the plan is picked up again in this run.
444
+ #
445
+ # @return [void]
446
+ def unblock_answered
447
+ @unblocking.reject { it.thread.alive? }.each { settle_unblock(it) }
448
+ return if @runner.dry_run? || Execution::Control.quit?
449
+
450
+ blocked = @runner.in_scope.select { BLOCKED.include?(it.status.key) }
451
+ return if blocked.empty?
452
+
453
+ busy = untouchable
454
+ blocked.each do |subject|
455
+ ordinal = subject.feature.ordinal.to_s
456
+ next if busy.include?(ordinal)
457
+
458
+ questions = Lifecycle::Unblocker.questions(subject)
459
+ next if questions.empty? || !questions.all?(&:answered)
460
+
461
+ answers = subject.read("blocked.md")
462
+ next if @unblock_tried[ordinal] == answers
463
+
464
+ # The drainer advances nothing itself, so {Registry#for_status},
465
+ # which offers only agents that move a plan on, never names it.
466
+ agent = @runner.agents.all.find { it.handles?(subject.status) } or next
467
+ next unless @runner.claim(subject)
468
+
469
+ @unblock_tried[ordinal] = answers
470
+ start_unblock(agent, subject)
471
+ end
472
+ end
473
+
474
+ # @param agent [OpenLanes::Agents::Agent]
475
+ # @param subject [OpenLanes::Plans::Subject]
476
+ # @return [void]
477
+ def start_unblock(agent, subject)
478
+ unblocker = Lifecycle::Unblocker.new(tree: Plans::Tree.new(dir: @runner.tree.dir),
479
+ agent:,
480
+ executor: @runner.executor,
481
+ commit: true)
482
+ 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)
484
+ unblock.thread = Thread.new do
485
+ Thread.current.report_on_exception = false
486
+ unblock.outcome = begin
487
+ unblocker.drain(subject)
488
+ rescue StandardError => e
489
+ e
490
+ end
491
+ end
492
+ @unblocking << unblock
493
+ end
494
+
495
+ # A drained plan's agents get their rounds back: a block is a question,
496
+ # not the agent running out of turns.
497
+ #
498
+ # @param unblock [OpenLanes::Engine::Dispatcher::Unblock]
499
+ # @return [void]
500
+ def settle_unblock(unblock)
501
+ @unblocking.delete(unblock)
502
+ outcome = unblock.outcome
503
+ if outcome.is_a?(Exception) || !outcome.ok
504
+ note = outcome.is_a?(Exception) ? outcome.message : outcome.note
505
+ return UI.log("unblock failed: #{note}", plan: unblock.ordinal)
506
+ end
507
+
508
+ @mutex.synchronize do
509
+ @history.each { |(ordinal, _), entries| entries.reject! { it[:status] == "Blocked" } if ordinal == unblock.ordinal }
510
+ end
511
+ @finished_plans.delete(unblock.ordinal)
512
+ wake!
513
+ UI.log(outcome.cleared? ? "unblocked: blocked.md retired" : "unblock folded #{outcome.folded.size}",
514
+ plan: unblock.ordinal)
515
+ end
516
+
517
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
518
+ # @return [OpenLanes::Engine::Board::Row]
519
+ def row_for(job)
520
+ task = job.task
521
+ job.frame = job.frame.to_i + 1
522
+ Board::Row.new(key: job.key,
523
+ at: Time.now,
524
+ ordinal: task.subject.feature.ordinal.to_s,
525
+ file: job.file || task.agent.ledger.first.to_s,
526
+ agent: task.agent.name,
527
+ role: task.agent.role,
528
+ round: task.round,
529
+ rounds: rounds_for(task.agent),
530
+ model: model_for(task.agent, task.subject),
531
+ remaining: job.handle.remaining,
532
+ phase: job.handle.phase,
533
+ up: job.up.to_i,
534
+ down: job.down.to_i,
535
+ message: job.message,
536
+ state: :running,
537
+ pr: pr_for(task),
538
+ frame: job.frame,
539
+ elapsed: (UI.monotonic - job.started_at).round,
540
+ subagents: job.subagents.to_i,
541
+ history: job.history || [],
542
+ context: job.context,
543
+ since: job.started_at,
544
+ session: job.handle.session)
545
+ end
546
+
547
+ # @param task [OpenLanes::Engine::Runner::Task]
548
+ # @return [Hash, nil]
549
+ def pr_for(task)
550
+ return nil unless task.agent.ledger.include?("pull-requests.md")
551
+
552
+ subject = Plans::Tree.new(dir: @runner.tree.dir).find(task.subject.feature.ordinal) or return nil
553
+ pull = subject.pull_requests.find(&:open?) or return nil
554
+ { number: pull.number, url: pull.url, rejected: subject.status.key == :rejected }
555
+ end
556
+
557
+ # @param agent [OpenLanes::Agents::Agent]
558
+ # @return [Integer]
559
+ def rounds_for(agent) = [agent.rounds, @rounds_cap].compact.min
560
+
561
+ # @param agent [OpenLanes::Agents::Agent]
562
+ # @param subject [OpenLanes::Plans::Subject, nil] whose spec.md may override it
563
+ # @return [String]
564
+ def model_for(agent, subject = nil)
565
+ executor = @runner.executor
566
+ unless subject && executor.respond_to?(:profile_for)
567
+ return (executor.respond_to?(:model) && executor.model) || agent.model || "default"
568
+ end
569
+
570
+ # Asked on every frame of the board, so spec.md is read once per plan
571
+ # and agent rather than once per frame.
572
+ (@models ||= {})[[subject.feature.ordinal.to_s, agent.name]] ||= executor.profile_for(agent, subject).label
573
+ end
574
+
575
+ # ---- starting ---------------------------------------------------------
576
+
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.
579
+ #
580
+ # 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.
584
+ #
585
+ # @return [void]
586
+ def resume_stranded
587
+ resumed = []
588
+ if @state
589
+ @state.load
590
+ @state.stranded.each do |ordinal, stage|
591
+ subject = @runner.tree.reload.find(Plans::Ordinal.parse(ordinal)) or next
592
+ next if @runner.foreign?(subject)
593
+
594
+ resumed << interrupt_stranded(subject, stage["agent"], stage["round"])
595
+ end
596
+ end
597
+ unless @runner.dry_run?
598
+ @runner.in_scope.reject { @runner.foreign?(it) }.each do |subject|
599
+ Plans::PlanState.for(subject.feature.path).stranded.each do |stage|
600
+ next if resumed.include?([subject.feature.ordinal.to_s, stage["agent"], stage["round"]])
601
+
602
+ resumed << interrupt_stranded(subject, stage["agent"], stage["round"])
603
+ end
604
+ end
605
+ end
606
+ @state&.begin_run!(root: @runner.root)
607
+ end
608
+
609
+ # @param subject [OpenLanes::Plans::Subject]
610
+ # @param name [String] the agent
611
+ # @param round [Integer]
612
+ # @return [Array(String, String, Integer), nil] what was resumed
613
+ def interrupt_stranded(subject, name, round)
614
+ agent = @runner.agents.find(name) or return nil
615
+ ordinal = subject.feature.ordinal.to_s
616
+ write_interrupted(subject, agent, round, "harness died")
617
+ remember(ordinal, agent.name, subject.status.key, "Interrupted", round)
618
+ @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)
620
+ [ordinal, agent.name, round]
621
+ end
622
+
623
+ # @return [void]
624
+ def dispatch
625
+ started = 0
626
+ 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)
630
+
631
+ start(*pair)
632
+ started += 1
633
+ end
634
+ settle_idle(started) if @stay
635
+ end
636
+
637
+ # ---- staying ----------------------------------------------------------
638
+
639
+ # Nothing running, draining or being thrown back.
640
+ #
641
+ # @return [Boolean]
642
+ def quiet? = @running.empty? && @unblocking.empty? && @discarding.empty?
643
+
644
+ # Whether an idle staying run skips this tick's dispatch. Dispatching
645
+ # 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
647
+ # looks again every {RESCAN} ticks, or at once when woken. Commands,
648
+ # drains and the board still run every tick; only the scan waits.
649
+ #
650
+ # @return [Boolean]
651
+ def resting?
652
+ return false unless @stay && @idle && quiet?
653
+
654
+ @idle_ticks += 1
655
+ return true unless @wake || @idle_ticks >= @rescan
656
+
657
+ @wake = false
658
+ @idle_ticks = 0
659
+ false
660
+ end
661
+
662
+ # After a dispatch that started nobody: idle when nothing else is under
663
+ # way, and said once per change, so the log reads as a story rather
664
+ # than a heartbeat.
665
+ #
666
+ # @param started [Integer] agents this dispatch started
667
+ # @return [void]
668
+ def settle_idle(started)
669
+ @idle = started.zero? && quiet?
670
+ subjects = @runner.in_scope
671
+ said = @idle ? idle_reason(subjects) : nil
672
+ UI.log(said) if said && said != @said
673
+ @said = said
674
+ end
675
+
676
+ # @param subjects [Array<OpenLanes::Plans::Subject>] the plans in scope
677
+ # @return [String] what an idle run is waiting for
678
+ def idle_reason(subjects)
679
+ return "idle: no plans yet; create one from the page or with openlanes create" if subjects.empty?
680
+ if subjects.all? { FINISHED.include?(it.status.key) }
681
+ return "idle: every plan is finished; create the next one from the page"
682
+ end
683
+
684
+ blocked = waiting
685
+ return "idle: waiting for answers on #{blocked.join(", ")}" unless blocked.empty?
686
+
687
+ "idle: nothing to do"
688
+ end
689
+
690
+ # Blocked plans in scope that this machine may answer for: on the board
691
+ # whether or not an agent touched them this run, so their questions
692
+ # reach the page from the moment the run starts.
693
+ #
694
+ # @return [Array<String>] ordinals
695
+ def waiting
696
+ @runner.blocked.reject { @runner.foreign?(it) }.map { it.feature.ordinal.to_s }
697
+ end
698
+
699
+ # The next agent and plan to start, honouring a `next:` an agent wrote
700
+ # over definition order.
701
+ #
702
+ # @return [Array(OpenLanes::Agents::Agent, OpenLanes::Plans::Subject), nil]
703
+ def next_dispatchable
704
+ reconcile
705
+ @runner.in_scope.each do |subject|
706
+ # 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)
709
+ next if Plans::StateMachine::SETTLED.include?(state.key)
710
+ next if @finished_plans.include?(subject.feature.ordinal.to_s)
711
+ next if @runner.foreign?(subject) || @discarding.key?(subject.feature.ordinal.to_s)
712
+
713
+ candidates = Agents::Routing.filter(@runner.agents.for_status(state), subject)
714
+ preferred = @preferred[subject.feature.ordinal.to_s]
715
+ candidates = candidates.sort_by { |a| a.name == preferred ? 0 : 1 } if preferred
716
+ agent = candidates.find { |a| eligible?(a, subject) }
717
+ return [agent, subject] if agent
718
+ end
719
+ nil
720
+ end
721
+
722
+ # A folder's name can lag its contents: a run killed before its rename,
723
+ # a plan.md written by hand, a resync nobody ran. So the names are
724
+ # 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
726
+ # name rather than to leah-researcher under its stale one. Plans an
727
+ # agent is working in right now are left alone: the prompt names the
728
+ # folder by path, and moving it under a running agent would strand
729
+ # every write it makes afterwards. Nor is a plan another machine holds
730
+ # touched: its name is that machine's to change.
731
+ #
732
+ # @return [void]
733
+ def reconcile
734
+ busy = untouchable
735
+ Lifecycle::Resync::Dirs.new(tree: @runner.tree.reload, except: busy).call(commit: !@runner.dry_run?)
736
+ fast_forward(busy) unless @runner.dry_run?
737
+ end
738
+
739
+ # Plans whose lane skips research, rewriting or planning are moved past
740
+ # those phases before anyone is assigned, so the first agent they meet
741
+ # is the one their lane starts with.
742
+ #
743
+ # @param busy [Array<String>] ordinals an agent is working right now
744
+ # @return [void]
745
+ def fast_forward(busy)
746
+ @runner.in_scope.each do |subject|
747
+ next if busy.include?(subject.feature.ordinal.to_s)
748
+
749
+ moved = Lifecycle::Lanes.advance(subject, commit: true) or next
750
+ UI.log("lane: #{moved.first} -> #{moved.last}", plan: subject.feature.ordinal.to_s)
751
+ end
752
+ end
753
+
754
+ # @param agent [OpenLanes::Agents::Agent]
755
+ # @param subject [OpenLanes::Plans::Subject]
756
+ # @return [Boolean]
757
+ def eligible?(agent, subject)
758
+ ordinal = subject.feature.ordinal.to_s
759
+ return false if @running.any? { |j| j.task.agent.name == agent.name && j.task.subject.feature.ordinal.to_s == ordinal }
760
+
761
+ past = @history[[ordinal, agent.name]].select { |h| h[:state] == subject.status.key }
762
+ return true if past.empty?
763
+ return false if %w[Completed Blocked].include?(past.last[:status])
764
+
765
+ past.size < rounds_for(agent)
766
+ end
767
+
768
+ # @param agent [OpenLanes::Agents::Agent]
769
+ # @param subject [OpenLanes::Plans::Subject]
770
+ # @return [void]
771
+ def start(agent, subject)
772
+ ordinal = subject.feature.ordinal.to_s
773
+ round = @history[[ordinal, agent.name]].count { |h| h[:state] == subject.status.key } + 1
774
+ from = subject.status.key
775
+ subject = rename_on_start(subject, agent) unless @runner.dry_run?
776
+ task = @runner.prepare(agent, subject, round)
777
+ handle = Execution::Executor::Handle.new
778
+ job = Job.new(key: "#{ordinal}/#{agent.name}",
779
+ task:,
780
+ handle:,
781
+ started_at: UI.monotonic,
782
+ from:,
783
+ state: subject.status.key,
784
+ up: 0,
785
+ down: 0,
786
+ subagents: 0,
787
+ frame: 0)
788
+ if agent.advances_to && Plans::STATUS_BY_KEY.key?(agent.advances_to)
789
+ successor = Agents::Routing.filter(@runner.agents.for_status(Plans::STATUS_BY_KEY.fetch(agent.advances_to)),
790
+ subject).first&.name
791
+ end
792
+ # Each member of a pair is told who the others are, so the executor
793
+ # can name them beside the plan's mailbox. A partner routing would
794
+ # never start is not a partner.
795
+ team = @runner.agents.team_for(Lifecycle::Resync::Dirs.target(subject))
796
+ partners = Agents::Routing.filter(team, subject) - [agent]
797
+ remember(ordinal, agent.name, job.state, "Started", round)
798
+ stage!(subject,
799
+ agent,
800
+ round,
801
+ status: "Started",
802
+ from: from.to_s,
803
+ started_at: Time.now.iso8601,
804
+ run: { pid: Process.pid, host: Socket.gethostname },
805
+ **profile_fields(agent, subject))
806
+ @state&.record(ordinal,
807
+ agent: agent.name,
808
+ round:,
809
+ status: "Started",
810
+ state: from.to_s,
811
+ model: model_for(agent, subject),
812
+ file: agent.ledger.first,
813
+ started_at: Time.now.iso8601)
814
+ UI.log("started", **task.log_fields)
815
+ job.thread = Thread.new do
816
+ Thread.current.report_on_exception = false
817
+ job.result = begin
818
+ @runner.executor.call(agent, subject, root: task.root, round:, successor:, handle:, partners:) { |progress|
819
+ job.up = progress.up
820
+ job.down = progress.down
821
+ job.subagents = progress.subagents
822
+ job.context = progress.context
823
+ job.message = progress.message || progress.activity
824
+ job.history = Board::Row.remember(job.history || [], job.message)
825
+ }
826
+ rescue StandardError => e
827
+ e
828
+ end
829
+ end
830
+ @running << job
831
+ end
832
+
833
+ # `starts_as`: the folder moves the moment the agent is dispatched, where
834
+ # the topology allows, so luke's start is what makes a plan 🟡.
835
+ #
836
+ # @return [OpenLanes::Plans::Subject] fresh, under whichever name it now has
837
+ def rename_on_start(subject, agent)
838
+ target = agent.starts_as
839
+ return subject unless target && subject.machine.may?(target)
840
+
841
+ subject.machine.promote!(target)
842
+ Plans::Tree.new(dir: @runner.tree.dir).find(subject.feature.ordinal) || subject
843
+ end
844
+
845
+ # ---- polling ----------------------------------------------------------
846
+
847
+ # @return [void]
848
+ def poll
849
+ @running.each do |job|
850
+ reading = read_ledger(job.task)
851
+ entry = Plans::Ledger.last_for(reading, job.task.agent.name)
852
+ job.file = entry&.file || job.file
853
+ job.status = entry&.status
854
+ @state&.record(job.task.subject.feature.ordinal.to_s,
855
+ agent: job.task.agent.name,
856
+ round: job.task.round,
857
+ status: entry&.status || "Started",
858
+ file: job.file,
859
+ up: job.up,
860
+ down: job.down,
861
+ pid: job.handle.pid,
862
+ **{ session: job.handle.session }.compact)
863
+ end
864
+ end
865
+
866
+ # @param task [OpenLanes::Engine::Runner::Task]
867
+ # @return [OpenLanes::Plans::Ledger::Reading]
868
+ def read_ledger(task)
869
+ subject = Plans::Tree.new(dir: @runner.tree.dir).find(task.subject.feature.ordinal) or return Plans::Ledger::Reading.empty
870
+ Plans::Ledger.read(subject.feature.path, task.agent.ledger)
871
+ end
872
+
873
+ # ---- settling ---------------------------------------------------------
874
+
875
+ # @return [void]
876
+ def reap
877
+ @running.select(&:finished?).each do |job|
878
+ @running.delete(job)
879
+ attempt = settle(job)
880
+ # A requeue, or a compaction that stopped the agent and then could
881
+ # not happen, hands the round back: the stop was a person's choice.
882
+ requeued = @requeued.delete(job.key)
883
+ forget_round(job) if requeued || job.handle.compaction&.restart
884
+ @attempts << attempt
885
+ row = row_for(job)
886
+ @recent << { at: UI.monotonic, row: row.with(state: attempt.ok ? :done : :failed,
887
+ message: attempt.note,
888
+ history: Board::Row.remember(row.history, attempt.note),
889
+ remaining: nil,
890
+ phase: nil,
891
+ bold: !attempt.ok) }
892
+ UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}", **job.task.log_fields)
893
+ end
894
+ end
895
+
896
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
897
+ # @return [OpenLanes::Engine::Runner::Attempt]
898
+ def settle(job)
899
+ task = job.task
900
+ agent = task.agent
901
+ ordinal = task.subject.feature.ordinal.to_s
902
+ result = job.result
903
+ base = attempt_for(job)
904
+ return crashed(job, base, result) if result.is_a?(Exception)
905
+ return base.with(note: result.note) if @runner.dry_run?
906
+
907
+ reading = read_ledger(task)
908
+ entry = Plans::Ledger.last_for(reading, agent.name)
909
+ entry = nil if entry && entry.round != task.round
910
+ problems = reading.problems.map { |p| "#{p.file}:#{p.line} unreadable ledger line" }
911
+
912
+ attempt = if (why = stopped_for(ordinal))
913
+ stopped_by_harness(job, base, why)
914
+ elsif result.killed || (result.ok && (entry.nil? || entry.status == "Started"))
915
+ verify_and_sign(job, base, reason_for(result))
916
+ elsif !result.ok
917
+ remember(ordinal, agent.name, job.state, "Interrupted", task.round)
918
+ base.with(ok: false, note: result.note)
919
+ elsif entry.completed?
920
+ complete(job, base, entry, reading)
921
+ elsif entry.blocked?
922
+ park(job, base, entry)
923
+ else
924
+ remember(ordinal, agent.name, job.state, entry.status, task.round)
925
+ base.with(status: entry.status, note: retry_note(agent, entry))
926
+ end
927
+ attempt = attempt.with(note: "#{attempt.note}; #{problems.join(", ")}") unless problems.empty?
928
+ attempt = attempt.with(to: fresh(task)&.status&.key || attempt.to)
929
+ if (settled = fresh(task))
930
+ stage!(settled,
931
+ agent,
932
+ task.round,
933
+ status: attempt.status || (attempt.ok ? "Completed" : "Interrupted"),
934
+ to: attempt.to.to_s,
935
+ finished_at: Time.now.iso8601,
936
+ up: attempt.up.to_i,
937
+ down: attempt.down.to_i,
938
+ seconds: attempt.seconds.to_f.round(1),
939
+ note: attempt.note.to_s)
940
+ end
941
+ @state&.record(ordinal,
942
+ agent: agent.name,
943
+ round: task.round,
944
+ status: attempt.status || "Completed",
945
+ exit: attempt.ok ? "ok" : attempt.note,
946
+ ended_at: Time.now.iso8601,
947
+ up: attempt.up,
948
+ down: attempt.down)
949
+ attempt
950
+ end
951
+
952
+ # @param ordinal [String]
953
+ # @return [String, nil] why the harness stopped the plan's agents, if it did
954
+ def stopped_for(ordinal)
955
+ return "claim lost to another machine" if @fenced.include?(ordinal)
956
+
957
+ "discarded by a person" if @discarding.key?(ordinal)
958
+ end
959
+
960
+ # An agent stopped because its plan was discarded or taken by another
961
+ # machine: what it left is about to be thrown away, or is no longer
962
+ # this node's to judge, so the harness signs nothing for it.
963
+ #
964
+ # @return [OpenLanes::Engine::Runner::Attempt]
965
+ def stopped_by_harness(job, base, why)
966
+ task = job.task
967
+ subject = fresh(task)
968
+ write_interrupted(subject, task.agent, task.round, why) if subject
969
+ remember(plan_of(job), task.agent.name, job.state, "Interrupted", task.round)
970
+ base.with(ok: false, status: "Interrupted", note: why)
971
+ end
972
+
973
+ # @param result [OpenLanes::Execution::Executor::Result]
974
+ # @return [String]
975
+ def reason_for(result)
976
+ case result.killed
977
+ when :key then "killed by harness after #{KILL_GRACE}s grace"
978
+ when :timeout then "timed out, killed #{Execution::Clock::GRACE}s after STOP"
979
+ when :quit then "interrupted, killed #{Execution::Control::GRACE}s after #{Execution::Control.interrupted? ? "ctrl-c" : "q"}"
980
+ else Execution::Control.interrupted? ? "interrupted by ctrl-c" : "no closing ledger line"
981
+ end
982
+ end
983
+
984
+ # @return [String]
985
+ def retry_note(agent, entry)
986
+ left = rounds_for(agent) - entry.round
987
+ left.positive? ? "#{entry.status}; #{left} round#{"s" unless left == 1} left" : "#{entry.status}; no rounds left"
988
+ end
989
+
990
+ # The agent stopped without saying it finished. If the state it was
991
+ # 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.
993
+ #
994
+ # @return [OpenLanes::Engine::Runner::Attempt]
995
+ def verify_and_sign(job, base, reason)
996
+ task = job.task
997
+ agent = task.agent
998
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
999
+ write_interrupted(subject, agent, task.round, reason)
1000
+ target = agent.advances_to
1001
+ if target && subject.machine.may?(target)
1002
+ 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)
1012
+ entry = Plans::Ledger.last_for(read_ledger(task), agent.name)
1013
+ complete(job, base.with(note: "#{reason}; signed by harness"), entry, read_ledger(task))
1014
+ else
1015
+ 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))}")
1017
+ end
1018
+ end
1019
+
1020
+ # @return [void]
1021
+ 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)
1031
+ end
1032
+
1033
+ # A Completed entry: publish if the move is to 🟢, then move the folder,
1034
+ # and honour the `next:` line.
1035
+ #
1036
+ # @return [OpenLanes::Engine::Runner::Attempt]
1037
+ def complete(job, base, entry, reading)
1038
+ task = job.task
1039
+ agent = task.agent
1040
+ ordinal = task.subject.feature.ordinal.to_s
1041
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
1042
+ remember(ordinal, agent.name, job.state, "Completed", task.round)
1043
+ Lifecycle::Completion.copy(subject) if agent.ledger.include?("plan.md")
1044
+ # Only a reviewer rules. A builder that signed `Completed (approved)`
1045
+ # once had its own work taken as approved: the plan was parked as
1046
+ # finished at 🟡, never reviewed and never published.
1047
+ verdict = verdict_of(entry) if agent.phase.to_s == REVIEW_PHASE
1048
+ partner_running = @running.any? { |j| j.task.subject.feature.ordinal.to_s == ordinal }
1049
+ target = target_for(agent, verdict, partner_running)
1050
+
1051
+ if verdict == :approved
1052
+ @finished_plans << ordinal
1053
+ return base.with(status: "Completed", note: "#{base.note}; approved, awaiting merge")
1054
+ end
1055
+
1056
+ attempt = base.with(status: "Completed")
1057
+ return built_locally(attempt, ordinal) if local_only_review?(target, subject)
1058
+
1059
+ if target
1060
+ attempt = attempt.with(note: "#{base.note}#{" (#{entry.note})" if entry.note}")
1061
+ published = publish_for_review(task, target, subject, attempt)
1062
+ return unpublished(published, ordinal) if published.refused
1063
+
1064
+ attempt = published.attempt
1065
+ 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)
1067
+
1068
+ subject.machine.promote!(target)
1069
+ attempt = attempt.with(to: target)
1070
+ end
1071
+
1072
+ handoff = Plans::Ledger.handoff_after(reading, entry)
1073
+ attempt = honour(handoff, subject, attempt) if handoff
1074
+ attempt
1075
+ end
1076
+
1077
+ # A build finished in a run that opens no pull request: 🟢 needs one,
1078
+ # so the folder cannot move, and failing the attempt would only start
1079
+ # the builder again on work that is done. Only the missing pull request
1080
+ # is forgiven, and pull-requests.md with it, where the pull request
1081
+ # would be recorded: a build that deleted another file 🟢 requires
1082
+ # (spec.md, plan.md) is still reported, rather than parked as done. 🟡's own
1083
+ # invariant is not asked, since a quick-lane plan.md has no work units.
1084
+ #
1085
+ # @param target [Symbol, nil]
1086
+ # @param subject [OpenLanes::Plans::Subject]
1087
+ # @return [Boolean]
1088
+ def local_only_review?(target, subject)
1089
+ return false unless target == :ready_for_review && !@runner.publishes? && !subject.machine.may?(target)
1090
+
1091
+ (Plans::STATUS_BY_KEY.fetch(target).requires - %w[pull-requests.md]).all? { subject.file?(it) }
1092
+ end
1093
+
1094
+ # The build stays at 🟡, finished for this run, saying why.
1095
+ #
1096
+ # @param attempt [OpenLanes::Engine::Runner::Attempt]
1097
+ # @param ordinal [String]
1098
+ # @return [OpenLanes::Engine::Runner::Attempt]
1099
+ def built_locally(attempt, ordinal)
1100
+ @finished_plans << ordinal
1101
+ attempt.with(note: "#{attempt.note}; built locally, no pull request in this run, so it waits for one")
1102
+ end
1103
+
1104
+ # @param entry [OpenLanes::Plans::Ledger::Entry]
1105
+ # @return [Symbol, nil] :rejected, :approved, :slop, :product or nil
1106
+ def verdict_of(entry)
1107
+ note = entry.note.to_s.downcase
1108
+ return :rejected if note.include?("reject")
1109
+ return :approved if note.include?("approv")
1110
+ return :slop if note.include?("slop") || note.include?("rewrite")
1111
+ return :product if note.include?("product")
1112
+
1113
+ nil
1114
+ end
1115
+
1116
+ # @return [Symbol, nil] where the folder goes
1117
+ def target_for(agent, verdict, partner_running)
1118
+ case verdict
1119
+ when :rejected then :rejected
1120
+ when :slop then :shit
1121
+ else partner_running ? agent.holds_at : agent.advances_to
1122
+ end
1123
+ end
1124
+
1125
+ # Push the build and open, or update, its pull request, before the
1126
+ # folder is asked whether it may move to 🟢.
1127
+ #
1128
+ # The order is the whole point. 🟢 is justified by an open pull request
1129
+ # recorded in `pull-requests.md`, and only the publisher records one.
1130
+ # Promoting first and publishing after, as this once did, asked the
1131
+ # invariant a question only the publish could answer: every first build
1132
+ # under worktree isolation failed with "Ready for Review requires an open
1133
+ # pull request; none are recorded", and no plan ever reached review.
1134
+ #
1135
+ # This runs in settle, after the executor's own boundary check has
1136
+ # already compared HEAD against where the agent started. The commit and
1137
+ # push are the harness's, so they cannot be mistaken for the agent's.
1138
+ #
1139
+ # @param task [OpenLanes::Engine::Runner::Task]
1140
+ # @param target [Symbol]
1141
+ # @param subject [OpenLanes::Plans::Subject] the plan, read fresh
1142
+ # @param attempt [OpenLanes::Engine::Runner::Attempt]
1143
+ # @return [OpenLanes::Engine::Dispatcher::Published]
1144
+ def publish_for_review(task, target, subject, attempt)
1145
+ unchanged = Published.new(attempt:, subject:, refused: false)
1146
+ return unchanged unless target == :ready_for_review
1147
+
1148
+ publication = @runner.publish(task, subject)
1149
+ if publication&.refusal
1150
+ Published.new(attempt: attempt.with(ok: false, note: "#{attempt.note}; publish refused: #{publication.refusal}"), subject:, refused: true)
1151
+ elsif publication&.published?
1152
+ verb = publication.updated ? "pushed to" : "opened"
1153
+ Published.new(attempt: attempt.with(note: "#{attempt.note}; #{verb} #{publication.url}"), subject: fresh(task) || subject, refused: false)
1154
+ else
1155
+ unchanged
1156
+ end
1157
+ end
1158
+
1159
+ # A refused publish leaves the plan where it was. Running the builder
1160
+ # again would redo finished work to hit the same rejected push, so the
1161
+ # plan is done for this run and the attempt says why.
1162
+ #
1163
+ # @param published [OpenLanes::Engine::Dispatcher::Published]
1164
+ # @param ordinal [String]
1165
+ # @return [OpenLanes::Engine::Runner::Attempt]
1166
+ def unpublished(published, ordinal)
1167
+ @finished_plans << ordinal
1168
+ published.attempt
1169
+ end
1170
+
1171
+ # A `next:` that names someone who handles the plan's new state is
1172
+ # honoured by preferring them; one that does not is reported, and the
1173
+ # state's own agents take it, because a wrong name must not stall a plan.
1174
+ #
1175
+ # @return [OpenLanes::Engine::Runner::Attempt]
1176
+ def honour(handoff, subject, attempt)
1177
+ current = fresh_by(subject.feature.ordinal) || subject
1178
+ named = @runner.agents.find(handoff.next)
1179
+ if named&.handles?(current.status)
1180
+ @preferred[current.feature.ordinal.to_s] = named.name
1181
+ attempt
1182
+ else
1183
+ who = named ? "does not handle #{current.status}" : "is not an agent"
1184
+ attempt.with(note: "#{attempt.note}; next: names #{handoff.next}, who #{who}")
1185
+ end
1186
+ end
1187
+
1188
+ # @return [OpenLanes::Engine::Runner::Attempt]
1189
+ def park(job, base, entry)
1190
+ task = job.task
1191
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
1192
+ remember(subject.feature.ordinal.to_s, task.agent.name, job.state, "Blocked", task.round)
1193
+ target = verdict_of(entry) == :product ? :product_blocked : :blocked
1194
+ if subject.machine.may?(target)
1195
+ subject.machine.promote!(target)
1196
+ base.with(to: target, status: "Blocked", note: "blocked; see blocked.md")
1197
+ else
1198
+ base.with(ok: false, status: "Blocked", note: "ledger says Blocked but #{Plans::STATUS_BY_KEY.fetch(target).violation(subject)}")
1199
+ end
1200
+ end
1201
+
1202
+ # @return [OpenLanes::Engine::Runner::Attempt]
1203
+ def crashed(job, base, error)
1204
+ remember(base.ordinal, base.agent, job.state, "Interrupted", base.round)
1205
+ base.with(ok: false, note: error.message.lines.first.to_s.strip)
1206
+ end
1207
+
1208
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
1209
+ # @return [OpenLanes::Engine::Runner::Attempt]
1210
+ def attempt_for(job)
1211
+ task = job.task
1212
+ result = job.result
1213
+ spend = ->(field) { result.respond_to?(field) ? result.public_send(field) : 0 }
1214
+ Runner::Attempt.new(ordinal: task.subject.feature.ordinal.to_s,
1215
+ agent: task.agent.name,
1216
+ from: job.from,
1217
+ to: job.from,
1218
+ ok: result.respond_to?(:ok) ? !!result.ok : false,
1219
+ note: result.respond_to?(:note) ? result.note.to_s : "",
1220
+ up: spend.call(:up),
1221
+ down: spend.call(:down),
1222
+ subagents: spend.call(:subagents),
1223
+ delegated: spend.call(:delegated),
1224
+ seconds: spend.call(:seconds),
1225
+ round: task.round,
1226
+ file: job.file || task.agent.ledger.first.to_s,
1227
+ model: model_for(task.agent, task.subject),
1228
+ status: job.status)
1229
+ end
1230
+
1231
+ # One round in the plan's own `state.json`. A dry run writes nothing, and
1232
+ # a harness that cannot write the file reports it and carries on: the
1233
+ # folder name and the ledger still say what happened.
1234
+ #
1235
+ # @param subject [OpenLanes::Plans::Subject]
1236
+ # @param agent [OpenLanes::Agents::Agent]
1237
+ # @param round [Integer]
1238
+ # @return [void]
1239
+ def stage!(subject, agent, round, **fields)
1240
+ return if @runner.dry_run?
1241
+
1242
+ Plans::PlanState.for(subject.feature.path).stage!(agent: agent.name, round:, **fields)
1243
+ rescue SystemCallError, OpenLanes::Error => e
1244
+ UI.log("state.json not written: #{e.message}", plan: subject.feature.ordinal.to_s)
1245
+ end
1246
+
1247
+ # @return [Hash] adapter, model and effort, when the executor can say
1248
+ def profile_fields(agent, subject)
1249
+ executor = @runner.executor
1250
+ return {} unless executor.respond_to?(:profile_for)
1251
+
1252
+ profile = executor.profile_for(agent, subject)
1253
+ { adapter: profile.adapter.name, model: profile.model, effort: profile.effort }
1254
+ end
1255
+
1256
+ # @return [OpenLanes::Plans::Subject, nil] the plan read fresh from the main tree
1257
+ def fresh(task) = fresh_by(task.subject.feature.ordinal)
1258
+
1259
+ # @return [OpenLanes::Plans::Subject, nil]
1260
+ def fresh_by(ordinal) = Plans::Tree.new(dir: @runner.tree.dir).find(ordinal)
1261
+
1262
+ # One entry per round: the Started written at dispatch is overwritten by
1263
+ # the outcome rather than joined by it. Two entries a round would spend
1264
+ # an agent's rounds at double speed and number them 1, 3, 5.
1265
+ #
1266
+ # @return [void]
1267
+ def remember(ordinal, agent, state, status, round)
1268
+ @mutex.synchronize do
1269
+ entries = @history[[ordinal.to_s, agent]]
1270
+ entry = entries.find { |h| h[:state] == state && h[:round] == round }
1271
+ entry ? entry[:status] = status : entries << { state:, status:, round: }
1272
+ end
1273
+ end
1274
+
1275
+ # A requeued agent's round is handed back: the stop was a person's
1276
+ # choice, not the agent running out of turns.
1277
+ #
1278
+ # @param job [OpenLanes::Engine::Dispatcher::Job]
1279
+ # @return [void]
1280
+ def forget_round(job)
1281
+ ordinal = job.task.subject.feature.ordinal.to_s
1282
+ @mutex.synchronize do
1283
+ @history[[ordinal, job.task.agent.name]].reject! { |h| h[:state] == job.state && h[:round] == job.task.round }
1284
+ end
1285
+ @finished_plans.delete(ordinal)
1286
+ end
1287
+
1288
+ # @return [void]
1289
+ def persist
1290
+ return unless @state
1291
+
1292
+ @state.heartbeat!
1293
+ @state.save
1294
+ end
1295
+ end
1296
+ end
1297
+ end