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.
- stallguard-0.1.0/LICENSE +21 -0
- stallguard-0.1.0/PKG-INFO +119 -0
- stallguard-0.1.0/README.md +107 -0
- stallguard-0.1.0/pyproject.toml +22 -0
- stallguard-0.1.0/setup.cfg +4 -0
- stallguard-0.1.0/stallguard/__init__.py +5 -0
- stallguard-0.1.0/stallguard/__main__.py +4 -0
- stallguard-0.1.0/stallguard/cli.py +75 -0
- stallguard-0.1.0/stallguard/detectors.py +24 -0
- stallguard-0.1.0/stallguard/goalcheck.py +53 -0
- stallguard-0.1.0/stallguard/render.py +36 -0
- stallguard-0.1.0/stallguard/tracer.py +199 -0
- stallguard-0.1.0/stallguard.egg-info/PKG-INFO +119 -0
- stallguard-0.1.0/stallguard.egg-info/SOURCES.txt +16 -0
- stallguard-0.1.0/stallguard.egg-info/dependency_links.txt +1 -0
- stallguard-0.1.0/stallguard.egg-info/entry_points.txt +2 -0
- stallguard-0.1.0/stallguard.egg-info/top_level.txt +1 -0
- stallguard-0.1.0/tests/test_stallguard.py +79 -0
stallguard-0.1.0/LICENSE
ADDED
|
@@ -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)
|
|
16
|
+
[](pyproject.toml)
|
|
17
|
+
[](stallguard/)
|
|
18
|
+
[](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
|
+

|
|
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)
|
|
4
|
+
[](pyproject.toml)
|
|
5
|
+
[](stallguard/)
|
|
6
|
+
[](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
|
+

|
|
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,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 ' '}</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)
|
|
16
|
+
[](pyproject.toml)
|
|
17
|
+
[](stallguard/)
|
|
18
|
+
[](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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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()
|