ctrlrelay 0.19.1__tar.gz → 0.20.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.19.1 → ctrlrelay-0.20.0}/CHANGELOG.md +49 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/PKG-INFO +1 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/configuration.md +25 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/pyproject.toml +1 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/cli.py +22 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/config.py +9 -2
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/dispatcher.py +4 -0
- ctrlrelay-0.20.0/src/ctrlrelay/core/orphans.py +388 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/state.py +65 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/worktree.py +319 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/base.py +5 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/dev.py +653 -136
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/secops.py +10 -3
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/task.py +10 -3
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/fleet.py +5 -0
- ctrlrelay-0.20.0/src/ctrlrelay/web/links.py +74 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/static/index.html +42 -12
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_start.py +54 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_integration.py +4 -0
- ctrlrelay-0.20.0/tests/test_dev_plan_step.py +395 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_step_switches.py +0 -1
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_work_steps.py +5 -2
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dispatcher.py +2 -0
- ctrlrelay-0.20.0/tests/test_orphans.py +564 -0
- ctrlrelay-0.20.0/tests/test_timeout_save.py +419 -0
- ctrlrelay-0.20.0/tests/test_web_peer_links.py +99 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_server.py +66 -3
- ctrlrelay-0.19.1/src/ctrlrelay/web/links.py +0 -35
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.gitignore +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/LICENSE +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/README.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/SECURITY.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/cli.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/development.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/index.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/operations.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.0] - 2026-11-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `repos[].steps.plan` (`off`, `on`, `required`; unset is `off`) runs a
|
|
15
|
+
planning session before the dev pipeline's code session. It changes no
|
|
16
|
+
files and writes a plan with numbered acceptance criteria, saved under
|
|
17
|
+
`<paths.contexts>/<owner>-<repo>/plans/issue-<n>.md` (outside the worktree),
|
|
18
|
+
posted on the issue as the criteria list, and added to the code session's
|
|
19
|
+
prompt. A vague issue gets one question instead of a pull request. A plan
|
|
20
|
+
without criteria is a failed plan: `on` reports it and codes without one,
|
|
21
|
+
`required` fails the run (#220).
|
|
22
|
+
- On the status page, a peer machine's name links to that machine's own
|
|
23
|
+
page: in the machine strip, in "Who has what" and on a "Needs you" card.
|
|
24
|
+
The address is the `fleet.peers` entry from this machine's config, cut to
|
|
25
|
+
`<scheme>://<host>[:port]/`; nothing a peer answers changes it, and an
|
|
26
|
+
entry with userinfo or a non-http scheme gives plain text (#319).
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A run whose relay process died mid-run (the machine ran out of memory, or
|
|
31
|
+
the service was stopped) no longer stays `running` for ever. Sessions now
|
|
32
|
+
record the process running them (`owner_pid`, `owner_run`) and their claim
|
|
33
|
+
id. When the poller starts, it finds `running` sessions whose process is
|
|
34
|
+
gone, and in whose worktree no process is still working, and marks them
|
|
35
|
+
failed, dropping any repo lock they held. A dev or task issue goes back to
|
|
36
|
+
the next poll unless an open pull request was found for its branch. Where
|
|
37
|
+
the run had claimed the issue, the relay posts a comment that names the
|
|
38
|
+
worktree and releases the claim. The relay deletes nothing in the
|
|
39
|
+
worktree. It detaches the worktree's HEAD, when that can be done safely,
|
|
40
|
+
so the next run can check the issue's branch out. Rows written by an older
|
|
41
|
+
version name no process and are left alone (#312).
|
|
42
|
+
- A dev run whose agent session hits its time limit no longer throws away
|
|
43
|
+
the agent's work. Before the worktree is removed, the files on disk are
|
|
44
|
+
committed on top of the commit it sits on, and that commit, or the
|
|
45
|
+
unpushed commits alone, are pushed to a new `wip/issue-<n>-<run id>`
|
|
46
|
+
branch. The run's own branch is not pushed, no pull request is opened, and
|
|
47
|
+
the local run branch is kept, since it can hold commits the worktree's
|
|
48
|
+
HEAD does not reach. The failure comment on the issue names the wip branch
|
|
49
|
+
and, when it is known, how long the run took. If the work cannot be saved,
|
|
50
|
+
the worktree is kept on disk and the comment names it. A run with nothing
|
|
51
|
+
new loses only its worktree. Files the worktree's `.gitignore` ignores are
|
|
52
|
+
not saved; files that cannot be read are not saved either, and then the
|
|
53
|
+
worktree is kept and the comment says so. The files are read by a git with
|
|
54
|
+
a private git dir, index, config and attributes override, so filters,
|
|
55
|
+
hooks and attributes named by the agent do not take effect (#311).
|
|
56
|
+
|
|
10
57
|
## [0.19.1] - 2026-11-03
|
|
11
58
|
|
|
12
59
|
### Fixed
|
|
@@ -2266,7 +2313,8 @@ pipeline).
|
|
|
2266
2313
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2267
2314
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2268
2315
|
|
|
2269
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2316
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...HEAD
|
|
2317
|
+
[0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
|
|
2270
2318
|
[0.19.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...v0.19.1
|
|
2271
2319
|
[0.19.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...v0.19.0
|
|
2272
2320
|
[0.18.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...v0.18.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.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/
|
|
@@ -865,7 +865,8 @@ repos:
|
|
|
865
865
|
|
|
866
866
|
### repos[].steps
|
|
867
867
|
|
|
868
|
-
The dev pipeline does its work in a fixed order: **
|
|
868
|
+
The dev pipeline does its work in a fixed order: **plan** (an optional
|
|
869
|
+
planning session, off unless asked for), **code** (the agent run and
|
|
869
870
|
any questions it asks), **publish** (the relay pushes the branch and opens the
|
|
870
871
|
pull request), **verify** (wait for CI and mergeability, asking the agent to
|
|
871
872
|
fix what fails, up to `max_fix_attempts` rounds) and **review** (the
|
|
@@ -893,6 +894,7 @@ repos:
|
|
|
893
894
|
|
|
894
895
|
| Step | Accepts | Unset means |
|
|
895
896
|
|---|---|---|
|
|
897
|
+
| `plan` | `off`, `on`, `required` | `off`: no planning session. |
|
|
896
898
|
| `code` | `required` | A failed or crashed agent run stops the run. Same as `required`. |
|
|
897
899
|
| `publish` | `required` | A refused or failed push stops the run (the branch is kept). Same as `required`. |
|
|
898
900
|
| `verify` | `off`, `required` | CI still red after the fix rounds stops the run. CI that is still pending when the wait times out hands the PR over. |
|
|
@@ -905,6 +907,28 @@ PR as ready while its CI is red, with no line in the announcement saying so.
|
|
|
905
907
|
Unknown step names (a typo such as `verfy`) are refused as well. A bare `on`
|
|
906
908
|
or `off` works unquoted.
|
|
907
909
|
|
|
910
|
+
**`plan: on`** or **`plan: required`** runs one planning session in the
|
|
911
|
+
run's worktree before the code session. It is told to read the issue and the
|
|
912
|
+
repo and change no files, and returns a plan with four headings: `## Change`,
|
|
913
|
+
`## Files`, `## Risks` and `## Acceptance criteria`, a numbered list of items
|
|
914
|
+
that can each be checked by a command or by reading the diff. The relay then:
|
|
915
|
+
|
|
916
|
+
- saves the plan to `<paths.contexts>/<owner>-<repo>/plans/issue-<n>.md`,
|
|
917
|
+
outside the worktree, so it is never committed;
|
|
918
|
+
- posts the criteria list as one comment on the issue;
|
|
919
|
+
- adds the plan to the code session's prompt under `## Plan`, telling the
|
|
920
|
+
agent the criteria are what "done" means.
|
|
921
|
+
|
|
922
|
+
If the issue is too vague to plan, the planning session asks one question,
|
|
923
|
+
through the same chat loop the code session uses. No code session runs and no
|
|
924
|
+
PR is opened until it is answered; an answer that arrives after the run
|
|
925
|
+
stopped resumes the planning session, then codes with the plan.
|
|
926
|
+
|
|
927
|
+
A plan with no numbered acceptance criteria is a failed plan, as is a
|
|
928
|
+
planning session that fails or crashes. With `on` the failure is posted on the
|
|
929
|
+
issue and the code session runs without a plan; with `required` the run fails
|
|
930
|
+
and no code session runs.
|
|
931
|
+
|
|
908
932
|
**`verify: required`** also fails the run when the CI wait times out, before
|
|
909
933
|
or after a fix round. (Checks that cannot be read at all fail the run in every
|
|
910
934
|
mode that waits on CI, not only under `required`.)
|
|
@@ -4203,6 +4203,28 @@ def poller_start(
|
|
|
4203
4203
|
skip_initial_persist=True,
|
|
4204
4204
|
)
|
|
4205
4205
|
|
|
4206
|
+
# Runs whose process died with them (#312): settle them before
|
|
4207
|
+
# the first poll, so the issues they held are taken again by
|
|
4208
|
+
# that poll rather than skipped as already seen.
|
|
4209
|
+
from ctrlrelay.core.orphans import reap_orphaned_sessions
|
|
4210
|
+
reaped = await reap_orphaned_sessions(
|
|
4211
|
+
state_db, poller=poller, github=github, worktree=worktree,
|
|
4212
|
+
)
|
|
4213
|
+
for r in reaped:
|
|
4214
|
+
where = (
|
|
4215
|
+
f"#{r['issue_number']} in {r['repo']}"
|
|
4216
|
+
if r["issue_number"] is not None else r["repo"]
|
|
4217
|
+
)
|
|
4218
|
+
kept = (
|
|
4219
|
+
f" Its worktree is kept at {r['worktree_path']}."
|
|
4220
|
+
if r["worktree_path"]
|
|
4221
|
+
and Path(r["worktree_path"]).exists() else ""
|
|
4222
|
+
)
|
|
4223
|
+
console.print(
|
|
4224
|
+
f"[yellow]Run {r['session_id']} for {where} stopped "
|
|
4225
|
+
f"when the relay did; marked failed.{kept}[/yellow]"
|
|
4226
|
+
)
|
|
4227
|
+
|
|
4206
4228
|
try:
|
|
4207
4229
|
await github_budget.refresh()
|
|
4208
4230
|
await run_poll_loop(
|
|
@@ -507,6 +507,9 @@ _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
|
507
507
|
# reads this table as well, so a caller that bypasses the config cannot
|
|
508
508
|
# get a mode accepted and ignored.
|
|
509
509
|
DEV_STEP_MODES: dict[str, tuple[str, ...]] = {
|
|
510
|
+
# ``plan`` (#220) runs before ``code``. Unset is ``off``: a repo that
|
|
511
|
+
# does not ask for a plan runs exactly as it did before the step.
|
|
512
|
+
"plan": ("off", "on", "required"),
|
|
510
513
|
"code": ("required",),
|
|
511
514
|
"publish": ("required",),
|
|
512
515
|
"verify": ("off", "required"),
|
|
@@ -514,6 +517,7 @@ DEV_STEP_MODES: dict[str, tuple[str, ...]] = {
|
|
|
514
517
|
}
|
|
515
518
|
|
|
516
519
|
_STEP_REFUSALS: dict[str, str] = {
|
|
520
|
+
"plan": "",
|
|
517
521
|
"code": (
|
|
518
522
|
"a dev run without the agent run has nothing to publish"
|
|
519
523
|
),
|
|
@@ -567,7 +571,7 @@ class DevStepsConfig(BaseModel):
|
|
|
567
571
|
``verify`` stops it when CI is still red after the fix rounds, but
|
|
568
572
|
hands the pull request over when the CI wait times out; ``review``
|
|
569
573
|
reports a review that did not post and hands the pull request over
|
|
570
|
-
anyway (``on``).
|
|
574
|
+
anyway (``on``); ``plan`` does not run (``off``).
|
|
571
575
|
|
|
572
576
|
Unknown keys are refused, so a typo such as ``verfy: required``
|
|
573
577
|
cannot load as "unset".
|
|
@@ -575,12 +579,15 @@ class DevStepsConfig(BaseModel):
|
|
|
575
579
|
|
|
576
580
|
model_config = {"extra": "forbid"}
|
|
577
581
|
|
|
582
|
+
plan: str | None = None
|
|
578
583
|
code: str | None = None
|
|
579
584
|
publish: str | None = None
|
|
580
585
|
verify: str | None = None
|
|
581
586
|
review: str | None = None
|
|
582
587
|
|
|
583
|
-
@field_validator(
|
|
588
|
+
@field_validator(
|
|
589
|
+
"plan", "code", "publish", "verify", "review", mode="before"
|
|
590
|
+
)
|
|
584
591
|
@classmethod
|
|
585
592
|
def _known_mode(cls, v: Any, info: Any) -> str | None:
|
|
586
593
|
return check_dev_step_mode(info.field_name, v)
|
|
@@ -133,6 +133,9 @@ class SessionResult:
|
|
|
133
133
|
stdout: str = ""
|
|
134
134
|
stderr: str = ""
|
|
135
135
|
agent_session_id: str | None = None
|
|
136
|
+
# The relay killed the agent at its time limit (#311). Set only by the
|
|
137
|
+
# relay's own timeout path, never parsed from anything the agent wrote.
|
|
138
|
+
timed_out: bool = False
|
|
136
139
|
|
|
137
140
|
@property
|
|
138
141
|
def success(self) -> bool:
|
|
@@ -352,6 +355,7 @@ class CliAgentAdapter:
|
|
|
352
355
|
exit_code=-1,
|
|
353
356
|
state=None,
|
|
354
357
|
stderr="Session timed out",
|
|
358
|
+
timed_out=True,
|
|
355
359
|
)
|
|
356
360
|
except asyncio.CancelledError as e:
|
|
357
361
|
# Scheduler/shutdown cancel: kill the child BEFORE re-raising
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Runs whose process died without settling them (#312).
|
|
2
|
+
|
|
3
|
+
A session's ``sessions`` row is moved off ``running`` only by the process
|
|
4
|
+
running it. When that process dies first (the kernel's out-of-memory
|
|
5
|
+
killer, a ``systemctl stop`` that takes the whole unit, a power cut), the
|
|
6
|
+
row says ``running`` for ever: the status page lists it as running, the
|
|
7
|
+
issue stays in the poller's ``seen_issues`` so nothing looks at it again,
|
|
8
|
+
its claim reads as working, and a repo lock it held can wedge the repo.
|
|
9
|
+
|
|
10
|
+
:func:`reap_orphaned_sessions` runs once when the poller starts, before
|
|
11
|
+
the first poll, and settles each such row: failed, lock dropped, issue
|
|
12
|
+
handed back to the poller, claim released, worktree kept and named.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from ctrlrelay.core.claims import PROCESS_RUN
|
|
23
|
+
from ctrlrelay.core.obs import get_logger, log_event
|
|
24
|
+
|
|
25
|
+
_logger = get_logger("core.orphans")
|
|
26
|
+
|
|
27
|
+
# Pipelines whose issue goes back to the poller when their run died. A
|
|
28
|
+
# secops sweep has no issue; it runs again on its own schedule.
|
|
29
|
+
_ISSUE_PIPELINES = frozenset({"dev", "task"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _command_line(pid: int) -> list[str] | None:
|
|
33
|
+
proc = Path("/proc")
|
|
34
|
+
if proc.is_dir():
|
|
35
|
+
try:
|
|
36
|
+
raw = (proc / str(pid) / "cmdline").read_bytes()
|
|
37
|
+
except OSError:
|
|
38
|
+
return None
|
|
39
|
+
return [a.decode("utf-8", "replace") for a in raw.split(b"\0") if a]
|
|
40
|
+
import subprocess
|
|
41
|
+
try:
|
|
42
|
+
out = subprocess.run(
|
|
43
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
44
|
+
capture_output=True, text=True, timeout=5,
|
|
45
|
+
)
|
|
46
|
+
except (OSError, subprocess.SubprocessError):
|
|
47
|
+
return None
|
|
48
|
+
if out.returncode != 0:
|
|
49
|
+
return None
|
|
50
|
+
return out.stdout.split()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def owner_is_alive(pid: int) -> bool:
|
|
54
|
+
"""Whether the process that owns a session may still be running it.
|
|
55
|
+
|
|
56
|
+
Errs towards alive, because the mistake in that direction leaves a
|
|
57
|
+
row as it is today, while the other one fails a live run under its
|
|
58
|
+
own feet. So: a process that exists and runs ctrlrelay, or whose
|
|
59
|
+
command line cannot be read, is alive. Gone, or reused by a program
|
|
60
|
+
that is not ctrlrelay, is dead.
|
|
61
|
+
|
|
62
|
+
This process's own pid is dead here. The caller has already kept
|
|
63
|
+
every row this process wrote, by its run id (``owner_run``), so a row
|
|
64
|
+
that names this pid with another run id was written by an earlier
|
|
65
|
+
process whose pid was handed on to this one, as happens to pid 1 in a
|
|
66
|
+
container on every restart.
|
|
67
|
+
"""
|
|
68
|
+
if pid <= 0 or pid == os.getpid():
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
os.kill(pid, 0)
|
|
72
|
+
except ProcessLookupError:
|
|
73
|
+
return False
|
|
74
|
+
except PermissionError:
|
|
75
|
+
# Exists, owned by another user: not ours, but not provably
|
|
76
|
+
# dead either.
|
|
77
|
+
return True
|
|
78
|
+
except OSError:
|
|
79
|
+
return True
|
|
80
|
+
args = _command_line(pid)
|
|
81
|
+
if args is None:
|
|
82
|
+
# Exited between the two checks, or unreadable. A vanished
|
|
83
|
+
# process reads as `None` too, so look once more.
|
|
84
|
+
try:
|
|
85
|
+
os.kill(pid, 0)
|
|
86
|
+
except ProcessLookupError:
|
|
87
|
+
return False
|
|
88
|
+
except OSError:
|
|
89
|
+
return True
|
|
90
|
+
return True
|
|
91
|
+
# The console script, or `python -m ctrlrelay...`. Not a substring
|
|
92
|
+
# test: the interpreter of a uv tool install lives under a directory
|
|
93
|
+
# named ctrlrelay, so any Python program run with it would match.
|
|
94
|
+
for i, arg in enumerate(args):
|
|
95
|
+
if os.path.basename(arg) == "ctrlrelay":
|
|
96
|
+
return True
|
|
97
|
+
if (arg == "ctrlrelay" or arg.startswith("ctrlrelay.")) and (
|
|
98
|
+
i > 0 and args[i - 1] == "-m"
|
|
99
|
+
):
|
|
100
|
+
return True
|
|
101
|
+
if arg == "-mctrlrelay" or arg.startswith("-mctrlrelay."):
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def worktree_in_use(path: str) -> bool | None:
|
|
107
|
+
"""Whether a live process of this user has its working directory
|
|
108
|
+
inside ``path``: ``True``, ``False``, or ``None`` when that cannot be
|
|
109
|
+
told.
|
|
110
|
+
|
|
111
|
+
The relay process can die alone (``kill -9``, the OOM killer choosing
|
|
112
|
+
it) while the agent it started keeps running, because the agent is
|
|
113
|
+
in the relay's process group, not under it. Such a run is not dead,
|
|
114
|
+
and settling it would detach the worktree under a live agent and
|
|
115
|
+
start a second run on the same issue. The agent works in its
|
|
116
|
+
worktree, as the relay's own user, so such a process there is the
|
|
117
|
+
sign. Processes of other users are not looked at: an agent never
|
|
118
|
+
runs as another user, and their working directories are not
|
|
119
|
+
readable anyway.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
root = os.path.realpath(path)
|
|
123
|
+
except OSError:
|
|
124
|
+
return None
|
|
125
|
+
prefix = root.rstrip(os.sep) + os.sep
|
|
126
|
+
uid = os.getuid()
|
|
127
|
+
proc = Path("/proc")
|
|
128
|
+
if proc.is_dir():
|
|
129
|
+
try:
|
|
130
|
+
entries = [e for e in os.listdir(proc) if e.isdigit()]
|
|
131
|
+
except OSError:
|
|
132
|
+
return None
|
|
133
|
+
# This process is of this user and has a working directory, so a
|
|
134
|
+
# scan that read none did not work, whatever it returned.
|
|
135
|
+
read_any = False
|
|
136
|
+
for pid in entries:
|
|
137
|
+
try:
|
|
138
|
+
if os.stat(f"/proc/{pid}").st_uid != uid:
|
|
139
|
+
continue
|
|
140
|
+
cwd = os.readlink(f"/proc/{pid}/cwd")
|
|
141
|
+
read_any = True
|
|
142
|
+
except OSError:
|
|
143
|
+
# Exited while being looked at, or a process of ours that
|
|
144
|
+
# made itself non-dumpable (systemd --user, ssh-agent).
|
|
145
|
+
# An agent is neither: its working directory reads,
|
|
146
|
+
# inside the bwrap sandbox too.
|
|
147
|
+
continue
|
|
148
|
+
if cwd == root or cwd.startswith(prefix):
|
|
149
|
+
return True
|
|
150
|
+
return False if read_any else None
|
|
151
|
+
import shutil
|
|
152
|
+
import subprocess
|
|
153
|
+
lsof = shutil.which("lsof")
|
|
154
|
+
if lsof is None:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
out = subprocess.run(
|
|
158
|
+
[lsof, "-w", "-n", "-P", "-a", "-u", str(uid), "-d", "cwd",
|
|
159
|
+
"-F", "n"],
|
|
160
|
+
capture_output=True, text=True, timeout=20,
|
|
161
|
+
)
|
|
162
|
+
except (OSError, subprocess.SubprocessError):
|
|
163
|
+
return None
|
|
164
|
+
if out.returncode not in (0, 1):
|
|
165
|
+
return None
|
|
166
|
+
cwds = [line[1:] for line in out.stdout.splitlines() if line.startswith("n")]
|
|
167
|
+
if not cwds:
|
|
168
|
+
# Not even this process's own: lsof did not answer.
|
|
169
|
+
return None
|
|
170
|
+
return any(cwd == root or cwd.startswith(prefix) for cwd in cwds)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def orphan_reason(row: dict[str, Any], branch: str | None) -> str:
|
|
174
|
+
"""The ``Reason:`` line of the issue comment for a dead run.
|
|
175
|
+
|
|
176
|
+
Says only what was checked: where the files are when the worktree was
|
|
177
|
+
found, and that the branch was freed only when it was.
|
|
178
|
+
"""
|
|
179
|
+
where = row.get("worktree_path")
|
|
180
|
+
if where and Path(where).exists():
|
|
181
|
+
kept = (
|
|
182
|
+
f"Its worktree is still on that machine at `{where}`"
|
|
183
|
+
+ (f", detached from `{branch}`" if branch else "")
|
|
184
|
+
+ ", and the relay deleted nothing in it."
|
|
185
|
+
)
|
|
186
|
+
elif where:
|
|
187
|
+
kept = f"Its worktree `{where}` was not found on that machine."
|
|
188
|
+
else:
|
|
189
|
+
kept = "No worktree was recorded for it."
|
|
190
|
+
return (
|
|
191
|
+
"the relay process running it stopped before the run ended "
|
|
192
|
+
"(for example the machine ran out of memory, or the service was "
|
|
193
|
+
f"restarted). {kept}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def _open_pr_for(github: Any, repo: str, branch: str | None) -> int | None:
|
|
198
|
+
"""The same-repo open pull request for ``branch``, or ``None`` when
|
|
199
|
+
none was found: there is none, the branch is unknown (no worktree to
|
|
200
|
+
read it from), or the lookup failed."""
|
|
201
|
+
if not branch:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
prs = await github.list_prs(repo, state="open", head=branch, timeout=30)
|
|
205
|
+
except asyncio.CancelledError:
|
|
206
|
+
raise
|
|
207
|
+
except Exception as e:
|
|
208
|
+
log_event(
|
|
209
|
+
_logger, "orphans.pr_lookup_failed", repo=repo, branch=branch,
|
|
210
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
211
|
+
)
|
|
212
|
+
return None
|
|
213
|
+
owner, _, name = repo.partition("/")
|
|
214
|
+
for pr in prs or []:
|
|
215
|
+
head_owner = ((pr.get("headRepositoryOwner") or {}).get("login") or "")
|
|
216
|
+
head_name = ((pr.get("headRepository") or {}).get("name") or "")
|
|
217
|
+
if head_owner.lower() == owner.lower() and head_name.lower() == name.lower():
|
|
218
|
+
return pr.get("number")
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def reap_orphaned_sessions(
|
|
223
|
+
state_db: Any,
|
|
224
|
+
*,
|
|
225
|
+
poller: Any,
|
|
226
|
+
github: Any,
|
|
227
|
+
worktree: Any,
|
|
228
|
+
is_alive: Callable[[int], bool] = owner_is_alive,
|
|
229
|
+
in_use: Callable[[str], bool | None] = worktree_in_use,
|
|
230
|
+
) -> list[dict[str, Any]]:
|
|
231
|
+
"""Settle every ``running`` session whose owning process is gone.
|
|
232
|
+
|
|
233
|
+
For each one:
|
|
234
|
+
|
|
235
|
+
- the row becomes ``failed`` with a summary saying why, and any repo
|
|
236
|
+
lock it held is dropped (one transaction, see
|
|
237
|
+
:meth:`StateDB.fail_orphaned_session`);
|
|
238
|
+
- its worktree, when there is one, is kept, and its HEAD detached
|
|
239
|
+
from the issue's branch when that can be done safely, so the next
|
|
240
|
+
run for the issue can check that branch out;
|
|
241
|
+
- a dev or task issue is removed from ``seen_issues`` so the next
|
|
242
|
+
poll takes it again, unless an open pull request was found for the
|
|
243
|
+
branch: then the pull request is the outcome and the issue stays
|
|
244
|
+
seen. When the lookup could not be made, the issue is handed back
|
|
245
|
+
anyway; the next run refuses a branch that backs an open pull
|
|
246
|
+
request on its own (``create_worktree_with_new_branch``);
|
|
247
|
+
- a comment on the issue names the worktree, and releases this
|
|
248
|
+
machine's claim by id where the row has one. A task run on a
|
|
249
|
+
single machine claims nothing and gets no comment.
|
|
250
|
+
|
|
251
|
+
Left alone: rows this process wrote (``owner_run``; a scheduled
|
|
252
|
+
sweep can start before this runs), rows whose owner is alive, rows
|
|
253
|
+
whose worktree a live process is still working in (the agent outlives
|
|
254
|
+
a relay killed on its own; see :func:`worktree_in_use`), and rows with
|
|
255
|
+
no ``owner_pid``. Those come from a version before #312 and name no
|
|
256
|
+
process, and could as well be a ``ctrlrelay run dev`` started by hand
|
|
257
|
+
and still running.
|
|
258
|
+
|
|
259
|
+
The row update comes first, so no later step touches a row something
|
|
260
|
+
else settled, and each step after it is best-effort. The cost is a
|
|
261
|
+
narrow gap: if this process dies between the update and the
|
|
262
|
+
hand-back, that row is no longer running and no later start looks at
|
|
263
|
+
it again. Returns one dict per row settled.
|
|
264
|
+
"""
|
|
265
|
+
from ctrlrelay.pipelines.dev import reconcile_claim_on_failure
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
rows = state_db.list_running_sessions()
|
|
269
|
+
except Exception as e:
|
|
270
|
+
log_event(
|
|
271
|
+
_logger, "orphans.list_failed",
|
|
272
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
273
|
+
)
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
settled: list[dict[str, Any]] = []
|
|
277
|
+
for row in rows:
|
|
278
|
+
session_id = row["id"]
|
|
279
|
+
pid = row.get("owner_pid")
|
|
280
|
+
if row.get("owner_run") == PROCESS_RUN:
|
|
281
|
+
continue
|
|
282
|
+
if pid is None:
|
|
283
|
+
log_event(
|
|
284
|
+
_logger, "orphans.owner_unknown", session_id=session_id,
|
|
285
|
+
repo=row.get("repo"), issue_number=row.get("issue_number"),
|
|
286
|
+
operator_action=(
|
|
287
|
+
"this row was written before ctrlrelay recorded which "
|
|
288
|
+
"process runs a session; if no run is live, set its "
|
|
289
|
+
"status to failed in state.db by hand"
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
if is_alive(int(pid)):
|
|
294
|
+
continue
|
|
295
|
+
worktree_path = row.get("worktree_path")
|
|
296
|
+
if worktree_path and Path(worktree_path).exists():
|
|
297
|
+
busy = in_use(worktree_path)
|
|
298
|
+
if busy is not False:
|
|
299
|
+
# In use, or could not be told: errs towards alive, as
|
|
300
|
+
# `owner_is_alive` does.
|
|
301
|
+
log_event(
|
|
302
|
+
_logger,
|
|
303
|
+
"orphans.worktree_in_use" if busy
|
|
304
|
+
else "orphans.worktree_use_unknown",
|
|
305
|
+
session_id=session_id,
|
|
306
|
+
repo=row.get("repo"), worktree_path=worktree_path,
|
|
307
|
+
operator_action=(
|
|
308
|
+
"the relay process that ran this session is gone, "
|
|
309
|
+
"but a process may still be working in its "
|
|
310
|
+
"worktree, so it is left as running. Stop that "
|
|
311
|
+
"process (or leave that directory) and restart the "
|
|
312
|
+
"poller to settle it."
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
repo = row["repo"]
|
|
318
|
+
issue_number = row.get("issue_number")
|
|
319
|
+
pipeline = row.get("pipeline")
|
|
320
|
+
|
|
321
|
+
summary = "Run stopped: " + orphan_reason(row, None)
|
|
322
|
+
try:
|
|
323
|
+
changed = state_db.fail_orphaned_session(session_id, summary)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
log_event(
|
|
326
|
+
_logger, "orphans.update_failed", session_id=session_id,
|
|
327
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
328
|
+
)
|
|
329
|
+
continue
|
|
330
|
+
if not changed:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# After the row, not before: a row something else settled in the
|
|
334
|
+
# meantime is not ours to touch, and neither is its worktree.
|
|
335
|
+
branch: str | None = None
|
|
336
|
+
if worktree_path and Path(worktree_path).exists():
|
|
337
|
+
try:
|
|
338
|
+
branch = worktree.detach_orphaned_worktree(repo, worktree_path)
|
|
339
|
+
except Exception as e:
|
|
340
|
+
log_event(
|
|
341
|
+
_logger, "orphans.detach_failed", session_id=session_id,
|
|
342
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
pr_number: int | None = None
|
|
346
|
+
handed_back = False
|
|
347
|
+
if issue_number is not None and pipeline in _ISSUE_PIPELINES:
|
|
348
|
+
pr_number = await _open_pr_for(github, repo, branch)
|
|
349
|
+
if pr_number is None:
|
|
350
|
+
poller.unmark_seen(repo, int(issue_number))
|
|
351
|
+
handed_back = True
|
|
352
|
+
claim_id = row.get("claim_id")
|
|
353
|
+
# A dev run always claims before its row exists. A task run
|
|
354
|
+
# claims only on a multi-machine setup, and posts nothing
|
|
355
|
+
# otherwise, so there is nothing of its to retract then.
|
|
356
|
+
if pipeline == "dev" or claim_id:
|
|
357
|
+
await reconcile_claim_on_failure(
|
|
358
|
+
github,
|
|
359
|
+
repo=repo,
|
|
360
|
+
issue_number=int(issue_number),
|
|
361
|
+
session_id=session_id,
|
|
362
|
+
reason=orphan_reason(row, branch),
|
|
363
|
+
exit_code=row.get("claude_exit_code"),
|
|
364
|
+
pr_number=pr_number,
|
|
365
|
+
claim_id=claim_id,
|
|
366
|
+
will_retry=handed_back,
|
|
367
|
+
# Only open pull requests were looked for, so "none
|
|
368
|
+
# was opened" is never known: one may have been
|
|
369
|
+
# closed or merged.
|
|
370
|
+
pr_known=False,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
log_event(
|
|
374
|
+
_logger, "orphans.reaped", session_id=session_id, repo=repo,
|
|
375
|
+
issue_number=issue_number, pipeline=pipeline, owner_pid=pid,
|
|
376
|
+
worktree_path=worktree_path, branch_freed=branch,
|
|
377
|
+
pr_number=pr_number, handed_back=handed_back,
|
|
378
|
+
)
|
|
379
|
+
settled.append({
|
|
380
|
+
"session_id": session_id,
|
|
381
|
+
"repo": repo,
|
|
382
|
+
"issue_number": issue_number,
|
|
383
|
+
"worktree_path": worktree_path,
|
|
384
|
+
"branch_freed": branch,
|
|
385
|
+
"pr_number": pr_number,
|
|
386
|
+
"handed_back": handed_back,
|
|
387
|
+
})
|
|
388
|
+
return settled
|