1125 lines
40 KiB
Python
1125 lines
40 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = []
|
|
# ///
|
|
"""spire:forks helper: trains one headless implementer per build and forks it per block.
|
|
|
|
Run as `uv run <abs path>/forks.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>-forks/`.
|
|
"""
|
|
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 subprocess
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import uuid
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Constants
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
SCHEMA = 1
|
|
|
|
EXIT_OK = 0
|
|
EXIT_USAGE = 2
|
|
EXIT_WAIT_TIMEOUT = 3
|
|
EXIT_JOB_FAILED = 4
|
|
|
|
IS_WINDOWS = os.name == "nt"
|
|
|
|
# How the detached supervisor is created (C6, P0.1 rung 3 hidden; POSIX: new session).
|
|
SPAWN_METHOD = "cim-hidden" if IS_WINDOWS else "setsid"
|
|
CREATE_NO_WINDOW = 0x08000000
|
|
_NOWIN = CREATE_NO_WINDOW if IS_WINDOWS else 0
|
|
|
|
# Registry file semantics (C3a).
|
|
LOCK_STALE_S = 30.0
|
|
LOCK_WAIT_S = 45.0 # longer than LOCK_STALE_S, so a waiter can break a stale lock
|
|
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")
|
|
PERMISSION_MODES = ("default", "acceptEdits", "dontAsk", "bypassPermissions", "auto")
|
|
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",
|
|
})
|
|
SCRUB_PINNED = {**ENV_PINNED_LAUNCH, "SPIRE_FORKS_CHILD": "1"}
|
|
SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path
|
|
CHILD_MARKER = "SPIRE_FORKS_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 = ("rebase", "usage", "tick")
|
|
CHILD_REFUSED = ("train", "fork", "continue", "rebase") # N3 (tick pings: Phase 2)
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Errors, output, clock
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
class ForksError(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("forks: " + 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 _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_hash(launch: dict) -> str:
|
|
return _sha256_bytes(canonical_json(launch).encode("utf-8"))
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Paths
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
_GITBASH_DRIVE = re.compile(r"^/([A-Za-z])(?=/|$)")
|
|
|
|
|
|
def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> str:
|
|
"""Normalise relative, `G:\\…`, `G:/…` and Git Bash `/g/…` forms.
|
|
|
|
Windows: absolute path with backslashes and an upper-case drive letter.
|
|
POSIX: `os.path.abspath` semantics (relative to `cwd`).
|
|
"""
|
|
if windows is None:
|
|
windows = IS_WINDOWS
|
|
if not isinstance(p, str) or not p.strip():
|
|
raise ForksError("empty path")
|
|
p = p.strip()
|
|
if not windows:
|
|
base = cwd if cwd is not None else os.getcwd()
|
|
return posixpath.normpath(posixpath.join(base, p))
|
|
m = _GITBASH_DRIVE.match(p)
|
|
if m:
|
|
p = m.group(1).upper() + ":" + (p[2:] or "/")
|
|
p = p.replace("/", "\\")
|
|
drive, rest = ntpath.splitdrive(p)
|
|
if not (drive and rest.startswith("\\")):
|
|
base = norm_path(cwd if cwd is not None else os.getcwd(), windows=True)
|
|
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 ForksError("cannot normalise path", path=p)
|
|
if len(drive) == 2 and drive[1] == ":":
|
|
p = drive.upper() + rest
|
|
return p
|
|
|
|
|
|
def state_dir_for(run: str) -> str:
|
|
"""`reports/<x>-run.md` -> `reports/<x>-run-forks` (a sibling of the run log)."""
|
|
run_n = norm_path(run)
|
|
if not run_n.lower().endswith(".md"):
|
|
raise ForksError("--run must name the run log .md file", run=run_n)
|
|
return run_n[:-3] + "-forks"
|
|
|
|
|
|
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 ForksError("corrupt state file", path=path, detail=str(e))
|
|
|
|
|
|
class RegistryLock:
|
|
"""`registry.lock`: create-exclusive; a lock older than LOCK_STALE_S may be broken."""
|
|
|
|
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) of the current lock, or (None, None) if gone."""
|
|
try:
|
|
raw = read_bytes_retry(self.path)
|
|
mtime = os.path.getmtime(self.path)
|
|
except (FileNotFoundError, PermissionError):
|
|
return None, None
|
|
at = None
|
|
try:
|
|
at = float(json.loads(raw.decode("utf-8")).get("at"))
|
|
except (ValueError, TypeError, AttributeError):
|
|
pass
|
|
if at is None:
|
|
at = mtime
|
|
return raw, _now() - at
|
|
|
|
def acquire(self) -> "RegistryLock":
|
|
deadline = time.monotonic() + self.timeout
|
|
while True:
|
|
if self._try_create():
|
|
self.held = True
|
|
return self
|
|
raw, age = self._read_lock()
|
|
if raw is not None and age is not None and age > LOCK_STALE_S:
|
|
# Break it only if it is still the same lock we judged stale.
|
|
again, _ = self._read_lock()
|
|
if again == raw:
|
|
with contextlib.suppress(FileNotFoundError, PermissionError):
|
|
os.remove(self.path)
|
|
self.broke_stale = True
|
|
_diag("broke stale registry lock (age %.1f s)" % age)
|
|
continue
|
|
if time.monotonic() >= deadline:
|
|
raise ForksError("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 ForksError("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_append(state: str, line: dict) -> None:
|
|
"""Append one JSON line. Caller must hold RegistryLock(state) (C3c)."""
|
|
data = (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)
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Processes: pid + creation time (A19), subprocess helpers, git (C7)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
if IS_WINDOWS:
|
|
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
|
|
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
_STILL_ACTIVE = 259
|
|
_ERROR_ACCESS_DENIED = 5
|
|
|
|
|
|
def proc_ctime(pid: int) -> int | None:
|
|
"""Process creation time: Windows FILETIME ticks; POSIX /proc start time; else None."""
|
|
if IS_WINDOWS:
|
|
h = _k32.OpenProcess(_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)
|
|
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 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`."""
|
|
if pid is None or int(pid) <= 0:
|
|
return False
|
|
pid = int(pid)
|
|
if IS_WINDOWS:
|
|
h = _k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
if not h:
|
|
# Exists but not openable: alive, creation time unverifiable.
|
|
return ctypes.get_last_error() == _ERROR_ACCESS_DENIED
|
|
try:
|
|
code = _wt.DWORD()
|
|
if not _k32.GetExitCodeProcess(h, ctypes.byref(code)) or code.value != _STILL_ACTIVE:
|
|
return False
|
|
finally:
|
|
_k32.CloseHandle(h)
|
|
else:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
pass
|
|
if ctime is not None:
|
|
now_ct = proc_ctime(pid)
|
|
if now_ct is not None and now_ct != ctime:
|
|
return False
|
|
return True
|
|
|
|
|
|
def record_alive(rec: dict | None) -> bool:
|
|
return bool(rec) and pid_alive(rec.get("pid"), rec.get("ctime"))
|
|
|
|
|
|
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, creationflags=_NOWIN)
|
|
|
|
|
|
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"
|
|
ep = environ.get("CLAUDE_CODE_EXECPATH")
|
|
if ep:
|
|
return norm_path(ep), "CLAUDE_CODE_EXECPATH"
|
|
w = shutil.which("claude")
|
|
if w:
|
|
return norm_path(w), "PATH"
|
|
return None, 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 ForksError otherwise."""
|
|
if not path or not os.path.isfile(path):
|
|
raise ForksError("claude binary not found", binary=path)
|
|
st = os.stat(path)
|
|
version = _binary_version(path)
|
|
if not version:
|
|
raise ForksError("claude binary did not report a version", binary=path)
|
|
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 ForksError 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}
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Argument parsing (argparse that never prints to stdout or exits on its own)
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
class _Parser(argparse.ArgumentParser):
|
|
def error(self, message):
|
|
raise ForksError("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 ForksError("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
|
|
|
|
|
|
COMMANDS = ("where", "init", "train", "fork", "continue", "wait", "list", "expire", "kill",
|
|
"rebase", "usage", "tick", "_supervise")
|
|
|
|
|
|
def build_parser() -> _Parser:
|
|
p = _Parser(prog="forks.py", description="spire:forks helper (one JSON object on stdout).")
|
|
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, spawn method, env scrub.")
|
|
w.add_argument("--binary", help="claude binary to report instead of the default resolution")
|
|
|
|
i = cmd("init", "Create the state dir and the launch config (idempotent).")
|
|
i.add_argument("--model", required=True, help="full model id, e.g. claude-opus-5-5[1m]")
|
|
i.add_argument("--effort", default="high", choices=EFFORTS)
|
|
i.add_argument("--permission-mode", default="dontAsk")
|
|
i.add_argument("--tools", default=DEFAULT_TOOLS, help="comma-separated tool names")
|
|
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 base and forks (default: git toplevel)")
|
|
i.add_argument("--binary", help="claude binary (default: $CLAUDE_CODE_EXECPATH, else PATH)")
|
|
i.add_argument("--parallel-cap", type=_pos_int, default=CONFIG_DEFAULTS["parallel_cap"])
|
|
i.add_argument("--orphan-after", type=_pos_int, default=CONFIG_DEFAULTS["orphan_after"])
|
|
i.add_argument("--pings", choices=("on", "off"), default=CONFIG_DEFAULTS["pings"])
|
|
i.add_argument("--ttl-s", type=_pos_int, default=CONFIG_DEFAULTS["ttl_s"])
|
|
i.add_argument("--max-minutes", type=_pos_int, default=CONFIG_DEFAULTS["max_minutes"])
|
|
i.add_argument("--stall-after-s", type=_pos_int, default=CONFIG_DEFAULTS["stall_after_s"])
|
|
for k, v in THRESHOLD_DEFAULTS.items():
|
|
i.add_argument("--" + k.replace("_", "-"), type=_pos_int, default=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")
|
|
|
|
f = cmd("fork", "Fork 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("--mode", choices=("sequential", "parallel"), default="sequential")
|
|
f.add_argument("--paths", nargs="+", action="extend", default=None)
|
|
f.add_argument("--max-minutes", type=_pos_int)
|
|
f.add_argument("--max-budget-usd", type=float)
|
|
f.add_argument("--force", action="store_true")
|
|
|
|
c = cmd("continue", "One more turn in a fork (resume without --fork-session).")
|
|
c.add_argument("--job", required=True)
|
|
c.add_argument("--brief", required=True)
|
|
c.add_argument("--max-minutes", type=_pos_int)
|
|
|
|
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")
|
|
wt.add_argument("--any", action="store_true")
|
|
wt.add_argument("--timeout", type=_pos_int, default=540)
|
|
|
|
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)
|
|
|
|
for name, text in (("rebase", "Rebase a base onto the current state (Phase 2)."),
|
|
("usage", "Aggregate the ledger (Phase 2)."),
|
|
("tick", "One keep-warm pass (Phase 2).")):
|
|
cmd(name, text) # arguments arrive with Phase 2; until then extra args are tolerated
|
|
return p
|
|
|
|
|
|
# --------------------------------------------------------------------------------------
|
|
# Commands
|
|
# --------------------------------------------------------------------------------------
|
|
|
|
|
|
def cmd_where(args) -> tuple[dict, int]:
|
|
reasons = []
|
|
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}
|
|
path, how = resolve_binary(args.binary)
|
|
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 ForksError as e:
|
|
binary["error"] = e.payload["error"]
|
|
reasons.append("%s: %s" % (e.payload["error"], path))
|
|
out = {
|
|
"script": script_path(),
|
|
"python": py,
|
|
"uv": _uv_report(),
|
|
"binary": binary,
|
|
"git": _git_report(),
|
|
"spawn": SPAWN_METHOD,
|
|
"scrub": scrub_report(),
|
|
"platform": sys.platform,
|
|
"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 ForksError("no --cwd given and the current directory is not inside a git repo")
|
|
if not os.path.isdir(cwd):
|
|
raise ForksError("--cwd is not a directory", cwd=cwd)
|
|
return cwd
|
|
|
|
|
|
def build_init_config(args, state: str) -> dict:
|
|
"""Validate the launch fields and build the config block. Writes nothing."""
|
|
model = args.model
|
|
if not MODEL_RE.match(model or ""):
|
|
raise ForksError("model must be a full id (aliases are refused)", model=model,
|
|
pattern=MODEL_RE.pattern)
|
|
mode = args.permission_mode
|
|
if mode in REFUSED_PERMISSION_MODES:
|
|
raise ForksError("permission mode refused", permission_mode=mode,
|
|
detail="plan mode swaps models; use dontAsk")
|
|
if mode not in PERMISSION_MODES:
|
|
raise ForksError("unknown permission mode", permission_mode=mode, allowed=list(PERMISSION_MODES))
|
|
tools = _tool_list([args.tools])
|
|
if not tools:
|
|
raise ForksError("--tools is empty")
|
|
allowed = _tool_list(args.allowed_tools)
|
|
if allowed is None:
|
|
allowed = list(tools)
|
|
disallowed = _tool_list(args.disallowed_tools)
|
|
if disallowed is None:
|
|
disallowed = list(DEFAULT_DISALLOWED)
|
|
|
|
cwd = _resolve_cwd(args.cwd)
|
|
|
|
adm = model_admission(model, cwd)
|
|
if not adm["admitted"]:
|
|
files = [x["file"] for x in adm["lists"]]
|
|
raise ForksError(
|
|
"model not admitted by availableModels", model=model, files=files,
|
|
message=("Model %s is not admitted by the availableModels list(s) in: %s. The user must "
|
|
"add the id (or its family alias) to one of these files; forks never writes "
|
|
"settings." % (model, ", ".join(files))))
|
|
|
|
bpath, how = resolve_binary(args.binary)
|
|
if bpath is None:
|
|
raise ForksError("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)")
|
|
binfo = binary_info(bpath)
|
|
|
|
pre = preamble_path()
|
|
if not os.path.isfile(pre):
|
|
raise ForksError("preamble.md missing next to forks.py", path=pre)
|
|
|
|
agent = None
|
|
if args.agent:
|
|
name = args.agent
|
|
if not re.match(r"^[A-Za-z0-9_.:-]+$", name) or name in (".", ".."):
|
|
raise ForksError("bad agent name", agent=name)
|
|
apath = os.path.join(cwd, ".claude", "agents", name + ".md")
|
|
if not os.path.isfile(apath):
|
|
raise ForksError("agent file not found", agent=name, path=apath)
|
|
agent = {"name": name, "path": apath, "sha256": _sha256_file(apath)}
|
|
|
|
launch = {
|
|
"model": model,
|
|
"effort": args.effort,
|
|
"permission_mode": mode,
|
|
"tools": tools,
|
|
"allowed_tools": allowed,
|
|
"disallowed_tools": disallowed,
|
|
"mcp": {"strict": True, "config": mcp_empty_path(state)},
|
|
"append_system_prompt": {"path": pre, "sha256": _sha256_file(pre)},
|
|
"agent": agent,
|
|
"cwd": cwd,
|
|
"binary": binfo,
|
|
"env": {"pinned": dict(ENV_PINNED_LAUNCH)},
|
|
}
|
|
cfg = {
|
|
"launch": launch,
|
|
"fingerprint": fingerprint_hash(launch),
|
|
"binary_resolved_by": how,
|
|
"parallel_cap": args.parallel_cap,
|
|
"orphan_after": args.orphan_after,
|
|
"pings": args.pings,
|
|
"ttl_s": args.ttl_s,
|
|
"max_minutes": args.max_minutes,
|
|
"stall_after_s": args.stall_after_s,
|
|
"thresholds": {k: getattr(args, k) for k in THRESHOLD_DEFAULTS},
|
|
"model_admission": adm["via"],
|
|
}
|
|
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]:
|
|
state = state_dir_for(args.run)
|
|
cfg = build_init_config(args, state) # all validation before any write
|
|
ensure_state(state)
|
|
with RegistryLock(state):
|
|
reg = read_registry(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")]
|
|
if old is not None and changed_fields and reg.get("bases"):
|
|
raise ForksError("init values differ and a base exists; train a new run instead",
|
|
fields=changed_fields, bases=sorted(reg["bases"]), state=state)
|
|
if old is None or changed_fields:
|
|
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,
|
|
"config": cfg}, EXIT_OK
|
|
|
|
|
|
def _not_implemented(block=None, phase=None):
|
|
def f(args):
|
|
out = {"error": "not implemented", "command": args.command}
|
|
if block:
|
|
out["block"] = block
|
|
if phase:
|
|
out["phase"] = phase
|
|
return out, EXIT_USAGE
|
|
return f
|
|
|
|
|
|
HANDLERS = {
|
|
"where": cmd_where,
|
|
"init": cmd_init,
|
|
"train": _not_implemented(block="1.2"),
|
|
"fork": _not_implemented(block="1.2"),
|
|
"continue": _not_implemented(block="1.2"),
|
|
"wait": _not_implemented(block="1.2"),
|
|
"list": _not_implemented(block="1.2"),
|
|
"expire": _not_implemented(block="1.2"),
|
|
"kill": _not_implemented(block="1.2"),
|
|
"_supervise": _not_implemented(block="1.2"),
|
|
"rebase": _not_implemented(phase=2),
|
|
"usage": _not_implemented(phase=2),
|
|
"tick": _not_implemented(phase=2),
|
|
}
|
|
|
|
|
|
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 os.environ.get(CHILD_MARKER):
|
|
raise ForksError("launching commands are refused inside a fork (%s is set)" % CHILD_MARKER,
|
|
command=args.command)
|
|
return HANDLERS[args.command](args)
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
if argv is None:
|
|
argv = sys.argv[1:]
|
|
try:
|
|
out, code = run(argv)
|
|
except ForksError 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
|
|
_emit(out)
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|