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.
Files changed (188) hide show
  1. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/CHANGELOG.md +34 -1
  2. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/PKG-INFO +1 -1
  3. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/configuration.md +15 -1
  4. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/operations.md +24 -0
  5. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/pyproject.toml +1 -1
  6. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/cli.py +134 -0
  7. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/poller.py +60 -7
  8. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/worktree.py +185 -12
  9. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/dev.py +126 -4
  10. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_agent_no_keys.py +205 -0
  11. ctrlrelay-0.16.4/tests/test_publish_failure_retry.py +438 -0
  12. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_worktree.py +206 -0
  13. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/dependabot.yml +0 -0
  17. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/build.yml +0 -0
  18. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/cla.yml +0 -0
  19. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/pages.yml +0 -0
  20. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/publish.yml +0 -0
  21. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.github/workflows/test.yml +0 -0
  22. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/.gitignore +0 -0
  23. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/CODE_OF_CONDUCT.md +0 -0
  24. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/CONTRIBUTING.md +0 -0
  25. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/LICENSE +0 -0
  26. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/README.md +0 -0
  27. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/SECURITY.md +0 -0
  28. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/config/orchestrator.yaml.example +0 -0
  29. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/Gemfile +0 -0
  30. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/_config.yml +0 -0
  31. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/adding-a-machine.md +0 -0
  32. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/architecture.md +0 -0
  33. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/bridge.md +0 -0
  34. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/cli.md +0 -0
  35. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/development.md +0 -0
  36. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/feedback-loop.md +0 -0
  37. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/getting-started.md +0 -0
  38. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/index.md +0 -0
  39. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/personalization.md +0 -0
  40. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  41. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  42. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/__init__.py +0 -0
  43. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__init__.py +0 -0
  44. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/__main__.py +0 -0
  45. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/factory.py +0 -0
  46. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/handler.py +0 -0
  47. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  48. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/node_tag.py +0 -0
  49. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/protocol.py +0 -0
  50. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/server.py +0 -0
  51. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  52. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/__init__.py +0 -0
  53. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/agent_env.py +0 -0
  54. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/archived.py +0 -0
  55. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/audit.py +0 -0
  56. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/binaries.py +0 -0
  57. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/checkpoint.py +0 -0
  58. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/claims.py +0 -0
  59. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/code_review.py +0 -0
  60. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/config.py +0 -0
  61. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatch_health.py +0 -0
  62. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/dispatcher.py +0 -0
  63. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/events.py +0 -0
  64. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github.py +0 -0
  65. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/github_budget.py +0 -0
  66. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/heartbeat.py +0 -0
  67. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/job_claims.py +0 -0
  68. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/network.py +0 -0
  69. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/node_check.py +0 -0
  70. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/obs.py +0 -0
  71. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_verifier.py +0 -0
  72. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/pr_watcher.py +0 -0
  73. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/publish.py +0 -0
  74. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/question_expiry.py +0 -0
  75. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/redact.py +0 -0
  76. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/scheduler.py +0 -0
  77. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/state.py +0 -0
  78. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/core/usage.py +0 -0
  79. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/__init__.py +0 -0
  80. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/dashboard/client.py +0 -0
  81. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/gh_protocol.py +0 -0
  82. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/install.py +0 -0
  83. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/__init__.py +0 -0
  84. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/manager.py +0 -0
  85. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/personalization/paths.py +0 -0
  86. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/__init__.py +0 -0
  87. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/base.py +0 -0
  88. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  89. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/secops.py +0 -0
  90. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/pipelines/task.py +0 -0
  91. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/setup.py +0 -0
  92. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/__init__.py +0 -0
  93. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  94. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  95. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  96. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  97. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  98. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  99. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  100. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  101. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/__init__.py +0 -0
  102. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/base.py +0 -0
  103. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/file_mock.py +0 -0
  104. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/transports/socket_client.py +0 -0
  105. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/__init__.py +0 -0
  106. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/fleet.py +0 -0
  107. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/health.py +0 -0
  108. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/links.py +0 -0
  109. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/server.py +0 -0
  110. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/static/index.html +0 -0
  111. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/status.py +0 -0
  112. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/transcript.py +0 -0
  113. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/src/ctrlrelay/web/waiting.py +0 -0
  114. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/__init__.py +0 -0
  115. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/_publish_fakes.py +0 -0
  116. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/conftest.py +0 -0
  117. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/fixtures/gh_search_issues.json +0 -0
  118. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  119. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/fixtures/transcript_sample.jsonl +0 -0
  120. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_agent_sandbox.py +0 -0
  121. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_archived.py +0 -0
  122. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_audit.py +0 -0
  123. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_binary_resolution.py +0 -0
  124. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_bridge_protocol.py +0 -0
  125. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_bridge_server.py +0 -0
  126. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_bridge_start_command.py +0 -0
  127. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_chat_machines.py +0 -0
  128. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_checkpoint.py +0 -0
  129. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_claim_reconciliation.py +0 -0
  130. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_ci_wait.py +0 -0
  131. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_dev.py +0 -0
  132. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_repos.py +0 -0
  133. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_secops.py +0 -0
  134. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_start.py +0 -0
  135. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_cli_version.py +0 -0
  136. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_code_review.py +0 -0
  137. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_config.py +0 -0
  138. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dashboard_client.py +0 -0
  139. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dev_integration.py +0 -0
  140. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dev_pipeline.py +0 -0
  141. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dispatch_health.py +0 -0
  142. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dispatch_outcome_hook.py +0 -0
  143. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_dispatcher.py +0 -0
  144. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_docs_site.py +0 -0
  145. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_event_emission.py +0 -0
  146. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_events.py +0 -0
  147. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_fleet.py +0 -0
  148. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_gh_protocol.py +0 -0
  149. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_github.py +0 -0
  150. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_github_budget.py +0 -0
  151. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_install.py +0 -0
  152. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_issue_outcome_announcement.py +0 -0
  153. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_job_claims.py +0 -0
  154. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_mattermost_handler.py +0 -0
  155. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_network.py +0 -0
  156. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_node_check.py +0 -0
  157. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_node_claims.py +0 -0
  158. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_notify_transport.py +0 -0
  159. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_obs.py +0 -0
  160. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_personalization.py +0 -0
  161. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pipeline_base.py +0 -0
  162. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_poller.py +0 -0
  163. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_post_merge.py +0 -0
  164. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pr_verifier.py +0 -0
  165. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pr_watcher.py +0 -0
  166. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_pre_dispatch_retry.py +0 -0
  167. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_question_backoff.py +0 -0
  168. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_question_expiry.py +0 -0
  169. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_redact.py +0 -0
  170. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_relay_alert_off_lock.py +0 -0
  171. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_resume_prompt_contract.py +0 -0
  172. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_scheduler.py +0 -0
  173. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_secops_integration.py +0 -0
  174. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_secops_pipeline.py +0 -0
  175. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_setup.py +0 -0
  176. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_state.py +0 -0
  177. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_task_pipeline.py +0 -0
  178. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_telegram_handler.py +0 -0
  179. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_transport.py +0 -0
  180. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_transport_resolution.py +0 -0
  181. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_unreviewed_pr_announcement.py +0 -0
  182. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_usage.py +0 -0
  183. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_health.py +0 -0
  184. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_links.py +0 -0
  185. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_server.py +0 -0
  186. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_status.py +0 -0
  187. {ctrlrelay-0.16.3 → ctrlrelay-0.16.4}/tests/test_web_transcript.py +0 -0
  188. {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.3...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
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
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.3"
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
@@ -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)
@@ -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
- raise WorktreeError(f"git failed: {err}")
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
- await self._run_git(
1117
- *git(
1118
- "push", "--no-verify", "--porcelain",
1119
- f"--force-with-lease=refs/heads/{branch}:"
1120
- f"{expected_remote_sha}",
1121
- url, f"{sha}:refs/heads/{branch}",
1122
- ),
1123
- env=env,
1124
- timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
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(