ctrlrelay 0.12.0__tar.gz → 0.12.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/test.yml +1 -6
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/CHANGELOG.md +21 -1
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/PKG-INFO +4 -4
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/README.md +3 -3
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/config/orchestrator.yaml.example +1 -1
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/bridge.md +5 -3
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/configuration.md +5 -4
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/pyproject.toml +1 -1
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/server.py +86 -30
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/dev.py +42 -2
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/secops.py +42 -2
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/task.py +44 -2
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_bridge_server.py +171 -14
- ctrlrelay-0.12.2/tests/test_docs_site.py +244 -0
- ctrlrelay-0.12.2/tests/test_resume_prompt_contract.py +255 -0
- ctrlrelay-0.12.0/tests/test_docs_site.py +0 -112
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/.gitignore +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/LICENSE +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/SECURITY.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/Gemfile +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/_config.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/architecture.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/cli.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/development.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/getting-started.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/index.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/operations.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/docs/personalization.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/conftest.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_archived.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_audit.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_config.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_github.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_install.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_network.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_obs.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_poller.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_setup.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_state.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_transport.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.2}/tests/test_worktree.py +0 -0
|
@@ -37,9 +37,4 @@ jobs:
|
|
|
37
37
|
run: uv run ruff check src/ tests/
|
|
38
38
|
|
|
39
39
|
- name: Pytest
|
|
40
|
-
|
|
41
|
-
# nav_order uniqueness across docs/ — tracked separately and
|
|
42
|
-
# unchanged by this PR.
|
|
43
|
-
run: |
|
|
44
|
-
uv run pytest tests/ -v \
|
|
45
|
-
--deselect tests/test_docs_site.py::test_nav_order_unique_per_sibling_group
|
|
40
|
+
run: uv run pytest tests/ -v
|
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.2] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Stay quiet when somebody is talking in the channel (#194)
|
|
15
|
+
|
|
16
|
+
## [0.12.1] - 2026-10-06
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Every checkpoint snippet creates its own directory (#190)
|
|
21
|
+
- Restate the checkpoint contract when resuming (#188)
|
|
22
|
+
- Run the whole suite, and make the next exclusion defendable (#186)
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Both-transports-at-once is not planned, not pending (#187)
|
|
27
|
+
|
|
10
28
|
## [0.12.0] - 2026-10-06
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -1671,7 +1689,9 @@ pipeline).
|
|
|
1671
1689
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1672
1690
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1673
1691
|
|
|
1674
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.
|
|
1692
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...HEAD
|
|
1693
|
+
[0.12.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...v0.12.2
|
|
1694
|
+
[0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
|
|
1675
1695
|
[0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
|
|
1676
1696
|
[0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
|
|
1677
1697
|
[0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.2
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -97,9 +97,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
|
|
|
97
97
|
relays it to you and resumes the session once you reply. Choose
|
|
98
98
|
**Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
|
|
99
99
|
server) with one `transport.type` setting. Exactly one is active at a
|
|
100
|
-
time; running both at once is not supported
|
|
101
|
-
([#176](https://github.com/AInvirion/ctrlrelay/issues/176)
|
|
102
|
-
[configuration][docs-config].
|
|
100
|
+
time; running both at once is not supported and not planned
|
|
101
|
+
([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
|
|
102
|
+
won't-do). See [configuration][docs-config].
|
|
103
103
|
- **PR watcher.** Tracks the opened PR to merge and closes the loop
|
|
104
104
|
with a notification on whichever chat transport is configured.
|
|
105
105
|
- **In-process scheduler** (APScheduler). Runs periodic jobs inside
|
|
@@ -56,9 +56,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
|
|
|
56
56
|
relays it to you and resumes the session once you reply. Choose
|
|
57
57
|
**Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
|
|
58
58
|
server) with one `transport.type` setting. Exactly one is active at a
|
|
59
|
-
time; running both at once is not supported
|
|
60
|
-
([#176](https://github.com/AInvirion/ctrlrelay/issues/176)
|
|
61
|
-
[configuration][docs-config].
|
|
59
|
+
time; running both at once is not supported and not planned
|
|
60
|
+
([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
|
|
61
|
+
won't-do). See [configuration][docs-config].
|
|
62
62
|
- **PR watcher.** Tracks the opened PR to merge and closes the loop
|
|
63
63
|
with a notification on whichever chat transport is configured.
|
|
64
64
|
- **In-process scheduler** (APScheduler). Runs periodic jobs inside
|
|
@@ -34,7 +34,7 @@ agent:
|
|
|
34
34
|
# EXACTLY ONE transport is active, and it is the one named by `type`.
|
|
35
35
|
# The other blocks below are inert: they may stay in the file, but nothing
|
|
36
36
|
# reads them. Running Telegram and Mattermost at the same time is NOT
|
|
37
|
-
# supported
|
|
37
|
+
# supported and NOT planned - designed in issue #176 and closed won't-do.
|
|
38
38
|
#
|
|
39
39
|
# Choose:
|
|
40
40
|
# type: "telegram" production, 1:1 chat with a bot
|
|
@@ -22,9 +22,11 @@ one-line config change and no pipeline code moves.
|
|
|
22
22
|
**One at a time.** `transport.type` names the transport that is active, and the
|
|
23
23
|
other block is inert — it may stay in the file, but nothing reads it, including
|
|
24
24
|
its `question_ttl_seconds`. There is no fallback: a question goes to the one
|
|
25
|
-
you named or nowhere. Running both at once is
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
you named or nowhere. Running both at once is **not supported and not
|
|
26
|
+
planned** — it was designed in
|
|
27
|
+
[#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed
|
|
28
|
+
won't-do. Reopen that issue rather than re-deriving the design if you need
|
|
29
|
+
it.
|
|
28
30
|
|
|
29
31
|
| | Telegram | Mattermost |
|
|
30
32
|
|---|---|---|
|
|
@@ -104,10 +104,11 @@ block in the file is inert — it may stay there, but nothing reads it.
|
|
|
104
104
|
| A channel on your own server | `"mattermost"` | A server URL, a bot token, and the channel's 26-character ID. |
|
|
105
105
|
| No chat at all | `"file_mock"` | Nothing. Questions go to a file; **nobody is asked anything.** |
|
|
106
106
|
|
|
107
|
-
**Running Telegram and Mattermost at the same time is not supported
|
|
108
|
-
`type` to one does not fall back to the other, and a
|
|
109
|
-
one place or not at all.
|
|
110
|
-
[
|
|
107
|
+
**Running Telegram and Mattermost at the same time is not supported, and is
|
|
108
|
+
not planned.** Setting `type` to one does not fall back to the other, and a
|
|
109
|
+
question is delivered to one place or not at all. It was designed in
|
|
110
|
+
[#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed won't-do;
|
|
111
|
+
the design is still in that issue if anyone wants it.
|
|
111
112
|
|
|
112
113
|
Switching is a two-line change — set `type`, add that transport's block — plus
|
|
113
114
|
a restart of **both** daemons, which share `state.db`.
|
|
@@ -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
|
|
|
@@ -318,7 +318,20 @@ class DevPipeline:
|
|
|
318
318
|
|
|
319
319
|
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
320
320
|
"""Resume blocked dev session with user answer."""
|
|
321
|
-
|
|
321
|
+
# The checkpoint contract is restated here, not just in the
|
|
322
|
+
# initial prompt (#173). A resumed agent that is told "continue"
|
|
323
|
+
# and nothing else can do its work, exit 0, write no state, and
|
|
324
|
+
# be indistinguishable from one that crashed - which is what
|
|
325
|
+
# happened on 2026-09-16, after three of four pull requests had
|
|
326
|
+
# already been merged.
|
|
327
|
+
contract = self._checkpoint_contract(
|
|
328
|
+
str(ctx.state_file), ctx.session_id
|
|
329
|
+
)
|
|
330
|
+
prompt = (
|
|
331
|
+
f"User answered: {answer}\n\n"
|
|
332
|
+
"Continue from where you left off.\n\n"
|
|
333
|
+
f"{contract}"
|
|
334
|
+
)
|
|
322
335
|
|
|
323
336
|
# Resume uses Claude's own session UUID (captured on the first spawn
|
|
324
337
|
# and persisted to state_db). Passing our composite id here makes
|
|
@@ -444,7 +457,34 @@ Do NOT merge the PR - wait for human review.
|
|
|
444
457
|
The orchestrator re-verifies CI and mergeability after you hand off; if it
|
|
445
458
|
finds the PR broken it will resume this session asking you to fix it.
|
|
446
459
|
|
|
447
|
-
|
|
460
|
+
{self._checkpoint_contract(state_file_path, session_id)}"""
|
|
461
|
+
|
|
462
|
+
def _checkpoint_contract(
|
|
463
|
+
self, state_file_path: str, session_id: str
|
|
464
|
+
) -> str:
|
|
465
|
+
"""How the agent signals how it stopped.
|
|
466
|
+
|
|
467
|
+
Extracted so the RESUME prompt can carry it too (#173). The
|
|
468
|
+
initial prompt spent ~30 lines establishing this; the resume
|
|
469
|
+
prompt was 58 characters and said only "continue", so a resumed
|
|
470
|
+
agent could do its work, exit 0, write no state, and be
|
|
471
|
+
indistinguishable from one that crashed.
|
|
472
|
+
|
|
473
|
+
Measured case, 2026-09-16:
|
|
474
|
+
secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
|
|
475
|
+
"Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
|
|
476
|
+
in fact merged three of four pull requests. The operator was told
|
|
477
|
+
the resume failed, which was wrong in both directions: most of
|
|
478
|
+
the work had landed, and the one thing outstanding was never
|
|
479
|
+
named.
|
|
480
|
+
|
|
481
|
+
Per-pipeline rather than shared, because the three contracts
|
|
482
|
+
differ in substance and not only wording: the dev DONE block
|
|
483
|
+
carries pr_url and pr_number outputs, and each names different
|
|
484
|
+
conditions for DONE. One shared block would have quietly changed
|
|
485
|
+
what two of them promise.
|
|
486
|
+
"""
|
|
487
|
+
return f"""## Signaling Completion
|
|
448
488
|
|
|
449
489
|
**CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
|
|
450
490
|
|
|
@@ -126,7 +126,20 @@ class SecopsPipeline:
|
|
|
126
126
|
|
|
127
127
|
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
128
128
|
"""Resume blocked secops with user answer."""
|
|
129
|
-
|
|
129
|
+
# The checkpoint contract is restated here, not just in the
|
|
130
|
+
# initial prompt (#173). A resumed agent that is told "continue"
|
|
131
|
+
# and nothing else can do its work, exit 0, write no state, and
|
|
132
|
+
# be indistinguishable from one that crashed - which is what
|
|
133
|
+
# happened on 2026-09-16, after three of four pull requests had
|
|
134
|
+
# already been merged.
|
|
135
|
+
contract = self._checkpoint_contract(
|
|
136
|
+
str(ctx.state_file), ctx.session_id
|
|
137
|
+
)
|
|
138
|
+
prompt = (
|
|
139
|
+
f"User answered: {answer}\n\n"
|
|
140
|
+
"Continue from where you left off.\n\n"
|
|
141
|
+
f"{contract}"
|
|
142
|
+
)
|
|
130
143
|
|
|
131
144
|
# Resume uses Claude's own session UUID (captured on the first spawn
|
|
132
145
|
# and persisted to state_db). Our composite id is not a UUID and will
|
|
@@ -425,7 +438,34 @@ yourself about to write a phrase like "no open PRs at all" or "N open
|
|
|
425
438
|
PRs total", you have already stepped outside the fence — you can only
|
|
426
439
|
speak to the author-filtered results.
|
|
427
440
|
|
|
428
|
-
|
|
441
|
+
{self._checkpoint_contract(state_file_path, session_id)}"""
|
|
442
|
+
|
|
443
|
+
def _checkpoint_contract(
|
|
444
|
+
self, state_file_path: str, session_id: str
|
|
445
|
+
) -> str:
|
|
446
|
+
"""How the agent signals how it stopped.
|
|
447
|
+
|
|
448
|
+
Extracted so the RESUME prompt can carry it too (#173). The
|
|
449
|
+
initial prompt spent ~30 lines establishing this; the resume
|
|
450
|
+
prompt was 58 characters and said only "continue", so a resumed
|
|
451
|
+
agent could do its work, exit 0, write no state, and be
|
|
452
|
+
indistinguishable from one that crashed.
|
|
453
|
+
|
|
454
|
+
Measured case, 2026-09-16:
|
|
455
|
+
secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
|
|
456
|
+
"Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
|
|
457
|
+
in fact merged three of four pull requests. The operator was told
|
|
458
|
+
the resume failed, which was wrong in both directions: most of
|
|
459
|
+
the work had landed, and the one thing outstanding was never
|
|
460
|
+
named.
|
|
461
|
+
|
|
462
|
+
Per-pipeline rather than shared, because the three contracts
|
|
463
|
+
differ in substance and not only wording: the dev DONE block
|
|
464
|
+
carries pr_url and pr_number outputs, and each names different
|
|
465
|
+
conditions for DONE. One shared block would have quietly changed
|
|
466
|
+
what two of them promise.
|
|
467
|
+
"""
|
|
468
|
+
return f"""## Signaling Completion
|
|
429
469
|
|
|
430
470
|
**CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
|
|
431
471
|
|
|
@@ -80,7 +80,20 @@ class TaskPipeline:
|
|
|
80
80
|
return self._session_to_result(result)
|
|
81
81
|
|
|
82
82
|
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
83
|
-
|
|
83
|
+
# The checkpoint contract is restated here, not just in the
|
|
84
|
+
# initial prompt (#173). A resumed agent that is told "continue"
|
|
85
|
+
# and nothing else can do its work, exit 0, write no state, and
|
|
86
|
+
# be indistinguishable from one that crashed - which is what
|
|
87
|
+
# happened on 2026-09-16, after three of four pull requests had
|
|
88
|
+
# already been merged.
|
|
89
|
+
contract = self._checkpoint_contract(
|
|
90
|
+
str(ctx.state_file), ctx.session_id
|
|
91
|
+
)
|
|
92
|
+
prompt = (
|
|
93
|
+
f"User answered: {answer}\n\n"
|
|
94
|
+
"Continue from where you left off.\n\n"
|
|
95
|
+
f"{contract}"
|
|
96
|
+
)
|
|
84
97
|
resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
|
|
85
98
|
log_event(
|
|
86
99
|
_logger,
|
|
@@ -181,7 +194,34 @@ pipeline. Don't BLOCK on things you can figure out yourself.
|
|
|
181
194
|
|
|
182
195
|
## Signaling completion
|
|
183
196
|
|
|
184
|
-
|
|
197
|
+
{self._checkpoint_contract(state_file_path, session_id)}"""
|
|
198
|
+
|
|
199
|
+
def _checkpoint_contract(
|
|
200
|
+
self, state_file_path: str, session_id: str
|
|
201
|
+
) -> str:
|
|
202
|
+
"""How the agent signals how it stopped.
|
|
203
|
+
|
|
204
|
+
Extracted so the RESUME prompt can carry it too (#173). The
|
|
205
|
+
initial prompt spent ~30 lines establishing this; the resume
|
|
206
|
+
prompt was 58 characters and said only "continue", so a resumed
|
|
207
|
+
agent could do its work, exit 0, write no state, and be
|
|
208
|
+
indistinguishable from one that crashed.
|
|
209
|
+
|
|
210
|
+
Measured case, 2026-09-16:
|
|
211
|
+
secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
|
|
212
|
+
"Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
|
|
213
|
+
in fact merged three of four pull requests. The operator was told
|
|
214
|
+
the resume failed, which was wrong in both directions: most of
|
|
215
|
+
the work had landed, and the one thing outstanding was never
|
|
216
|
+
named.
|
|
217
|
+
|
|
218
|
+
Per-pipeline rather than shared, because the three contracts
|
|
219
|
+
differ in substance and not only wording: the dev DONE block
|
|
220
|
+
carries pr_url and pr_number outputs, and each names different
|
|
221
|
+
conditions for DONE. One shared block would have quietly changed
|
|
222
|
+
what two of them promise.
|
|
223
|
+
"""
|
|
224
|
+
return f"""**CRITICAL**: Before exiting, write a checkpoint file.
|
|
185
225
|
|
|
186
226
|
STATE_FILE: {state_file_path}
|
|
187
227
|
SESSION_ID: {session_id}
|
|
@@ -196,6 +236,7 @@ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\\
|
|
|
196
236
|
|
|
197
237
|
**BLOCKED** (need operator input):
|
|
198
238
|
```bash
|
|
239
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
199
240
|
printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
|
|
200
241
|
'"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
|
|
201
242
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
|
|
@@ -203,6 +244,7 @@ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
|
|
|
203
244
|
|
|
204
245
|
**FAILED** (genuine failure, no way forward):
|
|
205
246
|
```bash
|
|
247
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
206
248
|
printf '{{"version":"1","status":"FAILED",'\\
|
|
207
249
|
'"session_id":"{session_id}","timestamp":"%s","error":"%s"}}' \\
|
|
208
250
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
|