Drop the systemd units; the script now locks itself and finds claude

This commit is contained in:
2026-07-29 22:57:13 +02:00
parent 891b28d28c
commit 6e5bfdfb2c
4 changed files with 87 additions and 87 deletions
+31 -64
View File
@@ -9,7 +9,7 @@ Claude Code has no auto-resume: on a usage limit it blocks further requests
until the reset time and the process exits. Nothing scheduled *inside* a
session (a 15-minute self-check, a `/loop`, a scheduled task) can rescue it,
because there is no process left to fire the schedule. Recovery has to come
from outside, so this is a systemd user timer.
from outside, so this is a cron job.
It also costs nothing while everything is healthy — it reads state files on
disk rather than waking sessions up. An in-session poll would re-send the
@@ -88,52 +88,32 @@ that is the one path that can burn a lot of tokens for nothing. Set
Needs Claude Code (for `claude agents --json` and `claude --bg --resume`) and
Python 3.9+ — standard library only.
Clone into `~/.claude/session-watchdog`. The unit file expects it there, and the
watchdog then travels with the rest of `~/.claude`:
Clone anywhere — `~/.claude/session-watchdog` just keeps it travelling with the
rest of `~/.claude`. Then add one cron entry with `crontab -e`:
```bash
git clone https://gitea.larvit.se/larvit/claude-session-watchdog.git ~/.claude/session-watchdog
chmod +x ~/.claude/session-watchdog/resume-stalled-sessions.py
mkdir -p ~/.config/systemd/user
ln -sf ~/.claude/session-watchdog/claude-session-watchdog.service ~/.config/systemd/user/
ln -sf ~/.claude/session-watchdog/claude-session-watchdog.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now claude-session-watchdog.timer
```
Cloned somewhere else? Point `ExecStart=` at it — that path is the only thing
tying the two together. `ledger.json` is written under `--claude-home`
regardless of where the script lives.
To keep it running while you are logged out (background jobs outlive your
shell, so this is usually what you want):
```bash
sudo loginctl enable-linger "$USER"
```
7,22,37,52 * * * * $HOME/.claude/session-watchdog/resume-stalled-sessions.py >> $HOME/.claude/session-watchdog/watchdog.log 2>&1
```
### cron (Alpine, macOS, anything without systemd)
`:07 :22 :37 :52` keeps it off the `:00` crowd. Nothing wraps it — no `PATH`
prefix, no `flock`, no `chmod`, because the script handles both of the things a
scheduler would otherwise have to:
The script itself is portable — only the scheduling is not. Add a cron entry
with `crontab -e`:
- **Finding `claude`** — `PATH` first, then `~/.local/bin`, `/usr/local/bin`,
`/opt/homebrew/bin`. If it finds nothing it says so and exits non-zero, rather
than logging `nothing to resume` and looking healthy while resuming nothing,
forever. `--claude-bin` still wins if you want a specific install.
- **Not overlapping itself** — it takes an exclusive lock on
`~/.claude/session-watchdog/.lock` and exits if another run holds it. Two
concurrent runs would read `ledger.json` before either writes it and resume
the same session twice, into two agents racing on one git worktree.
```
7,22,37,52 * * * * PATH=/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin /usr/bin/flock -n $HOME/.claude/session-watchdog/.lock $HOME/.claude/session-watchdog/resume-stalled-sessions.py >> $HOME/.claude/session-watchdog/watchdog.log 2>&1
```
Both prefixes matter, and neither is cosmetic:
- **`PATH`** — cron's default omits `/usr/local/bin`, which is where `claude`
usually is. Without it the watchdog fails *silently*: it logs `nothing to
resume` and looks perfectly healthy while resuming nothing, forever. Use
`CLAUDE_BIN=/full/path/to/claude` instead if you prefer. Verify with
`env -i HOME=$HOME PATH=/usr/bin:/bin ~/.claude/session-watchdog/resume-stalled-sessions.py --dry-run`
— that reproduces cron's environment, so it fails the same way cron would.
- **`flock`** — systemd's `Type=oneshot` refuses to run a second copy while the
first is going; cron happily overlaps them. Two concurrent runs read the same
`ledger.json` before either writes it, so both resume the same session. On a
host without `flock` (macOS), use a launchd agent with `StartInterval` set to
`900` instead — launchd also refuses to overlap a run with itself.
So any scheduler that fires every 15 minutes will do: cron, launchd with
`StartInterval 900`, a systemd timer, Task Scheduler.
Use absolute paths rather than `$HOME` if your cron does not export `HOME`
(busybox crond does). On Alpine, user crontabs live in `/etc/crontabs/<user>`
@@ -146,18 +126,10 @@ root.
# What would it do right now? Resumes nothing.
~/.claude/session-watchdog/resume-stalled-sessions.py --dry-run
# Timer health and next fire time
systemctl --user list-timers claude-session-watchdog.timer
# The same, with cron's bare environment instead of your shell's.
env -i HOME=$HOME PATH=/usr/bin:/bin ~/.claude/session-watchdog/resume-stalled-sessions.py --dry-run
# What it has done
journalctl --user -u claude-session-watchdog.service --since today
```
Under cron there is no journal, so the redirect in the crontab entry is the
record:
```bash
crontab -l # is it scheduled?
crontab -l # is it scheduled?
tail -f ~/.claude/session-watchdog/watchdog.log # what it has done
```
@@ -170,8 +142,7 @@ A stalled session that has been resumed shows up as a new job in
## Configuration
Every option is a CLI flag with an environment-variable fallback — nothing is
hardcoded. To make one permanent, put it in the unit's `[Service]` block, or on
the crontab line next to `PATH`.
hardcoded. To make one permanent, add it to the crontab line.
| Flag | Env var | Default |
| ----------------- | ------------------------- | -------------------- |
@@ -185,20 +156,16 @@ the crontab line next to `PATH`.
## Files
| Path | Role |
| ----------------------------------- | ------------------------------------------------- |
| `resume-stalled-sessions.py` | The watchdog |
| `claude-session-watchdog.service` | systemd unit that runs it once |
| `claude-session-watchdog.timer` | Fires at `:07 :22 :37 :52` (off the `:00` crowd) |
| `~/.claude/session-watchdog/ledger.json` | Per-session attempt counts and resume timestamps |
| Path | Role |
| ------------------------------------------ | ------------------------------------------ |
| `resume-stalled-sessions.py` | The watchdog — the only file it needs |
| `~/.claude/session-watchdog/ledger.json` | Attempt counts and resume timestamps |
| `~/.claude/session-watchdog/.lock` | Held for the duration of a run |
`ledger.json` is generated. Delete it to forget all history; edit the
`attempts` map to give a specific session another chance.
Both live under `--claude-home`, wherever the clone itself is. `ledger.json` is
generated: delete it to forget all history, or edit the `attempts` map to give a
specific session another chance.
## Uninstall
```bash
systemctl --user disable --now claude-session-watchdog.timer
rm ~/.config/systemd/user/claude-session-watchdog.{service,timer}
systemctl --user daemon-reload
```
Drop the line from `crontab -e` and delete the clone.