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
|
@@ -126,6 +126,17 @@ module OpenLanes
|
|
|
126
126
|
# @return [Integer]
|
|
127
127
|
def xlen(key) = locked { (live(key) || []).size }
|
|
128
128
|
|
|
129
|
+
# @return [Integer] how many were removed
|
|
130
|
+
def xtrim(key, minid:)
|
|
131
|
+
locked do
|
|
132
|
+
stream = live(key) or next 0
|
|
133
|
+
floor = id_of(minid.include?("-") ? minid : "#{minid}-0")
|
|
134
|
+
before = stream.size
|
|
135
|
+
stream.reject! { (id_of(it.id) <=> floor).negative? }
|
|
136
|
+
before - stream.size
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
129
140
|
# @return [Boolean] whether the group is new
|
|
130
141
|
def xgroup_create(key, group, from: "0")
|
|
131
142
|
locked do
|
|
@@ -141,7 +152,7 @@ module OpenLanes
|
|
|
141
152
|
# @return [Array<Store::Entry>]
|
|
142
153
|
def xreadgroup(key, group, consumer, id: ">", count: 100)
|
|
143
154
|
locked do
|
|
144
|
-
state = @groups[key][group] or raise
|
|
155
|
+
state = @groups[key][group] or raise GroupMissing, "NOGROUP #{group} on #{key}"
|
|
145
156
|
stream = live(key) || []
|
|
146
157
|
if id == ">"
|
|
147
158
|
fresh = stream.select { |e| (id_of(e.id) <=> state[:last]).positive? }.first(count)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# The nudge that tells every open page's event stream to look again
|
|
8
|
+
# ({Keys#news}). It carries no data: whoever wrote something the page
|
|
9
|
+
# shows writes it first, then publishes `{"kind": ...}`, and the stream
|
|
10
|
+
# re-reads Redis as it always has. The data stays in its keys and
|
|
11
|
+
# streams, so a nudge lost to a dropped connection costs latency, never
|
|
12
|
+
# correctness, and the stream's cursors work exactly as before.
|
|
13
|
+
#
|
|
14
|
+
# Who publishes, and why each one does:
|
|
15
|
+
#
|
|
16
|
+
# - `board`: {Publisher}, on the Dispatcher's tick, only when the
|
|
17
|
+
# snapshot changed apart from its clock, so an idle engine is quiet.
|
|
18
|
+
# - `reply`: {Commands}, on the Dispatcher's tick, for each reply.
|
|
19
|
+
# - `copilot`: {Copilot}, in the API server, for each turn written.
|
|
20
|
+
# - `app`: {Apps::Runner}, when a process changes state or the app stops.
|
|
21
|
+
# Its once-a-second heartbeat does not: nothing on the page moves.
|
|
22
|
+
# - `chat`: {Chat}, after a step handed a page something to show.
|
|
23
|
+
# Its events live in this process, not in Redis, so the nudge is the
|
|
24
|
+
# only way a stream blocked on Redis can hear of them.
|
|
25
|
+
# - `stop`: {Web::Server#stop}, so open streams end now rather than at
|
|
26
|
+
# their next ping.
|
|
27
|
+
#
|
|
28
|
+
# The `--demo` engine publishes `board` and `reply` the way the real one does.
|
|
29
|
+
module News
|
|
30
|
+
# Every kind a nudge may name. The stream does not branch on them; they
|
|
31
|
+
# are there for whoever watches the channel with `redis-cli SUBSCRIBE`.
|
|
32
|
+
KINDS = %w[board reply copilot app chat stop].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Nudge every stream following +keys+' project on +store+'s database.
|
|
37
|
+
# Best effort: the write it announces is already in Redis, and a
|
|
38
|
+
# stream that misses the nudge still finds it at its next ping.
|
|
39
|
+
#
|
|
40
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
41
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
42
|
+
# @param kind [String] one of {KINDS}
|
|
43
|
+
# @return [Integer] how many subscriptions heard it, 0 when Redis did not answer
|
|
44
|
+
def publish(store:, keys:, kind:)
|
|
45
|
+
bus = EventBus.for(store)
|
|
46
|
+
bus.publish(keys.news(db: bus.db), JSON.generate(kind:))
|
|
47
|
+
rescue Unavailable
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A subscription of its own to the nudges, for one event stream. The
|
|
52
|
+
# caller closes it: each is a Redis connection.
|
|
53
|
+
#
|
|
54
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
55
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
56
|
+
# @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
|
|
57
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
58
|
+
def subscribe(store:, keys:) = EventBus.for(store).then { it.subscribe(channels: [keys.news(db: it.db)]) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/openlanes/live/node.rb
CHANGED
|
@@ -46,7 +46,27 @@ module OpenLanes
|
|
|
46
46
|
ID_LENGTH = 24
|
|
47
47
|
CHECKOUT_LENGTH = 12
|
|
48
48
|
|
|
49
|
+
# A declared `user@host`: one of each, no spaces, no second `@`.
|
|
50
|
+
IDENTITY = /\A(?<user>[^@\s]+)@(?<host>[^@\s]+)\z/
|
|
51
|
+
|
|
52
|
+
# A declared identity that is not `user@host`.
|
|
53
|
+
class Invalid < ArgumentError; end
|
|
54
|
+
|
|
49
55
|
class << self
|
|
56
|
+
# This checkout as a machine of another name: `openlanes web --demo
|
|
57
|
+
# --identity demo@second-machine` registers as a second machine on the
|
|
58
|
+
# same Redis and repository, through the same digest every process
|
|
59
|
+
# is already named by, so nothing reads a second naming scheme.
|
|
60
|
+
#
|
|
61
|
+
# @param identity [String] `user@host`
|
|
62
|
+
# @param root [String] the checkout
|
|
63
|
+
# @return [OpenLanes::Live::Node]
|
|
64
|
+
# @raise [Invalid]
|
|
65
|
+
def declared(identity, root:, **)
|
|
66
|
+
match = IDENTITY.match(identity.to_s.strip) or raise Invalid, "an identity is user@host, not #{identity.inspect}"
|
|
67
|
+
current(root:, user: match[:user], host: match[:host], **)
|
|
68
|
+
end
|
|
69
|
+
|
|
50
70
|
# @param root [String] the checkout
|
|
51
71
|
# @param user [String]
|
|
52
72
|
# @param host [String]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# What the engine does when its Redis stops answering (W32).
|
|
6
|
+
#
|
|
7
|
+
# A blip is not an outage: Redis that is back within {GRACE} seconds
|
|
8
|
+
# changes nothing. Past that, every running agent is paused through its
|
|
9
|
+
# control file ({Execution::Control::PAUSE}), nothing new starts, and the
|
|
10
|
+
# outage goes into the run's state file, the record that survives Redis.
|
|
11
|
+
# Redis is then asked again after 1, 1, 2, 3, 5, 8 … seconds, the
|
|
12
|
+
# Fibonacci numbers scaled by `redis_backoff.initial` and capped at
|
|
13
|
+
# `redis_backoff.cap` ({MAX_GAP} by default), so a short outage is noticed
|
|
14
|
+
# quickly, a long one is not hammered, and a Redis that comes back is
|
|
15
|
+
# never left unnoticed for longer than the cap. With full jitter (the
|
|
16
|
+
# default) each gap is drawn uniformly below that, so engines sharing one
|
|
17
|
+
# Redis do not all ask in the same second. When it answers, the agents
|
|
18
|
+
# carry on, their clocks having stood still meanwhile. When it has not answered for
|
|
19
|
+
# `redis_outage_hours` (an hour by default), the run shuts down the way
|
|
20
|
+
# `q` does, and says why.
|
|
21
|
+
#
|
|
22
|
+
# Run on the dispatcher's tick, ahead of everything that needs Redis.
|
|
23
|
+
class Outage
|
|
24
|
+
# Seconds Redis may be unreachable before the agents are paused.
|
|
25
|
+
GRACE = 10
|
|
26
|
+
|
|
27
|
+
# Seconds between pings while Redis answers.
|
|
28
|
+
PING_EVERY = 1
|
|
29
|
+
|
|
30
|
+
# The longest gap between two asks while paused, in seconds. Without
|
|
31
|
+
# it the Fibonacci gaps pass 16 minutes within fifteen asks, and a Redis
|
|
32
|
+
# back after 17 minutes would leave every agent paused for 10 more.
|
|
33
|
+
MAX_GAP = 30
|
|
34
|
+
|
|
35
|
+
# How many past outages the state file keeps.
|
|
36
|
+
HISTORY = 20
|
|
37
|
+
|
|
38
|
+
# One outage that ended in Redis answering: wall-clock bounds, and the
|
|
39
|
+
# length by the monotonic clock, which a changed system time cannot
|
|
40
|
+
# skew. {OutageReport} turns it into `run.outage` (plan 022).
|
|
41
|
+
Window = Data.define(:started_at, :ended_at, :seconds)
|
|
42
|
+
|
|
43
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
44
|
+
# @param limit [Numeric] seconds paused before the run gives up
|
|
45
|
+
# @param clock [#call] monotonic seconds
|
|
46
|
+
# @param wall [#call] wall time, for the state file
|
|
47
|
+
# @param initial [Numeric] seconds before the first ask; the Fibonacci gaps are its multiples
|
|
48
|
+
# @param cap [Numeric] the longest gap between two asks
|
|
49
|
+
# @param jitter [Boolean] full jitter: each gap is drawn from [0, gap)
|
|
50
|
+
# @param random [#rand] the jitter's source; a spec hands in a fixed one
|
|
51
|
+
def initialize(store:, limit:, clock: -> { UI.monotonic }, wall: -> { Time.now },
|
|
52
|
+
initial: 1, cap: MAX_GAP, jitter: true, random: Random.new)
|
|
53
|
+
@store = store
|
|
54
|
+
@limit = limit
|
|
55
|
+
@initial = initial
|
|
56
|
+
@cap = cap
|
|
57
|
+
@jitter = jitter
|
|
58
|
+
@random = random
|
|
59
|
+
@clock = clock
|
|
60
|
+
@wall = wall
|
|
61
|
+
@state = :up
|
|
62
|
+
@down_since = nil
|
|
63
|
+
@last_ping = nil
|
|
64
|
+
@resumed = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Symbol] :up, :paused or :gave_up
|
|
68
|
+
attr_reader :state
|
|
69
|
+
|
|
70
|
+
# One tick.
|
|
71
|
+
#
|
|
72
|
+
# @param dispatcher [OpenLanes::Engine::Dispatcher] for its state file
|
|
73
|
+
# @return [void]
|
|
74
|
+
def call(dispatcher)
|
|
75
|
+
@state_file = dispatcher.state_file
|
|
76
|
+
now = @clock.call
|
|
77
|
+
case @state
|
|
78
|
+
when :up then watch(now)
|
|
79
|
+
when :paused then probe(now)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] whether agents are paused for this outage
|
|
84
|
+
def paused? = @state == :paused
|
|
85
|
+
|
|
86
|
+
# @return [Proc] {#call}, for `Dispatcher#before_tick`
|
|
87
|
+
def to_proc = method(:call).to_proc
|
|
88
|
+
|
|
89
|
+
# Something to run once Redis answers again, after the agents resume.
|
|
90
|
+
#
|
|
91
|
+
# @yieldparam window [Window]
|
|
92
|
+
# @return [void]
|
|
93
|
+
def on_resume(&hook) = @resumed << hook
|
|
94
|
+
|
|
95
|
+
# @return [Window, nil] the last outage that ended in Redis answering
|
|
96
|
+
attr_reader :window
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
def watch(now)
|
|
102
|
+
return if @last_ping && now - @last_ping < PING_EVERY
|
|
103
|
+
|
|
104
|
+
@last_ping = now
|
|
105
|
+
if answers?
|
|
106
|
+
@down_since = nil
|
|
107
|
+
else
|
|
108
|
+
@down_since ||= now
|
|
109
|
+
pause(now) if now - @down_since >= GRACE
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [void]
|
|
114
|
+
def pause(now)
|
|
115
|
+
@state = :paused
|
|
116
|
+
@delays = fibonacci
|
|
117
|
+
@attempts = 0
|
|
118
|
+
@next_probe = next_probe(now)
|
|
119
|
+
@record = { "since" => (@wall.call - (now - @down_since)).iso8601, "paused_at" => @wall.call.iso8601,
|
|
120
|
+
"limit_seconds" => @limit.round, "attempts" => 0 }
|
|
121
|
+
UI.log("Redis has not answered for #{GRACE}s: pausing every agent, starting nothing new, " \
|
|
122
|
+
"and giving up after #{hours(@limit)}",
|
|
123
|
+
level: :warn)
|
|
124
|
+
Execution::Control.pause!
|
|
125
|
+
save
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [void]
|
|
129
|
+
def probe(now)
|
|
130
|
+
return if now < @next_probe
|
|
131
|
+
|
|
132
|
+
@attempts += 1
|
|
133
|
+
@record["attempts"] = @attempts
|
|
134
|
+
@record["last_attempt_at"] = @wall.call.iso8601
|
|
135
|
+
if answers?
|
|
136
|
+
resume(now)
|
|
137
|
+
elsif now - @down_since >= @limit
|
|
138
|
+
give_up(now)
|
|
139
|
+
else
|
|
140
|
+
@next_probe = next_probe(now)
|
|
141
|
+
save
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [void]
|
|
146
|
+
def resume(now)
|
|
147
|
+
@state = :up
|
|
148
|
+
@record["resumed_at"] = @wall.call.iso8601
|
|
149
|
+
@record["seconds"] = (now - @down_since).round
|
|
150
|
+
UI.log("Redis answers again after #{(now - @down_since).round}s and #{@attempts} attempts: resuming every agent")
|
|
151
|
+
Execution::Control.resume!
|
|
152
|
+
@window = Window.new(started_at: @wall.call - (now - @down_since),
|
|
153
|
+
ended_at: @wall.call,
|
|
154
|
+
seconds: now - @down_since)
|
|
155
|
+
@down_since = nil
|
|
156
|
+
save(over: true)
|
|
157
|
+
@resumed.each { |hook| hook.call(@window) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [void]
|
|
161
|
+
def give_up(now)
|
|
162
|
+
@state = :gave_up
|
|
163
|
+
@record["gave_up_at"] = @wall.call.iso8601
|
|
164
|
+
@record["seconds"] = (now - @down_since).round
|
|
165
|
+
UI.log("Redis has not answered for #{hours(@limit)}: shutting the run down", level: :error)
|
|
166
|
+
Execution::Control.quit!
|
|
167
|
+
save(over: true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The next Fibonacci gap, scaled, capped, jittered, and never past the
|
|
171
|
+
# limit: the last ask is the one at the limit.
|
|
172
|
+
#
|
|
173
|
+
# @return [Float]
|
|
174
|
+
def next_probe(now) = [now + gap, @down_since + @limit].min
|
|
175
|
+
|
|
176
|
+
# @return [Float] seconds to the next ask
|
|
177
|
+
def gap
|
|
178
|
+
gap = [@delays.next * @initial, @cap].min.to_f
|
|
179
|
+
@jitter ? gap * @random.rand : gap
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [Boolean]
|
|
183
|
+
def answers?
|
|
184
|
+
@store.ping == true
|
|
185
|
+
rescue Unavailable, SystemCallError
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 1, 1, 2, 3, 5, 8, …
|
|
190
|
+
#
|
|
191
|
+
# @return [Enumerator]
|
|
192
|
+
def fibonacci
|
|
193
|
+
Enumerator.new do |out|
|
|
194
|
+
a, b = 1, 1
|
|
195
|
+
loop do
|
|
196
|
+
out << a
|
|
197
|
+
a, b = b, a + b
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The current outage, or, once it is over, into the history. Saved at
|
|
203
|
+
# once: this is the record a person reads to learn why agents stood
|
|
204
|
+
# still, and the run may not live to save it later.
|
|
205
|
+
#
|
|
206
|
+
# @param over [Boolean]
|
|
207
|
+
# @return [void]
|
|
208
|
+
def save(over: false)
|
|
209
|
+
return unless @state_file
|
|
210
|
+
|
|
211
|
+
@state_file.outage = over ? nil : @record
|
|
212
|
+
@state_file.outages = (@state_file.outages + [@record]).last(HISTORY) if over
|
|
213
|
+
@state_file.save
|
|
214
|
+
rescue SystemCallError => e
|
|
215
|
+
UI.log("outage not recorded: #{e.message}", level: :warn)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [String]
|
|
219
|
+
def hours(seconds)
|
|
220
|
+
hours = seconds / 3600.0
|
|
221
|
+
hours == hours.round ? "#{hours.round} hour#{"s" unless hours.round == 1}" : "#{hours.round(2)} hours"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# What the engine says once Redis is back: how long it was gone, how
|
|
9
|
+
# that was measured, and which copy of the run state won.
|
|
10
|
+
#
|
|
11
|
+
# The duration prefers this node's last heartbeat in Redis, because it
|
|
12
|
+
# is the last moment Redis provably had the engine's word. A heartbeat
|
|
13
|
+
# far older than the first failure is not that: it is what an RDB
|
|
14
|
+
# rollback restores, and measuring from it would overstate the outage by
|
|
15
|
+
# however long the snapshot was stale. Then the engine's own monotonic
|
|
16
|
+
# window is the answer, and `measured_by` says so.
|
|
17
|
+
class OutageReport
|
|
18
|
+
# Seconds a heartbeat may precede the first failure and still count.
|
|
19
|
+
# The publisher beats every tick, so a live one is a few seconds old.
|
|
20
|
+
FRESH = 15
|
|
21
|
+
|
|
22
|
+
# @param store [#hget]
|
|
23
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
24
|
+
# @param node [OpenLanes::Live::Node]
|
|
25
|
+
# @param window [OpenLanes::Live::Outage::Window]
|
|
26
|
+
# @param reconciled_from [Symbol, nil] `:redis`, `:file` or nil, from
|
|
27
|
+
# {RunRecord#reconcile}
|
|
28
|
+
def initialize(store:, keys:, node:, window:, reconciled_from:)
|
|
29
|
+
@store = store
|
|
30
|
+
@keys = keys
|
|
31
|
+
@node = node
|
|
32
|
+
@window = window
|
|
33
|
+
@reconciled_from = reconciled_from
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The `run.outage` wire shape (plan 022, shared with the page).
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def to_h
|
|
40
|
+
{ started_at: iso(started_at),
|
|
41
|
+
ended_at: iso(@window.ended_at),
|
|
42
|
+
seconds: seconds,
|
|
43
|
+
measured_by: measured_by,
|
|
44
|
+
reconciled_from: @reconciled_from&.to_s }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] one log line
|
|
48
|
+
def line
|
|
49
|
+
from = @reconciled_from ? "run state from #{@reconciled_from}" : "no run state to reconcile"
|
|
50
|
+
"Redis is back after #{seconds}s (by #{measured_by}), #{from}; resuming"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Integer]
|
|
54
|
+
def seconds
|
|
55
|
+
(heartbeat ? @window.ended_at - heartbeat : @window.seconds).round
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String] `"heartbeat"` or `"clock"`
|
|
59
|
+
def measured_by = heartbeat ? "heartbeat" : "clock"
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# @return [Time]
|
|
64
|
+
def started_at = heartbeat || @window.started_at
|
|
65
|
+
|
|
66
|
+
# This node's heartbeat, when it is from just before the first failure.
|
|
67
|
+
#
|
|
68
|
+
# @return [Time, nil]
|
|
69
|
+
def heartbeat
|
|
70
|
+
return @heartbeat if defined?(@heartbeat)
|
|
71
|
+
|
|
72
|
+
@heartbeat = read_heartbeat
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Time, nil]
|
|
76
|
+
def read_heartbeat
|
|
77
|
+
json = @store.hget(@keys.nodes, @node.name) or return nil
|
|
78
|
+
at = Time.iso8601(JSON.parse(json)["heartbeat_at"].to_s)
|
|
79
|
+
at if at <= @window.started_at && @window.started_at - at <= FRESH
|
|
80
|
+
rescue JSON::ParserError, ArgumentError, Unavailable
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param time [Time]
|
|
85
|
+
# @return [String]
|
|
86
|
+
def iso(time) = time.utc.iso8601
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -60,6 +60,12 @@ module OpenLanes
|
|
|
60
60
|
@on_lost = block
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# {OutageReport#to_h} of the outage this run last recovered from,
|
|
64
|
+
# published as `run.outage` from the next snapshot on.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash, nil]
|
|
67
|
+
attr_accessor :outage
|
|
68
|
+
|
|
63
69
|
# @return [OpenLanes::Live::Meter]
|
|
64
70
|
attr_reader :meter
|
|
65
71
|
|
|
@@ -77,11 +83,12 @@ module OpenLanes
|
|
|
77
83
|
@meter.record(board.rows, now:)
|
|
78
84
|
snapshot = build(board, now)
|
|
79
85
|
json = JSON.generate(snapshot)
|
|
80
|
-
@store.
|
|
86
|
+
EventBus.for(@store).transaction do |tx|
|
|
81
87
|
tx.set(@keys.snapshot, json, ex: Keys::SNAPSHOT_TTL)
|
|
82
|
-
tx.
|
|
88
|
+
tx.append(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
|
|
83
89
|
tx.hset(@keys.nodes, @node.name, JSON.generate(@node.wire(heartbeat_at: now)))
|
|
84
90
|
end
|
|
91
|
+
nudge(snapshot)
|
|
85
92
|
lost = @claims&.renew || []
|
|
86
93
|
unless lost.empty?
|
|
87
94
|
UI.log("claims lost to another machine: #{lost.join(", ")}; stopping their agents")
|
|
@@ -153,9 +160,42 @@ module OpenLanes
|
|
|
153
160
|
claims: @claims,
|
|
154
161
|
run_state: @run_state,
|
|
155
162
|
meter: @meter,
|
|
163
|
+
# The URL the engine publishes through, not one read back from the
|
|
164
|
+
# environment: the page's status bar says which Redis the claims and
|
|
165
|
+
# run state really live in, and only the store that wrote them knows.
|
|
166
|
+
redis_url: @store.url,
|
|
167
|
+
outage: @outage,
|
|
156
168
|
log:).to_h
|
|
157
169
|
end
|
|
158
170
|
|
|
171
|
+
# Fields that move with the clock alone: the sequence number, the
|
|
172
|
+
# snapshot's time, this node's heartbeat, and how long each plan has
|
|
173
|
+
# been worked on. A car's `elapsed` is left in on purpose: while an
|
|
174
|
+
# agent runs, its seconds counter is what the page is there to show.
|
|
175
|
+
#
|
|
176
|
+
# @param snapshot [Hash]
|
|
177
|
+
# @return [Hash] what the page would see change
|
|
178
|
+
def content(snapshot)
|
|
179
|
+
snapshot.except(:seq, :at)
|
|
180
|
+
.merge(node: snapshot[:node].except(:heartbeat_at),
|
|
181
|
+
plans: Array(snapshot[:plans]).map { it.except(:elapsed) })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Nudges the open pages ({News}) when this tick's snapshot says
|
|
185
|
+
# something the last one did not. The snapshot is still written every
|
|
186
|
+
# tick, because its expiry is how a node is known to be alive; only
|
|
187
|
+
# the nudge waits for news, so an engine with nothing to do is quiet.
|
|
188
|
+
#
|
|
189
|
+
# @param snapshot [Hash]
|
|
190
|
+
# @return [void]
|
|
191
|
+
def nudge(snapshot)
|
|
192
|
+
now = content(snapshot)
|
|
193
|
+
return if now == @published
|
|
194
|
+
|
|
195
|
+
News.publish(store: @store, keys: @keys, kind: "board")
|
|
196
|
+
@published = now
|
|
197
|
+
end
|
|
198
|
+
|
|
159
199
|
# One more than the last published, even across an engine restart:
|
|
160
200
|
# the API server reads a change of `seq` as news, and a restart that
|
|
161
201
|
# counted from one again would look like the past.
|
|
@@ -50,7 +50,7 @@ module OpenLanes
|
|
|
50
50
|
@state_file = dispatcher.state_file
|
|
51
51
|
redis = read_redis
|
|
52
52
|
file = @state_file&.live
|
|
53
|
-
newer =
|
|
53
|
+
newer = newest(redis, file)
|
|
54
54
|
# A copy that is missing is behind, unless both are: then there is
|
|
55
55
|
# nothing to keep until a person changes something.
|
|
56
56
|
@in_redis = redis ? redis["seq"].to_i : (newer && -1) || 0
|
|
@@ -62,6 +62,37 @@ module OpenLanes
|
|
|
62
62
|
ordinal, agent = key.split("/", 2)
|
|
63
63
|
dispatcher.prefer_model(ordinal, agent, model)
|
|
64
64
|
end
|
|
65
|
+
restore_concurrency(dispatcher)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A `/concurrency` set before the restart holds after it (plan 020.00).
|
|
69
|
+
# One this run cannot take (a shared checkout runs one agent) is said
|
|
70
|
+
# once and dropped, rather than stopping the engine from starting.
|
|
71
|
+
#
|
|
72
|
+
# @param dispatcher [OpenLanes::Engine::Dispatcher]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def restore_concurrency(dispatcher)
|
|
75
|
+
limit = @run_state.concurrency or return
|
|
76
|
+
dispatcher.concurrency = limit
|
|
77
|
+
rescue OpenLanes::Error => e
|
|
78
|
+
UI.log("concurrency #{limit} from before the restart not kept: #{e.message}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# After Redis comes back: which copy is the newer, by the same `seq`
|
|
82
|
+
# rule as {#restore}. An RDB rollback leaves Redis behind or empty and
|
|
83
|
+
# the file wins. The engine's own state kept going through the outage,
|
|
84
|
+
# so nothing is restored from either; both marks are reset to what is
|
|
85
|
+
# really there, so the next {#flush} rewrites whichever copy is behind.
|
|
86
|
+
#
|
|
87
|
+
# @return [Symbol, nil] `:redis` or `:file`, or nil with neither copy
|
|
88
|
+
def reconcile
|
|
89
|
+
@warned = false
|
|
90
|
+
redis = read_redis
|
|
91
|
+
file = @state_file&.live
|
|
92
|
+
@in_redis = redis ? redis["seq"].to_i : -1
|
|
93
|
+
@in_file = file ? file["seq"].to_i : -1
|
|
94
|
+
newer = newest(redis, file) or return nil
|
|
95
|
+
newer.equal?(redis) ? :redis : :file
|
|
65
96
|
end
|
|
66
97
|
|
|
67
98
|
# Bring both copies up to the state's `seq`. The file is written by the
|
|
@@ -98,6 +129,13 @@ module OpenLanes
|
|
|
98
129
|
|
|
99
130
|
private
|
|
100
131
|
|
|
132
|
+
# The copy with the higher `seq`; Redis on a tie, being listed first.
|
|
133
|
+
#
|
|
134
|
+
# @param redis [Hash, nil]
|
|
135
|
+
# @param file [Hash, nil]
|
|
136
|
+
# @return [Hash, nil]
|
|
137
|
+
def newest(redis, file) = [redis, file].compact.max_by { it["seq"].to_i }
|
|
138
|
+
|
|
101
139
|
# @return [void]
|
|
102
140
|
def mirror
|
|
103
141
|
return unless @state_file && @in_file != @run_state.seq
|
|
@@ -9,7 +9,8 @@ module OpenLanes
|
|
|
9
9
|
# Held in the engine, and kept by {RunRecord} in Redis and in
|
|
10
10
|
# `.plans/openlanes-state.json` (W6), so an engine that restarts still
|
|
11
11
|
# knows them. Every change moves {#seq} on by one, which is how the
|
|
12
|
-
# record tells which of its two copies is newer.
|
|
12
|
+
# record tells which of its two copies is newer. So does the concurrency
|
|
13
|
+
# a person set with `/concurrency` (plan 020.00). A model choice also
|
|
13
14
|
# lives in the executor, which is what applies it; this copy is what the
|
|
14
15
|
# page is shown and what a restart hands back to the executor.
|
|
15
16
|
class RunState
|
|
@@ -18,9 +19,24 @@ module OpenLanes
|
|
|
18
19
|
@held_questions = []
|
|
19
20
|
@held_cars = []
|
|
20
21
|
@models = {}
|
|
22
|
+
@concurrency = nil
|
|
21
23
|
@seq = 0
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# @return [Integer, nil] the limit `/concurrency` set; nil when nobody did
|
|
27
|
+
def concurrency = @mutex.synchronize { @concurrency }
|
|
28
|
+
|
|
29
|
+
# @param limit [Integer]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def concurrency=(limit)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
next if @concurrency == limit
|
|
34
|
+
|
|
35
|
+
@concurrency = limit
|
|
36
|
+
@seq += 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
# @param id [String] `"<ordinal>/B<n>"`
|
|
25
41
|
# @param held [Boolean]
|
|
26
42
|
# @return [void]
|
|
@@ -67,7 +83,7 @@ module OpenLanes
|
|
|
67
83
|
def to_h
|
|
68
84
|
@mutex.synchronize do
|
|
69
85
|
{ "seq" => @seq, "held_questions" => @held_questions.dup, "held_cars" => @held_cars.dup,
|
|
70
|
-
"models" => @models.dup }
|
|
86
|
+
"models" => @models.dup }.merge(@concurrency ? { "concurrency" => @concurrency } : {})
|
|
71
87
|
end
|
|
72
88
|
end
|
|
73
89
|
|
|
@@ -80,6 +96,7 @@ module OpenLanes
|
|
|
80
96
|
@held_questions = Array(data["held_questions"]).map(&:to_s)
|
|
81
97
|
@held_cars = Array(data["held_cars"]).map(&:to_s)
|
|
82
98
|
@models = (data["models"].is_a?(Hash) ? data["models"] : {}).to_h { |k, v| [k.to_s, v.to_s] }
|
|
99
|
+
@concurrency = data["concurrency"].is_a?(Integer) && data["concurrency"].positive? ? data["concurrency"] : nil
|
|
83
100
|
@seq = data["seq"].to_i
|
|
84
101
|
end
|
|
85
102
|
self
|
|
@@ -19,13 +19,16 @@ module OpenLanes
|
|
|
19
19
|
# @param node [OpenLanes::Live::Node]
|
|
20
20
|
# @param repo [String]
|
|
21
21
|
# @param clock [Proc] wall time
|
|
22
|
+
# @param outage [OpenLanes::Live::Outage] what pauses the run while
|
|
23
|
+
# Redis is gone; a spec hands in one on its own clock
|
|
22
24
|
def initialize(store:, root:, tree:, agents:, project: nil, node: Node.current(root:), repo: Repo.for(root),
|
|
23
|
-
clock: -> { Time.now })
|
|
25
|
+
clock: -> { Time.now }, outage: Outage.new(store:, limit: Config.current.outage_limit, **Config.current.redis_backoff))
|
|
24
26
|
@store = store
|
|
25
27
|
@node = node
|
|
26
28
|
@keys = Keys.new(repo:, node: node.name)
|
|
27
29
|
@run_state = RunState.new
|
|
28
30
|
@record = RunRecord.new(store:, keys: @keys, run_state: @run_state)
|
|
31
|
+
@outage = outage
|
|
29
32
|
@claims = Claims.new(store:, keys: @keys)
|
|
30
33
|
@publisher = Publisher.new(store:,
|
|
31
34
|
keys: @keys,
|
|
@@ -69,20 +72,45 @@ module OpenLanes
|
|
|
69
72
|
# @return [OpenLanes::Live::RunRecord]
|
|
70
73
|
attr_reader :record
|
|
71
74
|
|
|
75
|
+
# @return [OpenLanes::Live::Outage]
|
|
76
|
+
attr_reader :outage
|
|
77
|
+
|
|
72
78
|
# @return [Proc] the runner's `on_board:`
|
|
73
79
|
def on_board = @publisher.to_proc
|
|
74
80
|
|
|
75
81
|
# Wire the run in: the run state a previous engine left is taken back
|
|
76
|
-
# on the first tick, commands are applied at the top of each tick,
|
|
77
|
-
#
|
|
82
|
+
# on the first tick, commands are applied at the top of each tick, mail
|
|
83
|
+
# published to Redis is drained into the plans' `state.json`, and the
|
|
84
|
+
# log is heard from now on.
|
|
78
85
|
#
|
|
79
86
|
# @param dispatcher [OpenLanes::Engine::Dispatcher]
|
|
80
87
|
# @return [void]
|
|
81
88
|
def attach(dispatcher)
|
|
82
89
|
@publisher.listen
|
|
83
90
|
@publisher.on_lost { |ordinals| dispatcher.fence(ordinals) }
|
|
91
|
+
# First, so a tick that finds Redis gone pauses before anything else
|
|
92
|
+
# on it asks Redis for something (W32).
|
|
93
|
+
dispatcher.before_tick(&@outage)
|
|
84
94
|
dispatcher.before_tick(&@record)
|
|
85
95
|
dispatcher.before_tick(&@commands)
|
|
96
|
+
dispatcher.mail = Mail.new(store: @store, keys: @keys)
|
|
97
|
+
@outage.on_resume { recover(it) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Redis answers again: say how long it was gone and which copy of the
|
|
101
|
+
# run state won, and publish that as `run.outage`.
|
|
102
|
+
#
|
|
103
|
+
# @param window [OpenLanes::Live::Outage::Window]
|
|
104
|
+
# @return [OpenLanes::Live::OutageReport]
|
|
105
|
+
def recover(window)
|
|
106
|
+
report = OutageReport.new(store: @store,
|
|
107
|
+
keys: @keys,
|
|
108
|
+
node: @node,
|
|
109
|
+
window:,
|
|
110
|
+
reconciled_from: @record.reconcile)
|
|
111
|
+
UI.log(report.line)
|
|
112
|
+
@publisher.outage = report.to_h
|
|
113
|
+
report
|
|
86
114
|
end
|
|
87
115
|
|
|
88
116
|
# The run is over: the run state is written down, claims go back, and
|