ctrlrelay 0.20.0__tar.gz → 0.20.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.20.0 → ctrlrelay-0.20.1}/CHANGELOG.md +37 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/PKG-INFO +1 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/pyproject.toml +1 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/cli.py +18 -4
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/github.py +13 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/orphans.py +45 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/poller.py +87 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/state.py +137 -6
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/dev.py +128 -14
- ctrlrelay-0.20.1/src/ctrlrelay/web/health.py +396 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/status.py +16 -1
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_start.py +60 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_integration.py +8 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_plan_step.py +52 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_fleet.py +1 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_poller.py +20 -0
- ctrlrelay-0.20.1/tests/test_poller_recheck.py +240 -0
- ctrlrelay-0.20.1/tests/test_question_survives_restart.py +606 -0
- ctrlrelay-0.20.1/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.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/.gitignore +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/LICENSE +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/README.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/SECURITY.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/cli.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/configuration.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/development.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/index.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/llms.txt +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/operations.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_archived.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_config.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_events.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_network.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_redact.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_transport.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_usage.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.20.0 → ctrlrelay-0.20.1}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.1] - 2026-11-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The status page's server panel reads memory and uptime on macOS too. It
|
|
15
|
+
read those two from `/proc`, which macOS does not have, so on a Mac those
|
|
16
|
+
rows were empty and the page printed the read error. On macOS it now
|
|
17
|
+
uses `sysctl` and `vm_stat`. macOS has no `MemAvailable`, so "available"
|
|
18
|
+
there is free plus inactive pages: an estimate, and not the same
|
|
19
|
+
measurement as Linux's (#321).
|
|
20
|
+
|
|
21
|
+
- A run waiting for the operator's answer is no longer lost when the poller
|
|
22
|
+
stops during the wait. The question is now saved, and the session marked
|
|
23
|
+
`blocked`, before the wait starts rather than after it ends (if that
|
|
24
|
+
write fails it is logged and the wait goes ahead as before). If the
|
|
25
|
+
poller stops, a later answer in chat reaches the saved question and
|
|
26
|
+
resumes the same agent session; when the poller starts again it drops the
|
|
27
|
+
repo lock such a run held, if the process that ran it is gone, so the
|
|
28
|
+
repo is not wedged until then. An answer that arrives during the wait
|
|
29
|
+
still resumes the run at once and closes the saved question; if a reply
|
|
30
|
+
already answered the saved question first, that first answer stands and
|
|
31
|
+
the run is resumed once, with it. A planning session's question resumes
|
|
32
|
+
the plan, not the code. The claim on the issue is not marked held while the
|
|
33
|
+
question waits, so after a stop it goes stale as before (#324).
|
|
34
|
+
|
|
35
|
+
- An issue is read again from GitHub just before its run starts. A poll
|
|
36
|
+
returns a batch that is handled one issue at a time, so with one slot the
|
|
37
|
+
last issue could start an hour after the poll saw it, after its trigger
|
|
38
|
+
label was removed or the issue was closed. Now an issue that is closed,
|
|
39
|
+
has gained an `exclude_labels` label, or no longer meets any trigger is
|
|
40
|
+
skipped and left for the next poll to judge; so is one that cannot be read.
|
|
41
|
+
The run is routed on the labels read then, not the poll's. Who added a
|
|
42
|
+
trigger is not asked again: a trigger the operator removed and someone
|
|
43
|
+
else put back in that window still counts (#326).
|
|
44
|
+
|
|
10
45
|
## [0.20.0] - 2026-11-04
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -2313,7 +2348,8 @@ pipeline).
|
|
|
2313
2348
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2314
2349
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2315
2350
|
|
|
2316
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.
|
|
2351
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...HEAD
|
|
2352
|
+
[0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
|
|
2317
2353
|
[0.20.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.1...v0.20.0
|
|
2318
2354
|
[0.19.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...v0.19.1
|
|
2319
2355
|
[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.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/
|
|
@@ -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
|
|
|
@@ -2148,6 +2148,70 @@ def _plan_comment(criteria: str) -> str:
|
|
|
2148
2148
|
)
|
|
2149
2149
|
|
|
2150
2150
|
|
|
2151
|
+
def _park_question(run: _DevRun, question: str) -> bool:
|
|
2152
|
+
"""Save the question before waiting for its answer (#324); see
|
|
2153
|
+
``StateDB.park_blocked_question``. Best-effort: a failed write is
|
|
2154
|
+
logged and the wait goes ahead, as it did before the row existed.
|
|
2155
|
+
Returns whether it was saved."""
|
|
2156
|
+
try:
|
|
2157
|
+
run.state_db.park_blocked_question(
|
|
2158
|
+
run.session_id, "dev", run.repo, question,
|
|
2159
|
+
)
|
|
2160
|
+
return True
|
|
2161
|
+
except Exception as e:
|
|
2162
|
+
log_event(
|
|
2163
|
+
_logger, "dev.question.park_failed", session_id=run.session_id,
|
|
2164
|
+
repo=run.repo, issue_number=run.issue_number,
|
|
2165
|
+
error_type=type(e).__name__, error=str(e)[:200],
|
|
2166
|
+
)
|
|
2167
|
+
return False
|
|
2168
|
+
|
|
2169
|
+
|
|
2170
|
+
def _take_answer(run: _DevRun, answer: str) -> bool:
|
|
2171
|
+
"""The wait got its answer: close the saved question so nothing else
|
|
2172
|
+
resumes the session with it, and mark the session running again.
|
|
2173
|
+
|
|
2174
|
+
Returns ``False`` only when the saved question was answered first
|
|
2175
|
+
another way (a reply the bridge routed to it): that answer stands and
|
|
2176
|
+
the sweeper resumes with it, so this one must not. A row that is
|
|
2177
|
+
missing or expired is no other answer, so the run resumes with this
|
|
2178
|
+
one. If the row cannot be closed, it is expired instead, so no later
|
|
2179
|
+
reply can resume the session a second time; and if it turns out to be
|
|
2180
|
+
answered already, that answer stands as above. When the database can
|
|
2181
|
+
be neither written nor read, the run resumes as it did before the row
|
|
2182
|
+
existed, and the row may stay open: a later reply could then resume
|
|
2183
|
+
the session again."""
|
|
2184
|
+
sid = run.session_id
|
|
2185
|
+
try:
|
|
2186
|
+
if run.state_db.take_parked_answer(sid, answer):
|
|
2187
|
+
return True
|
|
2188
|
+
except Exception as e:
|
|
2189
|
+
log_event(
|
|
2190
|
+
_logger, "dev.question.unpark_failed", session_id=sid,
|
|
2191
|
+
repo=run.repo, issue_number=run.issue_number,
|
|
2192
|
+
error_type=type(e).__name__, error=str(e)[:200],
|
|
2193
|
+
)
|
|
2194
|
+
try:
|
|
2195
|
+
run.state_db.expire_pending_resume(
|
|
2196
|
+
sid, "answered while the run waited",
|
|
2197
|
+
)
|
|
2198
|
+
except Exception:
|
|
2199
|
+
pass
|
|
2200
|
+
try:
|
|
2201
|
+
row = run.state_db.get_pending_resume(sid)
|
|
2202
|
+
if (
|
|
2203
|
+
row is not None
|
|
2204
|
+
and row.get("answered_at") is not None
|
|
2205
|
+
and row.get("resumed_at") is None
|
|
2206
|
+
and row.get("expired_at") is None
|
|
2207
|
+
):
|
|
2208
|
+
return False
|
|
2209
|
+
run.state_db.mark_session_running(sid)
|
|
2210
|
+
except Exception:
|
|
2211
|
+
pass
|
|
2212
|
+
return True
|
|
2213
|
+
|
|
2214
|
+
|
|
2151
2215
|
async def _answer_while_blocked(
|
|
2152
2216
|
run: _DevRun,
|
|
2153
2217
|
result: PipelineResult,
|
|
@@ -2176,6 +2240,9 @@ async def _answer_while_blocked(
|
|
|
2176
2240
|
f"Session {run.session_id} is blocked but did not include a "
|
|
2177
2241
|
"question. Reply with guidance to resume."
|
|
2178
2242
|
)
|
|
2243
|
+
# Saved first, so a poller that stops during the wait leaves the
|
|
2244
|
+
# question answerable rather than lost (#324).
|
|
2245
|
+
parked = _park_question(run, question)
|
|
2179
2246
|
try:
|
|
2180
2247
|
answer = await run.transport.ask(
|
|
2181
2248
|
question,
|
|
@@ -2201,6 +2268,35 @@ async def _answer_while_blocked(
|
|
|
2201
2268
|
question=question,
|
|
2202
2269
|
outputs=result.outputs,
|
|
2203
2270
|
)
|
|
2271
|
+
if parked and not _take_answer(run, answer):
|
|
2272
|
+
# Answered first through the saved question (#324). That
|
|
2273
|
+
# answer stands; the sweeper resumes with it once this run
|
|
2274
|
+
# has let go of the repo lock.
|
|
2275
|
+
log_event(
|
|
2276
|
+
_logger, "dev.question.answered_elsewhere",
|
|
2277
|
+
session_id=run.session_id, repo=run.repo,
|
|
2278
|
+
issue_number=run.issue_number,
|
|
2279
|
+
)
|
|
2280
|
+
# Say so to whoever typed the second reply: it is not applied.
|
|
2281
|
+
try:
|
|
2282
|
+
await run.transport.send(
|
|
2283
|
+
f"Your reply for #{run.issue_number} in {run.repo} was "
|
|
2284
|
+
"not applied: an earlier reply had already answered "
|
|
2285
|
+
"that question, and the run resumes with it."
|
|
2286
|
+
)
|
|
2287
|
+
except Exception:
|
|
2288
|
+
pass
|
|
2289
|
+
return PipelineResult(
|
|
2290
|
+
success=False,
|
|
2291
|
+
blocked=True,
|
|
2292
|
+
session_id=run.session_id,
|
|
2293
|
+
summary=(
|
|
2294
|
+
f"{deferred}: answered through the saved question; "
|
|
2295
|
+
"the sweeper resumes it with that answer"
|
|
2296
|
+
),
|
|
2297
|
+
question=question,
|
|
2298
|
+
outputs=result.outputs,
|
|
2299
|
+
)
|
|
2204
2300
|
rounds += 1
|
|
2205
2301
|
result = await resume(run.ctx, answer)
|
|
2206
2302
|
# A resumed worker is the one whose exit code the failure
|
|
@@ -2235,8 +2331,8 @@ async def _finish_plan(run: _DevRun, result: PipelineResult) -> None:
|
|
|
2235
2331
|
"""
|
|
2236
2332
|
assert run.plan_path is not None
|
|
2237
2333
|
marker = _plan_pending_marker(run.plan_path)
|
|
2238
|
-
|
|
2239
|
-
|
|
2334
|
+
|
|
2335
|
+
def write_marker() -> None:
|
|
2240
2336
|
try:
|
|
2241
2337
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
2242
2338
|
marker.write_text(run.session_id)
|
|
@@ -2246,6 +2342,14 @@ async def _finish_plan(run: _DevRun, result: PipelineResult) -> None:
|
|
|
2246
2342
|
session_id=run.session_id, repo=run.repo,
|
|
2247
2343
|
issue_number=run.issue_number, error=str(e)[:200],
|
|
2248
2344
|
)
|
|
2345
|
+
|
|
2346
|
+
# Written before the wait too (#324): if the poller stops while the
|
|
2347
|
+
# plan's question is open, the late answer must resume the PLAN.
|
|
2348
|
+
if result.blocked:
|
|
2349
|
+
write_marker()
|
|
2350
|
+
result = await _answer_while_blocked(run, result, run.pipeline.resume_plan)
|
|
2351
|
+
if result.blocked:
|
|
2352
|
+
write_marker()
|
|
2249
2353
|
run.result = result
|
|
2250
2354
|
return
|
|
2251
2355
|
marker.unlink(missing_ok=True)
|
|
@@ -3041,12 +3145,17 @@ async def run_dev_issue(
|
|
|
3041
3145
|
# resume_dev_from_pending below.
|
|
3042
3146
|
if result.blocked:
|
|
3043
3147
|
try:
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3148
|
+
question = _question_for_persist(session_id, result)
|
|
3149
|
+
# The row written before the wait (#324) may already hold
|
|
3150
|
+
# an answer the bridge attached; re-writing it would clear
|
|
3151
|
+
# that answer.
|
|
3152
|
+
if not state_db.has_open_pending_resume(session_id, question):
|
|
3153
|
+
state_db.add_pending_resume(
|
|
3154
|
+
session_id=session_id,
|
|
3155
|
+
pipeline="dev",
|
|
3156
|
+
repo=repo,
|
|
3157
|
+
question=question,
|
|
3158
|
+
)
|
|
3050
3159
|
except Exception as e:
|
|
3051
3160
|
log_event(
|
|
3052
3161
|
_logger,
|
|
@@ -3569,12 +3678,17 @@ async def resume_dev_from_pending(
|
|
|
3569
3678
|
|
|
3570
3679
|
if result.blocked:
|
|
3571
3680
|
try:
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3681
|
+
question = _question_for_persist(session_id, result)
|
|
3682
|
+
# The row written before the wait (#324) may already hold
|
|
3683
|
+
# an answer the bridge attached; re-writing it would clear
|
|
3684
|
+
# that answer.
|
|
3685
|
+
if not state_db.has_open_pending_resume(session_id, question):
|
|
3686
|
+
state_db.add_pending_resume(
|
|
3687
|
+
session_id=session_id,
|
|
3688
|
+
pipeline="dev",
|
|
3689
|
+
repo=repo,
|
|
3690
|
+
question=question,
|
|
3691
|
+
)
|
|
3578
3692
|
except Exception as e:
|
|
3579
3693
|
log_event(
|
|
3580
3694
|
_logger,
|