ctrlrelay 0.22.0__tar.gz → 0.23.0__tar.gz
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.
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/CHANGELOG.md +78 -1
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/PKG-INFO +1 -1
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/cli.md +14 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/configuration.md +85 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/operations.md +129 -3
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/pyproject.toml +1 -1
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/cli.py +299 -4
- ctrlrelay-0.23.0/src/ctrlrelay/core/blockers.py +105 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/config.py +81 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/events.py +2 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/github.py +77 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/heartbeat.py +36 -2
- ctrlrelay-0.23.0/src/ctrlrelay/core/intake_hold.py +235 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/poller.py +165 -0
- ctrlrelay-0.23.0/src/ctrlrelay/core/reopens.py +109 -0
- ctrlrelay-0.23.0/src/ctrlrelay/core/self_update.py +509 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/state.py +131 -0
- ctrlrelay-0.23.0/src/ctrlrelay/core/weekly_summary.py +189 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/post_merge.py +17 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/secops.py +40 -5
- ctrlrelay-0.23.0/src/ctrlrelay/web/drift.py +267 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/fleet.py +3 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/numbers.py +69 -9
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/server.py +55 -1
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/static/index.html +46 -10
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/status.py +10 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/waiting.py +60 -8
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_events.py +1 -1
- ctrlrelay-0.23.0/tests/test_intake_hold.py +108 -0
- ctrlrelay-0.23.0/tests/test_intake_hold_poller.py +174 -0
- ctrlrelay-0.23.0/tests/test_issue_blockers.py +531 -0
- ctrlrelay-0.23.0/tests/test_reopens.py +269 -0
- ctrlrelay-0.23.0/tests/test_secops_operator_prs.py +239 -0
- ctrlrelay-0.23.0/tests/test_self_update.py +638 -0
- ctrlrelay-0.23.0/tests/test_web_drift.py +612 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_numbers.py +8 -6
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_server.py +24 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_waiting.py +58 -1
- ctrlrelay-0.23.0/tests/test_weekly_summary.py +231 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.gitignore +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/LICENSE +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/README.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/SECURITY.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/development.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/index.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/issue_questions.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/orphans.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_stuck.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_issue_questions.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_no_change_run.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_review_gate.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.23.0] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `repos[].automation.operator_prs` (`ask` or `ignore`; unset is `ask`) lets
|
|
15
|
+
the secops sweep leave the operator's own open PRs alone on a repo. By
|
|
16
|
+
default every such PR that touches more than `.github/dependabot.yml` is a
|
|
17
|
+
question, which can be asked again on later sweeps while the PR stays
|
|
18
|
+
open. With `ignore` those PRs get no question and are not named in the
|
|
19
|
+
summary; a PR that touches only `.github/dependabot.yml` is handled as
|
|
20
|
+
before (merged if it passes the author, diff and CI checks, a question if
|
|
21
|
+
not), and Dependabot PRs and alerts are unchanged. Unset renders the same
|
|
22
|
+
prompt as before (#335).
|
|
23
|
+
|
|
24
|
+
- The status page shows config drift between machines. Each machine's
|
|
25
|
+
`/api/status` carries a fingerprint of its config: the repo names, and a
|
|
26
|
+
hash per field of each repo's `automation` and `steps` and of
|
|
27
|
+
`schedules`; never a value. Each peer's card names what differs from this
|
|
28
|
+
machine, such as repos only here or only there, or a field that differs
|
|
29
|
+
on some repos, within one peer read. A peer on a different version shows
|
|
30
|
+
as unknown, not as drift. An intended difference, such as a routing label in
|
|
31
|
+
`exclude_labels`, is left out with `fleet.expected_drift` (#307).
|
|
32
|
+
|
|
33
|
+
- Reopened issues are recorded. When the relay sees an issue's PR merge
|
|
34
|
+
(and closes the issue), it notes the finish in the state database. If
|
|
35
|
+
that issue later turns up open in the poller's search, the poller reads
|
|
36
|
+
its GitHub events and, when there is a `reopened` event after the
|
|
37
|
+
finish, records the reopen once and puts a `reopened` event in the
|
|
38
|
+
feed. Ordinary polls make no extra GitHub call. The status page and
|
|
39
|
+
`/api/status` count reopens per window and per repo (`reopened`, with
|
|
40
|
+
`reopened_issues`), whether or not the relay picks the issue up again;
|
|
41
|
+
`null` until the daemon has added the new table (#230).
|
|
42
|
+
- A weekly chat summary. Once a week each node sends one message with its
|
|
43
|
+
last-7-day numbers: runs started, done, failed, blocked and stuck, the
|
|
44
|
+
success rate, time to PR, time to merge and reopens. A number it cannot
|
|
45
|
+
read says `unknown`, never 0. Set with `schedules.weekly_summary`
|
|
46
|
+
(`enabled`, `day`, `hour`; Monday 09:00 in the configured timezone by
|
|
47
|
+
default). The last delivery is kept in the state database, so a restart
|
|
48
|
+
does not send it twice, and a failed send is logged and tried again five
|
|
49
|
+
minutes later without affecting the poller (#230).
|
|
50
|
+
|
|
51
|
+
- `ctrlrelay self-update [--version X]` updates a node in one command, only
|
|
52
|
+
once it is idle. It reads the release from PyPI's JSON, downloads the
|
|
53
|
+
wheel and checks its sha256 (installing by path, because a node's index
|
|
54
|
+
has lagged a release by more than ten minutes), then holds new work with
|
|
55
|
+
a file beside the state database that the poller obeys the way it obeys
|
|
56
|
+
the usage pause. It waits, bounded, until the poller acknowledges that
|
|
57
|
+
nothing is in flight and no session is running, so a poll cannot start a
|
|
58
|
+
run between the check and the restart. Then `uv tool install --force`,
|
|
59
|
+
a restart of this node's systemd user units or LaunchAgents, and a check
|
|
60
|
+
that `/api/status` reports the new version and that a new poller process
|
|
61
|
+
reports it in its heartbeat and stays up (the heartbeat now carries the
|
|
62
|
+
poller's pid, version and start time). If a step after the install
|
|
63
|
+
fails, or the command is interrupted after the install began, the old
|
|
64
|
+
version is installed and restarted again; chat is told either way and
|
|
65
|
+
the command exits non-zero. The hold is removed on every
|
|
66
|
+
exit, and ignored once its process is gone or it expires (#306).
|
|
67
|
+
- The status page marks a peer whose version differs from this machine's:
|
|
68
|
+
its card is red and says which version each runs (#306).
|
|
69
|
+
|
|
70
|
+
- The poller does not start an issue whose GitHub "blocked by" issues are
|
|
71
|
+
still open (#310). Before claiming an issue it reads its blockers from
|
|
72
|
+
GitHub's issue dependencies, including blockers in other repositories.
|
|
73
|
+
An issue with an open blocker is not claimed, is logged as
|
|
74
|
+
`poll.issue.blocked`, is listed on the status page under Skipped as
|
|
75
|
+
"blocked by #A", and is said in chat once; every later poll asks again,
|
|
76
|
+
so it starts on the first poll after its last blocker closes. Blockers
|
|
77
|
+
that cannot be read (an API error, a rate limit, a 404 on a GitHub
|
|
78
|
+
without the dependencies API) hold the issue the same way
|
|
79
|
+
(`poll.issue.blockers_unknown`) and are never read as "no blockers"; the
|
|
80
|
+
chat is told only after 3 failed polls in a row. A held issue stays out
|
|
81
|
+
of `seen_issues` and is recorded in `blocked` in `poller_state.json`, so
|
|
82
|
+
a restart neither loses it nor repeats the chat line, and a failed
|
|
83
|
+
self-assignment check while it waits retries it next poll rather than
|
|
84
|
+
dropping it. Free text such as "Starts after: #A" is not read; use the link.
|
|
85
|
+
|
|
10
86
|
## [0.22.0] - 2026-11-05
|
|
11
87
|
|
|
12
88
|
### Added
|
|
@@ -2449,7 +2525,8 @@ pipeline).
|
|
|
2449
2525
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2450
2526
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2451
2527
|
|
|
2452
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2528
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...HEAD
|
|
2529
|
+
[0.23.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...v0.23.0
|
|
2453
2530
|
[0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
|
|
2454
2531
|
[0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
|
|
2455
2532
|
[0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.0
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -383,6 +383,20 @@ Opens the SQLite state DB at `paths.state_db` and prints:
|
|
|
383
383
|
|
|
384
384
|
If the DB doesn't exist yet, prints a hint to run a pipeline first.
|
|
385
385
|
|
|
386
|
+
## `ctrlrelay self-update`
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
ctrlrelay self-update [--version X] [--wait-minutes N] [--no-ack] [-c PATH]
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Installs a version already on PyPI on this node once it is idle: holds new
|
|
393
|
+
work, waits (up to `--wait-minutes`, default 60) for the run in flight,
|
|
394
|
+
installs the wheel after checking its sha256 against PyPI's JSON, restarts
|
|
395
|
+
this node's units and checks `/api/status` reports the new version. On any
|
|
396
|
+
failure the old version stays or is put back, chat is told, and it exits
|
|
397
|
+
non-zero. `--no-ack` is for updating from a poller too old to acknowledge
|
|
398
|
+
the hold. See [Operations: Updating a node](operations.md#updating-a-node).
|
|
399
|
+
|
|
386
400
|
## `ctrlrelay version`
|
|
387
401
|
|
|
388
402
|
```bash
|
|
@@ -623,6 +623,8 @@ fleet:
|
|
|
623
623
|
timeout_seconds: 5
|
|
624
624
|
silent_after_seconds: 300
|
|
625
625
|
account_label: "max-2"
|
|
626
|
+
expected_drift:
|
|
627
|
+
- "automation.exclude_labels"
|
|
626
628
|
```
|
|
627
629
|
|
|
628
630
|
| Key | Type | Default | Description |
|
|
@@ -632,6 +634,36 @@ fleet:
|
|
|
632
634
|
| `timeout_seconds` | number (0-60] | `5` | The longest wait for any one network step of a read; a whole read gets twice this before the peer is reported as `timeout`. |
|
|
633
635
|
| `silent_after_seconds` | int (>= 60) | `300` | A machine whose daemon heartbeat is older than this is shown as **silent**: its web server answers, but the daemon's heartbeat is missing or old, so its poller cannot be confirmed to be working. |
|
|
634
636
|
| `account_label` | string | unset | A name for this machine's agent subscription, shown on its card, such as `max-2`. Never an email address (refused). |
|
|
637
|
+
| `expected_drift` | list of strings | `[]` | Config differences with the peers that are intended, so the page does not report them. Each entry is a repo (`owner/name`: left out of the comparison entirely), a section (`automation`, `steps`, `schedules`) or one field of one (`automation.exclude_labels`, `steps.review`, `schedules.secops_cron`). Anything else is refused at load. See [Config drift](#config-drift). |
|
|
638
|
+
|
|
639
|
+
### Config drift
|
|
640
|
+
|
|
641
|
+
Each machine's status page publishes a fingerprint of its config in
|
|
642
|
+
`node.config_fingerprint`. Each time the page or `/api/status` is built, it
|
|
643
|
+
compares the latest fingerprint read from each peer with its own, so a change
|
|
644
|
+
on a peer shows within one peer read. A difference shows on that peer's card, for example
|
|
645
|
+
`config: 2 repos only here: org/a, org/b` or
|
|
646
|
+
`config: automation.exclude_labels differs on 3 repos: …`, and marks the card.
|
|
647
|
+
Identical configs show nothing.
|
|
648
|
+
|
|
649
|
+
What is compared: the list of repos; for each repo in both, every field of
|
|
650
|
+
`automation` and of `steps`; and every field of `schedules`. Only repo
|
|
651
|
+
names and hashes are sent, never a value. The fingerprint is the config the
|
|
652
|
+
server was started with, so an edit shows once the daemon is restarted.
|
|
653
|
+
|
|
654
|
+
A peer on a version that sends no fingerprint, or one in another format,
|
|
655
|
+
shows `config drift unknown`, not as drift. So does a peer on a different
|
|
656
|
+
ctrlrelay version than this machine, even one that sends a fingerprint: two
|
|
657
|
+
versions can hash the same config differently (a changed default, say), so
|
|
658
|
+
the two are compared once both run the same version. A field only one side
|
|
659
|
+
has is not compared.
|
|
660
|
+
|
|
661
|
+
`expected_drift` is read on each machine for its own page only. A routing
|
|
662
|
+
label that only some machines carry in `exclude_labels` is listed as
|
|
663
|
+
`automation.exclude_labels` on every machine whose page should not report
|
|
664
|
+
it. Leaving out a field leaves it out on every repo: a different change to
|
|
665
|
+
that field is not reported either, which is the cost of comparing hashes
|
|
666
|
+
rather than values.
|
|
635
667
|
|
|
636
668
|
## repos
|
|
637
669
|
|
|
@@ -681,6 +713,7 @@ and ask the operator), or `never` (skip).
|
|
|
681
713
|
| `codeql_dismiss` | `ask` | CodeQL alert dismissal. |
|
|
682
714
|
| `secret_alerts` | `never` | Secret-scan alerts. |
|
|
683
715
|
| `deploy_after_merge` | `auto` | Whether to deploy after a merged PR. |
|
|
716
|
+
| `operator_prs` | `ask` | `ask` or `ignore`. What the secops sweep does with your own open PRs that touch more than `.github/dependabot.yml`. See [operator_prs](#reposautomationoperator_prs) below. |
|
|
684
717
|
| `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by someone else. Default (`false`) runs the dev pipeline only on issues you self-assigned. |
|
|
685
718
|
| `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
|
|
686
719
|
| `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
|
|
@@ -690,6 +723,31 @@ and ask the operator), or `never` (skip).
|
|
|
690
723
|
The current secops and dev pipelines read these settings to bias their prompts
|
|
691
724
|
to the agent — they're not enforced by hard-coded checks.
|
|
692
725
|
|
|
726
|
+
### repos[].automation.operator_prs
|
|
727
|
+
|
|
728
|
+
The secops sweep looks at the open PRs you authored on each repo. One that
|
|
729
|
+
touches only `.github/dependabot.yml` is a "turn on Dependabot" change: it is
|
|
730
|
+
merged if it passes an author check, a diff check (additions only) and a CI
|
|
731
|
+
check, and is a question if it fails any of them.
|
|
732
|
+
Any other PR of yours is a question by default (`ask`), and while the PR
|
|
733
|
+
stays open that question can be asked again on later sweeps.
|
|
734
|
+
|
|
735
|
+
On a repo where you keep your own PRs open for a while (blog posts, drafts),
|
|
736
|
+
set `ignore`:
|
|
737
|
+
|
|
738
|
+
```yaml
|
|
739
|
+
repos:
|
|
740
|
+
- name: you/your-blog
|
|
741
|
+
automation:
|
|
742
|
+
operator_prs: ignore
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
With `ignore`, those PRs are left alone: no question, and they are not named
|
|
746
|
+
in the sweep's summary. A PR that touches only `.github/dependabot.yml` is
|
|
747
|
+
still handled as above, and Dependabot PRs and alerts are handled as before.
|
|
748
|
+
Unset is `ask`, and the sweep then runs exactly as it did before the setting
|
|
749
|
+
existed.
|
|
750
|
+
|
|
693
751
|
### repos[].automation.exclude_labels
|
|
694
752
|
|
|
695
753
|
Some issues you assign to the operator user aren't code work — they're operator
|
|
@@ -1024,12 +1082,39 @@ won't silently disable the job at runtime.
|
|
|
1024
1082
|
schedules:
|
|
1025
1083
|
secops_cron: "0 6 * * *" # daily 06:00 sweep across repos
|
|
1026
1084
|
personalization_cron: "*/15 * * * *" # optional auto-pull
|
|
1085
|
+
weekly_summary: # one chat message a week (#230)
|
|
1086
|
+
enabled: true
|
|
1087
|
+
day: mon
|
|
1088
|
+
hour: 9
|
|
1027
1089
|
```
|
|
1028
1090
|
|
|
1029
1091
|
| Key | Type | Required | Default | Description |
|
|
1030
1092
|
|---|---|---|---|---|
|
|
1031
1093
|
| `secops_cron` | string | no | `"0 6 * * *"` | When to run the secops sweep (`ctrlrelay secops run` equivalent) across all configured repos. Set to e.g. `"0 6 * * 1"` for weekly Mondays. |
|
|
1032
1094
|
| `personalization_cron` | string | no | unset | When to auto-pull the personalization repo on this machine. Only effective when [`personalization`](#personalization) is also set. Skip-on-dirty: never rebases under uncommitted operator edits. Adoption is never performed by auto-pull — that stays an init-time concern. |
|
|
1095
|
+
| `weekly_summary.enabled` | bool | no | `true` | Send this node's last-7-day numbers to the chat once a week. `false` turns it off. Nothing is sent when no chat [`transport`](#transport) is configured. |
|
|
1096
|
+
| `weekly_summary.day` | string | no | `"mon"` | The weekday to send it: `mon` to `sun` (full English names work too). |
|
|
1097
|
+
| `weekly_summary.hour` | int | no | `9` | The hour to send it, 0-23, in the top-level `timezone`. |
|
|
1098
|
+
|
|
1099
|
+
### schedules.weekly_summary
|
|
1100
|
+
|
|
1101
|
+
Once a week, each node sends one chat message with its own numbers for
|
|
1102
|
+
the last 7 days, the same ones its status page shows: runs started, done,
|
|
1103
|
+
failed, blocked and stuck, the success rate, the median time to PR and to
|
|
1104
|
+
merge, and how many issues the relay finished were reopened. A number the
|
|
1105
|
+
node cannot read is written `unknown`, with the reason, never 0.
|
|
1106
|
+
|
|
1107
|
+
The daemon checks every five minutes. It sends at the first check after
|
|
1108
|
+
`day` at `hour`:00 that finds nothing delivered since then, so a node that
|
|
1109
|
+
was off at that time sends when it comes back, and a send that fails (the
|
|
1110
|
+
bridge restarting, the chat unreachable) is logged as
|
|
1111
|
+
`summary.weekly.send_failed` and tried again five minutes later. The time
|
|
1112
|
+
of the last delivery is kept in the state database, so restarting the
|
|
1113
|
+
poller does not send the week a second time. Two cases can still give an
|
|
1114
|
+
extra message: a poller stopped after the message went out but before
|
|
1115
|
+
that time was written, and changing `day` or `hour` to a time that has
|
|
1116
|
+
already passed this week. The first check on a new or upgraded node only
|
|
1117
|
+
notes the time, so its first summary goes at the next `day` and `hour`.
|
|
1033
1118
|
|
|
1034
1119
|
## personalization
|
|
1035
1120
|
|
|
@@ -295,7 +295,7 @@ Fields added in #232 (all in `/api/status`):
|
|
|
295
295
|
|---|---|
|
|
296
296
|
| `url` | On every `running`, `needs_you`, `waiting.items`, `skipped` and `feed` item (and on `/api/events` events): a link to the GitHub issue, or for a feed event that names a PR by number, the PR. Built only as `https://github.com/<owner>/<repo>/issues/<n>` or `/pull/<n>`, from a repo matching `owner/name` and an integer; `null` otherwise (a secops run with no issue, a restart). A `pr_url` an agent reported is never used. |
|
|
297
297
|
| `refs` | On every `needs_you` item (#241): one `{"number", "url"}` per distinct `#N` in the question, in order, at most 20, with `url` built the same way as `url` above from the item's repo and N. A `#N` right after a letter, digit, `_`, `/` or `&` is not counted. The page turns each listed `#N` in the question into a link, so a secops question's PRs are clickable. `[]` when there are none. |
|
|
298
|
-
| `skipped` | Every open issue assigned to you that the poller will **not** pick up and is not running, from the same search as the waiting line (no extra GitHub calls): `repo`, `issue_number`, `title`, `url`, `updated`/`updated_ts` and `reason`, one of `repo not in config`, `excluded label <label>`, `already handled once (won't retry)`, `missing required label <label>` (several joined with "or": any one admits it), `assigned by someone else
|
|
298
|
+
| `skipped` | Every open issue assigned to you that the poller will **not** pick up and is not running, from the same search as the waiting line (no extra GitHub calls): `repo`, `issue_number`, `title`, `url`, `updated`/`updated_ts` and `reason`, one of `repo not in config`, `excluded label <label>`, `already handled once (won't retry)`, `missing required label <label>` (several joined with "or": any one admits it), `assigned by someone else`, `blocked by <#N, owner/repo#N>` or `could not read its blockers; asked again every poll` (#310, see [Issues blocked by other issues](#issues-blocked-by-other-issues); these two are listed for a label-triggered issue too). `null` until GitHub has answered once; `waiting.status` and `waiting.last_good_ts` say how current it is. The page shows it collapsed, with a count. |
|
|
299
299
|
| `server` | This machine's health, read on each request: `load_1m`, `load_5m`, `load_15m`, `cpu_count`, `memory` (`total_bytes`, `available_bytes`, `available_pct`, `low`), `disks` (one each for `home` and `worktrees`: `name`, `path`, `total_bytes`, `free_bytes`, `free_pct`, `low`), `uptime_s`, and `unavailable` (why any number is `null`; memory and uptime are `null` off Linux). `low` is true below 10% free and the page marks it. The whole section is `null`, with a line in `errors`, only if reading it failed outright. |
|
|
300
300
|
|
|
301
301
|
Added in #247:
|
|
@@ -333,6 +333,13 @@ Added in #249:
|
|
|
333
333
|
| `node.open_questions` | How many questions are waiting for an answer. |
|
|
334
334
|
| `fleet` | `{peers: [...]}` with one item per `fleet.peers` URL: `url`, `name`, `reachable`, `checked_at`, `error`, `state`, `node` (that peer's `node` block), `last_known` (its last block while it does not answer) and `needs_you`. `null` when no peers are configured. |
|
|
335
335
|
|
|
336
|
+
Added in #307:
|
|
337
|
+
|
|
338
|
+
| Field | What |
|
|
339
|
+
|---|---|
|
|
340
|
+
| `node.config_fingerprint` | `{v, repos_hash, profiles, repos, schedules}`, `v` is `2`. `profiles` maps a profile hash to `{automation, steps}`, each a map of field name to a hash of its value; `repos` maps each repo name to its profile hash, so repos with the same configuration share one profile; `repos_hash` is the hash of the sorted, lowercased repo names; `schedules` maps each `schedules` field to a hash. Hashes and repo names only, never a value. `null` if it could not be built. A peer's fingerprint is unknown unless every hash is 16 lowercase hex characters, `repos_hash` is the hash of its own repo names, each profile key is the hash of that profile, and each repo names a profile it carries. A field's hash cannot be checked against its value, which is never sent. |
|
|
341
|
+
| `fleet.peers[].drift` | `{status, reason, only_here, only_there, fields, lines}`. `status` is `same`, `differs` or `unknown` (with `reason`: not read yet, not reachable, the peer sends no fingerprint in this format, or the comparison failed); `fields` maps a field path such as `automation.exclude_labels` to the repos it differs on; `lines` is what the page shows. Leaves out `fleet.expected_drift`. The peer's own fingerprint is not repeated in `fleet.peers[].node`. |
|
|
342
|
+
|
|
336
343
|
Added in #230: `numbers` is how this machine's runs went, for the last 7 and
|
|
337
344
|
the last 30 days (`numbers.windows`, each with `days`, `since`/`since_ts`,
|
|
338
345
|
`until`/`until_ts`). The page shows it in the **Numbers** box, with a table
|
|
@@ -345,16 +352,23 @@ control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
|
|
|
345
352
|
| `done`, `failed`, `blocked`, `running`, `other` | How many of those runs have that status now. `running` includes a run left behind by a daemon that stopped mid-run. The five add up to `started`. |
|
|
346
353
|
| `success_rate` | `done / (done + failed + blocked)`, 0 to 1; `null` when none has finished. |
|
|
347
354
|
| `time_to_pr_s`, `time_to_merge_s` | `{median, n}`: seconds from the run's start to its first `pr_opened` (or `merged`) event, over the `n` runs that have one. Events stamped after now are not read. `null` when events cannot be read, or when a run in the window started before the oldest event still kept: its PR event may have been deleted. The daemon deletes events older than `web.history_days` (7 by default), so with the default the 30-day times are `null`. **To get them, set `web.history_days` to 30**; they appear once the events kept reach back past every run in the window (up to 30 days after the change, since deleted events do not come back). |
|
|
348
|
-
| `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50).
|
|
355
|
+
| `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). |
|
|
356
|
+
| `reopened` | Issues the relay finished (it saw their PR merge and closed them) that were reopened on GitHub in the window, dated by GitHub's `reopened` event. Counted whether or not the relay picks the issue up again; one per finish. The window's `reopened_issues` lists them. The poller notices a reopen only for an issue its search returns (open and assigned to the operator, or wearing a trigger label), with no extra GitHub call unless a finished issue shows up open. Finishes are recorded in the state database from this version on, plus those still in the kept `merged` events when the table is created. `null` on a database the daemon has not migrated yet. A `reopened` event goes to the feed when one is recorded. |
|
|
349
357
|
| `cost_per_run` | Always `null` for now: cost per run is not recorded yet (#223). The window's `unavailable` says this, and why any other field is `null`. |
|
|
350
358
|
| `stuck` | Runs the relay stopped as stuck (#224), also counted in `failed`. `null` only on a database the daemon has not migrated yet. |
|
|
351
359
|
|
|
352
360
|
`numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
|
|
353
361
|
|
|
362
|
+
Each machine also sends these 7-day numbers to the chat once a week; see
|
|
363
|
+
[configuration: schedules.weekly_summary]({{ '/configuration/' | relative_url }}#schedulesweekly_summary).
|
|
364
|
+
|
|
354
365
|
**The page with peers.** A **Machines** strip shows one card per machine,
|
|
355
366
|
this one first: state, slots, 5-hour and 7-day usage, heartbeat age. A card is
|
|
356
367
|
red when the machine is paused, silent, unknown, not reachable, or not read
|
|
357
|
-
yet. A peer that
|
|
368
|
+
yet. A peer that answers with a different version from this machine's is red
|
|
369
|
+
too, with `version X, this machine runs Y` on its card, so a node a release
|
|
370
|
+
missed shows up; a peer whose version is unknown is not marked. A peer that
|
|
371
|
+
does not
|
|
358
372
|
answer is shown as **not reachable from here**, with the reason (`timeout`,
|
|
359
373
|
`refused`, `name not found`, `bad host header`, an HTTP status) and its last
|
|
360
374
|
known numbers greyed out, never as idle. **Who has what** lists every claimed
|
|
@@ -623,6 +637,44 @@ unit files above set `ExitTimeOut=180` (launchd) and
|
|
|
623
637
|
and leaves stale `git worktree` admin state behind. If you're upgrading
|
|
624
638
|
from an older plist/unit that didn't set these, add them and reload.
|
|
625
639
|
|
|
640
|
+
## Issues blocked by other issues
|
|
641
|
+
|
|
642
|
+
When work has an order (B changes the same files as A, so B waits for A),
|
|
643
|
+
say so on GitHub with an issue dependency: on B, **Relationships → Mark as
|
|
644
|
+
blocked by → A**. The blocker may be in another repository. Before the
|
|
645
|
+
poller claims an issue it reads that issue's blockers
|
|
646
|
+
(`GET /repos/{owner}/{repo}/issues/{n}/dependencies/blocked_by`), and:
|
|
647
|
+
|
|
648
|
+
- **None is open:** the run starts as usual.
|
|
649
|
+
- **One or more is open:** the issue is not claimed and not started. The
|
|
650
|
+
log has `poll.issue.blocked` with `blocked_by` (the open ones, `#N` in
|
|
651
|
+
the same repository, `owner/repo#N` in another), the status page lists
|
|
652
|
+
it under **Skipped** as `blocked by …`, and the chat is told once per
|
|
653
|
+
issue, not every poll. Every later poll asks again, so it starts on the
|
|
654
|
+
first poll after the last blocker closes, with nothing to do by hand.
|
|
655
|
+
- **The blockers could not be read** (an API error, the GitHub API budget
|
|
656
|
+
used up, a 404 from a GitHub Enterprise Server without the dependencies
|
|
657
|
+
API): treated like an open blocker, never as "no blockers". The log has
|
|
658
|
+
`poll.issue.blockers_unknown` with the error on every failed poll, and
|
|
659
|
+
the next poll tries again. A single failure is usually a blip, so the
|
|
660
|
+
status page keeps showing the blockers last read and the chat is not
|
|
661
|
+
told; after 3 failed polls in a row the page says `could not read its
|
|
662
|
+
blockers` and the chat is told once per issue. If the blockers can then
|
|
663
|
+
be read and are the same as before, nothing more is said.
|
|
664
|
+
|
|
665
|
+
Only the dependency link counts. A line such as "Starts after: #A" in the
|
|
666
|
+
issue body is not read, so add the link as well.
|
|
667
|
+
|
|
668
|
+
A held issue is kept out of `seen_issues` (that is what brings it back)
|
|
669
|
+
and recorded in `blocked` in the same `poller_state.json`, so a restart
|
|
670
|
+
neither loses it nor repeats the chat line. Its self-assignment is
|
|
671
|
+
checked again every poll (it may have been unassigned and assigned again
|
|
672
|
+
by someone else), but a check that fails leaves a held issue for the next
|
|
673
|
+
poll instead of marking it handled for good.
|
|
674
|
+
Each poll costs a held issue one extra REST call (`core` budget) for its
|
|
675
|
+
blockers, plus the re-read every issue gets just before it starts. A blocked issue that is closed or loses its trigger is dropped
|
|
676
|
+
from `blocked` when the poller next sees it.
|
|
677
|
+
|
|
626
678
|
## Stuck runs
|
|
627
679
|
|
|
628
680
|
A run that goes in circles is stopped early instead of running until
|
|
@@ -673,6 +725,73 @@ page's numbers count these runs in `stuck`.
|
|
|
673
725
|
| Bot token env var | bridge **and** poller |
|
|
674
726
|
|
|
675
727
|
After a `ctrlrelay` package upgrade (`uv pip install -e .`), restart both.
|
|
728
|
+
For a node installed with `uv tool`, `ctrlrelay self-update` does the
|
|
729
|
+
install and the restarts for you; see below.
|
|
730
|
+
|
|
731
|
+
## Updating a node
|
|
732
|
+
|
|
733
|
+
```bash
|
|
734
|
+
ctrlrelay self-update # the latest version on PyPI
|
|
735
|
+
ctrlrelay self-update --version 0.23.0 # a given version
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
It only installs a version already published on PyPI; publishing stays a
|
|
739
|
+
human decision. In order:
|
|
740
|
+
|
|
741
|
+
1. Reads `https://pypi.org/pypi/ctrlrelay/<version>/json` and checks that
|
|
742
|
+
this node's status page answers on `127.0.0.1:<web.port>`; without it the
|
|
743
|
+
update could not be checked, so it refuses. It also refuses when there is
|
|
744
|
+
no poller or web unit installed, or ctrlrelay is not a `uv tool` install.
|
|
745
|
+
Nothing has changed yet.
|
|
746
|
+
2. Downloads the wheel into `wheels/` beside the state database and checks
|
|
747
|
+
its sha256 against that JSON. A mismatch deletes the file and installs
|
|
748
|
+
nothing. The wheel is installed by path rather than by name because a
|
|
749
|
+
node's package index has served a stale list for more than ten minutes
|
|
750
|
+
after a release. The file is kept, since uv's receipt records the path.
|
|
751
|
+
3. Holds new work: it writes `intake-hold.json` beside the state database.
|
|
752
|
+
While it is there the poller leaves new issues for the next poll and
|
|
753
|
+
skips the scheduled sweep and resumed questions, the same way the usage
|
|
754
|
+
pause does; runs already going finish. The poller answers in
|
|
755
|
+
`intake-hold.ack.json` with whether anything is still in flight. The
|
|
756
|
+
command waits, up to `--wait-minutes` (default 60), until the poller says
|
|
757
|
+
nothing is in flight and the state database has no `running` session.
|
|
758
|
+
Checking the database alone is not enough: an issue can pass the poller's
|
|
759
|
+
checks a few seconds before its session row is written, and a restart in
|
|
760
|
+
that gap cancels it.
|
|
761
|
+
4. `uv tool install --force <wheel>`, then restarts every installed unit:
|
|
762
|
+
`systemctl --user restart ctrlrelay-{bridge,poller,web}.service` on
|
|
763
|
+
Linux, `launchctl kickstart -k gui/<uid>/<label>` for each
|
|
764
|
+
`*.ctrlrelay-{bridge,poller,web}.plist` in `~/Library/LaunchAgents` on
|
|
765
|
+
macOS (the label is the file's name, whatever `--label-prefix` it was
|
|
766
|
+
installed with).
|
|
767
|
+
5. Waits up to two minutes for `/api/status` to report the new version,
|
|
768
|
+
and up to two and a half for a new poller process to report it in its
|
|
769
|
+
heartbeat (`heartbeat.json` now carries the poller's `pid`, `version`
|
|
770
|
+
and `started_at`) and still be the one reporting at least one heartbeat
|
|
771
|
+
interval (30 s) after it started. The status page's version
|
|
772
|
+
is the web server's own, and a restart command succeeds even when the
|
|
773
|
+
unit then crash-loops, so neither alone shows the poller is up.
|
|
774
|
+
|
|
775
|
+
If any step after the install fails, or the command is interrupted
|
|
776
|
+
(Ctrl-C, SIGTERM) once the install has begun, the old version is installed
|
|
777
|
+
and restarted the same way, and checked; a poller from before this command
|
|
778
|
+
writes no version in its heartbeat, so for the old version a fresh
|
|
779
|
+
heartbeat from a new process is enough. Chat (when a transport is configured)
|
|
780
|
+
gets one line either way: the update, or the step that failed and whether
|
|
781
|
+
the old version is running again. The command exits non-zero on any
|
|
782
|
+
failure. The hold is removed on every way out, including Ctrl-C and
|
|
783
|
+
SIGTERM; one left by a process that was killed outright is ignored once
|
|
784
|
+
that process is gone or the hold's expiry (the wait plus 30 minutes) has
|
|
785
|
+
passed.
|
|
786
|
+
|
|
787
|
+
A poller from before this command does not know the hold and never
|
|
788
|
+
acknowledges it, so the command stops after a minute without changing
|
|
789
|
+
anything. For that one update, `--no-ack` goes on the state database alone,
|
|
790
|
+
with the gap described in step 3. A poller that is not running (its
|
|
791
|
+
heartbeat is older than `fleet.silent_after_seconds`) is not waited on.
|
|
792
|
+
|
|
793
|
+
A scheduled security sweep whose time comes while the hold is in place is
|
|
794
|
+
skipped until its next time, not delayed.
|
|
676
795
|
|
|
677
796
|
After merging a PR that the dev pipeline opened, no manual restart is required —
|
|
678
797
|
the PR is referenced from the open branch in your bare repo, so cleanup is
|
|
@@ -846,6 +965,13 @@ number only — no assignee and no labels — so the number has to leave
|
|
|
846
965
|
`seen_issues` (and the relay has to be restarted, since the running
|
|
847
966
|
poller holds the set in memory and rewrites the file from it).
|
|
848
967
|
|
|
968
|
+
An issue held back by an open GitHub blocker (see [Issues blocked by
|
|
969
|
+
other issues](#issues-blocked-by-other-issues)) is never in `seen_issues`
|
|
970
|
+
while it waits; it is listed in `blocked[<repo>]` as `{"open": [...],
|
|
971
|
+
"failures": n}`: the open blockers as last read (`null` if never read)
|
|
972
|
+
and how many lookups have failed in a row since. Nothing needs deleting: it starts on
|
|
973
|
+
its own once the blockers close.
|
|
974
|
+
|
|
849
975
|
### A repo whose history was replaced
|
|
850
976
|
|
|
851
977
|
If the local copy's default branch can no longer fast-forward to GitHub's
|