ctrlrelay 0.20.0__tar.gz → 0.20.2__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.2}/CHANGELOG.md +49 -1
  2. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/PKG-INFO +1 -1
  3. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/pyproject.toml +1 -1
  4. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/cli.py +18 -4
  5. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/github.py +13 -0
  6. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/orphans.py +45 -0
  7. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/poller.py +87 -1
  8. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/state.py +137 -6
  9. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/usage.py +10 -2
  10. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/dev.py +128 -14
  11. ctrlrelay-0.20.2/src/ctrlrelay/web/health.py +396 -0
  12. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/status.py +16 -1
  13. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_start.py +60 -0
  14. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_integration.py +8 -0
  15. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_plan_step.py +52 -0
  16. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_fleet.py +1 -0
  17. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_poller.py +20 -0
  18. ctrlrelay-0.20.2/tests/test_poller_recheck.py +240 -0
  19. ctrlrelay-0.20.2/tests/test_question_survives_restart.py +606 -0
  20. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_usage.py +24 -0
  21. ctrlrelay-0.20.2/tests/test_web_health.py +624 -0
  22. ctrlrelay-0.20.0/src/ctrlrelay/web/health.py +0 -167
  23. ctrlrelay-0.20.0/tests/test_web_health.py +0 -228
  24. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/dependabot.yml +0 -0
  28. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/build.yml +0 -0
  29. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/cla.yml +0 -0
  30. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/pages.yml +0 -0
  31. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/publish.yml +0 -0
  32. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/test.yml +0 -0
  33. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.gitignore +0 -0
  34. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/CODE_OF_CONDUCT.md +0 -0
  35. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/CONTRIBUTING.md +0 -0
  36. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/LICENSE +0 -0
  37. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/README.md +0 -0
  38. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/SECURITY.md +0 -0
  39. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/config/orchestrator.yaml.example +0 -0
  40. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/Gemfile +0 -0
  41. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/_config.yml +0 -0
  42. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/adding-a-machine.md +0 -0
  43. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/architecture.md +0 -0
  44. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/bridge.md +0 -0
  45. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/cli.md +0 -0
  46. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/configuration.md +0 -0
  47. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/development.md +0 -0
  48. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/feedback-loop.md +0 -0
  49. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/getting-started.md +0 -0
  50. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/index.md +0 -0
  51. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/llms.txt +0 -0
  52. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/operations.md +0 -0
  53. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/personalization.md +0 -0
  54. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/registered-mode.md +0 -0
  55. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  56. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  57. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/__init__.py +0 -0
  58. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/__init__.py +0 -0
  59. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/__main__.py +0 -0
  60. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/factory.py +0 -0
  61. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/handler.py +0 -0
  62. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  63. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/node_tag.py +0 -0
  64. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/protocol.py +0 -0
  65. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/server.py +0 -0
  66. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  67. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/__init__.py +0 -0
  68. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/agent_env.py +0 -0
  69. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/archived.py +0 -0
  70. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/audit.py +0 -0
  71. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/binaries.py +0 -0
  72. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/checkpoint.py +0 -0
  73. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/claims.py +0 -0
  74. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/code_review.py +0 -0
  75. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/config.py +0 -0
  76. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/dispatch_health.py +0 -0
  77. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/dispatcher.py +0 -0
  78. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/events.py +0 -0
  79. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/github_budget.py +0 -0
  80. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/heartbeat.py +0 -0
  81. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/job_claims.py +0 -0
  82. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/network.py +0 -0
  83. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/node_check.py +0 -0
  84. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/obs.py +0 -0
  85. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
  86. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
  87. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/publish.py +0 -0
  88. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/question_expiry.py +0 -0
  89. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/redact.py +0 -0
  90. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/scheduler.py +0 -0
  91. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/worktree.py +0 -0
  92. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
  93. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/dashboard/client.py +0 -0
  94. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/gh_protocol.py +0 -0
  95. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/install.py +0 -0
  96. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/__init__.py +0 -0
  97. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/manager.py +0 -0
  98. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/merge.py +0 -0
  99. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/paths.py +0 -0
  100. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
  101. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/base.py +0 -0
  102. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  103. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/secops.py +0 -0
  104. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/task.py +0 -0
  105. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/setup.py +0 -0
  106. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/__init__.py +0 -0
  107. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  108. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  109. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  110. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  111. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  112. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  113. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  114. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  115. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/__init__.py +0 -0
  116. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/base.py +0 -0
  117. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/file_mock.py +0 -0
  118. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/socket_client.py +0 -0
  119. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/__init__.py +0 -0
  120. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/fleet.py +0 -0
  121. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/links.py +0 -0
  122. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/numbers.py +0 -0
  123. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/server.py +0 -0
  124. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/static/index.html +0 -0
  125. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/transcript.py +0 -0
  126. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/waiting.py +0 -0
  127. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/__init__.py +0 -0
  128. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/_poller_fakes.py +0 -0
  129. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/_publish_fakes.py +0 -0
  130. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/conftest.py +0 -0
  131. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/fixtures/gh_search_issues.json +0 -0
  132. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  133. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/fixtures/transcript_sample.jsonl +0 -0
  134. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_backend_wiring.py +0 -0
  135. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_no_keys.py +0 -0
  136. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_routing.py +0 -0
  137. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_sandbox.py +0 -0
  138. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_archived.py +0 -0
  139. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_audit.py +0 -0
  140. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_binary_resolution.py +0 -0
  141. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_bridge_protocol.py +0 -0
  142. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_bridge_server.py +0 -0
  143. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_bridge_start_command.py +0 -0
  144. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_chat_machines.py +0 -0
  145. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_checkpoint.py +0 -0
  146. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_claim_reconciliation.py +0 -0
  147. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_ci_wait.py +0 -0
  148. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_dev.py +0 -0
  149. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_repos.py +0 -0
  150. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_secops.py +0 -0
  151. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_version.py +0 -0
  152. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_code_review.py +0 -0
  153. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_config.py +0 -0
  154. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dashboard_client.py +0 -0
  155. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_pipeline.py +0 -0
  156. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_step_switches.py +0 -0
  157. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_work_steps.py +0 -0
  158. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dispatch_health.py +0 -0
  159. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dispatch_outcome_hook.py +0 -0
  160. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dispatcher.py +0 -0
  161. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_docs_site.py +0 -0
  162. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_event_emission.py +0 -0
  163. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_events.py +0 -0
  164. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_gh_protocol.py +0 -0
  165. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_github.py +0 -0
  166. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_github_budget.py +0 -0
  167. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_install.py +0 -0
  168. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_issue_outcome_announcement.py +0 -0
  169. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_job_claims.py +0 -0
  170. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_mattermost_handler.py +0 -0
  171. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_network.py +0 -0
  172. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_node_check.py +0 -0
  173. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_node_claims.py +0 -0
  174. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_notify_transport.py +0 -0
  175. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_obs.py +0 -0
  176. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_opencode_adapter.py +0 -0
  177. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_orphans.py +0 -0
  178. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_personalization.py +0 -0
  179. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_personalization_merge.py +0 -0
  180. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pipeline_base.py +0 -0
  181. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_poller_search.py +0 -0
  182. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_post_merge.py +0 -0
  183. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pr_verifier.py +0 -0
  184. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pr_watcher.py +0 -0
  185. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pre_dispatch_retry.py +0 -0
  186. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_publish_failure_retry.py +0 -0
  187. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_question_backoff.py +0 -0
  188. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_question_expiry.py +0 -0
  189. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_redact.py +0 -0
  190. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_relay_alert_off_lock.py +0 -0
  191. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_relay_git_263.py +0 -0
  192. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_resume_prompt_contract.py +0 -0
  193. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_review_copy.py +0 -0
  194. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_scheduler.py +0 -0
  195. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_secops_integration.py +0 -0
  196. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_secops_pipeline.py +0 -0
  197. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_setup.py +0 -0
  198. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_state.py +0 -0
  199. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_task_pipeline.py +0 -0
  200. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_telegram_handler.py +0 -0
  201. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_timeout_save.py +0 -0
  202. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_transport.py +0 -0
  203. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_transport_resolution.py +0 -0
  204. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_unreviewed_pr_announcement.py +0 -0
  205. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_links.py +0 -0
  206. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_numbers.py +0 -0
  207. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_peer_links.py +0 -0
  208. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_server.py +0 -0
  209. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_status.py +0 -0
  210. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_transcript.py +0 -0
  211. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_waiting.py +0 -0
  212. {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_worktree.py +0 -0
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.2] - 2026-11-04
11
+
12
+ ### Fixed
13
+
14
+ - A node no longer stops taking work when the agent CLI reports its usage
15
+ status as `allowed_warning`. That status means a usage window has passed
16
+ a warning line, and calls are still served; it was treated as a refusal,
17
+ so nodes at 26% of the 7-day window paused until that window reset, a
18
+ week away. How close a window is to its limit is still decided by the
19
+ pause threshold, and any other status still pauses (#338).
20
+
21
+ ## [0.20.1] - 2026-11-04
22
+
23
+ ### Fixed
24
+
25
+ - The status page's server panel reads memory and uptime on macOS too. It
26
+ read those two from `/proc`, which macOS does not have, so on a Mac those
27
+ rows were empty and the page printed the read error. On macOS it now
28
+ uses `sysctl` and `vm_stat`. macOS has no `MemAvailable`, so "available"
29
+ there is free plus inactive pages: an estimate, and not the same
30
+ measurement as Linux's (#321).
31
+
32
+ - A run waiting for the operator's answer is no longer lost when the poller
33
+ stops during the wait. The question is now saved, and the session marked
34
+ `blocked`, before the wait starts rather than after it ends (if that
35
+ write fails it is logged and the wait goes ahead as before). If the
36
+ poller stops, a later answer in chat reaches the saved question and
37
+ resumes the same agent session; when the poller starts again it drops the
38
+ repo lock such a run held, if the process that ran it is gone, so the
39
+ repo is not wedged until then. An answer that arrives during the wait
40
+ still resumes the run at once and closes the saved question; if a reply
41
+ already answered the saved question first, that first answer stands and
42
+ the run is resumed once, with it. A planning session's question resumes
43
+ the plan, not the code. The claim on the issue is not marked held while the
44
+ question waits, so after a stop it goes stale as before (#324).
45
+
46
+ - An issue is read again from GitHub just before its run starts. A poll
47
+ returns a batch that is handled one issue at a time, so with one slot the
48
+ last issue could start an hour after the poll saw it, after its trigger
49
+ label was removed or the issue was closed. Now an issue that is closed,
50
+ has gained an `exclude_labels` label, or no longer meets any trigger is
51
+ skipped and left for the next poll to judge; so is one that cannot be read.
52
+ The run is routed on the labels read then, not the poll's. Who added a
53
+ trigger is not asked again: a trigger the operator removed and someone
54
+ else put back in that window still counts (#326).
55
+
10
56
  ## [0.20.0] - 2026-11-04
11
57
 
12
58
  ### Added
@@ -2313,7 +2359,9 @@ pipeline).
2313
2359
  per-phase implementation plans (Phase 0 through Phase 4).
2314
2360
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2315
2361
 
2316
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...HEAD
2362
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...HEAD
2363
+ [0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
2364
+ [0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
2317
2365
  [0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
2318
2366
  [0.19.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...v0.19.1
2319
2367
  [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.2
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.2"
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
 
@@ -43,6 +43,14 @@ UNKNOWN_RESET_PAUSE_SECONDS = 3600
43
43
  # recursion limit (RecursionError). Any of them is unreadable, not fatal.
44
44
  _UNPARSEABLE = (ValueError, RecursionError)
45
45
  ALLOWED = "allowed"
46
+ # Statuses that still let a run through. The agent CLI reports
47
+ # ``allowed_warning`` once a window passes a warning line well short of
48
+ # its limit (seen at 26% of the 7-day window), and the call is served.
49
+ # Pausing on it idled machines for the rest of the 7-day window, so it
50
+ # counts as allowed; how close a window is to its limit is the
51
+ # threshold's question, not the status's. Any other status, including
52
+ # one not seen before, still pauses.
53
+ RUNNABLE_STATUSES = frozenset({ALLOWED, "allowed_warning"})
46
54
 
47
55
 
48
56
  @dataclass(frozen=True)
@@ -225,7 +233,7 @@ def pause_for(
225
233
  ) -> Pause | None:
226
234
  """Whether to stop taking new work.
227
235
 
228
- Paused when the status is not ``allowed`` or a window is at or above
236
+ Paused when the status is not one of ``RUNNABLE_STATUSES`` or a window is at or above
229
237
  ``threshold``, until that window's reset (for the status, the event's
230
238
  own reset). A reading whose reset time has passed says nothing about
231
239
  now, so it does not pause. When the reset time is missing or cannot
@@ -243,7 +251,7 @@ def pause_for(
243
251
  # From the reading, or from now if the reading claims to be from the
244
252
  # future (a clock set back): an hour, never longer.
245
253
  fallback = int(min(reading.read_at, now)) + UNKNOWN_RESET_PAUSE_SECONDS
246
- if reading.status != ALLOWED:
254
+ if reading.status not in RUNNABLE_STATUSES:
247
255
  until = reading.resets_at if reading.resets_at is not None else fallback
248
256
  hits.append((f"usage status is {reading.status}", until))
249
257
  for name, w in reading.windows.items():