← Files NightshiftARCHIVED FILE

runtime/codex/watchman.sh

22.1 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

#!/usr/bin/env bash
# watchman.sh — the night watchman for Codex shifts. Revives a session that DIED mid-shift;
# never one that ended, and never another host's.
#
# A hook can only act inside a living session; a session killed by an API outage, a crash, or a
# closed terminal fires nothing, and the punch list just sits there. This is the outside half for
# Codex: armed at shift start, it wakes every interval and, only when the site is mid-shift and
# provably dead quiet, resumes the shift's own conversation —
#
#   codex exec resume -c 'sandbox_mode="danger-full-access"' <session-id> "<revival order>"
#
# A persisted .nightshift/run/work-target keeps the resumed session aimed at the same child repository
# when run state lives in a parent workspace. The command appends to the same rollout the session
# was writing when it died (verified live: a
# SIGKILLed session's rollout ends mid-event with no terminal marker, and the resume continues
# that very file). The fallback is a fresh headless run; the punch list on disk is its handover.
# Before either spawn, the watchman advances a process lease and passes its generation/nonce to
# the child. An older Desktop or terminal process on that conversation then loses observed tools.
#
# The sandbox grant is the owner's to choose, in rules.json under recovery.launchScope. The
# shipped host-grant starts a revived session with danger-full-access, because the workspace-write
# sandbox protects .git — a revived session could edit but never commit (verified live: "Git
# cannot create .git/index.lock"), and one commit per item IS the contract in repository mode.
# host-default passes no sandbox argument at all and takes whatever the host gives, which is
# narrower and may leave a revived session unable to commit. Whichever is in force is named in the
# shift log on every revival, and a failed rung is retried at the same scope, never a broader one.
# Artifact mode writes a receipt instead. The fence around that access is
# nightshift's own guards: the hardhat denies what the owner forbade, in every mode — the same
# trade Claude Code makes with bypassPermissions.
#
#   watchman.sh [--project DIR] [--interval MIN] [--agent CMD] [--max-wakes N]
#
#   --interval  minutes between wakes (default: the rules file's watchMinutes, overridable by
#               $NIGHTSHIFT_WATCH; 0 exits immediately — the "disabled" spelling)
#   --agent     override the spawn command entirely (the test suite's lever). It is invoked as
#               $AGENT "<prompt>" with the project as cwd.
#   --max-wakes bound the number of wakes (0 = unbounded; tests use this)
#
# Evidence, conservative by construction — revive only on strong positive evidence of death:
#   ALIVE (stand by), any of:
#     · the recorded pid (line 3) exists and its start time matches line 4
#     · any process whose executable is exactly `codex` has this project as its cwd
#     · the recorded rollout (line 2) grew since the last wake
#   DEAD (revive): none of the above, boxes open, and the site armed.
# Evidence, conservative by construction — revive only on strong positive evidence of death:
#   ALIVE (stand by), any of:
#     · a fresh .shift-pulse (epoch within 2 * watchMinutes)
#     · a missing pulse still inside the first two wake intervals after arm
#     · the recorded pid (line 3) exists and its start time matches line 4
#     · an empty recorded pid — empty pid never decides death unless the pulse is stale
#     · any process whose executable is exactly `codex` has this project as its cwd
#     · the recorded rollout (line 2) grew since the last wake
#   DEAD (revive): pulse stale, no rollout growth, no .session-end, boxes open, site armed.
# Missing process evidence stands by. One signature overrides it: a turn that ended on an API
# error. Codex closes that turn with `task_complete` carrying an `error` object and leaves the
# session open and quiet, so a live process there is a wedge, not work. It is revived at once,
# except a usage limit, which waits for the reset time Codex reported.
#
# Stand-down order, checked at every wake — never override a declared ending:
#   0. the armed marker is gone (.shift-armed)        -> down (a disarmed site has no shift)
#   1. stop-work order (.nightshift/STOP)             -> down
#   2. shift ended (.nightshift/run/.ended, or no punch)  -> down
#   3. clean session end (.nightshift/run/.session-end)   -> down (owner closed it; Start re-arms)
#   4. another host's shift (.shift-session line 5)   -> down (its own watchman minds it)
#   5. every box ticked                                -> one clock-out spawn if .ended missing,
#                                                        then down (a crash at the finish line
#                                                        still gets receipts and the whistle)
#   6. deadline passed                                 -> one clock-out spawn, then down
set -u

