ctrlrelay 0.12.0__tar.gz → 0.12.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/test.yml +1 -6
  2. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/CHANGELOG.md +21 -1
  3. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/PKG-INFO +4 -4
  4. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/README.md +3 -3
  5. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/config/orchestrator.yaml.example +1 -1
  6. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/bridge.md +5 -3
  7. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/configuration.md +5 -4
  8. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/pyproject.toml +1 -1
  9. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/server.py +86 -30
  10. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/dev.py +42 -2
  11. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/secops.py +42 -2
  12. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/task.py +44 -2
  13. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_bridge_server.py +171 -14
  14. ctrlrelay-0.12.2/tests/test_docs_site.py +244 -0
  15. ctrlrelay-0.12.2/tests/test_resume_prompt_contract.py +255 -0
  16. ctrlrelay-0.12.0/tests/test_docs_site.py +0 -112
  17. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/dependabot.yml +0 -0
  21. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/build.yml +0 -0
  22. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/cla.yml +0 -0
  23. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/pages.yml +0 -0
  24. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/publish.yml +0 -0
  25. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.gitignore +0 -0
  26. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/CODE_OF_CONDUCT.md +0 -0
  27. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/CONTRIBUTING.md +0 -0
  28. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/LICENSE +0 -0
  29. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/SECURITY.md +0 -0
  30. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/Gemfile +0 -0
  31. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/_config.yml +0 -0
  32. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/architecture.md +0 -0
  33. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/cli.md +0 -0
  34. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/development.md +0 -0
  35. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/feedback-loop.md +0 -0
  36. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/getting-started.md +0 -0
  37. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/index.md +0 -0
  38. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/operations.md +0 -0
  39. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/personalization.md +0 -0
  40. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/__init__.py +0 -0
  41. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__init__.py +0 -0
  42. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__main__.py +0 -0
  43. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/factory.py +0 -0
  44. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/handler.py +0 -0
  45. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  46. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/protocol.py +0 -0
  47. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  48. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/cli.py +0 -0
  49. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/__init__.py +0 -0
  50. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/archived.py +0 -0
  51. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/audit.py +0 -0
  52. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/checkpoint.py +0 -0
  53. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/code_review.py +0 -0
  54. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/config.py +0 -0
  55. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/dispatcher.py +0 -0
  56. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/github.py +0 -0
  57. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/network.py +0 -0
  58. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/obs.py +0 -0
  59. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/poller.py +0 -0
  60. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
  61. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
  62. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/question_expiry.py +0 -0
  63. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/scheduler.py +0 -0
  64. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/state.py +0 -0
  65. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/worktree.py +0 -0
  66. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
  67. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/client.py +0 -0
  68. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/gh_protocol.py +0 -0
  69. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/install.py +0 -0
  70. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/__init__.py +0 -0
  71. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/manager.py +0 -0
  72. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/paths.py +0 -0
  73. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
  74. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/base.py +0 -0
  75. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  76. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/setup.py +0 -0
  77. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/__init__.py +0 -0
  78. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  79. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  80. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  81. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  82. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  83. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  84. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  85. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/__init__.py +0 -0
  86. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/base.py +0 -0
  87. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/file_mock.py +0 -0
  88. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/socket_client.py +0 -0
  89. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/__init__.py +0 -0
  90. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/conftest.py +0 -0
  91. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_archived.py +0 -0
  92. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_audit.py +0 -0
  93. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_bridge_protocol.py +0 -0
  94. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_checkpoint.py +0 -0
  95. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_ci_wait.py +0 -0
  96. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_dev.py +0 -0
  97. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_repos.py +0 -0
  98. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_secops.py +0 -0
  99. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_start.py +0 -0
  100. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_version.py +0 -0
  101. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_code_review.py +0 -0
  102. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_config.py +0 -0
  103. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dashboard_client.py +0 -0
  104. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dev_integration.py +0 -0
  105. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dev_pipeline.py +0 -0
  106. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dispatcher.py +0 -0
  107. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_gh_protocol.py +0 -0
  108. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_github.py +0 -0
  109. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_install.py +0 -0
  110. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_mattermost_handler.py +0 -0
  111. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_network.py +0 -0
  112. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_obs.py +0 -0
  113. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_personalization.py +0 -0
  114. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_pipeline_base.py +0 -0
  115. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_poller.py +0 -0
  116. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_post_merge.py +0 -0
  117. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_pr_verifier.py +0 -0
  118. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_pr_watcher.py +0 -0
  119. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_question_expiry.py +0 -0
  120. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_scheduler.py +0 -0
  121. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_secops_integration.py +0 -0
  122. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_secops_pipeline.py +0 -0
  123. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_setup.py +0 -0
  124. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_state.py +0 -0
  125. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_task_pipeline.py +0 -0
  126. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_telegram_handler.py +0 -0
  127. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_transport.py +0 -0
  128. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_transport_resolution.py +0 -0
  129. {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_worktree.py +0 -0
@@ -37,9 +37,4 @@ jobs:
37
37
  run: uv run ruff check src/ tests/
38
38
 
39
39
  - name: Pytest
40
- # Deselect the pre-existing unrelated docs-site test that asserts
41
- # nav_order uniqueness across docs/ — tracked separately and
42
- # unchanged by this PR.
43
- run: |
44
- uv run pytest tests/ -v \
45
- --deselect tests/test_docs_site.py::test_nav_order_unique_per_sibling_group
40
+ run: uv run pytest tests/ -v
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.2] - 2026-10-06
11
+
12
+ ### Fixed
13
+
14
+ - Stay quiet when somebody is talking in the channel (#194)
15
+
16
+ ## [0.12.1] - 2026-10-06
17
+
18
+ ### Fixed
19
+
20
+ - Every checkpoint snippet creates its own directory (#190)
21
+ - Restate the checkpoint contract when resuming (#188)
22
+ - Run the whole suite, and make the next exclusion defendable (#186)
23
+
24
+ ### Changed
25
+
26
+ - Both-transports-at-once is not planned, not pending (#187)
27
+
10
28
  ## [0.12.0] - 2026-10-06
11
29
 
12
30
  ### Added
@@ -1671,7 +1689,9 @@ pipeline).
1671
1689
  per-phase implementation plans (Phase 0 through Phase 4).
1672
1690
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1673
1691
 
1674
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...HEAD
1692
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...HEAD
1693
+ [0.12.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...v0.12.2
1694
+ [0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
1675
1695
  [0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
1676
1696
  [0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
1677
1697
  [0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.12.0
3
+ Version: 0.12.2
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -97,9 +97,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
97
97
  relays it to you and resumes the session once you reply. Choose
98
98
  **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
99
99
  server) with one `transport.type` setting. Exactly one is active at a
100
- time; running both at once is not supported yet
101
- ([#176](https://github.com/AInvirion/ctrlrelay/issues/176)). See
102
- [configuration][docs-config].
100
+ time; running both at once is not supported and not planned
101
+ ([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
102
+ won't-do). See [configuration][docs-config].
103
103
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
104
104
  with a notification on whichever chat transport is configured.
105
105
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
@@ -56,9 +56,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
56
56
  relays it to you and resumes the session once you reply. Choose
57
57
  **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
58
58
  server) with one `transport.type` setting. Exactly one is active at a
59
- time; running both at once is not supported yet
60
- ([#176](https://github.com/AInvirion/ctrlrelay/issues/176)). See
61
- [configuration][docs-config].
59
+ time; running both at once is not supported and not planned
60
+ ([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
61
+ won't-do). See [configuration][docs-config].
62
62
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
63
63
  with a notification on whichever chat transport is configured.
64
64
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
@@ -34,7 +34,7 @@ agent:
34
34
  # EXACTLY ONE transport is active, and it is the one named by `type`.
35
35
  # The other blocks below are inert: they may stay in the file, but nothing
36
36
  # reads them. Running Telegram and Mattermost at the same time is NOT
37
- # supported - that is tracked as issue #176.
37
+ # supported and NOT planned - designed in issue #176 and closed won't-do.
38
38
  #
39
39
  # Choose:
40
40
  # type: "telegram" production, 1:1 chat with a bot
@@ -22,9 +22,11 @@ one-line config change and no pipeline code moves.
22
22
  **One at a time.** `transport.type` names the transport that is active, and the
23
23
  other block is inert — it may stay in the file, but nothing reads it, including
24
24
  its `question_ttl_seconds`. There is no fallback: a question goes to the one
25
- you named or nowhere. Running both at once is
26
- [issue #176](https://github.com/AInvirion/ctrlrelay/issues/176) and is not
27
- built.
25
+ you named or nowhere. Running both at once is **not supported and not
26
+ planned** — it was designed in
27
+ [#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed
28
+ won't-do. Reopen that issue rather than re-deriving the design if you need
29
+ it.
28
30
 
29
31
  | | Telegram | Mattermost |
30
32
  |---|---|---|
@@ -104,10 +104,11 @@ block in the file is inert — it may stay there, but nothing reads it.
104
104
  | A channel on your own server | `"mattermost"` | A server URL, a bot token, and the channel's 26-character ID. |
105
105
  | No chat at all | `"file_mock"` | Nothing. Questions go to a file; **nobody is asked anything.** |
106
106
 
107
- **Running Telegram and Mattermost at the same time is not supported.** Setting
108
- `type` to one does not fall back to the other, and a question is delivered to
109
- one place or not at all. That is tracked as
110
- [issue #176](https://github.com/AInvirion/ctrlrelay/issues/176).
107
+ **Running Telegram and Mattermost at the same time is not supported, and is
108
+ not planned.** Setting `type` to one does not fall back to the other, and a
109
+ question is delivered to one place or not at all. It was designed in
110
+ [#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed won't-do;
111
+ the design is still in that issue if anyone wants it.
111
112
 
112
113
  Switching is a two-line change — set `type`, add that transport's block — plus
113
114
  a restart of **both** daemons, which share `state.db`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.12.0"
3
+ version = "0.12.2"
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"
@@ -37,6 +37,35 @@ _log = logging.getLogger(__name__)
37
37
  _ASKED_SESSIONS_MAX = 500
38
38
 
39
39
 
40
+ _PENDING_SHOWN = 5
41
+
42
+
43
+ def _format_pending(rows: list[dict]) -> str:
44
+ """A sample of outstanding questions, never the whole catalogue.
45
+
46
+ Capped because this list is unbounded by nature - one row per blocked
47
+ session, and a config error can block every repo in a sweep. Measured
48
+ on the live channel at 20 pending: 4042 characters, about 202 per
49
+ row. This deployment sweeps 87 repos, and the server's MaxPostSize is
50
+ 16383, so at roughly 81 blocked sessions the message exceeded the
51
+ limit and posted NOTHING - the notice whose job is to say a reply was
52
+ dropped, dropped.
53
+
54
+ The count of what is withheld is part of the message, not a detail:
55
+ truncating to "here are five" and not saying five of how many is the
56
+ same information loss one level down.
57
+ """
58
+ shown = rows[:_PENDING_SHOWN]
59
+ lines = [
60
+ f" • `{r['session_id']}` ({r['repo']}): {(r['question'] or '')[:80]}"
61
+ for r in shown
62
+ ]
63
+ rest = len(rows) - len(shown)
64
+ if rest > 0:
65
+ lines.append(f" … and {rest} more.")
66
+ return "Pending:\n" + "\n".join(lines)
67
+
68
+
40
69
  def names_session(text: str, session_id: str) -> bool:
41
70
  """True when ``text`` names ``session_id`` as a whole token.
42
71
 
@@ -617,7 +646,7 @@ class BridgeServer:
617
646
  match: _PendingQuestion | None = None
618
647
  hinted_session_id: str | None = None
619
648
  live_listing: str | None = None
620
- ambiguous_notice: str | None = None
649
+ unaddressed_pending: int | None = None
621
650
 
622
651
  async with self._pending_lock:
623
652
  self._drop_expired_questions()
@@ -629,20 +658,16 @@ class BridgeServer:
629
658
  elif len(self._pending_questions) == 1:
630
659
  match = next(iter(self._pending_questions.values()))
631
660
  elif len(self._pending_questions) > 1:
632
- ambiguous_notice = (
633
- "Your message wasn't routed - "
634
- f"{len(self._pending_questions)} questions are waiting "
635
- "and a plain message doesn't say which one you "
636
- "mean.\n\n"
637
- "Reply to the question you want to answer "
638
- "(Telegram: reply; Mattermost: reply in its thread)."
639
- "\n\n"
640
- f"{self._live_question_listing()}"
641
- )
661
+ # A plain message pointing at nothing, with several
662
+ # questions live. This used to post the whole list; the
663
+ # bridge shares its channel with people, so that made
664
+ # every sentence typed in it produce a wall. Counted,
665
+ # not inferred.
666
+ unaddressed_pending = len(self._pending_questions)
642
667
 
643
668
  if match is not None:
644
669
  self._pending_questions.pop(match.request_id, None)
645
- elif ambiguous_notice is None:
670
+ elif unaddressed_pending is None:
646
671
  # Snapshot what the orphan branch needs, so the lock can be
647
672
  # released before we touch the chat app or the database.
648
673
  hinted_session_id = (
@@ -656,10 +681,20 @@ class BridgeServer:
656
681
  else None
657
682
  )
658
683
 
659
- if ambiguous_notice is not None:
660
- await self._send_notice(
661
- ambiguous_notice,
662
- log="bridge: ambiguous fresh reply, refusing to guess",
684
+ if unaddressed_pending is not None:
685
+ # Silent on purpose. See `_format_pending` for the measurement
686
+ # and the tradeoff: a plain message while several questions
687
+ # are open is ignored, so an answer typed without the reply
688
+ # gesture gets no response. The alternative was a notice on
689
+ # every message in a shared channel, which is what the
690
+ # operator reported.
691
+ log_event(
692
+ _logger,
693
+ "bridge.reply.unaddressed",
694
+ pending=unaddressed_pending,
695
+ live=True,
696
+ text_length=len(text),
697
+ text_hash=hash_text(text),
663
698
  )
664
699
  return
665
700
 
@@ -764,19 +799,27 @@ class BridgeServer:
764
799
  f"({outcome['reason']})"
765
800
  ),
766
801
  )
767
- elif outcome["status"] == "ambiguous":
768
- pending_list = "\n".join(
769
- f" • `{r['session_id']}` ({r['repo']}): "
770
- f"{(r['question'] or '')[:80]}"
771
- for r in outcome["rows"]
802
+ elif outcome["status"] == "unaddressed":
803
+ # Somebody is talking in the channel. Say nothing: the bridge
804
+ # does not own this channel and a message that points at no
805
+ # question is not a failed answer. Logged so a genuinely
806
+ # missed answer is still diagnosable.
807
+ log_event(
808
+ _logger,
809
+ "bridge.reply.unaddressed",
810
+ pending=len(outcome["rows"]),
811
+ text_length=len(text),
812
+ text_hash=hash_text(text),
772
813
  )
814
+ elif outcome["status"] == "ambiguous":
815
+ # Reached only when the operator DID point at something - they
816
+ # named more than one session id in one message. That is a
817
+ # failed answer and is worth a reply.
773
818
  await self._send_notice(
774
- "⚠️ Your reply wasn't routed — multiple BLOCKED sessions "
775
- "are unanswered and the reply didn't point at one of "
776
- "them.\n\n"
777
- f"Pending:\n{pending_list}\n\n"
778
- "Reply to the question you mean, or paste "
779
- "its session_id anywhere in your message."
819
+ "⚠️ Your reply wasn't routed — it names more than one "
820
+ "session.\n\n"
821
+ f"{_format_pending(outcome['rows'])}\n\n"
822
+ "Send one message per session."
780
823
  )
781
824
  elif live_listing is not None:
782
825
  # Reply-to pointed at a message this bridge no longer tracks
@@ -830,11 +873,16 @@ class BridgeServer:
830
873
  text.
831
874
 
832
875
  Caller must hold ``_pending_lock``."""
876
+ qs = list(self._pending_questions.values())
877
+ shown = qs[:_PENDING_SHOWN]
833
878
  rows = "\n".join(
834
879
  f" - {q.repo or 'unknown repo'}"
835
880
  + (f" (session: {q.session_id})" if q.session_id else "")
836
- for q in self._pending_questions.values()
881
+ for q in shown
837
882
  )
883
+ rest = len(qs) - len(shown)
884
+ if rest > 0:
885
+ rows += f"\n … and {rest} more."
838
886
  return f"Still waiting:\n{rows}"
839
887
 
840
888
  async def _send_notice(self, text: str, log: str | None = None) -> None:
@@ -938,8 +986,16 @@ class BridgeServer:
938
986
  elif len(rows) == 1:
939
987
  target = rows[0]
940
988
  else:
941
- # Multiple unanswered, no session_id hint — can't route safely.
942
- return {"status": "ambiguous", "rows": rows}
989
+ # Multiple unanswered, and the message pointed at nothing: no
990
+ # reply-to, no session id. That is not a failed answer, it is
991
+ # somebody talking in the channel.
992
+ #
993
+ # This used to return "ambiguous", which posted every pending
994
+ # session as one block. Measured on the live channel: 4042
995
+ # characters, 29 lines, fired by `ok` and by a message
996
+ # addressed to @here. The operator could not hold a
997
+ # conversation in the channel the bot lives in.
998
+ return {"status": "unaddressed", "rows": rows}
943
999
 
944
1000
  return await self._attach_orphan_answer(target, text)
945
1001
 
@@ -318,7 +318,20 @@ class DevPipeline:
318
318
 
319
319
  async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
320
320
  """Resume blocked dev session with user answer."""
321
- prompt = f"User answered: {answer}\n\nContinue from where you left off."
321
+ # The checkpoint contract is restated here, not just in the
322
+ # initial prompt (#173). A resumed agent that is told "continue"
323
+ # and nothing else can do its work, exit 0, write no state, and
324
+ # be indistinguishable from one that crashed - which is what
325
+ # happened on 2026-09-16, after three of four pull requests had
326
+ # already been merged.
327
+ contract = self._checkpoint_contract(
328
+ str(ctx.state_file), ctx.session_id
329
+ )
330
+ prompt = (
331
+ f"User answered: {answer}\n\n"
332
+ "Continue from where you left off.\n\n"
333
+ f"{contract}"
334
+ )
322
335
 
323
336
  # Resume uses Claude's own session UUID (captured on the first spawn
324
337
  # and persisted to state_db). Passing our composite id here makes
@@ -444,7 +457,34 @@ Do NOT merge the PR - wait for human review.
444
457
  The orchestrator re-verifies CI and mergeability after you hand off; if it
445
458
  finds the PR broken it will resume this session asking you to fix it.
446
459
 
447
- ## Signaling Completion
460
+ {self._checkpoint_contract(state_file_path, session_id)}"""
461
+
462
+ def _checkpoint_contract(
463
+ self, state_file_path: str, session_id: str
464
+ ) -> str:
465
+ """How the agent signals how it stopped.
466
+
467
+ Extracted so the RESUME prompt can carry it too (#173). The
468
+ initial prompt spent ~30 lines establishing this; the resume
469
+ prompt was 58 characters and said only "continue", so a resumed
470
+ agent could do its work, exit 0, write no state, and be
471
+ indistinguishable from one that crashed.
472
+
473
+ Measured case, 2026-09-16:
474
+ secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
475
+ "Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
476
+ in fact merged three of four pull requests. The operator was told
477
+ the resume failed, which was wrong in both directions: most of
478
+ the work had landed, and the one thing outstanding was never
479
+ named.
480
+
481
+ Per-pipeline rather than shared, because the three contracts
482
+ differ in substance and not only wording: the dev DONE block
483
+ carries pr_url and pr_number outputs, and each names different
484
+ conditions for DONE. One shared block would have quietly changed
485
+ what two of them promise.
486
+ """
487
+ return f"""## Signaling Completion
448
488
 
449
489
  **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
450
490
 
@@ -126,7 +126,20 @@ class SecopsPipeline:
126
126
 
127
127
  async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
128
128
  """Resume blocked secops with user answer."""
129
- prompt = f"User answered: {answer}\n\nContinue from where you left off."
129
+ # The checkpoint contract is restated here, not just in the
130
+ # initial prompt (#173). A resumed agent that is told "continue"
131
+ # and nothing else can do its work, exit 0, write no state, and
132
+ # be indistinguishable from one that crashed - which is what
133
+ # happened on 2026-09-16, after three of four pull requests had
134
+ # already been merged.
135
+ contract = self._checkpoint_contract(
136
+ str(ctx.state_file), ctx.session_id
137
+ )
138
+ prompt = (
139
+ f"User answered: {answer}\n\n"
140
+ "Continue from where you left off.\n\n"
141
+ f"{contract}"
142
+ )
130
143
 
131
144
  # Resume uses Claude's own session UUID (captured on the first spawn
132
145
  # and persisted to state_db). Our composite id is not a UUID and will
@@ -425,7 +438,34 @@ yourself about to write a phrase like "no open PRs at all" or "N open
425
438
  PRs total", you have already stepped outside the fence — you can only
426
439
  speak to the author-filtered results.
427
440
 
428
- ## Signaling Completion
441
+ {self._checkpoint_contract(state_file_path, session_id)}"""
442
+
443
+ def _checkpoint_contract(
444
+ self, state_file_path: str, session_id: str
445
+ ) -> str:
446
+ """How the agent signals how it stopped.
447
+
448
+ Extracted so the RESUME prompt can carry it too (#173). The
449
+ initial prompt spent ~30 lines establishing this; the resume
450
+ prompt was 58 characters and said only "continue", so a resumed
451
+ agent could do its work, exit 0, write no state, and be
452
+ indistinguishable from one that crashed.
453
+
454
+ Measured case, 2026-09-16:
455
+ secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
456
+ "Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
457
+ in fact merged three of four pull requests. The operator was told
458
+ the resume failed, which was wrong in both directions: most of
459
+ the work had landed, and the one thing outstanding was never
460
+ named.
461
+
462
+ Per-pipeline rather than shared, because the three contracts
463
+ differ in substance and not only wording: the dev DONE block
464
+ carries pr_url and pr_number outputs, and each names different
465
+ conditions for DONE. One shared block would have quietly changed
466
+ what two of them promise.
467
+ """
468
+ return f"""## Signaling Completion
429
469
 
430
470
  **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
431
471
 
@@ -80,7 +80,20 @@ class TaskPipeline:
80
80
  return self._session_to_result(result)
81
81
 
82
82
  async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
83
- prompt = f"User answered: {answer}\n\nContinue from where you left off."
83
+ # The checkpoint contract is restated here, not just in the
84
+ # initial prompt (#173). A resumed agent that is told "continue"
85
+ # and nothing else can do its work, exit 0, write no state, and
86
+ # be indistinguishable from one that crashed - which is what
87
+ # happened on 2026-09-16, after three of four pull requests had
88
+ # already been merged.
89
+ contract = self._checkpoint_contract(
90
+ str(ctx.state_file), ctx.session_id
91
+ )
92
+ prompt = (
93
+ f"User answered: {answer}\n\n"
94
+ "Continue from where you left off.\n\n"
95
+ f"{contract}"
96
+ )
84
97
  resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
85
98
  log_event(
86
99
  _logger,
@@ -181,7 +194,34 @@ pipeline. Don't BLOCK on things you can figure out yourself.
181
194
 
182
195
  ## Signaling completion
183
196
 
184
- **CRITICAL**: Before exiting, write a checkpoint file.
197
+ {self._checkpoint_contract(state_file_path, session_id)}"""
198
+
199
+ def _checkpoint_contract(
200
+ self, state_file_path: str, session_id: str
201
+ ) -> str:
202
+ """How the agent signals how it stopped.
203
+
204
+ Extracted so the RESUME prompt can carry it too (#173). The
205
+ initial prompt spent ~30 lines establishing this; the resume
206
+ prompt was 58 characters and said only "continue", so a resumed
207
+ agent could do its work, exit 0, write no state, and be
208
+ indistinguishable from one that crashed.
209
+
210
+ Measured case, 2026-09-16:
211
+ secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
212
+ "Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
213
+ in fact merged three of four pull requests. The operator was told
214
+ the resume failed, which was wrong in both directions: most of
215
+ the work had landed, and the one thing outstanding was never
216
+ named.
217
+
218
+ Per-pipeline rather than shared, because the three contracts
219
+ differ in substance and not only wording: the dev DONE block
220
+ carries pr_url and pr_number outputs, and each names different
221
+ conditions for DONE. One shared block would have quietly changed
222
+ what two of them promise.
223
+ """
224
+ return f"""**CRITICAL**: Before exiting, write a checkpoint file.
185
225
 
186
226
  STATE_FILE: {state_file_path}
187
227
  SESSION_ID: {session_id}
@@ -196,6 +236,7 @@ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\\
196
236
 
197
237
  **BLOCKED** (need operator input):
198
238
  ```bash
239
+ mkdir -p "$(dirname '{state_file_path}')"
199
240
  printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
200
241
  '"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
201
242
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
@@ -203,6 +244,7 @@ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
203
244
 
204
245
  **FAILED** (genuine failure, no way forward):
205
246
  ```bash
247
+ mkdir -p "$(dirname '{state_file_path}')"
206
248
  printf '{{"version":"1","status":"FAILED",'\\
207
249
  '"session_id":"{session_id}","timestamp":"%s","error":"%s"}}' \\
208
250
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'