ctrlrelay 0.20.2__tar.gz → 0.21.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 (215) hide show
  1. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/CHANGELOG.md +50 -1
  2. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/config/orchestrator.yaml.example +4 -0
  4. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/configuration.md +3 -2
  5. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/operations.md +56 -1
  6. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/cli.py +105 -7
  8. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/claims.py +15 -0
  9. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/config.py +8 -0
  10. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/dispatcher.py +100 -1
  11. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/events.py +4 -0
  12. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/github.py +60 -0
  13. ctrlrelay-0.21.0/src/ctrlrelay/core/issue_questions.py +461 -0
  14. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/state.py +113 -6
  15. ctrlrelay-0.21.0/src/ctrlrelay/core/stuck.py +424 -0
  16. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/base.py +6 -0
  17. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/dev.py +407 -23
  18. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/numbers.py +24 -9
  19. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/server.py +202 -1
  20. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/static/index.html +3 -3
  21. ctrlrelay-0.21.0/tests/test_dev_stuck.py +244 -0
  22. ctrlrelay-0.21.0/tests/test_issue_questions.py +482 -0
  23. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_publish_failure_retry.py +94 -0
  24. ctrlrelay-0.21.0/tests/test_stuck.py +333 -0
  25. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_numbers.py +41 -8
  26. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_server.py +191 -0
  27. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  28. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  29. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  30. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/dependabot.yml +0 -0
  31. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/build.yml +0 -0
  32. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/cla.yml +0 -0
  33. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/pages.yml +0 -0
  34. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/publish.yml +0 -0
  35. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.github/workflows/test.yml +0 -0
  36. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/.gitignore +0 -0
  37. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/CODE_OF_CONDUCT.md +0 -0
  38. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/CONTRIBUTING.md +0 -0
  39. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/LICENSE +0 -0
  40. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/README.md +0 -0
  41. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/SECURITY.md +0 -0
  42. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/Gemfile +0 -0
  43. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/_config.yml +0 -0
  44. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/adding-a-machine.md +0 -0
  45. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/architecture.md +0 -0
  46. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/bridge.md +0 -0
  47. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/cli.md +0 -0
  48. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/development.md +0 -0
  49. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/feedback-loop.md +0 -0
  50. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/getting-started.md +0 -0
  51. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/index.md +0 -0
  52. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/llms.txt +0 -0
  53. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/personalization.md +0 -0
  54. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/registered-mode.md +0 -0
  55. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  56. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  57. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/__init__.py +0 -0
  58. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  59. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  60. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/factory.py +0 -0
  61. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/handler.py +0 -0
  62. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  63. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  64. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  65. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/server.py +0 -0
  66. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  67. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/__init__.py +0 -0
  68. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/agent_env.py +0 -0
  69. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/archived.py +0 -0
  70. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/audit.py +0 -0
  71. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/binaries.py +0 -0
  72. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  73. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/code_review.py +0 -0
  74. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  75. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/github_budget.py +0 -0
  76. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  77. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/job_claims.py +0 -0
  78. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/network.py +0 -0
  79. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/node_check.py +0 -0
  80. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/obs.py +0 -0
  81. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/orphans.py +0 -0
  82. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/poller.py +0 -0
  83. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  84. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  85. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/publish.py +0 -0
  86. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  87. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/redact.py +0 -0
  88. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/scheduler.py +0 -0
  89. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/usage.py +0 -0
  90. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/core/worktree.py +0 -0
  91. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  92. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/dashboard/client.py +0 -0
  93. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/gh_protocol.py +0 -0
  94. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/install.py +0 -0
  95. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  96. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/manager.py +0 -0
  97. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/merge.py +0 -0
  98. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/personalization/paths.py +0 -0
  99. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  100. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  101. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  102. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/pipelines/task.py +0 -0
  103. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/setup.py +0 -0
  104. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/__init__.py +0 -0
  105. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  106. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  107. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  108. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  109. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  110. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  111. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  112. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  113. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/__init__.py +0 -0
  114. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/base.py +0 -0
  115. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  116. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  117. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/__init__.py +0 -0
  118. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/fleet.py +0 -0
  119. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/health.py +0 -0
  120. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/links.py +0 -0
  121. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/status.py +0 -0
  122. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/transcript.py +0 -0
  123. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/src/ctrlrelay/web/waiting.py +0 -0
  124. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/__init__.py +0 -0
  125. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/_poller_fakes.py +0 -0
  126. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/_publish_fakes.py +0 -0
  127. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/conftest.py +0 -0
  128. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/fixtures/gh_search_issues.json +0 -0
  129. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  130. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  131. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_backend_wiring.py +0 -0
  132. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_no_keys.py +0 -0
  133. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_routing.py +0 -0
  134. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_agent_sandbox.py +0 -0
  135. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_archived.py +0 -0
  136. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_audit.py +0 -0
  137. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_binary_resolution.py +0 -0
  138. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_bridge_protocol.py +0 -0
  139. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_bridge_server.py +0 -0
  140. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_bridge_start_command.py +0 -0
  141. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_chat_machines.py +0 -0
  142. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_checkpoint.py +0 -0
  143. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_claim_reconciliation.py +0 -0
  144. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_ci_wait.py +0 -0
  145. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_dev.py +0 -0
  146. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_repos.py +0 -0
  147. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_secops.py +0 -0
  148. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_start.py +0 -0
  149. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_cli_version.py +0 -0
  150. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_code_review.py +0 -0
  151. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_config.py +0 -0
  152. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dashboard_client.py +0 -0
  153. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_integration.py +0 -0
  154. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_pipeline.py +0 -0
  155. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_plan_step.py +0 -0
  156. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_step_switches.py +0 -0
  157. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dev_work_steps.py +0 -0
  158. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dispatch_health.py +0 -0
  159. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dispatch_outcome_hook.py +0 -0
  160. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_dispatcher.py +0 -0
  161. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_docs_site.py +0 -0
  162. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_event_emission.py +0 -0
  163. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_events.py +0 -0
  164. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_fleet.py +0 -0
  165. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_gh_protocol.py +0 -0
  166. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_github.py +0 -0
  167. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_github_budget.py +0 -0
  168. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_install.py +0 -0
  169. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_issue_outcome_announcement.py +0 -0
  170. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_job_claims.py +0 -0
  171. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_mattermost_handler.py +0 -0
  172. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_network.py +0 -0
  173. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_node_check.py +0 -0
  174. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_node_claims.py +0 -0
  175. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_notify_transport.py +0 -0
  176. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_obs.py +0 -0
  177. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_opencode_adapter.py +0 -0
  178. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_orphans.py +0 -0
  179. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_personalization.py +0 -0
  180. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_personalization_merge.py +0 -0
  181. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pipeline_base.py +0 -0
  182. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_poller.py +0 -0
  183. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_poller_recheck.py +0 -0
  184. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_poller_search.py +0 -0
  185. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_post_merge.py +0 -0
  186. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pr_verifier.py +0 -0
  187. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pr_watcher.py +0 -0
  188. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_pre_dispatch_retry.py +0 -0
  189. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_question_backoff.py +0 -0
  190. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_question_expiry.py +0 -0
  191. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_question_survives_restart.py +0 -0
  192. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_redact.py +0 -0
  193. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_relay_alert_off_lock.py +0 -0
  194. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_relay_git_263.py +0 -0
  195. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_resume_prompt_contract.py +0 -0
  196. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_review_copy.py +0 -0
  197. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_scheduler.py +0 -0
  198. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_secops_integration.py +0 -0
  199. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_secops_pipeline.py +0 -0
  200. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_setup.py +0 -0
  201. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_state.py +0 -0
  202. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_task_pipeline.py +0 -0
  203. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_telegram_handler.py +0 -0
  204. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_timeout_save.py +0 -0
  205. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_transport.py +0 -0
  206. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_transport_resolution.py +0 -0
  207. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  208. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_usage.py +0 -0
  209. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_health.py +0 -0
  210. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_links.py +0 -0
  211. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_peer_links.py +0 -0
  212. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_status.py +0 -0
  213. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_transcript.py +0 -0
  214. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_web_waiting.py +0 -0
  215. {ctrlrelay-0.20.2 → ctrlrelay-0.21.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.21.0] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - A run going in circles is stopped early with a clear reason, instead of
