ctrlrelay 0.20.0__tar.gz → 0.20.2__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.20.0 → ctrlrelay-0.20.2}/CHANGELOG.md +49 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/PKG-INFO +1 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/pyproject.toml +1 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/cli.py +18 -4
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/github.py +13 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/orphans.py +45 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/poller.py +87 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/state.py +137 -6
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/usage.py +10 -2
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/dev.py +128 -14
- ctrlrelay-0.20.2/src/ctrlrelay/web/health.py +396 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/status.py +16 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_start.py +60 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_integration.py +8 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_plan_step.py +52 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_fleet.py +1 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_poller.py +20 -0
- ctrlrelay-0.20.2/tests/test_poller_recheck.py +240 -0
- ctrlrelay-0.20.2/tests/test_question_survives_restart.py +606 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_usage.py +24 -0
- ctrlrelay-0.20.2/tests/test_web_health.py +624 -0
- ctrlrelay-0.20.0/src/ctrlrelay/web/health.py +0 -167
- ctrlrelay-0.20.0/tests/test_web_health.py +0 -228
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/.gitignore +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/LICENSE +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/README.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/SECURITY.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/Gemfile +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/_config.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/architecture.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/bridge.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/cli.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/configuration.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/development.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/getting-started.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/index.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/llms.txt +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/operations.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/personalization.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/conftest.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_archived.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_audit.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_config.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_events.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_github.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_install.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_network.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_obs.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_redact.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_setup.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_state.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_transport.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.2}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.2] - 2026-11-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A node no longer stops taking work when the agent CLI reports its usage
|
|
15
|
+
status as `allowed_warning`. That status means a usage window has passed
|
|
16
|
+
a warning line, and calls are still served; it was treated as a refusal,
|
|
17
|
+
so nodes at 26% of the 7-day window paused until that window reset, a
|
|
18
|
+
week away. How close a window is to its limit is still decided by the
|
|
19
|
+
pause threshold, and any other status still pauses (#338).
|
|
20
|
+
|
|
21
|
+
## [0.20.1] - 2026-11-04
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- The status page's server panel reads memory and uptime on macOS too. It
|
|
26
|
+
read those two from `/proc`, which macOS does not have, so on a Mac those
|
|
27
|
+
rows were empty and the page printed the read error. On macOS it now
|
|
28
|
+
uses `sysctl` and `vm_stat`. macOS has no `MemAvailable`, so "available"
|
|
29
|
+
there is free plus inactive pages: an estimate, and not the same
|
|
30
|
+
measurement as Linux's (#321).
|
|
31
|
+
|
|
32
|
+
- A run waiting for the operator's answer is no longer lost when the poller
|
|
33
|
+
stops during the wait. The question is now saved, and the session marked
|
|
34
|
+
`blocked`, before the wait starts rather than after it ends (if that
|
|
35
|
+
write fails it is logged and the wait goes ahead as before). If the
|
|
36
|
+
poller stops, a later answer in chat reaches the saved question and
|
|
37
|
+
resumes the same agent session; when the poller starts again it drops the
|
|
38
|
+
repo lock such a run held, if the process that ran it is gone, so the
|
|
39
|
+
repo is not wedged until then. An answer that arrives during the wait
|
|
40
|
+
still resumes the run at once and closes the saved question; if a reply
|
|
41
|
+
already answered the saved question first, that first answer stands and
|
|
42
|
+
the run is resumed once, with it. A planning session's question resumes
|
|
43
|
+
the plan, not the code. The claim on the issue is not marked held while the
|
|
44
|
+
question waits, so after a stop it goes stale as before (#324).
|
|
45
|
+
|
|
46
|
+
- An issue is read again from GitHub just before its run starts. A poll
|
|
47
|
+
returns a batch that is handled one issue at a time, so with one slot the
|
|
48
|
+
last issue could start an hour after the poll saw it, after its trigger
|
|
49
|
+
label was removed or the issue was closed. Now an issue that is closed,
|
|
50
|
+
has gained an `exclude_labels` label, or no longer meets any trigger is
|
|
51
|
+
skipped and left for the next poll to judge; so is one that cannot be read.
|
|
52
|
+
The run is routed on the labels read then, not the poll's. Who added a
|
|
53
|
+
trigger is not asked again: a trigger the operator removed and someone
|
|
54
|
+
else put back in that window still counts (#326).
|
|
55
|
+
|
|
10
56
|
## [0.20.0] - 2026-11-04
|
|
11
57
|
|
|
12
58
|
### Added
|
|
@@ -2313,7 +2359,9 @@ pipeline).
|
|
|
2313
2359
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2314
2360
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2315
2361
|
|
|
2316
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.
|
|
2362
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...HEAD
|
|
2363
|
+
[0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
|
|
2364
|
+
[0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
|
|
2317
2365
|
[0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
|
|
2318
2366
|
[0.19.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...v0.19.1
|
|
2319
2367
|
[0.19.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...v0.19.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.20.
|
|
3
|
+
Version: 0.20.2
|
|
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/
|
|
@@ -3801,7 +3801,8 @@ def poller_start(
|
|
|
3801
3801
|
)
|
|
3802
3802
|
try:
|
|
3803
3803
|
state_db.mark_pending_resume_resumed(
|
|
3804
|
-
session_id
|
|
3804
|
+
session_id, answered_at=row["answered_at"],
|
|
3805
|
+
answer=row["answer"],
|
|
3805
3806
|
)
|
|
3806
3807
|
except Exception:
|
|
3807
3808
|
pass
|
|
@@ -3844,7 +3845,8 @@ def poller_start(
|
|
|
3844
3845
|
)
|
|
3845
3846
|
try:
|
|
3846
3847
|
state_db.mark_pending_resume_resumed(
|
|
3847
|
-
session_id
|
|
3848
|
+
session_id, answered_at=row["answered_at"],
|
|
3849
|
+
answer=row["answer"],
|
|
3848
3850
|
)
|
|
3849
3851
|
except Exception:
|
|
3850
3852
|
pass
|
|
@@ -3862,7 +3864,10 @@ def poller_start(
|
|
|
3862
3864
|
# same broken row. Operator can inspect via the
|
|
3863
3865
|
# sessions table.
|
|
3864
3866
|
try:
|
|
3865
|
-
state_db.mark_pending_resume_resumed(
|
|
3867
|
+
state_db.mark_pending_resume_resumed(
|
|
3868
|
+
session_id, answered_at=row["answered_at"],
|
|
3869
|
+
answer=row["answer"],
|
|
3870
|
+
)
|
|
3866
3871
|
except Exception:
|
|
3867
3872
|
pass
|
|
3868
3873
|
continue
|
|
@@ -3926,7 +3931,10 @@ def poller_start(
|
|
|
3926
3931
|
continue
|
|
3927
3932
|
|
|
3928
3933
|
try:
|
|
3929
|
-
state_db.mark_pending_resume_resumed(
|
|
3934
|
+
state_db.mark_pending_resume_resumed(
|
|
3935
|
+
session_id, answered_at=row["answered_at"],
|
|
3936
|
+
answer=row["answer"],
|
|
3937
|
+
)
|
|
3930
3938
|
except Exception:
|
|
3931
3939
|
pass
|
|
3932
3940
|
|
|
@@ -4224,6 +4232,12 @@ def poller_start(
|
|
|
4224
4232
|
f"[yellow]Run {r['session_id']} for {where} stopped "
|
|
4225
4233
|
f"when the relay did; marked failed.{kept}[/yellow]"
|
|
4226
4234
|
)
|
|
4235
|
+
# A run that stopped while waiting for an answer (#324) is
|
|
4236
|
+
# `blocked` with its question saved, and is resumed when the
|
|
4237
|
+
# answer comes; the repo lock it held would only wedge the
|
|
4238
|
+
# repo until then.
|
|
4239
|
+
from ctrlrelay.core.orphans import release_dead_blocked_locks
|
|
4240
|
+
release_dead_blocked_locks(state_db)
|
|
4227
4241
|
|
|
4228
4242
|
try:
|
|
4229
4243
|
await github_budget.refresh()
|
|
@@ -561,6 +561,19 @@ class GitHubCLI:
|
|
|
561
561
|
)
|
|
562
562
|
return json.loads(output)
|
|
563
563
|
|
|
564
|
+
async def get_issue_gate(self, repo: str, issue_number: int) -> dict[str, Any]:
|
|
565
|
+
"""Return ``{"state", "labels", "assignees"}`` for one issue, read
|
|
566
|
+
live over REST: the fields that decide whether the relay may still
|
|
567
|
+
start it (#326). ``labels`` and ``assignees`` are lists of names.
|
|
568
|
+
"""
|
|
569
|
+
output = await self._run_gh(
|
|
570
|
+
"api", f"repos/{repo}/issues/{issue_number}",
|
|
571
|
+
"--jq",
|
|
572
|
+
"{state: .state, labels: [.labels[].name], "
|
|
573
|
+
"assignees: [.assignees[].login]}",
|
|
574
|
+
)
|
|
575
|
+
return json.loads(output)
|
|
576
|
+
|
|
564
577
|
async def create_pr(
|
|
565
578
|
self,
|
|
566
579
|
repo: str,
|
|
@@ -386,3 +386,48 @@ async def reap_orphaned_sessions(
|
|
|
386
386
|
"handed_back": handed_back,
|
|
387
387
|
})
|
|
388
388
|
return settled
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def release_dead_blocked_locks(
|
|
392
|
+
state_db: Any, *, is_alive: Callable[[int], bool] = owner_is_alive,
|
|
393
|
+
) -> list[str]:
|
|
394
|
+
"""Drop repo locks held by ``blocked`` runs whose process is gone (#324).
|
|
395
|
+
|
|
396
|
+
A run that asks a question is ``blocked`` with its question saved
|
|
397
|
+
while its process waits in memory for the answer, and it keeps its
|
|
398
|
+
repo lock for that wait. If the process stops, the lock only wedges
|
|
399
|
+
the repo until the answer comes, and the resume takes the lock again
|
|
400
|
+
itself. Runs this process started, runs whose process is alive (a
|
|
401
|
+
``ctrlrelay run dev`` waiting in another terminal), and rows that name
|
|
402
|
+
no process are left alone, on the same terms as
|
|
403
|
+
:func:`reap_orphaned_sessions`. Returns the session ids released.
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
rows = state_db.list_blocked_sessions_holding_locks()
|
|
407
|
+
except Exception as e:
|
|
408
|
+
log_event(
|
|
409
|
+
_logger, "orphans.blocked_locks_list_failed",
|
|
410
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
411
|
+
)
|
|
412
|
+
return []
|
|
413
|
+
released: list[str] = []
|
|
414
|
+
for row in rows:
|
|
415
|
+
pid = row.get("owner_pid")
|
|
416
|
+
if row.get("owner_run") == PROCESS_RUN or pid is None:
|
|
417
|
+
continue
|
|
418
|
+
if is_alive(int(pid)):
|
|
419
|
+
continue
|
|
420
|
+
try:
|
|
421
|
+
if state_db.release_blocked_session_lock(row["id"]):
|
|
422
|
+
released.append(row["id"])
|
|
423
|
+
log_event(
|
|
424
|
+
_logger, "orphans.blocked_lock_released",
|
|
425
|
+
session_id=row["id"], repo=row.get("repo"),
|
|
426
|
+
)
|
|
427
|
+
except Exception as e:
|
|
428
|
+
log_event(
|
|
429
|
+
_logger, "orphans.blocked_lock_release_failed",
|
|
430
|
+
session_id=row["id"], reason=type(e).__name__,
|
|
431
|
+
error=str(e)[:200],
|
|
432
|
+
)
|
|
433
|
+
return released
|
|
@@ -954,6 +954,84 @@ class IssuePoller:
|
|
|
954
954
|
return True
|
|
955
955
|
return False
|
|
956
956
|
|
|
957
|
+
async def recheck_before_dispatch(
|
|
958
|
+
self, repo: str, issue: dict[str, Any],
|
|
959
|
+
) -> dict[str, Any] | None:
|
|
960
|
+
"""Read the issue again just before it is handled (#326).
|
|
961
|
+
|
|
962
|
+
``poll()`` returns a batch and the loop handles it one issue at a
|
|
963
|
+
time, so with one slot and long runs the last issue in a batch can
|
|
964
|
+
start an hour after the poll saw it. In that time the operator may
|
|
965
|
+
have removed the trigger label or closed the issue, and the run
|
|
966
|
+
would start anyway.
|
|
967
|
+
|
|
968
|
+
Returns the issue with today's ``state``, ``labels`` and
|
|
969
|
+
``assignees`` when it may still start. Returns ``None`` when it is
|
|
970
|
+
closed, now carries an ``exclude_labels`` label, no longer meets
|
|
971
|
+
any trigger, or could not be read; then it is also taken out of
|
|
972
|
+
``seen_issues``, so the next poll judges it afresh rather than
|
|
973
|
+
never again. Whether the operator was the one who added a label or
|
|
974
|
+
assignment is not asked again: that was settled by ``poll()``.
|
|
975
|
+
"""
|
|
976
|
+
number = int(issue["number"])
|
|
977
|
+
try:
|
|
978
|
+
live = await self.github.get_issue_gate(repo, number)
|
|
979
|
+
state = str(live["state"]).lower()
|
|
980
|
+
labels = [str(n) for n in live["labels"]]
|
|
981
|
+
assignees = [str(n) for n in live["assignees"]]
|
|
982
|
+
except asyncio.CancelledError:
|
|
983
|
+
raise
|
|
984
|
+
except Exception as e:
|
|
985
|
+
self.unmark_seen(repo, number)
|
|
986
|
+
log_event(
|
|
987
|
+
_logger, "poll.issue.recheck_failed", repo=repo,
|
|
988
|
+
issue_number=number, reason=type(e).__name__,
|
|
989
|
+
error=str(e)[:200],
|
|
990
|
+
)
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
fresh = {
|
|
994
|
+
**issue,
|
|
995
|
+
"state": state,
|
|
996
|
+
"labels": [{"name": n} for n in labels],
|
|
997
|
+
"assignees": [{"login": n} for n in assignees],
|
|
998
|
+
}
|
|
999
|
+
reason: str | None = None
|
|
1000
|
+
if state != "open":
|
|
1001
|
+
reason = "closed"
|
|
1002
|
+
else:
|
|
1003
|
+
excluded = self._matched_exclude_label(fresh, {
|
|
1004
|
+
x.lower() for x in self.exclude_labels_by_repo.get(repo, [])
|
|
1005
|
+
})
|
|
1006
|
+
include_lowered = {
|
|
1007
|
+
x.lower() for x in self.include_labels_by_repo.get(repo, [])
|
|
1008
|
+
}
|
|
1009
|
+
require_lowered = {
|
|
1010
|
+
x.lower() for x in self.require_labels_by_repo.get(repo, [])
|
|
1011
|
+
}
|
|
1012
|
+
# Logins compared without case, as the assignee search that
|
|
1013
|
+
# admitted the issue does: a case-only mismatch would skip the
|
|
1014
|
+
# issue on every poll and never start it.
|
|
1015
|
+
me = (self.username or "").lower()
|
|
1016
|
+
assigned = bool(me) and me in {n.lower() for n in assignees} and (
|
|
1017
|
+
not require_lowered
|
|
1018
|
+
or self._matched_require_label(fresh, require_lowered)
|
|
1019
|
+
is not None
|
|
1020
|
+
)
|
|
1021
|
+
labelled = self._matched_include_label(fresh, include_lowered)
|
|
1022
|
+
if excluded is not None:
|
|
1023
|
+
reason = f"excluded_by_label:{excluded}"
|
|
1024
|
+
elif not assigned and labelled is None:
|
|
1025
|
+
reason = "trigger_gone"
|
|
1026
|
+
if reason is not None:
|
|
1027
|
+
self.unmark_seen(repo, number)
|
|
1028
|
+
log_event(
|
|
1029
|
+
_logger, "poll.issue.stale_at_dispatch", repo=repo,
|
|
1030
|
+
issue_number=number, reason=reason,
|
|
1031
|
+
)
|
|
1032
|
+
return None
|
|
1033
|
+
return fresh
|
|
1034
|
+
|
|
957
1035
|
def mark_seen(self, repo: str, issue_number: int) -> None:
|
|
958
1036
|
"""Mark an issue as seen without triggering a poll.
|
|
959
1037
|
|
|
@@ -1310,7 +1388,15 @@ async def run_poll_loop(
|
|
|
1310
1388
|
# would otherwise be silently dropped until daemon restart.
|
|
1311
1389
|
for item in new_issues:
|
|
1312
1390
|
try:
|
|
1313
|
-
|
|
1391
|
+
# The batch can be an hour old by the time an item's turn
|
|
1392
|
+
# comes (#326): read it again, and skip it if it may no
|
|
1393
|
+
# longer start.
|
|
1394
|
+
fresh = await poller.recheck_before_dispatch(
|
|
1395
|
+
item["repo"], item["issue"],
|
|
1396
|
+
)
|
|
1397
|
+
if fresh is None:
|
|
1398
|
+
continue
|
|
1399
|
+
await handler(item["repo"], fresh)
|
|
1314
1400
|
except asyncio.CancelledError:
|
|
1315
1401
|
raise
|
|
1316
1402
|
except Exception as e:
|
|
@@ -1021,6 +1021,117 @@ class StateDB:
|
|
|
1021
1021
|
)
|
|
1022
1022
|
self._conn.commit()
|
|
1023
1023
|
|
|
1024
|
+
def park_blocked_question(
|
|
1025
|
+
self, session_id: str, pipeline: str, repo: str, question: str,
|
|
1026
|
+
) -> None:
|
|
1027
|
+
"""Record a question BEFORE the run waits on the chat answer (#324).
|
|
1028
|
+
|
|
1029
|
+
The session goes ``running`` -> ``blocked`` and its question gets a
|
|
1030
|
+
``pending_resumes`` row, in one commit. While the poller waits in
|
|
1031
|
+
process, this is only a record; if the poller stops during the
|
|
1032
|
+
wait, it is what the bridge routes a late answer to and what the
|
|
1033
|
+
sweeper resumes. Without it, the only record of the question was a
|
|
1034
|
+
coroutine, and a restart lost it with the session row left
|
|
1035
|
+
``running``.
|
|
1036
|
+
"""
|
|
1037
|
+
try:
|
|
1038
|
+
self._conn.execute(
|
|
1039
|
+
"UPDATE sessions SET status = 'blocked' "
|
|
1040
|
+
"WHERE id = ? AND status = 'running'",
|
|
1041
|
+
(session_id,),
|
|
1042
|
+
)
|
|
1043
|
+
# Commits both statements.
|
|
1044
|
+
self.add_pending_resume(
|
|
1045
|
+
session_id=session_id, pipeline=pipeline, repo=repo,
|
|
1046
|
+
question=question,
|
|
1047
|
+
)
|
|
1048
|
+
except BaseException:
|
|
1049
|
+
# Never leave the half-done write open through the wait: it
|
|
1050
|
+
# would hold the database's write lock against the bridge, and
|
|
1051
|
+
# a later commit would keep only half of it.
|
|
1052
|
+
if self._conn.in_transaction:
|
|
1053
|
+
self._conn.rollback()
|
|
1054
|
+
raise
|
|
1055
|
+
|
|
1056
|
+
def take_parked_answer(self, session_id: str, answer: str) -> bool:
|
|
1057
|
+
"""The in-process wait got its answer (#324): mark the parked row
|
|
1058
|
+
answered AND resumed, so neither the bridge nor the sweeper acts on
|
|
1059
|
+
it again, and put the session back to ``running``. One commit.
|
|
1060
|
+
|
|
1061
|
+
Only an unanswered, open row is taken. Returns ``False`` when no
|
|
1062
|
+
such row is there, and changes nothing: the row may have been
|
|
1063
|
+
answered some other way first (a reply the bridge routed to the
|
|
1064
|
+
saved question), expired, or be missing. The caller tells these
|
|
1065
|
+
apart; only the first means another answer stands."""
|
|
1066
|
+
now = int(time.time())
|
|
1067
|
+
ours = not self._conn.in_transaction
|
|
1068
|
+
try:
|
|
1069
|
+
cursor = self._conn.execute(
|
|
1070
|
+
"UPDATE pending_resumes SET answer = ?, answered_at = ?, "
|
|
1071
|
+
"resumed_at = ? WHERE session_id = ? AND answered_at IS NULL "
|
|
1072
|
+
"AND resumed_at IS NULL AND expired_at IS NULL",
|
|
1073
|
+
(answer, now, now, session_id),
|
|
1074
|
+
)
|
|
1075
|
+
if cursor.rowcount > 0:
|
|
1076
|
+
self._conn.execute(
|
|
1077
|
+
"UPDATE sessions SET status = 'running' "
|
|
1078
|
+
"WHERE id = ? AND status = 'blocked'",
|
|
1079
|
+
(session_id,),
|
|
1080
|
+
)
|
|
1081
|
+
if ours:
|
|
1082
|
+
self._conn.commit()
|
|
1083
|
+
except BaseException:
|
|
1084
|
+
if ours and self._conn.in_transaction:
|
|
1085
|
+
self._conn.rollback()
|
|
1086
|
+
raise
|
|
1087
|
+
return cursor.rowcount > 0
|
|
1088
|
+
|
|
1089
|
+
def mark_session_running(self, session_id: str) -> None:
|
|
1090
|
+
"""Put a ``blocked`` session back to ``running`` (#324): its run
|
|
1091
|
+
resumed in process with an answer the saved question did not
|
|
1092
|
+
record (that row expired or is missing)."""
|
|
1093
|
+
self._conn.execute(
|
|
1094
|
+
"UPDATE sessions SET status = 'running' "
|
|
1095
|
+
"WHERE id = ? AND status = 'blocked'",
|
|
1096
|
+
(session_id,),
|
|
1097
|
+
)
|
|
1098
|
+
self._conn.commit()
|
|
1099
|
+
|
|
1100
|
+
def has_open_pending_resume(self, session_id: str, question: str) -> bool:
|
|
1101
|
+
"""Whether this session already has a row for this same question
|
|
1102
|
+
that is neither resumed nor expired. Re-writing such a row would
|
|
1103
|
+
clear an answer already attached to it (#324)."""
|
|
1104
|
+
row = self._conn.execute(
|
|
1105
|
+
"SELECT 1 FROM pending_resumes WHERE session_id = ? "
|
|
1106
|
+
"AND question = ? AND resumed_at IS NULL AND expired_at IS NULL",
|
|
1107
|
+
(session_id, question),
|
|
1108
|
+
).fetchone()
|
|
1109
|
+
return row is not None
|
|
1110
|
+
|
|
1111
|
+
def list_blocked_sessions_holding_locks(self) -> list[dict[str, Any]]:
|
|
1112
|
+
"""``blocked`` sessions that still hold a repo lock, with the
|
|
1113
|
+
process that ran them (#324). A blocked run holds its lock only
|
|
1114
|
+
while that process waits in memory for the answer; see
|
|
1115
|
+
:func:`ctrlrelay.core.orphans.release_dead_blocked_locks`."""
|
|
1116
|
+
rows = self._conn.execute(
|
|
1117
|
+
"SELECT s.id, s.repo, s.owner_pid, s.owner_run "
|
|
1118
|
+
"FROM repo_locks l JOIN sessions s ON s.id = l.session_id "
|
|
1119
|
+
"WHERE s.status = 'blocked'"
|
|
1120
|
+
).fetchall()
|
|
1121
|
+
return [dict(r) for r in rows]
|
|
1122
|
+
|
|
1123
|
+
def release_blocked_session_lock(self, session_id: str) -> bool:
|
|
1124
|
+
"""Drop the lock of a session that is still ``blocked`` (#324).
|
|
1125
|
+
Guarded on the status, so a session resumed in the meantime keeps
|
|
1126
|
+
its lock."""
|
|
1127
|
+
cursor = self._conn.execute(
|
|
1128
|
+
"DELETE FROM repo_locks WHERE session_id = ? AND EXISTS ("
|
|
1129
|
+
"SELECT 1 FROM sessions WHERE id = ? AND status = 'blocked')",
|
|
1130
|
+
(session_id, session_id),
|
|
1131
|
+
)
|
|
1132
|
+
self._conn.commit()
|
|
1133
|
+
return cursor.rowcount > 0
|
|
1134
|
+
|
|
1024
1135
|
def get_oldest_unanswered_pending_resume(
|
|
1025
1136
|
self,
|
|
1026
1137
|
pipeline: str | None = None,
|
|
@@ -1186,7 +1297,12 @@ class StateDB:
|
|
|
1186
1297
|
).fetchall()
|
|
1187
1298
|
return [dict(row) for row in rows]
|
|
1188
1299
|
|
|
1189
|
-
def mark_pending_resume_resumed(
|
|
1300
|
+
def mark_pending_resume_resumed(
|
|
1301
|
+
self,
|
|
1302
|
+
session_id: str,
|
|
1303
|
+
answered_at: int | None = None,
|
|
1304
|
+
answer: str | None = None,
|
|
1305
|
+
) -> bool:
|
|
1190
1306
|
"""Mark a pending resume as executed so the sweeper doesn't pick
|
|
1191
1307
|
it up again.
|
|
1192
1308
|
|
|
@@ -1201,12 +1317,27 @@ class StateDB:
|
|
|
1201
1317
|
wedging the session. The guard makes this a no-op when the
|
|
1202
1318
|
row has been refreshed.
|
|
1203
1319
|
|
|
1320
|
+
``answered_at`` and ``answer``, when given, are those of the row
|
|
1321
|
+
the caller took (#324). Since the question is saved before the
|
|
1322
|
+
wait, a resume that re-blocks can have its NEW question answered
|
|
1323
|
+
(through the bridge) before this call; matching the answer and its
|
|
1324
|
+
time keeps this call from marking that new answer resumed, which
|
|
1325
|
+
would hide it for ever.
|
|
1326
|
+
|
|
1204
1327
|
Returns True iff a row was updated."""
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1328
|
+
if answered_at is None:
|
|
1329
|
+
cursor = self._conn.execute(
|
|
1330
|
+
"UPDATE pending_resumes SET resumed_at = ? "
|
|
1331
|
+
"WHERE session_id = ? AND answered_at IS NOT NULL",
|
|
1332
|
+
(int(time.time()), session_id),
|
|
1333
|
+
)
|
|
1334
|
+
else:
|
|
1335
|
+
cursor = self._conn.execute(
|
|
1336
|
+
"UPDATE pending_resumes SET resumed_at = ? "
|
|
1337
|
+
"WHERE session_id = ? AND answered_at = ? "
|
|
1338
|
+
"AND answer IS ?",
|
|
1339
|
+
(int(time.time()), session_id, answered_at, answer),
|
|
1340
|
+
)
|
|
1210
1341
|
self._conn.commit()
|
|
1211
1342
|
return cursor.rowcount > 0
|
|
1212
1343
|
|
|
@@ -43,6 +43,14 @@ UNKNOWN_RESET_PAUSE_SECONDS = 3600
|
|
|
43
43
|
# recursion limit (RecursionError). Any of them is unreadable, not fatal.
|
|
44
44
|
_UNPARSEABLE = (ValueError, RecursionError)
|
|
45
45
|
ALLOWED = "allowed"
|
|
46
|
+
# Statuses that still let a run through. The agent CLI reports
|
|
47
|
+
# ``allowed_warning`` once a window passes a warning line well short of
|
|
48
|
+
# its limit (seen at 26% of the 7-day window), and the call is served.
|
|
49
|
+
# Pausing on it idled machines for the rest of the 7-day window, so it
|
|
50
|
+
# counts as allowed; how close a window is to its limit is the
|
|
51
|
+
# threshold's question, not the status's. Any other status, including
|
|
52
|
+
# one not seen before, still pauses.
|
|
53
|
+
RUNNABLE_STATUSES = frozenset({ALLOWED, "allowed_warning"})
|
|
46
54
|
|
|
47
55
|
|
|
48
56
|
@dataclass(frozen=True)
|
|
@@ -225,7 +233,7 @@ def pause_for(
|
|
|
225
233
|
) -> Pause | None:
|
|
226
234
|
"""Whether to stop taking new work.
|
|
227
235
|
|
|
228
|
-
Paused when the status is not ``
|
|
236
|
+
Paused when the status is not one of ``RUNNABLE_STATUSES`` or a window is at or above
|
|
229
237
|
``threshold``, until that window's reset (for the status, the event's
|
|
230
238
|
own reset). A reading whose reset time has passed says nothing about
|
|
231
239
|
now, so it does not pause. When the reset time is missing or cannot
|
|
@@ -243,7 +251,7 @@ def pause_for(
|
|
|
243
251
|
# From the reading, or from now if the reading claims to be from the
|
|
244
252
|
# future (a clock set back): an hour, never longer.
|
|
245
253
|
fallback = int(min(reading.read_at, now)) + UNKNOWN_RESET_PAUSE_SECONDS
|
|
246
|
-
if reading.status
|
|
254
|
+
if reading.status not in RUNNABLE_STATUSES:
|
|
247
255
|
until = reading.resets_at if reading.resets_at is not None else fallback
|
|
248
256
|
hits.append((f"usage status is {reading.status}", until))
|
|
249
257
|
for name, w in reading.windows.items():
|