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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
require "socket"
|
|
7
8
|
require "time"
|
|
8
9
|
require "tmpdir"
|
|
@@ -22,6 +23,8 @@ module OpenLanes
|
|
|
22
23
|
# messages the agents, through `openlanes mail send`: what a pair
|
|
23
24
|
# says to each other, and the RESUME notes an interrupted
|
|
24
25
|
# agent leaves for its successor
|
|
26
|
+
# memory the harness again: each agent's journal (M2), the lines
|
|
27
|
+
# it printed as it worked, with the answers to its questions
|
|
25
28
|
#
|
|
26
29
|
# A stage still Started whose harness process is gone is a crash, which is
|
|
27
30
|
# how a restarted run finds what it has to resume.
|
|
@@ -104,38 +107,216 @@ module OpenLanes
|
|
|
104
107
|
# @return [String, nil]
|
|
105
108
|
def state = read["state"]
|
|
106
109
|
|
|
110
|
+
# Until when a 💣 on this plan does not halt the run: "Resume anyway
|
|
111
|
+
# for 24 h" from the control room. Kept here, not in `.openlanes.yml`,
|
|
112
|
+
# so it lapses on its own and nobody has to remember to take it out.
|
|
113
|
+
#
|
|
114
|
+
# @return [Time, nil]
|
|
115
|
+
def resume_until
|
|
116
|
+
text = read["resume_until"] or return nil
|
|
117
|
+
parse_time(text)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param now [Time]
|
|
121
|
+
# @return [Boolean] whether a halt on this plan is lifted right now
|
|
122
|
+
def resumed?(now: Time.now) = (resume_until || now) > now
|
|
123
|
+
|
|
124
|
+
# @param till [Time] when the halt comes back
|
|
125
|
+
# @return [Time] as written
|
|
126
|
+
def resume_anyway!(till:)
|
|
127
|
+
update { |data| data["resume_until"] = till.iso8601 }
|
|
128
|
+
till
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ---- finished ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# How a plan finished: hansolo approved it, or its pull request merged.
|
|
134
|
+
FINISH_KINDS = %w[approved merged].freeze
|
|
135
|
+
|
|
136
|
+
# When and how a plan finished, which the control room keeps on the
|
|
137
|
+
# board for the rest of the run (and a restarted engine carries the
|
|
138
|
+
# latest one over). Kept here because nothing else records the moment:
|
|
139
|
+
# the folder name says ✅ but not since when, and `pull-requests.md`
|
|
140
|
+
# has no times at all.
|
|
141
|
+
#
|
|
142
|
+
# @!attribute [r] kind
|
|
143
|
+
# @return [String] one of {FINISH_KINDS}
|
|
144
|
+
# @!attribute [r] at
|
|
145
|
+
# @return [Time]
|
|
146
|
+
Finished = Data.define(:kind, :at) do
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def merged? = kind == "merged"
|
|
149
|
+
|
|
150
|
+
# @return [Hash] as `state.json` and the snapshot carry it
|
|
151
|
+
def to_json_h = { "kind" => kind, "at" => at.utc.iso8601 }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [OpenLanes::Plans::PlanState::Finished, nil]
|
|
155
|
+
def finished
|
|
156
|
+
record = read["finished"] or return nil
|
|
157
|
+
Finished.new(kind: record["kind"], at: parse_time(record["at"]))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Record that the plan finished. A merge replaces an approval; nothing
|
|
161
|
+
# replaces a merge, so the watch, which reports the same merged pull
|
|
162
|
+
# request on every poll, keeps the time of the first report and writes
|
|
163
|
+
# nothing after it. A second approval, after a rejection and a rebuild,
|
|
164
|
+
# is a new approval and takes its own time.
|
|
165
|
+
#
|
|
166
|
+
# @param kind [String] one of {FINISH_KINDS}
|
|
167
|
+
# @param at [Time]
|
|
168
|
+
# @return [OpenLanes::Plans::PlanState::Finished] the record kept
|
|
169
|
+
# @raise [OpenLanes::Error] for a kind outside {FINISH_KINDS}
|
|
170
|
+
def finish!(kind, at: Time.now)
|
|
171
|
+
kind = kind.to_s
|
|
172
|
+
raise Error, "a plan finishes #{FINISH_KINDS.join(" or ")}, not #{kind.inspect}" unless FINISH_KINDS.include?(kind)
|
|
173
|
+
|
|
174
|
+
current = finished
|
|
175
|
+
return current if current&.merged?
|
|
176
|
+
|
|
177
|
+
kept = Finished.new(kind:, at:)
|
|
178
|
+
update do |data|
|
|
179
|
+
existing = data["finished"]
|
|
180
|
+
if existing && existing["kind"] == "merged"
|
|
181
|
+
kept = Finished.new(kind: "merged", at: parse_time(existing["at"]))
|
|
182
|
+
else
|
|
183
|
+
data["finished"] = kept.to_json_h
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
kept
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Forget that the plan finished: an agent is working on it again, or a
|
|
190
|
+
# person rejected what was approved. Writes nothing when there was
|
|
191
|
+
# nothing to forget.
|
|
192
|
+
#
|
|
193
|
+
# @return [void]
|
|
194
|
+
def unfinish!
|
|
195
|
+
return unless read.key?("finished")
|
|
196
|
+
|
|
197
|
+
update { |data| data.delete("finished") }
|
|
198
|
+
end
|
|
199
|
+
|
|
107
200
|
# ---- the harness --------------------------------------------------------
|
|
108
201
|
|
|
109
|
-
# Record or update one agent's round. A
|
|
110
|
-
# and round updates the
|
|
111
|
-
# to settlement.
|
|
202
|
+
# Record or update one agent's round. A later call for the same agent
|
|
203
|
+
# and round updates the latest record of it, so a round is one record
|
|
204
|
+
# from dispatch to settlement.
|
|
205
|
+
#
|
|
206
|
+
# A new dispatch passes +reset+, which appends a record rather than
|
|
207
|
+
# finding one: rounds are numbered per state and per run, so round 1 of
|
|
208
|
+
# a later run shares its number with round 1 of an earlier one. Merging
|
|
209
|
+
# into the earlier record would carry its session, checkout and outcome
|
|
210
|
+
# into a round that has none of them yet, and replacing it would lose
|
|
211
|
+
# that round from `openlanes state show`. Appending keeps both, in
|
|
212
|
+
# dispatch order, which {#resumption} relies on when two rounds started
|
|
213
|
+
# in the same second.
|
|
112
214
|
#
|
|
113
215
|
# @param agent [String]
|
|
114
216
|
# @param round [Integer]
|
|
217
|
+
# @param reset [Boolean] start a new record rather than update one
|
|
115
218
|
# @param fields [Hash] status, started_at, finished_at, adapter, model,
|
|
116
|
-
# effort, up, down, seconds, note, to, run
|
|
219
|
+
# effort, up, down, seconds, note, to, session, cwd; `run` only in files older releases wrote
|
|
117
220
|
# @return [Hash] the stage as written
|
|
118
|
-
def stage!(agent:, round:, **fields)
|
|
221
|
+
def stage!(agent:, round:, reset: false, **fields)
|
|
119
222
|
stage = nil
|
|
120
223
|
update do |data|
|
|
121
|
-
stage = data["stages"].
|
|
224
|
+
stage = data["stages"].rfind { |s| s["agent"] == agent && s["round"] == round } unless reset
|
|
122
225
|
unless stage
|
|
123
226
|
stage = { "agent" => agent, "round" => round }
|
|
124
227
|
data["stages"] << stage
|
|
125
228
|
end
|
|
126
229
|
stage.merge!(fields.transform_keys(&:to_s).compact)
|
|
230
|
+
# A settling round notes how often the plan had moved by then, in
|
|
231
|
+
# the same write, so {#resumption} sees a move no stage recorded.
|
|
232
|
+
stage["moves"] = data["moves"].to_i if fields.key?(:to)
|
|
127
233
|
end
|
|
128
234
|
stage
|
|
129
235
|
end
|
|
130
236
|
|
|
237
|
+
# Whether one agent's next round may carry on the session its last one
|
|
238
|
+
# ran in (M1), and if not, why not. Asked before the new round's own
|
|
239
|
+
# stage is written, so the answer is about the rounds before it.
|
|
240
|
+
#
|
|
241
|
+
# The latest stage of +agent+ that names a session is the candidate. It
|
|
242
|
+
# is resumable only when it settled leaving the plan in +state+, no
|
|
243
|
+
# stage started after it moved the plan anywhere else, and it ran in
|
|
244
|
+
# +root+: `claude` keeps its sessions per working directory, so an id
|
|
245
|
+
# recorded in a worktree is not found from the main checkout. The
|
|
246
|
+
# candidate's own `from` is not compared, because an agent that
|
|
247
|
+
# `starts_as` renames the folder on dispatch: its first round leaves
|
|
248
|
+
# from Planned and works in Building, and its second leaves from
|
|
249
|
+
# Building.
|
|
250
|
+
#
|
|
251
|
+
# A move no agent made, such as `resync dirs` or a person through
|
|
252
|
+
# the CLI, records no stage, so {#state!} counts every move in `moves`
|
|
253
|
+
# and a settling stage notes the count it saw: a count that has grown
|
|
254
|
+
# since means the plan went away and came back. A folder renamed by
|
|
255
|
+
# hand, outside openlanes, is not counted.
|
|
256
|
+
#
|
|
257
|
+
# A round that ends Blocked starts the next one fresh, after the
|
|
258
|
+
# person answers: the plan went to Blocked and back. That is the case
|
|
259
|
+
# where the earlier context would help most, and it is deliberately
|
|
260
|
+
# conservative for now; the answer reaches the agent through
|
|
261
|
+
# blocked.md either way.
|
|
262
|
+
#
|
|
263
|
+
# @param agent [String]
|
|
264
|
+
# @param state [#to_s] the state key the plan is in now
|
|
265
|
+
# @param root [String] the checkout the new round will run in
|
|
266
|
+
# @return [OpenLanes::Plans::PlanState::Resumption]
|
|
267
|
+
def resumption(agent:, state:, root:)
|
|
268
|
+
data = read
|
|
269
|
+
all = data["stages"]
|
|
270
|
+
# `started_at` has second precision, so two rounds can tie on it; the
|
|
271
|
+
# one dispatched later sits later in the array.
|
|
272
|
+
last, = all.each_with_index
|
|
273
|
+
.select { |s, _| s["agent"] == agent && !s["session"].to_s.empty? }
|
|
274
|
+
.max_by { |s, i| [started(s), i] }
|
|
275
|
+
return Resumption.new(session: nil, reason: "no session recorded for #{agent}") unless last
|
|
276
|
+
|
|
277
|
+
session = last["session"]
|
|
278
|
+
state = state.to_s
|
|
279
|
+
reason = if last["to"].to_s.empty?
|
|
280
|
+
"round #{last["round"]} (#{session}) never settled"
|
|
281
|
+
elsif last["to"] != state
|
|
282
|
+
"the plan moved to #{last["to"]} after round #{last["round"]} (#{session})"
|
|
283
|
+
elsif (moved = moved_since(all, last, state))
|
|
284
|
+
"the plan moved to #{moved} since round #{last["round"]} (#{session})"
|
|
285
|
+
elsif last.key?("moves") && data["moves"].to_i != last["moves"]
|
|
286
|
+
"the plan moved away and back since round #{last["round"]} (#{session})"
|
|
287
|
+
elsif File.expand_path(last["cwd"].to_s) != File.expand_path(root) || last["cwd"].to_s.empty?
|
|
288
|
+
"session #{session} ran in #{last["cwd"] || "an unrecorded checkout"}, not #{root}"
|
|
289
|
+
end
|
|
290
|
+
Resumption.new(session: reason ? nil : session, reason: reason || "round #{last["round"]} left it in #{state}")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# What {#resumption} decided.
|
|
294
|
+
#
|
|
295
|
+
# @!attribute [r] session
|
|
296
|
+
# @return [String, nil] the id to resume, nil to start fresh
|
|
297
|
+
# @!attribute [r] reason
|
|
298
|
+
# @return [String] why, for the log
|
|
299
|
+
Resumption = Data.define(:session, :reason) do
|
|
300
|
+
# @return [Boolean]
|
|
301
|
+
def resume? = !session.nil?
|
|
302
|
+
end
|
|
303
|
+
|
|
131
304
|
# @param key [Symbol, String] the state the folder was just moved to
|
|
132
305
|
# @return [void]
|
|
133
306
|
def state!(key)
|
|
134
|
-
update
|
|
307
|
+
update do |data|
|
|
308
|
+
data["moves"] = data["moves"].to_i + 1 unless data["state"] == key.to_s
|
|
309
|
+
data["state"] = key.to_s
|
|
310
|
+
end
|
|
135
311
|
end
|
|
136
312
|
|
|
313
|
+
# @return [Integer] how many times {#state!} has moved the plan
|
|
314
|
+
def moves = read["moves"].to_i
|
|
315
|
+
|
|
137
316
|
# Stages a dead harness left Started. Nobody is working them, and nobody
|
|
138
|
-
# will finish them unless a run picks them up.
|
|
317
|
+
# will finish them unless a run picks them up. Only a stage an older
|
|
318
|
+
# release wrote carries `run`; newer ones leave it to the run's
|
|
319
|
+
# {Engine::StateFile}, so they are never reported here.
|
|
139
320
|
#
|
|
140
321
|
# @param alive [Proc] pid → Boolean
|
|
141
322
|
# @param pid [Integer] this run, which is never its own crash
|
|
@@ -192,13 +373,385 @@ module OpenLanes
|
|
|
192
373
|
next if message
|
|
193
374
|
|
|
194
375
|
number = [data["messages"].map { |m| m["number"] }.max.to_i, after].max + 1
|
|
195
|
-
|
|
376
|
+
# The id, not the number, is what a drain dedupes on: a worktree's
|
|
377
|
+
# copy of this file numbers its messages independently of the main
|
|
378
|
+
# checkout's, so two different messages can both be #4.
|
|
379
|
+
message = { "id" => SecureRandom.uuid, "number" => number, "at" => at.iso8601, "from" => from, "to" => to,
|
|
380
|
+
"body" => body, "read" => "no" }
|
|
196
381
|
message["command"] = command if command
|
|
197
382
|
data["messages"] << message
|
|
198
383
|
end
|
|
199
384
|
message
|
|
200
385
|
end
|
|
201
386
|
|
|
387
|
+
# Keep a message that arrived with its number already given, as one
|
|
388
|
+
# drained from Redis does (B4).
|
|
389
|
+
#
|
|
390
|
+
# A message is the same message when its id matches, or, when either
|
|
391
|
+
# side was written before ids existed, when sender, recipient, time and
|
|
392
|
+
# body all match. Never by number alone: `mail send` in a worktree
|
|
393
|
+
# numbers against the worktree's `state.json`, the main checkout
|
|
394
|
+
# numbers against its own, and both can hand out #4. Deduping on the
|
|
395
|
+
# number dropped the second #4 as a "duplicate" without a word.
|
|
396
|
+
#
|
|
397
|
+
# So the usual case, `mail send` having already written here before it
|
|
398
|
+
# published, writes nothing at all and the file's mtime stays; and a
|
|
399
|
+
# different message whose number is taken is kept under the next free
|
|
400
|
+
# one.
|
|
401
|
+
#
|
|
402
|
+
# @param message [Hash] as {#message!} writes it, string keys
|
|
403
|
+
# @param reserved [Array<Integer>] numbers taken outside this file,
|
|
404
|
+
# i.e. by mailbox.md
|
|
405
|
+
# @return [Hash, nil] the message as written, nil when it was here
|
|
406
|
+
def deliver!(message, reserved: [])
|
|
407
|
+
return nil if messages.any? { |m| same_message?(m, message) }
|
|
408
|
+
|
|
409
|
+
delivered = nil
|
|
410
|
+
update do |data|
|
|
411
|
+
next if data["messages"].any? { |m| same_message?(m, message) }
|
|
412
|
+
|
|
413
|
+
taken = data["messages"].map { |m| m["number"].to_i } + reserved
|
|
414
|
+
number = taken.include?(message["number"]) ? taken.max + 1 : message["number"]
|
|
415
|
+
# Unread whatever it arrived saying: only `mail ack` in this file
|
|
416
|
+
# says its reader read it.
|
|
417
|
+
delivered = message.slice("id", "number", "at", "from", "to", "body", "command")
|
|
418
|
+
.merge("number" => number, "read" => "no")
|
|
419
|
+
data["messages"] << delivered
|
|
420
|
+
data["messages"].sort_by! { |m| m["number"].to_i }
|
|
421
|
+
end
|
|
422
|
+
delivered
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Mark one message read, under the lock, so an ack and a send in the
|
|
426
|
+
# same second cannot lose each other. Only its recipient may ack it:
|
|
427
|
+
# `read` says the reader read it, not that somebody did.
|
|
428
|
+
#
|
|
429
|
+
# @param number [Integer]
|
|
430
|
+
# @param by [String] the agent acking, who must be the recipient
|
|
431
|
+
# @return [Hash, nil] the message as written, nil when there is no
|
|
432
|
+
# such number
|
|
433
|
+
# @raise [OpenLanes::Error] when +by+ is not the recipient
|
|
434
|
+
def ack!(number, by:)
|
|
435
|
+
message = nil
|
|
436
|
+
update do |data|
|
|
437
|
+
message = data["messages"].find { |m| m["number"] == number }
|
|
438
|
+
next unless message
|
|
439
|
+
raise Error, "message ##{number} is for #{message["to"]}, not #{by}" unless message["to"] == by
|
|
440
|
+
|
|
441
|
+
message["read"] = "yes"
|
|
442
|
+
end
|
|
443
|
+
message
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# ---- conversions ------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
# Questions the engine rewrote into lettered choices (D9), and the ones
|
|
449
|
+
# it tried and could not. `blocked.md` keeps only the rewrite, so the
|
|
450
|
+
# words the agent wrote live here, beside the digest that keeps a
|
|
451
|
+
# restarted run from asking the model about the same text again.
|
|
452
|
+
#
|
|
453
|
+
# @return [Array<Hash>] oldest first; empty for a plan never converted
|
|
454
|
+
def conversions = read["conversions"] || []
|
|
455
|
+
|
|
456
|
+
# @param digest [String] SHA256 of the question's body as the agent wrote it
|
|
457
|
+
# @return [Boolean] whether that text was converted, or failed to be
|
|
458
|
+
def converted?(digest) = conversions.any? { it["digest"] == digest }
|
|
459
|
+
|
|
460
|
+
# @param question [Integer] the `n` of `## Bn`
|
|
461
|
+
# @param digest [String] SHA256 of +original+
|
|
462
|
+
# @param original [String] the body before the rewrite
|
|
463
|
+
# @param model [String] the model asked
|
|
464
|
+
# @param outcome [String] "converted" or "rejected"
|
|
465
|
+
# @param note [String, nil] why it was rejected
|
|
466
|
+
# @param at [Time]
|
|
467
|
+
# @return [Hash] the record as written
|
|
468
|
+
def conversion!(question:, digest:, original:, model:, outcome:, note: nil, at: Time.now)
|
|
469
|
+
record = { "question" => Integer(question), "digest" => digest, "original" => original, "at" => at.iso8601,
|
|
470
|
+
"model" => model, "outcome" => outcome, "note" => note }.compact
|
|
471
|
+
update { |data| (data["conversions"] ||= []) << record }
|
|
472
|
+
record
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# ---- extensions -------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
# The harness asking a person for more time or another round for one
|
|
478
|
+
# agent (E1), and how each request was answered. Kept here rather than
|
|
479
|
+
# in the run's memory, so a grant survives a restart and an answer can
|
|
480
|
+
# come from any control room, after the run that asked is gone.
|
|
481
|
+
#
|
|
482
|
+
# @return [Array<Hash>] oldest first; empty for a plan that never asked
|
|
483
|
+
def extensions = read["extensions"] || []
|
|
484
|
+
|
|
485
|
+
# @param agent [String]
|
|
486
|
+
# @param state [String] the plan state the agent was working, a {Status} key
|
|
487
|
+
# @param round [Integer]
|
|
488
|
+
# @param limit [String] "time" or "rounds", whichever ran out
|
|
489
|
+
# @param at [Time]
|
|
490
|
+
# @return [Hash] the request as written, with its `number`
|
|
491
|
+
def ask_extension!(agent:, state:, round:, limit:, at: Time.now)
|
|
492
|
+
record = nil
|
|
493
|
+
update do |data|
|
|
494
|
+
all = (data["extensions"] ||= [])
|
|
495
|
+
record = { "number" => (all.map { it["number"] }.max || 0) + 1, "agent" => agent, "state" => state.to_s,
|
|
496
|
+
"round" => Integer(round), "limit" => limit, "asked_at" => at.iso8601 }
|
|
497
|
+
all << record
|
|
498
|
+
end
|
|
499
|
+
record
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Answer a request, or close it unanswered. Once settled it stays
|
|
503
|
+
# settled: a second answer, from another page or a replayed command,
|
|
504
|
+
# changes nothing.
|
|
505
|
+
#
|
|
506
|
+
# @param number [Integer]
|
|
507
|
+
# @param fields [Hash{String => Object}] `answer`, `by`, `grant`, `seconds`, `closed`
|
|
508
|
+
# @param at [Time]
|
|
509
|
+
# @return [Hash, nil] the request as settled; nil when there is no such
|
|
510
|
+
# request, or when it was settled already
|
|
511
|
+
def settle_extension!(number, at: Time.now, **fields)
|
|
512
|
+
settled = nil
|
|
513
|
+
update do |data|
|
|
514
|
+
found = (data["extensions"] || []).find { it["number"] == Integer(number) } or next
|
|
515
|
+
next if found.key?("settled_at")
|
|
516
|
+
|
|
517
|
+
settled = found.merge!(fields.transform_keys(&:to_s).compact, "settled_at" => at.iso8601)
|
|
518
|
+
end
|
|
519
|
+
settled
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Correct how a settled request was granted, when the grant could not
|
|
523
|
+
# be given as settled: time for a clock that had already stopped.
|
|
524
|
+
#
|
|
525
|
+
# @param number [Integer]
|
|
526
|
+
# @param fields [Hash{String => Object}]
|
|
527
|
+
# @return [Hash, nil] the request as it now stands
|
|
528
|
+
def amend_extension!(number, **fields)
|
|
529
|
+
amended = nil
|
|
530
|
+
update do |data|
|
|
531
|
+
amended = (data["extensions"] || []).find { it["number"] == Integer(number) }&.merge!(fields.transform_keys(&:to_s))
|
|
532
|
+
end
|
|
533
|
+
amended
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# R4's question, when this plan is the one that asked it.
|
|
537
|
+
#
|
|
538
|
+
# @return [Hash, nil] `asked_at`, and once answered `answer`, `by`, `settled_at`
|
|
539
|
+
def quality_ask = read["quality_ask"]
|
|
540
|
+
|
|
541
|
+
# Record that this plan asked R4's question. Asking twice changes
|
|
542
|
+
# nothing, so a hand-off repeated every tick keeps its first moment.
|
|
543
|
+
#
|
|
544
|
+
# @param at [Time]
|
|
545
|
+
# @return [Hash] the ask as it stands
|
|
546
|
+
def ask_quality!(at: Time.now)
|
|
547
|
+
update { |data| data["quality_ask"] ||= { "asked_at" => at.iso8601 } }
|
|
548
|
+
quality_ask
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# @param answer [String] "high" or "mvp"
|
|
552
|
+
# @param by [String]
|
|
553
|
+
# @param at [Time]
|
|
554
|
+
# @return [Hash, nil] the ask as settled; nil when none is open
|
|
555
|
+
def settle_quality_ask!(answer:, by:, at: Time.now)
|
|
556
|
+
settled = nil
|
|
557
|
+
update do |data|
|
|
558
|
+
ask = data["quality_ask"]
|
|
559
|
+
next if ask.nil? || ask.key?("settled_at")
|
|
560
|
+
|
|
561
|
+
settled = ask.merge!("answer" => answer.to_s, "by" => by, "settled_at" => at.iso8601)
|
|
562
|
+
end
|
|
563
|
+
settled
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Mark a granted round's extra seconds as given to the round that ran on them.
|
|
567
|
+
#
|
|
568
|
+
# @param number [Integer]
|
|
569
|
+
# @return [void]
|
|
570
|
+
def spend_extension!(number)
|
|
571
|
+
update { |data| (data["extensions"] || []).find { it["number"] == Integer(number) }&.store("spent", true) }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# ---- merge risk ---------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
# palpatine's verdicts for this visit to 🏁 (P1, P4), keyed by pull
|
|
577
|
+
# request number, plus the moments the engine rebased a sibling (P5).
|
|
578
|
+
# A visit is told apart by {#moves}: a plan sent back to 🔴 and
|
|
579
|
+
# brought to 🏁 again has moved since, so its old verdicts no longer
|
|
580
|
+
# stand and palpatine weighs the new code.
|
|
581
|
+
#
|
|
582
|
+
# @return [Hash, nil] `moves`, `at`, `verdicts` and `rebased`, nil when
|
|
583
|
+
# palpatine has not ruled on this visit
|
|
584
|
+
def merge_risk
|
|
585
|
+
record = read["merge_risk"] or return nil
|
|
586
|
+
record["moves"] == moves ? record : nil
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# @param verdicts [Hash{Integer, String => String}] number => `auto` or `manual`
|
|
590
|
+
# @param at [Time]
|
|
591
|
+
# @return [Hash] the record kept
|
|
592
|
+
def assess!(verdicts, at: Time.now)
|
|
593
|
+
update do |data|
|
|
594
|
+
data["merge_risk"] = { "moves" => data["moves"].to_i, "at" => at.iso8601,
|
|
595
|
+
"verdicts" => verdicts.to_h { |n, v| [n.to_s, v.to_s] }, "rebased" => {} }
|
|
596
|
+
end
|
|
597
|
+
merge_risk
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# @param number [Integer] the sibling pull request the engine rebased
|
|
601
|
+
# @param at [Time]
|
|
602
|
+
# @return [void]
|
|
603
|
+
def rebased!(number, at: Time.now)
|
|
604
|
+
update { |data| data["merge_risk"]&.fetch("rebased")&.store(number.to_s, at.iso8601) }
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# ---- memory -----------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
# Where the journals live in the document (M2).
|
|
610
|
+
MEMORY = "memory"
|
|
611
|
+
|
|
612
|
+
# One agent's journal on this plan, oldest round first, each entry
|
|
613
|
+
# numbered as the agent refers to it: 1 up, across every round.
|
|
614
|
+
#
|
|
615
|
+
# @param agent [String]
|
|
616
|
+
# @return [Array<OpenLanes::Plans::Journal::Round>] empty for an agent
|
|
617
|
+
# that never printed an entry here
|
|
618
|
+
def journal(agent)
|
|
619
|
+
rounds = read.dig(MEMORY, agent, "rounds") || {}
|
|
620
|
+
first = 1
|
|
621
|
+
rounds.sort_by { |key, _| key.to_i }.map do |key, value|
|
|
622
|
+
entries = Array(value["entries"]).map { Journal::Entry.from_h(it) }
|
|
623
|
+
round = Journal::Round.new(number: key.to_i, round: value["round"], state: value["state"], entries:, first:)
|
|
624
|
+
first += entries.size
|
|
625
|
+
round
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# Append entries to one of the agent's journal rounds, as they arrive.
|
|
630
|
+
#
|
|
631
|
+
# The first call of a dispatch passes no +number+ and is given the next
|
|
632
|
+
# one, under the same lock as the write, so two runs on one plan cannot
|
|
633
|
+
# both take round 3. Later calls of the same dispatch pass it back.
|
|
634
|
+
#
|
|
635
|
+
# An `override` must name an entry before its own: one pointing at
|
|
636
|
+
# itself or ahead can only be a miscount, and the prompt would draw it
|
|
637
|
+
# as overturning something it never saw.
|
|
638
|
+
#
|
|
639
|
+
# @param agent [String]
|
|
640
|
+
# @param entries [Array<OpenLanes::Plans::Journal::Entry>]
|
|
641
|
+
# @param number [Integer, nil] the journal round, nil to start one
|
|
642
|
+
# @param round [Integer, nil] the dispatcher's round, kept beside it
|
|
643
|
+
# @param state [#to_s, nil] the state the round started in
|
|
644
|
+
# @return [Array(Integer, Array<OpenLanes::Plans::Journal::Rejection>)]
|
|
645
|
+
# the journal round written to, and what was refused
|
|
646
|
+
# @raise [OpenLanes::Error] on an agent name that is not one token
|
|
647
|
+
def journal!(agent:, entries:, number: nil, round: nil, state: nil)
|
|
648
|
+
raise Error, "a journal needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
|
|
649
|
+
|
|
650
|
+
rejected = []
|
|
651
|
+
update do |data|
|
|
652
|
+
rounds = ((data[MEMORY] ||= {})[agent] ||= {})["rounds"] ||= {}
|
|
653
|
+
number ||= rounds.keys.map(&:to_i).max.to_i + 1
|
|
654
|
+
target = (rounds[number.to_s] ||= { "round" => round, "state" => state&.to_s }.compact.merge("entries" => []))
|
|
655
|
+
count = rounds.sum { |_, value| Array(value["entries"]).size }
|
|
656
|
+
entries.each do |entry|
|
|
657
|
+
if entry.overrides && entry.overrides > count
|
|
658
|
+
rejected << Journal::Rejection.new(line: Journal.line(entry.kind, entry.text, overrides: entry.overrides),
|
|
659
|
+
reason: "entry #{count + 1} overrides #{entry.overrides}, which is not before it")
|
|
660
|
+
next
|
|
661
|
+
end
|
|
662
|
+
target["entries"] << entry.to_json_h
|
|
663
|
+
count += 1
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
[number, rejected]
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# A person's answer to one of the agent's `blocked.md` questions, kept
|
|
670
|
+
# in the agent's journal with the question (M3): B1 with A1. Appended to
|
|
671
|
+
# the agent's latest round, which is the one that asked. Written once:
|
|
672
|
+
# a drain that is run again finds the same ref and answer already there.
|
|
673
|
+
# The check is made under the lock the write takes, so two drains at
|
|
674
|
+
# once (two control rooms, or a hand run beside the broker) cannot both
|
|
675
|
+
# find it missing and both write it.
|
|
676
|
+
#
|
|
677
|
+
# @param agent [String]
|
|
678
|
+
# @param ref [String] `B<n>`
|
|
679
|
+
# @param question [String]
|
|
680
|
+
# @param answer [String]
|
|
681
|
+
# @return [OpenLanes::Plans::Journal::Entry, nil] nil when it was there
|
|
682
|
+
# @raise [OpenLanes::Error] on an agent name that is not one token
|
|
683
|
+
def answer!(agent:, ref:, question:, answer:)
|
|
684
|
+
raise Error, "a journal needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
|
|
685
|
+
|
|
686
|
+
entry = Journal::Entry.new(kind: "question", text: question, ref:, answer:)
|
|
687
|
+
written = nil
|
|
688
|
+
update do |data|
|
|
689
|
+
rounds = ((data[MEMORY] ||= {})[agent] ||= {})["rounds"] ||= {}
|
|
690
|
+
next if rounds.each_value.any? { Array(it["entries"]).include?(entry.to_json_h) }
|
|
691
|
+
|
|
692
|
+
latest = rounds.keys.map(&:to_i).max || 1
|
|
693
|
+
(rounds[latest.to_s] ||= { "entries" => [] })["entries"] << entry.to_json_h
|
|
694
|
+
written = entry
|
|
695
|
+
end
|
|
696
|
+
written
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# The agent whose questions `blocked.md` holds: whoever blocked last,
|
|
700
|
+
# a round that settled Blocked or a Blocked signature, since a plan
|
|
701
|
+
# blocked by hand-run agents has signatures and no stages. Neither
|
|
702
|
+
# kind outranks the other: a hand-run agent that blocks after a
|
|
703
|
+
# harness round did is the one that asked.
|
|
704
|
+
#
|
|
705
|
+
# @return [String, nil] nil when nothing on record ever blocked
|
|
706
|
+
def blocked_by
|
|
707
|
+
data = read
|
|
708
|
+
blocks = data["stages"].select { it["status"] == "Blocked" }
|
|
709
|
+
.map { [parse_time(it["finished_at"] || it["started_at"]), it["agent"]] }
|
|
710
|
+
blocks += data["signatures"].select { it["status"] == "Blocked" }.map { [parse_time(it["at"]), it["agent"]] }
|
|
711
|
+
blocks.each_with_index.max_by { |(at, _), i| [at, i] }&.first&.last
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# ---- last-known-state -------------------------------------------------
|
|
715
|
+
|
|
716
|
+
# Where the resume records live in the document.
|
|
717
|
+
LAST_KNOWN = "last-known-state"
|
|
718
|
+
|
|
719
|
+
# Keep one agent's resume record, keyed by agent, so an agent
|
|
720
|
+
# interrupted in rounds 1 and 3 leaves one entry, not two nobody prunes.
|
|
721
|
+
# A harness entry does not replace the agent's own of the same round or
|
|
722
|
+
# later: see {LastKnownState#supersedes?}.
|
|
723
|
+
#
|
|
724
|
+
# @param agent [String]
|
|
725
|
+
# @param entry [OpenLanes::Plans::LastKnownState]
|
|
726
|
+
# @return [OpenLanes::Plans::LastKnownState] the entry kept, which is
|
|
727
|
+
# the existing one when the new one may not replace it
|
|
728
|
+
# @raise [OpenLanes::Error] on an agent name that is not one token
|
|
729
|
+
def record_last_known(agent, entry)
|
|
730
|
+
raise Error, "a resume record needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
|
|
731
|
+
|
|
732
|
+
kept = entry
|
|
733
|
+
update do |data|
|
|
734
|
+
section = (data[LAST_KNOWN] ||= {})
|
|
735
|
+
existing = section[agent] && LastKnownState.from_h(section[agent])
|
|
736
|
+
if entry.supersedes?(existing)
|
|
737
|
+
section[agent] = entry.to_json_h
|
|
738
|
+
else
|
|
739
|
+
kept = existing
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
kept
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# @param agent [String]
|
|
746
|
+
# @return [OpenLanes::Plans::LastKnownState, nil]
|
|
747
|
+
def last_known(agent) = last_known_states[agent]
|
|
748
|
+
|
|
749
|
+
# @return [Hash{String => OpenLanes::Plans::LastKnownState}] empty when
|
|
750
|
+
# no agent was ever interrupted
|
|
751
|
+
def last_known_states
|
|
752
|
+
(read[LAST_KNOWN] || {}).transform_values { |h| LastKnownState.from_h(h) }
|
|
753
|
+
end
|
|
754
|
+
|
|
202
755
|
# The signatures as ledger entries, so the dispatcher reads them exactly
|
|
203
756
|
# as it reads a line in plan.md. Each signature takes two line numbers,
|
|
204
757
|
# the entry and its handoff, so {Ledger.handoff_after} pairs them.
|
|
@@ -266,6 +819,34 @@ module OpenLanes
|
|
|
266
819
|
Digest::SHA256.hexdigest("#{plans}\0#{identity["ordinal"] || File.basename(dir)}")[0, 16]
|
|
267
820
|
end
|
|
268
821
|
|
|
822
|
+
# @param stage [Hash]
|
|
823
|
+
# @return [Time]
|
|
824
|
+
def started(stage) = parse_time(stage["started_at"])
|
|
825
|
+
|
|
826
|
+
# @param all [Array<Hash>] every stage
|
|
827
|
+
# @param since [Hash] the stage to look after
|
|
828
|
+
# @param state [String]
|
|
829
|
+
# @return [String, nil] a state some later stage left the plan in, other
|
|
830
|
+
# than +state+
|
|
831
|
+
def moved_since(all, since, state)
|
|
832
|
+
after = started(since)
|
|
833
|
+
at = all.index { it.equal?(since) }
|
|
834
|
+
all.each_with_index
|
|
835
|
+
.select { |s, i| i > at || started(s) > after }
|
|
836
|
+
.map { |s, _| s["to"].to_s }
|
|
837
|
+
.find { !it.empty? && it != state }
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# @param one [Hash]
|
|
841
|
+
# @param other [Hash]
|
|
842
|
+
# @return [Boolean] whether the two are one message, by id when both
|
|
843
|
+
# carry one, by what they say otherwise
|
|
844
|
+
def same_message?(one, other)
|
|
845
|
+
return one["id"] == other["id"] if one["id"] && other["id"]
|
|
846
|
+
|
|
847
|
+
%w[from to body].all? { one[it] == other[it] } && parse_time(one["at"]) == parse_time(other["at"])
|
|
848
|
+
end
|
|
849
|
+
|
|
269
850
|
# @param text [String, nil]
|
|
270
851
|
# @return [Time]
|
|
271
852
|
def parse_time(text)
|