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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|