ctrlrelay 0.11.3__tar.gz → 0.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/workflows/test.yml +1 -6
  2. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/CHANGELOG.md +28 -1
  3. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/PKG-INFO +18 -9
  4. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/README.md +15 -8
  5. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/config/orchestrator.yaml.example +33 -1
  6. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/bridge.md +129 -10
  7. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/configuration.md +57 -9
  8. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/pyproject.toml +8 -1
  9. ctrlrelay-0.12.1/src/ctrlrelay/bridge/__init__.py +34 -0
  10. ctrlrelay-0.12.1/src/ctrlrelay/bridge/__main__.py +120 -0
  11. ctrlrelay-0.12.1/src/ctrlrelay/bridge/factory.py +112 -0
  12. ctrlrelay-0.12.1/src/ctrlrelay/bridge/handler.py +95 -0
  13. ctrlrelay-0.12.1/src/ctrlrelay/bridge/mattermost_handler.py +443 -0
  14. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/server.py +389 -99
  15. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/telegram_handler.py +29 -12
  16. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/cli.py +276 -78
  17. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/config.py +101 -5
  18. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/state.py +16 -0
  19. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/__init__.py +7 -1
  20. ctrlrelay-0.12.1/src/ctrlrelay/pipelines/base.py +114 -0
  21. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/dev.py +58 -3
  22. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/secops.py +67 -7
  23. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/task.py +53 -3
  24. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/setup.py +51 -5
  25. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/__init__.py +11 -4
  26. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/socket_client.py +11 -1
  27. ctrlrelay-0.12.1/tests/conftest.py +126 -0
  28. ctrlrelay-0.12.1/tests/test_bridge_server.py +1769 -0
  29. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_cli_start.py +12 -4
  30. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_config.py +165 -0
  31. ctrlrelay-0.12.1/tests/test_docs_site.py +244 -0
  32. ctrlrelay-0.12.1/tests/test_mattermost_handler.py +808 -0
  33. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_obs.py +177 -183
  34. ctrlrelay-0.12.1/tests/test_pipeline_base.py +528 -0
  35. ctrlrelay-0.12.1/tests/test_resume_prompt_contract.py +255 -0
  36. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_setup.py +239 -0
  37. ctrlrelay-0.12.1/tests/test_telegram_handler.py +88 -0
  38. ctrlrelay-0.12.1/tests/test_transport_resolution.py +327 -0
  39. ctrlrelay-0.11.3/src/ctrlrelay/bridge/__init__.py +0 -21
  40. ctrlrelay-0.11.3/src/ctrlrelay/bridge/__main__.py +0 -89
  41. ctrlrelay-0.11.3/src/ctrlrelay/pipelines/base.py +0 -50
  42. ctrlrelay-0.11.3/tests/conftest.py +0 -59
  43. ctrlrelay-0.11.3/tests/test_bridge_server.py +0 -1067
  44. ctrlrelay-0.11.3/tests/test_docs_site.py +0 -112
  45. ctrlrelay-0.11.3/tests/test_pipeline_base.py +0 -33
  46. ctrlrelay-0.11.3/tests/test_telegram_handler.py +0 -45
  47. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  48. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  49. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  50. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/dependabot.yml +0 -0
  51. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/workflows/build.yml +0 -0
  52. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/workflows/cla.yml +0 -0
  53. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/workflows/pages.yml +0 -0
  54. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.github/workflows/publish.yml +0 -0
  55. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/.gitignore +0 -0
  56. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/CODE_OF_CONDUCT.md +0 -0
  57. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/CONTRIBUTING.md +0 -0
  58. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/LICENSE +0 -0
  59. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/SECURITY.md +0 -0
  60. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/Gemfile +0 -0
  61. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/_config.yml +0 -0
  62. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/architecture.md +0 -0
  63. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/cli.md +0 -0
  64. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/development.md +0 -0
  65. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/feedback-loop.md +0 -0
  66. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/getting-started.md +0 -0
  67. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/index.md +0 -0
  68. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/operations.md +0 -0
  69. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/docs/personalization.md +0 -0
  70. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/__init__.py +0 -0
  71. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  72. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/__init__.py +0 -0
  73. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/archived.py +0 -0
  74. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/audit.py +0 -0
  75. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  76. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/code_review.py +0 -0
  77. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  78. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/github.py +0 -0
  79. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/network.py +0 -0
  80. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/obs.py +0 -0
  81. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/poller.py +0 -0
  82. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  83. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  84. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/question_expiry.py +0 -0
  85. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/scheduler.py +0 -0
  86. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/core/worktree.py +0 -0
  87. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  88. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/dashboard/client.py +0 -0
  89. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/gh_protocol.py +0 -0
  90. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/install.py +0 -0
  91. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  92. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/manager.py +0 -0
  93. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/paths.py +0 -0
  94. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  95. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/__init__.py +0 -0
  96. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  97. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  98. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  99. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  100. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  101. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  102. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  103. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/base.py +0 -0
  104. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  105. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/__init__.py +0 -0
  106. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_archived.py +0 -0
  107. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_audit.py +0 -0
  108. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_bridge_protocol.py +0 -0
  109. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_checkpoint.py +0 -0
  110. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_cli_ci_wait.py +0 -0
  111. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_cli_dev.py +0 -0
  112. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_cli_repos.py +0 -0
  113. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_cli_secops.py +0 -0
  114. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_cli_version.py +0 -0
  115. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_code_review.py +0 -0
  116. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_dashboard_client.py +0 -0
  117. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_dev_integration.py +0 -0
  118. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_dev_pipeline.py +0 -0
  119. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_dispatcher.py +0 -0
  120. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_gh_protocol.py +0 -0
  121. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_github.py +0 -0
  122. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_install.py +0 -0
  123. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_network.py +0 -0
  124. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_personalization.py +0 -0
  125. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_poller.py +0 -0
  126. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_post_merge.py +0 -0
  127. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_pr_verifier.py +0 -0
  128. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_pr_watcher.py +0 -0
  129. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_question_expiry.py +0 -0
  130. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_scheduler.py +0 -0
  131. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_secops_integration.py +0 -0
  132. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_secops_pipeline.py +0 -0
  133. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_state.py +0 -0
  134. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_task_pipeline.py +0 -0
  135. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/tests/test_transport.py +0 -0
  136. {ctrlrelay-0.11.3 → ctrlrelay-0.12.1}/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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-10-06