_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
# shellcheck source=plugins/nightshift/lib/lib.sh
. "$_here/../../lib/lib.sh" # pure-bash path — no dirname dependency

PROJECT="$PWD"
INTERVAL_MIN="${NIGHTSHIFT_WATCH:-}"
# Resolved from rules watchAgent (env override) after the project is known.
AGENT=""
MAX_WAKES=0

usage() { sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 1; }
need_value() { [ "$2" -ge 2 ] || { printf 'watchman: %s needs a value\n' "$1" >&2; usage; }; }
while [ $# -gt 0 ]; do
  case "$1" in
    --project) need_value "$1" $#; PROJECT="$2"; shift 2 ;;
    --interval) need_value "$1" $#; INTERVAL_MIN="$2"; shift 2 ;;
    --agent) need_value "$1" $#; AGENT="$2"; shift 2 ;;
    --max-wakes) need_value "$1" $#; MAX_WAKES="$2"; shift 2 ;;
    -h | --help) usage ;;
    *) printf 'watchman: unknown argument: %s\n' "$1" >&2; usage ;;
  esac
done

cd "$PROJECT" 2>/dev/null || exit 1
PROJECT="$PWD"
NS="$PROJECT/.nightshift"
WORK_TARGET="$(ns_work_target "$PROJECT" 2>/dev/null || true)"
[ -n "$WORK_TARGET" ] || WORK_TARGET="$PROJECT"
declare PUNCH LOG TICK SESSION_FILE SESSION_END ENDED DEADLINE STOP
ns_layout_set PUNCH "$NS" punch-list
ns_layout_set LOG "$NS" shift-log
ns_layout_set TICK "$NS" watchman-tick
ns_layout_set SESSION_FILE "$NS" session
ns_layout_set SESSION_END "$NS" session-end
ns_layout_set ENDED "$NS" ended
ns_layout_set DEADLINE "$NS" deadline
ns_layout_set STOP "$NS" stop
note() { ns_record_reason "$NS" "$1" "${2:-}"; }
STATE_KIND="$(ns_state_kind "$PROJECT")"
case "$STATE_KIND" in
  malformed | future)
    note unsupported-state "$STATE_KIND"
    printf 'watchman: %s\n' "$(ns_state_refuse_message "$STATE_KIND")" >&2
    exit 1
    ;;
esac

[ -n "$INTERVAL_MIN" ] || INTERVAL_MIN="$(rule "$PROJECT" watchMinutes "")"
case "$INTERVAL_MIN" in
  '' | *[!0-9]*)
    note unreadable-rules watchMinutes
    printf 'watchman: watchMinutes missing or not whole minutes — %s absent or incomplete; run Setup again (/nightshift:setup on Claude Code; ask Nightshift to set up on Codex)\n' "$(ns_layout_name "$NS" rules)" >&2
    exit 1
    ;;
esac
[ "$INTERVAL_MIN" -gt 0 ] || exit 0 # 0 = disabled, by design

# Empty watchAgent keeps Codex's default resume/fresh ladder; non-empty is owner verbatim.
[ -n "$AGENT" ] || AGENT="$(rule "$PROJECT" watchAgent "${NIGHTSHIFT_WATCH_AGENT:-}")"

RETRY_SPACING="$(rule "$PROJECT" watchRetrySeconds "${NIGHTSHIFT_WATCH_RETRY:-}")"
PROMPT_RESUME="$(ns_expand_injected_paths "$PROJECT" "$(rule "$PROJECT" revivalPrompt "${NIGHTSHIFT_REVIVAL_PROMPT:-}")")"
PROMPT_FRESH="$(ns_expand_injected_paths "$PROJECT" "$(rule "$PROJECT" freshRevivalPrompt "${NIGHTSHIFT_FRESH_PROMPT:-}")")"
for _req in "watchRetrySeconds:$RETRY_SPACING" "revivalPrompt:$PROMPT_RESUME" "freshRevivalPrompt:$PROMPT_FRESH"; do
  if [ -z "${_req#*:}" ]; then
    note unreadable-rules "${_req%%:*}"
    printf 'watchman: %s unreadable — %s absent or incomplete; run Setup again (/nightshift:setup on Claude Code; ask Nightshift to set up on Codex)\n' "${_req%%:*}" "$(ns_layout_name "$NS" rules)" >&2
    exit 1
  fi
