Drop the systemd units; the script now locks itself and finds claude
This commit is contained in:
@@ -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()]
|
||||
|
||||
Reference in New Issue
Block a user