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