4156 lines
186 KiB
Python
4156 lines
186 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = []
|
|
# ///
|
|
"""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 <abs path>/gremlins.py <command> [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: `<run log without .md>-gremlins/`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import datetime as _dt
|
|
import hashlib
|
|
import json
|
|
import ntpath
|
|
import os
|
|
import platform
|
|
import posixpath
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import uuid
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Constants
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
SCHEMA = 1
|
|
|
|
EXIT_OK = 0
|
|
EXIT_USAGE = 2
|
|
EXIT_WAIT_TIMEOUT = 3
|
|
EXIT_JOB_FAILED = 4
|
|
|
|
# Registry file semantics (C3a). A lock is stale when its holder (pid + creation time) is dead,
|
|
# or when it is older than LOCK_ABANDONED_S. Every critical section is a read-modify-write of a few
|
|
# small JSON files: milliseconds, at worst a few seconds of PermissionError retries (40 x 50 ms
|
|
# per file). 300 s is two orders of magnitude beyond that, so only a holder that cannot be judged
|
|
# (garbage content, an unverifiable pid) and is really gone ever reaches it.
|
|
LOCK_ABANDONED_S = 300.0
|
|
LOCK_WAIT_S = 45.0 # a dead holder's lock is broken at once; a live holder is waited for
|
|
LOCK_POLL_S = 0.05
|
|
RETRY_TRIES = 40 # PermissionError retries for replace and read (>= 20)
|
|
RETRY_SLEEP_S = 0.05
|
|
|
|
MODEL_RE = re.compile(r"^claude-[a-z0-9-]+(\[1m\])?$")
|
|
ONE_M_SUFFIX = "[1m]"
|
|
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
# `claude --help` 2.1.299 (checked by the Phase 1 review); `plan` is in the list but refused.
|
|
PERMISSION_MODES = ("acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan")
|
|
REFUSED_PERMISSION_MODES = ("plan",)
|
|
|
|
DEFAULT_TOOLS = "Bash,Read,Edit,Write,Glob,Grep,Skill"
|
|
DEFAULT_DISALLOWED = ["Bash(git push:*)", "Bash(git rebase:*)", "Bash(git reset --hard:*)",
|
|
"Bash(claude:*)"]
|
|
|
|
CONFIG_DEFAULTS = {
|
|
"parallel_cap": 2,
|
|
"orphan_after": 1200, # N1, seconds; > 2 x 570
|
|
"pings": "on", # P0.6 passed; used in Phase 2
|
|
"ttl_s": 3600,
|
|
"max_minutes": 90,
|
|
"stall_after_s": 900,
|
|
}
|
|
THRESHOLD_DEFAULTS = {
|
|
"rebase_commits": 6,
|
|
"rebase_lines": 1500,
|
|
"rebase_delta_tokens": 12000,
|
|
"max_base_tokens": 250000,
|
|
"max_rebases": 4,
|
|
}
|
|
|
|
# Env pinned into every launch's fingerprint (design 3.3).
|
|
ENV_PINNED_LAUNCH = {"CLAUDE_CODE_PROMPT_CACHE_TTL": "1h", "DISABLE_AUTOUPDATER": "1"}
|
|
|
|
# --- Env scrub: one constant (design 4 + plan 2 + P0.0) ---
|
|
SCRUB_DROP = frozenset({
|
|
# the twelve Desktop vars (design 4, research E1)
|
|
"CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_HOST_SESSION_ID",
|
|
"CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_DESKTOP_APP_VERSION",
|
|
"CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH", "CLAUDE_CODE_TERMINAL_MCP_TOOLS",
|
|
"CLAUDE_CODE_REPORT_FINDINGS", "CLAUDE_CODE_SESSION_ID", "CLAUDE_AGENT_SDK_VERSION",
|
|
"CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL", "CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
|
# fingerprint and auth path
|
|
"CLAUDE_EFFORT", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL",
|
|
"ENABLE_PROMPT_CACHING_1H", "FORCE_PROMPT_CACHING_5M", "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL",
|
|
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
|
|
# uv (plan 2, P0.0)
|
|
"VIRTUAL_ENV",
|
|
})
|
|
SCRUB_DROP_PATTERNS = (re.compile(r"^ANTHROPIC_DEFAULT_.+_MODEL$"), re.compile(r"^UV_"))
|
|
SCRUB_KEEP = frozenset({
|
|
"CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "AI_AGENT", "CLAUDE_PID", "CLAUDE_CODE_GIT_BASH_PATH",
|
|
"PATH", "HOME", "USERPROFILE",
|
|
"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_GREMLIN_CHILD": "1"}
|
|
SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path
|
|
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 gremlin may only run where, list, usage and wait (`tick` launches pings).
|
|
CHILD_REFUSED = ("train", "spawn", "continue", "rebase", "kill", "expire", "init", "tick")
|
|
# The platform gate (digest §4, amendment A1): on an unsupported platform these launch nothing.
|
|
# `init` checks the gate itself, as its first step (B1); `where` reports it.
|
|
GATED_COMMANDS = ("train", "spawn", "continue", "rebase", "tick")
|
|
# Porting aid only (C12, documented in the platform handoff, not in SKILL.md): `=1` opens the
|
|
# gate for that call on an unsupported platform.
|
|
UNSUPPORTED_OK_ENV = "SPIRE_GREMLIN_UNSUPPORTED_OK"
|
|
GATE_OVERRIDE_WARNING = ("platform gate overridden (%s=1); this platform is untested, use it only "
|
|
"to port and test spire:gremlins" % UNSUPPORTED_OK_ENV)
|
|
|
|
# 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 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", "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)
|
|
LOST_AFTER_S = 120.0 # heartbeat older than this and a dead supervisor -> `lost`
|
|
SUP_TICK_S = 0.25 # supervisor main-loop period
|
|
CLOCK_JUMP_S = 60.0 # a tick gap above this is a host suspend / clock jump (M3), not run time
|
|
ORPHAN_CHECK_S = 1.0 # how often the supervisor reads run.last_orchestrator_call_at (N1)
|
|
WAIT_POLL_S = 2.0 # `wait` poll period
|
|
WAIT_DEFAULT_S = 540
|
|
WAIT_MAX_S = 570
|
|
KILL_VERIFY_S = 10.0 # how long `kill` keeps sweeping before it reports survivors
|
|
READER_JOIN_S = 10.0 # after claude exits, how long the supervisor waits for the stream reader
|
|
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; 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 = ("spawn", "continue", "rebase") # parallel_cap counts these (pings never)
|
|
# `spawn --method`: how a gremlin is made. `fork` = `--resume <base> --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`).
|
|
RUNTIME_FIELDS = ("tools", "permissionMode", "plugins", "agents", "apiKeySource", "skills")
|
|
# Fingerprint-defining launch fields: a difference means a new base (retrain_due `defining:`).
|
|
DEFINING_FIELDS = ("model", "cwd", "tools", "permission_mode", "agent", "append_system_prompt")
|
|
# M11: a note counts for a job when its mtime is at or after the job's creation. File times on
|
|
# Windows can lag the precise clock by a timer tick; this slack absorbs that (a stale note from an
|
|
# earlier job of the same block is minutes old).
|
|
NOTE_MTIME_SLACK_S = 2.0
|
|
# P0.7: transcripts live in <projects dir>/<cwd, every non-alphanumeric -> '-'>/<session>.jsonl.
|
|
# Module-level so tests can point it at a temp dir.
|
|
CLAUDE_PROJECTS_DIR: str | None = None
|
|
TRANSCRIPT_TAIL_BYTES = 256 * 1024
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Errors, output, clock
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
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):
|
|
super().__init__(error)
|
|
self.code = code
|
|
self.payload = {"error": error, **fields}
|
|
|
|
|
|
class _HelpRequested(Exception):
|
|
def __init__(self, text: str):
|
|
super().__init__("help")
|
|
self.text = text
|
|
|
|
|
|
def _emit(obj: dict) -> None:
|
|
sys.stdout.write(json.dumps(obj) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def _diag(msg: str) -> None:
|
|
sys.stderr.write("gremlins: " + msg + "\n")
|
|
sys.stderr.flush()
|
|
|
|
|
|
def _now() -> float:
|
|
"""The only clock (epoch seconds). Tests monkeypatch it."""
|
|
return time.time()
|
|
|
|
|
|
def _iso(t: float | None = None) -> str:
|
|
"""ISO 8601 with offset, from `_now()` unless given."""
|
|
if t is None:
|
|
t = _now()
|
|
return _dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
def _ts(iso) -> float | None:
|
|
"""Epoch seconds of an ISO string written by `_iso`, or None."""
|
|
if not iso or not isinstance(iso, str):
|
|
return None
|
|
try:
|
|
return _dt.datetime.fromisoformat(iso).timestamp()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _uniq(items) -> list:
|
|
out = []
|
|
for x in items:
|
|
if x not in out:
|
|
out.append(x)
|
|
return out
|
|
|
|
|
|
def _sha256_bytes(b: bytes) -> str:
|
|
return hashlib.sha256(b).hexdigest()
|
|
|
|
|
|
def _sha256_file(path: str) -> str:
|
|
with open(path, "rb") as f:
|
|
return _sha256_bytes(f.read())
|
|
|
|
|
|
def canonical_json(obj) -> str:
|
|
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
|
|
|
|
def fingerprint_view(launch: dict) -> dict:
|
|
"""What the fingerprint hash covers (M7): the launch fields, with the mcp config's **content**
|
|
in place of its per-run path, so the same launch hashes the same in every run."""
|
|
view = dict(launch)
|
|
m = view.get("mcp")
|
|
if isinstance(m, dict):
|
|
view["mcp"] = {"strict": bool(m.get("strict")), "servers": dict(MCP_EMPTY["mcpServers"])}
|
|
return view
|
|
|
|
|
|
def fingerprint_hash(launch: dict) -> str:
|
|
return _sha256_bytes(canonical_json(fingerprint_view(launch)).encode("utf-8"))
|
|
|
|
|
|
# --- platform seam: begin -------------------------------------------------------------
|
|
# Everything that differs between Windows and POSIX lives in this one section (digest §3):
|
|
# the `Platform` interface, its two implementations, `select_platform()` and `PLATFORM`.
|
|
# Neutral code calls `PLATFORM.<method>` or the module-level delegates below the seam. Both
|
|
# classes can be built on any host: ctypes is loaded lazily (on the first process or window
|
|
# call), and `norm_path` is pure string logic (except Windows' `cygpath -w` for Git Bash
|
|
# rooted paths), so the path matrix and the gate tests run everywhere.
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
class Platform:
|
|
"""The platform interface. `supported` is the release gate (digest §4): only a tested
|
|
platform is supported; lifting the gate for another one is a code change."""
|
|
|
|
name = "?" # "windows" | "posix"
|
|
detach_method = "?" # "cim-hidden" | "setsid"
|
|
supported = False
|
|
|
|
def describe(self) -> dict:
|
|
return {"name": self.name, "sys_platform": sys.platform, "wsl": self.is_wsl(),
|
|
"supported": bool(self.supported), "detach": self.detach_method}
|
|
|
|
def is_wsl(self) -> bool:
|
|
return False
|
|
|
|
@staticmethod
|
|
def norm_path(p: str, cwd: str | None = None) -> str:
|
|
raise NotImplementedError
|
|
|
|
def popen_kwargs(self, new_group: bool = False) -> dict:
|
|
raise NotImplementedError
|
|
|
|
def detach(self, argv: list[str], cwd: str, log_path: str) -> dict:
|
|
"""Start `argv` detached from the caller. {"ok": True, "method", "pid", "ctime"} or
|
|
{"ok": False, "method", "detail"}."""
|
|
raise NotImplementedError
|
|
|
|
def proc_ctime(self, pid: int) -> int | None:
|
|
raise NotImplementedError
|
|
|
|
def epoch_to_ctime(self, t: float | None) -> int | None:
|
|
raise NotImplementedError
|
|
|
|
def _running(self, pid: int) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def pid_alive(self, pid: int, ctime: int | None = None) -> bool:
|
|
"""True if `pid` runs and (when both are known) its creation time equals `ctime`."""
|
|
if pid is None or int(pid) <= 0:
|
|
return False
|
|
pid = int(pid)
|
|
if not self._running(pid):
|
|
return False
|
|
if ctime is not None:
|
|
now_ct = self.proc_ctime(pid)
|
|
if now_ct is not None and now_ct != ctime:
|
|
return False
|
|
return True
|
|
|
|
def process_table(self) -> list[tuple[int, int, str]]:
|
|
raise NotImplementedError
|
|
|
|
def terminate(self, pid: int) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def tree_kill(self, pid: int) -> int | None:
|
|
raise NotImplementedError
|
|
|
|
def restore_no_replace(self, src: str, dst: str) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def visible_windows(self) -> list[tuple[int, int, str, str]] | None:
|
|
return None
|
|
|
|
|
|
class WindowsPlatform(Platform):
|
|
name = "windows"
|
|
detach_method = "cim-hidden" # C6, P0.1 rung 3 with ShowWindow=0
|
|
supported = True
|
|
|
|
CREATE_NO_WINDOW = 0x08000000
|
|
_GITBASH_DRIVE = re.compile(r"^/([A-Za-z])(?=/|$)")
|
|
_WSL_MOUNT = re.compile(r"^/mnt/[A-Za-z](?=/|$)")
|
|
_FILETIME_EPOCH = 116444736000000000 # 1970-01-01 in FILETIME ticks (100 ns since 1601)
|
|
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
_PROCESS_TERMINATE = 0x0001
|
|
_STILL_ACTIVE = 259
|
|
_ERROR_ACCESS_DENIED = 5
|
|
_TH32CS_SNAPPROCESS = 0x00000002
|
|
# CIM hidden spawn (P0.1 rung 3 + ShowWindow=0). Command line and cwd arrive through env vars.
|
|
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_GREMLIN_DETACH_CMD; CurrentDirectory=$env:SPIRE_GREMLIN_DETACH_CWD; "
|
|
"ProcessStartupInformation=$si}; "
|
|
"\"{0} {1}\" -f $r.ReturnValue, $r.ProcessId")
|
|
|
|
def __init__(self):
|
|
self._api = None
|
|
|
|
def _win(self):
|
|
"""(ctypes, ctypes.wintypes, kernel32, PROCESSENTRY32W, INVALID_HANDLE_VALUE), loaded on
|
|
first use (never at import or construction)."""
|
|
if self._api is None:
|
|
import ctypes
|
|
import ctypes.wintypes as wt
|
|
|
|
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
k32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
|
k32.OpenProcess.restype = wt.HANDLE
|
|
k32.GetProcessTimes.argtypes = [wt.HANDLE] + [ctypes.POINTER(wt.FILETIME)] * 4
|
|
k32.GetProcessTimes.restype = wt.BOOL
|
|
k32.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)]
|
|
k32.GetExitCodeProcess.restype = wt.BOOL
|
|
k32.CloseHandle.argtypes = [wt.HANDLE]
|
|
k32.CloseHandle.restype = wt.BOOL
|
|
|
|
class PE32(ctypes.Structure):
|
|
_fields_ = [("dwSize", wt.DWORD), ("cntUsage", wt.DWORD), ("th32ProcessID", wt.DWORD),
|
|
("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", wt.DWORD),
|
|
("cntThreads", wt.DWORD), ("th32ParentProcessID", wt.DWORD),
|
|
("pcPriClassBase", wt.LONG), ("dwFlags", wt.DWORD),
|
|
("szExeFile", wt.WCHAR * 260)]
|
|
|
|
k32.CreateToolhelp32Snapshot.argtypes = [wt.DWORD, wt.DWORD]
|
|
k32.CreateToolhelp32Snapshot.restype = wt.HANDLE
|
|
k32.Process32FirstW.argtypes = [wt.HANDLE, ctypes.POINTER(PE32)]
|
|
k32.Process32FirstW.restype = wt.BOOL
|
|
k32.Process32NextW.argtypes = [wt.HANDLE, ctypes.POINTER(PE32)]
|
|
k32.Process32NextW.restype = wt.BOOL
|
|
k32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT]
|
|
k32.TerminateProcess.restype = wt.BOOL
|
|
self._api = (ctypes, wt, k32, PE32, wt.HANDLE(-1).value)
|
|
return self._api
|
|
|
|
# --- paths ---
|
|
|
|
@staticmethod
|
|
def find_cygpath() -> str | None:
|
|
"""`cygpath` on PATH (Git Bash ships it), or None. Tests patch this lookup."""
|
|
return shutil.which("cygpath")
|
|
|
|
@staticmethod
|
|
def cygpath_w(p: str) -> str:
|
|
"""M13: a Git Bash rooted path that is not a drive form (`/tmp/x`, `/usr/...`) converted
|
|
with `cygpath -w`. Refused (exit 2) when cygpath is not on PATH or fails; never mapped
|
|
onto the cwd's drive."""
|
|
exe = WindowsPlatform.find_cygpath()
|
|
if not exe:
|
|
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 GremlinsError("cygpath failed", path=p, detail=str(e))
|
|
out = r.stdout.decode("utf-8", "replace").strip()
|
|
if r.returncode != 0 or not out:
|
|
raise GremlinsError("cygpath failed", path=p,
|
|
detail=r.stderr.decode("utf-8", "replace").strip()[:200] or "no output")
|
|
return out
|
|
|
|
@staticmethod
|
|
def norm_path(p: str, cwd: str | None = None) -> str:
|
|
"""Normalise relative, `G:\\…`, `G:/…`, UNC and Git Bash `/g/…` forms to an absolute path
|
|
with backslashes and an upper-case drive letter. A Git Bash rooted path that is not a
|
|
drive form (`/tmp/x`) goes through `cygpath -w` (M13); a native rooted path (`\\x`) keeps
|
|
its Windows meaning (the cwd's drive). Refused: empty, the WSL form `/mnt/<d>/…`, and
|
|
anything that does not end up absolute with a drive (`C:`, `C:x`); digest §3.4."""
|
|
if not isinstance(p, str) or not p.strip():
|
|
raise GremlinsError("empty path", path=p)
|
|
p = p.strip()
|
|
if WindowsPlatform._WSL_MOUNT.match(p):
|
|
raise GremlinsError("WSL path form on Windows; pass C:\\... or /c/...", path=p)
|
|
m = WindowsPlatform._GITBASH_DRIVE.match(p)
|
|
if m:
|
|
p = m.group(1).upper() + ":" + (p[2:] or "/")
|
|
elif p.startswith("/") and not p.startswith("//"):
|
|
p = WindowsPlatform.cygpath_w(p)
|
|
p = p.replace("/", "\\")
|
|
drive, rest = ntpath.splitdrive(p)
|
|
if not (drive and rest.startswith("\\")):
|
|
base = WindowsPlatform.norm_path(cwd if cwd is not None else os.getcwd())
|
|
if not drive and rest.startswith("\\"):
|
|
p = ntpath.splitdrive(base)[0] + rest # rooted, no drive: cwd's drive
|
|
else:
|
|
p = ntpath.join(base, p)
|
|
p = ntpath.normpath(p)
|
|
drive, rest = ntpath.splitdrive(p)
|
|
if not drive or not rest.startswith("\\"):
|
|
raise GremlinsError("cannot normalise path", path=p)
|
|
if len(drive) == 2 and drive[1] == ":":
|
|
p = drive.upper() + rest
|
|
return p
|
|
|
|
# --- launching ---
|
|
|
|
def popen_kwargs(self, new_group: bool = False) -> dict:
|
|
"""No console window for any child. `new_group` has no Windows meaning here (the tree
|
|
kill walks the whole tree)."""
|
|
return {"creationflags": self.CREATE_NO_WINDOW}
|
|
|
|
@staticmethod
|
|
def _powershell() -> str:
|
|
w = shutil.which("powershell")
|
|
if w:
|
|
return w
|
|
return os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "WindowsPowerShell",
|
|
"v1.0", "powershell.exe")
|
|
|
|
def detach(self, argv: list[str], cwd: str, log_path: str) -> dict:
|
|
"""CIM `Win32_Process.Create` with a hidden window, through a `powershell` that itself has
|
|
CREATE_NO_WINDOW. `log_path` is unused: the supervisor redirects itself."""
|
|
env = dict(os.environ)
|
|
env["SPIRE_GREMLIN_DETACH_CMD"] = subprocess.list2cmdline(argv)
|
|
env["SPIRE_GREMLIN_DETACH_CWD"] = cwd
|
|
try:
|
|
r = _run([self._powershell(), "-NoProfile", "-NonInteractive", "-Command", self.POWERSHELL_CIM],
|
|
env=env, timeout=120)
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
return {"ok": False, "method": self.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": self.detach_method,
|
|
"detail": "unparsable CIM output: %r / %r" % (r.stdout[-200:], r.stderr[-400:])}
|
|
if rv != 0:
|
|
return {"ok": False, "method": self.detach_method, "detail": "Win32_Process.Create returned %d" % rv}
|
|
return {"ok": True, "method": self.detach_method, "pid": pid, "ctime": self.proc_ctime(pid)}
|
|
|
|
# --- processes ---
|
|
|
|
def proc_ctime(self, pid: int) -> int | None:
|
|
"""Process creation time in FILETIME ticks, or None."""
|
|
ctypes, wt, k32, _, _ = self._win()
|
|
h = k32.OpenProcess(self._PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
|
if not h:
|
|
return None
|
|
try:
|
|
c, e, k, u = wt.FILETIME(), wt.FILETIME(), wt.FILETIME(), wt.FILETIME()
|
|
if not k32.GetProcessTimes(h, ctypes.byref(c), ctypes.byref(e), ctypes.byref(k),
|
|
ctypes.byref(u)):
|
|
return None
|
|
return (c.dwHighDateTime << 32) | c.dwLowDateTime
|
|
finally:
|
|
k32.CloseHandle(h)
|
|
|
|
def epoch_to_ctime(self, t: float | None) -> int | None:
|
|
if t is None:
|
|
return None
|
|
return int(float(t) * 10_000_000) + self._FILETIME_EPOCH
|
|
|
|
def _running(self, pid: int) -> bool:
|
|
ctypes, wt, k32, _, _ = self._win()
|
|
h = k32.OpenProcess(self._PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
if not h:
|
|
# Exists but not openable: alive, creation time unverifiable.
|
|
return ctypes.get_last_error() == self._ERROR_ACCESS_DENIED
|
|
try:
|
|
code = wt.DWORD()
|
|
return bool(k32.GetExitCodeProcess(h, ctypes.byref(code))) and code.value == self._STILL_ACTIVE
|
|
finally:
|
|
k32.CloseHandle(h)
|
|
|
|
def process_table(self) -> list[tuple[int, int, str]]:
|
|
"""One Toolhelp32 snapshot (fast, no PowerShell)."""
|
|
ctypes, _, k32, PE32, invalid = self._win()
|
|
out = []
|
|
snap = k32.CreateToolhelp32Snapshot(self._TH32CS_SNAPPROCESS, 0)
|
|
if not snap or snap == invalid:
|
|
return out
|
|
try:
|
|
e = PE32()
|
|
e.dwSize = ctypes.sizeof(PE32)
|
|
ok = k32.Process32FirstW(snap, ctypes.byref(e))
|
|
while ok:
|
|
out.append((int(e.th32ProcessID), int(e.th32ParentProcessID), e.szExeFile))
|
|
ok = k32.Process32NextW(snap, ctypes.byref(e))
|
|
finally:
|
|
k32.CloseHandle(snap)
|
|
return out
|
|
|
|
def terminate(self, pid: int) -> bool:
|
|
_, _, k32, _, _ = self._win()
|
|
h = k32.OpenProcess(self._PROCESS_TERMINATE | self._PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
|
if not h:
|
|
return False
|
|
try:
|
|
return bool(k32.TerminateProcess(h, 1))
|
|
finally:
|
|
k32.CloseHandle(h)
|
|
|
|
@staticmethod
|
|
def _taskkill() -> str:
|
|
p = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "taskkill.exe")
|
|
return p if os.path.isfile(p) else "taskkill"
|
|
|
|
def tree_kill(self, pid: int) -> int | None:
|
|
"""`taskkill /T /F` on the tree; its exit code, or None when it could not run."""
|
|
try:
|
|
return _run([self._taskkill(), "/T", "/F", "/PID", str(int(pid))], timeout=60).returncode
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
_diag("taskkill failed: %s" % e)
|
|
return None
|
|
|
|
# --- files ---
|
|
|
|
def restore_no_replace(self, src: str, dst: str) -> bool:
|
|
try:
|
|
_retry(lambda: os.rename(src, dst)) # Windows rename never overwrites
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
# --- desktop ---
|
|
|
|
def visible_windows(self) -> list[tuple[int, int, str, str]] | None:
|
|
"""Visible top-level windows: [(hwnd, pid, class, title)] (the M8 window check)."""
|
|
ctypes, wt, _, _, _ = self._win()
|
|
u32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
out = []
|
|
proto = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
|
|
|
|
def cb(hwnd, _):
|
|
if u32.IsWindowVisible(hwnd):
|
|
pid = wt.DWORD()
|
|
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
cls = ctypes.create_unicode_buffer(256)
|
|
u32.GetClassNameW(hwnd, cls, 256)
|
|
ttl = ctypes.create_unicode_buffer(256)
|
|
u32.GetWindowTextW(hwnd, ttl, 256)
|
|
out.append((int(hwnd or 0), pid.value, cls.value, ttl.value))
|
|
return True
|
|
u32.EnumWindows(proto(cb), 0)
|
|
return out
|
|
|
|
|
|
class PosixPlatform(Platform):
|
|
"""Linux, WSL (it is Linux) and macOS. Untested in this release: the gate is closed."""
|
|
|
|
name = "posix"
|
|
detach_method = "setsid"
|
|
# Lifting the gate (digest §4): add the tested `sys.platform` values here, with a test.
|
|
TESTED_SYS_PLATFORMS: tuple[str, ...] = ()
|
|
_DRIVE_FORM = re.compile(r"^[A-Za-z]:")
|
|
|
|
@property
|
|
def supported(self) -> bool:
|
|
return sys.platform in self.TESTED_SYS_PLATFORMS
|
|
|
|
def is_wsl(self) -> bool:
|
|
if os.environ.get("WSL_DISTRO_NAME"):
|
|
return True
|
|
try:
|
|
with open("/proc/sys/kernel/osrelease", "rb") as f:
|
|
return b"microsoft" in f.read().lower()
|
|
except OSError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def norm_path(p: str, cwd: str | None = None) -> str:
|
|
"""`os.path.abspath` semantics relative to `cwd`. Refused: empty, a drive form (`C:…`)
|
|
and any backslash (on POSIX almost always a Windows path); digest §3.4."""
|
|
if not isinstance(p, str) or not p.strip():
|
|
raise GremlinsError("empty path", path=p)
|
|
orig, p = p, p.strip()
|
|
if PosixPlatform._DRIVE_FORM.match(p):
|
|
raise GremlinsError("Windows drive path form on POSIX; pass a POSIX path", path=orig)
|
|
if "\\" in p:
|
|
raise GremlinsError("backslash in a path on POSIX (a Windows path form); pass a POSIX path",
|
|
path=orig)
|
|
base = cwd if cwd is not None else os.getcwd()
|
|
return posixpath.normpath(posixpath.join(base, p))
|
|
|
|
def popen_kwargs(self, new_group: bool = False) -> dict:
|
|
return {"start_new_session": bool(new_group)}
|
|
|
|
def detach(self, argv: list[str], cwd: str, log_path: str) -> dict:
|
|
"""A new session, stdout/stderr to `log_path`."""
|
|
log = open(log_path, "ab")
|
|
try:
|
|
p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
|
start_new_session=True, close_fds=True)
|
|
except OSError as e:
|
|
return {"ok": False, "method": self.detach_method, "detail": str(e)}
|
|
finally:
|
|
log.close()
|
|
return {"ok": True, "method": self.detach_method, "pid": p.pid, "ctime": self.proc_ctime(p.pid)}
|
|
|
|
def proc_ctime(self, pid: int) -> int | None:
|
|
"""/proc start time (clock ticks since boot), or None without /proc."""
|
|
try:
|
|
with open("/proc/%d/stat" % int(pid), "rb") as f:
|
|
stat = f.read().decode("ascii", "replace")
|
|
return int(stat[stat.rindex(")") + 2:].split()[19])
|
|
except (OSError, ValueError, IndexError):
|
|
return None
|
|
|
|
def epoch_to_ctime(self, t: float | None) -> int | None:
|
|
"""Epoch seconds in clock ticks since boot (/proc/stat btime), or None without /proc."""
|
|
if t is None:
|
|
return None
|
|
try:
|
|
with open("/proc/stat", "rb") as f:
|
|
btime = next(int(ln.split()[1]) for ln in f.read().splitlines() if ln.startswith(b"btime "))
|
|
return int((float(t) - btime) * os.sysconf("SC_CLK_TCK"))
|
|
except (OSError, ValueError, StopIteration, IndexError, AttributeError):
|
|
return None
|
|
|
|
def _running(self, pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
pass
|
|
return True
|
|
|
|
def process_table(self) -> list[tuple[int, int, str]]:
|
|
"""A /proc walk; [] without /proc (macOS: the known gap)."""
|
|
out = []
|
|
try:
|
|
names = os.listdir("/proc")
|
|
except OSError:
|
|
return out
|
|
for n in names:
|
|
if not n.isdigit():
|
|
continue
|
|
try:
|
|
with open("/proc/%s/stat" % n, "rb") as f:
|
|
st = f.read().decode("ascii", "replace")
|
|
comm = st[st.index("(") + 1: st.rindex(")")]
|
|
ppid = int(st[st.rindex(")") + 2:].split()[1])
|
|
out.append((int(n), ppid, comm))
|
|
except (OSError, ValueError, IndexError):
|
|
continue
|
|
return out
|
|
|
|
def terminate(self, pid: int) -> bool:
|
|
try:
|
|
os.kill(int(pid), signal.SIGKILL)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
def tree_kill(self, pid: int) -> int | None:
|
|
"""SIGKILL to the process group (M14: `claude` runs in its own session and is missed;
|
|
the per-pid sweep catches it)."""
|
|
with contextlib.suppress(OSError):
|
|
os.killpg(int(pid), signal.SIGKILL)
|
|
return None
|
|
|
|
def restore_no_replace(self, src: str, dst: str) -> bool:
|
|
try:
|
|
os.link(src, dst) # fails if the path exists
|
|
os.remove(src)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def select_platform() -> Platform:
|
|
return WindowsPlatform() if os.name == "nt" else PosixPlatform()
|
|
|
|
|
|
PLATFORM = select_platform()
|
|
|
|
# --- platform seam: end ---------------------------------------------------------------
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Platform gate (digest §4): fail closed where the helper is untested
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def gate_overridden() -> bool:
|
|
"""A12: the override counts only where the gate is closed (on a supported platform the env
|
|
var changes nothing)."""
|
|
return os.environ.get(UNSUPPORTED_OK_ENV) == "1" and not PLATFORM.supported
|
|
|
|
|
|
def platform_report() -> dict:
|
|
"""`where`'s `platform` block: `PLATFORM.describe()` plus `override`."""
|
|
return {**PLATFORM.describe(), "override": gate_overridden()}
|
|
|
|
|
|
def platform_gate() -> str | None:
|
|
"""None when the platform is supported or the gate is overridden; else the reason."""
|
|
if PLATFORM.supported or gate_overridden():
|
|
return None
|
|
d = PLATFORM.describe()
|
|
return ("spire:gremlins is not tested on %s%s yet; use fresh agents for every block "
|
|
"(see SKILL.md, Platforms)." % (d.get("sys_platform"), ", WSL" if d.get("wsl") else ""))
|
|
|
|
|
|
def require_platform() -> None:
|
|
"""Refuse (exit 2) on a closed gate: `init` and the launching commands (A1)."""
|
|
reason = platform_gate()
|
|
if reason is not None:
|
|
raise GremlinsError("platform not supported", reason=reason, platform=platform_report())
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Paths
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> str:
|
|
"""Normalise a path for this host (`PLATFORM.norm_path`). `windows` selects a platform's
|
|
rules explicitly (True: Windows, False: POSIX); the tests use it to check both anywhere."""
|
|
if windows is None:
|
|
return PLATFORM.norm_path(p, cwd)
|
|
return (WindowsPlatform if windows else PosixPlatform).norm_path(p, cwd)
|
|
|
|
|
|
def state_dir_for(run: str) -> str:
|
|
"""`reports/<x>-run.md` -> `reports/<x>-run-gremlins` (a sibling of the run log)."""
|
|
run_n = norm_path(run)
|
|
if not run_n.lower().endswith(".md"):
|
|
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")
|
|
STATE_GITIGNORE = "raw/\n*.lock\n"
|
|
MCP_EMPTY = {"mcpServers": {}}
|
|
|
|
|
|
def registry_path(state: str) -> str:
|
|
return os.path.join(state, "registry.json")
|
|
|
|
|
|
def lock_path(state: str) -> str:
|
|
return os.path.join(state, "registry.lock")
|
|
|
|
|
|
def ledger_path(state: str) -> str:
|
|
return os.path.join(state, "ledger.jsonl")
|
|
|
|
|
|
def mcp_empty_path(state: str) -> str:
|
|
return os.path.join(state, "mcp-empty.json")
|
|
|
|
|
|
def ensure_state(state: str) -> None:
|
|
"""Create the state dir and its fixed files. Idempotent."""
|
|
os.makedirs(state, exist_ok=True)
|
|
for d in STATE_SUBDIRS:
|
|
os.makedirs(os.path.join(state, d), exist_ok=True)
|
|
gi = os.path.join(state, ".gitignore")
|
|
if not os.path.exists(gi):
|
|
atomic_write_bytes(gi, STATE_GITIGNORE.encode("utf-8"))
|
|
mcp = mcp_empty_path(state)
|
|
if not os.path.exists(mcp):
|
|
atomic_write_bytes(mcp, (json.dumps(MCP_EMPTY) + "\n").encode("utf-8"))
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Atomic files, retries, the registry lock (C3)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def _retry(fn, tries: int = RETRY_TRIES, sleep_s: float = RETRY_SLEEP_S):
|
|
"""Call fn(); retry PermissionError (Windows sharing violations). Returns (value, attempts)."""
|
|
for attempt in range(1, tries + 1):
|
|
try:
|
|
return fn(), attempt
|
|
except PermissionError:
|
|
if attempt == tries:
|
|
raise
|
|
time.sleep(sleep_s)
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
def atomic_write_bytes(path: str, data: bytes) -> int:
|
|
"""Write to a temp file in the same dir, then `os.replace` with retries. Returns attempts."""
|
|
d = os.path.dirname(path) or "."
|
|
tmp = os.path.join(d, ".%s.%d.%s.tmp" % (os.path.basename(path), os.getpid(), uuid.uuid4().hex[:8]))
|
|
with open(tmp, "wb") as f:
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
try:
|
|
_, attempts = _retry(lambda: os.replace(tmp, path))
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
os.remove(tmp)
|
|
raise
|
|
return attempts
|
|
|
|
|
|
def write_json_atomic(path: str, obj) -> int:
|
|
return atomic_write_bytes(path, (json.dumps(obj, indent=1, sort_keys=True) + "\n").encode("utf-8"))
|
|
|
|
|
|
def read_bytes_retry(path: str) -> bytes:
|
|
def _read():
|
|
with open(path, "rb") as f:
|
|
return f.read()
|
|
return _retry(_read)[0]
|
|
|
|
|
|
def read_json(path: str):
|
|
"""Read a JSON file with PermissionError retries. Returns None if it does not exist."""
|
|
try:
|
|
data = read_bytes_retry(path)
|
|
except FileNotFoundError:
|
|
return None
|
|
try:
|
|
return json.loads(data.decode("utf-8"))
|
|
except ValueError as e:
|
|
raise GremlinsError("corrupt state file", path=path, detail=str(e))
|
|
|
|
|
|
class RegistryLock:
|
|
"""`registry.lock`: create-exclusive. A waiter breaks a lock only when it is stale (its holder's
|
|
pid + creation time is dead, or it is older than LOCK_ABANDONED_S), and only atomically: it
|
|
renames the lock aside and keeps it only if the renamed content is the one it judged stale;
|
|
otherwise it puts it back. So a waiter never deletes a fresh lock."""
|
|
|
|
def __init__(self, state: str, timeout: float = LOCK_WAIT_S):
|
|
self.path = lock_path(state)
|
|
self.timeout = timeout
|
|
self.token = uuid.uuid4().hex
|
|
self.held = False
|
|
self.broke_stale = False
|
|
|
|
def _content(self) -> bytes:
|
|
return json.dumps({"pid": os.getpid(), "ctime": proc_ctime(os.getpid()),
|
|
"at": _now(), "token": self.token}).encode("utf-8")
|
|
|
|
def _try_create(self) -> bool:
|
|
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0)
|
|
try:
|
|
fd = os.open(self.path, flags)
|
|
except FileExistsError:
|
|
return False
|
|
except PermissionError: # Windows: the old lock is being deleted
|
|
return False
|
|
try:
|
|
os.write(fd, self._content())
|
|
finally:
|
|
os.close(fd)
|
|
return True
|
|
|
|
def _read_lock(self):
|
|
"""(raw bytes, age in seconds, holder dict or None) of the current lock, or
|
|
(None, None, None) if it is gone or unreadable right now."""
|
|
try:
|
|
raw = read_bytes_retry(self.path)
|
|
mtime = os.path.getmtime(self.path)
|
|
except (FileNotFoundError, PermissionError):
|
|
return None, None, None
|
|
holder, at = None, None
|
|
try:
|
|
holder = json.loads(raw.decode("utf-8"))
|
|
at = float(holder.get("at"))
|
|
except (ValueError, TypeError, AttributeError):
|
|
pass
|
|
if at is None:
|
|
at = mtime
|
|
return raw, _now() - at, holder if isinstance(holder, dict) else None
|
|
|
|
@staticmethod
|
|
def _is_stale(holder, age) -> bool:
|
|
if age is not None and age > LOCK_ABANDONED_S:
|
|
return True
|
|
if holder and holder.get("pid"):
|
|
try:
|
|
return not pid_alive(int(holder["pid"]), holder.get("ctime"))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return False
|
|
|
|
def _restore(self, aside: str) -> bool:
|
|
"""Put a lock renamed aside back, but never over a lock that appeared meanwhile."""
|
|
return PLATFORM.restore_no_replace(aside, self.path)
|
|
|
|
def _break(self, raw: bytes) -> bool:
|
|
"""Remove the lock atomically if it still is `raw` (the content judged stale)."""
|
|
aside = os.path.join(os.path.dirname(self.path),
|
|
"registry.%s.broken.lock" % uuid.uuid4().hex[:12])
|
|
try:
|
|
os.rename(self.path, aside) # atomic: we now own whatever lock was there
|
|
except OSError: # gone already, or busy: judge again later
|
|
return False
|
|
try:
|
|
got = read_bytes_retry(aside)
|
|
except OSError:
|
|
got = None
|
|
if got == raw:
|
|
with contextlib.suppress(OSError):
|
|
_retry(lambda: os.remove(aside))
|
|
return True
|
|
if not self._restore(aside):
|
|
_diag("registry lock changed while being broken and could not be put back (left at %s)"
|
|
% aside)
|
|
return False
|
|
|
|
def acquire(self) -> "RegistryLock":
|
|
deadline = time.monotonic() + self.timeout
|
|
while True:
|
|
if self._try_create():
|
|
self.held = True
|
|
return self
|
|
raw, age, holder = self._read_lock()
|
|
if raw is not None and self._is_stale(holder, age) and self._break(raw):
|
|
self.broke_stale = True
|
|
_diag("broke stale registry lock (age %.1f s, holder pid %s)"
|
|
% (age, (holder or {}).get("pid")))
|
|
continue # the path is free: try to create at once
|
|
if time.monotonic() >= deadline:
|
|
raise GremlinsError("registry locked", lock=self.path, waited_s=self.timeout)
|
|
time.sleep(LOCK_POLL_S)
|
|
|
|
def release(self) -> None:
|
|
if not self.held:
|
|
return
|
|
self.held = False
|
|
try:
|
|
raw = read_bytes_retry(self.path)
|
|
except FileNotFoundError:
|
|
return
|
|
try:
|
|
mine = json.loads(raw.decode("utf-8")).get("token") == self.token
|
|
except (ValueError, AttributeError):
|
|
mine = False
|
|
if mine:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
_retry(lambda: os.remove(self.path))
|
|
else:
|
|
_diag("registry lock was taken over while held; not removing it")
|
|
|
|
def __enter__(self):
|
|
return self.acquire()
|
|
|
|
def __exit__(self, *exc):
|
|
self.release()
|
|
return False
|
|
|
|
|
|
def new_registry() -> dict:
|
|
return {"schema": SCHEMA, "config": None, "bases": {}, "jobs": {}, "run": {"created_at": _iso()}}
|
|
|
|
|
|
def read_registry(state: str) -> dict | None:
|
|
reg = read_json(registry_path(state))
|
|
if reg is None:
|
|
return None
|
|
if not isinstance(reg, dict) or reg.get("schema") != 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
|
|
|
|
|
|
def write_registry(state: str, reg: dict) -> int:
|
|
"""Caller must hold RegistryLock(state)."""
|
|
reg["schema"] = SCHEMA
|
|
return write_json_atomic(registry_path(state), reg)
|
|
|
|
|
|
def _ledger_jobs(state: str) -> tuple[set, bool]:
|
|
"""(job ids that already have a ledger line, whether the file ends without a newline)."""
|
|
try:
|
|
data = read_bytes_retry(ledger_path(state))
|
|
except FileNotFoundError:
|
|
return set(), False
|
|
jobs = set()
|
|
for ln in data.splitlines():
|
|
try:
|
|
obj = json.loads(ln.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
continue
|
|
if isinstance(obj, dict) and obj.get("job"):
|
|
jobs.add(obj["job"])
|
|
return jobs, bool(data) and not data.endswith(b"\n")
|
|
|
|
|
|
def ledger_append(state: str, line: dict) -> bool:
|
|
"""Append one JSON line, at most one per job (M1: skipped if the job already has one).
|
|
Caller must hold RegistryLock(state) (C3c). Returns True if a line was written."""
|
|
have, torn = _ledger_jobs(state)
|
|
if line.get("job") and line["job"] in have:
|
|
_diag("ledger already has a line for %s; not appending another" % line["job"])
|
|
return False
|
|
data = (("\n" if torn else "") + json.dumps(line, sort_keys=True) + "\n").encode("utf-8")
|
|
|
|
def _append():
|
|
with open(ledger_path(state), "ab") as f:
|
|
f.write(data)
|
|
_retry(_append)
|
|
return True
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Processes: pid + creation time (A19), subprocess helpers, git (C7)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
# The process primitives are the platform's (`PLATFORM`); these module-level names stay so that
|
|
# neutral code and the tests' patches (`mock.patch.object(gremlins, "proc_ctime", …)`) keep working.
|
|
|
|
|
|
def proc_ctime(pid: int) -> int | None:
|
|
"""Process creation time in the platform's units, or None when unknown."""
|
|
return PLATFORM.proc_ctime(pid)
|
|
|
|
|
|
def epoch_to_ctime(t: float | None) -> int | None:
|
|
"""Epoch seconds in the units of `proc_ctime`, or None when the conversion is unknown here."""
|
|
return PLATFORM.epoch_to_ctime(t)
|
|
|
|
|
|
def pid_record(pid: int) -> dict:
|
|
"""{"pid", "ctime"}: the pair every stored pid is compared by."""
|
|
return {"pid": int(pid), "ctime": proc_ctime(pid)}
|
|
|
|
|
|
def pid_alive(pid: int, ctime: int | None = None) -> bool:
|
|
"""True if `pid` runs and (when both are known) its creation time equals `ctime`."""
|
|
return PLATFORM.pid_alive(pid, ctime)
|
|
|
|
|
|
def record_alive(rec: dict | None) -> bool:
|
|
return bool(rec) and pid_alive(rec.get("pid"), rec.get("ctime"))
|
|
|
|
|
|
def process_table() -> list[tuple[int, int, str]]:
|
|
"""[(pid, ppid, name)] of every process ([] where the platform cannot list them)."""
|
|
return PLATFORM.process_table()
|
|
|
|
|
|
ALIVE_UNTIL_SLACK_S = 1.0 # job-file times are ISO with whole seconds: allow the truncated part
|
|
|
|
|
|
def process_tree(rec: dict | None, alive_until: float | None = None) -> list[dict]:
|
|
"""`rec` (if alive) and all its descendants, each {pid, ctime, ppid, name}. A process counts as
|
|
a child only if it was created at or after its parent (guards against pid reuse).
|
|
|
|
Dead root (I2): its pid may have been reused by a later process whose own children now carry
|
|
the same stale parent pid. Its direct children are therefore taken only if they were created
|
|
after the root's recorded creation time AND before `alive_until` (epoch seconds: the last
|
|
time the job was known alive, e.g. the job file's `heartbeat_at` / `ended_at`). Without a
|
|
recorded creation time or `alive_until`, a dead root claims nothing. Below a claimed (live,
|
|
verified) child the normal rule applies."""
|
|
if not rec or not rec.get("pid"):
|
|
return []
|
|
kids, names = {}, {}
|
|
for pid, ppid, name in process_table():
|
|
names[pid] = name
|
|
if pid != ppid:
|
|
kids.setdefault(ppid, []).append(pid)
|
|
root = int(rec["pid"])
|
|
root_ct = rec.get("ctime")
|
|
out = []
|
|
if record_alive(rec):
|
|
out.append({"pid": root, "ctime": root_ct, "ppid": None, "name": names.get(root)})
|
|
dead_bounds = None
|
|
else:
|
|
upper = epoch_to_ctime(None if alive_until is None else alive_until + ALIVE_UNTIL_SLACK_S)
|
|
if root_ct is None or upper is None:
|
|
return []
|
|
dead_bounds = (int(root_ct), upper)
|
|
stack, seen = [(root, root_ct)], {root}
|
|
while stack:
|
|
ppid, pct = stack.pop()
|
|
is_dead_root = ppid == root and dead_bounds is not None
|
|
if not is_dead_root:
|
|
now_ct = proc_ctime(ppid)
|
|
if pct is not None and now_ct is not None and now_ct != pct:
|
|
continue # this pid now belongs to another process: its children are not ours
|
|
for c in kids.get(ppid, []):
|
|
if c in seen:
|
|
continue
|
|
ct = proc_ctime(c)
|
|
if is_dead_root:
|
|
lo, hi = dead_bounds
|
|
if ct is None or not (lo < ct <= hi):
|
|
continue # created before the job, or after it was last known alive: not ours
|
|
elif pct is not None and ct is not None and ct < pct:
|
|
continue
|
|
seen.add(c)
|
|
out.append({"pid": c, "ctime": ct, "ppid": ppid, "name": names.get(c)})
|
|
stack.append((c, ct))
|
|
return out
|
|
|
|
|
|
def terminate(rec: dict) -> bool:
|
|
"""Kill one process identified by pid + creation time. True if it is gone or was signalled."""
|
|
if not record_alive(rec):
|
|
return True
|
|
return PLATFORM.terminate(int(rec["pid"]))
|
|
|
|
|
|
def kill_tree(rec: dict | None, extra=(), alive_until: float | None = None) -> dict:
|
|
"""Snapshot the tree of `rec` (plus the trees of `extra` records), kill it (`PLATFORM.tree_kill`
|
|
of the first live root), sweep every snapshot pid still alive, and verify.
|
|
`alive_until` bounds the descendants of a dead root (see `process_tree`).
|
|
Returns {"snapshot", "tree_kill_rc", "survivors"}; pids are always checked with their ctime."""
|
|
roots = [r for r in [rec, *extra] if r and r.get("pid")]
|
|
snap, seen = [], set()
|
|
for r in roots:
|
|
for p in process_tree(r, alive_until):
|
|
key = (p["pid"], p["ctime"])
|
|
if key not in seen:
|
|
seen.add(key)
|
|
snap.append(p)
|
|
rc = None
|
|
for r in roots:
|
|
if not record_alive(r):
|
|
continue
|
|
rc = PLATFORM.tree_kill(int(r["pid"]))
|
|
break # one tree kill of the first live root; the sweep covers the rest
|
|
deadline = time.monotonic() + KILL_VERIFY_S
|
|
while True:
|
|
table = {pid: name for pid, _, name in process_table()} # one table per sweep
|
|
survivors = [p for p in snap if snapshot_survivor(p, table)]
|
|
if not survivors or time.monotonic() >= deadline:
|
|
break
|
|
for p in survivors:
|
|
terminate(p)
|
|
time.sleep(0.1)
|
|
return {"snapshot": snap, "tree_kill_rc": rc, "survivors": survivors}
|
|
|
|
|
|
def snapshot_survivor(p: dict, table: dict) -> bool:
|
|
"""Is snapshot entry `p` ({pid, ctime, name}) still alive? (C16 as narrowed by A2.)
|
|
`table` is {pid: name} from one `process_table()`. The pid is dead when the table is
|
|
non-empty and lacks it, or when its snapshot name is known, the table now gives the pid another
|
|
name and its creation time cannot be read (`proc_ctime` None): pid reuse by a process that
|
|
refuses `OpenProcess`, which `pid_alive` reports alive (access denied) without being able to
|
|
compare creation times (class (a)). In every other case `record_alive` decides."""
|
|
pid = int(p["pid"])
|
|
if table and pid not in table:
|
|
return False
|
|
name = p.get("name")
|
|
if name is not None and pid in table and table[pid] != name and proc_ctime(pid) is None:
|
|
return False
|
|
return record_alive(p)
|
|
|
|
|
|
def _run(args, cwd=None, timeout=60, env=None) -> subprocess.CompletedProcess:
|
|
"""Run a short helper process: stdout captured (never leaks to ours), no window."""
|
|
return subprocess.run(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
|
timeout=timeout, **PLATFORM.popen_kwargs())
|
|
|
|
|
|
def git(args, cwd=None, timeout=60) -> subprocess.CompletedProcess:
|
|
"""Every helper git call goes through here: `git --no-optional-locks …` (C7)."""
|
|
return _run(["git", "--no-optional-locks", *args], cwd=cwd, timeout=timeout)
|
|
|
|
|
|
def git_toplevel(cwd: str | None = None) -> str | None:
|
|
try:
|
|
r = git(["rev-parse", "--show-toplevel"], cwd=cwd)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if r.returncode != 0:
|
|
return None
|
|
out = r.stdout.decode("utf-8", "replace").strip()
|
|
return norm_path(out) if out else None
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Env scrub
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def scrub_class(name: str) -> str | None:
|
|
"""'drop', 'pinned', 'keep' or None (not on any list)."""
|
|
n = name.upper()
|
|
if n in SCRUB_DROP or any(p.match(n) for p in SCRUB_DROP_PATTERNS):
|
|
return "drop"
|
|
if n in SCRUB_PINNED or n == SCRUB_PINNED_BINARY:
|
|
return "pinned"
|
|
if n in SCRUB_KEEP:
|
|
return "keep"
|
|
return None
|
|
|
|
|
|
def _venv_prefixes(environ) -> list[str]:
|
|
out = []
|
|
venv = environ.get("VIRTUAL_ENV")
|
|
if venv:
|
|
out.append(venv)
|
|
if os.path.normcase(os.path.abspath(sys.prefix)) != os.path.normcase(os.path.abspath(sys.base_prefix)):
|
|
out.append(sys.prefix)
|
|
return [os.path.normcase(os.path.normpath(os.path.abspath(x))) for x in out]
|
|
|
|
|
|
def _under(path: str, prefixes) -> bool:
|
|
try:
|
|
p = os.path.normcase(os.path.normpath(os.path.abspath(path)))
|
|
except ValueError:
|
|
return False
|
|
return any(p == px or p.startswith(px.rstrip("\\/") + os.sep) for px in prefixes)
|
|
|
|
|
|
def scrubbed_env(binary: str, environ=None) -> dict:
|
|
"""The child env for `claude`: host env minus the drop list, venv PATH entries removed,
|
|
pinned vars on top, `CLAUDE_CODE_EXECPATH` = the stored absolute binary path."""
|
|
if environ is None:
|
|
environ = os.environ
|
|
prefixes = _venv_prefixes(environ)
|
|
env = {}
|
|
for k, v in environ.items():
|
|
cls = scrub_class(k)
|
|
if cls in ("drop", "pinned"):
|
|
continue
|
|
if k.upper() == "PATH":
|
|
v = os.pathsep.join(e for e in v.split(os.pathsep) if e and not _under(e, prefixes))
|
|
env[k] = v
|
|
env.update(SCRUB_PINNED)
|
|
env[SCRUB_PINNED_BINARY] = binary
|
|
return env
|
|
|
|
|
|
def scrub_unknown(environ=None) -> list[str]:
|
|
"""Host CLAUDE_* / ANTHROPIC_* vars on neither list (kept, but worth a look)."""
|
|
if environ is None:
|
|
environ = os.environ
|
|
return sorted(k for k in environ
|
|
if k.upper().startswith(SCRUB_WATCH_PREFIXES) and scrub_class(k) is None)
|
|
|
|
|
|
def scrub_report(environ=None) -> dict:
|
|
return {
|
|
"drop": sorted(SCRUB_DROP),
|
|
"drop_patterns": [p.pattern for p in SCRUB_DROP_PATTERNS],
|
|
"drop_path_entries": "under VIRTUAL_ENV, or under sys.prefix when it differs from sys.base_prefix",
|
|
"keep": sorted(SCRUB_KEEP),
|
|
"keep_rule": "everything not dropped is kept",
|
|
"pinned": dict(SCRUB_PINNED, **{SCRUB_PINNED_BINARY: "<stored binary path>"}),
|
|
"unknown": scrub_unknown(environ),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Binary, uv, python
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def resolve_binary(explicit: str | None = None, environ=None) -> tuple[str | None, str | None]:
|
|
"""(absolute path, how): `--binary`, else $CLAUDE_CODE_EXECPATH, else PATH `claude`."""
|
|
if environ is None:
|
|
environ = os.environ
|
|
if explicit:
|
|
return norm_path(explicit), "--binary"
|
|
for how in BINARY_SOURCES:
|
|
path = resolve_binary_via(how, environ)
|
|
if path:
|
|
return path, how
|
|
return None, None
|
|
|
|
|
|
BINARY_SOURCES = ("CLAUDE_CODE_EXECPATH", "PATH") # the lookup order without --binary
|
|
|
|
|
|
def resolve_binary_via(how: str, environ=None) -> str | None:
|
|
"""The binary through one source only (N3, A4): `CLAUDE_CODE_EXECPATH` or a `PATH` lookup."""
|
|
if environ is None:
|
|
environ = os.environ
|
|
if how == "CLAUDE_CODE_EXECPATH":
|
|
ep = environ.get("CLAUDE_CODE_EXECPATH")
|
|
return norm_path(ep) if ep else None
|
|
if how == "PATH":
|
|
w = shutil.which("claude")
|
|
return norm_path(w) if w else None
|
|
return None
|
|
|
|
|
|
def _binary_version(path: str) -> str | None:
|
|
try:
|
|
r = _run([path, "--version"], timeout=60)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
toks = r.stdout.decode("utf-8", "replace").split()
|
|
return toks[0] if r.returncode == 0 and toks else None
|
|
|
|
|
|
def binary_info(path: str) -> dict:
|
|
"""{"path", "version", "size", "mtime"} of an existing binary; raises GremlinsError otherwise."""
|
|
if not path or not os.path.isfile(path):
|
|
raise GremlinsError("claude binary not found", binary=path)
|
|
st = os.stat(path)
|
|
version = _binary_version(path)
|
|
if not version:
|
|
raise GremlinsError("claude binary did not report a version", binary=path)
|
|
return {"path": path, "version": version, "size": st.st_size, "mtime": st.st_mtime}
|
|
|
|
|
|
def _uv_report(environ=None) -> dict:
|
|
if environ is None:
|
|
environ = os.environ
|
|
out = {"path": None, "version": None}
|
|
w = shutil.which("uv")
|
|
if w:
|
|
out["path"] = norm_path(w)
|
|
try:
|
|
r = _run([w, "--version"], timeout=30)
|
|
toks = r.stdout.decode("utf-8", "replace").split()
|
|
out["version"] = toks[1] if len(toks) > 1 else (toks[0] if toks else None)
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
out["error"] = str(e)
|
|
if environ.get("SCRIPTS_UV"):
|
|
out["SCRIPTS_UV"] = environ["SCRIPTS_UV"]
|
|
return out
|
|
|
|
|
|
def _git_report() -> dict:
|
|
out = {"path": None, "version": None}
|
|
w = shutil.which("git")
|
|
if w:
|
|
out["path"] = norm_path(w)
|
|
try:
|
|
r = git(["--version"], timeout=30)
|
|
toks = r.stdout.decode("utf-8", "replace").split()
|
|
out["version"] = toks[2] if len(toks) > 2 else None
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
out["error"] = str(e)
|
|
return out
|
|
|
|
|
|
def script_path() -> str:
|
|
return norm_path(os.path.abspath(__file__))
|
|
|
|
|
|
def preamble_path() -> str:
|
|
return norm_path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "preamble.md"))
|
|
|
|
|
|
def supervisor_interpreter() -> str:
|
|
"""C6: the base interpreter when `sys.executable` is a venv launcher."""
|
|
base = getattr(sys, "_base_executable", None)
|
|
return base if base and base != sys.executable else sys.executable
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# C8: availableModels admission
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def user_settings_path() -> str:
|
|
if USER_SETTINGS_PATH:
|
|
return USER_SETTINGS_PATH
|
|
return os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
|
|
|
|
def settings_files(cwd: str) -> list[str]:
|
|
return [user_settings_path(),
|
|
os.path.join(cwd, ".claude", "settings.json"),
|
|
os.path.join(cwd, ".claude", "settings.local.json")]
|
|
|
|
|
|
def model_family(model: str) -> str | None:
|
|
toks = model.replace(ONE_M_SUFFIX, "").split("-")
|
|
return toks[1] if len(toks) > 1 else None
|
|
|
|
|
|
def model_admission(model: str, cwd: str) -> dict:
|
|
"""Apply the P0.5 rule. Returns {"admitted", "lists": [{"file", "availableModels"}], "via"}."""
|
|
lists = []
|
|
for f in settings_files(cwd):
|
|
try:
|
|
data = read_json(f)
|
|
except GremlinsError as e:
|
|
_diag("ignoring unreadable settings file %s (%s)" % (f, e.payload.get("detail")))
|
|
continue
|
|
except OSError:
|
|
continue
|
|
if isinstance(data, dict) and isinstance(data.get("availableModels"), list):
|
|
lists.append({"file": f, "availableModels": [str(x) for x in data["availableModels"]]})
|
|
if not lists:
|
|
return {"admitted": True, "lists": [], "via": "no availableModels list"}
|
|
candidates = [("exact id", model)]
|
|
if model.endswith(ONE_M_SUFFIX):
|
|
candidates.append(("id without [1m]", model[: -len(ONE_M_SUFFIX)]))
|
|
fam = model_family(model)
|
|
if fam:
|
|
candidates.append(("family alias", fam))
|
|
for via, cand in candidates:
|
|
for entry in lists:
|
|
if cand in entry["availableModels"]:
|
|
return {"admitted": True, "lists": lists, "via": "%s %r in %s" % (via, cand, entry["file"])}
|
|
return {"admitted": False, "lists": lists, "via": None}
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Stream parsing, usage rule (P0.3), cache verdict (C5), warmth (C1)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
class StreamState:
|
|
"""What one `claude -p --output-format stream-json --verbose` stream told us so far."""
|
|
|
|
def __init__(self):
|
|
self.init = None
|
|
self.session_id = None
|
|
self.msg_ids: list = []
|
|
self._ids: set = set()
|
|
self.first_usage = None # usage of the first assistant event (cache verdict)
|
|
self.first_at = None # its arrival time (last_hit)
|
|
self.last_usage = None # usage of the last assistant event (base context)
|
|
self.last_assistant_at = None
|
|
self.last_event_at = None
|
|
self.result = None
|
|
self.refused = None # content of a model-restricted warning
|
|
self.events = 0
|
|
|
|
def feed(self, ev: dict, t: float | None) -> None:
|
|
if not isinstance(ev, dict):
|
|
return
|
|
self.events += 1
|
|
if t is not None:
|
|
self.last_event_at = t
|
|
ty = ev.get("type")
|
|
if ty == "system":
|
|
st = ev.get("subtype")
|
|
if st == "init" and self.init is None:
|
|
self.init = ev
|
|
self.session_id = ev.get("session_id") or self.session_id
|
|
elif (st == "informational" and ev.get("level") == "warning"
|
|
and "is restricted" in str(ev.get("content", ""))):
|
|
self.refused = str(ev.get("content"))
|
|
elif ty == "assistant":
|
|
m = ev.get("message") or {}
|
|
mid = m.get("id")
|
|
if mid not in self._ids:
|
|
self._ids.add(mid)
|
|
self.msg_ids.append(mid)
|
|
u = m.get("usage") or {}
|
|
if self.first_usage is None:
|
|
self.first_usage, self.first_at = u, t
|
|
self.last_usage, self.last_assistant_at = u, t
|
|
elif ty == "result":
|
|
self.result = ev
|
|
self.session_id = self.session_id or ev.get("session_id")
|
|
|
|
@property
|
|
def turns(self) -> int:
|
|
return len(self.msg_ids)
|
|
|
|
@classmethod
|
|
def from_file(cls, path: str) -> "StreamState":
|
|
"""Re-parse a raw stream file (event times unknown)."""
|
|
ss = cls()
|
|
try:
|
|
data = read_bytes_retry(path)
|
|
except OSError:
|
|
return ss
|
|
for line in data.splitlines():
|
|
try:
|
|
ss.feed(json.loads(line.decode("utf-8", "replace")), None)
|
|
except ValueError:
|
|
continue
|
|
return ss
|
|
|
|
|
|
def context_tokens(usage: dict | None) -> int:
|
|
"""Prompt-side tokens of one request: input + cache creation + cache read."""
|
|
if not usage:
|
|
return 0
|
|
return int(usage.get("input_tokens") or 0) + int(usage.get("cache_creation_input_tokens") or 0) \
|
|
+ int(usage.get("cache_read_input_tokens") or 0)
|
|
|
|
|
|
def plugin_id(p: dict) -> str:
|
|
"""`name@version`; a plugin without a version is identified by its path's last segment (M6),
|
|
so an in-place update of a versionless plugin (a new cache dir) shows as drift."""
|
|
ver = p.get("version")
|
|
if ver:
|
|
return "%s@%s" % (p.get("name"), ver)
|
|
path = str(p.get("path") or "").rstrip("/\\")
|
|
seg = re.split(r"[/\\]", path)[-1] if path else ""
|
|
return "%s@path:%s" % (p.get("name"), seg) if seg else "%s@" % p.get("name")
|
|
|
|
|
|
def _names(items) -> list[str]:
|
|
return sorted(str(a) if not isinstance(a, dict) else str(a.get("name")) for a in (items or []))
|
|
|
|
|
|
def runtime_fingerprint(init: dict | None) -> dict | None:
|
|
"""The runtime fields of a `system/init` event that must match the base's (RUNTIME_FIELDS)."""
|
|
if not init:
|
|
return None
|
|
tools = sorted(str(x) for x in (init.get("tools") or []))
|
|
plugins = sorted(plugin_id(p) for p in (init.get("plugins") or []) if isinstance(p, dict))
|
|
return {"tools": _sha256_bytes(canonical_json(tools).encode("utf-8")),
|
|
"permissionMode": init.get("permissionMode"), "plugins": plugins,
|
|
"agents": _names(init.get("agents")), "apiKeySource": init.get("apiKeySource"),
|
|
"skills": _names(init.get("skills"))}
|
|
|
|
|
|
def runtime_drift(rt: dict | None, ref: dict | None, launch: dict) -> list[str]:
|
|
"""Runtime fields of `rt` that differ from the base's `ref`. A field the reference does not
|
|
carry (a base recorded before that field was fingerprinted) is not compared."""
|
|
if not rt:
|
|
return []
|
|
out = []
|
|
if ref:
|
|
out += [k for k in sorted(rt) if k in ref and rt.get(k) != ref.get(k)]
|
|
elif rt.get("permissionMode") not in (None, launch.get("permission_mode")):
|
|
out.append("permissionMode")
|
|
return out
|
|
|
|
|
|
def usage_from_result(result: dict, before_mu: dict, cost_before: float) -> dict:
|
|
"""P0.3: the four columns and the 1h/5m split from `result.usage`; `models_used` = modelUsage
|
|
keys that grew against the session total before the call; cost delta against the session's
|
|
total before the call; `mismatch` when `result.usage` differs from the modelUsage delta."""
|
|
usage = result.get("usage") or {}
|
|
cc = usage.get("cache_creation") or {}
|
|
tokens = {
|
|
"input": int(usage.get("input_tokens") or 0),
|
|
"output": int(usage.get("output_tokens") or 0),
|
|
"cache_creation": int(usage.get("cache_creation_input_tokens") or 0),
|
|
"cache_creation_1h": int(cc.get("ephemeral_1h_input_tokens") or 0),
|
|
"cache_creation_5m": int(cc.get("ephemeral_5m_input_tokens") or 0),
|
|
"cache_read": int(usage.get("cache_read_input_tokens") or 0),
|
|
}
|
|
after = result.get("modelUsage") or {}
|
|
before_mu = before_mu or {}
|
|
delta = {}
|
|
for m, v in after.items():
|
|
b = before_mu.get(m) or {}
|
|
delta[m] = {f: int((v or {}).get(f) or 0) - int(b.get(f) or 0) for f in MU_FIELDS}
|
|
models_used = sorted(m for m, d in delta.items() if any(x > 0 for x in d.values()))
|
|
sums = {f: sum(d[f] for d in delta.values()) for f in MU_FIELDS}
|
|
mismatch = (sums["inputTokens"], sums["outputTokens"], sums["cacheCreationInputTokens"],
|
|
sums["cacheReadInputTokens"]) != (tokens["input"], tokens["output"],
|
|
tokens["cache_creation"], tokens["cache_read"])
|
|
total = result.get("total_cost_usd")
|
|
cost_delta = None if total is None else round(float(total) - float(cost_before or 0.0), 6)
|
|
return {"tokens": tokens, "models_used": models_used, "mismatch": mismatch,
|
|
"cost_delta": cost_delta, "total": total, "model_usage": after}
|
|
|
|
|
|
_API_ERROR_TEXT = ( # checked in this order, after the status code
|
|
("prompt_too_long", re.compile(r"prompt is too long|prompt too long|prompt_too_long", re.I)),
|
|
("usage_limit", re.compile(r"usage limit|rate[ _-]?limit|hit your limit|api error:?\s*429", re.I)),
|
|
("overloaded", re.compile(r"overloaded|api error:?\s*529", re.I)),
|
|
("api_error", re.compile(r"api error", re.I)),
|
|
)
|
|
|
|
|
|
def api_error_reason(res: dict | None) -> tuple[str, str] | None:
|
|
"""(reason, detail) for a result that carries an API error, else None (I1). `reason` is
|
|
`usage_limit` (429 / rate or usage limit), `overloaded` (529), `prompt_too_long`, or
|
|
`api_error` (any other API error status); `detail` holds the status and a short text."""
|
|
if not res:
|
|
return None
|
|
status = res.get("api_error_status")
|
|
try:
|
|
status = int(status) if status not in (None, "") else None
|
|
except (TypeError, ValueError):
|
|
status = None
|
|
if not res.get("is_error") and status is None:
|
|
return None
|
|
parts = [res.get("result"), res.get("error"), res.get("errors")]
|
|
text = " | ".join(p if isinstance(p, str) else json.dumps(p) for p in parts if p)
|
|
detail = "api_error_status %s: %s" % (status, text[:200] if text else "(no text)")
|
|
if status == 429:
|
|
return "usage_limit", detail
|
|
if status == 529:
|
|
return "overloaded", detail
|
|
for reason, rx in _API_ERROR_TEXT:
|
|
if rx.search(text):
|
|
return reason, detail
|
|
if status is not None:
|
|
return "api_error", detail
|
|
return None
|
|
|
|
|
|
def cache_verdict(first_usage: dict | None, base_context) -> str | None:
|
|
"""C5: first assistant event's cache_read against the base's context tokens."""
|
|
if first_usage is None:
|
|
return None
|
|
cr = int(first_usage.get("cache_read_input_tokens") or 0)
|
|
if base_context and cr >= CACHE_HIT_RATIO * float(base_context):
|
|
return "hit"
|
|
return "partial" if cr > 0 else "miss"
|
|
|
|
|
|
def infer_ttl(tokens: dict | None, prev, default=None):
|
|
"""Design 5: the TTL a base's cache entries live for, from its most recent call's tokens.
|
|
1h written -> 3600; only 5m written -> 300; otherwise (read only, or nothing) the previous
|
|
known TTL, and with none known `default` (the configured ttl_s)."""
|
|
if not tokens:
|
|
return prev if prev is not None else default
|
|
if int(tokens.get("cache_creation_1h") or 0) > 0:
|
|
return TTL_1H
|
|
if int(tokens.get("cache_creation_5m") or 0) > 0:
|
|
return TTL_5M
|
|
return prev if prev is not None else default
|
|
|
|
|
|
def wrote_only_5m(tokens: dict | None) -> bool:
|
|
"""`ttl_downgrade`: the call wrote 5-minute cache entries and no 1-hour ones."""
|
|
return bool(tokens) and int(tokens.get("cache_creation_5m") or 0) > 0 \
|
|
and int(tokens.get("cache_creation_1h") or 0) == 0
|
|
|
|
|
|
def break_even(ttl) -> int:
|
|
"""floor((write - read) / read): pings pay while fewer are needed (19 at 1 h, 11 at 5 min)."""
|
|
write = PRICE_RATIOS["write_1h"] if float(ttl) > TTL_5M else PRICE_RATIOS["write_5m"]
|
|
return (write - PRICE_RATIOS["read"]) // PRICE_RATIOS["read"]
|
|
|
|
|
|
def base_warmth(base: dict, cfg: dict, now: float | None = None) -> dict:
|
|
"""Design 5: ttl = the base's inferred TTL, else the configured ttl_s; warm_until = last_hit +
|
|
ttl; margin = init's warm_margin_s if set, capped at ttl/2 (Minor 2: a margin tuned for 1 h
|
|
must not make a 5-minute base `expiring` right after every hit), else max(120, ttl/10)."""
|
|
ttl = float((base or {}).get("ttl_s") or cfg.get("ttl_s") or CONFIG_DEFAULTS["ttl_s"])
|
|
wm = cfg.get("warm_margin_s")
|
|
margin = min(float(wm), ttl / 2.0) if wm is not None else max(120.0, ttl / 10.0)
|
|
out = {"ttl_s": int(ttl) if ttl.is_integer() else ttl,
|
|
"margin_s": int(margin) if margin.is_integer() else margin}
|
|
last = _ts((base or {}).get("last_hit"))
|
|
if last is None:
|
|
return {"base_state": "cold", "warm_until": None, **out}
|
|
now = _now() if now is None else now
|
|
wu = last + ttl
|
|
state = "warm" if now < wu - margin else ("expiring" if now < wu else "cold")
|
|
return {"base_state": state, "warm_until": _iso(wu), **out}
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Jobs: paths, templates, spawning, finalising (C3b, C3c)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def job_file(state: str, job: str) -> str:
|
|
return os.path.join(state, "jobs", job + ".json")
|
|
|
|
|
|
def result_file(state: str, job: str) -> str:
|
|
return os.path.join(state, "results", job + ".json")
|
|
|
|
|
|
def raw_file(state: str, job: str, ext: str = ".jsonl") -> str:
|
|
return os.path.join(state, "raw", job + ext)
|
|
|
|
|
|
def _safe_name(s: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9._-]", "_", str(s))
|
|
|
|
|
|
def note_rel(block) -> str | None:
|
|
return None if block in (None, "") else "notes/%s.md" % _safe_name(block)
|
|
|
|
|
|
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 gremlin a continue resumes) does not count."""
|
|
try:
|
|
mtime = os.path.getmtime(path)
|
|
except OSError:
|
|
return False
|
|
start = entry.get("created_ts")
|
|
if start is None:
|
|
start = _ts(entry.get("created_at"))
|
|
return start is None or mtime >= float(start) - NOTE_MTIME_SLACK_S
|
|
|
|
|
|
def _phase(v):
|
|
if v is None:
|
|
return None
|
|
return int(v) if re.fullmatch(r"\d+", str(v)) else str(v)
|
|
|
|
|
|
def render_template(name: str, values: dict) -> str:
|
|
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), TEMPLATES_DIR, name)
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
text = f.read()
|
|
return string.Template(text).safe_substitute(values)
|
|
|
|
|
|
def detach_supervisor(state: str, run: str, job: str) -> dict:
|
|
"""Start `_supervise` detached (C6) through `PLATFORM.detach`, in the state dir.
|
|
Returns {"ok", "pid", "ctime", "method"} or {"ok": False, "method", "detail"}."""
|
|
argv = [supervisor_interpreter(), "-I", script_path(), "_supervise", "--run", run, "--job", job]
|
|
return PLATFORM.detach(argv, cwd=state, log_path=raw_file(state, job, ".sup.log"))
|
|
|
|
|
|
def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint: str, flags, reason,
|
|
detail, exit_code, wall_s, base_commit, now: float):
|
|
"""Build the result, the ledger line, and apply the registry updates for one finished job.
|
|
`hint`: "exited" (decide ok/failed from the stream), or a final status given by the caller."""
|
|
cfg = reg["config"]
|
|
kind = entry["kind"]
|
|
base = reg["bases"].get(entry.get("base")) if entry.get("base") else None
|
|
launch = launch_record(base, cfg)
|
|
res = ss.result if ss else None
|
|
u = usage_from_result(res, entry.get("model_usage_before") or {}, entry.get("cost_before") or 0.0) \
|
|
if res else None
|
|
flags = list(flags or [])
|
|
models_used = u["models_used"] if u else []
|
|
if hint == "exited":
|
|
requested = entry.get("model_requested")
|
|
# Refused = the restriction warning, or a substitution: some other model billed and the
|
|
# requested one did not. An empty delta (an error before the model answered) is not one.
|
|
substituted = bool(models_used) and requested not in models_used
|
|
api = api_error_reason(res)
|
|
if ss and (ss.refused or (res and substituted)):
|
|
status, reason = "failed", "model_refused"
|
|
detail = ss.refused or "models_used %s, requested %s" % (models_used, requested)
|
|
elif res is None:
|
|
status = "failed"
|
|
reason = "exit_code" if exit_code else "no_result"
|
|
detail = "exit %s, no result event" % exit_code
|
|
elif api:
|
|
status, (reason, detail) = "failed", api
|
|
elif res.get("is_error"):
|
|
status, reason, detail = "failed", "is_error", str(res.get("subtype"))
|
|
elif exit_code:
|
|
status, reason, detail = "failed", "exit_code", "exit %s" % exit_code
|
|
else:
|
|
status, reason, detail = "ok", None, None
|
|
kr = entry.get("kill_requested")
|
|
if kr and status != "ok":
|
|
status, reason, detail = "killed", kr.get("by"), "kill requested"
|
|
if kr.get("by") == "expire":
|
|
flags.append("expired")
|
|
else:
|
|
status = hint
|
|
if u and u["mismatch"]:
|
|
flags.append("usage_mismatch")
|
|
denials = len((res or {}).get("permission_denials") or [])
|
|
if denials:
|
|
flags.append("needs_attention")
|
|
cache = cache_verdict(ss.first_usage if ss else None, entry.get("base_context")) \
|
|
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
|
|
rt_drift = runtime_drift(rt, ref, launch)
|
|
drift += rt_drift
|
|
drift = sorted(set(drift))
|
|
fingerprint = "ok" if not drift else "drift:" + ",".join(drift)
|
|
base_state = entry.get("base_state_at_launch")
|
|
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"]):
|
|
flags.append("ttl_downgrade")
|
|
# A ping refreshes the base unless it missed (P0.6: a `partial` ping still kept the base warm;
|
|
# it re-created about the shared system prefix). A miss or a failed ping leaves last_hit alone.
|
|
ping_refreshed = (kind == "ping" and status == "ok" and cache in ("hit", "partial")
|
|
and bool(ss and ss.first_at))
|
|
if kind == "ping" and not ping_refreshed:
|
|
flags.append("ping_missed")
|
|
# N4 (C15): a rebase that ends ok after its base stopped being active (a `train --supersedes`
|
|
# ended first, or an `expire` raced the kill) still creates its new base, with the old base's
|
|
# status. Flagged only when the new base is actually created (not on a `rebase_conflict`).
|
|
if (kind == "rebase" and status == "ok" and base is not None and base.get("status") != "active"
|
|
and _rebase_target_free(reg, entry)):
|
|
flags.append("parent_inactive")
|
|
flags = _uniq(flags)
|
|
turns = int(res.get("num_turns") or 0) if res else (ss.turns if ss else 0)
|
|
note = note_rel(entry.get("block")) if kind != "train" else None
|
|
if note and not note_written_by(os.path.join(state, note), entry):
|
|
note = None
|
|
session = (ss.session_id if ss and ss.session_id else None) or entry.get("session")
|
|
tokens = u["tokens"] if u else {k: None for k in TOKEN_KEYS}
|
|
ended = _iso(now)
|
|
result = {
|
|
"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),
|
|
"turns": turns, "denials": denials, "note": note, "raw": "raw/%s.jsonl" % entry["job"],
|
|
"paths": entry.get("paths"), "reason": reason, "model_requested": entry.get("model_requested"),
|
|
}
|
|
ledger = {
|
|
"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,
|
|
"cost_delta_usd": result["cost_delta_usd"], "cost_cumulative_usd": u["total"] if u else None,
|
|
"turns": turns, "cache": cache, "base_state": base_state, "fingerprint": fingerprint,
|
|
"denials": denials, "status": status, "flags": flags, "reason": reason, "note": note,
|
|
}
|
|
# registry updates
|
|
entry.update({"status": status, "flags": flags, "reason": reason, "reason_detail": detail,
|
|
"ended_at": ended, "session": session, "turns": turns, "exit_code": exit_code,
|
|
"fingerprint": fingerprint, "cache": cache,
|
|
# runtime fields that drifted, None when the call reported no init event:
|
|
# `rebase_due: runtime_drift:<fields>` reads the base's last call with one.
|
|
"runtime_drift": rt_drift if rt else None})
|
|
if ss and ss.first_at:
|
|
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 == "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 spawn's first
|
|
# request, a ping that did not miss (and the training's end, which writes it). A continue
|
|
# 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 ("spawn", "rebase"): # a rebase's first request reads the base prefix
|
|
hit_t = ss.first_at
|
|
elif ping_refreshed:
|
|
hit_t = ss.first_at
|
|
if hit_t and (_ts(base.get("last_hit")) or 0) < hit_t:
|
|
base["last_hit"] = _iso(hit_t)
|
|
if u: # design 5: TTL from the most recent call on this base (any kind)
|
|
base["ttl_s"] = infer_ttl(u["tokens"], base.get("ttl_s"))
|
|
base["ttl_from"] = entry["job"]
|
|
if kind == "ping" and base.get("ping_in_flight") == entry["job"]:
|
|
base["ping_in_flight"] = None # C3(d): cleared when the ping finishes, any status
|
|
if kind == "train" and base.get("train_job") == entry["job"]:
|
|
if status == "ok" and base.get("status") == "training":
|
|
base.update({"status": "active", "session": session, "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})
|
|
# Minor 1 (`train --supersedes`): the bases this one replaces stop being active,
|
|
# 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"):
|
|
ob.update({"status": "superseded", "superseded_by": base.get("name"),
|
|
"superseded_at": ended})
|
|
elif base.get("status") == "training":
|
|
base["status"] = "failed"
|
|
if kind == "rebase" and status == "ok":
|
|
_rebase_succeeded(reg, entry, base, ss, u, rt, base_commit, ended)
|
|
return result, ledger
|
|
|
|
|
|
def _rebase_target_free(reg: dict, entry: dict) -> bool:
|
|
"""True if a successful rebase `entry` may create its new base: it names one, and no base of
|
|
that name exists other than a failed one (which it replaces)."""
|
|
name = entry.get("rebase_as")
|
|
if not name:
|
|
return False
|
|
existing = reg["bases"].get(name)
|
|
return not (existing and existing.get("status") != "failed")
|
|
|
|
|
|
def _rebase_succeeded(reg: dict, entry: dict, old: dict, ss, u, rt, base_commit, ended: str) -> None:
|
|
"""A rebase ended `ok`: its session becomes base `entry.rebase_as` (active, parent = the old
|
|
base, one more rebase in the lineage, HEAD at the end, context from its last assistant
|
|
message, the launch record computed at launch: the old base's, with the binary it ran
|
|
under); the old base becomes `superseded`. On any other status nothing here runs: the old
|
|
base stays active and the new one is never created.
|
|
N4 (C15): if the old base is no longer active when the rebase ends (superseded by a training
|
|
that ended first, or retired by an `expire` that raced the kill), the new base takes the old
|
|
base's status (`superseded` by the same base, or `retired`, as of this end time) and the old
|
|
base is left untouched; the job records `rebase_inactive_parent`."""
|
|
name = entry.get("rebase_as")
|
|
if not name:
|
|
return
|
|
if not _rebase_target_free(reg, entry):
|
|
existing = reg["bases"][name]
|
|
entry["rebase_conflict"] = "base %s exists (%s); not replaced" % (name, existing.get("status"))
|
|
return
|
|
launch = entry.get("new_launch") or launch_record(old, reg["config"])
|
|
old_status = old.get("status")
|
|
reg["bases"][name] = {
|
|
"name": name, "status": "active", "session": entry.get("session"), "cwd": launch["cwd"],
|
|
"launch": launch, "fingerprint": fingerprint_hash(launch),
|
|
"overrides": list(old.get("overrides") or []), "binary": launch["binary"],
|
|
"created_at": entry.get("created_at"), "train_job": None, "rebase_job": entry["job"],
|
|
"parent": old.get("name") or entry.get("base"),
|
|
"lineage_rebases": int(old.get("lineage_rebases") or 0) + 1,
|
|
"phase": entry.get("phase"), "last_hit": _iso(ss.last_assistant_at) if ss and ss.last_assistant_at else None,
|
|
"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, "gremlins": [],
|
|
"ttl_s": infer_ttl(u["tokens"] if u else None, old.get("ttl_s")), "ttl_from": entry["job"],
|
|
"ping_in_flight": None,
|
|
}
|
|
if old_status != "active":
|
|
new = reg["bases"][name]
|
|
new["status"] = old_status
|
|
if old_status == "superseded":
|
|
new.update({"superseded_by": old.get("superseded_by"), "superseded_at": ended})
|
|
elif old_status == "retired":
|
|
new["retired_at"] = ended
|
|
entry["rebase_inactive_parent"] = "%s was %s when the rebase ended" % (
|
|
old.get("name") or entry.get("base"), old_status)
|
|
return
|
|
old["status"] = "superseded"
|
|
old["superseded_by"] = name
|
|
old["superseded_at"] = ended
|
|
|
|
|
|
def finalize_job(state: str, job: str, *, ss: StreamState | None, hint: str, flags=(), reason=None,
|
|
detail=None, exit_code=None, wall_s=None, base_commit=None,
|
|
head_at_end=None) -> dict | None:
|
|
"""Write `results/<job>.json`, the registry update and the ledger line, exactly once, under the
|
|
lock (C3c). The registry status is written first: it is the once-only guard, so a finaliser
|
|
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 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 ("spawn", "continue") and entry.get("cwd"):
|
|
head_at_end = _git_head(entry["cwd"])
|
|
if head_at_end:
|
|
entry["head_at_end"] = head_at_end
|
|
result, ledger = _conclude(state, reg, entry, ss, hint, flags, reason, detail, exit_code,
|
|
wall_s, base_commit, _now())
|
|
write_json_atomic(result_file(state, job), result)
|
|
write_registry(state, reg)
|
|
ledger_append(state, ledger)
|
|
return result
|
|
|
|
|
|
def bump_last_hit(state: str, base_name: str, t: float) -> None:
|
|
"""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)
|
|
if base is None or (_ts(base.get("last_hit")) or 0) >= t:
|
|
return
|
|
base["last_hit"] = _iso(t)
|
|
write_registry(state, reg)
|
|
|
|
|
|
def in_child() -> bool:
|
|
"""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 gremlin's own calls never stamp, so a gremlin cannot keep its own watchdog alive (M4)."""
|
|
if in_child():
|
|
return
|
|
run = reg.setdefault("run", {})
|
|
run["last_orchestrator_call_at"] = _iso(t)
|
|
if wait:
|
|
run["last_wait_at"] = run["last_orchestrator_call_at"]
|
|
|
|
|
|
def touch_orchestrator(state: str, wait: bool = False) -> None:
|
|
"""N1: record that the orchestrator called us (wait, spawn, continue, train, list)."""
|
|
if in_child():
|
|
return
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
if reg is None:
|
|
return
|
|
stamp_orchestrator(reg, wait=wait)
|
|
write_registry(state, reg)
|
|
|
|
|
|
def _job_runtime(state: str, job: str) -> dict:
|
|
try:
|
|
return read_json(job_file(state, job)) or {}
|
|
except GremlinsError:
|
|
return {}
|
|
|
|
|
|
def last_alive_at(rt: dict) -> float | None:
|
|
"""The last time a job's supervisor was known alive: the later of its job file's
|
|
`heartbeat_at` and `ended_at` (bounds a dead root's descendants, I2)."""
|
|
ts = [t for t in (_ts(rt.get("heartbeat_at")), _ts(rt.get("ended_at"))) if t is not None]
|
|
return max(ts) if ts else None
|
|
|
|
|
|
def reap_lost(state: str) -> list[str]:
|
|
"""Decision 7 / A19: a running job whose heartbeat is older than LOST_AFTER_S and whose
|
|
supervisor (pid + creation time) is dead becomes `lost`. Its claude tree, if any is left,
|
|
is killed with it."""
|
|
reg = read_registry(state)
|
|
lost = []
|
|
now = _now()
|
|
for job, e in sorted((reg or {}).get("jobs", {}).items()):
|
|
if e.get("status") not in RUNNING:
|
|
continue
|
|
rt = _job_runtime(state, job)
|
|
hb = _ts(rt.get("heartbeat_at")) or _ts(e.get("launched_at")) or _ts(e.get("created_at"))
|
|
if hb is not None and now - hb <= LOST_AFTER_S:
|
|
continue
|
|
sup = rt.get("supervisor") or e.get("supervisor")
|
|
if sup and record_alive(sup):
|
|
continue
|
|
if rt.get("claude"):
|
|
kill_tree(rt["claude"], alive_until=last_alive_at(rt))
|
|
start = _ts(rt.get("started_at")) or _ts(e.get("launched_at"))
|
|
res = finalize_job(state, job, ss=StreamState.from_file(raw_file(state, job)), hint="lost",
|
|
flags=rt.get("flags") or [], reason="supervisor_dead",
|
|
detail="heartbeat %s, supervisor %s dead" % (rt.get("heartbeat_at"), sup),
|
|
wall_s=(hb - start) if (hb and start) else None)
|
|
if res is not None:
|
|
lost.append(job)
|
|
return lost
|
|
|
|
|
|
def kill_job(state: str, job: str, by: str) -> dict:
|
|
"""`kill` (by="kill") and `expire` (by="expire"): snapshot the supervisor's tree, tree-kill it,
|
|
sweep, verify, finalise as `killed`."""
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
entry = reg["jobs"].get(job)
|
|
if entry is None:
|
|
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()}
|
|
write_registry(state, reg)
|
|
rt = _job_runtime(state, job)
|
|
sup = rt.get("supervisor") or entry.get("supervisor")
|
|
cl = rt.get("claude")
|
|
sup_alive = record_alive(sup)
|
|
k = kill_tree(sup, extra=[cl] if cl else [], alive_until=last_alive_at(rt))
|
|
flags = list(rt.get("flags") or [])
|
|
if by == "expire":
|
|
flags.append("expired")
|
|
start = _ts(rt.get("started_at")) or _ts(entry.get("launched_at"))
|
|
res = finalize_job(state, job, ss=StreamState.from_file(raw_file(state, job)), hint="killed",
|
|
flags=flags, reason=by, detail="killed by `%s`" % by,
|
|
wall_s=(_now() - start) if start else None)
|
|
if res is not None and not sup_alive and rt:
|
|
rt.update({"status": "killed", "ended_at": _iso()})
|
|
with contextlib.suppress(OSError):
|
|
write_json_atomic(job_file(state, job), rt)
|
|
final = read_registry(state)["jobs"][job].get("status")
|
|
return {"job": job, "killed": True, "status": final, "ok": not k["survivors"],
|
|
"snapshot": k["snapshot"], "survivors": k["survivors"], "tree_kill_rc": k["tree_kill_rc"]}
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Argument parsing (argparse that never prints to stdout or exits on its own)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
class _Parser(argparse.ArgumentParser):
|
|
def error(self, message):
|
|
raise GremlinsError("usage", detail=message, usage=self.format_usage().strip())
|
|
|
|
def print_help(self, file=None):
|
|
raise _HelpRequested(self.format_help())
|
|
|
|
def exit(self, status=0, message=None):
|
|
if message:
|
|
_diag(message.strip())
|
|
raise GremlinsError("usage", detail=(message or "").strip() or "argument parser exit")
|
|
|
|
|
|
def _split_top_level_commas(s: str) -> list[str]:
|
|
"""Split on commas outside parentheses: `Bash(a,b),Read` -> [`Bash(a,b)`, `Read`]."""
|
|
out, depth, cur = [], 0, []
|
|
for ch in s:
|
|
if ch == "(":
|
|
depth += 1
|
|
elif ch == ")":
|
|
depth = max(0, depth - 1)
|
|
if ch == "," and depth == 0:
|
|
out.append("".join(cur).strip())
|
|
cur = []
|
|
else:
|
|
cur.append(ch)
|
|
out.append("".join(cur).strip())
|
|
return [x for x in out if x]
|
|
|
|
|
|
def _tool_list(values) -> list[str] | None:
|
|
if values is None:
|
|
return None
|
|
out = []
|
|
for v in values:
|
|
out.extend(_split_top_level_commas(v))
|
|
return out
|
|
|
|
|
|
def _pos_int(s: str) -> int:
|
|
try:
|
|
v = int(s)
|
|
except ValueError:
|
|
raise argparse.ArgumentTypeError("not an integer: %r" % s)
|
|
if v <= 0:
|
|
raise argparse.ArgumentTypeError("must be > 0: %r" % s)
|
|
return v
|
|
|
|
|
|
def _pos_float(s: str) -> float:
|
|
try:
|
|
v = float(s)
|
|
except ValueError:
|
|
raise argparse.ArgumentTypeError("not a number: %r" % s)
|
|
if not v > 0:
|
|
raise argparse.ArgumentTypeError("must be > 0: %r" % s)
|
|
return v
|
|
|
|
|
|
COMMANDS = ("where", "init", "train", "spawn", "continue", "wait", "list", "expire", "kill",
|
|
"rebase", "usage", "tick", "_supervise")
|
|
|
|
|
|
def build_parser() -> _Parser:
|
|
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
|
|
|
|
def cmd(name, help_text):
|
|
sp = (sub.add_parser(name, help=help_text, description=help_text) if help_text
|
|
else sub.add_parser(name))
|
|
if name != "where":
|
|
sp.add_argument("--run", required=True, help="path of the run log .md")
|
|
return sp
|
|
|
|
w = cmd("where", "Preflight report: script, python, uv, binary, git, env scrub, platform.")
|
|
w.add_argument("--binary", help="claude binary to report instead of the default resolution")
|
|
|
|
# Minor 8: every init flag defaults to None. On the first init an omitted flag takes its
|
|
# default; on a re-run it keeps the current config value (build_init_config).
|
|
i = cmd("init", "Create the state dir and the launch config (idempotent). On a re-run, omitted "
|
|
"flags keep their current values.")
|
|
i.add_argument("--model", help="full model id, e.g. claude-opus-5-5[1m] (required on the first init)")
|
|
i.add_argument("--effort", choices=EFFORTS, help="default high")
|
|
i.add_argument("--permission-mode", help="default dontAsk")
|
|
i.add_argument("--tools", help="comma-separated tool names (default %s)" % DEFAULT_TOOLS)
|
|
i.add_argument("--allowed-tools", nargs="+", action="extend", default=None,
|
|
help="allow rules (default: the tools)")
|
|
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 (<cwd>/.claude/agents/<name>.md)")
|
|
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"])
|
|
i.add_argument("--pings", choices=("on", "off"), help="default %s" % CONFIG_DEFAULTS["pings"])
|
|
i.add_argument("--ttl-s", type=_pos_int,
|
|
help="TTL assumed until a base's calls show one (default 3600)")
|
|
i.add_argument("--warm-margin-s", type=_pos_int,
|
|
help="seconds before warm_until a base turns `expiring` (default max(120, ttl/10); "
|
|
"must be below ttl_s; at most half the base's TTL at runtime)")
|
|
i.add_argument("--max-minutes", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["max_minutes"])
|
|
i.add_argument("--stall-after-s", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["stall_after_s"])
|
|
for k, v in THRESHOLD_DEFAULTS.items():
|
|
i.add_argument("--" + k.replace("_", "-"), type=_pos_int, help="default %d" % v)
|
|
|
|
t = cmd("train", "Train a new base (headless session).")
|
|
t.add_argument("--base", required=True)
|
|
t.add_argument("--brief", required=True)
|
|
t.add_argument("--block")
|
|
t.add_argument("--phase")
|
|
# M10 (plan 4.3): train takes launch-field overrides, the same flags as init; a field not
|
|
# given comes from the run config. The base records what it was trained with.
|
|
g = t.add_argument_group("launch overrides (default: the init config)")
|
|
g.add_argument("--model", help="full model id")
|
|
g.add_argument("--effort", choices=EFFORTS)
|
|
g.add_argument("--permission-mode")
|
|
g.add_argument("--tools", help="comma-separated tool names")
|
|
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 (<cwd>/.claude/agents/<name>.md)")
|
|
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 "
|
|
"named base that is active becomes superseded")
|
|
|
|
def launch_overrides(sp):
|
|
# Accepted only so that they can be refused with a clear message (plan 4.3).
|
|
g = sp.add_argument_group("refused launch fields (fixed at init; read from the base record)")
|
|
for name in LAUNCH_FIELDS:
|
|
flag = "--" + name.replace("_", "-")
|
|
if name in ("allowed_tools", "disallowed_tools"):
|
|
g.add_argument(flag, nargs="+", default=None, help=argparse.SUPPRESS)
|
|
else:
|
|
g.add_argument(flag, default=None, help=argparse.SUPPRESS)
|
|
|
|
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="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 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 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)
|
|
launch_overrides(c)
|
|
|
|
wt = cmd("wait", "Block until jobs finish or the timeout (default 540, max 570).")
|
|
wt.add_argument("jobs", nargs="*")
|
|
wt.add_argument("--all", action="store_true", help="every job not yet settled by a wait")
|
|
wt.add_argument("--any", action="store_true", help="return when any one of them has finished")
|
|
wt.add_argument("--timeout", type=_pos_int, default=WAIT_DEFAULT_S)
|
|
|
|
cmd("list", "Show every base and job.")
|
|
|
|
e = cmd("expire", "Retire a base (or all); running jobs of it are killed.")
|
|
eg = e.add_mutually_exclusive_group(required=True)
|
|
eg.add_argument("--base")
|
|
eg.add_argument("--all", action="store_true")
|
|
|
|
k = cmd("kill", "Kill a job's supervisor and claude process tree.")
|
|
k.add_argument("--job", required=True)
|
|
|
|
s = cmd("_supervise", None) # internal
|
|
s.add_argument("--job", required=True)
|
|
|
|
cmd("tick", "One keep-warm pass: ping every base the rule picks (design 5).")
|
|
|
|
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 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",
|
|
help="a note the rebase must absorb (repeatable): appended after the delta header")
|
|
launch_overrides(r)
|
|
|
|
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 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")
|
|
return p
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Commands
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def cmd_where(args) -> tuple[dict, int]:
|
|
"""The preflight report. On a closed platform gate: `ok: false` with the gate reason first,
|
|
and every other part still filled (the porting hosts need it)."""
|
|
reasons = []
|
|
gate = platform_gate()
|
|
if gate is not None:
|
|
reasons.append(gate)
|
|
py_ok = sys.version_info >= (3, 10)
|
|
if not py_ok:
|
|
reasons.append("python < 3.10")
|
|
py = {"version": platform.python_version(), "executable": sys.executable,
|
|
"base_executable": getattr(sys, "_base_executable", sys.executable),
|
|
"supervisor_interpreter": supervisor_interpreter(), "ok": py_ok}
|
|
try:
|
|
path, how = resolve_binary(args.binary)
|
|
except GremlinsError as e: # A11: a path form this platform refuses
|
|
path = None
|
|
how = ("--binary" if args.binary else
|
|
"CLAUDE_CODE_EXECPATH" if os.environ.get("CLAUDE_CODE_EXECPATH") else "PATH")
|
|
binary = {"path": path, "how": how, "error": e.payload["error"],
|
|
"given": e.payload.get("path", args.binary)}
|
|
reasons.append("%s: %s" % (e.payload["error"], binary["given"]))
|
|
else:
|
|
binary = {"path": path, "how": how}
|
|
if path is None:
|
|
reasons.append("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)")
|
|
else:
|
|
try:
|
|
binary.update(binary_info(path))
|
|
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 gremlin, so they authenticate and bill through that endpoint"
|
|
% (base_url, DEFAULT_BASE_URL))
|
|
if gate_overridden():
|
|
warnings.append(GATE_OVERRIDE_WARNING)
|
|
out = {
|
|
"script": script_path(),
|
|
"python": py,
|
|
"uv": _uv_report(),
|
|
"binary": binary,
|
|
"git": _git_report(),
|
|
"scrub": scrub_report(),
|
|
"platform": platform_report(),
|
|
"warnings": warnings,
|
|
"ok": not reasons,
|
|
}
|
|
if reasons:
|
|
out["reason"] = "; ".join(reasons)
|
|
out["error"] = out["reason"]
|
|
return out, EXIT_USAGE
|
|
return out, EXIT_OK
|
|
|
|
|
|
def _resolve_cwd(arg: str | None) -> str:
|
|
if arg:
|
|
cwd = norm_path(arg)
|
|
else:
|
|
cwd = git_toplevel()
|
|
if not cwd:
|
|
raise GremlinsError("no --cwd given and the current directory is not inside a git repo")
|
|
if not os.path.isdir(cwd):
|
|
raise GremlinsError("--cwd is not a directory", cwd=cwd)
|
|
return cwd
|
|
|
|
|
|
def agent_path(cwd: str, name: str) -> str:
|
|
return os.path.join(cwd, ".claude", "agents", name + ".md")
|
|
|
|
|
|
def build_launch(state: str, *, model, effort, permission_mode, tools, allowed, disallowed, agent,
|
|
cwd: str, binary_path: str) -> tuple[dict, str]:
|
|
"""Validate launch fields and build a launch record (init's config and train's base record;
|
|
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 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 GremlinsError("permission mode refused", permission_mode=mode,
|
|
detail="plan mode swaps models; use dontAsk")
|
|
if mode not in 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 GremlinsError("unknown effort", effort=effort, allowed=list(EFFORTS))
|
|
if not tools:
|
|
raise GremlinsError("--tools is empty")
|
|
if not os.path.isdir(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 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; gremlins never writes "
|
|
"settings." % (model, ", ".join(files))))
|
|
|
|
binfo = binary_info(binary_path)
|
|
|
|
pre = preamble_path()
|
|
if not os.path.isfile(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 GremlinsError("bad agent name", agent=name)
|
|
apath = agent_path(cwd, name)
|
|
if not os.path.isfile(apath):
|
|
raise GremlinsError("agent file not found", agent=name, path=apath)
|
|
agent_rec = {"name": name, "path": apath, "sha256": _sha256_file(apath)}
|
|
|
|
launch = {
|
|
"model": model,
|
|
"effort": effort,
|
|
"permission_mode": mode,
|
|
"tools": list(tools),
|
|
"allowed_tools": list(allowed),
|
|
"disallowed_tools": list(disallowed),
|
|
"mcp": {"strict": True, "config": mcp_empty_path(state)},
|
|
"append_system_prompt": {"path": pre, "sha256": _sha256_file(pre)},
|
|
"agent": agent_rec,
|
|
"cwd": cwd,
|
|
"binary": binfo,
|
|
"env": {"pinned": dict(ENV_PINNED_LAUNCH)},
|
|
}
|
|
return launch, adm["via"]
|
|
|
|
|
|
def rerun_binary(stored: str, how: str | None, warnings: list) -> tuple[str, str | None]:
|
|
"""N3 (C14, A4): the binary of an `init` re-run without `--binary`. A `--binary` pin keeps the
|
|
stored path; otherwise the path is looked up again through the source it came from
|
|
(`CLAUDE_CODE_EXECPATH` or `PATH`, never the other one). Nothing found there keeps the stored
|
|
path. A change and a failed lookup each add one entry to `warnings`."""
|
|
if how not in BINARY_SOURCES: # "--binary" (an explicit pin), or no recorded source
|
|
return stored, how
|
|
found = resolve_binary_via(how)
|
|
if found is None:
|
|
warnings.append("could not re-resolve the claude binary through %s; kept the stored path %s"
|
|
% (how, stored))
|
|
return stored, how
|
|
if os.path.normcase(found) == os.path.normcase(stored):
|
|
return stored, how
|
|
warnings.append("binary changed: %s → %s (resolved by %s); existing bases keep their own binary "
|
|
"record, new trainings use the new one" % (stored, found, how))
|
|
return found, how
|
|
|
|
|
|
def build_init_config(args, state: str, old: dict | None = None, warnings: list | None = None) -> dict:
|
|
"""Validate the launch fields and build the config block. Writes nothing.
|
|
Minor 8: `old` is the current config (None on the first init). A flag that is not given keeps
|
|
its current value (launch fields and run-wide settings alike); defaults apply only on the
|
|
first init. What is read from disk (the binary's version, size and mtime, the preamble and
|
|
agent hashes) is re-read either way. N3: the binary is the exception (`rerun_binary`); its
|
|
warnings go to `warnings`."""
|
|
if warnings is None:
|
|
warnings = []
|
|
ol = (old or {}).get("launch") or {}
|
|
|
|
def keep(given, key, default):
|
|
if given is not None:
|
|
return given
|
|
if old is not None and key in old and old[key] is not None:
|
|
return old[key]
|
|
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 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)
|
|
if allowed is None:
|
|
allowed = list(ol["allowed_tools"]) if "allowed_tools" in ol else list(tools)
|
|
disallowed = _tool_list(args.disallowed_tools)
|
|
if disallowed is None:
|
|
disallowed = list(ol["disallowed_tools"]) if "disallowed_tools" in ol else list(DEFAULT_DISALLOWED)
|
|
if args.cwd is None and ol.get("cwd"):
|
|
cwd = ol["cwd"]
|
|
if not os.path.isdir(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 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 = rerun_binary(ol["binary"]["path"], (old or {}).get("binary_resolved_by"), warnings)
|
|
else:
|
|
bpath, how = resolve_binary(args.binary)
|
|
if bpath is None:
|
|
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",
|
|
permission_mode=(args.permission_mode if args.permission_mode is not None
|
|
else ol.get("permission_mode") or "dontAsk"),
|
|
tools=tools, allowed=allowed, disallowed=disallowed, agent=agent, cwd=cwd,
|
|
binary_path=bpath)
|
|
old_th = (old or {}).get("thresholds") or {}
|
|
cfg = {
|
|
"launch": launch,
|
|
"fingerprint": fingerprint_hash(launch),
|
|
"binary_resolved_by": how,
|
|
"parallel_cap": keep(args.parallel_cap, "parallel_cap", CONFIG_DEFAULTS["parallel_cap"]),
|
|
"orphan_after": keep(args.orphan_after, "orphan_after", CONFIG_DEFAULTS["orphan_after"]),
|
|
"pings": keep(args.pings, "pings", CONFIG_DEFAULTS["pings"]),
|
|
"ttl_s": keep(args.ttl_s, "ttl_s", CONFIG_DEFAULTS["ttl_s"]),
|
|
"warm_margin_s": keep(args.warm_margin_s, "warm_margin_s", None),
|
|
"max_minutes": keep(args.max_minutes, "max_minutes", CONFIG_DEFAULTS["max_minutes"]),
|
|
"stall_after_s": keep(args.stall_after_s, "stall_after_s", CONFIG_DEFAULTS["stall_after_s"]),
|
|
"thresholds": {k: (getattr(args, k) if getattr(args, k) is not None
|
|
else old_th.get(k, THRESHOLD_DEFAULTS[k])) for k in THRESHOLD_DEFAULTS},
|
|
"model_admission": via,
|
|
}
|
|
# 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 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
|
|
|
|
|
|
def _diff_fields(a, b, prefix="") -> list[str]:
|
|
if isinstance(a, dict) and isinstance(b, dict):
|
|
out = []
|
|
for k in sorted(set(a) | set(b)):
|
|
out += _diff_fields(a.get(k), b.get(k), prefix + k + ".")
|
|
return out
|
|
return [] if a == b else [prefix.rstrip(".")]
|
|
|
|
|
|
def cmd_init(args) -> tuple[dict, int]:
|
|
require_platform() # B1: the first step, before any path is read or written
|
|
state = state_dir_for(args.run)
|
|
snap = read_registry(state) if os.path.isdir(state) else None
|
|
old_cfg = (snap or {}).get("config")
|
|
warnings: list[str] = []
|
|
cfg = build_init_config(args, state, old_cfg, warnings) # all validation before any write
|
|
ensure_state(state)
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
if (reg or {}).get("config") != old_cfg:
|
|
raise GremlinsError("the config changed while init ran; run init again", state=state)
|
|
created = reg is None
|
|
if created:
|
|
reg = new_registry()
|
|
old = reg.get("config")
|
|
changed_fields = [] if old is None else _diff_fields(old, cfg)
|
|
# `model_admission` and `binary_resolved_by` describe how, not what: not a change.
|
|
changed_fields = [f for f in changed_fields
|
|
if f not in ("model_admission", "binary_resolved_by")]
|
|
# Block 2.2: values may change after bases exist (Phase 1 refused it). Every base keeps
|
|
# launching with its own record; a base that did not override a changed defining field
|
|
# reports `retrain_due: defining:<fields>`. A base from before per-base records gets the
|
|
# config it was trained with as its record first.
|
|
if old is None or changed_fields:
|
|
if old is not None:
|
|
for b in reg.get("bases", {}).values():
|
|
if not b.get("launch") and old.get("launch"):
|
|
b["launch"] = old["launch"]
|
|
b.setdefault("overrides", [])
|
|
reg["config"] = cfg
|
|
reg.setdefault("run", {})["init_at"] = _iso()
|
|
write_registry(state, reg)
|
|
else:
|
|
cfg = old
|
|
return {"ok": True, "state": state, "registry": registry_path(state), "created": created,
|
|
"changed": bool(old is not None and changed_fields), "changed_fields": changed_fields,
|
|
"bases": sorted(reg.get("bases", {})), "config": cfg, "warnings": warnings}, EXIT_OK
|
|
|
|
|
|
# --- 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 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 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]:
|
|
p = norm_path(path)
|
|
try:
|
|
with open(p, "r", encoding="utf-8") as f:
|
|
return p, f.read()
|
|
except OSError as 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 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"]
|
|
try:
|
|
if _sha256_file(asp["path"]) != asp["sha256"]:
|
|
drift.append("append_system_prompt")
|
|
except OSError:
|
|
drift.append("append_system_prompt")
|
|
ag = launch.get("agent")
|
|
if ag:
|
|
try:
|
|
if _sha256_file(ag["path"]) != ag["sha256"]:
|
|
drift.append("agent")
|
|
except OSError:
|
|
drift.append("agent")
|
|
return info, drift
|
|
|
|
|
|
def claude_argv(launch: dict) -> list[str]:
|
|
"""The fingerprinted command line (passed in full on every call; flags are not inherited)."""
|
|
a = [launch["binary"]["path"], "-p", "--output-format", "stream-json", "--verbose",
|
|
"--model", launch["model"], "--effort", launch["effort"],
|
|
"--permission-mode", launch["permission_mode"], "--tools", ",".join(launch["tools"])]
|
|
if launch.get("allowed_tools"):
|
|
a += ["--allowedTools", ",".join(launch["allowed_tools"])]
|
|
if launch.get("disallowed_tools"):
|
|
a += ["--disallowedTools", ",".join(launch["disallowed_tools"])]
|
|
a += ["--strict-mcp-config", "--mcp-config", launch["mcp"]["config"],
|
|
"--append-system-prompt-file", launch["append_system_prompt"]["path"]]
|
|
if launch.get("agent"):
|
|
a += ["--agent", launch["agent"]["name"]]
|
|
return a
|
|
|
|
|
|
def _git_head(cwd: str) -> str | None:
|
|
try:
|
|
r = git(["rev-parse", "HEAD"], cwd=cwd)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
out = r.stdout.decode("utf-8", "replace").strip()
|
|
return out if r.returncode == 0 and out else None
|
|
|
|
|
|
def delta_header(cwd: str, base_commit: str | None) -> str:
|
|
"""Commits since the base's training commit plus the working tree's shortstat against it."""
|
|
if not base_commit:
|
|
return ("No base commit is recorded (the base's cwd was not a git repository, or git failed "
|
|
"at training). Treat everything on disk as possibly changed since training.")
|
|
parts = []
|
|
try:
|
|
lg = git(["log", "--oneline", "%s..HEAD" % base_commit], cwd=cwd)
|
|
st = git(["diff", "--shortstat", base_commit], cwd=cwd)
|
|
log_txt = lg.stdout.decode("utf-8", "replace").strip() if lg.returncode == 0 else \
|
|
"(git log failed: %s)" % lg.stderr.decode("utf-8", "replace").strip()
|
|
st_txt = st.stdout.decode("utf-8", "replace").strip() if st.returncode == 0 else \
|
|
"(git diff failed: %s)" % st.stderr.decode("utf-8", "replace").strip()
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
log_txt = st_txt = "(git failed: %s)" % e
|
|
parts.append("Base commit: `%s`." % base_commit)
|
|
parts.append("Commits since then (`git log --oneline %s..HEAD`):\n\n```\n%s\n```"
|
|
% (base_commit[:12], log_txt or "(none)"))
|
|
parts.append("Working tree against the base commit (`git diff --shortstat %s`):\n\n```\n%s\n```"
|
|
% (base_commit[:12], st_txt or "(no changes)"))
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
# --- rebase and re-train flags (design 5, block 2.2) ------------------------------------
|
|
|
|
|
|
def git_delta_counts(cwd: str, base_commit: str) -> tuple[int | None, int | None]:
|
|
"""(commits since `base_commit`, insertions + deletions of `git diff --shortstat <base_commit>`);
|
|
None where git fails."""
|
|
commits = lines = None
|
|
with contextlib.suppress(OSError, subprocess.SubprocessError, ValueError):
|
|
r = git(["rev-list", "--count", "%s..HEAD" % base_commit], cwd=cwd)
|
|
if r.returncode == 0:
|
|
commits = int(r.stdout.decode("utf-8", "replace").strip() or 0)
|
|
with contextlib.suppress(OSError, subprocess.SubprocessError, ValueError):
|
|
s = git(["diff", "--shortstat", base_commit], cwd=cwd)
|
|
if s.returncode == 0:
|
|
txt = s.stdout.decode("utf-8", "replace")
|
|
ins = re.search(r"(\d+) insertion", txt)
|
|
dels = re.search(r"(\d+) deletion", txt)
|
|
lines = (int(ins.group(1)) if ins else 0) + (int(dels.group(1)) if dels else 0)
|
|
return commits, lines
|
|
|
|
|
|
# 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
|
|
# `## Changes since base` section of the brief. Other files a brief merely names (build plan,
|
|
# run log, files to edit) and the rest of the brief never count.
|
|
_CHANGES_HEADING = re.compile(r"^##[ \t]+Changes since base[ \t]*#*[ \t]*$", re.I | re.M)
|
|
_HEADING_1_2 = re.compile(r"^#{1,2}[ \t]", re.M)
|
|
|
|
|
|
def changes_section(brief: str | None) -> str:
|
|
"""The body of the brief's `## Changes since base` section (up to the next level-1 or level-2
|
|
heading, or the end), or "" when the brief has none."""
|
|
m = _CHANGES_HEADING.search(brief or "")
|
|
if not m:
|
|
return ""
|
|
rest = brief[m.end():]
|
|
nxt = _HEADING_1_2.search(rest)
|
|
return (rest[:nxt.start()] if nxt else rest).strip()
|
|
|
|
|
|
def read_delta_notes(paths) -> list[dict]:
|
|
"""The notes given with `--delta-note`: [{"path", "text", "mtime"}], each file once.
|
|
A note that cannot be read is refused (exit 2) before anything is written."""
|
|
out, seen = [], set()
|
|
for raw in paths or []:
|
|
p = norm_path(raw)
|
|
key = os.path.normcase(p)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
try:
|
|
text = read_bytes_retry(p).decode("utf-8", "replace")
|
|
mtime = os.path.getmtime(p)
|
|
except OSError as e:
|
|
raise GremlinsError("cannot read delta note", note=p, detail=str(e))
|
|
out.append({"path": p, "text": text, "mtime": mtime})
|
|
return out
|
|
|
|
|
|
def new_to_base(notes: list[dict], base: dict) -> list[dict]:
|
|
"""The attached notes the base does not know yet: mtime after its `trained_at` (a training's
|
|
or a rebase's end). With no `trained_at` every note counts."""
|
|
t = _ts((base or {}).get("trained_at"))
|
|
return [n for n in notes or [] if t is None or n["mtime"] > t]
|
|
|
|
|
|
def render_delta(header: str, notes: list[dict]) -> str:
|
|
"""The prompt's delta section: the delta header, then each attached note with its path."""
|
|
if not notes:
|
|
return header
|
|
parts = [header, "### Attached delta notes"]
|
|
for n in notes:
|
|
parts.append("#### `%s`\n\n%s" % (n["path"], n["text"].rstrip("\n")))
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
def projects_dir() -> str:
|
|
return CLAUDE_PROJECTS_DIR or os.path.join(os.path.expanduser("~"), ".claude", "projects")
|
|
|
|
|
|
def transcript_path(cwd: str, session: str) -> str:
|
|
"""P0.7: `~/.claude/projects/<cwd with every non-alphanumeric -> '-'>/<session>.jsonl`."""
|
|
return os.path.join(projects_dir(), re.sub(r"[^A-Za-z0-9]", "-", cwd), session + ".jsonl")
|
|
|
|
|
|
def transcript_context(path: str) -> int | None:
|
|
"""Context tokens of the last assistant line (with `message.usage`) of a transcript, read from
|
|
the tail; None when the file or such a line is missing."""
|
|
try:
|
|
size = os.path.getsize(path)
|
|
except OSError:
|
|
return None
|
|
n = TRANSCRIPT_TAIL_BYTES
|
|
while True:
|
|
start = max(0, size - n)
|
|
try:
|
|
with open(path, "rb") as f:
|
|
f.seek(start)
|
|
data = f.read()
|
|
except OSError:
|
|
return None
|
|
lines = data.splitlines()
|
|
if start > 0:
|
|
lines = lines[1:] # the first line of a tail may be cut
|
|
for ln in reversed(lines):
|
|
if b'"assistant"' not in ln:
|
|
continue
|
|
try:
|
|
obj = json.loads(ln.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
continue
|
|
if isinstance(obj, dict) and obj.get("type") == "assistant":
|
|
u = (obj.get("message") or {}).get("usage")
|
|
if u:
|
|
return context_tokens(u)
|
|
if start == 0:
|
|
return None
|
|
n *= 4
|
|
|
|
|
|
def base_context_now(base: dict, launch: dict) -> int | None:
|
|
"""The base's context tokens: from its transcript's last assistant line when the file exists
|
|
(P0.7), else the context stored at training / rebase."""
|
|
ctx = None
|
|
if base.get("session"):
|
|
ctx = transcript_context(transcript_path(launch["cwd"], base["session"]))
|
|
return ctx if ctx is not None else base.get("context_tokens")
|
|
|
|
|
|
def _int_phase(p) -> bool:
|
|
return isinstance(p, int) and not isinstance(p, bool)
|
|
|
|
|
|
def phase_boundary(reg: dict, name: str, phase) -> bool:
|
|
"""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]
|
|
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(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]:
|
|
"""Runtime fields (M6 included) the last call on base `name` that reported an init event
|
|
found drifted against the base (any kind but train)."""
|
|
best, fields = -1, []
|
|
for j, e in reg.get("jobs", {}).items():
|
|
if e.get("base") != name or e.get("kind") == "train" or e.get("runtime_drift") is None:
|
|
continue
|
|
if _job_num(j) > best:
|
|
best, fields = _job_num(j), list(e["runtime_drift"])
|
|
return [f for f in fields if f in RUNTIME_FIELDS]
|
|
|
|
|
|
def defining_drift(base: dict, cfg: dict) -> list[str]:
|
|
"""Fingerprint-defining fields of the base's launch record that differ from what a launch
|
|
would use now: for a field the base overrode at `train`, its own value; otherwise the run
|
|
config's. The agent file and the preamble are re-hashed from disk."""
|
|
rec = base.get("launch")
|
|
if not rec:
|
|
return []
|
|
over = set(base.get("overrides") or [])
|
|
now = cfg.get("launch") or {}
|
|
|
|
def want(f):
|
|
return rec.get(f) if f in over else now.get(f)
|
|
out = [f for f in ("model", "cwd", "tools", "permission_mode") if want(f) != rec.get(f)]
|
|
ra = rec.get("agent") or {}
|
|
name = ra.get("name") if "agent" in over else (now.get("agent") or {}).get("name")
|
|
sha = None
|
|
if name and want("cwd"):
|
|
try:
|
|
sha = _sha256_file(agent_path(want("cwd"), name))
|
|
except OSError:
|
|
sha = None
|
|
if (name, sha) != (ra.get("name"), ra.get("sha256")):
|
|
out.append("agent")
|
|
try:
|
|
pre = _sha256_file(preamble_path())
|
|
except OSError:
|
|
pre = None
|
|
if pre != (rec.get("append_system_prompt") or {}).get("sha256"):
|
|
out.append("append_system_prompt")
|
|
return [f for f in DEFINING_FIELDS if f in out]
|
|
|
|
|
|
def binary_state(base: dict, launch: dict, cache: dict | None = None) -> str:
|
|
""""missing" (the stored path is gone), "drift" (version, size or mtime differ from the base's
|
|
record) or "ok". `cache` maps a path to its binary_info within one call."""
|
|
path = launch["binary"]["path"]
|
|
if not os.path.isfile(path):
|
|
return "missing"
|
|
cache = {} if cache is None else cache
|
|
if path not in cache:
|
|
try:
|
|
cache[path] = binary_info(path)
|
|
except GremlinsError:
|
|
cache[path] = None
|
|
info = cache[path]
|
|
ref = base.get("binary") or launch["binary"]
|
|
if info is None or any(info.get(k) != ref.get(k) for k in ("version", "size", "mtime")):
|
|
return "drift"
|
|
return "ok"
|
|
|
|
|
|
def due_flags(state: str, reg: dict, name: str, *, phase=None, brief: str | None = 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 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"]
|
|
base = reg["bases"][name]
|
|
la = launch_record(base, cfg)
|
|
th = dict(THRESHOLD_DEFAULTS, **(cfg.get("thresholds") or {}))
|
|
rebase, retrain = [], []
|
|
if phase_boundary(reg, name, phase):
|
|
rebase.append("phase_boundary")
|
|
bc = base.get("base_commit")
|
|
if bc:
|
|
commits, lines = git_delta_counts(la["cwd"], bc)
|
|
if commits is not None and commits > int(th["rebase_commits"]):
|
|
rebase.append("commits:%d" % commits)
|
|
if lines is not None and lines > int(th["rebase_lines"]):
|
|
rebase.append("lines:%d" % lines)
|
|
if delta is None:
|
|
delta = delta_header(la["cwd"], bc)
|
|
chars = (len(delta) + sum(len(n["text"]) for n in new_to_base(notes, base))
|
|
+ len(changes_section(brief)))
|
|
if chars // 4 > int(th["rebase_delta_tokens"]):
|
|
rebase.append("delta_tokens:%d" % (chars // 4))
|
|
if binary is None:
|
|
binary = binary_state(base, la, bcache)
|
|
if binary == "drift":
|
|
rebase.append("binary_drift")
|
|
rd = last_runtime_drift(reg, name)
|
|
if rd:
|
|
rebase.append("runtime_drift:" + ",".join(rd))
|
|
dd = defining_drift(base, cfg)
|
|
if dd:
|
|
retrain.append("defining:" + ",".join(dd))
|
|
ctx = base_context_now(base, la)
|
|
if ctx is not None and ctx > int(th["max_base_tokens"]):
|
|
retrain.append("max_base_tokens:%d" % ctx)
|
|
lr = int(base.get("lineage_rebases") or 0)
|
|
if lr >= int(th["max_rebases"]):
|
|
retrain.append("rebases:%d" % lr)
|
|
if binary == "missing":
|
|
retrain.append("binary_missing")
|
|
return {"rebase_due": bool(rebase), "retrain_due": bool(retrain), "reasons": rebase + retrain}
|
|
|
|
|
|
def _next_job_id(reg: dict) -> str:
|
|
n = 0
|
|
for j in reg.get("jobs", {}):
|
|
m = re.fullmatch(r"j(\d+)", j)
|
|
if m:
|
|
n = max(n, int(m.group(1)))
|
|
return "j%04d" % (n + 1)
|
|
|
|
|
|
def _running_capped(reg: dict) -> list[str]:
|
|
return sorted(j for j, e in reg.get("jobs", {}).items()
|
|
if e.get("kind") in CAPPED_KINDS and e.get("status") in RUNNING)
|
|
|
|
|
|
def _pending_rebase_to(reg: dict, name: str) -> str | None:
|
|
"""The running rebase job that will create base `name`, if any."""
|
|
for j, e in sorted(reg.get("jobs", {}).items()):
|
|
if e.get("kind") == "rebase" and e.get("rebase_as") == name and e.get("status") in RUNNING:
|
|
return j
|
|
return None
|
|
|
|
|
|
def _new_entry(job: str, kind: str, **kw) -> dict:
|
|
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,
|
|
"brief": "briefs/%s.md" % job, "raw": "raw/%s.jsonl" % job, "max_s": None,
|
|
"cost_before": 0.0, "model_usage_before": {}, "base_context": None,
|
|
"base_state_at_launch": None, "launch_drift": [], "binary_version": None,
|
|
"head_at_launch": None, "continues": None, "delivered_at": None, "settled_at": None}
|
|
e.update(kw)
|
|
return e
|
|
|
|
|
|
def _start_job(state: str, run: str, job: str, prompt: str, launch: dict) -> dict:
|
|
"""Write the brief and the child env, detach the supervisor, record it. A failed detach ends
|
|
the job as `failed`, reason `detach_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 = detach_supervisor(state, run, job)
|
|
if not sp.get("ok"):
|
|
finalize_job(state, job, ss=None, hint="failed", reason="detach_failed", detail=sp.get("detail"))
|
|
return sp
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
e = reg["jobs"][job]
|
|
e["supervisor"] = {"pid": sp["pid"], "ctime": sp.get("ctime")}
|
|
e["launched_at"] = _iso()
|
|
if e.get("status") == "created":
|
|
e["status"] = "running"
|
|
write_registry(state, reg)
|
|
return sp
|
|
|
|
|
|
def train_launch(args, state: str, cfg: dict) -> tuple[dict, list[str]]:
|
|
"""M10 (plan 4.3): the base's launch record = the run config's launch fields with the
|
|
overrides given on `train` (the same flags as `init`), validated as `init` validates them.
|
|
Returns (launch, sorted overridden field names)."""
|
|
la = cfg["launch"]
|
|
over = sorted(n for n in LAUNCH_FIELDS if getattr(args, n, None) is not None)
|
|
tools = _tool_list([args.tools]) if args.tools is not None else la["tools"]
|
|
allowed = _tool_list(args.allowed_tools) if args.allowed_tools is not None else la["allowed_tools"]
|
|
disallowed = (_tool_list(args.disallowed_tools) if args.disallowed_tools is not None
|
|
else la["disallowed_tools"])
|
|
agent = args.agent if args.agent is not None else (la.get("agent") or {}).get("name")
|
|
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 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"],
|
|
permission_mode=args.permission_mode if args.permission_mode is not None else la["permission_mode"],
|
|
tools=tools, allowed=allowed, disallowed=disallowed, agent=agent, cwd=cwd, binary_path=bpath)
|
|
return launch, over
|
|
|
|
|
|
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 GremlinsError("bad base name", base=name)
|
|
cfg = reg["config"]
|
|
supersedes = _uniq(args.supersedes or [])
|
|
|
|
def check_supersedes(r):
|
|
if name in supersedes:
|
|
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 GremlinsError("unknown base", bases=unknown, option="--supersedes")
|
|
check_supersedes(reg)
|
|
la, overrides = train_launch(args, state, cfg)
|
|
binfo = la["binary"]
|
|
drift: list[str] = [] # the record was built from the files just now
|
|
brief_path, brief = _read_brief(args.brief)
|
|
session = str(uuid.uuid4())
|
|
argv = claude_argv(la) + ["--session-id", session]
|
|
head = _git_head(la["cwd"])
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
stamp_orchestrator(reg)
|
|
old = reg["bases"].get(name)
|
|
if old and old.get("status") != "failed":
|
|
raise GremlinsError("base exists", base=name, status=old.get("status"))
|
|
pending = _pending_rebase_to(reg, name)
|
|
if 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"],
|
|
"launch": la, "fingerprint": fingerprint_hash(la), "overrides": overrides,
|
|
"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,
|
|
"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,
|
|
launch_drift=drift, binary_version=binfo["version"], head_at_launch=head,
|
|
brief_source=brief_path)
|
|
write_registry(state, reg)
|
|
prompt = render_template("train.md", {"base": name, "run": norm_path(args.run), "state_dir": state,
|
|
"notes_dir": os.path.join(state, "notes"),
|
|
"logs_dir": os.path.join(state, "logs"), "brief": brief})
|
|
_start_job(state, norm_path(args.run), job, prompt, la)
|
|
return {"job": job, "base": name, "session_id": session}, EXIT_OK
|
|
|
|
|
|
def cmd_spawn(args) -> tuple[dict, int]:
|
|
_refuse_overrides(args)
|
|
if args.mode == "parallel" and not args.paths:
|
|
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 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 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 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)
|
|
delta = delta_header(la["cwd"], base.get("base_commit"))
|
|
due = due_flags(state, reg, args.base, phase=_phase(args.phase), brief=brief, delta=delta,
|
|
notes=notes, binary="drift" if "binary" in drift else "ok")
|
|
head = _git_head(la["cwd"])
|
|
session = str(uuid.uuid4())
|
|
argv = claude_argv(la) + ["--resume", base["session"], "--fork-session", "--session-id", session]
|
|
if args.max_budget_usd:
|
|
argv += ["--max-budget-usd", "%g" % args.max_budget_usd]
|
|
paths = list(args.paths) if args.paths else None
|
|
reap_lost(state)
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
stamp_orchestrator(reg)
|
|
base = reg["bases"][args.base]
|
|
if base.get("status") != "active" and not args.force:
|
|
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 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, "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,
|
|
cost_before=float(base.get("cost_cum_usd") or 0.0),
|
|
model_usage_before=base.get("model_usage_cum") or {},
|
|
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
|
warm_until_at_launch=warmth["warm_until"], launch_drift=drift,
|
|
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("gremlins", []).append(job)
|
|
write_registry(state, reg)
|
|
if args.mode == "parallel":
|
|
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 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("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, "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
|
|
|
|
|
|
def cmd_continue(args) -> tuple[dict, int]:
|
|
_refuse_overrides(args)
|
|
state, reg = _load_run(args)
|
|
cfg = reg["config"]
|
|
fj = reg["jobs"].get(args.job)
|
|
if fj is None:
|
|
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 GremlinsError("the gremlin is still running", job=args.job, running=busy(reg))
|
|
if not fj.get("session"):
|
|
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 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"])
|
|
argv = claude_argv(la) + ["--resume", fj["session"]]
|
|
reap_lost(state)
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
stamp_orchestrator(reg)
|
|
fj = reg["jobs"][args.job]
|
|
if 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 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)
|
|
warmth = base_warmth(base, cfg)
|
|
reg["jobs"][job] = _new_entry(
|
|
job, "continue", base=fj.get("base"), block=fj.get("block"), phase=fj.get("phase"),
|
|
mode=fj.get("mode"), paths=fj.get("paths"), session=fj["session"],
|
|
parent_session=fj.get("parent_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,
|
|
cost_before=float(cum if cum is not None else (base.get("cost_cum_usd") or 0.0)),
|
|
model_usage_before=(fj.get("session_model_usage_cum") if cum is not None
|
|
else base.get("model_usage_cum")) or {},
|
|
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
|
launch_drift=drift, binary_version=binfo["version"], head_at_launch=head,
|
|
continues=args.job, brief_source=brief_path)
|
|
write_registry(state, reg)
|
|
_start_job(state, norm_path(args.run), job, brief, la)
|
|
return {"job": job}, EXIT_OK
|
|
|
|
|
|
def cmd_rebase(args) -> tuple[dict, int]:
|
|
"""`rebase --base B --as NEW --brief FILE [--phase N]`: a fork of B that absorbs what changed
|
|
(`templates/rebase.md`). On `ok` its session becomes base NEW and B is superseded (finaliser,
|
|
`_rebase_succeeded`); otherwise B stays active and NEW is never created. Counts toward the cap;
|
|
N3-refused (dispatch)."""
|
|
_refuse_overrides(args)
|
|
state, reg = _load_run(args)
|
|
cfg = reg["config"]
|
|
new = args.as_name
|
|
if not re.fullmatch(r"[A-Za-z0-9_.-]+", new or ""):
|
|
raise GremlinsError("bad base name", base=new)
|
|
base = reg["bases"].get(args.base)
|
|
if base is None:
|
|
raise GremlinsError("unknown base", base=args.base, bases=sorted(reg["bases"]))
|
|
if new == args.base:
|
|
raise GremlinsError("--as must name a new base", base=new)
|
|
if base.get("status") != "active" or not base.get("session"):
|
|
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)
|
|
binfo, drift = _check_binary(base.get("binary") or la["binary"], la)
|
|
# The new base's record is the old one's; a binary drift means the rebase runs under the
|
|
# current binary, which the new base then records (design 5).
|
|
new_launch = dict(la, binary=binfo)
|
|
delta = delta_header(la["cwd"], base.get("base_commit"))
|
|
head = _git_head(la["cwd"])
|
|
session = str(uuid.uuid4())
|
|
argv = claude_argv(la) + ["--resume", base.get("session") or "", "--fork-session", "--session-id", session]
|
|
if args.max_budget_usd:
|
|
argv += ["--max-budget-usd", "%g" % args.max_budget_usd]
|
|
reap_lost(state)
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
stamp_orchestrator(reg)
|
|
base = reg["bases"][args.base]
|
|
if base.get("status") != "active" or not base.get("session"):
|
|
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 GremlinsError("base exists", base=new, status=old.get("status"))
|
|
pending = _pending_rebase_to(reg, new)
|
|
if 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 GremlinsError("the base is being rebased", base=args.base, running=busy)
|
|
running = _running_capped(reg)
|
|
if len(running) >= int(cfg["parallel_cap"]):
|
|
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, "rebase", base=args.base, phase=_phase(args.phase), 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,
|
|
cost_before=float(base.get("cost_cum_usd") or 0.0),
|
|
model_usage_before=base.get("model_usage_cum") or {},
|
|
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
|
warm_until_at_launch=warmth["warm_until"], launch_drift=drift,
|
|
binary_version=binfo["version"], head_at_launch=head, brief_source=brief_path,
|
|
rebase_as=new, new_launch=new_launch, delta_notes=[n["path"] for n in notes])
|
|
write_registry(state, reg)
|
|
prompt = render_template("rebase.md", {
|
|
"base": args.base, "new_base": new, "run": norm_path(args.run), "state_dir": state,
|
|
"notes_dir": os.path.join(state, "notes"), "logs_dir": os.path.join(state, "logs"),
|
|
"phase": "n/a" if args.phase is None else str(args.phase), "delta": render_delta(delta, notes),
|
|
"brief": brief})
|
|
_start_job(state, norm_path(args.run), job, prompt, la)
|
|
return {"job": job, "base": new, "parent": args.base, "session_id": session}, EXIT_OK
|
|
|
|
|
|
# --- keep warm: the ping rule and the tick (design 5, C3d) ----------------------------
|
|
|
|
|
|
def run_for_state(state: str) -> str:
|
|
"""The run log path of a state dir (inverse of `state_dir_for`)."""
|
|
return state[: -len("-gremlins")] + ".md"
|
|
|
|
|
|
def launch_record(base: dict | None, cfg: dict) -> dict:
|
|
"""The launch fields a launch on `base` uses (M10): the base's own record (written at `train`
|
|
or `rebase`); the run config only for a base from before per-base records."""
|
|
return (base or {}).get("launch") or cfg["launch"]
|
|
|
|
|
|
def _job_num(job) -> int:
|
|
m = re.fullmatch(r"j(\d+)", str(job or ""))
|
|
return int(m.group(1)) if m else -1
|
|
|
|
|
|
def pings_since_real_call(reg: dict, name: str) -> int:
|
|
"""Pings of base `name` launched after its last real (non-ping) call. Job ids are monotonic
|
|
per run, so "after" is by job number (launch times have whole seconds only)."""
|
|
jobs = reg.get("jobs", {})
|
|
last_real = max((_job_num(j) for j, e in jobs.items()
|
|
if e.get("base") == name and e.get("kind") in REAL_KINDS), default=-1)
|
|
return sum(1 for j, e in jobs.items()
|
|
if e.get("base") == name and e.get("kind") == "ping" and _job_num(j) > last_real)
|
|
|
|
|
|
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,
|
|
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():
|
|
if e.get("base") == name and e.get("kind") in REAL_KINDS and e.get("phase") is not None \
|
|
and _job_num(j) > best:
|
|
best, ph = _job_num(j), e["phase"]
|
|
if ph is None:
|
|
ph = (reg.get("bases", {}).get(name) or {}).get("phase")
|
|
return ph
|
|
|
|
|
|
def ping_in_flight(reg: dict, base: dict | None) -> str | None:
|
|
"""The base's in-flight ping job (C3d), or None. A mark whose job is no longer running (or
|
|
gone) does not count: it clears itself on the next marking."""
|
|
j = (base or {}).get("ping_in_flight")
|
|
if j and (reg.get("jobs", {}).get(j) or {}).get("status") in RUNNING:
|
|
return j
|
|
return None
|
|
|
|
|
|
def run_active(reg: dict, now: float) -> bool:
|
|
"""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 ("spawn", "continue"):
|
|
continue
|
|
if e.get("status") in RUNNING:
|
|
return True
|
|
end = _ts(e.get("ended_at"))
|
|
if end is not None and now - end <= ACTIVITY_WINDOW_S:
|
|
return True
|
|
return False
|
|
|
|
|
|
def ping_decision(reg: dict, name: str, now: float | None = None) -> dict:
|
|
"""The ping rule for one base: pings on, base active, state expiring, no ping in flight, the
|
|
run active in the last 10 min, and fewer pings since its last real call than break_even."""
|
|
now = _now() if now is None else now
|
|
cfg = reg.get("config") or {}
|
|
base = reg["bases"][name]
|
|
w = base_warmth(base, cfg, now)
|
|
n = pings_since_real_call(reg, name)
|
|
be = break_even(w["ttl_s"])
|
|
inflight = ping_in_flight(reg, base)
|
|
if cfg.get("pings", CONFIG_DEFAULTS["pings"]) != "on":
|
|
why = "pings_off"
|
|
elif base.get("status") != "active" or not base.get("session"):
|
|
why = "status:%s" % base.get("status")
|
|
elif w["base_state"] != "expiring":
|
|
why = "state:%s" % w["base_state"]
|
|
elif inflight:
|
|
why = "in_flight:%s" % inflight
|
|
elif not run_active(reg, now):
|
|
why = "no_activity"
|
|
elif n >= be:
|
|
why = "cap:%d/%d" % (n, be)
|
|
else:
|
|
why = None
|
|
return {"base": name, "ping": why is None, "why": why, "base_state": w["base_state"],
|
|
"warm_until": w["warm_until"], "ttl_s": w["ttl_s"], "pings_since_real_call": n,
|
|
"break_even": be, "ping_in_flight": inflight}
|
|
|
|
|
|
def tick_pass(state: str) -> dict:
|
|
"""One pass of the ping rule over every base of the run. The decision and the `ping_in_flight`
|
|
mark are made under the registry lock (C3d), so concurrent tickers launch one ping per base.
|
|
The binary check runs before the lock for the bases that look eligible. Pings go through the
|
|
same hidden spawn as any job. Returns {"pinged": [job...], "bases": [decision...]}.
|
|
On a closed platform gate (A1) it returns at once: no ping, nothing read or written."""
|
|
if platform_gate() is not None:
|
|
return {"pinged": [], "bases": []}
|
|
reg = read_registry(state)
|
|
if not reg or not reg.get("config"):
|
|
return {"pinged": [], "bases": []}
|
|
now = _now()
|
|
decisions = [ping_decision(reg, n, now) for n in sorted(reg.get("bases", {}))]
|
|
pre = [d["base"] for d in decisions if d["ping"]]
|
|
if not pre:
|
|
return {"pinged": [], "bases": decisions}
|
|
checks = {}
|
|
for n in pre:
|
|
base = reg["bases"][n]
|
|
la = launch_record(base, reg["config"])
|
|
try:
|
|
checks[n] = _check_binary(base.get("binary") or la["binary"], la)
|
|
except GremlinsError as e:
|
|
checks[n] = e
|
|
launches = []
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
cfg = reg["config"]
|
|
now = _now()
|
|
decisions = []
|
|
for n in sorted(reg["bases"]):
|
|
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], GremlinsError):
|
|
d.update(ping=False, why=checks[n].payload.get("reason") or checks[n].payload["error"])
|
|
if d["ping"]:
|
|
base = reg["bases"][n]
|
|
la = launch_record(base, cfg)
|
|
binfo, drift = checks[n]
|
|
job = _next_job_id(reg)
|
|
session = str(uuid.uuid4())
|
|
argv = claude_argv(la) + ["--resume", base["session"], "--fork-session",
|
|
"--session-id", session, "--max-turns", "1"]
|
|
reg["jobs"][job] = _new_entry(
|
|
job, "ping", base=n, phase=ping_phase(reg, n), session=session,
|
|
parent_session=base["session"],
|
|
model_requested=la["model"], cwd=la["cwd"], argv=argv, max_s=PING_MAX_S,
|
|
cost_before=float(base.get("cost_cum_usd") or 0.0),
|
|
model_usage_before=base.get("model_usage_cum") or {},
|
|
base_context=base.get("context_tokens"), base_state_at_launch=d["base_state"],
|
|
warm_until_at_launch=d["warm_until"], launch_drift=drift,
|
|
binary_version=binfo["version"])
|
|
base["ping_in_flight"] = job
|
|
base["ping_in_flight_at"] = _iso(now)
|
|
d["job"] = job
|
|
launches.append((job, la))
|
|
decisions.append(d)
|
|
if launches:
|
|
write_registry(state, reg)
|
|
run = run_for_state(state)
|
|
for job, la in launches:
|
|
_start_job(state, run, job, PING_PROMPT, la)
|
|
return {"pinged": [j for j, _ in launches], "bases": decisions}
|
|
|
|
|
|
def safe_tick(state: str, where: str) -> dict | None:
|
|
"""A tick pass for `wait` and the supervisor: never raises (diagnostics on stderr)."""
|
|
try:
|
|
out = tick_pass(state)
|
|
except Exception as ex: # a keep-warm failure must not break `wait` or a supervisor
|
|
_diag("%s tick failed: %s: %s" % (where, type(ex).__name__, ex))
|
|
return None
|
|
if out["pinged"]:
|
|
_diag("%s tick: pinged %s" % (where, ", ".join("%s (%s)" % (d["job"], d["base"])
|
|
for d in out["bases"] if d.get("job"))))
|
|
return out
|
|
|
|
|
|
def cmd_tick(args) -> tuple[dict, int]:
|
|
"""`tick --run R`: one pass by hand (N3-refused in a gremlin)."""
|
|
state, _ = _load_run(args)
|
|
reap_lost(state)
|
|
out = tick_pass(state)
|
|
return {"state": state, **out}, EXIT_OK
|
|
|
|
|
|
# --- the supervisor -------------------------------------------------------------------
|
|
|
|
|
|
class SupClock:
|
|
"""The supervisor's run time with host suspends cut out (M3). Each loop tick reports the
|
|
wall clock; a gap above `jump_s` between ticks (the tick is ~SUP_TICK_S) is a suspend or a
|
|
clock jump: the gap minus one tick is left out of `elapsed`, and the orphan watchdog is
|
|
re-armed at the jump (one more `orphan_after` from now)."""
|
|
|
|
def __init__(self, t0: float, tick_s: float | None = None, jump_s: float | None = None):
|
|
self.t0 = self.prev = t0
|
|
self.tick_s = SUP_TICK_S if tick_s is None else tick_s
|
|
self.jump_s = CLOCK_JUMP_S if jump_s is None else jump_s
|
|
self.excluded_s = 0.0
|
|
self.rearmed_at: float | None = None
|
|
self.jumps: list[float] = []
|
|
|
|
def tick(self, now: float) -> float | None:
|
|
"""Record a tick; returns the excluded seconds if this tick follows a jump, else None."""
|
|
gap, self.prev = now - self.prev, now
|
|
if gap <= self.jump_s:
|
|
return None
|
|
skipped = gap - self.tick_s
|
|
self.excluded_s += skipped
|
|
self.rearmed_at = now
|
|
self.jumps.append(skipped)
|
|
return skipped
|
|
|
|
def elapsed(self, now: float) -> float:
|
|
"""Seconds of run time since t0 (what `max_minutes` is measured against)."""
|
|
return now - self.t0 - self.excluded_s
|
|
|
|
def orphan_age(self, now: float, last_call: float) -> float:
|
|
"""Seconds the orphan watchdog counts: since the later of the last orchestrator call and
|
|
the last re-arm."""
|
|
ref = last_call if self.rearmed_at is None else max(last_call, self.rearmed_at)
|
|
return now - ref
|
|
|
|
|
|
class Supervisor:
|
|
"""`_supervise`: runs one `claude` call, parses its stream, watches it, writes the result.
|
|
The only writer of `jobs/<job>.json` (C3b)."""
|
|
|
|
def __init__(self, state: str, job: str):
|
|
self.state, self.job = state, job
|
|
reg = read_registry(state)
|
|
if not reg or job not in reg.get("jobs", {}):
|
|
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"))
|
|
self.ss = StreamState()
|
|
self.mu = threading.Lock()
|
|
self.flags: list[str] = []
|
|
self.last_output = _now()
|
|
# `messages` (M16): a progress counter of distinct assistant message ids; the result's and
|
|
# the ledger's `turns` is `result.num_turns`.
|
|
self.rt = {"job": job, "status": "running", "supervisor": pid_record(os.getpid()), "claude": None,
|
|
"started_at": _iso(), "heartbeat_at": _iso(), "messages": 0, "last_event_at": None,
|
|
"flags": []}
|
|
self._last_write = 0.0
|
|
|
|
def write_rt(self, **kw) -> None:
|
|
with self.mu:
|
|
self.rt.update(kw)
|
|
self.rt["messages"] = self.ss.turns
|
|
self.rt["last_event_at"] = _iso(self.ss.last_event_at) if self.ss.last_event_at else None
|
|
self.rt["flags"] = list(self.flags)
|
|
self.rt["heartbeat_at"] = _iso()
|
|
snap = dict(self.rt)
|
|
write_json_atomic(job_file(self.state, self.job), snap)
|
|
self._last_write = _now()
|
|
|
|
def _reader(self, stream, raw) -> None:
|
|
for line in iter(stream.readline, b""):
|
|
t = _now()
|
|
raw.write(line)
|
|
raw.flush()
|
|
try:
|
|
ev = json.loads(line.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
ev = None
|
|
with self.mu:
|
|
self.last_output = t
|
|
if ev is not None:
|
|
self.ss.feed(ev, t)
|
|
|
|
def _feeder(self, stdin, data: bytes) -> None:
|
|
with contextlib.suppress(OSError, ValueError):
|
|
stdin.write(data)
|
|
with contextlib.suppress(OSError, ValueError):
|
|
stdin.close()
|
|
|
|
def run(self) -> tuple[dict, int]:
|
|
e = self.entry
|
|
if e.get("status") not in RUNNING or e.get("kill_requested"):
|
|
return {"job": self.job, "skipped": "job is %s" % e.get("status")}, EXIT_OK
|
|
if self.env is None:
|
|
finalize_job(self.state, self.job, ss=None, hint="failed", reason="supervisor_error",
|
|
detail="missing env file")
|
|
return {"error": "missing env file"}, EXIT_USAGE
|
|
os.environ.clear() # git and tree-kill lookups use the child's PATH
|
|
os.environ.update(self.env)
|
|
self.write_rt()
|
|
with open(os.path.join(self.state, "briefs", self.job + ".md"), "rb") as f:
|
|
prompt = f.read()
|
|
t0 = _now()
|
|
raw = open(raw_file(self.state, self.job), "wb")
|
|
err = open(raw_file(self.state, self.job, ".err"), "wb")
|
|
try:
|
|
p = subprocess.Popen(e["argv"], cwd=e["cwd"], env=self.env, stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE, stderr=err,
|
|
**PLATFORM.popen_kwargs(new_group=True))
|
|
except OSError as ex:
|
|
raw.close()
|
|
err.close()
|
|
finalize_job(self.state, self.job, ss=self.ss, hint="failed", reason="launch_error",
|
|
detail=str(ex), wall_s=0)
|
|
self.write_rt(status="failed")
|
|
return {"error": "launch_error", "detail": str(ex)}, EXIT_USAGE
|
|
crec = pid_record(p.pid)
|
|
self.write_rt(claude=crec)
|
|
reader = threading.Thread(target=self._reader, args=(p.stdout, raw), daemon=True)
|
|
reader.start()
|
|
threading.Thread(target=self._feeder, args=(p.stdin, prompt), daemon=True).start()
|
|
max_s = float(e.get("max_s") or float(self.cfg["max_minutes"]) * 60)
|
|
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 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
|
|
last_tick = t0
|
|
while p.poll() is None:
|
|
now = _now()
|
|
skipped = clock.tick(now)
|
|
if skipped is not None:
|
|
_diag("clock jump: %.0f s between supervisor ticks at %s (host suspend?); not counted "
|
|
"toward max_minutes, orphan watchdog re-armed" % (skipped, _iso(now)))
|
|
if "clock_jump" not in self.flags:
|
|
self.flags.append("clock_jump")
|
|
self.write_rt()
|
|
with self.mu:
|
|
refused, first_at, messages = self.ss.refused, self.ss.first_at, self.ss.turns
|
|
quiet = now - self.last_output
|
|
if refused:
|
|
kill_tree(crec, alive_until=time.time())
|
|
break # decided as model_refused from the stream
|
|
if clock.elapsed(now) > max_s:
|
|
kill_tree(crec, alive_until=time.time())
|
|
hint, detail = "timeout", "run time over max_minutes (%.0f s)" % max_s
|
|
break
|
|
if quiet > stall_s and "stalled" not in self.flags:
|
|
self.flags.append("stalled")
|
|
self.write_rt()
|
|
if not hit_written and first_at:
|
|
hit_written = True
|
|
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 (GremlinsError, OSError):
|
|
last = None
|
|
if last is not None and clock.orphan_age(now, last) > orphan_s:
|
|
kill_tree(crec, alive_until=time.time())
|
|
hint, reason, flags_extra = "killed", "orphaned", ["orphaned"]
|
|
detail = "no orchestrator call for %.0f s (orphan_after %.0f s)" % (
|
|
clock.orphan_age(now, last), orphan_s)
|
|
break
|
|
if now - self._last_write >= HEARTBEAT_S or (messages != last_messages
|
|
and now - self._last_write >= 1.0):
|
|
last_messages = messages
|
|
self.write_rt()
|
|
if now - last_tick >= TICK_SUP_S: # keep warm (design 5): one pass a minute
|
|
last_tick = now
|
|
safe_tick(self.state, "supervisor %s" % self.job)
|
|
time.sleep(SUP_TICK_S)
|
|
exit_code = p.wait()
|
|
# leftovers of claude (backgrounded commands); claude exited just now
|
|
sweep = kill_tree(crec, alive_until=time.time())
|
|
if sweep["snapshot"]:
|
|
_diag("swept %d leftover process(es) of claude: %s" % (
|
|
len(sweep["snapshot"]), [(x["pid"], x["name"]) for x in sweep["snapshot"]]))
|
|
reader.join(READER_JOIN_S)
|
|
if not reader.is_alive(): # the reader is done with the pipe (Minor 10)
|
|
with contextlib.suppress(OSError):
|
|
p.stdout.close()
|
|
with contextlib.suppress(OSError):
|
|
raw.close()
|
|
with contextlib.suppress(OSError):
|
|
err.close()
|
|
wall = _now() - t0
|
|
head_end = _git_head(e["cwd"]) # HEAD when the call ended (`usage` Commit column)
|
|
base_commit = None
|
|
if e["kind"] in ("train", "rebase"): # the new base's HEAD at the end of the call
|
|
base_commit = head_end or e.get("head_at_launch")
|
|
res = finalize_job(self.state, self.job, ss=self.ss, hint=hint, flags=self.flags + flags_extra,
|
|
reason=reason, detail=detail, exit_code=exit_code, wall_s=wall,
|
|
base_commit=base_commit, head_at_end=head_end)
|
|
final = res["status"] if res else (read_registry(self.state)["jobs"][self.job].get("status"))
|
|
self.write_rt(status=final, ended_at=_iso(), exit_code=exit_code)
|
|
return {"job": self.job, "status": final}, EXIT_OK
|
|
|
|
|
|
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 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()))
|
|
try:
|
|
return Supervisor(state, args.job).run()
|
|
except Exception as ex:
|
|
traceback.print_exc(file=log)
|
|
with contextlib.suppress(Exception):
|
|
rt = _job_runtime(state, args.job)
|
|
if rt.get("claude"):
|
|
kill_tree(rt["claude"], alive_until=time.time())
|
|
finalize_job(state, args.job, ss=StreamState.from_file(raw_file(state, args.job)),
|
|
hint="failed", reason="supervisor_error", detail="%s: %s" % (type(ex).__name__, ex))
|
|
raise
|
|
|
|
|
|
# --- wait, list, kill, expire -----------------------------------------------------------
|
|
|
|
|
|
def _running_info(state: str, job: str, e: dict, now: float) -> dict:
|
|
rt = _job_runtime(state, job)
|
|
start = _ts(rt.get("started_at")) or _ts(e.get("launched_at")) or _ts(e.get("created_at"))
|
|
last = _ts(rt.get("last_event_at"))
|
|
return {"job": job, "block": e.get("block"),
|
|
"elapsed_s": None if start is None else round(now - start, 1),
|
|
"last_event_s_ago": None if last is None else round(now - last, 1),
|
|
"messages": int(rt.get("messages") or 0)}
|
|
|
|
|
|
def cmd_wait(args) -> tuple[dict, int]:
|
|
state, reg = _load_run(args)
|
|
if args.timeout > WAIT_MAX_S:
|
|
raise GremlinsError("--timeout over the maximum", timeout=args.timeout, max=WAIT_MAX_S)
|
|
if args.jobs and args.all:
|
|
raise GremlinsError("name jobs or --all, not both")
|
|
if not args.jobs and not args.all:
|
|
raise GremlinsError("name jobs or --all")
|
|
unknown = [j for j in args.jobs if j not in reg["jobs"]]
|
|
if unknown:
|
|
raise GremlinsError("unknown job", jobs=unknown)
|
|
pings = [j for j in args.jobs if reg["jobs"][j].get("kind") == "ping"]
|
|
if 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 gremlin's `wait` never launches pings
|
|
next_tick = time.monotonic() # one tick pass at the start, then every TICK_WAIT_S
|
|
|
|
def evaluate(r):
|
|
members = list(args.jobs) if args.jobs else sorted(
|
|
j for j, x in r["jobs"].items() if not x.get("settled_at") and x.get("kind") != "ping")
|
|
finished = [j for j in members if r["jobs"][j].get("status") in FINAL]
|
|
running = [j for j in members if j not in finished]
|
|
met = bool(finished) or not members if args.any else not running
|
|
return members, finished, running, met
|
|
|
|
while True:
|
|
reap_lost(state)
|
|
if ticks and time.monotonic() >= next_tick:
|
|
next_tick = time.monotonic() + TICK_WAIT_S
|
|
safe_tick(state, "wait")
|
|
members, finished, running, met = evaluate(read_registry(state))
|
|
left = deadline - time.monotonic()
|
|
if met or left <= 0:
|
|
break
|
|
time.sleep(min(WAIT_POLL_S, left))
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
members, finished, running, met = evaluate(reg)
|
|
done, now = [], _now()
|
|
for j in finished:
|
|
e = reg["jobs"][j]
|
|
if e.get("delivered_at"):
|
|
continue
|
|
res = read_json(result_file(state, j))
|
|
if res is not None:
|
|
done.append(res)
|
|
e["delivered_at"] = _iso(now)
|
|
if met:
|
|
for j in finished:
|
|
reg["jobs"][j]["settled_at"] = _iso(now)
|
|
# Pings never come back through `done` and never count toward the exit code: each
|
|
# finished ping result is returned once, in `pings`.
|
|
ping_results = []
|
|
for j, e in sorted(reg["jobs"].items()):
|
|
if e.get("kind") != "ping" or e.get("status") not in FINAL or e.get("delivered_at"):
|
|
continue
|
|
res = read_json(result_file(state, j))
|
|
if res is not None:
|
|
ping_results.append(res)
|
|
e["delivered_at"] = _iso(now)
|
|
stamp_orchestrator(reg, now, wait=True)
|
|
write_registry(state, reg)
|
|
statuses = {j: reg["jobs"][j].get("status") for j in finished}
|
|
entries = {j: dict(reg["jobs"][j]) for j in running}
|
|
out = {"done": done, "running": [_running_info(state, j, entries[j], now) for j in running],
|
|
"pings": ping_results}
|
|
if not met:
|
|
return out, EXIT_WAIT_TIMEOUT
|
|
return out, (EXIT_OK if all(s == "ok" for s in statuses.values()) else EXIT_JOB_FAILED)
|
|
|
|
|
|
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", "gremlins", "phase",
|
|
"created_at", "trained_at", "ttl_from", "ping_in_flight", "parent",
|
|
"lineage_rebases", "rebase_job", "superseded_by", "overrides", "fingerprint")
|
|
|
|
|
|
def cmd_list(args) -> tuple[dict, int]:
|
|
state, _ = _load_run(args)
|
|
touch_orchestrator(state)
|
|
lost = reap_lost(state)
|
|
reg = read_registry(state)
|
|
now = _now()
|
|
cfg = reg["config"]
|
|
bases = []
|
|
bcache: dict = {}
|
|
for name, b in sorted(reg["bases"].items()):
|
|
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 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))
|
|
else:
|
|
row.update({"rebase_due": False, "retrain_due": False, "reasons": []})
|
|
bases.append(row)
|
|
jobs, running = [], []
|
|
for j, e in sorted(reg["jobs"].items()):
|
|
row = {k: e.get(k) for k in LIST_JOB_KEYS}
|
|
if e.get("status") in RUNNING:
|
|
info = _running_info(state, j, e, now)
|
|
row.update({k: info[k] for k in ("elapsed_s", "last_event_s_ago", "messages")})
|
|
running.append(j)
|
|
else:
|
|
row["turns"] = e.get("turns")
|
|
jobs.append(row)
|
|
return {"state": state, "bases": bases, "jobs": jobs, "running": running, "lost_now": lost}, EXIT_OK
|
|
|
|
|
|
def cmd_kill(args) -> tuple[dict, int]:
|
|
state, reg = _load_run(args)
|
|
if args.job not in reg["jobs"]:
|
|
raise GremlinsError("unknown job", job=args.job)
|
|
return kill_job(state, args.job, "kill"), EXIT_OK
|
|
|
|
|
|
def cmd_expire(args) -> tuple[dict, int]:
|
|
state, reg = _load_run(args)
|
|
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 GremlinsError("unknown base", bases=unknown)
|
|
retired = []
|
|
with RegistryLock(state):
|
|
reg = read_registry(state)
|
|
for n in names:
|
|
b = reg["bases"][n]
|
|
if b.get("status") not in ("retired", "superseded"):
|
|
b["status"] = "retired"
|
|
b["retired_at"] = _iso()
|
|
retired.append(n)
|
|
write_registry(state, reg)
|
|
targets = sorted(j for j, e in reg["jobs"].items()
|
|
if e.get("base") in names and e.get("status") in RUNNING)
|
|
killed = [kill_job(state, j, "expire") for j in targets]
|
|
return {"retired": retired, "bases": names, "killed": killed,
|
|
"ok": all(k.get("ok", True) for k in killed)}, EXIT_OK
|
|
|
|
|
|
# --- usage (design 6, block 2.3) ------------------------------------------------------------
|
|
|
|
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 Δ |")
|
|
USAGE_PHASE_HEADER = "| Kind | Calls | in | out | cache write | cache read | cost |"
|
|
SHORT_SESSION = 8
|
|
SHORT_COMMIT = 7
|
|
|
|
|
|
def ledger_from_result(res: dict, entry: dict | None, mtime: float | None) -> dict:
|
|
"""R1: a ledger line for a result that has none, from the result plus its registry entry
|
|
(what `_conclude` would have appended). Marked `reconciled`."""
|
|
e = entry or {}
|
|
ended = e.get("ended_at") or (_iso(mtime) if mtime else None)
|
|
return {
|
|
"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"),
|
|
"model_requested": res.get("model_requested"), "models_used": res.get("models_used") or [],
|
|
"tokens": res.get("tokens") or {k: None for k in TOKEN_KEYS},
|
|
"cost_delta_usd": res.get("cost_delta_usd"), "cost_cumulative_usd": e.get("cost_cum_usd"),
|
|
"turns": res.get("turns"), "cache": res.get("cache"), "base_state": res.get("base_state"),
|
|
"fingerprint": res.get("fingerprint"), "denials": res.get("denials"),
|
|
"status": res.get("status"), "flags": res.get("flags") or [], "reason": res.get("reason"),
|
|
"note": res.get("note"), "reconciled": True,
|
|
}
|
|
|
|
|
|
def reconcile_ledger(state: str) -> list[str]:
|
|
"""R1: a kill between the registry write and the ledger append leaves a finished job with a
|
|
result and no ledger line. Append one for every result that has none, under the registry lock
|
|
and idempotently (the ledger dedupes on job id). Returns the job ids appended."""
|
|
rdir = os.path.join(state, "results")
|
|
try:
|
|
names = sorted(n for n in os.listdir(rdir) if n.endswith(".json"))
|
|
except FileNotFoundError:
|
|
return []
|
|
have, _ = _ledger_jobs(state)
|
|
if all(n[:-5] in have for n in names):
|
|
return [] # nothing to do: take no lock
|
|
added = []
|
|
with RegistryLock(state):
|
|
reg = read_registry(state) or {}
|
|
have, _ = _ledger_jobs(state) # re-read under the lock: a finaliser may have run
|
|
for n in names:
|
|
job = n[:-5]
|
|
if job in have:
|
|
continue
|
|
path = os.path.join(rdir, n)
|
|
try:
|
|
res = read_json(path)
|
|
mtime = os.path.getmtime(path)
|
|
except (GremlinsError, OSError) as ex:
|
|
_diag("cannot reconcile %s: %s" % (job, ex))
|
|
continue
|
|
if not isinstance(res, dict):
|
|
continue
|
|
res.setdefault("job", job)
|
|
if ledger_append(state, ledger_from_result(res, (reg.get("jobs") or {}).get(job), mtime)):
|
|
added.append(job)
|
|
if added:
|
|
_diag("ledger reconciled: appended %s" % ", ".join(added))
|
|
return added
|
|
|
|
|
|
def read_ledger(state: str) -> list[dict]:
|
|
try:
|
|
data = read_bytes_retry(ledger_path(state))
|
|
except FileNotFoundError:
|
|
return []
|
|
out = []
|
|
for ln in data.splitlines():
|
|
try:
|
|
obj = json.loads(ln.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
continue
|
|
if isinstance(obj, dict) and obj.get("job"):
|
|
out.append(obj)
|
|
return sorted(out, key=lambda x: _job_num(x["job"]))
|
|
|
|
|
|
def _usage_zero() -> dict:
|
|
return {"calls": 0, "input": 0, "output": 0, "cache_creation": 0, "cache_creation_1h": 0,
|
|
"cache_creation_5m": 0, "cache_read": 0, "cost_usd": 0.0}
|
|
|
|
|
|
def _usage_add(acc: dict, line: dict) -> None:
|
|
t = line.get("tokens") or {}
|
|
acc["calls"] += 1
|
|
for k in TOKEN_KEYS:
|
|
acc[k] += int(t.get(k) or 0)
|
|
acc["cost_usd"] = round(acc["cost_usd"] + float(line.get("cost_delta_usd") or 0.0), 6)
|
|
|
|
|
|
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 / (spawn + continue + rebase calls that have a verdict)."""
|
|
kinds = {k: _usage_zero() for k in USAGE_KINDS}
|
|
total = _usage_zero()
|
|
hits = verdicts = cold = 0
|
|
for ln in lines:
|
|
_usage_add(kinds.setdefault(ln.get("kind") or "?", _usage_zero()), ln)
|
|
_usage_add(total, ln)
|
|
if "cold_start" in (ln.get("flags") or []):
|
|
cold += 1
|
|
if ln.get("kind") in HIT_KINDS and ln.get("cache"):
|
|
verdicts += 1
|
|
hits += ln["cache"] == "hit"
|
|
return {"kinds": kinds, "total": total, "cold_starts": cold, "cache_hits": hits,
|
|
"cache_verdicts": verdicts,
|
|
"cache_hit_ratio": round(hits / verdicts, 4) if verdicts else None}
|
|
|
|
|
|
def commits_between(cwd: str | None, a: str | None, b: str | None) -> list[str]:
|
|
"""Short hashes of the commits in `a..b` (oldest first); empty when unknown or on a git failure."""
|
|
if not (cwd and a and b) or a == b:
|
|
return []
|
|
try:
|
|
r = git(["rev-list", "--reverse", "%s..%s" % (a, b)], cwd=cwd)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return []
|
|
if r.returncode != 0:
|
|
return []
|
|
return [x[:SHORT_COMMIT] for x in r.stdout.decode("utf-8", "replace").split()]
|
|
|
|
|
|
def _n(v) -> str:
|
|
return DASH if v is None else "{:,}".format(int(v))
|
|
|
|
|
|
def _cost(v) -> str:
|
|
return DASH if v is None else "${:.4f}".format(float(v))
|
|
|
|
|
|
def usage_md_block(lines: list[dict], jobs: dict) -> str:
|
|
"""The block table: one row per call; Commit = the commits from the job's `head_at_launch` to
|
|
its `head_at_end` for a sequential job, an em dash for a parallel one (the Athanor commits)."""
|
|
rows = [USAGE_BLOCK_HEADER, "|---|---|---|---|---|---|---|---|---|"]
|
|
for ln in lines:
|
|
e = jobs.get(ln["job"]) or {}
|
|
commit = DASH
|
|
if e.get("mode") != "parallel":
|
|
commit = ", ".join(commits_between(e.get("cwd"), e.get("head_at_launch"),
|
|
e.get("head_at_end"))) or DASH
|
|
t = ln.get("tokens") or {}
|
|
known = ln.get("tokens") is not None and any(t.get(k) is not None for k in TOKEN_KEYS)
|
|
sess = str(ln.get("session") or "")[:SHORT_SESSION] or DASH
|
|
cells = [str(ln.get("block") if ln.get("block") is not None else DASH),
|
|
", ".join(ln.get("models_used") or []) or DASH, sess, commit]
|
|
cells += [_n(t.get(k) if known else None) for k in ("input", "output", "cache_creation",
|
|
"cache_read")]
|
|
cells.append(_cost(ln.get("cost_delta_usd")))
|
|
rows.append("| " + " | ".join(cells) + " |")
|
|
return "\n".join(rows) + "\n"
|
|
|
|
|
|
def usage_md_phase(agg: dict) -> str:
|
|
"""The per-kind totals table with a total row, then cold starts, cache-hit ratio, priced total."""
|
|
rows = [USAGE_PHASE_HEADER, "|---|---|---|---|---|---|---|"]
|
|
|
|
def row(name, a):
|
|
return "| %s | %s | %s | %s | %s | %s | %s |" % (
|
|
name, a["calls"], _n(a["input"]), _n(a["output"]), _n(a["cache_creation"]),
|
|
_n(a["cache_read"]), _cost(a["cost_usd"]))
|
|
for k, a in agg["kinds"].items():
|
|
if a["calls"]:
|
|
rows.append(row(k, a))
|
|
rows.append(row("total", agg["total"]))
|
|
if agg["cache_verdicts"]:
|
|
ratio = "%d/%d (%.1f%%)" % (agg["cache_hits"], agg["cache_verdicts"],
|
|
100.0 * agg["cache_hits"] / agg["cache_verdicts"])
|
|
else:
|
|
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,
|
|
"- Priced total: %s" % _cost(agg["total"]["cost_usd"])]) + "\n"
|
|
|
|
|
|
RESULT_STATUS_KEYS = ("status", "flags", "reason")
|
|
|
|
|
|
def status_from_results(state: str, lines: list[dict]) -> tuple[list[dict], list[str]]:
|
|
"""Minor 11: the ledger is append-only, so a job finalised again after its line was written
|
|
(R1 reconciled a result whose entry still read `running`, then `reap_lost` or `kill` rewrote
|
|
the result as `lost`/`killed`) keeps its first status there. `usage` takes each job's
|
|
status, flags and reason from `results/<job>.json` when they differ from its ledger line;
|
|
tokens and cost stay the ledger's. Returns (lines, the jobs whose status came from a result)."""
|
|
out, changed = [], []
|
|
for ln in lines:
|
|
try:
|
|
res = read_json(result_file(state, ln["job"]))
|
|
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 []),
|
|
reason=res.get("reason"))
|
|
changed.append(ln["job"])
|
|
out.append(ln)
|
|
return out, changed
|
|
|
|
|
|
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 gremlin (N3)."""
|
|
state, _ = _load_run(args)
|
|
touch_orchestrator(state)
|
|
added = reconcile_ledger(state)
|
|
reg = read_registry(state) or {}
|
|
lines = read_ledger(state)
|
|
scope = None
|
|
if args.block is not None:
|
|
scope = {"block": args.block}
|
|
lines = [x for x in lines if x.get("block") is not None and str(x["block"]) == str(args.block)]
|
|
elif args.phase is not None:
|
|
scope = {"phase": _phase(args.phase)}
|
|
lines = [x for x in lines if x.get("phase") == scope["phase"]]
|
|
lines, from_result = status_from_results(state, lines)
|
|
if args.format == "md":
|
|
if args.block is not None:
|
|
return usage_md_block(lines, reg.get("jobs") or {}), EXIT_OK
|
|
return usage_md_phase(usage_aggregate(lines)), EXIT_OK
|
|
agg = usage_aggregate(lines)
|
|
return {"state": state, "scope": scope, "reconciled": added, "status_from_result": from_result,
|
|
**agg, "rows": lines}, EXIT_OK
|
|
|
|
|
|
HANDLERS = {
|
|
"where": cmd_where,
|
|
"init": cmd_init,
|
|
"train": cmd_train,
|
|
"spawn": cmd_spawn,
|
|
"continue": cmd_continue,
|
|
"wait": cmd_wait,
|
|
"list": cmd_list,
|
|
"expire": cmd_expire,
|
|
"kill": cmd_kill,
|
|
"_supervise": cmd_supervise,
|
|
"rebase": cmd_rebase,
|
|
"usage": cmd_usage,
|
|
"tick": cmd_tick,
|
|
}
|
|
|
|
|
|
def run(argv) -> tuple[dict, int]:
|
|
parser = build_parser()
|
|
try:
|
|
args, unknown = parser.parse_known_args(argv)
|
|
except _HelpRequested as h:
|
|
return {"help": h.text, "commands": [c for c in COMMANDS if not c.startswith("_")]}, EXIT_OK
|
|
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 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)
|
|
if args.command in GATED_COMMANDS:
|
|
require_platform() # A1: an existing registry launches nothing on a closed gate
|
|
return HANDLERS[args.command](args)
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
if argv is None:
|
|
argv = sys.argv[1:]
|
|
try:
|
|
out, code = run(argv)
|
|
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)
|
|
out, code = {"error": "internal error", "type": type(e).__name__, "detail": str(e)}, EXIT_USAGE
|
|
if isinstance(out, str): # `usage --format md`: one markdown fragment (plan 4.1 exception)
|
|
buf = getattr(sys.stdout, "buffer", None)
|
|
if buf is not None: # UTF-8 bytes whatever the console code page (the table has Δ, —)
|
|
sys.stdout.flush()
|
|
buf.write(out.encode("utf-8"))
|
|
buf.flush()
|
|
else:
|
|
sys.stdout.write(out)
|
|
sys.stdout.flush()
|
|
else:
|
|
_emit(out)
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|