ctrlrelay 0.12.1__tar.gz → 0.12.4__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 (141) hide show
  1. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/CHANGELOG.md +67 -1
  2. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/PKG-INFO +1 -1
  3. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/pyproject.toml +1 -1
  4. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/server.py +86 -30
  5. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/cli.py +1091 -79
  6. ctrlrelay-0.12.4/src/ctrlrelay/core/binaries.py +198 -0
  7. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/code_review.py +27 -1
  8. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/config.py +23 -0
  9. ctrlrelay-0.12.4/src/ctrlrelay/core/dispatch_health.py +276 -0
  10. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/dispatcher.py +215 -21
  11. ctrlrelay-0.12.4/src/ctrlrelay/core/redact.py +321 -0
  12. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/state.py +455 -1
  13. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/pipelines/base.py +12 -0
  14. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/pipelines/dev.py +436 -13
  15. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/pipelines/secops.py +7 -0
  16. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/templates/launchd/bridge.plist.template +9 -1
  17. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/templates/launchd/poller.plist.template +9 -1
  18. ctrlrelay-0.12.4/src/ctrlrelay/templates/systemd/bridge.service.template +37 -0
  19. ctrlrelay-0.12.4/src/ctrlrelay/templates/systemd/poller.service.template +42 -0
  20. ctrlrelay-0.12.4/tests/test_binary_resolution.py +1009 -0
  21. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_bridge_server.py +171 -14
  22. ctrlrelay-0.12.4/tests/test_claim_reconciliation.py +999 -0
  23. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_config.py +6 -2
  24. ctrlrelay-0.12.4/tests/test_dispatch_health.py +462 -0
  25. ctrlrelay-0.12.4/tests/test_dispatch_outcome_hook.py +1512 -0
  26. ctrlrelay-0.12.4/tests/test_issue_outcome_announcement.py +182 -0
  27. ctrlrelay-0.12.4/tests/test_notify_transport.py +1238 -0
  28. ctrlrelay-0.12.4/tests/test_redact.py +512 -0
  29. ctrlrelay-0.12.4/tests/test_unreviewed_pr_announcement.py +1009 -0
  30. ctrlrelay-0.12.1/src/ctrlrelay/templates/systemd/bridge.service.template +0 -20
  31. ctrlrelay-0.12.1/src/ctrlrelay/templates/systemd/poller.service.template +0 -25
  32. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  33. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  34. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  35. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/dependabot.yml +0 -0
  36. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/workflows/build.yml +0 -0
  37. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/workflows/cla.yml +0 -0
  38. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/workflows/pages.yml +0 -0
  39. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/workflows/publish.yml +0 -0
  40. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.github/workflows/test.yml +0 -0
  41. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/.gitignore +0 -0
  42. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/CODE_OF_CONDUCT.md +0 -0
  43. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/CONTRIBUTING.md +0 -0
  44. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/LICENSE +0 -0
  45. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/README.md +0 -0
  46. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/SECURITY.md +0 -0
  47. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/config/orchestrator.yaml.example +0 -0
  48. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/Gemfile +0 -0
  49. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/_config.yml +0 -0
  50. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/architecture.md +0 -0
  51. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/bridge.md +0 -0
  52. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/cli.md +0 -0
  53. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/configuration.md +0 -0
  54. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/development.md +0 -0
  55. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/feedback-loop.md +0 -0
  56. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/getting-started.md +0 -0
  57. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/index.md +0 -0
  58. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/operations.md +0 -0
  59. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/docs/personalization.md +0 -0
  60. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/__init__.py +0 -0
  61. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/__init__.py +0 -0
  62. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/__main__.py +0 -0
  63. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/factory.py +0 -0
  64. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/handler.py +0 -0
  65. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  66. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/protocol.py +0 -0
  67. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  68. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/__init__.py +0 -0
  69. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/archived.py +0 -0
  70. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/audit.py +0 -0
  71. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/checkpoint.py +0 -0
  72. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/github.py +0 -0
  73. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/network.py +0 -0
  74. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/obs.py +0 -0
  75. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/poller.py +0 -0
  76. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/pr_verifier.py +0 -0
  77. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/pr_watcher.py +0 -0
  78. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/question_expiry.py +0 -0
  79. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/scheduler.py +0 -0
  80. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/core/worktree.py +0 -0
  81. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/dashboard/__init__.py +0 -0
  82. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/dashboard/client.py +0 -0
  83. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/gh_protocol.py +0 -0
  84. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/install.py +0 -0
  85. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/personalization/__init__.py +0 -0
  86. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/personalization/manager.py +0 -0
  87. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/personalization/paths.py +0 -0
  88. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/pipelines/__init__.py +0 -0
  89. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  90. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/pipelines/task.py +0 -0
  91. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/setup.py +0 -0
  92. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/templates/__init__.py +0 -0
  93. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  94. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  95. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  96. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/transports/__init__.py +0 -0
  97. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/transports/base.py +0 -0
  98. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/transports/file_mock.py +0 -0
  99. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/src/ctrlrelay/transports/socket_client.py +0 -0
  100. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/__init__.py +0 -0
  101. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/conftest.py +0 -0
  102. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_archived.py +0 -0
  103. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_audit.py +0 -0
  104. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_bridge_protocol.py +0 -0
  105. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_checkpoint.py +0 -0
  106. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_cli_ci_wait.py +0 -0
  107. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_cli_dev.py +0 -0
  108. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_cli_repos.py +0 -0
  109. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_cli_secops.py +0 -0
  110. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_cli_start.py +0 -0
  111. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_cli_version.py +0 -0
  112. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_code_review.py +0 -0
  113. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_dashboard_client.py +0 -0
  114. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_dev_integration.py +0 -0
  115. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_dev_pipeline.py +0 -0
  116. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_dispatcher.py +0 -0
  117. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_docs_site.py +0 -0
  118. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_gh_protocol.py +0 -0
  119. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_github.py +0 -0
  120. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_install.py +0 -0
  121. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_mattermost_handler.py +0 -0
  122. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_network.py +0 -0
  123. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_obs.py +0 -0
  124. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_personalization.py +0 -0
  125. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_pipeline_base.py +0 -0
  126. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_poller.py +0 -0
  127. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_post_merge.py +0 -0
  128. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_pr_verifier.py +0 -0
  129. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_pr_watcher.py +0 -0
  130. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_question_expiry.py +0 -0
  131. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_resume_prompt_contract.py +0 -0
  132. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_scheduler.py +0 -0
  133. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_secops_integration.py +0 -0
  134. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_secops_pipeline.py +0 -0
  135. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_setup.py +0 -0
  136. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_state.py +0 -0
  137. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_task_pipeline.py +0 -0
  138. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_telegram_handler.py +0 -0
  139. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_transport.py +0 -0
  140. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_transport_resolution.py +0 -0
  141. {ctrlrelay-0.12.1 → ctrlrelay-0.12.4}/tests/test_worktree.py +0 -0
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.4] - 2026-10-18
11
+
12
+ Carries 0.12.3's entries too: v0.12.3 was never tagged, so nothing has
13
+ shipped since v0.12.2 and this is the first release to reach a running
14
+ daemon with any of it.
15
+
16
+ ### Fixed
17
+
18
+ - Reconcile the claim comment with a failed dispatch, alert on a run of
19
+ failures, and keep the exit code and the worker's output (#206, closes
20
+ #204)
21
+
22
+ The dev lane posted *"a PR will be opened for review"* before the
23
+ worker started and never returned to the issue when the worker
24
+ exited non-zero, so that sentence stood on a beta tester's own ticket
25
+ for eighteen hours. Separately, 163 consecutive failed dispatches over
26
+ two days told nobody while both units stayed `active (running)`.
27
+ Separately again, `sessions.claude_exit_code` was never written on the
28
+ failure path and no worker output was retained, so the only durable
29
+ evidence of why they failed was that each took about two seconds.
30
+
31
+ New: `sessions.output_tail`, the `dispatch_health` table, and an
32
+ `alerts` config block (`alerts.enabled`,
33
+ `alerts.consecutive_dispatch_failures`, default 3). All additive and
34
+ defaulted — no config edit is needed.
35
+
36
+ **Known limits**, stated because they are decisions rather than
37
+ oversights: the relay-health alert is awaited inside the dispatcher,
38
+ so it can hold the per-repo lock for up to ~15 s against a wedged
39
+ bridge; delivery is at-least-once, so an alert that is sent but whose
40
+ delivery cannot be recorded may be sent once more; and the output
41
+ scrubber removes known credential shapes, which is not a proof that
42
+ nothing sensitive remains. A numeric value under a credential key is
43
+ kept, because treating every number as a possible secret destroyed the
44
+ token counts in an API error.
45
+
46
+ ### Added
47
+
48
+ - Announce a pull request whose review could not run (#202, closes #196)
49
+
50
+ ### Fixed (0.12.3, never released)
51
+
52
+ - Resolve the reviewer binary before exec, and ship a unit PATH that finds
53
+ it (#201, closes #197)
54
+ - Resolve the notification transport at use, not at boot (#199, closes #198)
55
+
56
+ ## [0.12.3] - 2026-10-12
57
+
58
+ ### Added
59
+
60
+ - Announce a pull request whose review could not run (#202, closes #196)
61
+
62
+ ### Fixed
63
+
64
+ - Resolve the reviewer binary before exec, and ship a unit PATH that finds
65
+ it (#201, closes #197)
66
+ - Resolve the notification transport at use, not at boot (#199, closes #198)
67
+
68
+ ## [0.12.2] - 2026-10-06
69
+
70
+ ### Fixed
71
+
72
+ - Stay quiet when somebody is talking in the channel (#194)
73
+
10
74
  ## [0.12.1] - 2026-10-06
11
75
 
12
76
  ### Fixed
@@ -1683,7 +1747,9 @@ pipeline).
1683
1747
  per-phase implementation plans (Phase 0 through Phase 4).
1684
1748
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1685
1749
 
1686
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...HEAD
1750
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.3...HEAD
1751
+ [0.12.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.3
1752
+ [0.12.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...v0.12.2
1687
1753
  [0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
1688
1754
  [0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
1689
1755
  [0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.12.1
3
+ Version: 0.12.4
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.12.1"
3
+ version = "0.12.4"
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