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.
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/CHANGELOG.md +32 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/PKG-INFO +1 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/cli.md +2 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/configuration.md +6 -4
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/operations.md +81 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/pyproject.toml +1 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/cli.py +135 -52
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/agent_env.py +43 -3
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/config.py +5 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/dispatcher.py +47 -9
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/orphans.py +34 -4
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/poller.py +202 -0
- ctrlrelay-0.25.0/src/ctrlrelay/core/slots.py +96 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/state.py +96 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/worktree.py +72 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/dev.py +488 -13
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/secops.py +468 -437
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/fleet.py +10 -5
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/server.py +2 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/static/index.html +2 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/status.py +1 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_sandbox.py +198 -2
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_fleet.py +15 -1
- ctrlrelay-0.25.0/tests/test_max_parallel.py +416 -0
- ctrlrelay-0.25.0/tests/test_run_resume.py +660 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/.gitignore +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/LICENSE +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/README.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/SECURITY.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/development.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/index.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/blockers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/intake_hold.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/issue_questions.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/proof.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/reopens.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/self_update.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/weekly_summary.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/drift.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_stuck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_intake_hold.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_intake_hold_poller.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_issue_blockers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_issue_questions.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_no_change_run.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller_priority_order.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_proof.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_reopens.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_review_gate.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_sandbox_visibility.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_secops_operator_prs.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_self_update.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_drift.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.25.0}/tests/test_weekly_summary.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
365
|
-
|
|
366
|
-
sessions load
|
|
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
|
|
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... |
|
|
@@ -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
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
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
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
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
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
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
|
-
#
|
|
248
|
-
# load. One
|
|
249
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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,
|