done

ts() { date '+%Y-%m-%d %H:%M:%S'; }
log_line() { [ -d "$NS" ] && printf '%s · %s\n' "$(ts)" "$1" >>"$LOG"; }

# The marker is the shift. Without it there is nothing to revive and no reading to take, and
# the watchman never writes the marker back.
declare ARMED
ns_layout_set ARMED "$NS" armed
armed() { [ -f "$ARMED" ]; }
stand_down_disarmed() {
  note owner-disarm
  log_line "watchman: the armed marker is gone — standing down"
  exit 0
}

# One watchman per site — either host's. A stale pidfile from a dead holder is taken over.
declare PIDFILE
ns_layout_set PIDFILE "$NS" watchman
if [ -L "$PIDFILE" ]; then
  rm -f "$PIDFILE"
elif [ -f "$PIDFILE" ]; then
  oldpid="$(sed -n 1p "$PIDFILE" 2>/dev/null)"
  if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then
    printf 'watchman: already watching (pid %s)\n' "$oldpid" >&2
    exit 1
  fi
fi
printf '%s\n' "$$" >"$PIDFILE"
# The pidfile is this loop's claim on the site, and a claim can change hands: Reset and Purge
# remove it, a takeover replaces the pid inside it. Removing it on the way out is only right while
# it still names this process — otherwise a watchman that has already been replaced would delete
# the new one's claim as it exits, and the site would be left watched by a loop nothing records.
holds_pidfile() {
  [ -f "$PIDFILE" ] || return 1
  [ ! -L "$PIDFILE" ] || return 1
  [ "$(sed -n 1p "$PIDFILE" 2>/dev/null)" = "$$" ]
}
trap 'holds_pidfile && rm -f "$PIDFILE"' EXIT
WATCH_CLOCK="$(date +%s)"

sid()        { [ -L "$SESSION_FILE" ] && return; sed -n 1p "$SESSION_FILE" 2>/dev/null; }
rollout()    { [ -L "$SESSION_FILE" ] && return; sed -n 2p "$SESSION_FILE" 2>/dev/null; }
rec_pid()    { [ -L "$SESSION_FILE" ] && return; sed -n 3p "$SESSION_FILE" 2>/dev/null | tr -d '[:space:]'; }
rec_start()  { [ -L "$SESSION_FILE" ] && return; sed -n 4p "$SESSION_FILE" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; }
open_boxes() { ns_open_boxes "$PUNCH"; }

# The recorded pid counts only as the exact recorded process: pid + start time, a pair that pid
# reuse cannot counterfeit.
recorded_process_alive() {
  local p s
  p="$(rec_pid)"; s="$(rec_start)"
  [ -n "$p" ] || return 1
  ns_recorded_process "$p" "$s"
}

pulse_alive() {
  ns_pulse_fresh "$NS" "$INTERVAL_MIN" && return 0
  ns_pulse_stale "$NS" "$INTERVAL_MIN" "$WATCH_CLOCK" && return 1
  return 0
}

# Any codex working in this project stands the watchman by — matched on the exact executable
# name, never a substring: unrelated processes carry "codex" deep in their environment.
codex_in_project() {
  local p cwd
  ns_have_cmd pgrep || return 2
  for p in $(pgrep -x codex 2>/dev/null); do
    cwd="$(ns_proc_cwd "$p")" || continue
    [ "$cwd" = "$PROJECT" ] && return 0
  done
  return 1
}

