ctrlrelay 0.19.1__tar.gz → 0.20.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 (209) hide show
  1. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/CHANGELOG.md +49 -1
  2. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/configuration.md +25 -1
  4. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/pyproject.toml +1 -1
  5. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/cli.py +22 -0
  6. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/config.py +9 -2
  7. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/dispatcher.py +4 -0
  8. ctrlrelay-0.20.0/src/ctrlrelay/core/orphans.py +388 -0
  9. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/state.py +65 -1
  10. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/worktree.py +319 -1
  11. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/base.py +5 -0
  12. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/dev.py +653 -136
  13. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/secops.py +10 -3
  14. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/task.py +10 -3
  15. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/fleet.py +5 -0
  16. ctrlrelay-0.20.0/src/ctrlrelay/web/links.py +74 -0
  17. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/static/index.html +42 -12
  18. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_start.py +54 -0
  19. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_integration.py +4 -0
  20. ctrlrelay-0.20.0/tests/test_dev_plan_step.py +395 -0
  21. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_step_switches.py +0 -1
  22. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_work_steps.py +5 -2
  23. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dispatcher.py +2 -0
  24. ctrlrelay-0.20.0/tests/test_orphans.py +564 -0
  25. ctrlrelay-0.20.0/tests/test_timeout_save.py +419 -0
  26. ctrlrelay-0.20.0/tests/test_web_peer_links.py +99 -0
  27. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_server.py +66 -3
  28. ctrlrelay-0.19.1/src/ctrlrelay/web/links.py +0 -35
  29. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  30. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  31. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/dependabot.yml +0 -0
  33. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/build.yml +0 -0
  34. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/cla.yml +0 -0
  35. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/pages.yml +0 -0
  36. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/publish.yml +0 -0
  37. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.github/workflows/test.yml +0 -0
  38. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/.gitignore +0 -0
  39. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/CODE_OF_CONDUCT.md +0 -0
  40. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/CONTRIBUTING.md +0 -0
  41. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/LICENSE +0 -0
  42. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/README.md +0 -0
  43. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/SECURITY.md +0 -0
  44. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/config/orchestrator.yaml.example +0 -0
  45. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/Gemfile +0 -0
  46. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/_config.yml +0 -0
  47. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/adding-a-machine.md +0 -0
  48. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/architecture.md +0 -0
  49. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/bridge.md +0 -0
  50. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/cli.md +0 -0
  51. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/development.md +0 -0
  52. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/feedback-loop.md +0 -0
  53. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/getting-started.md +0 -0
  54. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/index.md +0 -0
  55. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/llms.txt +0 -0
  56. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/operations.md +0 -0
  57. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/personalization.md +0 -0
  58. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/registered-mode.md +0 -0
  59. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  60. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  61. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/__init__.py +0 -0
  62. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  63. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  64. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/factory.py +0 -0
  65. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/handler.py +0 -0
  66. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  67. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  68. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  69. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/server.py +0 -0
  70. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  71. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/__init__.py +0 -0
  72. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/agent_env.py +0 -0
  73. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/archived.py +0 -0
  74. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/audit.py +0 -0
  75. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/binaries.py +0 -0
  76. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  77. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/claims.py +0 -0
  78. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/code_review.py +0 -0
  79. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  80. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/events.py +0 -0
  81. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/github.py +0 -0
  82. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/github_budget.py +0 -0
  83. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  84. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/job_claims.py +0 -0
  85. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/network.py +0 -0
  86. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/node_check.py +0 -0
  87. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/obs.py +0 -0
  88. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/poller.py +0 -0
  89. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  90. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  91. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/publish.py +0 -0
  92. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  93. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/redact.py +0 -0
  94. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/scheduler.py +0 -0
  95. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/core/usage.py +0 -0
  96. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  97. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/dashboard/client.py +0 -0
  98. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/gh_protocol.py +0 -0
  99. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/install.py +0 -0
  100. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  101. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/manager.py +0 -0
  102. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/merge.py +0 -0
  103. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/personalization/paths.py +0 -0
  104. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  105. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  106. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/setup.py +0 -0
  107. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/__init__.py +0 -0
  108. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  109. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  110. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  111. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  112. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  113. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  114. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  115. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  116. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/__init__.py +0 -0
  117. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/base.py +0 -0
  118. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  119. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  120. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/__init__.py +0 -0
  121. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/health.py +0 -0
  122. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/numbers.py +0 -0
  123. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/server.py +0 -0
  124. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/status.py +0 -0
  125. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/transcript.py +0 -0
  126. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/src/ctrlrelay/web/waiting.py +0 -0
  127. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/__init__.py +0 -0
  128. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/_poller_fakes.py +0 -0
  129. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/_publish_fakes.py +0 -0
  130. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/conftest.py +0 -0
  131. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/fixtures/gh_search_issues.json +0 -0
  132. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  133. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  134. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_backend_wiring.py +0 -0
  135. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_no_keys.py +0 -0
  136. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_routing.py +0 -0
  137. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_agent_sandbox.py +0 -0
  138. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_archived.py +0 -0
  139. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_audit.py +0 -0
  140. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_binary_resolution.py +0 -0
  141. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_bridge_protocol.py +0 -0
  142. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_bridge_server.py +0 -0
  143. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_bridge_start_command.py +0 -0
  144. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_chat_machines.py +0 -0
  145. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_checkpoint.py +0 -0
  146. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_claim_reconciliation.py +0 -0
  147. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_ci_wait.py +0 -0
  148. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_dev.py +0 -0
  149. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_repos.py +0 -0
  150. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_secops.py +0 -0
  151. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_cli_version.py +0 -0
  152. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_code_review.py +0 -0
  153. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_config.py +0 -0
  154. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dashboard_client.py +0 -0
  155. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dev_pipeline.py +0 -0
  156. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dispatch_health.py +0 -0
  157. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_dispatch_outcome_hook.py +0 -0
  158. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_docs_site.py +0 -0
  159. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_event_emission.py +0 -0
  160. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_events.py +0 -0
  161. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_fleet.py +0 -0
  162. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_gh_protocol.py +0 -0
  163. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_github.py +0 -0
  164. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_github_budget.py +0 -0
  165. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_install.py +0 -0
  166. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_issue_outcome_announcement.py +0 -0
  167. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_job_claims.py +0 -0
  168. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_mattermost_handler.py +0 -0
  169. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_network.py +0 -0
  170. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_node_check.py +0 -0
  171. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_node_claims.py +0 -0
  172. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_notify_transport.py +0 -0
  173. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_obs.py +0 -0
  174. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_opencode_adapter.py +0 -0
  175. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_personalization.py +0 -0
  176. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_personalization_merge.py +0 -0
  177. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pipeline_base.py +0 -0
  178. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_poller.py +0 -0
  179. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_poller_search.py +0 -0
  180. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_post_merge.py +0 -0
  181. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pr_verifier.py +0 -0
  182. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pr_watcher.py +0 -0
  183. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_pre_dispatch_retry.py +0 -0
  184. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_publish_failure_retry.py +0 -0
  185. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_question_backoff.py +0 -0
  186. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_question_expiry.py +0 -0
  187. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_redact.py +0 -0
  188. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_relay_alert_off_lock.py +0 -0
  189. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_relay_git_263.py +0 -0
  190. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_resume_prompt_contract.py +0 -0
  191. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_review_copy.py +0 -0
  192. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_scheduler.py +0 -0
  193. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_secops_integration.py +0 -0
  194. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_secops_pipeline.py +0 -0
  195. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_setup.py +0 -0
  196. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_state.py +0 -0
  197. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_task_pipeline.py +0 -0
  198. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_telegram_handler.py +0 -0
  199. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_transport.py +0 -0
  200. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_transport_resolution.py +0 -0
  201. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  202. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_usage.py +0 -0
  203. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_health.py +0 -0
  204. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_links.py +0 -0
  205. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_numbers.py +0 -0
  206. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_status.py +0 -0
  207. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_transcript.py +0 -0
  208. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_web_waiting.py +0 -0
  209. {ctrlrelay-0.19.1 → ctrlrelay-0.20.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.0] - 2026-11-04
