ctrlrelay 0.9.0__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 (120) hide show
  1. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/workflows/publish.yml +11 -3
  2. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/CHANGELOG.md +141 -1
  3. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/PKG-INFO +1 -1
  4. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/operations.md +51 -0
  5. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/pyproject.toml +1 -1
  6. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/server.py +57 -18
  7. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/telegram_handler.py +19 -0
  8. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/cli.py +30 -2
  9. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/dispatcher.py +152 -16
  10. ctrlrelay-0.10.0/src/ctrlrelay/core/network.py +277 -0
  11. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/dev.py +2 -0
  12. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/secops.py +2 -0
  13. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/task.py +2 -0
  14. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/base.py +21 -0
  15. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/file_mock.py +3 -2
  16. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/socket_client.py +118 -23
  17. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_cli_start.py +136 -0
  18. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_dev_pipeline.py +50 -0
  19. ctrlrelay-0.10.0/tests/test_dispatcher.py +668 -0
  20. ctrlrelay-0.10.0/tests/test_network.py +405 -0
  21. ctrlrelay-0.10.0/tests/test_obs.py +1061 -0
  22. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_transport.py +2 -2
  23. ctrlrelay-0.9.0/tests/test_dispatcher.py +0 -293
  24. ctrlrelay-0.9.0/tests/test_obs.py +0 -315
  25. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  26. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/dependabot.yml +0 -0
  29. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/workflows/build.yml +0 -0
  30. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/workflows/cla.yml +0 -0
  31. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/workflows/pages.yml +0 -0
  32. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.github/workflows/test.yml +0 -0
  33. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/.gitignore +0 -0
  34. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/CODE_OF_CONDUCT.md +0 -0
  35. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/CONTRIBUTING.md +0 -0
  36. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/LICENSE +0 -0
  37. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/README.md +0 -0
  38. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/SECURITY.md +0 -0
  39. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/config/orchestrator.yaml.example +0 -0
  40. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/Gemfile +0 -0
  41. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/_config.yml +0 -0
  42. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/architecture.md +0 -0
  43. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/bridge.md +0 -0
  44. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/cli.md +0 -0
  45. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/configuration.md +0 -0
  46. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/development.md +0 -0
  47. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/feedback-loop.md +0 -0
  48. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/getting-started.md +0 -0
  49. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/index.md +0 -0
  50. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/docs/personalization.md +0 -0
  51. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/__init__.py +0 -0
  52. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  53. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  54. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  55. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/__init__.py +0 -0
  56. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/audit.py +0 -0
  57. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  58. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/config.py +0 -0
  59. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/github.py +0 -0
  60. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/obs.py +0 -0
  61. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/poller.py +0 -0
  62. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  63. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  64. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  65. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/scheduler.py +0 -0
  66. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/state.py +0 -0
  67. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/core/worktree.py +0 -0
  68. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  69. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/dashboard/client.py +0 -0
  70. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/gh_protocol.py +0 -0
  71. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/install.py +0 -0
  72. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  73. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/personalization/manager.py +0 -0
  74. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/personalization/paths.py +0 -0
  75. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  76. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/base.py +0 -0
  77. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  78. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/setup.py +0 -0
  79. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/__init__.py +0 -0
  80. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  81. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  82. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  83. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  84. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  85. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  86. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  87. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/src/ctrlrelay/transports/__init__.py +0 -0
  88. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/__init__.py +0 -0
  89. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/conftest.py +0 -0
  90. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_audit.py +0 -0
  91. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_bridge_protocol.py +0 -0
  92. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_bridge_server.py +0 -0
  93. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_checkpoint.py +0 -0
  94. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_cli_ci_wait.py +0 -0
  95. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_cli_dev.py +0 -0
  96. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_cli_repos.py +0 -0
  97. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_cli_secops.py +0 -0
  98. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_cli_version.py +0 -0
  99. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_config.py +0 -0
  100. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_dashboard_client.py +0 -0
  101. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_dev_integration.py +0 -0
  102. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_docs_site.py +0 -0
  103. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_gh_protocol.py +0 -0
  104. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_github.py +0 -0
  105. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_install.py +0 -0
  106. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_personalization.py +0 -0
  107. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_pipeline_base.py +0 -0
  108. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_poller.py +0 -0
  109. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_post_merge.py +0 -0
  110. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_pr_verifier.py +0 -0
  111. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_pr_watcher.py +0 -0
  112. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_question_expiry.py +0 -0
  113. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_scheduler.py +0 -0
  114. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_secops_integration.py +0 -0
  115. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_secops_pipeline.py +0 -0
  116. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_setup.py +0 -0
  117. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_state.py +0 -0
  118. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_task_pipeline.py +0 -0
  119. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_telegram_handler.py +0 -0
  120. {ctrlrelay-0.9.0 → ctrlrelay-0.10.0}/tests/test_worktree.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,145 @@ 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
