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,740 @@
1
+ """Bridge server for Telegram communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import stat
9
+ import time
10
+ from collections import OrderedDict
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ctrlrelay.bridge.protocol import (
16
+ BridgeMessage,
17
+ BridgeOp,
18
+ ProtocolError,
19
+ parse_message,
20
+ serialize_message,
21
+ )
22
+ from ctrlrelay.bridge.telegram_handler import (
23
+ TelegramHandler,
24
+ is_ambiguous_delivery,
25
+ )
26
+ from ctrlrelay.core.obs import get_logger, hash_text, log_event
27
+
28
+ if TYPE_CHECKING:
29
+ from ctrlrelay.core.state import StateDB
30
+
31
+ _logger = get_logger("bridge.server")
32
+ _log = logging.getLogger(__name__)
33
+
34
+
35
+ # Upper bound on the telegram_msg_id -> session_id map. A reply landing
36
+ # further back than this many questions is vanishingly rare, and the map is
37
+ # a routing convenience, not a source of truth — losing an entry degrades to
38
+ # the session_id-substring path, never to a wrong resume.
39
+ _ASKED_SESSIONS_MAX = 500
40
+
41
+
42
+ def format_question(
43
+ question: str,
44
+ *,
45
+ repo: str | None = None,
46
+ session_id: str | None = None,
47
+ issue_number: int | None = None,
48
+ ) -> str:
49
+ """Prefix an agent question with the repo and session it belongs to.
50
+
51
+ A secops sweep posts one consolidated question per repo back-to-back,
52
+ and the agent's own text almost never names its repo — the operator sees
53
+ a run of near-identical "approve #387?" messages with nothing to tell
54
+ them apart. The session_id matters just as much: when a reply arrives
55
+ after the ASK socket has timed out, the orphan router can only
56
+ disambiguate between several BLOCKED sessions if the operator can quote
57
+ one, and until now it was never sent to them.
58
+
59
+ Plain text on purpose — TelegramHandler sends without ``parse_mode``, so
60
+ any Markdown here would reach the operator as literal backticks.
61
+ """
62
+ header: list[str] = []
63
+ if repo:
64
+ line = f"[{repo}]"
65
+ if issue_number is not None:
66
+ line += f" issue #{issue_number}"
67
+ header.append(line)
68
+ if session_id:
69
+ header.append(f"session: {session_id}")
70
+ if not header:
71
+ return question
72
+ header.append("Reply to this message to answer.")
73
+ return "\n".join(header) + "\n\n" + question
74
+
75
+
76
+ def _deadline(received_at: float, timeout: int | None) -> float | None:
77
+ """Monotonic instant after which a posted question is no longer live.
78
+
79
+ Mirrors the client's own ``ask()`` deadline as closely as the two
80
+ processes allow. The client starts counting when it writes the ASK to
81
+ the socket and ``received_at`` is taken as we read it, so the two clocks
82
+ differ only by unix-socket transit — microseconds. Anchoring here rather
83
+ than after the Telegram post is what matters: that post takes on the
84
+ order of a second, and a deadline set on its far side would leave the
85
+ bridge treating a question as live for that whole second after the
86
+ client had abandoned it.
87
+
88
+ Deliberately no safety margin. Skewing early looks free but is not: a
89
+ reply landing between the bridge's deadline and the client's is routed
90
+ to a ``pending_resumes`` row the still-running pipeline has not written
91
+ yet, so the answer is refused and the operator has to send it again. A
92
+ margin trades a microsecond-wide hazard for a window thousands of times
93
+ wider.
94
+
95
+ ``None`` means the caller set no deadline; the entry then lives until
96
+ its socket closes, the pre-existing behaviour. A non-positive timeout
97
+ means the client gave up immediately, so the question is already
98
+ expired rather than eternal.
99
+ """
100
+ if timeout is None:
101
+ return None
102
+ return received_at + max(float(timeout), 0.0)
103
+
104
+
105
+ class _PendingQuestion:
106
+ """Question posted to Telegram, awaiting the operator's reply."""
107
+
108
+ __slots__ = (
109
+ "request_id", "telegram_msg_id", "writer", "expires_at",
110
+ "repo", "session_id",
111
+ )
112
+
113
+ def __init__(
114
+ self,
115
+ request_id: str,
116
+ telegram_msg_id: int,
117
+ writer: asyncio.StreamWriter,
118
+ expires_at: float | None = None,
119
+ repo: str | None = None,
120
+ session_id: str | None = None,
121
+ ) -> None:
122
+ self.request_id = request_id
123
+ self.telegram_msg_id = telegram_msg_id
124
+ self.writer = writer
125
+ self.repo = repo
126
+ self.session_id = session_id
127
+ # Monotonic deadline mirroring the client's own ask() timeout. The
128
+ # client stops waiting at this point and discards the request_id,
129
+ # but it does NOT close the socket — a secops sweep reuses one
130
+ # transport across every repo. Without this the bridge would keep
131
+ # treating the question as live and write the answer into a request
132
+ # nobody is listening for.
133
+ self.expires_at = expires_at
134
+
135
+ def is_expired(self, now: float) -> bool:
136
+ return self.expires_at is not None and now >= self.expires_at
137
+
138
+
139
+ class BridgeServer:
140
+ """Unix socket server that bridges to Telegram — bidirectional.
141
+
142
+ Outbound: clients send SEND/ASK over the socket and we hit Telegram.
143
+ Inbound: we long-poll Telegram for messages; when a reply arrives it's
144
+ matched to the oldest outstanding ASK (or by reply_to_message_id if
145
+ available) and we push an ANSWER frame over that client's socket."""
146
+
147
+ def __init__(
148
+ self,
149
+ socket_path: Path,
150
+ bot_token: str,
151
+ chat_id: int,
152
+ state_db: "StateDB | None" = None,
153
+ ) -> None:
154
+ self.socket_path = socket_path
155
+ self.bot_token = bot_token
156
+ self.chat_id = chat_id
157
+ # Optional: when provided, orphan Telegram replies (no live
158
+ # _pending_question to match) are routed to the oldest unanswered
159
+ # BLOCKED session in state_db's pending_resumes table. The poller's
160
+ # pending-resume sweeper then picks up the answer and drives the
161
+ # actual pipeline resume. Without state_db, orphan replies still
162
+ # get a "didn't land" Telegram notice but nothing gets queued.
163
+ self.state_db = state_db
164
+ self._server: asyncio.Server | None = None
165
+ self._running = False
166
+ self._telegram: TelegramHandler | None = None
167
+ # Insertion-ordered so FIFO dispatch is deterministic.
168
+ self._pending_questions: OrderedDict[str, _PendingQuestion] = OrderedDict()
169
+ self._pending_lock = asyncio.Lock()
170
+ # telegram_msg_id -> session_id for every ASK we've posted, kept
171
+ # after the question itself is gone. The pipeline process is what
172
+ # dies on an ASK timeout; this bridge outlives it, so a reply-to on
173
+ # a long-expired question can still name its session exactly instead
174
+ # of falling back to "which of these 12 did you mean?".
175
+ self._asked_sessions: OrderedDict[int, str] = OrderedDict()
176
+
177
+ async def start(self) -> None:
178
+ """Start the bridge server."""
179
+ self._telegram = TelegramHandler(
180
+ bot_token=self.bot_token,
181
+ chat_id=self.chat_id,
182
+ )
183
+ await self._telegram.start_polling(self._on_telegram_reply)
184
+
185
+ if self.socket_path.exists():
186
+ self.socket_path.unlink()
187
+
188
+ self.socket_path.parent.mkdir(parents=True, exist_ok=True)
189
+
190
+ self._server = await asyncio.start_unix_server(
191
+ self._handle_client,
192
+ path=str(self.socket_path),
193
+ )
194
+
195
+ os.chmod(self.socket_path, stat.S_IRUSR | stat.S_IWUSR)
196
+
197
+ self._running = True
198
+ async with self._server:
199
+ await self._server.serve_forever()
200
+
201
+ async def stop(self) -> None:
202
+ """Stop the bridge server."""
203
+ self._running = False
204
+ if self._server:
205
+ self._server.close()
206
+ await self._server.wait_closed()
207
+
208
+ if self._telegram:
209
+ await self._telegram.close()
210
+
211
+ if self.socket_path.exists():
212
+ self.socket_path.unlink()
213
+
214
+ async def _handle_client(
215
+ self,
216
+ reader: asyncio.StreamReader,
217
+ writer: asyncio.StreamWriter,
218
+ ) -> None:
219
+ """Handle a client connection."""
220
+ try:
221
+ while self._running:
222
+ line = await reader.readline()
223
+ if not line:
224
+ break
225
+
226
+ try:
227
+ msg = parse_message(line.decode())
228
+ except ProtocolError:
229
+ continue
230
+
231
+ response = await self._handle_message(msg, writer)
232
+ if response is None:
233
+ continue
234
+
235
+ # Response write races client disconnect: the transport
236
+ # (SocketTransport) finishes a send/ask round-trip and closes
237
+ # the socket while we're still flushing the ACK. Swallow the
238
+ # expected disconnect errors instead of propagating a
239
+ # traceback into bridge.error.log.
240
+ if writer.is_closing():
241
+ break
242
+ try:
243
+ writer.write(serialize_message(response).encode())
244
+ await writer.drain()
245
+ except (ConnectionResetError, BrokenPipeError, OSError) as e:
246
+ _log.debug(
247
+ "bridge: client disconnected mid-response "
248
+ "(op=%s request_id=%s err=%s)",
249
+ msg.op, msg.request_id, e,
250
+ )
251
+ break
252
+ finally:
253
+ # Client disconnected — drop any outstanding questions tied to
254
+ # this writer so we don't try to answer a dead socket later.
255
+ async with self._pending_lock:
256
+ dead = [
257
+ rid for rid, q in self._pending_questions.items()
258
+ if q.writer is writer
259
+ ]
260
+ for rid in dead:
261
+ self._pending_questions.pop(rid, None)
262
+ writer.close()
263
+ try:
264
+ await writer.wait_closed()
265
+ except Exception:
266
+ pass
267
+
268
+ async def _handle_message(
269
+ self,
270
+ msg: BridgeMessage,
271
+ writer: asyncio.StreamWriter,
272
+ ) -> BridgeMessage | None:
273
+ """Handle a single message and return response."""
274
+ if msg.op == BridgeOp.PING:
275
+ return BridgeMessage(op=BridgeOp.PONG)
276
+
277
+ if msg.op == BridgeOp.SEND:
278
+ try:
279
+ assert self._telegram is not None
280
+ telegram_msg_id = await self._telegram.send(msg.text or "")
281
+ # A SEND that names a session is answerable too: the secops
282
+ # sweep fans out one "blocked on <repo>" message per repo
283
+ # after the run, and that message is the most recent — and
284
+ # most prominent — one the operator sees for that repo. If
285
+ # its id isn't recorded, replying to it lands in the
286
+ # ambiguous branch even though the session is unmistakable.
287
+ if msg.session_id:
288
+ async with self._pending_lock:
289
+ self._remember_asked_session(
290
+ telegram_msg_id, msg.session_id
291
+ )
292
+ _log.info("bridge: SEND delivered, request_id=%s", msg.request_id)
293
+ return BridgeMessage(op=BridgeOp.ACK, request_id=msg.request_id, status="sent")
294
+ except Exception as e:
295
+ _log.warning("bridge: SEND failed, request_id=%s err=%s", msg.request_id, e)
296
+ return BridgeMessage(
297
+ op=BridgeOp.ERROR,
298
+ request_id=msg.request_id,
299
+ error="telegram_api_error",
300
+ message=str(e),
301
+ )
302
+
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
+ }
321
+ try:
322
+ assert self._telegram is not None
323
+ # Start the expiry clock before the Telegram round trip, not
324
+ # after it: the client began counting when it wrote the ASK
325
+ # to the socket. Anchoring on the far side of the post would
326
+ # leave the bridge treating the question as live for a full
327
+ # post latency after the client had abandoned it, and a
328
+ # reply-to in that window writes the answer into a dead
329
+ # request.
330
+ received_at = time.monotonic()
331
+ telegram_msg_id = await self._telegram.ask(
332
+ format_question(
333
+ question,
334
+ repo=msg.repo,
335
+ session_id=msg.session_id,
336
+ issue_number=msg.issue_number,
337
+ ),
338
+ options=msg.options,
339
+ )
340
+ async with self._pending_lock:
341
+ if msg.request_id:
342
+ self._pending_questions[msg.request_id] = _PendingQuestion(
343
+ request_id=msg.request_id,
344
+ telegram_msg_id=telegram_msg_id,
345
+ writer=writer,
346
+ expires_at=_deadline(received_at, msg.timeout),
347
+ repo=msg.repo,
348
+ session_id=msg.session_id,
349
+ )
350
+ if msg.session_id:
351
+ self._remember_asked_session(
352
+ telegram_msg_id, msg.session_id
353
+ )
354
+ _log.info(
355
+ "bridge: ASK posted request_id=%s telegram_msg_id=%s",
356
+ msg.request_id, telegram_msg_id,
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
+ )
366
+ return BridgeMessage(
367
+ op=BridgeOp.ACK, request_id=msg.request_id, status="pending",
368
+ )
369
+ except Exception as e:
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
+ )
389
+ return BridgeMessage(
390
+ op=BridgeOp.ERROR,
391
+ request_id=msg.request_id,
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
+ ),
402
+ message=str(e),
403
+ )
404
+
405
+ return None
406
+
407
+ def _remember_asked_session(
408
+ self, telegram_msg_id: int, session_id: str
409
+ ) -> None:
410
+ """Record which session a posted question belongs to.
411
+
412
+ Caller must hold ``_pending_lock``."""
413
+ self._asked_sessions[telegram_msg_id] = session_id
414
+ self._asked_sessions.move_to_end(telegram_msg_id)
415
+ while len(self._asked_sessions) > _ASKED_SESSIONS_MAX:
416
+ self._asked_sessions.popitem(last=False)
417
+
418
+ async def _on_telegram_reply(
419
+ self,
420
+ text: str,
421
+ reply_to_message_id: int | None,
422
+ ) -> None:
423
+ """Route an incoming Telegram message to the matching pending question.
424
+
425
+ Routing rules, in order:
426
+
427
+ 1. ``reply_to_message_id`` names a live pending question — deliver
428
+ there. This is the only path that can be right when more than one
429
+ question is outstanding, so it takes priority.
430
+ 2. The operator replied to *something*, but that question is no
431
+ longer live (its ASK timed out, or the bridge restarted). We do
432
+ NOT guess: fall through to the orphan router, which knows the
433
+ session from ``_asked_sessions`` and can attach the answer to the
434
+ right persisted row.
435
+ 3. A fresh message (no reply-to) with exactly one question
436
+ outstanding — deliver there; there is nothing to confuse it with.
437
+ 4. A fresh message with several outstanding — refuse, and tell the
438
+ operator to use Telegram's reply.
439
+
440
+ The previous FIFO fallback ran for cases 2 and 4 alike: replying to
441
+ the third question while the first was still open delivered the
442
+ answer to the *first* one, silently, and the operator had no way to
443
+ see it had happened.
444
+
445
+ ``_pending_lock`` guards only the decision. Every Telegram send and
446
+ every state_db write happens after it is released — holding it across
447
+ a stalled Telegram call would serialize routing behind the network.
448
+ Telegram updates are dispatched one at a time by
449
+ ``TelegramHandler._poll_loop``, so releasing early cannot interleave
450
+ two replies.
451
+ """
452
+ match: _PendingQuestion | None = None
453
+ hinted_session_id: str | None = None
454
+ live_listing: str | None = None
455
+ ambiguous_notice: str | None = None
456
+
457
+ async with self._pending_lock:
458
+ self._drop_expired_questions()
459
+ if reply_to_message_id is not None:
460
+ for q in self._pending_questions.values():
461
+ if q.telegram_msg_id == reply_to_message_id:
462
+ match = q
463
+ break
464
+ elif len(self._pending_questions) == 1:
465
+ match = next(iter(self._pending_questions.values()))
466
+ elif len(self._pending_questions) > 1:
467
+ ambiguous_notice = (
468
+ "Your message wasn't routed - "
469
+ f"{len(self._pending_questions)} questions are waiting "
470
+ "and a plain message doesn't say which one you "
471
+ "mean.\n\n"
472
+ "Use Telegram's reply on the question you want to "
473
+ "answer.\n\n"
474
+ f"{self._live_question_listing()}"
475
+ )
476
+
477
+ if match is not None:
478
+ self._pending_questions.pop(match.request_id, None)
479
+ elif ambiguous_notice is None:
480
+ # Snapshot what the orphan branch needs, so the lock can be
481
+ # released before we touch Telegram or the database.
482
+ hinted_session_id = (
483
+ self._asked_sessions.get(reply_to_message_id)
484
+ if reply_to_message_id is not None
485
+ else None
486
+ )
487
+ live_listing = (
488
+ self._live_question_listing()
489
+ if self._pending_questions
490
+ else None
491
+ )
492
+
493
+ if ambiguous_notice is not None:
494
+ await self._send_notice(
495
+ ambiguous_notice,
496
+ log="bridge: ambiguous fresh reply, refusing to guess",
497
+ )
498
+ return
499
+
500
+ if match is None:
501
+ await self._route_orphan_reply(text, hinted_session_id, live_listing)
502
+ return
503
+
504
+ _log.info(
505
+ "bridge: delivering ANSWER request_id=%s len=%d",
506
+ match.request_id, len(text),
507
+ )
508
+ log_event(
509
+ _logger,
510
+ "dev.answer.received",
511
+ transport="telegram",
512
+ source=f"telegram:chat={self.chat_id}",
513
+ request_id=match.request_id,
514
+ telegram_msg_id=match.telegram_msg_id,
515
+ reply_to_message_id=reply_to_message_id,
516
+ answer_length=len(text),
517
+ answer_hash=hash_text(text),
518
+ )
519
+ answer = BridgeMessage(
520
+ op=BridgeOp.ANSWER,
521
+ request_id=match.request_id,
522
+ answer=text,
523
+ answered_at=datetime.now(timezone.utc).isoformat(),
524
+ )
525
+ try:
526
+ match.writer.write(serialize_message(answer).encode())
527
+ await match.writer.drain()
528
+ except Exception as e:
529
+ _log.warning(
530
+ "bridge: failed to deliver ANSWER request_id=%s err=%s",
531
+ match.request_id, e,
532
+ )
533
+
534
+ async def _route_orphan_reply(
535
+ self,
536
+ text: str,
537
+ hinted_session_id: str | None,
538
+ live_listing: str | None,
539
+ ) -> None:
540
+ """Handle a reply that matched no live question.
541
+
542
+ Called with ``_pending_lock`` released; ``live_listing`` is the
543
+ snapshot of what was outstanding at decision time.
544
+ """
545
+ _log.info(
546
+ "bridge: incoming telegram msg with no pending question; "
547
+ "text=%r", text[:80],
548
+ )
549
+ # Try to route to a persisted BLOCKED session in state_db so the
550
+ # operator's reply actually drives a resume. Without this, the reply
551
+ # disappears the instant the session's ASK socket closes — which is
552
+ # exactly what happens when a scheduled secops sweep escalates
553
+ # BLOCKED and exits.
554
+ outcome = await self._queue_orphan_reply_as_resume_answer(
555
+ text, session_id=hinted_session_id
556
+ )
557
+ if outcome["status"] == "queued":
558
+ row = outcome["row"]
559
+ await self._send_notice(
560
+ "✅ Answer queued for BLOCKED session "
561
+ f"`{row['session_id']}` "
562
+ f"(pipeline={row['pipeline']}, repo={row['repo']}).\n"
563
+ "The pending-resume sweeper will drive it on the next "
564
+ "tick — you'll get another message with the result."
565
+ )
566
+ elif outcome["status"] == "ambiguous":
567
+ pending_list = "\n".join(
568
+ f" • `{r['session_id']}` ({r['repo']}): "
569
+ f"{(r['question'] or '')[:80]}"
570
+ for r in outcome["rows"]
571
+ )
572
+ await self._send_notice(
573
+ "⚠️ Your reply wasn't routed — multiple BLOCKED sessions "
574
+ "are unanswered and the reply didn't point at one of "
575
+ "them.\n\n"
576
+ f"Pending:\n{pending_list}\n\n"
577
+ "Use Telegram's reply on the question you mean, or paste "
578
+ "its session_id anywhere in your message."
579
+ )
580
+ elif live_listing is not None:
581
+ # Reply-to pointed at a message this bridge no longer tracks
582
+ # (restarted, or evicted from _asked_sessions) and nothing is
583
+ # persisted for it — but questions ARE live. Saying "no active
584
+ # session is waiting" here would be flatly wrong and would stop
585
+ # the operator retrying, so point them at what is waiting.
586
+ await self._send_notice(
587
+ "⚠️ Your reply wasn't routed — it pointed at a question "
588
+ "this bridge no longer tracks, and nothing is persisted "
589
+ f"for it.\n\n{live_listing}\n\n"
590
+ "Reply to one of those messages to answer it."
591
+ )
592
+ else:
593
+ await self._send_notice(
594
+ "⚠️ Your reply wasn't routed — no active session is "
595
+ "waiting on input and no persisted BLOCKED session is "
596
+ "unanswered. To act manually, re-run the pipeline, e.g. "
597
+ "`ctrlrelay run secops --repo <owner>/<repo>`."
598
+ )
599
+
600
+ def _drop_expired_questions(self) -> None:
601
+ """Forget questions whose client has already stopped waiting.
602
+
603
+ Caller must hold ``_pending_lock``. An expired entry is worse than
604
+ no entry: it would match a reply-to and swallow the answer, and it
605
+ inflates the "more than one question is live" count so that a plain
606
+ answer to the one real question gets refused. Dropping it here sends
607
+ the reply down the orphan path, which resolves the session's
608
+ ``pending_resumes`` row via ``_asked_sessions``.
609
+ """
610
+ now = time.monotonic()
611
+ expired = [
612
+ rid for rid, q in self._pending_questions.items() if q.is_expired(now)
613
+ ]
614
+ for rid in expired:
615
+ self._pending_questions.pop(rid, None)
616
+ if expired:
617
+ _log.info(
618
+ "bridge: dropped %d expired question(s): %s",
619
+ len(expired), ", ".join(expired),
620
+ )
621
+
622
+ def _live_question_listing(self) -> str:
623
+ """Render the outstanding questions for an operator notice.
624
+
625
+ Uses the same ``[repo] session:`` shape as the question header, so
626
+ the operator can match a line here to a message in their chat by
627
+ eye. Telegram never shows message ids in its UI and ``request_id``
628
+ means nothing to a human, so neither belongs in operator-facing
629
+ text.
630
+
631
+ Caller must hold ``_pending_lock``."""
632
+ rows = "\n".join(
633
+ f" - {q.repo or 'unknown repo'}"
634
+ + (f" (session: {q.session_id})" if q.session_id else "")
635
+ for q in self._pending_questions.values()
636
+ )
637
+ return f"Still waiting:\n{rows}"
638
+
639
+ async def _send_notice(self, text: str, log: str | None = None) -> None:
640
+ """Best-effort operator notice. A failure here must never take down
641
+ reply routing — the answer has already been dealt with."""
642
+ if log:
643
+ _log.info("%s", log)
644
+ if self._telegram is None:
645
+ return
646
+ try:
647
+ await self._telegram.send(text)
648
+ except Exception as e:
649
+ _log.warning("bridge: failed to notify operator: %s", e)
650
+
651
+ async def _queue_orphan_reply_as_resume_answer(
652
+ self, text: str, *, session_id: str | None = None
653
+ ) -> dict:
654
+ """Try to route an orphan Telegram reply to a persisted BLOCKED
655
+ session so the pending-resume sweeper can pick it up and drive a
656
+ pipeline resume.
657
+
658
+ Returns a dict with ``status`` set to one of:
659
+ - ``"queued"`` with ``row`` (dict) — answer was attached.
660
+ - ``"ambiguous"`` with ``rows`` (list[dict]) — multiple BLOCKED
661
+ sessions exist and the reply didn't name one, so we refuse to
662
+ guess. The sender is told which session_ids exist so they can
663
+ retry with one included.
664
+ - ``"none"`` — no state_db, no unanswered rows, or DB error.
665
+
666
+ Disambiguation rules, in order:
667
+
668
+ 1. ``session_id`` (resolved from the Telegram reply-to) names an
669
+ unanswered row — route there. This is exact: it comes from the
670
+ message the operator actually replied to.
671
+ 2. The reply text contains exactly one unanswered session_id as a
672
+ substring — route there.
673
+ 3. Exactly one unanswered row exists — route there.
674
+ 4. Anything else — ambiguous, and we refuse to guess.
675
+ """
676
+ if self.state_db is None:
677
+ return {"status": "none"}
678
+ try:
679
+ rows = self.state_db.list_unanswered_pending_resumes()
680
+ except Exception as e:
681
+ log_event(
682
+ _logger,
683
+ "bridge.pending_resume.list_failed",
684
+ reason=type(e).__name__,
685
+ error=str(e)[:200],
686
+ )
687
+ return {"status": "none"}
688
+
689
+ if not rows:
690
+ return {"status": "none"}
691
+
692
+ # A reply-to that resolves to a session we posted beats every
693
+ # heuristic below — the operator pointed at the exact question.
694
+ if session_id is not None:
695
+ hinted = [r for r in rows if r["session_id"] == session_id]
696
+ if hinted:
697
+ return await self._attach_orphan_answer(hinted[0], text)
698
+
699
+ matched_by_id = [r for r in rows if r["session_id"] in text]
700
+ if len(matched_by_id) == 1:
701
+ target = matched_by_id[0]
702
+ elif len(matched_by_id) > 1:
703
+ # Multiple session_ids named in the same reply — refuse to
704
+ # pick one. Let the operator send a single-session reply.
705
+ return {"status": "ambiguous", "rows": matched_by_id}
706
+ elif len(rows) == 1:
707
+ target = rows[0]
708
+ else:
709
+ # Multiple unanswered, no session_id hint — can't route safely.
710
+ return {"status": "ambiguous", "rows": rows}
711
+
712
+ return await self._attach_orphan_answer(target, text)
713
+
714
+ async def _attach_orphan_answer(self, target: dict, text: str) -> dict:
715
+ """Write an orphan answer onto one pending_resumes row."""
716
+ assert self.state_db is not None
717
+ try:
718
+ if not self.state_db.answer_pending_resume(
719
+ target["session_id"], text
720
+ ):
721
+ return {"status": "none"}
722
+ except Exception as e:
723
+ log_event(
724
+ _logger,
725
+ "bridge.pending_resume.update_failed",
726
+ reason=type(e).__name__,
727
+ error=str(e)[:200],
728
+ )
729
+ return {"status": "none"}
730
+
731
+ log_event(
732
+ _logger,
733
+ "bridge.pending_resume.queued",
734
+ session_id=target["session_id"],
735
+ pipeline=target["pipeline"],
736
+ repo=target["repo"],
737
+ answer_length=len(text),
738
+ answer_hash=hash_text(text),
739
+ )
740
+ return {"status": "queued", "row": target}