ctrlrelay 0.1.5__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 +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
ctrlrelay/core/config.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Configuration loading and validation for ctrlrelay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(Exception):
|
|
14
|
+
"""Raised when configuration loading or validation fails."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TransportType(str, Enum):
|
|
18
|
+
TELEGRAM = "telegram"
|
|
19
|
+
FILE_MOCK = "file_mock"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AutomationPolicy(str, Enum):
|
|
23
|
+
AUTO = "auto"
|
|
24
|
+
ASK = "ask"
|
|
25
|
+
NEVER = "never"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PathsConfig(BaseModel):
|
|
29
|
+
"""File system paths configuration."""
|
|
30
|
+
|
|
31
|
+
state_db: Path
|
|
32
|
+
worktrees: Path
|
|
33
|
+
bare_repos: Path
|
|
34
|
+
contexts: Path
|
|
35
|
+
skills: Path
|
|
36
|
+
|
|
37
|
+
@field_validator("*", mode="before")
|
|
38
|
+
@classmethod
|
|
39
|
+
def expand_path(cls, v: Any) -> Any:
|
|
40
|
+
if isinstance(v, str):
|
|
41
|
+
return Path(v).expanduser()
|
|
42
|
+
return v
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AgentConfig(BaseModel):
|
|
46
|
+
"""Headless coding-agent configuration.
|
|
47
|
+
|
|
48
|
+
``type`` selects which adapter the dispatcher uses to talk to the
|
|
49
|
+
agent CLI. Today only ``claude`` is implemented; the field exists
|
|
50
|
+
so future adapters (e.g. ``codex``, ``opencode``, ``hermes``) can
|
|
51
|
+
be plugged in without a config schema change. The other fields
|
|
52
|
+
(``binary``, ``default_timeout_seconds``, ``output_format``) are
|
|
53
|
+
common across most CLI-driven agents; adapters that need agent-
|
|
54
|
+
specific knobs can add a nested sub-model later.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
type: str = "claude"
|
|
58
|
+
binary: str = "claude"
|
|
59
|
+
default_timeout_seconds: int = 1800
|
|
60
|
+
output_format: str = "json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Backwards-compat alias. Older code and docs reference ``ClaudeConfig``;
|
|
64
|
+
# keep the name importable but have it resolve to the renamed class so
|
|
65
|
+
# `isinstance(cfg, ClaudeConfig)` and `ClaudeConfig(...)` still work.
|
|
66
|
+
# Scheduled for removal once downstream repos migrate.
|
|
67
|
+
ClaudeConfig = AgentConfig
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TelegramConfig(BaseModel):
|
|
71
|
+
"""Telegram transport configuration."""
|
|
72
|
+
|
|
73
|
+
bot_token_env: str = "CTRLRELAY_TELEGRAM_TOKEN"
|
|
74
|
+
chat_id: int = 0
|
|
75
|
+
socket_path: Path = Field(
|
|
76
|
+
default_factory=lambda: Path("~/.ctrlrelay/ctrlrelay.sock").expanduser()
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@field_validator("socket_path", mode="before")
|
|
80
|
+
@classmethod
|
|
81
|
+
def expand_socket_path(cls, v: Any) -> Any:
|
|
82
|
+
if isinstance(v, str):
|
|
83
|
+
return Path(v).expanduser()
|
|
84
|
+
return v
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FileMockConfig(BaseModel):
|
|
88
|
+
"""File mock transport configuration for testing."""
|
|
89
|
+
|
|
90
|
+
inbox: Path
|
|
91
|
+
outbox: Path
|
|
92
|
+
|
|
93
|
+
@field_validator("*", mode="before")
|
|
94
|
+
@classmethod
|
|
95
|
+
def expand_path(cls, v: Any) -> Any:
|
|
96
|
+
if isinstance(v, str):
|
|
97
|
+
return Path(v).expanduser()
|
|
98
|
+
return v
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TransportConfig(BaseModel):
|
|
102
|
+
"""Transport configuration (Telegram or file mock)."""
|
|
103
|
+
|
|
104
|
+
type: TransportType = TransportType.FILE_MOCK
|
|
105
|
+
telegram: TelegramConfig | None = None
|
|
106
|
+
file_mock: FileMockConfig | None = None
|
|
107
|
+
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def validate_transport_config(self) -> "TransportConfig":
|
|
110
|
+
if self.type == TransportType.TELEGRAM and self.telegram is None:
|
|
111
|
+
raise ValueError("telegram config required when type is 'telegram'")
|
|
112
|
+
if self.type == TransportType.FILE_MOCK and self.file_mock is None:
|
|
113
|
+
raise ValueError("file_mock config required when type is 'file_mock'")
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DashboardConfig(BaseModel):
|
|
118
|
+
"""Dashboard client configuration."""
|
|
119
|
+
|
|
120
|
+
enabled: bool = True
|
|
121
|
+
url: str = ""
|
|
122
|
+
auth_token_env: str = "CTRLRELAY_DASHBOARD_TOKEN"
|
|
123
|
+
sync_config_on_heartbeat: bool = False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DeployConfig(BaseModel):
|
|
127
|
+
"""Deployment configuration for a repo."""
|
|
128
|
+
|
|
129
|
+
provider: str = "digitalocean"
|
|
130
|
+
app_id: str = ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CodeReviewConfig(BaseModel):
|
|
134
|
+
"""Code review configuration for a repo."""
|
|
135
|
+
|
|
136
|
+
method: str = "mcp_then_cli"
|
|
137
|
+
mcp_tool: str = "mcp__codex-reviewer__codex_review"
|
|
138
|
+
cli_command: str = "codex review"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AutomationConfig(BaseModel):
|
|
142
|
+
"""Automation policies for a repo."""
|
|
143
|
+
|
|
144
|
+
dependabot_patch: AutomationPolicy = AutomationPolicy.AUTO
|
|
145
|
+
dependabot_minor: AutomationPolicy = AutomationPolicy.ASK
|
|
146
|
+
dependabot_major: AutomationPolicy = AutomationPolicy.NEVER
|
|
147
|
+
codeql_dismiss: AutomationPolicy = AutomationPolicy.ASK
|
|
148
|
+
secret_alerts: AutomationPolicy = AutomationPolicy.NEVER
|
|
149
|
+
deploy_after_merge: AutomationPolicy = AutomationPolicy.AUTO
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class RepoConfig(BaseModel):
|
|
153
|
+
"""Configuration for a single repository."""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
local_path: Path
|
|
157
|
+
automation: AutomationConfig = Field(default_factory=AutomationConfig)
|
|
158
|
+
deploy: DeployConfig | None = None
|
|
159
|
+
code_review: CodeReviewConfig = Field(default_factory=CodeReviewConfig)
|
|
160
|
+
dev_branch_template: str = "fix/issue-{n}"
|
|
161
|
+
|
|
162
|
+
@field_validator("local_path", mode="before")
|
|
163
|
+
@classmethod
|
|
164
|
+
def expand_local_path(cls, v: Any) -> Any:
|
|
165
|
+
if isinstance(v, str):
|
|
166
|
+
return Path(v).expanduser()
|
|
167
|
+
return v
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SchedulesConfig(BaseModel):
|
|
171
|
+
"""Cron schedules for background jobs run by the poller daemon.
|
|
172
|
+
|
|
173
|
+
Values are standard 5-field cron expressions (minute hour dom month dow),
|
|
174
|
+
evaluated in the top-level ``timezone``. Each schedule is validated at
|
|
175
|
+
config load time so an unparseable expression fails fast rather than
|
|
176
|
+
silently disabling the job.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
secops_cron: str = "0 6 * * *"
|
|
180
|
+
|
|
181
|
+
@field_validator("secops_cron")
|
|
182
|
+
@classmethod
|
|
183
|
+
def validate_cron(cls, v: str) -> str:
|
|
184
|
+
from ctrlrelay.core.scheduler import _build_vixie_trigger
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# Build through the same helper the scheduler uses so
|
|
188
|
+
# (a) DOW normalization and (b) Vixie DOM/DOW-OR splitting
|
|
189
|
+
# are both exercised at load time. Bad expressions surface
|
|
190
|
+
# synchronously instead of at daemon start.
|
|
191
|
+
_build_vixie_trigger(v, timezone=None)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"invalid cron expression {v!r}: {e}"
|
|
195
|
+
) from e
|
|
196
|
+
return v
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Config(BaseModel):
|
|
200
|
+
"""Root configuration model for ctrlrelay orchestrator."""
|
|
201
|
+
|
|
202
|
+
version: str = "1"
|
|
203
|
+
node_id: str
|
|
204
|
+
timezone: str = "UTC"
|
|
205
|
+
paths: PathsConfig
|
|
206
|
+
agent: AgentConfig = Field(default_factory=AgentConfig)
|
|
207
|
+
transport: TransportConfig
|
|
208
|
+
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
|
209
|
+
schedules: SchedulesConfig = Field(default_factory=SchedulesConfig)
|
|
210
|
+
repos: list[RepoConfig] = Field(default_factory=list)
|
|
211
|
+
|
|
212
|
+
@model_validator(mode="before")
|
|
213
|
+
@classmethod
|
|
214
|
+
def migrate_claude_to_agent(cls, data: Any) -> Any:
|
|
215
|
+
"""Accept the legacy ``claude:`` top-level key as an alias for
|
|
216
|
+
``agent:``. Emits a ``DeprecationWarning`` so operators see the
|
|
217
|
+
migration hint in logs. Removed in a future release.
|
|
218
|
+
|
|
219
|
+
If both keys are present, ``agent`` wins and ``claude`` is
|
|
220
|
+
ignored (fail-loud would break supervised daemons; we prefer
|
|
221
|
+
silent win-on-new-name during the migration window)."""
|
|
222
|
+
if isinstance(data, dict) and "claude" in data and "agent" not in data:
|
|
223
|
+
import warnings
|
|
224
|
+
warnings.warn(
|
|
225
|
+
"config key 'claude:' is deprecated; rename to 'agent:'. "
|
|
226
|
+
"See https://github.com/AInvirion/ctrlrelay/blob/main/"
|
|
227
|
+
"CHANGELOG.md for the migration.",
|
|
228
|
+
DeprecationWarning,
|
|
229
|
+
stacklevel=2,
|
|
230
|
+
)
|
|
231
|
+
data["agent"] = data.pop("claude")
|
|
232
|
+
return data
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def claude(self) -> AgentConfig:
|
|
236
|
+
"""Legacy attribute alias so callers still writing
|
|
237
|
+
``config.claude.binary`` keep working. New code should prefer
|
|
238
|
+
``config.agent.*``."""
|
|
239
|
+
return self.agent
|
|
240
|
+
|
|
241
|
+
@field_validator("timezone")
|
|
242
|
+
@classmethod
|
|
243
|
+
def validate_timezone(cls, v: str) -> str:
|
|
244
|
+
"""Reject unparseable IANA zones at load time.
|
|
245
|
+
|
|
246
|
+
Since the scheduler feeds ``timezone`` directly into APScheduler's
|
|
247
|
+
CronTrigger, a typo like ``America/Santiagoo`` would only surface
|
|
248
|
+
as a ``ZoneInfoNotFoundError`` when the poller daemon starts —
|
|
249
|
+
much worse than a synchronous config error at load.
|
|
250
|
+
"""
|
|
251
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
ZoneInfo(v)
|
|
255
|
+
except ZoneInfoNotFoundError as e:
|
|
256
|
+
raise ValueError(f"unknown timezone {v!r}: {e}") from e
|
|
257
|
+
return v
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_config(path: Path | str) -> Config:
|
|
261
|
+
"""Load and validate configuration from a YAML file.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
path: Path to the orchestrator.yaml file.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Validated Config object.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ConfigError: If the file cannot be loaded or validation fails.
|
|
271
|
+
"""
|
|
272
|
+
path = Path(path)
|
|
273
|
+
|
|
274
|
+
if not path.exists():
|
|
275
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
with open(path) as f:
|
|
279
|
+
data = yaml.safe_load(f)
|
|
280
|
+
except OSError as e:
|
|
281
|
+
raise ConfigError(f"Failed to read config file: {e}") from e
|
|
282
|
+
except yaml.YAMLError as e:
|
|
283
|
+
raise ConfigError(f"Failed to parse YAML: {e}") from e
|
|
284
|
+
|
|
285
|
+
if data is None:
|
|
286
|
+
raise ConfigError("Config file is empty")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
return Config.model_validate(data)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
raise ConfigError(f"Config validation failed: {e}") from e
|
|
@@ -0,0 +1,202 @@
|
|
|
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 os
|
|
14
|
+
import shutil
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol
|
|
18
|
+
|
|
19
|
+
from ctrlrelay.core.checkpoint import CheckpointState, CheckpointStatus, read_checkpoint
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ctrlrelay.core.config import AgentConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_claude() -> str:
|
|
26
|
+
"""Find claude binary, checking common paths if not in PATH."""
|
|
27
|
+
claude = shutil.which("claude")
|
|
28
|
+
if claude:
|
|
29
|
+
return claude
|
|
30
|
+
for path in [
|
|
31
|
+
os.path.expanduser("~/.local/bin/claude"),
|
|
32
|
+
"/usr/local/bin/claude",
|
|
33
|
+
"/opt/homebrew/bin/claude",
|
|
34
|
+
]:
|
|
35
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
36
|
+
return path
|
|
37
|
+
return "claude"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SessionResult:
|
|
42
|
+
"""Result of a Claude session."""
|
|
43
|
+
|
|
44
|
+
session_id: str
|
|
45
|
+
exit_code: int
|
|
46
|
+
state: CheckpointState | None
|
|
47
|
+
stdout: str = ""
|
|
48
|
+
stderr: str = ""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def success(self) -> bool:
|
|
52
|
+
return self.state is not None and self.state.status == CheckpointStatus.DONE
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def blocked(self) -> bool:
|
|
56
|
+
return (
|
|
57
|
+
self.state is not None
|
|
58
|
+
and self.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def failed(self) -> bool:
|
|
63
|
+
return self.state is None or self.state.status == CheckpointStatus.FAILED
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ClaudeDispatcher:
|
|
68
|
+
"""Spawns and manages Claude subprocess sessions."""
|
|
69
|
+
|
|
70
|
+
claude_binary: str = field(default_factory=_find_claude)
|
|
71
|
+
default_timeout: int = 1800
|
|
72
|
+
extra_env: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
# Only auto-resolve the bare default. Absolute paths, relative paths
|
|
76
|
+
# (resolved vs. working_dir by the child), and custom bare names pass
|
|
77
|
+
# through so explicit config is never silently overridden.
|
|
78
|
+
if self.claude_binary == "claude":
|
|
79
|
+
resolved = shutil.which("claude")
|
|
80
|
+
self.claude_binary = resolved or _find_claude()
|
|
81
|
+
|
|
82
|
+
async def spawn_session(
|
|
83
|
+
self,
|
|
84
|
+
session_id: str,
|
|
85
|
+
prompt: str,
|
|
86
|
+
working_dir: Path,
|
|
87
|
+
state_file: Path,
|
|
88
|
+
timeout: int | None = None,
|
|
89
|
+
resume_session_id: str | None = None,
|
|
90
|
+
) -> SessionResult:
|
|
91
|
+
"""Spawn a Claude session and wait for completion."""
|
|
92
|
+
timeout = timeout or self.default_timeout
|
|
93
|
+
|
|
94
|
+
env = os.environ.copy()
|
|
95
|
+
env.update(self.extra_env)
|
|
96
|
+
env["CTRLRELAY_SESSION_ID"] = session_id
|
|
97
|
+
env["CTRLRELAY_STATE_FILE"] = str(state_file)
|
|
98
|
+
|
|
99
|
+
cmd = [
|
|
100
|
+
self.claude_binary,
|
|
101
|
+
"-p", prompt,
|
|
102
|
+
"--output-format", "json",
|
|
103
|
+
"--dangerously-skip-permissions",
|
|
104
|
+
]
|
|
105
|
+
if resume_session_id:
|
|
106
|
+
cmd.extend(["--resume", resume_session_id])
|
|
107
|
+
|
|
108
|
+
proc = await asyncio.create_subprocess_exec(
|
|
109
|
+
*cmd,
|
|
110
|
+
cwd=working_dir,
|
|
111
|
+
env=env,
|
|
112
|
+
stdout=asyncio.subprocess.PIPE,
|
|
113
|
+
stderr=asyncio.subprocess.PIPE,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
stdout, stderr = await asyncio.wait_for(
|
|
118
|
+
proc.communicate(), timeout=timeout
|
|
119
|
+
)
|
|
120
|
+
except asyncio.TimeoutError:
|
|
121
|
+
proc.kill()
|
|
122
|
+
await proc.wait()
|
|
123
|
+
return SessionResult(
|
|
124
|
+
session_id=session_id,
|
|
125
|
+
exit_code=-1,
|
|
126
|
+
state=None,
|
|
127
|
+
stderr="Session timed out",
|
|
128
|
+
)
|
|
129
|
+
except asyncio.CancelledError:
|
|
130
|
+
# Scheduler/shutdown cancel: kill the child BEFORE re-raising
|
|
131
|
+
# so `claude` isn't left running against the worktree after
|
|
132
|
+
# the daemon exits. Shield the wait so further cancels don't
|
|
133
|
+
# orphan the process between `kill()` and the reaping.
|
|
134
|
+
if proc.returncode is None:
|
|
135
|
+
proc.kill()
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.shield(proc.wait())
|
|
138
|
+
except asyncio.CancelledError:
|
|
139
|
+
pass
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
state = None
|
|
143
|
+
if state_file.exists():
|
|
144
|
+
try:
|
|
145
|
+
state = read_checkpoint(state_file, delete_after=True)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
return SessionResult(
|
|
150
|
+
session_id=session_id,
|
|
151
|
+
exit_code=proc.returncode or 0,
|
|
152
|
+
state=state,
|
|
153
|
+
stdout=stdout.decode(),
|
|
154
|
+
stderr=stderr.decode(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AgentAdapter(Protocol):
|
|
159
|
+
"""Protocol every coding-agent adapter must satisfy.
|
|
160
|
+
|
|
161
|
+
An adapter is a thin wrapper over an agent CLI that handles:
|
|
162
|
+
spawning the subprocess, passing the prompt and state-file path,
|
|
163
|
+
enforcing the timeout, and translating the checkpoint JSON the
|
|
164
|
+
agent writes into a :class:`SessionResult`.
|
|
165
|
+
|
|
166
|
+
Pipelines (dev, secops) only ever interact with this protocol —
|
|
167
|
+
they never import concrete adapters — so adding a new backend
|
|
168
|
+
means implementing this and registering it in
|
|
169
|
+
:func:`make_agent_dispatcher`.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
async def spawn_session(
|
|
173
|
+
self,
|
|
174
|
+
session_id: str,
|
|
175
|
+
prompt: str,
|
|
176
|
+
working_dir: Path,
|
|
177
|
+
state_file: Path,
|
|
178
|
+
timeout: int | None = None,
|
|
179
|
+
resume_session_id: str | None = None,
|
|
180
|
+
) -> SessionResult:
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def make_agent_dispatcher(agent_config: "AgentConfig") -> AgentAdapter:
|
|
185
|
+
"""Build an adapter for the configured ``agent.type``.
|
|
186
|
+
|
|
187
|
+
Raises :class:`NotImplementedError` with a clear error message if
|
|
188
|
+
the configured type has no adapter — makes a typo surface loudly
|
|
189
|
+
at daemon startup instead of silently falling back to Claude.
|
|
190
|
+
"""
|
|
191
|
+
t = agent_config.type
|
|
192
|
+
if t == "claude":
|
|
193
|
+
return ClaudeDispatcher(
|
|
194
|
+
claude_binary=agent_config.binary,
|
|
195
|
+
default_timeout=agent_config.default_timeout_seconds,
|
|
196
|
+
)
|
|
197
|
+
raise NotImplementedError(
|
|
198
|
+
f"agent.type={t!r} has no adapter yet. Implement one that "
|
|
199
|
+
"satisfies the AgentAdapter protocol in "
|
|
200
|
+
"src/ctrlrelay/core/dispatcher.py and register it here, then "
|
|
201
|
+
"open a PR. Supported today: 'claude'."
|
|
202
|
+
)
|