🐛 gremlins: init re-resolves the binary, late rebase of an inactive base, kill verification

This commit is contained in:
2026-10-16 21:43:31 +02:00
parent 11c1085ba1
commit 62eef3b7c4
2 changed files with 379 additions and 16 deletions
+100 -15
View File
@@ -1179,7 +1179,8 @@ def kill_tree(rec: dict | None, extra=(), alive_until: float | None = None) -> d
break # one tree kill of the first live root; the sweep covers the rest
deadline = time.monotonic() + KILL_VERIFY_S
while True:
survivors = [p for p in snap if record_alive(p)]
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:
@@ -1188,6 +1189,22 @@ def kill_tree(rec: dict | None, extra=(), alive_until: float | None = None) -> d
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,
@@ -1295,15 +1312,29 @@ def resolve_binary(explicit: str | None = None, environ=None) -> tuple[str | Non
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"
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)
@@ -1785,6 +1816,12 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
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
@@ -1867,20 +1904,35 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
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."""
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
existing = reg["bases"].get(name)
if existing and existing.get("status") != "failed":
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),
@@ -1896,6 +1948,16 @@ def _rebase_succeeded(reg: dict, entry: dict, old: dict, ss, u, rt, base_commit,
"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
@@ -2393,12 +2455,34 @@ def build_launch(state: str, *, model, effort, permission_mode, tools, allowed,
return launch, adm["via"]
def build_init_config(args, state: str, old: dict | None = None) -> dict:
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."""
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):
@@ -2428,7 +2512,7 @@ def build_init_config(args, state: str, old: dict | None = None) -> dict:
raise GremlinsError("model must be a full id (aliases are refused)", model=model,
pattern=MODEL_RE.pattern)
if args.binary is None and (ol.get("binary") or {}).get("path"):
bpath, how = ol["binary"]["path"], (old or {}).get("binary_resolved_by")
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:
@@ -2477,7 +2561,8 @@ def cmd_init(args) -> tuple[dict, int]:
state = state_dir_for(args.run)
snap = read_registry(state) if os.path.isdir(state) else None
old_cfg = (snap or {}).get("config")
cfg = build_init_config(args, state, old_cfg) # all validation before any write
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)
@@ -2508,7 +2593,7 @@ def cmd_init(args) -> tuple[dict, int]:
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}, EXIT_OK
"bases": sorted(reg.get("bases", {})), "config": cfg, "warnings": warnings}, EXIT_OK
# --- launching: train, spawn, continue ------------------------------------------------