ctrlrelay 0.21.0__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.21.0 → ctrlrelay-0.22.0}/CHANGELOG.md +44 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/PKG-INFO +1 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/configuration.md +23 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/pyproject.toml +1 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/cli.py +11 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatcher.py +30 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/events.py +3 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github.py +25 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/publish.py +8 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/worktree.py +34 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/dev.py +537 -92
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/static/index.html +10 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/status.py +155 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/_publish_fakes.py +10 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_no_keys.py +4 -2
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_event_emission.py +9 -2
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_events.py +1 -1
- ctrlrelay-0.22.0/tests/test_no_change_run.py +219 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_relay_git_263.py +1 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_review_copy.py +42 -0
- ctrlrelay-0.22.0/tests/test_review_gate.py +493 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_status.py +136 -1
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.gitignore +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/LICENSE +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/README.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/SECURITY.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/cli.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/development.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/index.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/operations.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/issue_questions.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/orphans.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_stuck.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_issue_questions.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,48 @@ 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
|
+
|
|
10
52
|
## [0.21.0] - 2026-11-05
|
|
11
53
|
|
|
12
54
|
### Added
|
|
@@ -2407,7 +2449,8 @@ pipeline).
|
|
|
2407
2449
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2408
2450
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2409
2451
|
|
|
2410
|
-
[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
|
|
2411
2454
|
[0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
|
|
2412
2455
|
[0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
|
|
2413
2456
|
[0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
|
|
@@ -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/
|
|
@@ -539,7 +539,7 @@ web:
|
|
|
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
|
|
@@ -946,6 +946,28 @@ an exception inside the review are all failures. It is about the review
|
|
|
946
946
|
having happened, not about what it found. **`review: off`** is the same as
|
|
947
947
|
`code_review.method: off`, and loads as that.
|
|
948
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
|
+
|
|
949
971
|
Two settings for one reviewer must agree, or the config refuses to load:
|
|
950
972
|
|
|
951
973
|
- `code_review.method: off` with `steps.review: on` or `required`;
|
|
@@ -566,6 +566,17 @@ async def announce_dev_success(
|
|
|
566
566
|
the caller's tests assert on.
|
|
567
567
|
"""
|
|
568
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
|
|
569
580
|
warning = unreviewed_pr_warning(repo, result)
|
|
570
581
|
if warning is not None:
|
|
571
582
|
await transport.send(warning)
|
|
@@ -265,6 +265,34 @@ class CliAgentAdapter:
|
|
|
265
265
|
"""
|
|
266
266
|
return None
|
|
267
267
|
|
|
268
|
+
def _record_agent_pid(self, session_id: str, pid: int | None) -> None:
|
|
269
|
+
"""Note which process is this session's agent (#309), in
|
|
270
|
+
``agents/<session_id>.pid`` beside the state database, so the status
|
|
271
|
+
page can tell an agent inside a long command from one that stopped.
|
|
272
|
+
Several runs share the poller's pid, so only the agent's own pid
|
|
273
|
+
says which command belongs to which run. Best-effort; files older
|
|
274
|
+
than a week are pruned here."""
|
|
275
|
+
store = getattr(self, "usage_store", None)
|
|
276
|
+
if store is None or not pid:
|
|
277
|
+
return
|
|
278
|
+
folder = Path(store.path).parent / "agents"
|
|
279
|
+
try:
|
|
280
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
cutoff = time.time() - 7 * 86400
|
|
282
|
+
for old in folder.glob("*.pid"):
|
|
283
|
+
try:
|
|
284
|
+
if old.stat().st_mtime < cutoff:
|
|
285
|
+
old.unlink()
|
|
286
|
+
except OSError:
|
|
287
|
+
pass
|
|
288
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", session_id)
|
|
289
|
+
(folder / f"{safe}.pid").write_text(f"{int(pid)}\n")
|
|
290
|
+
except Exception as e: # noqa: BLE001 - must never fail a run
|
|
291
|
+
log_event(
|
|
292
|
+
_logger, "dispatcher.agent_pid.write_failed",
|
|
293
|
+
session_id=session_id, error=str(e)[:200],
|
|
294
|
+
)
|
|
295
|
+
|
|
268
296
|
def _record_usage(self, stdout_text: str, ident: dict) -> None:
|
|
269
297
|
"""Keep this run's usage reading, if the backend reports one.
|
|
270
298
|
|
|
@@ -393,6 +421,8 @@ class CliAgentAdapter:
|
|
|
393
421
|
)
|
|
394
422
|
raise
|
|
395
423
|
|
|
424
|
+
self._record_agent_pid(session_id, getattr(proc, "pid", None))
|
|
425
|
+
|
|
396
426
|
try:
|
|
397
427
|
stdout, stderr = await asyncio.wait_for(
|
|
398
428
|
self._communicate_watched(
|
|
@@ -42,6 +42,9 @@ class EventKind(StrEnum):
|
|
|
42
42
|
CI_FINISHED = "ci_finished"
|
|
43
43
|
REVIEW_STARTED = "review_started"
|
|
44
44
|
REVIEW_FINISHED = "review_finished"
|
|
45
|
+
# A review's commit is no longer the PR's head: somebody pushed after
|
|
46
|
+
# it, and the PR needs review again (#222).
|
|
47
|
+
REVIEW_STALE = "review_stale"
|
|
45
48
|
MESSAGE_SENT = "message_sent"
|
|
46
49
|
QUESTION_POSTED = "question_posted"
|
|
47
50
|
QUESTION_HELD = "question_held"
|
|
@@ -581,9 +581,14 @@ class GitHubCLI:
|
|
|
581
581
|
body: str,
|
|
582
582
|
head: str,
|
|
583
583
|
base: str,
|
|
584
|
+
draft: bool = False,
|
|
584
585
|
) -> dict[str, Any]:
|
|
585
586
|
"""Create a pull request; return ``{"number", "url"}``.
|
|
586
587
|
|
|
588
|
+
``draft`` opens it as a draft (#222): a repository whose review is
|
|
589
|
+
required gets a PR that reads "not ready" until a review passed
|
|
590
|
+
on its current head.
|
|
591
|
+
|
|
587
592
|
``gh pr create`` has no ``--json`` flag (it prints the new
|
|
588
593
|
pull request's URL), so the number is read from that URL. The
|
|
589
594
|
old version passed ``--json`` and could never have succeeded;
|
|
@@ -599,6 +604,7 @@ class GitHubCLI:
|
|
|
599
604
|
"--body", body,
|
|
600
605
|
"--head", head,
|
|
601
606
|
"--base", base,
|
|
607
|
+
*(("--draft",) if draft else ()),
|
|
602
608
|
)
|
|
603
609
|
url = ""
|
|
604
610
|
for line in output.splitlines():
|
|
@@ -612,6 +618,25 @@ class GitHubCLI:
|
|
|
612
618
|
)
|
|
613
619
|
return {"number": int(match.group(1)), "url": url}
|
|
614
620
|
|
|
621
|
+
async def get_pr_head(self, repo: str, pr_number: int) -> dict[str, Any]:
|
|
622
|
+
"""``{"sha", "draft"}`` for a pull request: the commit GitHub has
|
|
623
|
+
as its head, and whether it is a draft (#222)."""
|
|
624
|
+
output = await self._run_gh(
|
|
625
|
+
"pr", "view", str(pr_number),
|
|
626
|
+
"--repo", repo,
|
|
627
|
+
"--json", "headRefOid,isDraft",
|
|
628
|
+
)
|
|
629
|
+
data = json.loads(output)
|
|
630
|
+
return {"sha": data.get("headRefOid"), "draft": data.get("isDraft")}
|
|
631
|
+
|
|
632
|
+
async def set_pr_draft(self, repo: str, pr_number: int, draft: bool) -> None:
|
|
633
|
+
"""Mark a pull request as a draft, or as ready for review (#222)."""
|
|
634
|
+
await self._run_gh(
|
|
635
|
+
"pr", "ready", str(pr_number),
|
|
636
|
+
"--repo", repo,
|
|
637
|
+
*(("--undo",) if draft else ()),
|
|
638
|
+
)
|
|
639
|
+
|
|
615
640
|
async def get_default_branch(self, repo: str) -> str:
|
|
616
641
|
"""The repository's default branch, as GitHub reports it.
|
|
617
642
|
|
|
@@ -45,6 +45,14 @@ class PushRefusedError(Exception):
|
|
|
45
45
|
"""A push the relay will not make. The message names why."""
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
class NothingToPushError(PushRefusedError):
|
|
49
|
+
"""The run branch has no commit the default branch lacks (#336).
|
|
50
|
+
|
|
51
|
+
A refusal like any other, unless the agent declared that no change
|
|
52
|
+
was the right outcome: then the run finished, with nothing to push.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
48
56
|
def check_push_target(
|
|
49
57
|
target: str,
|
|
50
58
|
*,
|
|
@@ -1626,7 +1626,9 @@ class WorktreeManager:
|
|
|
1626
1626
|
session_id=session_id,
|
|
1627
1627
|
branch=branch,
|
|
1628
1628
|
)
|
|
1629
|
-
|
|
1629
|
+
from ctrlrelay.core.publish import NothingToPushError
|
|
1630
|
+
|
|
1631
|
+
raise NothingToPushError(
|
|
1630
1632
|
f"push refused: {branch!r} has no commits that "
|
|
1631
1633
|
f"{default_branch!r} does not already have; the agent "
|
|
1632
1634
|
f"must commit its work on {branch!r}"
|
|
@@ -1847,6 +1849,37 @@ class WorktreeManager:
|
|
|
1847
1849
|
|
|
1848
1850
|
return bare_path
|
|
1849
1851
|
|
|
1852
|
+
async def fetch_pr_head(self, repo: str, pr_number: int) -> str:
|
|
1853
|
+
"""Fetch a pull request's current head into the bare clone and
|
|
1854
|
+
return its commit id (#222).
|
|
1855
|
+
|
|
1856
|
+
Into ``refs/ctrlrelay/review/pr-<n>``, never into the branch: a
|
|
1857
|
+
run's branch is checked out in its worktree for the whole session,
|
|
1858
|
+
and :meth:`ensure_bare_repo` leaves such branches out of its fetch
|
|
1859
|
+
(#253), so a commit pushed by someone else never reached the clone
|
|
1860
|
+
and the review copy of it failed.
|
|
1861
|
+
"""
|
|
1862
|
+
if not isinstance(pr_number, int) or pr_number <= 0:
|
|
1863
|
+
raise WorktreeError(f"not a pull request number: {pr_number!r}")
|
|
1864
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
1865
|
+
ref = f"refs/ctrlrelay/review/pr-{pr_number}"
|
|
1866
|
+
await self._run_git(
|
|
1867
|
+
"fetch", "--no-tags", "origin",
|
|
1868
|
+
f"+refs/pull/{pr_number}/head:{ref}",
|
|
1869
|
+
cwd=bare_path,
|
|
1870
|
+
timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
|
|
1871
|
+
)
|
|
1872
|
+
sha = (
|
|
1873
|
+
await self._run_git(
|
|
1874
|
+
"rev-parse", "--verify", "--quiet", "--end-of-options",
|
|
1875
|
+
f"{ref}^{{commit}}",
|
|
1876
|
+
cwd=bare_path,
|
|
1877
|
+
)
|
|
1878
|
+
).strip()
|
|
1879
|
+
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha):
|
|
1880
|
+
raise WorktreeError(f"{ref} is not a commit: {sha[:80]!r}")
|
|
1881
|
+
return sha
|
|
1882
|
+
|
|
1850
1883
|
async def _follow_origin_default(
|
|
1851
1884
|
self, repo: str, bare_path: Path, default_branch: str,
|
|
1852
1885
|
) -> None:
|