# The rollout is the session's own pulse: a live session streams events into it. Growth since
# the last wake is life; the sentinel is re-baselined every wake and after every failed spawn so
# the watchman's own attempts never read as site activity.
ROLLOUT_SEEN=""
baseline_rollout() {
  local r
  r="$(rollout)"
  [ -n "$r" ] && [ -f "$r" ] && ROLLOUT_SEEN="$(wc -c <"$r" 2>/dev/null | tr -d ' ')" || ROLLOUT_SEEN=""
}
rollout_grew() {
  local r now
  r="$(rollout)"
  [ -n "$r" ] && [ -f "$r" ] || return 1
  now="$(wc -c <"$r" 2>/dev/null | tr -d ' ')"
  [ -n "$ROLLOUT_SEEN" ] && [ "$now" != "$ROLLOUT_SEEN" ]
}

# Spawn one revival attempt. Ownership transfers atomically before the child starts; every hook
# in that child inherits the new generation/nonce, and an older Desktop or terminal process on
# the same conversation is fenced. Rung 1 resumes the recorded conversation; rung 2 is a fresh
# headless run with the punch list as its handover.
# A non-resumable recorded id is never passed to `codex exec resume` and never treated as a
# successful resume of that thread.
# A fresh headless run at the resolved scope. The scope never widens between rungs: a revival that
# failed is retried at the same permissions, never at broader ones.
spawn_fresh() {
  local mode
  log_line "watchman: reviving under launch scope $1"
  case "$1" in
    host-default)
      ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
        CODEX_PROJECT_DIR "$PROJECT" \
        codex exec "$PROMPT_FRESH"
      return $?
      ;;
    recorded:*)
      mode="${1#recorded:}"
      ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
        CODEX_PROJECT_DIR "$PROJECT" \
        codex exec -s "$mode" "$PROMPT_FRESH"
      return $?
      ;;
  esac
  # host-grant, and only host-grant: the owner wrote it in their own file.
  ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
    CODEX_PROJECT_DIR "$PROJECT" \
    codex exec -s danger-full-access "$PROMPT_FRESH"
}

# Set once when a revival is refused because the recorded scope cannot be reproduced. Retrying
# cannot change that answer, so the ladder stops instead of spending its rungs on it.
RECOVERY_REFUSED=0

spawn() { # $1 = rung (1|2)
  local prompt kind rc scope open_before
  ns_ensure_work_target_link "$PROJECT" || true
  open_before="$(ns_open_boxes "$PUNCH")"
  scope="$(ns_recovery_effective_scope "$PROJECT" codex)"
  case "$scope" in
    unavailable:*)
      RECOVERY_REFUSED=1
      log_line "watchman: $(ns_recovery_refusal "$scope"). Not reviving at permissions it cannot show are no broader than the original."
      log_line "watchman: the work is untouched. Resume the shift yourself, or name the scope a revival may use by setting recovery.launchScope to host-default or host-grant in $(ns_layout_name "$NS" rules)."
      note recovery-scope-unavailable
      return 1
      ;;
  esac
  if [ -n "$AGENT" ]; then
    if [ "$1" -eq 1 ]; then prompt="$PROMPT_RESUME"; else prompt="$PROMPT_FRESH"; fi
    # shellcheck disable=SC2086 # owner-provided command line; splitting is intentional
    ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
      CODEX_PROJECT_DIR "$PROJECT" $AGENT "$prompt"
  elif [ "$1" -eq 1 ]; then
    kind="$(ns_codex_identity_kind "$(sid)")"
    if [ "$kind" = "resumable" ]; then
      log_line "watchman: reviving under launch scope $scope"
      case "$scope" in
        host-default)
          ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
            CODEX_PROJECT_DIR "$PROJECT" \
            codex exec resume "$(sid)" "$PROMPT_RESUME"
          ;;
        recorded:*)
          ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
            CODEX_PROJECT_DIR "$PROJECT" \
            codex exec resume -c "sandbox_mode=\"${scope#recorded:}\"" "$(sid)" "$PROMPT_RESUME"
          ;;
        *)
          ns_watchman_run_child "$NS" codex "$(sid)" "$WORK_TARGET" \
            CODEX_PROJECT_DIR "$PROJECT" \
            codex exec resume -c 'sandbox_mode="danger-full-access"' "$(sid)" "$PROMPT_RESUME"
          ;;
      esac
    else
      spawn_fresh "$scope"
    fi
  else
    spawn_fresh "$scope"
  fi
  rc=$?
  if [ "$rc" -eq 3 ]; then
    log_line "watchman: process lease transfer failed — not spawning beside an unfenced session"
    return 1
  fi
  if ns_watchman_revival_proved "$NS" "" "$INTERVAL_MIN" "$open_before"; then
    return 0
  fi
  log_line "watchman: revival child returned without moving the shift — not counting it as a resume"
  return 1
}

