openlanes 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -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 +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- 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 +7 -0
- 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 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- 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 +84 -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 +1175 -117
- 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 +6 -6
- 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 +12 -120
- 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 +223 -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 +17 -13
- 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 +60 -11
- 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 +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- 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 +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- 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 +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -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 +64 -5
- 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 +39 -24
- 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 +226 -8
- 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 +167 -6
- 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 +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- 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 +29 -10
- data/lib/openlanes/web.rb +6 -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/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- 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/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /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,20 +1124,35 @@ 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|
|
|
706
1136
|
working = @running.select { plan_of(it) == subject.feature.ordinal.to_s }.map { it.task.agent }
|
|
707
1137
|
# After {#reconcile} this is the folder's name. On a dry run, which
|
|
708
1138
|
# renames nothing, it is what the name would have become. A plan an
|
|
709
1139
|
# agent is in right now is never reconciled, so its contents are
|
|
710
1140
|
# not asked: see {#partners_only}.
|
|
711
|
-
state =
|
|
712
|
-
Lifecycle::Lanes.target(subject) || Lifecycle::Resync::Dirs.target(subject)
|
|
713
|
-
else
|
|
714
|
-
subject.status
|
|
715
|
-
end
|
|
1141
|
+
state = working.empty? ? @runner.effective_status(subject) : subject.status
|
|
716
1142
|
next if Plans::StateMachine::SETTLED.include?(state.key)
|
|
717
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)
|
|
718
1148
|
next if @runner.foreign?(subject) || @discarding.key?(subject.feature.ordinal.to_s)
|
|
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 }
|
|
719
1156
|
|
|
720
1157
|
candidates = partners_only(Agents::Routing.filter(@runner.agents.for_status(state), subject), working, state)
|
|
721
1158
|
preferred = @preferred[subject.feature.ordinal.to_s]
|
|
@@ -726,11 +1163,30 @@ module OpenLanes
|
|
|
726
1163
|
nil
|
|
727
1164
|
end
|
|
728
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
|
+
|
|
729
1185
|
# One agent at a time on a plan, except a pair working the same state.
|
|
730
1186
|
#
|
|
731
1187
|
# A plan with an agent in it is judged by its folder's name, never by
|
|
732
1188
|
# what its contents would justify. Asking the contents is how the
|
|
733
|
-
# minesweeper trial ran its whole chain inside
|
|
1189
|
+
# minesweeper trial ran its whole chain inside ⚪ New: leah wrote her
|
|
734
1190
|
# `## Research` chapter, the next tick saw a Researched plan and started
|
|
735
1191
|
# yoda beside her, and leah's own Completed then found a partner running
|
|
736
1192
|
# and held the folder where it was. Every later agent repeated it, so
|
|
@@ -753,18 +1209,20 @@ module OpenLanes
|
|
|
753
1209
|
# A folder's name can lag its contents: a run killed before its rename,
|
|
754
1210
|
# a plan.md written by hand, a resync nobody ran. So the names are
|
|
755
1211
|
# reconciled before every dispatch, the way `resync dirs` would, and a
|
|
756
|
-
#
|
|
1212
|
+
# ⚪ folder that already holds a plan goes to the pair under its honest
|
|
757
1213
|
# name rather than to leah-researcher under its stale one. Plans an
|
|
758
1214
|
# agent is working in right now are left alone: the prompt names the
|
|
759
1215
|
# folder by path, and moving it under a running agent would strand
|
|
760
1216
|
# every write it makes afterwards. Nor is a plan another machine holds
|
|
761
|
-
# 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.
|
|
762
1219
|
#
|
|
763
1220
|
# @return [void]
|
|
764
1221
|
def reconcile
|
|
765
1222
|
busy = untouchable
|
|
766
1223
|
Lifecycle::Resync::Dirs.new(tree: @runner.tree.reload, except: busy).call(commit: !@runner.dry_run?)
|
|
767
1224
|
fast_forward(busy) unless @runner.dry_run?
|
|
1225
|
+
@runner.age!(except: busy)
|
|
768
1226
|
end
|
|
769
1227
|
|
|
770
1228
|
# Plans whose lane skips research, rewriting or planning are moved past
|
|
@@ -793,19 +1251,39 @@ module OpenLanes
|
|
|
793
1251
|
return true if past.empty?
|
|
794
1252
|
return false if %w[Completed Blocked].include?(past.last[:status])
|
|
795
1253
|
|
|
796
|
-
past.size < rounds_for(agent)
|
|
1254
|
+
past.size < rounds_for(agent) + @extensions.rounds(subject.feature.path, agent.name, subject.status.key)
|
|
797
1255
|
end
|
|
798
1256
|
|
|
799
1257
|
# @param agent [OpenLanes::Agents::Agent]
|
|
800
1258
|
# @param subject [OpenLanes::Plans::Subject]
|
|
801
|
-
# @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
|
|
802
1261
|
def start(agent, subject)
|
|
803
1262
|
ordinal = subject.feature.ordinal.to_s
|
|
804
1263
|
round = @history[[ordinal, agent.name]].count { |h| h[:state] == subject.status.key } + 1
|
|
805
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
|
+
|
|
806
1269
|
subject = rename_on_start(subject, agent) unless @runner.dry_run?
|
|
807
|
-
|
|
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))
|
|
808
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?
|
|
809
1287
|
job = Job.new(key: "#{ordinal}/#{agent.name}",
|
|
810
1288
|
task:,
|
|
811
1289
|
handle:,
|
|
@@ -816,6 +1294,12 @@ module OpenLanes
|
|
|
816
1294
|
down: 0,
|
|
817
1295
|
subagents: 0,
|
|
818
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
|
|
819
1303
|
if agent.advances_to && Plans::STATUS_BY_KEY.key?(agent.advances_to)
|
|
820
1304
|
successor = Agents::Routing.filter(@runner.agents.for_status(Plans::STATUS_BY_KEY.fetch(agent.advances_to)),
|
|
821
1305
|
subject).first&.name
|
|
@@ -825,14 +1309,18 @@ module OpenLanes
|
|
|
825
1309
|
# never start is not a partner.
|
|
826
1310
|
team = @runner.agents.team_for(Lifecycle::Resync::Dirs.target(subject))
|
|
827
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)
|
|
828
1315
|
remember(ordinal, agent.name, job.state, "Started", round)
|
|
829
1316
|
stage!(subject,
|
|
830
1317
|
agent,
|
|
831
1318
|
round,
|
|
1319
|
+
reset: true,
|
|
832
1320
|
status: "Started",
|
|
833
1321
|
from: from.to_s,
|
|
834
|
-
started_at:
|
|
835
|
-
|
|
1322
|
+
started_at: @clock.now.iso8601,
|
|
1323
|
+
cwd: task.root,
|
|
836
1324
|
**profile_fields(agent, subject))
|
|
837
1325
|
@state&.record(ordinal,
|
|
838
1326
|
agent: agent.name,
|
|
@@ -841,26 +1329,107 @@ module OpenLanes
|
|
|
841
1329
|
state: from.to_s,
|
|
842
1330
|
model: model_for(agent, subject),
|
|
843
1331
|
file: agent.ledger.first,
|
|
844
|
-
started_at:
|
|
1332
|
+
started_at: @clock.now.iso8601)
|
|
1333
|
+
journal!(:plan_assigned, plan: ordinal, agent: agent.name, round:)
|
|
845
1334
|
UI.log("started", **task.log_fields)
|
|
846
1335
|
job.thread = Thread.new do
|
|
847
1336
|
Thread.current.report_on_exception = false
|
|
848
1337
|
job.result = begin
|
|
849
|
-
|
|
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|
|
|
850
1391
|
job.up = progress.up
|
|
851
1392
|
job.down = progress.down
|
|
852
|
-
job.
|
|
853
|
-
job.context = progress.context
|
|
854
|
-
job.message = progress.message || progress.activity
|
|
1393
|
+
job.message = "rebasing: #{progress.message || progress.activity}"
|
|
855
1394
|
job.history = Board::Row.remember(job.history || [], job.message)
|
|
856
1395
|
}
|
|
857
1396
|
rescue StandardError => e
|
|
858
1397
|
e
|
|
1398
|
+
ensure
|
|
1399
|
+
@locks.release(ordinal)
|
|
859
1400
|
end
|
|
860
1401
|
end
|
|
861
1402
|
@running << job
|
|
862
1403
|
end
|
|
863
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
|
+
|
|
864
1433
|
# `starts_as`: the folder moves the moment the agent is dispatched, where
|
|
865
1434
|
# the topology allows, so luke's start is what makes a plan 🟡.
|
|
866
1435
|
#
|
|
@@ -873,6 +1442,71 @@ module OpenLanes
|
|
|
873
1442
|
Plans::Tree.new(dir: @runner.tree.dir).find(subject.feature.ordinal) || subject
|
|
874
1443
|
end
|
|
875
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
|
+
|
|
876
1510
|
# ---- polling ----------------------------------------------------------
|
|
877
1511
|
|
|
878
1512
|
# @return [void]
|
|
@@ -882,6 +1516,9 @@ module OpenLanes
|
|
|
882
1516
|
entry = Plans::Ledger.last_for(reading, job.task.agent.name)
|
|
883
1517
|
job.file = entry&.file || job.file
|
|
884
1518
|
job.status = entry&.status
|
|
1519
|
+
record_session(job)
|
|
1520
|
+
record_journal(job)
|
|
1521
|
+
journal_progress(job)
|
|
885
1522
|
@state&.record(job.task.subject.feature.ordinal.to_s,
|
|
886
1523
|
agent: job.task.agent.name,
|
|
887
1524
|
round: job.task.round,
|
|
@@ -894,6 +1531,49 @@ module OpenLanes
|
|
|
894
1531
|
end
|
|
895
1532
|
end
|
|
896
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
|
+
|
|
897
1577
|
# @param task [OpenLanes::Engine::Runner::Task]
|
|
898
1578
|
# @return [OpenLanes::Plans::Ledger::Reading]
|
|
899
1579
|
def read_ledger(task)
|
|
@@ -907,22 +1587,30 @@ module OpenLanes
|
|
|
907
1587
|
def reap
|
|
908
1588
|
@running.select(&:finished?).each do |job|
|
|
909
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)
|
|
910
1594
|
attempt = settle(job)
|
|
1595
|
+
journal_end(job, attempt)
|
|
911
1596
|
# A requeue, or a compaction that stopped the agent and then could
|
|
912
1597
|
# not happen, hands the round back: the stop was a person's choice.
|
|
913
1598
|
requeued = @requeued.delete(job.key)
|
|
914
1599
|
forget_round(job) if requeued || job.handle.compaction&.restart
|
|
915
1600
|
@attempts << attempt
|
|
1601
|
+
close_extension(job)
|
|
916
1602
|
row = row_for(job)
|
|
917
1603
|
@recent << { at: UI.monotonic, row: row.with(state: attempt.ok ? :done : :failed,
|
|
918
1604
|
message: attempt.note,
|
|
919
1605
|
history: Board::Row.remember(row.history, attempt.note),
|
|
920
1606
|
remaining: nil,
|
|
921
1607
|
phase: nil,
|
|
1608
|
+
running: [],
|
|
922
1609
|
bold: !attempt.ok) }
|
|
923
|
-
# `to:`
|
|
1610
|
+
# `to:` names the state the plan moved to, on the terminal and in the log.
|
|
924
1611
|
moved = Plans::STATUS_BY_KEY[attempt.to]&.to_s if attempt.advanced?
|
|
925
1612
|
UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}",
|
|
1613
|
+
level: attempt.ok ? :info : :warn,
|
|
926
1614
|
**job.task.log_fields,
|
|
927
1615
|
**{ to: moved }.compact)
|
|
928
1616
|
end
|
|
@@ -936,7 +1624,12 @@ module OpenLanes
|
|
|
936
1624
|
ordinal = task.subject.feature.ordinal.to_s
|
|
937
1625
|
result = job.result
|
|
938
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
|
|
939
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)
|
|
940
1633
|
return base.with(note: result.note) if @runner.dry_run?
|
|
941
1634
|
|
|
942
1635
|
reading = read_ledger(task)
|
|
@@ -946,9 +1639,14 @@ module OpenLanes
|
|
|
946
1639
|
|
|
947
1640
|
attempt = if (why = stopped_for(ordinal))
|
|
948
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)
|
|
949
1646
|
elsif result.killed || (result.ok && (entry.nil? || entry.status == "Started"))
|
|
950
1647
|
verify_and_sign(job, base, reason_for(result))
|
|
951
1648
|
elsif !result.ok
|
|
1649
|
+
fresh(task)&.then { harness_resume(it, agent, task.round, result.note, phrase: job.message) }
|
|
952
1650
|
remember(ordinal, agent.name, job.state, "Interrupted", task.round)
|
|
953
1651
|
base.with(ok: false, note: result.note)
|
|
954
1652
|
elsif entry.completed?
|
|
@@ -971,18 +1669,19 @@ module OpenLanes
|
|
|
971
1669
|
task.round,
|
|
972
1670
|
status: outcome,
|
|
973
1671
|
to: attempt.to.to_s,
|
|
974
|
-
finished_at:
|
|
1672
|
+
finished_at: @clock.now.iso8601,
|
|
975
1673
|
up: attempt.up.to_i,
|
|
976
1674
|
down: attempt.down.to_i,
|
|
977
1675
|
seconds: attempt.seconds.to_f.round(1),
|
|
978
|
-
note: attempt.note.to_s
|
|
1676
|
+
note: attempt.note.to_s,
|
|
1677
|
+
session: job.handle&.session)
|
|
979
1678
|
end
|
|
980
1679
|
@state&.record(ordinal,
|
|
981
1680
|
agent: agent.name,
|
|
982
1681
|
round: task.round,
|
|
983
1682
|
status: outcome,
|
|
984
1683
|
exit: attempt.ok ? "ok" : attempt.note,
|
|
985
|
-
ended_at:
|
|
1684
|
+
ended_at: @clock.now.iso8601,
|
|
986
1685
|
up: attempt.up,
|
|
987
1686
|
down: attempt.down)
|
|
988
1687
|
attempt
|
|
@@ -1004,7 +1703,10 @@ module OpenLanes
|
|
|
1004
1703
|
def stopped_by_harness(job, base, why)
|
|
1005
1704
|
task = job.task
|
|
1006
1705
|
subject = fresh(task)
|
|
1007
|
-
|
|
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
|
|
1008
1710
|
remember(plan_of(job), task.agent.name, job.state, "Interrupted", task.round)
|
|
1009
1711
|
base.with(ok: false, status: "Interrupted", note: why)
|
|
1010
1712
|
end
|
|
@@ -1028,45 +1730,71 @@ module OpenLanes
|
|
|
1028
1730
|
|
|
1029
1731
|
# The agent stopped without saying it finished. If the state it was
|
|
1030
1732
|
# advancing to is justified by what is on disk, the work happened and
|
|
1031
|
-
# the harness signs for it
|
|
1733
|
+
# the harness signs for it in state.json, saying why it had to.
|
|
1032
1734
|
#
|
|
1033
1735
|
# @return [OpenLanes::Engine::Runner::Attempt]
|
|
1034
1736
|
def verify_and_sign(job, base, reason)
|
|
1035
1737
|
task = job.task
|
|
1036
1738
|
agent = task.agent
|
|
1037
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)
|
|
1038
1743
|
write_interrupted(subject, agent, task.round, reason)
|
|
1039
1744
|
target = agent.advances_to
|
|
1040
1745
|
if target && subject.machine.may?(target)
|
|
1041
1746
|
successor = @runner.agents.for_status(Plans::STATUS_BY_KEY.fetch(target)).first&.name
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
status:
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
file: "",
|
|
1048
|
-
line: 0))]
|
|
1049
|
-
lines << Plans::Ledger.render_handoff(Plans::Ledger::Handoff.new(at: Time.now, next: successor, file: "", line: 0)) if successor
|
|
1050
|
-
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)
|
|
1051
1752
|
entry = Plans::Ledger.last_for(read_ledger(task), agent.name)
|
|
1052
1753
|
complete(job, base.with(note: "#{reason}; signed by harness"), entry, read_ledger(task))
|
|
1053
1754
|
else
|
|
1755
|
+
harness_resume(subject, agent, task.round, reason, last:, phrase: job.message)
|
|
1054
1756
|
remember(subject.feature.ordinal.to_s, agent.name, job.state, "Interrupted", task.round)
|
|
1055
|
-
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))}")
|
|
1056
1758
|
end
|
|
1057
1759
|
end
|
|
1058
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
|
+
#
|
|
1059
1795
|
# @return [void]
|
|
1060
1796
|
def write_interrupted(subject, agent, round, reason)
|
|
1061
|
-
|
|
1062
|
-
agent: agent.name,
|
|
1063
|
-
status: "Interrupted",
|
|
1064
|
-
round:,
|
|
1065
|
-
note: reason,
|
|
1066
|
-
file: "",
|
|
1067
|
-
line: 0,
|
|
1068
|
-
bold: true))
|
|
1069
|
-
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)
|
|
1070
1798
|
end
|
|
1071
1799
|
|
|
1072
1800
|
# A Completed entry: publish if the move is to 🟢, then move the folder,
|
|
@@ -1083,14 +1811,16 @@ module OpenLanes
|
|
|
1083
1811
|
# Only a reviewer rules. A builder that signed `Completed (approved)`
|
|
1084
1812
|
# once had its own work taken as approved: the plan was parked as
|
|
1085
1813
|
# finished at 🟡, never reviewed and never published.
|
|
1086
|
-
|
|
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
|
+
|
|
1087
1819
|
partner_running = @running.any? { |j| j.task.subject.feature.ordinal.to_s == ordinal }
|
|
1088
1820
|
target = target_for(agent, verdict, partner_running)
|
|
1089
1821
|
|
|
1090
|
-
if verdict == :approved
|
|
1091
|
-
|
|
1092
|
-
return base.with(status: "Completed", note: "#{base.note}; approved, awaiting merge")
|
|
1093
|
-
end
|
|
1822
|
+
return approved(base, subject, ordinal) if verdict == :approved
|
|
1823
|
+
return built_locally(base.with(status: "Completed"), ordinal) if verdict == :verified
|
|
1094
1824
|
|
|
1095
1825
|
attempt = base.with(status: "Completed")
|
|
1096
1826
|
return built_locally(attempt, ordinal) if local_only_review?(target, subject)
|
|
@@ -1098,6 +1828,7 @@ module OpenLanes
|
|
|
1098
1828
|
if target
|
|
1099
1829
|
attempt = attempt.with(note: "#{base.note}#{" (#{entry.note})" if entry.note}")
|
|
1100
1830
|
published = publish_for_review(task, target, subject, attempt)
|
|
1831
|
+
return untestable(job, published.attempt, subject) if published.untestable
|
|
1101
1832
|
return unpublished(published, ordinal) if published.refused
|
|
1102
1833
|
|
|
1103
1834
|
attempt = published.attempt
|
|
@@ -1115,8 +1846,8 @@ module OpenLanes
|
|
|
1115
1846
|
|
|
1116
1847
|
# The agent signed Completed, but what it left does not justify the
|
|
1117
1848
|
# state it advances to. The folder stays, its `next:` is not honoured,
|
|
1118
|
-
# and the round is recorded as Interrupted
|
|
1119
|
-
#
|
|
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
|
|
1120
1851
|
# signature at its word instead parked the plan: nobody else handles
|
|
1121
1852
|
# the state it is still in, and its own agent counted itself done.
|
|
1122
1853
|
#
|
|
@@ -1156,11 +1887,146 @@ module OpenLanes
|
|
|
1156
1887
|
attempt.with(note: "#{attempt.note}; built locally, no pull request in this run, so it waits for one")
|
|
1157
1888
|
end
|
|
1158
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
|
+
|
|
1159
2023
|
# @param entry [OpenLanes::Plans::Ledger::Entry]
|
|
1160
|
-
# @return [Symbol, nil] :rejected, :approved, :slop, :product or nil
|
|
2024
|
+
# @return [Symbol, nil] :rejected, :verified, :approved, :slop, :product or nil
|
|
1161
2025
|
def verdict_of(entry)
|
|
1162
2026
|
note = entry.note.to_s.downcase
|
|
1163
|
-
|
|
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")
|
|
1164
2030
|
return :approved if note.include?("approv")
|
|
1165
2031
|
return :slop if note.include?("slop") || note.include?("rewrite")
|
|
1166
2032
|
return :product if note.include?("product")
|
|
@@ -1202,15 +2068,47 @@ module OpenLanes
|
|
|
1202
2068
|
|
|
1203
2069
|
publication = @runner.publish(task, subject)
|
|
1204
2070
|
if publication&.refusal
|
|
1205
|
-
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?)
|
|
1206
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) }
|
|
1207
2079
|
verb = publication.updated ? "pushed to" : "opened"
|
|
1208
|
-
|
|
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)
|
|
1209
2083
|
else
|
|
1210
2084
|
unchanged
|
|
1211
2085
|
end
|
|
1212
2086
|
end
|
|
1213
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
|
+
|
|
1214
2112
|
# A refused publish leaves the plan where it was. Running the builder
|
|
1215
2113
|
# again would redo finished work to hit the same rejected push, so the
|
|
1216
2114
|
# plan is done for this run and the attempt says why.
|
|
@@ -1246,6 +2144,7 @@ module OpenLanes
|
|
|
1246
2144
|
subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
|
|
1247
2145
|
remember(subject.feature.ordinal.to_s, task.agent.name, job.state, "Blocked", task.round)
|
|
1248
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)
|
|
1249
2148
|
if subject.machine.may?(target)
|
|
1250
2149
|
subject.machine.promote!(target)
|
|
1251
2150
|
base.with(to: target, status: "Blocked", note: "blocked; see blocked.md")
|
|
@@ -1256,6 +2155,7 @@ module OpenLanes
|
|
|
1256
2155
|
|
|
1257
2156
|
# @return [OpenLanes::Engine::Runner::Attempt]
|
|
1258
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) }
|
|
1259
2159
|
remember(base.ordinal, base.agent, job.state, "Interrupted", base.round)
|
|
1260
2160
|
base.with(ok: false, note: error.message.lines.first.to_s.strip)
|
|
1261
2161
|
end
|
|
@@ -1296,7 +2196,92 @@ module OpenLanes
|
|
|
1296
2196
|
|
|
1297
2197
|
Plans::PlanState.for(subject.feature.path).stage!(agent: agent.name, round:, **fields)
|
|
1298
2198
|
rescue SystemCallError, OpenLanes::Error => e
|
|
1299
|
-
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)
|
|
1300
2285
|
end
|
|
1301
2286
|
|
|
1302
2287
|
# @return [Hash] adapter, model and effort, when the executor can say
|
|
@@ -1340,6 +2325,79 @@ module OpenLanes
|
|
|
1340
2325
|
@finished_plans.delete(ordinal)
|
|
1341
2326
|
end
|
|
1342
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
|
+
|
|
1343
2401
|
# @return [void]
|
|
1344
2402
|
def persist
|
|
1345
2403
|
return unless @state
|