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.
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/CHANGELOG.md +8 -1
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/PKG-INFO +1 -1
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/pyproject.toml +1 -1
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/server.py +86 -30
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_bridge_server.py +171 -14
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/.gitignore +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/LICENSE +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/README.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/SECURITY.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/Gemfile +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/_config.yml +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/architecture.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/bridge.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/cli.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/configuration.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/development.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/getting-started.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/index.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/operations.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/docs/personalization.md +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/__init__.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/conftest.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_archived.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_audit.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_config.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_github.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_install.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_network.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_obs.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_poller.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_setup.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_state.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_transport.py +0 -0
- {ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/tests/test_transport_resolution.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
@@ -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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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"] == "
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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 —
|
|
775
|
-
"
|
|
776
|
-
"
|
|
777
|
-
|
|
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
|
|
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,
|
|
942
|
-
|
|
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
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
#
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
{ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/bridge.service.template
RENAMED
|
File without changes
|
{ctrlrelay-0.12.1 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/poller.service.template
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|