ctrlrelay 0.12.2__tar.gz → 0.13.0__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.2 → ctrlrelay-0.13.0}/CHANGELOG.md +86 -1
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/PKG-INFO +1 -1
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/config/orchestrator.yaml.example +3 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/bridge.md +32 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/configuration.md +2 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/pyproject.toml +1 -1
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/server.py +181 -3
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/cli.py +1463 -144
- ctrlrelay-0.13.0/src/ctrlrelay/core/binaries.py +198 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/code_review.py +27 -1
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/config.py +29 -0
- ctrlrelay-0.13.0/src/ctrlrelay/core/dispatch_health.py +276 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/dispatcher.py +215 -21
- ctrlrelay-0.13.0/src/ctrlrelay/core/question_expiry.py +498 -0
- ctrlrelay-0.13.0/src/ctrlrelay/core/redact.py +321 -0
- ctrlrelay-0.13.0/src/ctrlrelay/core/state.py +1216 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/base.py +12 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/dev.py +456 -19
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/secops.py +145 -10
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/task.py +20 -6
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +9 -1
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/launchd/poller.plist.template +9 -1
- ctrlrelay-0.13.0/src/ctrlrelay/templates/systemd/bridge.service.template +37 -0
- ctrlrelay-0.13.0/src/ctrlrelay/templates/systemd/poller.service.template +42 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/base.py +11 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/socket_client.py +8 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/conftest.py +18 -0
- ctrlrelay-0.13.0/tests/test_binary_resolution.py +1009 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_bridge_server.py +559 -0
- ctrlrelay-0.13.0/tests/test_claim_reconciliation.py +999 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_config.py +6 -2
- ctrlrelay-0.13.0/tests/test_dispatch_health.py +462 -0
- ctrlrelay-0.13.0/tests/test_dispatch_outcome_hook.py +1571 -0
- ctrlrelay-0.13.0/tests/test_issue_outcome_announcement.py +182 -0
- ctrlrelay-0.13.0/tests/test_notify_transport.py +1238 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_pipeline_base.py +1 -0
- ctrlrelay-0.13.0/tests/test_question_backoff.py +763 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_question_expiry.py +335 -0
- ctrlrelay-0.13.0/tests/test_redact.py +512 -0
- ctrlrelay-0.13.0/tests/test_relay_alert_off_lock.py +638 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_resume_prompt_contract.py +109 -1
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_secops_integration.py +1 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_secops_pipeline.py +21 -0
- ctrlrelay-0.13.0/tests/test_unreviewed_pr_announcement.py +1009 -0
- ctrlrelay-0.12.2/src/ctrlrelay/core/question_expiry.py +0 -193
- ctrlrelay-0.12.2/src/ctrlrelay/core/state.py +0 -658
- ctrlrelay-0.12.2/src/ctrlrelay/templates/systemd/bridge.service.template +0 -20
- ctrlrelay-0.12.2/src/ctrlrelay/templates/systemd/poller.service.template +0 -25
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.gitignore +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/LICENSE +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/README.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/SECURITY.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/cli.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/development.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/index.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/operations.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-10-30
|
|
11
|
+
|
|
12
|
+
New database columns and one new config value. All of them are added
|
|
13
|
+
automatically and have defaults, so **no config edit is required**.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- A re-asked question says so: "Repeat: asked N times before, last on
|
|
18
|
+
<date> UTC, no answer yet" (#213, closes #200)
|
|
19
|
+
- Finished questions older than 90 days are pruned from `pending_resumes`.
|
|
20
|
+
Open ones are always kept. New config value `question_retention_days`,
|
|
21
|
+
default 90 (#213, closes #200)
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The scheduled secops sweep holds back a question nobody answered, posting it again after 1, 2, 4, then 7 days (#215, closes #214)
|
|
26
|
+
- A reply to an earlier post of a question whose session expired unanswered goes to the newest open session asking the same thing in that repo, instead of being refused (#215, closes #214)
|
|
27
|
+
- The relay-health alert is sent in the background instead of while the per-repository lock is held, so a stuck chat bridge no longer slows down every repo in a sweep (#212, closes #208)
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A state file path with an apostrophe, a trailing newline in a folder name, or a leading dash broke every checkpoint snippet, so the agent looked crashed (#211, closes #191)
|
|
32
|
+
- A secops repo that failed after its result was recorded no longer adds a second result, which paired every later repo's notification with its neighbour's result (#215)
|
|
33
|
+
|
|
34
|
+
## [0.12.4] - 2026-10-18
|
|
35
|
+
|
|
36
|
+
Carries 0.12.3's entries too: v0.12.3 was never tagged, so nothing has
|
|
37
|
+
shipped since v0.12.2 and this is the first release to reach a running
|
|
38
|
+
daemon with any of it.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Reconcile the claim comment with a failed dispatch, alert on a run of
|
|
43
|
+
failures, and keep the exit code and the worker's output (#206, closes
|
|
44
|
+
#204)
|
|
45
|
+
|
|
46
|
+
The dev lane posted *"a PR will be opened for review"* before the
|
|
47
|
+
worker started and never returned to the issue when the worker
|
|
48
|
+
exited non-zero, so that sentence stood on a beta tester's own ticket
|
|
49
|
+
for eighteen hours. Separately, 163 consecutive failed dispatches over
|
|
50
|
+
two days told nobody while both units stayed `active (running)`.
|
|
51
|
+
Separately again, `sessions.claude_exit_code` was never written on the
|
|
52
|
+
failure path and no worker output was retained, so the only durable
|
|
53
|
+
evidence of why they failed was that each took about two seconds.
|
|
54
|
+
|
|
55
|
+
New: `sessions.output_tail`, the `dispatch_health` table, and an
|
|
56
|
+
`alerts` config block (`alerts.enabled`,
|
|
57
|
+
`alerts.consecutive_dispatch_failures`, default 3). All additive and
|
|
58
|
+
defaulted — no config edit is needed.
|
|
59
|
+
|
|
60
|
+
**Known limits**, stated because they are decisions rather than
|
|
61
|
+
oversights: the relay-health alert is awaited inside the dispatcher,
|
|
62
|
+
so it can hold the per-repo lock for up to ~15 s against a wedged
|
|
63
|
+
bridge; delivery is at-least-once, so an alert that is sent but whose
|
|
64
|
+
delivery cannot be recorded may be sent once more; and the output
|
|
65
|
+
scrubber removes known credential shapes, which is not a proof that
|
|
66
|
+
nothing sensitive remains. A numeric value under a credential key is
|
|
67
|
+
kept, because treating every number as a possible secret destroyed the
|
|
68
|
+
token counts in an API error.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- Announce a pull request whose review could not run (#202, closes #196)
|
|
73
|
+
|
|
74
|
+
### Fixed (0.12.3, never released)
|
|
75
|
+
|
|
76
|
+
- Resolve the reviewer binary before exec, and ship a unit PATH that finds
|
|
77
|
+
it (#201, closes #197)
|
|
78
|
+
- Resolve the notification transport at use, not at boot (#199, closes #198)
|
|
79
|
+
|
|
80
|
+
## [0.12.3] - 2026-10-12
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- Announce a pull request whose review could not run (#202, closes #196)
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- Resolve the reviewer binary before exec, and ship a unit PATH that finds
|
|
89
|
+
it (#201, closes #197)
|
|
90
|
+
- Resolve the notification transport at use, not at boot (#199, closes #198)
|
|
91
|
+
|
|
10
92
|
## [0.12.2] - 2026-10-06
|
|
11
93
|
|
|
12
94
|
### Fixed
|
|
@@ -1689,7 +1771,10 @@ pipeline).
|
|
|
1689
1771
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1690
1772
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1691
1773
|
|
|
1692
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1774
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...HEAD
|
|
1775
|
+
[0.13.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.4...v0.13.0
|
|
1776
|
+
[0.12.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.4
|
|
1777
|
+
[0.12.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.3
|
|
1693
1778
|
[0.12.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...v0.12.2
|
|
1694
1779
|
[0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
|
|
1695
1780
|
[0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
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/
|
|
@@ -76,6 +76,9 @@ transport:
|
|
|
76
76
|
# Read from whichever transport is active, so a Mattermost deployment
|
|
77
77
|
# takes this value and not the telegram block's.
|
|
78
78
|
question_ttl_seconds: 172800
|
|
79
|
+
# How long a finished (expired or resumed) question is kept before it
|
|
80
|
+
# is deleted. Open questions are never deleted.
|
|
81
|
+
question_retention_days: 90
|
|
79
82
|
file_mock:
|
|
80
83
|
inbox: "~/.ctrlrelay/inbox.txt"
|
|
81
84
|
outbox: "~/.ctrlrelay/outbox.txt"
|
|
@@ -245,6 +245,38 @@ exists. Messages it sends:
|
|
|
245
245
|
- `✅ PR ready: ...` — pipeline finished green.
|
|
246
246
|
- `❌ Failed on #123: ...` — pipeline failed.
|
|
247
247
|
|
|
248
|
+
**A question asked again is marked.** A scheduled sweep starts a fresh
|
|
249
|
+
session each day and the agent often reaches the same blocker and asks
|
|
250
|
+
again, in new words. When that happens the post carries a line such as
|
|
251
|
+
`Repeat: asked 3 times before, last on Oct 28 UTC, no answer yet.` Two
|
|
252
|
+
questions count as the same if they cite at least one PR or issue number
|
|
253
|
+
in common (or, when neither cites a number, have the same text). The
|
|
254
|
+
count runs from the last answer to that question, including an answer
|
|
255
|
+
on a row a later re-block rewrote (rows rewritten before this was kept
|
|
256
|
+
can still count too high). The history this reads is kept for
|
|
257
|
+
`question_retention_days`.
|
|
258
|
+
|
|
259
|
+
**A question nobody answers is asked less often.** On the scheduled
|
|
260
|
+
secops sweep, each earlier post of the same question that got no answer
|
|
261
|
+
counts as a miss: one that expired on `question_ttl_seconds`, or one
|
|
262
|
+
still open and more than 6 hours old (one closed as resolved elsewhere
|
|
263
|
+
does not count, and neither does one that never reached the chat
|
|
264
|
+
because the bridge was down, the post failed, or the bridge never
|
|
265
|
+
confirmed it). After one miss the next post waits 1 day from the last
|
|
266
|
+
post, after two 2 days, after three 4 days, then once a week, so a daily
|
|
267
|
+
sweep posts it on days 0, 1, 3, 7, 14, 21. An answer to any post of it
|
|
268
|
+
resets the count. A held question is not posted and
|
|
269
|
+
gets no `blocked on <repo>` message; the sweep summary lists it with its
|
|
270
|
+
session id and the time it is next due, and the poller logs
|
|
271
|
+
`secops.question.held`. The session still ends blocked with its row
|
|
272
|
+
saved, so it stays answerable and expires on the TTL like any unanswered
|
|
273
|
+
question. To answer it, name its session in a message, or reply to the
|
|
274
|
+
earlier post: while that post's own session is still open the reply
|
|
275
|
+
answers that session; once it has expired without an answer, the reply
|
|
276
|
+
goes to the newest open session asking the same thing in that repo, and
|
|
277
|
+
the confirmation says so. A manual
|
|
278
|
+
`ctrlrelay run secops` never holds a question back.
|
|
279
|
+
|
|
248
280
|
For the full BLOCKED → answer → resume mechanics, see
|
|
249
281
|
[Feedback loop]({{ '/feedback-loop/' | relative_url }}).
|
|
250
282
|
|
|
@@ -147,6 +147,7 @@ reach anybody.
|
|
|
147
147
|
| `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
|
|
148
148
|
| `ask_timeout_seconds` | int | `900` | How long a pipeline blocks waiting on your reply in-session. Minimum `60`. A reply arriving later still works: the question is persisted to `pending_resumes` and the every-minute sweeper drives the resume. |
|
|
149
149
|
| `question_ttl_seconds` | int | `172800` (48h) | How long an **unanswered** question stays routable before it is retired. Minimum `3600`. Without a ceiling those rows never die — a question whose PR was merged by hand weeks ago stays a live target for an orphan reply. The hourly `question_expiry_sweeper` also retires a question early once every issue/PR it cites is closed or merged. |
|
|
150
|
+
| `question_retention_days` | int | `90` | How long a **finished** question (expired, or answered and resumed) is kept before the hourly sweeper deletes it. Minimum `1`. Open questions are never deleted. The kept history is what lets a re-asked question be marked as a repeat. |
|
|
150
151
|
|
|
151
152
|
See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
|
|
152
153
|
|
|
@@ -160,6 +161,7 @@ See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkth
|
|
|
160
161
|
| `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
|
|
161
162
|
| `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
163
|
| `question_ttl_seconds` | int | `172800` (48h) | Same meaning as the Telegram key above. |
|
|
164
|
+
| `question_retention_days` | int | `90` | Same meaning as the Telegram key above. |
|
|
163
165
|
|
|
164
166
|
**The bot must be a member of that channel.** A bot without `post:all` cannot
|
|
165
167
|
post where it is not a member — and, the part that is easy to miss, it does not
|
|
@@ -21,6 +21,11 @@ from ctrlrelay.bridge.protocol import (
|
|
|
21
21
|
serialize_message,
|
|
22
22
|
)
|
|
23
23
|
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
24
|
+
from ctrlrelay.core.question_expiry import (
|
|
25
|
+
find_repeat_history,
|
|
26
|
+
format_repeat_note,
|
|
27
|
+
same_substance,
|
|
28
|
+
)
|
|
24
29
|
|
|
25
30
|
if TYPE_CHECKING:
|
|
26
31
|
from ctrlrelay.bridge.handler import ChatHandler
|
|
@@ -197,6 +202,7 @@ def format_question(
|
|
|
197
202
|
repo: str | None = None,
|
|
198
203
|
session_id: str | None = None,
|
|
199
204
|
issue_number: int | None = None,
|
|
205
|
+
repeat_note: str | None = None,
|
|
200
206
|
) -> str:
|
|
201
207
|
"""Prefix an agent question with the repo and session it belongs to.
|
|
202
208
|
|
|
@@ -211,6 +217,12 @@ def format_question(
|
|
|
211
217
|
Plain text on purpose — no handler sets a markup mode, so any Markdown
|
|
212
218
|
here would reach a Telegram operator as literal backticks. Mattermost
|
|
213
219
|
renders it, which is a difference the question text must not depend on.
|
|
220
|
+
|
|
221
|
+
``repeat_note`` says this question was asked before, with no
|
|
222
|
+
answer since the last time it was answered (if it ever was). It
|
|
223
|
+
goes straight under the repo line, ahead of everything else,
|
|
224
|
+
because a reader skimming a channel decides from the first lines
|
|
225
|
+
whether a message is new.
|
|
214
226
|
"""
|
|
215
227
|
header: list[str] = []
|
|
216
228
|
if repo:
|
|
@@ -218,6 +230,8 @@ def format_question(
|
|
|
218
230
|
if issue_number is not None:
|
|
219
231
|
line += f" issue #{issue_number}"
|
|
220
232
|
header.append(line)
|
|
233
|
+
if repeat_note:
|
|
234
|
+
header.append(repeat_note)
|
|
221
235
|
if session_id:
|
|
222
236
|
header.append(f"session: {session_id}")
|
|
223
237
|
if not header:
|
|
@@ -436,8 +450,20 @@ class BridgeServer:
|
|
|
436
450
|
return BridgeMessage(op=BridgeOp.PONG)
|
|
437
451
|
|
|
438
452
|
if msg.op == BridgeOp.SEND:
|
|
453
|
+
text = msg.text or ""
|
|
454
|
+
# The secops sweep's "blocked on <repo>" fan-out is the second
|
|
455
|
+
# time the operator sees the question, so it carries the same
|
|
456
|
+
# repeat line the ASK did. Only a SEND naming a session can be
|
|
457
|
+
# that message; anything else is posted as given.
|
|
458
|
+
if msg.session_id and msg.repo:
|
|
459
|
+
note = self._repeat_note_for_session(msg.repo, msg.session_id)
|
|
460
|
+
if note and text:
|
|
461
|
+
first, sep, rest = text.partition("\n")
|
|
462
|
+
text = f"{first}\n{note}{sep}{rest}"
|
|
463
|
+
elif note:
|
|
464
|
+
text = note
|
|
439
465
|
try:
|
|
440
|
-
post_id = await self.handler.send(
|
|
466
|
+
post_id = await self.handler.send(text)
|
|
441
467
|
# A SEND that names a session is answerable too: the secops
|
|
442
468
|
# sweep fans out one "blocked on <repo>" message per repo
|
|
443
469
|
# after the run, and that message is the most recent — and
|
|
@@ -478,6 +504,10 @@ class BridgeServer:
|
|
|
478
504
|
"question_hash": hash_text(question),
|
|
479
505
|
"options": msg.options,
|
|
480
506
|
}
|
|
507
|
+
repeat_note = self._repeat_note(
|
|
508
|
+
msg.repo, question, msg.session_id
|
|
509
|
+
)
|
|
510
|
+
post_fields["repeat"] = repeat_note is not None
|
|
481
511
|
try:
|
|
482
512
|
# Start the expiry clock before the chat round trip, not
|
|
483
513
|
# after it: the client began counting when it wrote the ASK
|
|
@@ -493,6 +523,7 @@ class BridgeServer:
|
|
|
493
523
|
repo=msg.repo,
|
|
494
524
|
session_id=msg.session_id,
|
|
495
525
|
issue_number=msg.issue_number,
|
|
526
|
+
repeat_note=repeat_note,
|
|
496
527
|
),
|
|
497
528
|
options=msg.options,
|
|
498
529
|
)
|
|
@@ -569,6 +600,79 @@ class BridgeServer:
|
|
|
569
600
|
|
|
570
601
|
return None
|
|
571
602
|
|
|
603
|
+
def _repeat_note(
|
|
604
|
+
self,
|
|
605
|
+
repo: str | None,
|
|
606
|
+
question: str,
|
|
607
|
+
session_id: str | None,
|
|
608
|
+
) -> str | None:
|
|
609
|
+
"""The "asked N times before" line for a question about to be
|
|
610
|
+
posted, or None when it is new or there is nothing to look in.
|
|
611
|
+
|
|
612
|
+
Anything that goes wrong, in the lookup OR in rendering the line
|
|
613
|
+
(a corrupt or millisecond ``created_at`` makes the date raise),
|
|
614
|
+
posts the question without the line and logs that it could not
|
|
615
|
+
tell. The question reaching the operator matters more than the
|
|
616
|
+
note on it; the log is what separates "first time asked" from
|
|
617
|
+
"could not check".
|
|
618
|
+
"""
|
|
619
|
+
if self.state_db is None or not repo:
|
|
620
|
+
return None
|
|
621
|
+
try:
|
|
622
|
+
history = find_repeat_history(
|
|
623
|
+
self.state_db,
|
|
624
|
+
repo=repo,
|
|
625
|
+
question=question,
|
|
626
|
+
exclude_session_id=session_id,
|
|
627
|
+
)
|
|
628
|
+
return format_repeat_note(history) if history else None
|
|
629
|
+
except Exception as e:
|
|
630
|
+
log_event(
|
|
631
|
+
_logger,
|
|
632
|
+
"bridge.repeat_lookup_failed",
|
|
633
|
+
session_id=session_id,
|
|
634
|
+
repo=repo,
|
|
635
|
+
error_type=type(e).__name__,
|
|
636
|
+
error=str(e)[:200],
|
|
637
|
+
)
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
def _repeat_note_for_session(
|
|
641
|
+
self, repo: str, session_id: str,
|
|
642
|
+
) -> str | None:
|
|
643
|
+
"""Repeat line for a SEND that names a session: the question is
|
|
644
|
+
read back from that session's persisted row.
|
|
645
|
+
|
|
646
|
+
Only while that row is an open question. Today the one SEND
|
|
647
|
+
carrying a session is the secops "blocked on <repo>" fan-out,
|
|
648
|
+
but a session id on a SEND exists to make a message answerable,
|
|
649
|
+
and an "answer applied" or "expired" notice that gains one later
|
|
650
|
+
must not be told the question is still waiting. Today that holds
|
|
651
|
+
because the fan-out is the only SEND with a session and a repo;
|
|
652
|
+
the open-row check is what keeps it true if that changes."""
|
|
653
|
+
if self.state_db is None:
|
|
654
|
+
return None
|
|
655
|
+
try:
|
|
656
|
+
row = self.state_db.get_pending_resume(session_id)
|
|
657
|
+
if (
|
|
658
|
+
row is None
|
|
659
|
+
or row["answered_at"] is not None
|
|
660
|
+
or row["expired_at"] is not None
|
|
661
|
+
):
|
|
662
|
+
return None
|
|
663
|
+
question = row["question"]
|
|
664
|
+
except Exception as e:
|
|
665
|
+
log_event(
|
|
666
|
+
_logger,
|
|
667
|
+
"bridge.repeat_lookup_failed",
|
|
668
|
+
session_id=session_id,
|
|
669
|
+
repo=repo,
|
|
670
|
+
error_type=type(e).__name__,
|
|
671
|
+
error=str(e)[:200],
|
|
672
|
+
)
|
|
673
|
+
return None
|
|
674
|
+
return self._repeat_note(repo, question, session_id)
|
|
675
|
+
|
|
572
676
|
def _error_code(self, unknown: bool) -> str:
|
|
573
677
|
"""The ERROR frame's ``error`` field for a failed post.
|
|
574
678
|
|
|
@@ -757,10 +861,18 @@ class BridgeServer:
|
|
|
757
861
|
)
|
|
758
862
|
if outcome["status"] == "queued":
|
|
759
863
|
row = outcome["row"]
|
|
864
|
+
redirected = (
|
|
865
|
+
f"The message you replied to was an earlier ask of the "
|
|
866
|
+
f"same question; its session "
|
|
867
|
+
f"`{outcome['redirected_from']}` had expired.\n"
|
|
868
|
+
if outcome.get("redirected_from")
|
|
869
|
+
else ""
|
|
870
|
+
)
|
|
760
871
|
await self._send_notice(
|
|
761
872
|
"✅ Answer queued for BLOCKED session "
|
|
762
873
|
f"`{row['session_id']}` "
|
|
763
874
|
f"(pipeline={row['pipeline']}, repo={row['repo']}).\n"
|
|
875
|
+
f"{redirected}"
|
|
764
876
|
"The pending-resume sweeper will drive it on the next "
|
|
765
877
|
"tick — you'll get another message with the result."
|
|
766
878
|
)
|
|
@@ -922,8 +1034,10 @@ class BridgeServer:
|
|
|
922
1034
|
2. ``session_id`` was given and does NOT name an unanswered row.
|
|
923
1035
|
The operator pointed at something specific, so the only other
|
|
924
1036
|
pointer we accept is an equally explicit one: a session_id
|
|
925
|
-
written in the reply text
|
|
926
|
-
|
|
1037
|
+
written in the reply text, or (#214) the same question open
|
|
1038
|
+
on a newer session in the same repo, when the row pointed at
|
|
1039
|
+
expired unanswered. Failing that we stop at ``"stale_hint"``
|
|
1040
|
+
and route nothing.
|
|
927
1041
|
3. No ``session_id`` at all: the reply text contains exactly one
|
|
928
1042
|
unanswered session_id as a substring — route there.
|
|
929
1043
|
4. No ``session_id`` at all and exactly one unanswered row exists —
|
|
@@ -974,6 +1088,28 @@ class BridgeServer:
|
|
|
974
1088
|
return await self._attach_orphan_answer(named[0], text)
|
|
975
1089
|
if len(named) > 1:
|
|
976
1090
|
return {"status": "ambiguous", "rows": named}
|
|
1091
|
+
# The post they replied to expired, but the same question
|
|
1092
|
+
# may be open on a newer session: the next sweep asked it
|
|
1093
|
+
# again, or held it back as a repeat and left the earlier
|
|
1094
|
+
# post as the only one on screen (#214). That open row is
|
|
1095
|
+
# what the operator is answering.
|
|
1096
|
+
newer = self._newest_open_repeat(session_id, rows)
|
|
1097
|
+
if newer is not None:
|
|
1098
|
+
outcome = await self._attach_orphan_answer(newer, text)
|
|
1099
|
+
if outcome["status"] == "queued":
|
|
1100
|
+
log_event(
|
|
1101
|
+
_logger,
|
|
1102
|
+
"bridge.reply.redirected",
|
|
1103
|
+
from_session_id=session_id,
|
|
1104
|
+
to_session_id=newer["session_id"],
|
|
1105
|
+
repo=newer["repo"],
|
|
1106
|
+
)
|
|
1107
|
+
return {**outcome, "redirected_from": session_id}
|
|
1108
|
+
# The newer row stopped taking answers between the two
|
|
1109
|
+
# reads (answered by another reply, or expired). That
|
|
1110
|
+
# row is what this reply was for, so say what happened
|
|
1111
|
+
# to IT, not that the older post expired.
|
|
1112
|
+
return self._describe_stale_hint(newer["session_id"])
|
|
977
1113
|
return self._describe_stale_hint(session_id)
|
|
978
1114
|
|
|
979
1115
|
matched_by_id = [r for r in rows if names_session(text, r["session_id"])]
|
|
@@ -999,6 +1135,48 @@ class BridgeServer:
|
|
|
999
1135
|
|
|
1000
1136
|
return await self._attach_orphan_answer(target, text)
|
|
1001
1137
|
|
|
1138
|
+
def _newest_open_repeat(
|
|
1139
|
+
self, session_id: str, open_rows: list[dict],
|
|
1140
|
+
) -> dict | None:
|
|
1141
|
+
"""The newest open row asking the same thing as ``session_id``'s
|
|
1142
|
+
EXPIRED question in the same repo, or None.
|
|
1143
|
+
|
|
1144
|
+
Only an expired, unanswered row redirects. An answered one keeps
|
|
1145
|
+
refusing: the operator already answered that question, and a
|
|
1146
|
+
second reply to it is not an answer to the next one. A pruned
|
|
1147
|
+
row has no question left to compare, so it keeps refusing too.
|
|
1148
|
+
|
|
1149
|
+
A failed lookup returns None, which keeps the refusal that was
|
|
1150
|
+
the behaviour before this existed, and is logged.
|
|
1151
|
+
"""
|
|
1152
|
+
assert self.state_db is not None
|
|
1153
|
+
try:
|
|
1154
|
+
old = self.state_db.get_pending_resume(session_id)
|
|
1155
|
+
if (
|
|
1156
|
+
old is None
|
|
1157
|
+
or old["answered_at"] is not None
|
|
1158
|
+
or old["expired_at"] is None
|
|
1159
|
+
):
|
|
1160
|
+
return None
|
|
1161
|
+
same = [
|
|
1162
|
+
r for r in open_rows
|
|
1163
|
+
if r["session_id"] != session_id
|
|
1164
|
+
and r["repo"] == old["repo"]
|
|
1165
|
+
and same_substance(old["question"], r["question"])
|
|
1166
|
+
]
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
log_event(
|
|
1169
|
+
_logger,
|
|
1170
|
+
"bridge.reply.redirect_lookup_failed",
|
|
1171
|
+
session_id=session_id,
|
|
1172
|
+
reason=type(e).__name__,
|
|
1173
|
+
error=str(e)[:200],
|
|
1174
|
+
)
|
|
1175
|
+
return None
|
|
1176
|
+
if not same:
|
|
1177
|
+
return None
|
|
1178
|
+
return max(same, key=lambda r: int(r["created_at"]))
|
|
1179
|
+
|
|
1002
1180
|
def _describe_stale_hint(self, session_id: str) -> dict:
|
|
1003
1181
|
"""Say WHY a resolved reply-to cannot take an answer.
|
|
1004
1182
|
|