ctrlrelay 0.8.1__tar.gz → 0.10.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 (123) hide show
  1. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/workflows/publish.yml +11 -3
  2. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/CHANGELOG.md +273 -1
  3. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/PKG-INFO +1 -1
  4. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/config/orchestrator.yaml.example +7 -0
  5. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/configuration.md +2 -0
  6. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/operations.md +51 -0
  7. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/pyproject.toml +1 -1
  8. ctrlrelay-0.10.0/src/ctrlrelay/bridge/server.py +740 -0
  9. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/telegram_handler.py +19 -0
  10. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/cli.py +119 -10
  11. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/config.py +29 -0
  12. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/dispatcher.py +152 -16
  13. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/github.py +26 -0
  14. ctrlrelay-0.10.0/src/ctrlrelay/core/network.py +277 -0
  15. ctrlrelay-0.10.0/src/ctrlrelay/core/question_expiry.py +193 -0
  16. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/state.py +71 -6
  17. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/worktree.py +26 -2
  18. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/dev.py +73 -0
  19. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/secops.py +70 -25
  20. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/task.py +2 -0
  21. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/setup.py +6 -0
  22. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/__init__.py +1 -0
  23. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/base.py +29 -2
  24. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/file_mock.py +13 -4
  25. ctrlrelay-0.10.0/src/ctrlrelay/transports/socket_client.py +298 -0
  26. ctrlrelay-0.10.0/tests/test_bridge_server.py +1067 -0
  27. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_cli_start.py +136 -0
  28. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_config.py +35 -0
  29. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_dev_pipeline.py +171 -0
  30. ctrlrelay-0.10.0/tests/test_dispatcher.py +668 -0
  31. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_github.py +75 -0
  32. ctrlrelay-0.10.0/tests/test_network.py +405 -0
  33. ctrlrelay-0.10.0/tests/test_obs.py +1061 -0
  34. ctrlrelay-0.10.0/tests/test_question_expiry.py +438 -0
  35. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_secops_pipeline.py +202 -0
  36. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_transport.py +78 -0
  37. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_worktree.py +66 -0
  38. ctrlrelay-0.8.1/src/ctrlrelay/bridge/server.py +0 -421
  39. ctrlrelay-0.8.1/src/ctrlrelay/transports/socket_client.py +0 -191
  40. ctrlrelay-0.8.1/tests/test_bridge_server.py +0 -453
  41. ctrlrelay-0.8.1/tests/test_dispatcher.py +0 -293
  42. ctrlrelay-0.8.1/tests/test_obs.py +0 -315
  43. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  44. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  45. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  46. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/dependabot.yml +0 -0
  47. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/workflows/build.yml +0 -0
  48. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/workflows/cla.yml +0 -0
  49. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/workflows/pages.yml +0 -0
  50. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.github/workflows/test.yml +0 -0
  51. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/.gitignore +0 -0
  52. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/CODE_OF_CONDUCT.md +0 -0
  53. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/CONTRIBUTING.md +0 -0
  54. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/LICENSE +0 -0
  55. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/README.md +0 -0
  56. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/SECURITY.md +0 -0
  57. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/Gemfile +0 -0
  58. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/_config.yml +0 -0
  59. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/architecture.md +0 -0
  60. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/bridge.md +0 -0
  61. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/cli.md +0 -0
  62. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/development.md +0 -0
  63. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/feedback-loop.md +0 -0
  64. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/getting-started.md +0 -0
  65. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/index.md +0 -0
  66. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/docs/personalization.md +0 -0
  67. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/__init__.py +0 -0
  68. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  69. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  70. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  71. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/__init__.py +0 -0
  72. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/audit.py +0 -0
  73. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  74. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/obs.py +0 -0
  75. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/poller.py +0 -0
  76. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  77. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  78. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/core/scheduler.py +0 -0
  79. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  80. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/dashboard/client.py +0 -0
  81. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/gh_protocol.py +0 -0
  82. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/install.py +0 -0
  83. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  84. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/personalization/manager.py +0 -0
  85. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/personalization/paths.py +0 -0
  86. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  87. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/base.py +0 -0
  88. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  89. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/__init__.py +0 -0
  90. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  91. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  92. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  93. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  94. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  95. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  96. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  97. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/__init__.py +0 -0
  98. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/conftest.py +0 -0
  99. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_audit.py +0 -0
  100. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_bridge_protocol.py +0 -0
  101. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_checkpoint.py +0 -0
  102. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_cli_ci_wait.py +0 -0
  103. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_cli_dev.py +0 -0
  104. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_cli_repos.py +0 -0
  105. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_cli_secops.py +0 -0
  106. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_cli_version.py +0 -0
  107. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_dashboard_client.py +0 -0
  108. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_dev_integration.py +0 -0
  109. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_docs_site.py +0 -0
  110. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_gh_protocol.py +0 -0
  111. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_install.py +0 -0
  112. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_personalization.py +0 -0
  113. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_pipeline_base.py +0 -0
  114. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_poller.py +0 -0
  115. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_post_merge.py +0 -0
  116. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_pr_verifier.py +0 -0
  117. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_pr_watcher.py +0 -0
  118. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_scheduler.py +0 -0
  119. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_secops_integration.py +0 -0
  120. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_setup.py +0 -0
  121. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_state.py +0 -0
  122. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_task_pipeline.py +0 -0
  123. {ctrlrelay-0.8.1 → ctrlrelay-0.10.0}/tests/test_telegram_handler.py +0 -0
