ctrlrelay 0.20.2__tar.gz → 0.21.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.21.0}/CHANGELOG.md +50 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/PKG-INFO +1 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/config/orchestrator.yaml.example +4 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/configuration.md +3 -2
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/operations.md +56 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/pyproject.toml +1 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/cli.py +105 -7
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/claims.py +15 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/config.py +8 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/dispatcher.py +100 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/events.py +4 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/github.py +60 -0
- ctrlrelay-0.21.0/src/ctrlrelay/core/issue_questions.py +461 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/state.py +113 -6
- ctrlrelay-0.21.0/src/ctrlrelay/core/stuck.py +424 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/base.py +6 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/dev.py +407 -23
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/numbers.py +24 -9
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/server.py +202 -1
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/static/index.html +3 -3
- ctrlrelay-0.21.0/tests/test_dev_stuck.py +244 -0
- ctrlrelay-0.21.0/tests/test_issue_questions.py +482 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_publish_failure_retry.py +94 -0
- ctrlrelay-0.21.0/tests/test_stuck.py +333 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_numbers.py +41 -8
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_server.py +191 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.gitignore +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/LICENSE +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/README.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/SECURITY.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/cli.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/development.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/index.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/orphans.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.21.0] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A run going in circles is stopped early with a clear reason, instead of
|
|
15
|
+
running until the session timeout (#224). The agent is stopped when its
|
|
16
|
+
live transcript shows the same command 5 times in a row with the same
|
|
17
|
+
result each time (polling a job whose output moves on is not a loop,
|
|
18
|
+
though 5 identical polls in a row still are), or
|
|
19
|
+
the same error from the same command 5 times in a row. Between CI fix
|
|
20
|
+
rounds each failed job's log is sorted on its own, as flaky infrastructure (the failed jobs are re-run once, without
|
|
21
|
+
the agent), out of budget (not retried), or a code failure (sent to the
|
|
22
|
+
agent, as before); the same tests failing in 3 CI rounds in a row stops the
|
|
23
|
+
run. A stopped run is recorded as failed with `sessions.stuck_reason`, the
|
|
24
|
+
issue comment and chat say it was stuck and why, and the status page counts
|
|
25
|
+
these runs. See "Stuck runs" in the operations guide.
|
|
26
|
+
|
|
27
|
+
- A dev run's question is now asked on its GitHub issue as well as in chat,
|
|
28
|
+
and the first answer from either one wins. An answer on the issue is a
|
|
29
|
+
comment that starts with `/answer`, since the relay and every working
|
|
30
|
+
session comment as the operator's account; it counts only from an account
|
|
31
|
+
with write access to the repository, and only if it is newer than the
|
|
32
|
+
question and carries no relay marker; other comments
|
|
33
|
+
are logged and ignored. The chat is told when the issue answered it, and a
|
|
34
|
+
chat answer is copied to the issue. A reply that loses is told it was not
|
|
35
|
+
applied, and never resumes the session a second time. On a public
|
|
36
|
+
repository the issue gets a short notice instead of the question unless
|
|
37
|
+
`repos[].question_on_issue` says `full`. An expired question is retired
|
|
38
|
+
in both channels and the issue says so. While the question waits, the
|
|
39
|
+
issue's claim is marked held, so another machine does not take the issue
|
|
40
|
+
if this one stops; it goes back to working when the run resumes.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- A push GitHub refuses because the branch changes a file under
|
|
45
|
+
`.github/workflows/` and the relay's token has no `workflow` scope is no
|
|
46
|
+
longer retried. It cost one full agent run per poll, up to three, for a
|
|
47
|
+
push that could never pass. Chat now says the branch changes a workflow
|
|
48
|
+
file, names it, and says why no retry can pass. For any other rejected
|
|
49
|
+
push, the chat line shows the remote's reason rather than the first 200
|
|
50
|
+
characters of the error, which ended before the reason (#325).
|
|
51
|
+
|
|
52
|
+
- The status page bound to a LAN address now also answers on
|
|
53
|
+
`127.0.0.1`, and it no longer keeps running, silently, on an address the
|
|
54
|
+
machine has lost. It checks every 30 seconds; when `web.host` is gone it
|
|
55
|
+
logs `web.address_gone`, says so in `web.error.log` and exits 1 so the
|
|
56
|
+
service manager restarts it (#322).
|
|
57
|
+
|
|
10
58
|
## [0.20.2] - 2026-11-04
|
|
11
59
|
|
|
12
60
|
### Fixed
|
|
@@ -2359,7 +2407,8 @@ pipeline).
|
|
|
2359
2407
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2360
2408
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2361
2409
|
|
|
2362
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2410
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...HEAD
|
|
2411
|
+
[0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
|
|
2363
2412
|
[0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
|
|
2364
2413
|
[0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
|
|
2365
2414
|
[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.21.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,7 +534,7 @@ 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. |
|
|
@@ -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
|
|
@@ -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
|
|
@@ -621,6 +626,12 @@ async def announce_issue_outcome(
|
|
|
621
626
|
line = f"⏸️ Blocked on #{issue_number}: {result.question}"
|
|
622
627
|
await transport.send(line)
|
|
623
628
|
sent.append(line)
|
|
629
|
+
elif getattr(result, "stuck_reason", None):
|
|
630
|
+
# Its own line (#224): a stuck run cost time and quota going in
|
|
631
|
+
# circles, which is a different thing to look at from a crash.
|
|
632
|
+
line = f"🔁 Stuck on #{issue_number}, stopped: {result.stuck_reason}"
|
|
633
|
+
await transport.send(line)
|
|
634
|
+
sent.append(line)
|
|
624
635
|
else:
|
|
625
636
|
line = f"❌ Failed on #{issue_number}: {failure_text(result)}"
|
|
626
637
|
await transport.send(line)
|
|
@@ -1192,9 +1203,17 @@ PUBLISH_ATTEMPTS = 3
|
|
|
1192
1203
|
|
|
1193
1204
|
def publish_failure_message(
|
|
1194
1205
|
repo: str, issue_number: int, error: str, *, attempts: int, retrying: bool,
|
|
1206
|
+
rejection: str | None = None,
|
|
1195
1207
|
) -> str:
|
|
1196
|
-
"""The chat line for a run whose branch the relay could not publish.
|
|
1197
|
-
|
|
1208
|
+
"""The chat line for a run whose branch the relay could not publish.
|
|
1209
|
+
|
|
1210
|
+
When the remote rejected the push, its reason is the line shown: the
|
|
1211
|
+
full error leads with the URL, sha and ref, and its first 200
|
|
1212
|
+
characters ended before the reason did (#325)."""
|
|
1213
|
+
if rejection and rejection.strip():
|
|
1214
|
+
why = rejection.strip()[:300]
|
|
1215
|
+
else:
|
|
1216
|
+
why = (error.strip().splitlines() or ["no error recorded"])[0][:200]
|
|
1198
1217
|
if retrying:
|
|
1199
1218
|
return (
|
|
1200
1219
|
f"\u23f3 #{issue_number} in {repo}: the agent finished but the "
|
|
@@ -1253,6 +1272,13 @@ async def settle_publish_failure(
|
|
|
1253
1272
|
_logger, "publish.refused_not_retried", repo=repo,
|
|
1254
1273
|
issue_number=issue_number, error=(result.error or "")[:200],
|
|
1255
1274
|
)
|
|
1275
|
+
refusal = outputs.get("publish_refusal")
|
|
1276
|
+
if isinstance(refusal, str) and refusal:
|
|
1277
|
+
return (
|
|
1278
|
+
f"\u26d4 #{issue_number} in {repo}: the agent finished, but "
|
|
1279
|
+
f"the branch cannot be published, so the relay is not "
|
|
1280
|
+
f"retrying: {refusal}."
|
|
1281
|
+
)
|
|
1256
1282
|
return (
|
|
1257
1283
|
f"\u26d4 #{issue_number} in {repo}: the relay refused to push "
|
|
1258
1284
|
f"the branch, so it is not retrying. {result.error or ''}".strip()
|
|
@@ -1286,9 +1312,11 @@ async def settle_publish_failure(
|
|
|
1286
1312
|
error=f"{type(e).__name__}: {str(e)[:200]}",
|
|
1287
1313
|
)
|
|
1288
1314
|
notice_failed = True
|
|
1315
|
+
rejection = outputs.get("push_rejection")
|
|
1289
1316
|
message = publish_failure_message(
|
|
1290
1317
|
repo, issue_number, result.error or "",
|
|
1291
1318
|
attempts=attempts, retrying=retrying,
|
|
1319
|
+
rejection=rejection if isinstance(rejection, str) else None,
|
|
1292
1320
|
)
|
|
1293
1321
|
if notice_failed:
|
|
1294
1322
|
# The issue's last word is still the retraction promising a
|
|
@@ -2514,6 +2542,7 @@ def run_dev(
|
|
|
2514
2542
|
code_review=repo_config.code_review,
|
|
2515
2543
|
max_fix_attempts=repo_config.max_fix_attempts,
|
|
2516
2544
|
steps=repo_config.steps,
|
|
2545
|
+
question_on_issue=repo_config.question_on_issue,
|
|
2517
2546
|
)
|
|
2518
2547
|
|
|
2519
2548
|
try:
|
|
@@ -3268,6 +3297,7 @@ def poller_start(
|
|
|
3268
3297
|
max_fix_attempts=repo_config.max_fix_attempts,
|
|
3269
3298
|
steps=repo_config.steps,
|
|
3270
3299
|
claims=claims,
|
|
3300
|
+
question_on_issue=repo_config.question_on_issue,
|
|
3271
3301
|
)
|
|
3272
3302
|
|
|
3273
3303
|
# Lost the race check to another machine (#245): not a
|
|
@@ -3673,6 +3703,32 @@ def poller_start(
|
|
|
3673
3703
|
f"[dim]question_expiry: retired "
|
|
3674
3704
|
f"{row['session_id']} ({row['expired_reason']})[/dim]"
|
|
3675
3705
|
)
|
|
3706
|
+
# Expired in every channel at once (#328): the row stops
|
|
3707
|
+
# being routable in chat and watched on the issue; the
|
|
3708
|
+
# issue is told.
|
|
3709
|
+
await note_expiry_on_issue(github, row)
|
|
3710
|
+
|
|
3711
|
+
async def _notify_chat_once(text: str) -> None:
|
|
3712
|
+
"""Send one chat message on a short-lived bridge connection:
|
|
3713
|
+
the note that a question was answered on GitHub (#328)."""
|
|
3714
|
+
settings = _bridge_socket_settings(config)
|
|
3715
|
+
if settings is None:
|
|
3716
|
+
return
|
|
3717
|
+
from ctrlrelay.transports import SocketTransport
|
|
3718
|
+
sock, ask_timeout = settings
|
|
3719
|
+
if not sock.exists():
|
|
3720
|
+
return
|
|
3721
|
+
note_transport = SocketTransport(
|
|
3722
|
+
sock, ask_timeout_seconds=ask_timeout,
|
|
3723
|
+
)
|
|
3724
|
+
await note_transport.connect()
|
|
3725
|
+
try:
|
|
3726
|
+
await note_transport.send(text)
|
|
3727
|
+
finally:
|
|
3728
|
+
try:
|
|
3729
|
+
await note_transport.close()
|
|
3730
|
+
except Exception:
|
|
3731
|
+
pass
|
|
3676
3732
|
|
|
3677
3733
|
async def _run_pending_resume_sweeper() -> None:
|
|
3678
3734
|
"""Drain pending_resumes rows that an operator answered via
|
|
@@ -3686,6 +3742,12 @@ def poller_start(
|
|
|
3686
3742
|
resumed and fans out a Telegram notification with the result
|
|
3687
3743
|
so the operator knows whether the resume landed.
|
|
3688
3744
|
"""
|
|
3745
|
+
# Questions asked on their GitHub issue as well (#328): read
|
|
3746
|
+
# each one's issue for an answer, so a row answered there is
|
|
3747
|
+
# resumed in this same tick.
|
|
3748
|
+
await watch_issue_answers(
|
|
3749
|
+
github, state_db, notify=_notify_chat_once,
|
|
3750
|
+
)
|
|
3689
3751
|
try:
|
|
3690
3752
|
pending = state_db.list_pending_resumes_to_execute()
|
|
3691
3753
|
except Exception as e:
|
|
@@ -3749,6 +3811,17 @@ def poller_start(
|
|
|
3749
3811
|
pipeline_name = row["pipeline"]
|
|
3750
3812
|
answer = row["answer"] or ""
|
|
3751
3813
|
|
|
3814
|
+
# A chat answer to a question also asked on the issue
|
|
3815
|
+
# is copied there before the resume (#328), once.
|
|
3816
|
+
sweep_cfg = repo_cfg_by_name.get(repo)
|
|
3817
|
+
await note_chat_answer_on_issue(
|
|
3818
|
+
github, state_db, row,
|
|
3819
|
+
mode=(
|
|
3820
|
+
sweep_cfg.question_on_issue
|
|
3821
|
+
if sweep_cfg is not None else "auto"
|
|
3822
|
+
),
|
|
3823
|
+
)
|
|
3824
|
+
|
|
3752
3825
|
if sweeper_transport:
|
|
3753
3826
|
try:
|
|
3754
3827
|
await sweeper_transport.send(
|
|
@@ -3822,6 +3895,7 @@ def poller_start(
|
|
|
3822
3895
|
max_fix_attempts=repo_cfg.max_fix_attempts,
|
|
3823
3896
|
code_review=repo_cfg.code_review,
|
|
3824
3897
|
steps=repo_cfg.steps,
|
|
3898
|
+
question_on_issue=repo_cfg.question_on_issue,
|
|
3825
3899
|
)
|
|
3826
3900
|
elif pipeline_name == "task":
|
|
3827
3901
|
result = await resume_task_from_pending(
|
|
@@ -4512,7 +4586,13 @@ def web_start(
|
|
|
4512
4586
|
pid_file.write_text(str(os.getpid()))
|
|
4513
4587
|
try:
|
|
4514
4588
|
from ctrlrelay.core.github import GitHubCLI
|
|
4515
|
-
from ctrlrelay.web.server import
|
|
4589
|
+
from ctrlrelay.web.server import (
|
|
4590
|
+
AddressWatch,
|
|
4591
|
+
Listeners,
|
|
4592
|
+
WaitingRefresher,
|
|
4593
|
+
make_loopback_twin,
|
|
4594
|
+
make_server,
|
|
4595
|
+
)
|
|
4516
4596
|
from ctrlrelay.web.status import make_sessions_reader
|
|
4517
4597
|
from ctrlrelay.web.waiting import WaitingTracker
|
|
4518
4598
|
|
|
@@ -4533,10 +4613,19 @@ def web_start(
|
|
|
4533
4613
|
) if fleet_cfg.peers else None
|
|
4534
4614
|
httpd = make_server(config, waiting=tracker, peers=peers)
|
|
4535
4615
|
except OSError as e:
|
|
4536
|
-
|
|
4537
|
-
|
|
4616
|
+
# stderr, so a restart loop on an address this machine no longer
|
|
4617
|
+
# has (#322) lands in web.error.log, not only beside web.started.
|
|
4618
|
+
Console(stderr=True).print(
|
|
4619
|
+
f"[red]Cannot listen on {config.web.host}:{config.web.port}:[/red] {e}",
|
|
4620
|
+
soft_wrap=True,
|
|
4538
4621
|
)
|
|
4539
4622
|
raise typer.Exit(1)
|
|
4623
|
+
# Answers on 127.0.0.1 too when web.host is one LAN address (#322).
|
|
4624
|
+
twin = make_loopback_twin(config, waiting=tracker, peers=peers)
|
|
4625
|
+
listeners = Listeners(
|
|
4626
|
+
httpd, twin,
|
|
4627
|
+
watch=AddressWatch(config.web.host, on_gone=lambda: None),
|
|
4628
|
+
)
|
|
4540
4629
|
from ctrlrelay.core.github_budget import LOW, OUT, BudgetStore
|
|
4541
4630
|
|
|
4542
4631
|
budget_store = BudgetStore.beside(config.paths.state_db)
|
|
@@ -4561,12 +4650,21 @@ def web_start(
|
|
|
4561
4650
|
if peers is not None:
|
|
4562
4651
|
peers.start()
|
|
4563
4652
|
try:
|
|
4564
|
-
|
|
4653
|
+
address_gone = listeners.serve_forever()
|
|
4565
4654
|
finally:
|
|
4566
4655
|
if peers is not None:
|
|
4567
4656
|
peers.stop()
|
|
4568
4657
|
refresher.stop()
|
|
4569
|
-
|
|
4658
|
+
listeners.close()
|
|
4659
|
+
if address_gone:
|
|
4660
|
+
# Exit non-zero so the service manager restarts it (#322).
|
|
4661
|
+
Console(stderr=True).print(
|
|
4662
|
+
f"[red]{config.web.host} is no longer an address of this "
|
|
4663
|
+
"machine; stopped the status page.[/red] Set web.host to the "
|
|
4664
|
+
"new address, or it restarts and fails until the old one is back.",
|
|
4665
|
+
soft_wrap=True,
|
|
4666
|
+
)
|
|
4667
|
+
raise typer.Exit(1)
|
|
4570
4668
|
finally:
|
|
4571
4669
|
pid_file.unlink(missing_ok=True)
|
|
4572
4670
|
|
|
@@ -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:
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import re
|
|
15
16
|
import shutil
|
|
16
17
|
import tempfile
|
|
17
18
|
import time
|
|
@@ -35,6 +36,7 @@ from ctrlrelay.core.agent_env import (
|
|
|
35
36
|
from ctrlrelay.core.binaries import resolve_binary
|
|
36
37
|
from ctrlrelay.core.checkpoint import CheckpointState, CheckpointStatus, read_checkpoint
|
|
37
38
|
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
39
|
+
from ctrlrelay.core.stuck import StuckPolicy, watch_transcript
|
|
38
40
|
from ctrlrelay.core.usage import (
|
|
39
41
|
UsageStore,
|
|
40
42
|
parse_agent_session_id,
|
|
@@ -136,6 +138,9 @@ class SessionResult:
|
|
|
136
138
|
# The relay killed the agent at its time limit (#311). Set only by the
|
|
137
139
|
# relay's own timeout path, never parsed from anything the agent wrote.
|
|
138
140
|
timed_out: bool = False
|
|
141
|
+
# The relay stopped the agent because its transcript showed it going in
|
|
142
|
+
# circles (#224), and why. Set only by the relay's own watch.
|
|
143
|
+
stuck_reason: str | None = None
|
|
139
144
|
|
|
140
145
|
@property
|
|
141
146
|
def success(self) -> bool:
|
|
@@ -188,11 +193,69 @@ class CliAgentAdapter:
|
|
|
188
193
|
# name the backend without every subclass remembering to.
|
|
189
194
|
agent_type: ClassVar[str] = "unknown"
|
|
190
195
|
|
|
196
|
+
# How the live transcript is watched for a run going in circles (#224).
|
|
197
|
+
# A plain class attribute, not a dataclass field: every adapter gets
|
|
198
|
+
# the watch by construction, and a test sets it on the instance.
|
|
199
|
+
# ``None`` turns the watch off.
|
|
200
|
+
stuck_policy: StuckPolicy | None = StuckPolicy()
|
|
201
|
+
|
|
191
202
|
@property
|
|
192
203
|
def agent_binary(self) -> str:
|
|
193
204
|
"""The executable this adapter runs. Subclasses override."""
|
|
194
205
|
raise NotImplementedError
|
|
195
206
|
|
|
207
|
+
def _transcript_dirs(
|
|
208
|
+
self, working_dir: Path, env: dict[str, str]
|
|
209
|
+
) -> list[Path]:
|
|
210
|
+
"""Where this CLI writes the live transcript of a run in
|
|
211
|
+
``working_dir``. Empty by default: a backend whose transcript is
|
|
212
|
+
not known is not watched, rather than watched somewhere wrong."""
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
async def _communicate_watched(
|
|
216
|
+
self,
|
|
217
|
+
proc: Any,
|
|
218
|
+
*,
|
|
219
|
+
folders: list[Path],
|
|
220
|
+
since: float,
|
|
221
|
+
ident: dict[str, Any],
|
|
222
|
+
stuck: list[str],
|
|
223
|
+
) -> tuple[bytes, bytes]:
|
|
224
|
+
"""``proc.communicate()``, while the transcript is watched (#224).
|
|
225
|
+
|
|
226
|
+
When the watch sees a loop it records the reason in ``stuck`` and
|
|
227
|
+
kills the child, which ends ``communicate()`` the normal way. The
|
|
228
|
+
watch is cancelled however this returns, the time limit included.
|
|
229
|
+
"""
|
|
230
|
+
if not folders or self.stuck_policy is None:
|
|
231
|
+
return await proc.communicate()
|
|
232
|
+
policy = self.stuck_policy
|
|
233
|
+
|
|
234
|
+
async def _watch() -> None:
|
|
235
|
+
reason = await watch_transcript(
|
|
236
|
+
folders, since=since, policy=policy, ident=ident,
|
|
237
|
+
)
|
|
238
|
+
stuck.append(reason)
|
|
239
|
+
_logger.warning(
|
|
240
|
+
"dispatcher.session.stuck",
|
|
241
|
+
extra={**ident, "reason": reason},
|
|
242
|
+
)
|
|
243
|
+
try:
|
|
244
|
+
if proc.returncode is None:
|
|
245
|
+
proc.kill()
|
|
246
|
+
except ProcessLookupError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
watcher = asyncio.create_task(_watch())
|
|
250
|
+
try:
|
|
251
|
+
return await proc.communicate()
|
|
252
|
+
finally:
|
|
253
|
+
watcher.cancel()
|
|
254
|
+
try:
|
|
255
|
+
await watcher
|
|
256
|
+
except BaseException:
|
|
257
|
+
pass
|
|
258
|
+
|
|
196
259
|
def _parse_agent_session_id(self, stdout_text: str) -> str | None:
|
|
197
260
|
"""The agent's own session id, for a later resume.
|
|
198
261
|
|
|
@@ -288,6 +351,10 @@ class CliAgentAdapter:
|
|
|
288
351
|
prompt_len=len(prompt),
|
|
289
352
|
)
|
|
290
353
|
started = time.monotonic()
|
|
354
|
+
# Transcript rows are stamped in wall-clock time. A second of slack
|
|
355
|
+
# for the clock the CLI reads being a hair behind this one.
|
|
356
|
+
wall_started = time.time() - 1.0
|
|
357
|
+
stuck: list[str] = []
|
|
291
358
|
|
|
292
359
|
try:
|
|
293
360
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -328,7 +395,14 @@ class CliAgentAdapter:
|
|
|
328
395
|
|
|
329
396
|
try:
|
|
330
397
|
stdout, stderr = await asyncio.wait_for(
|
|
331
|
-
|
|
398
|
+
self._communicate_watched(
|
|
399
|
+
proc,
|
|
400
|
+
folders=self._transcript_dirs(working_dir, env),
|
|
401
|
+
since=wall_started,
|
|
402
|
+
ident=ident,
|
|
403
|
+
stuck=stuck,
|
|
404
|
+
),
|
|
405
|
+
timeout=timeout,
|
|
332
406
|
)
|
|
333
407
|
except asyncio.TimeoutError as e:
|
|
334
408
|
proc.kill()
|
|
@@ -482,6 +556,15 @@ class CliAgentAdapter:
|
|
|
482
556
|
agent_session_id = self._parse_agent_session_id(stdout_text)
|
|
483
557
|
self._record_usage(stdout_text, ident)
|
|
484
558
|
|
|
559
|
+
# A checkpoint the agent wrote wins: it finished, or asked, before
|
|
560
|
+
# the kill landed, so it was not stuck at the end.
|
|
561
|
+
stuck_reason = stuck[0] if stuck and state is None else None
|
|
562
|
+
if stuck_reason is not None:
|
|
563
|
+
# In stderr too, so a lane that reads only stderr still says why.
|
|
564
|
+
stderr_text = (
|
|
565
|
+
f"Session stopped: stuck: {stuck_reason}\n{stderr_text}"
|
|
566
|
+
)
|
|
567
|
+
|
|
485
568
|
log_event(
|
|
486
569
|
_logger,
|
|
487
570
|
"dispatcher.session.finished",
|
|
@@ -510,6 +593,7 @@ class CliAgentAdapter:
|
|
|
510
593
|
stdout=stdout_text,
|
|
511
594
|
stderr=stderr_text,
|
|
512
595
|
agent_session_id=agent_session_id,
|
|
596
|
+
stuck_reason=stuck_reason,
|
|
513
597
|
)
|
|
514
598
|
|
|
515
599
|
|
|
@@ -560,6 +644,21 @@ class ClaudeDispatcher(CliAgentAdapter):
|
|
|
560
644
|
def _parse_agent_session_id(self, stdout_text: str) -> str | None:
|
|
561
645
|
return _extract_agent_session_id(stdout_text)
|
|
562
646
|
|
|
647
|
+
def _transcript_dirs(
|
|
648
|
+
self, working_dir: Path, env: dict[str, str]
|
|
649
|
+
) -> list[Path]:
|
|
650
|
+
"""``<config dir>/projects/<working dir, "/", "." and "_" as "-">``,
|
|
651
|
+
the folder :mod:`ctrlrelay.web.transcript` reads too. Both the path
|
|
652
|
+
as given and its resolved form, since which one the CLI sees
|
|
653
|
+
depends on how the worktree path was spelled."""
|
|
654
|
+
base = Path(env.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
655
|
+
out: list[Path] = []
|
|
656
|
+
for spelled in (str(working_dir), str(working_dir.resolve())):
|
|
657
|
+
folder = base / "projects" / re.sub(r"[/._]", "-", spelled)
|
|
658
|
+
if folder not in out:
|
|
659
|
+
out.append(folder)
|
|
660
|
+
return out
|
|
661
|
+
|
|
563
662
|
def __post_init__(self) -> None:
|
|
564
663
|
# Only auto-resolve the bare default. Absolute paths, relative paths
|
|
565
664
|
# (resolved vs. working_dir by the child), and custom bare names pass
|
|
@@ -148,6 +148,7 @@ def emit_outcome(
|
|
|
148
148
|
kind = _OUTCOME_KINDS[f["status"]]
|
|
149
149
|
exit_code = f.get("exit_code")
|
|
150
150
|
question = f.get("question")
|
|
151
|
+
stuck_reason = f.get("stuck_reason")
|
|
151
152
|
fields = {
|
|
152
153
|
"repo": f.get("repo"),
|
|
153
154
|
"issue_number": f.get("issue_number"),
|
|
@@ -167,6 +168,9 @@ def emit_outcome(
|
|
|
167
168
|
),
|
|
168
169
|
},
|
|
169
170
|
}
|
|
171
|
+
# Only on a run the relay stopped as stuck (#224).
|
|
172
|
+
if isinstance(stuck_reason, str) and stuck_reason:
|
|
173
|
+
fields["detail"]["stuck"] = stuck_reason[:QUESTION_DETAIL_CHARS]
|
|
170
174
|
except Exception as e: # noqa: BLE001 - best-effort by design
|
|
171
175
|
_log_failure("<outcome>", e)
|
|
172
176
|
return
|