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.
Files changed (255) hide show
  1. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/CHANGELOG.md +47 -1
  2. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/configuration.md +1 -1
  4. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/feedback-loop.md +34 -8
  5. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/pyproject.toml +1 -1
  6. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_check.py +12 -11
  7. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/checkpoint.py +61 -0
  8. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/github.py +12 -0
  9. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/issue_questions.py +59 -10
  10. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/question_expiry.py +76 -13
  11. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/worktree.py +79 -1
  12. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/dev.py +17 -11
  13. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/secops.py +19 -13
  14. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/task.py +15 -11
  15. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_check.py +3 -3
  16. ctrlrelay-0.28.1/tests/test_checkpoint_snippet.py +176 -0
  17. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_issue_questions.py +59 -0
  18. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_question_expiry.py +171 -0
  19. ctrlrelay-0.28.1/tests/test_relay_git_credential.py +218 -0
  20. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_resume_prompt_contract.py +6 -4
  21. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  22. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  23. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  24. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/dependabot.yml +0 -0
  25. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/build.yml +0 -0
  26. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/cla.yml +0 -0
  27. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/pages.yml +0 -0
  28. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/publish.yml +0 -0
  29. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.github/workflows/test.yml +0 -0
  30. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/.gitignore +0 -0
  31. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/CODE_OF_CONDUCT.md +0 -0
  32. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/CONTRIBUTING.md +0 -0
  33. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/LICENSE +0 -0
  34. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/README.md +0 -0
  35. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/SECURITY.md +0 -0
  36. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/config/orchestrator.yaml.example +0 -0
  37. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/Gemfile +0 -0
  38. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/_config.yml +0 -0
  39. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/adding-a-machine.md +0 -0
  40. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/architecture.md +0 -0
  41. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/bridge.md +0 -0
  42. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/cli.md +0 -0
  43. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/control-plane-brain.md +0 -0
  44. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/development.md +0 -0
  45. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/getting-started.md +0 -0
  46. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/index.md +0 -0
  47. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/llms.txt +0 -0
  48. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/operations.md +0 -0
  49. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/personalization.md +0 -0
  50. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/registered-mode.md +0 -0
  51. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  52. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  53. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/__init__.py +0 -0
  54. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  55. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  56. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/factory.py +0 -0
  57. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/handler.py +0 -0
  58. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  59. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/node_tag.py +0 -0
  60. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  61. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/server.py +0 -0
  62. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  63. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/cli.py +0 -0
  64. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/__init__.py +0 -0
  65. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_env.py +0 -0
  66. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_plugins.py +0 -0
  67. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/agent_presets.py +0 -0
  68. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/archived.py +0 -0
  69. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/audit.py +0 -0
  70. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/binaries.py +0 -0
  71. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/blockers.py +0 -0
  72. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/budget.py +0 -0
  73. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/claims.py +0 -0
  74. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/code_review.py +0 -0
  75. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/config.py +0 -0
  76. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
  77. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  78. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/events.py +0 -0
  79. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/generic_cli.py +0 -0
  80. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/github_budget.py +0 -0
  81. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/heartbeat.py +0 -0
  82. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/intake_hold.py +0 -0
  83. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/job_claims.py +0 -0
  84. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/network.py +0 -0
  85. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/node_check.py +0 -0
  86. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/obs.py +0 -0
  87. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/orphans.py +0 -0
  88. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/poller.py +0 -0
  89. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  90. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  91. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/proof.py +0 -0
  92. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/publish.py +0 -0
  93. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/redact.py +0 -0
  94. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/reopens.py +0 -0
  95. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/scheduler.py +0 -0
  96. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/self_update.py +0 -0
  97. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/slots.py +0 -0
  98. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/state.py +0 -0
  99. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/stuck.py +0 -0
  100. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/usage.py +0 -0
  101. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/core/weekly_summary.py +0 -0
  102. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  103. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/dashboard/client.py +0 -0
  104. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/gh_protocol.py +0 -0
  105. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/install.py +0 -0
  106. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  107. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/manager.py +0 -0
  108. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/merge.py +0 -0
  109. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/personalization/paths.py +0 -0
  110. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  111. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/base.py +0 -0
  112. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  113. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/setup.py +0 -0
  114. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/__init__.py +0 -0
  115. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  116. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  117. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  118. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  119. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  120. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  121. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  122. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  123. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/__init__.py +0 -0
  124. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/base.py +0 -0
  125. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  126. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  127. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/__init__.py +0 -0
  128. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/drift.py +0 -0
  129. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/fleet.py +0 -0
  130. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/health.py +0 -0
  131. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/links.py +0 -0
  132. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/numbers.py +0 -0
  133. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/server.py +0 -0
  134. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/static/index.html +0 -0
  135. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/status.py +0 -0
  136. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/transcript.py +0 -0
  137. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/src/ctrlrelay/web/waiting.py +0 -0
  138. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/__init__.py +0 -0
  139. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/_poller_fakes.py +0 -0
  140. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/_publish_fakes.py +0 -0
  141. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/conftest.py +0 -0
  142. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/fixtures/gh_search_issues.json +0 -0
  143. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  144. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/fixtures/transcript_sample.jsonl +0 -0
  145. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_backend_wiring.py +0 -0
  146. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_no_keys.py +0 -0
  147. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_plugins.py +0 -0
  148. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_presets.py +0 -0
  149. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_process_group.py +0 -0
  150. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_routing.py +0 -0
  151. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_agent_sandbox.py +0 -0
  152. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_allowed_accounts.py +0 -0
  153. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_archived.py +0 -0
  154. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_audit.py +0 -0
  155. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_binary_resolution.py +0 -0
  156. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_bridge_protocol.py +0 -0
  157. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_bridge_server.py +0 -0
  158. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_bridge_start_command.py +0 -0
  159. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_budget.py +0 -0
  160. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_chat_machines.py +0 -0
  161. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_checkpoint.py +0 -0
  162. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_claim_reconciliation.py +0 -0
  163. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_ci_wait.py +0 -0
  164. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_dev.py +0 -0
  165. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_repos.py +0 -0
  166. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_secops.py +0 -0
  167. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_start.py +0 -0
  168. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_cli_version.py +0 -0
  169. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_code_review.py +0 -0
  170. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_config.py +0 -0
  171. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dashboard_client.py +0 -0
  172. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_integration.py +0 -0
  173. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_pipeline.py +0 -0
  174. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_plan_step.py +0 -0
  175. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_step_switches.py +0 -0
  176. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_stuck.py +0 -0
  177. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dev_work_steps.py +0 -0
  178. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dispatch_health.py +0 -0
  179. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dispatch_outcome_hook.py +0 -0
  180. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_dispatcher.py +0 -0
  181. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_docs_site.py +0 -0
  182. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_event_emission.py +0 -0
  183. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_events.py +0 -0
  184. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_fleet.py +0 -0
  185. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_generic_cli_adapter.py +0 -0
  186. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_generic_cli_usage.py +0 -0
  187. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_gh_protocol.py +0 -0
  188. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_github.py +0 -0
  189. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_github_budget.py +0 -0
  190. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_install.py +0 -0
  191. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_intake_hold.py +0 -0
  192. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_intake_hold_poller.py +0 -0
  193. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_issue_blockers.py +0 -0
  194. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_issue_outcome_announcement.py +0 -0
  195. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_job_claims.py +0 -0
  196. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_mattermost_handler.py +0 -0
  197. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_max_parallel.py +0 -0
  198. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_network.py +0 -0
  199. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_no_change_run.py +0 -0
  200. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_node_check.py +0 -0
  201. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_node_claims.py +0 -0
  202. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_notify_transport.py +0 -0
  203. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_obs.py +0 -0
  204. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_opencode_adapter.py +0 -0
  205. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_orphans.py +0 -0
  206. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_personalization.py +0 -0
  207. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_personalization_merge.py +0 -0
  208. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pipeline_base.py +0 -0
  209. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller.py +0 -0
  210. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller_priority_order.py +0 -0
  211. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller_recheck.py +0 -0
  212. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_poller_search.py +0 -0
  213. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_post_merge.py +0 -0
  214. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pr_verifier.py +0 -0
  215. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pr_watcher.py +0 -0
  216. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_pre_dispatch_retry.py +0 -0
  217. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_proof.py +0 -0
  218. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_provider_credentials.py +0 -0
  219. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_publish_failure_retry.py +0 -0
  220. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_question_backoff.py +0 -0
  221. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_question_survives_restart.py +0 -0
  222. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_redact.py +0 -0
  223. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_relay_alert_off_lock.py +0 -0
  224. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_relay_git_263.py +0 -0
  225. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_reopens.py +0 -0
  226. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_review_copy.py +0 -0
  227. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_review_gate.py +0 -0
  228. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_run_resume.py +0 -0
  229. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_sandbox_visibility.py +0 -0
  230. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_scheduler.py +0 -0
  231. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_secops_integration.py +0 -0
  232. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_secops_operator_prs.py +0 -0
  233. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_secops_pipeline.py +0 -0
  234. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_self_update.py +0 -0
  235. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_setup.py +0 -0
  236. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_state.py +0 -0
  237. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_stuck.py +0 -0
  238. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_task_pipeline.py +0 -0
  239. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_telegram_handler.py +0 -0
  240. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_timeout_save.py +0 -0
  241. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_transport.py +0 -0
  242. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_transport_resolution.py +0 -0
  243. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_unreviewed_pr_announcement.py +0 -0
  244. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_usage.py +0 -0
  245. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_drift.py +0 -0
  246. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_health.py +0 -0
  247. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_links.py +0 -0
  248. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_numbers.py +0 -0
  249. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_peer_links.py +0 -0
  250. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_server.py +0 -0
  251. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_status.py +0 -0
  252. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_transcript.py +0 -0
  253. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_web_waiting.py +0 -0
  254. {ctrlrelay-0.28.0 → ctrlrelay-0.28.1}/tests/test_weekly_summary.py +0 -0
  255. {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.0...HEAD
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.0
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 using a shell `printf`:
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
- printf '{"version":"1","status":"BLOCKED_NEEDS_INPUT","session_id":"%s","timestamp":"%s","question":"%s"}' \
201
- "$CTRLRELAY_SESSION_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
202
- "Should I migrate the schema in this PR or split it?" \
203
- > "$CTRLRELAY_STATE_FILE"
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 dev-pipeline prompt template embeds this exact pattern — see the
208
- `_build_prompt` method in
209
- [`src/ctrlrelay/pipelines/dev.py`](https://github.com/AInvirion/ctrlrelay/blob/main/src/ctrlrelay/pipelines/dev.py).
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).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.28.0"
3
+ version = "0.28.1"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 ``printf`` snippet is the shape of
166
- :func:`ctrlrelay.pipelines.dev._blocked_and_failed_blocks` rather than
167
- a shared block with it, for the same reason :func:`check_prompt` gives:
168
- what is under test is the *mechanics* a real run uses — the quoted
169
- path, the ``date`` substitution, the single line of shell — and the
170
- dev contract's own blocks carry placeholders a check must not leave in.
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
- mkdir -p -- {quoted_dir}
186
- printf '{{"version":"1","status":"{ROUND1_EXPECTED}",'\
187
- '"session_id":"{session_id}","timestamp":"%s",'\
188
- '"question":"{RESUME_QUESTION}"}}' \\
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
- def expired_comment(session_id: str, reason: str | None) -> str:
102
- why = f" ({reason})" if reason else ""
103
- return (
104
- f"**The question above expired unanswered{why}.** An answer to it "
105
- "is no longer applied, here or in chat.\n"
106
- f"{_marker('expired', session_id)}"
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. Returns whether a
433
- comment was posted."""
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(row["session_id"], row.get("expired_reason")),
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
- repo: str,
362
- numbers: list[str],
425
+ targets: list[tuple[str, int]],
363
426
  ) -> bool:
364
- """True when every cited number is closed or merged.
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 numbers:
436
+ if not targets:
374
437
  return False
375
438
 
376
- for number in numbers:
439
+ for repo, number in targets:
377
440
  try:
378
441
  state = await github.get_issue_or_pr_state(
379
442
  repo,
380
- int(number),
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
- numbers = extract_referenced_numbers(row["question"])
446
- # Rows citing nothing are skipped without spending budget —
447
- # they can never be resolution-expired anyway.
448
- if not numbers:
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 CheckpointStatus
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
- return f"""**BLOCKED** (need input):
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
- mkdir -p -- {quoted_dir}
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
- mkdir -p -- {quoted_dir}
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