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