+
10
149
  ## [0.9.0] - 2026-09-07
11
150
 
12
151
  ### Added
@@ -1398,7 +1537,8 @@ pipeline).
1398
1537
  per-phase implementation plans (Phase 0 through Phase 4).
1399
1538
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1400
1539
 
1401
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.9.0...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
1402
1542
  [0.9.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.1...v0.9.0
1403
1543
  [0.8.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.0...v0.8.1
1404
1544
  [0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.9.0
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/
@@ -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.9.0"
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"
@@ -19,7 +19,10 @@ from ctrlrelay.bridge.protocol import (
19
19
  parse_message,
20
20
  serialize_message,
21
21
  )
22
- from ctrlrelay.bridge.telegram_handler import TelegramHandler
22
+ from ctrlrelay.bridge.telegram_handler import (
23
+ TelegramHandler,
24
+ is_ambiguous_delivery,
25
+ )
23
26
  from ctrlrelay.core.obs import get_logger, hash_text, log_event
24
27
 
25
28
  if TYPE_CHECKING:
@@ -298,6 +301,23 @@ class BridgeServer:
298
301
  )
299
302
 
300
303
  if msg.op == BridgeOp.ASK:
304
+ question = msg.question or ""
305
+ # Hash + length only: these records land in the journal and in
306
+ # poller.log, so the question text itself would persist there in
307
+ # plaintext indefinitely. The hash still joins this event to the
308
+ # client's own dev.question.posted. Built outside the try so the
309
+ # failure path can always describe what failed.
310
+ post_fields = {
311
+ "session_id": msg.session_id,
312
+ "repo": msg.repo,
313
+ "issue_number": msg.issue_number,
314
+ "transport": "telegram",
315
+ "destination": f"telegram:chat={self.chat_id}",
316
+ "request_id": msg.request_id,
317
+ "question_length": len(question),
318
+ "question_hash": hash_text(question),
319
+ "options": msg.options,
320
+ }
301
321
  try:
302
322
  assert self._telegram is not None
303
323
  # Start the expiry clock before the Telegram round trip, not
@@ -308,21 +328,6 @@ class BridgeServer:
308
328
  # reply-to in that window writes the answer into a dead
309
329
  # request.
310
330
  received_at = time.monotonic()
311
- question = msg.question or ""
312
- log_event(
313
- _logger,
314
- "dev.question.posted",
315
- session_id=msg.session_id,
316
- repo=msg.repo,
317
- issue_number=msg.issue_number,
318
- transport="telegram",
319
- destination=f"telegram:chat={self.chat_id}",
320
- request_id=msg.request_id,
321
- question=question,
322
- question_length=len(question),
323
- question_hash=hash_text(question),
324
- options=msg.options,
325
- )
326
331
  telegram_msg_id = await self._telegram.ask(
327
332
  format_question(
328
333
  question,
@@ -350,15 +355,50 @@ class BridgeServer:
350
355
  "bridge: ASK posted request_id=%s telegram_msg_id=%s",
351
356
  msg.request_id, telegram_msg_id,
352
357
  )
358
+ # Emitted here, not before the send: until Telegram has
359
+ # accepted the message there is nothing posted to claim.
360
+ log_event(
361
+ _logger,
362
+ "dev.question.posted",
363
+ **post_fields,
364
+ telegram_msg_id=telegram_msg_id,
365
+ )
353
366
  return BridgeMessage(
354
367
  op=BridgeOp.ACK, request_id=msg.request_id, status="pending",
355
368
  )
356
369
  except Exception as e:
357
370
  _log.warning("bridge: ASK failed, request_id=%s err=%s", msg.request_id, e)
371
+ # Same rule as the transport: only claim a definite
372
+ # failure when Telegram actually answered. A timeout or a
373
+ # bare transport error can be raised after Telegram
374
+ # accepted the message, in which case the question IS on
375
+ # the operator's phone and "post_failed" is a lie.
376
+ #
377
+ # The taxonomy is not intuitive, so it lives next to the
378
+ # library that defines it — see is_ambiguous_delivery.
379
+ unknown = is_ambiguous_delivery(e)
380
+ log_event(
381
+ _logger,
382
+ "dev.question.post_unknown"
383
+ if unknown
384
+ else "dev.question.post_failed",
385
+ **post_fields,
386
+ reason=type(e).__name__,
387
+ error=str(e)[:200],
388
+ )
358
389
  return BridgeMessage(
359
390
  op=BridgeOp.ERROR,
360
391
  request_id=msg.request_id,
361
- error="telegram_api_error",
392
+ # Carry the classification instead of letting the
393
+ # other side re-derive it: the transport cannot see
394
+ # the Telegram exception, so a generic ERROR forced
395
+ # it to assume the worst and contradict the
396
+ # post_unknown just logged here for the same request.
397
+ error=(
398
+ "telegram_delivery_unknown"
399
+ if unknown
400
+ else "telegram_api_error"
401
+ ),
362
402
  message=str(e),
363
403
  )
364
404
 
@@ -473,7 +513,6 @@ class BridgeServer:
473
513
  request_id=match.request_id,
474
514
  telegram_msg_id=match.telegram_msg_id,
475
515
  reply_to_message_id=reply_to_message_id,
476
- answer=text,
477
516
  answer_length=len(text),
478
517
  answer_hash=hash_text(text),
479
518
  )
@@ -7,12 +7,31 @@ import logging
7
7
  from typing import Awaitable, Callable
8
8
 
9
9
  from telegram import Bot, ReplyKeyboardMarkup, ReplyKeyboardRemove
10
+ from telegram.error import NetworkError, TimedOut
10
11
 
11
12
  _log = logging.getLogger(__name__)
12
13
 
13
14
  IncomingMessageHandler = Callable[[str, int | None], Awaitable[None]]
14
15
 
15
16
 
17
+
18
+ def is_ambiguous_delivery(exc: BaseException) -> bool:
19
+ """True when a send may have reached Telegram despite raising.
20
+
21
+ Delivery is only confirmed by a response. A timeout or a bare
22
+ transport error can be raised *after* Telegram accepted the message,
23
+ so calling those a failed post would put a lie in the log while the
24
+ question sits on the operator's phone.
25
+
26
+ The taxonomy is not intuitive and is worth stating: `BadRequest`
27
+ subclasses `NetworkError` here, so `isinstance(exc, NetworkError)`
28
+ would sweep definite rejections into the ambiguous bucket. Only
29
+ `TimedOut` and a bare `NetworkError` are genuinely unknown —
30
+ `BadRequest`, `Forbidden`, `InvalidToken` and the rest are Telegram
31
+ answering "no".
32
+ """
33
+ return isinstance(exc, TimedOut) or type(exc) is NetworkError
34
+
16
35
  class TelegramHandler:
17
36
  """Handles Telegram Bot API communication — outbound (send/ask) and
18
37
  inbound (long-poll get_updates) for answers from the operator."""
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
 
6
6
  import typer
7
7
  from rich.console import Console
8
+ from rich.markup import escape as rich_escape
8
9
  from rich.table import Table
9
10
 
10
11
  from ctrlrelay import __version__
@@ -13,6 +14,10 @@ from ctrlrelay.core.github import GitHubCLI, GitHubError
13
14
  from ctrlrelay.core.pr_verifier import PRVerifier
14
15
  from ctrlrelay.core.question_expiry import expire_stale_questions
15
16
 
17
+ # Startup probe cap. `GitHubCLI` uses 60s for ordinary calls; this one
18
+ # gates daemon startup, so a hung gh must not wedge it indefinitely.
19
+ _GH_PROBE_TIMEOUT_SECONDS = 60
20
+
16
21
  app = typer.Typer(
17
22
  name="ctrlrelay",
18
23
  help="Local-first orchestrator for Claude Code across multiple GitHub repos.",
@@ -1035,10 +1040,33 @@ def poller_start(
1035
1040
  capture_output=True,
1036
1041
  text=True,
1037
1042
  check=True,
1043
+ # Without this a hung gh hangs `poller start` forever, and
1044
+ # the TimeoutExpired branch of the classifier — the one
1045
+ # case that really does mean connectivity — can never fire.
1046
+ timeout=_GH_PROBE_TIMEOUT_SECONDS,
1038
1047
  )
1039
1048
  username = result.stdout.strip()
1040
- except subprocess.CalledProcessError as e:
1041
- console.print(f"[red]Failed to get GitHub username:[/red] {e}")
1049
+ except (subprocess.SubprocessError, OSError) as e:
1050
+ # `gh` exits 1 for offline, expired-token and API-error alike,
1051
+ # so classify from its stderr — otherwise operators debug auth
1052
+ # when the laptop is simply offline (#32).
1053
+ from ctrlrelay.core.network import gh_failure_from_exception
1054
+
1055
+ failure = gh_failure_from_exception(e)
1056
+ color = "yellow" if failure.is_transient else "red"
1057
+ console.print(
1058
+ f"[{color}]Could not determine GitHub username:[/{color}] "
1059
+ f"{failure.message}"
1060
+ )
1061
+ if failure.stderr:
1062
+ # gh's stderr is arbitrary text and Rich parses [...] as
1063
+ # markup: a stderr containing something like "[/docs]"
1064
+ # raises MarkupError, which replaces the diagnostic with a
1065
+ # traceback and skips the exit — losing exactly the detail
1066
+ # this line exists to show.
1067
+ console.print(
1068
+ f"[dim]gh: {rich_escape(failure.stderr)}[/dim]"
1069
+ )
1042
1070
  raise typer.Exit(1)
1043
1071
 
1044
1072
  if not username: