🔥 Gremlins MVP: cut unrequested surface per keep/cut calls

This commit is contained in:
2026-10-17 00:12:40 +02:00
parent 82ef04c118
commit e1884248e8
5 changed files with 42 additions and 48 deletions
@@ -23,3 +23,29 @@
| 25 | stderr to `raw/<J>.err` | keep | Cheap debugging aid. | | 25 | stderr to `raw/<J>.err` | keep | Cheap debugging aid. |
| 26 | Reader join timeout 10 s | keep | Without it an orphan holding stdout hangs the helper (spec §7 leftover). | | 26 | Reader join timeout 10 s | keep | Without it an orphan holding stdout hangs the helper (spec §7 leftover). |
| 28 | Unused `ss=None` injection parameter in `call.py` | cut | Used by no caller. | | 28 | Unused `ss=None` injection parameter in `call.py` | cut | Used by no caller. |
## Result
Premises checked in the code before each cut. Tests: `uv run --no-project --python 3.12 python -m unittest discover -s tests` gave **24 tests, OK** (run twice).
| File | Added | Removed |
|---|---|---|
| `grm/stream.py` | 9 | 39 |
| `grm/call.py` | 4 | 4 |
| `grm/cli.py` | 2 | 4 |
| `grm/launch.py` | 1 | 1 |
| **Total** | **16** | **48** |
Net code: 32 lines removed (16 added, 48 deleted); no test was removed or changed (none existed only for a cut item).
## Kept against the call
- **#4 `threading.Lock` around ledger append: kept.** Premise half true: in production each spawn is its own process, so no two threads of one process append. But with the lock removed `test_store.test_parallel_appends_give_whole_lines` (96 of 100 lines) and `test_cli.test_parallel_spawns` (2 of 3 ledger lines) fail, on every run. Appends from separate handles on Windows lose lines. The lock only makes those B3 tests pass in-process; across real processes the same race is unguarded either way (the review's open "Windows append atomicity" item). Cutting it would mean weakening two B3 tests, which the call forbids. Needs a decision: either keep the lock as is, or replace it with a cross-process append (for example a lock file or an `msvcrt` byte-range lock) in a separate change.
## Notes on the cuts
- #12: `session_id` from the `init` event is now taken only while unset (`self.session_id is None`), which matches the old "first init wins" behaviour.
- #28: `StreamState` is now imported at the top of `call.py` instead of lazily inside the `ss is None` branch.
- #17: premise holds. `spawn` refuses an existing `jobs/<J>.json` (`cli.py:255`) and `_execute` writes that file before the call starts, so a note that exists afterwards was written by this job.
- #11: nothing outside `stream.py` read `refused` or `model_refused` (code, tests, SKILL.md, templates).
- #8, #13, #14, #22: no reader in code, tests, SKILL.md or templates. SKILL.md and templates mentioned none of the cut items, so they are unchanged.
+4 -4
View File
@@ -6,6 +6,8 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from grm.stream import StreamState
POLL_S = 0.2 POLL_S = 0.2
READER_JOIN_S = 10.0 # an orphaned grandchild can hold stdout open after claude exits READER_JOIN_S = 10.0 # an orphaned grandchild can hold stdout open after claude exits
STOP_SIGNALS = tuple(s for s in (signal.SIGTERM, signal.SIGINT, getattr(signal, "SIGBREAK", None)) STOP_SIGNALS = tuple(s for s in (signal.SIGTERM, signal.SIGINT, getattr(signal, "SIGBREAK", None))
@@ -39,11 +41,9 @@ def _read(stdout, raw, ss) -> None:
pass # the raw file or pipe was closed after READER_JOIN_S pass # the raw file or pipe was closed after READER_JOIN_S
def run_claude(argv, prompt: str, env, cwd, raw_path, on_start, ss=None): def run_claude(argv, prompt: str, env, cwd, raw_path, on_start):
"""Run one call to its end. Returns (ss, exit_code, stopped).""" """Run one call to its end. Returns (ss, exit_code, stopped)."""
if ss is None: ss = StreamState()
from grm.stream import StreamState
ss = StreamState()
raw_path = Path(raw_path) raw_path = Path(raw_path)
with open(raw_path, "wb") as raw, open(raw_path.with_suffix(".err"), "wb") as err: with open(raw_path, "wb") as raw, open(raw_path.with_suffix(".err"), "wb") as err:
child = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, child = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE,
+2 -4
View File
@@ -14,7 +14,6 @@ from grm import call, launch, store, stream
SKILL_DIR = Path(__file__).resolve().parent.parent SKILL_DIR = Path(__file__).resolve().parent.parent
NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
NOTE_SLACK_S = 2.0 # coarse file times: a note written right at the start still counts
EXCERPT_CHARS = 500 EXCERPT_CHARS = 500
LAUNCHES = ("train", "spawn", "rebase") LAUNCHES = ("train", "spawn", "rebase")
@@ -163,7 +162,7 @@ def _execute(run: str, state: Path, r: dict, *, job, kind, base, sid, argv, prom
note = None note = None
if kind == "spawn": if kind == "spawn":
p = state / "notes" / (job + ".md") p = state / "notes" / (job + ".md")
if p.is_file() and p.stat().st_mtime >= t0 - NOTE_SLACK_S: if p.is_file():
note = str(p) note = str(p)
text = (ss.result or {}).get("result") text = (ss.result or {}).get("result")
rec.update(session_id=ss.session_id or sid, status=status, reason=reason, ended=stream.iso(), rec.update(session_id=ss.session_id or sid, status=status, reason=reason, ended=stream.iso(),
@@ -255,7 +254,6 @@ def cmd_spawn(args) -> dict:
if (state / "jobs" / (j + ".json")).exists(): if (state / "jobs" / (j + ".json")).exists():
raise Refusal("job exists; pick a new --job", job=j) raise Refusal("job exists; pick a new --job", job=j)
brief = _read_brief(args.brief) brief = _read_brief(args.brief)
warm = _warmth(store.ledger_lines(args.run), base, time.time())
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
prompt = render_template("spawn.md", { prompt = render_template("spawn.md", {
"block": j, "base": b, "run": r["run"], "block": j, "base": b, "run": r["run"],
@@ -266,7 +264,7 @@ def cmd_spawn(args) -> dict:
argv=launch.fork_argv(r, base["session_id"], sid), prompt=prompt, argv=launch.fork_argv(r, base["session_id"], sid), prompt=prompt,
before=(base.get("model_usage"), base.get("cost_cumulative_usd")), before=(base.get("model_usage"), base.get("cost_cumulative_usd")),
base_ctx=base.get("context_tokens")) base_ctx=base.get("context_tokens"))
return {**_out(rec, line), "base_state": warm["base_state"]} return _out(rec, line)
def cmd_rebase(args) -> dict: def cmd_rebase(args) -> dict:
+1 -1
View File
@@ -38,7 +38,7 @@ SCRUB_KEEP = frozenset({
"PATH", "HOME", "USERPROFILE", "PATH", "HOME", "USERPROFILE",
"ANTHROPIC_BASE_URL", # kept: gateways need it "ANTHROPIC_BASE_URL", # kept: gateways need it
}) })
SCRUB_PINNED = {**ENV_PINNED_LAUNCH, "SPIRE_GREMLIN_CHILD": "1"} SCRUB_PINNED = ENV_PINNED_LAUNCH
SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path
+9 -39
View File
@@ -31,37 +31,23 @@ class StreamState:
"""What one `claude -p --output-format stream-json --verbose` stream told us so far.""" """What one `claude -p --output-format stream-json --verbose` stream told us so far."""
def __init__(self): def __init__(self):
self.init = None # the system/init event
self.session_id = None self.session_id = None
self.msg_ids: list = []
self._ids: set = set()
self.first_usage = None # usage of the first assistant event (cache verdict) self.first_usage = None # usage of the first assistant event (cache verdict)
self.first_at = None # its arrival time (the base prefix read) self.first_at = None # its arrival time (the base prefix read)
self.last_usage = None # usage of the last assistant event (base context) self.last_usage = None # usage of the last assistant event (base context)
self.last_assistant_at = None self.last_assistant_at = None
self.result = None # the result event self.result = None # the result event
self.refused = None # text of a model-restricted warning
self.events = 0
def feed(self, ev: dict, t: float | None) -> None: def feed(self, ev: dict, t: float | None) -> None:
if not isinstance(ev, dict): if not isinstance(ev, dict):
return return
self.events += 1
ty = ev.get("type") ty = ev.get("type")
if ty == "system": if ty == "system":
st = ev.get("subtype") st = ev.get("subtype")
if st == "init" and self.init is None: if st == "init" and self.session_id is None:
self.init = ev self.session_id = ev.get("session_id")
self.session_id = ev.get("session_id") or self.session_id
elif (st == "informational" and ev.get("level") == "warning"
and "is restricted" in str(ev.get("content", ""))):
self.refused = str(ev.get("content"))
elif ty == "assistant": elif ty == "assistant":
m = ev.get("message") or {} m = ev.get("message") or {}
mid = m.get("id")
if mid not in self._ids:
self._ids.add(mid)
self.msg_ids.append(mid)
u = m.get("usage") or {} u = m.get("usage") or {}
if self.first_usage is None: if self.first_usage is None:
self.first_usage, self.first_at = u, t self.first_usage, self.first_at = u, t
@@ -70,10 +56,6 @@ class StreamState:
self.result = ev self.result = ev
self.session_id = ev.get("session_id") or self.session_id self.session_id = ev.get("session_id") or self.session_id
@property
def turns(self) -> int:
return len(self.msg_ids)
def context_tokens(usage: dict | None) -> int: def context_tokens(usage: dict | None) -> int:
"""Prompt-side tokens of one request: input + cache creation + cache read.""" """Prompt-side tokens of one request: input + cache creation + cache read."""
@@ -121,18 +103,16 @@ def api_error_reason(res: dict | None) -> tuple[str, str] | None:
def classify(ss: StreamState, exit_code, stopped: bool) -> tuple[str, str | None]: def classify(ss: StreamState, exit_code, stopped: bool) -> tuple[str, str | None]:
"""(status, reason) of a finished call; the first matching case wins.""" """(status, reason) of a finished call; the first matching case wins."""
res = ss.result res = ss.result
match (bool(stopped), ss.refused, res, api_error_reason(res), exit_code): match (bool(stopped), res, api_error_reason(res), exit_code):
case (True, _, _, _, _): case (True, _, _, _):
return "stopped", "stopped" return "stopped", "stopped"
case (_, str(), _, _, _): case (_, None, _, code):
return "error", "model_refused"
case (_, _, None, _, code):
return "error", "exit_code" if code else "no_result" return "error", "exit_code" if code else "no_result"
case (_, _, _, (reason, _), _): case (_, _, (reason, _), _):
return "error", reason return "error", reason
case (_, _, dict() as r, _, _) if r.get("is_error"): case (_, dict() as r, _, _) if r.get("is_error"):
return "error", "is_error" return "error", "is_error"
case (_, _, _, _, code) if code: case (_, _, _, code) if code:
return "error", "exit_code" return "error", "exit_code"
case _: case _:
return "ok", None return "ok", None
@@ -161,17 +141,13 @@ def usage_from_result(result: dict, before_mu: dict, cost_before) -> dict:
delta[m] = {f: int((v or {}).get(f) or 0) - int(b.get(f) or 0) for f in MU_FIELDS} delta[m] = {f: int((v or {}).get(f) or 0) - int(b.get(f) or 0) for f in MU_FIELDS}
grew = [m for m, d in delta.items() if any(x > 0 for x in d.values())] grew = [m for m, d in delta.items() if any(x > 0 for x in d.values())]
models_used = sorted(grew, key=lambda m: (-sum(delta[m].values()), m)) models_used = sorted(grew, key=lambda m: (-sum(delta[m].values()), m))
sums = {f: sum(d[f] for d in delta.values()) for f in MU_FIELDS}
mismatch = (sums["inputTokens"], sums["outputTokens"], sums["cacheCreationInputTokens"],
sums["cacheReadInputTokens"]) != (tokens["input"], tokens["output"],
tokens["cache_creation"], tokens["cache_read"])
total = result.get("total_cost_usd") total = result.get("total_cost_usd")
if total is None: if total is None:
cost_delta = None cost_delta = None
else: else:
cost_delta = round(float(total) - float(cost_before or 0.0), 6) cost_delta = round(float(total) - float(cost_before or 0.0), 6)
total = round(float(total), 6) total = round(float(total), 6)
return {"tokens": tokens, "models_used": models_used, "mismatch": mismatch, return {"tokens": tokens, "models_used": models_used,
"cost_delta": cost_delta, "total": total, "model_usage": after} "cost_delta": cost_delta, "total": total, "model_usage": after}
@@ -197,12 +173,6 @@ def infer_ttl(tokens: dict | None, prev):
return prev return prev
def wrote_only_5m(tokens: dict | None) -> bool:
"""The call wrote 5-minute cache entries and no 1-hour ones."""
return (bool(tokens) and int(tokens.get("cache_creation_5m") or 0) > 0
and int(tokens.get("cache_creation_1h") or 0) == 0)
def warmth(last_hit_ts: float | None, ttl_s, now: float) -> dict: def warmth(last_hit_ts: float | None, ttl_s, now: float) -> dict:
"""warm until `margin` before last_hit + ttl, expiring until last_hit + ttl, then cold; """warm until `margin` before last_hit + ttl, expiring until last_hit + ttl, then cold;
margin = max(120, ttl/10). No hit at all is cold.""" margin = max(120, ttl/10). No hit at all is cold."""