ctrlrelay 0.20.2__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/CHANGELOG.md +93 -1
  2. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/config/orchestrator.yaml.example +4 -0
  4. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/configuration.md +26 -3
  5. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/operations.md +56 -1
  6. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/cli.py +116 -7
  8. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/claims.py +15 -0
  9. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/config.py +8 -0
  10. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatcher.py +130 -1
  11. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/events.py +7 -0
  12. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github.py +85 -0
  13. ctrlrelay-0.22.0/src/ctrlrelay/core/issue_questions.py +461 -0
  14. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/publish.py +8 -0
  15. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/state.py +113 -6
  16. ctrlrelay-0.22.0/src/ctrlrelay/core/stuck.py +424 -0
  17. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/worktree.py +34 -1
  18. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/base.py +6 -0
  19. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/dev.py +944 -115
  20. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/numbers.py +24 -9
  21. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/server.py +202 -1
  22. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/static/index.html +13 -4
  23. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/status.py +155 -0
  24. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/_publish_fakes.py +10 -1
  25. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_no_keys.py +4 -2
  26. ctrlrelay-0.22.0/tests/test_dev_stuck.py +244 -0
  27. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_event_emission.py +9 -2
  28. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_events.py +1 -1
  29. ctrlrelay-0.22.0/tests/test_issue_questions.py +482 -0
  30. ctrlrelay-0.22.0/tests/test_no_change_run.py +219 -0
  31. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_publish_failure_retry.py +94 -0
  32. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_relay_git_263.py +1 -1
  33. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_review_copy.py +42 -0
  34. ctrlrelay-0.22.0/tests/test_review_gate.py +493 -0
  35. ctrlrelay-0.22.0/tests/test_stuck.py +333 -0
  36. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_numbers.py +41 -8
  37. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_server.py +191 -0
  38. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_status.py +136 -1
  39. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  40. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  41. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  42. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/dependabot.yml +0 -0
  43. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/build.yml +0 -0
  44. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/cla.yml +0 -0
  45. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/pages.yml +0 -0
  46. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/publish.yml +0 -0
  47. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.github/workflows/test.yml +0 -0
  48. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/.gitignore +0 -0
  49. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/CODE_OF_CONDUCT.md +0 -0
  50. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/CONTRIBUTING.md +0 -0
  51. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/LICENSE +0 -0
  52. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/README.md +0 -0
  53. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/SECURITY.md +0 -0
  54. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/Gemfile +0 -0
  55. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/_config.yml +0 -0
  56. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/adding-a-machine.md +0 -0
  57. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/architecture.md +0 -0
  58. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/bridge.md +0 -0
  59. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/cli.md +0 -0
  60. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/development.md +0 -0
  61. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/feedback-loop.md +0 -0
  62. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/getting-started.md +0 -0
  63. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/index.md +0 -0
  64. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/llms.txt +0 -0
  65. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/personalization.md +0 -0
  66. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/registered-mode.md +0 -0
  67. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  68. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  69. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/__init__.py +0 -0
  70. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  71. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  72. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/factory.py +0 -0
  73. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/handler.py +0 -0
  74. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  75. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  76. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  77. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/server.py +0 -0
  78. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  79. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/__init__.py +0 -0
  80. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/agent_env.py +0 -0
  81. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/archived.py +0 -0
  82. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/audit.py +0 -0
  83. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/binaries.py +0 -0
  84. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  85. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/code_review.py +0 -0
  86. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  87. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github_budget.py +0 -0
  88. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  89. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/job_claims.py +0 -0
  90. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/network.py +0 -0
  91. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/node_check.py +0 -0
  92. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/obs.py +0 -0
  93. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/orphans.py +0 -0
  94. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/poller.py +0 -0
  95. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  96. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  97. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  98. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/redact.py +0 -0
  99. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/scheduler.py +0 -0
  100. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/core/usage.py +0 -0
  101. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  102. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/client.py +0 -0
  103. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/gh_protocol.py +0 -0
  104. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/install.py +0 -0
  105. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  106. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/manager.py +0 -0
  107. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/merge.py +0 -0
  108. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/paths.py +0 -0
  109. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  110. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  111. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  112. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/task.py +0 -0
  113. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/setup.py +0 -0
  114. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/__init__.py +0 -0
  115. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  116. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  117. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  118. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  119. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  120. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  121. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  122. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  123. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/__init__.py +0 -0
  124. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/base.py +0 -0
  125. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  126. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  127. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/__init__.py +0 -0
  128. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/fleet.py +0 -0
  129. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/health.py +0 -0
  130. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/links.py +0 -0
  131. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/transcript.py +0 -0
  132. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/src/ctrlrelay/web/waiting.py +0 -0
  133. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/__init__.py +0 -0
  134. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/_poller_fakes.py +0 -0
  135. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/conftest.py +0 -0
  136. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/fixtures/gh_search_issues.json +0 -0
  137. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  138. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  139. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_backend_wiring.py +0 -0
  140. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_routing.py +0 -0
  141. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_agent_sandbox.py +0 -0
  142. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_archived.py +0 -0
  143. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_audit.py +0 -0
  144. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_binary_resolution.py +0 -0
  145. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_bridge_protocol.py +0 -0
  146. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_bridge_server.py +0 -0
  147. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_bridge_start_command.py +0 -0
  148. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_chat_machines.py +0 -0
  149. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_checkpoint.py +0 -0
  150. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_claim_reconciliation.py +0 -0
  151. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_ci_wait.py +0 -0
  152. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_dev.py +0 -0
  153. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_repos.py +0 -0
  154. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_secops.py +0 -0
  155. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_start.py +0 -0
  156. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_cli_version.py +0 -0
  157. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_code_review.py +0 -0
  158. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_config.py +0 -0
  159. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dashboard_client.py +0 -0
  160. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_integration.py +0 -0
  161. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_pipeline.py +0 -0
  162. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_plan_step.py +0 -0
  163. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_step_switches.py +0 -0
  164. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dev_work_steps.py +0 -0
  165. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dispatch_health.py +0 -0
  166. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dispatch_outcome_hook.py +0 -0
  167. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_dispatcher.py +0 -0
  168. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_docs_site.py +0 -0
  169. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_fleet.py +0 -0
  170. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_gh_protocol.py +0 -0
  171. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_github.py +0 -0
  172. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_github_budget.py +0 -0
  173. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_install.py +0 -0
  174. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_issue_outcome_announcement.py +0 -0
  175. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_job_claims.py +0 -0
  176. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_mattermost_handler.py +0 -0
  177. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_network.py +0 -0
  178. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_node_check.py +0 -0
  179. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_node_claims.py +0 -0
  180. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_notify_transport.py +0 -0
  181. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_obs.py +0 -0
  182. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_opencode_adapter.py +0 -0
  183. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_orphans.py +0 -0
  184. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_personalization.py +0 -0
  185. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_personalization_merge.py +0 -0
  186. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pipeline_base.py +0 -0
  187. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_poller.py +0 -0
  188. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_poller_recheck.py +0 -0
  189. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_poller_search.py +0 -0
  190. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_post_merge.py +0 -0
  191. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pr_verifier.py +0 -0
  192. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pr_watcher.py +0 -0
  193. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_pre_dispatch_retry.py +0 -0
  194. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_question_backoff.py +0 -0
  195. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_question_expiry.py +0 -0
  196. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_question_survives_restart.py +0 -0
  197. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_redact.py +0 -0
  198. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_relay_alert_off_lock.py +0 -0
  199. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_resume_prompt_contract.py +0 -0
  200. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_scheduler.py +0 -0
  201. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_secops_integration.py +0 -0
  202. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_secops_pipeline.py +0 -0
  203. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_setup.py +0 -0
  204. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_state.py +0 -0
  205. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_task_pipeline.py +0 -0
  206. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_telegram_handler.py +0 -0
  207. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_timeout_save.py +0 -0
  208. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_transport.py +0 -0
  209. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_transport_resolution.py +0 -0
  210. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  211. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_usage.py +0 -0
  212. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_health.py +0 -0
  213. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_links.py +0 -0
  214. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_peer_links.py +0 -0
  215. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_transcript.py +0 -0
  216. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_web_waiting.py +0 -0
  217. {ctrlrelay-0.20.2 → ctrlrelay-0.22.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.0] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - The code review is tied to the commit it read. Each `review_finished`
