diff --git a/plugins/spire/skills/forks/preamble.md b/plugins/spire/skills/forks/preamble.md deleted file mode 100644 index 1f607e2..0000000 --- a/plugins/spire/skills/forks/preamble.md +++ /dev/null @@ -1,9 +0,0 @@ -# Headless implementer (spire:forks) - -You run headless under `claude -p`. You are trained once on a build, then forked once per block; any session of yours may later be resumed or forked again. Nobody can answer a question mid-run: decide, act, and record what you decided. - -- Do the work yourself. Never dispatch agents, and never start `claude` or the forks helper (`forks.py`). -- Never use `run_in_background` on Bash. Run every command in the foreground. -- Stage and commit by explicit paths only, and only when your brief says this fork commits. Never push, rebase or reset. -- Write gate and test output to `.txt` files under the state dir's `logs/` folder (the brief names the state dir). -- When you finish a block, write the handoff note to the path the brief names, in the run-log handoff-note format (`plugins/spire/skills/implementation/references/run-log.md` in the Spire plugin), and stamp your exact model id. diff --git a/plugins/spire/skills/forks/templates/train.md b/plugins/spire/skills/forks/templates/train.md deleted file mode 100644 index 2f9c137..0000000 --- a/plugins/spire/skills/forks/templates/train.md +++ /dev/null @@ -1,11 +0,0 @@ -# Training (spire:forks) - -You are base `${base}` of the build whose run log is `${run}`. This session is trained once and then forked, once per block; every fork starts from what you know at the end of this session. Build an accurate picture of the code the brief below points you at: read it, trace the seams, note the invariants and the traps. - -- Do not change code in this session. If the brief asks for trunk notes, write them to the path it names; that is the only file you write. -- The helper's state dir is `${state_dir}`. Forks write handoff notes under `${notes_dir}` and gate and test output under `${logs_dir}`. -- Finish with one line `READY`, then a short summary of what you read and anything the brief asked for that you could not do. - -## Brief - -${brief} diff --git a/plugins/spire/skills/forks/forks.py b/plugins/spire/skills/gremlins/gremlins.py similarity index 90% rename from plugins/spire/skills/forks/forks.py rename to plugins/spire/skills/gremlins/gremlins.py index d954cba..09e2bc8 100644 --- a/plugins/spire/skills/forks/forks.py +++ b/plugins/spire/skills/gremlins/gremlins.py @@ -2,14 +2,15 @@ # requires-python = ">=3.10" # dependencies = [] # /// -"""spire:forks helper: trains one headless implementer per build and forks it per block. +"""spire:gremlins helper: trains one headless implementer (the base) per build and spawns a +gremlin from it per block (`spawn`; method `fork`: a fork of the base's session). -Run as `uv run /forks.py [args]`. Every call prints exactly one JSON +Run as `uv run /gremlins.py [args]`. Every call prints exactly one JSON object on stdout (newline-terminated); diagnostics go to stderr. Exit codes: 0 ok, 2 usage error or refused, 3 `wait` condition not met at timeout, 4 `wait` condition met and a job in it did not end `ok`. -Standard library only. State lives next to the run log: `-forks/`. +Standard library only. State lives next to the run log: `-gremlins/`. """ from __future__ import annotations @@ -47,7 +48,7 @@ EXIT_JOB_FAILED = 4 IS_WINDOWS = os.name == "nt" # How the detached supervisor is created (C6, P0.1 rung 3 hidden; POSIX: new session). -SPAWN_METHOD = "cim-hidden" if IS_WINDOWS else "setsid" +DETACH_METHOD = "cim-hidden" if IS_WINDOWS else "setsid" CREATE_NO_WINDOW = 0x08000000 _NOWIN = CREATE_NO_WINDOW if IS_WINDOWS else 0 @@ -114,28 +115,28 @@ SCRUB_KEEP = frozenset({ "ANTHROPIC_BASE_URL", # kept (gateways need it); `where` warns when it is not the default }) DEFAULT_BASE_URL = "https://api.anthropic.com" -SCRUB_PINNED = {**ENV_PINNED_LAUNCH, "SPIRE_FORKS_CHILD": "1"} +SCRUB_PINNED = {**ENV_PINNED_LAUNCH, "SPIRE_GREMLIN_CHILD": "1"} SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path -CHILD_MARKER = "SPIRE_FORKS_CHILD" # N3 +CHILD_MARKER = "SPIRE_GREMLIN_CHILD" # N3 SCRUB_WATCH_PREFIXES = ("CLAUDE_", "ANTHROPIC_") # `where` lists unknown vars with these # C8: the user settings file. Module-level so tests can point it at a temp file. USER_SETTINGS_PATH: str | None = None PHASE2_COMMANDS = () # commands that tolerate unknown arguments (none: all are implemented) -# N3: a fork may only run where, list, usage and wait (`tick` launches pings). -CHILD_REFUSED = ("train", "fork", "continue", "rebase", "kill", "expire", "init", "tick") +# N3: a gremlin may only run where, list, usage and wait (`tick` launches pings). +CHILD_REFUSED = ("train", "spawn", "continue", "rebase", "kill", "expire", "init", "tick") # Warmth and keep-warm (design 5, block 2.1). Module-level so tests can shorten them. TICK_SUP_S = 60.0 # a supervisor runs one tick pass this often TICK_WAIT_S = 30.0 # `wait` runs one tick pass at its start and then this often -ACTIVITY_WINDOW_S = 600.0 # ping only while a fork/continue ran or `wait` was called this recently +ACTIVITY_WINDOW_S = 600.0 # ping only while a spawn/continue ran or `wait` was called this recently PING_PROMPT = "Reply OK" PING_MAX_S = 300.0 # wall-time limit of one ping (a single turn) TTL_1H, TTL_5M = 3600, 300 # Price ratios against the base input price, in hundredths (integers: floor(1.9 / 0.1) is 18 in floats). PRICE_RATIOS = {"read": 10, "write_1h": 200, "write_5m": 125} -REAL_KINDS = ("train", "fork", "continue", "rebase") # every kind but `ping` +REAL_KINDS = ("train", "spawn", "continue", "rebase") # every kind but `ping` # Supervision and jobs (1.2). Module-level so tests can shorten the in-process ones. HEARTBEAT_S = 10.0 # the supervisor writes heartbeat_at at least this often (<= 30 s) @@ -152,11 +153,14 @@ CACHE_HIT_RATIO = 0.9 # C5 RUNNING = ("created", "running") FINAL = ("ok", "failed", "timeout", "killed", "lost") LAUNCH_FIELDS = ("model", "effort", "permission_mode", "tools", "allowed_tools", "disallowed_tools", - "agent", "cwd", "binary") # fixed at init; fork/continue refuse them + "agent", "cwd", "binary") # fixed at init; spawn/continue refuse them MU_FIELDS = ("inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens") TOKEN_KEYS = ("input", "output", "cache_creation", "cache_creation_1h", "cache_creation_5m", "cache_read") TEMPLATES_DIR = "templates" -CAPPED_KINDS = ("fork", "continue", "rebase") # parallel_cap counts these (pings never) +CAPPED_KINDS = ("spawn", "continue", "rebase") # parallel_cap counts these (pings never) +# `spawn --method`: how a gremlin is made. `fork` = `--resume --fork-session`. Recorded as +# `method` on every job (None on every kind but `spawn`). +SPAWN_METHODS = ("fork",) # Rebase and re-train flags (design 5, block 2.2). # Runtime fields of a `system/init` event (compared with the base's; M6 adds `skills`). @@ -176,7 +180,8 @@ TRANSCRIPT_TAIL_BYTES = 256 * 1024 POWERSHELL_CIM = ( "$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow=[uint16]0}; " "$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments " - "@{CommandLine=$env:SPIRE_FORKS_CMD; CurrentDirectory=$env:SPIRE_FORKS_CWD; ProcessStartupInformation=$si}; " + "@{CommandLine=$env:SPIRE_GREMLIN_DETACH_CMD; CurrentDirectory=$env:SPIRE_GREMLIN_DETACH_CWD; " + "ProcessStartupInformation=$si}; " "\"{0} {1}\" -f $r.ReturnValue, $r.ProcessId") # -------------------------------------------------------------------------------------- @@ -184,7 +189,7 @@ POWERSHELL_CIM = ( # -------------------------------------------------------------------------------------- -class ForksError(Exception): +class GremlinsError(Exception): """A refusal or usage error: exit `code`, payload printed as the stdout JSON object.""" def __init__(self, error: str, code: int = EXIT_USAGE, **fields): @@ -205,7 +210,7 @@ def _emit(obj: dict) -> None: def _diag(msg: str) -> None: - sys.stderr.write("forks: " + msg + "\n") + sys.stderr.write("gremlins: " + msg + "\n") sys.stderr.flush() @@ -284,16 +289,16 @@ def cygpath_w(p: str) -> str: cwd's drive.""" exe = find_cygpath() if not exe: - raise ForksError("Git Bash rooted path is not a drive form and cygpath is not on PATH to " - "convert it; pass a drive path (G:\\... or /g/...)", path=p) + raise GremlinsError("Git Bash rooted path is not a drive form and cygpath is not on PATH to " + "convert it; pass a drive path (G:\\... or /g/...)", path=p) try: r = _run([exe, "-w", p], timeout=30) except (OSError, subprocess.SubprocessError) as e: - raise ForksError("cygpath failed", path=p, detail=str(e)) + raise GremlinsError("cygpath failed", path=p, detail=str(e)) out = r.stdout.decode("utf-8", "replace").strip() if r.returncode != 0 or not out: - raise ForksError("cygpath failed", path=p, - detail=r.stderr.decode("utf-8", "replace").strip()[:200] or "no output") + raise GremlinsError("cygpath failed", path=p, + detail=r.stderr.decode("utf-8", "replace").strip()[:200] or "no output") return out @@ -308,7 +313,7 @@ def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> st if windows is None: windows = IS_WINDOWS if not isinstance(p, str) or not p.strip(): - raise ForksError("empty path") + raise GremlinsError("empty path") p = p.strip() if not windows: base = cwd if cwd is not None else os.getcwd() @@ -329,18 +334,18 @@ def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> st p = ntpath.normpath(p) drive, rest = ntpath.splitdrive(p) if not drive or not rest.startswith("\\"): - raise ForksError("cannot normalise path", path=p) + raise GremlinsError("cannot normalise path", path=p) if len(drive) == 2 and drive[1] == ":": p = drive.upper() + rest return p def state_dir_for(run: str) -> str: - """`reports/-run.md` -> `reports/-run-forks` (a sibling of the run log).""" + """`reports/-run.md` -> `reports/-run-gremlins` (a sibling of the run log).""" run_n = norm_path(run) if not run_n.lower().endswith(".md"): - raise ForksError("--run must name the run log .md file", run=run_n) - return run_n[:-3] + "-forks" + raise GremlinsError("--run must name the run log .md file", run=run_n) + return run_n[:-3] + "-gremlins" STATE_SUBDIRS = ("jobs", "briefs", "notes", "results", "logs", "raw") @@ -431,7 +436,7 @@ def read_json(path: str): try: return json.loads(data.decode("utf-8")) except ValueError as e: - raise ForksError("corrupt state file", path=path, detail=str(e)) + raise GremlinsError("corrupt state file", path=path, detail=str(e)) class RegistryLock: @@ -540,7 +545,7 @@ class RegistryLock: % (age, (holder or {}).get("pid"))) continue # the path is free: try to create at once if time.monotonic() >= deadline: - raise ForksError("registry locked", lock=self.path, waited_s=self.timeout) + raise GremlinsError("registry locked", lock=self.path, waited_s=self.timeout) time.sleep(LOCK_POLL_S) def release(self) -> None: @@ -578,9 +583,9 @@ def read_registry(state: str) -> dict | None: if reg is None: return None if not isinstance(reg, dict) or reg.get("schema") != SCHEMA: - raise ForksError("unsupported registry schema", path=registry_path(state), - schema=(reg or {}).get("schema") if isinstance(reg, dict) else None, - expected=SCHEMA) + raise GremlinsError("unsupported registry schema", path=registry_path(state), + schema=(reg or {}).get("schema") if isinstance(reg, dict) else None, + expected=SCHEMA) return reg @@ -1021,13 +1026,13 @@ def _binary_version(path: str) -> str | None: def binary_info(path: str) -> dict: - """{"path", "version", "size", "mtime"} of an existing binary; raises ForksError otherwise.""" + """{"path", "version", "size", "mtime"} of an existing binary; raises GremlinsError otherwise.""" if not path or not os.path.isfile(path): - raise ForksError("claude binary not found", binary=path) + raise GremlinsError("claude binary not found", binary=path) st = os.stat(path) version = _binary_version(path) if not version: - raise ForksError("claude binary did not report a version", binary=path) + raise GremlinsError("claude binary did not report a version", binary=path) return {"path": path, "version": version, "size": st.st_size, "mtime": st.st_mtime} @@ -1105,7 +1110,7 @@ def model_admission(model: str, cwd: str) -> dict: for f in settings_files(cwd): try: data = read_json(f) - except ForksError as e: + except GremlinsError as e: _diag("ignoring unreadable settings file %s (%s)" % (f, e.payload.get("detail"))) continue except OSError: @@ -1394,7 +1399,7 @@ def note_rel(block) -> str | None: def note_written_by(path: str, entry: dict) -> bool: """M11: the note file exists and was written during this job: its mtime is at or after the job's creation (less NOTE_MTIME_SLACK_S for coarse file times). A note left by an earlier job - of the same block (a re-run, or the fork a continue resumes) does not count.""" + of the same block (a re-run, or the gremlin a continue resumes) does not count.""" try: mtime = os.path.getmtime(path) except OSError: @@ -1426,38 +1431,38 @@ def _powershell() -> str: "v1.0", "powershell.exe") -def spawn_supervisor(state: str, run: str, job: str) -> dict: +def detach_supervisor(state: str, run: str, job: str) -> dict: """Start `_supervise` detached (C6). Windows: CIM `Win32_Process.Create` with a hidden window, through a `powershell` that itself has CREATE_NO_WINDOW. POSIX: a new session. Returns {"ok", "pid", "ctime", "method"} or {"ok": False, "detail"}.""" argv = [supervisor_interpreter(), "-I", script_path(), "_supervise", "--run", run, "--job", job] if IS_WINDOWS: env = dict(os.environ) - env["SPIRE_FORKS_CMD"] = subprocess.list2cmdline(argv) - env["SPIRE_FORKS_CWD"] = state + env["SPIRE_GREMLIN_DETACH_CMD"] = subprocess.list2cmdline(argv) + env["SPIRE_GREMLIN_DETACH_CWD"] = state try: r = _run([_powershell(), "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_CIM], env=env, timeout=120) except (OSError, subprocess.SubprocessError) as e: - return {"ok": False, "method": SPAWN_METHOD, "detail": "powershell: %s" % e} + return {"ok": False, "method": DETACH_METHOD, "detail": "powershell: %s" % e} toks = r.stdout.decode("utf-8", "replace").split() try: rv, pid = int(toks[-2]), int(toks[-1]) except (ValueError, IndexError): - return {"ok": False, "method": SPAWN_METHOD, + return {"ok": False, "method": DETACH_METHOD, "detail": "unparsable CIM output: %r / %r" % (r.stdout[-200:], r.stderr[-400:])} if rv != 0: - return {"ok": False, "method": SPAWN_METHOD, "detail": "Win32_Process.Create returned %d" % rv} - return {"ok": True, "method": SPAWN_METHOD, "pid": pid, "ctime": proc_ctime(pid)} + return {"ok": False, "method": DETACH_METHOD, "detail": "Win32_Process.Create returned %d" % rv} + return {"ok": True, "method": DETACH_METHOD, "pid": pid, "ctime": proc_ctime(pid)} log = open(raw_file(state, job, ".sup.log"), "ab") try: p = subprocess.Popen(argv, cwd=state, stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True, close_fds=True) except OSError as e: - return {"ok": False, "method": SPAWN_METHOD, "detail": str(e)} + return {"ok": False, "method": DETACH_METHOD, "detail": str(e)} finally: log.close() - return {"ok": True, "method": SPAWN_METHOD, "pid": p.pid, "ctime": proc_ctime(p.pid)} + return {"ok": True, "method": DETACH_METHOD, "pid": p.pid, "ctime": proc_ctime(p.pid)} def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: str, flags, reason, @@ -1507,7 +1512,7 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: if denials: flags.append("needs_attention") cache = cache_verdict(ss.first_usage if ss else None, entry.get("base_context")) \ - if kind in ("fork", "continue", "ping", "rebase") else None + if kind in ("spawn", "continue", "ping", "rebase") else None rt = runtime_fingerprint(ss.init) if ss else None drift = list(entry.get("launch_drift") or []) ref = (base or {}).get("runtime_fingerprint") if kind != "train" else None @@ -1516,7 +1521,7 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: drift = sorted(set(drift)) fingerprint = "ok" if not drift else "drift:" + ",".join(drift) base_state = entry.get("base_state_at_launch") - if kind in ("fork", "continue", "rebase") and (base_state == "cold" or ( + if kind in ("spawn", "continue", "rebase") and (base_state == "cold" or ( cache not in (None, "hit") and fingerprint == "ok")): flags.append("cold_start") if u and wrote_only_5m(u["tokens"]): @@ -1536,8 +1541,8 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: tokens = u["tokens"] if u else {k: None for k in TOKEN_KEYS} ended = _iso(now) result = { - "job": entry["job"], "kind": kind, "base": entry.get("base"), "block": entry.get("block"), - "phase": entry.get("phase"), "status": status, "flags": flags, "fingerprint": fingerprint, + "job": entry["job"], "kind": kind, "method": entry.get("method"), "base": entry.get("base"), + "block": entry.get("block"), "phase": entry.get("phase"), "status": status, "flags": flags, "fingerprint": fingerprint, "session": session, "parent_session": entry.get("parent_session"), "models_used": models_used, "tokens": tokens, "cost_delta_usd": u["cost_delta"] if u else None, "cache": cache, "base_state": base_state, "wall_s": None if wall_s is None else round(float(wall_s), 1), @@ -1545,8 +1550,8 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: "paths": entry.get("paths"), "reason": reason, "model_requested": entry.get("model_requested"), } ledger = { - "job": entry["job"], "kind": kind, "phase": entry.get("phase"), "block": entry.get("block"), - "base": entry.get("base"), "session": session, "parent_session": entry.get("parent_session"), + "job": entry["job"], "kind": kind, "method": entry.get("method"), "phase": entry.get("phase"), + "block": entry.get("block"), "base": entry.get("base"), "session": session, "parent_session": entry.get("parent_session"), "started": entry.get("started_at") or entry.get("launched_at") or entry.get("created_at"), "ended": ended, "wall_s": result["wall_s"], "binary": entry.get("binary_version"), "model_requested": entry.get("model_requested"), "models_used": models_used, "tokens": tokens, @@ -1565,18 +1570,18 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: entry["first_event_at"] = _iso(ss.first_at) if u: entry["cost_cum_usd"], entry["model_usage_cum"] = u["total"], u["model_usage"] - holder = entry if kind == "fork" else reg["jobs"].get(entry.get("continues") or "") - if kind in ("fork", "continue") and holder is not None: + holder = entry if kind == "spawn" else reg["jobs"].get(entry.get("continues") or "") + if kind in ("spawn", "continue") and holder is not None: holder["session_cost_cum_usd"] = u["total"] holder["session_model_usage_cum"] = u["model_usage"] if base is not None: - # Design 5: only a request that reads exactly the base prefix refreshes it: a fork's first + # Design 5: only a request that reads exactly the base prefix refreshes it: a spawn's first # request, a ping that did not miss (and the training's end, which writes it). A continue - # reads the fork's own longer prefix, so it leaves the base's last_hit alone (I3). + # reads the gremlin's own longer prefix, so it leaves the base's last_hit alone (I3). hit_t = None if ss and kind == "train": hit_t = ss.last_assistant_at - elif ss and kind in ("fork", "rebase"): # a rebase's first request reads the base prefix + elif ss and kind in ("spawn", "rebase"): # a rebase's first request reads the base prefix hit_t = ss.first_at elif ping_refreshed: hit_t = ss.first_at @@ -1596,7 +1601,7 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: "model_usage_cum": u["model_usage"] if u else {}, "runtime_fingerprint": rt, "trained_at": ended}) # Minor 1 (`train --supersedes`): the bases this one replaces stop being active, - # so they get no more pings and need --force to fork. Only on `ok`. + # so they get no more pings and need --force to spawn from. Only on `ok`. for old_name in base.get("supersedes") or []: ob = reg["bases"].get(old_name) if ob is not None and ob.get("status") == "active" and old_name != base.get("name"): @@ -1634,7 +1639,7 @@ def _rebase_succeeded(reg: dict, entry: dict, old: dict, ss, u, rt, base_commit, "base_commit": base_commit, "context_tokens": context_tokens(ss.last_usage if ss else None), "training_cost_usd": u["cost_delta"] if u else None, "cost_cum_usd": u["total"] if u else None, "model_usage_cum": u["model_usage"] if u else {}, - "runtime_fingerprint": rt, "trained_at": ended, "forks": [], + "runtime_fingerprint": rt, "trained_at": ended, "gremlins": [], "ttl_s": infer_ttl(u["tokens"] if u else None, old.get("ttl_s")), "ttl_from": entry["job"], "ping_in_flight": None, } @@ -1651,14 +1656,14 @@ def finalize_job(state: str, job: str, *, ss: StreamState | None, hint: str, fla killed after it leaves no second finaliser to run (M1); the ledger append also skips a job that already has a line. Returns the result, or None when the job was already final. `head_at_end` (HEAD of the job's cwd when the call ended) is recorded on the registry entry - for `usage`'s Commit column; a fork or continue finalised without one (killed, lost) reads + for `usage`'s Commit column; a spawn or continue finalised without one (killed, lost) reads HEAD now.""" with RegistryLock(state): reg = read_registry(state) entry = (reg or {}).get("jobs", {}).get(job) if not entry or entry.get("status") not in RUNNING: return None - if head_at_end is None and entry.get("kind") in ("fork", "continue") and entry.get("cwd"): + if head_at_end is None and entry.get("kind") in ("spawn", "continue") and entry.get("cwd"): head_at_end = _git_head(entry["cwd"]) if head_at_end: entry["head_at_end"] = head_at_end @@ -1671,7 +1676,7 @@ def finalize_job(state: str, job: str, *, ss: StreamState | None, hint: str, fla def bump_last_hit(state: str, base_name: str, t: float) -> None: - """Record a hit on the base prefix (first assistant event of a fork) as it happens.""" + """Record a hit on the base prefix (first assistant event of a spawn) as it happens.""" with RegistryLock(state): reg = read_registry(state) base = (reg or {}).get("bases", {}).get(base_name) @@ -1682,14 +1687,14 @@ def bump_last_hit(state: str, base_name: str, t: float) -> None: def in_child() -> bool: - """N3: this call comes from inside a fork (`SPIRE_FORKS_CHILD` is set).""" + """N3: this call comes from inside a gremlin (`SPIRE_GREMLIN_CHILD` is set).""" return bool(os.environ.get(CHILD_MARKER)) def stamp_orchestrator(reg: dict, t: float | None = None, wait: bool = False) -> None: """N1: set `run.last_orchestrator_call_at` in `reg` (caller holds the lock and writes it); `wait=True` also sets `run.last_wait_at` (the ping rule's activity condition, design 5). - A fork's own calls never stamp, so a fork cannot keep its own watchdog alive (M4).""" + A gremlin's own calls never stamp, so a gremlin cannot keep its own watchdog alive (M4).""" if in_child(): return run = reg.setdefault("run", {}) @@ -1699,7 +1704,7 @@ def stamp_orchestrator(reg: dict, t: float | None = None, wait: bool = False) -> def touch_orchestrator(state: str, wait: bool = False) -> None: - """N1: record that the orchestrator called us (wait, fork, continue, train, list).""" + """N1: record that the orchestrator called us (wait, spawn, continue, train, list).""" if in_child(): return with RegistryLock(state): @@ -1713,7 +1718,7 @@ def touch_orchestrator(state: str, wait: bool = False) -> None: def _job_runtime(state: str, job: str) -> dict: try: return read_json(job_file(state, job)) or {} - except ForksError: + except GremlinsError: return {} @@ -1760,7 +1765,7 @@ def kill_job(state: str, job: str, by: str) -> dict: reg = read_registry(state) entry = reg["jobs"].get(job) if entry is None: - raise ForksError("unknown job", job=job) + raise GremlinsError("unknown job", job=job) if entry.get("status") not in RUNNING: return {"job": job, "killed": False, "status": entry.get("status"), "ok": True} entry["kill_requested"] = {"by": by, "at": _iso()} @@ -1793,7 +1798,7 @@ def kill_job(state: str, job: str, by: str) -> dict: class _Parser(argparse.ArgumentParser): def error(self, message): - raise ForksError("usage", detail=message, usage=self.format_usage().strip()) + raise GremlinsError("usage", detail=message, usage=self.format_usage().strip()) def print_help(self, file=None): raise _HelpRequested(self.format_help()) @@ -1801,7 +1806,7 @@ class _Parser(argparse.ArgumentParser): def exit(self, status=0, message=None): if message: _diag(message.strip()) - raise ForksError("usage", detail=(message or "").strip() or "argument parser exit") + raise GremlinsError("usage", detail=(message or "").strip() or "argument parser exit") def _split_top_level_commas(s: str) -> list[str]: @@ -1850,12 +1855,12 @@ def _pos_float(s: str) -> float: return v -COMMANDS = ("where", "init", "train", "fork", "continue", "wait", "list", "expire", "kill", +COMMANDS = ("where", "init", "train", "spawn", "continue", "wait", "list", "expire", "kill", "rebase", "usage", "tick", "_supervise") def build_parser() -> _Parser: - p = _Parser(prog="forks.py", description="spire:forks helper (one JSON object on stdout).") + p = _Parser(prog="gremlins.py", description="spire:gremlins helper (one JSON object on stdout).") sub = p.add_subparsers(dest="command", metavar="command", parser_class=_Parser) sub.required = True @@ -1882,7 +1887,7 @@ def build_parser() -> _Parser: i.add_argument("--disallowed-tools", nargs="+", action="extend", default=None, help="deny rules (default: git push/rebase/reset --hard, claude)") i.add_argument("--agent", help="project agent name (/.claude/agents/.md)") - i.add_argument("--cwd", help="working dir of base and forks (default: git toplevel)") + i.add_argument("--cwd", help="working dir of the base and its gremlins (default: git toplevel)") i.add_argument("--binary", help="claude binary (default: $CLAUDE_CODE_EXECPATH, else PATH)") i.add_argument("--parallel-cap", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["parallel_cap"]) i.add_argument("--orphan-after", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["orphan_after"]) @@ -1912,7 +1917,7 @@ def build_parser() -> _Parser: g.add_argument("--allowed-tools", nargs="+", action="extend", default=None) g.add_argument("--disallowed-tools", nargs="+", action="extend", default=None) g.add_argument("--agent", help="project agent name (/.claude/agents/.md)") - g.add_argument("--cwd", help="working dir of the base and its forks") + g.add_argument("--cwd", help="working dir of the base and its gremlins") g.add_argument("--binary", help="claude binary") t.add_argument("--supersedes", action="append", default=None, metavar="BASE", help="a base this one replaces (repeatable): when the training ends ok, each " @@ -1928,22 +1933,24 @@ def build_parser() -> _Parser: else: g.add_argument(flag, default=None, help=argparse.SUPPRESS) - f = cmd("fork", "Fork a base for one block.") + f = cmd("spawn", "Spawn a gremlin from a base for one block.") f.add_argument("--base", required=True) f.add_argument("--block", required=True) f.add_argument("--phase") f.add_argument("--brief", required=True) + f.add_argument("--method", choices=SPAWN_METHODS, default="fork", + help="how the gremlin is made (fork: a fork of the base's session; the default)") f.add_argument("--mode", choices=("sequential", "parallel"), default="sequential") f.add_argument("--paths", nargs="+", action="extend", default=None) f.add_argument("--max-minutes", type=_pos_float, help="wall-time limit (default: init's max_minutes)") f.add_argument("--max-budget-usd", type=_pos_float) - f.add_argument("--force", action="store_true", help="fork a base that is not active") + f.add_argument("--force", action="store_true", help="spawn from a base that is not active") f.add_argument("--delta-note", action="append", default=None, metavar="PATH", dest="delta_notes", - help="a note the fork must learn (repeatable): appended to the prompt after the " + help="a note the gremlin must learn (repeatable): appended to the prompt after the " "delta header; counts toward delta_tokens when newer than the base's training") launch_overrides(f) - c = cmd("continue", "One more turn in a fork (resume without --fork-session).") + c = cmd("continue", "One more turn in a gremlin (resume without --fork-session).") c.add_argument("--job", required=True) c.add_argument("--brief", required=True) c.add_argument("--max-minutes", type=_pos_float) @@ -1970,11 +1977,11 @@ def build_parser() -> _Parser: cmd("tick", "One keep-warm pass: ping every base the rule picks (design 5).") - r = cmd("rebase", "Fork a base to absorb what changed; its session becomes a new base.") + r = cmd("rebase", "Fork a base's session to absorb what changed; that session becomes a new base.") r.add_argument("--base", required=True) r.add_argument("--as", dest="as_name", required=True, help="name of the new base") r.add_argument("--brief", required=True) - r.add_argument("--phase", help="the new base's phase (the phase whose forks it serves)") + r.add_argument("--phase", help="the new base's phase (the phase whose gremlins it serves)") r.add_argument("--max-minutes", type=_pos_float, help="wall-time limit (default: init's max_minutes)") r.add_argument("--max-budget-usd", type=_pos_float) r.add_argument("--delta-note", action="append", default=None, metavar="PATH", dest="delta_notes", @@ -1983,7 +1990,7 @@ def build_parser() -> _Parser: u = cmd("usage", "Aggregate the ledger: tokens and cost per kind, cold starts, cache-hit ratio.") ug = u.add_mutually_exclusive_group() - ug.add_argument("--block", help="only this block's calls (a fork and its continues)") + ug.add_argument("--block", help="only this block's calls (a spawn and its continues)") ug.add_argument("--phase", help="only this phase's calls") u.add_argument("--format", choices=("json", "md"), default="json", help="json (default) or md: one markdown fragment on stdout instead of JSON") @@ -2010,14 +2017,14 @@ def cmd_where(args) -> tuple[dict, int]: else: try: binary.update(binary_info(path)) - except ForksError as e: + except GremlinsError as e: binary["error"] = e.payload["error"] reasons.append("%s: %s" % (e.payload["error"], path)) warnings = [] base_url = os.environ.get("ANTHROPIC_BASE_URL") if base_url is not None and base_url.strip().rstrip("/").lower() != DEFAULT_BASE_URL: warnings.append("ANTHROPIC_BASE_URL is %r, not the default %s; it is passed to every " - "base and fork, so they authenticate and bill through that endpoint" + "base and gremlin, so they authenticate and bill through that endpoint" % (base_url, DEFAULT_BASE_URL)) out = { "script": script_path(), @@ -2025,7 +2032,7 @@ def cmd_where(args) -> tuple[dict, int]: "uv": _uv_report(), "binary": binary, "git": _git_report(), - "spawn": SPAWN_METHOD, + "spawn": DETACH_METHOD, "scrub": scrub_report(), "platform": sys.platform, "warnings": warnings, @@ -2044,9 +2051,9 @@ def _resolve_cwd(arg: str | None) -> str: else: cwd = git_toplevel() if not cwd: - raise ForksError("no --cwd given and the current directory is not inside a git repo") + raise GremlinsError("no --cwd given and the current directory is not inside a git repo") if not os.path.isdir(cwd): - raise ForksError("--cwd is not a directory", cwd=cwd) + raise GremlinsError("--cwd is not a directory", cwd=cwd) return cwd @@ -2060,45 +2067,45 @@ def build_launch(state: str, *, model, effort, permission_mode, tools, allowed, design 3.3). `tools`/`allowed`/`disallowed` are lists, `cwd` and `binary_path` normalised. Returns (launch, model admission `via`). Writes nothing.""" if not MODEL_RE.match(model or ""): - raise ForksError("model must be a full id (aliases are refused)", model=model, - pattern=MODEL_RE.pattern) + raise GremlinsError("model must be a full id (aliases are refused)", model=model, + pattern=MODEL_RE.pattern) mode = permission_mode if mode in REFUSED_PERMISSION_MODES: - raise ForksError("permission mode refused", permission_mode=mode, - detail="plan mode swaps models; use dontAsk") + raise GremlinsError("permission mode refused", permission_mode=mode, + detail="plan mode swaps models; use dontAsk") if mode not in PERMISSION_MODES: - raise ForksError("unknown permission mode", permission_mode=mode, - allowed=[m for m in PERMISSION_MODES if m not in REFUSED_PERMISSION_MODES]) + raise GremlinsError("unknown permission mode", permission_mode=mode, + allowed=[m for m in PERMISSION_MODES if m not in REFUSED_PERMISSION_MODES]) if effort not in EFFORTS: - raise ForksError("unknown effort", effort=effort, allowed=list(EFFORTS)) + raise GremlinsError("unknown effort", effort=effort, allowed=list(EFFORTS)) if not tools: - raise ForksError("--tools is empty") + raise GremlinsError("--tools is empty") if not os.path.isdir(cwd): - raise ForksError("--cwd is not a directory", cwd=cwd) + raise GremlinsError("--cwd is not a directory", cwd=cwd) adm = model_admission(model, cwd) if not adm["admitted"]: files = [x["file"] for x in adm["lists"]] - raise ForksError( + raise GremlinsError( "model not admitted by availableModels", model=model, files=files, message=("Model %s is not admitted by the availableModels list(s) in: %s. The user must " - "add the id (or its family alias) to one of these files; forks never writes " + "add the id (or its family alias) to one of these files; gremlins never writes " "settings." % (model, ", ".join(files)))) binfo = binary_info(binary_path) pre = preamble_path() if not os.path.isfile(pre): - raise ForksError("preamble.md missing next to forks.py", path=pre) + raise GremlinsError("preamble.md missing next to gremlins.py", path=pre) agent_rec = None if agent: name = agent if not re.match(r"^[A-Za-z0-9_.:-]+$", name) or name in (".", ".."): - raise ForksError("bad agent name", agent=name) + raise GremlinsError("bad agent name", agent=name) apath = agent_path(cwd, name) if not os.path.isfile(apath): - raise ForksError("agent file not found", agent=name, path=apath) + raise GremlinsError("agent file not found", agent=name, path=apath) agent_rec = {"name": name, "path": apath, "sha256": _sha256_file(apath)} launch = { @@ -2134,7 +2141,7 @@ def build_init_config(args, state: str, old: dict | None = None) -> dict: return default model = args.model if args.model is not None else ol.get("model") if model is None: # what argparse said when --model was required: a usage error - raise ForksError("usage", detail="the following arguments are required on the first init: --model") + raise GremlinsError("usage", detail="the following arguments are required on the first init: --model") tools = _tool_list([args.tools]) if args.tools is not None else \ (list(ol["tools"]) if ol.get("tools") else _tool_list([DEFAULT_TOOLS])) allowed = _tool_list(args.allowed_tools) @@ -2146,18 +2153,18 @@ def build_init_config(args, state: str, old: dict | None = None) -> dict: if args.cwd is None and ol.get("cwd"): cwd = ol["cwd"] if not os.path.isdir(cwd): - raise ForksError("--cwd is not a directory", cwd=cwd) + raise GremlinsError("--cwd is not a directory", cwd=cwd) else: cwd = _resolve_cwd(args.cwd) if not MODEL_RE.match(model or ""): # before the binary lookup, as in Phase 1 - raise ForksError("model must be a full id (aliases are refused)", model=model, - pattern=MODEL_RE.pattern) + raise GremlinsError("model must be a full id (aliases are refused)", model=model, + pattern=MODEL_RE.pattern) if args.binary is None and (ol.get("binary") or {}).get("path"): bpath, how = ol["binary"]["path"], (old or {}).get("binary_resolved_by") else: bpath, how = resolve_binary(args.binary) if bpath is None: - raise ForksError("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)") + raise GremlinsError("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)") agent = args.agent if args.agent is not None else (ol.get("agent") or {}).get("name") launch, via = build_launch(state, model=model, effort=args.effort if args.effort is not None else ol.get("effort") or "high", @@ -2183,8 +2190,8 @@ def build_init_config(args, state: str, old: dict | None = None) -> dict: } # Minor 2: a margin at or above the TTL makes a base `expiring` right after every hit. if cfg["warm_margin_s"] is not None and int(cfg["warm_margin_s"]) >= int(cfg["ttl_s"]): - raise ForksError("--warm-margin-s must be below ttl_s (a base would read `expiring` right " - "after every hit)", warm_margin_s=cfg["warm_margin_s"], ttl_s=cfg["ttl_s"]) + raise GremlinsError("--warm-margin-s must be below ttl_s (a base would read `expiring` right " + "after every hit)", warm_margin_s=cfg["warm_margin_s"], ttl_s=cfg["ttl_s"]) return cfg @@ -2206,7 +2213,7 @@ def cmd_init(args) -> tuple[dict, int]: with RegistryLock(state): reg = read_registry(state) if (reg or {}).get("config") != old_cfg: - raise ForksError("the config changed while init ran; run init again", state=state) + raise GremlinsError("the config changed while init ran; run init again", state=state) created = reg is None if created: reg = new_registry() @@ -2235,23 +2242,23 @@ def cmd_init(args) -> tuple[dict, int]: "bases": sorted(reg.get("bases", {})), "config": cfg}, EXIT_OK -# --- launching: train, fork, continue ------------------------------------------------- +# --- launching: train, spawn, continue ------------------------------------------------ def _load_run(args) -> tuple[str, dict]: state = state_dir_for(args.run) reg = read_registry(state) if os.path.isdir(state) else None if not reg or not reg.get("config"): - raise ForksError("no launch config for this run; call init first", state=state) + raise GremlinsError("no launch config for this run; call init first", state=state) return state, reg def _refuse_overrides(args) -> None: given = [n for n in LAUNCH_FIELDS if getattr(args, n, None) is not None] if given: - raise ForksError("launch fields are fixed at init; %s reads them from the base record and " - "cannot override them (a different value needs a new base)" % args.command, - fields=given, command=args.command) + raise GremlinsError("launch fields are fixed at init; %s reads them from the base record and " + "cannot override them (a different value needs a new base)" % args.command, + fields=given, command=args.command) def _read_brief(path: str) -> tuple[str, str]: @@ -2260,16 +2267,16 @@ def _read_brief(path: str) -> tuple[str, str]: with open(p, "r", encoding="utf-8") as f: return p, f.read() except OSError as e: - raise ForksError("cannot read brief", brief=p, detail=str(e)) + raise GremlinsError("cannot read brief", brief=p, detail=str(e)) def _check_binary(ref: dict, launch: dict) -> tuple[dict, list[str]]: """Launch-time binary re-check (version, size, mtime) against `ref`; also the hashed files.""" path = launch["binary"]["path"] if not os.path.isfile(path): - raise ForksError("claude binary missing (the stored path is gone); the base cannot be " - "launched with another binary", binary=path, reason="binary_missing", - retrain_due=True, reasons=["binary_missing"]) + raise GremlinsError("claude binary missing (the stored path is gone); the base cannot be " + "launched with another binary", binary=path, reason="binary_missing", + retrain_due=True, reasons=["binary_missing"]) info = binary_info(path) drift = [] if all(info.get(k) == (ref or {}).get(k) for k in ("version", "size", "mtime")) else ["binary"] asp = launch["append_system_prompt"] @@ -2357,7 +2364,7 @@ def git_delta_counts(cwd: str, base_commit: str) -> tuple[int | None, int | None return commits, lines -# I1 (Phase 2 review): "delta" is what a fork must learn that its base does not already know. +# I1 (Phase 2 review): "delta" is what a gremlin must learn that its base does not already know. # delta_tokens = chars / 4 of the delta header (commits and shortstat since the base's # base_commit) + the notes attached with `--delta-note` whose mtime is after the base's # `trained_at` (what the base was trained or rebased on it already knows) + the text of a @@ -2392,7 +2399,7 @@ def read_delta_notes(paths) -> list[dict]: text = read_bytes_retry(p).decode("utf-8", "replace") mtime = os.path.getmtime(p) except OSError as e: - raise ForksError("cannot read delta note", note=p, detail=str(e)) + raise GremlinsError("cannot read delta note", note=p, detail=str(e)) out.append({"path": p, "text": text, "mtime": mtime}) return out @@ -2472,15 +2479,15 @@ def _int_phase(p) -> bool: def phase_boundary(reg: dict, name: str, phase) -> bool: - """The fork's `--phase` is greater than the highest phase of any fork from this base, or than + """The spawn's `--phase` is greater than the highest phase of any spawn from this base, or than the base's own phase (integers only; a side that is not known does not count).""" if not _int_phase(phase): return False base = reg["bases"][name] - forks_ = [e.get("phase") for e in reg.get("jobs", {}).values() - if e.get("base") == name and e.get("kind") == "fork" and _int_phase(e.get("phase"))] + spawned_phases = [e.get("phase") for e in reg.get("jobs", {}).values() + if e.get("base") == name and e.get("kind") == "spawn" and _int_phase(e.get("phase"))] own = base.get("phase") - return bool(forks_ and phase > max(forks_)) or bool(_int_phase(own) and phase > own) + return bool(spawned_phases and phase > max(spawned_phases)) or bool(_int_phase(own) and phase > own) def last_runtime_drift(reg: dict, name: str) -> list[str]: @@ -2537,7 +2544,7 @@ def binary_state(base: dict, launch: dict, cache: dict | None = None) -> str: if path not in cache: try: cache[path] = binary_info(path) - except ForksError: + except GremlinsError: cache[path] = None info = cache[path] ref = base.get("binary") or launch["binary"] @@ -2550,8 +2557,8 @@ def due_flags(state: str, reg: dict, name: str, *, phase=None, brief: str | None delta: str | None = None, notes: list[dict] | None = None, binary: str | None = None, bcache: dict | None = None) -> dict: """Design 5's rebase and re-train flags for base `name`, with their reasons (advisory: only - `binary_missing` stops a launch). `phase`/`brief`/`notes` are the fork's (none in `list`, which - counts the delta header alone); `delta` the fork's delta header (computed here when not + `binary_missing` stops a launch). `phase`/`brief`/`notes` are the spawn's (none in `list`, which + counts the delta header alone); `delta` the spawn's delta header (computed here when not given); `notes` its `--delta-note` files (`read_delta_notes`); `binary` a `binary_state` value.""" cfg = reg["config"] @@ -2618,8 +2625,8 @@ def _pending_rebase_to(reg: dict, name: str) -> str | None: def _new_entry(job: str, kind: str, **kw) -> dict: - e = {"job": job, "kind": kind, "base": None, "block": None, "phase": None, "mode": None, - "paths": None, "status": "created", "flags": [], "reason": None, "session": None, + e = {"job": job, "kind": kind, "method": None, "base": None, "block": None, "phase": None, + "mode": None, "paths": None, "status": "created", "flags": [], "reason": None, "session": None, "parent_session": None, "model_requested": None, "created_at": _iso(), "created_ts": _now(), "launched_at": None, "ended_at": None, "supervisor": None, "cwd": None, "argv": None, @@ -2636,7 +2643,7 @@ def _start_job(state: str, run: str, job: str, prompt: str, launch: dict) -> dic job as `failed`, reason `spawn_failed`.""" atomic_write_bytes(os.path.join(state, "briefs", job + ".md"), prompt.encode("utf-8")) write_json_atomic(raw_file(state, job, ".env.json"), scrubbed_env(launch["binary"]["path"])) - sp = spawn_supervisor(state, run, job) + sp = detach_supervisor(state, run, job) if not sp.get("ok"): finalize_job(state, job, ss=None, hint="failed", reason="spawn_failed", detail=sp.get("detail")) return sp @@ -2665,8 +2672,8 @@ def train_launch(args, state: str, cfg: dict) -> tuple[dict, list[str]]: cwd = norm_path(args.cwd) if args.cwd is not None else la["cwd"] bpath = norm_path(args.binary) if args.binary is not None else la["binary"]["path"] if not os.path.isfile(bpath): - raise ForksError("claude binary missing (the stored path is gone); train with --binary or " - "re-run init with another binary", binary=bpath, reason="binary_missing") + raise GremlinsError("claude binary missing (the stored path is gone); train with --binary or " + "re-run init with another binary", binary=bpath, reason="binary_missing") launch, _ = build_launch( state, model=args.model if args.model is not None else la["model"], effort=args.effort if args.effort is not None else la["effort"], @@ -2679,16 +2686,16 @@ def cmd_train(args) -> tuple[dict, int]: state, reg = _load_run(args) name = args.base if not re.fullmatch(r"[A-Za-z0-9_.-]+", name or ""): - raise ForksError("bad base name", base=name) + raise GremlinsError("bad base name", base=name) cfg = reg["config"] supersedes = _uniq(args.supersedes or []) def check_supersedes(r): if name in supersedes: - raise ForksError("a base cannot supersede itself", base=name) + raise GremlinsError("a base cannot supersede itself", base=name) unknown = [n for n in supersedes if n not in r.get("bases", {})] if unknown: - raise ForksError("unknown base", bases=unknown, option="--supersedes") + raise GremlinsError("unknown base", bases=unknown, option="--supersedes") check_supersedes(reg) la, overrides = train_launch(args, state, cfg) binfo = la["binary"] @@ -2702,10 +2709,10 @@ def cmd_train(args) -> tuple[dict, int]: stamp_orchestrator(reg) old = reg["bases"].get(name) if old and old.get("status") != "failed": - raise ForksError("base exists", base=name, status=old.get("status")) + raise GremlinsError("base exists", base=name, status=old.get("status")) pending = _pending_rebase_to(reg, name) if pending: - raise ForksError("a running rebase creates this base", base=name, job=pending) + raise GremlinsError("a running rebase creates this base", base=name, job=pending) check_supersedes(reg) job = _next_job_id(reg) reg["bases"][name] = {"name": name, "status": "training", "session": session, "cwd": la["cwd"], @@ -2713,7 +2720,7 @@ def cmd_train(args) -> tuple[dict, int]: "binary": binfo, "created_at": _iso(), "train_job": job, "phase": _phase(args.phase), "last_hit": None, "parent": None, "lineage_rebases": 0, "base_commit": None, "context_tokens": None, - "forks": [], "supersedes": supersedes} + "gremlins": [], "supersedes": supersedes} reg["jobs"][job] = _new_entry( job, "train", base=name, block=args.block, phase=_phase(args.phase), session=session, model_requested=la["model"], cwd=la["cwd"], argv=argv, max_s=float(cfg["max_minutes"]) * 60, @@ -2727,21 +2734,22 @@ def cmd_train(args) -> tuple[dict, int]: return {"job": job, "base": name, "session_id": session}, EXIT_OK -def cmd_fork(args) -> tuple[dict, int]: +def cmd_spawn(args) -> tuple[dict, int]: _refuse_overrides(args) if args.mode == "parallel" and not args.paths: - raise ForksError("--mode parallel needs --paths (the files this fork may touch)", mode="parallel") + raise GremlinsError("--mode parallel needs --paths (the files this gremlin may touch)", + mode="parallel") state, reg = _load_run(args) cfg = reg["config"] base = reg["bases"].get(args.base) if base is None: - raise ForksError("unknown base", base=args.base, bases=sorted(reg["bases"])) + raise GremlinsError("unknown base", base=args.base, bases=sorted(reg["bases"])) la = launch_record(base, cfg) # M10: every launch on a base uses the base's record if base.get("status") != "active" and not args.force: - raise ForksError("base is not active (use --force to fork it anyway)", base=args.base, - status=base.get("status")) + raise GremlinsError("base is not active (use --force to spawn from it anyway)", base=args.base, + status=base.get("status")) if not base.get("session"): - raise ForksError("base has no session", base=args.base, status=base.get("status")) + raise GremlinsError("base has no session", base=args.base, status=base.get("status")) brief_path, brief = _read_brief(args.brief) notes = read_delta_notes(args.delta_notes) binfo, drift = _check_binary(base.get("binary") or la["binary"], la) @@ -2760,15 +2768,16 @@ def cmd_fork(args) -> tuple[dict, int]: stamp_orchestrator(reg) base = reg["bases"][args.base] if base.get("status") != "active" and not args.force: - raise ForksError("base is not active (use --force to fork it anyway)", base=args.base, - status=base.get("status")) + raise GremlinsError("base is not active (use --force to spawn from it anyway)", base=args.base, + status=base.get("status")) running = _running_capped(reg) if len(running) >= int(cfg["parallel_cap"]): - raise ForksError("parallel cap reached", parallel_cap=cfg["parallel_cap"], running=running) + raise GremlinsError("parallel cap reached", parallel_cap=cfg["parallel_cap"], running=running) job = _next_job_id(reg) warmth = base_warmth(base, cfg) reg["jobs"][job] = _new_entry( - job, "fork", base=args.base, block=args.block, phase=_phase(args.phase), mode=args.mode, + job, "spawn", method=args.method, base=args.base, block=args.block, phase=_phase(args.phase), + mode=args.mode, paths=paths, session=session, parent_session=base["session"], model_requested=la["model"], cwd=la["cwd"], argv=argv, max_s=float(args.max_minutes if args.max_minutes else cfg["max_minutes"]) * 60, @@ -2779,24 +2788,24 @@ def cmd_fork(args) -> tuple[dict, int]: binary_version=binfo["version"], head_at_launch=head, brief_source=brief_path, forced=bool(args.force and base.get("status") != "active"), delta_notes=[n["path"] for n in notes]) - base.setdefault("forks", []).append(job) + base.setdefault("gremlins", []).append(job) write_registry(state, reg) if args.mode == "parallel": - commit_rule = ("This fork runs in parallel with other forks: do NOT commit and do not stage. " + commit_rule = ("This gremlin runs in parallel with other gremlins: do NOT commit and do not stage. " "Touch only these paths: %s. The orchestrator commits after its gate." % ", ".join("`%s`" % p for p in paths)) else: - commit_rule = ("This fork commits: after your gate passes, stage and commit your block by " + commit_rule = ("This gremlin commits: after your gate passes, stage and commit your block by " "explicit paths only (never `git add -A` or `.`).") if paths: commit_rule += " Expected paths: %s." % ", ".join("`%s`" % p for p in paths) - prompt = render_template("fork.md", { + prompt = render_template("spawn.md", { "base": args.base, "run": norm_path(args.run), "state_dir": state, "block": args.block, "phase": "n/a" if args.phase is None else str(args.phase), "mode": args.mode, "delta": render_delta(delta, notes), "commit_rule": commit_rule, "note_path": os.path.join(state, *note_rel(args.block).split("/")), "logs_dir": os.path.join(state, "logs"), "brief": brief}) _start_job(state, norm_path(args.run), job, prompt, la) - return {"job": job, "base": args.base, "base_state": warmth["base_state"], + return {"job": job, "base": args.base, "method": args.method, "base_state": warmth["base_state"], "warm_until": warmth["warm_until"], "rebase_due": due["rebase_due"], "retrain_due": due["retrain_due"], "reasons": due["reasons"]}, EXIT_OK @@ -2807,19 +2816,19 @@ def cmd_continue(args) -> tuple[dict, int]: cfg = reg["config"] fj = reg["jobs"].get(args.job) if fj is None: - raise ForksError("unknown job", job=args.job) - if fj.get("kind") != "fork": - raise ForksError("continue takes a fork job", job=args.job, kind=fj.get("kind")) + raise GremlinsError("unknown job", job=args.job) + if fj.get("kind") != "spawn": + raise GremlinsError("continue takes a spawn job", job=args.job, kind=fj.get("kind")) def busy(r): return [j for j, e in r["jobs"].items() if e.get("status") in RUNNING and (j == args.job or (e.get("kind") == "continue" and e.get("continues") == args.job))] if busy(reg): - raise ForksError("the fork is still running", job=args.job, running=busy(reg)) + raise GremlinsError("the gremlin is still running", job=args.job, running=busy(reg)) if not fj.get("session"): - raise ForksError("the fork has no session", job=args.job) + raise GremlinsError("the gremlin has no session", job=args.job) base = reg["bases"].get(fj.get("base")) or {} - la = launch_record(base, cfg) # M10: the fork's base's record + la = launch_record(base, cfg) # M10: the gremlin's base's record brief_path, brief = _read_brief(args.brief) binfo, drift = _check_binary(base.get("binary") or la["binary"], la) head = _git_head(la["cwd"]) @@ -2830,10 +2839,10 @@ def cmd_continue(args) -> tuple[dict, int]: stamp_orchestrator(reg) fj = reg["jobs"][args.job] if busy(reg): - raise ForksError("the fork is still running", job=args.job, running=busy(reg)) + raise GremlinsError("the gremlin is still running", job=args.job, running=busy(reg)) running = _running_capped(reg) if len(running) >= int(cfg["parallel_cap"]): - raise ForksError("parallel cap reached", parallel_cap=cfg["parallel_cap"], running=running) + raise GremlinsError("parallel cap reached", parallel_cap=cfg["parallel_cap"], running=running) base = reg["bases"].get(fj.get("base")) or {} cum = fj.get("session_cost_cum_usd") job = _next_job_id(reg) @@ -2864,14 +2873,14 @@ def cmd_rebase(args) -> tuple[dict, int]: cfg = reg["config"] new = args.as_name if not re.fullmatch(r"[A-Za-z0-9_.-]+", new or ""): - raise ForksError("bad base name", base=new) + raise GremlinsError("bad base name", base=new) base = reg["bases"].get(args.base) if base is None: - raise ForksError("unknown base", base=args.base, bases=sorted(reg["bases"])) + raise GremlinsError("unknown base", base=args.base, bases=sorted(reg["bases"])) if new == args.base: - raise ForksError("--as must name a new base", base=new) + raise GremlinsError("--as must name a new base", base=new) if base.get("status") != "active" or not base.get("session"): - raise ForksError("base is not active", base=args.base, status=base.get("status")) + raise GremlinsError("base is not active", base=args.base, status=base.get("status")) la = launch_record(base, cfg) brief_path, brief = _read_brief(args.brief) notes = read_delta_notes(args.delta_notes) @@ -2891,20 +2900,20 @@ def cmd_rebase(args) -> tuple[dict, int]: stamp_orchestrator(reg) base = reg["bases"][args.base] if base.get("status") != "active" or not base.get("session"): - raise ForksError("base is not active", base=args.base, status=base.get("status")) + raise GremlinsError("base is not active", base=args.base, status=base.get("status")) old = reg["bases"].get(new) if old and old.get("status") != "failed": - raise ForksError("base exists", base=new, status=old.get("status")) + raise GremlinsError("base exists", base=new, status=old.get("status")) pending = _pending_rebase_to(reg, new) if pending: - raise ForksError("a running rebase creates this base", base=new, job=pending) + raise GremlinsError("a running rebase creates this base", base=new, job=pending) busy = sorted(j for j, e in reg["jobs"].items() if e.get("kind") == "rebase" and e.get("base") == args.base and e.get("status") in RUNNING) if busy: - raise ForksError("the base is being rebased", base=args.base, running=busy) + raise GremlinsError("the base is being rebased", base=args.base, running=busy) running = _running_capped(reg) if len(running) >= int(cfg["parallel_cap"]): - raise ForksError("parallel cap reached", parallel_cap=cfg["parallel_cap"], running=running) + raise GremlinsError("parallel cap reached", parallel_cap=cfg["parallel_cap"], running=running) job = _next_job_id(reg) warmth = base_warmth(base, cfg) reg["jobs"][job] = _new_entry( @@ -2932,7 +2941,7 @@ def cmd_rebase(args) -> tuple[dict, int]: def run_for_state(state: str) -> str: """The run log path of a state dir (inverse of `state_dir_for`).""" - return state[: -len("-forks")] + ".md" + return state[: -len("-gremlins")] + ".md" def launch_record(base: dict | None, cfg: dict) -> dict: @@ -2958,7 +2967,7 @@ def pings_since_real_call(reg: dict, name: str) -> int: def ping_phase(reg: dict, name: str): """I2: the phase a ping on base `name` belongs to: that of the latest (by job number) train, - fork, continue or rebase job on the base that has a phase; else the base's own phase; else + spawn, continue or rebase job on the base that has a phase; else the base's own phase; else None. So `usage --phase N` carries the keep-warm cost of the phase's bases.""" best, ph = -1, None for j, e in reg.get("jobs", {}).items(): @@ -2980,13 +2989,13 @@ def ping_in_flight(reg: dict, base: dict | None) -> str | None: def run_active(reg: dict, now: float) -> bool: - """Design 5's activity condition: a fork/continue job ran, or `wait` was called, in the last + """Design 5's activity condition: a spawn/continue job ran, or `wait` was called, in the last ACTIVITY_WINDOW_S (the Athanor is in its turn).""" lw = _ts((reg.get("run") or {}).get("last_wait_at")) if lw is not None and now - lw <= ACTIVITY_WINDOW_S: return True for e in reg.get("jobs", {}).values(): - if e.get("kind") not in ("fork", "continue"): + if e.get("kind") not in ("spawn", "continue"): continue if e.get("status") in RUNNING: return True @@ -3044,7 +3053,7 @@ def tick_pass(state: str) -> dict: la = launch_record(base, reg["config"]) try: checks[n] = _check_binary(base.get("binary") or la["binary"], la) - except ForksError as e: + except GremlinsError as e: checks[n] = e launches = [] with RegistryLock(state): @@ -3056,7 +3065,7 @@ def tick_pass(state: str) -> dict: d = ping_decision(reg, n, now) if d["ping"] and n not in checks: d.update(ping=False, why="eligible_since_this_tick_began") # the next tick takes it - elif d["ping"] and isinstance(checks[n], ForksError): + elif d["ping"] and isinstance(checks[n], GremlinsError): d.update(ping=False, why=checks[n].payload.get("reason") or checks[n].payload["error"]) if d["ping"]: base = reg["bases"][n] @@ -3102,7 +3111,7 @@ def safe_tick(state: str, where: str) -> dict | None: def cmd_tick(args) -> tuple[dict, int]: - """`tick --run R`: one pass by hand (N3-refused in a fork).""" + """`tick --run R`: one pass by hand (N3-refused in a gremlin).""" state, _ = _load_run(args) reap_lost(state) out = tick_pass(state) @@ -3156,7 +3165,7 @@ class Supervisor: self.state, self.job = state, job reg = read_registry(state) if not reg or job not in reg.get("jobs", {}): - raise ForksError("unknown job", job=job, state=state) + raise GremlinsError("unknown job", job=job, state=state) self.cfg = reg["config"] self.entry = reg["jobs"][job] self.env = read_json(raw_file(state, job, ".env.json")) @@ -3238,8 +3247,8 @@ class Supervisor: stall_s = float(self.cfg.get("stall_after_s") or CONFIG_DEFAULTS["stall_after_s"]) orphan_s = float(self.cfg.get("orphan_after") or CONFIG_DEFAULTS["orphan_after"]) hint, reason, detail, flags_extra = "exited", None, None, [] - # only a fork's (or a rebase's) first request reads the base prefix and refreshes it (I3) - hit_written = e["kind"] not in ("fork", "rebase") + # only a spawn's (or a rebase's) first request reads the base prefix and refreshes it (I3) + hit_written = e["kind"] not in ("spawn", "rebase") clock = SupClock(t0) last_orphan_check = 0.0 last_messages = 0 @@ -3268,14 +3277,14 @@ class Supervisor: self.write_rt() if not hit_written and first_at: hit_written = True - with contextlib.suppress(ForksError, OSError): + with contextlib.suppress(GremlinsError, OSError): bump_last_hit(self.state, e["base"], first_at) if now - last_orphan_check >= ORPHAN_CHECK_S: last_orphan_check = now try: reg = read_registry(self.state) last = _ts(((reg or {}).get("run") or {}).get("last_orchestrator_call_at")) - except (ForksError, OSError): + except (GremlinsError, OSError): last = None if last is not None and clock.orphan_age(now, last) > orphan_s: kill_tree(crec, alive_until=time.time()) @@ -3321,7 +3330,7 @@ class Supervisor: def cmd_supervise(args) -> tuple[dict, int]: state = state_dir_for(args.run) if not os.path.isdir(os.path.join(state, "raw")) or not re.fullmatch(r"j\d+", args.job): - raise ForksError("no such job state", state=state, job=args.job) + raise GremlinsError("no such job state", state=state, job=args.job) log = open(raw_file(state, args.job, ".sup.log"), "a", encoding="utf-8", buffering=1) sys.stdout = sys.stderr = log _diag("supervisor %d for %s starting (%s)" % (os.getpid(), args.job, _iso())) @@ -3354,20 +3363,20 @@ def _running_info(state: str, job: str, e: dict, now: float) -> dict: def cmd_wait(args) -> tuple[dict, int]: state, reg = _load_run(args) if args.timeout > WAIT_MAX_S: - raise ForksError("--timeout over the maximum", timeout=args.timeout, max=WAIT_MAX_S) + raise GremlinsError("--timeout over the maximum", timeout=args.timeout, max=WAIT_MAX_S) if args.jobs and args.all: - raise ForksError("name jobs or --all, not both") + raise GremlinsError("name jobs or --all, not both") if not args.jobs and not args.all: - raise ForksError("name jobs or --all") + raise GremlinsError("name jobs or --all") unknown = [j for j in args.jobs if j not in reg["jobs"]] if unknown: - raise ForksError("unknown job", jobs=unknown) + raise GremlinsError("unknown job", jobs=unknown) pings = [j for j in args.jobs if reg["jobs"][j].get("kind") == "ping"] if pings: - raise ForksError("pings are not waited on; their results arrive in `pings`", jobs=pings) + raise GremlinsError("pings are not waited on; their results arrive in `pings`", jobs=pings) touch_orchestrator(state, wait=True) deadline = time.monotonic() + args.timeout - ticks = not in_child() # N3: a fork's `wait` never launches pings + ticks = not in_child() # N3: a gremlin's `wait` never launches pings next_tick = time.monotonic() # one tick pass at the start, then every TICK_WAIT_S def evaluate(r): @@ -3424,11 +3433,11 @@ def cmd_wait(args) -> tuple[dict, int]: return out, (EXIT_OK if all(s == "ok" for s in statuses.values()) else EXIT_JOB_FAILED) -LIST_JOB_KEYS = ("job", "kind", "base", "block", "phase", "mode", "paths", "status", "flags", "reason", +LIST_JOB_KEYS = ("job", "kind", "method", "base", "block", "phase", "mode", "paths", "status", "flags", "reason", "reason_detail", "session", "parent_session", "continues", "created_at", "launched_at", "ended_at", "fingerprint", "cache", "delivered_at", "settled_at") LIST_BASE_KEYS = ("name", "status", "session", "cwd", "base_commit", "context_tokens", - "training_cost_usd", "cost_cum_usd", "last_hit", "train_job", "forks", "phase", + "training_cost_usd", "cost_cum_usd", "last_hit", "train_job", "gremlins", "phase", "created_at", "trained_at", "ttl_from", "ping_in_flight", "parent", "lineage_rebases", "rebase_job", "superseded_by", "overrides", "fingerprint") @@ -3446,7 +3455,7 @@ def cmd_list(args) -> tuple[dict, int]: row = {k: b.get(k) for k in LIST_BASE_KEYS} row["name"] = name row.update(base_warmth(b, cfg, now)) - # The flags are computed for active bases only (no fork's --phase or brief here, so no + # The flags are computed for active bases only (no spawn's --phase or brief here, so no # phase_boundary and delta_tokens from the delta header alone). if b.get("status") == "active": row.update(due_flags(state, reg, name, bcache=bcache)) @@ -3469,7 +3478,7 @@ def cmd_list(args) -> tuple[dict, int]: def cmd_kill(args) -> tuple[dict, int]: state, reg = _load_run(args) if args.job not in reg["jobs"]: - raise ForksError("unknown job", job=args.job) + raise GremlinsError("unknown job", job=args.job) return kill_job(state, args.job, "kill"), EXIT_OK @@ -3478,7 +3487,7 @@ def cmd_expire(args) -> tuple[dict, int]: names = sorted(reg["bases"]) if args.all else [args.base] unknown = [n for n in names if n not in reg["bases"]] if unknown: - raise ForksError("unknown base", bases=unknown) + raise GremlinsError("unknown base", bases=unknown) retired = [] with RegistryLock(state): reg = read_registry(state) @@ -3498,8 +3507,8 @@ def cmd_expire(args) -> tuple[dict, int]: # --- usage (design 6, block 2.3) ------------------------------------------------------------ -USAGE_KINDS = ("train", "rebase", "fork", "continue", "ping") -HIT_KINDS = ("fork", "continue", "rebase") # the calls whose cache verdict counts for the ratio +USAGE_KINDS = ("train", "rebase", "spawn", "continue", "ping") +HIT_KINDS = ("spawn", "continue", "rebase") # the calls whose cache verdict counts for the ratio DASH = "—" USAGE_BLOCK_HEADER = ("| Block | Owner (model id from modelUsage) | Session | Commit | in | out | " "cache write | cache read | cost Δ |") @@ -3514,8 +3523,8 @@ def ledger_from_result(res: dict, entry: dict | None, mtime: float | None) -> di e = entry or {} ended = e.get("ended_at") or (_iso(mtime) if mtime else None) return { - "job": res["job"], "kind": res.get("kind"), "phase": res.get("phase"), - "block": res.get("block"), "base": res.get("base"), "session": res.get("session"), + "job": res["job"], "kind": res.get("kind"), "method": res.get("method"), + "phase": res.get("phase"), "block": res.get("block"), "base": res.get("base"), "session": res.get("session"), "parent_session": res.get("parent_session"), "started": e.get("started_at") or e.get("launched_at") or e.get("created_at"), "ended": ended, "wall_s": res.get("wall_s"), "binary": e.get("binary_version"), @@ -3553,7 +3562,7 @@ def reconcile_ledger(state: str) -> list[str]: try: res = read_json(path) mtime = os.path.getmtime(path) - except (ForksError, OSError) as ex: + except (GremlinsError, OSError) as ex: _diag("cannot reconcile %s: %s" % (job, ex)) continue if not isinstance(res, dict): @@ -3597,7 +3606,7 @@ def _usage_add(acc: dict, line: dict) -> None: def usage_aggregate(lines: list[dict]) -> dict: """Totals per kind (every kind present, zero when unused) and overall, cold starts, and the - cache-hit ratio = hits / (fork + continue + rebase calls that have a verdict).""" + cache-hit ratio = hits / (spawn + continue + rebase calls that have a verdict).""" kinds = {k: _usage_zero() for k in USAGE_KINDS} total = _usage_zero() hits = verdicts = cold = 0 @@ -3673,7 +3682,7 @@ def usage_md_phase(agg: dict) -> str: ratio = "%d/%d (%.1f%%)" % (agg["cache_hits"], agg["cache_verdicts"], 100.0 * agg["cache_hits"] / agg["cache_verdicts"]) else: - ratio = "n/a (no fork, continue or rebase call with a verdict)" + ratio = "n/a (no spawn, continue or rebase call with a verdict)" return "\n".join(rows) + "\n\n" + "\n".join([ "- Cold starts: %d" % agg["cold_starts"], "- Cache-hit ratio: %s" % ratio, @@ -3693,7 +3702,7 @@ def status_from_results(state: str, lines: list[dict]) -> tuple[list[dict], list for ln in lines: try: res = read_json(result_file(state, ln["job"])) - except ForksError: + except GremlinsError: res = None if isinstance(res, dict) and any(res.get(k) != ln.get(k) for k in RESULT_STATUS_KEYS): ln = dict(ln, status=res.get("status"), flags=list(res.get("flags") or []), @@ -3705,7 +3714,7 @@ def status_from_results(state: str, lines: list[dict]) -> tuple[list[dict], list def cmd_usage(args) -> tuple[dict | str, int]: """`usage --run R [--block ID | --phase N] [--format json|md]`. R1 first: reconcile the ledger - against `results/` under the lock. Allowed inside a fork (N3).""" + against `results/` under the lock. Allowed inside a gremlin (N3).""" state, _ = _load_run(args) touch_orchestrator(state) added = reconcile_ledger(state) @@ -3732,7 +3741,7 @@ HANDLERS = { "where": cmd_where, "init": cmd_init, "train": cmd_train, - "fork": cmd_fork, + "spawn": cmd_spawn, "continue": cmd_continue, "wait": cmd_wait, "list": cmd_list, @@ -3754,8 +3763,8 @@ def run(argv) -> tuple[dict, int]: if unknown and args.command not in PHASE2_COMMANDS: parser.error("unrecognized arguments: %s" % " ".join(unknown)) if args.command in CHILD_REFUSED and in_child(): - raise ForksError("`%s` is refused inside a fork (%s is set); a fork may only run where, list, " - "usage and wait" % (args.command, CHILD_MARKER), command=args.command) + raise GremlinsError("`%s` is refused inside a gremlin (%s is set); a gremlin may only run where, " + "list, usage and wait" % (args.command, CHILD_MARKER), command=args.command) return HANDLERS[args.command](args) @@ -3764,7 +3773,7 @@ def main(argv=None) -> int: argv = sys.argv[1:] try: out, code = run(argv) - except ForksError as e: + except GremlinsError as e: out, code = e.payload, e.code except Exception as e: # never break the stdout invariant traceback.print_exc(file=sys.stderr) diff --git a/plugins/spire/skills/gremlins/preamble.md b/plugins/spire/skills/gremlins/preamble.md new file mode 100644 index 0000000..c1dbbdc --- /dev/null +++ b/plugins/spire/skills/gremlins/preamble.md @@ -0,0 +1,22 @@ +# Headless implementer (spire:gremlins) + +You run headless under `claude -p` in a Spire build: first as the trained base, then as gremlins spawned from it once per block (each a fork of the base's session). Any session of yours may later be resumed or forked again. Nobody can answer a question mid-run: decide, act, and record what you decided. + +- Do the work yourself. Never dispatch agents, and never start `claude` or the gremlins helper (`gremlins.py`). +- Never use `run_in_background` on Bash. Run every command in the foreground. +- Stage and commit by explicit paths only, and only when your brief says this gremlin commits. Never push, rebase or reset. +- Write gate and test output to `.txt` files under the state dir's `logs/` folder (the brief names the state dir). +- When you finish a block, write the handoff note to the path the brief names, in this format, and stamp your exact model id: + + ```markdown + ### Block — + + - Model: <exact model id> + - Start / end: <`date` output> / <`date` output> + - Done: <what changed, in a few lines> + - Deviations from plan: <what, and why> — or "none" + - Flagged for review: <anything the reviewer should look at> + - Left for later: <work deferred to a later block or phase> + - Expected broken: <areas that do not build or pass yet, and which block fixes them> + - Commits: <hashes> — or "none" when your brief says you do not commit + ``` diff --git a/plugins/spire/skills/forks/templates/rebase.md b/plugins/spire/skills/gremlins/templates/rebase.md similarity index 71% rename from plugins/spire/skills/forks/templates/rebase.md rename to plugins/spire/skills/gremlins/templates/rebase.md index daa2345..4076b82 100644 --- a/plugins/spire/skills/forks/templates/rebase.md +++ b/plugins/spire/skills/gremlins/templates/rebase.md @@ -1,6 +1,6 @@ -# Rebase (spire:forks) +# Rebase (spire:gremlins) -You are a fork of base `${base}` (run log `${run}`). When you finish, this session becomes the new base `${new_base}` (phase ${phase}): every later fork of this build starts from what you know at the end of it. Your job is to absorb what changed since `${base}` was trained or last rebased. +This session is a fork of base `${base}` (run log `${run}`). When you finish, this session becomes the new base `${new_base}` (phase ${phase}): every later gremlin of this build starts from what you know at the end of it. Your job is to absorb what changed since `${base}` was trained or last rebased. Your system prompt was snapshotted when the base was trained: the git status, the CLAUDE.md files and the environment it shows are **stale**. The delta header below lists what changed in the repository since then. Trust it and the files on disk over the snapshot and over what you remember. diff --git a/plugins/spire/skills/forks/templates/fork.md b/plugins/spire/skills/gremlins/templates/spawn.md similarity index 71% rename from plugins/spire/skills/forks/templates/fork.md rename to plugins/spire/skills/gremlins/templates/spawn.md index 8d99ea7..4dbab75 100644 --- a/plugins/spire/skills/forks/templates/fork.md +++ b/plugins/spire/skills/gremlins/templates/spawn.md @@ -1,6 +1,6 @@ -# Block ${block} (spire:forks) +# Block ${block} (spire:gremlins) -You are a fork of base `${base}` (run log `${run}`), now working on block `${block}` (phase ${phase}, mode ${mode}). +You are a gremlin spawned from base `${base}` (a fork of its session; run log `${run}`), now working on block `${block}` (phase ${phase}, mode ${mode}). Your system prompt was snapshotted when the base was trained: the git status, the CLAUDE.md files and the environment it shows are **stale**. The delta header below lists what changed in the repository since then. Trust it and the files on disk over the snapshot and over what you remember from training; re-read every file before you change it. @@ -8,7 +8,7 @@ Your system prompt was snapshotted when the base was trained: the git status, th ${delta} -## Rules for this fork +## Rules for this gremlin - ${commit_rule} - Write your handoff note to `${note_path}`. diff --git a/plugins/spire/skills/gremlins/templates/train.md b/plugins/spire/skills/gremlins/templates/train.md new file mode 100644 index 0000000..df92d9e --- /dev/null +++ b/plugins/spire/skills/gremlins/templates/train.md @@ -0,0 +1,11 @@ +# Training (spire:gremlins) + +You are base `${base}` of the build whose run log is `${run}`. This session is trained once; then one gremlin per block is spawned from it by forking it, and every gremlin starts from what you know at the end of this session. Build an accurate picture of the code the brief below points you at: read it, trace the seams, note the invariants and the traps. + +- Do not change code in this session. If the brief asks for trunk notes, write them to the path it names; that is the only file you write. +- The helper's state dir is `${state_dir}`. Gremlins write handoff notes under `${notes_dir}` and gate and test output under `${logs_dir}`. +- Finish with one line `READY`, then a short summary of what you read and anything the brief asked for that you could not do. + +## Brief + +${brief} diff --git a/plugins/spire/skills/forks/tests/test_forks.py b/plugins/spire/skills/gremlins/tests/test_gremlins.py similarity index 78% rename from plugins/spire/skills/forks/tests/test_forks.py rename to plugins/spire/skills/gremlins/tests/test_gremlins.py index 43e54a6..f2a4504 100644 --- a/plugins/spire/skills/forks/tests/test_forks.py +++ b/plugins/spire/skills/gremlins/tests/test_gremlins.py @@ -2,10 +2,10 @@ # requires-python = ">=3.10" # dependencies = [] # /// -"""Unit tests for the spire:forks helper. +"""Unit tests for the spire:gremlins helper. -Run from the repo root: `uv run plugins/spire/skills/forks/tests/test_forks.py`. -Imports forks.py by path. Everything generated (stub binary, state dirs, settings files) +Run from the repo root: `uv run plugins/spire/skills/gremlins/tests/test_gremlins.py`. +Imports gremlins.py by path. Everything generated (stub binary, state dirs, settings files) lives under `tempfile`; nothing touches ~/.claude or the repo's own state. """ from __future__ import annotations @@ -25,18 +25,18 @@ import unittest from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) -FORKS_PY = os.path.join(os.path.dirname(HERE), "forks.py") +GREMLINS_PY = os.path.join(os.path.dirname(HERE), "gremlins.py") -def _load_forks(): - spec = importlib.util.spec_from_file_location("forks", FORKS_PY) +def _load_gremlins(): + spec = importlib.util.spec_from_file_location("gremlins", GREMLINS_PY) mod = importlib.util.module_from_spec(spec) - sys.modules["forks"] = mod + sys.modules["gremlins"] = mod spec.loader.exec_module(mod) return mod -forks = _load_forks() +gremlins = _load_gremlins() IS_WINDOWS = os.name == "nt" STUB_VERSION = "2.1.999" @@ -56,28 +56,28 @@ def make_stub_binary(d: str, version: str = STUB_VERSION) -> str: def call(argv): - """Run forks.main in-process. Returns (exit code, parsed JSON, raw stdout).""" + """Run gremlins.main in-process. Returns (exit code, parsed JSON, raw stdout).""" out, err = io.StringIO(), io.StringIO() with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): - code = forks.main(list(argv)) + code = gremlins.main(list(argv)) raw = out.getvalue() return code, json.loads(raw), raw def child_env(): env = dict(os.environ) - env.pop(forks.CHILD_MARKER, None) + env.pop(gremlins.CHILD_MARKER, None) return env class Base(unittest.TestCase): def setUp(self): - self.tmp = tempfile.mkdtemp(prefix="forks-test-") + self.tmp = tempfile.mkdtemp(prefix="gremlins-test-") self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) env_patch = mock.patch.dict(os.environ) env_patch.start() self.addCleanup(env_patch.stop) - os.environ.pop(forks.CHILD_MARKER, None) + os.environ.pop(gremlins.CHILD_MARKER, None) def assert_one_json_line(self, raw: str): self.assertTrue(raw.endswith("\n"), repr(raw[-40:])) @@ -107,7 +107,7 @@ class PathTests(Base): } for src, want in cases.items(): with self.subTest(src=src): - self.assertEqual(forks.norm_path(src, cwd=cwd, windows=True), want) + self.assertEqual(gremlins.norm_path(src, cwd=cwd, windows=True), want) def fake_cygpath(self, code=0): """A fake `cygpath` (python behind a wrapper) that maps `/a/b` to `C:\\Fake\\a\\b` and logs @@ -134,80 +134,80 @@ class PathTests(Base): """M13: a Git Bash rooted path that is not a drive form is refused (exit 2) when cygpath is not on PATH; it is never mapped onto the cwd's drive. Drive forms need no cygpath.""" cwd = "G:\\dev\\proj" - with mock.patch.object(forks, "find_cygpath", lambda: None): + with mock.patch.object(gremlins, "find_cygpath", lambda: None): for src in ("/tmp/x", "/usr/share/doc", "/tmp/x-run.md"): with self.subTest(src=src): - with self.assertRaises(forks.ForksError) as cm: - forks.norm_path(src, cwd=cwd, windows=True) + with self.assertRaises(gremlins.GremlinsError) as cm: + gremlins.norm_path(src, cwd=cwd, windows=True) self.assertEqual(cm.exception.code, 2) self.assertIn("cygpath", cm.exception.payload["error"]) self.assertEqual(cm.exception.payload["path"], src) - self.assertEqual(forks.norm_path("/g/x/y.md", cwd=cwd, windows=True), "G:\\x\\y.md") - self.assertEqual(forks.norm_path("/c", cwd=cwd, windows=True), "C:\\") - self.assertEqual(forks.norm_path("\\rooted\\a", cwd=cwd, windows=True), "G:\\rooted\\a") + self.assertEqual(gremlins.norm_path("/g/x/y.md", cwd=cwd, windows=True), "G:\\x\\y.md") + self.assertEqual(gremlins.norm_path("/c", cwd=cwd, windows=True), "C:\\") + self.assertEqual(gremlins.norm_path("\\rooted\\a", cwd=cwd, windows=True), "G:\\rooted\\a") if IS_WINDOWS: # end to end: --run /tmp/... is refused, nothing written on the cwd's drive drive = os.path.splitdrive(os.getcwd())[0] - code, out, raw = call(["init", "--run", "/tmp/forks-m13-x-run.md", "--model", + code, out, raw = call(["init", "--run", "/tmp/gremlins-m13-x-run.md", "--model", "claude-opus-5-5", "--cwd", self.tmp]) self.assertEqual(code, 2, out) self.assert_one_json_line(raw) self.assertIn("cygpath", out["error"]) - self.assertFalse(os.path.exists(drive + "\\tmp\\forks-m13-x-run-forks")) + self.assertFalse(os.path.exists(gremlins.state_dir_for(drive + "\\tmp\\gremlins-m13-x-run.md"))) def test_gitbash_rooted_with_cygpath(self): """M13: with cygpath on PATH the path is converted by `cygpath -w`; a failing cygpath refuses.""" exe, log = self.fake_cygpath() - with mock.patch.object(forks, "find_cygpath", lambda: exe): - self.assertEqual(forks.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True), + with mock.patch.object(gremlins, "find_cygpath", lambda: exe): + self.assertEqual(gremlins.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True), "C:\\Fake\\tmp\\x") - self.assertEqual(forks.norm_path("/g/x", cwd="G:\\dev\\proj", windows=True), "G:\\x") + self.assertEqual(gremlins.norm_path("/g/x", cwd="G:\\dev\\proj", windows=True), "G:\\x") with open(log) as f: self.assertEqual(f.read().splitlines(), ["-w /tmp/x"]) # drive forms skip cygpath bad, _ = self.fake_cygpath(code=1) - with mock.patch.object(forks, "find_cygpath", lambda: bad): - with self.assertRaises(forks.ForksError) as cm: - forks.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True) + with mock.patch.object(gremlins, "find_cygpath", lambda: bad): + with self.assertRaises(gremlins.GremlinsError) as cm: + gremlins.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True) self.assertEqual(cm.exception.payload["error"], "cygpath failed") real = shutil.which("cygpath") if IS_WINDOWS and real: # the real one (Git Bash): never the cwd's drive + \tmp - got = forks.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True) + got = gremlins.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True) self.assertNotEqual(got, "G:\\tmp\\x") self.assertRegex(got, r"^[A-Z]:\\.+\\x$") def test_windows_cwd_in_gitbash_form(self): - self.assertEqual(forks.norm_path("a.md", cwd="/g/dev/proj", windows=True), "G:\\dev\\proj\\a.md") + self.assertEqual(gremlins.norm_path("a.md", cwd="/g/dev/proj", windows=True), "G:\\dev\\proj\\a.md") def test_posix_forms(self): cwd = "/home/u/proj" - self.assertEqual(forks.norm_path("a/b.md", cwd=cwd, windows=False), "/home/u/proj/a/b.md") - self.assertEqual(forks.norm_path("/abs/x.md", cwd=cwd, windows=False), "/abs/x.md") - self.assertEqual(forks.norm_path("../z", cwd=cwd, windows=False), "/home/u/z") + self.assertEqual(gremlins.norm_path("a/b.md", cwd=cwd, windows=False), "/home/u/proj/a/b.md") + self.assertEqual(gremlins.norm_path("/abs/x.md", cwd=cwd, windows=False), "/abs/x.md") + self.assertEqual(gremlins.norm_path("../z", cwd=cwd, windows=False), "/home/u/z") def test_native_relative(self): - self.assertEqual(os.path.normcase(forks.norm_path("x/y.md")), + self.assertEqual(os.path.normcase(gremlins.norm_path("x/y.md")), os.path.normcase(os.path.abspath(os.path.join("x", "y.md")))) def test_empty_refused(self): - with self.assertRaises(forks.ForksError): - forks.norm_path(" ") + with self.assertRaises(gremlins.GremlinsError): + gremlins.norm_path(" ") @unittest.skipUnless(IS_WINDOWS, "Windows path forms") def test_state_dir_windows_forms(self): - want = "G:\\w\\reports\\2026-10-16-08-x-run-forks" + want = "G:\\w\\reports\\2026-10-16-08-x-run-gremlins" for src in ("G:\\w\\reports\\2026-10-16-08-x-run.md", "G:/w/reports/2026-10-16-08-x-run.md", "/g/w/reports/2026-10-16-08-x-run.md"): with self.subTest(src=src): - self.assertEqual(forks.state_dir_for(src), want) + self.assertEqual(gremlins.state_dir_for(src), want) def test_state_dir_relative(self): - got = forks.state_dir_for(os.path.join("reports", "2026-10-16-08-x-run.md")) + got = gremlins.state_dir_for(os.path.join("reports", "2026-10-16-08-x-run.md")) self.assertEqual(os.path.normcase(got), - os.path.normcase(os.path.abspath(os.path.join("reports", "2026-10-16-08-x-run-forks")))) + os.path.normcase(os.path.abspath(os.path.join("reports", "2026-10-16-08-x-run-gremlins")))) def test_state_dir_needs_md(self): - with self.assertRaises(forks.ForksError) as cm: - forks.state_dir_for(os.path.join(self.tmp, "run.txt")) + with self.assertRaises(gremlins.GremlinsError) as cm: + gremlins.state_dir_for(os.path.join(self.tmp, "run.txt")) self.assertEqual(cm.exception.code, 2) @@ -224,7 +224,7 @@ class ScrubTests(Base): "CLAUDE_CODE_PROMPT_CACHE_TTL": "5m", "CLAUDE_CODE_EXECPATH": "old", "PATH": os.pathsep.join([os.path.join(venv, "Scripts"), keep_dir]), } - env = forks.scrubbed_env("BIN", environ=host) + env = gremlins.scrubbed_env("BIN", environ=host) for k in ("CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_SESSION_ID", "CLAUDE_EFFORT", "ANTHROPIC_API_KEY", "ANTHROPIC_DEFAULT_OPUS_MODEL", "UV_CACHE_DIR", "VIRTUAL_ENV"): self.assertNotIn(k, env) @@ -233,7 +233,7 @@ class ScrubTests(Base): self.assertEqual(env[k], host[k]) self.assertEqual(env["CLAUDE_CODE_PROMPT_CACHE_TTL"], "1h") self.assertEqual(env["DISABLE_AUTOUPDATER"], "1") - self.assertEqual(env["SPIRE_FORKS_CHILD"], "1") + self.assertEqual(env["SPIRE_GREMLIN_CHILD"], "1") self.assertEqual(env["CLAUDE_CODE_EXECPATH"], "BIN") self.assertEqual(env["PATH"], keep_dir) @@ -241,8 +241,8 @@ class ScrubTests(Base): host = {"CLAUDECODE": "1", "CLAUDE_EFFORT": "x", "CLAUDE_CODE_NEW_THING": "1", "ANTHROPIC_BASE_URL": "u", "OTHER": "1", "CLAUDE_CODE_EXECPATH": "p"} # M9: ANTHROPIC_BASE_URL is on the keep list now, so it is no longer "unknown" - self.assertEqual(forks.scrub_unknown(host), ["CLAUDE_CODE_NEW_THING"]) - self.assertEqual(forks.scrub_class("ANTHROPIC_BASE_URL"), "keep") + self.assertEqual(gremlins.scrub_unknown(host), ["CLAUDE_CODE_NEW_THING"]) + self.assertEqual(gremlins.scrub_class("ANTHROPIC_BASE_URL"), "keep") # -------------------------------------------------------------------------------------- @@ -253,96 +253,96 @@ class LockTests(Base): os.makedirs(self.state) def test_contention_times_out(self): - with forks.RegistryLock(self.state): + with gremlins.RegistryLock(self.state): t0 = time.monotonic() - with self.assertRaises(forks.ForksError) as cm: - forks.RegistryLock(self.state, timeout=0.3).acquire() + with self.assertRaises(gremlins.GremlinsError) as cm: + gremlins.RegistryLock(self.state, timeout=0.3).acquire() self.assertGreaterEqual(time.monotonic() - t0, 0.25) self.assertEqual(cm.exception.payload["error"], "registry locked") - with forks.RegistryLock(self.state, timeout=0.3): + with gremlins.RegistryLock(self.state, timeout=0.3): pass - self.assertFalse(os.path.exists(forks.lock_path(self.state))) + self.assertFalse(os.path.exists(gremlins.lock_path(self.state))) def test_waiter_gets_lock_after_release(self): - l1 = forks.RegistryLock(self.state).acquire() + l1 = gremlins.RegistryLock(self.state).acquire() threading.Timer(0.2, l1.release).start() t0 = time.monotonic() - with forks.RegistryLock(self.state, timeout=5) as l2: + with gremlins.RegistryLock(self.state, timeout=5) as l2: self.assertGreaterEqual(time.monotonic() - t0, 0.15) self.assertFalse(l2.broke_stale) def test_contention_across_processes(self): code = ("import importlib.util,sys;" - "s=importlib.util.spec_from_file_location('forks',sys.argv[1]);m=importlib.util.module_from_spec(s);" + "s=importlib.util.spec_from_file_location('gremlins',sys.argv[1]);m=importlib.util.module_from_spec(s);" "s.loader.exec_module(m)\n" "try:\n m.RegistryLock(sys.argv[2],timeout=0.3).acquire(); sys.exit(0)\n" - "except m.ForksError: sys.exit(7)\n") - with forks.RegistryLock(self.state): - r = subprocess.run([sys.executable, "-c", code, FORKS_PY, self.state], capture_output=True) + "except m.GremlinsError: sys.exit(7)\n") + with gremlins.RegistryLock(self.state): + r = subprocess.run([sys.executable, "-c", code, GREMLINS_PY, self.state], capture_output=True) self.assertEqual(r.returncode, 7, r.stderr) def dead_holder(self) -> dict: p = subprocess.Popen([sys.executable, "-c", "pass"]) - rec = forks.pid_record(p.pid) + rec = gremlins.pid_record(p.pid) p.wait() return rec def write_lock(self, holder: dict, at: float, token: str): - with open(forks.lock_path(self.state), "w") as f: + with open(gremlins.lock_path(self.state), "w") as f: json.dump({"pid": holder["pid"], "ctime": holder["ctime"], "at": at, "token": token}, f) def test_dead_holder_lock_broken_at_once(self): # M2: a lock whose holder (pid + creation time) is dead is stale even when it is fresh self.write_lock(self.dead_holder(), time.time(), "old") t0 = time.monotonic() - with forks.RegistryLock(self.state, timeout=2) as lk: + with gremlins.RegistryLock(self.state, timeout=2) as lk: self.assertTrue(lk.broke_stale) self.assertLess(time.monotonic() - t0, 1.5) - with open(forks.lock_path(self.state)) as f: + with open(gremlins.lock_path(self.state)) as f: self.assertEqual(json.load(f)["token"], lk.token) self.assertEqual([n for n in os.listdir(self.state) if n.endswith(".lock")], []) def test_abandoned_lock_broken_by_age(self): - me = forks.pid_record(os.getpid()) # a live holder: only the age rule applies - self.write_lock(me, time.time() - (forks.LOCK_ABANDONED_S + 1), "old") - with forks.RegistryLock(self.state, timeout=2) as lk: + me = gremlins.pid_record(os.getpid()) # a live holder: only the age rule applies + self.write_lock(me, time.time() - (gremlins.LOCK_ABANDONED_S + 1), "old") + with gremlins.RegistryLock(self.state, timeout=2) as lk: self.assertTrue(lk.broke_stale) def test_garbage_stale_lock_broken_by_mtime(self): - p = forks.lock_path(self.state) + p = gremlins.lock_path(self.state) with open(p, "w") as f: f.write("") - old = time.time() - (forks.LOCK_ABANDONED_S + 5) + old = time.time() - (gremlins.LOCK_ABANDONED_S + 5) os.utime(p, (old, old)) - with forks.RegistryLock(self.state, timeout=2) as lk: + with gremlins.RegistryLock(self.state, timeout=2) as lk: self.assertTrue(lk.broke_stale) def test_fresh_lock_not_broken(self): # a live holder's lock is never broken before LOCK_ABANDONED_S (garbage content neither) - self.write_lock(forks.pid_record(os.getpid()), time.time() - 60, "other") - with self.assertRaises(forks.ForksError): - forks.RegistryLock(self.state, timeout=0.3).acquire() - with open(forks.lock_path(self.state), "w") as f: + self.write_lock(gremlins.pid_record(os.getpid()), time.time() - 60, "other") + with self.assertRaises(gremlins.GremlinsError): + gremlins.RegistryLock(self.state, timeout=0.3).acquire() + with open(gremlins.lock_path(self.state), "w") as f: f.write("") - with self.assertRaises(forks.ForksError): - forks.RegistryLock(self.state, timeout=0.3).acquire() + with self.assertRaises(gremlins.GremlinsError): + gremlins.RegistryLock(self.state, timeout=0.3).acquire() def test_stale_under_injected_clock_and_safe_release(self): - l1 = forks.RegistryLock(self.state).acquire() + l1 = gremlins.RegistryLock(self.state).acquire() real = time.time() - with mock.patch.object(forks, "_now", lambda: real + forks.LOCK_ABANDONED_S + 1): - l2 = forks.RegistryLock(self.state, timeout=2).acquire() + with mock.patch.object(gremlins, "_now", lambda: real + gremlins.LOCK_ABANDONED_S + 1): + l2 = gremlins.RegistryLock(self.state, timeout=2).acquire() self.assertTrue(l2.broke_stale) l1.release() # must not remove l2's lock - self.assertTrue(os.path.exists(forks.lock_path(self.state))) + self.assertTrue(os.path.exists(gremlins.lock_path(self.state))) l2.release() - self.assertFalse(os.path.exists(forks.lock_path(self.state))) + self.assertFalse(os.path.exists(gremlins.lock_path(self.state))) def test_unbreakable_stale_lock_respects_deadline(self): """M2: a stale lock that cannot be removed or renamed (AV, sync client) must not spin: the call ends with its "registry locked" error once the deadline passes.""" self.write_lock(self.dead_holder(), time.time() - 1000, "old") # stale by holder and by age - lp = os.path.normcase(forks.lock_path(self.state)) + lp = os.path.normcase(gremlins.lock_path(self.state)) real_remove, real_rename = os.remove, os.rename def no_remove(p, *a, **k): @@ -359,9 +359,9 @@ class LockTests(Base): def attempt(): t0 = time.monotonic() try: - forks.RegistryLock(self.state, timeout=0.5).acquire() + gremlins.RegistryLock(self.state, timeout=0.5).acquire() box["result"] = "acquired" - except forks.ForksError as e: + except gremlins.GremlinsError as e: box["result"] = e.payload["error"] box["took"] = time.monotonic() - t0 with mock.patch.object(os, "remove", no_remove), mock.patch.object(os, "rename", no_rename): @@ -377,8 +377,8 @@ class LockTests(Base): """M2: between a waiter's judgement (stale) and its break, another waiter breaks the stale lock and takes a fresh one. The first waiter must not delete the fresh lock.""" self.write_lock(self.dead_holder(), time.time(), "stale") - lp = os.path.normcase(forks.lock_path(self.state)) - fresh = {"pid": os.getpid(), "ctime": forks.proc_ctime(os.getpid()), "at": time.time(), + lp = os.path.normcase(gremlins.lock_path(self.state)) + fresh = {"pid": os.getpid(), "ctime": gremlins.proc_ctime(os.getpid()), "at": time.time(), "token": "fresh"} real_remove, real_rename = os.remove, os.rename swapped = [] @@ -386,7 +386,7 @@ class LockTests(Base): def swap_in_fresh(): if not swapped: swapped.append(1) - with open(forks.lock_path(self.state), "w") as f: + with open(gremlins.lock_path(self.state), "w") as f: json.dump(dict(fresh, at=time.time()), f) def hook_remove(p, *a, **k): @@ -399,10 +399,10 @@ class LockTests(Base): swap_in_fresh() return real_rename(src, dst, *a, **k) with mock.patch.object(os, "remove", hook_remove), mock.patch.object(os, "rename", hook_rename): - with self.assertRaises(forks.ForksError): - forks.RegistryLock(self.state, timeout=0.5).acquire() + with self.assertRaises(gremlins.GremlinsError): + gremlins.RegistryLock(self.state, timeout=0.5).acquire() self.assertEqual(swapped, [1], "the break was never attempted") - with open(forks.lock_path(self.state)) as f: + with open(gremlins.lock_path(self.state)) as f: self.assertEqual(json.load(f)["token"], "fresh") self.assertEqual(sorted(n for n in os.listdir(self.state) if n.endswith(".lock")), ["registry.lock"]) @@ -412,7 +412,7 @@ class LockTests(Base): class AtomicTests(Base): def test_replace_while_reader_holds_target(self): target = os.path.join(self.tmp, "registry.json") - forks.write_json_atomic(target, {"v": 1}) + gremlins.write_json_atomic(target, {"v": 1}) opened, done = threading.Event(), threading.Event() def reader(): @@ -424,54 +424,54 @@ class AtomicTests(Base): th = threading.Thread(target=reader) th.start() opened.wait(2) - attempts = forks.write_json_atomic(target, {"v": 2}) + attempts = gremlins.write_json_atomic(target, {"v": 2}) th.join() - self.assertEqual(forks.read_json(target), {"v": 2}) + self.assertEqual(gremlins.read_json(target), {"v": 2}) if IS_WINDOWS: self.assertGreater(attempts, 1, "replace should have hit a sharing violation and retried") self.assertEqual([n for n in os.listdir(self.tmp) if n.endswith(".tmp")], []) def test_read_json_missing_is_none(self): - self.assertIsNone(forks.read_json(os.path.join(self.tmp, "nope.json"))) + self.assertIsNone(gremlins.read_json(os.path.join(self.tmp, "nope.json"))) def test_registry_roundtrip_and_schema(self): st = os.path.join(self.tmp, "st") - forks.ensure_state(st) - with forks.RegistryLock(st): - forks.write_registry(st, forks.new_registry()) - reg = forks.read_registry(st) + gremlins.ensure_state(st) + with gremlins.RegistryLock(st): + gremlins.write_registry(st, gremlins.new_registry()) + reg = gremlins.read_registry(st) self.assertEqual(reg["schema"], 1) - with open(forks.registry_path(st), "rb") as f: + with open(gremlins.registry_path(st), "rb") as f: self.assertNotIn(b"\r\n", f.read()) - forks.write_json_atomic(forks.registry_path(st), {"schema": 99}) - with self.assertRaises(forks.ForksError): - forks.read_registry(st) + gremlins.write_json_atomic(gremlins.registry_path(st), {"schema": 99}) + with self.assertRaises(gremlins.GremlinsError): + gremlins.read_registry(st) def test_ledger_append(self): st = os.path.join(self.tmp, "st") - forks.ensure_state(st) - with forks.RegistryLock(st): - forks.ledger_append(st, {"job": "j0001"}) - forks.ledger_append(st, {"job": "j0002"}) - with open(forks.ledger_path(st), "rb") as f: + gremlins.ensure_state(st) + with gremlins.RegistryLock(st): + gremlins.ledger_append(st, {"job": "j0001"}) + gremlins.ledger_append(st, {"job": "j0002"}) + with open(gremlins.ledger_path(st), "rb") as f: lines = f.read().decode().splitlines() self.assertEqual([json.loads(x)["job"] for x in lines], ["j0001", "j0002"]) def test_ledger_append_dedupes_by_job(self): """M1: a second finaliser (kill racing the supervisor) never writes a second line.""" st = os.path.join(self.tmp, "st") - forks.ensure_state(st) - with forks.RegistryLock(st): - self.assertTrue(forks.ledger_append(st, {"job": "j0001", "status": "ok"})) - self.assertFalse(forks.ledger_append(st, {"job": "j0001", "status": "killed"})) - self.assertTrue(forks.ledger_append(st, {"job": "j0002", "status": "ok"})) + gremlins.ensure_state(st) + with gremlins.RegistryLock(st): + self.assertTrue(gremlins.ledger_append(st, {"job": "j0001", "status": "ok"})) + self.assertFalse(gremlins.ledger_append(st, {"job": "j0001", "status": "killed"})) + self.assertTrue(gremlins.ledger_append(st, {"job": "j0002", "status": "ok"})) # a torn last line (writer killed mid-append) does not swallow the next line - with open(forks.ledger_path(st), "ab") as f: + with open(gremlins.ledger_path(st), "ab") as f: f.write(b'{"job": "j0003", "sta') - with forks.RegistryLock(st): - self.assertTrue(forks.ledger_append(st, {"job": "j0004", "status": "ok"})) - self.assertFalse(forks.ledger_append(st, {"job": "j0002", "status": "killed"})) - with open(forks.ledger_path(st), "rb") as f: + with gremlins.RegistryLock(st): + self.assertTrue(gremlins.ledger_append(st, {"job": "j0004", "status": "ok"})) + self.assertFalse(gremlins.ledger_append(st, {"job": "j0002", "status": "killed"})) + with open(gremlins.ledger_path(st), "rb") as f: lines = f.read().decode().splitlines() parsed = [] for x in lines: @@ -484,20 +484,20 @@ class AtomicTests(Base): # -------------------------------------------------------------------------------------- class PidTests(Base): def test_own_pid(self): - rec = forks.pid_record(os.getpid()) - self.assertTrue(forks.record_alive(rec)) + rec = gremlins.pid_record(os.getpid()) + self.assertTrue(gremlins.record_alive(rec)) if rec["ctime"] is not None: - self.assertFalse(forks.pid_alive(os.getpid(), rec["ctime"] + 1)) + self.assertFalse(gremlins.pid_alive(os.getpid(), rec["ctime"] + 1)) def test_dead_pid(self): p = subprocess.Popen([sys.executable, "-c", "pass"]) - rec = forks.pid_record(p.pid) + rec = gremlins.pid_record(p.pid) p.wait() - self.assertFalse(forks.record_alive(rec)) - self.assertFalse(forks.pid_alive(0)) + self.assertFalse(gremlins.record_alive(rec)) + self.assertFalse(gremlins.pid_alive(0)) def test_supervisor_interpreter(self): - self.assertTrue(os.path.isfile(forks.supervisor_interpreter())) + self.assertTrue(os.path.isfile(gremlins.supervisor_interpreter())) # -------------------------------------------------------------------------------------- @@ -510,7 +510,7 @@ class ProcessTreeTests(Base): super().setUp() self.t0 = time.time() - 5000 # the job (its root) started here self.hb = self.t0 + 500 # last heartbeat: the job was known alive until here - e2c = forks.epoch_to_ctime + e2c = gremlins.epoch_to_ctime if e2c(self.t0) is None: self.skipTest("no epoch -> creation-time conversion on this host") self.root_ct = e2c(self.t0) @@ -529,7 +529,7 @@ class ProcessTreeTests(Base): for name, fn in (("process_table", lambda: list(self.table)), ("proc_ctime", lambda pid: self.ctimes.get(int(pid)) if int(pid) in self.live else None), ("pid_alive", self.fake_alive)): - p = mock.patch.object(forks, name, fn) + p = mock.patch.object(gremlins, name, fn) p.start() self.addCleanup(p.stop) @@ -541,39 +541,39 @@ class ProcessTreeTests(Base): return {"pid": self.ROOT, "ctime": self.root_ct} def test_dead_root_claims_only_children_from_its_lifetime(self): - got = {p["pid"] for p in forks.process_tree(self.rec(), alive_until=self.hb)} + got = {p["pid"] for p in gremlins.process_tree(self.rec(), alive_until=self.hb)} self.assertEqual(got, {self.C, self.C_KID}) def test_dead_root_without_bounds_claims_nothing(self): - self.assertEqual(forks.process_tree(self.rec()), []) - self.assertEqual(forks.process_tree({"pid": self.ROOT, "ctime": None}, alive_until=self.hb), []) + self.assertEqual(gremlins.process_tree(self.rec()), []) + self.assertEqual(gremlins.process_tree({"pid": self.ROOT, "ctime": None}, alive_until=self.hb), []) def test_live_root_unchanged(self): self.live.add(self.ROOT) self.ctimes[self.ROOT] = self.root_ct - got = {p["pid"] for p in forks.process_tree(self.rec(), alive_until=None)} + got = {p["pid"] for p in gremlins.process_tree(self.rec(), alive_until=None)} self.assertEqual(got, {self.ROOT, self.A, self.A_KID, self.C, self.C_KID}) # B is older def test_reap_lost_bounds_by_heartbeat(self): st = os.path.join(self.tmp, "st") - forks.ensure_state(st) + gremlins.ensure_state(st) old = time.time() - 1000 - reg = forks.new_registry() + reg = gremlins.new_registry() reg["config"] = {"launch": {}, "ttl_s": 3600} dead_sup = {"pid": 900091, "ctime": 1} - reg["jobs"]["j0001"] = forks._new_entry("j0001", "fork", status="running", supervisor=dead_sup, - launched_at=forks._iso(old - 10)) - with forks.RegistryLock(st): - forks.write_registry(st, reg) - forks.write_json_atomic(forks.job_file(st, "j0001"), { - "supervisor": dead_sup, "claude": self.rec(), "started_at": forks._iso(old - 10), - "heartbeat_at": forks._iso(old)}) + reg["jobs"]["j0001"] = gremlins._new_entry("j0001", "spawn", status="running", supervisor=dead_sup, + launched_at=gremlins._iso(old - 10)) + with gremlins.RegistryLock(st): + gremlins.write_registry(st, reg) + gremlins.write_json_atomic(gremlins.job_file(st, "j0001"), { + "supervisor": dead_sup, "claude": self.rec(), "started_at": gremlins._iso(old - 10), + "heartbeat_at": gremlins._iso(old)}) seen = [] - with mock.patch.object(forks, "kill_tree", lambda rec, extra=(), alive_until=None: + with mock.patch.object(gremlins, "kill_tree", lambda rec, extra=(), alive_until=None: seen.append((rec, alive_until)) or {"snapshot": [], "survivors": [], "taskkill_rc": None}): - self.assertEqual(forks.reap_lost(st), ["j0001"]) - self.assertEqual(seen, [(self.rec(), forks._ts(forks._iso(old)))]) + self.assertEqual(gremlins.reap_lost(st), ["j0001"]) + self.assertEqual(seen, [(self.rec(), gremlins._ts(gremlins._iso(old)))]) # -------------------------------------------------------------------------------------- @@ -584,9 +584,9 @@ class InitTests(Base): os.makedirs(os.path.join(self.cwd, ".claude", "agents")) os.makedirs(os.path.join(self.tmp, "reports")) self.run_log = os.path.join(self.tmp, "reports", "2026-10-16-08-t-run.md") - self.state = forks.state_dir_for(self.run_log) + self.state = gremlins.state_dir_for(self.run_log) self.user_settings = os.path.join(self.tmp, "home", ".claude", "settings.json") - p = mock.patch.object(forks, "USER_SETTINGS_PATH", self.user_settings) + p = mock.patch.object(gremlins, "USER_SETTINGS_PATH", self.user_settings) p.start() self.addCleanup(p.stop) self.stub = make_stub_binary(self.tmp) @@ -602,13 +602,13 @@ class InitTests(Base): json.dump({"availableModels": models}, f) def add_fake_base(self): - with forks.RegistryLock(self.state): - reg = forks.read_registry(self.state) + with gremlins.RegistryLock(self.state): + reg = gremlins.read_registry(self.state) reg["bases"]["B1"] = {"status": "active", "session": "00000000-0000-0000-0000-000000000000"} - forks.write_registry(self.state, reg) + gremlins.write_registry(self.state, reg) def reg_bytes(self): - with open(forks.registry_path(self.state), "rb") as f: + with open(gremlins.registry_path(self.state), "rb") as f: return f.read() # --- basic --- @@ -618,13 +618,13 @@ class InitTests(Base): self.assert_one_json_line(raw) self.assertTrue(out["created"]) st = self.state - for d in forks.STATE_SUBDIRS: + for d in gremlins.STATE_SUBDIRS: self.assertTrue(os.path.isdir(os.path.join(st, d)), d) with open(os.path.join(st, ".gitignore"), "rb") as f: self.assertEqual(f.read(), b"raw/\n*.lock\n") - self.assertEqual(forks.read_json(forks.mcp_empty_path(st)), {"mcpServers": {}}) - self.assertFalse(os.path.exists(forks.lock_path(st))) - reg = forks.read_registry(st) + self.assertEqual(gremlins.read_json(gremlins.mcp_empty_path(st)), {"mcpServers": {}}) + self.assertFalse(os.path.exists(gremlins.lock_path(st))) + reg = gremlins.read_registry(st) self.assertEqual(reg["schema"], 1) self.assertEqual(reg["bases"], {}) self.assertEqual(reg["jobs"], {}) @@ -633,22 +633,22 @@ class InitTests(Base): self.assertEqual(la["model"], "claude-opus-5-5[1m]") self.assertEqual(la["effort"], "high") self.assertEqual(la["permission_mode"], "dontAsk") - self.assertEqual(la["tools"], forks.DEFAULT_TOOLS.split(",")) + self.assertEqual(la["tools"], gremlins.DEFAULT_TOOLS.split(",")) self.assertEqual(la["allowed_tools"], la["tools"]) - self.assertEqual(la["disallowed_tools"], forks.DEFAULT_DISALLOWED) - self.assertEqual(la["mcp"], {"strict": True, "config": forks.mcp_empty_path(st)}) - self.assertEqual(la["cwd"], forks.norm_path(self.cwd)) + self.assertEqual(la["disallowed_tools"], gremlins.DEFAULT_DISALLOWED) + self.assertEqual(la["mcp"], {"strict": True, "config": gremlins.mcp_empty_path(st)}) + self.assertEqual(la["cwd"], gremlins.norm_path(self.cwd)) self.assertIsNone(la["agent"]) self.assertEqual(la["env"]["pinned"], {"CLAUDE_CODE_PROMPT_CACHE_TTL": "1h", "DISABLE_AUTOUPDATER": "1"}) b = la["binary"] - self.assertEqual(b["path"], forks.norm_path(self.stub)) + self.assertEqual(b["path"], gremlins.norm_path(self.stub)) self.assertTrue(os.path.isabs(b["path"])) self.assertEqual(b["version"], STUB_VERSION) self.assertEqual(b["size"], os.path.getsize(self.stub)) pre = la["append_system_prompt"] - self.assertEqual(pre["path"], forks.preamble_path()) - self.assertEqual(pre["sha256"], forks._sha256_file(forks.preamble_path())) - self.assertEqual(cfg["fingerprint"], forks.fingerprint_hash(la)) + self.assertEqual(pre["path"], gremlins.preamble_path()) + self.assertEqual(pre["sha256"], gremlins._sha256_file(gremlins.preamble_path())) + self.assertEqual(cfg["fingerprint"], gremlins.fingerprint_hash(la)) self.assertEqual(len(cfg["fingerprint"]), 64) self.assertEqual(cfg["parallel_cap"], 2) self.assertEqual(cfg["orphan_after"], 1200) @@ -656,14 +656,14 @@ class InitTests(Base): self.assertEqual(cfg["ttl_s"], 3600) self.assertEqual(cfg["max_minutes"], 90) self.assertEqual(cfg["stall_after_s"], 900) - self.assertEqual(cfg["thresholds"], forks.THRESHOLD_DEFAULTS) + self.assertEqual(cfg["thresholds"], gremlins.THRESHOLD_DEFAULTS) self.assertEqual(out["config"], cfg) def test_fingerprint_canonical(self): a = {"b": [1, 2], "a": {"y": 1, "x": None}} b = {"a": {"x": None, "y": 1}, "b": [1, 2]} - self.assertEqual(forks.fingerprint_hash(a), forks.fingerprint_hash(b)) - self.assertEqual(forks.canonical_json(a), '{"a":{"x":null,"y":1},"b":[1,2]}') + self.assertEqual(gremlins.fingerprint_hash(a), gremlins.fingerprint_hash(b)) + self.assertEqual(gremlins.canonical_json(a), '{"a":{"x":null,"y":1},"b":[1,2]}') def test_list_flags(self): code, out, _ = self.init("--tools", "Read,Bash", "--allowed-tools", "Read", "Bash(git status:*)", @@ -692,7 +692,7 @@ class InitTests(Base): self.assertIn("launch.effort", out["changed_fields"]) self.assertIn("orphan_after", out["changed_fields"]) self.assertIn("fingerprint", out["changed_fields"]) - cfg = forks.read_registry(self.state)["config"] + cfg = gremlins.read_registry(self.state)["config"] self.assertEqual(cfg["launch"]["effort"], "medium") self.assertEqual(cfg["orphan_after"], 1500) self.assertNotEqual(cfg["fingerprint"], first["config"]["fingerprint"]) @@ -709,11 +709,11 @@ class InitTests(Base): self.assertTrue(out["changed"]) self.assertIn("launch.effort", out["changed_fields"]) self.assertEqual(out["bases"], ["B1"]) - reg = forks.read_registry(self.state) + reg = gremlins.read_registry(self.state) self.assertEqual(reg["config"]["launch"]["effort"], "medium") self.assertEqual(reg["bases"]["B1"]["launch"]["effort"], "high") self.assertEqual(reg["bases"]["B1"]["overrides"], []) - self.assertFalse(os.path.exists(forks.lock_path(self.state))) + self.assertFalse(os.path.exists(gremlins.lock_path(self.state))) # identical values stay unchanged with a base before = self.reg_bytes() code, out, _ = self.init("--effort", "medium") @@ -730,16 +730,16 @@ class InitTests(Base): model="claude-sonnet-5-5") self.assertEqual(code, 0, first) self.add_fake_base() - before = forks.read_registry(self.state)["config"] + before = gremlins.read_registry(self.state)["config"] code, out, raw = call(["init", "--run", self.run_log, "--warm-margin-s", "100"]) self.assertEqual(code, 0, out) self.assert_one_json_line(raw) self.assertTrue(out["changed"]) self.assertEqual(out["changed_fields"], ["warm_margin_s"]) - cfg = forks.read_registry(self.state)["config"] + cfg = gremlins.read_registry(self.state)["config"] self.assertEqual(cfg["warm_margin_s"], 100) self.assertEqual((cfg["pings"], cfg["parallel_cap"], cfg["orphan_after"]), ("off", 3, 1500)) - self.assertEqual(cfg["thresholds"], dict(forks.THRESHOLD_DEFAULTS, rebase_commits=9)) + self.assertEqual(cfg["thresholds"], dict(gremlins.THRESHOLD_DEFAULTS, rebase_commits=9)) self.assertEqual(cfg["launch"], before["launch"]) self.assertEqual((cfg["launch"]["model"], cfg["launch"]["effort"], cfg["launch"]["tools"]), ("claude-sonnet-5-5", "low", ["Read", "Bash"])) @@ -748,7 +748,7 @@ class InitTests(Base): code, out, _ = call(["init", "--run", self.run_log, "--pings", "on"]) self.assertEqual(code, 0, out) self.assertEqual(out["changed_fields"], ["pings"]) - cfg = forks.read_registry(self.state)["config"] + cfg = gremlins.read_registry(self.state)["config"] self.assertEqual((cfg["pings"], cfg["warm_margin_s"], cfg["parallel_cap"]), ("on", 100, 3)) # the first init still needs --model (a usage error, nothing written) other = os.path.join(self.tmp, "reports", "2026-10-16-09-u-run.md") @@ -757,7 +757,7 @@ class InitTests(Base): self.assert_one_json_line(raw) self.assertEqual(out["error"], "usage") self.assertIn("--model", out["detail"]) - self.assertFalse(os.path.exists(forks.state_dir_for(other))) + self.assertFalse(os.path.exists(gremlins.state_dir_for(other))) def test_warm_margin_must_be_below_ttl(self): """Minor 2: `init` refuses a warm_margin_s at or above the configured ttl_s (exit 2), on the @@ -789,19 +789,19 @@ class InitTests(Base): self.assertEqual(code, 0, b) la, lb = a["config"]["launch"], b["config"]["launch"] self.assertNotEqual(la["mcp"]["config"], lb["mcp"]["config"]) - self.assertEqual(la["mcp"]["config"], forks.mcp_empty_path(self.state)) + self.assertEqual(la["mcp"]["config"], gremlins.mcp_empty_path(self.state)) self.assertEqual(a["config"]["fingerprint"], b["config"]["fingerprint"]) - self.assertEqual(forks.fingerprint_view(la)["mcp"], {"strict": True, "servers": {}}) - self.assertNotIn("config", forks.fingerprint_view(la)["mcp"]) + self.assertEqual(gremlins.fingerprint_view(la)["mcp"], {"strict": True, "servers": {}}) + self.assertNotIn("config", gremlins.fingerprint_view(la)["mcp"]) # a second process computes the same hash (no per-process input) r = subprocess.run([sys.executable, "-I", "-c", "import importlib.util,json,sys;" "s=importlib.util.spec_from_file_location('f',sys.argv[1]);m=importlib.util.module_from_spec(s);" "s.loader.exec_module(m);print(m.fingerprint_hash(json.loads(sys.argv[2])))", - FORKS_PY, json.dumps(lb)], capture_output=True, timeout=60) + GREMLINS_PY, json.dumps(lb)], capture_output=True, timeout=60) self.assertEqual(r.stdout.decode().strip(), a["config"]["fingerprint"], r.stderr) # the content does count - self.assertNotEqual(forks.fingerprint_hash(dict(la, mcp=dict(la["mcp"], strict=False))), + self.assertNotEqual(gremlins.fingerprint_hash(dict(la, mcp=dict(la["mcp"], strict=False))), a["config"]["fingerprint"]) # --- refusals --- @@ -853,7 +853,7 @@ class InitTests(Base): self.assertEqual(code, 0, out) ag = out["config"]["launch"]["agent"] self.assertEqual(ag["name"], "impl") - self.assertEqual(ag["sha256"], forks._sha256_file(ap)) + self.assertEqual(ag["sha256"], gremlins._sha256_file(ap)) # --- C8 admission --- def proj(self): @@ -863,30 +863,30 @@ class InitTests(Base): return os.path.join(self.cwd, ".claude", "settings.local.json") def test_admission_no_list_admits(self): - self.assertTrue(forks.model_admission("claude-whatever-9", self.cwd)["admitted"]) + self.assertTrue(gremlins.model_admission("claude-whatever-9", self.cwd)["admitted"]) def test_admission_exact(self): self.write_settings(self.user_settings, ["claude-opus-5-5[1m]"]) - self.assertTrue(forks.model_admission("claude-opus-5-5[1m]", self.cwd)["admitted"]) + self.assertTrue(gremlins.model_admission("claude-opus-5-5[1m]", self.cwd)["admitted"]) # the bare id is not the [1m] entry and `opus` is not listed - self.assertFalse(forks.model_admission("claude-opus-5-5", self.cwd)["admitted"]) + self.assertFalse(gremlins.model_admission("claude-opus-5-5", self.cwd)["admitted"]) def test_admission_strips_1m(self): self.write_settings(self.proj(), ["claude-opus-5-5"]) - adm = forks.model_admission("claude-opus-5-5[1m]", self.cwd) + adm = gremlins.model_admission("claude-opus-5-5[1m]", self.cwd) self.assertTrue(adm["admitted"]) self.assertIn("without [1m]", adm["via"]) def test_admission_family_alias(self): self.write_settings(self.local(), ["sonnet"]) - adm = forks.model_admission("claude-sonnet-5-5", self.cwd) + adm = gremlins.model_admission("claude-sonnet-5-5", self.cwd) self.assertTrue(adm["admitted"]) self.assertIn("family alias", adm["via"]) def test_admission_any_list_admits(self): self.write_settings(self.user_settings, ["claude-opus-5-5"]) self.write_settings(self.local(), ["haiku"]) - self.assertTrue(forks.model_admission("claude-haiku-5-5", self.cwd)["admitted"]) + self.assertTrue(gremlins.model_admission("claude-haiku-5-5", self.cwd)["admitted"]) def test_admission_unlisted_refused_by_init(self): self.write_settings(self.user_settings, ["claude-opus-5-5"]) @@ -918,7 +918,7 @@ class CliTests(Base): with open(brief, "w") as f: f.write("x\n") cases = (("train", "--base", "B1", "--brief", brief), - ("fork", "--base", "B1", "--block", "1.1", "--brief", brief), + ("spawn", "--base", "B1", "--block", "1.1", "--brief", brief), ("continue", "--job", "j0001", "--brief", brief), ("wait", "--all"), ("list",), ("expire", "--all"), ("kill", "--job", "j0001"), ("_supervise", "--job", "j0001"), ("tick",), @@ -931,7 +931,7 @@ class CliTests(Base): self.assert_one_json_line(raw) self.assertIn("error", out) self.assertNotEqual(out["error"], "not implemented") - self.assertFalse(os.path.exists(run_log[:-3] + "-forks")) + self.assertFalse(os.path.exists(gremlins.state_dir_for(run_log))) def test_unknown_command_and_missing_run(self): for argv in (["bogus"], [], ["list"], ["init", "--run", "x.md"], @@ -953,20 +953,20 @@ class CliTests(Base): self.assertIn("--model", out["help"]) def test_child_refuses_launching(self): - os.environ[forks.CHILD_MARKER] = "1" + os.environ[gremlins.CHILD_MARKER] = "1" run_log = os.path.join(self.tmp, "r-run.md") - code, out, _ = call(["fork", "--run", run_log, "--base", "B1", "--block", "1", "--brief", "b"]) + code, out, _ = call(["spawn", "--run", run_log, "--base", "B1", "--block", "1", "--brief", "b"]) self.assertEqual(code, 2) - self.assertIn("inside a fork", out["error"]) + self.assertIn("inside a gremlin", out["error"]) code, out, _ = call(["list", "--run", run_log]) - self.assertNotIn("inside a fork", out["error"]) # not refused as a launch (no init yet) + self.assertNotIn("inside a gremlin", out["error"]) # not refused as a launch (no init yet) stub = make_stub_binary(self.tmp) self.assertEqual(call(["where", "--binary", stub])[0], 0) def test_internal_error_keeps_invariant(self): def boom(args): raise RuntimeError("kaboom") - with mock.patch.dict(forks.HANDLERS, {"list": boom}): + with mock.patch.dict(gremlins.HANDLERS, {"list": boom}): code, out, raw = call(["list", "--run", os.path.join(self.tmp, "r-run.md")]) self.assertEqual(code, 2) self.assert_one_json_line(raw) @@ -994,8 +994,8 @@ class CliTests(Base): self.assertIn("llm-gateway.example.internal", out["warnings"][0]) def test_child_refuses_kill_expire_init(self): - """M4: a fork may not run kill, expire or init.""" - os.environ[forks.CHILD_MARKER] = "1" + """M4: a gremlin may not run kill, expire or init.""" + os.environ[gremlins.CHILD_MARKER] = "1" stub = make_stub_binary(self.tmp) run_log = os.path.join(self.tmp, "r-run.md") for argv in (["kill", "--run", run_log, "--job", "j0001"], @@ -1006,29 +1006,29 @@ class CliTests(Base): code, out, raw = call(argv) self.assertEqual(code, 2, out) self.assert_one_json_line(raw) - self.assertIn("inside a fork", out["error"]) + self.assertIn("inside a gremlin", out["error"]) self.assertEqual(out["command"], argv[0]) - self.assertFalse(os.path.exists(run_log[:-3] + "-forks")) + self.assertFalse(os.path.exists(gremlins.state_dir_for(run_log))) def test_child_does_not_stamp_orchestrator(self): - """M4: list and wait from inside a fork leave run.last_orchestrator_call_at alone.""" + """M4: list and wait from inside a gremlin leave run.last_orchestrator_call_at alone.""" os.makedirs(os.path.join(self.tmp, "reports")) run_log = os.path.join(self.tmp, "reports", "r-run.md") - state = forks.state_dir_for(run_log) - forks.ensure_state(state) - old = forks._iso(time.time() - 5000) - reg = forks.new_registry() + state = gremlins.state_dir_for(run_log) + gremlins.ensure_state(state) + old = gremlins._iso(time.time() - 5000) + reg = gremlins.new_registry() reg["config"] = {"launch": {}, "ttl_s": 3600} reg["run"]["last_orchestrator_call_at"] = old - with forks.RegistryLock(state): - forks.write_registry(state, reg) - os.environ[forks.CHILD_MARKER] = "1" + with gremlins.RegistryLock(state): + gremlins.write_registry(state, reg) + os.environ[gremlins.CHILD_MARKER] = "1" self.assertEqual(call(["list", "--run", run_log])[0], 0) self.assertEqual(call(["wait", "--run", run_log, "--all", "--timeout", "1"])[0], 0) - self.assertEqual(forks.read_registry(state)["run"]["last_orchestrator_call_at"], old) - os.environ.pop(forks.CHILD_MARKER) + self.assertEqual(gremlins.read_registry(state)["run"]["last_orchestrator_call_at"], old) + os.environ.pop(gremlins.CHILD_MARKER) self.assertEqual(call(["list", "--run", run_log])[0], 0) # the orchestrator does stamp - self.assertNotEqual(forks.read_registry(state)["run"]["last_orchestrator_call_at"], old) + self.assertNotEqual(gremlins.read_registry(state)["run"]["last_orchestrator_call_at"], old) def test_where_in_process_missing_binary(self): code, out, raw = call(["where", "--binary", os.path.join(self.tmp, "nope.exe")]) @@ -1043,7 +1043,7 @@ class CliTests(Base): @unittest.skipUnless(shutil.which("uv"), "uv not on PATH") class SubprocessTests(Base): def run_uv(self, *argv): - return subprocess.run(["uv", "run", FORKS_PY, *argv], cwd=self.tmp, env=child_env(), + return subprocess.run(["uv", "run", GREMLINS_PY, *argv], cwd=self.tmp, env=child_env(), capture_output=True, timeout=180) def test_where_via_uv_stub(self): @@ -1055,7 +1055,7 @@ class SubprocessTests(Base): self.assertEqual(out["spawn"], "cim-hidden" if IS_WINDOWS else "setsid") self.assertEqual(out["binary"]["version"], STUB_VERSION) self.assertEqual(out["binary"]["how"], "--binary") - self.assertEqual(os.path.normcase(out["script"]), os.path.normcase(os.path.abspath(FORKS_PY))) + self.assertEqual(os.path.normcase(out["script"]), os.path.normcase(os.path.abspath(GREMLINS_PY))) for k in ("python", "uv", "git", "scrub"): self.assertIn(k, out) for k in ("drop", "keep", "pinned", "unknown"): @@ -1076,7 +1076,7 @@ class SubprocessTests(Base): self.assertEqual(r.returncode, 2, r.stderr) out = self.assert_one_json_line(r.stdout.decode("utf-8")) self.assertIn("error", out) - self.assertFalse(os.path.exists(os.path.join(self.tmp, "x-run-forks"))) + self.assertFalse(os.path.exists(gremlins.state_dir_for(run_log))) def test_unknown_command_exit_2(self): r = self.run_uv("frobnicate") @@ -1101,7 +1101,7 @@ SESS = os.path.join(HERE, "sessions") CALLS = os.path.join(HERE, "calls") VERSION = "%(version)s" PRICE = %(price)r -ENV_KEYS = ("CLAUDE_CODE_EXECPATH", "SPIRE_FORKS_CHILD", "CLAUDE_CODE_PROMPT_CACHE_TTL", +ENV_KEYS = ("CLAUDE_CODE_EXECPATH", "SPIRE_GREMLIN_CHILD", "CLAUDE_CODE_PROMPT_CACHE_TTL", "DISABLE_AUTOUPDATER", "VIRTUAL_ENV", "CLAUDE_EFFORT") @@ -1343,13 +1343,13 @@ class LaunchBase(Base): def setUp(self): super().setUp() for name, val in (("WAIT_POLL_S", 0.2),): - p = mock.patch.object(forks, name, val) + p = mock.patch.object(gremlins, name, val) p.start() self.addCleanup(p.stop) - p = mock.patch.object(forks, "USER_SETTINGS_PATH", os.path.join(self.tmp, "home", "settings.json")) + p = mock.patch.object(gremlins, "USER_SETTINGS_PATH", os.path.join(self.tmp, "home", "settings.json")) p.start() self.addCleanup(p.stop) - p = mock.patch.object(forks, "CLAUDE_PROJECTS_DIR", os.path.join(self.tmp, "home", "projects")) + p = mock.patch.object(gremlins, "CLAUDE_PROJECTS_DIR", os.path.join(self.tmp, "home", "projects")) p.start() self.addCleanup(p.stop) self.cwd = os.path.join(self.tmp, "proj") @@ -1358,7 +1358,7 @@ class LaunchBase(Base): self.commit_file("README.md", "hello\n", "initial") os.makedirs(os.path.join(self.tmp, "reports")) self.run_log = os.path.join(self.tmp, "reports", "2026-10-16-12-t-run.md") - self.state = forks.state_dir_for(self.run_log) + self.state = gremlins.state_dir_for(self.run_log) STATE_DIRS.append(self.state) self.stub = make_stream_stub(self.tmp) self.addCleanup(self.cleanup_processes) @@ -1406,8 +1406,8 @@ class LaunchBase(Base): self.assertEqual(set(out), {"job", "base", "session_id"}) return out - def fork(self, *directives, base="B1", block="1.1", extra=(), code=0): - return self.cmd("fork", "--base", base, "--block", block, "--brief", self.brief(*directives), + def spawn(self, *directives, base="B1", block="1.1", extra=(), code=0): + return self.cmd("spawn", "--base", base, "--block", block, "--brief", self.brief(*directives), *extra, code=code) def wait(self, *jobs, timeout=60, any_=False): @@ -1428,20 +1428,20 @@ class LaunchBase(Base): return got[0] def reg(self): - return forks.read_registry(self.state) + return gremlins.read_registry(self.state) def runtime(self, job): - return forks.read_json(forks.job_file(self.state, job)) or {} + return gremlins.read_json(gremlins.job_file(self.state, job)) or {} def ledger(self): - p = forks.ledger_path(self.state) + p = gremlins.ledger_path(self.state) if not os.path.exists(p): return [] with open(p, "rb") as f: return [json.loads(x) for x in f.read().decode("utf-8").splitlines() if x.strip()] def until_running(self, job, need_child=False): - """Wait until the job's claude runs and the stub (started by a `fork_marked` brief) has + """Wait until the job's claude runs and the stub (started by a `spawn_marked` brief) has recorded itself, with its grandchild when `need_child`.""" def ready(): rt = self.runtime(job) @@ -1456,25 +1456,25 @@ class LaunchBase(Base): return rt def assert_tree_dead(self, recs, timeout=15): - wait_for(lambda: not any(forks.record_alive(r) for r in recs), timeout=timeout) - alive = [r for r in recs if forks.record_alive(r)] + wait_for(lambda: not any(gremlins.record_alive(r) for r in recs), timeout=timeout) + alive = [r for r in recs if gremlins.record_alive(r)] self.assertEqual(alive, [], "processes still alive: %r" % alive) def cleanup_processes(self): reg = None with contextlib.suppress(Exception): - reg = forks.read_registry(self.state) + reg = gremlins.read_registry(self.state) for job, e in ((reg or {}).get("jobs") or {}).items(): rt = self.runtime(job) recs = [r for r in (rt.get("supervisor"), e.get("supervisor"), rt.get("claude")) if r] if recs: - forks.kill_tree(recs[0], extra=recs[1:]) + gremlins.kill_tree(recs[0], extra=recs[1:]) - def fork_marked(self, *directives, **kw): - """A fork whose brief carries `RUN-<job>` so the stub call can be matched to the job.""" - nxt = forks._next_job_id(self.reg()) + def spawn_marked(self, *directives, **kw): + """A spawn whose brief carries `RUN-<job>` so the stub call can be matched to the job.""" + nxt = gremlins._next_job_id(self.reg()) p = self.brief(*directives, text="RUN-%s" % nxt) - out = self.cmd("fork", "--base", kw.get("base", "B1"), "--block", kw.get("block", "1.1"), + out = self.cmd("spawn", "--base", kw.get("base", "B1"), "--block", kw.get("block", "1.1"), "--brief", p, *kw.get("extra", ()), code=kw.get("code", 0)) if kw.get("code", 0) == 0: self.assertEqual(out["job"], nxt) @@ -1482,11 +1482,11 @@ class LaunchBase(Base): class RuleTests(Base): - """Pure functions: the P0.3 usage rule (real numbers from the P0.2 seq-1 fork), C5, C1.""" + """Pure functions: the P0.3 usage rule (real numbers from the P0.2 seq-1 forked session), C5, C1.""" BASE_MU = {"claude-opus-5-5": {"inputTokens": 14, "outputTokens": 7250, "cacheReadInputTokens": 361690, "cacheCreationInputTokens": 176553, "costUSD": 1.629818}} - FORK_RESULT = { + SPAWN_RESULT = { "usage": {"input_tokens": 14, "cache_creation_input_tokens": 3113, "cache_read_input_tokens": 1288193, "output_tokens": 1448, "cache_creation": {"ephemeral_1h_input_tokens": 3113, "ephemeral_5m_input_tokens": 0}}, @@ -1495,8 +1495,8 @@ class RuleTests(Base): "cacheCreationInputTokens": 179666, "costUSD": 1.9413766}}, "total_cost_usd": 1.9413766} - def test_usage_rule_real_fork(self): - u = forks.usage_from_result(self.FORK_RESULT, self.BASE_MU, 1.629818) + def test_usage_rule_real_spawn(self): + u = gremlins.usage_from_result(self.SPAWN_RESULT, self.BASE_MU, 1.629818) self.assertEqual(u["tokens"], {"input": 14, "output": 1448, "cache_creation": 3113, "cache_creation_1h": 3113, "cache_creation_5m": 0, "cache_read": 1288193}) @@ -1505,41 +1505,41 @@ class RuleTests(Base): self.assertAlmostEqual(u["cost_delta"], 0.311559, places=6) def test_usage_rule_mismatch_and_new_model(self): - res = json.loads(json.dumps(self.FORK_RESULT)) + res = json.loads(json.dumps(self.SPAWN_RESULT)) res["usage"]["output_tokens"] += 1 - self.assertTrue(forks.usage_from_result(res, self.BASE_MU, 1.629818)["mismatch"]) + self.assertTrue(gremlins.usage_from_result(res, self.BASE_MU, 1.629818)["mismatch"]) # a model that did not grow is not "used"; a train has no prior totals - res = json.loads(json.dumps(self.FORK_RESULT)) + res = json.loads(json.dumps(self.SPAWN_RESULT)) res["modelUsage"]["claude-haiku-5-5"] = {"inputTokens": 0, "outputTokens": 0} - self.assertEqual(forks.usage_from_result(res, self.BASE_MU, 0)["models_used"], ["claude-opus-5-5"]) - self.assertTrue(forks.usage_from_result(self.FORK_RESULT, {}, 0)["mismatch"]) + self.assertEqual(gremlins.usage_from_result(res, self.BASE_MU, 0)["models_used"], ["claude-opus-5-5"]) + self.assertTrue(gremlins.usage_from_result(self.SPAWN_RESULT, {}, 0)["mismatch"]) def test_cache_verdict(self): u = lambda cr: {"cache_read_input_tokens": cr} - self.assertEqual(forks.cache_verdict(u(182202), 182204), "hit") - self.assertEqual(forks.cache_verdict(u(900), 1000), "hit") - self.assertEqual(forks.cache_verdict(u(899), 1000), "partial") - self.assertEqual(forks.cache_verdict(u(7469), 92877), "partial") # P0.6 base B - self.assertEqual(forks.cache_verdict(u(0), 1000), "miss") - self.assertIsNone(forks.cache_verdict(None, 1000)) + self.assertEqual(gremlins.cache_verdict(u(182202), 182204), "hit") + self.assertEqual(gremlins.cache_verdict(u(900), 1000), "hit") + self.assertEqual(gremlins.cache_verdict(u(899), 1000), "partial") + self.assertEqual(gremlins.cache_verdict(u(7469), 92877), "partial") # P0.6 base B + self.assertEqual(gremlins.cache_verdict(u(0), 1000), "miss") + self.assertIsNone(gremlins.cache_verdict(None, 1000)) def test_warmth(self): cfg = {"ttl_s": 3600} t0 = 1_800_000_000.0 - base = {"last_hit": forks._iso(t0)} - self.assertEqual(forks.base_warmth(base, cfg, t0 + 10)["base_state"], "warm") - self.assertEqual(forks.base_warmth(base, cfg, t0 + 3600 - 361)["base_state"], "warm") - self.assertEqual(forks.base_warmth(base, cfg, t0 + 3600 - 359)["base_state"], "expiring") - self.assertEqual(forks.base_warmth(base, cfg, t0 + 3601)["base_state"], "cold") - self.assertEqual(forks._ts(forks.base_warmth(base, cfg, t0)["warm_until"]), t0 + 3600) + base = {"last_hit": gremlins._iso(t0)} + self.assertEqual(gremlins.base_warmth(base, cfg, t0 + 10)["base_state"], "warm") + self.assertEqual(gremlins.base_warmth(base, cfg, t0 + 3600 - 361)["base_state"], "warm") + self.assertEqual(gremlins.base_warmth(base, cfg, t0 + 3600 - 359)["base_state"], "expiring") + self.assertEqual(gremlins.base_warmth(base, cfg, t0 + 3601)["base_state"], "cold") + self.assertEqual(gremlins._ts(gremlins.base_warmth(base, cfg, t0)["warm_until"]), t0 + 3600) short = {"ttl_s": 300} - self.assertEqual(forks.base_warmth(base, short, t0 + 300 - 121)["base_state"], "warm") - self.assertEqual(forks.base_warmth(base, short, t0 + 300 - 119)["base_state"], "expiring") - self.assertEqual(forks.base_warmth({}, cfg, t0)["base_state"], "cold") + self.assertEqual(gremlins.base_warmth(base, short, t0 + 300 - 121)["base_state"], "warm") + self.assertEqual(gremlins.base_warmth(base, short, t0 + 300 - 119)["base_state"], "expiring") + self.assertEqual(gremlins.base_warmth({}, cfg, t0)["base_state"], "cold") def test_api_error_reason(self): """I1: API errors get their own reason, from api_error_status or the result text.""" - f = forks.api_error_reason + f = gremlins.api_error_reason self.assertIsNone(f(None)) self.assertIsNone(f({"is_error": False, "result": "we talked about the rate limit"})) self.assertIsNone(f({"is_error": True, "subtype": "error_during_execution", "result": "boom"})) @@ -1563,7 +1563,7 @@ class RuleTests(Base): def test_sup_clock_jump(self): """M3: a suspend between supervisor ticks is not run time and re-arms the orphan watchdog.""" - c = forks.SupClock(1000.0, tick_s=0.25, jump_s=60.0) + c = gremlins.SupClock(1000.0, tick_s=0.25, jump_s=60.0) for t in (1000.25, 1000.5, 1000.75, 1030.75): # a 30 s stall is not a jump self.assertIsNone(c.tick(t)) self.assertAlmostEqual(c.elapsed(1030.75), 30.75) @@ -1575,7 +1575,7 @@ class RuleTests(Base): self.assertAlmostEqual(c.orphan_age(9000.0, 8900.0), 100.0) # later calls count as usual def test_stream_state(self): - ss = forks.StreamState() + ss = gremlins.StreamState() u1 = {"input_tokens": 2, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 100} u2 = {"input_tokens": 2, "cache_creation_input_tokens": 5, "cache_read_input_tokens": 112} for ev in ({"type": "system", "subtype": "init", "session_id": "s1", "tools": ["Read", "Bash"], @@ -1590,13 +1590,13 @@ class RuleTests(Base): ss.feed(ev, 5.0) self.assertEqual((ss.session_id, ss.turns), ("s1", 2)) self.assertEqual(ss.first_usage, u1) - self.assertEqual(forks.context_tokens(ss.last_usage), 119) + self.assertEqual(gremlins.context_tokens(ss.last_usage), 119) self.assertIn("is restricted", ss.refused) - rt = forks.runtime_fingerprint(ss.init) + rt = gremlins.runtime_fingerprint(ss.init) self.assertEqual(rt["plugins"], ["spire@0.2.2"]) other = dict(rt, plugins=["spire@0.3.0"], apiKeySource="oauth") - self.assertEqual(forks.runtime_drift(other, rt, {}), ["apiKeySource", "plugins"]) - self.assertEqual(forks.runtime_drift(rt, None, {"permission_mode": "default"}), ["permissionMode"]) + self.assertEqual(gremlins.runtime_drift(other, rt, {}), ["apiKeySource", "plugins"]) + self.assertEqual(gremlins.runtime_drift(rt, None, {"permission_mode": "default"}), ["permissionMode"]) def test_runtime_fingerprint_skills_and_versionless_plugins(self): """M6: sorted `skills` are compared; a plugin without a version is identified by the last @@ -1607,36 +1607,36 @@ class RuleTests(Base): {"name": "skill-creator", "path": "C:\\Users\\u\\.claude\\plugins\\cache\\skill-creator\\abc123\\"}, {"name": "builtin"}]} - rt = forks.runtime_fingerprint(init) + rt = gremlins.runtime_fingerprint(init) self.assertEqual(rt["plugins"], ["builtin@", "skill-creator@path:abc123", "spire@0.2.2"]) self.assertEqual(rt["skills"], ["implementation", "workpad"]) - self.assertEqual(sorted(rt), sorted(forks.RUNTIME_FIELDS)) + self.assertEqual(sorted(rt), sorted(gremlins.RUNTIME_FIELDS)) upd = json.loads(json.dumps(init)) upd["plugins"][1]["path"] = "/home/u/.claude/plugins/cache/skill-creator/def456" - self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(upd), rt, {}), ["plugins"]) + self.assertEqual(gremlins.runtime_drift(gremlins.runtime_fingerprint(upd), rt, {}), ["plugins"]) more = dict(init, skills=["workpad", "implementation", "new-skill"]) - self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(more), rt, {}), ["skills"]) + self.assertEqual(gremlins.runtime_drift(gremlins.runtime_fingerprint(more), rt, {}), ["skills"]) same = dict(init, skills=["implementation", "workpad"]) # order does not count - self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(same), rt, {}), []) + self.assertEqual(gremlins.runtime_drift(gremlins.runtime_fingerprint(same), rt, {}), []) old_ref = {k: v for k, v in rt.items() if k != "skills"} # a base from before M6 - self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(more), old_ref, {}), []) + self.assertEqual(gremlins.runtime_drift(gremlins.runtime_fingerprint(more), old_ref, {}), []) def test_note_written_by(self): """M11: a note counts only when its mtime is at or after the job's creation (less the file-time slack).""" p = os.path.join(self.tmp, "n.md") start = time.time() - 100 - entry = {"created_ts": start, "created_at": forks._iso(start)} - self.assertFalse(forks.note_written_by(p, entry)) # no file + entry = {"created_ts": start, "created_at": gremlins._iso(start)} + self.assertFalse(gremlins.note_written_by(p, entry)) # no file with open(p, "w") as f: f.write("note\n") - for delta, want in ((-forks.NOTE_MTIME_SLACK_S - 1, False), (-forks.NOTE_MTIME_SLACK_S + 0.5, True), + for delta, want in ((-gremlins.NOTE_MTIME_SLACK_S - 1, False), (-gremlins.NOTE_MTIME_SLACK_S + 0.5, True), (0, True), (50, True)): with self.subTest(delta=delta): os.utime(p, (start + delta, start + delta)) - self.assertEqual(forks.note_written_by(p, entry), want) + self.assertEqual(gremlins.note_written_by(p, entry), want) os.utime(p, (start - 60, start - 60)) - self.assertFalse(forks.note_written_by(p, {"created_at": forks._iso(start)})) # ISO fallback + self.assertFalse(gremlins.note_written_by(p, {"created_at": gremlins._iso(start)})) # ISO fallback class WarmthTests(Base): @@ -1647,76 +1647,76 @@ class WarmthTests(Base): E5M = T0 + 300 - 60 # expiring at 5 min (margin 120) def make_reg(self, now, *, pings="on", margin=None, base_ttl=None, status="active", wait_ago=60.0): - reg = forks.new_registry() + reg = gremlins.new_registry() reg["config"] = {"launch": {}, "ttl_s": 3600, "pings": pings, "warm_margin_s": margin} reg["bases"]["B1"] = {"name": "B1", "status": status, "session": "s-b1", - "last_hit": forks._iso(self.T0), "ttl_s": base_ttl, "ping_in_flight": None} - reg["jobs"]["j0001"] = forks._new_entry("j0001", "train", base="B1", status="ok") + "last_hit": gremlins._iso(self.T0), "ttl_s": base_ttl, "ping_in_flight": None} + reg["jobs"]["j0001"] = gremlins._new_entry("j0001", "train", base="B1", status="ok") if wait_ago is not None: - reg["run"]["last_wait_at"] = forks._iso(now - wait_ago) + reg["run"]["last_wait_at"] = gremlins._iso(now - wait_ago) return reg def add_job(self, reg, kind, base="B1", **kw): - j = forks._next_job_id(reg) - reg["jobs"][j] = forks._new_entry(j, kind, base=base, **kw) + j = gremlins._next_job_id(reg) + reg["jobs"][j] = gremlins._new_entry(j, kind, base=base, **kw) return j def decide(self, reg, now): - return forks.ping_decision(reg, "B1", now) + return gremlins.ping_decision(reg, "B1", now) def test_boundaries_1h_and_5m(self): T0 = self.T0 - b = {"last_hit": forks._iso(T0)} + b = {"last_hit": gremlins._iso(T0)} def st(base, cfg, t): - return forks.base_warmth(base, cfg, t)["base_state"] + return gremlins.base_warmth(base, cfg, t)["base_state"] cfg = {"ttl_s": 3600} for t, want in ((T0 + 3239, "warm"), (T0 + 3240, "expiring"), (T0 + 3599, "expiring"), (T0 + 3600, "cold")): with self.subTest(ttl=3600, t=t - T0): self.assertEqual(st(b, cfg, t), want) - w = forks.base_warmth(b, cfg, T0) - self.assertEqual((w["ttl_s"], w["margin_s"], forks._ts(w["warm_until"])), (3600, 360, T0 + 3600)) + w = gremlins.base_warmth(b, cfg, T0) + self.assertEqual((w["ttl_s"], w["margin_s"], gremlins._ts(w["warm_until"])), (3600, 360, T0 + 3600)) # 5 min: inferred on the base (config still 3600), and configured for base, c in (({**b, "ttl_s": 300}, cfg), (b, {"ttl_s": 300})): for t, want in ((T0 + 179, "warm"), (T0 + 180, "expiring"), (T0 + 299, "expiring"), (T0 + 300, "cold")): with self.subTest(ttl=300, base_ttl=base.get("ttl_s"), t=t - T0): self.assertEqual(st(base, c, t), want) - w = forks.base_warmth(base, c, T0) - self.assertEqual((w["ttl_s"], w["margin_s"], forks._ts(w["warm_until"])), (300, 120, T0 + 300)) + w = gremlins.base_warmth(base, c, T0) + self.assertEqual((w["ttl_s"], w["margin_s"], gremlins._ts(w["warm_until"])), (300, 120, T0 + 300)) # init --warm-margin-s overrides the formula, at either TTL m = {"ttl_s": 3600, "warm_margin_s": 900} for t, want in ((T0 + 2699, "warm"), (T0 + 2700, "expiring"), (T0 + 3600, "cold")): with self.subTest(margin=900, t=t - T0): self.assertEqual(st(b, m, t), want) - self.assertEqual(forks.base_warmth(b, m, T0)["margin_s"], 900) + self.assertEqual(gremlins.base_warmth(b, m, T0)["margin_s"], 900) # Minor 2: at a 5-minute TTL the 900 s margin is capped at ttl/2 = 150 s for t, want in ((T0, "warm"), (T0 + 149, "warm"), (T0 + 150, "expiring"), (T0 + 299, "expiring"), (T0 + 300, "cold")): with self.subTest(margin=900, base_ttl=300, t=t - T0): self.assertEqual(st({**b, "ttl_s": 300}, m, t), want) - self.assertEqual(forks.base_warmth({**b, "ttl_s": 300}, m, T0)["margin_s"], 150) + self.assertEqual(gremlins.base_warmth({**b, "ttl_s": 300}, m, T0)["margin_s"], 150) self.assertEqual(st({}, cfg, T0), "cold") def test_margin_capped_at_half_the_ttl(self): """Minor 2: a configured margin above ttl/2 is capped there, so a 5-minute downgrade does not leave a base `expiring` (and pinged every tick) right after every hit.""" T0 = self.T0 - b = {"last_hit": forks._iso(T0), "ttl_s": 300} - w = forks.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": 600}, T0 + 1) + b = {"last_hit": gremlins._iso(T0), "ttl_s": 300} + w = gremlins.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": 600}, T0 + 1) self.assertEqual((w["base_state"], w["margin_s"], w["ttl_s"]), ("warm", 150, 300)) reg = self.make_reg(T0 + 1, margin=600, base_ttl=300) self.assertEqual(self.decide(reg, T0 + 1)["why"], "state:warm") self.assertTrue(self.decide(reg, T0 + 150)["ping"]) # a margin below ttl/2 is used as given; the formula applies when none is set - self.assertEqual(forks.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": 100}, T0)["margin_s"], 100) - self.assertEqual(forks.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": None}, T0)["margin_s"], 120) + self.assertEqual(gremlins.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": 100}, T0)["margin_s"], 100) + self.assertEqual(gremlins.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": None}, T0)["margin_s"], 120) def test_infer_ttl_and_downgrade(self): def t(h=0, m=0, r=0): return {"cache_creation_1h": h, "cache_creation_5m": m, "cache_read": r} - f = forks.infer_ttl + f = gremlins.infer_ttl self.assertEqual(f(t(h=100, r=5), None), 3600) # 1h-only self.assertEqual(f(t(h=100), 300), 3600) self.assertEqual(f(t(m=100, r=5), 3600), 300) # 5m-only @@ -1727,18 +1727,18 @@ class WarmthTests(Base): self.assertEqual(f(t(r=500), None, 3600), 3600) self.assertEqual(f(t(), 300), 300) self.assertEqual(f(None, 300), 300) - self.assertTrue(forks.wrote_only_5m(t(m=100))) - self.assertFalse(forks.wrote_only_5m(t(h=1, m=1))) - self.assertFalse(forks.wrote_only_5m(t(h=100))) - self.assertFalse(forks.wrote_only_5m(t(r=5))) - self.assertFalse(forks.wrote_only_5m(None)) + self.assertTrue(gremlins.wrote_only_5m(t(m=100))) + self.assertFalse(gremlins.wrote_only_5m(t(h=1, m=1))) + self.assertFalse(gremlins.wrote_only_5m(t(h=100))) + self.assertFalse(gremlins.wrote_only_5m(t(r=5))) + self.assertFalse(gremlins.wrote_only_5m(None)) # warmth with no inferred TTL falls back to the configured one - b = {"last_hit": forks._iso(self.T0), "ttl_s": None} - self.assertEqual(forks.base_warmth(b, {"ttl_s": 300}, self.T0)["ttl_s"], 300) + b = {"last_hit": gremlins._iso(self.T0), "ttl_s": None} + self.assertEqual(gremlins.base_warmth(b, {"ttl_s": 300}, self.T0)["ttl_s"], 300) def test_break_even(self): - self.assertEqual(forks.break_even(3600), 19) - self.assertEqual(forks.break_even(300), 11) + self.assertEqual(gremlins.break_even(3600), 19) + self.assertEqual(gremlins.break_even(300), 11) def test_ping_rule_states_status_and_pings_off(self): now = self.E1H @@ -1770,17 +1770,17 @@ class WarmthTests(Base): d = self.decide(self.make_reg(now, wait_ago=ago), now) self.assertEqual((d["ping"], d["why"]), (False, "no_activity")) reg = self.make_reg(now, wait_ago=None) - self.add_job(reg, "train", base="B2", status="running") # not a fork/continue + self.add_job(reg, "train", base="B2", status="running") # not a spawn/continue self.add_job(reg, "ping", base="B2", status="running") self.assertEqual(self.decide(reg, now)["why"], "no_activity") - for kind in ("fork", "continue"): + for kind in ("spawn", "continue"): with self.subTest(kind=kind): reg = self.make_reg(now, wait_ago=None) j = self.add_job(reg, kind, base="B2", status="running") self.assertTrue(self.decide(reg, now)["ping"]) # running now - reg["jobs"][j].update(status="ok", ended_at=forks._iso(now - 590)) + reg["jobs"][j].update(status="ok", ended_at=gremlins._iso(now - 590)) self.assertTrue(self.decide(reg, now)["ping"]) # ended 590 s ago - reg["jobs"][j]["ended_at"] = forks._iso(now - 610) + reg["jobs"][j]["ended_at"] = gremlins._iso(now - 610) self.assertEqual(self.decide(reg, now)["why"], "no_activity") def test_ping_cap_19_and_11(self): @@ -1797,7 +1797,7 @@ class WarmthTests(Base): self.add_job(reg, "ping", base="B2", status="ok") # another base's pings do not self.assertEqual(self.decide(reg, now)["pings_since_real_call"], cap) # a real call on the base starts the count again - self.add_job(reg, "fork", status="ok", ended_at=forks._iso(now - 5000)) + self.add_job(reg, "spawn", status="ok", ended_at=gremlins._iso(now - 5000)) d = self.decide(reg, now) self.assertEqual((d["ping"], d["pings_since_real_call"]), (True, 0)) self.add_job(reg, "ping", status="ok") @@ -1817,54 +1817,54 @@ class WarmthTests(Base): def test_pings_not_in_cap(self): reg = self.make_reg(self.E1H) self.add_job(reg, "ping", status="running") - self.assertEqual(forks._running_capped(reg), []) - f = self.add_job(reg, "fork", status="running") - self.assertEqual(forks._running_capped(reg), [f]) + self.assertEqual(gremlins._running_capped(reg), []) + f = self.add_job(reg, "spawn", status="running") + self.assertEqual(gremlins._running_capped(reg), [f]) class TickTests(Base): - """`tick_pass` on a real state dir and lock; the spawn and the binary check are stubbed, so no + """`tick_pass` on a real state dir and lock; the detach and the binary check are stubbed, so no process is started (C3d dedupe, the `tick` command, N3).""" def setUp(self): super().setUp() os.makedirs(os.path.join(self.tmp, "reports")) self.run_log = os.path.join(self.tmp, "reports", "2026-10-16-16-t-run.md") - self.state = forks.state_dir_for(self.run_log) - forks.ensure_state(self.state) + self.state = gremlins.state_dir_for(self.run_log) + gremlins.ensure_state(self.state) now = time.time() self.stub = make_stub_binary(self.tmp) self.binfo = {"path": self.stub, "version": STUB_VERSION, "size": 0, "mtime": 0} launch = {"model": TEST_MODEL, "effort": "high", "permission_mode": "dontAsk", "tools": ["Read"], "allowed_tools": ["Read"], "disallowed_tools": [], - "mcp": {"strict": True, "config": forks.mcp_empty_path(self.state)}, - "append_system_prompt": {"path": forks.preamble_path(), "sha256": "x"}, "agent": None, + "mcp": {"strict": True, "config": gremlins.mcp_empty_path(self.state)}, + "append_system_prompt": {"path": gremlins.preamble_path(), "sha256": "x"}, "agent": None, "cwd": self.tmp, "binary": self.binfo, "env": {"pinned": {}}} - reg = forks.new_registry() + reg = gremlins.new_registry() reg["config"] = {"launch": launch, "ttl_s": 3600, "pings": "on", "warm_margin_s": None, "max_minutes": 90} reg["bases"]["B1"] = {"name": "B1", "status": "active", "session": "s-b1", - "last_hit": forks._iso(now - 3500), "cost_cum_usd": 1.25, + "last_hit": gremlins._iso(now - 3500), "cost_cum_usd": 1.25, "model_usage_cum": {TEST_MODEL: {"inputTokens": 1}}, "context_tokens": 1000} - reg["jobs"]["j0001"] = forks._new_entry("j0001", "train", base="B1", status="ok") - reg["run"]["last_wait_at"] = forks._iso(now - 60) - with forks.RegistryLock(self.state): - forks.write_registry(self.state, reg) + reg["jobs"]["j0001"] = gremlins._new_entry("j0001", "train", base="B1", status="ok") + reg["run"]["last_wait_at"] = gremlins._iso(now - 60) + with gremlins.RegistryLock(self.state): + gremlins.write_registry(self.state, reg) p = subprocess.Popen([sys.executable, "-c", "pass"]) - dead = forks.pid_record(p.pid) + dead = gremlins.pid_record(p.pid) p.wait() self.spawned = [] - def spawn(state, run, job): + def fake_detach(state, run, job): self.spawned.append((run, job)) return {"ok": True, "method": "test", "pid": dead["pid"], "ctime": dead["ctime"]} - for name, fn in (("spawn_supervisor", spawn), ("_check_binary", lambda ref, la: (self.binfo, []))): - pt = mock.patch.object(forks, name, fn) + for name, fn in (("detach_supervisor", fake_detach), ("_check_binary", lambda ref, la: (self.binfo, []))): + pt = mock.patch.object(gremlins, name, fn) pt.start() self.addCleanup(pt.stop) def reg(self): - return forks.read_registry(self.state) + return gremlins.read_registry(self.state) def test_two_tickers_one_ping(self): """C3(d): two tickers that both judged the base eligible (outside the lock) launch one ping; @@ -1878,10 +1878,10 @@ class TickTests(Base): def ticker(): try: - results.append(forks.tick_pass(self.state)) + results.append(gremlins.tick_pass(self.state)) except Exception as e: # surfaced below errors.append(e) - with mock.patch.object(forks, "_check_binary", check): + with mock.patch.object(gremlins, "_check_binary", check): ths = [threading.Thread(target=ticker) for _ in range(2)] for th in ths: th.start() @@ -1897,11 +1897,11 @@ class TickTests(Base): reg = self.reg() self.assertEqual([j for j, e in reg["jobs"].items() if e["kind"] == "ping"], [job]) self.assertEqual(reg["bases"]["B1"]["ping_in_flight"], job) - self.assertEqual(self.spawned, [(forks.norm_path(self.run_log), job)]) + self.assertEqual(self.spawned, [(gremlins.norm_path(self.run_log), job)]) e = reg["jobs"][job] self.assertEqual((e["status"], e["base"], e["parent_session"], e["base_state_at_launch"]), ("running", "B1", "s-b1", "expiring")) - self.assertEqual((e["cost_before"], e["base_context"], e["max_s"]), (1.25, 1000, forks.PING_MAX_S)) + self.assertEqual((e["cost_before"], e["base_context"], e["max_s"]), (1.25, 1000, gremlins.PING_MAX_S)) a = e["argv"] self.assertEqual(a[a.index("--resume") + 1], "s-b1") self.assertIn("--fork-session", a) @@ -1909,42 +1909,42 @@ class TickTests(Base): self.assertEqual(a[a.index("--max-turns") + 1], "1") self.assertEqual(a[a.index("--model") + 1], TEST_MODEL) with open(os.path.join(self.state, "briefs", job + ".md"), encoding="utf-8") as f: - self.assertEqual(f.read(), forks.PING_PROMPT) - self.assertEqual(forks._running_capped(reg), []) # pings are not in the cap + self.assertEqual(f.read(), gremlins.PING_PROMPT) + self.assertEqual(gremlins._running_capped(reg), []) # pings are not in the cap # a third ticker: still in flight - out = forks.tick_pass(self.state) + out = gremlins.tick_pass(self.state) self.assertEqual((out["pinged"], out["bases"][0]["why"]), ([], "in_flight:%s" % job)) # the ping ends (any status): the mark clears, the result says it missed - res = forks.finalize_job(self.state, job, ss=None, hint="killed", reason="kill") + res = gremlins.finalize_job(self.state, job, ss=None, hint="killed", reason="kill") self.assertIn("ping_missed", res["flags"]) self.assertEqual(res["kind"], "ping") self.assertIsNone(self.reg()["bases"]["B1"]["ping_in_flight"]) - out = forks.tick_pass(self.state) + out = gremlins.tick_pass(self.state) self.assertEqual(len(out["pinged"]), 1) self.assertEqual(out["bases"][0]["pings_since_real_call"], 1) # counted before this one def test_ping_carries_the_phase_and_counts_in_usage_phase(self): - """I2: a ping records the phase of the latest train/fork/continue/rebase job on its base + """I2: a ping records the phase of the latest train/spawn/continue/rebase job on its base (the base's own phase if none has one), so `usage --phase N` (md and json) lists it and its cost is in the priced total.""" reg = self.reg() - self.assertIsNone(forks.ping_phase(reg, "B1")) # nothing has a phase + self.assertIsNone(gremlins.ping_phase(reg, "B1")) # nothing has a phase reg["bases"]["B1"]["phase"] = 1 - self.assertEqual(forks.ping_phase(reg, "B1"), 1) # the base's own - reg["jobs"]["j0002"] = forks._new_entry("j0002", "fork", base="B1", phase=2, status="ok", - ended_at=forks._iso(time.time() - 4000)) - reg["jobs"]["j0003"] = forks._new_entry("j0003", "fork", base="B9", phase=7, status="ok") - reg["jobs"]["j0004"] = forks._new_entry("j0004", "continue", base="B1", phase=None, status="ok") - reg["jobs"]["j0005"] = forks._new_entry("j0005", "ping", base="B1", phase=5, status="ok") - self.assertEqual(forks.ping_phase(reg, "B1"), 2) # latest real job with a phase - with forks.RegistryLock(self.state): - forks.write_registry(self.state, reg) - out = forks.tick_pass(self.state) + self.assertEqual(gremlins.ping_phase(reg, "B1"), 1) # the base's own + reg["jobs"]["j0002"] = gremlins._new_entry("j0002", "spawn", base="B1", phase=2, status="ok", + ended_at=gremlins._iso(time.time() - 4000)) + reg["jobs"]["j0003"] = gremlins._new_entry("j0003", "spawn", base="B9", phase=7, status="ok") + reg["jobs"]["j0004"] = gremlins._new_entry("j0004", "continue", base="B1", phase=None, status="ok") + reg["jobs"]["j0005"] = gremlins._new_entry("j0005", "ping", base="B1", phase=5, status="ok") + self.assertEqual(gremlins.ping_phase(reg, "B1"), 2) # latest real job with a phase + with gremlins.RegistryLock(self.state): + gremlins.write_registry(self.state, reg) + out = gremlins.tick_pass(self.state) self.assertEqual(len(out["pinged"]), 1, out) job = out["pinged"][0] self.assertEqual(self.reg()["jobs"][job]["phase"], 2) # the ping ends ok with a known cost (cost basis: the base's 1.25) - ss = forks.StreamState() + ss = gremlins.StreamState() t = time.time() u = {"input_tokens": 1, "cache_read_input_tokens": 1000, "cache_creation_input_tokens": 0} ss.feed({"type": "assistant", "message": {"id": "m1", "usage": u}}, t) @@ -1953,9 +1953,9 @@ class TickTests(Base): "modelUsage": {TEST_MODEL: {"inputTokens": 2, "outputTokens": 2, "cacheReadInputTokens": 1000, "cacheCreationInputTokens": 0}}}, t) - res = forks.finalize_job(self.state, job, ss=ss, hint="exited", exit_code=0, wall_s=1.0) + res = gremlins.finalize_job(self.state, job, ss=ss, hint="exited", exit_code=0, wall_s=1.0) self.assertEqual((res["status"], res["phase"], res["cost_delta_usd"]), ("ok", 2, 0.0123), res) - line = [x for x in forks.read_ledger(self.state) if x["job"] == job][0] + line = [x for x in gremlins.read_ledger(self.state) if x["job"] == job][0] self.assertEqual(line["phase"], 2) code, js, _ = call(["usage", "--run", self.run_log, "--phase", "2"]) self.assertEqual(code, 0, js) @@ -1977,30 +1977,30 @@ class TickTests(Base): self.assertEqual(len(out["pinged"]), 1) self.assertEqual(out["state"], self.state) self.assertEqual(out["bases"][0]["job"], out["pinged"][0]) - os.environ[forks.CHILD_MARKER] = "1" + os.environ[gremlins.CHILD_MARKER] = "1" code, out, raw = call(["tick", "--run", self.run_log]) self.assertEqual(code, 2, out) self.assert_one_json_line(raw) - self.assertIn("inside a fork", out["error"]) + self.assertIn("inside a gremlin", out["error"]) self.assertEqual(len([e for e in self.reg()["jobs"].values() if e["kind"] == "ping"]), 1) def test_tick_nothing_to_do_takes_no_lock_and_binary_missing(self): - with forks.RegistryLock(self.state): + with gremlins.RegistryLock(self.state): reg = self.reg() reg["config"]["pings"] = "off" - forks.write_registry(self.state, reg) - with forks.RegistryLock(self.state): # held: a pass with nothing to ping must not wait - out = forks.tick_pass(self.state) + gremlins.write_registry(self.state, reg) + with gremlins.RegistryLock(self.state): # held: a pass with nothing to ping must not wait + out = gremlins.tick_pass(self.state) self.assertEqual((out["pinged"], out["bases"][0]["why"]), ([], "pings_off")) - with forks.RegistryLock(self.state): + with gremlins.RegistryLock(self.state): reg = self.reg() reg["config"]["pings"] = "on" - forks.write_registry(self.state, reg) + gremlins.write_registry(self.state, reg) def missing(ref, la): - raise forks.ForksError("claude binary missing", reason="binary_missing") - with mock.patch.object(forks, "_check_binary", missing): - out = forks.tick_pass(self.state) + raise gremlins.GremlinsError("claude binary missing", reason="binary_missing") + with mock.patch.object(gremlins, "_check_binary", missing): + out = gremlins.tick_pass(self.state) self.assertEqual((out["pinged"], out["bases"][0]["why"]), ([], "binary_missing")) self.assertEqual(self.spawned, []) self.assertEqual(list(self.reg()["jobs"]), ["j0001"]) @@ -2010,13 +2010,13 @@ def call_raw(argv): """Like `call`, but for `usage --format md`: stdout is not JSON. Returns (exit code, raw stdout).""" out, err = io.StringIO(), io.StringIO() with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): - code = forks.main(list(argv)) + code = gremlins.main(list(argv)) return code, out.getvalue() SONNET = "claude-sonnet-4-5" SESS_TRAIN = "aaaaaaaa-0000-4000-8000-000000000001" -SESS_FORK = "bbbbbbbb-0000-4000-8000-000000000002" +SESS_SPAWN = "bbbbbbbb-0000-4000-8000-000000000002" SESS_KILLED = "cccccccc-0000-4000-8000-000000000005" SESS_PAR = "dddddddd-0000-4000-8000-000000000004" SESS_PING = "eeeeeeee-0000-4000-8000-000000000006" @@ -2042,43 +2042,43 @@ class UsageTests(Base): self.sha.append(self.git("rev-parse", "HEAD")) os.makedirs(os.path.join(self.tmp, "reports")) self.run_log = os.path.join(self.tmp, "reports", "2026-10-16-18-t-run.md") - self.state = forks.state_dir_for(self.run_log) - forks.ensure_state(self.state) - reg = forks.new_registry() + self.state = gremlins.state_dir_for(self.run_log) + gremlins.ensure_state(self.state) + reg = gremlins.new_registry() reg["config"] = {"launch": {"cwd": self.cwd}, "ttl_s": 3600, "pings": "on", "max_minutes": 90} s = self.sha specs = [ # job, kind, block, phase, mode, session, launch, end, models, tokens, cost, cache, flags ("j0001", "train", None, 1, None, SESS_TRAIN, s[0], s[0], [TEST_MODEL], (10, 20, 1000, 1000, 0, 0), 0.5, None, []), - ("j0002", "fork", "1.1", 1, "sequential", SESS_FORK, s[0], s[2], [TEST_MODEL], + ("j0002", "spawn", "1.1", 1, "sequential", SESS_SPAWN, s[0], s[2], [TEST_MODEL], (2, 1500, 3000, 3000, 0, 1234567), 1.2345, "hit", []), - ("j0003", "continue", "1.1", 1, "sequential", SESS_FORK, s[2], s[2], [TEST_MODEL, SONNET], + ("j0003", "continue", "1.1", 1, "sequential", SESS_SPAWN, s[2], s[2], [TEST_MODEL, SONNET], (3, 700, 200, 0, 200, 1300000), 0.25, "partial", ["cold_start"]), - ("j0004", "fork", "1.2", 1, "parallel", SESS_PAR, s[0], s[2], [TEST_MODEL], + ("j0004", "spawn", "1.2", 1, "parallel", SESS_PAR, s[0], s[2], [TEST_MODEL], (1, 100, 0, 0, 0, 50000), 0.1, "hit", []), - ("j0005", "fork", "1.3", 2, "sequential", SESS_KILLED, s[2], None, [], + ("j0005", "spawn", "1.3", 2, "sequential", SESS_KILLED, s[2], None, [], None, None, None, []), ("j0006", "ping", None, None, None, SESS_PING, None, None, [TEST_MODEL], (1, 1, 50, 50, 0, 5000), 0.01, "partial", []), ] self.lines = [] for job, kind, block, phase, mode, sess, head0, head1, models, tok, cost, cache, flags in specs: - reg["jobs"][job] = forks._new_entry(job, kind, base="B1", block=block, phase=phase, mode=mode, + reg["jobs"][job] = gremlins._new_entry(job, kind, base="B1", block=block, phase=phase, mode=mode, cwd=self.cwd, session=sess, status="ok", head_at_launch=head0) if head1: reg["jobs"][job]["head_at_end"] = head1 - tokens = ({k: None for k in forks.TOKEN_KEYS} if tok is None else - dict(zip(forks.TOKEN_KEYS, tok))) + tokens = ({k: None for k in gremlins.TOKEN_KEYS} if tok is None else + dict(zip(gremlins.TOKEN_KEYS, tok))) self.lines.append({"job": job, "kind": kind, "phase": phase, "block": block, "base": "B1", "session": sess, "models_used": models, "tokens": tokens, "cost_delta_usd": cost, "cache": cache, "flags": flags, "status": "killed" if tok is None else "ok"}) reg["bases"]["B1"] = {"name": "B1", "status": "active", "session": "s-b1"} - with forks.RegistryLock(self.state): - forks.write_registry(self.state, reg) + with gremlins.RegistryLock(self.state): + gremlins.write_registry(self.state, reg) for ln in self.lines: - forks.ledger_append(self.state, ln) + gremlins.ledger_append(self.state, ln) def git(self, *args): r = subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=self.cwd, @@ -2098,7 +2098,7 @@ class UsageTests(Base): return raw def ledger_lines(self): - with open(forks.ledger_path(self.state), "rb") as f: + with open(gremlins.ledger_path(self.state), "rb") as f: return [json.loads(x) for x in f.read().decode("utf-8").splitlines() if x.strip()] # --- md --- @@ -2114,7 +2114,7 @@ class UsageTests(Base): ""])) def test_md_block_parallel_commit_is_dash(self): - """A parallel fork shows an em dash even though HEAD moved during it (the Athanor commits).""" + """A parallel spawn shows an em dash even though HEAD moved during it (the Athanor commits).""" self.assertNotEqual(self.sha[0], self.sha[2]) self.assertEqual(self.usage_md("--block", "1.2"), "\n".join([ BLOCK_HEADER, "|---|---|---|---|---|---|---|---|---|", @@ -2134,7 +2134,7 @@ class UsageTests(Base): "| Kind | Calls | in | out | cache write | cache read | cost |", "|---|---|---|---|---|---|---|", "| train | 1 | 10 | 20 | 1,000 | 0 | $0.5000 |", - "| fork | 2 | 3 | 1,600 | 3,000 | 1,284,567 | $1.3345 |", + "| spawn | 2 | 3 | 1,600 | 3,000 | 1,284,567 | $1.3345 |", "| continue | 1 | 3 | 700 | 200 | 1,300,000 | $0.2500 |", "| total | 4 | 16 | 2,320 | 4,200 | 2,584,567 | $2.0845 |", "", @@ -2147,11 +2147,11 @@ class UsageTests(Base): self.assertEqual(self.usage_md("--phase", "2"), "\n".join([ "| Kind | Calls | in | out | cache write | cache read | cost |", "|---|---|---|---|---|---|---|", - "| fork | 1 | 0 | 0 | 0 | 0 | $0.0000 |", + "| spawn | 1 | 0 | 0 | 0 | 0 | $0.0000 |", "| total | 1 | 0 | 0 | 0 | 0 | $0.0000 |", "", "- Cold starts: 0", - "- Cache-hit ratio: n/a (no fork, continue or rebase call with a verdict)", + "- Cache-hit ratio: n/a (no spawn, continue or rebase call with a verdict)", "- Priced total: $0.0000", ""])) @@ -2174,7 +2174,7 @@ class UsageTests(Base): """Delta and em dash are not in cp1252/ascii: the bytes must still be UTF-8.""" env = child_env() env["PYTHONIOENCODING"] = "ascii:strict" - r = subprocess.run([sys.executable, FORKS_PY, "usage", "--run", self.run_log, "--block", "1.3", + r = subprocess.run([sys.executable, GREMLINS_PY, "usage", "--run", self.run_log, "--block", "1.3", "--format", "md"], env=env, capture_output=True, timeout=120) self.assertEqual(r.returncode, 0, r.stderr) text = r.stdout.decode("utf-8") @@ -2187,17 +2187,17 @@ class UsageTests(Base): out = self.usage() self.assertEqual(out["scope"], None) self.assertEqual(out["reconciled"], []) - self.assertEqual(list(out["kinds"]), ["train", "rebase", "fork", "continue", "ping"]) + self.assertEqual(list(out["kinds"]), ["train", "rebase", "spawn", "continue", "ping"]) k = out["kinds"] self.assertEqual(k["rebase"], {"calls": 0, "input": 0, "output": 0, "cache_creation": 0, "cache_creation_1h": 0, "cache_creation_5m": 0, "cache_read": 0, "cost_usd": 0.0}) - self.assertEqual({x: k["fork"][x] for x in ("calls", "input", "output", "cache_creation", + self.assertEqual({x: k["spawn"][x] for x in ("calls", "input", "output", "cache_creation", "cache_creation_1h", "cache_creation_5m", "cache_read")}, {"calls": 3, "input": 3, "output": 1600, "cache_creation": 3000, "cache_creation_1h": 3000, "cache_creation_5m": 0, "cache_read": 1284567}) - self.assertAlmostEqual(k["fork"]["cost_usd"], 1.3345, places=6) + self.assertAlmostEqual(k["spawn"]["cost_usd"], 1.3345, places=6) self.assertEqual((k["continue"]["calls"], k["continue"]["cache_creation_5m"], k["continue"]["cache_creation_1h"]), (1, 200, 0)) self.assertEqual((k["ping"]["calls"], k["ping"]["cache_read"]), (1, 5000)) @@ -2222,7 +2222,7 @@ class UsageTests(Base): out = self.usage("--block", "1.1") self.assertEqual(out["scope"], {"block": "1.1"}) self.assertEqual([x["job"] for x in out["rows"]], ["j0002", "j0003"]) - self.assertEqual(out["kinds"]["fork"]["calls"], 1) + self.assertEqual(out["kinds"]["spawn"]["calls"], 1) out = self.usage("--block", "1.3") # null usage: counted as a call, adds nothing self.assertEqual((out["total"]["calls"], out["total"]["input"], out["total"]["cost_usd"]), (1, 0, 0.0)) @@ -2241,34 +2241,35 @@ class UsageTests(Base): self.assertEqual(c, 2) self.assertEqual(json.loads(raw)["error"], "no launch config for this run; call init first") - def test_allowed_inside_a_fork(self): - os.environ[forks.CHILD_MARKER] = "1" + def test_allowed_inside_a_gremlin(self): + os.environ[gremlins.CHILD_MARKER] = "1" out = self.usage("--phase", "1") self.assertEqual(out["total"]["calls"], 4) - self.assertIn("| fork | 2 |", self.usage_md("--phase", "1")) + self.assertIn("| spawn | 2 |", self.usage_md("--phase", "1")) # --- R1 --- def result_for(self, job, **kw): - res = {"job": job, "kind": "fork", "base": "B1", "block": "2.1", "phase": 2, "status": "ok", - "flags": [], "fingerprint": "ok", "session": "ffffffff-0000-4000-8000-000000000007", + res = {"job": job, "kind": "spawn", "method": "fork", "base": "B1", "block": "2.1", "phase": 2, + "status": "ok", "flags": [], "fingerprint": "ok", "session": "ffffffff-0000-4000-8000-000000000007", "parent_session": "s-b1", "models_used": [TEST_MODEL], - "tokens": dict(zip(forks.TOKEN_KEYS, (4, 50, 600, 600, 0, 7000))), + "tokens": dict(zip(gremlins.TOKEN_KEYS, (4, 50, 600, 600, 0, 7000))), "cost_delta_usd": 0.75, "cache": "hit", "base_state": "warm", "wall_s": 12.5, "turns": 3, "denials": 0, "note": None, "raw": "raw/%s.jsonl" % job, "paths": None, "reason": None, "model_requested": TEST_MODEL} res.update(kw) - forks.write_json_atomic(forks.result_file(self.state, job), res) + gremlins.write_json_atomic(gremlins.result_file(self.state, job), res) return res def test_r1_result_without_ledger_line_gets_exactly_one(self): # a finished result whose ledger line was lost (killed between registry write and append) - with forks.RegistryLock(self.state): - reg = forks.read_registry(self.state) - reg["jobs"]["j0007"] = forks._new_entry( - "j0007", "fork", base="B1", block="2.1", phase=2, status="ok", cwd=self.cwd, + with gremlins.RegistryLock(self.state): + reg = gremlins.read_registry(self.state) + reg["jobs"]["j0007"] = gremlins._new_entry( + "j0007", "spawn", method="fork", base="B1", block="2.1", phase=2, status="ok", + cwd=self.cwd, ended_at="2026-10-16T17:00:00+00:00", cost_cum_usd=3.25, binary_version="2.1.999", started_at="2026-10-16T16:59:00+00:00", head_at_launch=self.sha[0]) - forks.write_registry(self.state, reg) + gremlins.write_registry(self.state, reg) self.result_for("j0007") self.result_for("j0002") # has a line already: must stay at one n0 = len(self.ledger_lines()) @@ -2280,12 +2281,13 @@ class UsageTests(Base): ln = lines[0] self.assertTrue(ln["reconciled"]) self.assertEqual((ln["kind"], ln["block"], ln["phase"], ln["status"], ln["cache"], ln["turns"]), - ("fork", "2.1", 2, "ok", "hit", 3)) + ("spawn", "2.1", 2, "ok", "hit", 3)) + self.assertEqual(ln["method"], "fork") self.assertEqual(ln["tokens"]["cache_read"], 7000) self.assertEqual((ln["binary"], ln["cost_cumulative_usd"], ln["ended"], ln["started"]), ("2.1.999", 3.25, "2026-10-16T17:00:00+00:00", "2026-10-16T16:59:00+00:00")) self.assertEqual(ln["models_used"], [TEST_MODEL]) - # it is counted: phase 2 = the killed fork (null) + the reconciled one + # it is counted: phase 2 = the killed spawn (null) + the reconciled one self.assertEqual(out["total"]["calls"], 2) self.assertEqual(out["total"]["cache_read"], 7000) self.assertAlmostEqual(out["total"]["cost_usd"], 0.75, places=6) @@ -2299,7 +2301,7 @@ class UsageTests(Base): def test_r1_without_registry_entry_and_with_torn_ledger_tail(self): self.result_for("j0009", block="3.1", phase=3) # no registry entry at all - with open(forks.ledger_path(self.state), "ab") as f: # a torn last line (no newline) + with open(gremlins.ledger_path(self.state), "ab") as f: # a torn last line (no newline) f.write(b'{"job": "j00') out = self.usage("--phase", "3") self.assertEqual(out["reconciled"], ["j0009"]) @@ -2313,10 +2315,10 @@ class UsageTests(Base): on its orchestrator stamp first, so a reconciliation without the lock would pass too).""" self.result_for("j0010", block="4.1", phase=4) done = [] - lock = forks.RegistryLock(self.state) + lock = gremlins.RegistryLock(self.state) lock.acquire() try: - th = threading.Thread(target=lambda: done.append(forks.reconcile_ledger(self.state))) + th = threading.Thread(target=lambda: done.append(gremlins.reconcile_ledger(self.state))) th.start() time.sleep(1.0) self.assertEqual(done, []) # blocked on the lock @@ -2332,7 +2334,7 @@ class UsageTests(Base): (R1 line, then `reap_lost`/`kill` rewrote the result) reads its status, flags and reason from the result in `usage`; tokens and cost stay the ledger's.""" self.result_for("j0002", status="lost", flags=["cold_start"], reason="supervisor_dead", - block="1.1", phase=1, tokens=dict(zip(forks.TOKEN_KEYS, (9, 9, 9, 9, 0, 9))), + block="1.1", phase=1, tokens=dict(zip(gremlins.TOKEN_KEYS, (9, 9, 9, 9, 0, 9))), cost_delta_usd=9.0) self.result_for("j0004", block="1.2", phase=1, flags=[]) # agrees with its line n0 = len(self.ledger_lines()) @@ -2353,29 +2355,30 @@ class UsageTests(Base): def test_r1_nothing_missing_takes_no_lock(self): self.result_for("j0002") # a result that has its ledger line - with forks.RegistryLock(self.state): # held: a pass with nothing to add must not wait - self.assertEqual(forks.reconcile_ledger(self.state), []) + with gremlins.RegistryLock(self.state): # held: a pass with nothing to add must not wait + self.assertEqual(gremlins.reconcile_ledger(self.state), []) self.assertEqual(len([x for x in self.ledger_lines() if x["job"] == "j0002"]), 1) def test_r1_corrupt_result_is_skipped(self): - with open(forks.result_file(self.state, "j0011"), "w") as f: + with open(gremlins.result_file(self.state, "j0011"), "w") as f: f.write("{not json") self.assertEqual(self.usage("--phase", "1")["reconciled"], []) self.assertNotIn("j0011", [x["job"] for x in self.ledger_lines()]) class LaunchTests(LaunchBase): - def test_train_fork_continue_end_to_end(self): + def test_train_spawn_continue_end_to_end(self): self.setup_run() t = self.train("B1", "turns=4") self.assertEqual((t["job"], t["base"]), ("j0001", "B1")) r = self.finish("j0001") self.assertEqual(r["status"], "ok", r) - self.assertEqual(set(r), {"job", "kind", "base", "block", "phase", "status", "flags", "fingerprint", - "session", "parent_session", "models_used", "tokens", "cost_delta_usd", - "cache", "base_state", "wall_s", "turns", "denials", "note", "raw", - "paths", "reason", "model_requested"}) + self.assertEqual(set(r), {"job", "kind", "method", "base", "block", "phase", "status", "flags", + "fingerprint", "session", "parent_session", "models_used", "tokens", + "cost_delta_usd", "cache", "base_state", "wall_s", "turns", "denials", + "note", "raw", "paths", "reason", "model_requested"}) self.assertEqual(r["kind"], "train") + self.assertIsNone(r["method"]) # only a spawn has a method self.assertIsNone(r["cache"]) self.assertEqual(r["session"], t["session_id"]) self.assertIsNone(r["parent_session"]) @@ -2403,23 +2406,24 @@ class LaunchTests(LaunchBase): self.assertEqual(argv[argv.index("--model") + 1], TEST_MODEL) self.assertEqual(argv[argv.index("--permission-mode") + 1], "dontAsk") self.assertIn("--strict-mcp-config", argv) - self.assertEqual(argv[argv.index("--append-system-prompt-file") + 1], forks.preamble_path()) + self.assertEqual(argv[argv.index("--append-system-prompt-file") + 1], gremlins.preamble_path()) self.assertNotIn("--resume", argv) - self.assertEqual(self.reg()["jobs"]["j0001"]["argv"][0], forks.norm_path(self.stub)) + self.assertEqual(self.reg()["jobs"]["j0001"]["argv"][0], gremlins.norm_path(self.stub)) self.assertEqual(os.path.normcase(call0["cwd"]), os.path.normcase(self.cwd)) - self.assertEqual(call0["env"]["CLAUDE_CODE_EXECPATH"], forks.norm_path(self.stub)) - self.assertEqual(call0["env"]["SPIRE_FORKS_CHILD"], "1") + self.assertEqual(call0["env"]["CLAUDE_CODE_EXECPATH"], gremlins.norm_path(self.stub)) + self.assertEqual(call0["env"]["SPIRE_GREMLIN_CHILD"], "1") self.assertEqual(call0["env"]["CLAUDE_CODE_PROMPT_CACHE_TTL"], "1h") self.assertIsNone(call0["env"]["VIRTUAL_ENV"]) self.assertIn("READY", call0["prompt"]) # templates/train.md frame - # --- fork, after a commit on the trunk --- + # --- spawn (method fork), after a commit on the trunk --- self.commit_file("src.py", "x = 1\n", "add src after training") os.makedirs(os.path.join(self.state, "notes"), exist_ok=True) - f_out = self.fork("turns=2", "sleep=1", extra=("--phase", "1", "--paths", "src.py", "docs/a.md")) + f_out = self.spawn("turns=2", "sleep=1", extra=("--phase", "1", "--paths", "src.py", "docs/a.md")) with open(os.path.join(self.state, "notes", "1.1.md"), "w") as f: # M11: written during the job f.write("note\n") - self.assertEqual(set(f_out), {"job", "base", "base_state", "warm_until", "rebase_due", + self.assertEqual(set(f_out), {"job", "base", "method", "base_state", "warm_until", "rebase_due", "retrain_due", "reasons"}) + self.assertEqual(f_out["method"], "fork") self.assertEqual(f_out["base_state"], "warm") self.assertFalse(f_out["rebase_due"]) self.assertFalse(f_out["retrain_due"]) @@ -2427,7 +2431,8 @@ class LaunchTests(LaunchBase): fj = f_out["job"] r2 = self.finish(fj) self.assertEqual(r2["status"], "ok", r2) - self.assertEqual(r2["kind"], "fork") + self.assertEqual(r2["kind"], "spawn") + self.assertEqual(r2["method"], "fork") self.assertEqual(r2["cache"], "hit") self.assertEqual(r2["phase"], 1) self.assertEqual(r2["base_state"], "warm") @@ -2453,12 +2458,13 @@ class LaunchTests(LaunchBase): self.assertIn("1 file changed", brief_txt) # delta header: shortstat self.assertIn("stale", brief_txt) # R13 sentence self.assertIn(os.path.join(self.state, "notes", "1.1.md"), brief_txt) - # --- continue the fork: cost delta against the fork's own last total --- + # --- continue the gremlin: cost delta against the gremlin's own last total --- c_out = self.cmd("continue", "--job", fj, "--brief", self.brief("turns=1")) self.assertEqual(set(c_out), {"job"}) r3 = self.finish(c_out["job"]) self.assertEqual(r3["status"], "ok", r3) self.assertEqual(r3["kind"], "continue") + self.assertIsNone(r3["method"]) # a continue resumes, it does not fork self.assertEqual(r3["session"], r2["session"]) self.assertEqual(r3["block"], "1.1") self.assertAlmostEqual(r3["cost_delta_usd"], price(r3["tokens"]), places=6) @@ -2472,20 +2478,25 @@ class LaunchTests(LaunchBase): self.assertEqual(sorted(led), ["j0001", fj, c_out["job"]]) for res in (r, r2, r3): line = led[res["job"]] - for k in ("status", "flags", "tokens", "cost_delta_usd", "cache", "models_used", "session", - "parent_session", "fingerprint", "turns", "reason", "note"): + for k in ("kind", "method", "status", "flags", "tokens", "cost_delta_usd", "cache", "models_used", + "session", "parent_session", "fingerprint", "turns", "reason", "note"): self.assertEqual(line[k], res[k], k) self.assertEqual(line["binary"], STUB_VERSION) self.assertIsNotNone(line["cost_cumulative_usd"]) + self.assertEqual([led[j]["method"] for j in ("j0001", fj, c_out["job"])], [None, "fork", None]) # list shows every base and job; nothing running lst = self.cmd("list") self.assertEqual([b["name"] for b in lst["bases"]], ["B1"]) self.assertEqual(lst["bases"][0]["base_state"], "warm") self.assertEqual(len(lst["jobs"]), 3) + self.assertEqual([(x["kind"], x["method"]) for x in lst["jobs"]], + [("train", None), ("spawn", "fork"), ("continue", None)]) + self.assertEqual(lst["bases"][0]["gremlins"], [fj]) self.assertEqual(lst["running"], []) self.assertIsNotNone(self.reg()["run"]["last_orchestrator_call_at"]) # refusals on continue - self.cmd("continue", "--job", "j0001", "--brief", self.brief(), code=2) # not a fork + out = self.cmd("continue", "--job", "j0001", "--brief", self.brief(), code=2) # not a spawn + self.assertEqual(out["error"], "continue takes a spawn job") self.cmd("continue", "--job", "j0999", "--brief", self.brief(), code=2) # unknown self.cmd("train", "--base", "B1", "--brief", self.brief(), code=2) # base exists @@ -2493,8 +2504,8 @@ class LaunchTests(LaunchBase): self.setup_run() self.train("B1") self.finish("j0001") - a = self.fork("cache=partial", extra=("--max-budget-usd", "2.5"))["job"] - b = self.fork("cache=miss")["job"] + a = self.spawn("cache=partial", extra=("--max-budget-usd", "2.5"))["job"] + b = self.spawn("cache=miss")["job"] argv_a = self.reg()["jobs"][a]["argv"] self.assertEqual(argv_a[argv_a.index("--max-budget-usd") + 1], "2.5") self.assertNotIn("--max-budget-usd", self.reg()["jobs"][b]["argv"]) @@ -2508,11 +2519,11 @@ class LaunchTests(LaunchBase): self.assertEqual(res[j]["status"], "ok") self.assertEqual(res[j]["base_state"], "warm") # a cold base: hit, but cold at launch -> cold_start - with forks.RegistryLock(self.state): - reg = forks.read_registry(self.state) - reg["bases"]["B1"]["last_hit"] = forks._iso(time.time() - 4000) - forks.write_registry(self.state, reg) - out = self.fork() + with gremlins.RegistryLock(self.state): + reg = gremlins.read_registry(self.state) + reg["bases"]["B1"]["last_hit"] = gremlins._iso(time.time() - 4000) + gremlins.write_registry(self.state, reg) + out = self.spawn() self.assertEqual(out["base_state"], "cold") r = self.finish(out["job"]) self.assertEqual(r["cache"], "hit") @@ -2529,7 +2540,7 @@ class LaunchTests(LaunchBase): ("mismatch",), ("plugin=0.3.0",)] results = {} for i in range(0, len(cases), 2): - jobs = [self.fork(*c)["job"] for c in cases[i:i + 2]] + jobs = [self.spawn(*c)["job"] for c in cases[i:i + 2]] code, out = self.wait(*jobs) for x in out["done"]: results[x["job"]] = x @@ -2554,11 +2565,11 @@ class LaunchTests(LaunchBase): r = self.finish(t["job"]) self.assertEqual((r["status"], r["reason"]), ("failed", "model_refused")) self.assertEqual(self.reg()["bases"]["B2"]["status"], "failed") - self.fork(base="B2", code=2) # not active + self.spawn(base="B2", code=2) # not active # binary drift: the stored binary changed size and mtime since training with open(self.stub, "a", newline="\r\n" if IS_WINDOWS else "\n") as f: f.write("rem drift\n" if IS_WINDOWS else "# drift\n") - r = self.finish(self.fork()["job"]) + r = self.finish(self.spawn()["job"]) self.assertEqual(r["status"], "ok") self.assertEqual(r["fingerprint"], "drift:binary") @@ -2575,7 +2586,7 @@ class LaunchTests(LaunchBase): (("apierr=400", "errtext=Prompt_is_too_long"), "prompt_too_long", "400"), (("apierr=500",), "api_error", "500"), (("model=claude-other-9",), "model_refused", "claude-other-9")] - jobs = [self.fork(*c)["job"] for c, _, _ in cases] + jobs = [self.spawn(*c)["job"] for c, _, _ in cases] code, out = self.wait(*jobs) self.assertEqual(code, 4, out) res = {x["job"]: x for x in out["done"]} @@ -2591,23 +2602,23 @@ class LaunchTests(LaunchBase): self.assertIn(detail, reg["jobs"][job]["reason_detail"]) def test_continue_leaves_base_last_hit(self): - """I3: a continue reads the fork's own longer prefix; only a fork refreshes the base.""" + """I3: a continue reads the gremlin's own longer prefix; only a spawn refreshes the base.""" self.setup_run() self.train("B1") self.finish("j0001") - fj = self.fork("turns=1")["job"] + fj = self.spawn("turns=1")["job"] self.assertEqual(self.finish(fj)["status"], "ok") - old = forks._iso(time.time() - 1000) - with forks.RegistryLock(self.state): - reg = forks.read_registry(self.state) + old = gremlins._iso(time.time() - 1000) + with gremlins.RegistryLock(self.state): + reg = gremlins.read_registry(self.state) reg["bases"]["B1"]["last_hit"] = old - forks.write_registry(self.state, reg) + gremlins.write_registry(self.state, reg) r = self.finish(self.cmd("continue", "--job", fj, "--brief", self.brief("turns=2"))["job"]) self.assertEqual((r["status"], r["kind"]), ("ok", "continue")) self.assertEqual(self.reg()["bases"]["B1"]["last_hit"], old) - r = self.finish(self.fork("turns=1")["job"]) # a fork still refreshes it + r = self.finish(self.spawn("turns=1")["job"]) # a spawn still refreshes it self.assertEqual(r["status"], "ok") - self.assertGreater(forks._ts(self.reg()["bases"]["B1"]["last_hit"]), forks._ts(old)) + self.assertGreater(gremlins._ts(self.reg()["bases"]["B1"]["last_hit"]), gremlins._ts(old)) def test_clock_jump_not_orphaned_or_timed_out(self): """M3: a supervisor (run in-process under an injected clock) sees the clock jump 2 h mid-run, @@ -2617,19 +2628,19 @@ class LaunchTests(LaunchBase): self.train("B1") self.finish("j0001") p = subprocess.Popen([sys.executable, "-c", "pass"]) - dead = forks.pid_record(p.pid) # recorded as the supervisor: never us + dead = gremlins.pid_record(p.pid) # recorded as the supervisor: never us p.wait() fake = {"ok": True, "method": "test", "pid": dead["pid"], "ctime": dead["ctime"]} - with mock.patch.object(forks, "spawn_supervisor", lambda *a: fake): - job = self.fork("sleep=1", "turns=3", extra=("--max-minutes", "1"))["job"] - sup = forks.Supervisor(self.state, job) + with mock.patch.object(gremlins, "detach_supervisor", lambda *a: fake): + job = self.spawn("sleep=1", "turns=3", extra=("--max-minutes", "1"))["job"] + sup = gremlins.Supervisor(self.state, job) sup.rt["supervisor"] = dead offset = [0.0] real = time.time jump = threading.Timer(1.0, offset.__setitem__, args=(0, 7200.0)) err = io.StringIO() try: - with mock.patch.dict(os.environ), mock.patch.object(forks, "_now", lambda: real() + offset[0]), \ + with mock.patch.dict(os.environ), mock.patch.object(gremlins, "_now", lambda: real() + offset[0]), \ contextlib.redirect_stderr(err): jump.start() out, code = sup.run() @@ -2640,17 +2651,17 @@ class LaunchTests(LaunchBase): e = self.reg()["jobs"][job] self.assertEqual((e["status"], e["reason"]), ("ok", None), e) self.assertIn("clock_jump", e["flags"]) - self.assertIn("clock_jump", forks.read_json(forks.result_file(self.state, job))["flags"]) + self.assertIn("clock_jump", gremlins.read_json(gremlins.result_file(self.state, job))["flags"]) self.assertIn("clock jump", err.getvalue()) def test_timeout_and_stalled(self): self.setup_run(stall=1) self.train("B1") self.finish("j0001") - slow = self.fork("silent=2.5")["job"] - hang = self.fork_marked("hang=1", extra=("--max-minutes", "0.06"))["job"] + slow = self.spawn("silent=2.5")["job"] + hang = self.spawn_marked("hang=1", extra=("--max-minutes", "0.06"))["job"] rt = self.until_running(hang) - tree = forks.process_tree(rt["supervisor"]) + tree = gremlins.process_tree(rt["supervisor"]) self.assertGreaterEqual(len(tree), 3) code, out = self.wait(slow, hang, timeout=60) self.assertEqual(code, 4, out) @@ -2668,10 +2679,10 @@ class LaunchTests(LaunchBase): self.setup_run() self.train("B1") self.finish("j0001") - job = self.fork_marked("hang=1", "child")["job"] + job = self.spawn_marked("hang=1", "child")["job"] rt = self.until_running(job, need_child=True) sup = rt["supervisor"] - tree = forks.process_tree(sup) + tree = gremlins.process_tree(sup) pids = {p["pid"] for p in tree} stub = stub_call_for(self.stub, "RUN-%s" % job) self.assertIn(sup["pid"], pids) @@ -2680,7 +2691,7 @@ class LaunchTests(LaunchBase): self.assertIn(stub["child_pid"], pids) if IS_WINDOWS: # spawned through CIM: the supervisor's parent is the WMI provider host, not us - table = {pid: (ppid, name) for pid, ppid, name in forks.process_table()} + table = {pid: (ppid, name) for pid, ppid, name in gremlins.process_table()} parent = table.get(table[sup["pid"]][0], (None, None))[1] self.assertEqual((parent or "").lower(), "wmiprvse.exe") self.assertIn("python.exe", [p["name"].lower() for p in tree]) @@ -2706,15 +2717,15 @@ class LaunchTests(LaunchBase): self.setup_run() self.train("B1") self.finish("j0001") - job = self.fork_marked("hang=1")["job"] + job = self.spawn_marked("hang=1")["job"] rt = self.until_running(job) - tree = forks.process_tree(rt["supervisor"]) - self.assertTrue(forks.terminate(rt["supervisor"])) - self.assertTrue(wait_for(lambda: not forks.record_alive(rt["supervisor"]), 10)) + tree = gremlins.process_tree(rt["supervisor"]) + self.assertTrue(gremlins.terminate(rt["supervisor"])) + self.assertTrue(wait_for(lambda: not gremlins.record_alive(rt["supervisor"]), 10)) lst = self.cmd("list") # heartbeat is fresh: not lost yet self.assertEqual(lst["running"], [job]) - later = time.time() + forks.LOST_AFTER_S + 30 - with mock.patch.object(forks, "_now", lambda: later): + later = time.time() + gremlins.LOST_AFTER_S + 30 + with mock.patch.object(gremlins, "_now", lambda: later): lst = self.cmd("list") self.assertEqual(lst["lost_now"], [job]) self.assertEqual(lst["running"], []) @@ -2728,32 +2739,32 @@ class LaunchTests(LaunchBase): self.setup_run() # orphan_after 600 s self.train("B1") self.finish("j0001") - job = self.fork_marked("hang=1", "child")["job"] + job = self.spawn_marked("hang=1", "child")["job"] rt = self.until_running(job, need_child=True) - tree = forks.process_tree(rt["claude"]) + tree = gremlins.process_tree(rt["claude"]) self.assertGreaterEqual(len(tree), 2) self.assertEqual(self.reg()["jobs"][job]["status"], "running") # injected clock: the last orchestrator call is recorded as 2000 s ago past = time.time() - 2000 - with mock.patch.object(forks, "_now", lambda: past): - forks.touch_orchestrator(self.state) + with mock.patch.object(gremlins, "_now", lambda: past): + gremlins.touch_orchestrator(self.state) # no orchestrator call from here on: poll the registry directly (no touch) - final = wait_for(lambda: self.reg()["jobs"][job]["status"] not in forks.RUNNING, timeout=30, step=0.2) + final = wait_for(lambda: self.reg()["jobs"][job]["status"] not in gremlins.RUNNING, timeout=30, step=0.2) self.assertTrue(final) e = self.reg()["jobs"][job] self.assertEqual((e["status"], e["reason"]), ("killed", "orphaned")) self.assertIn("orphaned", e["flags"]) self.assert_tree_dead(tree) - self.assertTrue(wait_for(lambda: not forks.record_alive(rt["supervisor"]), 15)) + self.assertTrue(wait_for(lambda: not gremlins.record_alive(rt["supervisor"]), 15)) self.assertEqual([x["flags"] for x in self.ledger() if x["job"] == job], [e["flags"]]) def test_expire_kills_running_jobs(self): self.setup_run() self.train("B1") self.finish("j0001") - job = self.fork_marked("hang=1")["job"] + job = self.spawn_marked("hang=1")["job"] rt = self.until_running(job) - tree = forks.process_tree(rt["supervisor"]) + tree = gremlins.process_tree(rt["supervisor"]) out = self.cmd("expire", "--base", "B1") self.assertEqual(out["retired"], ["B1"]) self.assertEqual([k["job"] for k in out["killed"]], [job]) @@ -2763,8 +2774,8 @@ class LaunchTests(LaunchBase): self.assertEqual((r["status"], r["reason"]), ("killed", "expire")) self.assertIn("expired", r["flags"]) self.assertEqual(self.reg()["bases"]["B1"]["status"], "retired") - self.fork(code=2) # retired: refused without --force - r = self.finish(self.fork(extra=("--force",))["job"]) + self.spawn(code=2) # retired: refused without --force + r = self.finish(self.spawn(extra=("--force",))["job"]) self.assertEqual(r["status"], "ok") self.assertEqual(self.cmd("expire", "--all")["retired"], []) @@ -2772,8 +2783,8 @@ class LaunchTests(LaunchBase): self.setup_run() self.train("B1") self.assertEqual(self.wait()[0], 0) # settles the training job - a = self.fork("die=1")["job"] - b = self.fork("sleep=2", "turns=5")["job"] + a = self.spawn("die=1")["job"] + b = self.spawn("sleep=2", "turns=5")["job"] wait_for(lambda: self.reg()["jobs"][a]["status"] == "failed", timeout=30) code, out = self.wait(timeout=1) # mixed: one failed, one running self.assertEqual(code, 3, out) @@ -2792,8 +2803,8 @@ class LaunchTests(LaunchBase): self.assertEqual(self.wait(b)[0], 0) self.assertEqual(self.wait(a)[0], 4) # --any - c = self.fork("die=1")["job"] - d = self.fork("sleep=1", "turns=3")["job"] + c = self.spawn("die=1")["job"] + d = self.spawn("sleep=1", "turns=3")["job"] code, out = self.wait(timeout=60, any_=True) self.assertEqual(code, 4, out) self.assertEqual([x["job"] for x in out["done"]], [c]) @@ -2809,27 +2820,27 @@ class LaunchTests(LaunchBase): def test_spawn_failed(self): self.setup_run() - failed = {"ok": False, "method": forks.SPAWN_METHOD, "detail": "Win32_Process.Create returned 9"} - with mock.patch.object(forks, "spawn_supervisor", lambda *a: failed): + failed = {"ok": False, "method": gremlins.DETACH_METHOD, "detail": "Win32_Process.Create returned 9"} + with mock.patch.object(gremlins, "detach_supervisor", lambda *a: failed): t = self.train("B1") code, out = self.wait(t["job"], timeout=5) self.assertEqual(code, 4, out) r = out["done"][0] self.assertEqual((r["status"], r["reason"]), ("failed", "spawn_failed")) - self.assertEqual(r["tokens"], {k: None for k in forks.TOKEN_KEYS}) + self.assertEqual(r["tokens"], {k: None for k in gremlins.TOKEN_KEYS}) self.assertEqual(self.reg()["bases"]["B1"]["status"], "failed") self.assertEqual(self.reg()["jobs"][t["job"]]["reason_detail"], failed["detail"]) self.assertEqual([x["reason"] for x in self.ledger()], ["spawn_failed"]) self.assertEqual(stub_calls(self.stub), []) def test_refusals(self): - # launch-field overrides on fork and continue (refused before anything is read) + # launch-field overrides on spawn and continue (refused before anything is read) values = {"model": "claude-x-1", "effort": "low", "permission_mode": "default", "tools": "Read", "allowed_tools": "Read", "disallowed_tools": "Bash", "agent": "a", "cwd": self.tmp, "binary": self.stub} - for name in forks.LAUNCH_FIELDS: + for name in gremlins.LAUNCH_FIELDS: flag = "--" + name.replace("_", "-") - for argv in (["fork", "--base", "B1", "--block", "1", "--brief", "b.md"], + for argv in (["spawn", "--base", "B1", "--block", "1", "--brief", "b.md"], ["continue", "--job", "j0001", "--brief", "b.md"], ["rebase", "--base", "B1", "--as", "B2", "--brief", "b.md"]): with self.subTest(field=name, cmd=argv[0]): @@ -2838,33 +2849,33 @@ class LaunchTests(LaunchBase): self.assertFalse(os.path.exists(self.state)) self.setup_run() # N2: parallel needs --paths - out = self.cmd("fork", "--base", "B1", "--block", "1", "--brief", self.brief(), "--mode", + out = self.cmd("spawn", "--base", "B1", "--block", "1", "--brief", self.brief(), "--mode", "parallel", code=2) self.assertIn("--paths", out["error"]) - self.fork(base="B9", code=2) # unknown base + self.spawn(base="B9", code=2) # unknown base self.train("B1") self.finish("j0001") - # parallel cap (2): two hanging parallel forks, a third launch is refused - j1 = self.fork_marked("hang=1", extra=("--mode", "parallel", "--paths", "a.py"))["job"] - j2 = self.fork_marked("hang=1", extra=("--mode", "parallel", "--paths", "b.py", "c.py"))["job"] - out = self.fork(code=2) + # parallel cap (2): two hanging parallel gremlins, a third launch is refused + j1 = self.spawn_marked("hang=1", extra=("--mode", "parallel", "--paths", "a.py"))["job"] + j2 = self.spawn_marked("hang=1", extra=("--mode", "parallel", "--paths", "b.py", "c.py"))["job"] + out = self.spawn(code=2) self.assertEqual(out["error"], "parallel cap reached") self.assertEqual(out["running"], [j1, j2]) out = self.cmd("continue", "--job", j1, "--brief", self.brief(), code=2) # still running self.assertIn("running", out["error"]) - # N3: launching refused inside a fork; list and wait stay allowed - os.environ[forks.CHILD_MARKER] = "1" + # N3: launching refused inside a gremlin; list and wait stay allowed + os.environ[gremlins.CHILD_MARKER] = "1" try: for argv in (["train", "--base", "B3", "--brief", self.brief()], - ["fork", "--base", "B1", "--block", "2", "--brief", self.brief()], + ["spawn", "--base", "B1", "--block", "2", "--brief", self.brief()], ["continue", "--job", j1, "--brief", self.brief()]): out = self.cmd(*argv, code=2) - self.assertIn("inside a fork", out["error"]) + self.assertIn("inside a gremlin", out["error"]) self.assertEqual(self.cmd("list")["running"], [j1, j2]) code, out = self.wait(j1, timeout=1) self.assertEqual(code, 3) finally: - os.environ.pop(forks.CHILD_MARKER, None) + os.environ.pop(gremlins.CHILD_MARKER, None) for j in (j1, j2): self.until_running(j) self.assertTrue(self.cmd("kill", "--job", j)["ok"]) @@ -2875,13 +2886,13 @@ class LaunchTests(LaunchBase): moved = self.stub + ".moved" os.replace(self.stub, moved) try: - out = self.fork(code=2) + out = self.spawn(code=2) self.assertEqual(out["reason"], "binary_missing") out = self.cmd("train", "--base", "B4", "--brief", self.brief(), code=2) self.assertEqual(out["reason"], "binary_missing") finally: os.replace(moved, self.stub) - self.cmd("fork", "--base", "B1", "--block", "1", "--brief", os.path.join(self.tmp, "nope.md"), code=2) + self.cmd("spawn", "--base", "B1", "--block", "1", "--brief", os.path.join(self.tmp, "nope.md"), code=2) # --- block 2.1 ----------------------------------------------------------------------- @@ -2896,7 +2907,7 @@ class LaunchTests(LaunchBase): self.skipTest("no desktop: window enumeration sees no visible window") # positive control before = {w[0] for w in console_windows()} - marker = "forks-test-poscontrol-%d" % os.getpid() # matched by the module-end sweep + marker = "gremlins-test-poscontrol-%d" % os.getpid() # matched by the module-end sweep si = subprocess.STARTUPINFO() si.dwFlags |= subprocess.STARTF_USESHOWWINDOW si.wShowWindow = 7 # SW_SHOWMINNOACTIVE @@ -2904,12 +2915,12 @@ class LaunchTests(LaunchBase): [stub_interpreter(), "-I", "-c", "import ctypes, time; " "ctypes.windll.kernel32.SetConsoleTitleW(%r); time.sleep(120)" % marker], creationflags=subprocess.CREATE_NEW_CONSOLE, startupinfo=si) - ctl_rec = forks.pid_record(ctl.pid) + ctl_rec = gremlins.pid_record(ctl.pid) try: seen = wait_for(lambda: [w for w in console_windows() if w[0] not in before or marker in w[3]], timeout=15) finally: - forks.kill_tree(ctl_rec, alive_until=time.time()) + gremlins.kill_tree(ctl_rec, alive_until=time.time()) with contextlib.suppress(subprocess.TimeoutExpired): ctl.wait(10) self.assertTrue(seen, "positive control: a deliberately visible console was not detected") @@ -2921,11 +2932,11 @@ class LaunchTests(LaunchBase): self.train("B1") self.finish("j0001") before = {w[0] for w in console_windows()} - job = self.fork_marked("hang=1", "child")["job"] + job = self.spawn_marked("hang=1", "child")["job"] rt = self.until_running(job, need_child=True) try: for _ in range(5): - pids = {p["pid"] for p in forks.process_tree(rt["supervisor"])} + pids = {p["pid"] for p in gremlins.process_tree(rt["supervisor"])} self.assertIn(rt["claude"]["pid"], pids) mine = [w for w in visible_windows() if w[1] in pids] self.assertEqual(mine, [], "visible window of the job's process tree") @@ -2941,8 +2952,8 @@ class LaunchTests(LaunchBase): self.setup_run(cap=4) self.train("B1") self.finish("j0001") - a = self.fork("error", "billed", "turns=2")["job"] - b = self.fork("apierr=500", "billed", "turns=2")["job"] + a = self.spawn("error", "billed", "turns=2")["job"] + b = self.spawn("apierr=500", "billed", "turns=2")["job"] code, out = self.wait(a, b) self.assertEqual(code, 4, out) res = {x["job"]: x for x in out["done"]} @@ -2956,10 +2967,10 @@ class LaunchTests(LaunchBase): self.assertAlmostEqual(r["cost_delta_usd"], price(r["tokens"]), places=6) def set_registry(self, fn): - with forks.RegistryLock(self.state): - reg = forks.read_registry(self.state) + with gremlins.RegistryLock(self.state): + reg = gremlins.read_registry(self.state) fn(reg) - forks.write_registry(self.state, reg) + gremlins.write_registry(self.state, reg) def set_last_hit(self, iso): self.set_registry(lambda r: r["bases"]["B1"].__setitem__("last_hit", iso)) @@ -2983,9 +2994,9 @@ class LaunchTests(LaunchBase): row = self.cmd("list")["bases"][0] self.assertEqual((row["ttl_s"], row["margin_s"], row["ttl_from"]), (300, 120, "j0001")) # 400 s after the last hit: warm under the configured 3600 s, cold under the inferred 300 s - self.set_last_hit(forks._iso(time.time() - 400)) + self.set_last_hit(gremlins._iso(time.time() - 400)) self.assertEqual(self.cmd("list")["bases"][0]["base_state"], "cold") - out = self.fork("turns=1") + out = self.spawn("turns=1") self.assertEqual(out["base_state"], "cold") r2 = self.finish(out["job"]) self.assertEqual((r2["status"], r2["cache"], r2["base_state"]), ("ok", "hit", "cold"), r2) @@ -3001,7 +3012,7 @@ class LaunchTests(LaunchBase): def collect_ping(self, job): """Wait (pings off) until the ping ended; return it from `wait`'s `pings` list.""" - self.assertTrue(wait_for(lambda: self.reg()["jobs"][job]["status"] in forks.FINAL, timeout=60), + self.assertTrue(wait_for(lambda: self.reg()["jobs"][job]["status"] in gremlins.FINAL, timeout=60), self.reg()["jobs"][job]) code, out = self.wait(timeout=5) self.assertEqual((code, out["done"]), (0, []), out) @@ -3017,10 +3028,10 @@ class LaunchTests(LaunchBase): self.train("B1") self.finish("j0001") base_session = self.reg()["bases"]["B1"]["session"] - old = forks._iso(time.time() - 2000) + old = gremlins._iso(time.time() - 2000) self.set_last_hit(old) # 1. partial, launched by wait's tick (the wait itself is the activity) - with mock.patch.object(forks, "PING_PROMPT", "Reply OK\n@stub cache=partial"): + with mock.patch.object(gremlins, "PING_PROMPT", "Reply OK\n@stub cache=partial"): code, out = self.wait(timeout=5) self.assertEqual((code, out["done"], out["pings"]), (0, [], []), out) pings = self.ping_jobs() @@ -3035,7 +3046,7 @@ class LaunchTests(LaunchBase): self.assertEqual((r1["parent_session"], r1["turns"], r1["block"]), (base_session, 1, None)) self.assertEqual(r1["models_used"], [TEST_MODEL]) b = self.reg()["bases"]["B1"] - self.assertGreater(forks._ts(b["last_hit"]), forks._ts(old)) # partial refreshes + self.assertGreater(gremlins._ts(b["last_hit"]), gremlins._ts(old)) # partial refreshes self.assertIsNone(b["ping_in_flight"]) self.assertEqual(self.wait(timeout=5)[1]["pings"], []) # returned once c = stub_call_for(self.stub, "Reply OK") @@ -3044,11 +3055,11 @@ class LaunchTests(LaunchBase): self.assertIn("--fork-session", a) self.assertEqual(a[a.index("--session-id") + 1], r1["session"]) self.assertEqual(a[a.index("--max-turns") + 1], "1") - self.assertEqual(c["env"]["SPIRE_FORKS_CHILD"], "1") + self.assertEqual(c["env"]["SPIRE_GREMLIN_CHILD"], "1") # 2. a miss, launched by `tick` by hand: last_hit stays, flag ping_missed self.set_last_hit(old) self.set_pings(True) - with mock.patch.object(forks, "PING_PROMPT", "Reply OK\n@stub cache=miss"): + with mock.patch.object(gremlins, "PING_PROMPT", "Reply OK\n@stub cache=miss"): out = self.cmd("tick") self.assertEqual(len(out["pinged"]), 1, out) p2 = out["pinged"][0] @@ -3061,7 +3072,7 @@ class LaunchTests(LaunchBase): self.assertEqual(self.reg()["bases"]["B1"]["last_hit"], old) # 3. a failed ping: last_hit stays, flag ping_missed self.set_pings(True) - with mock.patch.object(forks, "PING_PROMPT", "Reply OK\n@stub error"): + with mock.patch.object(gremlins, "PING_PROMPT", "Reply OK\n@stub error"): p3 = self.cmd("tick")["pinged"][0] self.set_pings(False) r3 = self.collect_ping(p3) @@ -3074,7 +3085,7 @@ class LaunchTests(LaunchBase): self.assertEqual([x["status"] for x in led], ["ok", "ok", "failed"]) def test_tick_runs_in_wait_and_supervisor(self): - """The tick runs inside `wait` (at its start, then every TICK_WAIT_S; never in a fork) and + """The tick runs inside `wait` (at its start, then every TICK_WAIT_S; never in a gremlin) and inside every supervisor (every TICK_SUP_S).""" self.setup_run() self.train("B1") @@ -3084,8 +3095,8 @@ class LaunchTests(LaunchBase): def rec(state): calls.append(state) return {"pinged": [], "bases": []} - with mock.patch.object(forks, "TICK_WAIT_S", 0.5), mock.patch.object(forks, "tick_pass", rec): - job = self.fork("sleep=1", "turns=3")["job"] + with mock.patch.object(gremlins, "TICK_WAIT_S", 0.5), mock.patch.object(gremlins, "tick_pass", rec): + job = self.spawn("sleep=1", "turns=3")["job"] t0 = time.monotonic() self.finish(job) took = time.monotonic() - t0 @@ -3093,24 +3104,24 @@ class LaunchTests(LaunchBase): self.assertGreaterEqual(len(calls), 3, (len(calls), took)) self.assertEqual(set(calls), {self.state}) calls.clear() - os.environ[forks.CHILD_MARKER] = "1" + os.environ[gremlins.CHILD_MARKER] = "1" try: - with mock.patch.object(forks, "tick_pass", rec): + with mock.patch.object(gremlins, "tick_pass", rec): self.wait(job, timeout=1) finally: - os.environ.pop(forks.CHILD_MARKER, None) + os.environ.pop(gremlins.CHILD_MARKER, None) self.assertEqual(calls, []) # a supervisor, run in-process p = subprocess.Popen([sys.executable, "-c", "pass"]) - dead = forks.pid_record(p.pid) + dead = gremlins.pid_record(p.pid) p.wait() fake = {"ok": True, "method": "test", "pid": dead["pid"], "ctime": dead["ctime"]} - with mock.patch.object(forks, "spawn_supervisor", lambda *a: fake): - job = self.fork("sleep=1", "turns=3")["job"] - sup = forks.Supervisor(self.state, job) + with mock.patch.object(gremlins, "detach_supervisor", lambda *a: fake): + job = self.spawn("sleep=1", "turns=3")["job"] + sup = gremlins.Supervisor(self.state, job) sup.rt["supervisor"] = dead - with mock.patch.dict(os.environ), mock.patch.object(forks, "TICK_SUP_S", 0.5), \ - mock.patch.object(forks, "tick_pass", rec): + with mock.patch.dict(os.environ), mock.patch.object(gremlins, "TICK_SUP_S", 0.5), \ + mock.patch.object(gremlins, "tick_pass", rec): out, code = sup.run() self.assertEqual(code, 0, out) self.assertEqual(self.reg()["jobs"][job]["status"], "ok") @@ -3122,21 +3133,21 @@ class LaunchTests(LaunchBase): REBASE_REASONS = ("phase_boundary", "commits", "lines", "delta_tokens", "binary_drift", "runtime_drift") - def dead_spawn(self) -> dict: + def dead_detach(self) -> dict: p = subprocess.Popen([sys.executable, "-c", "pass"]) - dead = forks.pid_record(p.pid) + dead = gremlins.pid_record(p.pid) p.wait() return {"ok": True, "method": "test", "pid": dead["pid"], "ctime": dead["ctime"]} - def fake_fork(self, base, *extra, brief=None, code=0): - """A fork whose supervisor is never started (spawn stubbed with a dead pid), finalised at once + def fake_spawn(self, base, *extra, brief=None, code=0): + """A spawn whose supervisor is never started (detach stubbed with a dead pid), finalised at once as killed: only its launch return is looked at.""" - fake = self.dead_spawn() - with mock.patch.object(forks, "spawn_supervisor", lambda *a: fake): - out = self.cmd("fork", "--base", base, "--block", "d", "--brief", brief or self.brief(), + fake = self.dead_detach() + with mock.patch.object(gremlins, "detach_supervisor", lambda *a: fake): + out = self.cmd("spawn", "--base", base, "--block", "d", "--brief", brief or self.brief(), *extra, code=code) if code == 0: - forks.finalize_job(self.state, out["job"], ss=None, hint="killed", reason="kill") + gremlins.finalize_job(self.state, out["job"], ss=None, hint="killed", reason="kill") return out def clone_base(self, name, src="B1", **changes): @@ -3145,7 +3156,7 @@ class LaunchTests(LaunchBase): def f(reg): b = json.loads(json.dumps(reg["bases"][src])) - b.update({"name": name, "forks": [], "base_commit": head}) + b.update({"name": name, "gremlins": [], "base_commit": head}) b.update(changes) reg["bases"][name] = b self.set_registry(f) @@ -3158,42 +3169,42 @@ class LaunchTests(LaunchBase): def test_due_reasons_each_alone(self): """Design 5 / digest 2.2: every rebase_due and retrain_due reason triggered on its own; the - fork's launch return lists exactly it (and `list` the base-level ones).""" + spawn's launch return lists exactly it (and `list` the base-level ones).""" self.setup_run(cap=4) self.train("B1") self.finish("j0001") c0 = self.git("rev-parse", "HEAD") - self.assert_due(self.fake_fork("B1")) # nothing due - # phase_boundary: above the highest phase of the base's forks, or of the base itself + self.assert_due(self.fake_spawn("B1")) # nothing due + # phase_boundary: above the highest phase of the base's spawns, or of the base itself self.clone_base("Bp") - self.assert_due(self.fake_fork("Bp", "--phase", "1")) # nothing known yet - self.assert_due(self.fake_fork("Bp", "--phase", "1")) - self.assert_due(self.fake_fork("Bp", "--phase", "2"), "phase_boundary") - self.assert_due(self.fake_fork("Bp")) # no --phase + self.assert_due(self.fake_spawn("Bp", "--phase", "1")) # nothing known yet + self.assert_due(self.fake_spawn("Bp", "--phase", "1")) + self.assert_due(self.fake_spawn("Bp", "--phase", "2"), "phase_boundary") + self.assert_due(self.fake_spawn("Bp")) # no --phase self.clone_base("Bq", phase=3) - self.assert_due(self.fake_fork("Bq", "--phase", "3")) - self.assert_due(self.fake_fork("Bq", "--phase", "4"), "phase_boundary") - self.assert_due(self.fake_fork("Bq", "--phase", "4"), "phase_boundary") # still above the base's 3 - self.assert_due(self.fake_fork("Bq", "--phase", "2")) + self.assert_due(self.fake_spawn("Bq", "--phase", "3")) + self.assert_due(self.fake_spawn("Bq", "--phase", "4"), "phase_boundary") + self.assert_due(self.fake_spawn("Bq", "--phase", "4"), "phase_boundary") # still above the base's 3 + self.assert_due(self.fake_spawn("Bq", "--phase", "2")) # commits: 7 > rebase_commits 6 (seven one-line files) for i in range(7): self.commit_file("c%d.txt" % i, "x\n", "commit %d" % i) self.clone_base("Bc", base_commit=c0) - self.assert_due(self.fake_fork("Bc"), "commits:7") + self.assert_due(self.fake_spawn("Bc"), "commits:7") # lines: 1600 > rebase_lines 1500, in one commit c7 = self.git("rev-parse", "HEAD") self.commit_file("big.txt", "line\n" * 1600, "big change") self.clone_base("Bl", base_commit=c7) - self.assert_due(self.fake_fork("Bl"), "lines:1600") + self.assert_due(self.fake_spawn("Bl"), "lines:1600") # every clone from here on starts at HEAD # delta_tokens: chars / 4 of the delta header plus the attached delta notes > 12000 (I1) big_note = os.path.join(self.state, "notes", "big-note.md") with open(big_note, "w") as f: f.write("n" * 60000) self.clone_base("Bd") - self.assert_due(self.fake_fork("Bd")) # no note attached - self.assert_due(self.fake_fork("Bd", brief=self.brief(text="Read notes/big-note.md first."))) - out = self.fake_fork("Bd", "--delta-note", big_note) + self.assert_due(self.fake_spawn("Bd")) # no note attached + self.assert_due(self.fake_spawn("Bd", brief=self.brief(text="Read notes/big-note.md first."))) + out = self.fake_spawn("Bd", "--delta-note", big_note) self.assertEqual(len(out["reasons"]), 1, out) self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$") self.assertGreater(int(out["reasons"][0].split(":")[1]), 15000) @@ -3202,27 +3213,27 @@ class LaunchTests(LaunchBase): ext = os.path.splitext(self.stub)[1] b2 = os.path.join(os.path.dirname(self.stub), "claude2" + ext) shutil.copy2(self.stub, b2) - info = forks.binary_info(forks.norm_path(b2)) + info = gremlins.binary_info(gremlins.norm_path(b2)) b1_launch = self.reg()["bases"]["B1"]["launch"] self.clone_base("Bb", launch=dict(b1_launch, binary=info), binary=info) - self.assert_due(self.fake_fork("Bb")) + self.assert_due(self.fake_spawn("Bb")) with open(b2, "a", newline="\r\n" if IS_WINDOWS else "\n") as f: f.write("rem drift\n" if IS_WINDOWS else "# drift\n") - self.assert_due(self.fake_fork("Bb"), "binary_drift") + self.assert_due(self.fake_spawn("Bb"), "binary_drift") # runtime_drift (M6): the base's last call saw a versionless plugin and the skills change self.clone_base("Br") - r = self.finish(self.fork("turns=1", "nover=def456", "skill=extra", base="Br")["job"]) + r = self.finish(self.spawn("turns=1", "nover=def456", "skill=extra", base="Br")["job"]) self.assertEqual((r["status"], r["fingerprint"]), ("ok", "drift:plugins,skills"), r) - self.assert_due(self.fake_fork("Br"), "runtime_drift:plugins,skills") + self.assert_due(self.fake_spawn("Br"), "runtime_drift:plugins,skills") # defining: the base's record differs from the config in a field it did not override self.clone_base("Bm", launch=dict(b1_launch, model="claude-opus-4-1")) - self.assert_due(self.fake_fork("Bm"), "defining:model") + self.assert_due(self.fake_spawn("Bm"), "defining:model") # max_base_tokens: from the stored context, and from the transcript when it exists (P0.7) self.clone_base("Bx", context_tokens=300000) - self.assert_due(self.fake_fork("Bx"), "max_base_tokens:300000") + self.assert_due(self.fake_spawn("Bx"), "max_base_tokens:300000") sid = "11111111-2222-3333-4444-555555555555" self.clone_base("Bt", session=sid, context_tokens=1000) - tp = forks.transcript_path(b1_launch["cwd"], sid) + tp = gremlins.transcript_path(b1_launch["cwd"], sid) self.assertTrue(tp.startswith(os.path.join(self.tmp, "home", "projects")), tp) os.makedirs(os.path.dirname(tp)) lines = [{"type": "user", "message": {"role": "user", "content": "x" * 300}}, @@ -3233,17 +3244,17 @@ class LaunchTests(LaunchBase): {"type": "system", "subtype": "informational", "content": "y" * 300}] with open(tp, "w", newline="\n") as f: f.write("".join(json.dumps(x) + "\n" for x in lines)) - with mock.patch.object(forks, "TRANSCRIPT_TAIL_BYTES", 64): # the tail grows - self.assert_due(self.fake_fork("Bt"), "max_base_tokens:261002") + with mock.patch.object(gremlins, "TRANSCRIPT_TAIL_BYTES", 64): # the tail grows + self.assert_due(self.fake_spawn("Bt"), "max_base_tokens:261002") # rebases: lineage_rebases >= max_rebases 4 self.clone_base("Bn3", lineage_rebases=3) - self.assert_due(self.fake_fork("Bn3")) + self.assert_due(self.fake_spawn("Bn3")) self.clone_base("Bn", lineage_rebases=4) - self.assert_due(self.fake_fork("Bn"), "rebases:4") + self.assert_due(self.fake_spawn("Bn"), "rebases:4") # binary_missing: the stored path is gone; launches refuse (exit 2) gone = dict(info, path=os.path.join(self.tmp, "gone", "claude" + ext)) self.clone_base("Bz", launch=dict(b1_launch, binary=gone), binary=gone) - out = self.fake_fork("Bz", code=2) + out = self.fake_spawn("Bz", code=2) self.assertEqual((out["reason"], out["retrain_due"], out["reasons"]), ("binary_missing", True, ["binary_missing"]), out) # list: the base-level reasons, per active base @@ -3258,7 +3269,7 @@ class LaunchTests(LaunchBase): def test_delta_tokens_counts_only_what_the_base_does_not_know(self): """I1: delta_tokens = the delta header + `--delta-note` files newer than the base's training + a `## Changes since base` section of the brief. Large reference docs a brief merely names - never count; an attached note is in the fork's prompt with its path; a base trained or + never count; an attached note is in the gremlin's prompt with its path; a base trained or rebased after the note's mtime does not count it, so a rebase clears the reason.""" self.setup_run(cap=4) self.train("B1") @@ -3271,38 +3282,38 @@ class LaunchTests(LaunchBase): f.write("h" * 80000) named = self.brief(text="Read docs/build-plan.md, docs/run-log.md and notes/1.1.md; edit " "docs/run-log.md and %s." % os.path.join(self.cwd, "docs", "build-plan.md")) - self.assert_due(self.fake_fork("B1", brief=named)) # named only: nothing - # a large attached note trips it, and the fork's prompt carries it with its path + self.assert_due(self.fake_spawn("B1", brief=named)) # named only: nothing + # a large attached note trips it, and the gremlin's prompt carries it with its path note = os.path.join(self.tmp, "delta-1.md") with open(note, "w", newline="\n") as f: f.write("NOTE-MARKER\n" + "d" * 60000 + "\n") - out = self.fake_fork("B1", "--delta-note", note, brief=named) + out = self.fake_spawn("B1", "--delta-note", note, brief=named) self.assertEqual(len(out["reasons"]), 1, out) self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$") self.assertGreater(int(out["reasons"][0].split(":")[1]), 15000) with open(os.path.join(self.state, "briefs", out["job"] + ".md"), encoding="utf-8") as fh: prompt = fh.read() delta_at = prompt.index("## Delta since training") - self.assertLess(delta_at, prompt.index("`%s`" % forks.norm_path(note))) - self.assertLess(prompt.index("NOTE-MARKER"), prompt.index("## Rules for this fork")) - self.assertEqual(self.reg()["jobs"][out["job"]]["delta_notes"], [forks.norm_path(note)]) + self.assertLess(delta_at, prompt.index("`%s`" % gremlins.norm_path(note))) + self.assertLess(prompt.index("NOTE-MARKER"), prompt.index("## Rules for this gremlin")) + self.assertEqual(self.reg()["jobs"][out["job"]]["delta_notes"], [gremlins.norm_path(note)]) # a small attached note does not; a missing one is refused small = os.path.join(self.tmp, "delta-small.md") with open(small, "w") as f: f.write("small\n") - self.assert_due(self.fake_fork("B1", "--delta-note", small)) - self.assertEqual(self.fake_fork("B1", "--delta-note", os.path.join(self.tmp, "nope.md"), + self.assert_due(self.fake_spawn("B1", "--delta-note", small)) + self.assertEqual(self.fake_spawn("B1", "--delta-note", os.path.join(self.tmp, "nope.md"), code=2)["error"], "cannot read delta note") # a base trained after the note's mtime already knows it mtime = os.path.getmtime(note) - self.clone_base("Bk", trained_at=forks._iso(mtime + 60)) - self.assert_due(self.fake_fork("Bk", "--delta-note", note)) + self.clone_base("Bk", trained_at=gremlins._iso(mtime + 60)) + self.assert_due(self.fake_spawn("Bk", "--delta-note", note)) # a large `## Changes since base` section trips it; the same text under another heading not body = "c" * 60000 - out = self.fake_fork("Bk", brief=self.brief(text="## Changes since base\n\n%s\n\n## Gate\n\nrun it" % body)) + out = self.fake_spawn("Bk", brief=self.brief(text="## Changes since base\n\n%s\n\n## Gate\n\nrun it" % body)) self.assertEqual(len(out["reasons"]), 1, out) self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$") - self.assert_due(self.fake_fork("Bk", brief=self.brief(text="## Background\n\n%s\n" % body))) + self.assert_due(self.fake_spawn("Bk", brief=self.brief(text="## Background\n\n%s\n" % body))) # a rebase after the note clears the reason; the rebase's prompt carries the note too r = self.cmd("rebase", "--base", "B1", "--as", "B2", "--delta-note", note, "--brief", self.brief("turns=1", text="Absorb.")) @@ -3312,15 +3323,15 @@ class LaunchTests(LaunchBase): self.assertLess(rprompt.index("## Delta since the base commit"), rprompt.index("NOTE-MARKER")) self.assertLess(rprompt.index("NOTE-MARKER"), rprompt.index("## Brief")) self.assertEqual(self.finish(r["job"])["status"], "ok") - self.assertGreater(forks._ts(self.reg()["bases"]["B2"]["trained_at"]), mtime) - self.assert_due(self.fake_fork("B2", "--delta-note", note)) + self.assertGreater(gremlins._ts(self.reg()["bases"]["B2"]["trained_at"]), mtime) + self.assert_due(self.fake_spawn("B2", "--delta-note", note)) # list counts the delta header only rows = {b["name"]: b for b in self.cmd("list")["bases"]} self.assert_due(rows["B2"]) def test_train_supersedes(self): """Minor 1: `train --supersedes B` (repeatable) marks each named active base superseded by - the new one when its training ends ok: no more pings, and a fork needs --force. Unknown + the new one when its training ends ok: no more pings, and a spawn needs --force. Unknown names are refused at launch; a failed training changes nothing.""" self.setup_run(cap=4) self.train("B1") @@ -3350,9 +3361,9 @@ class LaunchTests(LaunchBase): self.assertIsNotNone(b["superseded_at"]) self.assertEqual(reg["bases"]["B2"]["status"], "failed") # not active: untouched self.assertEqual(reg["bases"]["B3"]["status"], "active") - self.assertEqual(forks.ping_decision(reg, "B1")["why"], "status:superseded") - self.assertIn("not active", self.fake_fork("B1", code=2)["error"]) - self.fake_fork("B1", "--force") + self.assertEqual(gremlins.ping_decision(reg, "B1")["why"], "status:superseded") + self.assertIn("not active", self.fake_spawn("B1", code=2)["error"]) + self.fake_spawn("B1", "--force") def test_train_overrides_and_init_model_change(self): """M10 (plan 4.3): `train` takes init's launch flags as overrides; the base records its @@ -3372,7 +3383,7 @@ class LaunchTests(LaunchBase): self.assertEqual(rec["allowed_tools"], cfg["launch"]["allowed_tools"]) # not overridden self.assertEqual(rec["cwd"], cfg["launch"]["cwd"]) self.assertEqual(b1["overrides"], ["effort", "model", "tools"]) - self.assertEqual(b1["fingerprint"], forks.fingerprint_hash(rec)) + self.assertEqual(b1["fingerprint"], gremlins.fingerprint_hash(rec)) self.assertNotEqual(b1["fingerprint"], cfg["fingerprint"]) a = stub_calls(self.stub)[0]["argv"] self.assertEqual((a[a.index("--model") + 1], a[a.index("--effort") + 1], a[a.index("--tools") + 1]), @@ -3383,8 +3394,8 @@ class LaunchTests(LaunchBase): b2 = self.reg()["bases"]["B2"] self.assertEqual((b2["launch"], b2["overrides"], b2["fingerprint"]), (cfg["launch"], [], cfg["fingerprint"])) - # fork and continue of B1 build their command line from B1's record - f = self.fork("turns=1", base="B1") + # spawn and continue of B1 build their command line from B1's record + f = self.spawn("turns=1", base="B1") self.assert_due(f) rf = self.finish(f["job"]) self.assertEqual((rf["status"], rf["model_requested"], rf["models_used"]), @@ -3404,11 +3415,11 @@ class LaunchTests(LaunchBase): rows = {b["name"]: b for b in self.cmd("list")["bases"]} self.assert_due(rows["B2"], "defining:model") self.assert_due(rows["B1"]) - f2 = self.fork("turns=1", base="B2") - self.assert_due(f2, "defining:model") # advisory: the fork still launches + f2 = self.spawn("turns=1", base="B2") + self.assert_due(f2, "defining:model") # advisory: the gremlin still launches r2 = self.finish(f2["job"]) self.assertEqual((r2["status"], r2["model_requested"]), ("ok", TEST_MODEL), r2) # B2's record - f1 = self.fork("turns=1", base="B1") + f1 = self.spawn("turns=1", base="B1") self.assert_due(f1) self.assertEqual(self.finish(f1["job"])["model_requested"], "claude-sonnet-5-5") # overrides pass init's checks: aliases, plan mode, availableModels @@ -3424,7 +3435,7 @@ class LaunchTests(LaunchBase): def test_note_only_from_this_job(self): """M11: `note` is set only when the note file was written during this job; a note left by an - earlier fork of the block, or by the fork a continue resumes, does not count.""" + earlier gremlin of the block, or by the gremlin a continue resumes, does not count.""" self.setup_run() self.train("B1") self.finish("j0001") @@ -3433,9 +3444,9 @@ class LaunchTests(LaunchBase): f.write("old note\n") old = time.time() - 600 os.utime(note, (old, old)) - r = self.finish(self.fork("turns=1")["job"]) + r = self.finish(self.spawn("turns=1")["job"]) self.assertEqual((r["status"], r["note"]), ("ok", None), r) - j = self.fork("sleep=1", "turns=2")["job"] + j = self.spawn("sleep=1", "turns=2")["job"] with open(note, "w") as f: # written while the job runs f.write("new note\n") r = self.finish(j) @@ -3448,7 +3459,7 @@ class LaunchTests(LaunchBase): def test_rebase_end_to_end(self): """`rebase` with the stub: the new base is active (parent, lineage, HEAD, context, launch - record), the old one superseded; a fork of the new base hits the new context; a failed + record), the old one superseded; a spawn from the new base hits the new context; a failed rebase leaves the base active and creates nothing; a binary drift is recorded by the new base.""" self.setup_run() @@ -3456,10 +3467,10 @@ class LaunchTests(LaunchBase): self.finish("j0001") b1 = self.reg()["bases"]["B1"] self.commit_file("src.py", "x = 1\n", "work of phase 1") - f1 = self.fork("turns=1", extra=("--phase", "1")) + f1 = self.spawn("turns=1", extra=("--phase", "1")) self.assert_due(f1) self.finish(f1["job"]) - f2 = self.fork("turns=1", extra=("--phase", "2")) + f2 = self.spawn("turns=1", extra=("--phase", "2")) self.assert_due(f2, "phase_boundary") self.finish(f2["job"]) out = self.cmd("rebase", "--base", "B1", "--as", "B2", "--phase", "2", @@ -3491,8 +3502,8 @@ class LaunchTests(LaunchBase): txt = fh.read() for s in ("READY", "Do not change code", "work of phase 1", "`B2`", "Absorb phase 1.", "stale"): self.assertIn(s, txt) - # a fork of the new base: a hit against the new base's context - f3 = self.fork("turns=1", base="B2", extra=("--phase", "2")) + # a spawn from the new base: a hit against the new base's context + f3 = self.spawn("turns=1", base="B2", extra=("--phase", "2")) self.assert_due(f3) r3 = self.finish(f3["job"]) self.assertEqual((r3["status"], r3["cache"], r3["parent_session"]), ("ok", "hit", new["session"]), r3) @@ -3500,8 +3511,8 @@ class LaunchTests(LaunchBase): self.assertGreaterEqual(r3["tokens"]["cache_read"], new["context_tokens"]) a = [c for c in stub_calls(self.stub) if c["session"] == r3["session"]][0]["argv"] self.assertEqual(a[a.index("--resume") + 1], new["session"]) - # the superseded base: no fork, no rebase - self.fork(base="B1", code=2) + # the superseded base: no spawn, no rebase + self.spawn(base="B1", code=2) self.cmd("rebase", "--base", "B1", "--as", "B9", "--brief", self.brief(), code=2) # a failed rebase: the base stays active, the new base is never created rf = self.finish(self.cmd("rebase", "--base", "B2", "--as", "B3", "--brief", self.brief("error"))["job"]) @@ -3519,29 +3530,29 @@ class LaunchTests(LaunchBase): (["--base", "B2", "--as", "a/b"], "bad base name")): with self.subTest(argv=argv): self.assertIn(why, self.cmd("rebase", *argv, "--brief", self.brief(), code=2)["error"]) - os.environ[forks.CHILD_MARKER] = "1" + os.environ[gremlins.CHILD_MARKER] = "1" try: out = self.cmd("rebase", "--base", "B2", "--as", "B4", "--brief", self.brief(), code=2) - self.assertIn("inside a fork", out["error"]) + self.assertIn("inside a gremlin", out["error"]) finally: - os.environ.pop(forks.CHILD_MARKER, None) + os.environ.pop(gremlins.CHILD_MARKER, None) # binary drift: the rebase runs under the current binary, and the new base records it with open(self.stub, "a", newline="\r\n" if IS_WINDOWS else "\n") as f: f.write("rem drift\n" if IS_WINDOWS else "# drift\n") - fd = self.fork("turns=1", base="B2") + fd = self.spawn("turns=1", base="B2") self.assert_due(fd, "binary_drift") self.finish(fd["job"]) out = self.cmd("rebase", "--base", "B2", "--as", "B4", "--brief", self.brief("turns=1")) rb = self.finish(out["job"]) self.assertEqual((rb["status"], rb["fingerprint"]), ("ok", "drift:binary"), rb) b2, b4 = self.reg()["bases"]["B2"], self.reg()["bases"]["B4"] - cur = forks.binary_info(forks.norm_path(self.stub)) + cur = gremlins.binary_info(gremlins.norm_path(self.stub)) for k in ("version", "size", "mtime"): self.assertEqual((b4["binary"][k], b4["launch"]["binary"][k]), (cur[k], cur[k]), k) self.assertEqual((b4["parent"], b4["lineage_rebases"], b2["status"]), ("B2", 2, "superseded")) - self.assertEqual(b4["fingerprint"], forks.fingerprint_hash(b4["launch"])) + self.assertEqual(b4["fingerprint"], gremlins.fingerprint_hash(b4["launch"])) self.assertNotEqual(b4["fingerprint"], b2["fingerprint"]) - f4 = self.fork("turns=1", base="B4") + f4 = self.spawn("turns=1", base="B4") self.assert_due(f4) r4 = self.finish(f4["job"]) self.assertEqual((r4["status"], r4["fingerprint"], r4["cache"]), ("ok", "ok", "hit"), r4) @@ -3552,13 +3563,13 @@ class LaunchTests(LaunchBase): self.setup_run(cap=1) self.train("B1") self.finish("j0001") - nxt = forks._next_job_id(self.reg()) + nxt = gremlins._next_job_id(self.reg()) out = self.cmd("rebase", "--base", "B1", "--as", "B2", "--brief", self.brief("hang=1", text="RUN-%s" % nxt)) job = out["job"] self.assertEqual(job, nxt) self.until_running(job) - refused = self.fork(code=2) + refused = self.spawn(code=2) self.assertEqual((refused["error"], refused["running"]), ("parallel cap reached", [job])) self.assertIn("rebased", self.cmd("rebase", "--base", "B1", "--as", "B3", "--brief", self.brief(), code=2)["error"]) @@ -3573,15 +3584,15 @@ class LaunchTests(LaunchBase): def test_head_at_end_and_usage_commit_column(self): """2.3: the supervisor records `head_at_end`; `usage --block` lists the commits made during a - sequential fork, and the ledger/usage figures agree with the result.""" + sequential spawn, and the ledger/usage figures agree with the result.""" self.setup_run() self.train("B1") self.finish("j0001") - out = self.fork_marked("turns=3", "sleep=1", extra=("--phase", "1")) + out = self.spawn_marked("turns=3", "sleep=1", extra=("--phase", "1")) job = out["job"] self.until_running(job) before = self.git("rev-parse", "HEAD") - self.commit_file("made.py", "x = 1\n", "commit made during the fork") + self.commit_file("made.py", "x = 1\n", "commit made during the spawn") after = self.git("rev-parse", "HEAD") res = self.finish(job) self.assertEqual(res["status"], "ok", res) @@ -3606,14 +3617,14 @@ class LaunchTests(LaunchBase): # the json view agrees with the ledger c, js, raw = call(["usage", "--run", self.run_log, "--phase", "1"]) self.assertEqual((c, js["reconciled"], js["total"]["calls"]), (0, [], 1)) - self.assertEqual(js["kinds"]["fork"]["cache_read"], res["tokens"]["cache_read"]) + self.assertEqual(js["kinds"]["spawn"]["cache_read"], res["tokens"]["cache_read"]) def tearDownModule(): """No supervisor or stub of this suite may survive it (checked by command line, never by name).""" if not IS_WINDOWS or not STATE_DIRS: return - ps = ("$p = 'forks' + '-test-'; Get-CimInstance Win32_Process | " + ps = ("$p = 'gremlins' + '-test-'; Get-CimInstance Win32_Process | " "Where-Object { $_.CommandLine -and $_.CommandLine.Contains($p) } | " "ForEach-Object { '{0}|{1}' -f $_.ProcessId, $_.CommandLine }") r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], @@ -3621,7 +3632,7 @@ def tearDownModule(): lines = [x for x in r.stdout.decode("utf-8", "replace").splitlines() if x.strip()] for x in lines: with contextlib.suppress(ValueError, OSError): - forks.terminate(forks.pid_record(int(x.split("|", 1)[0]))) + gremlins.terminate(gremlins.pid_record(int(x.split("|", 1)[0]))) if lines: raise AssertionError("leftover test processes (killed now): %s" % lines)