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.
Files changed (188) hide show
  1. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/CHANGELOG.md +50 -1
  2. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/PKG-INFO +1 -1
  3. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/configuration.md +15 -1
  4. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/operations.md +24 -0
  5. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/pyproject.toml +1 -1
  6. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/cli.py +134 -0
  7. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github.py +11 -7
  8. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/poller.py +84 -8
  9. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/worktree.py +185 -12
  10. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/dev.py +126 -4
  11. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_agent_no_keys.py +205 -0
  12. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_github.py +11 -5
  13. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_node_claims.py +1 -1
  14. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_poller.py +74 -2
  15. ctrlrelay-0.16.4/tests/test_publish_failure_retry.py +438 -0
  16. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_usage.py +1 -1
  17. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_waiting.py +5 -3
  18. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_worktree.py +206 -0
  19. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  21. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/dependabot.yml +0 -0
  23. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/build.yml +0 -0
  24. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/cla.yml +0 -0
  25. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/pages.yml +0 -0
  26. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/publish.yml +0 -0
  27. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.github/workflows/test.yml +0 -0
  28. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/.gitignore +0 -0
  29. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/CODE_OF_CONDUCT.md +0 -0
  30. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/CONTRIBUTING.md +0 -0
  31. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/LICENSE +0 -0
  32. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/README.md +0 -0
  33. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/SECURITY.md +0 -0
  34. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/config/orchestrator.yaml.example +0 -0
  35. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/Gemfile +0 -0
  36. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/_config.yml +0 -0
  37. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/adding-a-machine.md +0 -0
  38. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/architecture.md +0 -0
  39. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/bridge.md +0 -0
  40. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/cli.md +0 -0
  41. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/development.md +0 -0
  42. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/feedback-loop.md +0 -0
  43. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/getting-started.md +0 -0
  44. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/index.md +0 -0
  45. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/personalization.md +0 -0
  46. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  47. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  48. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/__init__.py +0 -0
  49. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__init__.py +0 -0
  50. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__main__.py +0 -0
  51. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/factory.py +0 -0
  52. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/handler.py +0 -0
  53. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  54. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/node_tag.py +0 -0
  55. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/protocol.py +0 -0
  56. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/server.py +0 -0
  57. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  58. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/__init__.py +0 -0
  59. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/agent_env.py +0 -0
  60. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/archived.py +0 -0
  61. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/audit.py +0 -0
  62. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/binaries.py +0 -0
  63. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/checkpoint.py +0 -0
  64. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/claims.py +0 -0
  65. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/code_review.py +0 -0
  66. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/config.py +0 -0
  67. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatch_health.py +0 -0
  68. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatcher.py +0 -0
  69. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/events.py +0 -0
  70. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github_budget.py +0 -0
  71. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/heartbeat.py +0 -0
  72. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/job_claims.py +0 -0
  73. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/network.py +0 -0
  74. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/node_check.py +0 -0
  75. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/obs.py +0 -0
  76. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_verifier.py +0 -0
  77. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_watcher.py +0 -0
  78. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/publish.py +0 -0
  79. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/question_expiry.py +0 -0
  80. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/redact.py +0 -0
  81. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/scheduler.py +0 -0
  82. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/state.py +0 -0
  83. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/core/usage.py +0 -0
  84. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/__init__.py +0 -0
  85. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/client.py +0 -0
  86. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/gh_protocol.py +0 -0
  87. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/install.py +0 -0
  88. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/__init__.py +0 -0
  89. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/manager.py +0 -0
  90. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/paths.py +0 -0
  91. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/__init__.py +0 -0
  92. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/base.py +0 -0
  93. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  94. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/secops.py +0 -0
  95. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/task.py +0 -0
  96. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/setup.py +0 -0
  97. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/__init__.py +0 -0
  98. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  99. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  100. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  101. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  102. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  103. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  104. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  105. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  106. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/__init__.py +0 -0
  107. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/base.py +0 -0
  108. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/file_mock.py +0 -0
  109. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/socket_client.py +0 -0
  110. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/__init__.py +0 -0
  111. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/fleet.py +0 -0
  112. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/health.py +0 -0
  113. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/links.py +0 -0
  114. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/server.py +0 -0
  115. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/static/index.html +0 -0
  116. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/status.py +0 -0
  117. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/transcript.py +0 -0
  118. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/src/ctrlrelay/web/waiting.py +0 -0
  119. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/__init__.py +0 -0
  120. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/_publish_fakes.py +0 -0
  121. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/conftest.py +0 -0
  122. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/fixtures/gh_search_issues.json +0 -0
  123. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  124. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/fixtures/transcript_sample.jsonl +0 -0
  125. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_agent_sandbox.py +0 -0
  126. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_archived.py +0 -0
  127. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_audit.py +0 -0
  128. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_binary_resolution.py +0 -0
  129. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_bridge_protocol.py +0 -0
  130. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_bridge_server.py +0 -0
  131. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_bridge_start_command.py +0 -0
  132. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_chat_machines.py +0 -0
  133. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_checkpoint.py +0 -0
  134. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_claim_reconciliation.py +0 -0
  135. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_ci_wait.py +0 -0
  136. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_dev.py +0 -0
  137. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_repos.py +0 -0
  138. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_secops.py +0 -0
  139. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_start.py +0 -0
  140. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_cli_version.py +0 -0
  141. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_code_review.py +0 -0
  142. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_config.py +0 -0
  143. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dashboard_client.py +0 -0
  144. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dev_integration.py +0 -0
  145. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dev_pipeline.py +0 -0
  146. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dispatch_health.py +0 -0
  147. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dispatch_outcome_hook.py +0 -0
  148. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_dispatcher.py +0 -0
  149. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_docs_site.py +0 -0
  150. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_event_emission.py +0 -0
  151. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_events.py +0 -0
  152. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_fleet.py +0 -0
  153. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_gh_protocol.py +0 -0
  154. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_github_budget.py +0 -0
  155. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_install.py +0 -0
  156. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_issue_outcome_announcement.py +0 -0
  157. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_job_claims.py +0 -0
  158. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_mattermost_handler.py +0 -0
  159. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_network.py +0 -0
  160. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_node_check.py +0 -0
  161. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_notify_transport.py +0 -0
  162. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_obs.py +0 -0
  163. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_personalization.py +0 -0
  164. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pipeline_base.py +0 -0
  165. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_post_merge.py +0 -0
  166. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pr_verifier.py +0 -0
  167. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pr_watcher.py +0 -0
  168. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_pre_dispatch_retry.py +0 -0
  169. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_question_backoff.py +0 -0
  170. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_question_expiry.py +0 -0
  171. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_redact.py +0 -0
  172. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_relay_alert_off_lock.py +0 -0
  173. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_resume_prompt_contract.py +0 -0
  174. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_scheduler.py +0 -0
  175. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_secops_integration.py +0 -0
  176. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_secops_pipeline.py +0 -0
  177. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_setup.py +0 -0
  178. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_state.py +0 -0
  179. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_task_pipeline.py +0 -0
  180. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_telegram_handler.py +0 -0
  181. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_transport.py +0 -0
  182. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_transport_resolution.py +0 -0
  183. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_unreviewed_pr_announcement.py +0 -0
  184. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_health.py +0 -0
  185. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_links.py +0 -0
  186. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_server.py +0 -0
  187. {ctrlrelay-0.16.2 → ctrlrelay-0.16.4}/tests/test_web_status.py +0 -0
  188. {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.2...HEAD
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.2
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. Text the agent wrote is posted with `@`-mentions and
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.16.2"
3
+ version = "0.16.4"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 ``actor`` (who performed the assignment) and
429
- ``assignee`` (who was assigned). Used by the poller to verify that the
430
- most recent self-assignment was actually performed by the operator.
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", '[.[] | select(.event=="assigned")]',
439
+ "--jq", '.[] | select(.event=="assigned")',
436
440
  )
437
- return json.loads(output) if output.strip() else []
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 _read_pre_dispatch_failures(raw: object) -> dict[str, dict[int, int]]:
58
- """``pre_dispatch_failures`` from the state file, keeping only
59
- entries that are an issue number and a positive whole count (#234).
60
- Anything else is dropped rather than trusted: a negative count would
61
- lift the retry limit, and a value that cannot convert would stop the
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 = _read_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("actor") or {}).get("login")
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