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.
Files changed (145) hide show
  1. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/CHANGELOG.md +86 -1
  2. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/config/orchestrator.yaml.example +3 -0
  4. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/bridge.md +32 -0
  5. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/configuration.md +2 -0
  6. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/server.py +181 -3
  8. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/cli.py +1463 -144
  9. ctrlrelay-0.13.0/src/ctrlrelay/core/binaries.py +198 -0
  10. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/code_review.py +27 -1
  11. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/config.py +29 -0
  12. ctrlrelay-0.13.0/src/ctrlrelay/core/dispatch_health.py +276 -0
  13. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/dispatcher.py +215 -21
  14. ctrlrelay-0.13.0/src/ctrlrelay/core/question_expiry.py +498 -0
  15. ctrlrelay-0.13.0/src/ctrlrelay/core/redact.py +321 -0
  16. ctrlrelay-0.13.0/src/ctrlrelay/core/state.py +1216 -0
  17. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/base.py +12 -0
  18. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/dev.py +456 -19
  19. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/secops.py +145 -10
  20. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/task.py +20 -6
  21. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +9 -1
  22. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/launchd/poller.plist.template +9 -1
  23. ctrlrelay-0.13.0/src/ctrlrelay/templates/systemd/bridge.service.template +37 -0
  24. ctrlrelay-0.13.0/src/ctrlrelay/templates/systemd/poller.service.template +42 -0
  25. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/base.py +11 -0
  26. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/socket_client.py +8 -0
  27. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/conftest.py +18 -0
  28. ctrlrelay-0.13.0/tests/test_binary_resolution.py +1009 -0
  29. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_bridge_server.py +559 -0
  30. ctrlrelay-0.13.0/tests/test_claim_reconciliation.py +999 -0
  31. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_config.py +6 -2
  32. ctrlrelay-0.13.0/tests/test_dispatch_health.py +462 -0
  33. ctrlrelay-0.13.0/tests/test_dispatch_outcome_hook.py +1571 -0
  34. ctrlrelay-0.13.0/tests/test_issue_outcome_announcement.py +182 -0
  35. ctrlrelay-0.13.0/tests/test_notify_transport.py +1238 -0
  36. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_pipeline_base.py +1 -0
  37. ctrlrelay-0.13.0/tests/test_question_backoff.py +763 -0
  38. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_question_expiry.py +335 -0
  39. ctrlrelay-0.13.0/tests/test_redact.py +512 -0
  40. ctrlrelay-0.13.0/tests/test_relay_alert_off_lock.py +638 -0
  41. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_resume_prompt_contract.py +109 -1
  42. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_secops_integration.py +1 -0
  43. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_secops_pipeline.py +21 -0
  44. ctrlrelay-0.13.0/tests/test_unreviewed_pr_announcement.py +1009 -0
  45. ctrlrelay-0.12.2/src/ctrlrelay/core/question_expiry.py +0 -193
  46. ctrlrelay-0.12.2/src/ctrlrelay/core/state.py +0 -658
  47. ctrlrelay-0.12.2/src/ctrlrelay/templates/systemd/bridge.service.template +0 -20
  48. ctrlrelay-0.12.2/src/ctrlrelay/templates/systemd/poller.service.template +0 -25
  49. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  50. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  51. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  52. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/dependabot.yml +0 -0
  53. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/build.yml +0 -0
  54. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/cla.yml +0 -0
  55. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/pages.yml +0 -0
  56. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/publish.yml +0 -0
  57. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.github/workflows/test.yml +0 -0
  58. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/.gitignore +0 -0
  59. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/CODE_OF_CONDUCT.md +0 -0
  60. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/CONTRIBUTING.md +0 -0
  61. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/LICENSE +0 -0
  62. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/README.md +0 -0
  63. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/SECURITY.md +0 -0
  64. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/Gemfile +0 -0
  65. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/_config.yml +0 -0
  66. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/architecture.md +0 -0
  67. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/cli.md +0 -0
  68. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/development.md +0 -0
  69. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/feedback-loop.md +0 -0
  70. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/getting-started.md +0 -0
  71. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/index.md +0 -0
  72. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/operations.md +0 -0
  73. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/docs/personalization.md +0 -0
  74. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/__init__.py +0 -0
  75. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  76. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  77. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/factory.py +0 -0
  78. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/handler.py +0 -0
  79. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  80. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  81. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  82. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/__init__.py +0 -0
  83. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/archived.py +0 -0
  84. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/audit.py +0 -0
  85. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  86. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/github.py +0 -0
  87. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/network.py +0 -0
  88. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/obs.py +0 -0
  89. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/poller.py +0 -0
  90. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  91. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  92. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/scheduler.py +0 -0
  93. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/core/worktree.py +0 -0
  94. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  95. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/dashboard/client.py +0 -0
  96. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/gh_protocol.py +0 -0
  97. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/install.py +0 -0
  98. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  99. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/personalization/manager.py +0 -0
  100. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/personalization/paths.py +0 -0
  101. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  102. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  103. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/setup.py +0 -0
  104. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/__init__.py +0 -0
  105. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  106. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  107. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  108. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/__init__.py +0 -0
  109. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  110. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/__init__.py +0 -0
  111. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_archived.py +0 -0
  112. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_audit.py +0 -0
  113. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_bridge_protocol.py +0 -0
  114. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_checkpoint.py +0 -0
  115. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_ci_wait.py +0 -0
  116. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_dev.py +0 -0
  117. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_repos.py +0 -0
  118. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_secops.py +0 -0
  119. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_start.py +0 -0
  120. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_cli_version.py +0 -0
  121. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_code_review.py +0 -0
  122. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dashboard_client.py +0 -0
  123. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dev_integration.py +0 -0
  124. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dev_pipeline.py +0 -0
  125. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_dispatcher.py +0 -0
  126. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_docs_site.py +0 -0
  127. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_gh_protocol.py +0 -0
  128. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_github.py +0 -0
  129. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_install.py +0 -0
  130. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_mattermost_handler.py +0 -0
  131. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_network.py +0 -0
  132. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_obs.py +0 -0
  133. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_personalization.py +0 -0
  134. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_poller.py +0 -0
  135. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_post_merge.py +0 -0
  136. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_pr_verifier.py +0 -0
  137. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_pr_watcher.py +0 -0
  138. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_scheduler.py +0 -0
  139. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_setup.py +0 -0
  140. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_state.py +0 -0
  141. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_task_pipeline.py +0 -0
  142. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_telegram_handler.py +0 -0
  143. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_transport.py +0 -0
  144. {ctrlrelay-0.12.2 → ctrlrelay-0.13.0}/tests/test_transport_resolution.py +0 -0
  145. {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.12.2...HEAD
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.12.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.12.2"
3
+ version = "0.13.0"
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"
@@ -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(msg.text or "")
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. Failing that we stop at
926
- ``"stale_hint"`` and route nothing.
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