@@ -12,9 +12,17 @@ name: Publish to PyPI
12
12
  # Workflow: publish.yml
13
13
  # Environment: pypi
14
14
  #
15
- # The `pypi` environment adds a manual approval gate — releases pause
16
- # at the "Publish" step until a repo maintainer approves, so a
17
- # compromised tag alone can't silently push to PyPI.
15
+ # Publishing is intentionally automatic: tag, publish the GitHub
16
+ # release, and this ships to PyPI with no human step. The `pypi`
17
+ # environment scopes the OIDC trust to this workflow — it is NOT an
18
+ # approval gate and carries no protection rules. Anything that can
19
+ # publish a release here can publish to PyPI.
20
+ #
21
+ # (This comment previously claimed the environment paused for a
22
+ # maintainer. It never did, and someone reading it planned a release
23
+ # around a checkpoint that was not there. If a gate is ever wanted,
24
+ # it is a required reviewer on the `pypi` environment in repo
25
+ # settings — not something this file can assert.)
18
26
 
19
27
  on:
20
28
  release:
@@ -7,6 +7,276 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-09-08
11
+
12
+ ### Added
13
+
14
+ - **The dispatcher now logs.** `core/dispatcher.py` is the module that
15
+ spawns and supervises every agent session, and it was the only core
16
+ module with no structured logging at all — so the one place where a
17
+ session is born, times out or dies was the one place with no trail.
18
+ It now emits `dispatcher.session.start` / `.finished` / `.timeout` /
19
+ `.subprocess_failed` / `.spawn_failed` / `.cancelled`, plus
20
+ `dispatcher.checkpoint.missing` when a session ends without
21
+ signalling and `dispatcher.checkpoint.read_failed` when the file
22
+ won't parse. Every failure event carries `error_type`, so a bare
23
+ `asyncio.TimeoutError` — whose `str()` is empty — is still
24
+ identifiable in the log. Prompts and agent output are never logged in
25
+ plaintext: only a `hash_text()` prefix and a length. See
26
+ [Tracing one agent session](https://ainvirion.github.io/ctrlrelay/operations/#tracing-one-agent-session).
27
+ - **`dispatcher.binary.fallback` / `.unresolved` warn when `claude`
28
+ isn't on `PATH`.** Under systemd and launchd the unit's `PATH` is
29
+ minimal, and falling through to a hard-coded path is the usual
30
+ precursor to every session failing to spawn. It used to happen
31
+ silently.
32
+
33
+ ### Changed
34
+
35
+ - `spawn_session` takes optional `repo` and `issue_number` arguments,
36
+ which the dev, task and secops pipelines now pass. They are
37
+ observability-only — they let dispatcher events be correlated with
38
+ pipeline events without re-parsing the composite session id.
39
+
40
+ ### Fixed
41
+
42
+ - **A slow post is no longer logged as a failed one.** The bridge ACKs
43
+ only after Telegram has accepted a question, so a post slower than the
44
+ transport's wait meant the transport timed out first and logged
45
+ `dev.question.post_failed` while the bridge logged
46
+ `dev.question.posted` for the same request — a false claim in the
47
+ opposite direction to the one being fixed. A timeout now emits
48
+ `dev.question.post_unknown`, because the outcome genuinely is unknown;
49
+ a write that actually failed keeps the definite `post_failed`.
50
+ Delivery is only ever confirmed by the far side's acknowledgement;
51
+ everything else divides into "the bytes provably never left" (a
52
+ definite failure) and "they may have" (unknown). A `drain()` failure
53
+ is unknown — `write()` had already handed the data over — and on the
54
+ bridge, a Telegram timeout or bare transport error is unknown too,
55
+ while `BadRequest`/`Forbidden`/`InvalidToken` are Telegram answering
56
+ "no". Note `BadRequest` subclasses `NetworkError` in
57
+ python-telegram-bot, so that check cannot be a plain isinstance; it
58
+ lives in `is_ambiguous_delivery` next to the library that defines the
59
+ taxonomy. `TransportTimeoutError` subclasses
60
+ `TransportUnknownDeliveryError` subclasses `TransportError`, so
61
+ existing handlers are unaffected. The bridge's ERROR response carries
62
+ which of the two it was (`telegram_delivery_unknown` vs
63
+ `telegram_api_error`) rather than letting the transport re-derive it —
64
+ the transport cannot see the Telegram exception, so a generic error
65
+ forced it to assume the worst and contradict the bridge's own record
66
+ for the same `request_id`.
67
+
68
+ **Known limit:** python-telegram-bot raises `TimedOut` for an HTTPX
69
+ connection-pool timeout too, where the request was never sent. That
70
+ case is currently reported as unknown rather than as the definite
71
+ non-delivery it is. Separating it means matching on library internals,
72
+ which is more fragile than the mislabelling is harmful.
73
+
74
+ - **`dev.question.posted` no longer claims a delivery that never
75
+ happened.** The transport logged it before writing to the socket and the
76
+ bridge logged it before calling Telegram, so a failed write or a Telegram
77
+ outage still left a record saying the operator had been asked. The
78
+ transport now emits it on the bridge's ACK — the only signal that says
79
+ the question actually reached Telegram — and the bridge emits it after
80
+ the API accepts the message, tagged with `telegram_msg_id`. Both sides
81
+ emit `dev.question.post_failed` (with `reason` and a truncated `error`)
82
+ on the failure path instead. A question that is posted and then goes
83
+ unanswered still logs only `posted`: the delivery succeeded, the operator
84
+ just did not reply.
85
+ - **Questions and operator answers are no longer written to logs in
86
+ plaintext.** Every event already carried `question_hash` / `answer_hash`,
87
+ and then defeated it by logging the raw text in the field next door.
88
+ These records go to stdout and are captured into `~/.ctrlrelay/logs` and
89
+ the systemd journal, so free-text operator replies — and questions that
90
+ now enumerate every open PR in a repo — persisted there indefinitely with
91
+ no retention policy tuned for that content. The hash and length remain,
92
+ which is enough to correlate the same question across the poller and
93
+ bridge logs. A test walks the source and fails on any `log_event()` call
94
+ that passes a raw payload field.
95
+ - **A broken `gh` install no longer reports itself as "network
96
+ unavailable".** `subprocess.run` raising `OSError` means the child could
97
+ not be exec'd — a non-executable binary, a bad path, a wrong
98
+ architecture. Python does no networking here, `gh` does, so an `OSError`
99
+ raised on our side can never mean offline. Reporting it that way was the
100
+ exact misdiagnosis this classification exists to prevent: it sends the
101
+ operator to check their wifi while their `gh` install is broken. Only a
102
+ hung `gh` (timeout) still reads as connectivity — there the child
103
+ launched and then stopped responding.
104
+ - **`broken pipe` now reads as a network failure.** A peer that vanished
105
+ mid-write carries none of the other network markers, so it fell through
106
+ to a generic API error — a miss of the classifier's own purpose. Bare
107
+ `EOF` is deliberately still not matched: `gh` passes API response
108
+ bodies through verbatim, so matching it would misclassify real API
109
+ errors as outages, the same mistake in the other direction.
110
+ - **A resource limit is no longer blamed on the `gh` install.** `EMFILE`
111
+ and `ENOMEM` also reach the classifier as `OSError` from a failed spawn,
112
+ but answering those with "install GitHub CLI" is the same misdirection
113
+ wearing a different hat. Only exec-family errnos say the install is
114
+ broken; the rest surface the errno.
115
+ - **Go transport failures are no longer reported as API errors.** `gh`
116
+ renders a failed round-trip as `Get "https://…": <cause>`, which by
117
+ construction means no HTTP response arrived — so `EOF`, `http2: client
118
+ connection lost`, `request canceled` and `remote error: tls: handshake
119
+ failure` were all landing as "GitHub API call failed". Matching the
120
+ shape rather than each wording catches the family; a real API error
121
+ carries `(HTTP nnn)` instead and is unaffected. `read tcp …: connection
122
+ timed out` (a link dropped mid-request) is matched too.
123
+ - **TLS trust failures are no longer called transient.** An untrusted CA
124
+ or a skewed clock is a persistent local problem, and "retry later" can
125
+ never fix it. These now report as `tls_trust` with remediation pointing
126
+ at the CA bundle and the system clock. A certificate valid for the
127
+ wrong host stays a network failure — that is the captive-portal case.
128
+ - **`gh` stderr is escaped before Rich renders it.** Stderr containing
129
+ bracketed text (e.g. `[/docs]`) raised `MarkupError`, replacing the
130
+ diagnostic with a traceback and skipping the exit — losing the raw
131
+ detail the line exists to show.
132
+ - **The startup `gh` probe is time-bounded.** Without a timeout a hung
133
+ `gh` wedged `poller start` indefinitely, and the one exception branch
134
+ that really does mean connectivity could never fire.
135
+
136
+ - **Startup no longer blames your GitHub auth when you are offline.**
137
+ `poller start` probes `gh api user` before it does anything else, and
138
+ every way that probe can fail — unplugged network, expired token, a
139
+ 500 from the API — exits 1, so the CLI printed
140
+ `Command '[...]' returned non-zero exit status 1.` for all of them.
141
+ A new `core/network.py` classifies the failure from gh's own stderr
142
+ into network / auth / rate-limit / API-error (plus "gh not
143
+ installed"), and the CLI prints the matching message —
144
+ `Network unavailable — could not reach api.github.com` in yellow when
145
+ it is transient, a pointer to `gh auth status` when the credentials are the
146
+ problem, the HTTP status when the API answered. gh's raw stderr is
147
+ still printed underneath.
148
+
149
+ ## [0.9.0] - 2026-09-07
150
+
151
+ ### Added
152
+
153
+ - **`transport.telegram.question_ttl_seconds`: unanswered questions now
154
+ expire.** A `pending_resumes` row deliberately outlives the in-session
155
+ wait so a late reply can still drive the resume — but nothing ever
156
+ retired one, so the table only grew. Measured on one deployment: 46
157
+ questions posted, 4 ever answered, 16 still live and five repos
158
+ re-asking the same question on consecutive days. An expired row stops
159
+ being offered as the routing target for an orphan Telegram reply, and
160
+ a late answer can no longer revive it. Defaults to 172800 (48h),
161
+ minimum 3600.
162
+ - **New hourly `question_expiry_sweeper` job.** Retires a question early
163
+ once every issue and PR it cites is closed or merged — the case where
164
+ someone merged the PR by hand and the question became meaningless
165
+ rather than merely unanswered. Fails safe in both directions: a
166
+ question citing nothing is never expired this way, and any probe error
167
+ leaves the row alone, because re-asking a dead question is recoverable
168
+ while dropping a live one is not. The TTL pass runs first and needs no
169
+ network, so a GitHub outage still lets age-based expiry make progress.
170
+ Probing is capped at 200 numbers per run so one pass cannot outlive its
171
+ own hourly interval — unbounded, a 2000-row backlog against a slow
172
+ GitHub would be 8.3h of serialized `gh api` reads. Rows past the cap
173
+ are examined on the next run, and the TTL pass retires them on age
174
+ regardless.
175
+
176
+ - **`transport.telegram.ask_timeout_seconds`: how long a pipeline waits on
177
+ your Telegram reply.** Was a hard-coded 300s in `SocketTransport.ask`,
178
+ with no call site overriding it and no way to configure it. Defaults to
179
+ 900s — deliberately short, because a sweep runs its repos sequentially
180
+ and holds each repo's lock and worktree for the whole wait, and the
181
+ poller awaits its issue handler inline. This value only buys the
182
+ in-session fast path; answering later is covered by the pending-resume
183
+ path below, so raising it to cover an overnight gap would turn a daily
184
+ sweep into a multi-day one for no benefit.
185
+
186
+ ### Fixed
187
+
188
+ - **An expired question can no longer be resumed by a stale bridge.**
189
+ `answer_pending_resume` already refuses to answer an expired row, but
190
+ that guard only holds while every process runs the same code. The
191
+ bridge writes the answer and the poller reads it back — separate
192
+ daemons — so a deploy that restarts one before the other leaves a
193
+ window where an old bridge stamps `answered_at` on a row the new
194
+ poller has already expired, and the resume fires against state that
195
+ moved on. Seen live: the bridge ran three-day-old code for an hour
196
+ after the poller was upgraded. `list_pending_resumes_to_execute` now
197
+ filters expired rows too, closing the window from the side that acts
198
+ on them.
199
+
200
+ - **A secops repo failure now says what broke.** A sweep that died before
201
+ the `sessions` INSERT — anything raised by `ensure_bare_repo` or
202
+ `create_worktree`, both network-bound and both ahead of it — left no
203
+ trace at all: the DB update is guarded on the insert having happened,
204
+ and the `except` block had no logging. With an exception whose `str()`
205
+ is empty the Telegram alert then fell back to its generic summary, so
206
+ the operator got `Error processing <repo>` and nothing else. The
207
+ failure is now logged as `secops.repo.failed` regardless of which
208
+ branch runs, and both `summary` and `error` carry the exception type.
209
+ - **`git` timeouts stringify to something.** `asyncio.wait_for` raises a
210
+ bare `TimeoutError` whose message is the empty string; it is now
211
+ re-raised as the same type with the command, timeout, and cwd
212
+ attached, so it stays useful wherever it surfaces.
213
+ - **Bare clone and fetch get a transfer-sized timeout.** Both inherited
214
+ the 120s budget meant for local git plumbing, which a multi-gigabyte
215
+ repo cannot meet on any normal link — a 1.8 GB repo failed its sweep
216
+ this way every day. Network transfers now use 1800s, matching claude's
217
+ own `default_timeout_seconds`.
218
+ - **secops no longer looks at PRs outside its two author-filtered queries.**
219
+ The prompt only ever tells the agent to run `gh pr list` with
220
+ `--author "app/dependabot"` and `--author "$OPERATOR"` — but a step-5
221
+ bullet for "PRs from anyone else" implied a wider list existed, so the
222
+ agent ran its own unfiltered `gh pr list` to populate the category and
223
+ then signalled BLOCKED on PRs it can never merge. That fired a Telegram
224
+ question every sweep, forever, and every one timed out unanswered; run
225
+ summaries reporting "no open PRs at all" were the fingerprint of the
226
+ out-of-scope query. A third party's PR is now explicitly out of scope:
227
+ not fetched, not evaluated, not named in the summary. An explicit scope
228
+ fence forbids broadening the search. Operator-authored code PRs still
229
+ ASK, because there the answer genuinely decides what happens next.
230
+ - **BLOCKED questions may no longer be padded with already-decided items.**
231
+ Real questions were bundling a genuine Dependabot decision with a
232
+ non-actionable tail ("...confirm no action wanted"), burying the part
233
+ that needed an answer. Decided items belong in the run summary; a repo
234
+ with nothing to decide now finishes DONE instead of signalling BLOCKED.
235
+ - **Telegram questions now say which repo and session they belong to.** A
236
+ secops sweep posts one consolidated question per repo back-to-back and
237
+ the agent's text rarely names its own repo, so the operator saw a run of
238
+ near-identical "approve #387?" messages with nothing to tell them apart.
239
+ Worse, the orphan router's "reply again with the session_id included"
240
+ instruction was impossible to follow — the session_id had never been sent
241
+ to the operator in the first place. `ASK` now prefixes the question with
242
+ `[owner/repo]`, the session_id, and (for dev) the issue number.
243
+ - **Replying to one question no longer answers a different one.** When the
244
+ bridge couldn't match an incoming reply it fell back to FIFO and
245
+ delivered the answer to the *oldest* outstanding question. Replying to
246
+ the third question while the first was still open silently answered the
247
+ first, with nothing shown to the operator. Reply-to now matches strictly;
248
+ a plain message routes only when exactly one question is outstanding, and
249
+ otherwise the operator is told to use Telegram's reply and shown what is
250
+ waiting.
251
+ - **A late reply now resumes the session it was actually aimed at.** The
252
+ bridge remembers which session each posted question belongs to (bounded
253
+ to the last 500), so a reply arriving after the ASK timed out resolves
254
+ its `pending_resumes` row exactly instead of reporting "multiple BLOCKED
255
+ sessions are unanswered" and asking for a session_id the operator was
256
+ never given.
257
+ - **An answer given after the pipeline stopped waiting is no longer thrown
258
+ away.** When `ask()` timed out, the client discarded the request but did
259
+ not close the socket — a secops sweep reuses one transport across every
260
+ repo — so the bridge went on treating the question as live. A later
261
+ reply-to matched that dead entry, and the ANSWER was written into a
262
+ request nobody was listening for: no log, no notice, nothing. The
263
+ operator replied exactly as instructed and the answer vanished. The
264
+ bridge now tracks each question's deadline and drops expired entries
265
+ before routing, sending the reply down the pending-resume path instead.
266
+ Expired entries also no longer inflate the "more than one question is
267
+ waiting" count, which was refusing plain answers to the one real
268
+ question.
269
+ - **The post-sweep "blocked on `<repo>`" message is answerable.** It is
270
+ sent as a one-way notice and its Telegram id was never recorded, so
271
+ replying to the most recent and most prominent message for a repo landed
272
+ in the ambiguous branch. A `SEND` that names a session is now tracked the
273
+ same way an `ASK` is.
274
+ - **Operator notices name repos and sessions, not internal ids.** The
275
+ "still waiting" listing rendered `telegram_msg_id: request_id` — Telegram
276
+ never shows message ids in its UI and `request_id` means nothing to a
277
+ human. It now uses the same `[owner/repo] (session: …)` shape as the
278
+ question header.
279
+
10
280
  ## [0.8.1] - 2026-09-04
11
281
 
12
282
  ### Fixed
@@ -1267,7 +1537,9 @@ pipeline).
1267
1537
  per-phase implementation plans (Phase 0 through Phase 4).
1268
1538
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1269
1539
 
1270
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.1...HEAD
1540
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...HEAD
1541
+ [0.10.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.9.0...v0.10.0
1542
+ [0.9.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.1...v0.9.0
1271
1543
  [0.8.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.0...v0.8.1
1272
1544
  [0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
1273
1545
  [0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.8.1
3
+ Version: 0.10.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/
@@ -37,6 +37,13 @@ transport:
37
37
  bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"
38
38
  chat_id: 123456789
39
39
  socket_path: "~/.ctrlrelay/ctrlrelay.sock"
40
+ # Seconds a pipeline waits on your reply before giving up and
41
+ # persisting the session as BLOCKED. This is only the in-session fast
42
+ # path — a reply that arrives later still attaches to the saved
43
+ # session and the pending-resume sweeper drives it. Don't raise this
44
+ # to cover being asleep: a sweep runs its repos one at a time and
45
+ # holds each repo's lock and worktree for the whole wait.
46
+ ask_timeout_seconds: 900
40
47
  file_mock:
41
48
  inbox: "~/.ctrlrelay/inbox.txt"
42
49
  outbox: "~/.ctrlrelay/outbox.txt"
@@ -118,6 +118,8 @@ transport:
118
118
  | `bot_token_env` | string | `"CTRLRELAY_TELEGRAM_TOKEN"` | Name of the environment variable holding the bot token. ctrlrelay never reads the token directly — only the variable name. |
119
119
  | `chat_id` | int | `0` | Telegram chat ID the bridge sends messages to and accepts replies from. |
120
120
  | `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
121
+ | `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. |
122
+ | `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. |
121
123
 
122
124
  See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
123
125
 
@@ -305,6 +305,57 @@ tail -f ~/.ctrlrelay/logs/poller.error.log
305
305
  The poller prints one line per detected issue and one line per pipeline outcome.
306
306
  The bridge prints connection events and Telegram API errors.
307
307
 
308
+ ### Tracing one agent session
309
+
310
+ Log lines are newline-delimited JSON, and the dispatcher — the module that
311
+ spawns and supervises every agent subprocess — tags each of its events with
312
+ the session id, so one grep reconstructs a whole run:
313
+
314
+ ```bash
315
+ grep dev-owner-repo-42- ~/.ctrlrelay/logs/poller.log | jq -c '{ts, level, event}'
316
+ ```
317
+
318
+ The events it emits:
319
+
320
+ | Event | Level | When |
321
+ |---|---|---|
322
+ | `dispatcher.session.start` | INFO | Subprocess about to launch. Carries `binary`, `timeout`, `resume`, `prompt_hash`, `prompt_len`. |
323
+ | `dispatcher.session.finished` | INFO | Subprocess exited. Carries `exit_code`, `checkpoint_status`, `elapsed_ms`. |
324
+ | `dispatcher.session.subprocess_failed` | ERROR | Non-zero exit, with `stderr_tail`. |
325
+ | `dispatcher.session.timeout` | ERROR | The `timeout` seconds elapsed and the child was killed. |
326
+ | `dispatcher.session.spawn_failed` | ERROR | The binary could not be exec'd at all. |
327
+ | `dispatcher.session.cancelled` | INFO | Daemon shutdown killed the child mid-run. |
328
+ | `dispatcher.checkpoint.missing` | WARNING | The session ended without writing a checkpoint. |
329
+ | `dispatcher.checkpoint.read_failed` | ERROR | A checkpoint file existed but would not parse. |
330
+ | `dispatcher.binary.fallback` | WARNING | `claude` was not on `PATH`; a well-known path was used instead. Expect this when the unit's `PATH` is minimal. |
331
+ | `dispatcher.binary.unresolved` | WARNING | Not on `PATH` and not at any known path — sessions are about to start failing to spawn. |
332
+
333
+ Prompts and agent output never appear in plaintext: only a short SHA-256
334
+ prefix and a length are logged.
335
+
336
+ ### Blocked-question events
337
+
338
+ When a session blocks on a question, the transport and the bridge each emit
339
+ their own record:
340
+
341
+ | Event | When |
342
+ |---|---|
343
+ | `dev.question.posted` | The question reached the operator. The transport emits it on the bridge's ACK; the bridge emits it once the Telegram API has accepted the message, and adds `telegram_msg_id`. |
344
+ | `dev.question.post_failed` | Delivery failed. Carries `reason` (`send_failed`, `bridge_error`, or the exception class on the bridge side) and a truncated `error`. |
345
+ | `dev.answer.received` | The operator's reply came back. |
346
+
347
+ Neither the question nor the answer is written to the log. Each event carries
348
+ `question_hash` / `question_length` (or `answer_hash` / `answer_length`)
349
+ instead — the hash is stable, so it still joins the poller's record to the
350
+ bridge's record for the same question:
351
+
352
+ ```bash
353
+ grep 44509098fa6eeaf3 ~/.ctrlrelay/logs/*.log | jq -c '{ts, logger, event}'
354
+ ```
355
+
356
+ This matters because these files and the systemd journal have no retention
357
+ policy tuned for human-entered content, and operator replies are free text.
358
+
308
359
  ## Inspecting state
309
360
 
310
361
  ### `ctrlrelay status`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.8.1"
3
+ version = "0.10.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"