11
+
12
+ ### Fixed
13
+
14
+ - Every checkpoint snippet creates its own directory (#190)
15
+ - Restate the checkpoint contract when resuming (#188)
16
+ - Run the whole suite, and make the next exclusion defendable (#186)
17
+
18
+ ### Changed
19
+
20
+ - Both-transports-at-once is not planned, not pending (#187)
21
+
22
+ ## [0.12.0] - 2026-10-06
23
+
24
+ ### Added
25
+
26
+ - Mattermost transport, and a transport-neutral bridge (#180)
27
+
28
+ ### Fixed
29
+
30
+ - Write the transport the operator asked for, and document the choice (#184)
31
+ - Resolve the bridge socket for whichever transport is set (#183)
32
+ - Say the real reason instead of "Unknown error" (#182)
33
+ - Refuse a reply whose reply-to names a non-routable session (#178)
34
+
10
35
  ## [0.11.3] - 2026-09-12
11
36
 
12
37
  ### Fixed
@@ -1658,7 +1683,9 @@ pipeline).
1658
1683
  per-phase implementation plans (Phase 0 through Phase 4).
1659
1684
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1660
1685
 
1661
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...HEAD
1686
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...HEAD
1687
+ [0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
1688
+ [0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
1662
1689
  [0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
1663
1690
  [0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
1664
1691
  [0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.11.3
3
+ Version: 0.12.1
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -25,11 +25,13 @@ Classifier: Topic :: Software Development :: Version Control :: Git
25
25
  Classifier: Topic :: System :: Systems Administration
26
26
  Requires-Python: >=3.12
27
27
  Requires-Dist: apscheduler>=3.10.0
28
+ Requires-Dist: httpx>=0.27.0
28
29
  Requires-Dist: pydantic>=2.0.0
29
30
  Requires-Dist: python-telegram-bot>=21.0
30
31
  Requires-Dist: pyyaml>=6.0.0
31
32
  Requires-Dist: rich>=13.0.0
32
33
  Requires-Dist: typer>=0.12.0
34
+ Requires-Dist: websockets>=12.0
33
35
  Provides-Extra: dev
34
36
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
35
37
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
@@ -48,7 +50,8 @@ Description-Content-Type: text/markdown
48
50
 
49
51
  > Local-first orchestrator for headless coding agents across your GitHub
50
52
  > repos. Watches for assigned issues, runs a dev pipeline in an isolated
51
- > git worktree, opens a PR, and asks you on Telegram when it gets stuck.
53
+ > git worktree, opens a PR, and asks you on Telegram or Mattermost when it
54
+ > gets stuck.
52
55
 
53
56
  ## Table of Contents
54
57
 
@@ -78,7 +81,7 @@ Your agent credentials, your GitHub credentials, your repos, your
78
81
  machine.
79
82
 
80
83
  Today `ctrlrelay` ships with a Claude Code (`claude -p`) backend. The
81
- orchestrator layer — worktrees, state DB, scheduler, Telegram bridge —
84
+ orchestrator layer — worktrees, state DB, scheduler, chat bridge —
82
85
  is agent-agnostic, and plug-in backends for other headless coding agents
83
86
  are on the roadmap (see [Roadmap](#roadmap)).
84
87
 
@@ -90,11 +93,15 @@ are on the roadmap (see [Roadmap](#roadmap)).
90
93
  and opens a PR. Label triggers let a teammate without rights on your
91
94
  account flag an issue as safe for the bot to pick up —
92
95
  see [`include_labels`][docs-config].
93
- - **Telegram bridge.** When a session hits a blocking question, the
94
- bridge relays it to you as a DM and resumes the session once you
95
- reply.
96
+ - **Chat bridge.** When a session hits a blocking question, the bridge
97
+ relays it to you and resumes the session once you reply. Choose
98
+ **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
99
+ server) with one `transport.type` setting. Exactly one is active at a
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].
96
103
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
97
- with a Telegram notification.
104
+ with a notification on whichever chat transport is configured.
98
105
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
99
106
  the poller daemon. Ships with a `secops` job that reviews Dependabot
100
107
  alerts and PRs daily at 6am; cron expressions follow standard Vixie
@@ -153,8 +160,10 @@ a launchd/systemd-supervised daemon.
153
160
  independent reviewer for the agent's output; you can disable it by
154
161
  setting `code_review.method: "none"` in your config if you prefer to
155
162
  skip the review step.
156
- - *(Optional)* a Telegram bot token if you want the bridge — see
157
- [Telegram bridge docs][docs-bridge].
163
+ - *(Optional)* if you want the bridge, credentials for one chat
164
+ transport: a **Telegram** bot token, or a **Mattermost** server URL,
165
+ bot token and channel ID — see [bridge docs][docs-bridge] and
166
+ [configuration][docs-config].
158
167
 
159
168
  ### Installation
160
169
 
@@ -9,7 +9,8 @@
9
9
 
10
10
  > Local-first orchestrator for headless coding agents across your GitHub
11
11
  > repos. Watches for assigned issues, runs a dev pipeline in an isolated
12
- > git worktree, opens a PR, and asks you on Telegram when it gets stuck.
12
+ > git worktree, opens a PR, and asks you on Telegram or Mattermost when it
13
+ > gets stuck.
13
14
 
14
15
  ## Table of Contents
15
16
 
@@ -39,7 +40,7 @@ Your agent credentials, your GitHub credentials, your repos, your
39
40
  machine.
40
41
 
41
42
  Today `ctrlrelay` ships with a Claude Code (`claude -p`) backend. The
42
- orchestrator layer — worktrees, state DB, scheduler, Telegram bridge —
43
+ orchestrator layer — worktrees, state DB, scheduler, chat bridge —
43
44
  is agent-agnostic, and plug-in backends for other headless coding agents
44
45
  are on the roadmap (see [Roadmap](#roadmap)).
45
46
 
@@ -51,11 +52,15 @@ are on the roadmap (see [Roadmap](#roadmap)).
51
52
  and opens a PR. Label triggers let a teammate without rights on your
52
53
  account flag an issue as safe for the bot to pick up —
53
54
  see [`include_labels`][docs-config].
54
- - **Telegram bridge.** When a session hits a blocking question, the
55
- bridge relays it to you as a DM and resumes the session once you
56
- reply.
55
+ - **Chat bridge.** When a session hits a blocking question, the bridge
56
+ relays it to you and resumes the session once you reply. Choose
57
+ **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
58
+ server) with one `transport.type` setting. Exactly one is active at a
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].
57
62
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
58
- with a Telegram notification.
63
+ with a notification on whichever chat transport is configured.
59
64
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
60
65
  the poller daemon. Ships with a `secops` job that reviews Dependabot
61
66
  alerts and PRs daily at 6am; cron expressions follow standard Vixie
@@ -114,8 +119,10 @@ a launchd/systemd-supervised daemon.
114
119
  independent reviewer for the agent's output; you can disable it by
115
120
  setting `code_review.method: "none"` in your config if you prefer to
116
121
  skip the review step.
117
- - *(Optional)* a Telegram bot token if you want the bridge — see
118
- [Telegram bridge docs][docs-bridge].
122
+ - *(Optional)* if you want the bridge, credentials for one chat
123
+ transport: a **Telegram** bot token, or a **Mattermost** server URL,
124
+ bot token and channel ID — see [bridge docs][docs-bridge] and
125
+ [configuration][docs-config].
119
126
 
120
127
  ### Installation
121
128
 
@@ -31,8 +31,22 @@ agent:
31
31
  default_timeout_seconds: 1800
32
32
  output_format: "json"
33
33
 
34
+ # EXACTLY ONE transport is active, and it is the one named by `type`.
35
+ # The other blocks below are inert: they may stay in the file, but nothing
36
+ # reads them. Running Telegram and Mattermost at the same time is NOT
37
+ # supported and NOT planned - designed in issue #176 and closed won't-do.
38
+ #
39
+ # Choose:
40
+ # type: "telegram" production, 1:1 chat with a bot
41
+ # type: "mattermost" production, a channel on your own server
42
+ # type: "file_mock" no chat at all; questions are written to files.
43
+ # Blocked sessions still persist and can be resumed
44
+ # later, but nobody is asked anything.
45
+ #
46
+ # Leaving a stale block for the transport you are NOT using is harmless,
47
+ # but delete it if you want the file to say what is true.
34
48
  transport:
35
- type: "file_mock" # Use "telegram" for production
49
+ type: "file_mock" # "telegram" or "mattermost" for production
36
50
  telegram:
37
51
  bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"
38
52
  chat_id: 123456789
@@ -44,6 +58,24 @@ transport:
44
58
  # to cover being asleep: a sweep runs its repos one at a time and
45
59
  # holds each repo's lock and worktree for the whole wait.
46
60
  ask_timeout_seconds: 900
61
+ mattermost:
62
+ url: "https://chat.example.com"
63
+ bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
64
+ # The 26-character channel ID, not the channel name: a name is only
65
+ # unique within a team and can be renamed under you. Channel menu ->
66
+ # View Info, or GET /api/v4/teams/name/{team}/channels/name/{channel}.
67
+ #
68
+ # The bot must be a MEMBER of this channel. A bot without `post:all`
69
+ # cannot post where it is not a member - and, the part that is easy to
70
+ # miss, it does not receive that channel's reply events either, so
71
+ # answers would silently never arrive.
72
+ channel_id: "abcdefghijklmnopqrstuvwxyz"
73
+ socket_path: "~/.ctrlrelay/ctrlrelay.sock"
74
+ ask_timeout_seconds: 900
75
+ # How long an UNANSWERED question stays routable before it is retired.
76
+ # Read from whichever transport is active, so a Mattermost deployment
77
+ # takes this value and not the telegram block's.
78
+ question_ttl_seconds: 172800
47
79
  file_mock:
48
80
  inbox: "~/.ctrlrelay/inbox.txt"
49
81
  outbox: "~/.ctrlrelay/outbox.txt"
@@ -1,19 +1,47 @@
1
1
  ---
2
- title: Telegram bridge
2
+ title: Chat bridge
3
3
  layout: default
4
4
  nav_order: 4
5
- description: "Set up the Telegram bridge that delivers BLOCKED questions to a human and routes their answers back to a paused Claude session."
5
+ description: "Set up the chat bridge — Telegram or Mattermost — that delivers BLOCKED questions to a human and routes their answers back to a paused Claude session."
6
6
  permalink: /bridge/
7
7
  ---
8
8
 
9
- # Telegram bridge
9
+ # Chat bridge
10
10
 
11
11
  The bridge is a small daemon that:
12
12
 
13
- 1. Listens on a Unix socket at `transport.telegram.socket_path`.
14
- 2. Forwards messages from ctrlrelay to a Telegram chat over the Bot API.
15
- 3. Long-polls Telegram for replies in that chat and delivers them back to the
16
- socket client that asked.
13
+ 1. Listens on a Unix socket at the configured transport's `socket_path`.
14
+ 2. Forwards messages from ctrlrelay to a chat channel.
15
+ 3. Streams replies back from that channel and delivers them to the socket
16
+ client that asked.
17
+
18
+ It speaks **Telegram** or **Mattermost**. Pipelines never know which: they
19
+ talk to the socket, and the chat app is the bridge's business. Switching is a
20
+ one-line config change and no pipeline code moves.
21
+
22
+ **One at a time.** `transport.type` names the transport that is active, and the
23
+ other block is inert — it may stay in the file, but nothing reads it, including
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 **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.
30
+
31
+ | | Telegram | Mattermost |
32
+ |---|---|---|
33
+ | Needs | a bot from BotFather, your chat id | a self-hosted server, a bot account, a channel id |
34
+ | Outbound | Bot API `sendMessage` | `POST /api/v4/posts` |
35
+ | Inbound | `getUpdates` long-poll | WebSocket `posted` events |
36
+ | "answer this one" | the reply gesture | reply **in the question's thread** |
37
+ | Tappable choices | reply keyboard | rendered as a numbered list you type |
38
+
39
+ **The one operator-facing difference worth knowing.** When several questions
40
+ are outstanding, the bridge can only route your answer if it knows which
41
+ question you mean. On Telegram that is Telegram's reply; on Mattermost it is
42
+ **replying inside the question's thread**. A loose message in the channel is
43
+ only routable when exactly one question is waiting — otherwise the bridge
44
+ refuses and tells you what is outstanding, rather than guessing.
17
45
 
18
46
  Pipelines use it as the human-in-the-loop channel: when Claude writes a
19
47
  `BLOCKED_NEEDS_INPUT` checkpoint, the dev pipeline calls `transport.ask(question)`,
@@ -108,6 +136,91 @@ Stop it:
108
136
  ctrlrelay bridge stop
109
137
  ```
110
138
 
139
+ ## Mattermost instead of Telegram
140
+
141
+ Steps 1–2 above are Telegram-specific. For Mattermost, do this instead and
142
+ then rejoin at step 4.
143
+
144
+ ### M1 — Enable bot accounts
145
+
146
+ **System Console → Integrations → Bot Accounts → Enable Bot Account Creation
147
+ = true.**
148
+
149
+ On a self-hosted install this is off by default. Note that editing
150
+ `config.json` on the server may not be enough — if the instance has no config
151
+ watcher the value will not take effect until `systemctl restart mattermost`,
152
+ and the live value is what matters. Read it back before believing it:
153
+
154
+ ```bash
155
+ curl -s "https://<your-server>/api/v4/config/client?format=old" \
156
+ | jq .EnableBotAccountCreation
157
+ ```
158
+
159
+ `EnableUserAccessTokens` is **not** required. That setting governs *user*
160
+ personal access tokens; bot account tokens are managed separately. Leaving it
161
+ off avoids letting every user mint long-lived full-access tokens.
162
+
163
+ ### M2 — Create the bot and its token
164
+
165
+ 1. **Integrations → Bot Accounts → Add Bot Account.**
166
+ 2. Username `ctrlrelay`, role **Member**. Leave `post:all` and `post:channels`
167
+ **off** — the bot will be added to one channel, and a token with no reach
168
+ beyond it is a smaller problem if it leaks.
169
+ 3. **Create New Token** and copy it immediately. Mattermost shows it once.
170
+
171
+ ### M3 — A channel, and put the bot in it
172
+
173
+ Create a channel for orchestrator questions, then invite the bot:
174
+
175
+ ```
176
+ /invite @ctrlrelay
177
+ ```
178
+
179
+ **This is not optional, and the reason is easy to miss.** A bot without
180
+ `post:all` cannot post where it is not a member — and it does not receive the
181
+ reply events for such a channel either. Forget this and questions fail to post
182
+ *and* answers would never arrive. `ctrlrelay bridge start` checks membership
183
+ before it binds its socket and refuses to start until the bot is invited.
184
+
185
+ The bot must also be on the team. If `/invite` complains, add it via the
186
+ team's **Invite People** first.
187
+
188
+ ### M4 — Find the channel id
189
+
190
+ The id, not the name: a name is only unique within a team and can be renamed
191
+ under you, while the id is stable. From the channel's **View Info**, or:
192
+
193
+ ```bash
194
+ curl -s -H "Authorization: Bearer $CTRLRELAY_MATTERMOST_TOKEN" \
195
+ "https://<your-server>/api/v4/teams/name/<team>/channels/name/<channel>" \
196
+ | jq -r .id
197
+ ```
198
+
199
+ ### M5 — Configure
200
+
201
+ ```bash
202
+ export CTRLRELAY_MATTERMOST_TOKEN="your-bot-token"
203
+ ```
204
+
205
+ ```yaml
206
+ transport:
207
+ type: "mattermost"
208
+ mattermost:
209
+ url: "https://chat.example.com" # scheme required
210
+ bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
211
+ channel_id: "emzhur1hwpyc38ehkfm5ppym8y"
212
+ socket_path: "~/.ctrlrelay/ctrlrelay.sock"
213
+ ask_timeout_seconds: 900
214
+ ```
215
+
216
+ Then `ctrlrelay config validate`, and continue from step 4 above —
217
+ `ctrlrelay bridge start` is the same command for either transport.
218
+
219
+ **Editions.** Everything here works on free self-hosted Mattermost. The REST
220
+ API, the WebSocket, bot accounts and bot tokens are core features, not
221
+ licensed ones; the paid tiers cover SSO/SAML/LDAP, compliance export and high
222
+ availability, none of which the bridge touches.
223
+
111
224
  ## 5 — Send a test message
112
225
 
113
226
  Once the bridge is running and reachable on its socket, send a one-off message
@@ -186,17 +299,23 @@ no running process", remove the orphan socket file (`rm
186
299
  ## Sequence: BLOCKED question round-trip
187
300
 
188
301
  ```
189
- pipeline bridge Telegram user
302
+ pipeline bridge chat app user
190
303
  │ │ │ │
191
304
  │── ask("Which?") ────>│ │ │
192
- │ │── sendMessage ──────>│ │
305
+ │ │─ post question ─────>│ │
193
306
  │ │ │── push ──────>│
194
307
  │ │ │ │
195
308
  │ │ │<── reply ─────│
196
- │ │<── getUpdates ───────│ │
309
+ │ │<─ reply + post id ───│ │
197
310
  │<── answer("the b") ──│ │ │
198
311
  ```
199
312
 
313
+ The bridge remembers the id of every question it posts, so the reply's
314
+ "this is what I am answering" — Telegram's `reply_to_message_id`, Mattermost's
315
+ thread `root_id` — names the session exactly. That is also how an answer
316
+ arriving *after* the pipeline stopped waiting still drives a resume, via the
317
+ `pending_resumes` table.
318
+
200
319
  The pipeline's `transport.ask()` call blocks (with the configured timeout) until
201
320
  the bridge returns the answer. The pipeline then resumes the Claude session via
202
321
  `claude --resume <session_id>` with a prompt of the form "User answered: …".
@@ -91,26 +91,53 @@ intentional — the orchestrator runs unattended.
91
91
  ## transport
92
92
 
93
93
  The transport carries `BLOCKED_NEEDS_INPUT` questions out of ctrlrelay to a
94
- human and routes the answer back. Pick one of two types.
94
+ human and routes the answer back.
95
+
96
+ ### Choosing one
97
+
98
+ **Exactly one transport is active: the one named by `type`.** Every other
99
+ block in the file is inert — it may stay there, but nothing reads it.
100
+
101
+ | You want | Set `type` to | You need |
102
+ |---|---|---|
103
+ | A 1:1 chat with a bot | `"telegram"` | A bot token and your chat ID. |
104
+ | A channel on your own server | `"mattermost"` | A server URL, a bot token, and the channel's 26-character ID. |
105
+ | No chat at all | `"file_mock"` | Nothing. Questions go to a file; **nobody is asked anything.** |
106
+
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.
112
+
113
+ Switching is a two-line change — set `type`, add that transport's block — plus
114
+ a restart of **both** daemons, which share `state.db`.
115
+
116
+ Every per-transport setting below is read from the **active** transport. A
117
+ Mattermost deployment takes `question_ttl_seconds` from the `mattermost` block,
118
+ not from a leftover `telegram` one.
95
119
 
96
120
  ```yaml
97
121
  transport:
98
- type: "telegram" # or "file_mock"
99
- telegram:
100
- bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"
101
- chat_id: 123456789
122
+ type: "mattermost" # or "telegram", or "file_mock"
123
+ mattermost:
124
+ url: "https://chat.example.com"
125
+ bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
126
+ channel_id: "abcdefghijklmnopqrstuvwxyz"
102
127
  socket_path: "~/.ctrlrelay/ctrlrelay.sock"
103
- file_mock:
104
- inbox: "~/.ctrlrelay/inbox.txt"
105
- outbox: "~/.ctrlrelay/outbox.txt"
106
128
  ```
107
129
 
108
130
  | Key | Type | Required | Default | Description |
109
131
  |---|---|---|---|---|
110
- | `type` | enum | no | `"file_mock"` | One of `"telegram"`, `"file_mock"`. |
132
+ | `type` | enum | no | `"file_mock"` | One of `"telegram"`, `"mattermost"`, `"file_mock"`. |
111
133
  | `telegram` | object | required when `type=telegram` | — | Telegram bridge settings — see below. |
134
+ | `mattermost` | object | required when `type=mattermost` | — | Mattermost bridge settings — see below. |
112
135
  | `file_mock` | object | required when `type=file_mock` | — | Local-file fake transport, used for tests/dev. |
113
136
 
137
+ The block for the selected type is **required**: a config naming a type whose
138
+ block is absent is refused at load, loudly, rather than starting with no way to
139
+ reach anybody.
140
+
114
141
  ### transport.telegram
115
142
 
116
143
  | Key | Type | Default | Description |
@@ -123,6 +150,27 @@ transport:
123
150
 
124
151
  See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
125
152
 
153
+ ### transport.mattermost
154
+
155
+ | Key | Type | Default | Description |
156
+ |---|---|---|---|
157
+ | `url` | string | — | Base URL of your Mattermost server, e.g. `https://chat.example.com`. Required. |
158
+ | `bot_token_env` | string | `"CTRLRELAY_MATTERMOST_TOKEN"` | Name of the environment variable holding the bot token. ctrlrelay never reads the token directly — only the variable name. |
159
+ | `channel_id` | string | `""` | The channel's **26-character ID**, not its name: a name is only unique within a team and can be renamed under you. Find it in the channel menu under *View Info*, or via `GET /api/v4/teams/name/{team}/channels/name/{channel}`. |
160
+ | `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
161
+ | `ask_timeout_seconds` | int | `900` | Same meaning as the Telegram key above: it bounds only the in-session wait, and a later reply still resolves through `pending_resumes`. |
162
+ | `question_ttl_seconds` | int | `172800` (48h) | Same meaning as the Telegram key above. |
163
+
164
+ **The bot must be a member of that channel.** A bot without `post:all` cannot
165
+ post where it is not a member — and, the part that is easy to miss, it does not
166
+ receive that channel's reply events either, so answers would silently never
167
+ arrive.
168
+
169
+ Outbound is `POST /api/v4/posts`; inbound is a WebSocket on
170
+ `/api/v4/websocket` filtered to the one channel. A threaded reply's `root_id`
171
+ equals the question's post ID, which is how an answer is matched to its
172
+ question — the same role `reply_to_message_id` plays on Telegram.
173
+
126
174
  ### transport.file_mock
127
175
 
128
176
  | Key | Type | Required | Description |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.11.3"
3
+ version = "0.12.1"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -32,6 +32,13 @@ dependencies = [
32
32
  "rich>=13.0.0",
33
33
  "python-telegram-bot>=21.0",
34
34
  "apscheduler>=3.10.0",
35
+ # Mattermost transport: REST out, WebSocket in. Both are permissive
36
+ # licences (httpx BSD-3-Clause, websockets BSD-3-Clause) and actively
37
+ # maintained. A Mattermost-specific client library was considered and
38
+ # skipped: the surface used here is four endpoints and one event type,
39
+ # which is less code than keeping a wrapper current.
40
+ "httpx>=0.27.0",
41
+ "websockets>=12.0",
35
42
  ]
36
43
 
37
44
  [project.optional-dependencies]
@@ -0,0 +1,34 @@
1
+ """Bridge process: a Unix socket on one side, a chat app on the other."""
2
+
3
+ from ctrlrelay.bridge.factory import (
4
+ HandlerConfigError,
5
+ make_handler,
6
+ token_env_var,
7
+ )
8
+ from ctrlrelay.bridge.handler import ChatHandler, IncomingMessageHandler
9
+ from ctrlrelay.bridge.mattermost_handler import MattermostHandler
10
+ from ctrlrelay.bridge.protocol import (
11
+ BridgeMessage,
12
+ BridgeOp,
13
+ ProtocolError,
14
+ parse_message,
15
+ serialize_message,
16
+ )
17
+ from ctrlrelay.bridge.server import BridgeServer
18
+ from ctrlrelay.bridge.telegram_handler import TelegramHandler
19
+
20
+ __all__ = [
21
+ "BridgeMessage",
22
+ "BridgeOp",
23
+ "BridgeServer",
24
+ "ChatHandler",
25
+ "HandlerConfigError",
26
+ "IncomingMessageHandler",
27
+ "MattermostHandler",
28
+ "ProtocolError",
29
+ "TelegramHandler",
30
+ "make_handler",
31
+ "parse_message",
32
+ "serialize_message",
33
+ "token_env_var",
34
+ ]
@@ -0,0 +1,120 @@
1
+ """Bridge process entry point for daemon mode.
2
+
3
+ Takes a config path rather than per-transport flags. The alternative —
4
+ growing a flag per chat app — means every new transport touches this file,
5
+ the CLI that spawns it, and the two of them have to agree. Reading the same
6
+ config the CLI read removes that agreement from the design.
7
+
8
+ The token is never an argument. It is read from the environment variable
9
+ the config names, which the parent process passes through, because argv is
10
+ world-readable via ``ps`` and ``/proc/*/cmdline``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import signal
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ctrlrelay.bridge import BridgeServer, HandlerConfigError, make_handler
22
+ from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
23
+
24
+
25
+ def main() -> None:
26
+ parser = argparse.ArgumentParser(description="ctrlrelay chat bridge")
27
+ parser.add_argument(
28
+ "--config",
29
+ default=None,
30
+ help="Path to orchestrator.yaml (default: auto-discover).",
31
+ )
32
+ parser.add_argument(
33
+ "--socket-path",
34
+ default=None,
35
+ help="Unix socket path. Defaults to the configured transport's.",
36
+ )
37
+ parser.add_argument(
38
+ "--state-db",
39
+ default=None,
40
+ help=(
41
+ "Path to the orchestrator state.db. When provided, orphan "
42
+ "replies route to persisted BLOCKED sessions in "
43
+ "pending_resumes. Required for the resume-via-chat flow."
44
+ ),
45
+ )
46
+ args = parser.parse_args()
47
+
48
+ try:
49
+ config = load_config(resolve_config_path(args.config))
50
+ except ConfigError as e:
51
+ print(f"error: {e}", file=sys.stderr)
52
+ sys.exit(2)
53
+
54
+ try:
55
+ handler = make_handler(config.transport)
56
+ except HandlerConfigError as e:
57
+ # Exit 2, not 1: this is a configuration fault the operator must
58
+ # fix, not a transient failure worth a supervisor restart loop.
59
+ print(f"error: {e}", file=sys.stderr)
60
+ sys.exit(2)
61
+
62
+ socket_path = (
63
+ Path(args.socket_path).expanduser()
64
+ if args.socket_path
65
+ else config.transport.socket_path
66
+ )
67
+ if socket_path is None:
68
+ print("error: no socket path in config or arguments", file=sys.stderr)
69
+ sys.exit(2)
70
+
71
+ state_db = None
72
+ if args.state_db:
73
+ from ctrlrelay.core.state import StateDB
74
+ state_db = StateDB(Path(args.state_db))
75
+
76
+ server = BridgeServer(
77
+ socket_path=Path(socket_path),
78
+ handler=handler,
79
+ state_db=state_db,
80
+ )
81
+
82
+ loop = asyncio.new_event_loop()
83
+ asyncio.set_event_loop(loop)
84
+
85
+ async def _run_server() -> None:
86
+ # Wrap start() in a finally that awaits stop() so the loop cannot
87
+ # close before the handler is closed and the socket unlinked.
88
+ try:
89
+ await server.start()
90
+ finally:
91
+ await server.stop()
92
+
93
+ main_task = loop.create_task(_run_server())
94
+
95
+ def handle_signal(sig: int) -> None:
96
+ main_task.cancel()
97
+
98
+ for sig in (signal.SIGTERM, signal.SIGINT):
99
+ loop.add_signal_handler(sig, handle_signal, sig)
100
+
101
+ try:
102
+ loop.run_until_complete(main_task)
103
+ except asyncio.CancelledError:
104
+ pass
105
+ except HandlerConfigError as e:
106
+ # The server's startup preflight: same class of fault as a bad
107
+ # token above, reported the same way and with the same exit code.
108
+ print(f"error: {e}", file=sys.stderr)
109
+ sys.exit(2)
110
+ finally:
111
+ loop.close()
112
+ if state_db is not None:
113
+ try:
114
+ state_db.close()
115
+ except Exception:
116
+ pass
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()