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,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Execution
5
+ # One `claude` process the harness started and therefore owns.
6
+ #
7
+ # `TTY::Command` never exposes the pid it spawned, so the executor used to
8
+ # find its child by scraping `ps` for a fresh `claude` under this process
9
+ # and could never safely signal it. Kill (`k`) and the sixty-second
10
+ # backstop after STOP both need a pid that is certainly ours, so the
11
+ # process is spawned here and the pid kept.
12
+ #
13
+ # stderr is folded into the same pipe as stdout: `claude` prints the cause
14
+ # of a failure there, and the transcript keeps non-JSON lines for exactly
15
+ # that reason.
16
+ #
17
+ # The process leads a process group of its own, and every stop is sent to
18
+ # the whole group. A SIGKILL aimed at `claude` alone once left the shell
19
+ # its Bash tool had started (and the `eza` an `ls` alias ran in it)
20
+ # holding the write end of this pipe: the reader never saw end of file,
21
+ # and `--timeout 600` ended nothing. The group is also why a terminal's
22
+ # Ctrl-C no longer reaches an agent directly; the harness forwards its
23
+ # own stop instead, and {.terminate_all} catches a harness that exits
24
+ # before its agents have.
25
+ class Child
26
+ # Seconds between TERM and KILL in {#terminate}: long enough for a
27
+ # process to flush and exit, short enough that a stop is a stop.
28
+ TERM_GRACE = 5
29
+
30
+ # Seconds {#terminate} waits, after KILL, for the reader to reach end of
31
+ # file on its own before closing the pipe under it. Only a process that
32
+ # left the group, by `setsid` or a daemonising double fork, can still be
33
+ # holding the pipe by then.
34
+ DRAIN_GRACE = 1
35
+
36
+ # How often {#terminate} looks again while it waits.
37
+ POLL = 0.05
38
+
39
+ @live = []
40
+ @mutex = Mutex.new
41
+
42
+ class << self
43
+ # @param argv [Array<String>]
44
+ # @param chdir [String, nil]
45
+ # @param env [Hash{String => String}] what the adapter adds on top of
46
+ # {.environment}, e.g. claude's autocompact threshold
47
+ # @return [OpenLanes::Execution::Child]
48
+ def spawn(argv, chdir: nil, env: {})
49
+ reader, writer = IO.pipe
50
+ options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true, pgroup: true }
51
+ options[:chdir] = chdir if chdir
52
+ pid = Process.spawn(environment.merge(env), *argv, **options)
53
+ writer.close
54
+ track(new(pid:, reader:))
55
+ end
56
+
57
+ # The environment the child starts in: the one Bundler was loaded under,
58
+ # less what Bundler itself put there. `exe/openlanes` pins `BUNDLE_GEMFILE`
59
+ # to its own Gemfile and `bundler/setup` adds `-rbundler/setup` to
60
+ # `RUBYOPT`; a child that inherited them would run every `bundle exec`,
61
+ # `standardrb` and `alock` against the harness's checkout instead of the
62
+ # worktree it was handed, and be told the gems that worktree's Gemfile adds
63
+ # are "not currently included in the bundle". Bundler knows exactly what it
64
+ # changed, so it is asked; without Bundler there is nothing to strip.
65
+ #
66
+ # @return [Hash{String => String}]
67
+ def environment
68
+ defined?(::Bundler) ? ::Bundler.unbundled_env : ENV.to_h
69
+ end
70
+
71
+ # @return [Array<OpenLanes::Execution::Child>] spawned and not yet reaped
72
+ def live = @mutex.synchronize { @live.dup }
73
+
74
+ # KILL every group still running. Registered with `at_exit`, so a
75
+ # second Ctrl-C, which raises in the harness and ends it at once, takes
76
+ # its agents down with it now that the terminal's own SIGINT no
77
+ # longer reaches them.
78
+ #
79
+ # A child stays here after its leader is reaped for as long as its
80
+ # group has members: a grandchild that ignores TERM and let go of the
81
+ # pipe outlives the leader, and this is what still reaches it.
82
+ #
83
+ # @return [void]
84
+ def terminate_all = live.each { it.kill("KILL") }
85
+
86
+ # @param child [OpenLanes::Execution::Child]
87
+ # @return [void]
88
+ def forget(child) = @mutex.synchronize { @live.delete(child) }
89
+
90
+ private
91
+
92
+ # @param child [OpenLanes::Execution::Child]
93
+ # @return [OpenLanes::Execution::Child]
94
+ def track(child)
95
+ @mutex.synchronize do
96
+ @at_exit ||= at_exit { terminate_all }
97
+ @live.reject!(&:finished?)
98
+ @live << child
99
+ end
100
+ child
101
+ end
102
+ end
103
+
104
+ # @param pid [Integer]
105
+ # @param reader [IO]
106
+ def initialize(pid:, reader:)
107
+ @pid = pid
108
+ @reader = reader
109
+ @status = nil
110
+ @terminating = false
111
+ @emptied = false
112
+ @guard = Mutex.new
113
+ end
114
+
115
+ # @return [Integer]
116
+ attr_reader :pid
117
+
118
+ # Read until the process closes its end. Runs on the caller's thread;
119
+ # the executor reads on the same thread it always did.
120
+ #
121
+ # @yieldparam text [String] whatever arrived, possibly a partial line
122
+ # @return [void]
123
+ def each_chunk
124
+ loop { yield @reader.readpartial(65_536) }
125
+ rescue IOError
126
+ # End of file, or {#terminate} closed the pipe under a reader that
127
+ # would otherwise have waited on it forever.
128
+ @reader.close unless @reader.closed?
129
+ end
130
+
131
+ # @return [Process::Status]
132
+ def wait
133
+ @status ||= begin
134
+ _, status = Process.wait2(pid)
135
+ status
136
+ end
137
+ rescue Errno::ECHILD
138
+ @status
139
+ ensure
140
+ self.class.forget(self) if finished?
141
+ end
142
+
143
+ # @return [Boolean] reaped, and nothing left in its group
144
+ def finished? = !@status.nil? && !group?
145
+
146
+ # Signal the whole group: the agent and everything it started.
147
+ #
148
+ # The group is signalled even after its leader has been reaped. A member
149
+ # that ignored TERM and let go of the pipe outlives the leader, and POSIX
150
+ # does not hand a group's id out as a pid while the group has members,
151
+ # so `-pid` still means this group. Once it answers ESRCH the group is
152
+ # empty, the id is free, and it is never signalled again.
153
+ #
154
+ # @param signal [String]
155
+ # @return [void]
156
+ def kill(signal = "KILL")
157
+ return if @emptied
158
+
159
+ Process.kill(signal, -pid)
160
+ rescue Errno::ESRCH
161
+ @emptied = true
162
+ rescue Errno::EPERM
163
+ # Not ours to signal, so not ours to end.
164
+ end
165
+
166
+ # The guarantee behind every stop: TERM to the group, KILL to whatever
167
+ # is left after +grace+, and the pipe closed if something outside the
168
+ # group still holds it, so the reader always finishes.
169
+ #
170
+ # Once only: the dispatcher asks again on every tick past a quit's
171
+ # deadline, and a second caller has nothing to add to the first.
172
+ #
173
+ # The leader being reaped mid-grace does not cut it short: the reader
174
+ # sees end of file as soon as the leader dies, and a member that
175
+ # ignored TERM is still there to KILL. The caller's thread must be left
176
+ # to finish; {Clock#stop} waits for an expiry under way for that reason.
177
+ #
178
+ # @param grace [Numeric] seconds between TERM and KILL
179
+ # @param drain [Numeric] seconds after KILL before the pipe is closed
180
+ # @return [void]
181
+ def terminate(grace: TERM_GRACE, drain: DRAIN_GRACE)
182
+ return if @guard.synchronize { @terminating.tap { @terminating = true } }
183
+
184
+ kill("TERM")
185
+ await(grace) { !group? }
186
+ kill("KILL")
187
+ await(drain) { @reader.closed? }
188
+ @reader.close unless @reader.closed?
189
+ rescue IOError
190
+ # The reader closed it first.
191
+ end
192
+
193
+ # @return [Boolean]
194
+ def alive?
195
+ return false if @status
196
+
197
+ Process.kill(0, pid)
198
+ true
199
+ rescue Errno::ESRCH
200
+ false
201
+ end
202
+
203
+ private
204
+
205
+ # @return [Boolean] whether anything in the group, a zombie leader
206
+ # included, is still there
207
+ def group?
208
+ return false if @emptied
209
+
210
+ Process.kill(0, -pid)
211
+ true
212
+ rescue Errno::ESRCH
213
+ @emptied = true
214
+ false
215
+ rescue Errno::EPERM
216
+ false
217
+ end
218
+
219
+ # @param seconds [Numeric]
220
+ # @yieldreturn [Boolean] done waiting
221
+ # @return [void]
222
+ def await(seconds)
223
+ deadline = UI.monotonic + seconds
224
+ sleep(POLL) until yield || UI.monotonic >= deadline
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Execution
5
+ # The advisory clock one invocation runs against.
6
+ #
7
+ # It never terminates the agent on its own. It writes into the control file
8
+ # the agent polls: a warning ten and five minutes out, a wrap-up request at
9
+ # one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
10
+ # is killed, and that is the executor's act on this clock's say-so, so the
11
+ # agent has every chance to write its ledger line first.
12
+ #
13
+ # `now` is injectable so the moments can be tested without sleeping.
14
+ class Clock
15
+ # Seconds before the deadline, and what the control file says then.
16
+ # A moment already in the past when the clock starts is skipped: a five
17
+ # minute agent told "10 minutes left" at t=0 has been lied to.
18
+ WARNINGS = [
19
+ [600, "WARN: 10 minutes left"],
20
+ [300, "WARN: 5 minutes left"],
21
+ [60, "WRAP_UP: 1 minute left, write to disk now"]
22
+ ].freeze
23
+
24
+ # Seconds after STOP before {#on_expire} fires.
25
+ GRACE = 60
26
+
27
+ # @param seconds [Integer] the budget
28
+ # @param control [String] the invocation's control file
29
+ # @param on_expire [Proc] called once, GRACE seconds after STOP
30
+ # @param now [Proc] a monotonic clock reading
31
+ def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
32
+ @control = control
33
+ @on_expire = on_expire
34
+ @now = now
35
+ @mutex = Mutex.new
36
+ arm(seconds)
37
+ end
38
+
39
+ # @return [Integer] whole seconds left, floored at zero
40
+ def remaining = [@deadline - @now.call, 0].max.round
41
+
42
+ # @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
43
+ attr_reader :phase
44
+
45
+ # Evaluate every moment once. Idempotent within a phase.
46
+ #
47
+ # @return [void]
48
+ def tick
49
+ @mutex.synchronize do
50
+ left = @deadline - @now.call
51
+ WARNINGS.each do |before, text|
52
+ next if @fired.include?(before) || left > before
53
+
54
+ @fired << before
55
+ Control.write(@control, text)
56
+ @phase = before == 60 ? :wrap_up : :warned
57
+ end
58
+ if left <= 0 && !@fired.include?(:stop)
59
+ @fired << :stop
60
+ Control.write(@control, Control::STOP)
61
+ @phase = :stopped
62
+ end
63
+ if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
64
+ @fired << :expired
65
+ @phase = :expired
66
+ @on_expire.call
67
+ end
68
+ end
69
+ end
70
+
71
+ # @return [self]
72
+ def start
73
+ @thread ||= Thread.new do
74
+ Thread.current.report_on_exception = false
75
+ loop do
76
+ tick
77
+ break if phase == :expired
78
+
79
+ sleep(1)
80
+ end
81
+ end
82
+ self
83
+ end
84
+
85
+ # Waits out an expiry under way before killing the thread. {#tick}
86
+ # holds the mutex while {#on_expire} runs, and that is the kill: a
87
+ # thread killed inside it, between TERM and KILL, once left a
88
+ # grandchild that ignored TERM running after the attempt had ended.
89
+ #
90
+ # @return [void]
91
+ def stop
92
+ thread = @mutex.synchronize { @thread.tap { @thread = nil } }
93
+ thread&.kill
94
+ end
95
+
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.
98
+ #
99
+ # @param seconds [Integer]
100
+ # @return [void]
101
+ def extend!(seconds)
102
+ @mutex.synchronize do
103
+ @deadline += seconds
104
+ @fired = []
105
+ @phase = :calm
106
+ Control.write(@control, "")
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # @param seconds [Integer]
113
+ # @return [void]
114
+ def arm(seconds)
115
+ @deadline = @now.call + seconds
116
+ @fired = WARNINGS.map(&:first).select { |before| before >= seconds }
117
+ @phase = :calm
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OpenLanes
6
+ module Execution
7
+ # The side channel between a keypress and a running agent.
8
+ #
9
+ # `claude -p` takes no input once it starts, so the only way to reach an
10
+ # agent mid-flight is a file it has been told to poll: each invocation gets
11
+ # a control file of its own, named in its prompt, and a keypress writes a
12
+ # word into every file currently registered. Like everything else about the
13
+ # prompt this is a request — an agent deep in a tool call reads the file at
14
+ # its next step, not instantly — which is why {#quit!} also arms a deadline
15
+ # the {Executor} checks, so "quit" is eventually a guarantee too.
16
+ module Control
17
+ # What a keypress writes into a control file. One word, one line, so an
18
+ # agent can act on `File.read(path).strip` and nothing subtler.
19
+ WRAP_UP = "WRAP_UP"
20
+ STOP = "STOP"
21
+
22
+ # Ctrl-C: STOP, and the run will be started again, so the agent leaves
23
+ # a resume note before it goes. The prompt says what that note is.
24
+ INTERRUPT = "INTERRUPT"
25
+
26
+ # The clock's warnings start with this; the agent's prompt explains them.
27
+ WARN = "WARN"
28
+
29
+ # From the page, to one agent: stop at the next safe point, read the
30
+ # plan's mailbox, and wait there for the next message before going on.
31
+ # A person holds an agent to say something to it.
32
+ HOLD = "HOLD"
33
+
34
+ # The words a person may send one agent from the page, by command kind.
35
+ # There is no word for compacting: `/compact` is the caller's command,
36
+ # not one an agent can run on itself, so the harness stops the agent and
37
+ # compacts its session from outside (see {Executor::Handle#compact!}).
38
+ WORDS = { "hold" => HOLD, "wrap_up" => WRAP_UP, "stop" => STOP }.freeze
39
+
40
+ # Seconds between {#quit!} and the harness terminating whatever is still
41
+ # running. Long enough to write files and a handoff note; short enough
42
+ # that q means quit rather than "quit eventually".
43
+ GRACE = 60
44
+
45
+ @mutex = Mutex.new
46
+ @files = []
47
+ @quit = false
48
+ @interrupted = false
49
+ @deadline = nil
50
+
51
+ class << self
52
+ # Register a fresh control file for one invocation and hand back its
53
+ # path, to be named in the agent's prompt.
54
+ #
55
+ # @param dir [String] where the file lives (the trace dir — outside the
56
+ # repository for the same reason traces are)
57
+ # @param name [String] something findable: plan ordinal and agent name
58
+ # @return [String]
59
+ def register(dir, name)
60
+ FileUtils.mkdir_p(dir)
61
+ path = File.join(dir, "control-#{name}-#{Process.pid}-#{format("%04x", rand(0x10000))}")
62
+ File.write(path, "")
63
+ @mutex.synchronize { @files << path }
64
+ path
65
+ end
66
+
67
+ # Forget a finished invocation's file. The file itself is removed so a
68
+ # crashed run does not leave stale STOPs for the next one to find.
69
+ #
70
+ # @param path [String]
71
+ # @return [void]
72
+ def release(path)
73
+ @mutex.synchronize { @files.delete(path) }
74
+ FileUtils.rm_f(path)
75
+ end
76
+
77
+ # One line into one file, from the clock. Not a broadcast: a warning is
78
+ # about one agent's own deadline.
79
+ #
80
+ # @param path [String]
81
+ # @param text [String]
82
+ # @return [void]
83
+ def write(path, text)
84
+ File.write(path, text.empty? ? "" : "#{text}\n")
85
+ rescue SystemCallError
86
+ # The invocation finished and released its file; nothing to warn.
87
+ end
88
+
89
+ # `w`: every running agent is asked to finish the essential remainder
90
+ # as fast as it can. The loop itself keeps going.
91
+ #
92
+ # @return [void]
93
+ def wrap_up! = broadcast(WRAP_UP)
94
+
95
+ # `n`: every running agent is asked to write out what it has and end
96
+ # its turn. The loop keeps going — under chaining that is exactly what
97
+ # hands the plan to the next agent.
98
+ #
99
+ # @return [void]
100
+ def stop! = broadcast(STOP)
101
+
102
+ # `q`: {#stop!}, plus the loop ends after this round, plus a deadline —
103
+ # {GRACE} seconds from now — after which the {Executor} aborts whatever
104
+ # is still running rather than waiting on its goodwill.
105
+ #
106
+ # @return [void]
107
+ def quit!
108
+ stop!
109
+ @mutex.synchronize {
110
+ @quit = true
111
+ @deadline ||= UI.monotonic + GRACE
112
+ }
113
+ end
114
+
115
+ # Ctrl-C, the first time: {#quit!}, except that every running agent is
116
+ # told the run is being interrupted rather than stopped, so it writes
117
+ # down where it got to. A restart then reads that note instead of
118
+ # redoing, or half-redoing, the work.
119
+ #
120
+ # @return [void]
121
+ def interrupt!
122
+ broadcast(INTERRUPT)
123
+ @mutex.synchronize {
124
+ @quit = true
125
+ @interrupted = true
126
+ @deadline ||= UI.monotonic + GRACE
127
+ }
128
+ end
129
+
130
+ # @return [Boolean] whether `q` has been pressed
131
+ def quit? = @mutex.synchronize { @quit }
132
+
133
+ # @return [Boolean] whether Ctrl-C has been pressed
134
+ def interrupted? = @mutex.synchronize { @interrupted }
135
+
136
+ # Ctrl-C: the graceful first press and the hard second.
137
+ #
138
+ # A trap handler may not take a mutex, so the first press hands
139
+ # {#interrupt!} to a thread; the second raises, as Ctrl-C always has.
140
+ #
141
+ # @param signals [Array<String>] each handled as Ctrl-C is; `openlanes
142
+ # start` adds TERM, so a service manager's stop is as graceful
143
+ # @yieldreturn [Object]
144
+ # @return [Object] the block's value
145
+ def on_interrupt(signals = %w[INT])
146
+ previous = signals.to_h do |signal|
147
+ [signal, Signal.trap(signal) do
148
+ # A plain read, not {#interrupted?}: a trap handler may not lock.
149
+ raise Interrupt if @interrupted || @pending_interrupt
150
+
151
+ @pending_interrupt = true
152
+ Thread.new { interrupt! }
153
+ end]
154
+ end
155
+ yield
156
+ ensure
157
+ @pending_interrupt = false
158
+ previous&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
159
+ end
160
+
161
+ # @return [Boolean] whether the grace period after `q` has run out
162
+ def overdue?
163
+ @mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
164
+ end
165
+
166
+ # Back to rest, for the next `Runner#call` — and for the suite, where
167
+ # one example's q must not quit every example after it.
168
+ #
169
+ # @return [void]
170
+ def reset!
171
+ @mutex.synchronize {
172
+ @files.each { |f| FileUtils.rm_f(f) }
173
+ @files.clear
174
+ @quit = false
175
+ @interrupted = false
176
+ @deadline = nil
177
+ }
178
+ end
179
+
180
+ private
181
+
182
+ # @param word [String]
183
+ # @return [void]
184
+ def broadcast(word)
185
+ @mutex.synchronize { @files.dup }.each do |path|
186
+ File.write(path, "#{word}\n")
187
+ rescue SystemCallError
188
+ # A file whose invocation just finished is not an error to miss.
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end