ctrlrelay 0.16.3__tar.gz → 0.16.4__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.16.3 → ctrlrelay-0.16.4}/CHANGELOG.md +34 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/PKG-INFO +1 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/configuration.md +15 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/operations.md +24 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/pyproject.toml +1 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/cli.py +134 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/poller.py +60 -7
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/worktree.py +185 -12
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/dev.py +126 -4
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_agent_no_keys.py +205 -0
- ctrlrelay-0.16.4/tests/test_publish_failure_retry.py +438 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_worktree.py +206 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.gitignore +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/LICENSE +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/README.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/SECURITY.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/Gemfile +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/_config.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/architecture.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/bridge.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/cli.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/development.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/getting-started.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/index.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/personalization.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/conftest.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_archived.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_audit.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_config.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_events.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_github.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_install.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_network.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_obs.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_poller.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_redact.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_setup.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_state.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_transport.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_usage.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_waiting.py +0 -0
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.4] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
Fixes for runs whose work could not be published. **No config edit is
|
|
13
|
+
required.** `poller_state.json` gains a `publish_failures` count, added
|
|
14
|
+
automatically.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- A failed publish no longer strands the issue. `poll()` marks an issue seen
|
|
19
|
+
before handing it to the handler, so a run whose agent finished but whose
|
|
20
|
+
branch the relay could not publish stayed in `seen_issues` forever: the
|
|
21
|
+
claim was retracted, the automation label was still on it, and the relay
|
|
22
|
+
never came back. The issue is now un-marked for the next poll, up to 3
|
|
23
|
+
runs, counted in `publish_failures` in `poller_state.json` (saved together
|
|
24
|
+
with the un-mark) so a restart does not reset the limit. After the third
|
|
25
|
+
the relay says on the issue that it has stopped, with the error scrubbed,
|
|
26
|
+
and says so in chat if that comment cannot be posted. A *refused* push is
|
|
27
|
+
not retried at all: the relay reaches the same refusal every time (#278,
|
|
28
|
+
closes #268)
|
|
29
|
+
- The retraction comment no longer advises a retry that cannot work. It said
|
|
30
|
+
to re-assign the issue or re-apply its automation label, but the skip
|
|
31
|
+
decision reads the issue number alone, so neither did anything. It now
|
|
32
|
+
says that while the poller is running the relay tries again a limited
|
|
33
|
+
number of times and says so if it stops, and otherwise names the one route
|
|
34
|
+
that works (#278, closes #268)
|
|
35
|
+
- A failed push says why. The rejected ref and git's reason (a stale lease,
|
|
36
|
+
a non-fast-forward, a protection rule) lead the error, the remote's own
|
|
37
|
+
`remote:` lines follow, and the reason reaches `dev.publish.failed` as its
|
|
38
|
+
own `push_rejection` field. Before, only git's summary line naming the URL
|
|
39
|
+
survived. Every failing git command now reports both of its output
|
|
40
|
+
streams, scrubbed of credential shapes (#277, closes #269)
|
|
41
|
+
|
|
10
42
|
## [0.16.3] - 2026-11-01
|
|
11
43
|
|
|
12
44
|
A security fix: **upgrade every machine.** No config edit is required.
|
|
@@ -2016,7 +2048,8 @@ pipeline).
|
|
|
2016
2048
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2017
2049
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2018
2050
|
|
|
2019
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.
|
|
2051
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...HEAD
|
|
2052
|
+
[0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
|
|
2020
2053
|
[0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
|
|
2021
2054
|
[0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
|
|
2022
2055
|
[0.16.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.0...v0.16.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.4
|
|
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/
|
|
@@ -133,7 +133,21 @@ private git dir with hooks off and only ctrlrelay's own git config, pushes the
|
|
|
133
133
|
commit it checked rather than the ref name, and uses `--force-with-lease`
|
|
134
134
|
against the head this session last pushed (recorded in the state DB). An
|
|
135
135
|
open PR found for the branch is reused only if its head repo is this repo and
|
|
136
|
-
its base is the default branch.
|
|
136
|
+
its base is the default branch.
|
|
137
|
+
|
|
138
|
+
A push git rejects for a ref is a different thing, logged as
|
|
139
|
+
`publish.push.rejected` with the per-ref reason git gave. Some are decided by
|
|
140
|
+
git itself before the remote is asked (a stale lease, a non-fast-forward:
|
|
141
|
+
`[rejected]`), others by the remote (a branch-protection or repository-rule
|
|
142
|
+
refusal: `[remote rejected]`). That reason
|
|
143
|
+
comes from the `!` line of `git push --porcelain`, which git writes to
|
|
144
|
+
**stdout**; stderr carries only `error: failed to push some refs to '<url>'`.
|
|
145
|
+
Both streams of any failing git command now reach the error it raises (stderr
|
|
146
|
+
whole, stdout capped, both scrubbed of credential shapes), and the reason
|
|
147
|
+
reaches `dev.publish.failed` as its own `push_rejection` field, so a
|
|
148
|
+
rejection is not reported as a URL and nothing else.
|
|
149
|
+
|
|
150
|
+
Text the agent wrote is posted with `@`-mentions and
|
|
137
151
|
issue-closing keywords disabled and its length capped (20,000 characters);
|
|
138
152
|
the PR's own `Closes #N` is added by ctrlrelay.
|
|
139
153
|
|
|
@@ -727,6 +727,30 @@ file, so a restart does not reset it. Each retry posts a fresh claim
|
|
|
727
727
|
comment and, when it fails, a fresh "nothing is working on this" comment
|
|
728
728
|
on the issue.
|
|
729
729
|
|
|
730
|
+
A run whose **agent finished but whose branch the relay could not
|
|
731
|
+
publish** is retried the same way, for the same reason: the issue is
|
|
732
|
+
already in `seen_issues`, so without this nothing would ever bring it
|
|
733
|
+
back. Up to 3 runs in all, counted in `publish_failures` in the same
|
|
734
|
+
file, and the retraction comment on the issue says the relay will
|
|
735
|
+
return rather than asking you to re-assign it. After the third run the
|
|
736
|
+
issue stays in `seen_issues` and the relay says so **on the issue** —
|
|
737
|
+
the commits are still on the run branch in the local checkout (on
|
|
738
|
+
GitHub too, if only opening the pull request failed), and the fix is to
|
|
739
|
+
clear the cause and delete the number as above. If that comment cannot
|
|
740
|
+
be posted, the chat line says so.
|
|
741
|
+
|
|
742
|
+
A push the relay **refuses** (a protected or default branch, a base it
|
|
743
|
+
did not expect, somebody else's commits on the branch) is not retried
|
|
744
|
+
at all: the relay reaches the same refusal every time, so another run
|
|
745
|
+
would spend a whole agent to arrive at it. The issue stays in
|
|
746
|
+
`seen_issues` and the comment names what to do.
|
|
747
|
+
|
|
748
|
+
Re-assigning an issue, or re-applying its automation label, does **not**
|
|
749
|
+
make the poller look at it again. The skip decision reads the issue
|
|
750
|
+
number only — no assignee and no labels — so the number has to leave
|
|
751
|
+
`seen_issues` (and the relay has to be restarted, since the running
|
|
752
|
+
poller holds the set in memory and rewrites the file from it).
|
|
753
|
+
|
|
730
754
|
### A repo whose history was replaced
|
|
731
755
|
|
|
732
756
|
If the local copy's default branch can no longer fast-forward to GitHub's
|
|
@@ -1183,6 +1183,123 @@ def settle_pre_dispatch_failure(poller, repo: str, issue_number: int, result):
|
|
|
1183
1183
|
)
|
|
1184
1184
|
|
|
1185
1185
|
|
|
1186
|
+
# Runs of one issue whose branch the relay may fail to publish before
|
|
1187
|
+
# the poller stops retrying it (#268). Same shape and same reason as
|
|
1188
|
+
# PRE_DISPATCH_ATTEMPTS: a blip deserves another poll, a permanent cause
|
|
1189
|
+
# must not spend one full agent run per poll forever.
|
|
1190
|
+
PUBLISH_ATTEMPTS = 3
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def publish_failure_message(
|
|
1194
|
+
repo: str, issue_number: int, error: str, *, attempts: int, retrying: bool,
|
|
1195
|
+
) -> str:
|
|
1196
|
+
"""The chat line for a run whose branch the relay could not publish."""
|
|
1197
|
+
why = (error.strip().splitlines() or ["no error recorded"])[0][:200]
|
|
1198
|
+
if retrying:
|
|
1199
|
+
return (
|
|
1200
|
+
f"\u23f3 #{issue_number} in {repo}: the agent finished but the "
|
|
1201
|
+
f"relay could not publish the branch (try {attempts} of "
|
|
1202
|
+
f"{PUBLISH_ATTEMPTS}): {why}. Will retry on the next poll."
|
|
1203
|
+
)
|
|
1204
|
+
return (
|
|
1205
|
+
f"\u274c #{issue_number} in {repo}: could not publish the branch "
|
|
1206
|
+
f"{attempts} times; not retrying. Last error: {why}. The commits "
|
|
1207
|
+
"are on the run branch locally; fix the cause, then remove the "
|
|
1208
|
+
"issue number from seen_issues in poller_state.json to try again."
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
async def settle_publish_failure(
|
|
1213
|
+
github, poller, repo: str, issue_number: int, result,
|
|
1214
|
+
):
|
|
1215
|
+
"""Decide what a failed publish means for retrying its issue (#268).
|
|
1216
|
+
|
|
1217
|
+
`poll()` marks an issue seen BEFORE handing it to the handler, so a
|
|
1218
|
+
publish failure nobody un-marks drops the issue forever: the claim
|
|
1219
|
+
is retracted, the automation label is still on it, and the relay
|
|
1220
|
+
never comes back. Nothing in the logs says why, because from the
|
|
1221
|
+
relay's own point of view nothing is wrong.
|
|
1222
|
+
|
|
1223
|
+
So: the agent finished and only the publish failed, and the push was
|
|
1224
|
+
not refused -> count it, and below ``PUBLISH_ATTEMPTS`` un-mark the
|
|
1225
|
+
issue so the next poll runs it again. At the limit the issue stays
|
|
1226
|
+
seen, the count is dropped, and the relay says on the ISSUE that it
|
|
1227
|
+
has stopped — the retraction already there promised a retry, and a
|
|
1228
|
+
promise silently abandoned is the original defect one step on.
|
|
1229
|
+
|
|
1230
|
+
A refused push is not retried at all: the relay re-reaches that
|
|
1231
|
+
refusal every time, so another attempt costs a whole agent run to
|
|
1232
|
+
arrive at the same answer. Returns the chat line, or ``None`` for
|
|
1233
|
+
any result this is not about.
|
|
1234
|
+
|
|
1235
|
+
Writing the issue comment is best-effort: this runs after a run that
|
|
1236
|
+
has already failed, and a `gh` error here must not replace the run's
|
|
1237
|
+
real failure with a different one.
|
|
1238
|
+
"""
|
|
1239
|
+
from ctrlrelay.pipelines.dev import (
|
|
1240
|
+
publish_failure_is_retryable,
|
|
1241
|
+
publish_retries_exhausted_comment,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
outputs = result.outputs or {}
|
|
1245
|
+
if result.success or result.blocked or not outputs.get("publish_failed"):
|
|
1246
|
+
poller.clear_publish_failures(repo, issue_number)
|
|
1247
|
+
return None
|
|
1248
|
+
if not publish_failure_is_retryable(result):
|
|
1249
|
+
# Refused, not failed. Left seen deliberately; the pipeline's
|
|
1250
|
+
# retraction on the issue already names the operator action.
|
|
1251
|
+
poller.clear_publish_failures(repo, issue_number)
|
|
1252
|
+
log_event(
|
|
1253
|
+
_logger, "publish.refused_not_retried", repo=repo,
|
|
1254
|
+
issue_number=issue_number, error=(result.error or "")[:200],
|
|
1255
|
+
)
|
|
1256
|
+
return (
|
|
1257
|
+
f"\u26d4 #{issue_number} in {repo}: the relay refused to push "
|
|
1258
|
+
f"the branch, so it is not retrying. {result.error or ''}".strip()
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
# The count and the un-mark are saved together, in one write.
|
|
1262
|
+
attempts = poller.publish_failure_count(repo, issue_number) + 1
|
|
1263
|
+
retrying = attempts < PUBLISH_ATTEMPTS
|
|
1264
|
+
poller.count_publish_failure(repo, issue_number, unmark=retrying)
|
|
1265
|
+
notice_failed = False
|
|
1266
|
+
if not retrying:
|
|
1267
|
+
poller.clear_publish_failures(repo, issue_number)
|
|
1268
|
+
try:
|
|
1269
|
+
await github.comment_on_issue(
|
|
1270
|
+
repo=repo,
|
|
1271
|
+
issue_number=issue_number,
|
|
1272
|
+
body=publish_retries_exhausted_comment(
|
|
1273
|
+
result.session_id,
|
|
1274
|
+
repo=repo,
|
|
1275
|
+
issue_number=issue_number,
|
|
1276
|
+
attempts=attempts,
|
|
1277
|
+
error=result.error or "",
|
|
1278
|
+
),
|
|
1279
|
+
)
|
|
1280
|
+
except asyncio.CancelledError:
|
|
1281
|
+
raise
|
|
1282
|
+
except Exception as e:
|
|
1283
|
+
log_event(
|
|
1284
|
+
_logger, "publish.exhausted_notice_failed", repo=repo,
|
|
1285
|
+
issue_number=issue_number,
|
|
1286
|
+
error=f"{type(e).__name__}: {str(e)[:200]}",
|
|
1287
|
+
)
|
|
1288
|
+
notice_failed = True
|
|
1289
|
+
message = publish_failure_message(
|
|
1290
|
+
repo, issue_number, result.error or "",
|
|
1291
|
+
attempts=attempts, retrying=retrying,
|
|
1292
|
+
)
|
|
1293
|
+
if notice_failed:
|
|
1294
|
+
# The issue's last word is still the retraction promising a
|
|
1295
|
+
# retry; chat is now the only place that says it stopped.
|
|
1296
|
+
message += (
|
|
1297
|
+
" The note saying so could not be posted on the issue, which "
|
|
1298
|
+
"still promises a retry."
|
|
1299
|
+
)
|
|
1300
|
+
return message
|
|
1301
|
+
|
|
1302
|
+
|
|
1186
1303
|
def reclone_message(repo: str, set_aside: Path) -> str:
|
|
1187
1304
|
"""The chat line for a clone that was set aside and cloned again
|
|
1188
1305
|
(#234). Names where the old copy went, because nothing deletes it."""
|
|
@@ -3168,6 +3285,23 @@ def poller_start(
|
|
|
3168
3285
|
pass
|
|
3169
3286
|
return
|
|
3170
3287
|
|
|
3288
|
+
# The agent finished and the relay could not publish the
|
|
3289
|
+
# branch (#268). Un-marked so the next poll runs it
|
|
3290
|
+
# again, bounded, because the issue is already marked
|
|
3291
|
+
# seen and nothing else would ever bring it back. No
|
|
3292
|
+
# early return: a fix round that failed to push still
|
|
3293
|
+
# has a pull request below that needs its merge watcher.
|
|
3294
|
+
publish_note = await settle_publish_failure(
|
|
3295
|
+
github, poller, repo, issue_number, result,
|
|
3296
|
+
)
|
|
3297
|
+
if publish_note is not None:
|
|
3298
|
+
console.print(f"[yellow]{publish_note}[/yellow]")
|
|
3299
|
+
if connected_transport is not None:
|
|
3300
|
+
try:
|
|
3301
|
+
await connected_transport.send(publish_note)
|
|
3302
|
+
except Exception:
|
|
3303
|
+
pass
|
|
3304
|
+
|
|
3171
3305
|
# Lock-contended DURING PR verification (issue #29): the
|
|
3172
3306
|
# PR already exists AND verification decided it wasn't
|
|
3173
3307
|
# ready (_verify_and_fix_pr only returns this error from
|
|
@@ -54,12 +54,16 @@ def _is_issues_disabled_error(exc: Exception) -> bool:
|
|
|
54
54
|
return "has disabled issues" in str(exc).lower()
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
"""
|
|
59
|
-
entries that are an issue number and a positive whole count
|
|
60
|
-
Anything else is dropped rather than trusted: a negative
|
|
61
|
-
lift the retry limit, and a value that cannot convert
|
|
62
|
-
poller from loading at all.
|
|
57
|
+
def _read_failure_counts(raw: object) -> dict[str, dict[int, int]]:
|
|
58
|
+
"""A per-repo, per-issue retry counter from the state file, keeping
|
|
59
|
+
only entries that are an issue number and a positive whole count
|
|
60
|
+
(#234). Anything else is dropped rather than trusted: a negative
|
|
61
|
+
count would lift the retry limit, and a value that cannot convert
|
|
62
|
+
would stop the poller from loading at all.
|
|
63
|
+
|
|
64
|
+
Shared by ``pre_dispatch_failures`` and ``publish_failures`` (#268):
|
|
65
|
+
one shape, one validator, so a second counter cannot be added with
|
|
66
|
+
a weaker guard than the first."""
|
|
63
67
|
out: dict[str, dict[int, int]] = {}
|
|
64
68
|
if not isinstance(raw, dict):
|
|
65
69
|
return out
|
|
@@ -137,6 +141,11 @@ class IssuePoller:
|
|
|
137
141
|
# (#234). Persisted with seen_issues so a restart does not reset
|
|
138
142
|
# the limit on a permanent problem.
|
|
139
143
|
pre_dispatch_failures: dict[str, dict[int, int]] = field(default_factory=dict)
|
|
144
|
+
# Runs whose agent finished but whose branch the relay could not
|
|
145
|
+
# publish, per repo and issue (#268). Bounds the retry the same way
|
|
146
|
+
# and for the same reason: the cause may be a blip, and it may be
|
|
147
|
+
# permanent, and only a counter tells them apart.
|
|
148
|
+
publish_failures: dict[str, dict[int, int]] = field(default_factory=dict)
|
|
140
149
|
# Issues another machine holds (#245), per repo: when to look again,
|
|
141
150
|
# as an epoch time. In memory only; after a restart the issue is
|
|
142
151
|
# simply checked once more.
|
|
@@ -187,9 +196,12 @@ class IssuePoller:
|
|
|
187
196
|
data = json.loads(self.state_file.read_text())
|
|
188
197
|
raw = data.get("seen_issues", {})
|
|
189
198
|
self.seen_issues = {repo: set(numbers) for repo, numbers in raw.items()}
|
|
190
|
-
self.pre_dispatch_failures =
|
|
199
|
+
self.pre_dispatch_failures = _read_failure_counts(
|
|
191
200
|
data.get("pre_dispatch_failures"),
|
|
192
201
|
)
|
|
202
|
+
self.publish_failures = _read_failure_counts(
|
|
203
|
+
data.get("publish_failures"),
|
|
204
|
+
)
|
|
193
205
|
except (ValueError, OSError, AttributeError, TypeError):
|
|
194
206
|
# Corrupt or unreadable state — start fresh. ValueError
|
|
195
207
|
# covers JSONDecodeError and an integer too long to convert;
|
|
@@ -197,6 +209,7 @@ class IssuePoller:
|
|
|
197
209
|
# shape, e.g. a list at the top level or for seen_issues.
|
|
198
210
|
self.seen_issues = {}
|
|
199
211
|
self.pre_dispatch_failures = {}
|
|
212
|
+
self.publish_failures = {}
|
|
200
213
|
|
|
201
214
|
def _save_state(self) -> None:
|
|
202
215
|
"""Persist seen issues and a ``last_poll`` timestamp to the state file."""
|
|
@@ -209,6 +222,11 @@ class IssuePoller:
|
|
|
209
222
|
for repo, counts in self.pre_dispatch_failures.items()
|
|
210
223
|
if counts
|
|
211
224
|
},
|
|
225
|
+
"publish_failures": {
|
|
226
|
+
repo: {str(n): c for n, c in sorted(counts.items())}
|
|
227
|
+
for repo, counts in self.publish_failures.items()
|
|
228
|
+
if counts
|
|
229
|
+
},
|
|
212
230
|
"last_poll": datetime.now(timezone.utc).isoformat(),
|
|
213
231
|
}
|
|
214
232
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -816,6 +834,41 @@ class IssuePoller:
|
|
|
816
834
|
del counts[issue_number]
|
|
817
835
|
self._save_state_best_effort()
|
|
818
836
|
|
|
837
|
+
def publish_failure_count(self, repo: str, issue_number: int) -> int:
|
|
838
|
+
"""How many publish failures are counted for ``issue_number``."""
|
|
839
|
+
return self.publish_failures.get(repo, {}).get(issue_number, 0)
|
|
840
|
+
|
|
841
|
+
def count_publish_failure(
|
|
842
|
+
self, repo: str, issue_number: int, *, unmark: bool,
|
|
843
|
+
) -> int:
|
|
844
|
+
"""Count one publish failure and, when ``unmark``, take the issue
|
|
845
|
+
out of the seen-set, in ONE state write, so a stop between the two
|
|
846
|
+
cannot leave them disagreeing (#268). Returns the new count."""
|
|
847
|
+
counts = self.publish_failures.setdefault(repo, {})
|
|
848
|
+
counts[issue_number] = counts.get(issue_number, 0) + 1
|
|
849
|
+
if unmark:
|
|
850
|
+
seen = self.seen_issues.get(repo)
|
|
851
|
+
if seen:
|
|
852
|
+
seen.discard(issue_number)
|
|
853
|
+
self._save_state_best_effort()
|
|
854
|
+
return counts[issue_number]
|
|
855
|
+
|
|
856
|
+
def record_publish_failure(self, repo: str, issue_number: int) -> int:
|
|
857
|
+
"""Count one run of ``issue_number`` whose agent finished but
|
|
858
|
+
whose branch the relay could not publish, and return how many
|
|
859
|
+
there have been (#268). Saved best-effort, like ``unmark_seen``."""
|
|
860
|
+
counts = self.publish_failures.setdefault(repo, {})
|
|
861
|
+
counts[issue_number] = counts.get(issue_number, 0) + 1
|
|
862
|
+
self._save_state_best_effort()
|
|
863
|
+
return counts[issue_number]
|
|
864
|
+
|
|
865
|
+
def clear_publish_failures(self, repo: str, issue_number: int) -> None:
|
|
866
|
+
"""Forget the count once a run publishes or is given up."""
|
|
867
|
+
counts = self.publish_failures.get(repo)
|
|
868
|
+
if counts and issue_number in counts:
|
|
869
|
+
del counts[issue_number]
|
|
870
|
+
self._save_state_best_effort()
|
|
871
|
+
|
|
819
872
|
async def seed_current(self) -> None:
|
|
820
873
|
"""Seed seen_issues with all currently-triggered issues.
|
|
821
874
|
|
|
@@ -89,10 +89,138 @@ def _rejections_are_only_non_fast_forward(stderr: str) -> list[str]:
|
|
|
89
89
|
return refs
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
# How much of each stream a failing git command contributes to the
|
|
93
|
+
# error it raises. #269: `git push --porcelain` writes its per-ref
|
|
94
|
+
# status to STDOUT, so for a rejected push the remote's reason is on
|
|
95
|
+
# stdout and stderr holds only `error: failed to push some refs to
|
|
96
|
+
# '<url>'`. Dropping stdout left every failed push reported by its
|
|
97
|
+
# least informative line — and that line names the URL, which sends
|
|
98
|
+
# the reader after the transport or the credential when neither was
|
|
99
|
+
# at fault.
|
|
100
|
+
_GIT_ERROR_TAIL_CHARS = 1000
|
|
101
|
+
# The reason is one porcelain line for the one ref we push. The cap is
|
|
102
|
+
# for a remote that answers with an essay.
|
|
103
|
+
_PUSH_REASON_MAX_CHARS = 500
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _git_failure_detail(
|
|
107
|
+
stdout: str, stderr: str, returncode: int | None
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Both streams of a failed git command, scrubbed and capped.
|
|
110
|
+
|
|
111
|
+
stderr first and whole: it carries git's own ``fatal:``/``error:``
|
|
112
|
+
lines and is what every existing caller's matcher reads. stdout
|
|
113
|
+
follows, labelled and capped, whenever it said anything — which for a
|
|
114
|
+
``--porcelain`` command is where the answer is.
|
|
115
|
+
|
|
116
|
+
Redaction runs before the cap (see :mod:`ctrlrelay.core.redact`):
|
|
117
|
+
clipping first can store half a token, and half a token no longer
|
|
118
|
+
matches the pattern that would have caught it.
|
|
119
|
+
|
|
120
|
+
When both streams are empty the exit code is all there is, so it is
|
|
121
|
+
named rather than rendered as a message with nothing after the
|
|
122
|
+
colon.
|
|
123
|
+
"""
|
|
124
|
+
from ctrlrelay.core.redact import redact_secrets, tail
|
|
125
|
+
|
|
126
|
+
parts: list[str] = []
|
|
127
|
+
# stderr whole, as the message carried it before stdout was added, so
|
|
128
|
+
# a matcher looking for git's words anywhere in it still finds them;
|
|
129
|
+
# stdout capped.
|
|
130
|
+
err = stderr.strip()
|
|
131
|
+
if err:
|
|
132
|
+
parts.append(redact_secrets(err))
|
|
133
|
+
out = stdout.strip()
|
|
134
|
+
if out:
|
|
135
|
+
parts.append("stdout: " + tail(redact_secrets(out), _GIT_ERROR_TAIL_CHARS))
|
|
136
|
+
if not parts:
|
|
137
|
+
return f"exited {returncode} with no output"
|
|
138
|
+
return "; ".join(parts)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_push_rejection(porcelain_stdout: str) -> str | None:
|
|
142
|
+
"""The reason ``git push --porcelain`` gave for refusing a ref.
|
|
143
|
+
|
|
144
|
+
Porcelain status is one TAB-separated line per ref on **stdout**:
|
|
145
|
+
``<flag>\\t<from>:<to>\\t<summary>``. A refused ref carries flag
|
|
146
|
+
``!`` and the remote's reason in the summary — ``[rejected] (stale
|
|
147
|
+
info)``, ``[remote rejected] (pre-receive hook declined)``,
|
|
148
|
+
``[remote rejected] (push declined due to repository rule
|
|
149
|
+
violations)``. Lines for refs that updated carry a different flag
|
|
150
|
+
and are not failures.
|
|
151
|
+
|
|
152
|
+
Returns ``None`` when no ref was rejected: the push died before it
|
|
153
|
+
got a per-ref answer (unreachable remote, bad credential, no such
|
|
154
|
+
repository), and then stderr is the whole story and must not be
|
|
155
|
+
dressed up as a rejection.
|
|
156
|
+
|
|
157
|
+
Also reads the non-porcelain form (``! [rejected] <src> -> <dst>
|
|
158
|
+
(reason)``), because the flag and the reason are in it too and a
|
|
159
|
+
caller should not have to know which form it has.
|
|
160
|
+
"""
|
|
161
|
+
from ctrlrelay.core.redact import redact_secrets
|
|
162
|
+
|
|
163
|
+
reasons: list[str] = []
|
|
164
|
+
for raw in porcelain_stdout.splitlines():
|
|
165
|
+
line = raw.strip()
|
|
166
|
+
if not line.startswith("!"):
|
|
167
|
+
continue
|
|
168
|
+
if "\t" in raw:
|
|
169
|
+
# At most two splits: the summary keeps any tab inside it.
|
|
170
|
+
fields = [f.strip() for f in raw.split("\t", 2)]
|
|
171
|
+
ref = fields[1] if len(fields) > 1 else ""
|
|
172
|
+
summary = fields[2] if len(fields) > 2 else ""
|
|
173
|
+
# Scrubbed before anything is cut (what the remote says can
|
|
174
|
+
# carry a credential, and half a token no longer matches the
|
|
175
|
+
# pattern); then the ref whole and the remote's words capped,
|
|
176
|
+
# so a long ref cannot crowd out the reason.
|
|
177
|
+
ref, summary = redact_secrets(ref), redact_secrets(summary)
|
|
178
|
+
text = f"{ref} {summary[:_PUSH_REASON_MAX_CHARS]}".strip() or line
|
|
179
|
+
else:
|
|
180
|
+
text = redact_secrets(line.lstrip("! ").strip() or line)
|
|
181
|
+
reasons.append(text)
|
|
182
|
+
if not reasons:
|
|
183
|
+
return None
|
|
184
|
+
return "; ".join(reasons)
|
|
185
|
+
|
|
186
|
+
|
|
92
187
|
class WorktreeError(Exception):
|
|
93
188
|
"""Raised when worktree operations fail."""
|
|
94
189
|
|
|
95
190
|
|
|
191
|
+
class GitCommandError(WorktreeError):
|
|
192
|
+
"""A git command exited non-zero, carrying both of its streams.
|
|
193
|
+
|
|
194
|
+
A ``WorktreeError`` like before, so every existing handler is
|
|
195
|
+
unaffected. The two attributes are for a caller that knows the
|
|
196
|
+
command's output format and can read more from it than the message
|
|
197
|
+
does — ``push --porcelain``, whose per-ref reason is on stdout.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self, message: str, *, stdout: str = "", stderr: str = ""
|
|
202
|
+
) -> None:
|
|
203
|
+
super().__init__(message)
|
|
204
|
+
self.stdout = stdout
|
|
205
|
+
self.stderr = stderr
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class PushRejectedError(WorktreeError):
|
|
209
|
+
"""Git rejected the push for a ref, named with the reason it gave.
|
|
210
|
+
|
|
211
|
+
Decided by git itself before the remote is asked (a stale lease, a
|
|
212
|
+
non-fast-forward) or by the remote (a protection rule). Distinct from
|
|
213
|
+
:class:`ctrlrelay.core.publish.PushRefusedError`, which is the relay
|
|
214
|
+
declining to push at all. ``reason`` is the ``!`` line from
|
|
215
|
+
``push --porcelain`` so a caller can log it as its own field
|
|
216
|
+
instead of parsing it back out of the message.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, message: str, *, reason: str | None = None) -> None:
|
|
220
|
+
super().__init__(message)
|
|
221
|
+
self.reason = reason
|
|
222
|
+
|
|
223
|
+
|
|
96
224
|
class DefaultBranchDivergedError(WorktreeError):
|
|
97
225
|
"""The bare clone's default branch cannot fast-forward to origin's.
|
|
98
226
|
|
|
@@ -340,14 +468,23 @@ class WorktreeManager:
|
|
|
340
468
|
raise
|
|
341
469
|
|
|
342
470
|
if proc.returncode != 0:
|
|
343
|
-
err = stderr.decode().strip()
|
|
471
|
+
err = stderr.decode(errors="replace").strip()
|
|
472
|
+
out = stdout.decode(errors="replace").strip()
|
|
344
473
|
declined = (
|
|
345
474
|
_rejections_are_only_non_fast_forward(err)
|
|
346
475
|
if tolerate_non_fast_forward
|
|
347
476
|
else []
|
|
348
477
|
)
|
|
349
478
|
if not declined:
|
|
350
|
-
|
|
479
|
+
# Both streams, because which one carried the reason
|
|
480
|
+
# depends on the command: a `--porcelain` push puts it
|
|
481
|
+
# on stdout (#269).
|
|
482
|
+
raise GitCommandError(
|
|
483
|
+
"git failed: "
|
|
484
|
+
+ _git_failure_detail(out, err, proc.returncode),
|
|
485
|
+
stdout=out,
|
|
486
|
+
stderr=err,
|
|
487
|
+
)
|
|
351
488
|
stuck = sorted(set(declined) & protected_refs)
|
|
352
489
|
if stuck:
|
|
353
490
|
raise DefaultBranchDivergedError(
|
|
@@ -1113,16 +1250,52 @@ class WorktreeManager:
|
|
|
1113
1250
|
"not overwriting it"
|
|
1114
1251
|
)
|
|
1115
1252
|
expected_remote_sha = remote
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1253
|
+
try:
|
|
1254
|
+
await self._run_git(
|
|
1255
|
+
*git(
|
|
1256
|
+
"push", "--no-verify", "--porcelain",
|
|
1257
|
+
f"--force-with-lease=refs/heads/{branch}:"
|
|
1258
|
+
f"{expected_remote_sha}",
|
|
1259
|
+
url, f"{sha}:refs/heads/{branch}",
|
|
1260
|
+
),
|
|
1261
|
+
env=env,
|
|
1262
|
+
timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
|
|
1263
|
+
)
|
|
1264
|
+
except GitCommandError as exc:
|
|
1265
|
+
# The remote's reason for refusing the ref is on
|
|
1266
|
+
# stdout, which the generic message now carries but
|
|
1267
|
+
# buries after git's summary line. Pull it out so the
|
|
1268
|
+
# reason leads, and hand it up as its own field (#269).
|
|
1269
|
+
reason = parse_push_rejection(exc.stdout)
|
|
1270
|
+
if reason is None:
|
|
1271
|
+
raise
|
|
1272
|
+
from ctrlrelay.core.redact import redact_secrets, tail
|
|
1273
|
+
|
|
1274
|
+
said = tail(redact_secrets("; ".join(
|
|
1275
|
+
line.strip()
|
|
1276
|
+
for line in exc.stderr.splitlines()
|
|
1277
|
+
if line.strip().startswith("remote:")
|
|
1278
|
+
)), _GIT_ERROR_TAIL_CHARS)
|
|
1279
|
+
_logger.error(
|
|
1280
|
+
"publish.push.rejected",
|
|
1281
|
+
extra={
|
|
1282
|
+
"repo": repo,
|
|
1283
|
+
"session_id": session_id,
|
|
1284
|
+
"branch": branch,
|
|
1285
|
+
"sha": sha,
|
|
1286
|
+
"reason": reason,
|
|
1287
|
+
},
|
|
1288
|
+
)
|
|
1289
|
+
# The reason leads; git's own failure text follows, as the
|
|
1290
|
+
# message carried it before, for anything matching on it.
|
|
1291
|
+
raise PushRejectedError(
|
|
1292
|
+
f"git failed: push rejected by {url}: {reason}"
|
|
1293
|
+
+ (f"; {said}" if said else "")
|
|
1294
|
+
# Already scrubbed where it was built; again here, so
|
|
1295
|
+
# this line does not depend on that.
|
|
1296
|
+
+ f"; {redact_secrets(str(exc))}",
|
|
1297
|
+
reason=reason,
|
|
1298
|
+
) from exc
|
|
1126
1299
|
finally:
|
|
1127
1300
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
1128
1301
|
log_event(
|