stargate-cli 1.0.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.
stargate/core.py ADDED
@@ -0,0 +1,341 @@
1
+ """Errors, the run's shared state, and the two things everything shells out to:
2
+ a subprocess runner that streams and stays killable, and git."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import re
8
+ import shlex
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, TextIO
17
+
18
+
19
+ class StargateError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class Terminated(KeyboardInterrupt):
24
+ """A terminating signal that follows the already-safe interrupt path."""
25
+
26
+ def __init__(self, signum: int) -> None:
27
+ super().__init__(f"signal {signum} ({signal.Signals(signum).name})")
28
+ self.signum = signum
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Detected:
33
+ command: str
34
+ source: str
35
+
36
+
37
+ @dataclass
38
+ class RunContext:
39
+ repo: Path
40
+ config: dict[str, Any]
41
+ run_id: str
42
+ slug: str
43
+ branch: str
44
+ base_ref: str
45
+ base_commit: str
46
+ worktree: Path
47
+ artifacts: Path
48
+ task: str = ""
49
+ stage: str = "init"
50
+ done: set[str] = field(default_factory=set)
51
+ tokens_used: int = 0
52
+ tag: str = ""
53
+ named_by_user: bool = False
54
+ test_command: str = ""
55
+ test_source: str = ""
56
+ detected: list[Detected] = field(default_factory=list)
57
+ test_artifacts: set[str] = field(default_factory=set)
58
+ commit: str = ""
59
+ commit_error: str = ""
60
+ mode: str = "linear"
61
+ fanout: dict[str, Any] = field(default_factory=dict)
62
+ review: dict[str, Any] = field(default_factory=dict)
63
+ # `review` is a checkpoint: control state for re-entering an interrupted
64
+ # loop, cleared once the run finishes. `findings` is a report: it has to
65
+ # survive to the terminal state, so it cannot share that dict.
66
+ findings: list[dict[str, Any]] = field(default_factory=list)
67
+ # Provenance survives every stage and resume independently of the task text.
68
+ task_source: str = ""
69
+
70
+
71
+ # How often a running agent prints that it is still alive.
72
+ HEARTBEAT_SECONDS = 30
73
+
74
+
75
+ # Cleanup must not turn a terminating signal into an indefinite wait.
76
+ KILL_GRACE_SECONDS = 10
77
+
78
+
79
+ _ACTIVE_PROCESS_LOCK = threading.RLock()
80
+ _ACTIVE_PROCESSES: set[subprocess.Popen[str]] = set()
81
+ _TERMINATION_REQUESTED = threading.Event()
82
+ _OUTPUT_LOCK = threading.RLock()
83
+
84
+
85
+ def print_output(
86
+ message: str,
87
+ *,
88
+ end: str = "\n",
89
+ file: TextIO | None = None,
90
+ ) -> None:
91
+ """Write one complete status message without another worker splicing it."""
92
+ stream = sys.stdout if file is None else file
93
+ with _OUTPUT_LOCK:
94
+ stream.write(message + end)
95
+ stream.flush()
96
+
97
+
98
+ def _kill_process_group(proc: subprocess.Popen[str]) -> None:
99
+ """Kill a tracked command and descendants that kept its process group."""
100
+ if proc.poll() is not None:
101
+ # Already reaped, so its PID is free for the kernel to reissue, and
102
+ # killpg would signal a whole unrelated group under that number.
103
+ return
104
+ try:
105
+ os.killpg(proc.pid, signal.SIGKILL)
106
+ except ProcessLookupError:
107
+ pass
108
+ except OSError:
109
+ # The Popen child remains the minimum cleanup guarantee if a platform
110
+ # cannot address the new session as a process group.
111
+ with contextlib.suppress(OSError):
112
+ proc.kill()
113
+
114
+
115
+ def terminate_active_processes() -> None:
116
+ """Kill all tracked process groups before fan-out workers unwind."""
117
+ _TERMINATION_REQUESTED.set()
118
+ with _ACTIVE_PROCESS_LOCK:
119
+ processes = list(_ACTIVE_PROCESSES)
120
+ for proc in processes:
121
+ _kill_process_group(proc)
122
+
123
+ # Reap every direct child against one shared deadline. Waiting a full
124
+ # grace period for each concurrent worker would serialize shutdown.
125
+ deadline = time.monotonic() + KILL_GRACE_SECONDS
126
+ for proc in processes:
127
+ remaining = deadline - time.monotonic()
128
+ if remaining <= 0:
129
+ break
130
+ with contextlib.suppress(subprocess.TimeoutExpired, OSError):
131
+ proc.wait(timeout=remaining)
132
+
133
+
134
+ def termination_requested() -> bool:
135
+ return _TERMINATION_REQUESTED.is_set()
136
+
137
+
138
+ def wait_for_termination(timeout: float) -> bool:
139
+ """Wait up to timeout seconds, returning early when shutdown starts."""
140
+ return _TERMINATION_REQUESTED.wait(timeout)
141
+
142
+
143
+ def run_process(
144
+ args: list[str],
145
+ cwd: Path,
146
+ *,
147
+ capture: bool = True,
148
+ check: bool = True,
149
+ timeout: float | None = None,
150
+ log_path: Path | None = None,
151
+ env: dict[str, str] | None = None,
152
+ timeout_is_error: bool = True,
153
+ output_label: str | None = None,
154
+ display_args: list[str] | None = None,
155
+ ) -> subprocess.CompletedProcess[str]:
156
+ if termination_requested():
157
+ raise StargateError("Orchestrator is terminating.")
158
+ prefix = f"[{output_label}] " if output_label else ""
159
+ # Callers may hide credentials in diagnostics without changing execution.
160
+ command = shlex.join(args if display_args is None else display_args)
161
+ print_output(f"\n{prefix}$ {command}")
162
+ if log_path is not None:
163
+ # Straight to disk, so a silent multi-minute agent can be tailed live
164
+ # instead of surfacing only once the process exits.
165
+ with log_path.open("w") as handle:
166
+ proc: subprocess.Popen[str] | None = None
167
+ try:
168
+ proc = subprocess.Popen(
169
+ args,
170
+ cwd=str(cwd),
171
+ text=True,
172
+ stdout=handle,
173
+ stderr=subprocess.STDOUT,
174
+ stdin=subprocess.DEVNULL,
175
+ env=env,
176
+ start_new_session=True,
177
+ )
178
+ with _ACTIVE_PROCESS_LOCK:
179
+ _ACTIVE_PROCESSES.add(proc)
180
+ if termination_requested():
181
+ _kill_process_group(proc)
182
+ started = time.monotonic()
183
+ deadline = None if timeout is None else started + timeout
184
+ timed_out = False
185
+ while True:
186
+ # Clamped to what is left of the deadline, not a fixed
187
+ # heartbeat: waiting the full interval means a timeout
188
+ # shorter than one heartbeat is not enforced until one has
189
+ # passed, and a process that exits inside that window
190
+ # breaks out before the deadline is ever consulted.
191
+ remaining = (
192
+ HEARTBEAT_SECONDS if deadline is None
193
+ else max(0.0, min(HEARTBEAT_SECONDS, deadline - time.monotonic()))
194
+ )
195
+ try:
196
+ proc.wait(timeout=remaining)
197
+ break
198
+ except subprocess.TimeoutExpired:
199
+ pass
200
+ if deadline is not None and time.monotonic() >= deadline:
201
+ _kill_process_group(proc)
202
+ proc.wait()
203
+ if timeout_is_error:
204
+ raise StargateError(
205
+ f"Command timed out after {timeout}s "
206
+ f"(partial trace in {log_path}): {command}"
207
+ )
208
+ timed_out = True
209
+ break
210
+ # Growing byte count is the "still moving, not hung" signal;
211
+ # the trace itself stays out of the terminal.
212
+ size = log_path.stat().st_size if log_path.exists() else 0
213
+ elapsed = time.monotonic() - started
214
+ print_output(
215
+ f"{prefix} ... {elapsed:.0f}s elapsed, "
216
+ f"{size:,} bytes written"
217
+ )
218
+ except BaseException:
219
+ # A signal reaches the orchestrator, not necessarily the agent.
220
+ # Leaving it alive would let it keep editing during a resume.
221
+ if proc is not None:
222
+ _kill_process_group(proc)
223
+ # The signal handler already reaps all active children
224
+ # against one shared deadline. Keep standalone interrupt
225
+ # cleanup bounded when no orchestrator signal was handled.
226
+ if not termination_requested():
227
+ with contextlib.suppress(
228
+ subprocess.TimeoutExpired, OSError
229
+ ):
230
+ proc.wait(timeout=KILL_GRACE_SECONDS)
231
+ raise
232
+ finally:
233
+ if proc is not None:
234
+ with _ACTIVE_PROCESS_LOCK:
235
+ _ACTIVE_PROCESSES.discard(proc)
236
+ if proc is None: # pragma: no cover - Popen either returns or raises
237
+ raise StargateError(f"Could not start command: {command}")
238
+ output = log_path.read_text() if log_path.exists() else ""
239
+ returncode = 124 if timed_out else proc.returncode
240
+ if check and returncode != 0:
241
+ raise StargateError(
242
+ f"Command failed with exit code {returncode} "
243
+ f"(trace in {log_path}): {command}"
244
+ )
245
+ return subprocess.CompletedProcess(args, returncode, output, None)
246
+ try:
247
+ proc = subprocess.run(
248
+ args,
249
+ cwd=str(cwd),
250
+ text=True,
251
+ stdout=subprocess.PIPE if capture else None,
252
+ stderr=subprocess.STDOUT if capture else None,
253
+ stdin=subprocess.DEVNULL,
254
+ timeout=timeout,
255
+ env=env,
256
+ )
257
+ except subprocess.TimeoutExpired as exc:
258
+ raise StargateError(
259
+ f"Command timed out after {timeout}s: {command}"
260
+ ) from exc
261
+ if capture and proc.stdout:
262
+ print_output(proc.stdout, end="" if proc.stdout.endswith("\n") else "\n")
263
+ if check and proc.returncode != 0:
264
+ raise StargateError(
265
+ f"Command failed with exit code {proc.returncode}: {command}"
266
+ )
267
+ return proc
268
+
269
+
270
+ def git(repo: Path, *args: str, capture: bool = True, check: bool = True):
271
+ return run_process(["git", *args], repo, capture=capture, check=check)
272
+
273
+
274
+ def repo_root(start: Path) -> Path:
275
+ proc = subprocess.run(
276
+ ["git", "rev-parse", "--show-toplevel"],
277
+ cwd=str(start),
278
+ text=True,
279
+ capture_output=True,
280
+ )
281
+ if proc.returncode != 0:
282
+ raise StargateError("Current directory is not inside a Git repository.")
283
+ return Path(proc.stdout.strip()).resolve()
284
+
285
+
286
+ def slugify(text: str, max_len: int = 42) -> str:
287
+ text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
288
+ return (text or "task")[:max_len].rstrip("-")
289
+
290
+
291
+ BRANCH_NAME_WORDS = 5
292
+
293
+
294
+ BRANCH_NAME_CHARS = 32
295
+
296
+
297
+ def short_name(text: str) -> str:
298
+ """Return a short slug made only from whole words."""
299
+ words = [word.lower() for word in re.findall(r"[a-zA-Z0-9]+", text)]
300
+ kept: list[str] = []
301
+ for word in words[:BRANCH_NAME_WORDS]:
302
+ candidate = "-".join([*kept, word])
303
+ if len(candidate) > BRANCH_NAME_CHARS:
304
+ break
305
+ kept.append(word)
306
+ return "-".join(kept)
307
+
308
+
309
+ NAME_PREFIX = "NAME:"
310
+
311
+
312
+ def split_plan_name(plan: str) -> tuple[str, str]:
313
+ """Extract an optional first-line name without damaging older plans.
314
+
315
+ Only a valid name on the first non-empty line is removed. Custom and frozen
316
+ prompts predate this contract, and an ignored or malformed instruction must
317
+ keep flowing verbatim instead of turning a compatible run into a failure.
318
+ """
319
+ lines = plan.splitlines()
320
+ first = next((index for index, line in enumerate(lines) if line.strip()), None)
321
+ if first is None:
322
+ return "", plan
323
+ line = lines[first].strip()
324
+ if not line.startswith(NAME_PREFIX):
325
+ return "", plan
326
+ name = short_name(line[len(NAME_PREFIX):].strip())
327
+ if not name:
328
+ return "", plan
329
+ return name, "\n".join([*lines[:first], *lines[first + 1:]]).strip()
330
+
331
+
332
+ def git_quiet(repo: Path, *args: str) -> str:
333
+ """Read Git state without burying run output under a full printed diff."""
334
+ proc = subprocess.run(
335
+ ["git", *args], cwd=str(repo), text=True, capture_output=True
336
+ )
337
+ if proc.returncode:
338
+ raise StargateError(
339
+ f"git {' '.join(args)} failed in {repo}: {proc.stderr.strip()}"
340
+ )
341
+ return proc.stdout
stargate/detect.py ADDED
@@ -0,0 +1,140 @@
1
+ """Guessing the project's test command from what is checked in, and deciding
2
+ whether a guess may run."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .core import Detected, StargateError
12
+
13
+ DETECTION_MODES = ("report", "auto", "off")
14
+
15
+
16
+ DETECTION_READ_LIMIT = 65536
17
+
18
+
19
+ # A detected command is input the user never wrote. It may run unattended after
20
+ # every fixer, influence the review verdict and set the process exit code; a bad
21
+ # guess can also be a deploy-ish Make target or a watcher that only ends at the
22
+ # timeout. The default therefore fixes the dangerous silence by reporting the
23
+ # guess and its evidence, without executing it. Users who accept that risk can
24
+ # opt into `auto`; `off` records that the repository intentionally has no test.
25
+ def detection_mode(config: dict[str, Any]) -> str:
26
+ settings = config.get("settings", {})
27
+ mode = str(settings.get("test_command_detection", "report") or "report")
28
+ mode = mode.strip().lower()
29
+ if mode not in DETECTION_MODES:
30
+ raise StargateError(
31
+ "settings.test_command_detection must be one of report, auto, off"
32
+ )
33
+ return mode
34
+
35
+
36
+ def read_detection_file(path: Path) -> str | None:
37
+ """Read enough evidence for detection without trusting project file size."""
38
+ try:
39
+ with path.open(encoding="utf-8") as handle:
40
+ return handle.read(DETECTION_READ_LIMIT)
41
+ except (OSError, UnicodeError, ValueError):
42
+ return None
43
+
44
+
45
+ def is_file(path: Path) -> bool:
46
+ with contextlib.suppress(OSError):
47
+ return path.is_file()
48
+ return False
49
+
50
+
51
+ def detect_test_commands(root: Path) -> list[Detected]:
52
+ """Return all likely commands in a deliberate, user-visible priority."""
53
+ detected: list[Detected] = []
54
+
55
+ # A hand-written project entry point commonly wraps the native runner, so
56
+ # it outranks language metadata. All lower-ranked matches are still shown.
57
+ for name in ("Makefile", "makefile", "GNUmakefile"):
58
+ text = read_detection_file(root / name)
59
+ if text is not None and re.search(r"(?m)^test\s*:(?!=)", text):
60
+ detected.append(Detected("make test", f"{name}: test target"))
61
+ break
62
+
63
+ package = read_detection_file(root / "package.json")
64
+ if package is not None:
65
+ with contextlib.suppress(json.JSONDecodeError, TypeError, AttributeError):
66
+ script = json.loads(package).get("scripts", {}).get("test", "")
67
+ script = script.strip() if isinstance(script, str) else ""
68
+ if script and "error: no test specified" not in script.lower():
69
+ runner = (
70
+ "pnpm" if is_file(root / "pnpm-lock.yaml")
71
+ else "yarn" if is_file(root / "yarn.lock")
72
+ else "npm"
73
+ )
74
+ detected.append(Detected(
75
+ f"{runner} test", "package.json: scripts.test"
76
+ ))
77
+
78
+ for filename, command in (
79
+ ("Cargo.toml", "cargo test"),
80
+ ("go.mod", "go test ./..."),
81
+ ("Package.swift", "swift test"),
82
+ ):
83
+ if is_file(root / filename):
84
+ detected.append(Detected(command, filename))
85
+
86
+ pytest_source = ""
87
+ pyproject = read_detection_file(root / "pyproject.toml")
88
+ if pyproject is not None:
89
+ if re.search(r"(?im)^\s*\[tool\.pytest(?:\.|\])", pyproject):
90
+ pytest_source = "pyproject.toml: tool.pytest"
91
+ elif (
92
+ re.search(r"(?im)^\s*pytest(?:[-_.][\w.-]+)?\s*=", pyproject)
93
+ or re.search(
94
+ r'''(?i)["']pytest(?:[-_.][\w.-]+)?(?:[<>=~!][^"']*)?["']''',
95
+ pyproject,
96
+ )
97
+ ):
98
+ pytest_source = "pyproject.toml: pytest dependency"
99
+ if not pytest_source and is_file(root / "pytest.ini"):
100
+ pytest_source = "pytest.ini"
101
+ if not pytest_source:
102
+ for filename in ("setup.cfg", "tox.ini"):
103
+ text = read_detection_file(root / filename)
104
+ if text is not None and re.search(r"(?im)^\s*\[tool:pytest\]", text):
105
+ pytest_source = f"{filename}: tool:pytest"
106
+ break
107
+ if not pytest_source:
108
+ tests = root / "tests"
109
+ with contextlib.suppress(OSError):
110
+ if tests.is_dir() and any(path.is_file() for path in tests.rglob("test_*.py")):
111
+ pytest_source = "tests/: test_*.py"
112
+ # A root-level test_*.py is not enough evidence: this repository's smoke
113
+ # test takes a custom argument and pytest collection would fail. That false
114
+ # positive is exactly why detection reports rather than runs by default.
115
+ if pytest_source:
116
+ detected.append(Detected("pytest -q", pytest_source))
117
+
118
+ return detected
119
+
120
+
121
+ def selected_test_command(
122
+ config: dict[str, Any], root: Path
123
+ ) -> tuple[str, list[Detected]]:
124
+ """The command stargate would run, plus every detected candidate.
125
+
126
+ Report-only detection intentionally grants nothing: its candidate is input
127
+ the user has not approved, so letting an agent execute it would defeat the
128
+ mode even if the orchestrator itself abstained.
129
+ """
130
+ settings = config.get("settings", {})
131
+ configured = str(settings.get("test_command", "") or "").strip()
132
+ if configured:
133
+ return configured, []
134
+ mode = detection_mode(config)
135
+ if mode == "off":
136
+ return "", []
137
+ detected = detect_test_commands(root)
138
+ if mode == "auto" and detected:
139
+ return detected[0].command, detected
140
+ return "", detected