openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OpenLanes
6
+ module Engine
7
+ module Verification
8
+ # One verification of one plan: `<plan folder>/verification/<number>/`
9
+ # (M14), where chewie leaves its screenshots and `report.md`.
10
+ #
11
+ # The number is read from disk, never kept anywhere else: a round is
12
+ # finished once its `report.md` exists, so the next run of chewie gets
13
+ # the next number, while a run that died before reporting hands its
14
+ # folder to the one that follows it rather than leaving a gap.
15
+ #
16
+ # @!attribute [r] plan_dir
17
+ # @return [String] the plan folder, in the main checkout
18
+ # @!attribute [r] number
19
+ # @return [Integer] from 1
20
+ Round = Data.define(:plan_dir, :number) do
21
+ # @return [String] the round's own folder
22
+ def dir = File.join(plan_dir, Round::FOLDER, number.to_s)
23
+
24
+ # @return [String] what chewie writes when it is done
25
+ def report_path = File.join(dir, Round::REPORT)
26
+
27
+ # @return [Boolean] whether chewie, or the engine for it, reported
28
+ def reported? = File.file?(report_path)
29
+
30
+ # @return [Report, nil]
31
+ def report = reported? ? Report.read(report_path) : nil
32
+
33
+ # @return [String] the folder, relative to the plan folder
34
+ def relative = File.join(Round::FOLDER, number.to_s)
35
+
36
+ # @return [String] where the engine records the verification commit
37
+ def published_path = File.join(dir, Round::PUBLISHED)
38
+
39
+ # Whether the engine pushed this round's evidence. chewie's
40
+ # `Verdict: verified` is only chewie's word: a run that died after
41
+ # writing it, before its signature or the push, would otherwise be
42
+ # taken for a round waiting on CI, and the old head labelled ready.
43
+ #
44
+ # @return [Boolean]
45
+ def published? = File.file?(published_path)
46
+
47
+ # @param sha [String, nil] the commit that carried the round
48
+ # @return [void]
49
+ def publish!(sha) = File.write(published_path, "#{sha}\n")
50
+
51
+ # @return [self] with its folder made
52
+ def make!
53
+ FileUtils.mkdir_p(dir)
54
+ self
55
+ end
56
+ end
57
+
58
+ class Round
59
+ # Under the plan folder.
60
+ FOLDER = "verification"
61
+
62
+ # chewie's own account of the round.
63
+ REPORT = "report.md"
64
+
65
+ # The engine's note to chewie about the app it started.
66
+ APP_NOTE = "app.md"
67
+
68
+ # The engine's record that the round's evidence was pushed: the SHA.
69
+ PUBLISHED = "published.txt"
70
+
71
+ class << self
72
+ # @param plan_dir [String]
73
+ # @return [Array<Round>] every round on disk, oldest first
74
+ def all(plan_dir)
75
+ base = File.join(plan_dir, FOLDER)
76
+ return [] unless File.directory?(base)
77
+
78
+ Dir.children(base).grep(/\A[1-9]\d*\z/).map(&:to_i).sort.map { new(plan_dir:, number: it) }
79
+ end
80
+
81
+ # @param plan_dir [String]
82
+ # @return [Round, nil] the newest round, reported or not
83
+ def latest(plan_dir) = all(plan_dir).last
84
+
85
+ # The round a new run of chewie works in: the newest one when it
86
+ # has no report yet, otherwise the one after it.
87
+ #
88
+ # @param plan_dir [String]
89
+ # @return [Round]
90
+ def next(plan_dir)
91
+ last = latest(plan_dir)
92
+ return last if last && !last.reported?
93
+
94
+ new(plan_dir:, number: (last&.number || 0) + 1)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module OpenLanes
7
+ module Engine
8
+ module Verification
9
+ # The images one round leaves, made ready to commit (M14): PNG only,
10
+ # only the failing step and the final state, each optimised.
11
+ #
12
+ # PNG because GitHub renders neither AVIF nor WebP inline in a pull
13
+ # request comment, and a screenshot nobody sees proves nothing. Two
14
+ # images because every one lands in the branch, then on `main`, for
15
+ # good: a plan rejected three times already adds six.
16
+ class Screenshots
17
+ # Anything an image could be. Only `.png` survives {#prepare}.
18
+ IMAGE = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|bmp|tiff?)\z/i
19
+
20
+ # The last screenshot of every round, pass or fail.
21
+ FINAL = "final.png"
22
+
23
+ # Optimisers, in the order tried (M14 names both). Each writes a
24
+ # recompressed copy; the smallest file, the original included, is
25
+ # the one kept, so an optimiser that grows an already tight PNG (vips
26
+ # does, on small flat images) costs nothing.
27
+ TOOLS = {
28
+ "vips" => ->(from, to) { ["vips", "pngsave", from, to, "--compression", "9", "--keep", "none"] },
29
+ "magick" => ->(from, to) { ["magick", from, "-strip", "-define", "png:compression-level=9", to] }
30
+ }.freeze
31
+
32
+ # A round holding an image other than PNG.
33
+ class Refused < Error; end
34
+
35
+ # @param dir [String] the round's folder
36
+ # @param tools [Hash{String => Proc}] name to argv builder, see {TOOLS}
37
+ # @param which [#call] a tool name to whether it is on `PATH`
38
+ # @param runner [#call] argv to whether it succeeded
39
+ def initialize(dir:, tools: TOOLS, which: method(:on_path?), runner: method(:run))
40
+ @dir = dir
41
+ @tools = tools
42
+ @which = which
43
+ @runner = runner
44
+ end
45
+
46
+ # Refuse what is not PNG, drop every image but the failing step's and
47
+ # {FINAL}, and optimise those.
48
+ #
49
+ # @param failing_step [Integer, nil] the step whose screenshot to keep
50
+ # @return [Array<String>] the kept paths, failing step first
51
+ # @raise [Refused] naming every image that is not PNG; nothing is deleted then
52
+ def prepare(failing_step: nil)
53
+ images = Dir.children(@dir).select { it.match?(IMAGE) && File.file?(File.join(@dir, it)) }.sort
54
+ foreign = images.grep_v(/\.png\z/i)
55
+ raise Refused, "#{@dir}: not PNG, which GitHub does not show inline: #{foreign.join(", ")}" unless foreign.empty?
56
+
57
+ keep = [(format(Report::STEP_PNG, failing_step) if failing_step), FINAL].compact
58
+ (images - keep).each { FileUtils.rm_f(File.join(@dir, it)) }
59
+ kept = keep.map { File.join(@dir, it) }.select { File.file?(it) }
60
+ optimise(kept)
61
+ kept
62
+ end
63
+
64
+ # Delete every image that is not PNG. {#prepare} deletes nothing when
65
+ # it refuses, so that a person reading the round can see what was
66
+ # there; but the round's folder is committed whole, so whatever is
67
+ # left in it is published. A caller about to commit calls this
68
+ # first, then {#prepare} again.
69
+ #
70
+ # @return [Array<String>] the names removed
71
+ def discard_foreign!
72
+ foreign = Dir.children(@dir).select { it.match?(IMAGE) && !it.match?(/\.png\z/i) && File.file?(File.join(@dir, it)) }.sort
73
+ foreign.each { FileUtils.rm_f(File.join(@dir, it)) }
74
+ foreign
75
+ end
76
+
77
+ private
78
+
79
+ # @param paths [Array<String>]
80
+ # @return [void]
81
+ def optimise(paths)
82
+ return if paths.empty?
83
+
84
+ available = @tools.select { |name, _| @which.call(name) }
85
+ if available.empty?
86
+ UI.log("screenshots not optimised: neither #{@tools.keys.join(" nor ")} is installed", level: :warn)
87
+ return
88
+ end
89
+
90
+ paths.each { |path| shrink(path, available.values) }
91
+ end
92
+
93
+ # @return [void]
94
+ def shrink(path, builders)
95
+ candidates = builders.each_with_index.filter_map do |build, i|
96
+ out = "#{path}.#{i}.tmp.png"
97
+ @runner.call(build.call(path, out)) && File.file?(out) ? out : nil
98
+ end
99
+ best = candidates.min_by { File.size(it) }
100
+ FileUtils.mv(best, path) if best && File.size(best) < File.size(path)
101
+ ensure
102
+ candidates&.each { FileUtils.rm_f(it) }
103
+ end
104
+
105
+ # @param name [String]
106
+ # @return [Boolean]
107
+ def on_path?(name) = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, name)) }
108
+
109
+ # @param argv [Array<String>]
110
+ # @return [Boolean]
111
+ def run(argv)
112
+ _, status = Open3.capture2e(*argv)
113
+ status.success?
114
+ rescue SystemCallError
115
+ false
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # Running a reviewed pull request before it may merge (plan 014, M1).
6
+ #
7
+ # hansolo's approval moves the plan to 🧪 Verifying. The engine starts the
8
+ # plan's app ({AppSession}), `chewie-verifier` follows the pull request's
9
+ # `## How to test` against it and leaves screenshots and a report in a
10
+ # {Round}'s folder, and the engine acts on the verdict ({Outcome}):
11
+ # commit, comment, label, and the plan to 🔴, or green CI, the coverage
12
+ # gate and `ready-to-merge`.
13
+ module Verification
14
+ # The phase an agent definition declares to be the verifier. Its
15
+ # Completed notes are verdicts, as the review phase's are.
16
+ PHASE = "verification"
17
+
18
+ # The verdicts that go on to verification at every quality.
19
+ FROM_REVIEW = [Plans::ReviewLabel::APPROVED].freeze
20
+
21
+ # The verdict whose fate `desired_codebase_quality` decides (R4): on
22
+ # to verification under `mvp`, back to luke and rey under `high`.
23
+ ON_QUALITY = Plans::ReviewLabel::OK_NOT_PERFECT
24
+
25
+ module_function
26
+
27
+ # @param plan_dir [String]
28
+ # @return [Round] the round a new run of chewie works in
29
+ def round_for(plan_dir) = Round.next(plan_dir)
30
+
31
+ # @param plan_dir [String]
32
+ # @return [Round, nil] the newest round
33
+ def latest(plan_dir) = Round.latest(plan_dir)
34
+
35
+ # @return [AppSession]
36
+ def session(**) = AppSession.new(**)
37
+
38
+ # @return [Outcome]
39
+ def outcome(**) = Outcome.new(**)
40
+
41
+ # @param labels [Array<String>] a pull request's labels
42
+ # @param quality [Symbol, nil] {Config#desired_codebase_quality}; nil
43
+ # while the project is still to be asked, which lets nothing but
44
+ # {FROM_REVIEW} through
45
+ # @return [Boolean] whether its review verdict goes on to verification
46
+ def from_review?(labels, quality: nil)
47
+ return true if FROM_REVIEW.any? { labels.include?(it.name) }
48
+
49
+ quality == :mvp && labels.include?(ON_QUALITY.name)
50
+ end
51
+
52
+ # @param labels [Array<String>] a pull request's labels
53
+ # @return [Boolean] whether the verdict is the one the quality decides
54
+ def ok_not_perfect?(labels) = labels.include?(ON_QUALITY.name) && FROM_REVIEW.none? { labels.include?(it.name) }
55
+ end
56
+ end
57
+ end
@@ -6,14 +6,43 @@ module OpenLanes
6
6
  module Engine
