ctrlrelay 0.16.4__tar.gz → 0.17.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 (190) hide show
  1. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/CHANGELOG.md +30 -1
  2. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/configuration.md +16 -12
  4. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/pyproject.toml +1 -1
  5. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/archived.py +16 -0
  6. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/config.py +1 -1
  7. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github.py +106 -0
  8. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/poller.py +244 -40
  9. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_watcher.py +3 -1
  10. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/post_merge.py +2 -2
  11. ctrlrelay-0.17.0/tests/_poller_fakes.py +38 -0
  12. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/conftest.py +24 -0
  13. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_archived.py +17 -8
  14. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_poller.py +13 -260
  15. ctrlrelay-0.17.0/tests/test_poller_search.py +291 -0
  16. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_server.py +1 -1
  17. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/dependabot.yml +0 -0
  21. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/build.yml +0 -0
  22. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/cla.yml +0 -0
  23. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/pages.yml +0 -0
  24. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/publish.yml +0 -0
  25. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/test.yml +0 -0
  26. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.gitignore +0 -0
  27. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/CODE_OF_CONDUCT.md +0 -0
  28. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/CONTRIBUTING.md +0 -0
  29. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/LICENSE +0 -0
  30. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/README.md +0 -0
  31. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/SECURITY.md +0 -0
  32. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/config/orchestrator.yaml.example +0 -0
  33. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/Gemfile +0 -0
  34. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/_config.yml +0 -0
  35. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/adding-a-machine.md +0 -0
  36. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/architecture.md +0 -0
  37. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/bridge.md +0 -0
  38. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/cli.md +0 -0
  39. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/development.md +0 -0
  40. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/feedback-loop.md +0 -0
  41. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/getting-started.md +0 -0
  42. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/index.md +0 -0
  43. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/operations.md +0 -0
  44. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/personalization.md +0 -0
  45. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  46. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  47. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/__init__.py +0 -0
  48. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  49. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  50. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/factory.py +0 -0
  51. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/handler.py +0 -0
  52. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  53. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  54. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  55. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/server.py +0 -0
  56. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  57. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/cli.py +0 -0
  58. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/__init__.py +0 -0
  59. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/agent_env.py +0 -0
  60. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/audit.py +0 -0
  61. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/binaries.py +0 -0
  62. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  63. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/claims.py +0 -0
  64. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/code_review.py +0 -0
  65. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  66. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  67. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/events.py +0 -0
  68. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github_budget.py +0 -0
  69. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  70. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/job_claims.py +0 -0
  71. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/network.py +0 -0
  72. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/node_check.py +0 -0
  73. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/obs.py +0 -0
  74. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  75. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/publish.py +0 -0
  76. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  77. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/redact.py +0 -0
  78. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/scheduler.py +0 -0
  79. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/state.py +0 -0
  80. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/usage.py +0 -0
  81. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/worktree.py +0 -0
  82. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  83. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/client.py +0 -0
  84. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/gh_protocol.py +0 -0
  85. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/install.py +0 -0
  86. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  87. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/manager.py +0 -0
  88. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/paths.py +0 -0
  89. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  90. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/base.py +0 -0
  91. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/dev.py +0 -0
  92. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  93. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/task.py +0 -0
  94. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/setup.py +0 -0
  95. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/__init__.py +0 -0
  96. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  97. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  98. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  99. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  100. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  101. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  102. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  103. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  104. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/__init__.py +0 -0
  105. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/base.py +0 -0
  106. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  107. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  108. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/__init__.py +0 -0
  109. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/fleet.py +0 -0
  110. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/health.py +0 -0
  111. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/links.py +0 -0
  112. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/server.py +0 -0
  113. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/static/index.html +0 -0
  114. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/status.py +0 -0
  115. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/transcript.py +0 -0
  116. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/waiting.py +0 -0
  117. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/__init__.py +0 -0
  118. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/_publish_fakes.py +0 -0
  119. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/fixtures/gh_search_issues.json +0 -0
  120. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  121. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  122. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_agent_no_keys.py +0 -0
  123. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_agent_sandbox.py +0 -0
  124. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_audit.py +0 -0
  125. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_binary_resolution.py +0 -0
  126. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_bridge_protocol.py +0 -0
  127. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_bridge_server.py +0 -0
  128. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_bridge_start_command.py +0 -0
  129. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_chat_machines.py +0 -0
  130. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_checkpoint.py +0 -0
  131. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_claim_reconciliation.py +0 -0
  132. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_ci_wait.py +0 -0
  133. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_dev.py +0 -0
  134. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_repos.py +0 -0
  135. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_secops.py +0 -0
  136. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_start.py +0 -0
  137. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_version.py +0 -0
  138. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_code_review.py +0 -0
  139. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_config.py +0 -0
  140. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dashboard_client.py +0 -0
  141. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dev_integration.py +0 -0
  142. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dev_pipeline.py +0 -0
  143. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dispatch_health.py +0 -0
  144. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dispatch_outcome_hook.py +0 -0
  145. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dispatcher.py +0 -0
  146. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_docs_site.py +0 -0
  147. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_event_emission.py +0 -0
  148. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_events.py +0 -0
  149. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_fleet.py +0 -0
  150. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_gh_protocol.py +0 -0
  151. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_github.py +0 -0
  152. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_github_budget.py +0 -0
  153. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_install.py +0 -0
  154. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_issue_outcome_announcement.py +0 -0
  155. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_job_claims.py +0 -0
  156. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_mattermost_handler.py +0 -0
  157. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_network.py +0 -0
  158. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_node_check.py +0 -0
  159. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_node_claims.py +0 -0
  160. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_notify_transport.py +0 -0
  161. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_obs.py +0 -0
  162. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_personalization.py +0 -0
  163. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pipeline_base.py +0 -0
  164. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_post_merge.py +0 -0
  165. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pr_verifier.py +0 -0
  166. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pr_watcher.py +0 -0
  167. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pre_dispatch_retry.py +0 -0
  168. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_publish_failure_retry.py +0 -0
  169. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_question_backoff.py +0 -0
  170. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_question_expiry.py +0 -0
  171. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_redact.py +0 -0
  172. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_relay_alert_off_lock.py +0 -0
  173. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_resume_prompt_contract.py +0 -0
  174. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_scheduler.py +0 -0
  175. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_secops_integration.py +0 -0
  176. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_secops_pipeline.py +0 -0
  177. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_setup.py +0 -0
  178. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_state.py +0 -0
  179. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_task_pipeline.py +0 -0
  180. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_telegram_handler.py +0 -0
  181. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_transport.py +0 -0
  182. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_transport_resolution.py +0 -0
  183. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  184. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_usage.py +0 -0
  185. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_health.py +0 -0
  186. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_links.py +0 -0
  187. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_status.py +0 -0
  188. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_transcript.py +0 -0
  189. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_waiting.py +0 -0
  190. {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0] - 2026-11-01
