🐛 gremlins: init re-resolves the binary, late rebase of an inactive base, kill verification
This commit is contained in:
@@ -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 ------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user