7
7
  module_function
8
8
 
9
+ # @param options [Hash] passed to {RunWorkflow#initialize}
10
+ # @return [OpenLanes::Engine::RunWorkflow] `openlanes run`, without the command
11
+ def run(**) = RunWorkflow.new(**)
12
+
13
+ # @param options [Hash] passed to {StartWorkflow#initialize}
14
+ # @return [OpenLanes::Engine::StartWorkflow] `openlanes start`, without the command
15
+ def start(**) = StartWorkflow.new(**)
16
+
9
17
  # @return [OpenLanes::Engine::Runner]
10
18
  def runner(**) = Runner.new(**)
11
19
 
12
20
  # @param tree [OpenLanes::Plans::Tree]
13
21
  # @return [OpenLanes::Engine::StateFile]
14
- def state_file(tree) = StateFile.new(path: StateFile.for(tree))
22
+ def state_file(tree) = StateFile.new(path: StateFile.for(tree), legacy: StateFile.legacy_for(tree))
15
23
 
16
24
  # @return [OpenLanes::Engine::Tally]
17
25
  def tally(**) = Tally.new(**)
26
+
27
+ # The coverage check `ready-to-merge` waits on (M15).
28
+ #
29
+ # @return [OpenLanes::Engine::CoverageGate]
30
+ def coverage_gate(**) = CoverageGate.new(**)
31
+
32
+ @agent_locks = ->(dir, own: []) { AgentLocks.new(root: dir, own:) }
33
+
34
+ # The plans' alock register (W31), for the repository holding +dir+; nil
35
+ # when the factory is, which is how the suite keeps `alock` unrun.
36
+ #
37
+ # @param dir [String] any directory in the repository, the plans' one
38
+ # @param own [Array<String>] the engine's agents, whose locks are not outside ones
39
+ # @return [OpenLanes::Engine::AgentLocks, nil]
40
+ def agent_locks(dir, own: []) = @agent_locks&.call(dir, own:)
41
+
42
+ # @param factory [#call, nil] answers `call(dir, own:)` with an {AgentLocks}
43
+ # @return [void]
44
+ def agent_locks=(factory)
45
+ @agent_locks = factory
46
+ end
18
47
  end
