ctrlrelay 0.20.2__tar.gz → 0.22.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.20.2 → ctrlrelay-0.22.0}/CHANGELOG.md +93 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/PKG-INFO +1 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/config/orchestrator.yaml.example +4 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/configuration.md +26 -3
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/operations.md +56 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/pyproject.toml +1 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/cli.py +116 -7
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/claims.py +15 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/config.py +8 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatcher.py +130 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/events.py +7 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github.py +85 -0
- ctrlrelay-0.22.0/src/ctrlrelay/core/issue_questions.py +461 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/publish.py +8 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/state.py +113 -6
- ctrlrelay-0.22.0/src/ctrlrelay/core/stuck.py +424 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/worktree.py +34 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/base.py +6 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/dev.py +944 -115
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/numbers.py +24 -9
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/server.py +202 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/static/index.html +13 -4
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/status.py +155 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/_publish_fakes.py +10 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_no_keys.py +4 -2
- ctrlrelay-0.22.0/tests/test_dev_stuck.py +244 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_event_emission.py +9 -2
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_events.py +1 -1
- ctrlrelay-0.22.0/tests/test_issue_questions.py +482 -0
- ctrlrelay-0.22.0/tests/test_no_change_run.py +219 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_publish_failure_retry.py +94 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_relay_git_263.py +1 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_review_copy.py +42 -0
- ctrlrelay-0.22.0/tests/test_review_gate.py +493 -0
- ctrlrelay-0.22.0/tests/test_stuck.py +333 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_numbers.py +41 -8
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_server.py +191 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_status.py +136 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.gitignore +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/LICENSE +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/README.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/SECURITY.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/cli.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/development.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/index.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/orphans.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.0] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The code review is tied to the commit it read. Each `review_finished`
|
|
15
|
+
event records that commit (`head_sha`) and whether the findings reached
|
|
16
|
+
the PR (`posted`), so the status page feed and `/api/status` show what a
|
|
17
|
+
verdict is about. After a review the relay reads the PR's head from
|
|
18
|
+
GitHub; if somebody pushed after the review, the PR gets a comment saying
|
|
19
|
+
it needs review again, a `review_stale` event is recorded, and the new
|
|
20
|
+
head is reviewed before the run reports done. With
|
|
21
|
+
`steps.review: required`, the PR is opened as a draft (a reused PR is put
|
|
22
|
+
back to draft when the run pushes to it) and marked ready only once the
|
|
23
|
+
latest review posted on its current head. The new head is fetched from
|
|
24
|
+
the PR's own ref, since the run's branch is checked out in its worktree.
|
|
25
|
+
A repository that does not allow drafts (a private one on GitHub's free
|
|
26
|
+
plan) gets a normal PR with a comment saying it has not been reviewed
|
|
27
|
+
yet. A reused PR that cannot be put back to draft gets the same comment.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- The status page no longer says "looks stuck" while the agent is inside
|
|
32
|
+
one long command, such as a 20-minute test run. A tool call writes
|
|
33
|
+
nothing to the transcript until it returns. So before calling a card
|
|
34
|
+
stuck, the page reads `ps` for a process under that run's own agent
|
|
35
|
+
(the dispatcher records its pid in `agents/<session>.pid` beside the
|
|
36
|
+
state database) that started at or after the agent's last step. If it
|
|
37
|
+
finds one, it shows "running `<program>`" instead, with the program's
|
|
38
|
+
name only. An idle agent is still stuck. Where the process
|
|
39
|
+
table cannot be read, the card stays stuck and says why (#309).
|
|
40
|
+
|
|
41
|
+
- A run that correctly changes nothing is no longer recorded as a failed
|
|
42
|
+
publish. The agent can now finish with `"no_changes": True` and a one-line
|
|
43
|
+
`"no_changes_reason"`. When the relay's own check also finds no new
|
|
44
|
+
commits, it opens no pull request, ends the session `done`, says so on
|
|
45
|
+
the issue, and tells chat "finished, no change needed". A DONE with no
|
|
46
|
+
commits and no declaration still fails as a forgotten commit. A
|
|
47
|
+
declaration on a branch that has commits is ignored, and the commits are
|
|
48
|
+
published. So is a declaration from a run that has already pushed or has
|
|
49
|
+
an open PR, such as a fix round or a resumed session, since ending it
|
|
50
|
+
done would orphan that PR (#336).
|
|
51
|
+
|
|
52
|
+
## [0.21.0] - 2026-11-05
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- A run going in circles is stopped early with a clear reason, instead of
|
|
57
|
+
running until the session timeout (#224). The agent is stopped when its
|
|
58
|
+
live transcript shows the same command 5 times in a row with the same
|
|
59
|
+
result each time (polling a job whose output moves on is not a loop,
|
|
60
|
+
though 5 identical polls in a row still are), or
|
|
61
|
+
the same error from the same command 5 times in a row. Between CI fix
|
|
62
|
+
rounds each failed job's log is sorted on its own, as flaky infrastructure (the failed jobs are re-run once, without
|
|
63
|
+
the agent), out of budget (not retried), or a code failure (sent to the
|
|
64
|
+
agent, as before); the same tests failing in 3 CI rounds in a row stops the
|
|
65
|
+
run. A stopped run is recorded as failed with `sessions.stuck_reason`, the
|
|
66
|
+
issue comment and chat say it was stuck and why, and the status page counts
|
|
67
|
+
these runs. See "Stuck runs" in the operations guide.
|
|
68
|
+
|
|
69
|
+
- A dev run's question is now asked on its GitHub issue as well as in chat,
|
|
70
|
+
and the first answer from either one wins. An answer on the issue is a
|
|
71
|
+
comment that starts with `/answer`, since the relay and every working
|
|
72
|
+
session comment as the operator's account; it counts only from an account
|
|
73
|
+
with write access to the repository, and only if it is newer than the
|
|
74
|
+
question and carries no relay marker; other comments
|
|
75
|
+
are logged and ignored. The chat is told when the issue answered it, and a
|
|
76
|
+
chat answer is copied to the issue. A reply that loses is told it was not
|
|
77
|
+
applied, and never resumes the session a second time. On a public
|
|
78
|
+
repository the issue gets a short notice instead of the question unless
|
|
79
|
+
`repos[].question_on_issue` says `full`. An expired question is retired
|
|
80
|
+
in both channels and the issue says so. While the question waits, the
|
|
81
|
+
issue's claim is marked held, so another machine does not take the issue
|
|
82
|
+
if this one stops; it goes back to working when the run resumes.
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- A push GitHub refuses because the branch changes a file under
|
|
87
|
+
`.github/workflows/` and the relay's token has no `workflow` scope is no
|
|
88
|
+
longer retried. It cost one full agent run per poll, up to three, for a
|
|
89
|
+
push that could never pass. Chat now says the branch changes a workflow
|
|
90
|
+
file, names it, and says why no retry can pass. For any other rejected
|
|
91
|
+
push, the chat line shows the remote's reason rather than the first 200
|
|
92
|
+
characters of the error, which ended before the reason (#325).
|
|
93
|
+
|
|
94
|
+
- The status page bound to a LAN address now also answers on
|
|
95
|
+
`127.0.0.1`, and it no longer keeps running, silently, on an address the
|
|
96
|
+
machine has lost. It checks every 30 seconds; when `web.host` is gone it
|
|
97
|
+
logs `web.address_gone`, says so in `web.error.log` and exits 1 so the
|
|
98
|
+
service manager restarts it (#322).
|
|
99
|
+
|
|
10
100
|
## [0.20.2] - 2026-11-04
|
|
11
101
|
|
|
12
102
|
### Fixed
|
|
@@ -2359,7 +2449,9 @@ pipeline).
|
|
|
2359
2449
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2360
2450
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2361
2451
|
|
|
2362
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2452
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...HEAD
|
|
2453
|
+
[0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
|
|
2454
|
+
[0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
|
|
2363
2455
|
[0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
|
|
2364
2456
|
[0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
|
|
2365
2457
|
[0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.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/
|
|
@@ -271,3 +271,7 @@ repos: []
|
|
|
271
271
|
# # Fix rounds for a PR whose CI is red or whose branch conflicts,
|
|
272
272
|
# # before the run fails. Defaults to 3; 0 means none.
|
|
273
273
|
# max_fix_attempts: 3
|
|
274
|
+
# # A dev run's question is asked on its issue as well as in chat.
|
|
275
|
+
# # auto: full text on a private repo, a short notice on a public one.
|
|
276
|
+
# # full / notice force one or the other.
|
|
277
|
+
# question_on_issue: auto
|
|
@@ -207,7 +207,7 @@ agent:
|
|
|
207
207
|
|---|---|---|---|
|
|
208
208
|
| `type` | string | `"claude"` | Default backend for every repo: `"claude"` or `"opencode"`. See [agent backends](#agent-backends). |
|
|
209
209
|
| `binary` | string | `"claude"` | Path to the `claude` executable. The bare name `"claude"` is auto-resolved at startup using `shutil.which("claude")`, then `~/.local/bin/claude`, `/usr/local/bin/claude`, `/opt/homebrew/bin/claude`. Set an absolute path to skip lookup (useful under launchd/systemd where PATH is minimal). |
|
|
210
|
-
| `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. |
|
|
210
|
+
| `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. A session going in circles is stopped before this; see [stuck runs](operations.md#stuck-runs). |
|
|
211
211
|
| `output_format` | string | `"json"` | Not used. The relay always runs `claude -p --output-format stream-json --verbose`, because only that output carries the subscription usage it reads (see [usage](#usage)). Kept so existing configs still load. |
|
|
212
212
|
|
|
213
213
|
ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
|
|
@@ -534,12 +534,12 @@ web:
|
|
|
534
534
|
|
|
535
535
|
| Key | Type | Default | Description |
|
|
536
536
|
|---|---|---|---|
|
|
537
|
-
| `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
|
|
537
|
+
| `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. A LAN address also gets a second listener on `127.0.0.1`, and the server exits (for the service manager to restart it) if that address stops being this machine's. |
|
|
538
538
|
| `port` | int (1-65535) | `8787` | Port to listen on. |
|
|
539
539
|
| `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. Events older than this are deleted, so the run numbers' time to PR and time to merge are shown only for a window whose runs all started after the oldest event kept. Set it to `30` to get the 30-day ones; they appear once 30 days of events have been kept. |
|
|
540
540
|
| `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
|
|
541
541
|
| `github_check_seconds` | int (>= 15) | `300` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). Every check spends from the GitHub account's budget, which machines on the account share. |
|
|
542
|
-
| `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
|
|
542
|
+
| `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it, or the agent is still inside a command it started at its last step (read from `ps`; the card then says "running `<program>`"). Where the process table cannot be read, the card stays "looks stuck" and says why. |
|
|
543
543
|
| `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
|
|
544
544
|
|
|
545
545
|
The page has no login and shows repository names, issue titles and the
|
|
@@ -665,6 +665,7 @@ repos:
|
|
|
665
665
|
| `code_review` | object | no | (defaults) | Review run over an agent branch before its PR is handed over. See [code_review](#code_review). |
|
|
666
666
|
| `steps` | object | no | (all unset) | How strict each dev pipeline work step is. See [steps](#repossteps). |
|
|
667
667
|
| `max_fix_attempts` | integer ≥ 0 | no | `3` | Fix rounds the dev pipeline asks for when a PR's CI is red or its branch conflicts, before the run fails. `0` means none. See [steps](#repossteps). |
|
|
668
|
+
| `question_on_issue` | `auto` \| `full` \| `notice` | no | `auto` | What a dev run's question looks like on its GitHub issue. The question is asked there as well as in chat, and the first answer from either wins; on the issue only a comment that starts with `/answer`, from an account with write access, counts. `auto` posts the full question on a private repository and a short "a question is waiting for the operator in chat" notice on a public one (or one whose visibility could not be read); a chat answer is copied to the issue under the same rule. `full` and `notice` force one or the other. |
|
|
668
669
|
| `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
|
|
669
670
|
|
|
670
671
|
### repos[].automation
|
|
@@ -945,6 +946,28 @@ an exception inside the review are all failures. It is about the review
|
|
|
945
946
|
having happened, not about what it found. **`review: off`** is the same as
|
|
946
947
|
`code_review.method: off`, and loads as that.
|
|
947
948
|
|
|
949
|
+
A review is about one commit. With `review: required` the PR is opened as a
|
|
950
|
+
**draft**, and a PR the run reuses is put back to draft when the run pushes to
|
|
951
|
+
it, because its new commits have not been reviewed. The relay marks it ready
|
|
952
|
+
for review only when the latest review posted on the PR's current head. If
|
|
953
|
+
the PR stays a draft, nothing was handed over. Draft pull requests need a
|
|
954
|
+
public repository or a paid GitHub plan; where GitHub refuses a draft, the
|
|
955
|
+
publish step fails and says why.
|
|
956
|
+
|
|
957
|
+
With the review `on`, unset or `required`, the relay reads the PR's head from
|
|
958
|
+
GitHub after each review. If somebody pushed after the review, the verdict is
|
|
959
|
+
stale: the PR gets a comment saying it needs review again, a `review_stale`
|
|
960
|
+
event is recorded, and the new head is reviewed before the run reports done
|
|
961
|
+
(at most three reviews in all; a head still moving after that is not a
|
|
962
|
+
pass). With `required`, a head GitHub will not report is not a pass either.
|
|
963
|
+
|
|
964
|
+
Each `review_finished` event names the commit it reviewed, so the status page
|
|
965
|
+
feed and `/api/status` show what the verdict is about:
|
|
966
|
+
`{"pr_number", "ok", "head_sha", "status", "posted"}`. `ok` means the reviewer
|
|
967
|
+
read the diff; `posted` means its findings reached the PR, which is what
|
|
968
|
+
`required` checks. `review_stale` carries `{"pr_number", "head_sha",
|
|
969
|
+
"current_sha"}`.
|
|
970
|
+
|
|
948
971
|
Two settings for one reviewer must agree, or the config refuses to load:
|
|
949
972
|
|
|
950
973
|
- `code_review.method: off` with `steps.review: on` or `required`;
|
|
@@ -346,7 +346,8 @@ control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
|
|
|
346
346
|
| `success_rate` | `done / (done + failed + blocked)`, 0 to 1; `null` when none has finished. |
|
|
347
347
|
| `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
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). An issue reopened on GitHub and not picked up again is not counted: ctrlrelay does not record reopening. |
|
|
349
|
-
| `cost_per_run
|
|
349
|
+
| `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
|
+
| `stuck` | Runs the relay stopped as stuck (#224), also counted in `failed`. `null` only on a database the daemon has not migrated yet. |
|
|
350
351
|
|
|
351
352
|
`numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
|
|
352
353
|
|
|
@@ -400,6 +401,23 @@ The first line in `web.log` is a `web.started` event naming the address it
|
|
|
400
401
|
bound. If `web.host` is `0.0.0.0` it also logs a warning: that listens on
|
|
401
402
|
every interface, which is rarely what you want.
|
|
402
403
|
|
|
404
|
+
With `web.host` set to a LAN address the page also answers on
|
|
405
|
+
`127.0.0.1:8787`, so a browser or health check on the machine itself is not
|
|
406
|
+
refused, and `web.log` has a second `web.started` line for it. The LAN side
|
|
407
|
+
still listens on `web.host` and nothing else. If that loopback port is taken
|
|
408
|
+
by something else, `web.loopback_unavailable` is logged and the LAN page
|
|
409
|
+
starts anyway.
|
|
410
|
+
|
|
411
|
+
Every 30 seconds the server checks that `web.host` is still one of this
|
|
412
|
+
machine's addresses. If it is not (a new DHCP lease, a router reboot,
|
|
413
|
+
another network), it logs `web.address_gone` to `web.log`, prints why to
|
|
414
|
+
`web.error.log` and exits 1. systemd (or launchd's `KeepAlive`) restarts it;
|
|
415
|
+
on the old address that start fails with `Cannot listen on ...: Cannot
|
|
416
|
+
assign requested address` in `web.error.log`, again every few seconds, until
|
|
417
|
+
the address comes back or you set `web.host` to the new one. A check that
|
|
418
|
+
cannot be made (a `web.host` name that does not resolve) is logged once as
|
|
419
|
+
`web.address_unchecked` and the page keeps running.
|
|
420
|
+
|
|
403
421
|
The page answers only to names it knows: `localhost`, `127.0.0.1`, `[::1]`,
|
|
404
422
|
`web.host`, and this machine's own hostname (plus `.local`). A request whose
|
|
405
423
|
`Host` header carries any other name gets `421 misdirected request`, and the
|
|
@@ -605,6 +623,43 @@ unit files above set `ExitTimeOut=180` (launchd) and
|
|
|
605
623
|
and leaves stale `git worktree` admin state behind. If you're upgrading
|
|
606
624
|
from an older plist/unit that didn't set these, add them and reload.
|
|
607
625
|
|
|
626
|
+
## Stuck runs
|
|
627
|
+
|
|
628
|
+
A run that goes in circles is stopped early instead of running until
|
|
629
|
+
`agent.default_timeout_seconds`.
|
|
630
|
+
|
|
631
|
+
- **While the agent works**, ctrlrelay reads its live transcript every 30
|
|
632
|
+
seconds. If the agent runs the same command with the same input 5 times in
|
|
633
|
+
a row and gets the same result back each time, or gets the same error
|
|
634
|
+
back from the same command 5 times in a row with no success of that
|
|
635
|
+
command in between (edits in between do not count), the agent is stopped.
|
|
636
|
+
A command polled while its output moves on is waiting, not a loop; a poll
|
|
637
|
+
that gets the identical output back 5 times in a row is still treated as
|
|
638
|
+
one. Only the `claude` backend writes a transcript ctrlrelay knows how to
|
|
639
|
+
read; other backends are not watched.
|
|
640
|
+
- **Between CI fix rounds**, each failed job's log is sorted on its own into
|
|
641
|
+
one of three kinds. The round is *out of budget* if any job is,
|
|
642
|
+
*infrastructure* only if every job is, and a *code failure* otherwise; a
|
|
643
|
+
job whose log could not be fetched counts as a code failure, and a check
|
|
644
|
+
that is not an Actions job, or one past the first 3 jobs, is not sorted
|
|
645
|
+
at all:
|
|
646
|
+
- *flaky infrastructure* (the runner was lost, the network failed, a
|
|
647
|
+
registry answered 502/503, and no failing test is named): the failed
|
|
648
|
+
GitHub Actions jobs are re-run once, without asking the agent. If they
|
|
649
|
+
fail on infrastructure again, the run stops. A fix round in between
|
|
650
|
+
resets this, so a new flake later gets its own re-run.
|
|
651
|
+
- *out of budget* (the job hit its maximum execution time or a spending
|
|
652
|
+
limit): the run stops at once. A re-run would spend the same budget.
|
|
653
|
+
- *code failure* (anything else, including every log that names failing
|
|
654
|
+
tests): sent to the agent as a fix round, as before. If the same tests
|
|
655
|
+
fail in 3 CI rounds in a row (the first failure and two fixes that changed
|
|
656
|
+
nothing), the run stops instead of spending the next round.
|
|
657
|
+
|
|
658
|
+
A stopped run is recorded as `failed` with a `stuck_reason` in the
|
|
659
|
+
`sessions` table, its summary starts with `Stuck:`, the issue comment gives
|
|
660
|
+
the reason, and chat gets `🔁 Stuck on #<n>, stopped: <reason>`. The status
|
|
661
|
+
page's numbers count these runs in `stuck`.
|
|
662
|
+
|
|
608
663
|
## When to restart
|
|
609
664
|
|
|
610
665
|
| You changed... | Restart... |
|
|
@@ -15,6 +15,11 @@ from ctrlrelay import __version__
|
|
|
15
15
|
from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
|
|
16
16
|
from ctrlrelay.core.events import EventKind, emit_event
|
|
17
17
|
from ctrlrelay.core.github import GitHubCLI, GitHubError
|
|
18
|
+
from ctrlrelay.core.issue_questions import (
|
|
19
|
+
note_chat_answer_on_issue,
|
|
20
|
+
note_expiry_on_issue,
|
|
21
|
+
watch_issue_answers,
|
|
22
|
+
)
|
|
18
23
|
from ctrlrelay.core.job_claims import JobClaimer
|
|
19
24
|
from ctrlrelay.core.obs import get_logger, log_event
|
|
20
25
|
from ctrlrelay.core.pr_verifier import PRVerifier
|
|
@@ -561,6 +566,17 @@ async def announce_dev_success(
|
|
|
561
566
|
the caller's tests assert on.
|
|
562
567
|
"""
|
|
563
568
|
sent: list[str] = []
|
|
569
|
+
outputs = getattr(result, "outputs", None) or {}
|
|
570
|
+
if outputs.get("no_change") is True:
|
|
571
|
+
# No pull request by design (#336): nothing to warn about, and
|
|
572
|
+
# "PR ready" with an empty link would be false.
|
|
573
|
+
line = (
|
|
574
|
+
f"✅ {repo}: finished, no change needed: "
|
|
575
|
+
f"{outputs.get('no_change_reason') or 'no reason given'}"
|
|
576
|
+
)
|
|
577
|
+
await transport.send(line)
|
|
578
|
+
sent.append(line)
|
|
579
|
+
return sent
|
|
564
580
|
warning = unreviewed_pr_warning(repo, result)
|
|
565
581
|
if warning is not None:
|
|
566
582
|
await transport.send(warning)
|
|
@@ -621,6 +637,12 @@ async def announce_issue_outcome(
|
|
|
621
637
|
line = f"⏸️ Blocked on #{issue_number}: {result.question}"
|
|
622
638
|
await transport.send(line)
|
|
623
639
|
sent.append(line)
|
|
640
|
+
elif getattr(result, "stuck_reason", None):
|
|
641
|
+
# Its own line (#224): a stuck run cost time and quota going in
|
|
642
|
+
# circles, which is a different thing to look at from a crash.
|
|
643
|
+
line = f"🔁 Stuck on #{issue_number}, stopped: {result.stuck_reason}"
|
|
644
|
+
await transport.send(line)
|
|
645
|
+
sent.append(line)
|
|
624
646
|
else:
|
|
625
647
|
line = f"❌ Failed on #{issue_number}: {failure_text(result)}"
|
|
626
648
|
await transport.send(line)
|
|
@@ -1192,9 +1214,17 @@ PUBLISH_ATTEMPTS = 3
|
|
|
1192
1214
|
|
|
1193
1215
|
def publish_failure_message(
|
|
1194
1216
|
repo: str, issue_number: int, error: str, *, attempts: int, retrying: bool,
|
|
1217
|
+
rejection: str | None = None,
|
|
1195
1218
|
) -> str:
|
|
1196
|
-
"""The chat line for a run whose branch the relay could not publish.
|
|
1197
|
-
|
|
1219
|
+
"""The chat line for a run whose branch the relay could not publish.
|
|
1220
|
+
|
|
1221
|
+
When the remote rejected the push, its reason is the line shown: the
|
|
1222
|
+
full error leads with the URL, sha and ref, and its first 200
|
|
1223
|
+
characters ended before the reason did (#325)."""
|
|
1224
|
+
if rejection and rejection.strip():
|
|
1225
|
+
why = rejection.strip()[:300]
|
|
1226
|
+
else:
|
|
1227
|
+
why = (error.strip().splitlines() or ["no error recorded"])[0][:200]
|
|
1198
1228
|
if retrying:
|
|
1199
1229
|
return (
|
|
1200
1230
|
f"\u23f3 #{issue_number} in {repo}: the agent finished but the "
|
|
@@ -1253,6 +1283,13 @@ async def settle_publish_failure(
|
|
|
1253
1283
|
_logger, "publish.refused_not_retried", repo=repo,
|
|
1254
1284
|
issue_number=issue_number, error=(result.error or "")[:200],
|
|
1255
1285
|
)
|
|
1286
|
+
refusal = outputs.get("publish_refusal")
|
|
1287
|
+
if isinstance(refusal, str) and refusal:
|
|
1288
|
+
return (
|
|
1289
|
+
f"\u26d4 #{issue_number} in {repo}: the agent finished, but "
|
|
1290
|
+
f"the branch cannot be published, so the relay is not "
|
|
1291
|
+
f"retrying: {refusal}."
|
|
1292
|
+
)
|
|
1256
1293
|
return (
|
|
1257
1294
|
f"\u26d4 #{issue_number} in {repo}: the relay refused to push "
|
|
1258
1295
|
f"the branch, so it is not retrying. {result.error or ''}".strip()
|
|
@@ -1286,9 +1323,11 @@ async def settle_publish_failure(
|
|
|
1286
1323
|
error=f"{type(e).__name__}: {str(e)[:200]}",
|
|
1287
1324
|
)
|
|
1288
1325
|
notice_failed = True
|
|
1326
|
+
rejection = outputs.get("push_rejection")
|
|
1289
1327
|
message = publish_failure_message(
|
|
1290
1328
|
repo, issue_number, result.error or "",
|
|
1291
1329
|
attempts=attempts, retrying=retrying,
|
|
1330
|
+
rejection=rejection if isinstance(rejection, str) else None,
|
|
1292
1331
|
)
|
|
1293
1332
|
if notice_failed:
|
|
1294
1333
|
# The issue's last word is still the retraction promising a
|
|
@@ -2514,6 +2553,7 @@ def run_dev(
|
|
|
2514
2553
|
code_review=repo_config.code_review,
|
|
2515
2554
|
max_fix_attempts=repo_config.max_fix_attempts,
|
|
2516
2555
|
steps=repo_config.steps,
|
|
2556
|
+
question_on_issue=repo_config.question_on_issue,
|
|
2517
2557
|
)
|
|
2518
2558
|
|
|
2519
2559
|
try:
|
|
@@ -3268,6 +3308,7 @@ def poller_start(
|
|
|
3268
3308
|
max_fix_attempts=repo_config.max_fix_attempts,
|
|
3269
3309
|
steps=repo_config.steps,
|
|
3270
3310
|
claims=claims,
|
|
3311
|
+
question_on_issue=repo_config.question_on_issue,
|
|
3271
3312
|
)
|
|
3272
3313
|
|
|
3273
3314
|
# Lost the race check to another machine (#245): not a
|
|
@@ -3673,6 +3714,32 @@ def poller_start(
|
|
|
3673
3714
|
f"[dim]question_expiry: retired "
|
|
3674
3715
|
f"{row['session_id']} ({row['expired_reason']})[/dim]"
|
|
3675
3716
|
)
|
|
3717
|
+
# Expired in every channel at once (#328): the row stops
|
|
3718
|
+
# being routable in chat and watched on the issue; the
|
|
3719
|
+
# issue is told.
|
|
3720
|
+
await note_expiry_on_issue(github, row)
|
|
3721
|
+
|
|
3722
|
+
async def _notify_chat_once(text: str) -> None:
|
|
3723
|
+
"""Send one chat message on a short-lived bridge connection:
|
|
3724
|
+
the note that a question was answered on GitHub (#328)."""
|
|
3725
|
+
settings = _bridge_socket_settings(config)
|
|
3726
|
+
if settings is None:
|
|
3727
|
+
return
|
|
3728
|
+
from ctrlrelay.transports import SocketTransport
|
|
3729
|
+
sock, ask_timeout = settings
|
|
3730
|
+
if not sock.exists():
|
|
3731
|
+
return
|
|
3732
|
+
note_transport = SocketTransport(
|
|
3733
|
+
sock, ask_timeout_seconds=ask_timeout,
|
|
3734
|
+
)
|
|
3735
|
+
await note_transport.connect()
|
|
3736
|
+
try:
|
|
3737
|
+
await note_transport.send(text)
|
|
3738
|
+
finally:
|
|
3739
|
+
try:
|
|
3740
|
+
await note_transport.close()
|
|
3741
|
+
except Exception:
|
|
3742
|
+
pass
|
|
3676
3743
|
|
|
3677
3744
|
async def _run_pending_resume_sweeper() -> None:
|
|
3678
3745
|
"""Drain pending_resumes rows that an operator answered via
|
|
@@ -3686,6 +3753,12 @@ def poller_start(
|
|
|
3686
3753
|
resumed and fans out a Telegram notification with the result
|
|
3687
3754
|
so the operator knows whether the resume landed.
|
|
3688
3755
|
"""
|
|
3756
|
+
# Questions asked on their GitHub issue as well (#328): read
|
|
3757
|
+
# each one's issue for an answer, so a row answered there is
|
|
3758
|
+
# resumed in this same tick.
|
|
3759
|
+
await watch_issue_answers(
|
|
3760
|
+
github, state_db, notify=_notify_chat_once,
|
|
3761
|
+
)
|
|
3689
3762
|
try:
|
|
3690
3763
|
pending = state_db.list_pending_resumes_to_execute()
|
|
3691
3764
|
except Exception as e:
|
|
@@ -3749,6 +3822,17 @@ def poller_start(
|
|
|
3749
3822
|
pipeline_name = row["pipeline"]
|
|
3750
3823
|
answer = row["answer"] or ""
|
|
3751
3824
|
|
|
3825
|
+
# A chat answer to a question also asked on the issue
|
|
3826
|
+
# is copied there before the resume (#328), once.
|
|
3827
|
+
sweep_cfg = repo_cfg_by_name.get(repo)
|
|
3828
|
+
await note_chat_answer_on_issue(
|
|
3829
|
+
github, state_db, row,
|
|
3830
|
+
mode=(
|
|
3831
|
+
sweep_cfg.question_on_issue
|
|
3832
|
+
if sweep_cfg is not None else "auto"
|
|
3833
|
+
),
|
|
3834
|
+
)
|
|
3835
|
+
|
|
3752
3836
|
if sweeper_transport:
|
|
3753
3837
|
try:
|
|
3754
3838
|
await sweeper_transport.send(
|
|
@@ -3822,6 +3906,7 @@ def poller_start(
|
|
|
3822
3906
|
max_fix_attempts=repo_cfg.max_fix_attempts,
|
|
3823
3907
|
code_review=repo_cfg.code_review,
|
|
3824
3908
|
steps=repo_cfg.steps,
|
|
3909
|
+
question_on_issue=repo_cfg.question_on_issue,
|
|
3825
3910
|
)
|
|
3826
3911
|
elif pipeline_name == "task":
|
|
3827
3912
|
result = await resume_task_from_pending(
|
|
@@ -4512,7 +4597,13 @@ def web_start(
|
|
|
4512
4597
|
pid_file.write_text(str(os.getpid()))
|
|
4513
4598
|
try:
|
|
4514
4599
|
from ctrlrelay.core.github import GitHubCLI
|
|
4515
|
-
from ctrlrelay.web.server import
|
|
4600
|
+
from ctrlrelay.web.server import (
|
|
4601
|
+
AddressWatch,
|
|
4602
|
+
Listeners,
|
|
4603
|
+
WaitingRefresher,
|
|
4604
|
+
make_loopback_twin,
|
|
4605
|
+
make_server,
|
|
4606
|
+
)
|
|
4516
4607
|
from ctrlrelay.web.status import make_sessions_reader
|
|
4517
4608
|
from ctrlrelay.web.waiting import WaitingTracker
|
|
4518
4609
|
|
|
@@ -4533,10 +4624,19 @@ def web_start(
|
|
|
4533
4624
|
) if fleet_cfg.peers else None
|
|
4534
4625
|
httpd = make_server(config, waiting=tracker, peers=peers)
|
|
4535
4626
|
except OSError as e:
|
|
4536
|
-
|
|
4537
|
-
|
|
4627
|
+
# stderr, so a restart loop on an address this machine no longer
|
|
4628
|
+
# has (#322) lands in web.error.log, not only beside web.started.
|
|
4629
|
+
Console(stderr=True).print(
|
|
4630
|
+
f"[red]Cannot listen on {config.web.host}:{config.web.port}:[/red] {e}",
|
|
4631
|
+
soft_wrap=True,
|
|
4538
4632
|
)
|
|
4539
4633
|
raise typer.Exit(1)
|
|
4634
|
+
# Answers on 127.0.0.1 too when web.host is one LAN address (#322).
|
|
4635
|
+
twin = make_loopback_twin(config, waiting=tracker, peers=peers)
|
|
4636
|
+
listeners = Listeners(
|
|
4637
|
+
httpd, twin,
|
|
4638
|
+
watch=AddressWatch(config.web.host, on_gone=lambda: None),
|
|
4639
|
+
)
|
|
4540
4640
|
from ctrlrelay.core.github_budget import LOW, OUT, BudgetStore
|
|
4541
4641
|
|
|
4542
4642
|
budget_store = BudgetStore.beside(config.paths.state_db)
|
|
@@ -4561,12 +4661,21 @@ def web_start(
|
|
|
4561
4661
|
if peers is not None:
|
|
4562
4662
|
peers.start()
|
|
4563
4663
|
try:
|
|
4564
|
-
|
|
4664
|
+
address_gone = listeners.serve_forever()
|
|
4565
4665
|
finally:
|
|
4566
4666
|
if peers is not None:
|
|
4567
4667
|
peers.stop()
|
|
4568
4668
|
refresher.stop()
|
|
4569
|
-
|
|
4669
|
+
listeners.close()
|
|
4670
|
+
if address_gone:
|
|
4671
|
+
# Exit non-zero so the service manager restarts it (#322).
|
|
4672
|
+
Console(stderr=True).print(
|
|
4673
|
+
f"[red]{config.web.host} is no longer an address of this "
|
|
4674
|
+
"machine; stopped the status page.[/red] Set web.host to the "
|
|
4675
|
+
"new address, or it restarts and fails until the old one is back.",
|
|
4676
|
+
soft_wrap=True,
|
|
4677
|
+
)
|
|
4678
|
+
raise typer.Exit(1)
|
|
4570
4679
|
finally:
|
|
4571
4680
|
pid_file.unlink(missing_ok=True)
|
|
4572
4681
|
|
|
@@ -606,6 +606,21 @@ class ClaimKeeper:
|
|
|
606
606
|
task.add_done_callback(_forget)
|
|
607
607
|
return False
|
|
608
608
|
|
|
609
|
+
async def resume_working(self) -> bool:
|
|
610
|
+
"""Back to ``working`` after a ``hold`` for a question that has
|
|
611
|
+
now been answered in process (#328): cancel any background hold
|
|
612
|
+
retry, write ``working`` and start refreshing again. A claim taken
|
|
613
|
+
over stays the taker's. Returns whether ``working`` was written;
|
|
614
|
+
refreshing restarts either way, and its next tick writes it."""
|
|
615
|
+
if self.taken_over_by is not None:
|
|
616
|
+
return False
|
|
617
|
+
pending = _BACKGROUND_HOLDS.pop(self.claim_id, None)
|
|
618
|
+
if pending is not None:
|
|
619
|
+
pending.cancel()
|
|
620
|
+
written = await self._write(WORKING)
|
|
621
|
+
self.start()
|
|
622
|
+
return written
|
|
623
|
+
|
|
609
624
|
async def _hold_later(self) -> None:
|
|
610
625
|
deadline = self._confirmed_at + self.settings.stale_after_seconds
|
|
611
626
|
while True:
|
|
@@ -624,6 +624,14 @@ class RepoConfig(BaseModel):
|
|
|
624
624
|
# default is the pipeline's own (DEFAULT_MAX_FIX_ATTEMPTS in
|
|
625
625
|
# pipelines/dev.py; a test keeps the two equal). 0 means no fix round.
|
|
626
626
|
max_fix_attempts: int = Field(default=3, ge=0)
|
|
627
|
+
# What a dev run's question looks like on its GitHub issue (#328).
|
|
628
|
+
# The question is always asked there as well as in chat. ``auto``:
|
|
629
|
+
# the full text on a private repository and a short "a question is
|
|
630
|
+
# waiting in chat" notice on a public one (or one whose visibility
|
|
631
|
+
# could not be read); ``full`` and ``notice`` force one or the other.
|
|
632
|
+
# A question can name machines, other repositories and plans, which
|
|
633
|
+
# is why a public repository does not get it by default.
|
|
634
|
+
question_on_issue: Literal["auto", "full", "notice"] = "auto"
|
|
627
635
|
|
|
628
636
|
@model_validator(mode="after")
|
|
629
637
|
def _review_settings_agree(self) -> RepoConfig:
|