ctrlrelay 0.10.0__py3-none-any.whl

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 (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>${LABEL_PREFIX}.ctrlrelay-bridge</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>${CTRLRELAY_BIN}</string>
10
+ <string>bridge</string>
11
+ <string>start</string>
12
+ <string>--foreground</string>
13
+ </array>
14
+ <key>EnvironmentVariables</key>
15
+ <dict>
16
+ <key>CTRLRELAY_TELEGRAM_TOKEN</key>
17
+ <string>${CTRLRELAY_TELEGRAM_TOKEN}</string>
18
+ <key>PATH</key>
19
+ <string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
20
+ <!-- Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's
21
+ stdio buffer instead of flushing line-by-line, making
22
+ `tail -f ~/.ctrlrelay/logs/bridge.log` lag noticeably. -->
23
+ <key>PYTHONUNBUFFERED</key>
24
+ <string>1</string>
25
+ </dict>
26
+ <key>WorkingDirectory</key>
27
+ <string>${WORKDIR}</string>
28
+ <key>RunAtLoad</key>
29
+ <true/>
30
+ <key>KeepAlive</key>
31
+ <true/>
32
+ <key>StandardOutPath</key>
33
+ <string>${HOME}/.ctrlrelay/logs/bridge.log</string>
34
+ <key>StandardErrorPath</key>
35
+ <string>${HOME}/.ctrlrelay/logs/bridge.error.log</string>
36
+ </dict>
37
+ </plist>
@@ -0,0 +1,44 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>${LABEL_PREFIX}.ctrlrelay-poller</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>${CTRLRELAY_BIN}</string>
10
+ <string>poller</string>
11
+ <string>start</string>
12
+ <string>--foreground</string>
13
+ <string>--interval</string>
14
+ <string>${POLLER_INTERVAL}</string>
15
+ </array>
16
+ <key>EnvironmentVariables</key>
17
+ <dict>
18
+ <key>CTRLRELAY_TELEGRAM_TOKEN</key>
19
+ <string>${CTRLRELAY_TELEGRAM_TOKEN}</string>
20
+ <key>PATH</key>
21
+ <string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
22
+ <!-- Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's
23
+ stdio buffer instead of flushing line-by-line, making
24
+ `tail -f ~/.ctrlrelay/logs/poller.log` lag noticeably. -->
25
+ <key>PYTHONUNBUFFERED</key>
26
+ <string>1</string>
27
+ </dict>
28
+ <key>WorkingDirectory</key>
29
+ <string>${WORKDIR}</string>
30
+ <key>RunAtLoad</key>
31
+ <true/>
32
+ <key>KeepAlive</key>
33
+ <true/>
34
+ <!-- Give the in-process scheduler time to drain a running secops
35
+ sweep on stop (worktree cleanup can take ~120s). launchd's
36
+ default ExitTimeOut is 20s, which would SIGKILL mid-cleanup. -->
37
+ <key>ExitTimeOut</key>
38
+ <integer>180</integer>
39
+ <key>StandardOutPath</key>
40
+ <string>${HOME}/.ctrlrelay/logs/poller.log</string>
41
+ <key>StandardErrorPath</key>
42
+ <string>${HOME}/.ctrlrelay/logs/poller.error.log</string>
43
+ </dict>
44
+ </plist>
File without changes
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=ctrlrelay Telegram bridge
3
+ After=network-online.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory=${WORKDIR}
8
+ Environment=CTRLRELAY_TELEGRAM_TOKEN=${CTRLRELAY_TELEGRAM_TOKEN}
9
+ # Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's stdio
10
+ # buffer instead of flushing line-by-line, making `tail -f` and
11
+ # `journalctl -f` lag noticeably.
12
+ Environment=PYTHONUNBUFFERED=1
13
+ ExecStart=${CTRLRELAY_BIN} bridge start --foreground
14
+ Restart=always
15
+ RestartSec=5
16
+ StandardOutput=append:%h/.ctrlrelay/logs/bridge.log
17
+ StandardError=append:%h/.ctrlrelay/logs/bridge.error.log
18
+
19
+ [Install]
20
+ WantedBy=default.target
@@ -0,0 +1,25 @@
1
+ [Unit]
2
+ Description=ctrlrelay issue poller
3
+ After=network-online.target ctrlrelay-bridge.service
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory=${WORKDIR}
8
+ Environment=CTRLRELAY_TELEGRAM_TOKEN=${CTRLRELAY_TELEGRAM_TOKEN}
9
+ # Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's stdio
10
+ # buffer instead of flushing line-by-line, making `tail -f` and
11
+ # `journalctl -f` lag noticeably.
12
+ Environment=PYTHONUNBUFFERED=1
13
+ ExecStart=${CTRLRELAY_BIN} poller start --foreground --interval ${POLLER_INTERVAL}
14
+ Restart=always
15
+ RestartSec=5
16
+ # Give the in-process scheduler time to drain a running secops sweep
17
+ # on stop (worktree cleanup can take ~120s). systemd's default
18
+ # TimeoutStopSec is 90s, which would SIGKILL the poller mid-cleanup
19
+ # and leak git worktree admin state.
20
+ TimeoutStopSec=180
21
+ StandardOutput=append:%h/.ctrlrelay/logs/poller.log
22
+ StandardError=append:%h/.ctrlrelay/logs/poller.error.log
23
+
24
+ [Install]
25
+ WantedBy=default.target
@@ -0,0 +1,34 @@
1
+ """Transport abstraction for orchestrator communication."""
2
+
3
+ from ctrlrelay.core.config import TransportConfig, TransportType
4
+ from ctrlrelay.transports.base import Transport, TransportError
5
+ from ctrlrelay.transports.file_mock import FileMockTransport
6
+ from ctrlrelay.transports.socket_client import SocketTransport
7
+
8
+
9
+ def get_transport(config: TransportConfig) -> Transport:
10
+ """Create transport instance from config."""
11
+ if config.type == TransportType.FILE_MOCK:
12
+ assert config.file_mock is not None
13
+ return FileMockTransport(
14
+ inbox=config.file_mock.inbox,
15
+ outbox=config.file_mock.outbox,
16
+ )
17
+
18
+ if config.type == TransportType.TELEGRAM:
19
+ assert config.telegram is not None
20
+ return SocketTransport(
21
+ socket_path=config.telegram.socket_path,
22
+ ask_timeout_seconds=config.telegram.ask_timeout_seconds,
23
+ )
24
+
25
+ raise TransportError(f"Unknown transport type: {config.type}")
26
+
27
+
28
+ __all__ = [
29
+ "FileMockTransport",
30
+ "SocketTransport",
31
+ "Transport",
32
+ "TransportError",
33
+ "get_transport",
34
+ ]
@@ -0,0 +1,74 @@
1
+ """Transport protocol definition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+
8
+ class TransportError(Exception):
9
+ """Raised when transport operations fail."""
10
+
11
+
12
+ class TransportUnknownDeliveryError(TransportError):
13
+ """Raised when a request may or may not have been delivered.
14
+
15
+ Delivery is only ever *confirmed* by the far side's acknowledgement.
16
+ Everything else divides in two: we know the bytes never left (a
17
+ definite failure), or we do not (unknown). Enumerating individual
18
+ ambiguous cases does not converge — the honest discriminator is
19
+ whether anything was written at all.
20
+
21
+ Subclasses TransportError so existing handlers are unaffected.
22
+ """
23
+
24
+
25
+ class TransportTimeoutError(TransportUnknownDeliveryError):
26
+ """Sent, but no reply arrived in time.
27
+
28
+ A specific unknown: the request went out and the far side may still
29
+ act on it after we stop waiting.
30
+ """
31
+
32
+
33
+ @runtime_checkable
34
+ class Transport(Protocol):
35
+ """Protocol for orchestrator-to-human communication.
36
+
37
+ Implementations accept optional correlation kwargs (``session_id``,
38
+ ``repo``, ``issue_number``) used solely for structured logging — they are
39
+ never part of the wire payload.
40
+ """
41
+
42
+ async def send(
43
+ self,
44
+ message: str,
45
+ *,
46
+ session_id: str | None = None,
47
+ repo: str | None = None,
48
+ issue_number: int | None = None,
49
+ ) -> None:
50
+ """Send a one-way message (no response expected)."""
51
+ ...
52
+
53
+ async def ask(
54
+ self,
55
+ question: str,
56
+ options: list[str] | None = None,
57
+ timeout: int | None = None,
58
+ *,
59
+ session_id: str | None = None,
60
+ repo: str | None = None,
61
+ issue_number: int | None = None,
62
+ ) -> str:
63
+ """Ask a question and wait for response.
64
+
65
+ ``timeout`` of ``None`` means "use the implementation's configured
66
+ default" — callers that have no opinion must not pin a literal here,
67
+ or the operator's ``ask_timeout_seconds`` setting is silently
68
+ bypassed.
69
+ """
70
+ ...
71
+
72
+ async def close(self) -> None:
73
+ """Close the transport connection."""
74
+ ...
@@ -0,0 +1,103 @@
1
+ """File-based mock transport for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from ctrlrelay.core.obs import get_logger, hash_text, log_event
11
+ from ctrlrelay.transports.base import TransportError
12
+
13
+ _logger = get_logger("transport.file_mock")
14
+
15
+
16
+ class FileMockTransport:
17
+ """Transport that reads/writes to files for testing."""
18
+
19
+ def __init__(
20
+ self,
21
+ inbox: Path,
22
+ outbox: Path,
23
+ ask_timeout_seconds: int = 300,
24
+ ) -> None:
25
+ self.inbox = inbox
26
+ self.outbox = outbox
27
+ self.ask_timeout_seconds = ask_timeout_seconds
28
+
29
+ async def send(
30
+ self,
31
+ message: str,
32
+ *,
33
+ session_id: str | None = None,
34
+ repo: str | None = None,
35
+ issue_number: int | None = None,
36
+ ) -> None:
37
+ """Write message to outbox file."""
38
+ timestamp = datetime.now(timezone.utc).isoformat()
39
+ with self.outbox.open("a") as f:
40
+ f.write(f"[{timestamp}] {message}\n")
41
+
42
+ async def ask(
43
+ self,
44
+ question: str,
45
+ options: list[str] | None = None,
46
+ timeout: int | None = None,
47
+ *,
48
+ session_id: str | None = None,
49
+ repo: str | None = None,
50
+ issue_number: int | None = None,
51
+ ) -> str:
52
+ """Write question to outbox and poll inbox for answer."""
53
+ if timeout is None:
54
+ timeout = self.ask_timeout_seconds
55
+ timestamp = datetime.now(timezone.utc).isoformat()
56
+ opts = f" [{'/'.join(options)}]" if options else ""
57
+ with self.outbox.open("a") as f:
58
+ f.write(f"[{timestamp}] QUESTION: {question}{opts}\n")
59
+
60
+ # Logged only after the outbox write, and without the question text:
61
+ # hash + length are enough to correlate, and keep operator content
62
+ # out of the log stream.
63
+ log_event(
64
+ _logger,
65
+ "dev.question.posted",
66
+ session_id=session_id,
67
+ repo=repo,
68
+ issue_number=issue_number,
69
+ transport="file_mock",
70
+ destination=str(self.outbox),
71
+ question_length=len(question),
72
+ question_hash=hash_text(question),
73
+ options=options,
74
+ )
75
+
76
+ sent_at = time.monotonic()
77
+ start = asyncio.get_event_loop().time()
78
+ while True:
79
+ content = self.inbox.read_text().strip()
80
+ if content:
81
+ self.inbox.write_text("")
82
+ answer = content.split("\n")[0].strip()
83
+ log_event(
84
+ _logger,
85
+ "dev.answer.received",
86
+ session_id=session_id,
87
+ repo=repo,
88
+ issue_number=issue_number,
89
+ transport="file_mock",
90
+ answer_length=len(answer),
91
+ answer_hash=hash_text(answer),
92
+ elapsed_ms=int((time.monotonic() - sent_at) * 1000),
93
+ )
94
+ return answer
95
+
96
+ if asyncio.get_event_loop().time() - start > timeout:
97
+ raise TransportError(f"ask() timeout after {timeout}s")
98
+
99
+ await asyncio.sleep(0.5)
100
+
101
+ async def close(self) -> None:
102
+ """No-op for file transport."""
103
+ pass
@@ -0,0 +1,298 @@
1
+ """Unix socket transport client for bridge communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ from ctrlrelay.bridge.protocol import (
12
+ BridgeMessage,
13
+ BridgeOp,
14
+ ProtocolError,
15
+ parse_message,
16
+ serialize_message,
17
+ )
18
+ from ctrlrelay.core.obs import get_logger, hash_text, log_event
19
+ from ctrlrelay.transports.base import (
20
+ TransportError,
21
+ TransportTimeoutError,
22
+ TransportUnknownDeliveryError,
23
+ )
24
+
25
+ _logger = get_logger("transport.socket")
26
+
27
+
28
+ class SocketTransport:
29
+ """Transport that connects to bridge via Unix socket."""
30
+
31
+ def __init__(
32
+ self,
33
+ socket_path: Path,
34
+ ask_timeout_seconds: int = 300,
35
+ ) -> None:
36
+ self.socket_path = socket_path
37
+ # Per-instance default for ask(). Callers construct this from
38
+ # ``transport.telegram.ask_timeout_seconds`` so a single operator
39
+ # setting governs every pipeline's BLOCKED wait; the 300s fallback
40
+ # only applies to ad-hoc construction in tests and one-shot CLI
41
+ # helpers that never ask.
42
+ self.ask_timeout_seconds = ask_timeout_seconds
43
+ self._reader: asyncio.StreamReader | None = None
44
+ self._writer: asyncio.StreamWriter | None = None
45
+ self._pending: dict[str, asyncio.Future[BridgeMessage]] = {}
46
+ self._on_ack: dict[str, Callable[[], None]] = {}
47
+ self._receive_task: asyncio.Task | None = None
48
+
49
+ @property
50
+ def connected(self) -> bool:
51
+ return self._writer is not None and not self._writer.is_closing()
52
+
53
+ async def connect(self) -> None:
54
+ """Connect to bridge socket."""
55
+ try:
56
+ self._reader, self._writer = await asyncio.open_unix_connection(
57
+ str(self.socket_path)
58
+ )
59
+ self._receive_task = asyncio.create_task(self._receive_loop())
60
+ except (OSError, ConnectionRefusedError) as e:
61
+ raise TransportError(f"Failed to connect to bridge: {e}") from e
62
+
63
+ async def _receive_loop(self) -> None:
64
+ """Background task to receive messages from bridge."""
65
+ assert self._reader is not None
66
+ try:
67
+ while True:
68
+ line = await self._reader.readline()
69
+ if not line:
70
+ break
71
+ try:
72
+ msg = parse_message(line.decode())
73
+ if msg.request_id and msg.request_id in self._pending:
74
+ # The bridge sends two messages back for an ASK:
75
+ # an intermediate ACK(status="pending") immediately
76
+ # after queuing for Telegram, then an ANSWER (or
77
+ # ERROR) once the operator replies. Resolving the
78
+ # future on the first ACK collapses the wait
79
+ # prematurely — caller sees "Unexpected response:
80
+ # BridgeOp.ACK" and throws. Skip those to keep
81
+ # waiting. Terminal ACKs (status="sent" from SEND)
82
+ # remain the final response and resolve normally.
83
+ if msg.op == BridgeOp.ACK and msg.status == "pending":
84
+ # This ACK is also the only proof the bridge got
85
+ # the question in front of the operator, so it is
86
+ # what "posted" is allowed to mean.
87
+ callback = self._on_ack.get(msg.request_id)
88
+ if callback is not None:
89
+ callback()
90
+ continue
91
+ self._pending[msg.request_id].set_result(msg)
92
+ except ProtocolError:
93
+ pass
94
+ except asyncio.CancelledError:
95
+ pass
96
+
97
+ async def _send_message(self, msg: BridgeMessage) -> None:
98
+ """Send message to bridge.
99
+
100
+ Raises ``TransportError`` while nothing has been written — a
101
+ definite non-delivery — and ``TransportUnknownDeliveryError``
102
+ once bytes may have reached the far side. `drain()` failing does
103
+ not mean the bridge never saw the request: `write()` has already
104
+ handed the data to the transport, so the ASK may well have been
105
+ received and acted on.
106
+ """
107
+ if not self.connected:
108
+ raise TransportError("Transport not connected")
109
+ assert self._writer is not None
110
+ data = serialize_message(msg).encode()
111
+ try:
112
+ self._writer.write(data)
113
+ except Exception as e:
114
+ raise TransportError(f"Write failed: {e}") from e
115
+ try:
116
+ await self._writer.drain()
117
+ except Exception as e:
118
+ raise TransportUnknownDeliveryError(f"Drain failed: {e}") from e
119
+
120
+ async def _send_and_wait(
121
+ self,
122
+ msg: BridgeMessage,
123
+ timeout: int,
124
+ *,
125
+ on_ack: Callable[[], None] | None = None,
126
+ ) -> BridgeMessage:
127
+ """Send message and wait for response.
128
+
129
+ ``on_ack`` fires from the receive loop when the bridge sends its
130
+ intermediate ACK — the point at which the request is known to have
131
+ been accepted, as opposed to merely written to the socket.
132
+ """
133
+ assert msg.request_id is not None
134
+ future: asyncio.Future[BridgeMessage] = asyncio.get_event_loop().create_future()
135
+ self._pending[msg.request_id] = future
136
+ if on_ack is not None:
137
+ self._on_ack[msg.request_id] = on_ack
138
+
139
+ try:
140
+ await self._send_message(msg)
141
+ return await asyncio.wait_for(future, timeout=timeout)
142
+ except asyncio.TimeoutError as e:
143
+ raise TransportTimeoutError("Timeout waiting for response") from e
144
+ finally:
145
+ self._pending.pop(msg.request_id, None)
146
+ self._on_ack.pop(msg.request_id, None)
147
+
148
+ async def send(
149
+ self,
150
+ message: str,
151
+ *,
152
+ session_id: str | None = None,
153
+ repo: str | None = None,
154
+ issue_number: int | None = None,
155
+ ) -> None:
156
+ """Send a one-way message."""
157
+ request_id = f"r-{uuid.uuid4().hex[:8]}"
158
+ msg = BridgeMessage(
159
+ op=BridgeOp.SEND,
160
+ request_id=request_id,
161
+ text=message,
162
+ session_id=session_id,
163
+ repo=repo,
164
+ issue_number=issue_number,
165
+ )
166
+ await self._send_message(msg)
167
+
168
+ async def ask(
169
+ self,
170
+ question: str,
171
+ options: list[str] | None = None,
172
+ timeout: int | None = None,
173
+ *,
174
+ session_id: str | None = None,
175
+ repo: str | None = None,
176
+ issue_number: int | None = None,
177
+ ) -> str:
178
+ """Ask a question and wait for response."""
179
+ if timeout is None:
180
+ timeout = self.ask_timeout_seconds
181
+ request_id = f"r-{uuid.uuid4().hex[:8]}"
182
+ msg = BridgeMessage(
183
+ op=BridgeOp.ASK,
184
+ request_id=request_id,
185
+ question=question,
186
+ options=options,
187
+ timeout=timeout,
188
+ session_id=session_id,
189
+ repo=repo,
190
+ issue_number=issue_number,
191
+ )
192
+
193
+ # The question itself never reaches the log stream: these records go
194
+ # to stdout and are captured into ~/.ctrlrelay/logs, where operator
195
+ # content would sit in plaintext with no retention policy. The hash
196
+ # is enough to correlate one question across poller and bridge.
197
+ common = {
198
+ "session_id": session_id,
199
+ "repo": repo,
200
+ "issue_number": issue_number,
201
+ "transport": "socket",
202
+ "destination": str(self.socket_path),
203
+ "request_id": request_id,
204
+ "question_length": len(question),
205
+ "question_hash": hash_text(question),
206
+ "options": options,
207
+ }
208
+
209
+ posted = False
210
+
211
+ def _mark_posted() -> None:
212
+ # Fired by the receive loop on the bridge's ACK, which is the
213
+ # first moment the question is known to have reached Telegram.
214
+ # Logging before the write would claim a delivery that a failed
215
+ # socket write or a Telegram outage never made.
216
+ nonlocal posted
217
+ posted = True
218
+ log_event(_logger, "dev.question.posted", **common)
219
+
220
+ sent_at = time.monotonic()
221
+ try:
222
+ response = await self._send_and_wait(msg, timeout, on_ack=_mark_posted)
223
+ except Exception as e:
224
+ if not posted:
225
+ # A timeout is not a failed delivery. The bridge ACKs only
226
+ # after Telegram has accepted the message, so a post slower
227
+ # than our wait leaves us timing out first and the bridge
228
+ # succeeding afterwards — logging "post_failed" there would
229
+ # contradict the bridge's own "posted", which is the same
230
+ # false-claim problem this change exists to remove, just
231
+ # pointing the other way.
232
+ # Only claim a definite failure when the request
233
+ # provably never left. Anything past the write is
234
+ # unknown: no ACK arrived, but the bridge may have
235
+ # received and acted on it regardless.
236
+ unknown = isinstance(e, TransportUnknownDeliveryError)
237
+ log_event(
238
+ _logger,
239
+ "dev.question.post_unknown"
240
+ if unknown
241
+ else "dev.question.post_failed",
242
+ **common,
243
+ reason=type(e).__name__ if unknown else "send_failed",
244
+ error=str(e)[:200],
245
+ )
246
+ raise
247
+
248
+ if response.op == BridgeOp.ERROR:
249
+ if not posted:
250
+ # The bridge knows whether Telegram answered; we do not.
251
+ # Re-deriving it here is what produced contradictory
252
+ # pairs — post_unknown on the bridge, post_failed on the
253
+ # transport, same request_id.
254
+ bridge_unknown = response.error == "telegram_delivery_unknown"
255
+ log_event(
256
+ _logger,
257
+ "dev.question.post_unknown"
258
+ if bridge_unknown
259
+ else "dev.question.post_failed",
260
+ **common,
261
+ reason="bridge_error",
262
+ error=str(response.message)[:200],
263
+ )
264
+ raise TransportError(f"Bridge error: {response.message}")
265
+ if response.op == BridgeOp.ANSWER and response.answer:
266
+ if not posted:
267
+ # An answer proves the question was posted even if the ACK
268
+ # never arrived (older bridge, or the ACK was lost).
269
+ _mark_posted()
270
+ log_event(
271
+ _logger,
272
+ "dev.answer.received",
273
+ session_id=session_id,
274
+ repo=repo,
275
+ issue_number=issue_number,
276
+ transport="socket",
277
+ request_id=request_id,
278
+ answer_length=len(response.answer),
279
+ answer_hash=hash_text(response.answer),
280
+ elapsed_ms=int((time.monotonic() - sent_at) * 1000),
281
+ )
282
+ return response.answer
283
+
284
+ raise TransportError(f"Unexpected response: {response.op}")
285
+
286
+ async def close(self) -> None:
287
+ """Close the connection."""
288
+ if self._receive_task:
289
+ self._receive_task.cancel()
290
+ try:
291
+ await self._receive_task
292
+ except asyncio.CancelledError:
293
+ pass
294
+ if self._writer:
295
+ self._writer.close()
296
+ await self._writer.wait_closed()
297
+ self._reader = None
298
+ self._writer = None