ctrlrelay 0.24.0__tar.gz → 0.25.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 (240) hide show
  1. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/CHANGELOG.md +32 -1
  2. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/cli.md +2 -1
  4. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/configuration.md +6 -4
  5. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/operations.md +81 -0
  6. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/cli.py +135 -52
  8. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/agent_env.py +43 -3
  9. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/config.py +5 -0
  10. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/dispatcher.py +47 -9
  11. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/orphans.py +34 -4
  12. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/poller.py +202 -0
  13. ctrlrelay-0.25.0/src/ctrlrelay/core/slots.py +96 -0
  14. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/state.py +96 -1
  15. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/worktree.py +72 -1
  16. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/dev.py +488 -13
  17. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/secops.py +468 -437
  18. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/fleet.py +10 -5
  19. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/server.py +2 -0
  20. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/static/index.html +2 -1
  21. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/status.py +1 -0
  22. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_sandbox.py +198 -2
  23. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_fleet.py +15 -1
  24. ctrlrelay-0.25.0/tests/test_max_parallel.py +416 -0
  25. ctrlrelay-0.25.0/tests/test_run_resume.py +660 -0
  26. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  27. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  29. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/dependabot.yml +0 -0
  30. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/build.yml +0 -0
  31. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/cla.yml +0 -0
  32. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/pages.yml +0 -0
  33. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/publish.yml +0 -0
  34. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/test.yml +0 -0
  35. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.gitignore +0 -0
  36. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/CODE_OF_CONDUCT.md +0 -0
  37. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/CONTRIBUTING.md +0 -0
  38. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/LICENSE +0 -0
  39. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/README.md +0 -0
  40. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/SECURITY.md +0 -0
  41. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/config/orchestrator.yaml.example +0 -0
  42. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/Gemfile +0 -0
  43. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/_config.yml +0 -0
  44. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/adding-a-machine.md +0 -0
  45. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/architecture.md +0 -0
  46. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/bridge.md +0 -0
  47. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/development.md +0 -0
  48. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/feedback-loop.md +0 -0
  49. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/getting-started.md +0 -0
  50. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/index.md +0 -0
  51. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/llms.txt +0 -0
  52. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/personalization.md +0 -0
  53. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/registered-mode.md +0 -0
  54. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  55. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  56. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/__init__.py +0 -0
  57. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  58. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  59. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/factory.py +0 -0
  60. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/handler.py +0 -0
  61. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  62. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  63. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  64. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/server.py +0 -0
  65. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  66. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/__init__.py +0 -0
  67. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/archived.py +0 -0
  68. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/audit.py +0 -0
  69. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/binaries.py +0 -0
  70. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/blockers.py +0 -0
  71. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/budget.py +0 -0
  72. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  73. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/claims.py +0 -0
  74. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/code_review.py +0 -0
  75. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  76. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/events.py +0 -0
  77. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/github.py +0 -0
  78. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/github_budget.py +0 -0
  79. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  80. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/intake_hold.py +0 -0
  81. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/issue_questions.py +0 -0
  82. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/job_claims.py +0 -0
  83. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/network.py +0 -0
  84. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/node_check.py +0 -0
  85. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/obs.py +0 -0
  86. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  87. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  88. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/proof.py +0 -0
  89. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/publish.py +0 -0
  90. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  91. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/redact.py +0 -0
  92. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/reopens.py +0 -0
  93. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/scheduler.py +0 -0
  94. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/self_update.py +0 -0
  95. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/stuck.py +0 -0
  96. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/usage.py +0 -0
  97. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/weekly_summary.py +0 -0
  98. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  99. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/dashboard/client.py +0 -0
  100. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/gh_protocol.py +0 -0
  101. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/install.py +0 -0
  102. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  103. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/manager.py +0 -0
  104. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/merge.py +0 -0
  105. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/paths.py +0 -0
  106. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  107. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/base.py +0 -0
  108. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  109. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/task.py +0 -0
  110. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/setup.py +0 -0
  111. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/__init__.py +0 -0
  112. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  113. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  114. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  115. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  116. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  117. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  118. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  119. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  120. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/__init__.py +0 -0
  121. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/base.py +0 -0
  122. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  123. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  124. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/__init__.py +0 -0
  125. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/drift.py +0 -0
  126. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/health.py +0 -0
  127. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/links.py +0 -0
  128. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/numbers.py +0 -0
  129. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/transcript.py +0 -0
  130. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/waiting.py +0 -0
  131. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/__init__.py +0 -0
  132. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/_poller_fakes.py +0 -0
  133. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/_publish_fakes.py +0 -0
  134. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/conftest.py +0 -0
  135. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/fixtures/gh_search_issues.json +0 -0
  136. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  137. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  138. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_backend_wiring.py +0 -0
  139. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_no_keys.py +0 -0
  140. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_routing.py +0 -0
  141. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_archived.py +0 -0
  142. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_audit.py +0 -0
  143. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_binary_resolution.py +0 -0
  144. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_bridge_protocol.py +0 -0
  145. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_bridge_server.py +0 -0
  146. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_bridge_start_command.py +0 -0
  147. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_budget.py +0 -0
  148. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_chat_machines.py +0 -0
  149. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_checkpoint.py +0 -0
  150. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_claim_reconciliation.py +0 -0
  151. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_ci_wait.py +0 -0
  152. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_dev.py +0 -0
  153. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_repos.py +0 -0
  154. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_secops.py +0 -0
  155. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_start.py +0 -0
  156. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_version.py +0 -0
  157. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_code_review.py +0 -0
  158. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_config.py +0 -0
  159. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dashboard_client.py +0 -0
  160. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_integration.py +0 -0
  161. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_pipeline.py +0 -0
  162. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_plan_step.py +0 -0
  163. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_step_switches.py +0 -0
  164. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_stuck.py +0 -0
  165. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_work_steps.py +0 -0
  166. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dispatch_health.py +0 -0
  167. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dispatch_outcome_hook.py +0 -0
  168. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dispatcher.py +0 -0
  169. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_docs_site.py +0 -0
  170. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_event_emission.py +0 -0
  171. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_events.py +0 -0
  172. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_gh_protocol.py +0 -0
  173. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_github.py +0 -0
  174. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_github_budget.py +0 -0
  175. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_install.py +0 -0
  176. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_intake_hold.py +0 -0
  177. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_intake_hold_poller.py +0 -0
  178. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_issue_blockers.py +0 -0
  179. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_issue_outcome_announcement.py +0 -0
  180. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_issue_questions.py +0 -0
  181. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_job_claims.py +0 -0
  182. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_mattermost_handler.py +0 -0
  183. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_network.py +0 -0
  184. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_no_change_run.py +0 -0
  185. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_node_check.py +0 -0
  186. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_node_claims.py +0 -0
  187. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_notify_transport.py +0 -0
  188. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_obs.py +0 -0
  189. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_opencode_adapter.py +0 -0
  190. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_orphans.py +0 -0
  191. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_personalization.py +0 -0
  192. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_personalization_merge.py +0 -0
  193. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pipeline_base.py +0 -0
  194. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller.py +0 -0
  195. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller_priority_order.py +0 -0
  196. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller_recheck.py +0 -0
  197. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller_search.py +0 -0
  198. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_post_merge.py +0 -0
  199. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pr_verifier.py +0 -0
  200. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pr_watcher.py +0 -0
  201. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pre_dispatch_retry.py +0 -0
  202. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_proof.py +0 -0
  203. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_publish_failure_retry.py +0 -0
  204. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_question_backoff.py +0 -0
  205. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_question_expiry.py +0 -0
  206. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_question_survives_restart.py +0 -0
  207. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_redact.py +0 -0
  208. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_relay_alert_off_lock.py +0 -0
  209. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_relay_git_263.py +0 -0
  210. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_reopens.py +0 -0
  211. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_resume_prompt_contract.py +0 -0
  212. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_review_copy.py +0 -0
  213. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_review_gate.py +0 -0
  214. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_sandbox_visibility.py +0 -0
  215. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_scheduler.py +0 -0
  216. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_secops_integration.py +0 -0
  217. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_secops_operator_prs.py +0 -0
  218. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_secops_pipeline.py +0 -0
  219. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_self_update.py +0 -0
  220. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_setup.py +0 -0
  221. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_state.py +0 -0
  222. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_stuck.py +0 -0
  223. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_task_pipeline.py +0 -0
  224. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_telegram_handler.py +0 -0
  225. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_timeout_save.py +0 -0
  226. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_transport.py +0 -0
  227. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_transport_resolution.py +0 -0
  228. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  229. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_usage.py +0 -0
  230. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_drift.py +0 -0
  231. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_health.py +0 -0
  232. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_links.py +0 -0
  233. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_numbers.py +0 -0
  234. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_peer_links.py +0 -0
  235. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_server.py +0 -0
  236. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_status.py +0 -0
  237. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_transcript.py +0 -0
  238. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_waiting.py +0 -0
  239. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_weekly_summary.py +0 -0
  240. {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.0] - 2026-11-06
11
+
12
+ ### Fixed
13
+
14
+ - A sandboxed run (`agent.sandbox: bwrap`) failed at once with "Argument
15
+ list too long" on a machine with thousands of past agent project folders
16
+ (#362). The sandbox mounted each `~/.claude/projects/*/memory` on its own,
17
+ which ran past both the kernel's argument limit and bwrap's 9000 arguments.
18
+ It now mounts `~/.claude/projects` read-only once, opens only the run's
19
+ own transcript folder for writing, and passes its options to bwrap
20
+ through a file descriptor. A config that lists the old glob in
21
+ `sandbox_readonly_paths` should drop it.
22
+
23
+ ### Added
24
+
25
+ - `agent.max_parallel` lets one machine run several agents at once (#210).
26
+ It is one cap over new issues, resumed answers and the security sweep.
27
+ Each poll's issues start side by side up to the free slots, in priority
28
+ order, never two of one repo at once, and the loop polls again while they
29
+ run. Unset keeps the old one-at-a-time behaviour. The status page's slot
30
+ count now shows the real number of runs and the cap, or "no cap".
31
+ - A dev run saves each step it finishes (plan, code, publish, verify) in a
32
+ new `run_steps` table, and a run whose process died is continued after
33
+ its last saved step instead of starting over (#231). The branch must
34
+ still be at the saved commit. The agent is not run again for a saved
35
+ code step, and a saved pull request is reused rather than opened twice.
36
+ Proof and review always run again. `ctrlrelay run dev --from-step
37
+ plan|code|publish` starts a new run from a past step, keeping the old
38
+ branch head under `refs/ctrlrelay/before-retry/`.
39
+
10
40
  ## [0.24.0] - 2026-11-05
11
41
 
12
42
  ### Added
@@ -2591,7 +2621,8 @@ pipeline).
2591
2621
  per-phase implementation plans (Phase 0 through Phase 4).
2592
2622
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2593
2623
 
2594
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...HEAD
2624
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.25.0...HEAD
2625
+ [0.25.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...v0.25.0
2595
2626
  [0.24.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...v0.24.0
2596
2627
  [0.23.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...v0.23.0
2597
2628
  [0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.24.0
3
+ Version: 0.25.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/
@@ -192,7 +192,7 @@ without waiting for a real pipeline run.
192
192
  ### `run dev`
193
193
 
194
194
  ```bash
195
- ctrlrelay run dev -i ISSUE [-r REPO] [-c PATH]
195
+ ctrlrelay run dev -i ISSUE [-r REPO] [-c PATH] [--from-step STEP]
196
196
  ```
197
197
 
198
198
  | Flag | Required | Default | Description |
@@ -200,6 +200,7 @@ ctrlrelay run dev -i ISSUE [-r REPO] [-c PATH]
200
200
  | `--issue` / `-i` | yes | — | GitHub issue number. |
201
201
  | `--repo` / `-r` | when more than one repo is configured | — | Limit to a single `owner/repo`. |
202
202
  | `--config` / `-c` | no | `config/orchestrator.yaml` | Config path. |
203
+ | `--from-step` | no | — | Start again from `plan`, `code` or `publish`, taking the earlier steps from the issue's newest run that saved them and moving the branch back. See [A run whose process died](operations.md#a-run-whose-process-died). |
203
204
 
204
205
  Acquires the per-repo lock, creates the worktree, spawns the repo's agent
205
206
  (Claude Code or OpenCode, per `agent.type` / `repos[].agent_type`) with the
@@ -209,6 +209,7 @@ agent:
209
209
  | `type` | string | `"claude"` | Default backend for every repo: `"claude"` or `"opencode"`. See [agent backends](#agent-backends). |
210
210
  | `binary` | string | `"claude"` | Path to the `claude` executable. The bare name `"claude"` is auto-resolved at startup using `shutil.which("claude")`, then `~/.local/bin/claude`, `/usr/local/bin/claude`, `/opt/homebrew/bin/claude`. Set an absolute path to skip lookup (useful under launchd/systemd where PATH is minimal). |
211
211
  | `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. A session going in circles is stopped before this; see [stuck runs](operations.md#stuck-runs). |
212
+ | `max_parallel` | int (at least 1) | unset | How many agent runs this machine may have going at once, counted over new issues, resumed answers and the security sweep together. With it set, each poll's issues start side by side up to the free slots, in priority order, and never two of one repo at once; the poll loop polls again while they run. Unset keeps the old behaviour: new issues one at a time, and no shared limit. The status page shows `slots busy/max_parallel`, or `slots N (no cap)` when unset. See [running several agents at once](operations.md#running-several-agents-at-once). |
212
213
  | `output_format` | string | `"json"` | Not used. The relay always runs `claude -p --output-format stream-json --verbose`, because only that output carries the subscription usage it reads (see [usage](#usage)). Kept so existing configs still load. |
213
214
 
214
215
  ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
@@ -361,9 +362,10 @@ login, does not):
361
362
  snapshots, which your own sessions run or write back (the sandboxed run
362
363
  gets an empty, writable one of each);
363
364
  - read-only again, over those: `sandbox_readonly_paths` (the agent CLI's
364
- settings, rules, hooks, plugins, skills, browser host and install, and each
365
- project's memory under `~/.claude/projects/*/memory`, which your own
366
- sessions load) and, in the repository, `config`, `config.worktree`,
365
+ settings, rules, hooks, plugins, skills, browser host and install, and the
366
+ whole `~/.claude/projects` folder with every project's memory, which your
367
+ own sessions load; only the run's own transcript folder in it is writable)
368
+ and, in the repository, `config`, `config.worktree`,
367
369
  `hooks`, `modules`, the other runs' worktree directories, a linked
368
370
  worktree's `commondir` and `gitdir`, and the worktree's `.git` pointer,
369
371
  which every later git command there reads. So the agent cannot change git
@@ -438,7 +440,7 @@ then run the check again. On a single-user laptop this is the whole setup.
438
440
  | `sandbox` | `"none"` or `"bwrap"` | `"none"` | `"bwrap"` runs the dev and task agents in the read-only sandbox above. |
439
441
  | `sandbox_hide_paths` | list of strings | `~/.ssh`, `~/.config/gh`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.netrc`, `~/.local/share/keyrings`, `$XDG_RUNTIME_DIR/bus`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker/config.json`, `~/.npmrc`, `~/.pypirc`, `~/.cargo/credentials`, `~/.cargo/credentials.toml`, `~/.config/doctl`, `~/.password-store`, `~/.1password`, `~/.docker/run`, `~/.docker/desktop`, `~/.config/ctrlrelay`, `$XDG_RUNTIME_DIR`, `/run/user/<uid>`, `/run/docker.sock`, `/var/run/docker.sock`, `~/.claude/shell-snapshots`, `~/.claude/session-env`, `~/.claude/ide`, `~/.claude/file-history` | Made empty under `sandbox: bwrap`. A directory becomes an empty tmpfs, a file or socket becomes `/dev/null`, a missing path is skipped. |
440
442
  | `sandbox_writable_paths` | list of strings | `~/.claude/*` | Writable under `sandbox: bwrap`, besides the run's own paths. A last part of `*` opens each entry in the directory now (not links), not the directory itself. |
441
- | `sandbox_readonly_paths` | list of strings | `~/.claude/settings.json`, `settings.local.json`, `CLAUDE.md`, `agents`, `commands`, `skills`, `hooks`, `plugins`, `output-styles`, `scripts`, `rules`, `chrome`, `local`, `projects/*/memory` (all under `~/.claude`) | Read-only again inside a writable path. One `*` part matches the directories there at the start of each run; a missing path under a writable one is created first (an empty `memory` directory in each project, for one). |
443
+ | `sandbox_readonly_paths` | list of strings | `~/.claude/settings.json`, `settings.local.json`, `CLAUDE.md`, `agents`, `commands`, `skills`, `hooks`, `plugins`, `output-styles`, `scripts`, `rules`, `chrome`, `local`, `projects` (all under `~/.claude`) | Read-only again inside a writable path. One `*` part matches the directories there at the start of each run; a missing path under a writable one is created first. The run's own transcript folder under `projects` is made writable again by the relay. Before 0.25.0 the default had `projects/*/memory`, one mount per project folder; on a home with thousands of them bwrap could not start (#362). A config that still lists that glob has the same problem, so drop it. |
442
444
  | `sandbox_cache_dir` | string | `~/.ctrlrelay/agent-cache` | The agent's own package cache under `sandbox: bwrap`. |
443
445
 
444
446
  ## transport
@@ -781,6 +781,87 @@ limit until one is set.
781
781
  read. A run on `opencode` is not measured; with a limit set, the log gets
782
782
  `dispatcher.budget.unmeasured` and nothing is enforced.
783
783
 
784
+ ## A run whose process died
785
+
786
+ A dev run saves each step it finishes: `plan`, `code`, `publish` and
787
+ `verify` (#231). Each is a row in the `run_steps` table of the state DB,
788
+ with the run branch's commit at that moment and what the later steps need
789
+ (the plan, the agent's result, the pull request). No commit is added to the
790
+ branch for it.
791
+
792
+ When the relay process dies in the middle of a run (out of memory, a
793
+ restart, a power cut), the poller settles that run when it starts again and
794
+ hands the issue back. The next run for the issue then continues after the
795
+ last saved step:
796
+
797
+ - it checks that the branch is still at the saved commit, and starts from
798
+ the beginning if not (or stops with the reason, when the dead run had
799
+ already opened its pull request);
800
+ - it does not run those steps again: no new agent session for a saved code
801
+ step, no second push or pull request for a saved publish step;
802
+ - it comments on the issue which run it continues and from where;
803
+ - `proof` and `review` always run again, because they read the pull
804
+ request as it is now.
805
+
806
+ A dead run that had opened its pull request is handed back too, and only
807
+ that pull request is reused. A run that failed for its own reasons is not
808
+ continued; the next run starts from the beginning, as before.
809
+
810
+ To start again from a past step by hand, for example to redo the code with
811
+ the same plan:
812
+
813
+ ```bash
814
+ ctrlrelay run dev --issue 42 --repo your-org/your-repo --from-step code
815
+ ```
816
+
817
+ `--from-step` takes `plan`, `code` or `publish`, uses the newest run of the
818
+ issue that saved steps, and moves the branch back to where it was before
819
+ that step. The old head is kept at `refs/ctrlrelay/before-retry/<run id>`
820
+ in the relay's clone. It refuses a branch behind an open pull request, and a
821
+ branch that is on GitHub is synced back to GitHub's head; delete the remote
822
+ branch first in that case.
823
+
824
+ The log events are `dev.step.saved`, `dev.resume.restored` and
825
+ `dev.resume.head_moved`.
826
+
827
+ ## Running several agents at once
828
+
829
+ Set `agent.max_parallel` to let one machine run more than one agent (#210):
830
+
831
+ ```yaml
832
+ agent:
833
+ max_parallel: 3
834
+ ```
835
+
836
+ - **One cap over everything.** New issues from the poll, runs resumed after
837
+ you answer a question, and the scheduled security sweep all take a slot
838
+ from the same pool. A run takes its slot before the repo lock, and gives
839
+ it back when it ends.
840
+ - **New issues side by side.** Polled issues wait in a backlog, in priority
841
+ order, and start as slots free up. Two issues of the same repo never run
842
+ together: the second waits, and an issue of another repo behind it goes
843
+ first. The loop keeps polling on its interval whatever is running, so one
844
+ slow issue never holds up new ones.
845
+ - **A run holds its slot for its whole life**, including the wait for CI
846
+ and the fix rounds, and while it waits in the run for your answer. Once
847
+ it gives up waiting and saves the question, the slot is free again, and
848
+ the resume takes one when your answer comes. Two pull requests waiting on
849
+ slow CI can fill `max_parallel: 2`.
850
+ - **A hold placed while a run waited for its slot is respected.** The resume
851
+ sweeper and the security sweep check again once they have a slot, so a
852
+ `self-update` that started meanwhile is not raced.
853
+ - **On shutdown** no new run starts in a slot a stopping run frees, and
854
+ issues polled but not started are offered again after the restart.
855
+ - **Unset** keeps the old behaviour: new issues one at a time, and nothing
856
+ limits the total.
857
+
858
+ Each run uses its own agent session, worktree and memory. Size the number to
859
+ the machine and to your plan's usage limits. Start with 2.
860
+
861
+ The status page shows `slots busy/max`, and `slots N (no cap)` when it is
862
+ unset. The log event `poll.batch.parallel` names each batch, the limit and
863
+ how many slots were busy.
864
+
784
865
  ## When to restart
785
866
 
786
867
  | You changed... | Restart... |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.24.0"
3
+ version = "0.25.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"
@@ -2503,6 +2503,16 @@ def run_dev(
2503
2503
  "-c",
2504
2504
  help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2505
2505
  ),
2506
+ from_step: str | None = typer.Option(
2507
+ None,
2508
+ "--from-step",
2509
+ help=(
2510
+ "Start again from a past step of the issue's newest run that "
2511
+ "saved steps: plan, code or publish. The steps before it are "
2512
+ "taken from that run and the branch is moved back to where "
2513
+ "it was then."
2514
+ ),
2515
+ ),
2506
2516
  ) -> None:
2507
2517
  """Run dev pipeline for a GitHub issue."""
2508
2518
  import asyncio
@@ -2598,6 +2608,7 @@ def run_dev(
2598
2608
  steps=repo_config.steps,
2599
2609
  question_on_issue=repo_config.question_on_issue,
2600
2610
  require_proof=repo_config.automation.require_proof,
2611
+ from_step=from_step,
2601
2612
  )
2602
2613
 
2603
2614
  try:
@@ -3727,6 +3738,12 @@ def poller_start(
3727
3738
  github, config.node_id,
3728
3739
  window=sweep_window(config.schedules.secops_cron, config.timezone),
3729
3740
  ),
3741
+ slots=agent_slots,
3742
+ # Asked again per repo once it has a slot (#210).
3743
+ held=lambda: (
3744
+ None if (h := intake_hold.active()) is None
3745
+ else h.reason
3746
+ ),
3730
3747
  )
3731
3748
  ok = sum(1 for r in results if r.success)
3732
3749
  console.print(
@@ -3894,6 +3911,35 @@ def poller_start(
3894
3911
  except Exception:
3895
3912
  pass
3896
3913
 
3914
+ # The node's cap on agent runs (#210), shared by the poll loop, the
3915
+ # resume sweeper and the security sweep. No limit when unset.
3916
+ from ctrlrelay.core.slots import AgentSlots
3917
+ agent_slots = AgentSlots(config.agent.max_parallel)
3918
+
3919
+ def _resume_no_longer_wanted(row: dict) -> str | None:
3920
+ """Why an answered question should not be resumed right now,
3921
+ read after the wait for a slot (#210), or None to go ahead. A
3922
+ hold placed meanwhile (a self-update) leaves the row for a
3923
+ later tick; a row resumed, expired or answered again meanwhile
3924
+ is not this tick's to run."""
3925
+ hold = intake_hold.active()
3926
+ if hold is not None:
3927
+ return f"new work is held ({hold.reason})"
3928
+ try:
3929
+ current = state_db.get_pending_resume(row["session_id"])
3930
+ except Exception as e:
3931
+ # Could not look: leave the answer for the next tick rather
3932
+ # than letting the sweeper's error path mark it resumed.
3933
+ return f"the question could not be read ({type(e).__name__})"
3934
+ if (
3935
+ current is None
3936
+ or current.get("resumed_at") is not None
3937
+ or current.get("expired_at") is not None
3938
+ or current.get("answered_at") != row.get("answered_at")
3939
+ ):
3940
+ return "the question is no longer waiting for this answer"
3941
+ return None
3942
+
3897
3943
  async def _run_pending_resume_sweeper() -> None:
3898
3944
  """Drain pending_resumes rows that an operator answered via
3899
3945
  Telegram while the original BLOCKED session had already torn
@@ -3998,28 +4044,40 @@ def poller_start(
3998
4044
  try:
3999
4045
  if pipeline_name == "secops":
4000
4046
  sweep_repo_cfg = repo_cfg_by_name.get(repo)
4001
- result = await resume_secops_from_pending(
4002
- session_id=session_id,
4003
- repo=repo,
4004
- answer=answer,
4005
- dispatcher=dispatcher,
4006
- github=github,
4007
- worktree=worktree,
4008
- dashboard=scheduled_dashboard,
4009
- state_db=state_db,
4010
- transport=sweeper_transport,
4011
- contexts_dir=config.paths.contexts,
4012
- automation=(
4013
- sweep_repo_cfg.automation
4014
- if sweep_repo_cfg is not None
4015
- else None
4016
- ),
4017
- question=row.get("question"),
4018
- # Same tracker as the poller and the
4019
- # sweep: a repo archived while a session
4020
- # sat blocked must not be resumed into.
4021
- archived=poller.archived_tracker,
4022
- )
4047
+ # A slot of the node's cap (#210), before the repo lock.
4048
+ async with agent_slots.hold():
4049
+ # The wait for a slot can be long (#210):
4050
+ # ask again whether to go ahead.
4051
+ gone = _resume_no_longer_wanted(row)
4052
+ if gone is not None:
4053
+ console.print(
4054
+ "[dim]pending_resume_sweeper: "
4055
+ f"{session_id} not resumed now: "
4056
+ f"{gone}[/dim]"
4057
+ )
4058
+ continue
4059
+ result = await resume_secops_from_pending(
4060
+ session_id=session_id,
4061
+ repo=repo,
4062
+ answer=answer,
4063
+ dispatcher=dispatcher,
4064
+ github=github,
4065
+ worktree=worktree,
4066
+ dashboard=scheduled_dashboard,
4067
+ state_db=state_db,
4068
+ transport=sweeper_transport,
4069
+ contexts_dir=config.paths.contexts,
4070
+ automation=(
4071
+ sweep_repo_cfg.automation
4072
+ if sweep_repo_cfg is not None
4073
+ else None
4074
+ ),
4075
+ question=row.get("question"),
4076
+ # Same tracker as the poller and the
4077
+ # sweep: a repo archived while a session
4078
+ # sat blocked must not be resumed into.
4079
+ archived=poller.archived_tracker,
4080
+ )
4023
4081
  elif pipeline_name == "dev":
4024
4082
  # Dev resume needs the repo's branch template
4025
4083
  # to reconstruct the session's existing branch
@@ -4043,37 +4101,61 @@ def poller_start(
4043
4101
  except Exception:
4044
4102
  pass
4045
4103
  continue
4046
- result = await resume_dev_from_pending(
4047
- session_id=session_id,
4048
- repo=repo,
4049
- answer=answer,
4050
- branch_template=repo_cfg.dev_branch_template,
4051
- dispatcher=dispatcher,
4052
- github=github,
4053
- worktree=worktree,
4054
- dashboard=scheduled_dashboard,
4055
- state_db=state_db,
4056
- transport=sweeper_transport,
4057
- contexts_dir=config.paths.contexts,
4058
- max_fix_attempts=repo_cfg.max_fix_attempts,
4059
- code_review=repo_cfg.code_review,
4060
- steps=repo_cfg.steps,
4061
- question_on_issue=repo_cfg.question_on_issue,
4062
- require_proof=repo_cfg.automation.require_proof,
4063
- )
4104
+ # A slot of the node's cap (#210), before the repo lock.
4105
+ async with agent_slots.hold():
4106
+ # The wait for a slot can be long (#210):
4107
+ # ask again whether to go ahead.
4108
+ gone = _resume_no_longer_wanted(row)
4109
+ if gone is not None:
4110
+ console.print(
4111
+ "[dim]pending_resume_sweeper: "
4112
+ f"{session_id} not resumed now: "
4113
+ f"{gone}[/dim]"
4114
+ )
4115
+ continue
4116
+ result = await resume_dev_from_pending(
4117
+ session_id=session_id,
4118
+ repo=repo,
4119
+ answer=answer,
4120
+ branch_template=repo_cfg.dev_branch_template,
4121
+ dispatcher=dispatcher,
4122
+ github=github,
4123
+ worktree=worktree,
4124
+ dashboard=scheduled_dashboard,
4125
+ state_db=state_db,
4126
+ transport=sweeper_transport,
4127
+ contexts_dir=config.paths.contexts,
4128
+ max_fix_attempts=repo_cfg.max_fix_attempts,
4129
+ code_review=repo_cfg.code_review,
4130
+ steps=repo_cfg.steps,
4131
+ question_on_issue=repo_cfg.question_on_issue,
4132
+ require_proof=repo_cfg.automation.require_proof,
4133
+ )
4064
4134
  elif pipeline_name == "task":
4065
- result = await resume_task_from_pending(
4066
- session_id=session_id,
4067
- repo=repo,
4068
- answer=answer,
4069
- dispatcher=dispatcher,
4070
- github=github,
4071
- worktree=worktree,
4072
- dashboard=scheduled_dashboard,
4073
- state_db=state_db,
4074
- transport=sweeper_transport,
4075
- contexts_dir=config.paths.contexts,
4076
- )
4135
+ # A slot of the node's cap (#210), before the repo lock.
4136
+ async with agent_slots.hold():
4137
+ # The wait for a slot can be long (#210):
4138
+ # ask again whether to go ahead.
4139
+ gone = _resume_no_longer_wanted(row)
4140
+ if gone is not None:
4141
+ console.print(
4142
+ "[dim]pending_resume_sweeper: "
4143
+ f"{session_id} not resumed now: "
4144
+ f"{gone}[/dim]"
4145
+ )
4146
+ continue
4147
+ result = await resume_task_from_pending(
4148
+ session_id=session_id,
4149
+ repo=repo,
4150
+ answer=answer,
4151
+ dispatcher=dispatcher,
4152
+ github=github,
4153
+ worktree=worktree,
4154
+ dashboard=scheduled_dashboard,
4155
+ state_db=state_db,
4156
+ transport=sweeper_transport,
4157
+ contexts_dir=config.paths.contexts,
4158
+ )
4077
4159
  else:
4078
4160
  console.print(
4079
4161
  "[yellow]pending_resume_sweeper: "
@@ -4498,6 +4580,7 @@ def poller_start(
4498
4580
  await run_poll_loop(
4499
4581
  poller=poller, handler=handle_issue_unless_held,
4500
4582
  interval=interval,
4583
+ slots=agent_slots,
4501
4584
  interval_scale=lambda: (
4502
4585
  config.github_api.slow_factor
4503
4586
  if github_budget.is_low() else 1.0
@@ -244,9 +244,12 @@ DEFAULT_SANDBOX_READONLY_PATHS: tuple[str, ...] = (
244
244
  "~/.claude/chrome",
245
245
  # The agent CLI itself, when installed under its state directory.
246
246
  "~/.claude/local",
247
- # Each project's memory, which the operator's sessions in that project
248
- # load. One ``*`` part is expanded to the directories there now.
249
- "~/.claude/projects/*/memory",
247
+ # Every project's folder, memory included, which the operator's
248
+ # sessions in that project load. One mount for all of them (#362): a
249
+ # mount per project folder ran past bwrap's argument limit on a home
250
+ # with thousands. The run's own transcript folder is opened again,
251
+ # deeper, by the dispatcher.
252
+ "~/.claude/projects",
250
253
  )
251
254
 
252
255
  # The agent's package caches: its own, never the operator's, so a run
@@ -285,6 +288,43 @@ def _existing(raw: str | Path) -> Path | None:
285
288
  return p if p.exists() else None
286
289
 
287
290
 
291
+ def bwrap_args_via_fd(prefix: list[str]) -> tuple[list[str], int | None]:
292
+ """Move a ``bwrap_prefix`` argv's options into a file descriptor (#362).
293
+
294
+ One mount is a few path arguments, and a home with thousands of agent
295
+ project folders makes thousands of them: past ``ARG_MAX`` the kernel
296
+ refuses to start bwrap at all (``E2BIG``, "Argument list too long").
297
+ bwrap reads NUL-separated options from ``--args FD``, which has no such
298
+ limit, so the command line stays ``bwrap --args FD --`` however many
299
+ mounts there are.
300
+
301
+ Returns the short argv and the descriptor, which the caller passes to
302
+ the child (``pass_fds``) and closes once it has started. An empty
303
+ prefix (no sandbox) comes back as it is, with ``None``.
304
+ """
305
+ if not prefix:
306
+ return prefix, None
307
+ if prefix[-1] != "--":
308
+ raise ValueError("not a bwrap_prefix argv: it must end with '--'")
309
+ data = b"".join(os.fsencode(a) + b"\0" for a in prefix[1:-1])
310
+ if hasattr(os, "memfd_create"):
311
+ fd = os.memfd_create("ctrlrelay-bwrap-args")
312
+ else: # pragma: no cover - bwrap is Linux-only, and Linux has memfd
313
+ import tempfile
314
+
315
+ fd, path = tempfile.mkstemp(prefix="ctrlrelay-bwrap-args-")
316
+ os.unlink(path)
317
+ try:
318
+ view = memoryview(data)
319
+ while view:
320
+ view = view[os.write(fd, view):]
321
+ os.lseek(fd, 0, os.SEEK_SET)
322
+ except BaseException:
323
+ os.close(fd)
324
+ raise
325
+ return [prefix[0], "--args", str(fd), "--"], fd
326
+
327
+
288
328
  def bwrap_prefix(
289
329
  bwrap: str,
290
330
  hide_paths: Sequence[str],
@@ -230,6 +230,11 @@ class AgentConfig(BaseModel):
230
230
  opencode: OpenCodeConfig = Field(default_factory=OpenCodeConfig)
231
231
  # #223. Off until a limit is set.
232
232
  budget: BudgetConfig = Field(default_factory=BudgetConfig)
233
+ # #210: how many agent runs this node may have going at once, over
234
+ # every track (new issues, resumed answers, the security sweep).
235
+ # Unset: no shared limit, and the poll loop takes one issue at a time,
236
+ # as before the setting existed.
237
+ max_parallel: int | None = Field(default=None, ge=1)
233
238
 
234
239
 
235
240
  # Backwards-compat alias. Older code and docs reference ``ClaudeConfig``;
@@ -10,6 +10,7 @@ so pipelines can stay agent-agnostic.
10
10
  from __future__ import annotations
11
11
 
12
12
  import asyncio
13
+ import contextlib
13
14
  import json
14
15
  import os
15
16
  import re
@@ -29,6 +30,7 @@ from ctrlrelay.core.agent_env import (
29
30
  SandboxUnavailableError,
30
31
  agent_cache_env,
31
32
  build_agent_env,
33
+ bwrap_args_via_fd,
32
34
  bwrap_prefix,
33
35
  git_dirs,
34
36
  resolve_bwrap,
@@ -388,8 +390,15 @@ class CliAgentAdapter:
388
390
  repo: str | None,
389
391
  issue_number: int | None,
390
392
  budget_scope: BudgetScope | None = None,
393
+ sandbox_prefix_len: int = 0,
391
394
  ) -> SessionResult:
392
- """Run the child built by :meth:`spawn_session` to completion."""
395
+ """Run the child built by :meth:`spawn_session` to completion.
396
+
397
+ The first ``sandbox_prefix_len`` items of ``cmd`` are the bwrap
398
+ prefix. Its options are moved into a file descriptor right before
399
+ the child starts, and closed right after (#362), so no error path
400
+ can leave the descriptor open.
401
+ """
393
402
 
394
403
  # The prompt can carry issue bodies and operator answers, so only its
395
404
  # hash and length are ever logged (#41).
@@ -424,13 +433,24 @@ class CliAgentAdapter:
424
433
  watches.append(("budget", budget.run))
425
434
 
426
435
  try:
427
- proc = await asyncio.create_subprocess_exec(
428
- *cmd,
429
- cwd=working_dir,
430
- env=env,
431
- stdout=asyncio.subprocess.PIPE,
432
- stderr=asyncio.subprocess.PIPE,
433
- )
436
+ # The sandbox's options go through a file descriptor, not the
437
+ # command line, so no number of mounts can pass ARG_MAX (#362).
438
+ short, args_fd = bwrap_args_via_fd(cmd[:sandbox_prefix_len])
439
+ pass_fds = () if args_fd is None else (args_fd,)
440
+ try:
441
+ proc = await asyncio.create_subprocess_exec(
442
+ *short, *cmd[sandbox_prefix_len:],
443
+ cwd=working_dir,
444
+ env=env,
445
+ stdout=asyncio.subprocess.PIPE,
446
+ stderr=asyncio.subprocess.PIPE,
447
+ pass_fds=pass_fds,
448
+ )
449
+ finally:
450
+ # The child has its own copy now; bwrap reads it at start.
451
+ for fd in pass_fds:
452
+ with contextlib.suppress(OSError):
453
+ os.close(fd)
434
454
  except Exception as e:
435
455
  # Usually the binary isn't where we resolved it — the exception
436
456
  # propagates to the pipeline, but without this it lands there
@@ -886,12 +906,29 @@ class ClaudeDispatcher(CliAgentAdapter):
886
906
  copy.chmod(0o600)
887
907
  # At the real path: a bind on a link lands where it leads.
888
908
  copies.append((copy, Path(os.path.realpath(main_config))))
909
+ # Where the agent CLI writes this run's transcript: the only part
910
+ # of the read-only projects folder the run may write (#362). Made
911
+ # here, since the sandbox cannot create it under a read-only one.
912
+ transcript_dirs = self._transcript_dirs(working_dir, env)
913
+ projects = Path(os.path.realpath(Path.home() / ".claude" / "projects"))
914
+ for folder in transcript_dirs:
915
+ folder.mkdir(parents=True, exist_ok=True)
916
+ # Bound writable as it resolves: a link left there (a run before
917
+ # #362 could write the projects folder) would open its target,
918
+ # the home directory for one. Only a plain folder directly in
919
+ # `projects` is opened.
920
+ if folder.is_symlink() or Path(os.path.realpath(folder)).parent != projects:
921
+ raise SandboxUnavailableError(
922
+ f"the transcript folder {folder} is a link or is not "
923
+ f"directly in {projects}; remove it before running "
924
+ "sandboxed"
925
+ )
889
926
  return bwrap_prefix(
890
927
  self._bwrap,
891
928
  self.sandbox_hide_paths,
892
929
  writable=[
893
930
  working_dir, *repo_dirs, private_root, cache_dir,
894
- *self.sandbox_writable_paths,
931
+ *self.sandbox_writable_paths, *transcript_dirs,
895
932
  ],
896
933
  readonly=[*self.sandbox_readonly_paths, *git_protected],
897
934
  copies=copies,
@@ -999,6 +1036,7 @@ class ClaudeDispatcher(CliAgentAdapter):
999
1036
  self.started += 1
1000
1037
  try:
1001
1038
  return await self._run_session(
1039
+ sandbox_prefix_len=len(prefix),
1002
1040
  cmd=cmd,
1003
1041
  env=env,
1004
1042
  ident=ident,