19
48
  end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module OpenLanes
6
+ module Execution
7
+ # The AFTER half of the autonomy boundary: what the checkout looked like
8
+ # before an agent ran, and what crossing the line looks like afterwards.
9
+ #
10
+ # Two things are checked. HEAD must not move, because the boundary is docs
11
+ # plus code and no commits. And no remote-tracking ref may have been moved
12
+ # by a push, because a push is the one thing that leaves the machine.
13
+ #
14
+ # The push check reads only local state: `git push` updates the
15
+ # remote-tracking ref of every branch it pushes (`refs/remotes/origin/x`)
16
+ # and logs that update as "update by push". `git ls-remote` would also see
17
+ # pushes made some other way (a URL instead of a remote name, the GitHub
18
+ # API), but it costs a network round trip on every invocation, and it
19
+ # cannot tell an agent's push from a teammate's, from CI, or from this
20
+ # harness publishing another plan a second earlier, so it would fail
21
+ # honest runs for other people's work. A check that cries wolf gets
22
+ # turned off.
23
+ #
24
+ # Comparing tracking refs alone is not enough either: an agent that runs
25
+ # `git fetch` moves them to whatever the remote already holds, and nothing
26
+ # left the machine. So a ref that moved counts only when its reflog says a
27
+ # push moved it during the run. A ref that moved with no reflog entry at all
28
+ # (reflogs switched off) cannot be told apart from a push, and is reported.
29
+ #
30
+ # Worktrees share `refs/remotes`, so a push from any other checkout of
31
+ # the repository (a person, another session, a sibling agent's harness)
32
+ # moves the same refs during this run. A pushed commit reachable from
33
+ # another worktree's HEAD, and not from this one's, was pushed from there
34
+ # and is not this agent's. The main checkout counts as another worktree,
35
+ # so a push of whatever its HEAD names is exempt too; that is accepted,
36
+ # because it is a push from that checkout, not from this agent. A sibling
37
+ # that sits on this checkout's own commit is not told apart from it, so a
38
+ # push of that shared commit is still reported.
39
+ #
40
+ # Blind spots, by construction: a push to a bare URL updates no tracking
41
+ # ref, and a deleted remote branch takes its tracking ref and that ref's
42
+ # reflog with it, which locally looks the same as `git fetch --prune`.
43
+ module Boundary
44
+ # How `git push` signs the tracking refs it updates.
45
+ PUSHED = "update by push"
46
+
47
+ # What one checkout looked like before an agent ran in it.
48
+ #
49
+ # @!attribute [r] head
50
+ # @return [String, nil] nil outside a repository, which turns the check off
51
+ # @!attribute [r] remotes
52
+ # @return [Hash{String => String}] remote-tracking ref to the commit it named
53
+ # @!attribute [r] reflogs
54
+ # @return [Hash{String => Integer}] remote-tracking ref to how many
55
+ # reflog entries it had, so the entries the run added can be told
56
+ # from the ones before it. Counting, rather than comparing reflog
57
+ # timestamps, because those have one-second resolution and a
58
+ # publish in the same second as the snapshot would read as new.
59
+ Snapshot = Data.define(:head, :remotes, :reflogs) do
60
+ # @param root [String] the checkout the agent worked in
61
+ # @return [String, nil] what boundary was crossed, or nil
62
+ def violation(root)
63
+ return nil if head.nil?
64
+
65
+ after = Boundary.head(root)
66
+ return "agent committed (HEAD moved #{head[0, 7]} → #{after.to_s[0, 7]}) — the boundary is docs plus code, no commits" if after != head
67
+
68
+ pushed = Boundary.pushed(self, root)
69
+ return nil if pushed.empty?
70
+
71
+ "agent pushed (#{pushed.map { |ref| ref.delete_prefix("refs/remotes/") }.join(", ")}) — the boundary is docs plus code, nothing leaves the machine"
72
+ end
73
+ end
74
+
75
+ @sanctioned = Hash.new { |hash, ref| hash[ref] = [] }
76
+ @lock = Mutex.new
77
+
78
+ class << self
79
+ # @param root [String]
80
+ # @return [OpenLanes::Execution::Boundary::Snapshot]
81
+ def snapshot(root)
82
+ Snapshot.new(head: head(root), remotes: remotes(root), reflogs: reflogs(root).transform_values(&:size))
83
+ end
84
+
85
+ # Records a push the harness itself is about to make, so an agent
86
+ # running in another worktree of the same repository is not blamed
87
+ # for it. Worktrees share `refs/remotes`, and the publisher pushes one
88
+ # plan's branch while other agents are still working theirs.
89
+ #
90
+ # @param path [String] the checkout being pushed
91
+ # @param remote [String]
92
+ # @param branch [String]
93
+ # @return [void]
94
+ def sanction(path, remote, branch)
95
+ sha = rev_parse(path, "refs/heads/#{branch}")
96
+ return if sha.nil?
97
+
98
+ @lock.synchronize { @sanctioned["refs/remotes/#{remote}/#{branch}"] << sha }
99
+ end
100
+
101
+ # @param ref [String]
102
+ # @param sha [String]
103
+ # @return [Boolean]
104
+ def sanctioned?(ref, sha) = @lock.synchronize { @sanctioned.fetch(ref, []).include?(sha) }
105
+
106
+ # For specs: forget every sanctioned push.
107
+ #
108
+ # @return [void]
109
+ def reset! = @lock.synchronize { @sanctioned.clear }
110
+
111
+ # @param root [String]
112
+ # @return [String, nil] current commit, or nil outside a repository
113
+ def head(root) = rev_parse(root, "HEAD")
114
+
115
+ # The symbolic `refs/remotes/<remote>/HEAD` is left out: it moves with
116
+ # `git remote set-head`, which pushes nothing.
117
+ #
118
+ # @param root [String]
119
+ # @return [Hash{String => String}]
120
+ def remotes(root)
121
+ out = `git -C #{root.shellescape} for-each-ref --format='%(refname) %(objectname)' refs/remotes 2>/dev/null`
122
+ out.lines.map(&:split).select { |pair| pair.size == 2 }.reject { |ref, _| ref.end_with?("/HEAD") }.to_h
123
+ end
124
+
125
+ # @param before [OpenLanes::Execution::Boundary::Snapshot]
126
+ # @param root [String]
127
+ # @return [Array<String>] tracking refs a push moved since the snapshot
128
+ def pushed(before, root)
129
+ logs = elsewhere = nil
130
+ remotes(root).filter_map do |ref, sha|
131
+ next if before.remotes[ref] == sha || sanctioned?(ref, sha)
132
+ next if pushed_elsewhere?(root, sha, elsewhere ||= other_heads(root))
133
+
134
+ logs ||= reflogs(root)
135
+ entries = logs.fetch(ref, [])
136
+ added = entries.first([entries.size - before.reflogs.fetch(ref, 0), 0].max)
137
+ ref if added.empty? || added.any? { |reason| reason.start_with?(PUSHED) }
138
+ end
139
+ end
140
+
141
+ # Whether a pushed commit came from another worktree: it is reachable
142
+ # from that worktree's HEAD (not merely equal to it, so a sibling that
143
+ # pushed and then committed again before this check is still
144
+ # recognised) and not reachable from this checkout's own HEAD. The
145
+ # second half matters because an agent's HEAD is unmoved, so
146
+ # everything it could push is an ancestor of it; without the
147
+ # exclusion a sibling branched from the same commit would exempt the
148
+ # agent's own base commit.
149
+ #
150
+ # @param root [String]
151
+ # @param sha [String]
152
+ # @param heads [Set<String>] from {#other_heads}
153
+ # @return [Boolean]
154
+ def pushed_elsewhere?(root, sha, heads)
155
+ return false if ancestor?(root, sha, "HEAD")
156
+
157
+ heads.any? { |head| ancestor?(root, sha, head) }
158
+ end
159
+
160
+ # The commit checked out in every worktree of the repository but
161
+ # +root+'s own: what a push from one of them would carry.
162
+ #
163
+ # @param root [String]
164
+ # @return [Set<String>]
165
+ def other_heads(root)
166
+ own = real(rev_parse_path(root))
167
+ out = `git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
168
+ out.split("\n\n").each_with_object(Set.new) do |block, heads|
169
+ fields = block.lines.to_h { |line| line.chomp.split(" ", 2) }
170
+ heads << fields["HEAD"] if fields["HEAD"] && real(fields["worktree"]) != own
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ # @return [Boolean] whether +sha+ is reachable from +rev+ (or is it)
177
+ def ancestor?(root, sha, rev) = system("git", "-C", root, "merge-base", "--is-ancestor", sha, rev, err: File::NULL)
178
+
179
+ # @return [String, nil] the checkout's top level
180
+ def rev_parse_path(root)
181
+ out = `git -C #{root.shellescape} rev-parse --show-toplevel 2>/dev/null`.strip
182
+ out.empty? ? root : out
183
+ end
184
+
185
+ # @return [String, nil] the path with symlinks resolved, as git prints it
186
+ def real(path)
187
+ File.realpath(path.to_s)
188
+ rescue SystemCallError
189
+ path
190
+ end
191
+
192
+ # @return [String, nil]
193
+ def rev_parse(root, rev)
194
+ out = `git -C #{root.shellescape} rev-parse --verify --quiet #{rev.shellescape} 2>/dev/null`.strip
195
+ out.empty? ? nil : out
196
+ end
197
+
198
+ # Every remote-tracking ref's reflog, in one call.
199
+ #
200
+ # @return [Hash{String => Array<String>}] ref to why it moved, newest first
201
+ def reflogs(root)
202
+ out = `git -C #{root.shellescape} log --walk-reflogs --glob=refs/remotes --format=%gD%x09%gs 2>/dev/null`
203
+ out.lines.each_with_object(Hash.new { |hash, ref| hash[ref] = [] }) do |line, logs|
204
+ selector, reason = line.chomp.split("\t", 2)
205
+ ref, index = selector.to_s.match(/\A(.+)@\{(\d+)\}\z/)&.captures
206
+ logs[ref][index.to_i] = reason.to_s if ref
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -44,12 +44,14 @@ module OpenLanes
44
44
  # @param chdir [String, nil]
