ctrlrelay 0.12.1__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 (128) hide show
  1. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/CHANGELOG.md +8 -1
  2. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/PKG-INFO +1 -1
  3. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/pyproject.toml +1 -1
  4. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/server.py +86 -30
  5. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_bridge_server.py +171 -14
  6. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  7. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  8. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  9. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/dependabot.yml +0 -0
  10. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/build.yml +0 -0
  11. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/cla.yml +0 -0
  12. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/pages.yml +0 -0
  13. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/publish.yml +0 -0
  14. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/test.yml +0 -0
  15. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.gitignore +0 -0
  16. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/CODE_OF_CONDUCT.md +0 -0
  17. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/CONTRIBUTING.md +0 -0
  18. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/LICENSE +0 -0
  19. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/README.md +0 -0
  20. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/SECURITY.md +0 -0
  21. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/config/orchestrator.yaml.example +0 -0
  22. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/Gemfile +0 -0
  23. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/_config.yml +0 -0
  24. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/architecture.md +0 -0
  25. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/bridge.md +0 -0
  26. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/cli.md +0 -0
  27. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/configuration.md +0 -0
  28. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/development.md +0 -0
  29. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/feedback-loop.md +0 -0
  30. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/getting-started.md +0 -0
  31. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/index.md +0 -0
  32. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/operations.md +0 -0
  33. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/personalization.md +0 -0
  34. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/__init__.py +0 -0
  35. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__init__.py +0 -0
  36. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__main__.py +0 -0
  37. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/factory.py +0 -0
  38. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/handler.py +0 -0
  39. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  40. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/protocol.py +0 -0
  41. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  42. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/cli.py +0 -0
  43. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/__init__.py +0 -0
  44. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/archived.py +0 -0
  45. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/audit.py +0 -0
  46. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/checkpoint.py +0 -0
  47. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/code_review.py +0 -0
  48. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/config.py +0 -0
  49. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/dispatcher.py +0 -0
  50. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/github.py +0 -0
  51. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/network.py +0 -0
  52. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/obs.py +0 -0
  53. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/poller.py +0 -0
  54. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
  55. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
  56. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/question_expiry.py +0 -0
  57. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/scheduler.py +0 -0
  58. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/state.py +0 -0
  59. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/worktree.py +0 -0
  60. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
  61. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/client.py +0 -0
  62. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/gh_protocol.py +0 -0
  63. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/install.py +0 -0
  64. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/__init__.py +0 -0
  65. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/manager.py +0 -0
  66. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/paths.py +0 -0
  67. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
  68. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/base.py +0 -0
  69. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/dev.py +0 -0
  70. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  71. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/secops.py +0 -0
  72. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/task.py +0 -0
  73. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/setup.py +0 -0
  74. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/__init__.py +0 -0
  75. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  76. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  77. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  78. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  79. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  80. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  81. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  82. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/__init__.py +0 -0
  83. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/base.py +0 -0
  84. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/file_mock.py +0 -0
  85. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/socket_client.py +0 -0
  86. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/__init__.py +0 -0
  87. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/conftest.py +0 -0
  88. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_archived.py +0 -0
  89. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_audit.py +0 -0
  90. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_bridge_protocol.py +0 -0
  91. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_checkpoint.py +0 -0
  92. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_ci_wait.py +0 -0
  93. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_dev.py +0 -0
  94. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_repos.py +0 -0
  95. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_secops.py +0 -0
  96. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_start.py +0 -0
  97. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_version.py +0 -0
  98. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_code_review.py +0 -0
  99. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_config.py +0 -0
  100. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dashboard_client.py +0 -0
  101. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dev_integration.py +0 -0
  102. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dev_pipeline.py +0 -0
  103. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dispatcher.py +0 -0
  104. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_docs_site.py +0 -0
  105. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_gh_protocol.py +0 -0
  106. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_github.py +0 -0
  107. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_install.py +0 -0
  108. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_mattermost_handler.py +0 -0
  109. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_network.py +0 -0
  110. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_obs.py +0 -0
  111. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_personalization.py +0 -0
  112. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_pipeline_base.py +0 -0
  113. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_poller.py +0 -0
  114. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_post_merge.py +0 -0
  115. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_pr_verifier.py +0 -0
  116. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_pr_watcher.py +0 -0
  117. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_question_expiry.py +0 -0
  118. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_resume_prompt_contract.py +0 -0
  119. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_scheduler.py +0 -0
  120. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_secops_integration.py +0 -0
  121. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_secops_pipeline.py +0 -0
  122. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_setup.py +0 -0
  123. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_state.py +0 -0
  124. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_task_pipeline.py +0 -0
  125. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_telegram_handler.py +0 -0
  126. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_transport.py +0 -0
  127. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_transport_resolution.py +0 -0
  128. {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_worktree.py +0 -0
@@ -7,6 +7,12 @@ 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
+
10
16
  ## [0.12.1] - 2026-10-06
11
17
 
12
18
  ### Fixed
@@ -1683,7 +1689,8 @@ pipeline).
1683
1689
  per-phase implementation plans (Phase 0 through Phase 4).
