ctrlrelay 0.23.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.23.0 → ctrlrelay-0.25.0}/CHANGELOG.md +99 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/PKG-INFO +1 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/architecture.md +8 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/cli.md +2 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/configuration.md +194 -11
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/operations.md +151 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/pyproject.toml +1 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/cli.py +211 -68
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/agent_env.py +43 -3
- ctrlrelay-0.25.0/src/ctrlrelay/core/budget.py +596 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/config.py +122 -2
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/dispatcher.py +187 -46
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/events.py +5 -2
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/orphans.py +34 -4
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/poller.py +280 -0
- ctrlrelay-0.25.0/src/ctrlrelay/core/proof.py +1226 -0
- ctrlrelay-0.25.0/src/ctrlrelay/core/slots.py +96 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/state.py +114 -2
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/weekly_summary.py +25 -2
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/worktree.py +72 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/base.py +6 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/dev.py +902 -40
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/secops.py +468 -437
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/task.py +31 -6
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/fleet.py +14 -5
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/numbers.py +106 -9
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/server.py +5 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/static/index.html +67 -4
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/status.py +62 -2
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_agent_routing.py +4 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_agent_sandbox.py +198 -2
- ctrlrelay-0.25.0/tests/test_budget.py +661 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dev_stuck.py +143 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dev_work_steps.py +5 -3
- {ctrlrelay-0.23.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_poller_priority_order.py +265 -0
- ctrlrelay-0.25.0/tests/test_proof.py +1479 -0
- ctrlrelay-0.25.0/tests/test_run_resume.py +660 -0
- ctrlrelay-0.25.0/tests/test_sandbox_visibility.py +155 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_drift.py +2 -1
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_numbers.py +166 -5
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_status.py +76 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_weekly_summary.py +34 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/.gitignore +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/LICENSE +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/README.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/SECURITY.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/development.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/index.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/blockers.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/intake_hold.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/issue_questions.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/reopens.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/self_update.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/drift.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_intake_hold.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_intake_hold_poller.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_issue_blockers.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_issue_questions.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_no_change_run.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_reopens.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_review_gate.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_secops_operator_prs.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_self_update.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.23.0 → ctrlrelay-0.25.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,102 @@ 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
|
+
|
|
40
|
+
## [0.24.0] - 2026-11-05
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Each poll's new issues run in priority order, oldest first (#356). The
|
|
45
|
+
batch is sorted by the position of the issue's priority label in
|
|
46
|
+
`intake.priority_labels` (default `["P0", "P1", "P2", "P3"]`, compared
|
|
47
|
+
without case; an issue with several runs at its earliest; unlabeled
|
|
48
|
+
last), then by when the issue was opened, then by number. An issue whose
|
|
49
|
+
creation time cannot be read runs after the others with its label. `[]`
|
|
50
|
+
keeps the oldest-first order only. Each poll that found issues logs the
|
|
51
|
+
order once as `poll.batch.order`. The re-read before each dispatch and
|
|
52
|
+
the blocker hold are unchanged; the order is per machine and fixed for
|
|
53
|
+
the batch.
|
|
54
|
+
|
|
55
|
+
- Cost per run in the numbers (#230). Each 7- and 30-day window's
|
|
56
|
+
`cost_per_run` (in `/api/status` and the status page's **Numbers** box)
|
|
57
|
+
now gives the median and total tokens per run that ended (`done` or
|
|
58
|
+
`failed`), counted as the run budget counts them (#223), and the median
|
|
59
|
+
and total `total_cost_usd` where the agent reported one. Runs with no
|
|
60
|
+
usage recorded (from before #223, or on a backend that is not measured)
|
|
61
|
+
are left out and counted in `runs_without_usage`, never read as 0; a
|
|
62
|
+
window with no measured run is `null` with the reason in `unavailable`.
|
|
63
|
+
The weekly chat summary gets a `tokens per run` line, `unknown` when not
|
|
64
|
+
measured.
|
|
65
|
+
|
|
66
|
+
- A dev run's PR goes ready only with proof the change works (#221). The
|
|
67
|
+
agent names a proving test in its DONE checkpoint. The relay runs it on
|
|
68
|
+
the pushed commit (must pass) and on the base with only the branch's test
|
|
69
|
+
changes (must fail), in fresh copies, with a time limit, without the
|
|
70
|
+
GitHub token, and under bwrap with no network when the agent was
|
|
71
|
+
sandboxed. It also flags deleted, skipped or loosened tests and empty or
|
|
72
|
+
whitespace-only diffs. A result counts only with evidence the named test
|
|
73
|
+
ran (pytest's junit report, or the exit code and the test in the output
|
|
74
|
+
for other runners), and the PR's head must still be the proven commit when
|
|
75
|
+
it is marked ready. The outcome is `proven`, `failed` or `not proven`;
|
|
76
|
+
only `proven` (or a docs-only diff) lets the PR go ready. Otherwise the PR
|
|
77
|
+
stays a draft and the run fails with `PR #N held as a draft`. The PR gets
|
|
78
|
+
one comment, edited in place, with the test, the command and both results.
|
|
79
|
+
Off by default: set `repos[].automation.require_proof: true` for a repo
|
|
80
|
+
whose tests can run offline in a fresh copy. With it off, the agent is not
|
|
81
|
+
asked for a proof and nothing is posted.
|
|
82
|
+
|
|
83
|
+
- Each machine's agent sandbox mode is shown (#305). `/api/status` carries
|
|
84
|
+
`node.sandbox` (`"none"` or `"bwrap"`; `null` for a peer on an older
|
|
85
|
+
version), each machine's card on the status page says `sandbox off` in the
|
|
86
|
+
warning colour when the agent runs unconfined, and the poller logs
|
|
87
|
+
`poller.sandbox_off` once at start with what to do about it.
|
|
88
|
+
- A token budget for each run (#223). `agent.budget.soft_tokens` warns once
|
|
89
|
+
in chat and the log; `agent.budget.hard_tokens` stops the agent, records the
|
|
90
|
+
run as failed with the reason `budget: used N tokens, hard limit M`, and
|
|
91
|
+
says `💸 Over budget` in chat. `agent.budget.rules` sets other limits by
|
|
92
|
+
issue label, which covers `size:*`; the first match wins. Nothing is limited
|
|
93
|
+
until a limit is set. The count is input + cache writes + output tokens,
|
|
94
|
+
read live from the agent's transcript, and covers the whole issue session
|
|
95
|
+
across resumes and fix rounds. Cache reads are recorded but not counted.
|
|
96
|
+
A run stopped over budget or as stuck after it opened a PR puts the PR back
|
|
97
|
+
to draft (or posts a "Do not merge" comment when that fails), starts no
|
|
98
|
+
merge watcher for it, and names it in chat and the issue comment.
|
|
99
|
+
- Each session records the tokens it used (`sessions.usage_tokens` and
|
|
100
|
+
`sessions.usage_detail`, with the cost the agent CLI reported). Running
|
|
101
|
+
cards on the status page and `/api/status` show `tokens_used`,
|
|
102
|
+
`cache_read_tokens`, `cost_usd`, `token_soft_limit` and `token_hard_limit`.
|
|
103
|
+
A reading that could not be parsed shows as unknown (`null`), never as 0,
|
|
104
|
+
and never stops a run on its own (#223).
|
|
105
|
+
|
|
10
106
|
## [0.23.0] - 2026-11-05
|
|
11
107
|
|
|
12
108
|
### Added
|
|
@@ -2525,7 +2621,9 @@ pipeline).
|
|
|
2525
2621
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2526
2622
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2527
2623
|
|
|
2528
|
-
[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
|
|
2626
|
+
[0.24.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...v0.24.0
|
|
2529
2627
|
[0.23.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...v0.23.0
|
|
2530
2628
|
[0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
|
|
2531
2629
|
[0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.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/
|
|
@@ -140,6 +140,14 @@ to confirm the PR is mergeable and CI is green. If not, it builds a fix prompt
|
|
|
140
140
|
fix"), resumes the same agent session with that prompt, and re-verifies. Up
|
|
141
141
|
to `DEFAULT_MAX_FIX_ATTEMPTS` (3) attempts.
|
|
142
142
|
|
|
143
|
+
Then the proof step
|
|
144
|
+
([`src/ctrlrelay/core/proof.py`](https://github.com/AInvirion/ctrlrelay/blob/main/src/ctrlrelay/core/proof.py),
|
|
145
|
+
#221) runs the test the agent named, in fresh copies of the pushed commit
|
|
146
|
+
(must pass) and of the base with only the branch's test changes (must fail),
|
|
147
|
+
and scans the diff for weakened tests, when the repo turns it on. Only a proven run's PR is marked
|
|
148
|
+
ready; any other outcome leaves it a draft and fails the run. See
|
|
149
|
+
[`automation.require_proof`](configuration.md#reposautomationrequire_proof).
|
|
150
|
+
|
|
143
151
|
`PRWatcher` (in
|
|
144
152
|
[`src/ctrlrelay/core/pr_watcher.py`](https://github.com/AInvirion/ctrlrelay/blob/main/src/ctrlrelay/core/pr_watcher.py))
|
|
145
153
|
polls a PR for merge status; the post-merge handler in
|
|
@@ -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
|
|
@@ -40,6 +40,7 @@ repos: [ ... ]
|
|
|
40
40
|
| `transport` | object | **yes** | — | See [transport](#transport). |
|
|
41
41
|
| `dashboard` | object | no | (defaults) | See [dashboard](#dashboard). |
|
|
42
42
|
| `web` | object | no | (defaults) | The read-only status page. See [web](#web). |
|
|
43
|
+
| `intake` | object | no | (defaults) | The order each poll's new issues run in. See [intake](#intake). |
|
|
43
44
|
| `claims` | object | no | (defaults) | Claim timings for several machines on one account. See [claims](#claims). |
|
|
44
45
|
| `repos` | list | no | `[]` | See [repos](#repos). |
|
|
45
46
|
| `schedules` | object | no | (defaults) | See [schedules](#schedules). |
|
|
@@ -208,6 +209,7 @@ agent:
|
|
|
208
209
|
| `type` | string | `"claude"` | Default backend for every repo: `"claude"` or `"opencode"`. See [agent backends](#agent-backends). |
|
|
209
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). |
|
|
210
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). |
|
|
211
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. |
|
|
212
214
|
|
|
213
215
|
ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
|
|
@@ -217,6 +219,41 @@ modes either need every shell command allow-listed or filter by command text,
|
|
|
217
219
|
which a wrapper script walks past, so they are not used as a guard. The guard
|
|
218
220
|
is the next section: the agent holds no GitHub credentials.
|
|
219
221
|
|
|
222
|
+
### agent.budget
|
|
223
|
+
|
|
224
|
+
A token budget for each run (#223). Both limits unset (the default): no limit,
|
|
225
|
+
and nothing changes. Usage is measured and shown on the status page either
|
|
226
|
+
way. See [run budgets](operations.md#run-budgets) for what is counted.
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
agent:
|
|
230
|
+
budget:
|
|
231
|
+
soft_tokens: 2000000 # warn once in chat and the log
|
|
232
|
+
hard_tokens: 6000000 # stop the agent
|
|
233
|
+
rules: # first rule naming any of the issue's labels wins
|
|
234
|
+
- labels: ["size:S", "size:XS"]
|
|
235
|
+
soft_tokens: 500000
|
|
236
|
+
hard_tokens: 1500000
|
|
237
|
+
- labels: ["size:L", "size:XL"]
|
|
238
|
+
hard_tokens: 15000000 # soft_tokens left out: 2000000 from above
|
|
239
|
+
- labels: ["no-budget"]
|
|
240
|
+
soft_tokens: null # null: no limit for these runs
|
|
241
|
+
hard_tokens: null
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
| Key | Type | Default | Description |
|
|
245
|
+
|---|---|---|---|
|
|
246
|
+
| `soft_tokens` | int or null | `null` | When a run's total first reaches it, chat gets one line and the log gets `budget.soft_limit`. Once per session. |
|
|
247
|
+
| `hard_tokens` | int or null | `null` | When a run's total reaches it, the agent is stopped and the run is recorded as failed with the reason `budget: used N tokens, hard limit M`. Must be at least `soft_tokens`. |
|
|
248
|
+
| `rules` | list | `[]` | Other limits by issue label. Each rule has `labels` (a non-empty list) and optionally `soft_tokens` and `hard_tokens`. Labels match without regard to case. A size is a label (`size:S`), so this covers sizes too. A limit a rule leaves out comes from the top level; one set to `null` is no limit. |
|
|
249
|
+
|
|
250
|
+
The count is input tokens + cache writes + output tokens; cache reads are
|
|
251
|
+
recorded but not counted. The budget is for the whole issue session, across
|
|
252
|
+
resumes and CI fix rounds. Unknown keys and a hard limit below the soft one
|
|
253
|
+
(after a rule inherits from the top level) are refused when the config loads.
|
|
254
|
+
Only the `claude` backend is measured. Runs with no issue (secops) get the
|
|
255
|
+
top-level limits for each spawn.
|
|
256
|
+
|
|
220
257
|
### What the agent can reach (#219)
|
|
221
258
|
|
|
222
259
|
The dev and task agents run **without GitHub access**. ctrlrelay starts them
|
|
@@ -290,6 +327,14 @@ your user. With `sandbox: "none"` a deliberately hostile agent can read
|
|
|
290
327
|
and use them by hand, and it can write anywhere your user can, including your
|
|
291
328
|
dotfiles and ctrlrelay's install. On macOS the login keychain is reachable
|
|
292
329
|
whatever the environment says. `sandbox: "bwrap"` closes this on Linux.
|
|
330
|
+
macOS has no sandbox yet, so a macOS node should only take issues from
|
|
331
|
+
repositories you trust.
|
|
332
|
+
|
|
333
|
+
So that an unconfined node is never a surprise (#305), each machine's mode is
|
|
334
|
+
in `/api/status` as `node.sandbox` (`"none"` or `"bwrap"`; `null` for a peer on
|
|
335
|
+
an older version), its card on the status page says `sandbox off` in the
|
|
336
|
+
warning colour when it is `"none"`, and the poller logs `poller.sandbox_off`
|
|
337
|
+
once at start.
|
|
293
338
|
|
|
294
339
|
**`sandbox: "bwrap"` (#236).** The dev and task agents run under bubblewrap
|
|
295
340
|
in a read-only copy of the machine (the secops agent, which needs your GitHub
|
|
@@ -317,9 +362,10 @@ login, does not):
|
|
|
317
362
|
snapshots, which your own sessions run or write back (the sandboxed run
|
|
318
363
|
gets an empty, writable one of each);
|
|
319
364
|
- read-only again, over those: `sandbox_readonly_paths` (the agent CLI's
|
|
320
|
-
settings, rules, hooks, plugins, skills, browser host and install, and
|
|
321
|
-
|
|
322
|
-
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`,
|
|
323
369
|
`hooks`, `modules`, the other runs' worktree directories, a linked
|
|
324
370
|
worktree's `commondir` and `gitdir`, and the worktree's `.git` pointer,
|
|
325
371
|
which every later git command there reads. So the agent cannot change git
|
|
@@ -394,7 +440,7 @@ then run the check again. On a single-user laptop this is the whole setup.
|
|
|
394
440
|
| `sandbox` | `"none"` or `"bwrap"` | `"none"` | `"bwrap"` runs the dev and task agents in the read-only sandbox above. |
|
|
395
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. |
|
|
396
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. |
|
|
397
|
-
| `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. |
|
|
398
444
|
| `sandbox_cache_dir` | string | `~/.ctrlrelay/agent-cache` | The agent's own package cache under `sandbox: bwrap`. |
|
|
399
445
|
|
|
400
446
|
## transport
|
|
@@ -545,6 +591,29 @@ web:
|
|
|
545
591
|
The page has no login and shows repository names, issue titles and the
|
|
546
592
|
questions agents asked. **Never expose it to the internet.**
|
|
547
593
|
|
|
594
|
+
## intake
|
|
595
|
+
|
|
596
|
+
The order each poll's new issues run in (#356). A poll gathers one batch of
|
|
597
|
+
new issues and works through it before the next poll; this sets the order of
|
|
598
|
+
that batch.
|
|
599
|
+
|
|
600
|
+
```yaml
|
|
601
|
+
intake:
|
|
602
|
+
priority_labels: ["P0", "P1", "P2", "P3"]
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
| Key | Type | Default | Description |
|
|
606
|
+
|---|---|---|---|
|
|
607
|
+
| `priority_labels` | list of strings | `["P0", "P1", "P2", "P3"]` | Issues carrying an earlier label in this list run first; labels are compared without case, and an issue carrying several runs at its earliest. Issues carrying none of them run last. Within the same label, the oldest issue runs first (by when it was opened, then by number). An issue whose creation time cannot be read runs after the others with its label. `[]` keeps the oldest-first order only. Each label must be a non-empty string and listed once. |
|
|
608
|
+
|
|
609
|
+
Each poll that found new issues logs the order it will run them in, as one
|
|
610
|
+
`poll.batch.order` event (`order`: `owner/repo#N` in run order). The order is
|
|
611
|
+
fixed when the poll ends: an urgent issue opened while a batch runs waits for
|
|
612
|
+
the next poll. Each issue is still read again just before it starts, and an
|
|
613
|
+
issue blocked by an open issue is still held until the blocker closes; those
|
|
614
|
+
can skip an issue, never reorder the batch. The order applies to this machine
|
|
615
|
+
only, not across machines.
|
|
616
|
+
|
|
548
617
|
## claims
|
|
549
618
|
|
|
550
619
|
How machines that share one GitHub account keep off each other's issues.
|
|
@@ -719,9 +788,11 @@ and ask the operator), or `never` (skip).
|
|
|
719
788
|
| `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
|
|
720
789
|
| `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
|
|
721
790
|
| `ci_wait_timeout_seconds` | `600` | Unused since #219 (the agent no longer waits on CI; ctrlrelay does). Still accepted so existing configs load. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
|
|
791
|
+
| `require_proof` | `false` | A dev run's PR goes ready only when the relay itself proved the change: a test that fails on the base and passes on the branch, and no weakened tests. See [require_proof](#reposautomationrequire_proof) below. |
|
|
722
792
|
|
|
723
|
-
The current secops and dev pipelines read
|
|
724
|
-
to the agent — they're not enforced by hard-coded checks.
|
|
793
|
+
The current secops and dev pipelines read the policy settings to bias their
|
|
794
|
+
prompts to the agent — they're not enforced by hard-coded checks.
|
|
795
|
+
`require_proof` is the exception: the relay enforces it.
|
|
725
796
|
|
|
726
797
|
### repos[].automation.operator_prs
|
|
727
798
|
|
|
@@ -922,13 +993,120 @@ repos:
|
|
|
922
993
|
- This does not change the overall session timeout (`agent.default_timeout_seconds`,
|
|
923
994
|
default 1800s) — it only bounds the one `ci wait` call.
|
|
924
995
|
|
|
996
|
+
### repos[].automation.require_proof
|
|
997
|
+
|
|
998
|
+
Off by default (#221). Green CI says a change did not break what was already
|
|
999
|
+
tested; it does not say the issue was fixed. With `require_proof` the relay
|
|
1000
|
+
checks real evidence before it hands a dev run's PR over as ready.
|
|
1001
|
+
|
|
1002
|
+
The agent names its proof in its DONE checkpoint:
|
|
1003
|
+
|
|
1004
|
+
```json
|
|
1005
|
+
"proof": {"command": ["uv", "run", "--offline", "pytest", "tests/test_x.py::test_y"],
|
|
1006
|
+
"tests": ["tests/test_x.py::test_y"]}
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
After CI is green, the relay runs `command` twice, as an argument list with
|
|
1010
|
+
no shell, each time in a fresh copy of committed content (never the agent's
|
|
1011
|
+
worktree):
|
|
1012
|
+
|
|
1013
|
+
- on the commit it pushed, where it must pass;
|
|
1014
|
+
- on the base commit (the merge base with the default branch) with only the
|
|
1015
|
+
branch's changes to test files applied, where it must fail.
|
|
1016
|
+
|
|
1017
|
+
Each run has a 10-minute limit and never gets the operator's GitHub token.
|
|
1018
|
+
When the agent ran under `agent.sandbox: bwrap`, the proof runs under the
|
|
1019
|
+
same kind of bwrap confinement, with no network and only the copy and the
|
|
1020
|
+
agent's cache dir writable. When the agent ran with `sandbox: none`, it
|
|
1021
|
+
already ran arbitrary code as this user, so the proof runs unconfined too.
|
|
1022
|
+
The copies have no untracked files, so the command must set up what it needs
|
|
1023
|
+
offline.
|
|
1024
|
+
|
|
1025
|
+
A run counts only with evidence that the named tests ran. For a pytest
|
|
1026
|
+
command (`pytest`, `uv run pytest`, `python -m pytest`) the relay adds
|
|
1027
|
+
`--junitxml` and reads the report: on the branch each named test must have
|
|
1028
|
+
passed, on the base each must have failed or errored. pytest exiting 2 to 5
|
|
1029
|
+
(a collection error, a usage error, no tests collected, or `uv run` failing
|
|
1030
|
+
to start it), no report, or a named test missing from it is no evidence.
|
|
1031
|
+
One exception, on the base only: a collection error in the file of a named
|
|
1032
|
+
test (a new test file that imports code only the branch has) counts as
|
|
1033
|
+
failing before, recorded as "could not import on base". A collection error
|
|
1034
|
+
only in other files does not. For any other runner the command must exit 0 on the
|
|
1035
|
+
branch and 1 on the base, and each named test (the part after the last
|
|
1036
|
+
`::`) must appear in its output.
|
|
1037
|
+
|
|
1038
|
+
There are three outcomes:
|
|
1039
|
+
|
|
1040
|
+
| Outcome | When |
|
|
1041
|
+
|---|---|
|
|
1042
|
+
| `proven` | The named tests failed on the base and passed on the branch. |
|
|
1043
|
+
| `failed` | The named tests ran on the branch and did not pass; they ran on the base and passed, so they prove nothing; the diff has no real change (empty or whitespace only); or the diff weakens tests. |
|
|
1044
|
+
| `not proven` | No proof was named, the proof is malformed or names a test outside the test files, there is no evidence the named tests ran (a setup, dependency or network failure, a usage error, a collection error), the command could not start, it timed out, or the branch moved after the proof ran. |
|
|
1045
|
+
|
|
1046
|
+
Only `proven` lets the PR go ready. Two exemptions, recorded as `not
|
|
1047
|
+
required`: a diff that only touches docs (`*.md`, `*.rst`, `*.adoc`,
|
|
1048
|
+
`docs/**`), and a repo with `require_proof: false`.
|
|
1049
|
+
|
|
1050
|
+
**The test weakening scan** reads the diff's test files (`tests/`, `test_*.py`,
|
|
1051
|
+
`*_test.py`, `conftest.py`, `*.test.ts`, `*.spec.js`, `__tests__/` and the
|
|
1052
|
+
like) and the test runner's config. These are blockers:
|
|
1053
|
+
|
|
1054
|
+
- a deleted test file, in any language;
|
|
1055
|
+
- a test file renamed to a name the runner does not collect, or moved out
|
|
1056
|
+
of the tests;
|
|
1057
|
+
- a removed test function;
|
|
1058
|
+
- an added skip, xfail or `.only` marker: decorators, a module-level
|
|
1059
|
+
`pytestmark`, `pytest.importorskip`, unittest skips, and JS `.skip`,
|
|
1060
|
+
`xit`, `it.only`;
|
|
1061
|
+
- an added line in runner config that can deselect or skip: `addopts`,
|
|
1062
|
+
`--deselect`, `-k`, `testpaths`, `norecursedirs`, `collect_ignore` (in
|
|
1063
|
+
`pytest.ini`, `pyproject.toml`, `setup.cfg`, `tox.ini`, `conftest.py`),
|
|
1064
|
+
and jest's `testPathIgnorePatterns`, `testMatch`, `testRegex`;
|
|
1065
|
+
- a bare `return` added as the first statement of an existing Python test;
|
|
1066
|
+
- an assert line removed from a diff hunk that adds no assert.
|
|
1067
|
+
|
|
1068
|
+
An assert line removed from a hunk that also adds one is an edit. The scan
|
|
1069
|
+
cannot tell a corrected expectation from a loosened one, so it is shown as a
|
|
1070
|
+
warning on the PR and does not block. Lines are matched by text, so a test or
|
|
1071
|
+
an assert moved to another file is not a hit. Any blocker makes the run
|
|
1072
|
+
`failed`, unless the plan's acceptance criteria say the change is intended
|
|
1073
|
+
with an item `Test removal intended: <why>`. Python and JS/TS are read; a
|
|
1074
|
+
test file in another language makes the scan report `not checked`, never
|
|
1075
|
+
`clean`.
|
|
1076
|
+
|
|
1077
|
+
If no test can show the change, the plan's acceptance criteria can say `No
|
|
1078
|
+
test possible: <why>`. The run is still `not proven` and the PR stays a
|
|
1079
|
+
draft for a person to decide; the PR comment says why.
|
|
1080
|
+
|
|
1081
|
+
**What the PR shows.** One comment, edited in place on a later run: the
|
|
1082
|
+
outcome and why, the proving test, the command, the fail-before and
|
|
1083
|
+
pass-after results with a short redacted tail of each, and any scan hits.
|
|
1084
|
+
A run that is not proven fails like any required step after the PR exists
|
|
1085
|
+
(see [below](#reposteps)): the PR stays a draft, and chat gets `❌ Failed on
|
|
1086
|
+
#N: PR #M held as a draft: not proven …`. A PR GitHub would not open as a
|
|
1087
|
+
draft gets a comment telling readers not to merge it.
|
|
1088
|
+
|
|
1089
|
+
With `require_proof` on, the PR is opened as a draft, as with
|
|
1090
|
+
`steps.review: required`. When both are on, the review step marks it ready
|
|
1091
|
+
after the proof passed and the review posted. Right before it is marked
|
|
1092
|
+
ready, the relay reads the PR's head from GitHub: if it is not the commit the
|
|
1093
|
+
proof ran on, or cannot be read, the run is `not proven` and the PR stays a
|
|
1094
|
+
draft.
|
|
1095
|
+
|
|
1096
|
+
Turn it on only for a repo whose tests can run offline in a fresh copy of
|
|
1097
|
+
the commit. With it off (the default) the agent is not asked for a proof,
|
|
1098
|
+
the PR is opened as before, and no proof comment is posted.
|
|
1099
|
+
|
|
925
1100
|
### repos[].steps
|
|
926
1101
|
|
|
927
1102
|
The dev pipeline does its work in a fixed order: **plan** (an optional
|
|
928
1103
|
planning session, off unless asked for), **code** (the agent run and
|
|
929
1104
|
any questions it asks), **publish** (the relay pushes the branch and opens the
|
|
930
1105
|
pull request), **verify** (wait for CI and mergeability, asking the agent to
|
|
931
|
-
fix what fails, up to `max_fix_attempts` rounds)
|
|
1106
|
+
fix what fails, up to `max_fix_attempts` rounds), **proof** (the relay runs
|
|
1107
|
+
the agent's proving test before and after; switched by
|
|
1108
|
+
[`automation.require_proof`](#reposautomationrequire_proof), not here) and
|
|
1109
|
+
**review** (the
|
|
932
1110
|
configured reviewer reads the pushed commit and posts its findings). The order
|
|
933
1111
|
is not configurable. `steps` says how strict each step is for this repo.
|
|
934
1112
|
Only the dev pipeline reads it. The task and secops pipelines have no
|
|
@@ -976,7 +1154,10 @@ that can each be checked by a command or by reading the diff. The relay then:
|
|
|
976
1154
|
outside the worktree, so it is never committed;
|
|
977
1155
|
- posts the criteria list as one comment on the issue;
|
|
978
1156
|
- adds the plan to the code session's prompt under `## Plan`, telling the
|
|
979
|
-
agent the criteria are what "done" means
|
|
1157
|
+
agent the criteria are what "done" means;
|
|
1158
|
+
- reads two items from the criteria for the proof step: `Test removal
|
|
1159
|
+
intended: <why>` and `No test possible: <why>` (see
|
|
1160
|
+
[require_proof](#reposautomationrequire_proof)).
|
|
980
1161
|
|
|
981
1162
|
If the issue is too vague to plan, the planning session asks one question,
|
|
982
1163
|
through the same chat loop the code session uses. No code session runs and no
|
|
@@ -1047,7 +1228,7 @@ exists.** The ordinary failure path, unchanged:
|
|
|
1047
1228
|
- chat gets `❌ Failed on #N: …`, never `✅ PR ready`.
|
|
1048
1229
|
|
|
1049
1230
|
A resumed session (an answer that arrived after the run stopped to ask) goes
|
|
1050
|
-
through the same publish and
|
|
1231
|
+
through the same publish, verify and proof steps. It did not run the review before
|
|
1051
1232
|
these switches, and with `review` unset or `on` it still does not; with
|
|
1052
1233
|
`review: required` it does.
|
|
1053
1234
|
|
|
@@ -1101,8 +1282,10 @@ schedules:
|
|
|
1101
1282
|
Once a week, each node sends one chat message with its own numbers for
|
|
1102
1283
|
the last 7 days, the same ones its status page shows: runs started, done,
|
|
1103
1284
|
failed, blocked and stuck, the success rate, the median time to PR and to
|
|
1104
|
-
merge,
|
|
1105
|
-
|
|
1285
|
+
merge, how many issues the relay finished were reopened, and the median
|
|
1286
|
+
tokens per run (with the cost, when the agent reported one) over the runs
|
|
1287
|
+
that ended with their usage recorded. A number the node cannot read is
|
|
1288
|
+
written `unknown`, with the reason, never 0.
|
|
1106
1289
|
|
|
1107
1290
|
The daemon checks every five minutes. It sends at the first check after
|
|
1108
1291
|
`day` at `hour`:00 that finds nothing delivered since then, so a node that
|
|
@@ -304,6 +304,18 @@ Added in #247:
|
|
|
304
304
|
|---|---|
|
|
305
305
|
| `node` | This machine's own block: `name` (`node_id`); `usage`, the agent subscription's usage as the last run or probe read it: `status`, `read_at`, and for `five_hour` and `seven_day` each `{used (0 to 1), resets_at}`, or `"unknown"` for a window it could not read; the whole `usage` is `"unknown"` before any reading. `pause_at` is `usage.pause_at`, and `paused` is `{reason, until}` while the machine takes no new issues, else `null`. The page shows it in the **Usage** box. `node` is `null`, with a line in `errors`, only if reading it failed outright. |
|
|
306
306
|
|
|
307
|
+
Added in #223, on every `running` card (see [Run budgets](#run-budgets)):
|
|
308
|
+
|
|
309
|
+
| Field | What |
|
|
310
|
+
|---|---|
|
|
311
|
+
| `tokens_used` | Tokens the session has used so far, over all its spawns: input + cache writes + output, what the budget counts. `null` before anything was measured, on a backend that is not measured, or when a transcript row could not be read (then `reason` gives the number of rows and the floor). Never `0` for unknown. |
|
|
312
|
+
| `cache_read_tokens` | Cache reads, recorded but not counted. `null` as for `tokens_used`. |
|
|
313
|
+
| `cost_usd` | The sum of `total_cost_usd` the agent CLI reported at the end of each spawn. Information only. `null` until a spawn reported one. |
|
|
314
|
+
| `token_soft_limit`, `token_hard_limit` | The limits applied to this run. `null` when no limit applies or nothing was measured yet. |
|
|
315
|
+
|
|
316
|
+
The page shows them in a `tokens:` row on each running card; the count is in
|
|
317
|
+
the warning colour once it passes the soft limit.
|
|
318
|
+
|
|
307
319
|
Added in #237: `node.github_api` is the account's GitHub API budget as this
|
|
308
320
|
machine last read it: `read_at`, and for `core`, `search` and `graphql` each
|
|
309
321
|
`{remaining, limit, resets_at, state}` with `state` one of `ok`, `low`, `out`,
|
|
@@ -340,6 +352,12 @@ Added in #307:
|
|
|
340
352
|
| `node.config_fingerprint` | `{v, repos_hash, profiles, repos, schedules}`, `v` is `2`. `profiles` maps a profile hash to `{automation, steps}`, each a map of field name to a hash of its value; `repos` maps each repo name to its profile hash, so repos with the same configuration share one profile; `repos_hash` is the hash of the sorted, lowercased repo names; `schedules` maps each `schedules` field to a hash. Hashes and repo names only, never a value. `null` if it could not be built. A peer's fingerprint is unknown unless every hash is 16 lowercase hex characters, `repos_hash` is the hash of its own repo names, each profile key is the hash of that profile, and each repo names a profile it carries. A field's hash cannot be checked against its value, which is never sent. |
|
|
341
353
|
| `fleet.peers[].drift` | `{status, reason, only_here, only_there, fields, lines}`. `status` is `same`, `differs` or `unknown` (with `reason`: not read yet, not reachable, the peer sends no fingerprint in this format, or the comparison failed); `fields` maps a field path such as `automation.exclude_labels` to the repos it differs on; `lines` is what the page shows. Leaves out `fleet.expected_drift`. The peer's own fingerprint is not repeated in `fleet.peers[].node`. |
|
|
342
354
|
|
|
355
|
+
Added in #305:
|
|
356
|
+
|
|
357
|
+
| Field | What |
|
|
358
|
+
|---|---|
|
|
359
|
+
| `node.sandbox` | This machine's `agent.sandbox`: `"none"` (the agent runs with the user's full access) or `"bwrap"`. In `fleet.peers[].node` it is `null` when the peer is on an older version or sent anything else, which means unknown, never off. The page shows `sandbox off` in the warning colour for `"none"`. |
|
|
360
|
+
|
|
343
361
|
Added in #230: `numbers` is how this machine's runs went, for the last 7 and
|
|
344
362
|
the last 30 days (`numbers.windows`, each with `days`, `since`/`since_ts`,
|
|
345
363
|
`until`/`until_ts`). The page shows it in the **Numbers** box, with a table
|
|
@@ -354,7 +372,7 @@ control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
|
|
|
354
372
|
| `time_to_pr_s`, `time_to_merge_s` | `{median, n}`: seconds from the run's start to its first `pr_opened` (or `merged`) event, over the `n` runs that have one. Events stamped after now are not read. `null` when events cannot be read, or when a run in the window started before the oldest event still kept: its PR event may have been deleted. The daemon deletes events older than `web.history_days` (7 by default), so with the default the 30-day times are `null`. **To get them, set `web.history_days` to 30**; they appear once the events kept reach back past every run in the window (up to 30 days after the change, since deleted events do not come back). |
|
|
355
373
|
| `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). |
|
|
356
374
|
| `reopened` | Issues the relay finished (it saw their PR merge and closed them) that were reopened on GitHub in the window, dated by GitHub's `reopened` event. Counted whether or not the relay picks the issue up again; one per finish. The window's `reopened_issues` lists them. The poller notices a reopen only for an issue its search returns (open and assigned to the operator, or wearing a trigger label), with no extra GitHub call unless a finished issue shows up open. Finishes are recorded in the state database from this version on, plus those still in the kept `merged` events when the table is created. `null` on a database the daemon has not migrated yet. A `reopened` event goes to the feed when one is recorded. |
|
|
357
|
-
| `cost_per_run` |
|
|
375
|
+
| `cost_per_run` | What the runs that ended (`done` or `failed`) in the window used, from the token usage the run budget records on each run (#223, see [Run budgets](#run-budgets)): `{tokens, cost_usd, runs_without_usage}`. `tokens` is `{median, total, n}` over the `n` ended runs whose usage was recorded and read in full, counted as the budget counts (input + cache writes + output; cache reads left out). `cost_usd` is `{median, total, n}` in US dollars over those of them whose agent reported a cost (`total_cost_usd`, information only), or `null` when none did. `runs_without_usage` is how many ended runs are left out: no usage recorded (runs from before #223, or a backend that is not measured, such as `opencode`) or usage that could not be read in full. They are never counted as 0. Blocked and running runs are left out, since their count still grows. `null` when no ended run in the window has its usage measured, or on a database the daemon has not migrated yet; the window's `unavailable` says which, and why any other field is `null`. |
|
|
358
376
|
| `stuck` | Runs the relay stopped as stuck (#224), also counted in `failed`. `null` only on a database the daemon has not migrated yet. |
|
|
359
377
|
|
|
360
378
|
`numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
|
|
@@ -712,6 +730,138 @@ A stopped run is recorded as `failed` with a `stuck_reason` in the
|
|
|
712
730
|
the reason, and chat gets `🔁 Stuck on #<n>, stopped: <reason>`. The status
|
|
713
731
|
page's numbers count these runs in `stuck`.
|
|
714
732
|
|
|
733
|
+
## Run budgets
|
|
734
|
+
|
|
735
|
+
Each run can have a token budget (#223), set in
|
|
736
|
+
[`agent.budget`]({{ '/configuration/' | relative_url }}#agentbudget). There is no
|
|
737
|
+
limit until one is set.
|
|
738
|
+
|
|
739
|
+
- **What is counted.** Every 30 seconds ctrlrelay adds up the usage in the
|
|
740
|
+
agent's live transcript, sub-agents included: input tokens, cache writes
|
|
741
|
+
and output tokens. Cache reads are recorded but not counted. One API
|
|
742
|
+
message written as several rows is counted once.
|
|
743
|
+
- **Per session, not per spawn.** A dev or task run spawns the agent more
|
|
744
|
+
than once (the first run, resumes after a question, CI fix rounds). They
|
|
745
|
+
all count against the same budget, because each spawn starts from what the
|
|
746
|
+
session row already holds.
|
|
747
|
+
- **Soft limit.** The first time the total passes it, chat gets one line
|
|
748
|
+
(`💰 #<n> (<repo>) has used <N> tokens, past its soft limit ...`) and the
|
|
749
|
+
log gets `budget.soft_limit`. Once per session.
|
|
750
|
+
- **Hard limit.** The agent is stopped through the same path as a stuck run.
|
|
751
|
+
The run is recorded as `failed` with `stuck_reason` set to
|
|
752
|
+
`budget: used <N> tokens, hard limit <M>`, its summary starts with
|
|
753
|
+
`Stopped:`, the issue comment gives the reason, and chat gets
|
|
754
|
+
`💸 Over budget on #<n>, stopped: <reason>`. A checkpoint the agent wrote
|
|
755
|
+
just before the stop is not used: the work is not handed over as ready.
|
|
756
|
+
When no pull request exists yet, the unfinished work is not saved to a
|
|
757
|
+
branch (only a timeout does that). The status page's numbers count the
|
|
758
|
+
run in `failed`, not in `stuck`.
|
|
759
|
+
- **A stop after the PR was opened** (over budget in a CI fix round, or
|
|
760
|
+
stuck there): the PR is put back to draft and kept with the work so far,
|
|
761
|
+
no merge watcher is started for it, and chat and the issue comment say
|
|
762
|
+
`PR #<n> was kept as a draft with the work so far.` If GitHub refuses the
|
|
763
|
+
draft, a "Do not merge" comment goes on the PR instead, and the messages
|
|
764
|
+
say so.
|
|
765
|
+
- **A floor, not an exact bill.** Only API calls that write an assistant
|
|
766
|
+
row to the transcript are counted; calls the CLI makes on its own without
|
|
767
|
+
one (for example, short helper calls) are not. The count can be a little
|
|
768
|
+
under what the subscription was charged.
|
|
769
|
+
- **Unknown is not zero.** A transcript row whose usage cannot be read adds
|
|
770
|
+
nothing and is counted as unreadable; the log gets `budget.usage_unreadable`
|
|
771
|
+
and the page shows the total as unknown. What could be read is still a
|
|
772
|
+
true floor, so a run is stopped only when that floor reaches the hard limit.
|
|
773
|
+
An unreadable row on its own never stops a run.
|
|
774
|
+
- **What is stored.** `sessions.usage_tokens` is the counted total (a floor
|
|
775
|
+
when rows were unreadable) and `sessions.usage_detail` holds the breakdown,
|
|
776
|
+
the unreadable row count, the cost and the limits applied, as JSON.
|
|
777
|
+
`cost_usd` is what the agent CLI's final `result` event reported
|
|
778
|
+
(`total_cost_usd`), summed over the session's spawns; it is information
|
|
779
|
+
only.
|
|
780
|
+
- **Backends.** Only the `claude` backend writes a transcript ctrlrelay can
|
|
781
|
+
read. A run on `opencode` is not measured; with a limit set, the log gets
|
|
782
|
+
`dispatcher.budget.unmeasured` and nothing is enforced.
|
|
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
|
+
|
|
715
865
|
## When to restart
|
|
716
866
|
|
|
717
867
|
| You changed... | Restart... |
|