Skip to content

Commit 476429b

Browse files
tbitcsoz-agent
andcommitted
feat: process abort/PID tracking, language templates, RTD/release workflows
Process execution and abort: - New executor.py: cross-platform PID tracking (.specsmith/pids/), timeout enforcement, graceful SIGTERM+SIGKILL (POSIX) / taskkill (Windows) - CLI commands: specsmith exec, specsmith ps, specsmith abort - Updated exec.cmd.j2 and exec.sh.j2 shims with PID file tracking Language-specific templates (#41): - Rust: Cargo.toml, src/main.rs (CLI) - Go: go.mod, cmd/main.go - JS/TS: package.json (web-frontend, fullstack-js variants) ReadTheDocs integration (#38): - .readthedocs.yaml and mkdocs.yml templates for Python/doc projects Release workflow templates (#44): - .github/workflows/release.yml template with test gate, language-aware build, GitHub Release, PyPI publish (Python) Closes #38, #41, #44 Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 0184f40 commit 476429b

13 files changed

Lines changed: 710 additions & 27 deletions

File tree

src/specsmith/cli.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,5 +1544,92 @@ def serve(port: int) -> None:
15441544
)
15451545

15461546

1547+
# ---------------------------------------------------------------------------
1548+
# Process execution and abort
1549+
# ---------------------------------------------------------------------------
1550+
1551+
1552+
@main.command(name="exec")
1553+
@click.argument("command")
1554+
@click.option("--timeout", default=120, help="Timeout in seconds (default: 120).")
1555+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
1556+
def exec_cmd(command: str, timeout: int, project_dir: str) -> None:
1557+
"""Execute a command with PID tracking and timeout enforcement.
1558+
1559+
Tracks the process in .specsmith/pids/ so it can be listed (specsmith ps)
1560+
or aborted (specsmith abort). Logs stdout/stderr to .specsmith/logs/.
1561+
Works cross-platform: Windows, Linux, macOS.
1562+
"""
1563+
from specsmith.executor import run_tracked
1564+
1565+
root = Path(project_dir).resolve()
1566+
console.print(f"[bold]exec[/bold] {command} (timeout={timeout}s)")
1567+
1568+
result = run_tracked(root, command, timeout=timeout)
1569+
1570+
if result.timed_out:
1571+
console.print(f"[red]TIMEOUT[/red] after {timeout}s (PID {result.pid})")
1572+
elif result.exit_code == 0:
1573+
console.print(f"[green]OK[/green] ({result.duration:.1f}s) — exit code 0")
1574+
else:
1575+
console.print(f"[red]FAILED[/red] ({result.duration:.1f}s) — exit code {result.exit_code}")
1576+
if result.stdout_file:
1577+
console.print(f" stdout: {result.stdout_file}")
1578+
if result.stderr_file:
1579+
console.print(f" stderr: {result.stderr_file}")
1580+
raise SystemExit(result.exit_code)
1581+
1582+
1583+
@main.command(name="ps")
1584+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
1585+
def ps_cmd(project_dir: str) -> None:
1586+
"""List tracked running processes."""
1587+
from specsmith.executor import list_processes
1588+
1589+
root = Path(project_dir).resolve()
1590+
procs = list_processes(root)
1591+
if not procs:
1592+
console.print("No tracked processes running.")
1593+
return
1594+
for p in procs:
1595+
elapsed = p.elapsed
1596+
remaining = max(0, p.timeout - elapsed)
1597+
status = "[red]EXPIRED[/red]" if p.is_expired else f"{remaining:.0f}s left"
1598+
console.print(f" PID {p.pid} {status} {p.command}")
1599+
console.print(f"\n {len(procs)} process(es)")
1600+
1601+
1602+
@main.command(name="abort")
1603+
@click.option("--pid", type=int, default=None, help="Abort a specific PID.")
1604+
@click.option("--all", "abort_all_flag", is_flag=True, default=False, help="Abort all tracked.")
1605+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
1606+
def abort_cmd(pid: int | None, abort_all_flag: bool, project_dir: str) -> None:
1607+
"""Abort tracked process(es). Sends SIGTERM then SIGKILL (POSIX) or taskkill (Windows)."""
1608+
from specsmith.executor import abort_all, abort_process, list_processes
1609+
1610+
root = Path(project_dir).resolve()
1611+
1612+
if abort_all_flag:
1613+
killed = abort_all(root)
1614+
if killed:
1615+
console.print(f"[green]Aborted {len(killed)} process(es): {killed}[/green]")
1616+
else:
1617+
console.print("No tracked processes to abort.")
1618+
elif pid:
1619+
if abort_process(root, pid):
1620+
console.print(f"[green]Aborted PID {pid}[/green]")
1621+
else:
1622+
console.print(f"[red]Could not abort PID {pid}[/red]")
1623+
else:
1624+
procs = list_processes(root)
1625+
if not procs:
1626+
console.print("No tracked processes. Use --pid or --all.")
1627+
return
1628+
console.print("Tracked processes:")
1629+
for p in procs:
1630+
console.print(f" PID {p.pid} {p.command}")
1631+
console.print("\nUse --pid <N> or --all to abort.")
1632+
1633+
15471634
if __name__ == "__main__":
15481635
main()