1684
1690
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1685
1691
 
1686
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...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
1687
1694
  [0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
1688
1695
  [0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
1689
1696
  [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.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.12.1"
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
 
@@ -291,7 +291,7 @@ class TestBridgeServer:
291
291
  task.cancel()
292
292
 
293
293
  @pytest.mark.asyncio
294
- async def test_orphan_reply_refuses_to_guess_when_multiple_blocked(
294
+ async def test_orphan_reply_stays_silent_when_multiple_blocked(
295
295
  self, socket_path, tmp_path,
296
296
  ) -> None:
297
297
  """When two repos are BLOCKED at once and the reply text doesn't
@@ -334,12 +334,13 @@ class TestBridgeServer:
334
334
  assert len(unanswered) == 2
335
335
  assert all(r["answer"] is None for r in unanswered)
336
336
 
337
- # And the operator is told which session_ids are pending.
338
- sent_text = server.handler.send.await_args.args[0] # type: ignore[attr-defined]
339
- assert "multiple BLOCKED sessions" in sent_text.lower() or \
340
- "multiple" in sent_text
341
- assert "secops-owner-repoA-111" in sent_text
342
- assert "secops-owner-repoB-222" in sent_text
337
+ # And NOTHING is posted (#193). A message pointing at no question
338
+ # is somebody talking in the channel, not a failed answer. This
339
+ # used to post every pending session as one block - measured on
340
+ # the live channel at 4042 characters, fired by `ok` and by a
341
+ # message addressed to @here, which made the channel unusable for
342
+ # conversation.
343
+ server.handler.send.assert_not_awaited() # type: ignore[attr-defined]
343
344
 
344
345
  db.close()
345
346
  await server.stop()
@@ -518,7 +519,7 @@ class TestReplyRoutingIsStrict:
518
519
  shutil.rmtree(d, ignore_errors=True)
519
520
 
520
521
  @pytest.mark.asyncio
521
- async def test_fresh_message_refuses_when_several_questions_live(
522
+ async def test_fresh_message_is_ignored_when_several_questions_live(
522
523
  self, socket_path,
523
524
  ) -> None:
524
525
  from unittest.mock import AsyncMock
@@ -548,10 +549,12 @@ class TestReplyRoutingIsStrict:
548
549
  await asyncio.wait_for(reader.readline(), timeout=0.3)
549
550
  assert set(server._pending_questions) == {"r-1", "r-2"}
550
551
 
551
- server.handler.send.assert_awaited_once() # type: ignore[attr-defined]
552
- notice = server.handler.send.await_args.args[0] # type: ignore[attr-defined]
553
- assert "wasn't routed" in notice
554
- assert "reply" in notice.lower()
552
+ # Silent (#193). The tradeoff is stated rather than hidden:
553
+ # an answer typed without the reply gesture now gets no
554
+ # response at all, and the operator learns of it by the
555
+ # session not resuming. The alternative was a notice on every
556
+ # message typed in a channel the bridge shares with people.
557
+ server.handler.send.assert_not_awaited() # type: ignore[attr-defined]
555
558
  finally:
556
559
  writer.close()
557
560
  await writer.wait_closed()
@@ -798,11 +801,20 @@ class TestReplyRoutingIsStrict:
798
801
  await writer.drain()
799
802
  await asyncio.wait_for(reader.readline(), timeout=1)
800
803
 
801
- # Two live questions + plain message -> ambiguous notice path.
804
+ # A reply-to naming a post this bridge does not track, with
805
+ # questions still live -> the "pointed at a question I no
806
+ # longer track" notice. That path still posts.
807
+ #
808
+ # It used to drive the plain-message-with-several-live path,
809
+ # which #193 made silent on purpose. A lock test that observes
810
+ # the lock THROUGH a notice stops observing anything the day
811
+ # that notice is removed, and would have gone green while
812
+ # testing nothing had the assertion been `send` not awaited.
802
813
  await asyncio.wait_for(
803
- server._on_reply("yes", reply_to_post_id=None),
814
+ server._on_reply("yes", reply_to_post_id="not-a-tracked-post"),
804
815
  timeout=2,
805
816
  )
817
+ server.handler.send.assert_awaited() # type: ignore[attr-defined]
806
818
  assert lock_was_free.is_set()
807
819
  finally:
808
820
  writer.close()
@@ -1767,3 +1779,148 @@ class TestStartRunsTheHandlerPreflight:
1767
1779
  await task
1768
1780
  except asyncio.CancelledError:
1769
1781
  pass
1782
+
1783
+
1784
+ class TestTheBridgeSharesItsChannelWithPeople:
1785
+ """#193: a message pointing at no question is conversation, not an answer.
1786
+
1787
+ Measured on the live Mattermost channel on 2026-10-06: a `⚠️ Your reply
1788
+ wasn't routed` notice of **4042 characters over 29 lines**, listing
1789
+ every pending session, fired by `ok` and by a message addressed to
1790
+ `@here`. The operator could not talk to their team in the channel the
1791
+ bridge posts into.
1792
+
1793
+ It also had a hard ceiling. At ~202 characters per row and a server
1794
+ `MaxPostSize` of 16383 - read from that server, not assumed - roughly
1795
+ 81 blocked sessions make the notice exceed the limit and post
1796
+ **nothing**: the message whose job is to say a reply was dropped,
1797
+ dropped. This deployment sweeps 87 repos.
1798
+ """
1799
+
1800
+ @pytest.fixture
1801
+ def socket_path(self):
1802
+ # Same as the other classes: tmp_path can exceed AF_UNIX's
1803
+ # 104-char limit, so use a short directory.
1804
+ d = tempfile.mkdtemp()
1805
+ yield Path(d) / "b.sock"
1806
+ shutil.rmtree(d, ignore_errors=True)
1807
+
1808
+ @pytest.mark.asyncio
1809
+ async def test_chat_in_the_channel_produces_no_post(
1810
+ self, socket_path, tmp_path,
1811
+ ) -> None:
1812
+ from unittest.mock import AsyncMock
1813
+
1814
+ from ctrlrelay.bridge.server import BridgeServer
1815
+ from ctrlrelay.core.state import StateDB
1816
+
1817
+ db = StateDB(tmp_path / "state.db")
1818
+ for i in range(6):
1819
+ db.add_pending_resume(
1820
+ session_id=f"secops-owner-repo{i}-{i}{i}{i}",
1821
+ pipeline="secops",
1822
+ repo=f"owner/repo{i}",
1823
+ question=f"question {i}?",
1824
+ )
1825
+
1826
+ server = BridgeServer(
1827
+ socket_path=socket_path, handler=FakeChatHandler(), state_db=db,
1828
+ )
1829
+ task = asyncio.create_task(server.start())
1830
+ await asyncio.sleep(0.1)
1831
+ server.handler.send = AsyncMock() # type: ignore[attr-defined]
1832
+
1833
+ await server._on_reply(
1834
+ "@here por cierto, agregue a CTRLRelay aca", reply_to_post_id=None
1835
+ )
1836
+
1837
+ server.handler.send.assert_not_awaited() # type: ignore[attr-defined]
1838
+
1839
+ # And nothing was routed to anybody by accident.
1840
+ assert all(
1841
+ r["answer"] is None for r in db.list_unanswered_pending_resumes()
1842
+ )
1843
+
1844
+ db.close()
1845
+ await server.stop()
1846
+ task.cancel()
1847
+
1848
+ @pytest.mark.asyncio
1849
+ async def test_naming_a_session_still_routes(
1850
+ self, socket_path, tmp_path,
1851
+ ) -> None:
1852
+ """The half that must not regress.
1853
+
1854
+ Going quiet is only safe if the deliberate path still works. If
1855
+ this ever fails, the fix above has made the bridge unanswerable
1856
+ rather than merely quiet.
1857
+ """
1858
+ from unittest.mock import AsyncMock
1859
+
1860
+ from ctrlrelay.bridge.server import BridgeServer
1861
+ from ctrlrelay.core.state import StateDB
1862
+
1863
+ db = StateDB(tmp_path / "state.db")
1864
+ for i in range(6):
1865
+ db.add_pending_resume(
1866
+ session_id=f"secops-owner-repo{i}-{i}{i}{i}",
1867
+ pipeline="secops",
1868
+ repo=f"owner/repo{i}",
1869
+ question=f"question {i}?",
1870
+ )
1871
+
1872
+ server = BridgeServer(
1873
+ socket_path=socket_path, handler=FakeChatHandler(), state_db=db,
1874
+ )
1875
+ task = asyncio.create_task(server.start())
1876
+ await asyncio.sleep(0.1)
1877
+ server.handler.send = AsyncMock() # type: ignore[attr-defined]
1878
+
1879
+ await server._on_reply(
1880
+ "secops-owner-repo3-333 approved", reply_to_post_id=None
1881
+ )
1882
+
1883
+ row = db.get_pending_resume("secops-owner-repo3-333")
1884
+ assert row["answer"] == "secops-owner-repo3-333 approved"
1885
+ assert row["answered_at"] is not None
1886
+
1887
+ # and only that one
1888
+ others = [
1889
+ r for r in db.list_unanswered_pending_resumes()
1890
+ if r["session_id"] != "secops-owner-repo3-333"
1891
+ ]
1892
+ assert len(others) == 5
1893
+ assert all(r["answer"] is None for r in others)
1894
+
1895
+ db.close()
1896
+ await server.stop()
1897
+ task.cancel()
1898
+
1899
+ def test_the_pending_list_is_capped_and_says_how_many_it_withheld(
1900
+ self,
1901
+ ) -> None:
1902
+ """Truncating without the count is the same loss one level down."""
1903
+ from ctrlrelay.bridge.server import _PENDING_SHOWN, _format_pending
1904
+
1905
+ rows = [
1906
+ {"session_id": f"s-{i}", "repo": f"owner/r{i}", "question": "q" * 200}
1907
+ for i in range(40)
1908
+ ]
1909
+ out = _format_pending(rows)
1910
+
1911
+ assert out.count(" • ") == _PENDING_SHOWN
1912
+ assert f"… and {40 - _PENDING_SHOWN} more." in out
1913
+
1914
+ # The ceiling this exists for: comfortably under MaxPostSize even
1915
+ # at a count that used to overflow it.
1916
+ assert len(out) < 2000, len(out)
1917
+
1918
+ def test_a_short_list_says_nothing_about_withholding(self) -> None:
1919
+ """The paired case, so the cap is not asserted by a constant."""
1920
+ from ctrlrelay.bridge.server import _format_pending
1921
+
1922
+ rows = [{"session_id": "s-1", "repo": "owner/r1", "question": "q?"}]
1923
+ out = _format_pending(rows)
1924
+
1925
+ assert " • " in out
1926
+ assert "more." not in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes