ctrlrelay 0.28.0__tar.gz → 0.28.1__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.28.0 → ctrlrelay-0.28.1}/CHANGELOG.md +47 -1
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/PKG-INFO +1 -1
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/configuration.md +1 -1
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/feedback-loop.md +34 -8
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/pyproject.toml +1 -1
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_check.py +12 -11
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/checkpoint.py +61 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/github.py +12 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/issue_questions.py +59 -10
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/question_expiry.py +76 -13
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/worktree.py +79 -1
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/dev.py +17 -11
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/secops.py +19 -13
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/task.py +15 -11
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_check.py +3 -3
- ctrlrelay-0.28.1/tests/test_checkpoint_snippet.py +176 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_issue_questions.py +59 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_question_expiry.py +171 -0
- ctrlrelay-0.28.1/tests/test_relay_git_credential.py +218 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_resume_prompt_contract.py +6 -4
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.gitignore +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/LICENSE +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/README.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/SECURITY.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/cli.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/control-plane-brain.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/development.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/index.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/llms.txt +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/operations.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_plugins.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_presets.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/blockers.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/budget.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/generic_cli.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/intake_hold.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/orphans.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/proof.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/reopens.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/self_update.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/slots.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/weekly_summary.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/drift.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_plugins.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_presets.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_process_group.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_allowed_accounts.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_archived.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_budget.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_config.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_stuck.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_events.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_generic_cli_adapter.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_generic_cli_usage.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_intake_hold.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_intake_hold_poller.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_issue_blockers.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_max_parallel.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_network.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_no_change_run.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller_priority_order.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_proof.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_provider_credentials.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_redact.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_reopens.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_review_gate.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_run_resume.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_sandbox_visibility.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_secops_operator_prs.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_self_update.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_transport.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_usage.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_drift.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_weekly_summary.py +0 -0
- {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.28.1] - 2026-11-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The relay's own clone, fetch and `ls-remote` now use gh's login on a node
|
|
15
|
+
where git has no credential for github.com (#398). The first dev run on
|
|
16
|
+
such a node failed before the agent started with `could not read
|
|
17
|
+
Username for 'https://github.com'`. `gh auth git-credential` is appended
|
|
18
|
+
as a helper for `https://github.com` through `GIT_CONFIG_COUNT`, after
|
|
19
|
+
any entries already in the environment, so a unit's `insteadOf` rewrite
|
|
20
|
+
keeps working and an operator's own helper is still asked first. These
|
|
21
|
+
calls also set `GIT_TERMINAL_PROMPT=0`, so a missing credential fails at
|
|
22
|
+
once. Pushes already used gh's login; the agent's environment is
|
|
23
|
+
unchanged.
|
|
24
|
+
- The checkpoint snippets in the dev, task and secops prompts, and in
|
|
25
|
+
`ctrlrelay agent check --resume`, no longer give an agent anything to
|
|
26
|
+
re-quote (#397). The BLOCKED and FAILED snippets (and secops' DONE) were a
|
|
27
|
+
`printf` built from single-quoted pieces joined by backslash
|
|
28
|
+
continuations. Agents sometimes rewrote the quoting or dropped a
|
|
29
|
+
continuation, and the round ended with a malformed or missing checkpoint
|
|
30
|
+
after the work was done. They now write the file with `python3` from a
|
|
31
|
+
quoted heredoc, the shape the DONE snippets already used. The text goes on
|
|
32
|
+
its own lines in a raw string and `json.dump` escapes it, so a question or
|
|
33
|
+
error may hold double quotes, newlines and backslashes; the old `%s`
|
|
34
|
+
pasted those into the JSON unescaped. One function renders the snippet for
|
|
35
|
+
every pipeline and for `agent check`, so the check runs exactly the shell
|
|
36
|
+
a real run uses.
|
|
37
|
+
- A run paused on a question is no longer abandoned half an hour later
|
|
38
|
+
while its issue is still open (#400). The hourly sweep that retires a
|
|
39
|
+
question once everything it cites is closed read `oaf-kernel#118` as
|
|
40
|
+
this repository's `#118`, found an unrelated issue closed days before,
|
|
41
|
+
and retired a live question 33 minutes after it was asked. A reference
|
|
42
|
+
written `owner/repo#N`, or linked, is now looked up in that repository;
|
|
43
|
+
one whose repository cannot be told (`name#N`, `PR#N`) keeps the
|
|
44
|
+
question; and the issue the question was asked on must be closed too.
|
|
45
|
+
Closing that issue now retires its question even when the question
|
|
46
|
+
cites nothing else. The 48-hour
|
|
47
|
+
`question_ttl_seconds` was never the cause.
|
|
48
|
+
- A question asked on an issue that expires unanswered now says on the
|
|
49
|
+
issue that the run has stopped and how to go on, and labels the issue
|
|
50
|
+
`ctrlrelay:unanswered` so it can be found. Adding it creates the label
|
|
51
|
+
in the repository if it is missing; nothing removes it. If the label
|
|
52
|
+
cannot be added, the comment still posts and does not mention it. A question retired
|
|
53
|
+
because its issue was closed is not labelled.
|
|
54
|
+
|
|
10
55
|
## [0.28.0] - 2026-11-11
|
|
11
56
|
|
|
12
57
|
### Added
|
|
@@ -2819,7 +2864,8 @@ pipeline).
|
|
|
2819
2864
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2820
2865
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2821
2866
|
|
|
2822
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.28.
|
|
2867
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.28.1...HEAD
|
|
2868
|
+
[0.28.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.28.0...v0.28.1
|
|
2823
2869
|
[0.28.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.27.0...v0.28.0
|
|
2824
2870
|
[0.27.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.26.0...v0.27.0
|
|
2825
2871
|
[0.26.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.25.0...v0.26.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.28.
|
|
3
|
+
Version: 0.28.1
|
|
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/
|
|
@@ -958,7 +958,7 @@ reach anybody.
|
|
|
958
958
|
| `chat_id` | int | `0` | Telegram chat ID the bridge sends messages to and accepts replies from. |
|
|
959
959
|
| `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
|
|
960
960
|
| `ask_timeout_seconds` | int | `900` | How long a pipeline blocks waiting on your reply in-session. Minimum `60`. A reply arriving later still works: the question is persisted to `pending_resumes` and the every-minute sweeper drives the resume. |
|
|
961
|
-
| `question_ttl_seconds` | int | `172800` (48h) | How long an **unanswered** question stays routable before it is retired. Minimum `3600`. Without a ceiling those rows never die — a question whose PR was merged by hand weeks ago stays a live target for an orphan reply. The hourly `question_expiry_sweeper` also retires a question early once every issue/PR it cites is closed or merged. |
|
|
961
|
+
| `question_ttl_seconds` | int | `172800` (48h) | How long an **unanswered** question stays routable before it is retired. Minimum `3600`. Without a ceiling those rows never die — a question whose PR was merged by hand weeks ago stays a live target for an orphan reply. The hourly `question_expiry_sweeper` also retires a question early once every issue/PR it cites is closed or merged, and the issue it was asked on is closed too. A question asked on an issue that expires on age labels the issue `ctrlrelay:unanswered`, when the label can be added. |
|
|
962
962
|
| `question_retention_days` | int | `90` | How long a **finished** question (expired, or answered and resumed) is kept before the hourly sweeper deletes it. Minimum `1`. Open questions are never deleted. The kept history is what lets a re-asked question be marked as a repeat. |
|
|
963
963
|
|
|
964
964
|
See [Chat bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
|
|
@@ -182,6 +182,16 @@ Step-by-step:
|
|
|
182
182
|
that arrives later still lands on the session's `pending_resumes` row and
|
|
183
183
|
the scheduled sweeper drives the resume. An unanswered question stays
|
|
184
184
|
routable for `question_ttl_seconds` (default 48 hours), then expires.
|
|
185
|
+
When it was asked on an issue, the expiry is said there and the issue is
|
|
186
|
+
labelled `ctrlrelay:unanswered` (when the label can be added): the run has stopped, and someone has to
|
|
187
|
+
decide the question and start the issue again. Nothing removes the label.
|
|
188
|
+
- **A question is retired early only when everything it rests on is
|
|
189
|
+
closed:** each `#N` it cites (in the question's repository), each
|
|
190
|
+
`owner/repo#N` or link to an issue or pull request (in that
|
|
191
|
+
repository), and the issue it was asked on. A question citing a
|
|
192
|
+
reference whose repository cannot be told, such as `name#N` or
|
|
193
|
+
`PR#N`, is never retired early: it stays open until it is answered or
|
|
194
|
+
its TTL ends.
|
|
185
195
|
- **Maximum 5 BLOCKED rounds per session.** This bounds the chat noise from a
|
|
186
196
|
pathological loop. After 5 rounds the pipeline gives up.
|
|
187
197
|
- **State is durable.** The checkpoint file and the SQLite state DB persist
|
|
@@ -193,17 +203,33 @@ Step-by-step:
|
|
|
193
203
|
## Implementing your own BLOCKED in an agent session
|
|
194
204
|
|
|
195
205
|
If you write a custom prompt or skill that runs under ctrlrelay, signal a
|
|
196
|
-
question from inside the agent session
|
|
206
|
+
question from inside the agent session by writing the checkpoint with
|
|
207
|
+
`python3`, so the question is JSON-escaped for you and the shell has
|
|
208
|
+
nothing to re-quote:
|
|
197
209
|
|
|
198
210
|
```bash
|
|
199
211
|
# Inside an agent session — both vars are set by the dispatcher.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
212
|
+
python3 - "$CTRLRELAY_STATE_FILE" <<'CTRLRELAY_STATE'
|
|
213
|
+
import datetime, json, os, sys
|
|
214
|
+
text = r'''
|
|
215
|
+
Should I migrate the schema in this PR or split it?
|
|
216
|
+
'''.strip()
|
|
217
|
+
state = {
|
|
218
|
+
"version": "1",
|
|
219
|
+
"status": "BLOCKED_NEEDS_INPUT",
|
|
220
|
+
"session_id": os.environ["CTRLRELAY_SESSION_ID"],
|
|
221
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
222
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
223
|
+
),
|
|
224
|
+
"question": text,
|
|
225
|
+
}
|
|
226
|
+
with open(sys.argv[1], "w") as f:
|
|
227
|
+
json.dump(state, f)
|
|
228
|
+
CTRLRELAY_STATE
|
|
204
229
|
exit 0
|
|
205
230
|
```
|
|
206
231
|
|
|
207
|
-
The
|
|
208
|
-
|
|
209
|
-
|
|
232
|
+
The question may span lines and hold any quotes, but not three single
|
|
233
|
+
quotes in a row. The pipelines' prompts build the same shape with
|
|
234
|
+
`shell_checkpoint` in
|
|
235
|
+
[`src/ctrlrelay/core/checkpoint.py`](https://github.com/AInvirion/ctrlrelay/blob/main/src/ctrlrelay/core/checkpoint.py).
|
|
@@ -51,6 +51,7 @@ from ctrlrelay.core.checkpoint import (
|
|
|
51
51
|
CheckpointState,
|
|
52
52
|
CheckpointStatus,
|
|
53
53
|
read_checkpoint,
|
|
54
|
+
shell_checkpoint,
|
|
54
55
|
)
|
|
55
56
|
from ctrlrelay.core.dispatcher import (
|
|
56
57
|
SessionResult,
|
|
@@ -162,12 +163,13 @@ def resume_round1_prompt(
|
|
|
162
163
|
) -> str:
|
|
163
164
|
"""Round 1: remember ``token``, then write ``BLOCKED_NEEDS_INPUT``.
|
|
164
165
|
|
|
165
|
-
The
|
|
166
|
-
:func:`ctrlrelay.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
dev contract's
|
|
166
|
+
The snippet comes from
|
|
167
|
+
:func:`ctrlrelay.core.checkpoint.shell_checkpoint`, the same function
|
|
168
|
+
the pipelines' BLOCKED and FAILED blocks use (#397), so the check
|
|
169
|
+
proves the exact shell a real run depends on — the quoted path, the
|
|
170
|
+
quoted heredoc, ``json.dump`` — and cannot drift from it. Only the
|
|
171
|
+
text differs: the dev contract's blocks carry a placeholder a check
|
|
172
|
+
must not leave in.
|
|
171
173
|
|
|
172
174
|
The question is fixed rather than a ``<QUESTION>`` placeholder because
|
|
173
175
|
the relay only has to see that a question arrived; what it says is the
|
|
@@ -182,11 +184,10 @@ SESSION_ID: {session_id}
|
|
|
182
184
|
TOKEN: {token}
|
|
183
185
|
|
|
184
186
|
```bash
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > {quoted_path}
|
|
187
|
+
{shell_checkpoint(
|
|
188
|
+
quoted_dir, quoted_path, session_id=session_id,
|
|
189
|
+
status=ROUND1_EXPECTED, field="question", text=RESUME_QUESTION,
|
|
190
|
+
)}
|
|
190
191
|
```
|
|
191
192
|
|
|
192
193
|
Do nothing else. There is no repository to read and nothing to change.
|
|
@@ -125,6 +125,67 @@ def failed(error: str, recoverable: bool = True) -> None:
|
|
|
125
125
|
# A checkpoint is a few hundred bytes; a DONE with a long pull-request
|
|
126
126
|
# description is a few kilobytes. Anything past this is not a checkpoint,
|
|
127
127
|
# and reading it whole is how an agent would exhaust the relay's memory.
|
|
128
|
+
# What an agent is told about free text it puts in a checkpoint written
|
|
129
|
+
# by :func:`shell_checkpoint`. The text sits on its own lines inside a
|
|
130
|
+
# Python raw triple-quoted string in a quoted heredoc, so quotes,
|
|
131
|
+
# backslashes, `$`, backticks and newlines all arrive as written (a
|
|
132
|
+
# carriage return arrives as a newline). Two things end it early: three
|
|
133
|
+
# single quotes in a row close the string, and a line that is exactly the
|
|
134
|
+
# heredoc's delimiter closes the heredoc.
|
|
135
|
+
CHECKPOINT_TEXT_RULE = (
|
|
136
|
+
"Put your text on the line between the triple quotes. It may span "
|
|
137
|
+
"lines and contain any quotes, but not three single quotes in a row "
|
|
138
|
+
"and no line that is exactly CTRLRELAY_STATE."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def shell_checkpoint(
|
|
143
|
+
quoted_dir: str,
|
|
144
|
+
quoted_path: str,
|
|
145
|
+
*,
|
|
146
|
+
session_id: str,
|
|
147
|
+
status: str,
|
|
148
|
+
field: str,
|
|
149
|
+
text: str,
|
|
150
|
+
) -> str:
|
|
151
|
+
"""The shell an agent runs to write a one-text-field checkpoint (#397).
|
|
152
|
+
|
|
153
|
+
``quoted_dir`` and ``quoted_path`` must already be shell-quoted
|
|
154
|
+
(#191). ``text`` is a placeholder such as ``<QUESTION>`` for the agent
|
|
155
|
+
to replace, or fixed text when the caller knows it.
|
|
156
|
+
|
|
157
|
+
The earlier snippet was a ``printf`` whose format string was several
|
|
158
|
+
single-quoted pieces joined by backslash continuations. Agents
|
|
159
|
+
re-quoted it (``'…''…'``) or dropped a continuation, and the round
|
|
160
|
+
ended with no checkpoint after the work was done. It also could not
|
|
161
|
+
carry a question holding a double quote or a newline: ``%s`` pasted it
|
|
162
|
+
into the JSON unescaped. Here the shell sees one plain command and a
|
|
163
|
+
heredoc whose delimiter is quoted, so nothing is expanded and nothing
|
|
164
|
+
needs re-quoting, and ``json.dump`` does the escaping.
|
|
165
|
+
|
|
166
|
+
The text goes on its own lines and is stripped, so a backslash at its
|
|
167
|
+
end cannot escape the closing quotes.
|
|
168
|
+
"""
|
|
169
|
+
return f"""mkdir -p -- {quoted_dir}
|
|
170
|
+
python3 - {quoted_path} <<'CTRLRELAY_STATE'
|
|
171
|
+
import datetime, json, sys
|
|
172
|
+
text = r'''
|
|
173
|
+
{text}
|
|
174
|
+
'''.strip()
|
|
175
|
+
state = {{
|
|
176
|
+
"version": "1",
|
|
177
|
+
"status": "{status}",
|
|
178
|
+
"session_id": "{session_id}",
|
|
179
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
180
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
181
|
+
),
|
|
182
|
+
"{field}": text,
|
|
183
|
+
}}
|
|
184
|
+
with open(sys.argv[1], "w") as f:
|
|
185
|
+
json.dump(state, f)
|
|
186
|
+
CTRLRELAY_STATE"""
|
|
187
|
+
|
|
188
|
+
|
|
128
189
|
MAX_CHECKPOINT_BYTES = 256 * 1024
|
|
129
190
|
|
|
130
191
|
|
|
@@ -894,6 +894,18 @@ class GitHubCLI:
|
|
|
894
894
|
f"posting a comment returned no id: {output.strip()[:200]!r}"
|
|
895
895
|
) from None
|
|
896
896
|
|
|
897
|
+
async def add_issue_label(
|
|
898
|
+
self, repo: str, issue_number: int, label: str,
|
|
899
|
+
) -> None:
|
|
900
|
+
"""Add one label to an issue or PR. GitHub creates a label the
|
|
901
|
+
repository does not have yet."""
|
|
902
|
+
await self._run_gh(
|
|
903
|
+
"api", "-X", "POST",
|
|
904
|
+
f"repos/{repo}/issues/{issue_number}/labels",
|
|
905
|
+
"-f", f"labels[]={label}",
|
|
906
|
+
"--jq", ".[].name",
|
|
907
|
+
)
|
|
908
|
+
|
|
897
909
|
async def edit_issue_comment(
|
|
898
910
|
self, repo: str, comment_id: int, body: str,
|
|
899
911
|
) -> None:
|
|
@@ -98,13 +98,42 @@ def chat_answer_comment(session_id: str, answer: str, full: bool) -> str:
|
|
|
98
98
|
return f"{body}\n{_marker('answer', session_id)}"
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
# Put on an issue whose question expired unanswered (#400), so a run that
|
|
102
|
+
# stopped for want of an answer is findable work rather than a comment
|
|
103
|
+
# nobody re-reads. Nothing removes it: a person does, once they have
|
|
104
|
+
# decided the question and started the issue again.
|
|
105
|
+
UNANSWERED_LABEL = "ctrlrelay:unanswered"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def expired_comment(
|
|
109
|
+
session_id: str, reason: str | None, *, labelled: bool = False,
|
|
110
|
+
) -> str:
|
|
111
|
+
if reason == "ttl":
|
|
112
|
+
found = (
|
|
113
|
+
f" The issue is labelled `{UNANSWERED_LABEL}` so it can be found."
|
|
114
|
+
if labelled else ""
|
|
115
|
+
)
|
|
116
|
+
body = (
|
|
117
|
+
"**The question above expired unanswered.** This issue's run "
|
|
118
|
+
"has stopped and will not continue on its own, and an answer "
|
|
119
|
+
f"to that question is no longer applied, here or in chat.{found} "
|
|
120
|
+
"To go on, decide the question in a comment, then remove the "
|
|
121
|
+
"issue number from seen_issues in poller_state.json so the "
|
|
122
|
+
"relay runs it again."
|
|
123
|
+
)
|
|
124
|
+
elif reason == "resolved_elsewhere":
|
|
125
|
+
body = (
|
|
126
|
+
"**The question above was retired:** this issue is closed, "
|
|
127
|
+
"along with any issue or pull request the question cites. An "
|
|
128
|
+
"answer to it is no longer applied, here or in chat."
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
why = f" ({reason})" if reason else ""
|
|
132
|
+
body = (
|
|
133
|
+
f"**The question above expired unanswered{why}.** An answer "
|
|
134
|
+
"to it is no longer applied, here or in chat."
|
|
135
|
+
)
|
|
136
|
+
return f"{body}\n{_marker('expired', session_id)}"
|
|
108
137
|
|
|
109
138
|
|
|
110
139
|
def already_answered_comment(session_id: str, login: str) -> str:
|
|
@@ -429,14 +458,33 @@ async def note_chat_answer_on_issue(
|
|
|
429
458
|
|
|
430
459
|
|
|
431
460
|
async def note_expiry_on_issue(github: Any, row: dict[str, Any]) -> bool:
|
|
432
|
-
"""Say on the issue that its question expired
|
|
433
|
-
comment was
|
|
461
|
+
"""Say on the issue that its question expired, and label an issue
|
|
462
|
+
whose question aged out unanswered. Returns whether a comment was
|
|
463
|
+
posted."""
|
|
434
464
|
if row.get("issue_comment_id") is None or row.get("issue_number") is None:
|
|
435
465
|
return False
|
|
466
|
+
labelled = False
|
|
467
|
+
if row.get("expired_reason") == "ttl":
|
|
468
|
+
# Before the comment, so the comment says whether it worked; a
|
|
469
|
+
# label that cannot be added still leaves the comment.
|
|
470
|
+
try:
|
|
471
|
+
await github.add_issue_label(
|
|
472
|
+
row["repo"], int(row["issue_number"]), UNANSWERED_LABEL,
|
|
473
|
+
)
|
|
474
|
+
labelled = True
|
|
475
|
+
except Exception as e:
|
|
476
|
+
log_event(
|
|
477
|
+
_logger, "issue_question.unanswered_label_failed",
|
|
478
|
+
session_id=row["session_id"], repo=row["repo"],
|
|
479
|
+
error_type=type(e).__name__, error=str(e)[:200],
|
|
480
|
+
)
|
|
436
481
|
try:
|
|
437
482
|
await github.post_issue_comment(
|
|
438
483
|
row["repo"], int(row["issue_number"]),
|
|
439
|
-
expired_comment(
|
|
484
|
+
expired_comment(
|
|
485
|
+
row["session_id"], row.get("expired_reason"),
|
|
486
|
+
labelled=labelled,
|
|
487
|
+
),
|
|
440
488
|
)
|
|
441
489
|
except Exception as e:
|
|
442
490
|
log_event(
|
|
@@ -451,6 +499,7 @@ async def note_expiry_on_issue(github: Any, row: dict[str, Any]) -> bool:
|
|
|
451
499
|
__all__ = [
|
|
452
500
|
"QUESTION_ON_ISSUE_MODES",
|
|
453
501
|
"RELAY_MARKER_PREFIX",
|
|
502
|
+
"UNANSWERED_LABEL",
|
|
454
503
|
"ask_on_issue",
|
|
455
504
|
"check_issue_answer",
|
|
456
505
|
"note_chat_answer_on_issue",
|
|
@@ -82,6 +82,70 @@ def extract_referenced_numbers(question: str) -> list[str]:
|
|
|
82
82
|
return out
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
# A ``#N`` and whatever word sits right before it. ``#118`` is in the
|
|
86
|
+
# question's own repository; ``owner/repo#118`` names another one; and
|
|
87
|
+
# ``oaf-kernel#118`` names another one without saying whose, so it cannot
|
|
88
|
+
# be looked up at all. Reading every one of those as ``#118`` here probed
|
|
89
|
+
# an unrelated, long-closed issue of the same number and retired a live
|
|
90
|
+
# question as resolved 33 minutes after it was asked (#400). A ``#12a`` is
|
|
91
|
+
# not a reference.
|
|
92
|
+
# The prefix takes every slash-joined part, so ``group/sub/repo#12`` is
|
|
93
|
+
# read whole (and refused) rather than as ``sub/repo#12``.
|
|
94
|
+
_REF_WITH_PREFIX_RE = re.compile(r"([\w.-]+(?:/[\w.-]+)*)?#(\d+)(?!\w)")
|
|
95
|
+
# A link to an issue or PR names its repository outright. The host must
|
|
96
|
+
# start the link (after a scheme, or standing alone), so neither
|
|
97
|
+
# ``notgithub.com/…`` nor ``example.com/github.com/…`` is read as one.
|
|
98
|
+
_REF_URL_RE = re.compile(
|
|
99
|
+
r"(?:https?://|(?<![\w./-]))(?:www\.)?github\.com/"
|
|
100
|
+
r"([\w.-]+/[\w.-]+)/(?:issues|pull)/"
|
|
101
|
+
r"(\d+)(?!\w)",
|
|
102
|
+
re.IGNORECASE,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolution_targets(row: dict[str, Any]) -> list[tuple[str, int]] | None:
|
|
107
|
+
"""The issues and PRs that must ALL be closed before ``row``'s
|
|
108
|
+
question counts as resolved elsewhere, as ``(repo, number)``.
|
|
109
|
+
|
|
110
|
+
Each ``#N`` the question cites, in its own repository, or in
|
|
111
|
+
``owner/repo`` when written ``owner/repo#N`` or as a link to the
|
|
112
|
+
issue; plus the issue the question was asked on, when it was asked on
|
|
113
|
+
one. A markdown link ``[#12](…/other/thing/issues/12)`` yields both
|
|
114
|
+
targets, so both must be closed. A run paused on an open issue is still waiting for
|
|
115
|
+
its answer whatever else the question mentions, so that issue being
|
|
116
|
+
open keeps the question.
|
|
117
|
+
|
|
118
|
+
None when the question cites something that cannot be looked up
|
|
119
|
+
(``name#N`` with no owner, ``PR#N`` written without a space): that reference can never be shown
|
|
120
|
+
closed, so the question cannot be resolved this way. An empty list
|
|
121
|
+
means there is nothing to check.
|
|
122
|
+
"""
|
|
123
|
+
repo = row["repo"]
|
|
124
|
+
question = row.get("question") or ""
|
|
125
|
+
found: list[tuple[str, int]] = []
|
|
126
|
+
for prefix, number in _REF_WITH_PREFIX_RE.findall(question):
|
|
127
|
+
if not prefix:
|
|
128
|
+
found.append((repo, int(number)))
|
|
129
|
+
elif prefix.count("/") == 1:
|
|
130
|
+
found.append((prefix, int(number)))
|
|
131
|
+
else:
|
|
132
|
+
# ``PR#12`` included: ``PR`` could as well be a repository's
|
|
133
|
+
# name, and guessing wrong retires a live question.
|
|
134
|
+
return None
|
|
135
|
+
found.extend(
|
|
136
|
+
(owner_repo, int(number))
|
|
137
|
+
for owner_repo, number in _REF_URL_RE.findall(question)
|
|
138
|
+
)
|
|
139
|
+
targets: list[tuple[str, int]] = []
|
|
140
|
+
for target in found:
|
|
141
|
+
if target not in targets:
|
|
142
|
+
targets.append(target)
|
|
143
|
+
issue_number = row.get("issue_number")
|
|
144
|
+
if issue_number is not None and (repo, int(issue_number)) not in targets:
|
|
145
|
+
targets.append((repo, int(issue_number)))
|
|
146
|
+
return targets
|
|
147
|
+
|
|
148
|
+
|
|
85
149
|
def _normalise(question: str) -> str:
|
|
86
150
|
return re.sub(r"\s+", " ", (question or "").lower()).strip(" .?!")
|
|
87
151
|
|
|
@@ -358,10 +422,9 @@ def prune_finished_questions(
|
|
|
358
422
|
async def _all_references_resolved(
|
|
359
423
|
github: GitHubCLI,
|
|
360
424
|
*,
|
|
361
|
-
|
|
362
|
-
numbers: list[str],
|
|
425
|
+
targets: list[tuple[str, int]],
|
|
363
426
|
) -> bool:
|
|
364
|
-
"""True when every
|
|
427
|
+
"""True when every target is closed or merged.
|
|
365
428
|
|
|
366
429
|
Conservative on purpose. One still-open reference means the question
|
|
367
430
|
retains something to decide, so the row stays. Any probe error at
|
|
@@ -370,14 +433,14 @@ async def _all_references_resolved(
|
|
|
370
433
|
one, since the operator can ignore noise but cannot recover a
|
|
371
434
|
question that was never posted.
|
|
372
435
|
"""
|
|
373
|
-
if not
|
|
436
|
+
if not targets:
|
|
374
437
|
return False
|
|
375
438
|
|
|
376
|
-
for number in
|
|
439
|
+
for repo, number in targets:
|
|
377
440
|
try:
|
|
378
441
|
state = await github.get_issue_or_pr_state(
|
|
379
442
|
repo,
|
|
380
|
-
|
|
443
|
+
number,
|
|
381
444
|
timeout=_STATE_PROBE_TIMEOUT_SECONDS,
|
|
382
445
|
)
|
|
383
446
|
except (GitHubError, TimeoutError, OSError, ValueError):
|
|
@@ -442,11 +505,13 @@ async def expire_stale_questions(
|
|
|
442
505
|
|
|
443
506
|
probes_used = 0
|
|
444
507
|
for row in state_db.list_unanswered_pending_resumes():
|
|
445
|
-
|
|
446
|
-
# Rows
|
|
447
|
-
# they can never
|
|
448
|
-
|
|
508
|
+
targets = resolution_targets(row)
|
|
509
|
+
# Rows with nothing to check, or citing something that cannot be
|
|
510
|
+
# looked up, are skipped without spending budget: they can never
|
|
511
|
+
# be resolution-expired. The TTL pass still retires them on age.
|
|
512
|
+
if not targets:
|
|
449
513
|
continue
|
|
514
|
+
numbers = [f"{r}#{n}" for r, n in targets]
|
|
450
515
|
|
|
451
516
|
# Check the budget against what this row will actually COST,
|
|
452
517
|
# before spending any of it. Testing `probes_used` alone lets a
|
|
@@ -477,9 +542,7 @@ async def expire_stale_questions(
|
|
|
477
542
|
)
|
|
478
543
|
break
|
|
479
544
|
probes_used += len(numbers)
|
|
480
|
-
if not await _all_references_resolved(
|
|
481
|
-
github, repo=row["repo"], numbers=numbers
|
|
482
|
-
):
|
|
545
|
+
if not await _all_references_resolved(github, targets=targets):
|
|
483
546
|
continue
|
|
484
547
|
if state_db.expire_pending_resume(
|
|
485
548
|
row["session_id"], EXPIRY_REASON_RESOLVED
|
|
@@ -7,11 +7,12 @@ import contextlib
|
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
9
|
import secrets
|
|
10
|
+
import shlex
|
|
10
11
|
import shutil
|
|
11
12
|
import stat
|
|
12
13
|
import tempfile
|
|
13
14
|
import time
|
|
14
|
-
from collections.abc import Awaitable, Callable, Iterator, Sequence
|
|
15
|
+
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import TYPE_CHECKING
|
|
@@ -550,6 +551,81 @@ def _relay_git_env(global_config: Path) -> dict[str, str]:
|
|
|
550
551
|
return env
|
|
551
552
|
|
|
552
553
|
|
|
554
|
+
# The git subcommands that talk to origin. The relay runs them on the
|
|
555
|
+
# operator's own config (their helpers, rewrites and proxies apply), so
|
|
556
|
+
# these get only an extra credential helper appended, never a
|
|
557
|
+
# replacement config.
|
|
558
|
+
_NETWORK_SUBCOMMANDS = frozenset({"clone", "fetch", "ls-remote"})
|
|
559
|
+
|
|
560
|
+
_GH_CREDENTIAL_KEY = "credential.https://github.com.helper"
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# Global options whose value is the next argument.
|
|
564
|
+
_GIT_OPTIONS_WITH_VALUE = frozenset({
|
|
565
|
+
"-c", "-C", "--git-dir", "--work-tree", "--namespace",
|
|
566
|
+
"--config-env", "--super-prefix",
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _git_subcommand(args: tuple[str, ...]) -> str | None:
|
|
571
|
+
"""The subcommand in ``git <args>``, skipping global options and the
|
|
572
|
+
values of those that take one as the next argument."""
|
|
573
|
+
skip_next = False
|
|
574
|
+
for arg in args:
|
|
575
|
+
if skip_next:
|
|
576
|
+
skip_next = False
|
|
577
|
+
continue
|
|
578
|
+
if arg in _GIT_OPTIONS_WITH_VALUE:
|
|
579
|
+
skip_next = True
|
|
580
|
+
continue
|
|
581
|
+
if arg.startswith("-"):
|
|
582
|
+
continue
|
|
583
|
+
return arg
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _with_gh_credential(base: Mapping[str, str]) -> dict[str, str]:
|
|
588
|
+
"""``base`` with gh's login appended as a credential helper for
|
|
589
|
+
https://github.com, for the relay's own fetch, clone and ls-remote
|
|
590
|
+
(#398).
|
|
591
|
+
|
|
592
|
+
The relay clones over https. On a node where gh is logged in but
|
|
593
|
+
``gh auth setup-git`` was never run, git has no credential for a
|
|
594
|
+
private repository and the first run failed before the agent
|
|
595
|
+
started. The helper goes in through ``GIT_CONFIG_COUNT``, appended
|
|
596
|
+
after any entries already there (a unit's ``insteadOf`` rewrite
|
|
597
|
+
keeps working). Config from the environment is read after every
|
|
598
|
+
config file, so this helper comes last in git's list: an operator's
|
|
599
|
+
own helper is asked first and wins when it answers. When gh has no
|
|
600
|
+
login and no token, the helper answers nothing and git fails as before.
|
|
601
|
+
|
|
602
|
+
``GIT_TERMINAL_PROMPT=0`` is set in every case, including when no
|
|
603
|
+
helper is added: the relay runs unattended, so a missing credential
|
|
604
|
+
fails at once instead of waiting on a prompt nobody will answer.
|
|
605
|
+
No helper is added when gh cannot be found, when
|
|
606
|
+
``GIT_CONFIG_COUNT`` is set and is not at most six plain digits (left for git to read or
|
|
607
|
+
refuse as it does), or when the slot it would take is already set: an
|
|
608
|
+
entry is never overwritten. An empty count reads as zero.
|
|
609
|
+
"""
|
|
610
|
+
from ctrlrelay.core.github import _find_gh
|
|
611
|
+
|
|
612
|
+
env = dict(base)
|
|
613
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
614
|
+
gh = shutil.which(_find_gh())
|
|
615
|
+
if gh is None:
|
|
616
|
+
return env
|
|
617
|
+
raw = env.get("GIT_CONFIG_COUNT", "").strip()
|
|
618
|
+
if raw and not (raw.isascii() and raw.isdigit() and len(raw) <= 6):
|
|
619
|
+
return env
|
|
620
|
+
count = int(raw) if raw else 0
|
|
621
|
+
if f"GIT_CONFIG_KEY_{count}" in env or f"GIT_CONFIG_VALUE_{count}" in env:
|
|
622
|
+
return env
|
|
623
|
+
env[f"GIT_CONFIG_KEY_{count}"] = _GH_CREDENTIAL_KEY
|
|
624
|
+
env[f"GIT_CONFIG_VALUE_{count}"] = f"!{shlex.quote(gh)} auth git-credential"
|
|
625
|
+
env["GIT_CONFIG_COUNT"] = str(count + 1)
|
|
626
|
+
return env
|
|
627
|
+
|
|
628
|
+
|
|
553
629
|
@dataclass
|
|
554
630
|
class WorktreeManager:
|
|
555
631
|
"""Manages git worktrees for session isolation."""
|
|
@@ -599,6 +675,8 @@ class WorktreeManager:
|
|
|
599
675
|
would run against a tree origin no longer has.
|
|
600
676
|
"""
|
|
601
677
|
cmd = ["git", *args]
|
|
678
|
+
if env is None and _git_subcommand(args) in _NETWORK_SUBCOMMANDS:
|
|
679
|
+
env = _with_gh_credential(os.environ)
|
|
602
680
|
effective_timeout = timeout if timeout is not None else self.timeout
|
|
603
681
|
proc = await asyncio.create_subprocess_exec(
|
|
604
682
|
*cmd,
|
|
@@ -22,7 +22,11 @@ from ctrlrelay.core.agent_plugins import (
|
|
|
22
22
|
agent_identity_from_mapping,
|
|
23
23
|
)
|
|
24
24
|
from ctrlrelay.core.budget import is_budget_reason, label_names, session_scope
|
|
25
|
-
from ctrlrelay.core.checkpoint import
|
|
25
|
+
from ctrlrelay.core.checkpoint import (
|
|
26
|
+
CHECKPOINT_TEXT_RULE,
|
|
27
|
+
CheckpointStatus,
|
|
28
|
+
shell_checkpoint,
|
|
29
|
+
)
|
|
26
30
|
from ctrlrelay.core.claims import (
|
|
27
31
|
CLAIM_MARKER_PREFIX,
|
|
28
32
|
DISPATCH_FAILED_MARKER,
|
|
@@ -943,20 +947,22 @@ def _blocked_and_failed_blocks(
|
|
|
943
947
|
) -> str:
|
|
944
948
|
"""The BLOCKED and FAILED checkpoint snippets, shared by the code and
|
|
945
949
|
plan contracts (#220). Their DONE blocks differ; these do not."""
|
|
946
|
-
|
|
950
|
+
blocked = shell_checkpoint(
|
|
951
|
+
quoted_dir, quoted_path, session_id=session_id,
|
|
952
|
+
status="BLOCKED_NEEDS_INPUT", field="question", text="<QUESTION>",
|
|
953
|
+
)
|
|
954
|
+
failed = shell_checkpoint(
|
|
955
|
+
quoted_dir, quoted_path, session_id=session_id,
|
|
956
|
+
status="FAILED", field="error", text="<ERROR>",
|
|
957
|
+
)
|
|
958
|
+
return f"""**BLOCKED** (need input). {CHECKPOINT_TEXT_RULE}
|
|
947
959
|
```bash
|
|
948
|
-
|
|
949
|
-
printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\
|
|
950
|
-
'"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
|
|
951
|
-
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > {quoted_path}
|
|
960
|
+
{blocked}
|
|
952
961
|
```
|
|
953
962
|
|
|
954
|
-
**FAILED
|
|
963
|
+
**FAILED**. {CHECKPOINT_TEXT_RULE}
|
|
955
964
|
```bash
|
|
956
|
-
|
|
957
|
-
printf '{{"version":"1","status":"FAILED","session_id":"{session_id}",'\
|
|
958
|
-
'"timestamp":"%s","error":"%s"}}' \\
|
|
959
|
-
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > {quoted_path}
|
|
965
|
+
{failed}
|
|
960
966
|
```"""
|
|
961
967
|
|
|
962
968
|
|