ctrlrelay 0.16.2__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.2 → ctrlrelay-0.16.4}/CHANGELOG.md +50 -1
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/PKG-INFO +1 -1
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/configuration.md +15 -1
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/operations.md +24 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/pyproject.toml +1 -1
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/cli.py +134 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github.py +11 -7
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/poller.py +84 -8
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/worktree.py +185 -12
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/dev.py +126 -4
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_agent_no_keys.py +205 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_github.py +11 -5
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_node_claims.py +1 -1
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_poller.py +74 -2
- ctrlrelay-0.16.4/tests/test_publish_failure_retry.py +438 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_usage.py +1 -1
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_waiting.py +5 -3
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_worktree.py +206 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.gitignore +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/LICENSE +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/README.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/SECURITY.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/Gemfile +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/_config.yml +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/architecture.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/bridge.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/cli.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/development.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/getting-started.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/index.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/personalization.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/__init__.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/conftest.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_archived.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_audit.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_config.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_events.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_install.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_network.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_obs.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_redact.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_setup.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_state.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_transport.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_transcript.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.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
|
+
|
|
42
|
+
## [0.16.3] - 2026-11-01
|
|
43
|
+
|
|
44
|
+
A security fix: **upgrade every machine.** No config edit is required.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Only issues the operator assigned to themselves are taken, as intended.
|
|
49
|
+
The check read `actor` on GitHub's `assigned` events, which is the account
|
|
50
|
+
assigned (the operator), not the one who assigned it (`assigner`), so an
|
|
51
|
+
issue anyone else assigned to the operator was taken too. It now reads
|
|
52
|
+
`assigner`, refuses an event without one, refuses an unnamed assignment
|
|
53
|
+
after the operator's last one, and reads every page of an issue's events.
|
|
54
|
+
The status page's "assigned by someone else" uses the same check (#275,
|
|
55
|
+
closes #274)
|
|
56
|
+
|
|
10
57
|
## [0.16.2] - 2026-11-01
|
|
11
58
|
|
|
12
59
|
Status page changes only. **No config edit is required.**
|
|
@@ -2001,7 +2048,9 @@ pipeline).
|
|
|
2001
2048
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2002
2049
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2003
2050
|
|
|
2004
|
-
[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
|
|
2053
|
+
[0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
|
|
2005
2054
|
[0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
|
|
2006
2055
|
[0.16.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.0...v0.16.1
|
|
2007
2056
|
[0.16.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.15.0...v0.16.0
|
|
@@ -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
|
|
@@ -424,17 +424,21 @@ class GitHubCLI:
|
|
|
424
424
|
) -> list[dict[str, Any]]:
|
|
425
425
|
"""List ``assigned`` events for an issue in chronological order.
|
|
426
426
|
|
|
427
|
-
Returns the GitHub issue-events payload filtered to ``event == "assigned"
|
|
428
|
-
Each entry includes ``
|
|
429
|
-
``assignee`` (who was assigned)
|
|
430
|
-
|
|
427
|
+
Returns the GitHub issue-events payload filtered to ``event == "assigned"``,
|
|
428
|
+
every page of it. Each entry includes ``assigner`` (who performed the
|
|
429
|
+
assignment) and ``assignee`` (who was assigned); its ``actor`` is the
|
|
430
|
+
assignee too, not who made it (#274). Used by the poller to verify
|
|
431
|
+
that the most recent self-assignment was actually performed by the
|
|
432
|
+
operator.
|
|
431
433
|
"""
|
|
434
|
+
# Every page: on an issue with more than one page of events the
|
|
435
|
+
# latest assignment would otherwise be missed.
|
|
432
436
|
output = await self._run_gh(
|
|
433
|
-
"api",
|
|
437
|
+
"api", "--paginate",
|
|
434
438
|
f"/repos/{repo}/issues/{issue_number}/events",
|
|
435
|
-
"--jq", '
|
|
439
|
+
"--jq", '.[] | select(.event=="assigned")',
|
|
436
440
|
)
|
|
437
|
-
return json.loads(output) if
|
|
441
|
+
return [json.loads(line) for line in output.splitlines() if line.strip()]
|
|
438
442
|
|
|
439
443
|
async def get_issue(
|
|
440
444
|
self,
|
|
@@ -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)
|
|
@@ -617,6 +635,25 @@ class IssuePoller:
|
|
|
617
635
|
return True
|
|
618
636
|
|
|
619
637
|
events = await self.github.list_assignment_events(repo, issue_number)
|
|
638
|
+
# An assignment after the operator's last one that names nobody
|
|
639
|
+
# cannot be ruled out as a newer one by someone else: refuse rather
|
|
640
|
+
# than skip past it. One before it is overtaken and does not count.
|
|
641
|
+
last_mine = max(
|
|
642
|
+
(i for i, e in enumerate(events)
|
|
643
|
+
if (e.get("assignee") or {}).get("login") == self.username),
|
|
644
|
+
default=-1,
|
|
645
|
+
)
|
|
646
|
+
if any(not (e.get("assignee") or {}).get("login")
|
|
647
|
+
for e in events[last_mine + 1:]):
|
|
648
|
+
log_event(
|
|
649
|
+
_logger,
|
|
650
|
+
"poll.issue.foreign_assignment",
|
|
651
|
+
repo=repo,
|
|
652
|
+
number=issue_number,
|
|
653
|
+
assigner_login=None,
|
|
654
|
+
reason="assignment_without_assignee",
|
|
655
|
+
)
|
|
656
|
+
return False
|
|
620
657
|
relevant = [
|
|
621
658
|
e
|
|
622
659
|
for e in events
|
|
@@ -634,8 +671,12 @@ class IssuePoller:
|
|
|
634
671
|
return False
|
|
635
672
|
|
|
636
673
|
# Events endpoint returns chronological order; the last one wins.
|
|
674
|
+
# Who made the assignment is ``assigner``. On an ``assigned`` event
|
|
675
|
+
# ``actor`` is the account assigned, which is always the operator
|
|
676
|
+
# here, so reading it let anyone's assignment through (#274). No
|
|
677
|
+
# ``assigner`` is treated as foreign.
|
|
637
678
|
latest = relevant[-1]
|
|
638
|
-
assigner_login = (latest.get("
|
|
679
|
+
assigner_login = (latest.get("assigner") or {}).get("login")
|
|
639
680
|
if assigner_login == self.username:
|
|
640
681
|
return True
|
|
641
682
|
|
|
@@ -793,6 +834,41 @@ class IssuePoller:
|
|
|
793
834
|
del counts[issue_number]
|
|
794
835
|
self._save_state_best_effort()
|
|
795
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
|
+
|
|
796
872
|
async def seed_current(self) -> None:
|
|
797
873
|
"""Seed seen_issues with all currently-triggered issues.
|
|
798
874
|
|