Rename a resumed session's husk with an 'X ' prefix #2

Merged
lilleman merged 1 commits from rename-husk into main 2026-09-04 11:20:03 +02:00
3 changed files with 91 additions and 2 deletions
+7
View File
@@ -62,6 +62,12 @@ runs `claude stop <old id>` after a successful resume. Without that the husk
would be picked up again on a later tick and resumed into a *second* session
working the same conversation — two agents racing on the same git worktree.
A stopped husk is still listed, under the same name as the successor that
replaced it, which is confusing to work next to. So the watchdog also prefixes
it — `X rewrite larvitsmpp typescript esm` — by rewriting `name` in the husk's
`state.json`, which is where `claude agents` reads it and which no command sets.
`--superseded-prefix` changes it.
### Host headroom
A resume starts a fresh process that reloads a whole conversation, so before
@@ -190,6 +196,7 @@ hardcoded. To make one permanent, add it to the crontab line.
| `--max-swap-used-pct` | `WATCHDOG_MAX_SWAP_USED_PCT` | `50` |
| `--min-available-mb` | `WATCHDOG_MIN_AVAILABLE_MB` | `2048` |
| `--prompt` | `WATCHDOG_PROMPT` | see `--help` |
| `--superseded-prefix` | `WATCHDOG_SUPERSEDED_PREFIX` | `X ` |
| `--dry-run` | — | off |
## Files
+45 -2
View File
@@ -29,6 +29,8 @@ DEFAULT_PROMPT = (
"transient failure (usage limit, API outage, or network), which has cleared."
)
DEFAULT_PREFIX = "X "
# Every quota Claude Code can report as "You've hit your <limit> · <how to clear
# it>", including the ones that cost money rather than time.
QUOTA_BLOCK = re.compile(
@@ -287,11 +289,42 @@ def candidates(sessions, jobs_dir, now):
"blocked_at": blocked_at,
"name": session.get("name"),
"session_id": session_id,
"state_file": state_file,
})
return sorted(found, key=lambda job: job["blocked_at"])
def resume(job, claude_bin, prompt, dry_run):
def mark_superseded(state_file, prefix):
"""Prefix the husk's name so it reads apart from its successor, or say why not.
`claude agents` takes the name from state.json and offers no command to set
it, so the file is the only seam.
"""
try:
state = json.loads(state_file.read_text())
except (OSError, json.JSONDecodeError) as err:
return str(err)
name = state.get("name")
if not isinstance(name, str) or name.startswith(prefix):
return None
state["name"] = prefix + name
flags = state.get("respawnFlags")
if isinstance(flags, list) and "--name" in flags:
at = flags.index("--name") + 1
if at < len(flags):
flags[at] = state["name"]
staged = state_file.with_name(state_file.name + ".new")
try:
staged.write_text(json.dumps(state, indent=2))
staged.replace(state_file)
except OSError as err:
return str(err)
return None
def resume(job, claude_bin, prompt, prefix, dry_run):
cwd = job["cwd"]
if not cwd or not Path(cwd).is_dir():
log(f"{job['id']}: skipped, working directory is gone ({cwd})")
@@ -326,6 +359,10 @@ def resume(job, claude_bin, prompt, dry_run):
)
except (OSError, subprocess.SubprocessError) as err:
log(f"{job['id']}: could not retire stale entry: {err}")
problem = mark_superseded(job["state_file"], prefix)
if problem:
log(f"{job['id']}: could not rename stale entry: {problem}")
return True
@@ -367,6 +404,12 @@ def main():
help="memory a resume needs the host to have spare, in MB (default: 2048)",
)
parser.add_argument("--prompt", default=os.environ.get("WATCHDOG_PROMPT", DEFAULT_PROMPT))
parser.add_argument(
"--superseded-prefix",
default=os.environ.get("WATCHDOG_SUPERSEDED_PREFIX", DEFAULT_PREFIX),
help="prepended to the name of a husk once its successor runs "
f"(default: {DEFAULT_PREFIX!r})",
)
args = parser.parse_args()
claude_bin = resolve_claude(args.claude_bin)
@@ -417,7 +460,7 @@ def main():
if strain:
log(f"holding off: {strain}")
break
if not resume(job, claude_bin, args.prompt, args.dry_run):
if not resume(job, claude_bin, args.prompt, args.superseded_prefix, args.dry_run):
continue
ledger["attempts"][job["session_id"]] = tried + 1
ledger["resumes"] = recent + [now.timestamp()]
+39
View File
@@ -172,11 +172,50 @@ class Candidates(unittest.TestCase):
session = self.blocked_job(self.SPEND, timedelta(hours=2))
found = watchdog.candidates([session], self.jobs, self.now)
self.assertEqual([job["id"] for job in found], [session["id"]])
self.assertEqual(found[0]["state_file"], self.jobs / session["id"] / "state.json")
def test_fresh_spend_block_waits(self):
session = self.blocked_job(self.SPEND, timedelta(minutes=10))
self.assertEqual(watchdog.candidates([session], self.jobs, self.now), [])
class MarkSuperseded(unittest.TestCase):
"""The husk keeps its conversation, so its name must not read as the live one."""
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.state = Path(temp.name) / "state.json"
def write(self, **fields):
self.state.write_text(json.dumps(fields))
def read(self):
return json.loads(self.state.read_text())
def test_name_gains_the_prefix(self):
self.write(name="rewrite larvitsmpp typescript esm", state="blocked")
self.assertIsNone(watchdog.mark_superseded(self.state, "X "))
self.assertEqual(self.read()["name"], "X rewrite larvitsmpp typescript esm")
def test_the_respawn_name_stays_in_step(self):
self.write(name="fejkdata", respawnFlags=["--agent", "claude", "--name", "fejkdata"])
watchdog.mark_superseded(self.state, "X ")
self.assertEqual(self.read()["respawnFlags"], ["--agent", "claude", "--name", "X fejkdata"])
def test_a_husk_is_prefixed_only_once(self):
self.write(name="X fejkdata")
self.assertIsNone(watchdog.mark_superseded(self.state, "X "))
self.assertEqual(self.read()["name"], "X fejkdata")
def test_an_unnamed_job_is_left_alone(self):
self.write(state="blocked")
self.assertIsNone(watchdog.mark_superseded(self.state, "X "))
self.assertNotIn("name", self.read())
def test_unwritable_state_is_reported_rather_than_raised(self):
self.assertIsNotNone(watchdog.mark_superseded(self.state.with_name("gone.json"), "X "))
if __name__ == "__main__":
unittest.main()