Some checks failed
A tooling review predicted a PID-1 signal problem from two symptoms we could not
explain: `OOMKilled: true` with **ExitCode 0**, and `--continue` failing to find
a conversation that plainly existed. Traced it, and the prediction was right --
though the culprit is not PID 1, it is one level below.
The path is tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
`spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
is the process Docker signals, and everything depends on it passing things on.
It did neither, in two lines:
1. NO SIGNAL FORWARDING, no trap of any kind. `docker stop` sent SIGTERM to
expect, which died and took the pty with it. Claude Code never got a SIGTERM,
so it never ran SessionEnd hooks and never wrote lastSessionId/history --
which are written ONLY at a graceful shutdown. That is the entire reason
`claude --continue` answered "No conversation found to continue" with 33 MB of
transcripts in the volume beside it, and why we resume by scraping a session
id off a transcript filename.
2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
ZERO. When the OOM-killer took the child, expect saw EOF and reported a clean
exit. `OOMKilled: true` with `ExitCode 0` was never Docker being odd -- it was
this line. It also meant `--restart on-failure` would read a memory kill as
success, which is why the policy had to be `unless-stopped`.
Fixed and MEASURED, old against new, in a container:
child exits 7 old -> 0 (the bug) new -> 7
SIGTERM to wrapper old -> 143, child's trap NEVER RAN
new -> 42, child trapped and cleaned up
Same file in both images; they were byte-identical, so the port copy takes the
same change.
Consequences worth stating: a kill now reports 137 rather than 0, so exit codes
mean what they say; `docker stop` gives Claude Code a real SIGTERM, so it runs
SessionEnd and writes the session index -- which may make the transcript-filename
resume unnecessary. That is not assumed here: the resume path stays as it is
until it is verified redundant.
177 lines
7.1 KiB
Bash
Executable File
177 lines
7.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Restart an agent whose Claude session is parked at an expired login.
|
|
#
|
|
# ./sylph-watchdog watch forever (run detached)
|
|
# ./sylph-watchdog --once one pass, for cron or a manual check
|
|
# ./sylph-watchdog --status what it would do right now, changing nothing
|
|
#
|
|
# Env: SYLPH_WATCH_INTERVAL (default 300s), SYLPH_WATCH_CONTAINERS
|
|
#
|
|
# ── Why this exists ─────────────────────────────────────────────────────────
|
|
#
|
|
# 🔴 `--restart unless-stopped` DOES NOT COVER THIS, and that is the whole point.
|
|
# Docker restarts a container that EXITS. A Claude session sitting at
|
|
#
|
|
# Login expired · Please run /login
|
|
#
|
|
# never exits. The process is healthy, the container is Up, `docker ps` is green,
|
|
# and the agent has done nothing for hours. Three times now (2026-08-30,
|
|
# 09-02, 09-03) that has been noticed only because a human saw Remote Control
|
|
# report "Can't reach your computer" — which is a symptom of the session being
|
|
# unable to attach, not a report about the machine.
|
|
#
|
|
# The fix is already in the entrypoint: it copies the host's credentials in when
|
|
# they are newer than the container's. It just needs something to notice and
|
|
# bounce the container. That is all this does.
|
|
#
|
|
# ⚠️ It restarts rather than logging in. A restart re-runs the entrypoint, which
|
|
# re-seeds credentials AND resumes the session by id, so the agent keeps its
|
|
# context. There is nothing here that could log a session in on its own, and it
|
|
# should not pretend to: if the HOST's credentials are also stale, this loop will
|
|
# bounce the container and the agent will park again. It says so instead of
|
|
# retrying silently.
|
|
set -uo pipefail
|
|
|
|
CONTAINERS="${SYLPH_WATCH_CONTAINERS:-sylpheed-agent sylpheed-port}"
|
|
INTERVAL="${SYLPH_WATCH_INTERVAL:-300}"
|
|
|
|
# How far back to look. Longer than the interval so a stall spanning two passes
|
|
# is still seen, short enough that a login expiry cured an hour ago does not
|
|
# read as current.
|
|
WINDOW="${SYLPH_WATCH_WINDOW:-20m}"
|
|
|
|
log() { printf '[watchdog %s] %s\n' "$(date -u '+%H:%M:%S')" "$*"; }
|
|
|
|
# Has this container printed an expiry recently, and NOT recovered since?
|
|
#
|
|
# "Recovered" matters: the string stays in the log forever, so a bare grep would
|
|
# restart a healthy agent every pass on the strength of an hours-old line. The
|
|
# test is whether the transcript has been written SINCE the last expiry — a
|
|
# working agent writes constantly.
|
|
parked() {
|
|
local c="$1"
|
|
docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || return 1
|
|
|
|
local hits
|
|
hits=$(docker logs --since "$WINDOW" "$c" 2>&1 \
|
|
| sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' \
|
|
| grep -c 'Login expired' 2>/dev/null || true)
|
|
[ "${hits:-0}" -gt 0 ] || return 1
|
|
|
|
# Transcript idle for longer than one interval => it really is stuck. A busy
|
|
# agent that merely logged an expiry and recovered keeps writing.
|
|
local age
|
|
age=$(docker exec "$c" bash -lc '
|
|
f=$(ls -1t "$HOME/.claude/projects"/*/*.jsonl 2>/dev/null | head -1)
|
|
[ -n "$f" ] && echo $(( $(date +%s) - $(stat -c %Y "$f") )) || echo 999999
|
|
' 2>/dev/null | tr -d '[:space:]')
|
|
case "$age" in ''|*[!0-9]*) age=999999 ;; esac
|
|
[ "$age" -gt "$INTERVAL" ]
|
|
}
|
|
|
|
# Is the HOST's copy actually newer? If not, a restart cannot help and saying so
|
|
# is the useful output — otherwise this becomes a loop that bounces a container
|
|
# every five minutes and reports success.
|
|
host_is_newer() {
|
|
local c="$1"
|
|
docker exec "$c" bash -lc '
|
|
s="$HOME/.claude.seed/.credentials.json"; o="$HOME/.claude/.credentials.json"
|
|
[ -e "$s" ] || exit 2
|
|
[ ! -e "$o" ] || [ "$s" -nt "$o" ]
|
|
' >/dev/null 2>&1
|
|
}
|
|
|
|
pass() {
|
|
local acted=0
|
|
for c in $CONTAINERS; do
|
|
if parked "$c"; then
|
|
if host_is_newer "$c"; then
|
|
log "$c is parked at an expired login; host credentials are newer -- restarting"
|
|
[ "${1:-}" = "--status" ] || docker restart "$c" >/dev/null 2>&1 \
|
|
&& log "$c restarted (entrypoint re-seeds and resumes the session)"
|
|
else
|
|
log "🔴 $c is parked at an expired login and the HOST's credentials are"
|
|
log " NO NEWER. A restart cannot fix this -- log in on the host first."
|
|
fi
|
|
acted=1
|
|
fi
|
|
done
|
|
[ "$acted" = 0 ] && log "all watched agents are alive"
|
|
return 0
|
|
}
|
|
|
|
# ── The control, EXECUTED ───────────────────────────────────────────────────
|
|
#
|
|
# 🔴 A watchdog that has never fired is a hope, not a guard. Its whole value is
|
|
# in the true-positive path, and that path only runs when an agent is already
|
|
# broken -- so it gets a synthetic one.
|
|
#
|
|
# Two cases against real containers, because the detection is `docker logs` plus
|
|
# `docker exec` and neither can be reasoned about from the shell:
|
|
#
|
|
# a container printing "Login expired" with no transcript -> parked (TRUE positive)
|
|
# a live agent -> not parked (negative)
|
|
#
|
|
# ⚠️ Written after claiming, wrongly and without checking, that a bare grep
|
|
# "would have fired" on a recovered container. The count was zero. That is the
|
|
# same error this whole corpus keeps cataloguing -- asserting what an instrument
|
|
# would have said instead of running it -- so the instrument now runs.
|
|
selftest() {
|
|
local ok=0 name="sylph-watchdog-control-$$"
|
|
echo "control:"
|
|
|
|
docker run -d --rm --name "$name" alpine:latest \
|
|
sh -c 'echo "Login expired · Please run /login"; sleep 120' >/dev/null 2>&1
|
|
# Give docker a moment to have the line available in the log.
|
|
for _ in 1 2 3 4 5; do
|
|
docker logs "$name" 2>&1 | grep -q 'Login expired' && break
|
|
sleep 1
|
|
done
|
|
|
|
if SYLPH_WATCH_CONTAINERS="$name" parked "$name"; then
|
|
printf ' %-46s ✅\n' "an expired login with no transcript reads PARKED"
|
|
else
|
|
printf ' %-46s 🔴\n' "an expired login with no transcript reads PARKED"; ok=1
|
|
fi
|
|
|
|
# And it must NOT fire on the same container once it is gone -- a stopped
|
|
# container is not a parked one, and restarting it would be wrong.
|
|
docker rm -f "$name" >/dev/null 2>&1
|
|
if parked "$name"; then
|
|
printf ' %-46s 🔴\n' "a container that is gone reads NOT parked"; ok=1
|
|
else
|
|
printf ' %-46s ✅\n' "a container that is gone reads NOT parked"
|
|
fi
|
|
|
|
# The live negative, against whatever is actually running.
|
|
local live=0
|
|
for c in $CONTAINERS; do
|
|
docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || continue
|
|
live=1
|
|
if parked "$c"; then
|
|
printf ' %-46s 🔴 (%s)\n' "a working agent reads NOT parked" "$c"; ok=1
|
|
else
|
|
printf ' %-46s ✅ (%s)\n' "a working agent reads NOT parked" "$c"
|
|
fi
|
|
done
|
|
[ "$live" = 1 ] || printf ' %-46s -- no agent running\n' "a working agent reads NOT parked"
|
|
|
|
echo
|
|
[ $ok -eq 0 ] && echo "the watchdog fires on a parked session and not otherwise" \
|
|
|| echo "🔴 the watchdog cannot tell parked from alive"
|
|
return $ok
|
|
}
|
|
|
|
case "${1:-}" in
|
|
--once) pass ;;
|
|
--status) pass --status ;;
|
|
--selftest) selftest; exit $? ;;
|
|
*)
|
|
log "watching [$CONTAINERS] every ${INTERVAL}s"
|
|
while true; do
|
|
pass
|
|
sleep "$INTERVAL"
|
|
done
|
|
;;
|
|
esac
|