ctrlrelay 0.20.0__tar.gz → 0.20.1__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 (212) hide show
  1. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/CHANGELOG.md +37 -1
  2. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/pyproject.toml +1 -1
  4. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/cli.py +18 -4
  5. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/github.py +13 -0
  6. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/orphans.py +45 -0
  7. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/poller.py +87 -1
  8. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/state.py +137 -6
  9. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/dev.py +128 -14
  10. ctrlrelay-0.20.1/src/ctrlrelay/web/health.py +396 -0
  11. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/status.py +16 -1
  12. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_start.py +60 -0
  13. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_integration.py +8 -0
  14. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_plan_step.py +52 -0
  15. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_fleet.py +1 -0
  16. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_poller.py +20 -0
  17. ctrlrelay-0.20.1/tests/test_poller_recheck.py +240 -0
  18. ctrlrelay-0.20.1/tests/test_question_survives_restart.py +606 -0
  19. ctrlrelay-0.20.1/tests/test_web_health.py +624 -0
  20. ctrlrelay-0.20.0/src/ctrlrelay/web/health.py +0 -167
  21. ctrlrelay-0.20.0/tests/test_web_health.py +0 -228
  22. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  23. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/dependabot.yml +0 -0
  26. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/build.yml +0 -0
  27. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/cla.yml +0 -0
  28. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/pages.yml +0 -0
  29. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/publish.yml +0 -0
  30. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/test.yml +0 -0
  31. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.gitignore +0 -0
  32. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/CODE_OF_CONDUCT.md +0 -0
  33. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/CONTRIBUTING.md +0 -0
  34. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/LICENSE +0 -0
  35. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/README.md +0 -0
  36. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/SECURITY.md +0 -0
  37. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/config/orchestrator.yaml.example +0 -0
  38. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/Gemfile +0 -0
  39. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/_config.yml +0 -0
  40. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/adding-a-machine.md +0 -0
  41. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/architecture.md +0 -0
  42. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/bridge.md +0 -0
  43. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/cli.md +0 -0
  44. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/configuration.md +0 -0
  45. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/development.md +0 -0
  46. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/feedback-loop.md +0 -0
  47. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/getting-started.md +0 -0
  48. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/index.md +0 -0
  49. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/llms.txt +0 -0
  50. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/operations.md +0 -0
  51. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/personalization.md +0 -0
  52. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/registered-mode.md +0 -0
  53. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  54. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  55. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/__init__.py +0 -0
  56. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  57. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  58. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/factory.py +0 -0
  59. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/handler.py +0 -0
  60. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  61. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/node_tag.py +0 -0
  62. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  63. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/server.py +0 -0
  64. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  65. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/__init__.py +0 -0
  66. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/agent_env.py +0 -0
  67. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/archived.py +0 -0
  68. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/audit.py +0 -0
  69. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/binaries.py +0 -0
  70. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  71. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/claims.py +0 -0
  72. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/code_review.py +0 -0
  73. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/config.py +0 -0
  74. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
  75. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  76. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/events.py +0 -0
  77. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/github_budget.py +0 -0
  78. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/heartbeat.py +0 -0
  79. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/job_claims.py +0 -0
  80. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/network.py +0 -0
  81. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/node_check.py +0 -0
  82. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/obs.py +0 -0
  83. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  84. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  85. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/publish.py +0 -0
  86. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/question_expiry.py +0 -0
  87. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/redact.py +0 -0
  88. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/scheduler.py +0 -0
  89. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/usage.py +0 -0
  90. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/worktree.py +0 -0
  91. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  92. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/dashboard/client.py +0 -0
  93. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/gh_protocol.py +0 -0
  94. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/install.py +0 -0
  95. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  96. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/manager.py +0 -0
  97. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/merge.py +0 -0
  98. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/paths.py +0 -0
  99. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  100. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/base.py +0 -0
  101. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  102. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/secops.py +0 -0
  103. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/task.py +0 -0
  104. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/setup.py +0 -0
  105. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/__init__.py +0 -0
  106. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  107. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  108. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  109. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  110. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  111. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  112. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  113. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  114. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/__init__.py +0 -0
  115. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/base.py +0 -0
  116. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  117. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  118. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/__init__.py +0 -0
  119. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/fleet.py +0 -0
  120. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/links.py +0 -0
  121. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/numbers.py +0 -0
  122. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/server.py +0 -0
  123. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/static/index.html +0 -0
  124. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/transcript.py +0 -0
  125. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/waiting.py +0 -0
  126. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/__init__.py +0 -0
  127. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/_poller_fakes.py +0 -0
  128. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/_publish_fakes.py +0 -0
  129. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/conftest.py +0 -0
  130. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/fixtures/gh_search_issues.json +0 -0
  131. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  132. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/fixtures/transcript_sample.jsonl +0 -0
  133. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_backend_wiring.py +0 -0
  134. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_no_keys.py +0 -0
  135. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_routing.py +0 -0
  136. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_sandbox.py +0 -0
  137. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_archived.py +0 -0
  138. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_audit.py +0 -0
  139. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_binary_resolution.py +0 -0
  140. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_bridge_protocol.py +0 -0
  141. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_bridge_server.py +0 -0
  142. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_bridge_start_command.py +0 -0
  143. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_chat_machines.py +0 -0
  144. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_checkpoint.py +0 -0
  145. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_claim_reconciliation.py +0 -0
  146. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_ci_wait.py +0 -0
  147. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_dev.py +0 -0
  148. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_repos.py +0 -0
  149. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_secops.py +0 -0
  150. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_version.py +0 -0
  151. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_code_review.py +0 -0
  152. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_config.py +0 -0
  153. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dashboard_client.py +0 -0
  154. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_pipeline.py +0 -0
  155. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_step_switches.py +0 -0
  156. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_work_steps.py +0 -0
  157. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dispatch_health.py +0 -0
  158. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dispatch_outcome_hook.py +0 -0
  159. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dispatcher.py +0 -0
  160. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_docs_site.py +0 -0
  161. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_event_emission.py +0 -0
  162. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_events.py +0 -0
  163. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_gh_protocol.py +0 -0
  164. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_github.py +0 -0
  165. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_github_budget.py +0 -0
  166. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_install.py +0 -0
  167. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_issue_outcome_announcement.py +0 -0
  168. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_job_claims.py +0 -0
  169. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_mattermost_handler.py +0 -0
  170. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_network.py +0 -0
  171. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_node_check.py +0 -0
  172. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_node_claims.py +0 -0
  173. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_notify_transport.py +0 -0
  174. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_obs.py +0 -0
  175. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_opencode_adapter.py +0 -0
  176. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_orphans.py +0 -0
  177. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_personalization.py +0 -0
  178. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_personalization_merge.py +0 -0
  179. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pipeline_base.py +0 -0
  180. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_poller_search.py +0 -0
  181. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_post_merge.py +0 -0
  182. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pr_verifier.py +0 -0
  183. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pr_watcher.py +0 -0
  184. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pre_dispatch_retry.py +0 -0
  185. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_publish_failure_retry.py +0 -0
  186. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_question_backoff.py +0 -0
  187. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_question_expiry.py +0 -0
  188. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_redact.py +0 -0
  189. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_relay_alert_off_lock.py +0 -0
  190. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_relay_git_263.py +0 -0
  191. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_resume_prompt_contract.py +0 -0
  192. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_review_copy.py +0 -0
  193. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_scheduler.py +0 -0
  194. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_secops_integration.py +0 -0
  195. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_secops_pipeline.py +0 -0
  196. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_setup.py +0 -0
  197. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_state.py +0 -0
  198. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_task_pipeline.py +0 -0
  199. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_telegram_handler.py +0 -0
  200. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_timeout_save.py +0 -0
  201. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_transport.py +0 -0
  202. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_transport_resolution.py +0 -0
  203. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_unreviewed_pr_announcement.py +0 -0
  204. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_usage.py +0 -0
  205. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_links.py +0 -0
  206. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_numbers.py +0 -0
  207. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_peer_links.py +0 -0
  208. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_server.py +0 -0
  209. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_status.py +0 -0
  210. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_transcript.py +0 -0
  211. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_waiting.py +0 -0
  212. {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_worktree.py +0 -0
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.1] - 2026-11-04
11
+
12
+ ### Fixed
13
+
14
+ - The status page's server panel reads memory and uptime on macOS too. It
15
+ read those two from `/proc`, which macOS does not have, so on a Mac those
16
+ rows were empty and the page printed the read error. On macOS it now
17
+ uses `sysctl` and `vm_stat`. macOS has no `MemAvailable`, so "available"
18
+ there is free plus inactive pages: an estimate, and not the same
19
+ measurement as Linux's (#321).
20
+
21
+ - A run waiting for the operator's answer is no longer lost when the poller
22
+ stops during the wait. The question is now saved, and the session marked
23
+ `blocked`, before the wait starts rather than after it ends (if that
24
+ write fails it is logged and the wait goes ahead as before). If the
25
+ poller stops, a later answer in chat reaches the saved question and
26
+ resumes the same agent session; when the poller starts again it drops the
27
+ repo lock such a run held, if the process that ran it is gone, so the
28
+ repo is not wedged until then. An answer that arrives during the wait
29
+ still resumes the run at once and closes the saved question; if a reply
30
+ already answered the saved question first, that first answer stands and
31
+ the run is resumed once, with it. A planning session's question resumes
32
+ the plan, not the code. The claim on the issue is not marked held while the
33
+ question waits, so after a stop it goes stale as before (#324).
34
+
35
+ - An issue is read again from GitHub just before its run starts. A poll
36
+ returns a batch that is handled one issue at a time, so with one slot the
37
+ last issue could start an hour after the poll saw it, after its trigger
38
+ label was removed or the issue was closed. Now an issue that is closed,
39
+ has gained an `exclude_labels` label, or no longer meets any trigger is
40
+ skipped and left for the next poll to judge; so is one that cannot be read.
41
+ The run is routed on the labels read then, not the poll's. Who added a
42
+ trigger is not asked again: a trigger the operator removed and someone
43
+ else put back in that window still counts (#326).
44
+
10
45
  ## [0.20.0] - 2026-11-04
11
46
 
12
47
  ### Added
@@ -2313,7 +2348,8 @@ pipeline).
2313
2348
  per-phase implementation plans (Phase 0 through Phase 4).
2314
2349
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2315
2350
 
2316
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...HEAD
2351
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...HEAD
2352
+ [0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
2317
2353
  [0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
2318
2354
  [0.19.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...v0.19.1
2319
2355
  [0.19.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...v0.19.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.20.0
3
+ Version: 0.20.1
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.20.0"
3
+ version = "0.20.1"
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"
@@ -3801,7 +3801,8 @@ def poller_start(
3801
3801
  )
3802
3802
  try:
3803
3803
  state_db.mark_pending_resume_resumed(
3804
- session_id
3804
+ session_id, answered_at=row["answered_at"],
3805
+ answer=row["answer"],
3805
3806
  )
3806
3807
  except Exception:
3807
3808
  pass
@@ -3844,7 +3845,8 @@ def poller_start(
3844
3845
  )
3845
3846
  try:
3846
3847
  state_db.mark_pending_resume_resumed(
3847
- session_id
3848
+ session_id, answered_at=row["answered_at"],
3849
+ answer=row["answer"],
3848
3850
  )
3849
3851
  except Exception:
3850
3852
  pass
@@ -3862,7 +3864,10 @@ def poller_start(
3862
3864
  # same broken row. Operator can inspect via the
3863
3865
  # sessions table.
3864
3866
  try:
3865
- state_db.mark_pending_resume_resumed(session_id)
3867
+ state_db.mark_pending_resume_resumed(
3868
+ session_id, answered_at=row["answered_at"],
3869
+ answer=row["answer"],
3870
+ )
3866
3871
  except Exception:
3867
3872
  pass
3868
3873
  continue
@@ -3926,7 +3931,10 @@ def poller_start(
3926
3931
  continue
3927
3932
 
3928
3933
  try:
3929
- state_db.mark_pending_resume_resumed(session_id)
3934
+ state_db.mark_pending_resume_resumed(
3935
+ session_id, answered_at=row["answered_at"],
3936
+ answer=row["answer"],
3937
+ )
3930
3938
  except Exception:
3931
3939
  pass
3932
3940
 
@@ -4224,6 +4232,12 @@ def poller_start(
4224
4232
  f"[yellow]Run {r['session_id']} for {where} stopped "
4225
4233
  f"when the relay did; marked failed.{kept}[/yellow]"
4226
4234
  )
4235
+ # A run that stopped while waiting for an answer (#324) is
4236
+ # `blocked` with its question saved, and is resumed when the
4237
+ # answer comes; the repo lock it held would only wedge the
4238
+ # repo until then.
4239
+ from ctrlrelay.core.orphans import release_dead_blocked_locks
4240
+ release_dead_blocked_locks(state_db)
4227
4241
 
4228
4242
  try:
4229
4243
  await github_budget.refresh()
@@ -561,6 +561,19 @@ class GitHubCLI:
561
561
  )
562
562
  return json.loads(output)
563
563
 
564
+ async def get_issue_gate(self, repo: str, issue_number: int) -> dict[str, Any]:
565
+ """Return ``{"state", "labels", "assignees"}`` for one issue, read
566
+ live over REST: the fields that decide whether the relay may still
567
+ start it (#326). ``labels`` and ``assignees`` are lists of names.
568
+ """
569
+ output = await self._run_gh(
570
+ "api", f"repos/{repo}/issues/{issue_number}",
571
+ "--jq",
572
+ "{state: .state, labels: [.labels[].name], "
573
+ "assignees: [.assignees[].login]}",
574
+ )
575
+ return json.loads(output)
576
+
564
577
  async def create_pr(
565
578
  self,
566
579
  repo: str,
@@ -386,3 +386,48 @@ async def reap_orphaned_sessions(
386
386
  "handed_back": handed_back,
387
387
  })
388
388
  return settled
389
+
390
+
391
+ def release_dead_blocked_locks(
392
+ state_db: Any, *, is_alive: Callable[[int], bool] = owner_is_alive,
393
+ ) -> list[str]:
394
+ """Drop repo locks held by ``blocked`` runs whose process is gone (#324).
395
+
396
+ A run that asks a question is ``blocked`` with its question saved
397
+ while its process waits in memory for the answer, and it keeps its
398
+ repo lock for that wait. If the process stops, the lock only wedges
399
+ the repo until the answer comes, and the resume takes the lock again
400
+ itself. Runs this process started, runs whose process is alive (a
401
+ ``ctrlrelay run dev`` waiting in another terminal), and rows that name
402
+ no process are left alone, on the same terms as
403
+ :func:`reap_orphaned_sessions`. Returns the session ids released.
404
+ """
405
+ try:
406
+ rows = state_db.list_blocked_sessions_holding_locks()
407
+ except Exception as e:
408
+ log_event(
409
+ _logger, "orphans.blocked_locks_list_failed",
410
+ reason=type(e).__name__, error=str(e)[:200],
411
+ )
412
+ return []
413
+ released: list[str] = []
414
+ for row in rows:
415
+ pid = row.get("owner_pid")
416
+ if row.get("owner_run") == PROCESS_RUN or pid is None:
417
+ continue
418
+ if is_alive(int(pid)):
419
+ continue
420
+ try:
421
+ if state_db.release_blocked_session_lock(row["id"]):
422
+ released.append(row["id"])
423
+ log_event(
424
+ _logger, "orphans.blocked_lock_released",
425
+ session_id=row["id"], repo=row.get("repo"),
426
+ )
427
+ except Exception as e:
428
+ log_event(
429
+ _logger, "orphans.blocked_lock_release_failed",
430
+ session_id=row["id"], reason=type(e).__name__,
431
+ error=str(e)[:200],
432
+ )
433
+ return released
@@ -954,6 +954,84 @@ class IssuePoller:
954
954
  return True
955
955
  return False
956
956
 
957
+ async def recheck_before_dispatch(
958
+ self, repo: str, issue: dict[str, Any],
959
+ ) -> dict[str, Any] | None:
960
+ """Read the issue again just before it is handled (#326).
961
+
962
+ ``poll()`` returns a batch and the loop handles it one issue at a
963
+ time, so with one slot and long runs the last issue in a batch can
964
+ start an hour after the poll saw it. In that time the operator may
965
+ have removed the trigger label or closed the issue, and the run
966
+ would start anyway.
967
+
968
+ Returns the issue with today's ``state``, ``labels`` and
969
+ ``assignees`` when it may still start. Returns ``None`` when it is
970
+ closed, now carries an ``exclude_labels`` label, no longer meets
971
+ any trigger, or could not be read; then it is also taken out of
972
+ ``seen_issues``, so the next poll judges it afresh rather than
973
+ never again. Whether the operator was the one who added a label or
974
+ assignment is not asked again: that was settled by ``poll()``.
975
+ """
976
+ number = int(issue["number"])
977
+ try:
978
+ live = await self.github.get_issue_gate(repo, number)
979
+ state = str(live["state"]).lower()
980
+ labels = [str(n) for n in live["labels"]]
981
+ assignees = [str(n) for n in live["assignees"]]
982
+ except asyncio.CancelledError:
983
+ raise
984
+ except Exception as e:
985
+ self.unmark_seen(repo, number)
986
+ log_event(
987
+ _logger, "poll.issue.recheck_failed", repo=repo,
988
+ issue_number=number, reason=type(e).__name__,
989
+ error=str(e)[:200],
990
+ )
991
+ return None
992
+
993
+ fresh = {
994
+ **issue,
995
+ "state": state,
996
+ "labels": [{"name": n} for n in labels],
997
+ "assignees": [{"login": n} for n in assignees],
998
+ }
999
+ reason: str | None = None
1000
+ if state != "open":
1001
+ reason = "closed"
1002
+ else:
1003
+ excluded = self._matched_exclude_label(fresh, {
1004
+ x.lower() for x in self.exclude_labels_by_repo.get(repo, [])
1005
+ })
1006
+ include_lowered = {
1007
+ x.lower() for x in self.include_labels_by_repo.get(repo, [])
1008
+ }
1009
+ require_lowered = {
1010
+ x.lower() for x in self.require_labels_by_repo.get(repo, [])
1011
+ }
1012
+ # Logins compared without case, as the assignee search that
1013
+ # admitted the issue does: a case-only mismatch would skip the
1014
+ # issue on every poll and never start it.
1015
+ me = (self.username or "").lower()
1016
+ assigned = bool(me) and me in {n.lower() for n in assignees} and (
1017
+ not require_lowered
1018
+ or self._matched_require_label(fresh, require_lowered)
1019
+ is not None
1020
+ )
1021
+ labelled = self._matched_include_label(fresh, include_lowered)
1022
+ if excluded is not None:
1023
+ reason = f"excluded_by_label:{excluded}"
1024
+ elif not assigned and labelled is None:
1025
+ reason = "trigger_gone"
1026
+ if reason is not None:
1027
+ self.unmark_seen(repo, number)
1028
+ log_event(
1029
+ _logger, "poll.issue.stale_at_dispatch", repo=repo,
1030
+ issue_number=number, reason=reason,
1031
+ )
1032
+ return None
1033
+ return fresh
1034
+
957
1035
  def mark_seen(self, repo: str, issue_number: int) -> None:
958
1036
  """Mark an issue as seen without triggering a poll.
959
1037
 
@@ -1310,7 +1388,15 @@ async def run_poll_loop(
1310
1388
  # would otherwise be silently dropped until daemon restart.
1311
1389
  for item in new_issues:
1312
1390
  try:
1313
- await handler(item["repo"], item["issue"])
1391
+ # The batch can be an hour old by the time an item's turn
1392
+ # comes (#326): read it again, and skip it if it may no
1393
+ # longer start.
1394
+ fresh = await poller.recheck_before_dispatch(
1395
+ item["repo"], item["issue"],
1396
+ )
1397
+ if fresh is None:
1398
+ continue
1399
+ await handler(item["repo"], fresh)
1314
1400
  except asyncio.CancelledError:
1315
1401
  raise
1316
1402
  except Exception as e:
@@ -1021,6 +1021,117 @@ class StateDB:
1021
1021
  )
1022
1022
  self._conn.commit()
1023
1023
 
1024
+ def park_blocked_question(
1025
+ self, session_id: str, pipeline: str, repo: str, question: str,
1026
+ ) -> None:
1027
+ """Record a question BEFORE the run waits on the chat answer (#324).
1028
+
1029
+ The session goes ``running`` -> ``blocked`` and its question gets a
1030
+ ``pending_resumes`` row, in one commit. While the poller waits in
1031
+ process, this is only a record; if the poller stops during the
1032
+ wait, it is what the bridge routes a late answer to and what the
1033
+ sweeper resumes. Without it, the only record of the question was a
1034
+ coroutine, and a restart lost it with the session row left
1035
+ ``running``.
1036
+ """
1037
+ try:
1038
+ self._conn.execute(
1039
+ "UPDATE sessions SET status = 'blocked' "
1040
+ "WHERE id = ? AND status = 'running'",
1041
+ (session_id,),
1042
+ )
1043
+ # Commits both statements.
1044
+ self.add_pending_resume(
1045
+ session_id=session_id, pipeline=pipeline, repo=repo,
1046
+ question=question,
1047
+ )
1048
+ except BaseException:
1049
+ # Never leave the half-done write open through the wait: it
1050
+ # would hold the database's write lock against the bridge, and
1051
+ # a later commit would keep only half of it.
1052
+ if self._conn.in_transaction:
1053
+ self._conn.rollback()
1054
+ raise
1055
+
1056
+ def take_parked_answer(self, session_id: str, answer: str) -> bool:
1057
+ """The in-process wait got its answer (#324): mark the parked row
1058
+ answered AND resumed, so neither the bridge nor the sweeper acts on
1059
+ it again, and put the session back to ``running``. One commit.
1060
+
1061
+ Only an unanswered, open row is taken. Returns ``False`` when no
1062
+ such row is there, and changes nothing: the row may have been
1063
+ answered some other way first (a reply the bridge routed to the
1064
+ saved question), expired, or be missing. The caller tells these
1065
+ apart; only the first means another answer stands."""
1066
+ now = int(time.time())
1067
+ ours = not self._conn.in_transaction
1068
+ try:
1069
+ cursor = self._conn.execute(
1070
+ "UPDATE pending_resumes SET answer = ?, answered_at = ?, "
1071
+ "resumed_at = ? WHERE session_id = ? AND answered_at IS NULL "
1072
+ "AND resumed_at IS NULL AND expired_at IS NULL",
1073
+ (answer, now, now, session_id),
1074
+ )
1075
+ if cursor.rowcount > 0:
1076
+ self._conn.execute(
1077
+ "UPDATE sessions SET status = 'running' "
1078
+ "WHERE id = ? AND status = 'blocked'",
1079
+ (session_id,),
1080
+ )
1081
+ if ours:
1082
+ self._conn.commit()
1083
+ except BaseException:
1084
+ if ours and self._conn.in_transaction:
1085
+ self._conn.rollback()
1086
+ raise
1087
+ return cursor.rowcount > 0
1088
+
1089
+ def mark_session_running(self, session_id: str) -> None:
1090
+ """Put a ``blocked`` session back to ``running`` (#324): its run
1091
+ resumed in process with an answer the saved question did not
1092
+ record (that row expired or is missing)."""
1093
+ self._conn.execute(
1094
+ "UPDATE sessions SET status = 'running' "
1095
+ "WHERE id = ? AND status = 'blocked'",
1096
+ (session_id,),
1097
+ )
1098
+ self._conn.commit()
1099
+
1100
+ def has_open_pending_resume(self, session_id: str, question: str) -> bool:
1101
+ """Whether this session already has a row for this same question
1102
+ that is neither resumed nor expired. Re-writing such a row would
1103
+ clear an answer already attached to it (#324)."""
1104
+ row = self._conn.execute(
1105
+ "SELECT 1 FROM pending_resumes WHERE session_id = ? "
1106
+ "AND question = ? AND resumed_at IS NULL AND expired_at IS NULL",
1107
+ (session_id, question),
1108
+ ).fetchone()
1109
+ return row is not None
1110
+
1111
+ def list_blocked_sessions_holding_locks(self) -> list[dict[str, Any]]:
1112
+ """``blocked`` sessions that still hold a repo lock, with the
1113
+ process that ran them (#324). A blocked run holds its lock only
1114
+ while that process waits in memory for the answer; see
1115
+ :func:`ctrlrelay.core.orphans.release_dead_blocked_locks`."""
1116
+ rows = self._conn.execute(
1117
+ "SELECT s.id, s.repo, s.owner_pid, s.owner_run "
1118
+ "FROM repo_locks l JOIN sessions s ON s.id = l.session_id "
1119
+ "WHERE s.status = 'blocked'"
1120
+ ).fetchall()
1121
+ return [dict(r) for r in rows]
1122
+
1123
+ def release_blocked_session_lock(self, session_id: str) -> bool:
1124
+ """Drop the lock of a session that is still ``blocked`` (#324).
1125
+ Guarded on the status, so a session resumed in the meantime keeps
1126
+ its lock."""
1127
+ cursor = self._conn.execute(
1128
+ "DELETE FROM repo_locks WHERE session_id = ? AND EXISTS ("
1129
+ "SELECT 1 FROM sessions WHERE id = ? AND status = 'blocked')",
1130
+ (session_id, session_id),
1131
+ )
1132
+ self._conn.commit()
1133
+ return cursor.rowcount > 0
1134
+
1024
1135
  def get_oldest_unanswered_pending_resume(
1025
1136
  self,
1026
1137
  pipeline: str | None = None,
@@ -1186,7 +1297,12 @@ class StateDB:
1186
1297
  ).fetchall()
1187
1298
  return [dict(row) for row in rows]
1188
1299
 
1189
- def mark_pending_resume_resumed(self, session_id: str) -> bool:
1300
+ def mark_pending_resume_resumed(
1301
+ self,
1302
+ session_id: str,
1303
+ answered_at: int | None = None,
1304
+ answer: str | None = None,
1305
+ ) -> bool:
1190
1306
  """Mark a pending resume as executed so the sweeper doesn't pick
1191
1307
  it up again.
1192
1308
 
@@ -1201,12 +1317,27 @@ class StateDB:
1201
1317
  wedging the session. The guard makes this a no-op when the
1202
1318
  row has been refreshed.
1203
1319
 
1320
+ ``answered_at`` and ``answer``, when given, are those of the row
1321
+ the caller took (#324). Since the question is saved before the
1322
+ wait, a resume that re-blocks can have its NEW question answered
1323
+ (through the bridge) before this call; matching the answer and its
1324
+ time keeps this call from marking that new answer resumed, which
1325
+ would hide it for ever.
1326
+
1204
1327
  Returns True iff a row was updated."""
1205
- cursor = self._conn.execute(
1206
- "UPDATE pending_resumes SET resumed_at = ? "
1207
- "WHERE session_id = ? AND answered_at IS NOT NULL",
1208
- (int(time.time()), session_id),
1209
- )
1328
+ if answered_at is None:
1329
+ cursor = self._conn.execute(
1330
+ "UPDATE pending_resumes SET resumed_at = ? "
1331
+ "WHERE session_id = ? AND answered_at IS NOT NULL",
1332
+ (int(time.time()), session_id),
1333
+ )
1334
+ else:
1335
+ cursor = self._conn.execute(
1336
+ "UPDATE pending_resumes SET resumed_at = ? "
1337
+ "WHERE session_id = ? AND answered_at = ? "
1338
+ "AND answer IS ?",
1339
+ (int(time.time()), session_id, answered_at, answer),
1340
+ )
1210
1341
  self._conn.commit()
1211
1342
  return cursor.rowcount > 0
1212
1343
 
@@ -2148,6 +2148,70 @@ def _plan_comment(criteria: str) -> str:
2148
2148
  )
2149
2149
 
2150
2150
 
2151
+ def _park_question(run: _DevRun, question: str) -> bool:
2152
+ """Save the question before waiting for its answer (#324); see
2153
+ ``StateDB.park_blocked_question``. Best-effort: a failed write is
2154
+ logged and the wait goes ahead, as it did before the row existed.
2155
+ Returns whether it was saved."""
2156
+ try:
2157
+ run.state_db.park_blocked_question(
2158
+ run.session_id, "dev", run.repo, question,
2159
+ )
2160
+ return True
2161
+ except Exception as e:
2162
+ log_event(
2163
+ _logger, "dev.question.park_failed", session_id=run.session_id,
2164
+ repo=run.repo, issue_number=run.issue_number,
2165
+ error_type=type(e).__name__, error=str(e)[:200],
2166
+ )
2167
+ return False
2168
+
2169
+
2170
+ def _take_answer(run: _DevRun, answer: str) -> bool:
2171
+ """The wait got its answer: close the saved question so nothing else
2172
+ resumes the session with it, and mark the session running again.
2173
+
2174
+ Returns ``False`` only when the saved question was answered first
2175
+ another way (a reply the bridge routed to it): that answer stands and
2176
+ the sweeper resumes with it, so this one must not. A row that is
2177
+ missing or expired is no other answer, so the run resumes with this
2178
+ one. If the row cannot be closed, it is expired instead, so no later
2179
+ reply can resume the session a second time; and if it turns out to be
2180
+ answered already, that answer stands as above. When the database can
2181
+ be neither written nor read, the run resumes as it did before the row
2182
+ existed, and the row may stay open: a later reply could then resume
2183
+ the session again."""
2184
+ sid = run.session_id
2185
+ try:
2186
+ if run.state_db.take_parked_answer(sid, answer):
2187
+ return True
2188
+ except Exception as e:
2189
+ log_event(
2190
+ _logger, "dev.question.unpark_failed", session_id=sid,
2191
+ repo=run.repo, issue_number=run.issue_number,
2192
+ error_type=type(e).__name__, error=str(e)[:200],
2193
+ )
2194
+ try:
2195
+ run.state_db.expire_pending_resume(
2196
+ sid, "answered while the run waited",
2197
+ )
2198
+ except Exception:
2199
+ pass
2200
+ try:
2201
+ row = run.state_db.get_pending_resume(sid)
2202
+ if (
2203
+ row is not None
2204
+ and row.get("answered_at") is not None
2205
+ and row.get("resumed_at") is None
2206
+ and row.get("expired_at") is None
2207
+ ):
2208
+ return False
2209
+ run.state_db.mark_session_running(sid)
2210
+ except Exception:
2211
+ pass
2212
+ return True
2213
+
2214
+
2151
2215
  async def _answer_while_blocked(
2152
2216
  run: _DevRun,
2153
2217
  result: PipelineResult,
@@ -2176,6 +2240,9 @@ async def _answer_while_blocked(
2176
2240
  f"Session {run.session_id} is blocked but did not include a "
2177
2241
  "question. Reply with guidance to resume."
2178
2242
  )
2243
+ # Saved first, so a poller that stops during the wait leaves the
2244
+ # question answerable rather than lost (#324).
2245
+ parked = _park_question(run, question)
2179
2246
  try:
2180
2247
  answer = await run.transport.ask(
2181
2248
  question,
@@ -2201,6 +2268,35 @@ async def _answer_while_blocked(
2201
2268
  question=question,
2202
2269
  outputs=result.outputs,
2203
2270
  )
2271
+ if parked and not _take_answer(run, answer):
2272
+ # Answered first through the saved question (#324). That
2273
+ # answer stands; the sweeper resumes with it once this run
2274
+ # has let go of the repo lock.
2275
+ log_event(
2276
+ _logger, "dev.question.answered_elsewhere",
2277
+ session_id=run.session_id, repo=run.repo,
2278
+ issue_number=run.issue_number,
2279
+ )
2280
+ # Say so to whoever typed the second reply: it is not applied.
2281
+ try:
2282
+ await run.transport.send(
2283
+ f"Your reply for #{run.issue_number} in {run.repo} was "
2284
+ "not applied: an earlier reply had already answered "
2285
+ "that question, and the run resumes with it."
2286
+ )
2287
+ except Exception:
2288
+ pass
2289
+ return PipelineResult(
2290
+ success=False,
2291
+ blocked=True,
2292
+ session_id=run.session_id,
2293
+ summary=(
2294
+ f"{deferred}: answered through the saved question; "
2295
+ "the sweeper resumes it with that answer"
2296
+ ),
2297
+ question=question,
2298
+ outputs=result.outputs,
2299
+ )
2204
2300
  rounds += 1
2205
2301
  result = await resume(run.ctx, answer)
2206
2302
  # A resumed worker is the one whose exit code the failure
@@ -2235,8 +2331,8 @@ async def _finish_plan(run: _DevRun, result: PipelineResult) -> None:
2235
2331
  """
2236
2332
  assert run.plan_path is not None
2237
2333
  marker = _plan_pending_marker(run.plan_path)
2238
- result = await _answer_while_blocked(run, result, run.pipeline.resume_plan)
2239
- if result.blocked:
2334
+
2335
+ def write_marker() -> None:
2240
2336
  try:
2241
2337
  marker.parent.mkdir(parents=True, exist_ok=True)
2242
2338
  marker.write_text(run.session_id)
@@ -2246,6 +2342,14 @@ async def _finish_plan(run: _DevRun, result: PipelineResult) -> None:
2246
2342
  session_id=run.session_id, repo=run.repo,
2247
2343
  issue_number=run.issue_number, error=str(e)[:200],
2248
2344
  )
2345
+
2346
+ # Written before the wait too (#324): if the poller stops while the
2347
+ # plan's question is open, the late answer must resume the PLAN.
2348
+ if result.blocked:
2349
+ write_marker()
2350
+ result = await _answer_while_blocked(run, result, run.pipeline.resume_plan)
2351
+ if result.blocked:
2352
+ write_marker()
2249
2353
  run.result = result
2250
2354
  return
2251
2355
  marker.unlink(missing_ok=True)
@@ -3041,12 +3145,17 @@ async def run_dev_issue(
3041
3145
  # resume_dev_from_pending below.
3042
3146
  if result.blocked:
3043
3147
  try:
3044
- state_db.add_pending_resume(
3045
- session_id=session_id,
3046
- pipeline="dev",
3047
- repo=repo,
3048
- question=_question_for_persist(session_id, result),
3049
- )
3148
+ question = _question_for_persist(session_id, result)
3149
+ # The row written before the wait (#324) may already hold
3150
+ # an answer the bridge attached; re-writing it would clear
3151
+ # that answer.
3152
+ if not state_db.has_open_pending_resume(session_id, question):
3153
+ state_db.add_pending_resume(
3154
+ session_id=session_id,
3155
+ pipeline="dev",
3156
+ repo=repo,
3157
+ question=question,
3158
+ )
3050
3159
  except Exception as e:
3051
3160
  log_event(
3052
3161
  _logger,
@@ -3569,12 +3678,17 @@ async def resume_dev_from_pending(
3569
3678
 
3570
3679
  if result.blocked:
3571
3680
  try:
3572
- state_db.add_pending_resume(
3573
- session_id=session_id,
3574
- pipeline="dev",
3575
- repo=repo,
3576
- question=_question_for_persist(session_id, result),
3577
- )
3681
+ question = _question_for_persist(session_id, result)
3682
+ # The row written before the wait (#324) may already hold
3683
+ # an answer the bridge attached; re-writing it would clear
3684
+ # that answer.
3685
+ if not state_db.has_open_pending_resume(session_id, question):
3686
+ state_db.add_pending_resume(
3687
+ session_id=session_id,
3688
+ pipeline="dev",
3689
+ repo=repo,
3690
+ question=question,
3691
+ )
3578
3692
  except Exception as e:
3579
3693
  log_event(
3580
3694
  _logger,