ctrlrelay 0.22.0__tar.gz → 0.23.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 (231) hide show
  1. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/CHANGELOG.md +78 -1
  2. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/cli.md +14 -0
  4. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/configuration.md +85 -0
  5. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/operations.md +129 -3
  6. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/cli.py +299 -4
  8. ctrlrelay-0.23.0/src/ctrlrelay/core/blockers.py +105 -0
  9. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/config.py +81 -0
  10. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/events.py +2 -0
  11. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/github.py +77 -0
  12. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/heartbeat.py +36 -2
  13. ctrlrelay-0.23.0/src/ctrlrelay/core/intake_hold.py +235 -0
  14. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/poller.py +165 -0
  15. ctrlrelay-0.23.0/src/ctrlrelay/core/reopens.py +109 -0
  16. ctrlrelay-0.23.0/src/ctrlrelay/core/self_update.py +509 -0
  17. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/state.py +131 -0
  18. ctrlrelay-0.23.0/src/ctrlrelay/core/weekly_summary.py +189 -0
  19. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/post_merge.py +17 -0
  20. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/secops.py +40 -5
  21. ctrlrelay-0.23.0/src/ctrlrelay/web/drift.py +267 -0
  22. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/fleet.py +3 -0
  23. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/numbers.py +69 -9
  24. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/server.py +55 -1
  25. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/static/index.html +46 -10
  26. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/status.py +10 -0
  27. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/waiting.py +60 -8
  28. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_events.py +1 -1
  29. ctrlrelay-0.23.0/tests/test_intake_hold.py +108 -0
  30. ctrlrelay-0.23.0/tests/test_intake_hold_poller.py +174 -0
  31. ctrlrelay-0.23.0/tests/test_issue_blockers.py +531 -0
  32. ctrlrelay-0.23.0/tests/test_reopens.py +269 -0
  33. ctrlrelay-0.23.0/tests/test_secops_operator_prs.py +239 -0
  34. ctrlrelay-0.23.0/tests/test_self_update.py +638 -0
  35. ctrlrelay-0.23.0/tests/test_web_drift.py +612 -0
  36. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_numbers.py +8 -6
  37. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_server.py +24 -0
  38. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_waiting.py +58 -1
  39. ctrlrelay-0.23.0/tests/test_weekly_summary.py +231 -0
  40. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  41. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  42. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  43. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/dependabot.yml +0 -0
  44. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/build.yml +0 -0
  45. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/cla.yml +0 -0
  46. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/pages.yml +0 -0
  47. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/publish.yml +0 -0
  48. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.github/workflows/test.yml +0 -0
  49. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/.gitignore +0 -0
  50. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/CODE_OF_CONDUCT.md +0 -0
  51. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/CONTRIBUTING.md +0 -0
  52. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/LICENSE +0 -0
  53. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/README.md +0 -0
  54. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/SECURITY.md +0 -0
  55. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/config/orchestrator.yaml.example +0 -0
  56. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/Gemfile +0 -0
  57. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/_config.yml +0 -0
  58. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/adding-a-machine.md +0 -0
  59. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/architecture.md +0 -0
  60. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/bridge.md +0 -0
  61. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/development.md +0 -0
  62. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/feedback-loop.md +0 -0
  63. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/getting-started.md +0 -0
  64. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/index.md +0 -0
  65. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/llms.txt +0 -0
  66. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/personalization.md +0 -0
  67. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/registered-mode.md +0 -0
  68. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  69. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  70. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/__init__.py +0 -0
  71. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  72. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  73. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/factory.py +0 -0
  74. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/handler.py +0 -0
  75. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  76. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  77. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  78. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/server.py +0 -0
  79. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  80. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/__init__.py +0 -0
  81. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/agent_env.py +0 -0
  82. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/archived.py +0 -0
  83. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/audit.py +0 -0
  84. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/binaries.py +0 -0
  85. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  86. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/claims.py +0 -0
  87. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/code_review.py +0 -0
  88. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  89. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  90. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/github_budget.py +0 -0
  91. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/issue_questions.py +0 -0
  92. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/job_claims.py +0 -0
  93. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/network.py +0 -0
  94. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/node_check.py +0 -0
  95. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/obs.py +0 -0
  96. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/orphans.py +0 -0
  97. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  98. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  99. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/publish.py +0 -0
  100. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  101. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/redact.py +0 -0
  102. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/scheduler.py +0 -0
  103. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/stuck.py +0 -0
  104. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/usage.py +0 -0
  105. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/core/worktree.py +0 -0
  106. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  107. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/dashboard/client.py +0 -0
  108. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/gh_protocol.py +0 -0
  109. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/install.py +0 -0
  110. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  111. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/manager.py +0 -0
  112. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/merge.py +0 -0
  113. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/personalization/paths.py +0 -0
  114. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  115. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/base.py +0 -0
  116. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/dev.py +0 -0
  117. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/pipelines/task.py +0 -0
  118. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/setup.py +0 -0
  119. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/__init__.py +0 -0
  120. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  121. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  122. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  123. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  124. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  125. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  126. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  127. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  128. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/__init__.py +0 -0
  129. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/base.py +0 -0
  130. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  131. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  132. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/__init__.py +0 -0
  133. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/health.py +0 -0
  134. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/links.py +0 -0
  135. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/src/ctrlrelay/web/transcript.py +0 -0
  136. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/__init__.py +0 -0
  137. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/_poller_fakes.py +0 -0
  138. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/_publish_fakes.py +0 -0
  139. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/conftest.py +0 -0
  140. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/fixtures/gh_search_issues.json +0 -0
  141. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  142. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  143. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_backend_wiring.py +0 -0
  144. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_no_keys.py +0 -0
  145. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_routing.py +0 -0
  146. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_agent_sandbox.py +0 -0
  147. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_archived.py +0 -0
  148. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_audit.py +0 -0
  149. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_binary_resolution.py +0 -0
  150. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_bridge_protocol.py +0 -0
  151. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_bridge_server.py +0 -0
  152. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_bridge_start_command.py +0 -0
  153. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_chat_machines.py +0 -0
  154. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_checkpoint.py +0 -0
  155. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_claim_reconciliation.py +0 -0
  156. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_ci_wait.py +0 -0
  157. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_dev.py +0 -0
  158. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_repos.py +0 -0
  159. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_secops.py +0 -0
  160. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_start.py +0 -0
  161. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_cli_version.py +0 -0
  162. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_code_review.py +0 -0
  163. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_config.py +0 -0
  164. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dashboard_client.py +0 -0
  165. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_integration.py +0 -0
  166. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_pipeline.py +0 -0
  167. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_plan_step.py +0 -0
  168. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_step_switches.py +0 -0
  169. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_stuck.py +0 -0
  170. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dev_work_steps.py +0 -0
  171. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dispatch_health.py +0 -0
  172. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dispatch_outcome_hook.py +0 -0
  173. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_dispatcher.py +0 -0
  174. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_docs_site.py +0 -0
  175. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_event_emission.py +0 -0
  176. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_fleet.py +0 -0
  177. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_gh_protocol.py +0 -0
  178. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_github.py +0 -0
  179. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_github_budget.py +0 -0
  180. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_install.py +0 -0
  181. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_issue_outcome_announcement.py +0 -0
  182. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_issue_questions.py +0 -0
  183. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_job_claims.py +0 -0
  184. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_mattermost_handler.py +0 -0
  185. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_network.py +0 -0
  186. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_no_change_run.py +0 -0
  187. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_node_check.py +0 -0
  188. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_node_claims.py +0 -0
  189. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_notify_transport.py +0 -0
  190. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_obs.py +0 -0
  191. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_opencode_adapter.py +0 -0
  192. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_orphans.py +0 -0
  193. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_personalization.py +0 -0
  194. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_personalization_merge.py +0 -0
  195. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pipeline_base.py +0 -0
  196. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_poller.py +0 -0
  197. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_poller_recheck.py +0 -0
  198. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_poller_search.py +0 -0
  199. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_post_merge.py +0 -0
  200. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pr_verifier.py +0 -0
  201. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pr_watcher.py +0 -0
  202. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_pre_dispatch_retry.py +0 -0
  203. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_publish_failure_retry.py +0 -0
  204. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_question_backoff.py +0 -0
  205. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_question_expiry.py +0 -0
  206. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_question_survives_restart.py +0 -0
  207. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_redact.py +0 -0
  208. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_relay_alert_off_lock.py +0 -0
  209. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_relay_git_263.py +0 -0
  210. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_resume_prompt_contract.py +0 -0
  211. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_review_copy.py +0 -0
  212. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_review_gate.py +0 -0
  213. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_scheduler.py +0 -0
  214. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_secops_integration.py +0 -0
  215. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_secops_pipeline.py +0 -0
  216. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_setup.py +0 -0
  217. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_state.py +0 -0
  218. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_stuck.py +0 -0
  219. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_task_pipeline.py +0 -0
  220. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_telegram_handler.py +0 -0
  221. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_timeout_save.py +0 -0
  222. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_transport.py +0 -0
  223. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_transport_resolution.py +0 -0
  224. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  225. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_usage.py +0 -0
  226. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_health.py +0 -0
  227. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_links.py +0 -0
  228. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_peer_links.py +0 -0
  229. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_status.py +0 -0
  230. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_web_transcript.py +0 -0
  231. {ctrlrelay-0.22.0 → ctrlrelay-0.23.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.23.0] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - `repos[].automation.operator_prs` (`ask` or `ignore`; unset is `ask`) lets