11
+
12
+ ### Added
13
+
14
+ - `repos[].steps.plan` (`off`, `on`, `required`; unset is `off`) runs a
15
+ planning session before the dev pipeline's code session. It changes no
16
+ files and writes a plan with numbered acceptance criteria, saved under
17
+ `<paths.contexts>/<owner>-<repo>/plans/issue-<n>.md` (outside the worktree),
18
+ posted on the issue as the criteria list, and added to the code session's
19
+ prompt. A vague issue gets one question instead of a pull request. A plan
20
+ without criteria is a failed plan: `on` reports it and codes without one,
21
+ `required` fails the run (#220).
22
+ - On the status page, a peer machine's name links to that machine's own
23
+ page: in the machine strip, in "Who has what" and on a "Needs you" card.
24
+ The address is the `fleet.peers` entry from this machine's config, cut to
25
+ `<scheme>://<host>[:port]/`; nothing a peer answers changes it, and an
26
+ entry with userinfo or a non-http scheme gives plain text (#319).
27
+
28
+ ### Fixed
29
+
30
+ - A run whose relay process died mid-run (the machine ran out of memory, or
31
+ the service was stopped) no longer stays `running` for ever. Sessions now
32
+ record the process running them (`owner_pid`, `owner_run`) and their claim
33
+ id. When the poller starts, it finds `running` sessions whose process is
34
+ gone, and in whose worktree no process is still working, and marks them
35
+ failed, dropping any repo lock they held. A dev or task issue goes back to
36
+ the next poll unless an open pull request was found for its branch. Where
37
+ the run had claimed the issue, the relay posts a comment that names the
38
+ worktree and releases the claim. The relay deletes nothing in the
39
+ worktree. It detaches the worktree's HEAD, when that can be done safely,
40
+ so the next run can check the issue's branch out. Rows written by an older
41
+ version name no process and are left alone (#312).
42
+ - A dev run whose agent session hits its time limit no longer throws away
43
+ the agent's work. Before the worktree is removed, the files on disk are
44
+ committed on top of the commit it sits on, and that commit, or the
45
+ unpushed commits alone, are pushed to a new `wip/issue-<n>-<run id>`
46
+ branch. The run's own branch is not pushed, no pull request is opened, and
47
+ the local run branch is kept, since it can hold commits the worktree's
48
+ HEAD does not reach. The failure comment on the issue names the wip branch
49
+ and, when it is known, how long the run took. If the work cannot be saved,
50
+ the worktree is kept on disk and the comment names it. A run with nothing
51
+ new loses only its worktree. Files the worktree's `.gitignore` ignores are
52
+ not saved; files that cannot be read are not saved either, and then the
53
+ worktree is kept and the comment says so. The files are read by a git with
54
+ a private git dir, index, config and attributes override, so filters,
55
+ hooks and attributes named by the agent do not take effect (#311).
56
+
10
57
  ## [0.19.1] - 2026-11-03
11
58
 
12
59
  ### Fixed
@@ -2266,7 +2313,8 @@ pipeline).
2266
2313
  per-phase implementation plans (Phase 0 through Phase 4).
2267
2314
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2268
2315
 
2269
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...HEAD
2316
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...HEAD
2317
+ [0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
2270
2318
  [0.19.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...v0.19.1
2271
2319
  [0.19.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...v0.19.0
2272
2320
  [0.18.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...v0.18.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.19.1
3
+ Version: 0.20.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/
@@ -865,7 +865,8 @@ repos:
865
865
 
866
866
  ### repos[].steps
867
867
 
868
- The dev pipeline does its work in a fixed order: **code** (the agent run and
868
+ The dev pipeline does its work in a fixed order: **plan** (an optional
869
+ planning session, off unless asked for), **code** (the agent run and
869
870
  any questions it asks), **publish** (the relay pushes the branch and opens the
870
871
  pull request), **verify** (wait for CI and mergeability, asking the agent to
871
872
  fix what fails, up to `max_fix_attempts` rounds) and **review** (the
@@ -893,6 +894,7 @@ repos:
893
894
 
894
895
  | Step | Accepts | Unset means |
895
896
  |---|---|---|
897
+ | `plan` | `off`, `on`, `required` | `off`: no planning session. |
896
898
  | `code` | `required` | A failed or crashed agent run stops the run. Same as `required`. |
897
899
  | `publish` | `required` | A refused or failed push stops the run (the branch is kept). Same as `required`. |
898
900
  | `verify` | `off`, `required` | CI still red after the fix rounds stops the run. CI that is still pending when the wait times out hands the PR over. |
@@ -905,6 +907,28 @@ PR as ready while its CI is red, with no line in the announcement saying so.
905
907
  Unknown step names (a typo such as `verfy`) are refused as well. A bare `on`
906
908
  or `off` works unquoted.
907
909
 
910
+ **`plan: on`** or **`plan: required`** runs one planning session in the
911
+ run's worktree before the code session. It is told to read the issue and the
912
+ repo and change no files, and returns a plan with four headings: `## Change`,
913
+ `## Files`, `## Risks` and `## Acceptance criteria`, a numbered list of items
914
+ that can each be checked by a command or by reading the diff. The relay then:
915
+
916
+ - saves the plan to `<paths.contexts>/<owner>-<repo>/plans/issue-<n>.md`,
917
+ outside the worktree, so it is never committed;
918
+ - posts the criteria list as one comment on the issue;
919
+ - adds the plan to the code session's prompt under `## Plan`, telling the
920
+ agent the criteria are what "done" means.
921
+
922
+ If the issue is too vague to plan, the planning session asks one question,
923
+ through the same chat loop the code session uses. No code session runs and no
924
+ PR is opened until it is answered; an answer that arrives after the run
925
+ stopped resumes the planning session, then codes with the plan.
926
+
927
+ A plan with no numbered acceptance criteria is a failed plan, as is a
928
+ planning session that fails or crashes. With `on` the failure is posted on the
929
+ issue and the code session runs without a plan; with `required` the run fails
930
+ and no code session runs.
931
+
908
932
  **`verify: required`** also fails the run when the CI wait times out, before
909
933
  or after a fix round. (Checks that cannot be read at all fail the run in every
910
934
  mode that waits on CI, not only under `required`.)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.19.1"
3
+ version = "0.20.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"
@@ -4203,6 +4203,28 @@ def poller_start(
4203
4203
  skip_initial_persist=True,
4204
4204
  )
4205
4205
 
4206
+ # Runs whose process died with them (#312): settle them before
4207
+ # the first poll, so the issues they held are taken again by
4208
+ # that poll rather than skipped as already seen.
4209
+ from ctrlrelay.core.orphans import reap_orphaned_sessions
4210
+ reaped = await reap_orphaned_sessions(
4211
+ state_db, poller=poller, github=github, worktree=worktree,
4212
+ )
4213
+ for r in reaped:
4214
+ where = (
4215
+ f"#{r['issue_number']} in {r['repo']}"
4216
+ if r["issue_number"] is not None else r["repo"]
4217
+ )
4218
+ kept = (
4219
+ f" Its worktree is kept at {r['worktree_path']}."
4220
+ if r["worktree_path"]
4221
+ and Path(r["worktree_path"]).exists() else ""
4222
+ )
4223
+ console.print(
4224
+ f"[yellow]Run {r['session_id']} for {where} stopped "
4225
+ f"when the relay did; marked failed.{kept}[/yellow]"
4226
+ )
4227
+
4206
4228
  try:
4207
4229
  await github_budget.refresh()
4208
4230
  await run_poll_loop(
@@ -507,6 +507,9 @@ _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
507
507
  # reads this table as well, so a caller that bypasses the config cannot
508
508
  # get a mode accepted and ignored.
509
509
  DEV_STEP_MODES: dict[str, tuple[str, ...]] = {
510
+ # ``plan`` (#220) runs before ``code``. Unset is ``off``: a repo that
511
+ # does not ask for a plan runs exactly as it did before the step.
512
+ "plan": ("off", "on", "required"),
510
513
  "code": ("required",),
511
514
  "publish": ("required",),
512
515
  "verify": ("off", "required"),
@@ -514,6 +517,7 @@ DEV_STEP_MODES: dict[str, tuple[str, ...]] = {
514
517
  }
515
518
 
516
519
  _STEP_REFUSALS: dict[str, str] = {
520
+ "plan": "",
517
521
  "code": (
518
522
  "a dev run without the agent run has nothing to publish"
519
523
  ),
@@ -567,7 +571,7 @@ class DevStepsConfig(BaseModel):
567
571
  ``verify`` stops it when CI is still red after the fix rounds, but
568
572
  hands the pull request over when the CI wait times out; ``review``
569
573
  reports a review that did not post and hands the pull request over
570
- anyway (``on``).
574
+ anyway (``on``); ``plan`` does not run (``off``).
571
575
 
572
576
  Unknown keys are refused, so a typo such as ``verfy: required``
573
577
  cannot load as "unset".
@@ -575,12 +579,15 @@ class DevStepsConfig(BaseModel):
575
579
 
576
580
  model_config = {"extra": "forbid"}
577
581
 
582
+ plan: str | None = None
578
583
  code: str | None = None
579
584
  publish: str | None = None
580
585
  verify: str | None = None
581
586
  review: str | None = None
582
587
 
583
- @field_validator("code", "publish", "verify", "review", mode="before")
588
+ @field_validator(
589
+ "plan", "code", "publish", "verify", "review", mode="before"
590
+ )
584
591
  @classmethod
585
592
  def _known_mode(cls, v: Any, info: Any) -> str | None:
586
593
  return check_dev_step_mode(info.field_name, v)
@@ -133,6 +133,9 @@ class SessionResult:
133
133
  stdout: str = ""
134
134
  stderr: str = ""
135
135
  agent_session_id: str | None = None
136
+ # The relay killed the agent at its time limit (#311). Set only by the
137
+ # relay's own timeout path, never parsed from anything the agent wrote.
138
+ timed_out: bool = False
136
139
 
137
140
  @property
138
141
  def success(self) -> bool:
@@ -352,6 +355,7 @@ class CliAgentAdapter:
352
355
  exit_code=-1,
353
356
  state=None,
354
357
  stderr="Session timed out",
358
+ timed_out=True,
355
359
  )
356
360
  except asyncio.CancelledError as e:
357
361
  # Scheduler/shutdown cancel: kill the child BEFORE re-raising
@@ -0,0 +1,388 @@
1
+ """Runs whose process died without settling them (#312).
2
+
3
+ A session's ``sessions`` row is moved off ``running`` only by the process
4
+ running it. When that process dies first (the kernel's out-of-memory
5
+ killer, a ``systemctl stop`` that takes the whole unit, a power cut), the
6
+ row says ``running`` for ever: the status page lists it as running, the
7
+ issue stays in the poller's ``seen_issues`` so nothing looks at it again,
8
+ its claim reads as working, and a repo lock it held can wedge the repo.
9
+
10
+ :func:`reap_orphaned_sessions` runs once when the poller starts, before
11
+ the first poll, and settles each such row: failed, lock dropped, issue
12
+ handed back to the poller, claim released, worktree kept and named.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import os
19
+ from pathlib import Path
20
+ from typing import Any, Callable
21
+
22
+ from ctrlrelay.core.claims import PROCESS_RUN
23
+ from ctrlrelay.core.obs import get_logger, log_event
24
+
25
+ _logger = get_logger("core.orphans")
26
+
27
+ # Pipelines whose issue goes back to the poller when their run died. A
28
+ # secops sweep has no issue; it runs again on its own schedule.
29
+ _ISSUE_PIPELINES = frozenset({"dev", "task"})
30
+
31
+
32
+ def _command_line(pid: int) -> list[str] | None:
33
+ proc = Path("/proc")
34
+ if proc.is_dir():
35
+ try:
36
+ raw = (proc / str(pid) / "cmdline").read_bytes()
37
+ except OSError:
38
+ return None
39
+ return [a.decode("utf-8", "replace") for a in raw.split(b"\0") if a]
40
+ import subprocess
41
+ try:
42
+ out = subprocess.run(
43
+ ["ps", "-o", "command=", "-p", str(pid)],
44
+ capture_output=True, text=True, timeout=5,
45
+ )
46
+ except (OSError, subprocess.SubprocessError):
47
+ return None
48
+ if out.returncode != 0:
49
+ return None
50
+ return out.stdout.split()
51
+
52
+
53
+ def owner_is_alive(pid: int) -> bool:
54
+ """Whether the process that owns a session may still be running it.
55
+
56
+ Errs towards alive, because the mistake in that direction leaves a
57
+ row as it is today, while the other one fails a live run under its
58
+ own feet. So: a process that exists and runs ctrlrelay, or whose
59
+ command line cannot be read, is alive. Gone, or reused by a program
60
+ that is not ctrlrelay, is dead.
61
+
62
+ This process's own pid is dead here. The caller has already kept
63
+ every row this process wrote, by its run id (``owner_run``), so a row
64
+ that names this pid with another run id was written by an earlier
65
+ process whose pid was handed on to this one, as happens to pid 1 in a
66
+ container on every restart.
67
+ """
68
+ if pid <= 0 or pid == os.getpid():
69
+ return False
70
+ try:
71
+ os.kill(pid, 0)
72
+ except ProcessLookupError:
73
+ return False
74
+ except PermissionError:
75
+ # Exists, owned by another user: not ours, but not provably
76
+ # dead either.
77
+ return True
78
+ except OSError:
79
+ return True
80
+ args = _command_line(pid)
81
+ if args is None:
82
+ # Exited between the two checks, or unreadable. A vanished
83
+ # process reads as `None` too, so look once more.
84
+ try:
85
+ os.kill(pid, 0)
86
+ except ProcessLookupError:
87
+ return False
88
+ except OSError:
89
+ return True
90
+ return True
91
+ # The console script, or `python -m ctrlrelay...`. Not a substring
92
+ # test: the interpreter of a uv tool install lives under a directory
93
+ # named ctrlrelay, so any Python program run with it would match.
94
+ for i, arg in enumerate(args):
95
+ if os.path.basename(arg) == "ctrlrelay":
96
+ return True
97
+ if (arg == "ctrlrelay" or arg.startswith("ctrlrelay.")) and (
98
+ i > 0 and args[i - 1] == "-m"
99
+ ):
100
+ return True
101
+ if arg == "-mctrlrelay" or arg.startswith("-mctrlrelay."):
102
+ return True
103
+ return False
104
+
105
+
106
+ def worktree_in_use(path: str) -> bool | None:
107
+ """Whether a live process of this user has its working directory
108
+ inside ``path``: ``True``, ``False``, or ``None`` when that cannot be
109
+ told.
110
+
111
+ The relay process can die alone (``kill -9``, the OOM killer choosing
112
+ it) while the agent it started keeps running, because the agent is
113
+ in the relay's process group, not under it. Such a run is not dead,
114
+ and settling it would detach the worktree under a live agent and
115
+ start a second run on the same issue. The agent works in its
116
+ worktree, as the relay's own user, so such a process there is the
117
+ sign. Processes of other users are not looked at: an agent never
118
+ runs as another user, and their working directories are not
119
+ readable anyway.
120
+ """
121
+ try:
122
+ root = os.path.realpath(path)
123
+ except OSError:
124
+ return None
125
+ prefix = root.rstrip(os.sep) + os.sep
126
+ uid = os.getuid()
127
+ proc = Path("/proc")
128
+ if proc.is_dir():
129
+ try:
130
+ entries = [e for e in os.listdir(proc) if e.isdigit()]
131
+ except OSError:
132
+ return None
133
+ # This process is of this user and has a working directory, so a
134
+ # scan that read none did not work, whatever it returned.
135
+ read_any = False
136
+ for pid in entries:
137
+ try:
138
+ if os.stat(f"/proc/{pid}").st_uid != uid:
139
+ continue
140
+ cwd = os.readlink(f"/proc/{pid}/cwd")
141
+ read_any = True
142
+ except OSError:
143
+ # Exited while being looked at, or a process of ours that
144
+ # made itself non-dumpable (systemd --user, ssh-agent).
145
+ # An agent is neither: its working directory reads,
146
+ # inside the bwrap sandbox too.
147
+ continue
148
+ if cwd == root or cwd.startswith(prefix):
149
+ return True
150
+ return False if read_any else None
151
+ import shutil
152
+ import subprocess
153
+ lsof = shutil.which("lsof")
154
+ if lsof is None:
155
+ return None
156
+ try:
157
+ out = subprocess.run(
158
+ [lsof, "-w", "-n", "-P", "-a", "-u", str(uid), "-d", "cwd",
159
+ "-F", "n"],
160
+ capture_output=True, text=True, timeout=20,
161
+ )
162
+ except (OSError, subprocess.SubprocessError):
163
+ return None
164
+ if out.returncode not in (0, 1):
165
+ return None
166
+ cwds = [line[1:] for line in out.stdout.splitlines() if line.startswith("n")]
167
+ if not cwds:
168
+ # Not even this process's own: lsof did not answer.
169
+ return None
170
+ return any(cwd == root or cwd.startswith(prefix) for cwd in cwds)
171
+
172
+
173
+ def orphan_reason(row: dict[str, Any], branch: str | None) -> str:
174
+ """The ``Reason:`` line of the issue comment for a dead run.
175
+
176
+ Says only what was checked: where the files are when the worktree was
177
+ found, and that the branch was freed only when it was.
178
+ """
179
+ where = row.get("worktree_path")
180
+ if where and Path(where).exists():
181
+ kept = (
182
+ f"Its worktree is still on that machine at `{where}`"
183
+ + (f", detached from `{branch}`" if branch else "")
184
+ + ", and the relay deleted nothing in it."
185
+ )
186
+ elif where:
187
+ kept = f"Its worktree `{where}` was not found on that machine."
188
+ else:
189
+ kept = "No worktree was recorded for it."
190
+ return (
191
+ "the relay process running it stopped before the run ended "
192
+ "(for example the machine ran out of memory, or the service was "
193
+ f"restarted). {kept}"
194
+ )
195
+
196
+
197
+ async def _open_pr_for(github: Any, repo: str, branch: str | None) -> int | None:
198
+ """The same-repo open pull request for ``branch``, or ``None`` when
199
+ none was found: there is none, the branch is unknown (no worktree to
200
+ read it from), or the lookup failed."""
201
+ if not branch:
202
+ return None
203
+ try:
204
+ prs = await github.list_prs(repo, state="open", head=branch, timeout=30)
205
+ except asyncio.CancelledError:
206
+ raise
207
+ except Exception as e:
208
+ log_event(
209
+ _logger, "orphans.pr_lookup_failed", repo=repo, branch=branch,
210
+ reason=type(e).__name__, error=str(e)[:200],
211
+ )
212
+ return None
213
+ owner, _, name = repo.partition("/")
214
+ for pr in prs or []:
215
+ head_owner = ((pr.get("headRepositoryOwner") or {}).get("login") or "")
216
+ head_name = ((pr.get("headRepository") or {}).get("name") or "")
217
+ if head_owner.lower() == owner.lower() and head_name.lower() == name.lower():
218
+ return pr.get("number")
219
+ return None
220
+
221
+
222
+ async def reap_orphaned_sessions(
223
+ state_db: Any,
224
+ *,
225
+ poller: Any,
226
+ github: Any,
227
+ worktree: Any,
228
+ is_alive: Callable[[int], bool] = owner_is_alive,
229
+ in_use: Callable[[str], bool | None] = worktree_in_use,
230
+ ) -> list[dict[str, Any]]:
231
+ """Settle every ``running`` session whose owning process is gone.
232
+
233
+ For each one:
234
+
235
+ - the row becomes ``failed`` with a summary saying why, and any repo
236
+ lock it held is dropped (one transaction, see
237
+ :meth:`StateDB.fail_orphaned_session`);
238
+ - its worktree, when there is one, is kept, and its HEAD detached
239
+ from the issue's branch when that can be done safely, so the next
240
+ run for the issue can check that branch out;
241
+ - a dev or task issue is removed from ``seen_issues`` so the next
242
+ poll takes it again, unless an open pull request was found for the
243
+ branch: then the pull request is the outcome and the issue stays
244
+ seen. When the lookup could not be made, the issue is handed back
245
+ anyway; the next run refuses a branch that backs an open pull
246
+ request on its own (``create_worktree_with_new_branch``);
247
+ - a comment on the issue names the worktree, and releases this
248
+ machine's claim by id where the row has one. A task run on a
249
+ single machine claims nothing and gets no comment.
250
+
251
+ Left alone: rows this process wrote (``owner_run``; a scheduled
252
+ sweep can start before this runs), rows whose owner is alive, rows
253
+ whose worktree a live process is still working in (the agent outlives
254
+ a relay killed on its own; see :func:`worktree_in_use`), and rows with
255
+ no ``owner_pid``. Those come from a version before #312 and name no
256
+ process, and could as well be a ``ctrlrelay run dev`` started by hand
257
+ and still running.
258
+
259
+ The row update comes first, so no later step touches a row something
260
+ else settled, and each step after it is best-effort. The cost is a
261
+ narrow gap: if this process dies between the update and the
262
+ hand-back, that row is no longer running and no later start looks at
263
+ it again. Returns one dict per row settled.
264
+ """
265
+ from ctrlrelay.pipelines.dev import reconcile_claim_on_failure
266
+
267
+ try:
268
+ rows = state_db.list_running_sessions()
269
+ except Exception as e:
270
+ log_event(
271
+ _logger, "orphans.list_failed",
272
+ reason=type(e).__name__, error=str(e)[:200],
273
+ )
274
+ return []
275
+
276
+ settled: list[dict[str, Any]] = []
277
+ for row in rows:
278
+ session_id = row["id"]
279
+ pid = row.get("owner_pid")
280
+ if row.get("owner_run") == PROCESS_RUN:
281
+ continue
282
+ if pid is None:
283
+ log_event(
284
+ _logger, "orphans.owner_unknown", session_id=session_id,
285
+ repo=row.get("repo"), issue_number=row.get("issue_number"),
286
+ operator_action=(
287
+ "this row was written before ctrlrelay recorded which "
288
+ "process runs a session; if no run is live, set its "
289
+ "status to failed in state.db by hand"
290
+ ),
291
+ )
292
+ continue
293
+ if is_alive(int(pid)):
294
+ continue
295
+ worktree_path = row.get("worktree_path")
296
+ if worktree_path and Path(worktree_path).exists():
297
+ busy = in_use(worktree_path)
298
+ if busy is not False:
299
+ # In use, or could not be told: errs towards alive, as
300
+ # `owner_is_alive` does.
301
+ log_event(
302
+ _logger,
303
+ "orphans.worktree_in_use" if busy
304
+ else "orphans.worktree_use_unknown",
305
+ session_id=session_id,
306
+ repo=row.get("repo"), worktree_path=worktree_path,
307
+ operator_action=(
308
+ "the relay process that ran this session is gone, "
309
+ "but a process may still be working in its "
310
+ "worktree, so it is left as running. Stop that "
311
+ "process (or leave that directory) and restart the "
312
+ "poller to settle it."
313
+ ),
314
+ )
315
+ continue
316
+
317
+ repo = row["repo"]
318
+ issue_number = row.get("issue_number")
319
+ pipeline = row.get("pipeline")
320
+
321
+ summary = "Run stopped: " + orphan_reason(row, None)
322
+ try:
323
+ changed = state_db.fail_orphaned_session(session_id, summary)
324
+ except Exception as e:
325
+ log_event(
326
+ _logger, "orphans.update_failed", session_id=session_id,
327
+ reason=type(e).__name__, error=str(e)[:200],
328
+ )
329
+ continue
330
+ if not changed:
331
+ continue
332
+
333
+ # After the row, not before: a row something else settled in the
334
+ # meantime is not ours to touch, and neither is its worktree.
335
+ branch: str | None = None
336
+ if worktree_path and Path(worktree_path).exists():
337
+ try:
338
+ branch = worktree.detach_orphaned_worktree(repo, worktree_path)
339
+ except Exception as e:
340
+ log_event(
341
+ _logger, "orphans.detach_failed", session_id=session_id,
342
+ reason=type(e).__name__, error=str(e)[:200],
343
+ )
344
+
345
+ pr_number: int | None = None
346
+ handed_back = False
347
+ if issue_number is not None and pipeline in _ISSUE_PIPELINES:
348
+ pr_number = await _open_pr_for(github, repo, branch)
349
+ if pr_number is None:
350
+ poller.unmark_seen(repo, int(issue_number))
351
+ handed_back = True
352
+ claim_id = row.get("claim_id")
353
+ # A dev run always claims before its row exists. A task run
354
+ # claims only on a multi-machine setup, and posts nothing
355
+ # otherwise, so there is nothing of its to retract then.
356
+ if pipeline == "dev" or claim_id:
357
+ await reconcile_claim_on_failure(
358
+ github,
359
+ repo=repo,
360
+ issue_number=int(issue_number),
361
+ session_id=session_id,
362
+ reason=orphan_reason(row, branch),
363
+ exit_code=row.get("claude_exit_code"),
364
+ pr_number=pr_number,
365
+ claim_id=claim_id,
366
+ will_retry=handed_back,
367
+ # Only open pull requests were looked for, so "none
368
+ # was opened" is never known: one may have been
369
+ # closed or merged.
370
+ pr_known=False,
371
+ )
372
+
373
+ log_event(
374
+ _logger, "orphans.reaped", session_id=session_id, repo=repo,
375
+ issue_number=issue_number, pipeline=pipeline, owner_pid=pid,
376
+ worktree_path=worktree_path, branch_freed=branch,
377
+ pr_number=pr_number, handed_back=handed_back,
378
+ )
379
+ settled.append({
380
+ "session_id": session_id,
381
+ "repo": repo,
382
+ "issue_number": issue_number,
383
+ "worktree_path": worktree_path,
384
+ "branch_freed": branch,
385
+ "pr_number": pr_number,
386
+ "handed_back": handed_back,
387
+ })
388
+ return settled