11
+
12
+ Polling costs one GitHub search instead of one call per repo. **No config
13
+ edit is required.** Check one thing if you use `include_labels`: a trigger
14
+ label now starts a run only when the configured user added it.
15
+
16
+ ### Changed
17
+
18
+ - Each poll asks GitHub's search once for open issues assigned to the user
19
+ across all configured repos, plus once per distinct `include_labels`
20
+ label, instead of one `gh issue list` per repo and one "is it archived"
21
+ probe per repo. Measured on 87 repos: 1 call per poll, down from about
22
+ 174. A repo is probed for "archived" only when it has an issue not yet
23
+ handled, and a "not archived" answer is kept for 6 hours (#283, closes
24
+ #282)
25
+ - A search GitHub marks incomplete, or that returns fewer rows than its
26
+ total, fails the poll as "could not look": nothing is marked seen or
27
+ treated as new. A startup seed that could not look is finished before
28
+ the next poll, so issues already there are never read as new (#283)
29
+ - A trigger label (`include_labels`) starts a run only when the configured
30
+ user added it, whatever `accept_foreign_assignments` says; the issue is
31
+ still taken if the user also assigned it to themselves. Before, anyone
32
+ who could label an issue could start a run (#283)
33
+ - The post-merge PR watcher and the status page's GitHub check
34
+ (`web.github_check_seconds`) default to 300 s, from 60 s (#283)
35
+ - GitHub logins compare without regard to case in the assignment and label
36
+ checks (#283)
37
+
10
38
  ## [0.16.4] - 2026-11-01
11
39
 
12
40
  Fixes for runs whose work could not be published. **No config edit is
@@ -2048,7 +2076,8 @@ pipeline).
2048
2076
  per-phase implementation plans (Phase 0 through Phase 4).
2049
2077
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2050
2078
 
2051
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...HEAD
2079
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.0...HEAD
2080
+ [0.17.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...v0.17.0
2052
2081
  [0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
2053
2082
  [0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
2054
2083
  [0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.16.4
3
+ Version: 0.17.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/
@@ -404,7 +404,7 @@ web:
404
404
  port: 8787
405
405
  history_days: 7
406
406
  refresh_seconds: 5
407
- github_check_seconds: 60
407
+ github_check_seconds: 300
408
408
  stale_after_minutes: 15
409
409
  allowed_hosts: [] # e.g. ["mybox", "mybox.lan"]
410
410
  ```
@@ -415,7 +415,7 @@ web:
415
415
  | `port` | int (1-65535) | `8787` | Port to listen on. |
416
416
  | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
417
417
  | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
418
- | `github_check_seconds` | int (>= 15) | `60` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). |
418
+ | `github_check_seconds` | int (>= 15) | `300` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). Every check spends from the GitHub account's budget, which machines on the account share. |
419
419
  | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
420
420
  | `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
421
421
 
@@ -623,21 +623,25 @@ repos:
623
623
  - An issue is accepted when **either** (a) it's assigned to the configured
624
624
  user (subject to the self-assignment filter from #79 and
625
625
  `accept_foreign_assignments`) **or** (b) it carries any label in
626
- `include_labels`. A label match **skips** the self-assignment check — the
627
- operator's config choice is the trust boundary.
626
+ `include_labels` **that the configured user added** (the latest
627
+ `labeled` event for that label names that account; `accept_foreign_assignments`
628
+ does not widen this). A label someone else added does not start a run;
629
+ the issue is still taken if the user also assigned it to themselves.
628
630
  - Dedup: an issue that is **both** labeled and assigned is picked up exactly
629
631
  once per poll cycle — no duplicate entries in `seen_issues` and no double
630
632
  pipeline spawn.
631
633
  - `exclude_labels` always wins over `include_labels` on the same issue: an
632
634
  explicit "not for the agent" opt-OUT beats the generic label opt-IN.
633
- - When a repo configures `include_labels`, the poller runs **targeted**
634
- queries per cycle: the existing `gh issue list --assignee <user>` plus
635
- one `gh issue list --label <L>` call per configured label. Results merge
636
- by issue number. This keeps the label path scale-safe on busy repos
637
- where an unfiltered fetch would silently cap at gh's `--limit` and miss
638
- labeled issues on later pages. Repos without `include_labels` run only
639
- the cheap `--assignee` query, so enabling the feature on one repo does
640
- not add API calls on the others.
635
+ - Each poll asks GitHub's search **once** for open issues assigned to the
636
+ user across all configured repos, plus **once per distinct
637
+ `include_labels` label** across the repos using it, whatever the number
638
+ of repos (split by owner only if the owner list is too long for one
639
+ query). A search GitHub marks incomplete, or that returns fewer rows
640
+ than its total, fails the poll as "could not look": nothing is marked
641
+ seen and nothing is treated as new. After that, only issues not yet
642
+ handled cost a call each (who assigned it, or who added the label), and
643
+ a repo is asked whether it is archived only when it has such an issue,
644
+ at most once every 6 hours.
641
645
  - The event log entry for a label-triggered acceptance is
642
646
  `poll.issue.included_by_label`, alongside the existing
643
647
  `poll.issue.excluded_by_label` for exclusions.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.16.4"
3
+ version = "0.17.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"
@@ -25,6 +25,8 @@ and an unarchived repo recovers on its own.
25
25
  from __future__ import annotations
26
26
 
27
27
  import asyncio
28
+ import time
29
+ from collections.abc import Callable
28
30
  from dataclasses import dataclass, field
29
31
  from typing import Any
30
32
 
@@ -37,6 +39,10 @@ _logger = get_logger("core.archived")
37
39
  # cycle, and a hung lookup would delay every repo behind it. Timing out
38
40
  # just means "unknown" — the repo is polled as normal.
39
41
  ARCHIVED_PROBE_TIMEOUT_SECONDS = 20
42
+ # How long a confirmed "not archived" answer is kept (#282). Each probe is a
43
+ # GitHub call on an account several machines share, and a repo is archived
44
+ # rarely; a repo archived meanwhile is noticed within this long.
45
+ ACTIVE_ANSWER_SECONDS = 6 * 3600
40
46
 
41
47
 
42
48
  @dataclass
@@ -62,6 +68,10 @@ class ArchivedRepoTracker:
62
68
  # the cache miss before either awaits, so the "one probe, one log"
63
69
  # contract in the docstring above is only true single-threaded.
64
70
  _locks: dict[str, asyncio.Lock] = field(default_factory=dict, repr=False)
71
+ # Repo -> monotonic time until which a confirmed "not archived" answer
72
+ # stands (#282). Only a definite answer is kept; an error never is.
73
+ _active_until: dict[str, float] = field(default_factory=dict, repr=False)
74
+ clock: Callable[[], float] = field(default=time.monotonic, repr=False)
65
75
 
66
76
  async def is_archived(self, repo: str) -> bool:
67
77
  """Return True only for a confirmed-archived repo.
@@ -73,6 +83,8 @@ class ArchivedRepoTracker:
73
83
  """
74
84
  if repo in self.archived_repos:
75
85
  return True
86
+ if self._active_until.get(repo, 0.0) > self.clock():
87
+ return False
76
88
 
77
89
  lock = self._locks.setdefault(repo, asyncio.Lock())
78
90
  async with lock:
@@ -108,6 +120,10 @@ class ArchivedRepoTracker:
108
120
  # `is True` on purpose: a truthy non-boolean (a Mock, a "true"
109
121
  # string from a future gh format change) must not skip a repo.
110
122
  if result is not True:
123
+ if result is False:
124
+ # A definite "not archived": kept for a while (#282). A
125
+ # truthy non-boolean is not definite and is not kept.
126
+ self._active_until[repo] = self.clock() + ACTIVE_ANSWER_SECONDS
111
127
  return False
112
128
 
113
129
  self.archived_repos.add(repo)
@@ -349,7 +349,7 @@ class WebConfig(BaseModel):
349
349
  refresh_seconds: int = Field(default=5, ge=1)
350
350
  # How often the waiting line asks GitHub. One search per check, plus
351
351
  # one query per include label; below 15 s that spends the rate limit.
352
- github_check_seconds: int = Field(default=60, ge=15)
352
+ github_check_seconds: int = Field(default=300, ge=15)
353
353
  # A running card with no new agent step for this long, and no CI wait
354
354
  # or review open, is marked "looks stuck".
355
355
  stale_after_minutes: int = Field(default=15, ge=1)
@@ -6,6 +6,7 @@ import asyncio
6
6
  import json
7
7
  import re
8
8
  import shutil
9
+ from collections.abc import Sequence
9
10
  from dataclasses import dataclass, field
10
11
  from typing import Any
11
12
 
@@ -95,6 +96,21 @@ def _find_gh() -> str:
95
96
  return "gh"
96
97
 
97
98
 
99
+
100
+ # GitHub refuses search queries longer than 256 characters; kept below it.
101
+ _SEARCH_QUERY_MAX = 240
102
+
103
+ # One page of ``search/issues`` as one JSON line, with the rows in
104
+ # ``gh issue list``'s field names (#282).
105
+ _SEARCH_PAGE_JQ = (
106
+ "{incomplete: .incomplete_results, total: .total_count, items: [.items[] | "
107
+ "{number, title, state, body, labels: [.labels[] | {name}], "
108
+ "assignees: [.assignees[] | {login}], createdAt: .created_at, "
109
+ "updatedAt: .updated_at, "
110
+ "repo: (.repository_url | sub(\"^https://api.github.com/repos/\"; \"\"))}]}"
111
+ " | tojson"
112
+ )
113
+
98
114
  @dataclass
99
115
  class GitHubCLI:
100
116
  """Async wrapper around the gh CLI."""
@@ -365,6 +381,96 @@ class GitHubCLI:
365
381
  issue["repo"] = (issue.get("repository") or {}).get("nameWithOwner", "")
366
382
  return issues
367
383
 
384
+ async def search_open_issues(
385
+ self,
386
+ *,
387
+ repos: Sequence[str],
388
+ assignee: str | None = None,
389
+ label: str | None = None,
390
+ ) -> dict[str, list[dict[str, Any]]]:
391
+ """Open issues in ``repos`` assigned to ``assignee`` or carrying
392
+ ``label``, in ONE search rather than one call per repo (#282): the
393
+ poller's intake, whatever the number of repos.
394
+
395
+ GitHub's search API directly (``gh api search/issues``), because
396
+ it says when an answer is partial: a page marked
397
+ ``incomplete_results`` (the search timed out on GitHub's side), or
398
+ fewer rows than ``total_count`` (more than search returns), raises
399
+ ``GitHubError`` rather than pass for complete, and so do empty or
400
+ unreadable output. Scoped to the owners of ``repos``; rows are kept
401
+ only for ``repos`` (matched without regard to case) and keyed by
402
+ the configured name. Every configured repo is a key, with ``[]``
403
+ when nothing matched, so "looked, found nothing" differs from "did
404
+ not look", which raises. Rows use ``gh issue list``'s field names.
405
+ """
406
+ if (assignee is None) == (label is None):
407
+ raise ValueError("pass exactly one of assignee or label")
408
+ by_name = {r.lower(): r for r in repos}
409
+ owners = sorted({r.split("/", 1)[0] for r in repos if "/" in r})
410
+ base = ["is:issue", "is:open"]
411
+ if assignee is not None:
412
+ base.append(f"assignee:{assignee}")
413
+ else:
414
+ base.append('label:"' + label.replace('"', "") + '"')
415
+ # GitHub caps a search query's length, so owners are split across
416
+ # as few queries as fit: the count grows with the number of
417
+ # owners, never with the number of repos.
418
+ queries: list[list[str]] = [[]]
419
+ for owner in owners:
420
+ term = f"user:{owner}"
421
+ if queries[-1] and len(" ".join(base + queries[-1] + [term])) > _SEARCH_QUERY_MAX:
422
+ queries.append([])
423
+ queries[-1].append(term)
424
+ rows: list[dict[str, Any]] = []
425
+ for owner_terms in queries:
426
+ rows += await self._search_issue_pages(" ".join(base + owner_terms))
427
+ out: dict[str, list[dict[str, Any]]] = {r: [] for r in repos}
428
+ for row in rows:
429
+ repo = by_name.get(str(row.get("repo", "")).lower())
430
+ if repo is not None:
431
+ out[repo].append(row)
432
+ return out
433
+
434
+ async def _search_issue_pages(self, query: str) -> list[dict[str, Any]]:
435
+ """Every row of one search, or ``GitHubError`` when the answer
436
+ could be partial or could not be read."""
437
+ output = await self._run_gh(
438
+ "api", "-X", "GET", "search/issues", "--paginate",
439
+ "-f", "q=" + query, "-f", "per_page=100",
440
+ "--jq", _SEARCH_PAGE_JQ,
441
+ )
442
+ pages = [json.loads(line) for line in output.splitlines() if line.strip()]
443
+ if not pages or not all(isinstance(p, dict) and "items" in p for p in pages):
444
+ raise GitHubError("gh api search/issues gave no readable answer")
445
+ if any(p.get("incomplete") for p in pages):
446
+ raise GitHubError("GitHub marked the search incomplete")
447
+ rows = [row for p in pages for row in p["items"]]
448
+ totals = {p.get("total") for p in pages}
449
+ # Every page must agree on the total: one that changed mid-way means
450
+ # the result set moved while it was being read.
451
+ total = totals.pop() if len(totals) == 1 else None
452
+ if not isinstance(total, int) or len(rows) < total:
453
+ raise GitHubError(
454
+ f"the search returned {len(rows)} of {total} issues, so the "
455
+ "answer may be incomplete"
456
+ )
457
+ return rows
458
+
459
+ async def list_label_events(
460
+ self,
461
+ repo: str,
462
+ issue_number: int,
463
+ ) -> list[dict[str, Any]]:
464
+ """``labeled`` events for an issue, oldest first, every page. On a
465
+ ``labeled`` event ``actor`` is the account that added the label
466
+ (unlike an ``assigned`` event, where it is the assignee, #274)."""
467
+ output = await self._run_gh(
468
+ "api", "--paginate",
469
+ f"/repos/{repo}/issues/{issue_number}/events",
470
+ "--jq", '.[] | select(.event=="labeled")',
471
+ )
472
+ return [json.loads(line) for line in output.splitlines() if line.strip()]
473
+
368
474
  async def current_login(self) -> str:
369
475
  """The login ``gh`` is authenticated as (``gh api user``).
370
476