15
+ running until the session timeout (#224). The agent is stopped when its
16
+ live transcript shows the same command 5 times in a row with the same
17
+ result each time (polling a job whose output moves on is not a loop,
18
+ though 5 identical polls in a row still are), or
19
+ the same error from the same command 5 times in a row. Between CI fix
20
+ rounds each failed job's log is sorted on its own, as flaky infrastructure (the failed jobs are re-run once, without
21
+ the agent), out of budget (not retried), or a code failure (sent to the
22
+ agent, as before); the same tests failing in 3 CI rounds in a row stops the
23
+ run. A stopped run is recorded as failed with `sessions.stuck_reason`, the
24
+ issue comment and chat say it was stuck and why, and the status page counts
25
+ these runs. See "Stuck runs" in the operations guide.
26
+
27
+ - A dev run's question is now asked on its GitHub issue as well as in chat,
28
+ and the first answer from either one wins. An answer on the issue is a
29
+ comment that starts with `/answer`, since the relay and every working
30
+ session comment as the operator's account; it counts only from an account
31
+ with write access to the repository, and only if it is newer than the
32
+ question and carries no relay marker; other comments
33
+ are logged and ignored. The chat is told when the issue answered it, and a
34
+ chat answer is copied to the issue. A reply that loses is told it was not
35
+ applied, and never resumes the session a second time. On a public
36
+ repository the issue gets a short notice instead of the question unless
37
+ `repos[].question_on_issue` says `full`. An expired question is retired
38
+ in both channels and the issue says so. While the question waits, the
39
+ issue's claim is marked held, so another machine does not take the issue
40
+ if this one stops; it goes back to working when the run resumes.
41
+
42
+ ### Fixed
43
+
44
+ - A push GitHub refuses because the branch changes a file under
45
+ `.github/workflows/` and the relay's token has no `workflow` scope is no
46
+ longer retried. It cost one full agent run per poll, up to three, for a
47
+ push that could never pass. Chat now says the branch changes a workflow
48
+ file, names it, and says why no retry can pass. For any other rejected
49
+ push, the chat line shows the remote's reason rather than the first 200
50
+ characters of the error, which ended before the reason (#325).
51
+
52
+ - The status page bound to a LAN address now also answers on
53
+ `127.0.0.1`, and it no longer keeps running, silently, on an address the
54
+ machine has lost. It checks every 30 seconds; when `web.host` is gone it
55
+ logs `web.address_gone`, says so in `web.error.log` and exits 1 so the
56
+ service manager restarts it (#322).
57
+
10
58
  ## [0.20.2] - 2026-11-04
11
59
 
12
60
  ### Fixed
@@ -2359,7 +2407,8 @@ pipeline).
2359
2407
  per-phase implementation plans (Phase 0 through Phase 4).
2360
2408
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2361
2409
 
2362
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...HEAD
2410
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...HEAD
2411
+ [0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
2363
2412
  [0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
2364
2413
  [0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
2365
2414
  [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.21.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,7 +534,7 @@ 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. |
@@ -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
@@ -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.21.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
@@ -621,6 +626,12 @@ async def announce_issue_outcome(
621
626
  line = f"⏸️ Blocked on #{issue_number}: {result.question}"
622
627
  await transport.send(line)
623
628
  sent.append(line)
629
+ elif getattr(result, "stuck_reason", None):
630
+ # Its own line (#224): a stuck run cost time and quota going in
631
+ # circles, which is a different thing to look at from a crash.
632
+ line = f"🔁 Stuck on #{issue_number}, stopped: {result.stuck_reason}"
633
+ await transport.send(line)
634
+ sent.append(line)
624
635
  else:
625
636
  line = f"❌ Failed on #{issue_number}: {failure_text(result)}"
626
637
  await transport.send(line)
@@ -1192,9 +1203,17 @@ PUBLISH_ATTEMPTS = 3
1192
1203
 
1193
1204
  def publish_failure_message(
1194
1205
  repo: str, issue_number: int, error: str, *, attempts: int, retrying: bool,
1206
+ rejection: str | None = None,
1195
1207
  ) -> 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]
1208
+ """The chat line for a run whose branch the relay could not publish.
1209
+
1210
+ When the remote rejected the push, its reason is the line shown: the
1211
+ full error leads with the URL, sha and ref, and its first 200
1212
+ characters ended before the reason did (#325)."""
1213
+ if rejection and rejection.strip():
1214
+ why = rejection.strip()[:300]
1215
+ else:
1216
+ why = (error.strip().splitlines() or ["no error recorded"])[0][:200]
1198
1217
  if retrying:
1199
1218
  return (
1200
1219
  f"\u23f3 #{issue_number} in {repo}: the agent finished but the "
@@ -1253,6 +1272,13 @@ async def settle_publish_failure(
1253
1272
  _logger, "publish.refused_not_retried", repo=repo,
1254
1273
  issue_number=issue_number, error=(result.error or "")[:200],
1255
1274
  )
1275
+ refusal = outputs.get("publish_refusal")
1276
+ if isinstance(refusal, str) and refusal:
1277
+ return (
1278
+ f"\u26d4 #{issue_number} in {repo}: the agent finished, but "
1279
+ f"the branch cannot be published, so the relay is not "
1280
+ f"retrying: {refusal}."
1281
+ )
1256
1282
  return (
1257
1283
  f"\u26d4 #{issue_number} in {repo}: the relay refused to push "
1258
1284
  f"the branch, so it is not retrying. {result.error or ''}".strip()
@@ -1286,9 +1312,11 @@ async def settle_publish_failure(
1286
1312
  error=f"{type(e).__name__}: {str(e)[:200]}",
1287
1313
  )
1288
1314
  notice_failed = True
1315
+ rejection = outputs.get("push_rejection")
1289
1316
  message = publish_failure_message(
1290
1317
  repo, issue_number, result.error or "",
1291
1318
  attempts=attempts, retrying=retrying,
1319
+ rejection=rejection if isinstance(rejection, str) else None,
1292
1320
  )
1293
1321
  if notice_failed:
1294
1322
  # The issue's last word is still the retraction promising a
@@ -2514,6 +2542,7 @@ def run_dev(
2514
2542
  code_review=repo_config.code_review,
2515
2543
  max_fix_attempts=repo_config.max_fix_attempts,
2516
2544
  steps=repo_config.steps,
2545
+ question_on_issue=repo_config.question_on_issue,
2517
2546
  )
2518
2547
 
2519
2548
  try:
@@ -3268,6 +3297,7 @@ def poller_start(
3268
3297
  max_fix_attempts=repo_config.max_fix_attempts,
3269
3298
  steps=repo_config.steps,
3270
3299
  claims=claims,
3300
+ question_on_issue=repo_config.question_on_issue,
3271
3301
  )
3272
3302
 
3273
3303
  # Lost the race check to another machine (#245): not a
@@ -3673,6 +3703,32 @@ def poller_start(
3673
3703
  f"[dim]question_expiry: retired "
3674
3704
  f"{row['session_id']} ({row['expired_reason']})[/dim]"
3675
3705
  )
3706
+ # Expired in every channel at once (#328): the row stops
3707
+ # being routable in chat and watched on the issue; the
3708
+ # issue is told.
3709
+ await note_expiry_on_issue(github, row)
3710
+
3711
+ async def _notify_chat_once(text: str) -> None:
3712
+ """Send one chat message on a short-lived bridge connection:
3713
+ the note that a question was answered on GitHub (#328)."""
3714
+ settings = _bridge_socket_settings(config)
3715
+ if settings is None:
3716
+ return
3717
+ from ctrlrelay.transports import SocketTransport
3718
+ sock, ask_timeout = settings
3719
+ if not sock.exists():
3720
+ return
3721
+ note_transport = SocketTransport(
3722
+ sock, ask_timeout_seconds=ask_timeout,
3723
+ )
3724
+ await note_transport.connect()
3725
+ try:
3726
+ await note_transport.send(text)
3727
+ finally:
3728
+ try:
3729
+ await note_transport.close()
3730
+ except Exception:
3731
+ pass
3676
3732
 
3677
3733
  async def _run_pending_resume_sweeper() -> None:
3678
3734
  """Drain pending_resumes rows that an operator answered via
@@ -3686,6 +3742,12 @@ def poller_start(
3686
3742
  resumed and fans out a Telegram notification with the result
3687
3743
  so the operator knows whether the resume landed.
3688
3744
  """
3745
+ # Questions asked on their GitHub issue as well (#328): read
3746
+ # each one's issue for an answer, so a row answered there is
3747
+ # resumed in this same tick.
3748
+ await watch_issue_answers(
3749
+ github, state_db, notify=_notify_chat_once,
3750
+ )
3689
3751
  try:
3690
3752
  pending = state_db.list_pending_resumes_to_execute()
3691
3753
  except Exception as e:
@@ -3749,6 +3811,17 @@ def poller_start(
3749
3811
  pipeline_name = row["pipeline"]
3750
3812
  answer = row["answer"] or ""
3751
3813
 
3814
+ # A chat answer to a question also asked on the issue
3815
+ # is copied there before the resume (#328), once.
3816
+ sweep_cfg = repo_cfg_by_name.get(repo)
3817
+ await note_chat_answer_on_issue(
3818
+ github, state_db, row,
3819
+ mode=(
3820
+ sweep_cfg.question_on_issue
3821
+ if sweep_cfg is not None else "auto"
3822
+ ),
3823
+ )
3824
+
3752
3825
  if sweeper_transport:
3753
3826
  try:
3754
3827
  await sweeper_transport.send(
@@ -3822,6 +3895,7 @@ def poller_start(
3822
3895
  max_fix_attempts=repo_cfg.max_fix_attempts,
3823
3896
  code_review=repo_cfg.code_review,
3824
3897
  steps=repo_cfg.steps,
3898
+ question_on_issue=repo_cfg.question_on_issue,
3825
3899
  )
3826
3900
  elif pipeline_name == "task":
3827
3901
  result = await resume_task_from_pending(
@@ -4512,7 +4586,13 @@ def web_start(
4512
4586
  pid_file.write_text(str(os.getpid()))
4513
4587
  try:
4514
4588
  from ctrlrelay.core.github import GitHubCLI
4515
- from ctrlrelay.web.server import WaitingRefresher, make_server
4589
+ from ctrlrelay.web.server import (
4590
+ AddressWatch,
4591
+ Listeners,
4592
+ WaitingRefresher,
4593
+ make_loopback_twin,
4594
+ make_server,
4595
+ )
4516
4596
  from ctrlrelay.web.status import make_sessions_reader
4517
4597
  from ctrlrelay.web.waiting import WaitingTracker
4518
4598
 
@@ -4533,10 +4613,19 @@ def web_start(
4533
4613
  ) if fleet_cfg.peers else None
4534
4614
  httpd = make_server(config, waiting=tracker, peers=peers)
4535
4615
  except OSError as e:
4536
- console.print(
4537
- f"[red]Cannot listen on {config.web.host}:{config.web.port}:[/red] {e}"
4616
+ # stderr, so a restart loop on an address this machine no longer
4617
+ # has (#322) lands in web.error.log, not only beside web.started.
4618
+ Console(stderr=True).print(
4619
+ f"[red]Cannot listen on {config.web.host}:{config.web.port}:[/red] {e}",
4620
+ soft_wrap=True,
4538
4621
  )
4539
4622
  raise typer.Exit(1)
4623
+ # Answers on 127.0.0.1 too when web.host is one LAN address (#322).
4624
+ twin = make_loopback_twin(config, waiting=tracker, peers=peers)
4625
+ listeners = Listeners(
4626
+ httpd, twin,
4627
+ watch=AddressWatch(config.web.host, on_gone=lambda: None),
4628
+ )
4540
4629
  from ctrlrelay.core.github_budget import LOW, OUT, BudgetStore
4541
4630
 
4542
4631
  budget_store = BudgetStore.beside(config.paths.state_db)
@@ -4561,12 +4650,21 @@ def web_start(
4561
4650
  if peers is not None:
4562
4651
  peers.start()
4563
4652
  try:
4564
- httpd.serve_forever()
4653
+ address_gone = listeners.serve_forever()
4565
4654
  finally:
4566
4655
  if peers is not None:
4567
4656
  peers.stop()
4568
4657
  refresher.stop()
4569
- httpd.server_close()
4658
+ listeners.close()
4659
+ if address_gone:
4660
+ # Exit non-zero so the service manager restarts it (#322).
4661
+ Console(stderr=True).print(
4662
+ f"[red]{config.web.host} is no longer an address of this "
4663
+ "machine; stopped the status page.[/red] Set web.host to the "
4664
+ "new address, or it restarts and fails until the old one is back.",
4665
+ soft_wrap=True,
4666
+ )
4667
+ raise typer.Exit(1)
4570
4668
  finally:
4571
4669
  pid_file.unlink(missing_ok=True)
4572
4670
 
@@ -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:
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import asyncio
13
13
  import json
14
14
  import os
15
+ import re
15
16
  import shutil
16
17
  import tempfile
17
18
  import time
@@ -35,6 +36,7 @@ from ctrlrelay.core.agent_env import (
35
36
  from ctrlrelay.core.binaries import resolve_binary
36
37
  from ctrlrelay.core.checkpoint import CheckpointState, CheckpointStatus, read_checkpoint
37
38
  from ctrlrelay.core.obs import get_logger, hash_text, log_event
39
+ from ctrlrelay.core.stuck import StuckPolicy, watch_transcript
38
40
  from ctrlrelay.core.usage import (
39
41
  UsageStore,
40
42
  parse_agent_session_id,
@@ -136,6 +138,9 @@ class SessionResult:
136
138
  # The relay killed the agent at its time limit (#311). Set only by the
137
139
  # relay's own timeout path, never parsed from anything the agent wrote.
138
140
  timed_out: bool = False
141
+ # The relay stopped the agent because its transcript showed it going in
142
+ # circles (#224), and why. Set only by the relay's own watch.
143
+ stuck_reason: str | None = None
139
144
 
140
145
  @property
141
146
  def success(self) -> bool:
@@ -188,11 +193,69 @@ class CliAgentAdapter:
188
193
  # name the backend without every subclass remembering to.
189
194
  agent_type: ClassVar[str] = "unknown"
190
195
 
196
+ # How the live transcript is watched for a run going in circles (#224).
197
+ # A plain class attribute, not a dataclass field: every adapter gets
198
+ # the watch by construction, and a test sets it on the instance.
199
+ # ``None`` turns the watch off.
200
+ stuck_policy: StuckPolicy | None = StuckPolicy()
201
+
191
202
  @property
192
203
  def agent_binary(self) -> str:
193
204
  """The executable this adapter runs. Subclasses override."""
194
205
  raise NotImplementedError
195
206
 
207
+ def _transcript_dirs(
208
+ self, working_dir: Path, env: dict[str, str]
209
+ ) -> list[Path]:
210
+ """Where this CLI writes the live transcript of a run in
211
+ ``working_dir``. Empty by default: a backend whose transcript is
212
+ not known is not watched, rather than watched somewhere wrong."""
213
+ return []
214
+
215
+ async def _communicate_watched(
216
+ self,
217
+ proc: Any,
218
+ *,
219
+ folders: list[Path],
220
+ since: float,
221
+ ident: dict[str, Any],
222
+ stuck: list[str],
223
+ ) -> tuple[bytes, bytes]:
224
+ """``proc.communicate()``, while the transcript is watched (#224).
225
+
226
+ When the watch sees a loop it records the reason in ``stuck`` and
227
+ kills the child, which ends ``communicate()`` the normal way. The
228
+ watch is cancelled however this returns, the time limit included.
229
+ """
230
+ if not folders or self.stuck_policy is None:
231
+ return await proc.communicate()
232
+ policy = self.stuck_policy
233
+
234
+ async def _watch() -> None:
235
+ reason = await watch_transcript(
236
+ folders, since=since, policy=policy, ident=ident,
237
+ )
238
+ stuck.append(reason)
239
+ _logger.warning(
240
+ "dispatcher.session.stuck",
241
+ extra={**ident, "reason": reason},
242
+ )
243
+ try:
244
+ if proc.returncode is None:
245
+ proc.kill()
246
+ except ProcessLookupError:
247
+ pass
248
+
249
+ watcher = asyncio.create_task(_watch())
250
+ try:
251
+ return await proc.communicate()
252
+ finally:
253
+ watcher.cancel()
254
+ try:
255
+ await watcher
256
+ except BaseException:
257
+ pass
258
+
196
259
  def _parse_agent_session_id(self, stdout_text: str) -> str | None:
197
260
  """The agent's own session id, for a later resume.
198
261
 
@@ -288,6 +351,10 @@ class CliAgentAdapter:
288
351
  prompt_len=len(prompt),
289
352
  )
290
353
  started = time.monotonic()
354
+ # Transcript rows are stamped in wall-clock time. A second of slack
355
+ # for the clock the CLI reads being a hair behind this one.
356
+ wall_started = time.time() - 1.0
357
+ stuck: list[str] = []
291
358
 
292
359
  try:
293
360
  proc = await asyncio.create_subprocess_exec(
@@ -328,7 +395,14 @@ class CliAgentAdapter:
328
395
 
329
396
  try:
330
397
  stdout, stderr = await asyncio.wait_for(
331
- proc.communicate(), timeout=timeout
398
+ self._communicate_watched(
399
+ proc,
400
+ folders=self._transcript_dirs(working_dir, env),
401
+ since=wall_started,
402
+ ident=ident,
403
+ stuck=stuck,
404
+ ),
405
+ timeout=timeout,
332
406
  )
333
407
  except asyncio.TimeoutError as e:
334
408
  proc.kill()
@@ -482,6 +556,15 @@ class CliAgentAdapter:
482
556
  agent_session_id = self._parse_agent_session_id(stdout_text)
483
557
  self._record_usage(stdout_text, ident)
484
558
 
559
+ # A checkpoint the agent wrote wins: it finished, or asked, before
560
+ # the kill landed, so it was not stuck at the end.
561
+ stuck_reason = stuck[0] if stuck and state is None else None
562
+ if stuck_reason is not None:
563
+ # In stderr too, so a lane that reads only stderr still says why.
564
+ stderr_text = (
565
+ f"Session stopped: stuck: {stuck_reason}\n{stderr_text}"
566
+ )
567
+
485
568
  log_event(
486
569
  _logger,
487
570
  "dispatcher.session.finished",
@@ -510,6 +593,7 @@ class CliAgentAdapter:
510
593
  stdout=stdout_text,
511
594
  stderr=stderr_text,
512
595
  agent_session_id=agent_session_id,
596
+ stuck_reason=stuck_reason,
513
597
  )
514
598
 
515
599
 
@@ -560,6 +644,21 @@ class ClaudeDispatcher(CliAgentAdapter):
560
644
  def _parse_agent_session_id(self, stdout_text: str) -> str | None:
561
645
  return _extract_agent_session_id(stdout_text)
562
646
 
647
+ def _transcript_dirs(
648
+ self, working_dir: Path, env: dict[str, str]
649
+ ) -> list[Path]:
650
+ """``<config dir>/projects/<working dir, "/", "." and "_" as "-">``,
651
+ the folder :mod:`ctrlrelay.web.transcript` reads too. Both the path
652
+ as given and its resolved form, since which one the CLI sees
653
+ depends on how the worktree path was spelled."""
654
+ base = Path(env.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
655
+ out: list[Path] = []
656
+ for spelled in (str(working_dir), str(working_dir.resolve())):
657
+ folder = base / "projects" / re.sub(r"[/._]", "-", spelled)
658
+ if folder not in out:
659
+ out.append(folder)
660
+ return out
661
+
563
662
  def __post_init__(self) -> None:
564
663
  # Only auto-resolve the bare default. Absolute paths, relative paths
565
664
  # (resolved vs. working_dir by the child), and custom bare names pass
@@ -148,6 +148,7 @@ def emit_outcome(
148
148
  kind = _OUTCOME_KINDS[f["status"]]
149
149
  exit_code = f.get("exit_code")
150
150
  question = f.get("question")
151
+ stuck_reason = f.get("stuck_reason")
151
152
  fields = {
152
153
  "repo": f.get("repo"),
153
154
  "issue_number": f.get("issue_number"),
@@ -167,6 +168,9 @@ def emit_outcome(
167
168
  ),
168
169
  },
169
170
  }
171
+ # Only on a run the relay stopped as stuck (#224).
172
+ if isinstance(stuck_reason, str) and stuck_reason:
173
+ fields["detail"]["stuck"] = stuck_reason[:QUESTION_DETAIL_CHARS]
170
174
  except Exception as e: # noqa: BLE001 - best-effort by design
171
175
  _log_failure("<outcome>", e)
172
176
  return