ctrlrelay 0.24.0__tar.gz → 0.26.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.26.0}/CHANGELOG.md +52 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/PKG-INFO +1 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/cli.md +2 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/configuration.md +7 -5
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/operations.md +81 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/pyproject.toml +1 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/cli.py +150 -52
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/agent_env.py +43 -3
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/config.py +5 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/dispatcher.py +47 -9
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/orphans.py +34 -4
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/poller.py +202 -0
- ctrlrelay-0.26.0/src/ctrlrelay/core/slots.py +96 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/state.py +96 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/worktree.py +72 -1
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/dev.py +586 -19
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/secops.py +468 -437
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/fleet.py +10 -5
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/server.py +2 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/static/index.html +15 -3
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/status.py +1 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_sandbox.py +198 -2
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_fleet.py +15 -1
- ctrlrelay-0.26.0/tests/test_max_parallel.py +416 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_no_change_run.py +141 -2
- ctrlrelay-0.26.0/tests/test_run_resume.py +660 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_drift.py +35 -6
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.gitignore +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/LICENSE +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/README.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/SECURITY.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/development.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/index.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/blockers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/intake_hold.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/issue_questions.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/proof.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/reopens.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/self_update.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/weekly_summary.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/drift.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_stuck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_intake_hold.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_intake_hold_poller.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_issue_blockers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_issue_questions.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller_priority_order.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_proof.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_reopens.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_review_gate.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_sandbox_visibility.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_secops_operator_prs.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_self_update.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_weekly_summary.py +0 -0
- {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.26.0] - 2026-11-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A dev run can now finish with an answer instead of code (#368). For an
|
|
15
|
+
issue that asks a question, an investigation, a plan or a proposed split,
|
|
16
|
+
the agent commits nothing and writes its answer as `"reply"` in the DONE
|
|
17
|
+
block, and the relay posts it on the issue as a comment, in full (cut at
|
|
18
|
+
60,000 characters). A document that belongs in the repository is still
|
|
19
|
+
committed and opened as a pull request. Replies follow
|
|
20
|
+
`question_on_issue`: a public repository gets a short note and the reply
|
|
21
|
+
goes to chat, unless the repository is set to `full`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The status page says `5-hour none (ultra plan)` instead of
|
|
26
|
+
`5-hour unknown` when a usage reading has a 7-day window and no 5-hour
|
|
27
|
+
one, which is how ultra plans report (#367).
|
|
28
|
+
|
|
29
|
+
## [0.25.0] - 2026-11-06
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- A sandboxed run (`agent.sandbox: bwrap`) failed at once with "Argument
|
|
34
|
+
list too long" on a machine with thousands of past agent project folders
|
|
35
|
+
(#362). The sandbox mounted each `~/.claude/projects/*/memory` on its own,
|
|
36
|
+
which ran past both the kernel's argument limit and bwrap's 9000 arguments.
|
|
37
|
+
It now mounts `~/.claude/projects` read-only once, opens only the run's
|
|
38
|
+
own transcript folder for writing, and passes its options to bwrap
|
|
39
|
+
through a file descriptor. A config that lists the old glob in
|
|
40
|
+
`sandbox_readonly_paths` should drop it.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `agent.max_parallel` lets one machine run several agents at once (#210).
|
|
45
|
+
It is one cap over new issues, resumed answers and the security sweep.
|
|
46
|
+
Each poll's issues start side by side up to the free slots, in priority
|
|
47
|
+
order, never two of one repo at once, and the loop polls again while they
|
|
48
|
+
run. Unset keeps the old one-at-a-time behaviour. The status page's slot
|
|
49
|
+
count now shows the real number of runs and the cap, or "no cap".
|
|
50
|
+
- A dev run saves each step it finishes (plan, code, publish, verify) in a
|
|
51
|
+
new `run_steps` table, and a run whose process died is continued after
|
|
52
|
+
its last saved step instead of starting over (#231). The branch must
|
|
53
|
+
still be at the saved commit. The agent is not run again for a saved
|
|
54
|
+
code step, and a saved pull request is reused rather than opened twice.
|
|
55
|
+
Proof and review always run again. `ctrlrelay run dev --from-step
|
|
56
|
+
plan|code|publish` starts a new run from a past step, keeping the old
|
|
57
|
+
branch head under `refs/ctrlrelay/before-retry/`.
|
|
58
|
+
|
|
10
59
|
## [0.24.0] - 2026-11-05
|
|
11
60
|
|
|
12
61
|
### Added
|
|
@@ -2591,7 +2640,9 @@ pipeline).
|
|
|
2591
2640
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2592
2641
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2593
2642
|
|
|
2594
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2643
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.26.0...HEAD
|
|
2644
|
+
[0.26.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.25.0...v0.26.0
|
|
2645
|
+
[0.25.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...v0.25.0
|
|
2595
2646
|
[0.24.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...v0.24.0
|
|
2596
2647
|
[0.23.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...v0.23.0
|
|
2597
2648
|
[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.26.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
|
|
@@ -764,7 +766,7 @@ repos:
|
|
|
764
766
|
| `code_review` | object | no | (defaults) | Review run over an agent branch before its PR is handed over. See [code_review](#code_review). |
|
|
765
767
|
| `steps` | object | no | (all unset) | How strict each dev pipeline work step is. See [steps](#repossteps). |
|
|
766
768
|
| `max_fix_attempts` | integer ≥ 0 | no | `3` | Fix rounds the dev pipeline asks for when a PR's CI is red or its branch conflicts, before the run fails. `0` means none. See [steps](#repossteps). |
|
|
767
|
-
| `question_on_issue` | `auto` \| `full` \| `notice` | no | `auto` | What a dev run's question looks like on its GitHub issue. The question is asked there as well as in chat, and the first answer from either wins; on the issue only a comment that starts with `/answer`, from an account with write access, counts. `auto` posts the full question on a private repository and a short "a question is waiting for the operator in chat" notice on a public one (or one whose visibility could not be read); a chat answer is copied to the issue under the same rule. `full` and `notice` force one or the other. |
|
|
769
|
+
| `question_on_issue` | `auto` \| `full` \| `notice` | no | `auto` | What a dev run's question looks like on its GitHub issue. The question is asked there as well as in chat, and the first answer from either wins; on the issue only a comment that starts with `/answer`, from an account with write access, counts. `auto` posts the full question on a private repository and a short "a question is waiting for the operator in chat" notice on a public one (or one whose visibility could not be read); a chat answer is copied to the issue under the same rule. `full` and `notice` force one or the other. A run that finishes with a reply instead of code (#368) follows the same rule: the reply is posted on the issue when the question would be, and otherwise the issue gets a short note and the reply goes to chat. |
|
|
768
770
|
| `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
|
|
769
771
|
|
|
770
772
|
### repos[].automation
|
|
@@ -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... |
|
|
@@ -545,6 +545,11 @@ def unresolved_review_programs(config) -> dict[str, list[str]]:
|
|
|
545
545
|
}
|
|
546
546
|
|
|
547
547
|
|
|
548
|
+
# A chat message is capped by the transport (Telegram: 4,096 characters);
|
|
549
|
+
# the reply is cut well short of that, leaving room for the first line.
|
|
550
|
+
_CHAT_REPLY_MAX = 3_000
|
|
551
|
+
|
|
552
|
+
|
|
548
553
|
async def announce_dev_success(
|
|
549
554
|
transport, *, repo: str, result
|
|
550
555
|
) -> list[str]:
|
|
@@ -575,6 +580,16 @@ async def announce_dev_success(
|
|
|
575
580
|
f"✅ {repo}: finished, no change needed: "
|
|
576
581
|
f"{outputs.get('no_change_reason') or 'no reason given'}"
|
|
577
582
|
)
|
|
583
|
+
reply = outputs.get("no_change_reply")
|
|
584
|
+
if isinstance(reply, str) and reply:
|
|
585
|
+
# #368: posted on the issue, or held back from a public one,
|
|
586
|
+
# in which case chat is the only place it lands.
|
|
587
|
+
if outputs.get("no_change_reply_posted") is True:
|
|
588
|
+
line += "\nThe agent's reply is posted on the issue."
|
|
589
|
+
else:
|
|
590
|
+
cut = reply[:_CHAT_REPLY_MAX]
|
|
591
|
+
more = "\n(cut here)" if len(reply) > _CHAT_REPLY_MAX else ""
|
|
592
|
+
line += f"\nThe agent's reply (not posted on the issue):\n{cut}{more}"
|
|
578
593
|
await transport.send(line)
|
|
579
594
|
sent.append(line)
|
|
580
595
|
return sent
|
|
@@ -2503,6 +2518,16 @@ def run_dev(
|
|
|
2503
2518
|
"-c",
|
|
2504
2519
|
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2505
2520
|
),
|
|
2521
|
+
from_step: str | None = typer.Option(
|
|
2522
|
+
None,
|
|
2523
|
+
"--from-step",
|
|
2524
|
+
help=(
|
|
2525
|
+
"Start again from a past step of the issue's newest run that "
|
|
2526
|
+
"saved steps: plan, code or publish. The steps before it are "
|
|
2527
|
+
"taken from that run and the branch is moved back to where "
|
|
2528
|
+
"it was then."
|
|
2529
|
+
),
|
|
2530
|
+
),
|
|
2506
2531
|
) -> None:
|
|
2507
2532
|
"""Run dev pipeline for a GitHub issue."""
|
|
2508
2533
|
import asyncio
|
|
@@ -2598,6 +2623,7 @@ def run_dev(
|
|
|
2598
2623
|
steps=repo_config.steps,
|
|
2599
2624
|
question_on_issue=repo_config.question_on_issue,
|
|
2600
2625
|
require_proof=repo_config.automation.require_proof,
|
|
2626
|
+
from_step=from_step,
|
|
2601
2627
|
)
|
|
2602
2628
|
|
|
2603
2629
|
try:
|
|
@@ -3727,6 +3753,12 @@ def poller_start(
|
|
|
3727
3753
|
github, config.node_id,
|
|
3728
3754
|
window=sweep_window(config.schedules.secops_cron, config.timezone),
|
|
3729
3755
|
),
|
|
3756
|
+
slots=agent_slots,
|
|
3757
|
+
# Asked again per repo once it has a slot (#210).
|
|
3758
|
+
held=lambda: (
|
|
3759
|
+
None if (h := intake_hold.active()) is None
|
|
3760
|
+
else h.reason
|
|
3761
|
+
),
|
|
3730
3762
|
)
|
|
3731
3763
|
ok = sum(1 for r in results if r.success)
|
|
3732
3764
|
console.print(
|
|
@@ -3894,6 +3926,35 @@ def poller_start(
|
|
|
3894
3926
|
except Exception:
|
|
3895
3927
|
pass
|
|
3896
3928
|
|
|
3929
|
+
# The node's cap on agent runs (#210), shared by the poll loop, the
|
|
3930
|
+
# resume sweeper and the security sweep. No limit when unset.
|
|
3931
|
+
from ctrlrelay.core.slots import AgentSlots
|
|
3932
|
+
agent_slots = AgentSlots(config.agent.max_parallel)
|
|
3933
|
+
|
|
3934
|
+
def _resume_no_longer_wanted(row: dict) -> str | None:
|
|
3935
|
+
"""Why an answered question should not be resumed right now,
|
|
3936
|
+
read after the wait for a slot (#210), or None to go ahead. A
|
|
3937
|
+
hold placed meanwhile (a self-update) leaves the row for a
|
|
3938
|
+
later tick; a row resumed, expired or answered again meanwhile
|
|
3939
|
+
is not this tick's to run."""
|
|
3940
|
+
hold = intake_hold.active()
|
|
3941
|
+
if hold is not None:
|
|
3942
|
+
return f"new work is held ({hold.reason})"
|
|
3943
|
+
try:
|
|
3944
|
+
current = state_db.get_pending_resume(row["session_id"])
|
|
3945
|
+
except Exception as e:
|
|
3946
|
+
# Could not look: leave the answer for the next tick rather
|
|
3947
|
+
# than letting the sweeper's error path mark it resumed.
|
|
3948
|
+
return f"the question could not be read ({type(e).__name__})"
|
|
3949
|
+
if (
|
|
3950
|
+
current is None
|
|
3951
|
+
or current.get("resumed_at") is not None
|
|
3952
|
+
or current.get("expired_at") is not None
|
|
3953
|
+
or current.get("answered_at") != row.get("answered_at")
|
|
3954
|
+
):
|
|
3955
|
+
return "the question is no longer waiting for this answer"
|
|
3956
|
+
return None
|
|
3957
|
+
|
|
3897
3958
|
async def _run_pending_resume_sweeper() -> None:
|
|
3898
3959
|
"""Drain pending_resumes rows that an operator answered via
|
|
3899
3960
|
Telegram while the original BLOCKED session had already torn
|
|
@@ -3998,28 +4059,40 @@ def poller_start(
|
|
|
3998
4059
|
try:
|
|
3999
4060
|
if pipeline_name == "secops":
|
|
4000
4061
|
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
|
-
|
|
4062
|
+
# A slot of the node's cap (#210), before the repo lock.
|
|
4063
|
+
async with agent_slots.hold():
|
|
4064
|
+
# The wait for a slot can be long (#210):
|
|
4065
|
+
# ask again whether to go ahead.
|
|
4066
|
+
gone = _resume_no_longer_wanted(row)
|
|
4067
|
+
if gone is not None:
|
|
4068
|
+
console.print(
|
|
4069
|
+
"[dim]pending_resume_sweeper: "
|
|
4070
|
+
f"{session_id} not resumed now: "
|
|
4071
|
+
f"{gone}[/dim]"
|
|
4072
|
+
)
|
|
4073
|
+
continue
|
|
4074
|
+
result = await resume_secops_from_pending(
|
|
4075
|
+
session_id=session_id,
|
|
4076
|
+
repo=repo,
|
|
4077
|
+
answer=answer,
|
|
4078
|
+
dispatcher=dispatcher,
|
|
4079
|
+
github=github,
|
|
4080
|
+
worktree=worktree,
|
|
4081
|
+
dashboard=scheduled_dashboard,
|
|
4082
|
+
state_db=state_db,
|
|
4083
|
+
transport=sweeper_transport,
|
|
4084
|
+
contexts_dir=config.paths.contexts,
|
|
4085
|
+
automation=(
|
|
4086
|
+
sweep_repo_cfg.automation
|
|
4087
|
+
if sweep_repo_cfg is not None
|
|
4088
|
+
else None
|
|
4089
|
+
),
|
|
4090
|
+
question=row.get("question"),
|
|
4091
|
+
# Same tracker as the poller and the
|
|
4092
|
+
# sweep: a repo archived while a session
|
|
4093
|
+
# sat blocked must not be resumed into.
|
|
4094
|
+
archived=poller.archived_tracker,
|
|
4095
|
+
)
|
|
4023
4096
|
elif pipeline_name == "dev":
|
|
4024
4097
|
# Dev resume needs the repo's branch template
|
|
4025
4098
|
# to reconstruct the session's existing branch
|
|
@@ -4043,37 +4116,61 @@ def poller_start(
|
|
|
4043
4116
|
except Exception:
|
|
4044
4117
|
pass
|
|
4045
4118
|
continue
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4119
|
+
# A slot of the node's cap (#210), before the repo lock.
|
|
4120
|
+
async with agent_slots.hold():
|
|
4121
|
+
# The wait for a slot can be long (#210):
|
|
4122
|
+
# ask again whether to go ahead.
|
|
4123
|
+
gone = _resume_no_longer_wanted(row)
|
|
4124
|
+
if gone is not None:
|
|
4125
|
+
console.print(
|
|
4126
|
+
"[dim]pending_resume_sweeper: "
|
|
4127
|
+
f"{session_id} not resumed now: "
|
|
4128
|
+
f"{gone}[/dim]"
|
|
4129
|
+
)
|
|
4130
|
+
continue
|
|
4131
|
+
result = await resume_dev_from_pending(
|
|
4132
|
+
session_id=session_id,
|
|
4133
|
+
repo=repo,
|
|
4134
|
+
answer=answer,
|
|
4135
|
+
branch_template=repo_cfg.dev_branch_template,
|
|
4136
|
+
dispatcher=dispatcher,
|
|
4137
|
+
github=github,
|
|
4138
|
+
worktree=worktree,
|
|
4139
|
+
dashboard=scheduled_dashboard,
|
|
4140
|
+
state_db=state_db,
|
|
4141
|
+
transport=sweeper_transport,
|
|
4142
|
+
contexts_dir=config.paths.contexts,
|
|
4143
|
+
max_fix_attempts=repo_cfg.max_fix_attempts,
|
|
4144
|
+
code_review=repo_cfg.code_review,
|
|
4145
|
+
steps=repo_cfg.steps,
|
|
4146
|
+
question_on_issue=repo_cfg.question_on_issue,
|
|
4147
|
+
require_proof=repo_cfg.automation.require_proof,
|
|
4148
|
+
)
|
|
4064
4149
|
elif pipeline_name == "task":
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4150
|
+
# A slot of the node's cap (#210), before the repo lock.
|
|
4151
|
+
async with agent_slots.hold():
|
|
4152
|
+
# The wait for a slot can be long (#210):
|
|
4153
|
+
# ask again whether to go ahead.
|
|
4154
|
+
gone = _resume_no_longer_wanted(row)
|
|
4155
|
+
if gone is not None:
|
|
4156
|
+
console.print(
|
|
4157
|
+
"[dim]pending_resume_sweeper: "
|
|
4158
|
+
f"{session_id} not resumed now: "
|
|
4159
|
+
f"{gone}[/dim]"
|
|
4160
|
+
)
|
|
4161
|
+
continue
|
|
4162
|
+
result = await resume_task_from_pending(
|
|
4163
|
+
session_id=session_id,
|
|
4164
|
+
repo=repo,
|
|
4165
|
+
answer=answer,
|
|
4166
|
+
dispatcher=dispatcher,
|
|
4167
|
+
github=github,
|
|
4168
|
+
worktree=worktree,
|
|
4169
|
+
dashboard=scheduled_dashboard,
|
|
4170
|
+
state_db=state_db,
|
|
4171
|
+
transport=sweeper_transport,
|
|
4172
|
+
contexts_dir=config.paths.contexts,
|
|
4173
|
+
)
|
|
4077
4174
|
else:
|
|
4078
4175
|
console.print(
|
|
4079
4176
|
"[yellow]pending_resume_sweeper: "
|
|
@@ -4498,6 +4595,7 @@ def poller_start(
|
|
|
4498
4595
|
await run_poll_loop(
|
|
4499
4596
|
poller=poller, handler=handle_issue_unless_held,
|
|
4500
4597
|
interval=interval,
|
|
4598
|
+
slots=agent_slots,
|
|
4501
4599
|
interval_scale=lambda: (
|
|
4502
4600
|
config.github_api.slow_factor
|
|
4503
4601
|
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``;
|