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
+56 -6
View File
@@ -8,15 +8,20 @@ cause has cleared.
"""
import argparse
import fcntl
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
# Where `claude` installs itself, and what a scheduler's minimal PATH omits.
CLAUDE_FALLBACK_DIRS = ("~/.local/bin", "/usr/local/bin", "/opt/homebrew/bin")
DEFAULT_PROMPT = (
"Continue where you left off. Your previous run was interrupted by a "
"transient failure (usage limit, API outage, or network), which has cleared."
@@ -64,8 +69,36 @@ def log(message):
print(f"{datetime.now().astimezone().isoformat(timespec='seconds')} {message}", flush=True)
def resolve_claude(name):
"""Absolute path to the Claude Code binary, or None if it cannot be found."""
found = shutil.which(name)
if found:
return found
for directory in CLAUDE_FALLBACK_DIRS:
candidate = Path(directory).expanduser() / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return None
def single_instance(path):
"""Exclusive lock for this run, or None when another run already holds it.
Overlapping runs both read the ledger before either writes it, so both
resume the same session — two agents racing on one git worktree.
"""
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("w")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def active_sessions(claude_bin):
"""Sessions Claude Code still considers active (completed ones are excluded)."""
"""Sessions Claude Code still considers active, or None if they cannot be read."""
try:
done = subprocess.run(
[claude_bin, "agents", "--json"],
@@ -73,12 +106,12 @@ def active_sessions(claude_bin):
)
except (OSError, subprocess.SubprocessError) as err:
log(f"error: could not list sessions: {err}")
return []
return None
try:
return json.loads(done.stdout)
except json.JSONDecodeError as err:
log(f"error: unparseable session list: {err}")
return []
return None
def pid_alive(pid):
@@ -259,8 +292,22 @@ def main():
parser.add_argument("--prompt", default=os.environ.get("WATCHDOG_PROMPT", DEFAULT_PROMPT))
args = parser.parse_args()
claude_bin = resolve_claude(args.claude_bin)
if not claude_bin:
log(f"error: '{args.claude_bin}' is not in PATH or "
f"{', '.join(CLAUDE_FALLBACK_DIRS)} — resuming nothing")
return 1
state_dir = args.claude_home / "session-watchdog"
lock = None
if not args.dry_run:
lock = single_instance(state_dir / ".lock") # released when main returns
if lock is None:
log("another run is still going, skipping")
return 0
now = datetime.now().astimezone()
ledger_path = args.claude_home / "session-watchdog" / "ledger.json"
ledger_path = state_dir / "ledger.json"
ledger = load_ledger(ledger_path)
recent = [at for at in ledger["resumes"] if now.timestamp() - at < 86400]
@@ -271,7 +318,10 @@ def main():
log(f"circuit breaker: {len(recent)} resumes in the last 24h (max {args.max_per_day})")
return 0
sessions = active_sessions(args.claude_bin)
sessions = active_sessions(claude_bin)
if sessions is None:
return 1
resumed = 0
for job in candidates(sessions, args.claude_home / "jobs", now):
if resumed >= budget:
@@ -279,7 +329,7 @@ def main():
tried = ledger["attempts"].get(job["session_id"], 0)
if tried >= args.max_attempts:
continue
if not resume(job, args.claude_bin, args.prompt, args.dry_run):
if not resume(job, claude_bin, args.prompt, args.dry_run):
continue
ledger["attempts"][job["session_id"]] = tried + 1
ledger["resumes"] = recent + [now.timestamp()]