🐛 forks: Phase 2 review fixes (I1, I2, minors 1, 2, 8-11)
This commit is contained in:
@@ -1350,10 +1350,11 @@ def break_even(ttl) -> int:
|
|||||||
|
|
||||||
def base_warmth(base: dict, cfg: dict, now: float | None = None) -> dict:
|
def base_warmth(base: dict, cfg: dict, now: float | None = None) -> dict:
|
||||||
"""Design 5: ttl = the base's inferred TTL, else the configured ttl_s; warm_until = last_hit +
|
"""Design 5: ttl = the base's inferred TTL, else the configured ttl_s; warm_until = last_hit +
|
||||||
ttl; margin = init's warm_margin_s if set, else max(120, ttl/10)."""
|
ttl; margin = init's warm_margin_s if set, capped at ttl/2 (Minor 2: a margin tuned for 1 h
|
||||||
|
must not make a 5-minute base `expiring` right after every hit), else max(120, ttl/10)."""
|
||||||
ttl = float((base or {}).get("ttl_s") or cfg.get("ttl_s") or CONFIG_DEFAULTS["ttl_s"])
|
ttl = float((base or {}).get("ttl_s") or cfg.get("ttl_s") or CONFIG_DEFAULTS["ttl_s"])
|
||||||
wm = cfg.get("warm_margin_s")
|
wm = cfg.get("warm_margin_s")
|
||||||
margin = float(wm) if wm is not None else max(120.0, ttl / 10.0)
|
margin = min(float(wm), ttl / 2.0) if wm is not None else max(120.0, ttl / 10.0)
|
||||||
out = {"ttl_s": int(ttl) if ttl.is_integer() else ttl,
|
out = {"ttl_s": int(ttl) if ttl.is_integer() else ttl,
|
||||||
"margin_s": int(margin) if margin.is_integer() else margin}
|
"margin_s": int(margin) if margin.is_integer() else margin}
|
||||||
last = _ts((base or {}).get("last_hit"))
|
last = _ts((base or {}).get("last_hit"))
|
||||||
@@ -1594,6 +1595,13 @@ def _conclude(state: str, reg: dict, entry: dict, ss: StreamState | None, hint:
|
|||||||
"cost_cum_usd": u["total"] if u else None,
|
"cost_cum_usd": u["total"] if u else None,
|
||||||
"model_usage_cum": u["model_usage"] if u else {},
|
"model_usage_cum": u["model_usage"] if u else {},
|
||||||
"runtime_fingerprint": rt, "trained_at": ended})
|
"runtime_fingerprint": rt, "trained_at": ended})
|
||||||
|
# Minor 1 (`train --supersedes`): the bases this one replaces stop being active,
|
||||||
|
# so they get no more pings and need --force to fork. Only on `ok`.
|
||||||
|
for old_name in base.get("supersedes") or []:
|
||||||
|
ob = reg["bases"].get(old_name)
|
||||||
|
if ob is not None and ob.get("status") == "active" and old_name != base.get("name"):
|
||||||
|
ob.update({"status": "superseded", "superseded_by": base.get("name"),
|
||||||
|
"superseded_at": ended})
|
||||||
elif base.get("status") == "training":
|
elif base.get("status") == "training":
|
||||||
base["status"] = "failed"
|
base["status"] = "failed"
|
||||||
if kind == "rebase" and status == "ok":
|
if kind == "rebase" and status == "ok":
|
||||||
@@ -1861,11 +1869,14 @@ def build_parser() -> _Parser:
|
|||||||
w = cmd("where", "Preflight report: script, python, uv, binary, git, spawn method, env scrub.")
|
w = cmd("where", "Preflight report: script, python, uv, binary, git, spawn method, env scrub.")
|
||||||
w.add_argument("--binary", help="claude binary to report instead of the default resolution")
|
w.add_argument("--binary", help="claude binary to report instead of the default resolution")
|
||||||
|
|
||||||
i = cmd("init", "Create the state dir and the launch config (idempotent).")
|
# Minor 8: every init flag defaults to None. On the first init an omitted flag takes its
|
||||||
i.add_argument("--model", required=True, help="full model id, e.g. claude-opus-5-5[1m]")
|
# default; on a re-run it keeps the current config value (build_init_config).
|
||||||
i.add_argument("--effort", default="high", choices=EFFORTS)
|
i = cmd("init", "Create the state dir and the launch config (idempotent). On a re-run, omitted "
|
||||||
i.add_argument("--permission-mode", default="dontAsk")
|
"flags keep their current values.")
|
||||||
i.add_argument("--tools", default=DEFAULT_TOOLS, help="comma-separated tool names")
|
i.add_argument("--model", help="full model id, e.g. claude-opus-5-5[1m] (required on the first init)")
|
||||||
|
i.add_argument("--effort", choices=EFFORTS, help="default high")
|
||||||
|
i.add_argument("--permission-mode", help="default dontAsk")
|
||||||
|
i.add_argument("--tools", help="comma-separated tool names (default %s)" % DEFAULT_TOOLS)
|
||||||
i.add_argument("--allowed-tools", nargs="+", action="extend", default=None,
|
i.add_argument("--allowed-tools", nargs="+", action="extend", default=None,
|
||||||
help="allow rules (default: the tools)")
|
help="allow rules (default: the tools)")
|
||||||
i.add_argument("--disallowed-tools", nargs="+", action="extend", default=None,
|
i.add_argument("--disallowed-tools", nargs="+", action="extend", default=None,
|
||||||
@@ -1873,17 +1884,18 @@ def build_parser() -> _Parser:
|
|||||||
i.add_argument("--agent", help="project agent name (<cwd>/.claude/agents/<name>.md)")
|
i.add_argument("--agent", help="project agent name (<cwd>/.claude/agents/<name>.md)")
|
||||||
i.add_argument("--cwd", help="working dir of base and forks (default: git toplevel)")
|
i.add_argument("--cwd", help="working dir of base and forks (default: git toplevel)")
|
||||||
i.add_argument("--binary", help="claude binary (default: $CLAUDE_CODE_EXECPATH, else PATH)")
|
i.add_argument("--binary", help="claude binary (default: $CLAUDE_CODE_EXECPATH, else PATH)")
|
||||||
i.add_argument("--parallel-cap", type=_pos_int, default=CONFIG_DEFAULTS["parallel_cap"])
|
i.add_argument("--parallel-cap", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["parallel_cap"])
|
||||||
i.add_argument("--orphan-after", type=_pos_int, default=CONFIG_DEFAULTS["orphan_after"])
|
i.add_argument("--orphan-after", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["orphan_after"])
|
||||||
i.add_argument("--pings", choices=("on", "off"), default=CONFIG_DEFAULTS["pings"])
|
i.add_argument("--pings", choices=("on", "off"), help="default %s" % CONFIG_DEFAULTS["pings"])
|
||||||
i.add_argument("--ttl-s", type=_pos_int, default=CONFIG_DEFAULTS["ttl_s"],
|
i.add_argument("--ttl-s", type=_pos_int,
|
||||||
help="TTL assumed until a base's calls show one (default 3600)")
|
help="TTL assumed until a base's calls show one (default 3600)")
|
||||||
i.add_argument("--warm-margin-s", type=_pos_int, default=None,
|
i.add_argument("--warm-margin-s", type=_pos_int,
|
||||||
help="seconds before warm_until a base turns `expiring` (default max(120, ttl/10))")
|
help="seconds before warm_until a base turns `expiring` (default max(120, ttl/10); "
|
||||||
i.add_argument("--max-minutes", type=_pos_int, default=CONFIG_DEFAULTS["max_minutes"])
|
"must be below ttl_s; at most half the base's TTL at runtime)")
|
||||||
i.add_argument("--stall-after-s", type=_pos_int, default=CONFIG_DEFAULTS["stall_after_s"])
|
i.add_argument("--max-minutes", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["max_minutes"])
|
||||||
|
i.add_argument("--stall-after-s", type=_pos_int, help="default %d" % CONFIG_DEFAULTS["stall_after_s"])
|
||||||
for k, v in THRESHOLD_DEFAULTS.items():
|
for k, v in THRESHOLD_DEFAULTS.items():
|
||||||
i.add_argument("--" + k.replace("_", "-"), type=_pos_int, default=v)
|
i.add_argument("--" + k.replace("_", "-"), type=_pos_int, help="default %d" % v)
|
||||||
|
|
||||||
t = cmd("train", "Train a new base (headless session).")
|
t = cmd("train", "Train a new base (headless session).")
|
||||||
t.add_argument("--base", required=True)
|
t.add_argument("--base", required=True)
|
||||||
@@ -1902,6 +1914,9 @@ def build_parser() -> _Parser:
|
|||||||
g.add_argument("--agent", help="project agent name (<cwd>/.claude/agents/<name>.md)")
|
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("--cwd", help="working dir of the base and its forks")
|
||||||
g.add_argument("--binary", help="claude binary")
|
g.add_argument("--binary", help="claude binary")
|
||||||
|
t.add_argument("--supersedes", action="append", default=None, metavar="BASE",
|
||||||
|
help="a base this one replaces (repeatable): when the training ends ok, each "
|
||||||
|
"named base that is active becomes superseded")
|
||||||
|
|
||||||
def launch_overrides(sp):
|
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).
|
||||||
@@ -1923,6 +1938,9 @@ def build_parser() -> _Parser:
|
|||||||
f.add_argument("--max-minutes", type=_pos_float, help="wall-time limit (default: init's max_minutes)")
|
f.add_argument("--max-minutes", type=_pos_float, help="wall-time limit (default: init's max_minutes)")
|
||||||
f.add_argument("--max-budget-usd", type=_pos_float)
|
f.add_argument("--max-budget-usd", type=_pos_float)
|
||||||
f.add_argument("--force", action="store_true", help="fork a base that is not active")
|
f.add_argument("--force", action="store_true", help="fork a base that is not active")
|
||||||
|
f.add_argument("--delta-note", action="append", default=None, metavar="PATH", dest="delta_notes",
|
||||||
|
help="a note the fork must learn (repeatable): appended to the prompt after the "
|
||||||
|
"delta header; counts toward delta_tokens when newer than the base's training")
|
||||||
launch_overrides(f)
|
launch_overrides(f)
|
||||||
|
|
||||||
c = cmd("continue", "One more turn in a fork (resume without --fork-session).")
|
c = cmd("continue", "One more turn in a fork (resume without --fork-session).")
|
||||||
@@ -1959,6 +1977,8 @@ def build_parser() -> _Parser:
|
|||||||
r.add_argument("--phase", help="the new base's phase (the phase whose forks it serves)")
|
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-minutes", type=_pos_float, help="wall-time limit (default: init's max_minutes)")
|
||||||
r.add_argument("--max-budget-usd", type=_pos_float)
|
r.add_argument("--max-budget-usd", type=_pos_float)
|
||||||
|
r.add_argument("--delta-note", action="append", default=None, metavar="PATH", dest="delta_notes",
|
||||||
|
help="a note the rebase must absorb (repeatable): appended after the delta header")
|
||||||
launch_overrides(r)
|
launch_overrides(r)
|
||||||
|
|
||||||
u = cmd("usage", "Aggregate the ledger: tokens and cost per kind, cold starts, cache-hit ratio.")
|
u = cmd("usage", "Aggregate the ledger: tokens and cost per kind, cold starts, cache-hit ratio.")
|
||||||
@@ -2098,39 +2118,73 @@ def build_launch(state: str, *, model, effort, permission_mode, tools, allowed,
|
|||||||
return launch, adm["via"]
|
return launch, adm["via"]
|
||||||
|
|
||||||
|
|
||||||
def build_init_config(args, state: str) -> dict:
|
def build_init_config(args, state: str, old: dict | None = None) -> dict:
|
||||||
"""Validate the launch fields and build the config block. Writes nothing."""
|
"""Validate the launch fields and build the config block. Writes nothing.
|
||||||
tools = _tool_list([args.tools])
|
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."""
|
||||||
|
ol = (old or {}).get("launch") or {}
|
||||||
|
|
||||||
|
def keep(given, key, default):
|
||||||
|
if given is not None:
|
||||||
|
return given
|
||||||
|
if old is not None and key in old and old[key] is not None:
|
||||||
|
return old[key]
|
||||||
|
return default
|
||||||
|
model = args.model if args.model is not None else ol.get("model")
|
||||||
|
if model is None: # what argparse said when --model was required: a usage error
|
||||||
|
raise ForksError("usage", detail="the following arguments are required on the first init: --model")
|
||||||
|
tools = _tool_list([args.tools]) if args.tools is not None else \
|
||||||
|
(list(ol["tools"]) if ol.get("tools") else _tool_list([DEFAULT_TOOLS]))
|
||||||
allowed = _tool_list(args.allowed_tools)
|
allowed = _tool_list(args.allowed_tools)
|
||||||
if allowed is None:
|
if allowed is None:
|
||||||
allowed = list(tools)
|
allowed = list(ol["allowed_tools"]) if "allowed_tools" in ol else list(tools)
|
||||||
disallowed = _tool_list(args.disallowed_tools)
|
disallowed = _tool_list(args.disallowed_tools)
|
||||||
if disallowed is None:
|
if disallowed is None:
|
||||||
disallowed = list(DEFAULT_DISALLOWED)
|
disallowed = list(ol["disallowed_tools"]) if "disallowed_tools" in ol else list(DEFAULT_DISALLOWED)
|
||||||
|
if args.cwd is None and ol.get("cwd"):
|
||||||
|
cwd = ol["cwd"]
|
||||||
|
if not os.path.isdir(cwd):
|
||||||
|
raise ForksError("--cwd is not a directory", cwd=cwd)
|
||||||
|
else:
|
||||||
cwd = _resolve_cwd(args.cwd)
|
cwd = _resolve_cwd(args.cwd)
|
||||||
if not MODEL_RE.match(args.model or ""): # before the binary lookup, as in Phase 1
|
if not MODEL_RE.match(model or ""): # before the binary lookup, as in Phase 1
|
||||||
raise ForksError("model must be a full id (aliases are refused)", model=args.model,
|
raise ForksError("model must be a full id (aliases are refused)", model=model,
|
||||||
pattern=MODEL_RE.pattern)
|
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")
|
||||||
|
else:
|
||||||
bpath, how = resolve_binary(args.binary)
|
bpath, how = resolve_binary(args.binary)
|
||||||
if bpath is None:
|
if bpath is None:
|
||||||
raise ForksError("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)")
|
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,
|
agent = args.agent if args.agent is not None else (ol.get("agent") or {}).get("name")
|
||||||
permission_mode=args.permission_mode, tools=tools, allowed=allowed,
|
launch, via = build_launch(state, model=model,
|
||||||
disallowed=disallowed, agent=args.agent, cwd=cwd, binary_path=bpath)
|
effort=args.effort if args.effort is not None else ol.get("effort") or "high",
|
||||||
|
permission_mode=(args.permission_mode if args.permission_mode is not None
|
||||||
|
else ol.get("permission_mode") or "dontAsk"),
|
||||||
|
tools=tools, allowed=allowed, disallowed=disallowed, agent=agent, cwd=cwd,
|
||||||
|
binary_path=bpath)
|
||||||
|
old_th = (old or {}).get("thresholds") or {}
|
||||||
cfg = {
|
cfg = {
|
||||||
"launch": launch,
|
"launch": launch,
|
||||||
"fingerprint": fingerprint_hash(launch),
|
"fingerprint": fingerprint_hash(launch),
|
||||||
"binary_resolved_by": how,
|
"binary_resolved_by": how,
|
||||||
"parallel_cap": args.parallel_cap,
|
"parallel_cap": keep(args.parallel_cap, "parallel_cap", CONFIG_DEFAULTS["parallel_cap"]),
|
||||||
"orphan_after": args.orphan_after,
|
"orphan_after": keep(args.orphan_after, "orphan_after", CONFIG_DEFAULTS["orphan_after"]),
|
||||||
"pings": args.pings,
|
"pings": keep(args.pings, "pings", CONFIG_DEFAULTS["pings"]),
|
||||||
"ttl_s": args.ttl_s,
|
"ttl_s": keep(args.ttl_s, "ttl_s", CONFIG_DEFAULTS["ttl_s"]),
|
||||||
"warm_margin_s": args.warm_margin_s,
|
"warm_margin_s": keep(args.warm_margin_s, "warm_margin_s", None),
|
||||||
"max_minutes": args.max_minutes,
|
"max_minutes": keep(args.max_minutes, "max_minutes", CONFIG_DEFAULTS["max_minutes"]),
|
||||||
"stall_after_s": args.stall_after_s,
|
"stall_after_s": keep(args.stall_after_s, "stall_after_s", CONFIG_DEFAULTS["stall_after_s"]),
|
||||||
"thresholds": {k: getattr(args, k) for k in THRESHOLD_DEFAULTS},
|
"thresholds": {k: (getattr(args, k) if getattr(args, k) is not None
|
||||||
|
else old_th.get(k, THRESHOLD_DEFAULTS[k])) for k in THRESHOLD_DEFAULTS},
|
||||||
"model_admission": via,
|
"model_admission": via,
|
||||||
}
|
}
|
||||||
|
# Minor 2: a margin at or above the TTL makes a base `expiring` right after every hit.
|
||||||
|
if cfg["warm_margin_s"] is not None and int(cfg["warm_margin_s"]) >= int(cfg["ttl_s"]):
|
||||||
|
raise ForksError("--warm-margin-s must be below ttl_s (a base would read `expiring` right "
|
||||||
|
"after every hit)", warm_margin_s=cfg["warm_margin_s"], ttl_s=cfg["ttl_s"])
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -2145,10 +2199,14 @@ def _diff_fields(a, b, prefix="") -> list[str]:
|
|||||||
|
|
||||||
def cmd_init(args) -> tuple[dict, int]:
|
def cmd_init(args) -> tuple[dict, int]:
|
||||||
state = state_dir_for(args.run)
|
state = state_dir_for(args.run)
|
||||||
cfg = build_init_config(args, state) # all validation before any write
|
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
|
||||||
ensure_state(state)
|
ensure_state(state)
|
||||||
with RegistryLock(state):
|
with RegistryLock(state):
|
||||||
reg = read_registry(state)
|
reg = read_registry(state)
|
||||||
|
if (reg or {}).get("config") != old_cfg:
|
||||||
|
raise ForksError("the config changed while init ran; run init again", state=state)
|
||||||
created = reg is None
|
created = reg is None
|
||||||
if created:
|
if created:
|
||||||
reg = new_registry()
|
reg = new_registry()
|
||||||
@@ -2299,33 +2357,61 @@ def git_delta_counts(cwd: str, base_commit: str) -> tuple[int | None, int | None
|
|||||||
return commits, lines
|
return commits, lines
|
||||||
|
|
||||||
|
|
||||||
_NOTE_REF = re.compile(r"[^\s`'\"()<>\[\]{},;|*]+\.md\b")
|
# I1 (Phase 2 review): "delta" is what a fork must learn that its base does not already know.
|
||||||
|
# delta_tokens = chars / 4 of the delta header (commits and shortstat since the base's
|
||||||
|
# base_commit) + the notes attached with `--delta-note` whose mtime is after the base's
|
||||||
|
# `trained_at` (what the base was trained or rebased on it already knows) + the text of a
|
||||||
|
# `## Changes since base` section of the brief. Other files a brief merely names (build plan,
|
||||||
|
# run log, files to edit) and the rest of the brief never count.
|
||||||
|
_CHANGES_HEADING = re.compile(r"^##[ \t]+Changes since base[ \t]*#*[ \t]*$", re.I | re.M)
|
||||||
|
_HEADING_1_2 = re.compile(r"^#{1,2}[ \t]", re.M)
|
||||||
|
|
||||||
|
|
||||||
def listed_notes(brief: str | None, cwd: str, state: str) -> list[str]:
|
def changes_section(brief: str | None) -> str:
|
||||||
"""The `.md` files a brief names that exist: absolute paths as given, relative ones against
|
"""The body of the brief's `## Changes since base` section (up to the next level-1 or level-2
|
||||||
the base's cwd, then against the state dir (`notes/1.1.md`). Each file once."""
|
heading, or the end), or "" when the brief has none."""
|
||||||
|
m = _CHANGES_HEADING.search(brief or "")
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
rest = brief[m.end():]
|
||||||
|
nxt = _HEADING_1_2.search(rest)
|
||||||
|
return (rest[:nxt.start()] if nxt else rest).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_delta_notes(paths) -> list[dict]:
|
||||||
|
"""The notes given with `--delta-note`: [{"path", "text", "mtime"}], each file once.
|
||||||
|
A note that cannot be read is refused (exit 2) before anything is written."""
|
||||||
out, seen = [], set()
|
out, seen = [], set()
|
||||||
for ref in _NOTE_REF.findall(brief or ""):
|
for raw in paths or []:
|
||||||
for root in (cwd, state):
|
p = norm_path(raw)
|
||||||
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)
|
key = os.path.normcase(p)
|
||||||
if os.path.isfile(p):
|
if key in seen:
|
||||||
if key not in seen:
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
out.append(p)
|
try:
|
||||||
break
|
text = read_bytes_retry(p).decode("utf-8", "replace")
|
||||||
|
mtime = os.path.getmtime(p)
|
||||||
|
except OSError as e:
|
||||||
|
raise ForksError("cannot read delta note", note=p, detail=str(e))
|
||||||
|
out.append({"path": p, "text": text, "mtime": mtime})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _text_chars(path: str) -> int:
|
def new_to_base(notes: list[dict], base: dict) -> list[dict]:
|
||||||
try:
|
"""The attached notes the base does not know yet: mtime after its `trained_at` (a training's
|
||||||
return len(read_bytes_retry(path).decode("utf-8", "replace"))
|
or a rebase's end). With no `trained_at` every note counts."""
|
||||||
except OSError:
|
t = _ts((base or {}).get("trained_at"))
|
||||||
return 0
|
return [n for n in notes or [] if t is None or n["mtime"] > t]
|
||||||
|
|
||||||
|
|
||||||
|
def render_delta(header: str, notes: list[dict]) -> str:
|
||||||
|
"""The prompt's delta section: the delta header, then each attached note with its path."""
|
||||||
|
if not notes:
|
||||||
|
return header
|
||||||
|
parts = [header, "### Attached delta notes"]
|
||||||
|
for n in notes:
|
||||||
|
parts.append("#### `%s`\n\n%s" % (n["path"], n["text"].rstrip("\n")))
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def projects_dir() -> str:
|
def projects_dir() -> str:
|
||||||
@@ -2461,10 +2547,13 @@ def binary_state(base: dict, launch: dict, cache: dict | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def due_flags(state: str, reg: dict, name: str, *, phase=None, brief: str | None = None,
|
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:
|
delta: str | None = None, notes: list[dict] | None = None, binary: str | None = None,
|
||||||
|
bcache: dict | None = None) -> dict:
|
||||||
"""Design 5's rebase and re-train flags for base `name`, with their reasons (advisory: only
|
"""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
|
`binary_missing` stops a launch). `phase`/`brief`/`notes` are the fork's (none in `list`, which
|
||||||
fork's delta header (computed here when not given); `binary` a `binary_state` value."""
|
counts the delta header alone); `delta` the fork's delta header (computed here when not
|
||||||
|
given); `notes` its `--delta-note` files (`read_delta_notes`); `binary` a `binary_state`
|
||||||
|
value."""
|
||||||
cfg = reg["config"]
|
cfg = reg["config"]
|
||||||
base = reg["bases"][name]
|
base = reg["bases"][name]
|
||||||
la = launch_record(base, cfg)
|
la = launch_record(base, cfg)
|
||||||
@@ -2481,7 +2570,8 @@ def due_flags(state: str, reg: dict, name: str, *, phase=None, brief: str | None
|
|||||||
rebase.append("lines:%d" % lines)
|
rebase.append("lines:%d" % lines)
|
||||||
if delta is None:
|
if delta is None:
|
||||||
delta = delta_header(la["cwd"], bc)
|
delta = delta_header(la["cwd"], bc)
|
||||||
chars = len(delta) + sum(_text_chars(p) for p in listed_notes(brief, la["cwd"], state))
|
chars = (len(delta) + sum(len(n["text"]) for n in new_to_base(notes, base))
|
||||||
|
+ len(changes_section(brief)))
|
||||||
if chars // 4 > int(th["rebase_delta_tokens"]):
|
if chars // 4 > int(th["rebase_delta_tokens"]):
|
||||||
rebase.append("delta_tokens:%d" % (chars // 4))
|
rebase.append("delta_tokens:%d" % (chars // 4))
|
||||||
if binary is None:
|
if binary is None:
|
||||||
@@ -2591,6 +2681,15 @@ def cmd_train(args) -> tuple[dict, int]:
|
|||||||
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"]
|
||||||
|
supersedes = _uniq(args.supersedes or [])
|
||||||
|
|
||||||
|
def check_supersedes(r):
|
||||||
|
if name in supersedes:
|
||||||
|
raise ForksError("a base cannot supersede itself", base=name)
|
||||||
|
unknown = [n for n in supersedes if n not in r.get("bases", {})]
|
||||||
|
if unknown:
|
||||||
|
raise ForksError("unknown base", bases=unknown, option="--supersedes")
|
||||||
|
check_supersedes(reg)
|
||||||
la, overrides = train_launch(args, state, cfg)
|
la, overrides = train_launch(args, state, cfg)
|
||||||
binfo = la["binary"]
|
binfo = la["binary"]
|
||||||
drift: list[str] = [] # the record was built from the files just now
|
drift: list[str] = [] # the record was built from the files just now
|
||||||
@@ -2607,13 +2706,14 @@ def cmd_train(args) -> tuple[dict, int]:
|
|||||||
pending = _pending_rebase_to(reg, name)
|
pending = _pending_rebase_to(reg, name)
|
||||||
if pending:
|
if pending:
|
||||||
raise ForksError("a running rebase creates this base", base=name, job=pending)
|
raise ForksError("a running rebase creates this base", base=name, job=pending)
|
||||||
|
check_supersedes(reg)
|
||||||
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"],
|
||||||
"launch": la, "fingerprint": fingerprint_hash(la), "overrides": overrides,
|
"launch": la, "fingerprint": fingerprint_hash(la), "overrides": overrides,
|
||||||
"binary": binfo, "created_at": _iso(), "train_job": job,
|
"binary": binfo, "created_at": _iso(), "train_job": job,
|
||||||
"phase": _phase(args.phase), "last_hit": None, "parent": None,
|
"phase": _phase(args.phase), "last_hit": None, "parent": None,
|
||||||
"lineage_rebases": 0, "base_commit": None, "context_tokens": None,
|
"lineage_rebases": 0, "base_commit": None, "context_tokens": None,
|
||||||
"forks": []}
|
"forks": [], "supersedes": supersedes}
|
||||||
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,
|
||||||
@@ -2643,10 +2743,11 @@ def cmd_fork(args) -> tuple[dict, int]:
|
|||||||
if not base.get("session"):
|
if not base.get("session"):
|
||||||
raise ForksError("base has no session", base=args.base, status=base.get("status"))
|
raise ForksError("base has no session", base=args.base, status=base.get("status"))
|
||||||
brief_path, brief = _read_brief(args.brief)
|
brief_path, brief = _read_brief(args.brief)
|
||||||
|
notes = read_delta_notes(args.delta_notes)
|
||||||
binfo, drift = _check_binary(base.get("binary") or la["binary"], la)
|
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,
|
due = due_flags(state, reg, args.base, phase=_phase(args.phase), brief=brief, delta=delta,
|
||||||
binary="drift" if "binary" in drift else "ok")
|
notes=notes, 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]
|
||||||
@@ -2676,7 +2777,8 @@ def cmd_fork(args) -> tuple[dict, int]:
|
|||||||
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
||||||
warm_until_at_launch=warmth["warm_until"], launch_drift=drift,
|
warm_until_at_launch=warmth["warm_until"], launch_drift=drift,
|
||||||
binary_version=binfo["version"], head_at_launch=head, brief_source=brief_path,
|
binary_version=binfo["version"], head_at_launch=head, brief_source=brief_path,
|
||||||
forced=bool(args.force and base.get("status") != "active"))
|
forced=bool(args.force and base.get("status") != "active"),
|
||||||
|
delta_notes=[n["path"] for n in notes])
|
||||||
base.setdefault("forks", []).append(job)
|
base.setdefault("forks", []).append(job)
|
||||||
write_registry(state, reg)
|
write_registry(state, reg)
|
||||||
if args.mode == "parallel":
|
if args.mode == "parallel":
|
||||||
@@ -2690,8 +2792,8 @@ def cmd_fork(args) -> tuple[dict, int]:
|
|||||||
commit_rule += " Expected paths: %s." % ", ".join("`%s`" % p for p in paths)
|
commit_rule += " Expected paths: %s." % ", ".join("`%s`" % p for p in paths)
|
||||||
prompt = render_template("fork.md", {
|
prompt = render_template("fork.md", {
|
||||||
"base": args.base, "run": norm_path(args.run), "state_dir": state, "block": args.block,
|
"base": args.base, "run": norm_path(args.run), "state_dir": state, "block": args.block,
|
||||||
"phase": "n/a" if args.phase is None else str(args.phase), "mode": args.mode, "delta": delta,
|
"phase": "n/a" if args.phase is None else str(args.phase), "mode": args.mode,
|
||||||
"commit_rule": commit_rule, "note_path": os.path.join(state, *note_rel(args.block).split("/")),
|
"delta": render_delta(delta, notes), "commit_rule": commit_rule, "note_path": os.path.join(state, *note_rel(args.block).split("/")),
|
||||||
"logs_dir": os.path.join(state, "logs"), "brief": brief})
|
"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"],
|
||||||
@@ -2772,6 +2874,7 @@ def cmd_rebase(args) -> tuple[dict, int]:
|
|||||||
raise ForksError("base is not active", base=args.base, status=base.get("status"))
|
raise ForksError("base is not active", base=args.base, status=base.get("status"))
|
||||||
la = launch_record(base, cfg)
|
la = launch_record(base, cfg)
|
||||||
brief_path, brief = _read_brief(args.brief)
|
brief_path, brief = _read_brief(args.brief)
|
||||||
|
notes = read_delta_notes(args.delta_notes)
|
||||||
binfo, drift = _check_binary(base.get("binary") or la["binary"], la)
|
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
|
# 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).
|
# current binary, which the new base then records (design 5).
|
||||||
@@ -2813,12 +2916,13 @@ def cmd_rebase(args) -> tuple[dict, int]:
|
|||||||
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
base_context=base.get("context_tokens"), base_state_at_launch=warmth["base_state"],
|
||||||
warm_until_at_launch=warmth["warm_until"], launch_drift=drift,
|
warm_until_at_launch=warmth["warm_until"], launch_drift=drift,
|
||||||
binary_version=binfo["version"], head_at_launch=head, brief_source=brief_path,
|
binary_version=binfo["version"], head_at_launch=head, brief_source=brief_path,
|
||||||
rebase_as=new, new_launch=new_launch)
|
rebase_as=new, new_launch=new_launch, delta_notes=[n["path"] for n in notes])
|
||||||
write_registry(state, reg)
|
write_registry(state, reg)
|
||||||
prompt = render_template("rebase.md", {
|
prompt = render_template("rebase.md", {
|
||||||
"base": args.base, "new_base": new, "run": norm_path(args.run), "state_dir": state,
|
"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"),
|
"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})
|
"phase": "n/a" if args.phase is None else str(args.phase), "delta": render_delta(delta, notes),
|
||||||
|
"brief": brief})
|
||||||
_start_job(state, norm_path(args.run), job, prompt, la)
|
_start_job(state, norm_path(args.run), job, prompt, la)
|
||||||
return {"job": job, "base": new, "parent": args.base, "session_id": session}, EXIT_OK
|
return {"job": job, "base": new, "parent": args.base, "session_id": session}, EXIT_OK
|
||||||
|
|
||||||
@@ -2852,6 +2956,20 @@ def pings_since_real_call(reg: dict, name: str) -> int:
|
|||||||
if e.get("base") == name and e.get("kind") == "ping" and _job_num(j) > last_real)
|
if e.get("base") == name and e.get("kind") == "ping" and _job_num(j) > last_real)
|
||||||
|
|
||||||
|
|
||||||
|
def ping_phase(reg: dict, name: str):
|
||||||
|
"""I2: the phase a ping on base `name` belongs to: that of the latest (by job number) train,
|
||||||
|
fork, continue or rebase job on the base that has a phase; else the base's own phase; else
|
||||||
|
None. So `usage --phase N` carries the keep-warm cost of the phase's bases."""
|
||||||
|
best, ph = -1, None
|
||||||
|
for j, e in reg.get("jobs", {}).items():
|
||||||
|
if e.get("base") == name and e.get("kind") in REAL_KINDS and e.get("phase") is not None \
|
||||||
|
and _job_num(j) > best:
|
||||||
|
best, ph = _job_num(j), e["phase"]
|
||||||
|
if ph is None:
|
||||||
|
ph = (reg.get("bases", {}).get(name) or {}).get("phase")
|
||||||
|
return ph
|
||||||
|
|
||||||
|
|
||||||
def ping_in_flight(reg: dict, base: dict | None) -> str | None:
|
def ping_in_flight(reg: dict, base: dict | None) -> str | None:
|
||||||
"""The base's in-flight ping job (C3d), or None. A mark whose job is no longer running (or
|
"""The base's in-flight ping job (C3d), or None. A mark whose job is no longer running (or
|
||||||
gone) does not count: it clears itself on the next marking."""
|
gone) does not count: it clears itself on the next marking."""
|
||||||
@@ -2949,7 +3067,8 @@ def tick_pass(state: str) -> dict:
|
|||||||
argv = claude_argv(la) + ["--resume", base["session"], "--fork-session",
|
argv = claude_argv(la) + ["--resume", base["session"], "--fork-session",
|
||||||
"--session-id", session, "--max-turns", "1"]
|
"--session-id", session, "--max-turns", "1"]
|
||||||
reg["jobs"][job] = _new_entry(
|
reg["jobs"][job] = _new_entry(
|
||||||
job, "ping", base=n, session=session, parent_session=base["session"],
|
job, "ping", base=n, phase=ping_phase(reg, n), session=session,
|
||||||
|
parent_session=base["session"],
|
||||||
model_requested=la["model"], cwd=la["cwd"], argv=argv, max_s=PING_MAX_S,
|
model_requested=la["model"], cwd=la["cwd"], argv=argv, max_s=PING_MAX_S,
|
||||||
cost_before=float(base.get("cost_cum_usd") or 0.0),
|
cost_before=float(base.get("cost_cum_usd") or 0.0),
|
||||||
model_usage_before=base.get("model_usage_cum") or {},
|
model_usage_before=base.get("model_usage_cum") or {},
|
||||||
@@ -3179,6 +3298,9 @@ class Supervisor:
|
|||||||
_diag("swept %d leftover process(es) of claude: %s" % (
|
_diag("swept %d leftover process(es) of claude: %s" % (
|
||||||
len(sweep["snapshot"]), [(x["pid"], x["name"]) for x in sweep["snapshot"]]))
|
len(sweep["snapshot"]), [(x["pid"], x["name"]) for x in sweep["snapshot"]]))
|
||||||
reader.join(READER_JOIN_S)
|
reader.join(READER_JOIN_S)
|
||||||
|
if not reader.is_alive(): # the reader is done with the pipe (Minor 10)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
p.stdout.close()
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
raw.close()
|
raw.close()
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
@@ -3558,6 +3680,29 @@ def usage_md_phase(agg: dict) -> str:
|
|||||||
"- Priced total: %s" % _cost(agg["total"]["cost_usd"])]) + "\n"
|
"- Priced total: %s" % _cost(agg["total"]["cost_usd"])]) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
RESULT_STATUS_KEYS = ("status", "flags", "reason")
|
||||||
|
|
||||||
|
|
||||||
|
def status_from_results(state: str, lines: list[dict]) -> tuple[list[dict], list[str]]:
|
||||||
|
"""Minor 11: the ledger is append-only, so a job finalised again after its line was written
|
||||||
|
(R1 reconciled a result whose entry still read `running`, then `reap_lost` or `kill` rewrote
|
||||||
|
the result as `lost`/`killed`) keeps its first status there. `usage` takes each job's
|
||||||
|
status, flags and reason from `results/<job>.json` when they differ from its ledger line;
|
||||||
|
tokens and cost stay the ledger's. Returns (lines, the jobs whose status came from a result)."""
|
||||||
|
out, changed = [], []
|
||||||
|
for ln in lines:
|
||||||
|
try:
|
||||||
|
res = read_json(result_file(state, ln["job"]))
|
||||||
|
except ForksError:
|
||||||
|
res = None
|
||||||
|
if isinstance(res, dict) and any(res.get(k) != ln.get(k) for k in RESULT_STATUS_KEYS):
|
||||||
|
ln = dict(ln, status=res.get("status"), flags=list(res.get("flags") or []),
|
||||||
|
reason=res.get("reason"))
|
||||||
|
changed.append(ln["job"])
|
||||||
|
out.append(ln)
|
||||||
|
return out, changed
|
||||||
|
|
||||||
|
|
||||||
def cmd_usage(args) -> tuple[dict | str, int]:
|
def cmd_usage(args) -> tuple[dict | str, int]:
|
||||||
"""`usage --run R [--block ID | --phase N] [--format json|md]`. R1 first: reconcile the ledger
|
"""`usage --run R [--block ID | --phase N] [--format json|md]`. R1 first: reconcile the ledger
|
||||||
against `results/` under the lock. Allowed inside a fork (N3)."""
|
against `results/` under the lock. Allowed inside a fork (N3)."""
|
||||||
@@ -3573,12 +3718,14 @@ def cmd_usage(args) -> tuple[dict | str, int]:
|
|||||||
elif args.phase is not None:
|
elif args.phase is not None:
|
||||||
scope = {"phase": _phase(args.phase)}
|
scope = {"phase": _phase(args.phase)}
|
||||||
lines = [x for x in lines if x.get("phase") == scope["phase"]]
|
lines = [x for x in lines if x.get("phase") == scope["phase"]]
|
||||||
|
lines, from_result = status_from_results(state, lines)
|
||||||
if args.format == "md":
|
if args.format == "md":
|
||||||
if args.block is not None:
|
if args.block is not None:
|
||||||
return usage_md_block(lines, reg.get("jobs") or {}), EXIT_OK
|
return usage_md_block(lines, reg.get("jobs") or {}), EXIT_OK
|
||||||
return usage_md_phase(usage_aggregate(lines)), EXIT_OK
|
return usage_md_phase(usage_aggregate(lines)), EXIT_OK
|
||||||
agg = usage_aggregate(lines)
|
agg = usage_aggregate(lines)
|
||||||
return {"state": state, "scope": scope, "reconciled": added, **agg, "rows": lines}, EXIT_OK
|
return {"state": state, "scope": scope, "reconciled": added, "status_from_result": from_result,
|
||||||
|
**agg, "rows": lines}, EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
HANDLERS = {
|
HANDLERS = {
|
||||||
|
|||||||
@@ -721,6 +721,62 @@ class InitTests(Base):
|
|||||||
self.assertFalse(out["changed"])
|
self.assertFalse(out["changed"])
|
||||||
self.assertEqual(self.reg_bytes(), before)
|
self.assertEqual(self.reg_bytes(), before)
|
||||||
|
|
||||||
|
def test_rerun_keeps_omitted_settings(self):
|
||||||
|
"""Minor 8: on a re-run, flags that are not given keep their current values (launch fields
|
||||||
|
and run-wide settings); defaults apply only on the first init; `changed_fields` lists only
|
||||||
|
what the given flags changed."""
|
||||||
|
code, first, _ = self.init("--pings", "off", "--parallel-cap", "3", "--rebase-commits", "9",
|
||||||
|
"--orphan-after", "1500", "--effort", "low", "--tools", "Read,Bash",
|
||||||
|
model="claude-sonnet-5-5")
|
||||||
|
self.assertEqual(code, 0, first)
|
||||||
|
self.add_fake_base()
|
||||||
|
before = forks.read_registry(self.state)["config"]
|
||||||
|
code, out, raw = call(["init", "--run", self.run_log, "--warm-margin-s", "100"])
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
self.assert_one_json_line(raw)
|
||||||
|
self.assertTrue(out["changed"])
|
||||||
|
self.assertEqual(out["changed_fields"], ["warm_margin_s"])
|
||||||
|
cfg = forks.read_registry(self.state)["config"]
|
||||||
|
self.assertEqual(cfg["warm_margin_s"], 100)
|
||||||
|
self.assertEqual((cfg["pings"], cfg["parallel_cap"], cfg["orphan_after"]), ("off", 3, 1500))
|
||||||
|
self.assertEqual(cfg["thresholds"], dict(forks.THRESHOLD_DEFAULTS, rebase_commits=9))
|
||||||
|
self.assertEqual(cfg["launch"], before["launch"])
|
||||||
|
self.assertEqual((cfg["launch"]["model"], cfg["launch"]["effort"], cfg["launch"]["tools"]),
|
||||||
|
("claude-sonnet-5-5", "low", ["Read", "Bash"]))
|
||||||
|
self.assertEqual(cfg["fingerprint"], before["fingerprint"])
|
||||||
|
# a given flag still changes its value, and only it is listed
|
||||||
|
code, out, _ = call(["init", "--run", self.run_log, "--pings", "on"])
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
self.assertEqual(out["changed_fields"], ["pings"])
|
||||||
|
cfg = forks.read_registry(self.state)["config"]
|
||||||
|
self.assertEqual((cfg["pings"], cfg["warm_margin_s"], cfg["parallel_cap"]), ("on", 100, 3))
|
||||||
|
# the first init still needs --model (a usage error, nothing written)
|
||||||
|
other = os.path.join(self.tmp, "reports", "2026-10-16-09-u-run.md")
|
||||||
|
code, out, raw = call(["init", "--run", other, "--cwd", self.cwd, "--binary", self.stub])
|
||||||
|
self.assertEqual(code, 2, out)
|
||||||
|
self.assert_one_json_line(raw)
|
||||||
|
self.assertEqual(out["error"], "usage")
|
||||||
|
self.assertIn("--model", out["detail"])
|
||||||
|
self.assertFalse(os.path.exists(forks.state_dir_for(other)))
|
||||||
|
|
||||||
|
def test_warm_margin_must_be_below_ttl(self):
|
||||||
|
"""Minor 2: `init` refuses a warm_margin_s at or above the configured ttl_s (exit 2), on the
|
||||||
|
first init and on a re-run that lowers ttl_s under the current margin."""
|
||||||
|
out = self.assert_refused_no_state("--warm-margin-s", "3600")
|
||||||
|
self.assertEqual((out["warm_margin_s"], out["ttl_s"]), (3600, 3600))
|
||||||
|
self.assert_refused_no_state("--ttl-s", "300", "--warm-margin-s", "300")
|
||||||
|
code, out, _ = self.init("--warm-margin-s", "3599")
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
before = self.reg_bytes()
|
||||||
|
code, out, raw = self.init("--ttl-s", "3000")
|
||||||
|
self.assertEqual(code, 2, out)
|
||||||
|
self.assert_one_json_line(raw)
|
||||||
|
self.assertIn("below ttl_s", out["error"])
|
||||||
|
self.assertEqual(self.reg_bytes(), before)
|
||||||
|
code, out, _ = self.init("--ttl-s", "300", "--warm-margin-s", "200")
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
self.assertEqual(sorted(out["changed_fields"]), ["ttl_s", "warm_margin_s"])
|
||||||
|
|
||||||
def test_fingerprint_hash_stable_across_runs(self):
|
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
|
"""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."""
|
same launch fields hash the same; the path stays in the config, outside the hash."""
|
||||||
@@ -1635,11 +1691,28 @@ class WarmthTests(Base):
|
|||||||
with self.subTest(margin=900, t=t - T0):
|
with self.subTest(margin=900, t=t - T0):
|
||||||
self.assertEqual(st(b, m, t), want)
|
self.assertEqual(st(b, m, t), want)
|
||||||
self.assertEqual(forks.base_warmth(b, m, T0)["margin_s"], 900)
|
self.assertEqual(forks.base_warmth(b, m, T0)["margin_s"], 900)
|
||||||
for t, want in ((T0, "expiring"), (T0 + 299, "expiring"), (T0 + 300, "cold")):
|
# Minor 2: at a 5-minute TTL the 900 s margin is capped at ttl/2 = 150 s
|
||||||
|
for t, want in ((T0, "warm"), (T0 + 149, "warm"), (T0 + 150, "expiring"), (T0 + 299, "expiring"),
|
||||||
|
(T0 + 300, "cold")):
|
||||||
with self.subTest(margin=900, base_ttl=300, t=t - T0):
|
with self.subTest(margin=900, base_ttl=300, t=t - T0):
|
||||||
self.assertEqual(st({**b, "ttl_s": 300}, m, t), want)
|
self.assertEqual(st({**b, "ttl_s": 300}, m, t), want)
|
||||||
|
self.assertEqual(forks.base_warmth({**b, "ttl_s": 300}, m, T0)["margin_s"], 150)
|
||||||
self.assertEqual(st({}, cfg, T0), "cold")
|
self.assertEqual(st({}, cfg, T0), "cold")
|
||||||
|
|
||||||
|
def test_margin_capped_at_half_the_ttl(self):
|
||||||
|
"""Minor 2: a configured margin above ttl/2 is capped there, so a 5-minute downgrade does not
|
||||||
|
leave a base `expiring` (and pinged every tick) right after every hit."""
|
||||||
|
T0 = self.T0
|
||||||
|
b = {"last_hit": forks._iso(T0), "ttl_s": 300}
|
||||||
|
w = forks.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": 600}, T0 + 1)
|
||||||
|
self.assertEqual((w["base_state"], w["margin_s"], w["ttl_s"]), ("warm", 150, 300))
|
||||||
|
reg = self.make_reg(T0 + 1, margin=600, base_ttl=300)
|
||||||
|
self.assertEqual(self.decide(reg, T0 + 1)["why"], "state:warm")
|
||||||
|
self.assertTrue(self.decide(reg, T0 + 150)["ping"])
|
||||||
|
# a margin below ttl/2 is used as given; the formula applies when none is set
|
||||||
|
self.assertEqual(forks.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": 100}, T0)["margin_s"], 100)
|
||||||
|
self.assertEqual(forks.base_warmth(b, {"ttl_s": 3600, "warm_margin_s": None}, T0)["margin_s"], 120)
|
||||||
|
|
||||||
def test_infer_ttl_and_downgrade(self):
|
def test_infer_ttl_and_downgrade(self):
|
||||||
def t(h=0, m=0, r=0):
|
def t(h=0, m=0, r=0):
|
||||||
return {"cache_creation_1h": h, "cache_creation_5m": m, "cache_read": r}
|
return {"cache_creation_1h": h, "cache_creation_5m": m, "cache_read": r}
|
||||||
@@ -1684,8 +1757,11 @@ class WarmthTests(Base):
|
|||||||
# 5 min TTL inferred on the base, margin from init
|
# 5 min TTL inferred on the base, margin from init
|
||||||
d = self.decide(self.make_reg(self.E5M, base_ttl=300), self.E5M)
|
d = self.decide(self.make_reg(self.E5M, base_ttl=300), self.E5M)
|
||||||
self.assertEqual((d["ping"], d["ttl_s"], d["break_even"]), (True, 300, 11))
|
self.assertEqual((d["ping"], d["ttl_s"], d["break_even"]), (True, 300, 11))
|
||||||
t = self.T0 + 100
|
# init's margin, capped at ttl/2 (Minor 2): 3600 acts as 1800 at a 1 h TTL
|
||||||
|
t = self.T0 + 1800
|
||||||
self.assertTrue(self.decide(self.make_reg(t, margin=3600), t)["ping"])
|
self.assertTrue(self.decide(self.make_reg(t, margin=3600), t)["ping"])
|
||||||
|
t = self.T0 + 1799
|
||||||
|
self.assertEqual(self.decide(self.make_reg(t, margin=3600), t)["why"], "state:warm")
|
||||||
|
|
||||||
def test_ping_rule_activity_window(self):
|
def test_ping_rule_activity_window(self):
|
||||||
now = self.E1H
|
now = self.E1H
|
||||||
@@ -1847,6 +1923,53 @@ class TickTests(Base):
|
|||||||
self.assertEqual(len(out["pinged"]), 1)
|
self.assertEqual(len(out["pinged"]), 1)
|
||||||
self.assertEqual(out["bases"][0]["pings_since_real_call"], 1) # counted before this one
|
self.assertEqual(out["bases"][0]["pings_since_real_call"], 1) # counted before this one
|
||||||
|
|
||||||
|
def test_ping_carries_the_phase_and_counts_in_usage_phase(self):
|
||||||
|
"""I2: a ping records the phase of the latest train/fork/continue/rebase job on its base
|
||||||
|
(the base's own phase if none has one), so `usage --phase N` (md and json) lists it and its
|
||||||
|
cost is in the priced total."""
|
||||||
|
reg = self.reg()
|
||||||
|
self.assertIsNone(forks.ping_phase(reg, "B1")) # nothing has a phase
|
||||||
|
reg["bases"]["B1"]["phase"] = 1
|
||||||
|
self.assertEqual(forks.ping_phase(reg, "B1"), 1) # the base's own
|
||||||
|
reg["jobs"]["j0002"] = forks._new_entry("j0002", "fork", base="B1", phase=2, status="ok",
|
||||||
|
ended_at=forks._iso(time.time() - 4000))
|
||||||
|
reg["jobs"]["j0003"] = forks._new_entry("j0003", "fork", base="B9", phase=7, status="ok")
|
||||||
|
reg["jobs"]["j0004"] = forks._new_entry("j0004", "continue", base="B1", phase=None, status="ok")
|
||||||
|
reg["jobs"]["j0005"] = forks._new_entry("j0005", "ping", base="B1", phase=5, status="ok")
|
||||||
|
self.assertEqual(forks.ping_phase(reg, "B1"), 2) # latest real job with a phase
|
||||||
|
with forks.RegistryLock(self.state):
|
||||||
|
forks.write_registry(self.state, reg)
|
||||||
|
out = forks.tick_pass(self.state)
|
||||||
|
self.assertEqual(len(out["pinged"]), 1, out)
|
||||||
|
job = out["pinged"][0]
|
||||||
|
self.assertEqual(self.reg()["jobs"][job]["phase"], 2)
|
||||||
|
# the ping ends ok with a known cost (cost basis: the base's 1.25)
|
||||||
|
ss = forks.StreamState()
|
||||||
|
t = time.time()
|
||||||
|
u = {"input_tokens": 1, "cache_read_input_tokens": 1000, "cache_creation_input_tokens": 0}
|
||||||
|
ss.feed({"type": "assistant", "message": {"id": "m1", "usage": u}}, t)
|
||||||
|
ss.feed({"type": "result", "is_error": False, "num_turns": 1, "total_cost_usd": 1.2623,
|
||||||
|
"usage": dict(u, output_tokens=2, cache_creation={}),
|
||||||
|
"modelUsage": {TEST_MODEL: {"inputTokens": 2, "outputTokens": 2,
|
||||||
|
"cacheReadInputTokens": 1000, "cacheCreationInputTokens": 0}}},
|
||||||
|
t)
|
||||||
|
res = forks.finalize_job(self.state, job, ss=ss, hint="exited", exit_code=0, wall_s=1.0)
|
||||||
|
self.assertEqual((res["status"], res["phase"], res["cost_delta_usd"]), ("ok", 2, 0.0123), res)
|
||||||
|
line = [x for x in forks.read_ledger(self.state) if x["job"] == job][0]
|
||||||
|
self.assertEqual(line["phase"], 2)
|
||||||
|
code, js, _ = call(["usage", "--run", self.run_log, "--phase", "2"])
|
||||||
|
self.assertEqual(code, 0, js)
|
||||||
|
self.assertEqual([x["job"] for x in js["rows"]], [job])
|
||||||
|
self.assertEqual(js["kinds"]["ping"]["calls"], 1)
|
||||||
|
self.assertAlmostEqual(js["kinds"]["ping"]["cost_usd"], 0.0123, places=6)
|
||||||
|
self.assertAlmostEqual(js["total"]["cost_usd"], 0.0123, places=6)
|
||||||
|
code, md = call_raw(["usage", "--run", self.run_log, "--phase", "2", "--format", "md"])
|
||||||
|
self.assertEqual(code, 0, md)
|
||||||
|
self.assertIn("| ping | 1 | 1 | 2 | 0 | 1,000 | $0.0123 |\n", md)
|
||||||
|
self.assertIn("| total | 1 | 1 | 2 | 0 | 1,000 | $0.0123 |\n", md)
|
||||||
|
self.assertTrue(md.endswith("- Priced total: $0.0123\n"), md)
|
||||||
|
self.assertEqual(call(["usage", "--run", self.run_log, "--phase", "1"])[1]["rows"], [])
|
||||||
|
|
||||||
def test_tick_command_and_n3(self):
|
def test_tick_command_and_n3(self):
|
||||||
code, out, raw = call(["tick", "--run", self.run_log])
|
code, out, raw = call(["tick", "--run", self.run_log])
|
||||||
self.assertEqual(code, 0, out)
|
self.assertEqual(code, 0, out)
|
||||||
@@ -2185,21 +2308,48 @@ class UsageTests(Base):
|
|||||||
self.assertEqual(self.usage("--phase", "3")["reconciled"], [])
|
self.assertEqual(self.usage("--phase", "3")["reconciled"], [])
|
||||||
|
|
||||||
def test_r1_waits_for_the_registry_lock(self):
|
def test_r1_waits_for_the_registry_lock(self):
|
||||||
"""With the lock held and a line missing, `usage` blocks until it is released, then
|
"""With the lock held and a line missing, the reconciliation itself blocks until the lock is
|
||||||
reconciles."""
|
released, then appends. Minor 9: `reconcile_ledger` is called directly (`usage` would wait
|
||||||
|
on its orchestrator stamp first, so a reconciliation without the lock would pass too)."""
|
||||||
self.result_for("j0010", block="4.1", phase=4)
|
self.result_for("j0010", block="4.1", phase=4)
|
||||||
done = []
|
done = []
|
||||||
lock = forks.RegistryLock(self.state)
|
lock = forks.RegistryLock(self.state)
|
||||||
lock.acquire()
|
lock.acquire()
|
||||||
try:
|
try:
|
||||||
th = threading.Thread(target=lambda: done.append(self.usage("--phase", "4")))
|
th = threading.Thread(target=lambda: done.append(forks.reconcile_ledger(self.state)))
|
||||||
th.start()
|
th.start()
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
self.assertEqual(done, []) # blocked on the lock
|
self.assertEqual(done, []) # blocked on the lock
|
||||||
|
self.assertNotIn("j0010", [x["job"] for x in self.ledger_lines()])
|
||||||
finally:
|
finally:
|
||||||
lock.release()
|
lock.release()
|
||||||
th.join(30)
|
th.join(30)
|
||||||
self.assertEqual(done[0]["reconciled"], ["j0010"])
|
self.assertEqual(done, [["j0010"]])
|
||||||
|
self.assertEqual(len([x for x in self.ledger_lines() if x["job"] == "j0010"]), 1)
|
||||||
|
|
||||||
|
def test_status_from_result_when_it_changed_after_the_ledger_line(self):
|
||||||
|
"""Minor 11: the ledger stays append-only; a job finalised again after its line was written
|
||||||
|
(R1 line, then `reap_lost`/`kill` rewrote the result) reads its status, flags and reason
|
||||||
|
from the result in `usage`; tokens and cost stay the ledger's."""
|
||||||
|
self.result_for("j0002", status="lost", flags=["cold_start"], reason="supervisor_dead",
|
||||||
|
block="1.1", phase=1, tokens=dict(zip(forks.TOKEN_KEYS, (9, 9, 9, 9, 0, 9))),
|
||||||
|
cost_delta_usd=9.0)
|
||||||
|
self.result_for("j0004", block="1.2", phase=1, flags=[]) # agrees with its line
|
||||||
|
n0 = len(self.ledger_lines())
|
||||||
|
out = self.usage("--phase", "1")
|
||||||
|
self.assertEqual(out["status_from_result"], ["j0002"])
|
||||||
|
row = {x["job"]: x for x in out["rows"]}["j0002"]
|
||||||
|
self.assertEqual((row["status"], row["flags"], row["reason"]), ("lost", ["cold_start"], "supervisor_dead"))
|
||||||
|
self.assertEqual(row["tokens"]["cache_read"], 1234567) # the ledger's, not the result's
|
||||||
|
self.assertAlmostEqual(row["cost_delta_usd"], 1.2345, places=6)
|
||||||
|
self.assertAlmostEqual(out["total"]["cost_usd"], 2.0845, places=6)
|
||||||
|
self.assertEqual(out["cold_starts"], 2) # the result's flags count
|
||||||
|
self.assertEqual({x["job"]: x["status"] for x in out["rows"]}["j0004"], "ok")
|
||||||
|
# the ledger itself is unchanged (append-only)
|
||||||
|
led = self.ledger_lines()
|
||||||
|
self.assertEqual(len(led), n0)
|
||||||
|
self.assertEqual([x["status"] for x in led if x["job"] == "j0002"], ["ok"])
|
||||||
|
self.assertEqual(self.usage("--phase", "2")["status_from_result"], [])
|
||||||
|
|
||||||
def test_r1_nothing_missing_takes_no_lock(self):
|
def test_r1_nothing_missing_takes_no_lock(self):
|
||||||
self.result_for("j0002") # a result that has its ledger line
|
self.result_for("j0002") # a result that has its ledger line
|
||||||
@@ -2862,11 +3012,12 @@ class LaunchTests(LaunchBase):
|
|||||||
"""A stubbed ping: launched by `wait`'s tick, returned once through `wait`'s `pings` (never
|
"""A stubbed ping: launched by `wait`'s tick, returned once through `wait`'s `pings` (never
|
||||||
`done`); a `partial` ping refreshes last_hit, a missed or failed one does not and carries
|
`done`); a `partial` ping refreshes last_hit, a missed or failed one does not and carries
|
||||||
`ping_missed`; the in-flight mark clears; each ping has its ledger line."""
|
`ping_missed`; the in-flight mark clears; each ping has its ledger line."""
|
||||||
self.setup_run("--warm-margin-s", "3600") # expiring from the last hit on
|
# expiring from 1800 s after the last hit on (a margin must be below ttl_s; Minor 2)
|
||||||
|
self.setup_run("--warm-margin-s", "1800")
|
||||||
self.train("B1")
|
self.train("B1")
|
||||||
self.finish("j0001")
|
self.finish("j0001")
|
||||||
base_session = self.reg()["bases"]["B1"]["session"]
|
base_session = self.reg()["bases"]["B1"]["session"]
|
||||||
old = forks._iso(time.time() - 100)
|
old = forks._iso(time.time() - 2000)
|
||||||
self.set_last_hit(old)
|
self.set_last_hit(old)
|
||||||
# 1. partial, launched by wait's tick (the wait itself is the activity)
|
# 1. partial, launched by wait's tick (the wait itself is the activity)
|
||||||
with mock.patch.object(forks, "PING_PROMPT", "Reply OK\n@stub cache=partial"):
|
with mock.patch.object(forks, "PING_PROMPT", "Reply OK\n@stub cache=partial"):
|
||||||
@@ -3035,12 +3186,14 @@ class LaunchTests(LaunchBase):
|
|||||||
self.clone_base("Bl", base_commit=c7)
|
self.clone_base("Bl", base_commit=c7)
|
||||||
self.assert_due(self.fake_fork("Bl"), "lines:1600")
|
self.assert_due(self.fake_fork("Bl"), "lines:1600")
|
||||||
# every clone from here on starts at HEAD
|
# every clone from here on starts at HEAD
|
||||||
# delta_tokens: chars / 4 of the delta header plus the notes the brief lists > 12000
|
# delta_tokens: chars / 4 of the delta header plus the attached delta notes > 12000 (I1)
|
||||||
with open(os.path.join(self.state, "notes", "big-note.md"), "w") as f:
|
big_note = os.path.join(self.state, "notes", "big-note.md")
|
||||||
|
with open(big_note, "w") as f:
|
||||||
f.write("n" * 60000)
|
f.write("n" * 60000)
|
||||||
self.clone_base("Bd")
|
self.clone_base("Bd")
|
||||||
self.assert_due(self.fake_fork("Bd")) # brief lists no note
|
self.assert_due(self.fake_fork("Bd")) # no note attached
|
||||||
out = self.fake_fork("Bd", brief=self.brief(text="Read notes/big-note.md first."))
|
self.assert_due(self.fake_fork("Bd", brief=self.brief(text="Read notes/big-note.md first.")))
|
||||||
|
out = self.fake_fork("Bd", "--delta-note", big_note)
|
||||||
self.assertEqual(len(out["reasons"]), 1, out)
|
self.assertEqual(len(out["reasons"]), 1, out)
|
||||||
self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$")
|
self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$")
|
||||||
self.assertGreater(int(out["reasons"][0].split(":")[1]), 15000)
|
self.assertGreater(int(out["reasons"][0].split(":")[1]), 15000)
|
||||||
@@ -3102,6 +3255,105 @@ class LaunchTests(LaunchBase):
|
|||||||
with self.subTest(base=name):
|
with self.subTest(base=name):
|
||||||
self.assert_due(rows[name], *want)
|
self.assert_due(rows[name], *want)
|
||||||
|
|
||||||
|
def test_delta_tokens_counts_only_what_the_base_does_not_know(self):
|
||||||
|
"""I1: delta_tokens = the delta header + `--delta-note` files newer than the base's training
|
||||||
|
+ a `## Changes since base` section of the brief. Large reference docs a brief merely names
|
||||||
|
never count; an attached note is in the fork's prompt with its path; a base trained or
|
||||||
|
rebased after the note's mtime does not count it, so a rebase clears the reason."""
|
||||||
|
self.setup_run(cap=4)
|
||||||
|
self.train("B1")
|
||||||
|
self.finish("j0001")
|
||||||
|
os.makedirs(os.path.join(self.cwd, "docs"))
|
||||||
|
for rel in ("docs/build-plan.md", "docs/run-log.md"):
|
||||||
|
with open(os.path.join(self.cwd, *rel.split("/")), "w") as f:
|
||||||
|
f.write("r" * 80000)
|
||||||
|
with open(os.path.join(self.state, "notes", "1.1.md"), "w") as f:
|
||||||
|
f.write("h" * 80000)
|
||||||
|
named = self.brief(text="Read docs/build-plan.md, docs/run-log.md and notes/1.1.md; edit "
|
||||||
|
"docs/run-log.md and %s." % os.path.join(self.cwd, "docs", "build-plan.md"))
|
||||||
|
self.assert_due(self.fake_fork("B1", brief=named)) # named only: nothing
|
||||||
|
# a large attached note trips it, and the fork's prompt carries it with its path
|
||||||
|
note = os.path.join(self.tmp, "delta-1.md")
|
||||||
|
with open(note, "w", newline="\n") as f:
|
||||||
|
f.write("NOTE-MARKER\n" + "d" * 60000 + "\n")
|
||||||
|
out = self.fake_fork("B1", "--delta-note", note, brief=named)
|
||||||
|
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)
|
||||||
|
with open(os.path.join(self.state, "briefs", out["job"] + ".md"), encoding="utf-8") as fh:
|
||||||
|
prompt = fh.read()
|
||||||
|
delta_at = prompt.index("## Delta since training")
|
||||||
|
self.assertLess(delta_at, prompt.index("`%s`" % forks.norm_path(note)))
|
||||||
|
self.assertLess(prompt.index("NOTE-MARKER"), prompt.index("## Rules for this fork"))
|
||||||
|
self.assertEqual(self.reg()["jobs"][out["job"]]["delta_notes"], [forks.norm_path(note)])
|
||||||
|
# a small attached note does not; a missing one is refused
|
||||||
|
small = os.path.join(self.tmp, "delta-small.md")
|
||||||
|
with open(small, "w") as f:
|
||||||
|
f.write("small\n")
|
||||||
|
self.assert_due(self.fake_fork("B1", "--delta-note", small))
|
||||||
|
self.assertEqual(self.fake_fork("B1", "--delta-note", os.path.join(self.tmp, "nope.md"),
|
||||||
|
code=2)["error"], "cannot read delta note")
|
||||||
|
# a base trained after the note's mtime already knows it
|
||||||
|
mtime = os.path.getmtime(note)
|
||||||
|
self.clone_base("Bk", trained_at=forks._iso(mtime + 60))
|
||||||
|
self.assert_due(self.fake_fork("Bk", "--delta-note", note))
|
||||||
|
# a large `## Changes since base` section trips it; the same text under another heading not
|
||||||
|
body = "c" * 60000
|
||||||
|
out = self.fake_fork("Bk", brief=self.brief(text="## Changes since base\n\n%s\n\n## Gate\n\nrun it" % body))
|
||||||
|
self.assertEqual(len(out["reasons"]), 1, out)
|
||||||
|
self.assertRegex(out["reasons"][0], r"^delta_tokens:\d+$")
|
||||||
|
self.assert_due(self.fake_fork("Bk", brief=self.brief(text="## Background\n\n%s\n" % body)))
|
||||||
|
# a rebase after the note clears the reason; the rebase's prompt carries the note too
|
||||||
|
r = self.cmd("rebase", "--base", "B1", "--as", "B2", "--delta-note", note,
|
||||||
|
"--brief", self.brief("turns=1", text="Absorb."))
|
||||||
|
with open(os.path.join(self.state, "briefs", r["job"] + ".md"), encoding="utf-8") as fh:
|
||||||
|
rprompt = fh.read()
|
||||||
|
self.assertIn("NOTE-MARKER", rprompt)
|
||||||
|
self.assertLess(rprompt.index("## Delta since the base commit"), rprompt.index("NOTE-MARKER"))
|
||||||
|
self.assertLess(rprompt.index("NOTE-MARKER"), rprompt.index("## Brief"))
|
||||||
|
self.assertEqual(self.finish(r["job"])["status"], "ok")
|
||||||
|
self.assertGreater(forks._ts(self.reg()["bases"]["B2"]["trained_at"]), mtime)
|
||||||
|
self.assert_due(self.fake_fork("B2", "--delta-note", note))
|
||||||
|
# list counts the delta header only
|
||||||
|
rows = {b["name"]: b for b in self.cmd("list")["bases"]}
|
||||||
|
self.assert_due(rows["B2"])
|
||||||
|
|
||||||
|
def test_train_supersedes(self):
|
||||||
|
"""Minor 1: `train --supersedes B` (repeatable) marks each named active base superseded by
|
||||||
|
the new one when its training ends ok: no more pings, and a fork needs --force. Unknown
|
||||||
|
names are refused at launch; a failed training changes nothing."""
|
||||||
|
self.setup_run(cap=4)
|
||||||
|
self.train("B1")
|
||||||
|
self.finish("j0001")
|
||||||
|
self.train("B0")
|
||||||
|
self.finish("j0002")
|
||||||
|
out = self.cmd("train", "--base", "B2", "--brief", self.brief(), "--supersedes", "nope", code=2)
|
||||||
|
self.assertEqual((out["error"], out["bases"]), ("unknown base", ["nope"]))
|
||||||
|
out = self.cmd("train", "--base", "B2", "--brief", self.brief(), "--supersedes", "B2", code=2)
|
||||||
|
self.assertIn("itself", out["error"])
|
||||||
|
self.assertNotIn("B2", self.reg()["bases"])
|
||||||
|
# a failed training: nothing changes
|
||||||
|
t = self.cmd("train", "--base", "B2", "--brief", self.brief("error"), "--supersedes", "B1")
|
||||||
|
self.assertEqual(self.finish(t["job"])["status"], "failed")
|
||||||
|
reg = self.reg()
|
||||||
|
self.assertEqual((reg["bases"]["B2"]["status"], reg["bases"]["B1"]["status"]), ("failed", "active"))
|
||||||
|
self.assertNotIn("superseded_by", reg["bases"]["B1"])
|
||||||
|
# an ok training supersedes every named active base
|
||||||
|
t = self.cmd("train", "--base", "B3", "--brief", self.brief(), "--supersedes", "B1",
|
||||||
|
"--supersedes", "B0", "--supersedes", "B2")
|
||||||
|
self.assertEqual(self.finish(t["job"])["status"], "ok")
|
||||||
|
reg = self.reg()
|
||||||
|
for name in ("B1", "B0"):
|
||||||
|
with self.subTest(base=name):
|
||||||
|
b = reg["bases"][name]
|
||||||
|
self.assertEqual((b["status"], b["superseded_by"]), ("superseded", "B3"))
|
||||||
|
self.assertIsNotNone(b["superseded_at"])
|
||||||
|
self.assertEqual(reg["bases"]["B2"]["status"], "failed") # not active: untouched
|
||||||
|
self.assertEqual(reg["bases"]["B3"]["status"], "active")
|
||||||
|
self.assertEqual(forks.ping_decision(reg, "B1")["why"], "status:superseded")
|
||||||
|
self.assertIn("not active", self.fake_fork("B1", code=2)["error"])
|
||||||
|
self.fake_fork("B1", "--force")
|
||||||
|
|
||||||
def test_train_overrides_and_init_model_change(self):
|
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
|
"""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`
|
launch fields and their hash, and every later launch on it uses that record. `init`
|
||||||
|
|||||||
Reference in New Issue
Block a user