src/specsmith/executor.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3+
"""Executor — cross-platform process execution with PID tracking and abort.
4+
5+
Provides governed command execution with:
6+
- PID file tracking in .specsmith/pids/
7+
- Configurable timeout enforcement
8+
- Cross-platform abort (Windows taskkill / POSIX SIGTERM+SIGKILL)
9+
- Process listing for agent visibility
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import os
16+
import signal
17+
import subprocess
18+
import sys
19+
import time
20+
from dataclasses import asdict, dataclass
21+
from datetime import datetime, timezone
22+
from pathlib import Path
23+
24+
25+
@dataclass
26+
class TrackedProcess:
27+
"""Metadata for a tracked process."""
28+
29+
pid: int
30+
command: str
31+
started: str # ISO timestamp
32+
timeout: int # seconds
33+
pid_file: str = ""
34+
35+
@property
36+
def started_dt(self) -> datetime:
37+
return datetime.fromisoformat(self.started)
38+
39+
@property
40+
def elapsed(self) -> float:
41+
return (datetime.now(tz=timezone.utc) - self.started_dt).total_seconds()
42+
43+
@property
44+
def is_expired(self) -> bool:
45+
return self.elapsed > self.timeout
46+
47+
48+
@dataclass
49+
class ExecResult:
50+
"""Result of a tracked execution."""
51+
52+
command: str
53+
exit_code: int
54+
pid: int
55+
duration: float
56+
timed_out: bool = False
57+
aborted: bool = False
58+
stdout_file: str = ""
59+
stderr_file: str = ""
60+
61+
62+
def _pids_dir(root: Path) -> Path:
63+
d = root / ".specsmith" / "pids"
64+
d.mkdir(parents=True, exist_ok=True)
65+
return d
66+
67+
68+
def _logs_dir(root: Path) -> Path:
69+
d = root / ".specsmith" / "logs"
70+
d.mkdir(parents=True, exist_ok=True)
71+
return d
72+
73+
74+
def _write_pid_file(root: Path, proc: TrackedProcess) -> Path:
75+
"""Write PID tracking file. Returns path to PID file."""
76+
pid_file = _pids_dir(root) / f"{proc.pid}.json"
77+
proc.pid_file = str(pid_file)
78+
pid_file.write_text(json.dumps(asdict(proc), indent=2), encoding="utf-8")
79+
return pid_file
80+
81+
82+
def _remove_pid_file(root: Path, pid: int) -> None:
83+
"""Remove PID tracking file."""
84+
pid_file = _pids_dir(root) / f"{pid}.json"
85+
if pid_file.exists():
86+
pid_file.unlink()
87+
88+
89+
def _is_process_alive(pid: int) -> bool:
90+
"""Check if a process is still running (cross-platform)."""
91+
try:
92+
if sys.platform == "win32":
93+
# Windows: use tasklist to check
94+
result = subprocess.run(
95+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
96+
capture_output=True,
97+
text=True,
98+
timeout=5,
99+
)
100+
return str(pid) in result.stdout
101+
else:
102+
# POSIX: signal 0 checks existence without killing
103+
os.kill(pid, 0)
104+
return True
105+
except (OSError, subprocess.TimeoutExpired):
106+
return False
107+
108+
109+
def _kill_process(pid: int, *, graceful_timeout: float = 5.0) -> bool:
110+
"""Kill a process cross-platform. Returns True if killed.
111+
112+
Strategy:
113+
- POSIX: SIGTERM → wait → SIGKILL
114+
- Windows: taskkill → taskkill /F
115+
"""
116+
if not _is_process_alive(pid):
117+
return True # Already dead
118+
119+
try:
120+
if sys.platform == "win32":
121+
# Graceful first
122+
subprocess.run(
123+
["taskkill", "/PID", str(pid)],
124+
capture_output=True,
125+
timeout=graceful_timeout,
126+
)
127+
time.sleep(min(graceful_timeout, 2.0))
128+
if not _is_process_alive(pid):
129+
return True
130+
# Force kill
131+
subprocess.run(
132+
["taskkill", "/F", "/PID", str(pid), "/T"],
133+
capture_output=True,
134+
timeout=5,
135+
)
136+
else:
137+
# SIGTERM first
138+
os.kill(pid, signal.SIGTERM)
139+
deadline = time.monotonic() + graceful_timeout
140+
while time.monotonic() < deadline:
141+
if not _is_process_alive(pid):
142+
return True
143+
time.sleep(0.2)
144+
# SIGKILL
145+
os.kill(pid, signal.SIGKILL)
146+
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
147+
pass
148+
149+
time.sleep(0.5)
150+
return not _is_process_alive(pid)
151+
152+
153+
def run_tracked(
154+
root: Path,
155+
command: str,
156+
*,
157+
timeout: int = 120,
158+
capture: bool = True,
159+
) -> ExecResult:
160+
"""Execute a command with PID tracking and timeout enforcement.
161+
162+
- Writes PID file to .specsmith/pids/<pid>.json
163+
- Enforces timeout via subprocess.Popen + polling
164+
- Logs stdout/stderr to .specsmith/logs/
165+
- Cleans up PID file on completion
166+
- Cross-platform: works on Windows, Linux, macOS
167+
"""
168+
started = datetime.now(tz=timezone.utc).isoformat()
169+
ts = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
170+
171+
stdout_path = _logs_dir(root) / f"exec_{ts}.stdout"
172+
stderr_path = _logs_dir(root) / f"exec_{ts}.stderr"
173+
174+
# Determine shell
175+
if sys.platform == "win32":
176+
shell_args: list[str] = ["cmd", "/c", command]
177+
else:
178+
shell_args = ["bash", "-c", command]
179+
180+
stdout_fh = open(stdout_path, "w", encoding="utf-8") if capture else None # noqa: SIM115
181+
stderr_fh = open(stderr_path, "w", encoding="utf-8") if capture else None # noqa: SIM115
182+
183+
try:
184+
proc = subprocess.Popen( # noqa: S603
185+
shell_args,
186+
stdout=stdout_fh or subprocess.PIPE,
187+
stderr=stderr_fh or subprocess.PIPE,
188+
cwd=str(root),
189+
)
190+
191+
tracked = TrackedProcess(
192+
pid=proc.pid,
193+
command=command,
194+
started=started,
195+
timeout=timeout,
196+
)
197+
pid_file = _write_pid_file(root, tracked)
198+
199+
start = time.monotonic()
200+
timed_out = False
201+
202+
try:
203+
proc.wait(timeout=timeout)
204+
except subprocess.TimeoutExpired:
205+
timed_out = True
206+
_kill_process(proc.pid)
207+
proc.wait(timeout=5) # Reap zombie
208+
209+
duration = time.monotonic() - start
210+
exit_code = proc.returncode if proc.returncode is not None else -1
211+
212+
# Clean up PID file
213+
if pid_file.exists():
214+
pid_file.unlink()
215+
216+
return ExecResult(
217+
command=command,
218+
exit_code=124 if timed_out else exit_code,
219+
pid=proc.pid,
220+
duration=duration,
221+
timed_out=timed_out,
222+
stdout_file=str(stdout_path) if capture else "",
223+
stderr_file=str(stderr_path) if capture else "",
224+
)
225+
226+
finally:
227+
if stdout_fh:
228+
stdout_fh.close()
229+
if stderr_fh:
230+
stderr_fh.close()
231+
232+
233+
def list_processes(root: Path) -> list[TrackedProcess]:
234+
"""List all tracked processes. Prunes stale PID files for dead processes."""
235+
pids_dir = _pids_dir(root)
236+
result: list[TrackedProcess] = []
237+
238+
for pid_file in pids_dir.glob("*.json"):
239+
try:
240+
data = json.loads(pid_file.read_text(encoding="utf-8"))
241+
tp = TrackedProcess(**data)
242+
if _is_process_alive(tp.pid):
243+
result.append(tp)
244+
else:
245+
# Stale PID file — process already exited
246+
pid_file.unlink()
247+
except (json.JSONDecodeError, TypeError, OSError):
248+
pid_file.unlink(missing_ok=True)
249+
250+
return result
251+
252+
253+
def abort_process(root: Path, pid: int) -> bool:
254+
"""Abort a specific tracked process by PID. Returns True if killed."""
255+
killed = _kill_process(pid)
256+
_remove_pid_file(root, pid)
257+
return killed
258+
259+
260+
def abort_all(root: Path) -> list[int]:
261+
"""Abort all tracked processes. Returns list of killed PIDs."""
262+
killed: list[int] = []
263+
for tp in list_processes(root):
264+
if _kill_process(tp.pid):
265+
killed.append(tp.pid)
266+
_remove_pid_file(root, tp.pid)
267+
return killed

0 commit comments

Comments
 (0)