🤖 forks: rebase and re-train flags (block 2.2)

This commit is contained in:
2026-10-16 17:23:20 +02:00
parent 4446827cad
commit 5fda2e2f36
3 changed files with 1120 additions and 88 deletions
+587 -71
View File
@@ -122,7 +122,7 @@ SCRUB_WATCH_PREFIXES = ("CLAUDE_", "ANTHROPIC_") # `where` lists unknown vars
# C8: the user settings file. Module-level so tests can point it at a temp file. # C8: the user settings file. Module-level so tests can point it at a temp file.
USER_SETTINGS_PATH: str | None = None USER_SETTINGS_PATH: str | None = None
PHASE2_COMMANDS = ("rebase", "usage") PHASE2_COMMANDS = ("usage",)
# N3: a fork may only run where, list, usage and wait (`tick` launches pings). # N3: a fork may only run where, list, usage and wait (`tick` launches pings).
CHILD_REFUSED = ("train", "fork", "continue", "rebase", "kill", "expire", "init", "tick") CHILD_REFUSED = ("train", "fork", "continue", "rebase", "kill", "expire", "init", "tick")
@@ -156,6 +156,21 @@ LAUNCH_FIELDS = ("model", "effort", "permission_mode", "tools", "allowed_tools",
MU_FIELDS = ("inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens") MU_FIELDS = ("inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens")
TOKEN_KEYS = ("input", "output", "cache_creation", "cache_creation_1h", "cache_creation_5m", "cache_read") TOKEN_KEYS = ("input", "output", "cache_creation", "cache_creation_1h", "cache_creation_5m", "cache_read")
TEMPLATES_DIR = "templates" TEMPLATES_DIR = "templates"
CAPPED_KINDS = ("fork", "continue", "rebase") # parallel_cap counts these (pings never)
# 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
# CIM hidden spawn (P0.1 rung 3 + ShowWindow=0). Command line and cwd arrive through env vars. # CIM hidden spawn (P0.1 rung 3 + ShowWindow=0). Command line and cwd arrive through env vars.
POWERSHELL_CIM = ( POWERSHELL_CIM = (
@@ -237,8 +252,18 @@ def canonical_json(obj) -> str:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) 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: def fingerprint_hash(launch: dict) -> str:
return _sha256_bytes(canonical_json(launch).encode("utf-8")) return _sha256_bytes(canonical_json(fingerprint_view(launch)).encode("utf-8"))
# -------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------
@@ -248,10 +273,36 @@ def fingerprint_hash(launch: dict) -> str:
_GITBASH_DRIVE = re.compile(r"^/([A-Za-z])(?=/|$)") _GITBASH_DRIVE = re.compile(r"^/([A-Za-z])(?=/|$)")
def find_cygpath() -> str | None:
"""`cygpath` on PATH (Git Bash ships it), or None. Tests monkeypatch this lookup."""
return shutil.which("cygpath")
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 = find_cygpath()
if not exe:
raise ForksError("Git Bash rooted path is not a drive form and cygpath is not on PATH to "
"convert it; pass a drive path (G:\\... or /g/...)", path=p)
try:
r = _run([exe, "-w", p], timeout=30)
except (OSError, subprocess.SubprocessError) as e:
raise ForksError("cygpath failed", path=p, detail=str(e))
out = r.stdout.decode("utf-8", "replace").strip()
if r.returncode != 0 or not out:
raise ForksError("cygpath failed", path=p,
detail=r.stderr.decode("utf-8", "replace").strip()[:200] or "no output")
return out
def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> str: def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> str:
"""Normalise relative, `G:\\…`, `G:/…` and Git Bash `/g/…` forms. """Normalise relative, `G:\\…`, `G:/…` and Git Bash `/g/…` forms.
Windows: absolute path with backslashes and an upper-case drive letter. Windows: 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).
POSIX: `os.path.abspath` semantics (relative to `cwd`). POSIX: `os.path.abspath` semantics (relative to `cwd`).
""" """
if windows is None: if windows is None:
@@ -265,6 +316,8 @@ def norm_path(p: str, cwd: str | None = None, windows: bool | None = None) -> st
m = _GITBASH_DRIVE.match(p) m = _GITBASH_DRIVE.match(p)
if m: if m:
p = m.group(1).upper() + ":" + (p[2:] or "/") p = m.group(1).upper() + ":" + (p[2:] or "/")
elif p.startswith("/") and not p.startswith("//"):
p = cygpath_w(p)
p = p.replace("/", "\\") p = p.replace("/", "\\")
drive, rest = ntpath.splitdrive(p) drive, rest = ntpath.splitdrive(p)
if not (drive and rest.startswith("\\")): if not (drive and rest.startswith("\\")):
@@ -1153,25 +1206,41 @@ def context_tokens(usage: dict | None) -> int:
+ int(usage.get("cache_read_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: def runtime_fingerprint(init: dict | None) -> dict | None:
"""The runtime fields of a `system/init` event that must match the base's.""" """The runtime fields of a `system/init` event that must match the base's (RUNTIME_FIELDS)."""
if not init: if not init:
return None return None
tools = sorted(str(x) for x in (init.get("tools") or [])) tools = sorted(str(x) for x in (init.get("tools") or []))
plugins = sorted("%s@%s" % (p.get("name"), p.get("version") or "") plugins = sorted(plugin_id(p) for p in (init.get("plugins") or []) if isinstance(p, dict))
for p in (init.get("plugins") or []) if isinstance(p, dict))
agents = sorted(str(a) if not isinstance(a, dict) else str(a.get("name")) for a in (init.get("agents") or []))
return {"tools": _sha256_bytes(canonical_json(tools).encode("utf-8")), return {"tools": _sha256_bytes(canonical_json(tools).encode("utf-8")),
"permissionMode": init.get("permissionMode"), "plugins": plugins, "agents": agents, "permissionMode": init.get("permissionMode"), "plugins": plugins,
"apiKeySource": init.get("apiKeySource")} "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]: 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: if not rt:
return [] return []
out = [] out = []
if ref: if ref:
out += [k for k in sorted(rt) if rt.get(k) != ref.get(k)] 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")): elif rt.get("permissionMode") not in (None, launch.get("permission_mode")):
out.append("permissionMode") out.append("permissionMode")
return out return out
@@ -1321,6 +1390,20 @@ def note_rel(block) -> str | None:
return None if block in (None, "") else "notes/%s.md" % _safe_name(block) 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 fork 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): def _phase(v):
if v is None: if v is None:
return None return None
@@ -1381,9 +1464,9 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
"""Build the result, the ledger line, and apply the registry updates for one finished job. """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.""" `hint`: "exited" (decide ok/failed from the stream), or a final status given by the caller."""
cfg = reg["config"] cfg = reg["config"]
launch = cfg["launch"]
kind = entry["kind"] kind = entry["kind"]
base = reg["bases"].get(entry.get("base")) if entry.get("base") else None 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 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) \ u = usage_from_result(res, entry.get("model_usage_before") or {}, entry.get("cost_before") or 0.0) \
if res else None if res else None
@@ -1423,16 +1506,17 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
if denials: if denials:
flags.append("needs_attention") flags.append("needs_attention")
cache = cache_verdict(ss.first_usage if ss else None, entry.get("base_context")) \ cache = cache_verdict(ss.first_usage if ss else None, entry.get("base_context")) \
if kind in ("fork", "continue", "ping") else None if kind in ("fork", "continue", "ping", "rebase") else None
rt = runtime_fingerprint(ss.init) if ss else None rt = runtime_fingerprint(ss.init) if ss else None
drift = list(entry.get("launch_drift") or []) drift = list(entry.get("launch_drift") or [])
ref = (base or {}).get("runtime_fingerprint") if kind != "train" else None ref = (base or {}).get("runtime_fingerprint") if kind != "train" else None
drift += runtime_drift(rt, ref, launch) rt_drift = runtime_drift(rt, ref, launch)
drift += rt_drift
drift = sorted(set(drift)) drift = sorted(set(drift))
fingerprint = "ok" if not drift else "drift:" + ",".join(drift) fingerprint = "ok" if not drift else "drift:" + ",".join(drift)
base_state = entry.get("base_state_at_launch") base_state = entry.get("base_state_at_launch")
if kind in ("fork", "continue") and (base_state == "cold" or (cache not in (None, "hit") if kind in ("fork", "continue", "rebase") and (base_state == "cold" or (
and fingerprint == "ok")): cache not in (None, "hit") and fingerprint == "ok")):
flags.append("cold_start") flags.append("cold_start")
if u and wrote_only_5m(u["tokens"]): if u and wrote_only_5m(u["tokens"]):
flags.append("ttl_downgrade") flags.append("ttl_downgrade")
@@ -1445,7 +1529,7 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
flags = _uniq(flags) flags = _uniq(flags)
turns = int(res.get("num_turns") or 0) if res else (ss.turns if ss else 0) 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 note = note_rel(entry.get("block")) if kind != "train" else None
if note and not os.path.isfile(os.path.join(state, note)): if note and not note_written_by(os.path.join(state, note), entry):
note = None note = None
session = (ss.session_id if ss and ss.session_id else None) or entry.get("session") 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} tokens = u["tokens"] if u else {k: None for k in TOKEN_KEYS}
@@ -1472,7 +1556,10 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
# registry updates # registry updates
entry.update({"status": status, "flags": flags, "reason": reason, "reason_detail": detail, entry.update({"status": status, "flags": flags, "reason": reason, "reason_detail": detail,
"ended_at": ended, "session": session, "turns": turns, "exit_code": exit_code, "ended_at": ended, "session": session, "turns": turns, "exit_code": exit_code,
"fingerprint": fingerprint, "cache": cache}) "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: if ss and ss.first_at:
entry["first_event_at"] = _iso(ss.first_at) entry["first_event_at"] = _iso(ss.first_at)
if u: if u:
@@ -1488,7 +1575,7 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
hit_t = None hit_t = None
if ss and kind == "train": if ss and kind == "train":
hit_t = ss.last_assistant_at hit_t = ss.last_assistant_at
elif ss and kind == "fork": elif ss and kind in ("fork", "rebase"): # a rebase's first request reads the base prefix
hit_t = ss.first_at hit_t = ss.first_at
elif ping_refreshed: elif ping_refreshed:
hit_t = ss.first_at hit_t = ss.first_at
@@ -1509,9 +1596,45 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
"runtime_fingerprint": rt, "trained_at": ended}) "runtime_fingerprint": rt, "trained_at": ended})
elif base.get("status") == "training": elif base.get("status") == "training":
base["status"] = "failed" base["status"] = "failed"
if kind == "rebase" and status == "ok":
_rebase_succeeded(reg, entry, base, ss, u, rt, base_commit, ended)
return result, ledger return result, ledger
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."""
name = entry.get("rebase_as")
if not name:
return
existing = reg["bases"].get(name)
if existing and existing.get("status") != "failed":
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"])
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, "forks": [],
"ttl_s": infer_ttl(u["tokens"] if u else None, old.get("ttl_s")), "ttl_from": entry["job"],
"ping_in_flight": None,
}
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, 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) -> dict | None: detail=None, exit_code=None, wall_s=None, base_commit=None) -> dict | None:
"""Write `results/<job>.json`, the registry update and the ledger line, exactly once, under the """Write `results/<job>.json`, the registry update and the ledger line, exactly once, under the
@@ -1759,6 +1882,18 @@ def build_parser() -> _Parser:
t.add_argument("--brief", required=True) t.add_argument("--brief", required=True)
t.add_argument("--block") t.add_argument("--block")
t.add_argument("--phase") 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 forks")
g.add_argument("--binary", help="claude binary")
def launch_overrides(sp): def launch_overrides(sp):
# Accepted only so that they can be refused with a clear message (plan 4.3). # Accepted only so that they can be refused with a clear message (plan 4.3).
@@ -1809,9 +1944,16 @@ def build_parser() -> _Parser:
cmd("tick", "One keep-warm pass: ping every base the rule picks (design 5).") cmd("tick", "One keep-warm pass: ping every base the rule picks (design 5).")
for name, text in (("rebase", "Rebase a base onto the current state (Phase 2)."), r = cmd("rebase", "Fork a base to absorb what changed; its session becomes a new base.")
("usage", "Aggregate the ledger (Phase 2).")): r.add_argument("--base", required=True)
cmd(name, text) # arguments arrive with Phase 2; until then extra args are tolerated r.add_argument("--as", dest="as_name", required=True, help="name of the new base")
r.add_argument("--brief", required=True)
r.add_argument("--phase", help="the new base's phase (the phase whose forks it serves)")
r.add_argument("--max-minutes", type=_pos_float, help="wall-time limit (default: init's max_minutes)")
r.add_argument("--max-budget-usd", type=_pos_float)
launch_overrides(r)
cmd("usage", "Aggregate the ledger (Phase 2).") # arguments arrive with 2.3; extra args tolerated
return p return p
@@ -1875,30 +2017,31 @@ def _resolve_cwd(arg: str | None) -> str:
return cwd return cwd
def build_init_config(args, state: str) -> dict: def agent_path(cwd: str, name: str) -> str:
"""Validate the launch fields and build the config block. Writes nothing.""" return os.path.join(cwd, ".claude", "agents", name + ".md")
model = args.model
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 ""): if not MODEL_RE.match(model or ""):
raise ForksError("model must be a full id (aliases are refused)", model=model, raise ForksError("model must be a full id (aliases are refused)", model=model,
pattern=MODEL_RE.pattern) pattern=MODEL_RE.pattern)
mode = args.permission_mode mode = permission_mode
if mode in REFUSED_PERMISSION_MODES: if mode in REFUSED_PERMISSION_MODES:
raise ForksError("permission mode refused", permission_mode=mode, raise ForksError("permission mode refused", permission_mode=mode,
detail="plan mode swaps models; use dontAsk") detail="plan mode swaps models; use dontAsk")
if mode not in PERMISSION_MODES: if mode not in PERMISSION_MODES:
raise ForksError("unknown permission mode", permission_mode=mode, raise ForksError("unknown permission mode", permission_mode=mode,
allowed=[m for m in PERMISSION_MODES if m not in REFUSED_PERMISSION_MODES]) allowed=[m for m in PERMISSION_MODES if m not in REFUSED_PERMISSION_MODES])
tools = _tool_list([args.tools]) if effort not in EFFORTS:
raise ForksError("unknown effort", effort=effort, allowed=list(EFFORTS))
if not tools: if not tools:
raise ForksError("--tools is empty") raise ForksError("--tools is empty")
allowed = _tool_list(args.allowed_tools) if not os.path.isdir(cwd):
if allowed is None: raise ForksError("--cwd is not a directory", cwd=cwd)
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) adm = model_admission(model, cwd)
if not adm["admitted"]: if not adm["admitted"]:
@@ -1909,39 +2052,58 @@ def build_init_config(args, state: str) -> dict:
"add the id (or its family alias) to one of these files; forks never writes " "add the id (or its family alias) to one of these files; forks never writes "
"settings." % (model, ", ".join(files)))) "settings." % (model, ", ".join(files))))
bpath, how = resolve_binary(args.binary) binfo = binary_info(binary_path)
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() pre = preamble_path()
if not os.path.isfile(pre): if not os.path.isfile(pre):
raise ForksError("preamble.md missing next to forks.py", path=pre) raise ForksError("preamble.md missing next to forks.py", path=pre)
agent = None agent_rec = None
if args.agent: if agent:
name = args.agent name = agent
if not re.match(r"^[A-Za-z0-9_.:-]+$", name) or name in (".", ".."): if not re.match(r"^[A-Za-z0-9_.:-]+$", name) or name in (".", ".."):
raise ForksError("bad agent name", agent=name) raise ForksError("bad agent name", agent=name)
apath = os.path.join(cwd, ".claude", "agents", name + ".md") apath = agent_path(cwd, name)
if not os.path.isfile(apath): if not os.path.isfile(apath):
raise ForksError("agent file not found", agent=name, path=apath) raise ForksError("agent file not found", agent=name, path=apath)
agent = {"name": name, "path": apath, "sha256": _sha256_file(apath)} agent_rec = {"name": name, "path": apath, "sha256": _sha256_file(apath)}
launch = { launch = {
"model": model, "model": model,
"effort": args.effort, "effort": effort,
"permission_mode": mode, "permission_mode": mode,
"tools": tools, "tools": list(tools),
"allowed_tools": allowed, "allowed_tools": list(allowed),
"disallowed_tools": disallowed, "disallowed_tools": list(disallowed),
"mcp": {"strict": True, "config": mcp_empty_path(state)}, "mcp": {"strict": True, "config": mcp_empty_path(state)},
"append_system_prompt": {"path": pre, "sha256": _sha256_file(pre)}, "append_system_prompt": {"path": pre, "sha256": _sha256_file(pre)},
"agent": agent, "agent": agent_rec,
"cwd": cwd, "cwd": cwd,
"binary": binfo, "binary": binfo,
"env": {"pinned": dict(ENV_PINNED_LAUNCH)}, "env": {"pinned": dict(ENV_PINNED_LAUNCH)},
} }
return launch, adm["via"]
def build_init_config(args, state: str) -> dict:
"""Validate the launch fields and build the config block. Writes nothing."""
tools = _tool_list([args.tools])
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)
if not MODEL_RE.match(args.model or ""): # before the binary lookup, as in Phase 1
raise ForksError("model must be a full id (aliases are refused)", model=args.model,
pattern=MODEL_RE.pattern)
bpath, how = resolve_binary(args.binary)
if bpath is None:
raise ForksError("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)")
launch, via = build_launch(state, model=args.model, effort=args.effort,
permission_mode=args.permission_mode, tools=tools, allowed=allowed,
disallowed=disallowed, agent=args.agent, cwd=cwd, binary_path=bpath)
cfg = { cfg = {
"launch": launch, "launch": launch,
"fingerprint": fingerprint_hash(launch), "fingerprint": fingerprint_hash(launch),
@@ -1954,7 +2116,7 @@ def build_init_config(args, state: str) -> dict:
"max_minutes": args.max_minutes, "max_minutes": args.max_minutes,
"stall_after_s": args.stall_after_s, "stall_after_s": args.stall_after_s,
"thresholds": {k: getattr(args, k) for k in THRESHOLD_DEFAULTS}, "thresholds": {k: getattr(args, k) for k in THRESHOLD_DEFAULTS},
"model_admission": adm["via"], "model_admission": via,
} }
return cfg return cfg
@@ -1982,10 +2144,16 @@ def cmd_init(args) -> tuple[dict, int]:
# `model_admission` and `binary_resolved_by` describe how, not what: not a change. # `model_admission` and `binary_resolved_by` describe how, not what: not a change.
changed_fields = [f for f in changed_fields changed_fields = [f for f in changed_fields
if f not in ("model_admission", "binary_resolved_by")] if f not in ("model_admission", "binary_resolved_by")]
if old is not None and changed_fields and reg.get("bases"): # Block 2.2: values may change after bases exist (Phase 1 refused it). Every base keeps
raise ForksError("init values differ and a base exists; train a new run instead", # launching with its own record; a base that did not override a changed defining field
fields=changed_fields, bases=sorted(reg["bases"]), state=state) # 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 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["config"] = cfg
reg.setdefault("run", {})["init_at"] = _iso() reg.setdefault("run", {})["init_at"] = _iso()
write_registry(state, reg) write_registry(state, reg)
@@ -1993,7 +2161,7 @@ def cmd_init(args) -> tuple[dict, int]:
cfg = old cfg = old
return {"ok": True, "state": state, "registry": registry_path(state), "created": created, return {"ok": True, "state": state, "registry": registry_path(state), "created": created,
"changed": bool(old is not None and changed_fields), "changed_fields": changed_fields, "changed": bool(old is not None and changed_fields), "changed_fields": changed_fields,
"config": cfg}, EXIT_OK "bases": sorted(reg.get("bases", {})), "config": cfg}, EXIT_OK
def _not_implemented(block=None, phase=None): def _not_implemented(block=None, phase=None):
@@ -2040,7 +2208,8 @@ def _check_binary(ref: dict, launch: dict) -> tuple[dict, list[str]]:
path = launch["binary"]["path"] path = launch["binary"]["path"]
if not os.path.isfile(path): if not os.path.isfile(path):
raise ForksError("claude binary missing (the stored path is gone); the base cannot be " raise ForksError("claude binary missing (the stored path is gone); the base cannot be "
"launched with another binary", binary=path, reason="binary_missing") "launched with another binary", binary=path, reason="binary_missing",
retrain_due=True, reasons=["binary_missing"])
info = binary_info(path) info = binary_info(path)
drift = [] if all(info.get(k) == (ref or {}).get(k) for k in ("version", "size", "mtime")) else ["binary"] drift = [] if all(info.get(k) == (ref or {}).get(k) for k in ("version", "size", "mtime")) else ["binary"]
asp = launch["append_system_prompt"] asp = launch["append_system_prompt"]
@@ -2107,6 +2276,233 @@ def delta_header(cwd: str, base_commit: str | None) -> str:
return "\n\n".join(parts) 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
_NOTE_REF = re.compile(r"[^\s`'\"()<>\[\]{},;|*]+\.md\b")
def listed_notes(brief: str | None, cwd: str, state: str) -> list[str]:
"""The `.md` files a brief names that exist: absolute paths as given, relative ones against
the base's cwd, then against the state dir (`notes/1.1.md`). Each file once."""
out, seen = [], set()
for ref in _NOTE_REF.findall(brief or ""):
for root in (cwd, state):
try:
p = norm_path(ref, cwd=root)
except ForksError:
break # e.g. a rooted Git Bash path cygpath cannot convert
key = os.path.normcase(p)
if os.path.isfile(p):
if key not in seen:
seen.add(key)
out.append(p)
break
return out
def _text_chars(path: str) -> int:
try:
return len(read_bytes_retry(path).decode("utf-8", "replace"))
except OSError:
return 0
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 fork's `--phase` is greater than the highest phase of any fork from this base, or than
the base's own phase (integers only; a side that is not known does not count)."""
if not _int_phase(phase):
return False
base = reg["bases"][name]
forks_ = [e.get("phase") for e in reg.get("jobs", {}).values()
if e.get("base") == name and e.get("kind") == "fork" and _int_phase(e.get("phase"))]
own = base.get("phase")
return bool(forks_ and phase > max(forks_)) 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 ForksError:
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, 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` are the fork's (none in `list`); `delta` the
fork's delta header (computed here when not given); `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(_text_chars(p) for p in listed_notes(brief, la["cwd"], state))
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: def _next_job_id(reg: dict) -> str:
n = 0 n = 0
for j in reg.get("jobs", {}): for j in reg.get("jobs", {}):
@@ -2118,13 +2514,22 @@ def _next_job_id(reg: dict) -> str:
def _running_capped(reg: dict) -> list[str]: def _running_capped(reg: dict) -> list[str]:
return sorted(j for j, e in reg.get("jobs", {}).items() return sorted(j for j, e in reg.get("jobs", {}).items()
if e.get("kind") in ("fork", "continue") and e.get("status") in RUNNING) 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: def _new_entry(job: str, kind: str, **kw) -> dict:
e = {"job": job, "kind": kind, "base": None, "block": None, "phase": None, "mode": None, e = {"job": job, "kind": kind, "base": None, "block": None, "phase": None, "mode": None,
"paths": None, "status": "created", "flags": [], "reason": None, "session": None, "paths": None, "status": "created", "flags": [], "reason": None, "session": None,
"parent_session": None, "model_requested": None, "created_at": _iso(), "launched_at": 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, "ended_at": None, "supervisor": None, "cwd": None, "argv": None,
"brief": "briefs/%s.md" % job, "raw": "raw/%s.jsonl" % job, "max_s": None, "brief": "briefs/%s.md" % job, "raw": "raw/%s.jsonl" % job, "max_s": None,
"cost_before": 0.0, "model_usage_before": {}, "base_context": None, "cost_before": 0.0, "model_usage_before": {}, "base_context": None,
@@ -2154,15 +2559,40 @@ def _start_job(state: str, run: str, job: str, prompt: str, launch: dict) -> dic
return sp 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 ForksError("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]: def cmd_train(args) -> tuple[dict, int]:
state, reg = _load_run(args) state, reg = _load_run(args)
name = args.base name = args.base
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name or ""): if not re.fullmatch(r"[A-Za-z0-9_.-]+", name or ""):
raise ForksError("bad base name", base=name) raise ForksError("bad base name", base=name)
cfg = reg["config"] cfg = reg["config"]
la = cfg["launch"] 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) brief_path, brief = _read_brief(args.brief)
binfo, drift = _check_binary(la["binary"], la)
session = str(uuid.uuid4()) session = str(uuid.uuid4())
argv = claude_argv(la) + ["--session-id", session] argv = claude_argv(la) + ["--session-id", session]
head = _git_head(la["cwd"]) head = _git_head(la["cwd"])
@@ -2172,11 +2602,16 @@ def cmd_train(args) -> tuple[dict, int]:
old = reg["bases"].get(name) old = reg["bases"].get(name)
if old and old.get("status") != "failed": if old and old.get("status") != "failed":
raise ForksError("base exists", base=name, status=old.get("status")) raise ForksError("base exists", base=name, status=old.get("status"))
pending = _pending_rebase_to(reg, name)
if pending:
raise ForksError("a running rebase creates this base", base=name, job=pending)
job = _next_job_id(reg) job = _next_job_id(reg)
reg["bases"][name] = {"name": name, "status": "training", "session": session, "cwd": la["cwd"], reg["bases"][name] = {"name": name, "status": "training", "session": session, "cwd": la["cwd"],
"fingerprint": cfg["fingerprint"], "binary": binfo, "created_at": _iso(), "launch": la, "fingerprint": fingerprint_hash(la), "overrides": overrides,
"train_job": job, "phase": _phase(args.phase), "last_hit": None, "binary": binfo, "created_at": _iso(), "train_job": job,
"base_commit": None, "context_tokens": None, "forks": []} "phase": _phase(args.phase), "last_hit": None, "parent": None,
"lineage_rebases": 0, "base_commit": None, "context_tokens": None,
"forks": []}
reg["jobs"][job] = _new_entry( reg["jobs"][job] = _new_entry(
job, "train", base=name, block=args.block, phase=_phase(args.phase), session=session, 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, model_requested=la["model"], cwd=la["cwd"], argv=argv, max_s=float(cfg["max_minutes"]) * 60,
@@ -2196,10 +2631,10 @@ def cmd_fork(args) -> tuple[dict, int]:
raise ForksError("--mode parallel needs --paths (the files this fork may touch)", mode="parallel") raise ForksError("--mode parallel needs --paths (the files this fork may touch)", mode="parallel")
state, reg = _load_run(args) state, reg = _load_run(args)
cfg = reg["config"] cfg = reg["config"]
la = cfg["launch"]
base = reg["bases"].get(args.base) base = reg["bases"].get(args.base)
if base is None: if base is None:
raise ForksError("unknown base", base=args.base, bases=sorted(reg["bases"])) raise ForksError("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: if base.get("status") != "active" and not args.force:
raise ForksError("base is not active (use --force to fork it anyway)", base=args.base, raise ForksError("base is not active (use --force to fork it anyway)", base=args.base,
status=base.get("status")) status=base.get("status"))
@@ -2208,6 +2643,8 @@ def cmd_fork(args) -> tuple[dict, int]:
brief_path, brief = _read_brief(args.brief) brief_path, brief = _read_brief(args.brief)
binfo, drift = _check_binary(base.get("binary") or la["binary"], la) binfo, drift = _check_binary(base.get("binary") or la["binary"], la)
delta = delta_header(la["cwd"], base.get("base_commit")) delta = delta_header(la["cwd"], base.get("base_commit"))
due = due_flags(state, reg, args.base, phase=_phase(args.phase), brief=brief, delta=delta,
binary="drift" if "binary" in drift else "ok")
head = _git_head(la["cwd"]) head = _git_head(la["cwd"])
session = str(uuid.uuid4()) session = str(uuid.uuid4())
argv = claude_argv(la) + ["--resume", base["session"], "--fork-session", "--session-id", session] argv = claude_argv(la) + ["--resume", base["session"], "--fork-session", "--session-id", session]
@@ -2256,15 +2693,14 @@ def cmd_fork(args) -> tuple[dict, int]:
"logs_dir": os.path.join(state, "logs"), "brief": brief}) "logs_dir": os.path.join(state, "logs"), "brief": brief})
_start_job(state, norm_path(args.run), job, prompt, la) _start_job(state, norm_path(args.run), job, prompt, la)
return {"job": job, "base": args.base, "base_state": warmth["base_state"], return {"job": job, "base": args.base, "base_state": warmth["base_state"],
"warm_until": warmth["warm_until"], "rebase_due": False, "retrain_due": False, "warm_until": warmth["warm_until"], "rebase_due": due["rebase_due"],
"reasons": []}, EXIT_OK "retrain_due": due["retrain_due"], "reasons": due["reasons"]}, EXIT_OK
def cmd_continue(args) -> tuple[dict, int]: def cmd_continue(args) -> tuple[dict, int]:
_refuse_overrides(args) _refuse_overrides(args)
state, reg = _load_run(args) state, reg = _load_run(args)
cfg = reg["config"] cfg = reg["config"]
la = cfg["launch"]
fj = reg["jobs"].get(args.job) fj = reg["jobs"].get(args.job)
if fj is None: if fj is None:
raise ForksError("unknown job", job=args.job) raise ForksError("unknown job", job=args.job)
@@ -2279,6 +2715,7 @@ def cmd_continue(args) -> tuple[dict, int]:
if not fj.get("session"): if not fj.get("session"):
raise ForksError("the fork has no session", job=args.job) raise ForksError("the fork has no session", job=args.job)
base = reg["bases"].get(fj.get("base")) or {} base = reg["bases"].get(fj.get("base")) or {}
la = launch_record(base, cfg) # M10: the fork's base's record
brief_path, brief = _read_brief(args.brief) brief_path, brief = _read_brief(args.brief)
binfo, drift = _check_binary(base.get("binary") or la["binary"], la) binfo, drift = _check_binary(base.get("binary") or la["binary"], la)
head = _git_head(la["cwd"]) head = _git_head(la["cwd"])
@@ -2313,6 +2750,77 @@ def cmd_continue(args) -> tuple[dict, int]:
return {"job": job}, EXIT_OK 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 ForksError("bad base name", base=new)
base = reg["bases"].get(args.base)
if base is None:
raise ForksError("unknown base", base=args.base, bases=sorted(reg["bases"]))
if new == args.base:
raise ForksError("--as must name a new base", base=new)
if base.get("status") != "active" or not base.get("session"):
raise ForksError("base is not active", base=args.base, status=base.get("status"))
la = launch_record(base, cfg)
brief_path, brief = _read_brief(args.brief)
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 ForksError("base is not active", base=args.base, status=base.get("status"))
old = reg["bases"].get(new)
if old and old.get("status") != "failed":
raise ForksError("base exists", base=new, status=old.get("status"))
pending = _pending_rebase_to(reg, new)
if pending:
raise ForksError("a running rebase creates this base", base=new, job=pending)
busy = sorted(j for j, e in reg["jobs"].items() if e.get("kind") == "rebase"
and e.get("base") == args.base and e.get("status") in RUNNING)
if busy:
raise ForksError("the base is being rebased", base=args.base, running=busy)
running = _running_capped(reg)
if len(running) >= int(cfg["parallel_cap"]):
raise ForksError("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)
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": delta, "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) ---------------------------- # --- keep warm: the ping rule and the tick (design 5, C3d) ----------------------------
@@ -2322,8 +2830,8 @@ def run_for_state(state: str) -> str:
def launch_record(base: dict | None, cfg: dict) -> dict: def launch_record(base: dict | None, cfg: dict) -> dict:
"""The launch fields a launch on `base` uses: the base's own record when it has one, else the """The launch fields a launch on `base` uses (M10): the base's own record (written at `train`
run config (every base has the init config's fields until bases carry their own record).""" or `rebase`); the run config only for a base from before per-base records."""
return (base or {}).get("launch") or cfg["launch"] return (base or {}).get("launch") or cfg["launch"]
@@ -2609,7 +3117,8 @@ class Supervisor:
stall_s = float(self.cfg.get("stall_after_s") or CONFIG_DEFAULTS["stall_after_s"]) 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"]) orphan_s = float(self.cfg.get("orphan_after") or CONFIG_DEFAULTS["orphan_after"])
hint, reason, detail, flags_extra = "exited", None, None, [] hint, reason, detail, flags_extra = "exited", None, None, []
hit_written = e["kind"] != "fork" # only a fork's first request refreshes (I3) # only a fork's (or a rebase's) first request reads the base prefix and refreshes it (I3)
hit_written = e["kind"] not in ("fork", "rebase")
clock = SupClock(t0) clock = SupClock(t0)
last_orphan_check = 0.0 last_orphan_check = 0.0
last_messages = 0 last_messages = 0
@@ -2674,7 +3183,7 @@ class Supervisor:
err.close() err.close()
wall = _now() - t0 wall = _now() - t0
base_commit = None base_commit = None
if e["kind"] == "train": if e["kind"] in ("train", "rebase"): # the new base's HEAD at the end of the call
base_commit = _git_head(e["cwd"]) or e.get("head_at_launch") base_commit = _git_head(e["cwd"]) or e.get("head_at_launch")
res = finalize_job(self.state, self.job, ss=self.ss, hint=hint, flags=self.flags + flags_extra, 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, reason=reason, detail=detail, exit_code=exit_code, wall_s=wall,
@@ -2795,7 +3304,8 @@ LIST_JOB_KEYS = ("job", "kind", "base", "block", "phase", "mode", "paths", "stat
"ended_at", "fingerprint", "cache", "delivered_at", "settled_at") "ended_at", "fingerprint", "cache", "delivered_at", "settled_at")
LIST_BASE_KEYS = ("name", "status", "session", "cwd", "base_commit", "context_tokens", LIST_BASE_KEYS = ("name", "status", "session", "cwd", "base_commit", "context_tokens",
"training_cost_usd", "cost_cum_usd", "last_hit", "train_job", "forks", "phase", "training_cost_usd", "cost_cum_usd", "last_hit", "train_job", "forks", "phase",
"created_at", "trained_at", "ttl_from", "ping_in_flight") "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]: def cmd_list(args) -> tuple[dict, int]:
@@ -2806,10 +3316,16 @@ def cmd_list(args) -> tuple[dict, int]:
now = _now() now = _now()
cfg = reg["config"] cfg = reg["config"]
bases = [] bases = []
bcache: dict = {}
for name, b in sorted(reg["bases"].items()): for name, b in sorted(reg["bases"].items()):
row = {k: b.get(k) for k in LIST_BASE_KEYS} row = {k: b.get(k) for k in LIST_BASE_KEYS}
row["name"] = name row["name"] = name
row.update(base_warmth(b, cfg, now)) row.update(base_warmth(b, cfg, now))
# The flags are computed for active bases only (no fork'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": []}) row.update({"rebase_due": False, "retrain_due": False, "reasons": []})
bases.append(row) bases.append(row)
jobs, running = [], [] jobs, running = [], []
@@ -2866,7 +3382,7 @@ HANDLERS = {
"expire": cmd_expire, "expire": cmd_expire,
"kill": cmd_kill, "kill": cmd_kill,
"_supervise": cmd_supervise, "_supervise": cmd_supervise,
"rebase": _not_implemented(phase=2), "rebase": cmd_rebase,
"usage": _not_implemented(phase=2), "usage": _not_implemented(phase=2),
"tick": cmd_tick, "tick": cmd_tick,
} }
@@ -0,0 +1,17 @@
# Rebase (spire:forks)
You are a fork of base `${base}` (run log `${run}`). When you finish, this session becomes the new base `${new_base}` (phase ${phase}): every later fork of this build starts from what you know at the end of it. Your job is to absorb what changed since `${base}` was trained or last rebased.
Your system prompt was snapshotted when the base was trained: the git status, the CLAUDE.md files and the environment it shows are **stale**. The delta header below lists what changed in the repository since then. Trust it and the files on disk over the snapshot and over what you remember.
- Read the handoff notes and the diffs the brief lists (`git --no-optional-locks diff`, `git --no-optional-locks log -p`), and re-read the files they touch. Handoff notes live under `${notes_dir}`.
- Do not change code and do not commit. If the brief names a trunk-notes path, update that file; it is the only file you write.
- Finish with one line `READY`, then a short summary of what changed and anything the brief asked for that you could not do.
## Delta since the base commit
${delta}
## Brief
${brief}
+515 -16
View File
@@ -102,14 +102,79 @@ class PathTests(Base):
"/c": "C:\\", "/c": "C:\\",
"/c/": "C:\\", "/c/": "C:\\",
"../other/./z.md": "G:\\dev\\other\\z.md", "../other/./z.md": "G:\\dev\\other\\z.md",
"\\rooted\\a": "G:\\rooted\\a", "\\rooted\\a": "G:\\rooted\\a", # native rooted path: the cwd's drive (Windows meaning)
"/tmp/x": "G:\\tmp\\x", # not a drive form: rooted on the cwd's drive
" G:/x/y.md ": "G:\\x\\y.md", " G:/x/y.md ": "G:\\x\\y.md",
} }
for src, want in cases.items(): for src, want in cases.items():
with self.subTest(src=src): with self.subTest(src=src):
self.assertEqual(forks.norm_path(src, cwd=cwd, windows=True), want) self.assertEqual(forks.norm_path(src, cwd=cwd, windows=True), want)
def fake_cygpath(self, code=0):
"""A fake `cygpath` (python behind a wrapper) that maps `/a/b` to `C:\\Fake\\a\\b` and logs
its arguments."""
d = os.path.join(self.tmp, "cyg")
os.makedirs(d, exist_ok=True)
py = os.path.join(d, "cygpath.py")
log = os.path.join(d, "calls.txt")
with open(py, "w", newline="\n") as f:
f.write("import sys\nopen(%r, 'a').write(' '.join(sys.argv[1:]) + '\\n')\n"
"print('C:\\\\Fake' + sys.argv[-1].replace('/', '\\\\'))\nsys.exit(%d)\n" % (log, code))
if IS_WINDOWS:
exe = os.path.join(d, "cygpath.cmd")
with open(exe, "w", newline="\r\n") as f:
f.write('@echo off\n"%s" -I "%s" %%*\n' % (stub_interpreter(), py))
else:
exe = os.path.join(d, "cygpath")
with open(exe, "w", newline="\n") as f:
f.write('#!/bin/sh\nexec "%s" -I "%s" "$@"\n' % (stub_interpreter(), py))
os.chmod(exe, 0o755)
return exe, log
def test_gitbash_rooted_without_cygpath(self):
"""M13: a Git Bash rooted path that is not a drive form is refused (exit 2) when cygpath is
not on PATH; it is never mapped onto the cwd's drive. Drive forms need no cygpath."""
cwd = "G:\\dev\\proj"
with mock.patch.object(forks, "find_cygpath", lambda: None):
for src in ("/tmp/x", "/usr/share/doc", "/tmp/x-run.md"):
with self.subTest(src=src):
with self.assertRaises(forks.ForksError) as cm:
forks.norm_path(src, cwd=cwd, windows=True)
self.assertEqual(cm.exception.code, 2)
self.assertIn("cygpath", cm.exception.payload["error"])
self.assertEqual(cm.exception.payload["path"], src)
self.assertEqual(forks.norm_path("/g/x/y.md", cwd=cwd, windows=True), "G:\\x\\y.md")
self.assertEqual(forks.norm_path("/c", cwd=cwd, windows=True), "C:\\")
self.assertEqual(forks.norm_path("\\rooted\\a", cwd=cwd, windows=True), "G:\\rooted\\a")
if IS_WINDOWS: # end to end: --run /tmp/... is refused, nothing written on the cwd's drive
drive = os.path.splitdrive(os.getcwd())[0]
code, out, raw = call(["init", "--run", "/tmp/forks-m13-x-run.md", "--model",
"claude-opus-5-5", "--cwd", self.tmp])
self.assertEqual(code, 2, out)
self.assert_one_json_line(raw)
self.assertIn("cygpath", out["error"])
self.assertFalse(os.path.exists(drive + "\\tmp\\forks-m13-x-run-forks"))
def test_gitbash_rooted_with_cygpath(self):
"""M13: with cygpath on PATH the path is converted by `cygpath -w`; a failing cygpath
refuses."""
exe, log = self.fake_cygpath()
with mock.patch.object(forks, "find_cygpath", lambda: exe):
self.assertEqual(forks.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True),
"C:\\Fake\\tmp\\x")
self.assertEqual(forks.norm_path("/g/x", cwd="G:\\dev\\proj", windows=True), "G:\\x")
with open(log) as f:
self.assertEqual(f.read().splitlines(), ["-w /tmp/x"]) # drive forms skip cygpath
bad, _ = self.fake_cygpath(code=1)
with mock.patch.object(forks, "find_cygpath", lambda: bad):
with self.assertRaises(forks.ForksError) as cm:
forks.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True)
self.assertEqual(cm.exception.payload["error"], "cygpath failed")
real = shutil.which("cygpath")
if IS_WINDOWS and real: # the real one (Git Bash): never the cwd's drive + \tmp
got = forks.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True)
self.assertNotEqual(got, "G:\\tmp\\x")
self.assertRegex(got, r"^[A-Z]:\\.+\\x$")
def test_windows_cwd_in_gitbash_form(self): def test_windows_cwd_in_gitbash_form(self):
self.assertEqual(forks.norm_path("a.md", cwd="/g/dev/proj", windows=True), "G:\\dev\\proj\\a.md") self.assertEqual(forks.norm_path("a.md", cwd="/g/dev/proj", windows=True), "G:\\dev\\proj\\a.md")
@@ -632,22 +697,56 @@ class InitTests(Base):
self.assertEqual(cfg["orphan_after"], 1500) self.assertEqual(cfg["orphan_after"], 1500)
self.assertNotEqual(cfg["fingerprint"], first["config"]["fingerprint"]) self.assertNotEqual(cfg["fingerprint"], first["config"]["fingerprint"])
def test_changed_values_after_base_refused(self): def test_changed_values_after_base_allowed(self):
"""Block 2.2 replaces Phase 1's refusal: `init` may change values after a base exists (the
input path of `retrain_due: defining:`). The base keeps its own launch record; a base from
before per-base records gets the config it was trained with as its record."""
self.assertEqual(self.init()[0], 0) self.assertEqual(self.init()[0], 0)
self.add_fake_base() self.add_fake_base()
before = self.reg_bytes()
code, out, raw = self.init("--effort", "medium") code, out, raw = self.init("--effort", "medium")
self.assertEqual(code, 2) self.assertEqual(code, 0, out)
self.assert_one_json_line(raw) self.assert_one_json_line(raw)
self.assertIn("base exists", out["error"]) self.assertTrue(out["changed"])
self.assertIn("launch.effort", out["changed_fields"])
self.assertEqual(out["bases"], ["B1"]) self.assertEqual(out["bases"], ["B1"])
self.assertIn("launch.effort", out["fields"]) reg = forks.read_registry(self.state)
self.assertEqual(self.reg_bytes(), before) self.assertEqual(reg["config"]["launch"]["effort"], "medium")
self.assertEqual(reg["bases"]["B1"]["launch"]["effort"], "high")
self.assertEqual(reg["bases"]["B1"]["overrides"], [])
self.assertFalse(os.path.exists(forks.lock_path(self.state))) self.assertFalse(os.path.exists(forks.lock_path(self.state)))
# identical values stay fine with a base # identical values stay unchanged with a base
code, out, _ = self.init() before = self.reg_bytes()
code, out, _ = self.init("--effort", "medium")
self.assertEqual(code, 0, out) self.assertEqual(code, 0, out)
self.assertFalse(out["changed"]) self.assertFalse(out["changed"])
self.assertEqual(self.reg_bytes(), before)
def test_fingerprint_hash_stable_across_runs(self):
"""M7: the hash covers the mcp config's content, not its per-run path: two runs with the
same launch fields hash the same; the path stays in the config, outside the hash."""
code, a, _ = self.init()
self.assertEqual(code, 0, a)
other = os.path.join(self.tmp, "elsewhere", "2026-10-16-09-u-run.md")
os.makedirs(os.path.dirname(other))
code, b, _ = call(["init", "--run", other, "--model", "claude-opus-5-5[1m]", "--cwd", self.cwd,
"--binary", self.stub])
self.assertEqual(code, 0, b)
la, lb = a["config"]["launch"], b["config"]["launch"]
self.assertNotEqual(la["mcp"]["config"], lb["mcp"]["config"])
self.assertEqual(la["mcp"]["config"], forks.mcp_empty_path(self.state))
self.assertEqual(a["config"]["fingerprint"], b["config"]["fingerprint"])
self.assertEqual(forks.fingerprint_view(la)["mcp"], {"strict": True, "servers": {}})
self.assertNotIn("config", forks.fingerprint_view(la)["mcp"])
# a second process computes the same hash (no per-process input)
r = subprocess.run([sys.executable, "-I", "-c",
"import importlib.util,json,sys;"
"s=importlib.util.spec_from_file_location('f',sys.argv[1]);m=importlib.util.module_from_spec(s);"
"s.loader.exec_module(m);print(m.fingerprint_hash(json.loads(sys.argv[2])))",
FORKS_PY, json.dumps(lb)], capture_output=True, timeout=60)
self.assertEqual(r.stdout.decode().strip(), a["config"]["fingerprint"], r.stderr)
# the content does count
self.assertNotEqual(forks.fingerprint_hash(dict(la, mcp=dict(la["mcp"], strict=False))),
a["config"]["fingerprint"])
# --- refusals --- # --- refusals ---
def assert_refused_no_state(self, *extra, model="claude-opus-5-5[1m]"): def assert_refused_no_state(self, *extra, model="claude-opus-5-5[1m]"):
@@ -759,7 +858,7 @@ class InitTests(Base):
class CliTests(Base): class CliTests(Base):
def test_phase2_not_implemented(self): def test_phase2_not_implemented(self):
run_log = os.path.join(self.tmp, "r-run.md") run_log = os.path.join(self.tmp, "r-run.md")
for argv in (("rebase",), ("usage", "--phase", "1")): # `tick` is implemented (2.1) for argv in (("usage", "--phase", "1"),): # `tick` (2.1) and `rebase` (2.2) are implemented
with self.subTest(cmd=argv[0]): with self.subTest(cmd=argv[0]):
code, out, raw = call([argv[0], "--run", run_log, *argv[1:]]) code, out, raw = call([argv[0], "--run", run_log, *argv[1:]])
self.assertEqual(code, 2) self.assertEqual(code, 2)
@@ -777,7 +876,8 @@ class CliTests(Base):
("fork", "--base", "B1", "--block", "1.1", "--brief", brief), ("fork", "--base", "B1", "--block", "1.1", "--brief", brief),
("continue", "--job", "j0001", "--brief", brief), ("continue", "--job", "j0001", "--brief", brief),
("wait", "--all"), ("list",), ("expire", "--all"), ("kill", "--job", "j0001"), ("wait", "--all"), ("list",), ("expire", "--all"), ("kill", "--job", "j0001"),
("_supervise", "--job", "j0001"), ("tick",)) ("_supervise", "--job", "j0001"), ("tick",),
("rebase", "--base", "B1", "--as", "B2", "--brief", brief))
for argv in cases: for argv in cases:
with self.subTest(cmd=argv[0]): with self.subTest(cmd=argv[0]):
code, out, raw = call([argv[0], "--run", run_log, *argv[1:]]) code, out, raw = call([argv[0], "--run", run_log, *argv[1:]])
@@ -1015,7 +1115,10 @@ def main():
"permissionMode": opt(a, "--permission-mode"), "tools": tools, "mcp_servers": [], "permissionMode": opt(a, "--permission-mode"), "tools": tools, "mcp_servers": [],
"apiKeySource": d.get("apikey", "none"), "claude_code_version": VERSION, "apiKeySource": d.get("apikey", "none"), "claude_code_version": VERSION,
"plugins": [{"name": "spire", "path": "x", "source": "spire@reliquary", "plugins": [{"name": "spire", "path": "x", "source": "spire@reliquary",
"version": d.get("plugin", "0.2.2")}], "version": d.get("plugin", "0.2.2")},
{"name": "skill-creator", "source": "skill-creator@official",
"path": "C:\\plugins\\cache\\skill-creator\\" + d.get("nover", "abc123")}],
"skills": sorted(["implementation", "workpad"] + ([d["skill"]] if "skill" in d else [])),
"agents": ["claude", "general-purpose"]}) "agents": ["claude", "general-purpose"]})
used = model used = model
if "restricted" in d: if "restricted" in d:
@@ -1200,6 +1303,9 @@ class LaunchBase(Base):
p = mock.patch.object(forks, "USER_SETTINGS_PATH", os.path.join(self.tmp, "home", "settings.json")) p = mock.patch.object(forks, "USER_SETTINGS_PATH", os.path.join(self.tmp, "home", "settings.json"))
p.start() p.start()
self.addCleanup(p.stop) self.addCleanup(p.stop)
p = mock.patch.object(forks, "CLAUDE_PROJECTS_DIR", os.path.join(self.tmp, "home", "projects"))
p.start()
self.addCleanup(p.stop)
self.cwd = os.path.join(self.tmp, "proj") self.cwd = os.path.join(self.tmp, "proj")
os.makedirs(self.cwd) os.makedirs(self.cwd)
self.git("init", "-q") self.git("init", "-q")
@@ -1446,6 +1552,46 @@ class RuleTests(Base):
self.assertEqual(forks.runtime_drift(other, rt, {}), ["apiKeySource", "plugins"]) self.assertEqual(forks.runtime_drift(other, rt, {}), ["apiKeySource", "plugins"])
self.assertEqual(forks.runtime_drift(rt, None, {"permission_mode": "default"}), ["permissionMode"]) self.assertEqual(forks.runtime_drift(rt, None, {"permission_mode": "default"}), ["permissionMode"])
def test_runtime_fingerprint_skills_and_versionless_plugins(self):
"""M6: sorted `skills` are compared; a plugin without a version is identified by the last
segment of its path. Both drift."""
init = {"tools": ["Read"], "permissionMode": "dontAsk", "apiKeySource": "none",
"agents": ["claude"], "skills": ["workpad", "implementation"],
"plugins": [{"name": "spire", "version": "0.2.2", "path": "C:\\c\\spire\\0.2.2"},
{"name": "skill-creator",
"path": "C:\\Users\\u\\.claude\\plugins\\cache\\skill-creator\\abc123\\"},
{"name": "builtin"}]}
rt = forks.runtime_fingerprint(init)
self.assertEqual(rt["plugins"], ["builtin@", "skill-creator@path:abc123", "spire@0.2.2"])
self.assertEqual(rt["skills"], ["implementation", "workpad"])
self.assertEqual(sorted(rt), sorted(forks.RUNTIME_FIELDS))
upd = json.loads(json.dumps(init))
upd["plugins"][1]["path"] = "/home/u/.claude/plugins/cache/skill-creator/def456"
self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(upd), rt, {}), ["plugins"])
more = dict(init, skills=["workpad", "implementation", "new-skill"])
self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(more), rt, {}), ["skills"])
same = dict(init, skills=["implementation", "workpad"]) # order does not count
self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(same), rt, {}), [])
old_ref = {k: v for k, v in rt.items() if k != "skills"} # a base from before M6
self.assertEqual(forks.runtime_drift(forks.runtime_fingerprint(more), old_ref, {}), [])
def test_note_written_by(self):
"""M11: a note counts only when its mtime is at or after the job's creation (less the
file-time slack)."""
p = os.path.join(self.tmp, "n.md")
start = time.time() - 100
entry = {"created_ts": start, "created_at": forks._iso(start)}
self.assertFalse(forks.note_written_by(p, entry)) # no file
with open(p, "w") as f:
f.write("note\n")
for delta, want in ((-forks.NOTE_MTIME_SLACK_S - 1, False), (-forks.NOTE_MTIME_SLACK_S + 0.5, True),
(0, True), (50, True)):
with self.subTest(delta=delta):
os.utime(p, (start + delta, start + delta))
self.assertEqual(forks.note_written_by(p, entry), want)
os.utime(p, (start - 60, start - 60))
self.assertFalse(forks.note_written_by(p, {"created_at": forks._iso(start)})) # ISO fallback
class WarmthTests(Base): class WarmthTests(Base):
"""Design 5 under an injected clock (block 2.1): warmth boundaries, TTL inference, the ping rule.""" """Design 5 under an injected clock (block 2.1): warmth boundaries, TTL inference, the ping rule."""
@@ -1798,9 +1944,9 @@ class LaunchTests(LaunchBase):
# --- fork, after a commit on the trunk --- # --- fork, after a commit on the trunk ---
self.commit_file("src.py", "x = 1\n", "add src after training") self.commit_file("src.py", "x = 1\n", "add src after training")
os.makedirs(os.path.join(self.state, "notes"), exist_ok=True) os.makedirs(os.path.join(self.state, "notes"), exist_ok=True)
with open(os.path.join(self.state, "notes", "1.1.md"), "w") as f: f_out = self.fork("turns=2", "sleep=1", extra=("--phase", "1", "--paths", "src.py", "docs/a.md"))
with open(os.path.join(self.state, "notes", "1.1.md"), "w") as f: # M11: written during the job
f.write("note\n") f.write("note\n")
f_out = self.fork("turns=2", extra=("--phase", "1", "--paths", "src.py", "docs/a.md"))
self.assertEqual(set(f_out), {"job", "base", "base_state", "warm_until", "rebase_due", self.assertEqual(set(f_out), {"job", "base", "base_state", "warm_until", "rebase_due",
"retrain_due", "reasons"}) "retrain_due", "reasons"})
self.assertEqual(f_out["base_state"], "warm") self.assertEqual(f_out["base_state"], "warm")
@@ -2213,7 +2359,8 @@ class LaunchTests(LaunchBase):
for name in forks.LAUNCH_FIELDS: for name in forks.LAUNCH_FIELDS:
flag = "--" + name.replace("_", "-") flag = "--" + name.replace("_", "-")
for argv in (["fork", "--base", "B1", "--block", "1", "--brief", "b.md"], for argv in (["fork", "--base", "B1", "--block", "1", "--brief", "b.md"],
["continue", "--job", "j0001", "--brief", "b.md"]): ["continue", "--job", "j0001", "--brief", "b.md"],
["rebase", "--base", "B1", "--as", "B2", "--brief", "b.md"]):
with self.subTest(field=name, cmd=argv[0]): with self.subTest(field=name, cmd=argv[0]):
out = self.cmd(*argv, flag, values[name], code=2) out = self.cmd(*argv, flag, values[name], code=2)
self.assertEqual(out["fields"], [name]) self.assertEqual(out["fields"], [name])
@@ -2499,6 +2646,358 @@ class LaunchTests(LaunchBase):
self.assertIn("messages", self.runtime(job)) # M16: the job file's counter self.assertIn("messages", self.runtime(job)) # M16: the job file's counter
self.assertNotIn("turns", self.runtime(job)) self.assertNotIn("turns", self.runtime(job))
# --- block 2.2 -----------------------------------------------------------------------
REBASE_REASONS = ("phase_boundary", "commits", "lines", "delta_tokens", "binary_drift", "runtime_drift")
def dead_spawn(self) -> dict:
p = subprocess.Popen([sys.executable, "-c", "pass"])
dead = forks.pid_record(p.pid)
p.wait()
return {"ok": True, "method": "test", "pid": dead["pid"], "ctime": dead["ctime"]}
def fake_fork(self, base, *extra, brief=None, code=0):
"""A fork whose supervisor is never started (spawn stubbed with a dead pid), finalised at once
as killed: only its launch return is looked at."""
fake = self.dead_spawn()
with mock.patch.object(forks, "spawn_supervisor", lambda *a: fake):
out = self.cmd("fork", "--base", base, "--block", "d", "--brief", brief or self.brief(),
*extra, code=code)
if code == 0:
forks.finalize_job(self.state, out["job"], ss=None, hint="killed", reason="kill")
return out
def clone_base(self, name, src="B1", **changes):
"""A copy of base `src` under a new name, at the current HEAD unless `base_commit` is given."""
head = self.git("rev-parse", "HEAD")
def f(reg):
b = json.loads(json.dumps(reg["bases"][src]))
b.update({"name": name, "forks": [], "base_commit": head})
b.update(changes)
reg["bases"][name] = b
self.set_registry(f)
def assert_due(self, out, *reasons):
self.assertEqual(out["reasons"], list(reasons), out)
rebase = [r for r in reasons if r.split(":")[0] in self.REBASE_REASONS]
self.assertEqual(out["rebase_due"], bool(rebase), out)
self.assertEqual(out["retrain_due"], len(rebase) < len(reasons), out)
def test_due_reasons_each_alone(self):
"""Design 5 / digest 2.2: every rebase_due and retrain_due reason triggered on its own; the
fork's launch return lists exactly it (and `list` the base-level ones)."""
self.setup_run(cap=4)
self.train("B1")
self.finish("j0001")
c0 = self.git("rev-parse", "HEAD")
self.assert_due(self.fake_fork("B1")) # nothing due
# phase_boundary: above the highest phase of the base's forks, or of the base itself
self.clone_base("Bp")
self.assert_due(self.fake_fork("Bp", "--phase", "1")) # nothing known yet
self.assert_due(self.fake_fork("Bp", "--phase", "1"))
self.assert_due(self.fake_fork("Bp", "--phase", "2"), "phase_boundary")
self.assert_due(self.fake_fork("Bp")) # no --phase
self.clone_base("Bq", phase=3)
self.assert_due(self.fake_fork("Bq", "--phase", "3"))
self.assert_due(self.fake_fork("Bq", "--phase", "4"), "phase_boundary")
self.assert_due(self.fake_fork("Bq", "--phase", "4"), "phase_boundary") # still above the base's 3
self.assert_due(self.fake_fork("Bq", "--phase", "2"))
# commits: 7 > rebase_commits 6 (seven one-line files)
for i in range(7):
self.commit_file("c%d.txt" % i, "x\n", "commit %d" % i)
self.clone_base("Bc", base_commit=c0)
self.assert_due(self.fake_fork("Bc"), "commits:7")
# lines: 1600 > rebase_lines 1500, in one commit
c7 = self.git("rev-parse", "HEAD")
self.commit_file("big.txt", "line\n" * 1600, "big change")
self.clone_base("Bl", base_commit=c7)
self.assert_due(self.fake_fork("Bl"), "lines:1600")
# every clone from here on starts at HEAD
# delta_tokens: chars / 4 of the delta header plus the notes the brief lists > 12000
with open(os.path.join(self.state, "notes", "big-note.md"), "w") as f:
f.write("n" * 60000)
self.clone_base("Bd")
self.assert_due(self.fake_fork("Bd")) # brief lists no note
out = self.fake_fork("Bd", brief=self.brief(text="Read notes/big-note.md first."))
self.assertEqual(len(out["reasons"]), 1, out)
self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$")
self.assertGreater(int(out["reasons"][0].split(":")[1]), 15000)
self.assertEqual((out["rebase_due"], out["retrain_due"]), (True, False))
# binary_drift: a clone with its own copy of the binary, changed after it was recorded
ext = os.path.splitext(self.stub)[1]
b2 = os.path.join(os.path.dirname(self.stub), "claude2" + ext)
shutil.copy2(self.stub, b2)
info = forks.binary_info(forks.norm_path(b2))
b1_launch = self.reg()["bases"]["B1"]["launch"]
self.clone_base("Bb", launch=dict(b1_launch, binary=info), binary=info)
self.assert_due(self.fake_fork("Bb"))
with open(b2, "a", newline="\r\n" if IS_WINDOWS else "\n") as f:
f.write("rem drift\n" if IS_WINDOWS else "# drift\n")
self.assert_due(self.fake_fork("Bb"), "binary_drift")
# runtime_drift (M6): the base's last call saw a versionless plugin and the skills change
self.clone_base("Br")
r = self.finish(self.fork("turns=1", "nover=def456", "skill=extra", base="Br")["job"])
self.assertEqual((r["status"], r["fingerprint"]), ("ok", "drift:plugins,skills"), r)
self.assert_due(self.fake_fork("Br"), "runtime_drift:plugins,skills")
# defining: the base's record differs from the config in a field it did not override
self.clone_base("Bm", launch=dict(b1_launch, model="claude-opus-4-1"))
self.assert_due(self.fake_fork("Bm"), "defining:model")
# max_base_tokens: from the stored context, and from the transcript when it exists (P0.7)
self.clone_base("Bx", context_tokens=300000)
self.assert_due(self.fake_fork("Bx"), "max_base_tokens:300000")
sid = "11111111-2222-3333-4444-555555555555"
self.clone_base("Bt", session=sid, context_tokens=1000)
tp = forks.transcript_path(b1_launch["cwd"], sid)
self.assertTrue(tp.startswith(os.path.join(self.tmp, "home", "projects")), tp)
os.makedirs(os.path.dirname(tp))
lines = [{"type": "user", "message": {"role": "user", "content": "x" * 300}},
{"type": "assistant", "message": {"id": "m1", "usage": {
"input_tokens": 2, "cache_creation_input_tokens": 500, "cache_read_input_tokens": 1000}}},
{"type": "assistant", "message": {"id": "m2", "usage": {
"input_tokens": 2, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 260000}}},
{"type": "system", "subtype": "informational", "content": "y" * 300}]
with open(tp, "w", newline="\n") as f:
f.write("".join(json.dumps(x) + "\n" for x in lines))
with mock.patch.object(forks, "TRANSCRIPT_TAIL_BYTES", 64): # the tail grows
self.assert_due(self.fake_fork("Bt"), "max_base_tokens:261002")
# rebases: lineage_rebases >= max_rebases 4
self.clone_base("Bn3", lineage_rebases=3)
self.assert_due(self.fake_fork("Bn3"))
self.clone_base("Bn", lineage_rebases=4)
self.assert_due(self.fake_fork("Bn"), "rebases:4")
# binary_missing: the stored path is gone; launches refuse (exit 2)
gone = dict(info, path=os.path.join(self.tmp, "gone", "claude" + ext))
self.clone_base("Bz", launch=dict(b1_launch, binary=gone), binary=gone)
out = self.fake_fork("Bz", code=2)
self.assertEqual((out["reason"], out["retrain_due"], out["reasons"]),
("binary_missing", True, ["binary_missing"]), out)
# list: the base-level reasons, per active base
rows = {b["name"]: b for b in self.cmd("list")["bases"]}
for name, want in (("Bl", ["lines:1600"]), ("Bd", []), ("Bb", ["binary_drift"]),
("Br", ["runtime_drift:plugins,skills"]), ("Bm", ["defining:model"]),
("Bx", ["max_base_tokens:300000"]), ("Bt", ["max_base_tokens:261002"]),
("Bn", ["rebases:4"]), ("Bz", ["binary_missing"])):
with self.subTest(base=name):
self.assert_due(rows[name], *want)
def test_train_overrides_and_init_model_change(self):
"""M10 (plan 4.3): `train` takes init's launch flags as overrides; the base records its
launch fields and their hash, and every later launch on it uses that record. `init`
changing the model afterwards: a base that did not override it reports `retrain_due:
defining:model`; one that did reports nothing."""
self.setup_run(cap=4)
t = self.cmd("train", "--base", "B1", "--brief", self.brief(), "--model", "claude-sonnet-5-5",
"--effort", "low", "--tools", "Read,Bash")
r = self.finish(t["job"])
self.assertEqual((r["status"], r["model_requested"], r["models_used"]),
("ok", "claude-sonnet-5-5", ["claude-sonnet-5-5"]), r)
reg = self.reg()
cfg, b1 = reg["config"], reg["bases"]["B1"]
rec = b1["launch"]
self.assertEqual((rec["model"], rec["effort"], rec["tools"]), ("claude-sonnet-5-5", "low", ["Read", "Bash"]))
self.assertEqual(rec["allowed_tools"], cfg["launch"]["allowed_tools"]) # not overridden
self.assertEqual(rec["cwd"], cfg["launch"]["cwd"])
self.assertEqual(b1["overrides"], ["effort", "model", "tools"])
self.assertEqual(b1["fingerprint"], forks.fingerprint_hash(rec))
self.assertNotEqual(b1["fingerprint"], cfg["fingerprint"])
a = stub_calls(self.stub)[0]["argv"]
self.assertEqual((a[a.index("--model") + 1], a[a.index("--effort") + 1], a[a.index("--tools") + 1]),
("claude-sonnet-5-5", "low", "Read,Bash"))
# a base trained without overrides records the config's fields
self.train("B2")
self.assertEqual(self.finish("j0002")["status"], "ok")
b2 = self.reg()["bases"]["B2"]
self.assertEqual((b2["launch"], b2["overrides"], b2["fingerprint"]),
(cfg["launch"], [], cfg["fingerprint"]))
# fork and continue of B1 build their command line from B1's record
f = self.fork("turns=1", base="B1")
self.assert_due(f)
rf = self.finish(f["job"])
self.assertEqual((rf["status"], rf["model_requested"], rf["models_used"]),
("ok", "claude-sonnet-5-5", ["claude-sonnet-5-5"]), rf)
c = self.cmd("continue", "--job", f["job"], "--brief", self.brief("turns=1"))
rc = self.finish(c["job"])
self.assertEqual((rc["status"], rc["model_requested"]), ("ok", "claude-sonnet-5-5"), rc)
for job in (f["job"], c["job"]):
with self.subTest(job=job):
argv = self.reg()["jobs"][job]["argv"]
self.assertEqual((argv[argv.index("--model") + 1], argv[argv.index("--effort") + 1],
argv[argv.index("--tools") + 1]), ("claude-sonnet-5-5", "low", "Read,Bash"))
# init changes the model after the bases exist
out = self.setup_run("--model", "claude-opus-4-1", cap=4)
self.assertTrue(out["changed"], out)
self.assertIn("launch.model", out["changed_fields"])
rows = {b["name"]: b for b in self.cmd("list")["bases"]}
self.assert_due(rows["B2"], "defining:model")
self.assert_due(rows["B1"])
f2 = self.fork("turns=1", base="B2")
self.assert_due(f2, "defining:model") # advisory: the fork still launches
r2 = self.finish(f2["job"])
self.assertEqual((r2["status"], r2["model_requested"]), ("ok", TEST_MODEL), r2) # B2's record
f1 = self.fork("turns=1", base="B1")
self.assert_due(f1)
self.assertEqual(self.finish(f1["job"])["model_requested"], "claude-sonnet-5-5")
# overrides pass init's checks: aliases, plan mode, availableModels
self.cmd("train", "--base", "B3", "--brief", self.brief(), "--model", "sonnet", code=2)
self.cmd("train", "--base", "B3", "--brief", self.brief(), "--permission-mode", "plan", code=2)
settings = os.path.join(self.tmp, "home", "settings.json")
os.makedirs(os.path.dirname(settings), exist_ok=True)
with open(settings, "w") as fh:
json.dump({"availableModels": ["opus"]}, fh)
out = self.cmd("train", "--base", "B3", "--brief", self.brief(), "--model", "claude-sonnet-5-5", code=2)
self.assertEqual(out["error"], "model not admitted by availableModels")
self.assertNotIn("B3", self.reg()["bases"])
def test_note_only_from_this_job(self):
"""M11: `note` is set only when the note file was written during this job; a note left by an
earlier fork of the block, or by the fork a continue resumes, does not count."""
self.setup_run()
self.train("B1")
self.finish("j0001")
note = os.path.join(self.state, "notes", "1.1.md")
with open(note, "w") as f:
f.write("old note\n")
old = time.time() - 600
os.utime(note, (old, old))
r = self.finish(self.fork("turns=1")["job"])
self.assertEqual((r["status"], r["note"]), ("ok", None), r)
j = self.fork("sleep=1", "turns=2")["job"]
with open(note, "w") as f: # written while the job runs
f.write("new note\n")
r = self.finish(j)
self.assertEqual((r["status"], r["note"]), ("ok", "notes/1.1.md"), r)
os.utime(note, (old, old))
rc = self.finish(self.cmd("continue", "--job", j, "--brief", self.brief("turns=1"))["job"])
self.assertEqual((rc["status"], rc["note"]), ("ok", None), rc)
led = {x["job"]: x for x in self.ledger()}
self.assertEqual((led[j]["note"], led[rc["job"]]["note"]), ("notes/1.1.md", None))
def test_rebase_end_to_end(self):
"""`rebase` with the stub: the new base is active (parent, lineage, HEAD, context, launch
record), the old one superseded; a fork of the new base hits the new context; a failed
rebase leaves the base active and creates nothing; a binary drift is recorded by the new
base."""
self.setup_run()
self.train("B1")
self.finish("j0001")
b1 = self.reg()["bases"]["B1"]
self.commit_file("src.py", "x = 1\n", "work of phase 1")
f1 = self.fork("turns=1", extra=("--phase", "1"))
self.assert_due(f1)
self.finish(f1["job"])
f2 = self.fork("turns=1", extra=("--phase", "2"))
self.assert_due(f2, "phase_boundary")
self.finish(f2["job"])
out = self.cmd("rebase", "--base", "B1", "--as", "B2", "--phase", "2",
"--brief", self.brief("turns=2", text="Absorb phase 1."))
self.assertEqual(set(out), {"job", "base", "parent", "session_id"})
self.assertEqual((out["base"], out["parent"]), ("B2", "B1"))
rr = self.finish(out["job"])
self.assertEqual((rr["kind"], rr["status"], rr["base"], rr["cache"], rr["phase"], rr["fingerprint"]),
("rebase", "ok", "B1", "hit", 2, "ok"), rr)
self.assertEqual((rr["session"], rr["parent_session"]), (out["session_id"], b1["session"]))
reg = self.reg()
old, new = reg["bases"]["B1"], reg["bases"]["B2"]
self.assertEqual((old["status"], old["superseded_by"]), ("superseded", "B2"))
self.assertEqual((new["status"], new["parent"], new["lineage_rebases"], new["session"], new["phase"],
new["rebase_job"]), ("active", "B1", 1, out["session_id"], 2, out["job"]))
self.assertEqual(new["base_commit"], self.git("rev-parse", "HEAD"))
self.assertEqual((new["launch"], new["fingerprint"], new["overrides"]),
(old["launch"], old["fingerprint"], []))
self.assertGreater(new["context_tokens"], old["context_tokens"])
self.assertIsNotNone(new["last_hit"])
line = {x["job"]: x for x in self.ledger()}[out["job"]]
self.assertEqual((line["kind"], line["status"]), ("rebase", "ok"))
self.assertAlmostEqual(new["cost_cum_usd"], line["cost_cumulative_usd"], places=6)
a = [c for c in stub_calls(self.stub) if c["session"] == out["session_id"]][0]["argv"]
self.assertEqual(a[a.index("--resume") + 1], b1["session"])
self.assertIn("--fork-session", a)
self.assertEqual(a[a.index("--session-id") + 1], out["session_id"])
with open(os.path.join(self.state, "briefs", out["job"] + ".md"), encoding="utf-8") as fh:
txt = fh.read()
for s in ("READY", "Do not change code", "work of phase 1", "`B2`", "Absorb phase 1.", "stale"):
self.assertIn(s, txt)
# a fork of the new base: a hit against the new base's context
f3 = self.fork("turns=1", base="B2", extra=("--phase", "2"))
self.assert_due(f3)
r3 = self.finish(f3["job"])
self.assertEqual((r3["status"], r3["cache"], r3["parent_session"]), ("ok", "hit", new["session"]), r3)
self.assertEqual(self.reg()["jobs"][f3["job"]]["base_context"], new["context_tokens"])
self.assertGreaterEqual(r3["tokens"]["cache_read"], new["context_tokens"])
a = [c for c in stub_calls(self.stub) if c["session"] == r3["session"]][0]["argv"]
self.assertEqual(a[a.index("--resume") + 1], new["session"])
# the superseded base: no fork, no rebase
self.fork(base="B1", code=2)
self.cmd("rebase", "--base", "B1", "--as", "B9", "--brief", self.brief(), code=2)
# a failed rebase: the base stays active, the new base is never created
rf = self.finish(self.cmd("rebase", "--base", "B2", "--as", "B3", "--brief", self.brief("error"))["job"])
self.assertEqual((rf["status"], rf["reason"]), ("failed", "is_error"), rf)
reg = self.reg()
self.assertEqual(reg["bases"]["B2"]["status"], "active")
self.assertNotIn("B3", reg["bases"])
rows = {b["name"]: b for b in self.cmd("list")["bases"]}
self.assertEqual((rows["B1"]["status"], rows["B1"]["superseded_by"], rows["B2"]["parent"]),
("superseded", "B2", "B1"))
# refusals
for argv, why in ((["--base", "B2", "--as", "B2"], "new base"),
(["--base", "B2", "--as", "B1"], "base exists"),
(["--base", "B9", "--as", "B4"], "unknown base"),
(["--base", "B2", "--as", "a/b"], "bad base name")):
with self.subTest(argv=argv):
self.assertIn(why, self.cmd("rebase", *argv, "--brief", self.brief(), code=2)["error"])
os.environ[forks.CHILD_MARKER] = "1"
try:
out = self.cmd("rebase", "--base", "B2", "--as", "B4", "--brief", self.brief(), code=2)
self.assertIn("inside a fork", out["error"])
finally:
os.environ.pop(forks.CHILD_MARKER, None)
# binary drift: the rebase runs under the current binary, and the new base records it
with open(self.stub, "a", newline="\r\n" if IS_WINDOWS else "\n") as f:
f.write("rem drift\n" if IS_WINDOWS else "# drift\n")
fd = self.fork("turns=1", base="B2")
self.assert_due(fd, "binary_drift")
self.finish(fd["job"])
out = self.cmd("rebase", "--base", "B2", "--as", "B4", "--brief", self.brief("turns=1"))
rb = self.finish(out["job"])
self.assertEqual((rb["status"], rb["fingerprint"]), ("ok", "drift:binary"), rb)
b2, b4 = self.reg()["bases"]["B2"], self.reg()["bases"]["B4"]
cur = forks.binary_info(forks.norm_path(self.stub))
for k in ("version", "size", "mtime"):
self.assertEqual((b4["binary"][k], b4["launch"]["binary"][k]), (cur[k], cur[k]), k)
self.assertEqual((b4["parent"], b4["lineage_rebases"], b2["status"]), ("B2", 2, "superseded"))
self.assertEqual(b4["fingerprint"], forks.fingerprint_hash(b4["launch"]))
self.assertNotEqual(b4["fingerprint"], b2["fingerprint"])
f4 = self.fork("turns=1", base="B4")
self.assert_due(f4)
r4 = self.finish(f4["job"])
self.assertEqual((r4["status"], r4["fingerprint"], r4["cache"]), ("ok", "ok", "hit"), r4)
def test_rebase_counts_toward_cap(self):
"""A running rebase counts toward parallel_cap, blocks a second rebase of its base and a
train of its new name; killed, it leaves the base active and creates nothing."""
self.setup_run(cap=1)
self.train("B1")
self.finish("j0001")
nxt = forks._next_job_id(self.reg())
out = self.cmd("rebase", "--base", "B1", "--as", "B2",
"--brief", self.brief("hang=1", text="RUN-%s" % nxt))
job = out["job"]
self.assertEqual(job, nxt)
self.until_running(job)
refused = self.fork(code=2)
self.assertEqual((refused["error"], refused["running"]), ("parallel cap reached", [job]))
self.assertIn("rebased", self.cmd("rebase", "--base", "B1", "--as", "B3", "--brief", self.brief(),
code=2)["error"])
self.assertIn("rebase creates", self.cmd("train", "--base", "B2", "--brief", self.brief(),
code=2)["error"])
self.assertTrue(self.cmd("kill", "--job", job)["ok"])
r = self.finish(job)
self.assertEqual((r["status"], r["kind"]), ("killed", "rebase"))
reg = self.reg()
self.assertEqual(reg["bases"]["B1"]["status"], "active")
self.assertNotIn("B2", reg["bases"])
def tearDownModule(): def tearDownModule():
"""No supervisor or stub of this suite may survive it (checked by command line, never by name).""" """No supervisor or stub of this suite may survive it (checked by command line, never by name)."""