Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions .claude/skills/agentic-delivery/scripts/lane_resume_nudge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
(`<transcript dir>/<session>/subagents/agent-<id>.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.
(`<transcript dir>/<session>/subagents/agent-<id>.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-<id>.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"}
Expand All @@ -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 "
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/lane-resume-age-cutoff.md
Original file line number Diff line number Diff line change
@@ -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-<id>.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.
Loading