45
45
  # @param env [Hash{String => String}] what the adapter adds on top of
46
46
  # {.environment}, e.g. claude's autocompact threshold
47
+ # @param spawner [#spawn] {OpenLanes::Spawner::System} unless a spec
48
+ # starts nothing
47
49
  # @return [OpenLanes::Execution::Child]
48
- def spawn(argv, chdir: nil, env: {})
50
+ def spawn(argv, chdir: nil, env: {}, spawner: Spawner::System)
49
51
  reader, writer = IO.pipe
50
52
  options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true, pgroup: true }
51
53
  options[:chdir] = chdir if chdir
52
- pid = Process.spawn(environment.merge(env), *argv, **options)
54
+ pid = spawner.spawn(environment.merge(env), *argv, **options)
53
55
  writer.close
54
56
  track(new(pid:, reader:))
55
57
  end
@@ -47,6 +47,8 @@ module OpenLanes
47
47
  # @return [void]
48
48
  def tick
49
49
  @mutex.synchronize do
50
+ next if paused
51
+
50
52
  left = @deadline - @now.call
51
53
  WARNINGS.each do |before, text|
52
54
  next if @fired.include?(before) || left > before
@@ -89,26 +91,54 @@ module OpenLanes
89
91
  #
90
92
  # @return [void]
