diff --git a/.claude/skills/agentic-delivery/scripts/lane_resume_nudge.py b/.claude/skills/agentic-delivery/scripts/lane_resume_nudge.py index 09b09c6c9..b370f399d 100644 --- a/.claude/skills/agentic-delivery/scripts/lane_resume_nudge.py +++ b/.claude/skills/agentic-delivery/scripts/lane_resume_nudge.py @@ -4,19 +4,25 @@ Spawning a new lane costs a 30-60k-token startup that re-reads the same code an earlier lane already read. This hook compares the new lane's `description` with the descriptions in this session's subagent meta files -(`//subagents/agent-.meta.json`, found from the hook's `transcript_path`). When the -keyword overlap (Jaccard over words of 3+ letters/digits minus stopwords) is at least 0.5 and shares 2+ words, it -prints a PreToolUse `additionalContext` advisory naming the earlier lane to continue with SendMessage. Advisory only: -it never denies. A missing field, directory or unreadable meta file exits 0 silently. +(`//subagents/agent-.meta.json`, found from the hook's `transcript_path`). A lane +whose last activity is older than `LANE_RESUME_MAX_AGE_H` hours is skipped, so a session that runs for days is not +sent back to a lane that went idle long ago (default 6; a missing, non-numeric or non-positive value means 6). Last +activity is the mtime of `agent-.jsonl` beside the meta file when it exists, else the meta file's own mtime; a +lane whose mtime cannot be read is skipped. When the keyword overlap (Jaccard over words of 3+ letters/digits minus +stopwords) with a remaining lane is at least 0.5 and shares 2+ words, it prints a PreToolUse `additionalContext` +advisory naming the earlier lane to continue with SendMessage. Advisory only: it never denies. A missing field, +directory or unreadable meta file exits 0 silently. Run `lane_resume_nudge.py --selftest` to check it. """ import io import json +import os import re import shutil import sys import tempfile +import time from pathlib import Path STOP = {"the", "and", "for", "with", "from", "into", "this", "that", "run", "fix", "add", "new"} @@ -26,21 +32,36 @@ def words(text): return set(re.findall(r"[a-z0-9]{3,}", str(text).lower())) - STOP +def max_age_s(): + """Seconds a lane may sit idle and still be offered: LANE_RESUME_MAX_AGE_H hours, else 6 (unset, non-numeric, <= 0).""" + try: + hours = float(os.environ.get("LANE_RESUME_MAX_AGE_H", "")) + except ValueError: + hours = 0.0 + return (hours if hours > 0 else 6) * 3600 + + def check(payload): """The advisory line for this PreToolUse payload, or None.""" new = words((payload.get("tool_input") or {}).get("description") or "") tp = Path(payload["transcript_path"]) + cutoff = time.time() - max_age_s() best = (0.0, None, "") for meta in (tp.parent / tp.stem / "subagents").glob("*.meta.json"): + stem = meta.name.removesuffix(".meta.json") + log = meta.with_name(stem + ".jsonl") try: desc = json.loads(meta.read_text()).get("description") or "" + active = (log if log.exists() else meta).stat().st_mtime except (OSError, ValueError): continue + if active < cutoff: + continue old = words(desc) shared = len(new & old) score = shared / len(new | old) if new | old else 0.0 if shared >= 2 and score >= 0.5 and score > best[0]: - best = (score, meta.name.removesuffix(".meta.json").removeprefix("agent-"), desc) + best = (score, stem.removeprefix("agent-"), desc) if best[1] is None: return None return (f"Earlier lane agent {best[1]} (\"{best[2]}\") covers the same area: resume it with SendMessage instead " @@ -71,6 +92,8 @@ def _run(payload): def _selftest(): tmp = Path(tempfile.mkdtemp(prefix="lane-resume-")) res = [] + env_key = "LANE_RESUME_MAX_AGE_H" + saved = os.environ.pop(env_key, None) # the cases run on the default window unless they set it try: sub = tmp / "sess" / "subagents" sub.mkdir(parents=True) @@ -90,8 +113,37 @@ def _selftest(): ctx = json.loads(out)["hookSpecificOutput"] res.append(("advisory-json-never-denies", rc == 0 and ctx["hookEventName"] == "PreToolUse" and "permissionDecision" not in ctx)) + meta, log = sub / "agent-abc123.meta.json", sub / "agent-abc123.jsonl" + + def age(path, hours): + t = time.time() - hours * 3600 + os.utime(path, (t, t)) + + def offered(meta_h, log_h=None, env=None): + """True if the lane is named when its meta file is meta_h hours old and its jsonl log_h (None: no jsonl).""" + age(meta, meta_h) + if log_h is None: + log.unlink(missing_ok=True) + else: + log.touch() + age(log, log_h) + if env is None: + os.environ.pop(env_key, None) + else: + os.environ[env_key] = env + return "abc123" in (check(pl) or "") + + res.append(("jsonl-7h-old-silent-even-if-meta-fresh", not offered(0, 7))) + res.append(("jsonl-5h-old-advises-even-if-meta-old", offered(30, 5))) + res.append(("no-jsonl-meta-7h-old-silent", not offered(7))) + res.append(("max-age-env-sets-window", offered(2) and not offered(2, env="1") and offered(7, env="24"))) + res.append(("max-age-env-invalid-means-6h", + all(offered(5, env=v) and not offered(7, env=v) for v in ("abc", "nan", "0", "-1", "")))) finally: shutil.rmtree(tmp, ignore_errors=True) + os.environ.pop(env_key, None) + if saved is not None: + os.environ[env_key] = saved for n, ok in res: print(f"{'PASS' if ok else 'FAIL'} {n}") print(f"\nselftest: {sum(ok for _, ok in res)}/{len(res)} passed") diff --git a/changelog.d/lane-resume-age-cutoff.md b/changelog.d/lane-resume-age-cutoff.md new file mode 100644 index 000000000..87ed4b634 --- /dev/null +++ b/changelog.d/lane-resume-age-cutoff.md @@ -0,0 +1,5 @@ +### Fixed +- `lane_resume_nudge.py` no longer tells the coordinator to resume a lane that went idle long ago. It compared a new lane's description with every subagent meta file in the session, however old, so in a session that ran for days it could name a lane whose last activity was days earlier. It now skips a lane whose last activity is older than `LANE_RESUME_MAX_AGE_H` hours (default 6; a missing, non-numeric or non-positive value means 6). Last activity is the mtime of the lane's `agent-.jsonl` beside its meta file when that exists, else the meta file's own mtime; a lane whose mtime cannot be read is skipped. The nudge stays advisory only and still fails open. + +### Changed +- Tests: `python3 .claude/skills/agentic-delivery/scripts/lane_resume_nudge.py --selftest` (already run by CI) goes from 6 to 11 cases. The new cases pin a 7 h old jsonl as silent even when the meta file is fresh, a 5 h old jsonl as advising even when the meta file is old, a lane with no jsonl and a 7 h old meta file as silent, the env var both narrowing and widening the window, and a non-numeric, `nan`, zero, negative or empty value acting as 6.