15
+ the secops sweep leave the operator's own open PRs alone on a repo. By
16
+ default every such PR that touches more than `.github/dependabot.yml` is a
17
+ question, which can be asked again on later sweeps while the PR stays
18
+ open. With `ignore` those PRs get no question and are not named in the
19
+ summary; a PR that touches only `.github/dependabot.yml` is handled as
20
+ before (merged if it passes the author, diff and CI checks, a question if
21
+ not), and Dependabot PRs and alerts are unchanged. Unset renders the same
22
+ prompt as before (#335).
23
+
24
+ - The status page shows config drift between machines. Each machine's
25
+ `/api/status` carries a fingerprint of its config: the repo names, and a
26
+ hash per field of each repo's `automation` and `steps` and of
27
+ `schedules`; never a value. Each peer's card names what differs from this
28
+ machine, such as repos only here or only there, or a field that differs
29
+ on some repos, within one peer read. A peer on a different version shows
30
+ as unknown, not as drift. An intended difference, such as a routing label in
31
+ `exclude_labels`, is left out with `fleet.expected_drift` (#307).
32
+
33
+ - Reopened issues are recorded. When the relay sees an issue's PR merge
34
+ (and closes the issue), it notes the finish in the state database. If
35
+ that issue later turns up open in the poller's search, the poller reads
36
+ its GitHub events and, when there is a `reopened` event after the
37
+ finish, records the reopen once and puts a `reopened` event in the
38
+ feed. Ordinary polls make no extra GitHub call. The status page and
39
+ `/api/status` count reopens per window and per repo (`reopened`, with
40
+ `reopened_issues`), whether or not the relay picks the issue up again;
41
+ `null` until the daemon has added the new table (#230).
42
+ - A weekly chat summary. Once a week each node sends one message with its
43
+ last-7-day numbers: runs started, done, failed, blocked and stuck, the
44
+ success rate, time to PR, time to merge and reopens. A number it cannot
45
+ read says `unknown`, never 0. Set with `schedules.weekly_summary`
46
+ (`enabled`, `day`, `hour`; Monday 09:00 in the configured timezone by
47
+ default). The last delivery is kept in the state database, so a restart
48
+ does not send it twice, and a failed send is logged and tried again five
49
+ minutes later without affecting the poller (#230).
50
+
51
+ - `ctrlrelay self-update [--version X]` updates a node in one command, only
52
+ once it is idle. It reads the release from PyPI's JSON, downloads the
53
+ wheel and checks its sha256 (installing by path, because a node's index
54
+ has lagged a release by more than ten minutes), then holds new work with
55
+ a file beside the state database that the poller obeys the way it obeys
56
+ the usage pause. It waits, bounded, until the poller acknowledges that
57
+ nothing is in flight and no session is running, so a poll cannot start a
58
+ run between the check and the restart. Then `uv tool install --force`,
59
+ a restart of this node's systemd user units or LaunchAgents, and a check
60
+ that `/api/status` reports the new version and that a new poller process
61
+ reports it in its heartbeat and stays up (the heartbeat now carries the
62
+ poller's pid, version and start time). If a step after the install
63
+ fails, or the command is interrupted after the install began, the old
64
+ version is installed and restarted again; chat is told either way and
65
+ the command exits non-zero. The hold is removed on every
66
+ exit, and ignored once its process is gone or it expires (#306).
67
+ - The status page marks a peer whose version differs from this machine's:
68
+ its card is red and says which version each runs (#306).
69
+
70
+ - The poller does not start an issue whose GitHub "blocked by" issues are
71
+ still open (#310). Before claiming an issue it reads its blockers from
72
+ GitHub's issue dependencies, including blockers in other repositories.
73
+ An issue with an open blocker is not claimed, is logged as
74
+ `poll.issue.blocked`, is listed on the status page under Skipped as
75
+ "blocked by #A", and is said in chat once; every later poll asks again,
76
+ so it starts on the first poll after its last blocker closes. Blockers
77
+ that cannot be read (an API error, a rate limit, a 404 on a GitHub
78
+ without the dependencies API) hold the issue the same way
79
+ (`poll.issue.blockers_unknown`) and are never read as "no blockers"; the
80
+ chat is told only after 3 failed polls in a row. A held issue stays out
81
+ of `seen_issues` and is recorded in `blocked` in `poller_state.json`, so
82
+ a restart neither loses it nor repeats the chat line, and a failed
83
+ self-assignment check while it waits retries it next poll rather than
84
+ dropping it. Free text such as "Starts after: #A" is not read; use the link.
85
+
10
86
  ## [0.22.0] - 2026-11-05
11
87
 
12
88
  ### Added
@@ -2449,7 +2525,8 @@ pipeline).
2449
2525
  per-phase implementation plans (Phase 0 through Phase 4).
2450
2526
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2451
2527
 
2452
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...HEAD
2528
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...HEAD
2529
+ [0.23.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...v0.23.0
2453
2530
  [0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
2454
2531
  [0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
2455
2532
  [0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.22.0
3
+ Version: 0.23.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/
@@ -383,6 +383,20 @@ Opens the SQLite state DB at `paths.state_db` and prints:
383
383
 
384
384
  If the DB doesn't exist yet, prints a hint to run a pipeline first.
385
385
 
386
+ ## `ctrlrelay self-update`
387
+
388
+ ```bash
389
+ ctrlrelay self-update [--version X] [--wait-minutes N] [--no-ack] [-c PATH]
390
+ ```
391
+
392
+ Installs a version already on PyPI on this node once it is idle: holds new
393
+ work, waits (up to `--wait-minutes`, default 60) for the run in flight,
394
+ installs the wheel after checking its sha256 against PyPI's JSON, restarts
395
+ this node's units and checks `/api/status` reports the new version. On any
396
+ failure the old version stays or is put back, chat is told, and it exits
397
+ non-zero. `--no-ack` is for updating from a poller too old to acknowledge
398
+ the hold. See [Operations: Updating a node](operations.md#updating-a-node).
399
+
386
400
  ## `ctrlrelay version`
387
401
 
388
402
  ```bash
@@ -623,6 +623,8 @@ fleet:
623
623
  timeout_seconds: 5
624
624
  silent_after_seconds: 300
625
625
  account_label: "max-2"
626
+ expected_drift:
627
+ - "automation.exclude_labels"
626
628
  ```
627
629
 
628
630
  | Key | Type | Default | Description |
@@ -632,6 +634,36 @@ fleet:
632
634
  | `timeout_seconds` | number (0-60] | `5` | The longest wait for any one network step of a read; a whole read gets twice this before the peer is reported as `timeout`. |
633
635
  | `silent_after_seconds` | int (>= 60) | `300` | A machine whose daemon heartbeat is older than this is shown as **silent**: its web server answers, but the daemon's heartbeat is missing or old, so its poller cannot be confirmed to be working. |
634
636
  | `account_label` | string | unset | A name for this machine's agent subscription, shown on its card, such as `max-2`. Never an email address (refused). |
637
+ | `expected_drift` | list of strings | `[]` | Config differences with the peers that are intended, so the page does not report them. Each entry is a repo (`owner/name`: left out of the comparison entirely), a section (`automation`, `steps`, `schedules`) or one field of one (`automation.exclude_labels`, `steps.review`, `schedules.secops_cron`). Anything else is refused at load. See [Config drift](#config-drift). |
638
+
639
+ ### Config drift
640
+
641
+ Each machine's status page publishes a fingerprint of its config in
642
+ `node.config_fingerprint`. Each time the page or `/api/status` is built, it
643
+ compares the latest fingerprint read from each peer with its own, so a change
644
+ on a peer shows within one peer read. A difference shows on that peer's card, for example
645
+ `config: 2 repos only here: org/a, org/b` or
646
+ `config: automation.exclude_labels differs on 3 repos: …`, and marks the card.
647
+ Identical configs show nothing.
648
+
649
+ What is compared: the list of repos; for each repo in both, every field of
650
+ `automation` and of `steps`; and every field of `schedules`. Only repo
651
+ names and hashes are sent, never a value. The fingerprint is the config the
652
+ server was started with, so an edit shows once the daemon is restarted.
653
+
654
+ A peer on a version that sends no fingerprint, or one in another format,
655
+ shows `config drift unknown`, not as drift. So does a peer on a different
656
+ ctrlrelay version than this machine, even one that sends a fingerprint: two
657
+ versions can hash the same config differently (a changed default, say), so
658
+ the two are compared once both run the same version. A field only one side
659
+ has is not compared.
660
+
661
+ `expected_drift` is read on each machine for its own page only. A routing
662
+ label that only some machines carry in `exclude_labels` is listed as
663
+ `automation.exclude_labels` on every machine whose page should not report
664
+ it. Leaving out a field leaves it out on every repo: a different change to
665
+ that field is not reported either, which is the cost of comparing hashes
666
+ rather than values.
635
667
 
636
668
  ## repos
637
669
 
@@ -681,6 +713,7 @@ and ask the operator), or `never` (skip).
681
713
  | `codeql_dismiss` | `ask` | CodeQL alert dismissal. |
682
714
  | `secret_alerts` | `never` | Secret-scan alerts. |
683
715
  | `deploy_after_merge` | `auto` | Whether to deploy after a merged PR. |
716
+ | `operator_prs` | `ask` | `ask` or `ignore`. What the secops sweep does with your own open PRs that touch more than `.github/dependabot.yml`. See [operator_prs](#reposautomationoperator_prs) below. |
684
717
  | `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by someone else. Default (`false`) runs the dev pipeline only on issues you self-assigned. |
685
718
  | `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
686
719
  | `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
@@ -690,6 +723,31 @@ and ask the operator), or `never` (skip).
690
723
  The current secops and dev pipelines read these settings to bias their prompts
691
724
  to the agent — they're not enforced by hard-coded checks.
692
725
 
726
+ ### repos[].automation.operator_prs
727
+
728
+ The secops sweep looks at the open PRs you authored on each repo. One that
729
+ touches only `.github/dependabot.yml` is a "turn on Dependabot" change: it is
730
+ merged if it passes an author check, a diff check (additions only) and a CI
731
+ check, and is a question if it fails any of them.
732
+ Any other PR of yours is a question by default (`ask`), and while the PR
733
+ stays open that question can be asked again on later sweeps.
734
+
735
+ On a repo where you keep your own PRs open for a while (blog posts, drafts),
736
+ set `ignore`:
737
+
738
+ ```yaml
739
+ repos:
740
+ - name: you/your-blog
741
+ automation:
742
+ operator_prs: ignore
743
+ ```
744
+
745
+ With `ignore`, those PRs are left alone: no question, and they are not named
746
+ in the sweep's summary. A PR that touches only `.github/dependabot.yml` is
747
+ still handled as above, and Dependabot PRs and alerts are handled as before.
748
+ Unset is `ask`, and the sweep then runs exactly as it did before the setting
749
+ existed.
750
+
693
751
  ### repos[].automation.exclude_labels
694
752
 
695
753
  Some issues you assign to the operator user aren't code work — they're operator
@@ -1024,12 +1082,39 @@ won't silently disable the job at runtime.
1024
1082
  schedules:
1025
1083
  secops_cron: "0 6 * * *" # daily 06:00 sweep across repos
1026
1084
  personalization_cron: "*/15 * * * *" # optional auto-pull
1085
+ weekly_summary: # one chat message a week (#230)
1086
+ enabled: true
1087
+ day: mon
1088
+ hour: 9
1027
1089
  ```
1028
1090
 
1029
1091
  | Key | Type | Required | Default | Description |
1030
1092
  |---|---|---|---|---|
1031
1093
  | `secops_cron` | string | no | `"0 6 * * *"` | When to run the secops sweep (`ctrlrelay secops run` equivalent) across all configured repos. Set to e.g. `"0 6 * * 1"` for weekly Mondays. |
1032
1094
  | `personalization_cron` | string | no | unset | When to auto-pull the personalization repo on this machine. Only effective when [`personalization`](#personalization) is also set. Skip-on-dirty: never rebases under uncommitted operator edits. Adoption is never performed by auto-pull — that stays an init-time concern. |
1095
+ | `weekly_summary.enabled` | bool | no | `true` | Send this node's last-7-day numbers to the chat once a week. `false` turns it off. Nothing is sent when no chat [`transport`](#transport) is configured. |
1096
+ | `weekly_summary.day` | string | no | `"mon"` | The weekday to send it: `mon` to `sun` (full English names work too). |
1097
+ | `weekly_summary.hour` | int | no | `9` | The hour to send it, 0-23, in the top-level `timezone`. |
1098
+
1099
+ ### schedules.weekly_summary
1100
+
1101
+ Once a week, each node sends one chat message with its own numbers for
1102
+ the last 7 days, the same ones its status page shows: runs started, done,
1103
+ failed, blocked and stuck, the success rate, the median time to PR and to
1104
+ merge, and how many issues the relay finished were reopened. A number the
1105
+ node cannot read is written `unknown`, with the reason, never 0.
1106
+
1107
+ The daemon checks every five minutes. It sends at the first check after
1108
+ `day` at `hour`:00 that finds nothing delivered since then, so a node that
1109
+ was off at that time sends when it comes back, and a send that fails (the
1110
+ bridge restarting, the chat unreachable) is logged as
1111
+ `summary.weekly.send_failed` and tried again five minutes later. The time
1112
+ of the last delivery is kept in the state database, so restarting the
1113
+ poller does not send the week a second time. Two cases can still give an
1114
+ extra message: a poller stopped after the message went out but before
1115
+ that time was written, and changing `day` or `hour` to a time that has
1116
+ already passed this week. The first check on a new or upgraded node only
1117
+ notes the time, so its first summary goes at the next `day` and `hour`.
1033
1118
 
1034
1119
  ## personalization
1035
1120
 
@@ -295,7 +295,7 @@ Fields added in #232 (all in `/api/status`):
295
295
  |---|---|
296
296
  | `url` | On every `running`, `needs_you`, `waiting.items`, `skipped` and `feed` item (and on `/api/events` events): a link to the GitHub issue, or for a feed event that names a PR by number, the PR. Built only as `https://github.com/<owner>/<repo>/issues/<n>` or `/pull/<n>`, from a repo matching `owner/name` and an integer; `null` otherwise (a secops run with no issue, a restart). A `pr_url` an agent reported is never used. |
297
297
  | `refs` | On every `needs_you` item (#241): one `{"number", "url"}` per distinct `#N` in the question, in order, at most 20, with `url` built the same way as `url` above from the item's repo and N. A `#N` right after a letter, digit, `_`, `/` or `&` is not counted. The page turns each listed `#N` in the question into a link, so a secops question's PRs are clickable. `[]` when there are none. |
298
- | `skipped` | Every open issue assigned to you that the poller will **not** pick up and is not running, from the same search as the waiting line (no extra GitHub calls): `repo`, `issue_number`, `title`, `url`, `updated`/`updated_ts` and `reason`, one of `repo not in config`, `excluded label <label>`, `already handled once (won't retry)`, `missing required label <label>` (several joined with "or": any one admits it), `assigned by someone else`. `null` until GitHub has answered once; `waiting.status` and `waiting.last_good_ts` say how current it is. The page shows it collapsed, with a count. |
298
+ | `skipped` | Every open issue assigned to you that the poller will **not** pick up and is not running, from the same search as the waiting line (no extra GitHub calls): `repo`, `issue_number`, `title`, `url`, `updated`/`updated_ts` and `reason`, one of `repo not in config`, `excluded label <label>`, `already handled once (won't retry)`, `missing required label <label>` (several joined with "or": any one admits it), `assigned by someone else`, `blocked by <#N, owner/repo#N>` or `could not read its blockers; asked again every poll` (#310, see [Issues blocked by other issues](#issues-blocked-by-other-issues); these two are listed for a label-triggered issue too). `null` until GitHub has answered once; `waiting.status` and `waiting.last_good_ts` say how current it is. The page shows it collapsed, with a count. |
299
299
  | `server` | This machine's health, read on each request: `load_1m`, `load_5m`, `load_15m`, `cpu_count`, `memory` (`total_bytes`, `available_bytes`, `available_pct`, `low`), `disks` (one each for `home` and `worktrees`: `name`, `path`, `total_bytes`, `free_bytes`, `free_pct`, `low`), `uptime_s`, and `unavailable` (why any number is `null`; memory and uptime are `null` off Linux). `low` is true below 10% free and the page marks it. The whole section is `null`, with a line in `errors`, only if reading it failed outright. |
300
300
 
301
301
  Added in #247:
@@ -333,6 +333,13 @@ Added in #249:
333
333
  | `node.open_questions` | How many questions are waiting for an answer. |
334
334
  | `fleet` | `{peers: [...]}` with one item per `fleet.peers` URL: `url`, `name`, `reachable`, `checked_at`, `error`, `state`, `node` (that peer's `node` block), `last_known` (its last block while it does not answer) and `needs_you`. `null` when no peers are configured. |
335
335
 
336
+ Added in #307:
337
+
338
+ | Field | What |
339
+ |---|---|
340
+ | `node.config_fingerprint` | `{v, repos_hash, profiles, repos, schedules}`, `v` is `2`. `profiles` maps a profile hash to `{automation, steps}`, each a map of field name to a hash of its value; `repos` maps each repo name to its profile hash, so repos with the same configuration share one profile; `repos_hash` is the hash of the sorted, lowercased repo names; `schedules` maps each `schedules` field to a hash. Hashes and repo names only, never a value. `null` if it could not be built. A peer's fingerprint is unknown unless every hash is 16 lowercase hex characters, `repos_hash` is the hash of its own repo names, each profile key is the hash of that profile, and each repo names a profile it carries. A field's hash cannot be checked against its value, which is never sent. |
341
+ | `fleet.peers[].drift` | `{status, reason, only_here, only_there, fields, lines}`. `status` is `same`, `differs` or `unknown` (with `reason`: not read yet, not reachable, the peer sends no fingerprint in this format, or the comparison failed); `fields` maps a field path such as `automation.exclude_labels` to the repos it differs on; `lines` is what the page shows. Leaves out `fleet.expected_drift`. The peer's own fingerprint is not repeated in `fleet.peers[].node`. |
342
+
336
343
  Added in #230: `numbers` is how this machine's runs went, for the last 7 and
337
344
  the last 30 days (`numbers.windows`, each with `days`, `since`/`since_ts`,
338
345
  `until`/`until_ts`). The page shows it in the **Numbers** box, with a table
@@ -345,16 +352,23 @@ control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
345
352
  | `done`, `failed`, `blocked`, `running`, `other` | How many of those runs have that status now. `running` includes a run left behind by a daemon that stopped mid-run. The five add up to `started`. |
346
353
  | `success_rate` | `done / (done + failed + blocked)`, 0 to 1; `null` when none has finished. |
347
354
  | `time_to_pr_s`, `time_to_merge_s` | `{median, n}`: seconds from the run's start to its first `pr_opened` (or `merged`) event, over the `n` runs that have one. Events stamped after now are not read. `null` when events cannot be read, or when a run in the window started before the oldest event still kept: its PR event may have been deleted. The daemon deletes events older than `web.history_days` (7 by default), so with the default the 30-day times are `null`. **To get them, set `web.history_days` to 30**; they appear once the events kept reach back past every run in the window (up to 30 days after the change, since deleted events do not come back). |
348
- | `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). An issue reopened on GitHub and not picked up again is not counted: ctrlrelay does not record reopening. |
355
+ | `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). |
356
+ | `reopened` | Issues the relay finished (it saw their PR merge and closed them) that were reopened on GitHub in the window, dated by GitHub's `reopened` event. Counted whether or not the relay picks the issue up again; one per finish. The window's `reopened_issues` lists them. The poller notices a reopen only for an issue its search returns (open and assigned to the operator, or wearing a trigger label), with no extra GitHub call unless a finished issue shows up open. Finishes are recorded in the state database from this version on, plus those still in the kept `merged` events when the table is created. `null` on a database the daemon has not migrated yet. A `reopened` event goes to the feed when one is recorded. |
349
357
  | `cost_per_run` | Always `null` for now: cost per run is not recorded yet (#223). The window's `unavailable` says this, and why any other field is `null`. |
350
358
  | `stuck` | Runs the relay stopped as stuck (#224), also counted in `failed`. `null` only on a database the daemon has not migrated yet. |
351
359
 
352
360
  `numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
353
361
 
362
+ Each machine also sends these 7-day numbers to the chat once a week; see
363
+ [configuration: schedules.weekly_summary]({{ '/configuration/' | relative_url }}#schedulesweekly_summary).
364
+
354
365
  **The page with peers.** A **Machines** strip shows one card per machine,
355
366
  this one first: state, slots, 5-hour and 7-day usage, heartbeat age. A card is
356
367
  red when the machine is paused, silent, unknown, not reachable, or not read
357
- yet. A peer that does not
368
+ yet. A peer that answers with a different version from this machine's is red
369
+ too, with `version X, this machine runs Y` on its card, so a node a release
370
+ missed shows up; a peer whose version is unknown is not marked. A peer that
371
+ does not
358
372
  answer is shown as **not reachable from here**, with the reason (`timeout`,
359
373
  `refused`, `name not found`, `bad host header`, an HTTP status) and its last
360
374
  known numbers greyed out, never as idle. **Who has what** lists every claimed
@@ -623,6 +637,44 @@ unit files above set `ExitTimeOut=180` (launchd) and
623
637
  and leaves stale `git worktree` admin state behind. If you're upgrading
624
638
  from an older plist/unit that didn't set these, add them and reload.
625
639
 
640
+ ## Issues blocked by other issues
641
+
642
+ When work has an order (B changes the same files as A, so B waits for A),
643
+ say so on GitHub with an issue dependency: on B, **Relationships → Mark as
644
+ blocked by → A**. The blocker may be in another repository. Before the
645
+ poller claims an issue it reads that issue's blockers
646
+ (`GET /repos/{owner}/{repo}/issues/{n}/dependencies/blocked_by`), and:
647
+
648
+ - **None is open:** the run starts as usual.
649
+ - **One or more is open:** the issue is not claimed and not started. The
650
+ log has `poll.issue.blocked` with `blocked_by` (the open ones, `#N` in
651
+ the same repository, `owner/repo#N` in another), the status page lists
652
+ it under **Skipped** as `blocked by …`, and the chat is told once per
653
+ issue, not every poll. Every later poll asks again, so it starts on the
654
+ first poll after the last blocker closes, with nothing to do by hand.
655
+ - **The blockers could not be read** (an API error, the GitHub API budget
656
+ used up, a 404 from a GitHub Enterprise Server without the dependencies
657
+ API): treated like an open blocker, never as "no blockers". The log has
658
+ `poll.issue.blockers_unknown` with the error on every failed poll, and
659
+ the next poll tries again. A single failure is usually a blip, so the
660
+ status page keeps showing the blockers last read and the chat is not
661
+ told; after 3 failed polls in a row the page says `could not read its
662
+ blockers` and the chat is told once per issue. If the blockers can then
663
+ be read and are the same as before, nothing more is said.
664
+
665
+ Only the dependency link counts. A line such as "Starts after: #A" in the
666
+ issue body is not read, so add the link as well.
667
+
668
+ A held issue is kept out of `seen_issues` (that is what brings it back)
669
+ and recorded in `blocked` in the same `poller_state.json`, so a restart
670
+ neither loses it nor repeats the chat line. Its self-assignment is
671
+ checked again every poll (it may have been unassigned and assigned again
672
+ by someone else), but a check that fails leaves a held issue for the next
673
+ poll instead of marking it handled for good.
674
+ Each poll costs a held issue one extra REST call (`core` budget) for its
675
+ blockers, plus the re-read every issue gets just before it starts. A blocked issue that is closed or loses its trigger is dropped
676
+ from `blocked` when the poller next sees it.
677
+
626
678
  ## Stuck runs
627
679
 
628
680
  A run that goes in circles is stopped early instead of running until
@@ -673,6 +725,73 @@ page's numbers count these runs in `stuck`.
673
725
  | Bot token env var | bridge **and** poller |
674
726
 
675
727
  After a `ctrlrelay` package upgrade (`uv pip install -e .`), restart both.
728
+ For a node installed with `uv tool`, `ctrlrelay self-update` does the
729
+ install and the restarts for you; see below.
730
+
731
+ ## Updating a node
732
+
733
+ ```bash
734
+ ctrlrelay self-update # the latest version on PyPI
735
+ ctrlrelay self-update --version 0.23.0 # a given version
736
+ ```
737
+
738
+ It only installs a version already published on PyPI; publishing stays a
739
+ human decision. In order:
740
+
741
+ 1. Reads `https://pypi.org/pypi/ctrlrelay/<version>/json` and checks that
742
+ this node's status page answers on `127.0.0.1:<web.port>`; without it the
743
+ update could not be checked, so it refuses. It also refuses when there is
744
+ no poller or web unit installed, or ctrlrelay is not a `uv tool` install.
745
+ Nothing has changed yet.
746
+ 2. Downloads the wheel into `wheels/` beside the state database and checks
747
+ its sha256 against that JSON. A mismatch deletes the file and installs
748
+ nothing. The wheel is installed by path rather than by name because a
749
+ node's package index has served a stale list for more than ten minutes
750
+ after a release. The file is kept, since uv's receipt records the path.
751
+ 3. Holds new work: it writes `intake-hold.json` beside the state database.
752
+ While it is there the poller leaves new issues for the next poll and
753
+ skips the scheduled sweep and resumed questions, the same way the usage
754
+ pause does; runs already going finish. The poller answers in
755
+ `intake-hold.ack.json` with whether anything is still in flight. The
756
+ command waits, up to `--wait-minutes` (default 60), until the poller says
757
+ nothing is in flight and the state database has no `running` session.
758
+ Checking the database alone is not enough: an issue can pass the poller's
759
+ checks a few seconds before its session row is written, and a restart in
760
+ that gap cancels it.
761
+ 4. `uv tool install --force <wheel>`, then restarts every installed unit:
762
+ `systemctl --user restart ctrlrelay-{bridge,poller,web}.service` on
763
+ Linux, `launchctl kickstart -k gui/<uid>/<label>` for each
764
+ `*.ctrlrelay-{bridge,poller,web}.plist` in `~/Library/LaunchAgents` on
765
+ macOS (the label is the file's name, whatever `--label-prefix` it was
766
+ installed with).
767
+ 5. Waits up to two minutes for `/api/status` to report the new version,
768
+ and up to two and a half for a new poller process to report it in its
769
+ heartbeat (`heartbeat.json` now carries the poller's `pid`, `version`
770
+ and `started_at`) and still be the one reporting at least one heartbeat
771
+ interval (30 s) after it started. The status page's version
772
+ is the web server's own, and a restart command succeeds even when the
773
+ unit then crash-loops, so neither alone shows the poller is up.
774
+
775
+ If any step after the install fails, or the command is interrupted
776
+ (Ctrl-C, SIGTERM) once the install has begun, the old version is installed
777
+ and restarted the same way, and checked; a poller from before this command
778
+ writes no version in its heartbeat, so for the old version a fresh
779
+ heartbeat from a new process is enough. Chat (when a transport is configured)
780
+ gets one line either way: the update, or the step that failed and whether
781
+ the old version is running again. The command exits non-zero on any
782
+ failure. The hold is removed on every way out, including Ctrl-C and
783
+ SIGTERM; one left by a process that was killed outright is ignored once
784
+ that process is gone or the hold's expiry (the wait plus 30 minutes) has
785
+ passed.
786
+
787
+ A poller from before this command does not know the hold and never
788
+ acknowledges it, so the command stops after a minute without changing
789
+ anything. For that one update, `--no-ack` goes on the state database alone,
790
+ with the gap described in step 3. A poller that is not running (its
791
+ heartbeat is older than `fleet.silent_after_seconds`) is not waited on.
792
+
793
+ A scheduled security sweep whose time comes while the hold is in place is
794
+ skipped until its next time, not delayed.
676
795
 
677
796
  After merging a PR that the dev pipeline opened, no manual restart is required —
678
797
  the PR is referenced from the open branch in your bare repo, so cleanup is
@@ -846,6 +965,13 @@ number only — no assignee and no labels — so the number has to leave
846
965
  `seen_issues` (and the relay has to be restarted, since the running
847
966
  poller holds the set in memory and rewrites the file from it).
848
967
 
968
+ An issue held back by an open GitHub blocker (see [Issues blocked by
969
+ other issues](#issues-blocked-by-other-issues)) is never in `seen_issues`
970
+ while it waits; it is listed in `blocked[<repo>]` as `{"open": [...],
971
+ "failures": n}`: the open blockers as last read (`null` if never read)
972
+ and how many lookups have failed in a row since. Nothing needs deleting: it starts on
973
+ its own once the blockers close.
974
+
849
975
  ### A repo whose history was replaced
850
976
 
851
977
  If the local copy's default branch can no longer fast-forward to GitHub's
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.22.0"
3
+ version = "0.23.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"