stallguard 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stallguard contributors
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.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: stallguard
3
+ Version: 0.1.0
4
+ Summary: Drop-in wrapper that streams agent steps and kills/flags stalls, loops, and fake-GOAL finishes.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/gpt4omni/stallguard
7
+ Keywords: ai-agents,llm,coding-assistant,devtools,cli
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # stallguard
14
+
15
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
16
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](pyproject.toml)
17
+ [![No dependencies](https://img.shields.io/badge/deps-zero-brightgreen.svg)](stallguard/)
18
+ [![CI](https://github.com/gpt4omni/stallguard/actions/workflows/test.yml/badge.svg)](https://github.com/gpt4omni/stallguard/actions/workflows/test.yml)
19
+
20
+ **Your coding agent says "done". Did it do anything?** stallguard is a drop-in
21
+ wrapper that streams agent steps, kills stalls and retry-loops, and flags
22
+ fake-GOAL finishes — with a shareable trace for every run.
23
+
24
+ ![stallguard demo](demo.gif)
25
+
26
+ ```bash
27
+ pipx install stallguard
28
+ stallguard run -- npx claude-code "fix the login bug"
29
+ # [stallguard] status=ok exit=0 duration=42.1s trace=traces/stallguard-....html
30
+ ```
31
+
32
+ No code changes. Just prefix your agent command with `stallguard run --`.
33
+
34
+ ## The problem
35
+
36
+ AI coding agents fail in three boring, expensive ways:
37
+
38
+ 1. **Stalls** — no output for 20 minutes while your CI minute-burn ticks.
39
+ 2. **Loops** — the same tool call retried 500 times (`retrying with same args...`).
40
+ 3. **Fake-GOALs** — prints `DONE ✅` while `git diff` is empty.
41
+
42
+ Logs scroll by, nobody watches, green checkmarks lie. stallguard turns each run
43
+ into a verdict: `ok`, `error`, `stalled`, `looped`, or `fake-goal` — enforced
44
+ by exit code, so CI can act on it.
45
+
46
+ ## 30-second quickstart
47
+
48
+ ```bash
49
+ # any agent, any command — just prefix it
50
+ stallguard run -- python -u your_agent.py
51
+ stallguard run --stall-timeout 90 --require-diff -- ./run.sh
52
+
53
+ # inspect a run later
54
+ stallguard view traces/stallguard-*.jsonl
55
+ # open traces/stallguard-*.html in a browser for the full step-by-step trace
56
+ ```
57
+
58
+ ## What it catches
59
+
60
+ | flag | default | catches | exit |
61
+ |---|---|---|---|
62
+ | `--stall-timeout N` | 120 | no output for N seconds → `SIGKILL` | 124 |
63
+ | `--repeat-limit K` | 15 | K identical lines in a row → `SIGKILL` | 125 |
64
+ | `--require-diff` | off | exit 0 + "done" text but empty `git diff` | 3 |
65
+ | `--expect-file PATH` | — | required file missing after run (repeatable) | 3 |
66
+ | `--trace-dir DIR` | `traces` | JSONL + summary JSON + self-contained HTML per run | — |
67
+
68
+ `0` = ok, `1` = agent errored, `3` = fake-goal, `124` = stalled, `125` = looped.
69
+
70
+ ## Why not just `timeout`?
71
+
72
+ `timeout 300 ./agent` kills a hung run but tells you nothing: was it a stall, a
73
+ loop, or nearly done? And it can't catch the worst case — the agent that
74
+ finishes fast, prints success, and changed zero files. stallguard records
75
+ *timestamped evidence* for every run and verifies the goal actually happened.
76
+
77
+ ## CI: fail on fake success
78
+
79
+ ```yaml
80
+ - name: Run agent under stallguard
81
+ run: stallguard run --stall-timeout 300 --require-diff -- ./run-agent.sh
82
+ - name: Upload traces
83
+ uses: actions/upload-artifact@v4
84
+ with:
85
+ name: stallguard-traces
86
+ path: traces/
87
+ ```
88
+
89
+ Or use the bundled composite action (see [action.yml](action.yml)).
90
+ Every failed run links its HTML trace — debugging starts from evidence, not
91
+ from re-running and hoping.
92
+
93
+ ## Works with any agent
94
+
95
+ `stallguard` sees stdout, not APIs, so it works with everything:
96
+ Claude Code, Codex CLI, OpenHands, AutoGPT, your own Python script — if it
97
+ prints steps, stallguard can supervise it. Zero dependencies, stdlib only.
98
+
99
+ ## Dev
100
+
101
+ ```bash
102
+ python3 -m venv .venv && source .venv/bin/activate
103
+ pip install -e .
104
+ python -m stallguard run -- python -u examples/ok.py
105
+ python -m stallguard run --stall-timeout 1 -- python -u examples/hang.py; echo $?
106
+ python -m stallguard run --repeat-limit 3 -- python -u examples/loop.py; echo $?
107
+ python -m unittest discover -s tests
108
+ ```
109
+
110
+ ## Roadmap
111
+
112
+ - [ ] Token/step budgets (`--max-lines`, `--max-minutes`)
113
+ - [ ] Semantic loop detection (same tool call, different formatting)
114
+ - [ ] Live web dashboard for parallel agent fleets
115
+ - [ ] PyPI release + Homebrew formula
116
+
117
+ PRs welcome — especially real traces of agents failing in new and creative ways.
118
+
119
+ MIT. If stallguard caught your agent slacking, leave a ⭐.
@@ -0,0 +1,107 @@
1
+ # stallguard
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](pyproject.toml)
5
+ [![No dependencies](https://img.shields.io/badge/deps-zero-brightgreen.svg)](stallguard/)
6
+ [![CI](https://github.com/gpt4omni/stallguard/actions/workflows/test.yml/badge.svg)](https://github.com/gpt4omni/stallguard/actions/workflows/test.yml)
7
+
8
+ **Your coding agent says "done". Did it do anything?** stallguard is a drop-in
9
+ wrapper that streams agent steps, kills stalls and retry-loops, and flags
10
+ fake-GOAL finishes — with a shareable trace for every run.
11
+
12
+ ![stallguard demo](demo.gif)
13
+
14
+ ```bash
15
+ pipx install stallguard
16
+ stallguard run -- npx claude-code "fix the login bug"
17
+ # [stallguard] status=ok exit=0 duration=42.1s trace=traces/stallguard-....html
18
+ ```
19
+
20
+ No code changes. Just prefix your agent command with `stallguard run --`.
21
+
22
+ ## The problem
23
+
24
+ AI coding agents fail in three boring, expensive ways:
25
+
26
+ 1. **Stalls** — no output for 20 minutes while your CI minute-burn ticks.
27
+ 2. **Loops** — the same tool call retried 500 times (`retrying with same args...`).
28
+ 3. **Fake-GOALs** — prints `DONE ✅` while `git diff` is empty.
29
+
30
+ Logs scroll by, nobody watches, green checkmarks lie. stallguard turns each run
31
+ into a verdict: `ok`, `error`, `stalled`, `looped`, or `fake-goal` — enforced
32
+ by exit code, so CI can act on it.
33
+
34
+ ## 30-second quickstart
35
+
36
+ ```bash
37
+ # any agent, any command — just prefix it
38
+ stallguard run -- python -u your_agent.py
39
+ stallguard run --stall-timeout 90 --require-diff -- ./run.sh
40
+
41
+ # inspect a run later
42
+ stallguard view traces/stallguard-*.jsonl
43
+ # open traces/stallguard-*.html in a browser for the full step-by-step trace
44
+ ```
45
+
46
+ ## What it catches
47
+
48
+ | flag | default | catches | exit |
49
+ |---|---|---|---|
50
+ | `--stall-timeout N` | 120 | no output for N seconds → `SIGKILL` | 124 |
51
+ | `--repeat-limit K` | 15 | K identical lines in a row → `SIGKILL` | 125 |
52
+ | `--require-diff` | off | exit 0 + "done" text but empty `git diff` | 3 |
53
+ | `--expect-file PATH` | — | required file missing after run (repeatable) | 3 |
54
+ | `--trace-dir DIR` | `traces` | JSONL + summary JSON + self-contained HTML per run | — |
55
+
56
+ `0` = ok, `1` = agent errored, `3` = fake-goal, `124` = stalled, `125` = looped.
57
+
58
+ ## Why not just `timeout`?
59
+
60
+ `timeout 300 ./agent` kills a hung run but tells you nothing: was it a stall, a
61
+ loop, or nearly done? And it can't catch the worst case — the agent that
62
+ finishes fast, prints success, and changed zero files. stallguard records
63
+ *timestamped evidence* for every run and verifies the goal actually happened.
64
+
65
+ ## CI: fail on fake success
66
+
67
+ ```yaml
68
+ - name: Run agent under stallguard
69
+ run: stallguard run --stall-timeout 300 --require-diff -- ./run-agent.sh
70
+ - name: Upload traces
71
+ uses: actions/upload-artifact@v4
72
+ with:
73
+ name: stallguard-traces
74
+ path: traces/
75
+ ```
76
+
77
+ Or use the bundled composite action (see [action.yml](action.yml)).
78
+ Every failed run links its HTML trace — debugging starts from evidence, not
79
+ from re-running and hoping.
80
+
81
+ ## Works with any agent
82
+
83
+ `stallguard` sees stdout, not APIs, so it works with everything:
84
+ Claude Code, Codex CLI, OpenHands, AutoGPT, your own Python script — if it
85
+ prints steps, stallguard can supervise it. Zero dependencies, stdlib only.
86
+
87
+ ## Dev
88
+
89
+ ```bash
90
+ python3 -m venv .venv && source .venv/bin/activate
91
+ pip install -e .
92
+ python -m stallguard run -- python -u examples/ok.py
93
+ python -m stallguard run --stall-timeout 1 -- python -u examples/hang.py; echo $?
94
+ python -m stallguard run --repeat-limit 3 -- python -u examples/loop.py; echo $?
95
+ python -m unittest discover -s tests
96
+ ```
97
+
98
+ ## Roadmap
99
+
100
+ - [ ] Token/step budgets (`--max-lines`, `--max-minutes`)
101
+ - [ ] Semantic loop detection (same tool call, different formatting)
102
+ - [ ] Live web dashboard for parallel agent fleets
103
+ - [ ] PyPI release + Homebrew formula
104
+
105
+ PRs welcome — especially real traces of agents failing in new and creative ways.
106
+
107
+ MIT. If stallguard caught your agent slacking, leave a ⭐.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stallguard"
7
+ version = "0.1.0"
8
+ description = "Drop-in wrapper that streams agent steps and kills/flags stalls, loops, and fake-GOAL finishes."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["ai-agents", "llm", "coding-assistant", "devtools", "cli"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/gpt4omni/stallguard"
16
+
17
+ [project.scripts]
18
+ stallguard = "stallguard.cli:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+ include = ["stallguard*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """stallguard: trace agent runs, kill stalls/loops, verify real progress."""
2
+ from .tracer import run_command, RunConfig, RunResult
3
+
4
+ __all__ = ["run_command", "RunConfig", "RunResult"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from stallguard.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,75 @@
1
+ """CLI: stallguard run -- <cmd> | stallguard view <trace.jsonl>"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from .tracer import RunConfig, run_command
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ p = argparse.ArgumentParser(prog="stallguard", description="Trace agent runs; kill stalls/loops; verify real progress.")
13
+ sub = p.add_subparsers(dest="sub")
14
+
15
+ r = sub.add_parser("run", help="run a command under supervision")
16
+ r.add_argument("--stall-timeout", type=float, default=120, help="kill if no output for N seconds (default 120)")
17
+ r.add_argument("--repeat-limit", type=int, default=15, help="kill after K identical lines in a row (default 15)")
18
+ r.add_argument("--require-diff", action="store_true", help="exit!=0-style flag when git diff is empty on success")
19
+ r.add_argument("--expect-file", action="append", default=[], help="file that must exist after run (repeatable)")
20
+ r.add_argument("--trace-dir", default="traces", help="where to write traces (default traces/)")
21
+ r.add_argument("--label", default="", help="label stored in summary")
22
+ r.add_argument("cmd", nargs=argparse.REMAINDER, help="command after --")
23
+ # allow `stallguard -- cmd` shorthand: handled in main()
24
+ v = sub.add_parser("view", help="print summary for a trace")
25
+ v.add_argument("trace", help="path to .jsonl or .summary.json")
26
+ return p
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ args_in = sys.argv[1:] if argv is None else argv
31
+ # shorthand: stallguard -- <cmd> / stallguard --stall-timeout 5 -- <cmd>
32
+ if args_in[:1] == ["--"] or (args_in and "run" not in args_in and "view" not in args_in and "-h" not in args_in and "--help" not in args_in):
33
+ args_in = ["run"] + args_in
34
+ p = build_parser()
35
+ args = p.parse_args(args_in)
36
+ if args.sub == "view":
37
+ path = args.trace
38
+ if path.endswith(".summary.json"):
39
+ print(open(path, encoding="utf-8").read())
40
+ else:
41
+ summ = path.replace(".jsonl", ".summary.json")
42
+ try:
43
+ print(open(summ, encoding="utf-8").read())
44
+ except FileNotFoundError:
45
+ print(json.dumps({"error": f"no summary at {summ}"}, indent=2))
46
+ return 1
47
+ return 0
48
+ if args.sub != "run":
49
+ p.print_help()
50
+ return 2
51
+ cmd = args.cmd
52
+ if cmd[:1] == ["--"]:
53
+ cmd = cmd[1:]
54
+ if not cmd:
55
+ print("usage: stallguard run [--stall-timeout N] [--require-diff] -- <command...>", file=sys.stderr)
56
+ return 2
57
+ cfg = RunConfig(
58
+ cmd=cmd,
59
+ stall_timeout=args.stall_timeout,
60
+ repeat_limit=args.repeat_limit,
61
+ require_diff=args.require_diff,
62
+ expect_files=args.expect_file,
63
+ trace_dir=args.trace_dir,
64
+ label=args.label,
65
+ )
66
+ res = run_command(cfg)
67
+ exit_disp = "killed" if res.exit_code is not None and res.exit_code < 0 else res.exit_code
68
+ print(f"\n[stallguard] status={res.status} exit={exit_disp} "
69
+ f"duration={res.duration_s:.1f}s trace={res.trace_jsonl}", flush=True)
70
+ code = 0 if res.status == "ok" else {"error": 1, "fake-goal": 3, "stalled": 124, "looped": 125}.get(res.status, 1)
71
+ return code
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ """Stall/loop detectors (stdlib only)."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class LoopDetector:
6
+ """Flags K consecutive identical lines (agent stuck in a retry loop)."""
7
+
8
+ def __init__(self, repeat_limit: int = 15):
9
+ self.repeat_limit = max(2, repeat_limit)
10
+ self._last: str | None = None
11
+ self._count = 0
12
+
13
+ def feed(self, line: str) -> str | None:
14
+ norm = " ".join(line.strip().split())
15
+ if not norm:
16
+ return None
17
+ if norm == self._last:
18
+ self._count += 1
19
+ else:
20
+ self._last = norm
21
+ self._count = 1
22
+ if self._count >= self.repeat_limit:
23
+ return f"line repeated {self._count}x: {norm[:160]}"
24
+ return None
@@ -0,0 +1,53 @@
1
+ """GOAL verification: did the agent actually change anything?"""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+
6
+
7
+ def git_changed_files(cwd: str) -> list[str]:
8
+ """All working-tree changes: staged, unstaged, AND untracked new files."""
9
+ try:
10
+ out = subprocess.run(
11
+ ["git", "status", "--porcelain"],
12
+ cwd=cwd,
13
+ capture_output=True,
14
+ text=True,
15
+ timeout=10,
16
+ )
17
+ if out.returncode != 0:
18
+ return []
19
+ files = []
20
+ for line in out.stdout.splitlines():
21
+ rest = line[3:] if len(line) > 3 else ""
22
+ if " -> " in rest: # renames: "orig -> new"
23
+ rest = rest.split(" -> ")[-1]
24
+ rest = rest.strip().strip('"')
25
+ if rest:
26
+ files.append(rest)
27
+ return files
28
+ except Exception:
29
+ return []
30
+
31
+
32
+ SUCCESS_TOKENS = ("goal", "done", "complete", "success", "finished", "all tests pass")
33
+
34
+
35
+ def check_goal(
36
+ exit_code: int,
37
+ output_text: str,
38
+ changed_files: list[str],
39
+ require_diff: bool,
40
+ expect_files: list[str],
41
+ missing: list[str],
42
+ ) -> tuple[bool, str]:
43
+ lowered = output_text.lower()
44
+ claims_success = any(tok in lowered for tok in SUCCESS_TOKENS)
45
+ if missing:
46
+ return False, f"missing expected files: {missing}"
47
+ if expect_files and not missing:
48
+ return True, "expected files present"
49
+ if require_diff and not changed_files and exit_code == 0:
50
+ if claims_success:
51
+ return False, "claimed success but git diff is empty"
52
+ return False, "require-diff: no changed files"
53
+ return True, "ok"
@@ -0,0 +1,36 @@
1
+ """Render a self-contained HTML trace report (no JS deps)."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+
6
+
7
+ def write_html(path: str, summary: dict, events: list[dict]) -> None:
8
+ color = {
9
+ "ok": "#16a34a",
10
+ "error": "#dc2626",
11
+ "stalled": "#d97706",
12
+ "looped": "#d97706",
13
+ "fake-goal": "#9333ea",
14
+ }.get(summary.get("status", ""), "#555")
15
+ rows = []
16
+ for e in events[-2000:]:
17
+ rows.append(
18
+ f'<div class="ln"><span class="t">{e["t"]:>8.2f}s</span>'
19
+ f"<span>{html.escape(e['line']) or '&nbsp;'}</span></div>"
20
+ )
21
+ files = "".join(f"<li><code>{html.escape(f)}</code></li>" for f in summary.get("changed_files", [])) or "<li><i>none</i></li>"
22
+ page = f"""<!doctype html><html><head><meta charset="utf-8">
23
+ <title>stallguard — {html.escape(str(summary.get('status')))}</title>
24
+ <style>body{{font-family:ui-monospace,monospace;max-width:900px;margin:24px auto;padding:0 16px}}
25
+ .badge{{display:inline-block;padding:2px 10px;border-radius:999px;color:#fff;background:{color}}}
26
+ .ln{{display:flex;gap:12px;border-bottom:1px solid #eee;padding:2px 0;white-space:pre-wrap}}.t{{color:#888;min-width:70px}}</style>
27
+ </head><body>
28
+ <h1>stallguard trace</h1>
29
+ <p><span class="badge">{html.escape(str(summary.get('status')))}</span>
30
+ exit={html.escape(str(summary.get('exit_code')))} duration={html.escape(str(summary.get('duration_s')))}s</p>
31
+ <p><code>{html.escape(' '.join(summary.get('cmd', [])))}</code></p>
32
+ <h3>Changed files</h3><ul>{files}</ul>
33
+ <h3>Log (last {len(rows)} lines)</h3>{''.join(rows)}
34
+ </body></html>"""
35
+ with open(path, "w", encoding="utf-8") as f:
36
+ f.write(page)
@@ -0,0 +1,199 @@
1
+ """Run a subprocess, stream output, detect stalls/loops, write trace files."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass, field
11
+
12
+ from .detectors import LoopDetector
13
+ from .goalcheck import git_changed_files, check_goal
14
+ from .render import write_html
15
+
16
+
17
+ @dataclass
18
+ class RunConfig:
19
+ cmd: list[str]
20
+ cwd: str = "."
21
+ stall_timeout: float = 120.0
22
+ repeat_limit: int = 15
23
+ require_diff: bool = False
24
+ expect_files: list[str] = field(default_factory=list)
25
+ trace_dir: str = "traces"
26
+ label: str = ""
27
+
28
+
29
+ @dataclass
30
+ class RunResult:
31
+ status: str # ok | error | stalled | looped | fake-goal
32
+ exit_code: int | None
33
+ duration_s: float
34
+ trace_jsonl: str
35
+ summary_json: str
36
+ report_html: str
37
+ changed_files: list[str] = field(default_factory=list)
38
+
39
+
40
+ def _reader_thread(pipe, q: queue.Queue):
41
+ try:
42
+ for line in iter(pipe.readline, ""):
43
+ q.put(line)
44
+ finally:
45
+ try:
46
+ pipe.close()
47
+ except Exception:
48
+ pass
49
+ q.put(None)
50
+
51
+
52
+ def run_command(cfg: RunConfig) -> RunResult:
53
+ os.makedirs(cfg.trace_dir, exist_ok=True)
54
+ stamp = time.strftime("%Y%m%d-%H%M%S")
55
+ base = os.path.join(cfg.trace_dir, f"stallguard-{stamp}")
56
+ trace_jsonl = base + ".jsonl"
57
+ summary_json = base + ".summary.json"
58
+ report_html = base + ".html"
59
+
60
+ before = git_changed_files(cfg.cwd)
61
+ start = time.monotonic()
62
+ wall_start = time.time()
63
+
64
+ proc = subprocess.Popen(
65
+ cfg.cmd,
66
+ cwd=cfg.cwd,
67
+ stdout=subprocess.PIPE,
68
+ stderr=subprocess.STDOUT,
69
+ text=True,
70
+ bufsize=1,
71
+ )
72
+ assert proc.stdout is not None
73
+ q: queue.Queue = queue.Queue()
74
+ t = threading.Thread(target=_reader_thread, args=(proc.stdout, q), daemon=True)
75
+ t.start()
76
+
77
+ loop = LoopDetector(repeat_limit=cfg.repeat_limit)
78
+ events = []
79
+ last_output = time.monotonic()
80
+ status = "ok"
81
+ exit_code: int | None = None
82
+ loop_hit: str | None = None
83
+
84
+ with open(trace_jsonl, "w", encoding="utf-8") as f:
85
+ alive = True
86
+ while alive:
87
+ try:
88
+ line = q.get(timeout=0.2)
89
+ except queue.Empty:
90
+ line = None
91
+ now = time.monotonic()
92
+ if line is None:
93
+ if proc.poll() is not None and q.empty():
94
+ alive = False
95
+ elif now - last_output > cfg.stall_timeout:
96
+ status = "stalled"
97
+ proc.kill()
98
+ try:
99
+ proc.wait(timeout=5)
100
+ except Exception:
101
+ pass
102
+ alive = False
103
+ continue
104
+ if line is None: # sentinel
105
+ if proc.poll() is not None:
106
+ # drain check done on next loop
107
+ continue
108
+ continue
109
+ # real output line
110
+ last_output = now
111
+ ts = time.time() - wall_start
112
+ ev = {"t": round(ts, 3), "stream": "stdout", "line": line.rstrip("\n")}
113
+ events.append(ev)
114
+ f.write(json.dumps(ev) + "\n")
115
+ f.flush()
116
+ print(line, end="", flush=True)
117
+ hit = loop.feed(ev["line"])
118
+ if hit is not None:
119
+ loop_hit = hit
120
+ status = "looped"
121
+ proc.kill()
122
+ try:
123
+ proc.wait(timeout=5)
124
+ except Exception:
125
+ pass
126
+ alive = False
127
+
128
+ exit_code = proc.poll()
129
+ if exit_code is None:
130
+ try:
131
+ exit_code = proc.wait(timeout=5)
132
+ except Exception:
133
+ exit_code = None
134
+
135
+ duration_s = time.monotonic() - start
136
+ after = git_changed_files(cfg.cwd)
137
+ changed = sorted(set(after) - set(before))
138
+ # stallguard's own traces are evidence, not agent progress:
139
+ # exclude the trace dir prefix AND our exact trace basenames
140
+ # (covers --trace-dir pointing at the repo root itself)
141
+ td = os.path.relpath(os.path.abspath(cfg.trace_dir), os.path.abspath(cfg.cwd))
142
+ own = {
143
+ os.path.basename(trace_jsonl),
144
+ os.path.basename(summary_json),
145
+ os.path.basename(report_html),
146
+ }
147
+ changed = [
148
+ f
149
+ for f in changed
150
+ if os.path.basename(f) not in own
151
+ and f != td
152
+ and not f.startswith(td + os.sep)
153
+ ]
154
+
155
+ # expect_files are relative to cwd; check existence
156
+ missing = [p for p in cfg.expect_files if not os.path.exists(os.path.join(cfg.cwd, p))]
157
+
158
+ if status == "ok":
159
+ if exit_code not in (0, None):
160
+ status = "error"
161
+ else:
162
+ goal_ok, reason = check_goal(
163
+ exit_code=exit_code or 0,
164
+ output_text="\n".join(e["line"] for e in events[-50:]),
165
+ changed_files=changed,
166
+ require_diff=cfg.require_diff,
167
+ expect_files=cfg.expect_files,
168
+ missing=missing,
169
+ )
170
+ if not goal_ok:
171
+ status = "fake-goal"
172
+
173
+ summary = {
174
+ "label": cfg.label,
175
+ "cmd": cfg.cmd,
176
+ "status": status,
177
+ "exit_code": exit_code,
178
+ "duration_s": round(duration_s, 2),
179
+ "stall_timeout": cfg.stall_timeout,
180
+ "repeat_limit": cfg.repeat_limit,
181
+ "changed_files": changed,
182
+ "loop_detail": loop_hit,
183
+ "trace": os.path.basename(trace_jsonl),
184
+ "require_diff": cfg.require_diff,
185
+ "expect_files": cfg.expect_files,
186
+ }
187
+ with open(summary_json, "w", encoding="utf-8") as f:
188
+ json.dump(summary, f, indent=2)
189
+ write_html(report_html, summary, events)
190
+
191
+ return RunResult(
192
+ status=status,
193
+ exit_code=exit_code,
194
+ duration_s=duration_s,
195
+ trace_jsonl=trace_jsonl,
196
+ summary_json=summary_json,
197
+ report_html=report_html,
198
+ changed_files=changed,
199
+ )
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: stallguard
3
+ Version: 0.1.0
4
+ Summary: Drop-in wrapper that streams agent steps and kills/flags stalls, loops, and fake-GOAL finishes.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/gpt4omni/stallguard
7
+ Keywords: ai-agents,llm,coding-assistant,devtools,cli
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # stallguard
14
+
15
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
16
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](pyproject.toml)
17
+ [![No dependencies](https://img.shields.io/badge/deps-zero-brightgreen.svg)](stallguard/)
18
+ [![CI](https://github.com/gpt4omni/stallguard/actions/workflows/test.yml/badge.svg)](https://github.com/gpt4omni/stallguard/actions/workflows/test.yml)
19
+
20
+ **Your coding agent says "done". Did it do anything?** stallguard is a drop-in
21
+ wrapper that streams agent steps, kills stalls and retry-loops, and flags
22
+ fake-GOAL finishes — with a shareable trace for every run.
23
+
24
+ ![stallguard demo](demo.gif)
25
+
26
+ ```bash
27
+ pipx install stallguard
28
+ stallguard run -- npx claude-code "fix the login bug"
29
+ # [stallguard] status=ok exit=0 duration=42.1s trace=traces/stallguard-....html
30
+ ```
31
+
32
+ No code changes. Just prefix your agent command with `stallguard run --`.
33
+
34
+ ## The problem
35
+
36
+ AI coding agents fail in three boring, expensive ways:
37
+
38
+ 1. **Stalls** — no output for 20 minutes while your CI minute-burn ticks.
39
+ 2. **Loops** — the same tool call retried 500 times (`retrying with same args...`).
40
+ 3. **Fake-GOALs** — prints `DONE ✅` while `git diff` is empty.
41
+
42
+ Logs scroll by, nobody watches, green checkmarks lie. stallguard turns each run
43
+ into a verdict: `ok`, `error`, `stalled`, `looped`, or `fake-goal` — enforced
44
+ by exit code, so CI can act on it.
45
+
46
+ ## 30-second quickstart
47
+
48
+ ```bash
49
+ # any agent, any command — just prefix it
50
+ stallguard run -- python -u your_agent.py
51
+ stallguard run --stall-timeout 90 --require-diff -- ./run.sh
52
+
53
+ # inspect a run later
54
+ stallguard view traces/stallguard-*.jsonl
55
+ # open traces/stallguard-*.html in a browser for the full step-by-step trace
56
+ ```
57
+
58
+ ## What it catches
59
+
60
+ | flag | default | catches | exit |
61
+ |---|---|---|---|
62
+ | `--stall-timeout N` | 120 | no output for N seconds → `SIGKILL` | 124 |
63
+ | `--repeat-limit K` | 15 | K identical lines in a row → `SIGKILL` | 125 |
64
+ | `--require-diff` | off | exit 0 + "done" text but empty `git diff` | 3 |
65
+ | `--expect-file PATH` | — | required file missing after run (repeatable) | 3 |
66
+ | `--trace-dir DIR` | `traces` | JSONL + summary JSON + self-contained HTML per run | — |
67
+
68
+ `0` = ok, `1` = agent errored, `3` = fake-goal, `124` = stalled, `125` = looped.
69
+
70
+ ## Why not just `timeout`?
71
+
72
+ `timeout 300 ./agent` kills a hung run but tells you nothing: was it a stall, a
73
+ loop, or nearly done? And it can't catch the worst case — the agent that
74
+ finishes fast, prints success, and changed zero files. stallguard records
75
+ *timestamped evidence* for every run and verifies the goal actually happened.
76
+
77
+ ## CI: fail on fake success
78
+
79
+ ```yaml
80
+ - name: Run agent under stallguard
81
+ run: stallguard run --stall-timeout 300 --require-diff -- ./run-agent.sh
82
+ - name: Upload traces
83
+ uses: actions/upload-artifact@v4
84
+ with:
85
+ name: stallguard-traces
86
+ path: traces/
87
+ ```
88
+
89
+ Or use the bundled composite action (see [action.yml](action.yml)).
90
+ Every failed run links its HTML trace — debugging starts from evidence, not
91
+ from re-running and hoping.
92
+
93
+ ## Works with any agent
94
+
95
+ `stallguard` sees stdout, not APIs, so it works with everything:
96
+ Claude Code, Codex CLI, OpenHands, AutoGPT, your own Python script — if it
97
+ prints steps, stallguard can supervise it. Zero dependencies, stdlib only.
98
+
99
+ ## Dev
100
+
101
+ ```bash
102
+ python3 -m venv .venv && source .venv/bin/activate
103
+ pip install -e .
104
+ python -m stallguard run -- python -u examples/ok.py
105
+ python -m stallguard run --stall-timeout 1 -- python -u examples/hang.py; echo $?
106
+ python -m stallguard run --repeat-limit 3 -- python -u examples/loop.py; echo $?
107
+ python -m unittest discover -s tests
108
+ ```
109
+
110
+ ## Roadmap
111
+
112
+ - [ ] Token/step budgets (`--max-lines`, `--max-minutes`)
113
+ - [ ] Semantic loop detection (same tool call, different formatting)
114
+ - [ ] Live web dashboard for parallel agent fleets
115
+ - [ ] PyPI release + Homebrew formula
116
+
117
+ PRs welcome — especially real traces of agents failing in new and creative ways.
118
+
119
+ MIT. If stallguard caught your agent slacking, leave a ⭐.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ stallguard/__init__.py
5
+ stallguard/__main__.py
6
+ stallguard/cli.py
7
+ stallguard/detectors.py
8
+ stallguard/goalcheck.py
9
+ stallguard/render.py
10
+ stallguard/tracer.py
11
+ stallguard.egg-info/PKG-INFO
12
+ stallguard.egg-info/SOURCES.txt
13
+ stallguard.egg-info/dependency_links.txt
14
+ stallguard.egg-info/entry_points.txt
15
+ stallguard.egg-info/top_level.txt
16
+ tests/test_stallguard.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stallguard = stallguard.cli:main
@@ -0,0 +1 @@
1
+ stallguard
@@ -0,0 +1,79 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+
8
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
9
+
10
+
11
+ def run_stallguard(args, cwd):
12
+ env = dict(os.environ, PYTHONPATH=ROOT)
13
+ return subprocess.run(
14
+ [sys.executable, "-m", "stallguard"] + args,
15
+ cwd=cwd,
16
+ capture_output=True,
17
+ text=True,
18
+ timeout=60,
19
+ env=env,
20
+ )
21
+
22
+
23
+ class TestStallguard(unittest.TestCase):
24
+ def test_ok(self):
25
+ with tempfile.TemporaryDirectory() as d:
26
+ p = run_stallguard(["run", "--trace-dir", d, "--", sys.executable, "-u", f"{ROOT}/examples/ok.py"], cwd=d)
27
+ self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
28
+ self.assertIn("status=ok", p.stdout)
29
+
30
+ def test_loop_kill(self):
31
+ with tempfile.TemporaryDirectory() as d:
32
+ p = run_stallguard(["run", "--trace-dir", d, "--repeat-limit", "3", "--", sys.executable, "-u", f"{ROOT}/examples/loop.py"], cwd=d)
33
+ self.assertEqual(p.returncode, 125, p.stderr + p.stdout)
34
+ self.assertIn("status=looped", p.stdout)
35
+
36
+ def test_stall_kill(self):
37
+ with tempfile.TemporaryDirectory() as d:
38
+ p = run_stallguard(["run", "--trace-dir", d, "--stall-timeout", "1", "--", sys.executable, "-u", f"{ROOT}/examples/hang.py"], cwd=d)
39
+ self.assertEqual(p.returncode, 124, p.stderr + p.stdout)
40
+ self.assertIn("status=stalled", p.stdout)
41
+
42
+ def test_fake_goal_require_diff(self):
43
+ with tempfile.TemporaryDirectory() as d:
44
+ subprocess.run(["git", "init", "-q"], cwd=d, check=True)
45
+ code = "print('GOAL done success', flush=True)"
46
+ p = run_stallguard(["run", "--trace-dir", d, "--require-diff", "--", sys.executable, "-u", "-c", code], cwd=d)
47
+ self.assertEqual(p.returncode, 3, p.stderr + p.stdout)
48
+ self.assertIn("status=fake-goal", p.stdout)
49
+
50
+ def test_new_untracked_file_counts_as_progress(self):
51
+ with tempfile.TemporaryDirectory() as d:
52
+ subprocess.run(["git", "init", "-q"], cwd=d, check=True)
53
+ code = "open('out.txt','w').write('x'); print('GOAL done', flush=True)"
54
+ p = run_stallguard(["run", "--trace-dir", d, "--require-diff", "--", sys.executable, "-u", "-c", code], cwd=d)
55
+ self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
56
+ self.assertIn("status=ok", p.stdout)
57
+
58
+ def test_own_traces_dont_count_as_progress(self):
59
+ with tempfile.TemporaryDirectory() as d:
60
+ subprocess.run(["git", "init", "-q"], cwd=d, check=True)
61
+ code = "print('GOAL done success', flush=True)"
62
+ p = run_stallguard(["run", "--trace-dir", d, "--require-diff", "--", sys.executable, "-u", "-c", code], cwd=d)
63
+ self.assertEqual(p.returncode, 3, p.stderr + p.stdout)
64
+
65
+ def test_trace_files_written(self):
66
+ with tempfile.TemporaryDirectory() as d:
67
+ run_stallguard(["run", "--trace-dir", d, "--", sys.executable, "-u", f"{ROOT}/examples/ok.py"], cwd=d)
68
+ names = os.listdir(d)
69
+ self.assertTrue(any(n.endswith(".jsonl") for n in names))
70
+ self.assertTrue(any(n.endswith(".summary.json") for n in names))
71
+ self.assertTrue(any(n.endswith(".html") for n in names))
72
+ summ = [n for n in names if n.endswith(".summary.json")][0]
73
+ with open(os.path.join(d, summ), encoding="utf-8") as fh:
74
+ data = json.load(fh)
75
+ self.assertEqual(data["status"], "ok")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()