rung_name() { if [ "$1" -eq 1 ] && [ -n "$(sid)" ]; then printf 'resuming the recorded conversation'; else printf 'fresh session'; fi; }

log_line "watchman (codex) armed · every ${INTERVAL_MIN}m"
BASE_SLEEP="${NIGHTSHIFT_WATCH_SLEEP:-$((INTERVAL_MIN * 60))}"
wake=0
WEDGE_SEEN=""
baseline_rollout
: >"$TICK" 2>/dev/null || true

while :; do
  sleep "$BASE_SLEEP"
  wake=$((wake + 1))

  armed || stand_down_disarmed
  if [ -f "$STOP" ]; then note owner-stop; log_line "watchman: stop-work order — standing down"; exit 0; fi
  if [ -f "$ENDED" ] && [ ! -L "$ENDED" ]; then note completed; exit 0; fi
  if [ ! -f "$PUNCH" ]; then note stand-down "punch list missing"; exit 0; fi
  if [ -f "$SESSION_END" ] && [ ! -L "$SESSION_END" ]; then
    note clean-session-end
    log_line "watchman: clean session end — the owner closed it; standing down (start re-arms)"
    exit 0
  fi

  # Another host's shift is another watchman's business: resuming it from here would spawn
  # codex against a conversation a different agent owns.
  host="$(ns_session_host "$NS")"
  if [ "$host" != codex ]; then
    note wrong-host "$host"
    log_line "watchman: shift is owned by $host — standing down"
    exit 0
  fi

  if [ "$(open_boxes)" -eq 0 ]; then
    log_line "watchman: every box ticked but the shift never clocked out — spawning the clock-out (attempt 1/1)"
    spawn 1 || true
    ns_watchman_clockout_pending "$NS" "$TICK"
    clock_rc=$?
    if [ "$clock_rc" -eq 0 ]; then
      note completed
      exit 0
    fi
    note clock-out-failed
    log_line "watchman: clock-out attempt 1/1 returned without releasing the shift — standing down"
    exit 0
  fi
  if [ -f "$DEADLINE" ] && [ ! -L "$DEADLINE" ]; then
    dl="$(tr -d '[:space:]' <"$DEADLINE" 2>/dev/null)"
    if printf '%s' "$dl" | grep -qE '^[0-9]+$' && [ "$(date +%s)" -ge "$dl" ]; then
      log_line "watchman: past the deadline with the session gone — spawning the clock-out (attempt 1/1)"
      spawn 1 || true
      ns_watchman_clockout_pending "$NS" "$TICK"
      clock_rc=$?
      if [ "$clock_rc" -eq 0 ]; then
        note deadline
        exit 0
      fi
      note clock-out-failed
      log_line "watchman: clock-out attempt 1/1 returned without releasing the shift — standing down"
      exit 0
    fi
  fi

  # A turn that ended on an API error is a wedge whatever the process evidence says, until the
  # rollout moves again. The gap is recorded once, from the moment the turn failed.
  WEDGED=0
  turn_error="$(ns_codex_turn_error "$(rollout)")"
  if [ -n "$turn_error" ] && ! rollout_grew; then
    failed_at="$(ns_codex_turn_error_at "$(rollout)")"
    if [ "$failed_at" != "$WEDGE_SEEN" ]; then
      WEDGE_SEEN="$failed_at"
      ns_usage_pause "$NS" "the session stopped on an API error ($turn_error)" "$failed_at" || true
      log_line "watchman: the last turn ended on an API error ($turn_error) — the session is wedged, not working"
    fi
    reset_at="$(ns_codex_limit_reset "$(rollout)")"
    # A usage limit is revived once it resets, unless the owner turned that off
    # (watchAfterUsageLimit false): then the limit is recorded and the shift waits for them.
    if [ "$turn_error" = usage_limit_exceeded ] &&
      { [ "$(rule "$PROJECT" watchAfterUsageLimit "${NIGHTSHIFT_WATCH_AFTER_USAGE_LIMIT:-}")" = false ] ||
        { [ -n "$reset_at" ] && [ "$(date +%s)" -lt "$reset_at" ]; }; }; then
      note usage-limit "$reset_at"
      : >"$TICK" 2>/dev/null || true
      if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
      continue
    fi
    note api-error "$turn_error"
    WEDGED=1
  fi

  # Life, in evidence order: the recorded process, any codex in the project, the rollout pulse.
  # Missing optional tools are not death — stand down rather than revive beside a living session.
  rec_rc=1
  if [ "$WEDGED" -eq 0 ] && [ -n "$(rec_pid)" ]; then
    recorded_process_alive
    rec_rc=$?
    if [ "$rec_rc" -eq 0 ]; then
      note silent-standby
      baseline_rollout
      : >"$TICK" 2>/dev/null || true
      if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
      continue
    fi
  fi
  in_rc=1
  [ "$WEDGED" -eq 1 ] || { codex_in_project; in_rc=$?; }
  if [ "$WEDGED" -eq 0 ] && { [ "$in_rc" -eq 0 ] || rollout_grew; }; then
    note silent-standby
    baseline_rollout
    : >"$TICK" 2>/dev/null || true
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi
  if [ "$WEDGED" -eq 0 ] && pulse_alive; then
    note silent-standby
    baseline_rollout
    : >"$TICK" 2>/dev/null || true
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi
  if [ "$WEDGED" -eq 0 ] && { [ "$rec_rc" -eq 3 ] || { [ "$in_rc" -eq 2 ] && [ "$rec_rc" -ne 1 ]; }; }; then
    note process-evidence-unavailable
    log_line "watchman: process evidence unavailable — standing down, not reviving"
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi

  # A recorded identity that cannot be resumed is not a missing first-record (that still gets
  # the fresh fallback). Guessing, or starting an unrelated conversation, would claim a thread
  # this watchman did not resume.
  if [ -z "$AGENT" ]; then
    kind="$(ns_codex_identity_kind "$(sid)")"
    if [ "$kind" != "resumable" ] && [ "$kind" != "missing" ]; then
      note non-resumable-session "$kind"
      log_line "watchman: recorded Codex identity is $kind — standing down; not resuming and not starting a fresh thread"
      exit 0
    fi
  fi

  # Dead quiet, mid-shift: revive. Attempts = watchRetrySeconds values + 1, re-checking
  # life between them — a site that comes back mid-wake cancels the rest.
  attempt=0
  revived=1
  # shellcheck disable=SC2086  # RETRY_SPACING is a space-separated list; splitting is the point
  set -- $RETRY_SPACING
  total=$(( $# + 1 ))
  for gap in 0 $RETRY_SPACING; do
    [ "$gap" -gt 0 ] && sleep "$gap"
    if rollout_grew || { [ "$WEDGED" -eq 0 ] && { recorded_process_alive || codex_in_project || pulse_alive; }; }; then
      note silent-standby
      log_line "watchman: session activity during retries — holding the remaining attempts"
      break
    fi
    attempt=$((attempt + 1))
    [ "$attempt" -le "$total" ] || break
    log_line "watchman: site dead quiet mid-shift — resume attempt $attempt ($(rung_name $attempt))"
    if [ "$RECOVERY_REFUSED" -eq 1 ]; then break; fi
    if spawn "$attempt"; then
      revived=0
      if [ "$attempt" -ge 2 ] || [ -z "$(sid)" ]; then
        note fresh-fallback
      else
        note revived
      fi
      log_line "watchman: revival returned — the night continues: codex exec resume $(sid)"
      break
    fi
    baseline_rollout
  done
  if [ "$revived" -eq 1 ] && [ "$attempt" -gt 0 ]; then
    note exhausted-retry
    # Every attempt took the lease for its own child. A ladder that revived nothing hands it
    # back, so the recorded conversation is not left waiting on a generation nobody holds.
    ns_lease_restore_interactive "$NS" || true
  fi
  baseline_rollout
  : >"$TICK" 2>/dev/null || true

  if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
done

SHA-256: 21426e69aae7f670e2340b39122fa37808d8f55999e81d61280340f20e3cf5e0