91
93
  def stop
92
- thread = @mutex.synchronize { @thread.tap { @thread = nil } }
94
+ thread = @mutex.synchronize do
95
+ @closed = true
96
+ @thread.tap { @thread = nil }
97
+ end
93
98
  thread&.kill
94
99
  end
95
100
 
96
- # Ten more minutes, or however many. The file is emptied so the next poll
97
- # reads a reprieve, and every moment is re-armed against the new deadline.
101
+ # Ten more minutes, or however many. The file tells the agent how long
102
+ # it now has, so one told "5 minutes left" does not wrap up on the old
103
+ # deadline, and every moment is re-armed against the new one.
104
+ #
105
+ # Refused once STOP is out or the clock is stopped: that agent is
106
+ # exiting, and time given to it would reach nobody.
98
107
  #
99
108
  # @param seconds [Integer]
100
- # @return [void]
109
+ # @return [Boolean] whether the time reached a clock still running
101
110
  def extend!(seconds)
102
111
  @mutex.synchronize do
112
+ next false if @closed || %i[stopped expired].include?(@phase)
113
+
103
114
  @deadline += seconds
104
- @fired = []
115
+ @fired = WARNINGS.map(&:first).select { |before| before >= @deadline - @now.call }
105
116
  @phase = :calm
106
- Control.write(@control, "")
117
+ Control.write(@control, "#{Control::WARN}: #{((@deadline - @now.call) / 60.0).round} minutes left, time was extended")
118
+ true
107
119
  end
108
120
  end
109
121
 
110
122
  private
111
123
 
124
+ # While agents are paused (W32) the clock stands still: no warning
125
+ # overwrites the PAUSE in the agent's file, and on resume the deadline
126
+ # moves on by as long as the pause lasted, so a paused agent is never
127
+ # stopped for time it spent waiting.
128
+ #
129
+ # @return [Boolean] whether this tick is skipped
130
+ def paused
131
+ if Control.paused?
132
+ @paused_at ||= @now.call
133
+ return true
134
+ end
135
+ if @paused_at
136
+ @deadline += @now.call - @paused_at
137
+ @paused_at = nil
138
+ end
139
+ false
140
+ end
141
+
112
142
  # @param seconds [Integer]
113
143
  # @return [void]
114
144
  def arm(seconds)