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,372 @@
1
+ """Headless coding-agent subprocess dispatcher for ctrlrelay.
2
+
3
+ Today the only concrete adapter is :class:`ClaudeDispatcher` (wraps
4
+ ``claude -p``). The :func:`make_agent_dispatcher` factory is the seam
5
+ where additional backends (Codex, OpenCode, Hermes, Kiro, …) will plug
6
+ in: each adapter must expose the same async ``spawn_session`` shape
7
+ so pipelines can stay agent-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import shutil
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any, Protocol
20
+
21
+ from ctrlrelay.core.checkpoint import CheckpointState, CheckpointStatus, read_checkpoint
22
+ from ctrlrelay.core.obs import get_logger, hash_text, log_event
23
+
24
+ if TYPE_CHECKING:
25
+ from ctrlrelay.core.config import AgentConfig
26
+
27
+ _logger = get_logger("core.dispatcher")
28
+
29
+ # Enough of the child's stderr to name the failure without dumping agent
30
+ # output into the log stream.
31
+ _STDERR_TAIL_CHARS = 500
32
+
33
+
34
+ def _find_claude() -> str:
35
+ """Find claude binary, checking common paths if not in PATH.
36
+
37
+ Both fallback branches log at WARNING: under systemd/launchd the unit's
38
+ PATH is minimal, so ``shutil.which`` missing is the usual precursor to
39
+ every session failing to spawn, and it is worth seeing in the log
40
+ before the failures start.
41
+ """
42
+ claude = shutil.which("claude")
43
+ if claude:
44
+ return claude
45
+ for path in [
46
+ os.path.expanduser("~/.local/bin/claude"),
47
+ "/usr/local/bin/claude",
48
+ "/opt/homebrew/bin/claude",
49
+ ]:
50
+ if os.path.isfile(path) and os.access(path, os.X_OK):
51
+ _logger.warning(
52
+ "dispatcher.binary.fallback",
53
+ extra={"binary": path, "reason": "not_on_path"},
54
+ )
55
+ return path
56
+ _logger.warning(
57
+ "dispatcher.binary.unresolved",
58
+ extra={"binary": "claude", "reason": "not_on_path"},
59
+ )
60
+ return "claude"
61
+
62
+
63
+ @dataclass
64
+ class SessionResult:
65
+ """Result of a Claude session.
66
+
67
+ - ``session_id`` is the orchestrator's composite id (``dev-<owner>-<repo>-
68
+ <issue>-<hex>``) used as the primary key in state_db.
69
+ - ``agent_session_id`` is Claude's own session UUID, parsed from the JSON
70
+ stdout. It's the value that must be passed to ``claude --resume`` on
71
+ subsequent calls; newer CLI versions reject non-UUID strings.
72
+ ``None`` when stdout wasn't parseable JSON or didn't include the field.
73
+ """
74
+
75
+ session_id: str
76
+ exit_code: int
77
+ state: CheckpointState | None
78
+ stdout: str = ""
79
+ stderr: str = ""
80
+ agent_session_id: str | None = None
81
+
82
+ @property
83
+ def success(self) -> bool:
84
+ return self.state is not None and self.state.status == CheckpointStatus.DONE
85
+
86
+ @property
87
+ def blocked(self) -> bool:
88
+ return (
89
+ self.state is not None
90
+ and self.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT
91
+ )
92
+
93
+ @property
94
+ def failed(self) -> bool:
95
+ return self.state is None or self.state.status == CheckpointStatus.FAILED
96
+
97
+
98
+ @dataclass
99
+ class ClaudeDispatcher:
100
+ """Spawns and manages Claude subprocess sessions."""
101
+
102
+ claude_binary: str = field(default_factory=_find_claude)
103
+ default_timeout: int = 1800
104
+ extra_env: dict[str, str] = field(default_factory=dict)
105
+
106
+ def __post_init__(self) -> None:
107
+ # Only auto-resolve the bare default. Absolute paths, relative paths
108
+ # (resolved vs. working_dir by the child), and custom bare names pass
109
+ # through so explicit config is never silently overridden.
110
+ if self.claude_binary == "claude":
111
+ resolved = shutil.which("claude")
112
+ self.claude_binary = resolved or _find_claude()
113
+
114
+ async def spawn_session(
115
+ self,
116
+ session_id: str,
117
+ prompt: str,
118
+ working_dir: Path,
119
+ state_file: Path,
120
+ timeout: int | None = None,
121
+ resume_session_id: str | None = None,
122
+ repo: str | None = None,
123
+ issue_number: int | None = None,
124
+ ) -> SessionResult:
125
+ """Spawn a Claude session and wait for completion.
126
+
127
+ ``repo``/``issue_number`` are observability-only: they let the
128
+ dispatcher's events be correlated with the pipeline's without
129
+ re-parsing the composite session id.
130
+ """
131
+ timeout = timeout or self.default_timeout
132
+
133
+ # Identity fields repeated on every event of this session, so a
134
+ # single grep on session_id reconstructs the whole run.
135
+ ident: dict[str, Any] = {
136
+ "session_id": session_id,
137
+ "repo": repo,
138
+ "issue_number": issue_number,
139
+ }
140
+
141
+ env = os.environ.copy()
142
+ env.update(self.extra_env)
143
+ env["CTRLRELAY_SESSION_ID"] = session_id
144
+ env["CTRLRELAY_STATE_FILE"] = str(state_file)
145
+
146
+ cmd = [
147
+ self.claude_binary,
148
+ "-p", prompt,
149
+ "--output-format", "json",
150
+ "--dangerously-skip-permissions",
151
+ ]
152
+ if resume_session_id:
153
+ cmd.extend(["--resume", resume_session_id])
154
+
155
+ # The prompt can carry issue bodies and operator answers, so only its
156
+ # hash and length are ever logged (#41).
157
+ log_event(
158
+ _logger,
159
+ "dispatcher.session.start",
160
+ **ident,
161
+ resume=resume_session_id is not None,
162
+ binary=self.claude_binary,
163
+ working_dir=str(working_dir),
164
+ timeout=timeout,
165
+ prompt_hash=hash_text(prompt),
166
+ prompt_len=len(prompt),
167
+ )
168
+ started = time.monotonic()
169
+
170
+ try:
171
+ proc = await asyncio.create_subprocess_exec(
172
+ *cmd,
173
+ cwd=working_dir,
174
+ env=env,
175
+ stdout=asyncio.subprocess.PIPE,
176
+ stderr=asyncio.subprocess.PIPE,
177
+ )
178
+ except Exception as e:
179
+ # Usually the binary isn't where we resolved it — the exception
180
+ # propagates to the pipeline, but without this it lands there
181
+ # with no record of which path we tried.
182
+ _logger.error(
183
+ "dispatcher.session.spawn_failed",
184
+ extra={
185
+ **ident,
186
+ "binary": self.claude_binary,
187
+ "error_type": type(e).__name__,
188
+ "error": str(e)[:200],
189
+ },
190
+ )
191
+ raise
192
+
193
+ try:
194
+ stdout, stderr = await asyncio.wait_for(
195
+ proc.communicate(), timeout=timeout
196
+ )
197
+ except asyncio.TimeoutError as e:
198
+ proc.kill()
199
+ await proc.wait()
200
+ _logger.error(
201
+ "dispatcher.session.timeout",
202
+ extra={
203
+ **ident,
204
+ "error_type": type(e).__name__,
205
+ "timeout": timeout,
206
+ "elapsed_ms": _elapsed_ms(started),
207
+ },
208
+ )
209
+ return SessionResult(
210
+ session_id=session_id,
211
+ exit_code=-1,
212
+ state=None,
213
+ stderr="Session timed out",
214
+ )
215
+ except asyncio.CancelledError as e:
216
+ # Scheduler/shutdown cancel: kill the child BEFORE re-raising
217
+ # so `claude` isn't left running against the worktree after
218
+ # the daemon exits. Shield the wait so further cancels don't
219
+ # orphan the process between `kill()` and the reaping.
220
+ log_event(
221
+ _logger,
222
+ "dispatcher.session.cancelled",
223
+ **ident,
224
+ error_type=type(e).__name__,
225
+ elapsed_ms=_elapsed_ms(started),
226
+ )
227
+ if proc.returncode is None:
228
+ proc.kill()
229
+ try:
230
+ await asyncio.shield(proc.wait())
231
+ except asyncio.CancelledError:
232
+ pass
233
+ raise
234
+
235
+ elapsed_ms = _elapsed_ms(started)
236
+ exit_code = proc.returncode or 0
237
+ stdout_text = stdout.decode()
238
+ stderr_text = stderr.decode()
239
+
240
+ state = None
241
+ if state_file.exists():
242
+ try:
243
+ state = read_checkpoint(state_file, delete_after=True)
244
+ except Exception as e:
245
+ _logger.error(
246
+ "dispatcher.checkpoint.read_failed",
247
+ extra={
248
+ **ident,
249
+ "state_file": str(state_file),
250
+ "error_type": type(e).__name__,
251
+ "error": str(e)[:200],
252
+ },
253
+ )
254
+ else:
255
+ # The session ended without signalling: the pipeline will report
256
+ # it as failed, and this is the only record of why.
257
+ _logger.warning(
258
+ "dispatcher.checkpoint.missing",
259
+ extra={
260
+ **ident,
261
+ "state_file": str(state_file),
262
+ "exit_code": exit_code,
263
+ },
264
+ )
265
+
266
+ if exit_code != 0:
267
+ _logger.error(
268
+ "dispatcher.session.subprocess_failed",
269
+ extra={
270
+ **ident,
271
+ "exit_code": exit_code,
272
+ "stderr_tail": stderr_text[-_STDERR_TAIL_CHARS:],
273
+ "elapsed_ms": elapsed_ms,
274
+ },
275
+ )
276
+
277
+ agent_session_id = _extract_agent_session_id(stdout_text)
278
+
279
+ log_event(
280
+ _logger,
281
+ "dispatcher.session.finished",
282
+ **ident,
283
+ exit_code=exit_code,
284
+ checkpoint_status=state.status.value if state else None,
285
+ elapsed_ms=elapsed_ms,
286
+ agent_session_id=agent_session_id,
287
+ stdout_hash=hash_text(stdout_text),
288
+ stdout_len=len(stdout_text),
289
+ )
290
+
291
+ return SessionResult(
292
+ session_id=session_id,
293
+ exit_code=exit_code,
294
+ state=state,
295
+ stdout=stdout_text,
296
+ stderr=stderr_text,
297
+ agent_session_id=agent_session_id,
298
+ )
299
+
300
+
301
+ def _elapsed_ms(started: float) -> int:
302
+ """Milliseconds since ``started`` (a :func:`time.monotonic` reading)."""
303
+ return int((time.monotonic() - started) * 1000)
304
+
305
+
306
+ def _extract_agent_session_id(stdout_text: str) -> str | None:
307
+ """Pull Claude's session UUID out of ``claude -p --output-format json``.
308
+
309
+ Claude emits a single JSON object on stdout whose ``session_id`` field is
310
+ the UUID required by ``--resume``. If the output is empty or not JSON
311
+ (errors, interrupted runs) we return ``None`` and let the caller decide
312
+ how to fall back.
313
+ """
314
+ if not stdout_text.strip():
315
+ return None
316
+ try:
317
+ payload = json.loads(stdout_text)
318
+ except json.JSONDecodeError:
319
+ return None
320
+ if not isinstance(payload, dict):
321
+ return None
322
+ value = payload.get("session_id")
323
+ return value if isinstance(value, str) and value else None
324
+
325
+
326
+ class AgentAdapter(Protocol):
327
+ """Protocol every coding-agent adapter must satisfy.
328
+
329
+ An adapter is a thin wrapper over an agent CLI that handles:
330
+ spawning the subprocess, passing the prompt and state-file path,
331
+ enforcing the timeout, and translating the checkpoint JSON the
332
+ agent writes into a :class:`SessionResult`.
333
+
334
+ Pipelines (dev, secops) only ever interact with this protocol —
335
+ they never import concrete adapters — so adding a new backend
336
+ means implementing this and registering it in
337
+ :func:`make_agent_dispatcher`.
338
+ """
339
+
340
+ async def spawn_session(
341
+ self,
342
+ session_id: str,
343
+ prompt: str,
344
+ working_dir: Path,
345
+ state_file: Path,
346
+ timeout: int | None = None,
347
+ resume_session_id: str | None = None,
348
+ repo: str | None = None,
349
+ issue_number: int | None = None,
350
+ ) -> SessionResult:
351
+ ...
352
+
353
+
354
+ def make_agent_dispatcher(agent_config: "AgentConfig") -> AgentAdapter:
355
+ """Build an adapter for the configured ``agent.type``.
356
+
357
+ Raises :class:`NotImplementedError` with a clear error message if
358
+ the configured type has no adapter — makes a typo surface loudly
359
+ at daemon startup instead of silently falling back to Claude.
360
+ """
361
+ t = agent_config.type
362
+ if t == "claude":
363
+ return ClaudeDispatcher(
364
+ claude_binary=agent_config.binary,
365
+ default_timeout=agent_config.default_timeout_seconds,
366
+ )
367
+ raise NotImplementedError(
368
+ f"agent.type={t!r} has no adapter yet. Implement one that "
369
+ "satisfies the AgentAdapter protocol in "
370
+ "src/ctrlrelay/core/dispatcher.py and register it here, then "
371
+ "open a PR. Supported today: 'claude'."
372
+ )