From 9a59c8cb502e79104395d37ff226c43f83799f81 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 10:54:04 +0200 Subject: [PATCH] Rename a resumed session's husk with an 'X ' prefix --- README.md | 7 +++++ resume-stalled-sessions.py | 47 +++++++++++++++++++++++++++++++-- test-resume-stalled-sessions.py | 39 +++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 695d23e..77ef57e 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,12 @@ runs `claude stop ` 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 diff --git a/resume-stalled-sessions.py b/resume-stalled-sessions.py index 454a407..1626a23 100755 --- a/resume-stalled-sessions.py +++ b/resume-stalled-sessions.py @@ -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 · ", 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()] diff --git a/test-resume-stalled-sessions.py b/test-resume-stalled-sessions.py index 5d95989..9e2dc98 100644 --- a/test-resume-stalled-sessions.py +++ b/test-resume-stalled-sessions.py @@ -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() -- 2.52.0