orchestrator-runner 0.1.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.
- orchestrator_runner/__init__.py +12 -0
- orchestrator_runner/backends.py +273 -0
- orchestrator_runner/cli.py +352 -0
- orchestrator_runner/coding.py +199 -0
- orchestrator_runner/command.py +97 -0
- orchestrator_runner-0.1.0.dist-info/METADATA +69 -0
- orchestrator_runner-0.1.0.dist-info/RECORD +10 -0
- orchestrator_runner-0.1.0.dist-info/WHEEL +4 -0
- orchestrator_runner-0.1.0.dist-info/entry_points.txt +2 -0
- orchestrator_runner-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""orchestrator-runner — the local runner for the SDD Orchestrator.
|
|
2
|
+
|
|
3
|
+
Run your own LLM subscription (Claude / Codex / Gemini) from your own machine: pair once against your
|
|
4
|
+
orchestrator deployment, then ``orchestrator-runner run`` leases generation (and, with ``--allow-build``,
|
|
5
|
+
build) tasks and runs them on your local seat CLIs — your subscription login and your code never leave your
|
|
6
|
+
computer. The server only ever receives generated text (or a patch it independently verifies).
|
|
7
|
+
|
|
8
|
+
A standalone client with **no dependency on the (private) orchestrator server package** — pure stdlib, so it
|
|
9
|
+
installs cleanly from PyPI with ``pipx install orchestrator-runner``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Model backends — the LLM/harness-agnostic seam.
|
|
2
|
+
|
|
3
|
+
A backend turns ``(system, prompt)`` into text + real token usage. This protocol is where
|
|
4
|
+
"LLM-agnostic, harness-agnostic, works with everyone's subscription" actually lives: swap the
|
|
5
|
+
backend, the rest of the orchestrator is unchanged.
|
|
6
|
+
|
|
7
|
+
Shipped here:
|
|
8
|
+
- ``ClaudeCliBackend`` — shells to the ``claude`` CLI (uses the user's subscription, no API key),
|
|
9
|
+
the proven invocation the loop uses: ``claude -p --output-format json --model <id>`` with the
|
|
10
|
+
nested-session env markers stripped so the spawn runs at full strength.
|
|
11
|
+
- ``FakeBackend`` — deterministic, for unit tests (a test double, not a product path).
|
|
12
|
+
|
|
13
|
+
Next adapters plug in here with no other change: an Anthropic-API backend (SDK + key), OpenAI,
|
|
14
|
+
or a local model (Ollama). That is the "primitives for everyone" promise, made concrete.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BackendError(RuntimeError):
|
|
27
|
+
"""A backend failed to produce a generation (bad invocation, API error, timeout, no output)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Generation:
|
|
32
|
+
"""One model response with real usage (what the cost ledger and router training consume)."""
|
|
33
|
+
|
|
34
|
+
text: str
|
|
35
|
+
model: str
|
|
36
|
+
input_tokens: int
|
|
37
|
+
output_tokens: int
|
|
38
|
+
cost_usd: float | None = None
|
|
39
|
+
cache_read_tokens: int = 0 # prompt tokens served from cache (a cache hit)
|
|
40
|
+
cache_write_tokens: int = 0 # prompt tokens written to cache (a cache miss that populates it)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class ModelBackend(Protocol):
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clean_claude_env() -> dict[str, str]:
|
|
51
|
+
"""``os.environ`` without Claude Code's own session markers. If the orchestrator runs inside a
|
|
52
|
+
Claude Code session, a nested ``claude -p`` that inherits ``CLAUDECODE`` / ``CLAUDE_CODE_*`` runs
|
|
53
|
+
in a degraded nested mode and under-drives the agent; stripping them makes it a top-level run."""
|
|
54
|
+
return {k: v for k, v in os.environ.items() if k != "CLAUDECODE" and not k.startswith("CLAUDE_CODE_")}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClaudeCliBackend:
|
|
58
|
+
"""Real backend over the ``claude`` CLI (subscription auth; no API key required)."""
|
|
59
|
+
|
|
60
|
+
name = "claude-cli"
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
runtime: str = "claude",
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
timeout_s: int = 1200,
|
|
68
|
+
permission_mode: str | None = None,
|
|
69
|
+
cwd: str | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.runtime = runtime
|
|
72
|
+
self.model = model or os.environ.get("ORCHESTRATOR_MODEL", "claude-sonnet-5")
|
|
73
|
+
self.timeout_s = timeout_s
|
|
74
|
+
self.permission_mode = permission_mode
|
|
75
|
+
self.cwd = cwd
|
|
76
|
+
|
|
77
|
+
def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
|
|
78
|
+
chosen = model or self.model
|
|
79
|
+
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
|
80
|
+
argv = [self.runtime, "-p", "--output-format", "json", "--model", chosen]
|
|
81
|
+
if self.permission_mode:
|
|
82
|
+
argv += ["--permission-mode", self.permission_mode]
|
|
83
|
+
argv.append(full_prompt)
|
|
84
|
+
try:
|
|
85
|
+
proc = subprocess.run(
|
|
86
|
+
argv,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
errors="replace",
|
|
91
|
+
check=False,
|
|
92
|
+
env=_clean_claude_env(),
|
|
93
|
+
cwd=self.cwd,
|
|
94
|
+
timeout=self.timeout_s,
|
|
95
|
+
)
|
|
96
|
+
except FileNotFoundError as exc:
|
|
97
|
+
raise BackendError(f"`{self.runtime}` not found on PATH") from exc
|
|
98
|
+
except subprocess.TimeoutExpired as exc:
|
|
99
|
+
raise BackendError(f"`{self.runtime}` timed out after {self.timeout_s}s") from exc
|
|
100
|
+
|
|
101
|
+
stdout = (proc.stdout or "").strip()
|
|
102
|
+
if not stdout:
|
|
103
|
+
raise BackendError((proc.stderr or "claude produced no output").strip()[:300])
|
|
104
|
+
try:
|
|
105
|
+
obj: dict[str, Any] = json.loads(stdout)
|
|
106
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
107
|
+
raise BackendError(f"claude output was not JSON: {stdout[:200]}") from exc
|
|
108
|
+
if obj.get("is_error"):
|
|
109
|
+
raise BackendError(str(obj.get("result") or "claude reported an error")[:300])
|
|
110
|
+
|
|
111
|
+
usage = obj.get("usage") or {}
|
|
112
|
+
return Generation(
|
|
113
|
+
text=str(obj.get("result") or ""),
|
|
114
|
+
model=chosen,
|
|
115
|
+
input_tokens=int(usage.get("input_tokens", 0)),
|
|
116
|
+
output_tokens=int(usage.get("output_tokens", 0)),
|
|
117
|
+
cost_usd=_as_float(obj.get("total_cost_usd")),
|
|
118
|
+
cache_read_tokens=int(usage.get("cache_read_input_tokens", 0)),
|
|
119
|
+
cache_write_tokens=int(usage.get("cache_creation_input_tokens", 0)),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _run_cli(argv: list[str], *, timeout_s: int, cwd: str | None) -> subprocess.CompletedProcess[str]:
|
|
124
|
+
"""Run a vendor agent CLI, mapping the two expected failures to a clear ``BackendError`` (fail-closed):
|
|
125
|
+
a missing binary (the CLI isn't installed where the backend runs) and a timeout."""
|
|
126
|
+
try:
|
|
127
|
+
return subprocess.run(
|
|
128
|
+
argv,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
encoding="utf-8",
|
|
132
|
+
errors="replace",
|
|
133
|
+
check=False,
|
|
134
|
+
env=_clean_claude_env(),
|
|
135
|
+
cwd=cwd,
|
|
136
|
+
timeout=timeout_s,
|
|
137
|
+
)
|
|
138
|
+
except FileNotFoundError as exc:
|
|
139
|
+
raise BackendError(f"`{argv[0]}` not found on PATH") from exc
|
|
140
|
+
except subprocess.TimeoutExpired as exc:
|
|
141
|
+
raise BackendError(f"`{argv[0]}` timed out after {timeout_s}s") from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _int(value: Any) -> int:
|
|
145
|
+
try:
|
|
146
|
+
return int(value)
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class CodexCliBackend:
|
|
152
|
+
"""Real backend over the OpenAI ``codex`` CLI (ChatGPT subscription auth; no API key).
|
|
153
|
+
|
|
154
|
+
Runs ``codex exec`` read-only (no file edits) and takes the final agent message from stdout — the
|
|
155
|
+
documented non-interactive contract (progress goes to stderr, the result to stdout). ``codex``'s
|
|
156
|
+
``--json`` event schema varies by version, so we read plain stdout and keep usage best-effort (0);
|
|
157
|
+
the subscription path is flat-billed, so precise token accounting is not needed here. ``runtime`` and
|
|
158
|
+
``extra_args`` are overridable so a deployment/runner can match its installed CLI without a code change.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
name = "codex-cli"
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
runtime: str = "codex",
|
|
167
|
+
model: str | None = None,
|
|
168
|
+
timeout_s: int = 1200,
|
|
169
|
+
cwd: str | None = None,
|
|
170
|
+
extra_args: tuple[str, ...] = (),
|
|
171
|
+
) -> None:
|
|
172
|
+
self.runtime = runtime
|
|
173
|
+
self.model = model or os.environ.get("ORCHESTRATOR_CODEX_MODEL", "")
|
|
174
|
+
self.timeout_s = timeout_s
|
|
175
|
+
self.cwd = cwd
|
|
176
|
+
self.extra_args = tuple(extra_args)
|
|
177
|
+
|
|
178
|
+
def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
|
|
179
|
+
chosen = model or self.model
|
|
180
|
+
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
|
181
|
+
argv = [self.runtime, "exec", "--skip-git-repo-check"]
|
|
182
|
+
if chosen:
|
|
183
|
+
argv += ["--model", chosen]
|
|
184
|
+
argv += [*self.extra_args, full_prompt]
|
|
185
|
+
proc = _run_cli(argv, timeout_s=self.timeout_s, cwd=self.cwd)
|
|
186
|
+
text = (proc.stdout or "").strip()
|
|
187
|
+
if not text:
|
|
188
|
+
raise BackendError((proc.stderr or "codex produced no output").strip()[:300])
|
|
189
|
+
return Generation(text=text, model=chosen or "codex", input_tokens=0, output_tokens=0)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class GeminiCliBackend:
|
|
193
|
+
"""Real backend over the Google ``gemini`` CLI (Google-account/subscription auth; no API key).
|
|
194
|
+
|
|
195
|
+
Runs headless with ``--output-format json`` and parses the single result object (``response`` +
|
|
196
|
+
usage stats), falling back to raw stdout when the output is not JSON — the CLI's flags/schema vary by
|
|
197
|
+
version, so parsing is tolerant and never hard-fails on a schema change. ``runtime`` is overridable so
|
|
198
|
+
a runner can match its installed CLI.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
name = "gemini-cli"
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
runtime: str = "gemini",
|
|
207
|
+
model: str | None = None,
|
|
208
|
+
timeout_s: int = 1200,
|
|
209
|
+
cwd: str | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self.runtime = runtime
|
|
212
|
+
self.model = model or os.environ.get("ORCHESTRATOR_GEMINI_MODEL", "")
|
|
213
|
+
self.timeout_s = timeout_s
|
|
214
|
+
self.cwd = cwd
|
|
215
|
+
|
|
216
|
+
def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
|
|
217
|
+
chosen = model or self.model
|
|
218
|
+
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
|
219
|
+
argv = [self.runtime, "--output-format", "json"]
|
|
220
|
+
if chosen:
|
|
221
|
+
argv += ["--model", chosen]
|
|
222
|
+
argv += ["-p", full_prompt]
|
|
223
|
+
proc = _run_cli(argv, timeout_s=self.timeout_s, cwd=self.cwd)
|
|
224
|
+
stdout = (proc.stdout or "").strip()
|
|
225
|
+
text = ""
|
|
226
|
+
in_tok = out_tok = 0
|
|
227
|
+
if stdout:
|
|
228
|
+
try:
|
|
229
|
+
obj: Any = json.loads(stdout)
|
|
230
|
+
except (json.JSONDecodeError, ValueError):
|
|
231
|
+
obj = None
|
|
232
|
+
if isinstance(obj, dict):
|
|
233
|
+
if obj.get("error"):
|
|
234
|
+
raise BackendError(str(obj.get("error"))[:300])
|
|
235
|
+
text = str(obj.get("response") or obj.get("text") or "")
|
|
236
|
+
stats = obj.get("stats") or obj.get("usage") or {}
|
|
237
|
+
if isinstance(stats, dict):
|
|
238
|
+
in_tok = _int(stats.get("input_tokens") or stats.get("promptTokenCount"))
|
|
239
|
+
out_tok = _int(stats.get("output_tokens") or stats.get("candidatesTokenCount"))
|
|
240
|
+
else:
|
|
241
|
+
text = stdout # not JSON (older/text mode) -> treat raw stdout as the completion
|
|
242
|
+
if not text:
|
|
243
|
+
raise BackendError((proc.stderr or "gemini produced no output").strip()[:300])
|
|
244
|
+
return Generation(text=text, model=chosen or "gemini", input_tokens=in_tok, output_tokens=out_tok)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _as_float(value: Any) -> float | None:
|
|
248
|
+
return float(value) if isinstance(value, int | float) else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class FakeBackend:
|
|
252
|
+
"""Deterministic backend for unit tests. Records the last call so tests can assert the prompt
|
|
253
|
+
was built correctly. Not a product path — real runs use a live backend."""
|
|
254
|
+
|
|
255
|
+
name = "fake"
|
|
256
|
+
|
|
257
|
+
def __init__(self, reply: str = "# Draft\n\nGenerated by the fake backend.\n") -> None:
|
|
258
|
+
self.reply = reply
|
|
259
|
+
self.last_system = ""
|
|
260
|
+
self.last_prompt = ""
|
|
261
|
+
self.calls = 0
|
|
262
|
+
|
|
263
|
+
def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
|
|
264
|
+
self.last_system = system
|
|
265
|
+
self.last_prompt = prompt
|
|
266
|
+
self.calls += 1
|
|
267
|
+
return Generation(
|
|
268
|
+
text=self.reply,
|
|
269
|
+
model=model or "fake/deterministic",
|
|
270
|
+
input_tokens=len(prompt.split()),
|
|
271
|
+
output_tokens=len(self.reply.split()),
|
|
272
|
+
cost_usd=0.0,
|
|
273
|
+
)
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""``orchestrator-runner`` — connect a local runner so your own LLM subscription powers your work.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
- ``pair --url <site> --code <code>`` — exchange a one-time pairing code (from the UI) for a runner
|
|
5
|
+
credential, saved locally (0600). Reports which provider CLIs (claude/codex/gemini) are present.
|
|
6
|
+
- ``run`` — poll for tasks and run them on the local seat CLIs: generation (2b) and, when paired with
|
|
7
|
+
``--allow-build``, builds (2c — clone, run the coding agent, return a patch the server verifies).
|
|
8
|
+
- ``whoami`` — verify the stored credential against the server.
|
|
9
|
+
|
|
10
|
+
This runs on the user's machine; the provider seat logins never leave it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from orchestrator_runner.backends import (
|
|
30
|
+
ClaudeCliBackend,
|
|
31
|
+
CodexCliBackend,
|
|
32
|
+
GeminiCliBackend,
|
|
33
|
+
ModelBackend,
|
|
34
|
+
)
|
|
35
|
+
from orchestrator_runner.coding import (
|
|
36
|
+
ImplementationBrief,
|
|
37
|
+
agent_available,
|
|
38
|
+
agent_spec,
|
|
39
|
+
run_coding_agent,
|
|
40
|
+
)
|
|
41
|
+
from orchestrator_runner.command import LocalCommandRunner
|
|
42
|
+
|
|
43
|
+
CONFIG_PATH = Path.home() / ".orchestrator-runner" / "config.json"
|
|
44
|
+
|
|
45
|
+
# provider -> the seat CLI that serves it (presence here = a subscription this runner can run)
|
|
46
|
+
_PROVIDER_CLIS = {"anthropic": "claude", "openai": "codex", "google": "gemini"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect_capabilities(*, allow_build: bool = False) -> dict[str, Any]:
|
|
50
|
+
"""Which provider seat CLIs are installed on this machine (presence only — login is checked at run time),
|
|
51
|
+
plus whether this runner may BUILD — an explicit opt-in (``--allow-build``) AND git present, since a
|
|
52
|
+
build runs agent-written code + tests on this machine."""
|
|
53
|
+
providers = [name for name, cli in _PROVIDER_CLIS.items() if shutil.which(cli)]
|
|
54
|
+
return {"providers": providers, "build": bool(allow_build and shutil.which("git"))}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _request(
|
|
58
|
+
url: str, *, method: str, payload: dict[str, Any] | None = None, token: str = ""
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
61
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
62
|
+
if payload is not None:
|
|
63
|
+
req.add_header("Content-Type", "application/json")
|
|
64
|
+
if token:
|
|
65
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
66
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
67
|
+
obj: Any = json.loads(resp.read().decode("utf-8"))
|
|
68
|
+
if not isinstance(obj, dict):
|
|
69
|
+
raise ValueError("expected a JSON object from the server")
|
|
70
|
+
return obj
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _save_config(cfg: dict[str, Any]) -> None:
|
|
74
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
76
|
+
with contextlib.suppress(OSError):
|
|
77
|
+
CONFIG_PATH.chmod(0o600)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_config() -> dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
obj: Any = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
83
|
+
except (OSError, json.JSONDecodeError):
|
|
84
|
+
return {}
|
|
85
|
+
return obj if isinstance(obj, dict) else {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_pair(args: argparse.Namespace) -> int:
|
|
89
|
+
url = str(args.url).rstrip("/")
|
|
90
|
+
caps = detect_capabilities(allow_build=bool(args.allow_build))
|
|
91
|
+
try:
|
|
92
|
+
result = _request(
|
|
93
|
+
f"{url}/runners/pair",
|
|
94
|
+
method="POST",
|
|
95
|
+
payload={"code": str(args.code), "name": str(args.name), "capabilities": caps},
|
|
96
|
+
)
|
|
97
|
+
except urllib.error.HTTPError as exc:
|
|
98
|
+
sys.stderr.write(f"pairing failed: {exc.code} {exc.read().decode('utf-8', 'replace')}\n")
|
|
99
|
+
return 1
|
|
100
|
+
except urllib.error.URLError as exc:
|
|
101
|
+
sys.stderr.write(f"could not reach {url}: {exc}\n")
|
|
102
|
+
return 1
|
|
103
|
+
_save_config(
|
|
104
|
+
{
|
|
105
|
+
"url": url,
|
|
106
|
+
"token": result["token"],
|
|
107
|
+
"runner_id": result["runner_id"],
|
|
108
|
+
"user_id": result["user_id"],
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
print(f"paired: runner {result['runner_id']} for user {result['user_id']}")
|
|
112
|
+
print(f"capabilities: providers={caps['providers']} build={caps['build']}")
|
|
113
|
+
if not caps["providers"]:
|
|
114
|
+
print(
|
|
115
|
+
"warning: no provider CLI (claude/codex/gemini) found on PATH — this runner can't serve a "
|
|
116
|
+
"subscription yet. Install and log into at least one."
|
|
117
|
+
)
|
|
118
|
+
print(f"credential saved to {CONFIG_PATH}")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_whoami(_args: argparse.Namespace) -> int:
|
|
123
|
+
cfg = _load_config()
|
|
124
|
+
if not cfg.get("token"):
|
|
125
|
+
sys.stderr.write("not paired — run `orchestrator-runner pair --url <site> --code <code>`\n")
|
|
126
|
+
return 1
|
|
127
|
+
try:
|
|
128
|
+
me = _request(f"{str(cfg['url']).rstrip('/')}/runners/me", method="GET", token=str(cfg["token"]))
|
|
129
|
+
except urllib.error.HTTPError as exc:
|
|
130
|
+
sys.stderr.write(f"credential rejected: {exc.code} (revoked? re-pair)\n")
|
|
131
|
+
return 1
|
|
132
|
+
except urllib.error.URLError as exc:
|
|
133
|
+
sys.stderr.write(f"could not reach {cfg.get('url')}: {exc}\n")
|
|
134
|
+
return 1
|
|
135
|
+
print(f"runner {me['runner_id']} for user {me['user_id']} — capabilities {me.get('capabilities')}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_BACKEND_BUILDERS: dict[str, Callable[[str | None], ModelBackend]] = {
|
|
140
|
+
"anthropic": lambda model: ClaudeCliBackend(model=model),
|
|
141
|
+
"openai": lambda model: CodexCliBackend(model=model),
|
|
142
|
+
"google": lambda model: GeminiCliBackend(model=model),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _local_backends() -> dict[str, ModelBackend]:
|
|
147
|
+
"""The seat-CLI backends installed on this machine, by provider (only CLIs actually on PATH)."""
|
|
148
|
+
out: dict[str, ModelBackend] = {}
|
|
149
|
+
for provider, cli in _PROVIDER_CLIS.items():
|
|
150
|
+
if shutil.which(cli):
|
|
151
|
+
out[provider] = _BACKEND_BUILDERS[provider](None)
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _post_fail(url: str, token: str, task_id: str, detail: str) -> None:
|
|
156
|
+
with contextlib.suppress(urllib.error.HTTPError, urllib.error.URLError):
|
|
157
|
+
_request(
|
|
158
|
+
f"{url}/runners/tasks/{task_id}/fail", method="POST", payload={"detail": detail}, token=token
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_one(task: dict[str, Any], backends: dict[str, ModelBackend], url: str, token: str) -> None:
|
|
163
|
+
task_id = str(task.get("task_id", ""))
|
|
164
|
+
provider = str(task.get("provider", ""))
|
|
165
|
+
backend = backends.get(provider)
|
|
166
|
+
if backend is None:
|
|
167
|
+
_post_fail(url, token, task_id, f"runner has no local backend for provider {provider!r}")
|
|
168
|
+
return
|
|
169
|
+
try:
|
|
170
|
+
gen = backend.generate(
|
|
171
|
+
system=str(task.get("system", "")),
|
|
172
|
+
prompt=str(task.get("prompt", "")),
|
|
173
|
+
model=(str(task["model"]) if task.get("model") else None),
|
|
174
|
+
)
|
|
175
|
+
except Exception as exc: # a failed generation (CLI missing/unauth'd, timeout) -> report, stay alive
|
|
176
|
+
_post_fail(url, token, task_id, str(exc)[:500])
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
_request(
|
|
180
|
+
f"{url}/runners/tasks/{task_id}/result",
|
|
181
|
+
method="POST",
|
|
182
|
+
payload={
|
|
183
|
+
"text": gen.text,
|
|
184
|
+
"input_tokens": gen.input_tokens,
|
|
185
|
+
"output_tokens": gen.output_tokens,
|
|
186
|
+
"model": gen.model,
|
|
187
|
+
},
|
|
188
|
+
token=token,
|
|
189
|
+
)
|
|
190
|
+
print(f"task {task_id}: done ({gen.output_tokens} output tokens)")
|
|
191
|
+
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
|
|
192
|
+
sys.stderr.write(f"task {task_id}: could not post result: {exc}\n")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _post_build_result(
|
|
196
|
+
url: str, token: str, task_id: str, *, ok: bool, patch: str, changed_files: list[str], detail: str
|
|
197
|
+
) -> None:
|
|
198
|
+
with contextlib.suppress(urllib.error.HTTPError, urllib.error.URLError):
|
|
199
|
+
_request(
|
|
200
|
+
f"{url}/runners/tasks/{task_id}/build-result",
|
|
201
|
+
method="POST",
|
|
202
|
+
payload={"ok": ok, "patch": patch, "changed_files": changed_files, "detail": detail},
|
|
203
|
+
token=token,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_build(task: dict[str, Any], url: str, token: str) -> None:
|
|
208
|
+
"""Clone the repo (read-only, this machine's git), run the coding agent on the seat, return a PATCH.
|
|
209
|
+
The server applies + authoritatively verifies it — this runner never pushes."""
|
|
210
|
+
task_id = str(task.get("task_id", ""))
|
|
211
|
+
runtime = str(task.get("runtime") or "claude-code")
|
|
212
|
+
spec = agent_spec(runtime)
|
|
213
|
+
if spec is None:
|
|
214
|
+
_post_build_result(
|
|
215
|
+
url, token, task_id, ok=False, patch="", changed_files=[], detail=f"unknown runtime {runtime!r}"
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
cmd = LocalCommandRunner()
|
|
219
|
+
repo_url = str(task.get("repo_url", ""))
|
|
220
|
+
base = str(task.get("base_branch") or "main")
|
|
221
|
+
with tempfile.TemporaryDirectory(prefix="orch-runner-build-") as tmp:
|
|
222
|
+
clone_dir = os.path.join(tmp, "repo")
|
|
223
|
+
cloned = cmd.run(
|
|
224
|
+
["git", "clone", "--depth", "1", "--branch", base, repo_url, clone_dir], timeout_s=600
|
|
225
|
+
)
|
|
226
|
+
if cloned.returncode != 0:
|
|
227
|
+
_post_build_result(
|
|
228
|
+
url,
|
|
229
|
+
token,
|
|
230
|
+
task_id,
|
|
231
|
+
ok=False,
|
|
232
|
+
patch="",
|
|
233
|
+
changed_files=[],
|
|
234
|
+
detail=f"clone failed: {cloned.stderr[:300]}",
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
if not agent_available(spec, cmd, cwd=clone_dir):
|
|
238
|
+
_post_build_result(
|
|
239
|
+
url,
|
|
240
|
+
token,
|
|
241
|
+
task_id,
|
|
242
|
+
ok=False,
|
|
243
|
+
patch="",
|
|
244
|
+
changed_files=[],
|
|
245
|
+
detail=f"{spec.name} CLI not available/logged in on this runner",
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
brief = ImplementationBrief(
|
|
249
|
+
goal=str(task.get("goal", "")),
|
|
250
|
+
plan=str(task.get("plan", "")),
|
|
251
|
+
specs=tuple(str(s) for s in (task.get("specs") or [])),
|
|
252
|
+
)
|
|
253
|
+
result = run_coding_agent(spec, prompt=brief.to_prompt(), cwd=clone_dir, runner=cmd, timeout_s=3600)
|
|
254
|
+
if not result.ok:
|
|
255
|
+
_post_build_result(
|
|
256
|
+
url,
|
|
257
|
+
token,
|
|
258
|
+
task_id,
|
|
259
|
+
ok=False,
|
|
260
|
+
patch="",
|
|
261
|
+
changed_files=list(result.changed_files),
|
|
262
|
+
detail=f"{spec.name}: {result.detail}",
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
cmd.run(["git", "add", "-A"], cwd=clone_dir, timeout_s=120)
|
|
266
|
+
diff = cmd.run(["git", "diff", "--cached"], cwd=clone_dir, timeout_s=120)
|
|
267
|
+
_post_build_result(
|
|
268
|
+
url,
|
|
269
|
+
token,
|
|
270
|
+
task_id,
|
|
271
|
+
ok=True,
|
|
272
|
+
patch=diff.stdout,
|
|
273
|
+
changed_files=list(result.changed_files),
|
|
274
|
+
detail="",
|
|
275
|
+
)
|
|
276
|
+
print(f"build task {task_id}: done ({len(result.changed_files)} files)")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
280
|
+
cfg = _load_config()
|
|
281
|
+
if not cfg.get("token"):
|
|
282
|
+
sys.stderr.write("not paired — run `orchestrator-runner pair --url <site> --code <code>`\n")
|
|
283
|
+
return 1
|
|
284
|
+
url = str(cfg["url"]).rstrip("/")
|
|
285
|
+
token = str(cfg["token"])
|
|
286
|
+
backends = _local_backends()
|
|
287
|
+
if not backends:
|
|
288
|
+
sys.stderr.write("no provider CLI (claude/codex/gemini) on PATH — install and log into one\n")
|
|
289
|
+
return 1
|
|
290
|
+
poll = float(args.poll)
|
|
291
|
+
once = bool(args.once)
|
|
292
|
+
print(f"runner online for {sorted(backends)} — polling {url} every {poll}s (Ctrl-C to stop)")
|
|
293
|
+
while True:
|
|
294
|
+
try:
|
|
295
|
+
resp = _request(f"{url}/runners/tasks", method="GET", token=token)
|
|
296
|
+
except urllib.error.HTTPError as exc:
|
|
297
|
+
if exc.code == 401:
|
|
298
|
+
sys.stderr.write("credential rejected (revoked?) — re-pair\n")
|
|
299
|
+
return 1
|
|
300
|
+
sys.stderr.write(f"poll error: {exc.code}\n")
|
|
301
|
+
resp = {}
|
|
302
|
+
except urllib.error.URLError as exc:
|
|
303
|
+
sys.stderr.write(f"could not reach {url}: {exc}\n")
|
|
304
|
+
if once:
|
|
305
|
+
return 1
|
|
306
|
+
time.sleep(poll)
|
|
307
|
+
continue
|
|
308
|
+
task = resp.get("task")
|
|
309
|
+
if task:
|
|
310
|
+
if str(task.get("kind")) == "build":
|
|
311
|
+
_run_build(task, url, token)
|
|
312
|
+
else:
|
|
313
|
+
_run_one(task, backends, url, token)
|
|
314
|
+
if once:
|
|
315
|
+
return 0
|
|
316
|
+
if not task:
|
|
317
|
+
time.sleep(poll)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def main(argv: list[str] | None = None) -> int:
|
|
321
|
+
parser = argparse.ArgumentParser(
|
|
322
|
+
prog="orchestrator-runner",
|
|
323
|
+
description="Connect a local runner so your own LLM subscription powers your orchestrator work.",
|
|
324
|
+
)
|
|
325
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
326
|
+
|
|
327
|
+
p_pair = sub.add_parser("pair", help="exchange a one-time pairing code for a runner credential")
|
|
328
|
+
p_pair.add_argument("--url", required=True, help="the orchestrator site, e.g. https://host")
|
|
329
|
+
p_pair.add_argument("--code", required=True, help="the one-time pairing code shown in the UI")
|
|
330
|
+
p_pair.add_argument("--name", default="", help="a name for this runner (optional)")
|
|
331
|
+
p_pair.add_argument(
|
|
332
|
+
"--allow-build",
|
|
333
|
+
action="store_true",
|
|
334
|
+
help="let this runner BUILD (run agent-written code + tests on this machine), not just generate",
|
|
335
|
+
)
|
|
336
|
+
p_pair.set_defaults(func=cmd_pair)
|
|
337
|
+
|
|
338
|
+
p_run = sub.add_parser("run", help="run queued generation tasks on this machine's seat CLIs")
|
|
339
|
+
p_run.add_argument("--poll", type=float, default=3.0, help="seconds between polls when idle")
|
|
340
|
+
p_run.add_argument("--once", action="store_true", help="handle at most one poll cycle, then exit")
|
|
341
|
+
p_run.set_defaults(func=cmd_run)
|
|
342
|
+
|
|
343
|
+
p_who = sub.add_parser("whoami", help="verify the stored runner credential against the server")
|
|
344
|
+
p_who.set_defaults(func=cmd_whoami)
|
|
345
|
+
|
|
346
|
+
args = parser.parse_args(argv)
|
|
347
|
+
func: Any = args.func
|
|
348
|
+
return int(func(args))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == "__main__":
|
|
352
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Third-party coding agents — run an EXTERNAL agent CLI (Claude Code, Codex, Kiro) as the implementer.
|
|
2
|
+
|
|
3
|
+
The orchestrator's own builder is a TDD cohort loop over a model backend. Some teams would rather drive the
|
|
4
|
+
implementation with a full coding agent they already use. This module makes that a drop-in: a
|
|
5
|
+
:class:`CodingAgentSpec` describes how to invoke one as a non-interactive CLI, and :func:`run_coding_agent`
|
|
6
|
+
runs it IN THE RUN SANDBOX (through the same ``CommandRunner`` the verification ladder uses), so the agent
|
|
7
|
+
edits the run's worktree and the SAME gates + verification ladder + dual review then judge its output — the
|
|
8
|
+
agent is swappable, the governance around it is not.
|
|
9
|
+
|
|
10
|
+
Execution needs the chosen CLI present in the run's devbox (like the Docker/K8s sandbox backends, it is
|
|
11
|
+
infra-gated): :func:`agent_available` probes for it and the external path fails CLOSED with a clear message
|
|
12
|
+
when it is missing, so a run never silently falls back to something the operator did not choose.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from orchestrator_runner.command import CommandRunner
|
|
23
|
+
|
|
24
|
+
# the default: the orchestrator's own TDD cohort loop (no external CLI). Selecting this is "no third party".
|
|
25
|
+
BUILTIN_RUNTIME = "builtin"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class CodingAgentSpec:
|
|
30
|
+
"""How to invoke one external coding agent as a non-interactive CLI. ``prefix`` is the fixed argv; the
|
|
31
|
+
task prompt is appended as a single final argument. ``version_argv`` is the availability probe."""
|
|
32
|
+
|
|
33
|
+
id: str
|
|
34
|
+
name: str
|
|
35
|
+
prefix: tuple[str, ...]
|
|
36
|
+
version_argv: tuple[str, ...]
|
|
37
|
+
description: str = ""
|
|
38
|
+
|
|
39
|
+
def command(self, prompt: str) -> list[str]:
|
|
40
|
+
"""The full argv to run ``prompt`` — the prompt is one argument, never shell-interpolated."""
|
|
41
|
+
return [*self.prefix, prompt]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# The built-in catalog. Each entry is the documented non-interactive invocation of a real agent CLI; the
|
|
45
|
+
# exact flags can be tuned per deployment. These run only where the CLI is installed in the devbox.
|
|
46
|
+
BUILTIN_AGENTS: tuple[CodingAgentSpec, ...] = (
|
|
47
|
+
CodingAgentSpec(
|
|
48
|
+
id="claude-code",
|
|
49
|
+
name="Claude Code",
|
|
50
|
+
prefix=("claude", "-p"), # -p = print/non-interactive: run the prompt headless and exit
|
|
51
|
+
version_argv=("claude", "--version"),
|
|
52
|
+
description="Anthropic's Claude Code CLI, run headless over the run's worktree.",
|
|
53
|
+
),
|
|
54
|
+
CodingAgentSpec(
|
|
55
|
+
id="codex",
|
|
56
|
+
name="OpenAI Codex",
|
|
57
|
+
prefix=("codex", "exec"), # exec = non-interactive one-shot
|
|
58
|
+
version_argv=("codex", "--version"),
|
|
59
|
+
description="OpenAI's Codex CLI, run non-interactively over the run's worktree.",
|
|
60
|
+
),
|
|
61
|
+
CodingAgentSpec(
|
|
62
|
+
id="kiro",
|
|
63
|
+
name="Kiro",
|
|
64
|
+
prefix=("kiro", "run", "--prompt"),
|
|
65
|
+
version_argv=("kiro", "--version"),
|
|
66
|
+
description="AWS Kiro agent CLI, run over the run's worktree.",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_BY_ID: dict[str, CodingAgentSpec] = {a.id: a for a in BUILTIN_AGENTS}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def agent_spec(runtime_id: str) -> CodingAgentSpec | None:
|
|
74
|
+
"""The spec for an external runtime id, or None (``builtin`` / unknown has no external CLI)."""
|
|
75
|
+
return _BY_ID.get(runtime_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_external(runtime_id: str) -> bool:
|
|
79
|
+
return runtime_id in _BY_ID
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_runtimes() -> list[dict[str, str]]:
|
|
83
|
+
"""The selectable implementation runtimes: the built-in loop + every external agent (id + name)."""
|
|
84
|
+
out = [
|
|
85
|
+
{
|
|
86
|
+
"id": BUILTIN_RUNTIME,
|
|
87
|
+
"name": "Built-in (TDD loop)",
|
|
88
|
+
"description": "The orchestrator's own builder.",
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
out += [{"id": a.id, "name": a.name, "description": a.description} for a in BUILTIN_AGENTS]
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class CodingAgentResult:
|
|
97
|
+
ok: bool
|
|
98
|
+
runtime: str
|
|
99
|
+
exit_code: int
|
|
100
|
+
changed_files: tuple[str, ...] = ()
|
|
101
|
+
output_tail: str = ""
|
|
102
|
+
detail: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def agent_available(spec: CodingAgentSpec, runner: CommandRunner, *, cwd: str | Path | None = None) -> bool:
|
|
106
|
+
"""Whether the agent's CLI is runnable in the sandbox (the version probe exits 0). Fail-closed: a missing
|
|
107
|
+
binary (FileNotFoundError), a timeout, or a non-zero exit all mean 'not available'."""
|
|
108
|
+
try:
|
|
109
|
+
res = runner.run(list(spec.version_argv), cwd=cwd, timeout_s=30)
|
|
110
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
111
|
+
return False
|
|
112
|
+
return res.returncode == 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _changed_files(runner: CommandRunner, cwd: str | Path) -> tuple[str, ...]:
|
|
116
|
+
"""Files the agent created/modified, via ``git status --porcelain`` in the worktree (best-effort)."""
|
|
117
|
+
try:
|
|
118
|
+
res = runner.run(["git", "status", "--porcelain"], cwd=cwd, timeout_s=60)
|
|
119
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
120
|
+
return ()
|
|
121
|
+
if res.returncode != 0:
|
|
122
|
+
return ()
|
|
123
|
+
files: list[str] = []
|
|
124
|
+
for line in res.stdout.splitlines():
|
|
125
|
+
path = line[3:].strip() if len(line) > 3 else line.strip()
|
|
126
|
+
if path:
|
|
127
|
+
files.append(path)
|
|
128
|
+
return tuple(files)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run_coding_agent(
|
|
132
|
+
spec: CodingAgentSpec,
|
|
133
|
+
*,
|
|
134
|
+
prompt: str,
|
|
135
|
+
cwd: str | Path,
|
|
136
|
+
runner: CommandRunner,
|
|
137
|
+
timeout_s: int = 1800,
|
|
138
|
+
env: Mapping[str, str] | None = None,
|
|
139
|
+
) -> CodingAgentResult:
|
|
140
|
+
"""Run the external agent over the worktree at ``cwd`` with ``prompt``, then report what it changed.
|
|
141
|
+
|
|
142
|
+
Fail-closed: a missing CLI (FileNotFoundError) or a timeout returns ``ok=False`` with a clear reason
|
|
143
|
+
rather than raising, so the executor can surface it as a run failure the operator can act on."""
|
|
144
|
+
argv = spec.command(prompt)
|
|
145
|
+
try:
|
|
146
|
+
res = runner.run(argv, cwd=cwd, timeout_s=timeout_s, env=env)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
return CodingAgentResult(
|
|
149
|
+
ok=False,
|
|
150
|
+
runtime=spec.id,
|
|
151
|
+
exit_code=127,
|
|
152
|
+
detail=f"{spec.name} CLI ({spec.prefix[0]!r}) is not installed in this run's devbox",
|
|
153
|
+
)
|
|
154
|
+
except subprocess.TimeoutExpired:
|
|
155
|
+
return CodingAgentResult(
|
|
156
|
+
ok=False, runtime=spec.id, exit_code=124, detail=f"{spec.name} timed out after {timeout_s}s"
|
|
157
|
+
)
|
|
158
|
+
changed = _changed_files(runner, cwd)
|
|
159
|
+
output = f"{res.stdout}\n{res.stderr}".strip()
|
|
160
|
+
tail = "\n".join(output.splitlines()[-20:])
|
|
161
|
+
ok = res.returncode == 0 and bool(changed)
|
|
162
|
+
detail = ""
|
|
163
|
+
if res.returncode != 0:
|
|
164
|
+
detail = f"{spec.name} exited {res.returncode}"
|
|
165
|
+
elif not changed:
|
|
166
|
+
detail = f"{spec.name} made no file changes"
|
|
167
|
+
return CodingAgentResult(
|
|
168
|
+
ok=ok,
|
|
169
|
+
runtime=spec.id,
|
|
170
|
+
exit_code=res.returncode,
|
|
171
|
+
changed_files=changed,
|
|
172
|
+
output_tail=tail,
|
|
173
|
+
detail=detail,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class ImplementationBrief:
|
|
179
|
+
"""What an external agent is told to build — assembled from the approved product + engineering tiers."""
|
|
180
|
+
|
|
181
|
+
goal: str
|
|
182
|
+
plan: str = ""
|
|
183
|
+
specs: tuple[str, ...] = field(default_factory=tuple)
|
|
184
|
+
|
|
185
|
+
def to_prompt(self) -> str:
|
|
186
|
+
parts = [
|
|
187
|
+
"Implement the following feature in this repository. Make the changes directly in the working "
|
|
188
|
+
"tree; write real, tested, production-quality code that satisfies the specification and plan.",
|
|
189
|
+
f"\n## Goal\n{self.goal}",
|
|
190
|
+
]
|
|
191
|
+
if self.plan:
|
|
192
|
+
parts.append(f"\n## Engineering plan\n{self.plan}")
|
|
193
|
+
for i, spec in enumerate(self.specs, 1):
|
|
194
|
+
parts.append(f"\n## Specification {i}\n{spec}")
|
|
195
|
+
parts.append(
|
|
196
|
+
"\nWhen done, ensure the code lints, type-checks, and its tests pass. Do not open a pull "
|
|
197
|
+
"request — the orchestrator handles delivery after its own review."
|
|
198
|
+
)
|
|
199
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The command-execution seam — how a run's build/verify commands actually run.
|
|
2
|
+
|
|
3
|
+
The verification ladder (and anything else that shells out during a run) executes commands through a
|
|
4
|
+
``CommandRunner`` instead of calling ``subprocess`` directly. The default ``LocalCommandRunner`` runs them
|
|
5
|
+
as subprocesses on the host — exactly today's behavior. A containerized runner is a drop-in behind the same
|
|
6
|
+
port (the per-run sandbox), so a run's lint/typecheck/tests can run inside an isolated devbox without
|
|
7
|
+
touching the ladder or the executor above it — the same agnostic-seam pattern as the model backends and the
|
|
8
|
+
filesystem ``Sandbox``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class CommandResult:
|
|
23
|
+
"""The outcome of one command: its exit code and captured output."""
|
|
24
|
+
|
|
25
|
+
returncode: int
|
|
26
|
+
stdout: str
|
|
27
|
+
stderr: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class CommandRunner(Protocol):
|
|
32
|
+
"""Runs one command and returns its result.
|
|
33
|
+
|
|
34
|
+
Implementations run it wherever isolation requires — on the host (local) or inside a per-run container
|
|
35
|
+
(sandbox). ``run`` may raise ``FileNotFoundError`` (the executable is not present) or
|
|
36
|
+
``subprocess.TimeoutExpired`` (it exceeded ``timeout_s``); callers that must fail closed catch those
|
|
37
|
+
(the verification ladder does). ``env`` is ADDITIVE — the given vars are added to (not a replacement of)
|
|
38
|
+
the ambient environment, so a run's per-project secrets layer on top of the toolchain's own PATH/env;
|
|
39
|
+
``None`` means no additions.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def run(
|
|
43
|
+
self,
|
|
44
|
+
argv: Sequence[str],
|
|
45
|
+
*,
|
|
46
|
+
cwd: str | Path | None = None,
|
|
47
|
+
timeout_s: int = 300,
|
|
48
|
+
env: Mapping[str, str] | None = None,
|
|
49
|
+
) -> CommandResult: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LocalCommandRunner:
|
|
53
|
+
"""Runs commands as subprocesses on the local host — the default, today's behavior."""
|
|
54
|
+
|
|
55
|
+
def run(
|
|
56
|
+
self,
|
|
57
|
+
argv: Sequence[str],
|
|
58
|
+
*,
|
|
59
|
+
cwd: str | Path | None = None,
|
|
60
|
+
timeout_s: int = 300,
|
|
61
|
+
env: Mapping[str, str] | None = None,
|
|
62
|
+
) -> CommandResult:
|
|
63
|
+
proc = subprocess.run(
|
|
64
|
+
list(argv),
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
errors="replace",
|
|
69
|
+
cwd=cwd,
|
|
70
|
+
# additive: merge onto the ambient env so PATH/etc survive; a bare replacement would strip them
|
|
71
|
+
env={**os.environ, **env} if env is not None else None,
|
|
72
|
+
timeout=timeout_s,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
return CommandResult(proc.returncode, proc.stdout, proc.stderr)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class ScopedEnvRunner:
|
|
80
|
+
"""Wraps a ``CommandRunner`` to inject a fixed base environment into EVERY command it runs — a run's
|
|
81
|
+
per-project secrets, applied to each build/verify step. A per-call ``env`` overrides the base key-wise.
|
|
82
|
+
The base is additive wherever the inner runner is (the local runner merges onto the host env; the
|
|
83
|
+
container runner adds ``-e`` flags), so the toolchain's own environment is preserved."""
|
|
84
|
+
|
|
85
|
+
inner: CommandRunner
|
|
86
|
+
base_env: Mapping[str, str]
|
|
87
|
+
|
|
88
|
+
def run(
|
|
89
|
+
self,
|
|
90
|
+
argv: Sequence[str],
|
|
91
|
+
*,
|
|
92
|
+
cwd: str | Path | None = None,
|
|
93
|
+
timeout_s: int = 300,
|
|
94
|
+
env: Mapping[str, str] | None = None,
|
|
95
|
+
) -> CommandResult:
|
|
96
|
+
merged = {**self.base_env, **(env or {})}
|
|
97
|
+
return self.inner.run(argv, cwd=cwd, timeout_s=timeout_s, env=merged or None)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: orchestrator-runner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local runner for the SDD Orchestrator — run your own LLM subscription (Claude/Codex/Gemini) from your machine.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator
|
|
6
|
+
Project-URL: Repository, https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator
|
|
7
|
+
Project-URL: Issues, https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator/issues
|
|
8
|
+
Author: BizEnrich
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# orchestrator-runner
|
|
24
|
+
|
|
25
|
+
The **local runner** for the SDD Orchestrator. It lets you run your **own LLM subscription**
|
|
26
|
+
(Claude / Codex / Gemini) from **your own machine** — your subscription login and your code never leave your
|
|
27
|
+
computer. The orchestrator server only ever receives the generated text, or (for builds) a patch it
|
|
28
|
+
independently verifies.
|
|
29
|
+
|
|
30
|
+
It's a standalone client: pure Python stdlib, **no dependency on the orchestrator server**.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install orchestrator-runner
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You also need, on this machine:
|
|
39
|
+
|
|
40
|
+
- The provider's own CLI, installed **and logged in** — `claude` (Pro/Max), `codex` (Sign-in-with-ChatGPT),
|
|
41
|
+
or `gemini` (Google). The runner only serves providers whose CLI is present.
|
|
42
|
+
- `git`, if you want this runner to also **build** (`--allow-build`).
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
1. In the orchestrator web app: **Integrations → Local runners → Pair a new runner** (gives a one-time code,
|
|
47
|
+
valid ~10 minutes).
|
|
48
|
+
2. On this machine, in a terminal:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
orchestrator-runner pair --url https://<your-orchestrator-site> --code <code> # one-time
|
|
52
|
+
# add --allow-build to also run builds here (runs agent-written code + tests on this machine)
|
|
53
|
+
orchestrator-runner run
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Keep `run` going while your projects work. Closing it takes your runner offline; subscription-backed
|
|
57
|
+
stages then pause until you start it again.
|
|
58
|
+
|
|
59
|
+
3. `orchestrator-runner whoami` verifies the stored credential.
|
|
60
|
+
|
|
61
|
+
The credential is saved to `~/.orchestrator-runner/config.json` (mode 0600). Revoke a runner any time from
|
|
62
|
+
the web app.
|
|
63
|
+
|
|
64
|
+
## Security
|
|
65
|
+
|
|
66
|
+
- Your provider **seat login** stays in your provider CLI on this machine — the server stores only a
|
|
67
|
+
revocable runner credential (its hash).
|
|
68
|
+
- For builds, the runner clones **read-only** with your own git and returns a **patch**; it never pushes.
|
|
69
|
+
The server applies the patch and runs the authoritative verification + review + delivery.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
orchestrator_runner/__init__.py,sha256=MhtNAshigASkEQiKQ_YOKzUZJaNVsfPL_82fS24THeg,693
|
|
2
|
+
orchestrator_runner/backends.py,sha256=89dxBl3B1wd_p4YzVJEq-6PTZdezmraShoy9L9XaIiw,10934
|
|
3
|
+
orchestrator_runner/cli.py,sha256=AkEs-WTiL1JnF6YaiYYrLJ0DTR4qDUKygbnt1y4zwGw,13600
|
|
4
|
+
orchestrator_runner/coding.py,sha256=EhtPuRWt3lJknLt2-XpgVVApuUPkubnWjSoz-i9ivaY,7761
|
|
5
|
+
orchestrator_runner/command.py,sha256=KWPxsmP7C6AAh9K8sJkwWJypHQp6QETmvJqACSc07yM,3623
|
|
6
|
+
orchestrator_runner-0.1.0.dist-info/METADATA,sha256=HX2suJXQhmgvCiNes3L5FYzaLDgULljeweoqhqmLr64,2903
|
|
7
|
+
orchestrator_runner-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
orchestrator_runner-0.1.0.dist-info/entry_points.txt,sha256=AeJSVDc6nRVSMRW--Sy5X_iZ0ybveth0ItxV0zv-2cc,69
|
|
9
|
+
orchestrator_runner-0.1.0.dist-info/licenses/LICENSE,sha256=Qe5w6IDSIaCw-M5xRaU_xWuzBhGuXrVnJgtyTmOLxLg,1066
|
|
10
|
+
orchestrator_runner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BizEnrich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|