15
+ event records that commit (`head_sha`) and whether the findings reached
16
+ the PR (`posted`), so the status page feed and `/api/status` show what a
17
+ verdict is about. After a review the relay reads the PR's head from
18
+ GitHub; if somebody pushed after the review, the PR gets a comment saying
19
+ it needs review again, a `review_stale` event is recorded, and the new
20
+ head is reviewed before the run reports done. With
21
+ `steps.review: required`, the PR is opened as a draft (a reused PR is put
22
+ back to draft when the run pushes to it) and marked ready only once the
23
+ latest review posted on its current head. The new head is fetched from
24
+ the PR's own ref, since the run's branch is checked out in its worktree.
25
+ A repository that does not allow drafts (a private one on GitHub's free
26
+ plan) gets a normal PR with a comment saying it has not been reviewed
27
+ yet. A reused PR that cannot be put back to draft gets the same comment.
28
+
29
+ ### Fixed
30
+
31
+ - The status page no longer says "looks stuck" while the agent is inside
32
+ one long command, such as a 20-minute test run. A tool call writes
33
+ nothing to the transcript until it returns. So before calling a card
34
+ stuck, the page reads `ps` for a process under that run's own agent
35
+ (the dispatcher records its pid in `agents/<session>.pid` beside the
36
+ state database) that started at or after the agent's last step. If it
37
+ finds one, it shows "running `<program>`" instead, with the program's
38
+ name only. An idle agent is still stuck. Where the process
39
+ table cannot be read, the card stays stuck and says why (#309).
40
+
41
+ - A run that correctly changes nothing is no longer recorded as a failed
42
+ publish. The agent can now finish with `"no_changes": True` and a one-line
43
+ `"no_changes_reason"`. When the relay's own check also finds no new
44
+ commits, it opens no pull request, ends the session `done`, says so on
45
+ the issue, and tells chat "finished, no change needed". A DONE with no
46
+ commits and no declaration still fails as a forgotten commit. A
47
+ declaration on a branch that has commits is ignored, and the commits are
48
+ published. So is a declaration from a run that has already pushed or has
49
+ an open PR, such as a fix round or a resumed session, since ending it
50
+ done would orphan that PR (#336).
51
+
52
+ ## [0.21.0] - 2026-11-05
53
+
54
+ ### Added
55
+
56
+ - A run going in circles is stopped early with a clear reason, instead of
57
+ running until the session timeout (#224). The agent is stopped when its
58
+ live transcript shows the same command 5 times in a row with the same
59
+ result each time (polling a job whose output moves on is not a loop,
60
+ though 5 identical polls in a row still are), or
61
+ the same error from the same command 5 times in a row. Between CI fix
62
+ rounds each failed job's log is sorted on its own, as flaky infrastructure (the failed jobs are re-run once, without
63
+ the agent), out of budget (not retried), or a code failure (sent to the
64
+ agent, as before); the same tests failing in 3 CI rounds in a row stops the
65
+ run. A stopped run is recorded as failed with `sessions.stuck_reason`, the
66
+ issue comment and chat say it was stuck and why, and the status page counts
67
+ these runs. See "Stuck runs" in the operations guide.
68
+
69
+ - A dev run's question is now asked on its GitHub issue as well as in chat,
70
+ and the first answer from either one wins. An answer on the issue is a
71
+ comment that starts with `/answer`, since the relay and every working
72
+ session comment as the operator's account; it counts only from an account
73
+ with write access to the repository, and only if it is newer than the
74
+ question and carries no relay marker; other comments
75
+ are logged and ignored. The chat is told when the issue answered it, and a
76
+ chat answer is copied to the issue. A reply that loses is told it was not
77
+ applied, and never resumes the session a second time. On a public
78
+ repository the issue gets a short notice instead of the question unless
79
+ `repos[].question_on_issue` says `full`. An expired question is retired
80
+ in both channels and the issue says so. While the question waits, the
81
+ issue's claim is marked held, so another machine does not take the issue
82
+ if this one stops; it goes back to working when the run resumes.
83
+
84
+ ### Fixed
85
+
86
+ - A push GitHub refuses because the branch changes a file under
87
+ `.github/workflows/` and the relay's token has no `workflow` scope is no
88
+ longer retried. It cost one full agent run per poll, up to three, for a
89
+ push that could never pass. Chat now says the branch changes a workflow
90
+ file, names it, and says why no retry can pass. For any other rejected
91
+ push, the chat line shows the remote's reason rather than the first 200
92
+ characters of the error, which ended before the reason (#325).
93
+
94
+ - The status page bound to a LAN address now also answers on
95
+ `127.0.0.1`, and it no longer keeps running, silently, on an address the
96
+ machine has lost. It checks every 30 seconds; when `web.host` is gone it
97
+ logs `web.address_gone`, says so in `web.error.log` and exits 1 so the
98
+ service manager restarts it (#322).
99
+
10
100
  ## [0.20.2] - 2026-11-04
11
101
 
12
102
  ### Fixed
@@ -2359,7 +2449,9 @@ pipeline).
2359
2449
  per-phase implementation plans (Phase 0 through Phase 4).
2360
2450
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2361
2451
 
2362
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...HEAD
2452
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...HEAD
2453
+ [0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
2454
+ [0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
2363
2455
  [0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
2364
2456
  [0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
2365
2457
  [0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.20.2
3
+ Version: 0.22.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -271,3 +271,7 @@ repos: []
271
271
  # # Fix rounds for a PR whose CI is red or whose branch conflicts,
272
272
  # # before the run fails. Defaults to 3; 0 means none.
273
273
  # max_fix_attempts: 3
274
+ # # A dev run's question is asked on its issue as well as in chat.
275
+ # # auto: full text on a private repo, a short notice on a public one.
276
+ # # full / notice force one or the other.
277
+ # question_on_issue: auto
@@ -207,7 +207,7 @@ agent:
207
207
  |---|---|---|---|
208
208
  | `type` | string | `"claude"` | Default backend for every repo: `"claude"` or `"opencode"`. See [agent backends](#agent-backends). |
209
209
  | `binary` | string | `"claude"` | Path to the `claude` executable. The bare name `"claude"` is auto-resolved at startup using `shutil.which("claude")`, then `~/.local/bin/claude`, `/usr/local/bin/claude`, `/opt/homebrew/bin/claude`. Set an absolute path to skip lookup (useful under launchd/systemd where PATH is minimal). |
210
- | `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. |
210
+ | `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. A session going in circles is stopped before this; see [stuck runs](operations.md#stuck-runs). |
211
211
  | `output_format` | string | `"json"` | Not used. The relay always runs `claude -p --output-format stream-json --verbose`, because only that output carries the subscription usage it reads (see [usage](#usage)). Kept so existing configs still load. |
212
212
 
213
213
  ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
@@ -534,12 +534,12 @@ web:
534
534
 
535
535
  | Key | Type | Default | Description |
536
536
  |---|---|---|---|
537
- | `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
537
+ | `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. A LAN address also gets a second listener on `127.0.0.1`, and the server exits (for the service manager to restart it) if that address stops being this machine's. |
538
538
  | `port` | int (1-65535) | `8787` | Port to listen on. |
539
539
  | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. Events older than this are deleted, so the run numbers' time to PR and time to merge are shown only for a window whose runs all started after the oldest event kept. Set it to `30` to get the 30-day ones; they appear once 30 days of events have been kept. |
540
540
  | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
541
541
  | `github_check_seconds` | int (>= 15) | `300` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). Every check spends from the GitHub account's budget, which machines on the account share. |
542
- | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
542
+ | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it, or the agent is still inside a command it started at its last step (read from `ps`; the card then says "running `<program>`"). Where the process table cannot be read, the card stays "looks stuck" and says why. |
543
543
  | `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
544
544
 
545
545
  The page has no login and shows repository names, issue titles and the
@@ -665,6 +665,7 @@ repos:
665
665
  | `code_review` | object | no | (defaults) | Review run over an agent branch before its PR is handed over. See [code_review](#code_review). |
666
666
  | `steps` | object | no | (all unset) | How strict each dev pipeline work step is. See [steps](#repossteps). |
667
667
  | `max_fix_attempts` | integer ≥ 0 | no | `3` | Fix rounds the dev pipeline asks for when a PR's CI is red or its branch conflicts, before the run fails. `0` means none. See [steps](#repossteps). |
668
+ | `question_on_issue` | `auto` \| `full` \| `notice` | no | `auto` | What a dev run's question looks like on its GitHub issue. The question is asked there as well as in chat, and the first answer from either wins; on the issue only a comment that starts with `/answer`, from an account with write access, counts. `auto` posts the full question on a private repository and a short "a question is waiting for the operator in chat" notice on a public one (or one whose visibility could not be read); a chat answer is copied to the issue under the same rule. `full` and `notice` force one or the other. |
668
669
  | `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
669
670
 
670
671
  ### repos[].automation
@@ -945,6 +946,28 @@ an exception inside the review are all failures. It is about the review
945
946
  having happened, not about what it found. **`review: off`** is the same as
946
947
  `code_review.method: off`, and loads as that.
947
948
 
949
+ A review is about one commit. With `review: required` the PR is opened as a
950
+ **draft**, and a PR the run reuses is put back to draft when the run pushes to
951
+ it, because its new commits have not been reviewed. The relay marks it ready
952
+ for review only when the latest review posted on the PR's current head. If
953
+ the PR stays a draft, nothing was handed over. Draft pull requests need a
954
+ public repository or a paid GitHub plan; where GitHub refuses a draft, the
955
+ publish step fails and says why.
956
+
957
+ With the review `on`, unset or `required`, the relay reads the PR's head from
958
+ GitHub after each review. If somebody pushed after the review, the verdict is
959
+ stale: the PR gets a comment saying it needs review again, a `review_stale`
960
+ event is recorded, and the new head is reviewed before the run reports done
961
+ (at most three reviews in all; a head still moving after that is not a
962
+ pass). With `required`, a head GitHub will not report is not a pass either.
963
+
964
+ Each `review_finished` event names the commit it reviewed, so the status page
965
+ feed and `/api/status` show what the verdict is about:
966
+ `{"pr_number", "ok", "head_sha", "status", "posted"}`. `ok` means the reviewer
967
+ read the diff; `posted` means its findings reached the PR, which is what
968
+ `required` checks. `review_stale` carries `{"pr_number", "head_sha",
969
+ "current_sha"}`.
970
+
948
971
  Two settings for one reviewer must agree, or the config refuses to load:
949
972
 
950
973
  - `code_review.method: off` with `steps.review: on` or `required`;
@@ -346,7 +346,8 @@ control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
346
346
  | `success_rate` | `done / (done + failed + blocked)`, 0 to 1; `null` when none has finished. |
347
347
  | `time_to_pr_s`, `time_to_merge_s` | `{median, n}`: seconds from the run's start to its first `pr_opened` (or `merged`) event, over the `n` runs that have one. Events stamped after now are not read. `null` when events cannot be read, or when a run in the window started before the oldest event still kept: its PR event may have been deleted. The daemon deletes events older than `web.history_days` (7 by default), so with the default the 30-day times are `null`. **To get them, set `web.history_days` to 30**; they appear once the events kept reach back past every run in the window (up to 30 days after the change, since deleted events do not come back). |
348
348
  | `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). An issue reopened on GitHub and not picked up again is not counted: ctrlrelay does not record reopening. |
349
- | `cost_per_run`, `stuck` | Always `null` for now: cost per run is not recorded yet (#223), and no run records that it was stuck (#224). The window's `unavailable` says this, and why any other field is `null`. |
349
+ | `cost_per_run` | Always `null` for now: cost per run is not recorded yet (#223). The window's `unavailable` says this, and why any other field is `null`. |
350
+ | `stuck` | Runs the relay stopped as stuck (#224), also counted in `failed`. `null` only on a database the daemon has not migrated yet. |
350
351
 
351
352
  `numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
352
353
 
@@ -400,6 +401,23 @@ The first line in `web.log` is a `web.started` event naming the address it
400
401
  bound. If `web.host` is `0.0.0.0` it also logs a warning: that listens on
401
402
  every interface, which is rarely what you want.
402
403
 
404
+ With `web.host` set to a LAN address the page also answers on
405
+ `127.0.0.1:8787`, so a browser or health check on the machine itself is not
406
+ refused, and `web.log` has a second `web.started` line for it. The LAN side
407
+ still listens on `web.host` and nothing else. If that loopback port is taken
408
+ by something else, `web.loopback_unavailable` is logged and the LAN page
409
+ starts anyway.
410
+
411
+ Every 30 seconds the server checks that `web.host` is still one of this
412
+ machine's addresses. If it is not (a new DHCP lease, a router reboot,
413
+ another network), it logs `web.address_gone` to `web.log`, prints why to
414
+ `web.error.log` and exits 1. systemd (or launchd's `KeepAlive`) restarts it;
415
+ on the old address that start fails with `Cannot listen on ...: Cannot
416
+ assign requested address` in `web.error.log`, again every few seconds, until
417
+ the address comes back or you set `web.host` to the new one. A check that
418
+ cannot be made (a `web.host` name that does not resolve) is logged once as
419
+ `web.address_unchecked` and the page keeps running.
420
+
403
421
  The page answers only to names it knows: `localhost`, `127.0.0.1`, `[::1]`,
404
422
  `web.host`, and this machine's own hostname (plus `.local`). A request whose
405
423
  `Host` header carries any other name gets `421 misdirected request`, and the
@@ -605,6 +623,43 @@ unit files above set `ExitTimeOut=180` (launchd) and
605
623
  and leaves stale `git worktree` admin state behind. If you're upgrading
606
624
  from an older plist/unit that didn't set these, add them and reload.
607
625
 
626
+ ## Stuck runs
627
+
628
+ A run that goes in circles is stopped early instead of running until
629
+ `agent.default_timeout_seconds`.
630
+
631
+ - **While the agent works**, ctrlrelay reads its live transcript every 30
632
+ seconds. If the agent runs the same command with the same input 5 times in
633
+ a row and gets the same result back each time, or gets the same error
634
+ back from the same command 5 times in a row with no success of that
635
+ command in between (edits in between do not count), the agent is stopped.
636
+ A command polled while its output moves on is waiting, not a loop; a poll
637
+ that gets the identical output back 5 times in a row is still treated as
638
+ one. Only the `claude` backend writes a transcript ctrlrelay knows how to
639
+ read; other backends are not watched.
640
+ - **Between CI fix rounds**, each failed job's log is sorted on its own into
641
+ one of three kinds. The round is *out of budget* if any job is,
642
+ *infrastructure* only if every job is, and a *code failure* otherwise; a
643
+ job whose log could not be fetched counts as a code failure, and a check
644
+ that is not an Actions job, or one past the first 3 jobs, is not sorted
645
+ at all:
646
+ - *flaky infrastructure* (the runner was lost, the network failed, a
647
+ registry answered 502/503, and no failing test is named): the failed
648
+ GitHub Actions jobs are re-run once, without asking the agent. If they
649
+ fail on infrastructure again, the run stops. A fix round in between
650
+ resets this, so a new flake later gets its own re-run.
651
+ - *out of budget* (the job hit its maximum execution time or a spending
652
+ limit): the run stops at once. A re-run would spend the same budget.
653
+ - *code failure* (anything else, including every log that names failing
654
+ tests): sent to the agent as a fix round, as before. If the same tests
655
+ fail in 3 CI rounds in a row (the first failure and two fixes that changed
656
+ nothing), the run stops instead of spending the next round.
657
+
658
+ A stopped run is recorded as `failed` with a `stuck_reason` in the
659
+ `sessions` table, its summary starts with `Stuck:`, the issue comment gives
660
+ the reason, and chat gets `🔁 Stuck on #<n>, stopped: <reason>`. The status
661
+ page's numbers count these runs in `stuck`.
662
+
608
663
  ## When to restart
609
664
 
610
665
  | You changed... | Restart... |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.20.2"
3
+ version = "0.22.0"
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"
@@ -15,6 +15,11 @@ from ctrlrelay import __version__
15
15
  from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
16
16
  from ctrlrelay.core.events import EventKind, emit_event
17
17
  from ctrlrelay.core.github import GitHubCLI, GitHubError
18
+ from ctrlrelay.core.issue_questions import (
19
+ note_chat_answer_on_issue,
20
+ note_expiry_on_issue,
21
+ watch_issue_answers,
22
+ )
18
23
  from ctrlrelay.core.job_claims import JobClaimer
19
24
  from ctrlrelay.core.obs import get_logger, log_event
20
25
  from ctrlrelay.core.pr_verifier import PRVerifier
@@ -561,6 +566,17 @@ async def announce_dev_success(
561
566
  the caller's tests assert on.
562
567
  """
563
568
  sent: list[str] = []
569
+ outputs = getattr(result, "outputs", None) or {}
570
+ if outputs.get("no_change") is True:
571
+ # No pull request by design (#336): nothing to warn about, and
572
+ # "PR ready" with an empty link would be false.
573
+ line = (
574
+ f"✅ {repo}: finished, no change needed: "
575
+ f"{outputs.get('no_change_reason') or 'no reason given'}"
576
+ )
577
+ await transport.send(line)
578
+ sent.append(line)
579
+ return sent
564
580
  warning = unreviewed_pr_warning(repo, result)
565
581
  if warning is not None:
566
582
  await transport.send(warning)
@@ -621,6 +637,12 @@ async def announce_issue_outcome(
621
637
  line = f"⏸️ Blocked on #{issue_number}: {result.question}"
622
638
  await transport.send(line)
623
639
  sent.append(line)
640
+ elif getattr(result, "stuck_reason", None):
641
+ # Its own line (#224): a stuck run cost time and quota going in
642
+ # circles, which is a different thing to look at from a crash.
643
+ line = f"🔁 Stuck on #{issue_number}, stopped: {result.stuck_reason}"
644
+ await transport.send(line)
645
+ sent.append(line)
624
646
  else:
625
647
  line = f"❌ Failed on #{issue_number}: {failure_text(result)}"
626
648
  await transport.send(line)
@@ -1192,9 +1214,17 @@ PUBLISH_ATTEMPTS = 3
1192
1214
 
1193
1215
  def publish_failure_message(
1194
1216
  repo: str, issue_number: int, error: str, *, attempts: int, retrying: bool,
1217
+ rejection: str | None = None,
1195
1218
  ) -> 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]
1219
+ """The chat line for a run whose branch the relay could not publish.
1220
+
1221
+ When the remote rejected the push, its reason is the line shown: the
1222
+ full error leads with the URL, sha and ref, and its first 200
1223
+ characters ended before the reason did (#325)."""
1224
+ if rejection and rejection.strip():
1225
+ why = rejection.strip()[:300]
1226
+ else:
1227
+ why = (error.strip().splitlines() or ["no error recorded"])[0][:200]
1198
1228
  if retrying:
1199
1229
  return (
1200
1230
  f"\u23f3 #{issue_number} in {repo}: the agent finished but the "
@@ -1253,6 +1283,13 @@ async def settle_publish_failure(
1253
1283
  _logger, "publish.refused_not_retried", repo=repo,
1254
1284
  issue_number=issue_number, error=(result.error or "")[:200],
1255
1285
  )
1286
+ refusal = outputs.get("publish_refusal")
1287
+ if isinstance(refusal, str) and refusal:
1288
+ return (
1289
+ f"\u26d4 #{issue_number} in {repo}: the agent finished, but "
1290
+ f"the branch cannot be published, so the relay is not "
1291
+ f"retrying: {refusal}."
1292
+ )
1256
1293
  return (
1257
1294
  f"\u26d4 #{issue_number} in {repo}: the relay refused to push "
1258
1295
  f"the branch, so it is not retrying. {result.error or ''}".strip()
@@ -1286,9 +1323,11 @@ async def settle_publish_failure(
1286
1323
  error=f"{type(e).__name__}: {str(e)[:200]}",
1287
1324
  )
1288
1325
  notice_failed = True
1326
+ rejection = outputs.get("push_rejection")
1289
1327
  message = publish_failure_message(
1290
1328
  repo, issue_number, result.error or "",
1291
1329
  attempts=attempts, retrying=retrying,
1330
+ rejection=rejection if isinstance(rejection, str) else None,
1292
1331
  )
1293
1332
  if notice_failed:
1294
1333
  # The issue's last word is still the retraction promising a
@@ -2514,6 +2553,7 @@ def run_dev(
2514
2553
  code_review=repo_config.code_review,
2515
2554
  max_fix_attempts=repo_config.max_fix_attempts,
2516
2555
  steps=repo_config.steps,
2556
+ question_on_issue=repo_config.question_on_issue,
2517
2557
  )
2518
2558
 
2519
2559
  try:
@@ -3268,6 +3308,7 @@ def poller_start(
3268
3308
  max_fix_attempts=repo_config.max_fix_attempts,
3269
3309
  steps=repo_config.steps,
3270
3310
  claims=claims,
3311
+ question_on_issue=repo_config.question_on_issue,
3271
3312
  )
3272
3313
 
3273
3314
  # Lost the race check to another machine (#245): not a
@@ -3673,6 +3714,32 @@ def poller_start(
3673
3714
  f"[dim]question_expiry: retired "
3674
3715
  f"{row['session_id']} ({row['expired_reason']})[/dim]"
3675
3716
  )
3717
+ # Expired in every channel at once (#328): the row stops
3718
+ # being routable in chat and watched on the issue; the
3719
+ # issue is told.
3720
+ await note_expiry_on_issue(github, row)
3721
+
3722
+ async def _notify_chat_once(text: str) -> None:
3723
+ """Send one chat message on a short-lived bridge connection:
3724
+ the note that a question was answered on GitHub (#328)."""
3725
+ settings = _bridge_socket_settings(config)
3726
+ if settings is None:
3727
+ return
3728
+ from ctrlrelay.transports import SocketTransport
3729
+ sock, ask_timeout = settings
3730
+ if not sock.exists():
3731
+ return
3732
+ note_transport = SocketTransport(
3733
+ sock, ask_timeout_seconds=ask_timeout,
3734
+ )
3735
+ await note_transport.connect()
3736
+ try:
3737
+ await note_transport.send(text)
3738
+ finally:
3739
+ try:
3740
+ await note_transport.close()
3741
+ except Exception:
3742
+ pass
3676
3743
 
3677
3744
  async def _run_pending_resume_sweeper() -> None:
3678
3745
  """Drain pending_resumes rows that an operator answered via
@@ -3686,6 +3753,12 @@ def poller_start(
3686
3753
  resumed and fans out a Telegram notification with the result
3687
3754
  so the operator knows whether the resume landed.
3688
3755
  """
3756
+ # Questions asked on their GitHub issue as well (#328): read
3757
+ # each one's issue for an answer, so a row answered there is
3758
+ # resumed in this same tick.
3759
+ await watch_issue_answers(
3760
+ github, state_db, notify=_notify_chat_once,
3761
+ )
3689
3762
  try:
3690
3763
  pending = state_db.list_pending_resumes_to_execute()
3691
3764
  except Exception as e:
@@ -3749,6 +3822,17 @@ def poller_start(
3749
3822
  pipeline_name = row["pipeline"]
3750
3823
  answer = row["answer"] or ""
3751
3824
 
3825
+ # A chat answer to a question also asked on the issue
3826
+ # is copied there before the resume (#328), once.
3827
+ sweep_cfg = repo_cfg_by_name.get(repo)
3828
+ await note_chat_answer_on_issue(
3829
+ github, state_db, row,
3830
+ mode=(
3831
+ sweep_cfg.question_on_issue
3832
+ if sweep_cfg is not None else "auto"
3833
+ ),
3834
+ )
3835
+
3752
3836
  if sweeper_transport:
3753
3837
  try:
3754
3838
  await sweeper_transport.send(
@@ -3822,6 +3906,7 @@ def poller_start(
3822
3906
  max_fix_attempts=repo_cfg.max_fix_attempts,
3823
3907
  code_review=repo_cfg.code_review,
3824
3908
  steps=repo_cfg.steps,
3909
+ question_on_issue=repo_cfg.question_on_issue,
3825
3910
  )
3826
3911
  elif pipeline_name == "task":
3827
3912
  result = await resume_task_from_pending(
@@ -4512,7 +4597,13 @@ def web_start(
4512
4597
  pid_file.write_text(str(os.getpid()))
4513
4598
  try:
4514
4599
  from ctrlrelay.core.github import GitHubCLI
4515
- from ctrlrelay.web.server import WaitingRefresher, make_server
4600
+ from ctrlrelay.web.server import (
4601
+ AddressWatch,
4602
+ Listeners,
4603
+ WaitingRefresher,
4604
+ make_loopback_twin,
4605
+ make_server,
4606
+ )
4516
4607
  from ctrlrelay.web.status import make_sessions_reader
4517
4608
  from ctrlrelay.web.waiting import WaitingTracker
4518
4609
 
@@ -4533,10 +4624,19 @@ def web_start(
4533
4624
  ) if fleet_cfg.peers else None
4534
4625
  httpd = make_server(config, waiting=tracker, peers=peers)
4535
4626
  except OSError as e:
4536
- console.print(
4537
- f"[red]Cannot listen on {config.web.host}:{config.web.port}:[/red] {e}"
4627
+ # stderr, so a restart loop on an address this machine no longer
4628
+ # has (#322) lands in web.error.log, not only beside web.started.
4629
+ Console(stderr=True).print(
4630
+ f"[red]Cannot listen on {config.web.host}:{config.web.port}:[/red] {e}",
4631
+ soft_wrap=True,
4538
4632
  )
4539
4633
  raise typer.Exit(1)
4634
+ # Answers on 127.0.0.1 too when web.host is one LAN address (#322).
4635
+ twin = make_loopback_twin(config, waiting=tracker, peers=peers)
4636
+ listeners = Listeners(
4637
+ httpd, twin,
4638
+ watch=AddressWatch(config.web.host, on_gone=lambda: None),
4639
+ )
4540
4640
  from ctrlrelay.core.github_budget import LOW, OUT, BudgetStore
4541
4641
 
4542
4642
  budget_store = BudgetStore.beside(config.paths.state_db)
@@ -4561,12 +4661,21 @@ def web_start(
4561
4661
  if peers is not None:
4562
4662
  peers.start()
4563
4663
  try:
4564
- httpd.serve_forever()
4664
+ address_gone = listeners.serve_forever()
4565
4665
  finally:
4566
4666
  if peers is not None:
4567
4667
  peers.stop()
4568
4668
  refresher.stop()
4569
- httpd.server_close()
4669
+ listeners.close()
4670
+ if address_gone:
4671
+ # Exit non-zero so the service manager restarts it (#322).
4672
+ Console(stderr=True).print(
4673
+ f"[red]{config.web.host} is no longer an address of this "
4674
+ "machine; stopped the status page.[/red] Set web.host to the "
4675
+ "new address, or it restarts and fails until the old one is back.",
4676
+ soft_wrap=True,
4677
+ )
4678
+ raise typer.Exit(1)
4570
4679
  finally:
4571
4680
  pid_file.unlink(missing_ok=True)
4572
4681
 
@@ -606,6 +606,21 @@ class ClaimKeeper:
606
606
  task.add_done_callback(_forget)
607
607
  return False
608
608
 
609
+ async def resume_working(self) -> bool:
610
+ """Back to ``working`` after a ``hold`` for a question that has
611
+ now been answered in process (#328): cancel any background hold
612
+ retry, write ``working`` and start refreshing again. A claim taken
613
+ over stays the taker's. Returns whether ``working`` was written;
614
+ refreshing restarts either way, and its next tick writes it."""
615
+ if self.taken_over_by is not None:
616
+ return False
617
+ pending = _BACKGROUND_HOLDS.pop(self.claim_id, None)
618
+ if pending is not None:
619
+ pending.cancel()
620
+ written = await self._write(WORKING)
621
+ self.start()
622
+ return written
623
+
609
624
  async def _hold_later(self) -> None:
610
625
  deadline = self._confirmed_at + self.settings.stale_after_seconds
611
626
  while True:
@@ -624,6 +624,14 @@ class RepoConfig(BaseModel):
624
624
  # default is the pipeline's own (DEFAULT_MAX_FIX_ATTEMPTS in
625
625
  # pipelines/dev.py; a test keeps the two equal). 0 means no fix round.
626
626
  max_fix_attempts: int = Field(default=3, ge=0)
627
+ # What a dev run's question looks like on its GitHub issue (#328).
628
+ # The question is always asked there as well as in chat. ``auto``:
629
+ # the full text on a private repository and a short "a question is
630
+ # waiting in chat" notice on a public one (or one whose visibility
631
+ # could not be read); ``full`` and ``notice`` force one or the other.
632
+ # A question can name machines, other repositories and plans, which
633
+ # is why a public repository does not get it by default.
634
+ question_on_issue: Literal["auto", "full", "notice"] = "auto"
627
635
 
628
636
  @model_validator(mode="after")
629
637
  def _review_settings_agree(self) -> RepoConfig: