← Files NightshiftARCHIVED FILE

runtime/cursor/watchman.sh

16.8 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

#!/usr/bin/env bash
# watchman.sh — the night watchman for Cursor shifts. Revives a session that DIED mid-shift;
# never one that ended, and never another host's.
#
# Cursor keeps two conversation stores. The origin IDE tab records a conversation_id under
# ~/.cursor/projects/.../agent-transcripts. agent --resume talks only to ~/.cursor/chats.
# Those ids are not interchangeable: this watchman never passes the IDE id to --resume.
#
# First wake after an IDE death mints a CLI chat (agent create-chat), records it in
# .shift-worker, and resumes that id with freshRevivalPrompt. Later wakes resume the same
# CLI id with revivalPrompt. A shift that started from the CLI already has a CLI id — that
# id is recorded as the worker and resumed from the first wake.
#
#   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:
#     · 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
#     · the recorded transcript (line 2) grew since the last wake
#     · a live .shift-lease pid+start (a recovered CLI that holds the lease)
#   DEAD (mint/revive): pulse stale, no transcript growth, no .session-end, and
#     the lease pid is missing or dead. Missing process evidence stands by.
# There is no Esc / roster / wedge ladder on Cursor.
#
# 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)
#   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
#   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"

PROJECT="$PWD"
INTERVAL_MIN="${NIGHTSHIFT_WATCH:-}"
AGENT=""
MAX_WAKES=0

usage() { sed -n '2,36p' "$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 LOT TICK SESSION_FILE SESSION_END ENDED DEADLINE STOP MINT_FAILED
ns_layout_set PUNCH "$NS" punch-list
ns_layout_set LOG "$NS" shift-log
ns_layout_set LOT "$NS" parking-lot
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
ns_layout_set MINT_FAILED "$NS" mint-failed
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

[ -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
}

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; }
transcript() { [ -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"; }

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
}

lease_pid_live() {
  ns_lease_pid_live "$NS"
}

TRANSCRIPT_SEEN=""
baseline_transcript() {
  local t
  t="$(transcript)"
  [ -n "$t" ] && [ -f "$t" ] && TRANSCRIPT_SEEN="$(wc -c <"$t" 2>/dev/null | tr -d ' ')" || TRANSCRIPT_SEEN=""
}
transcript_grew() {
  local t now
  t="$(transcript)"
  [ -n "$t" ] && [ -f "$t" ] || return 1
  now="$(wc -c <"$t" 2>/dev/null | tr -d ' ')"
  [ -n "$TRANSCRIPT_SEEN" ] && [ "$now" != "$TRANSCRIPT_SEEN" ]
}

# Resolve the CLI worker id. Never returns the origin IDE conversation_id unless that id
# already lives in the CLI store (Start from agent).
ensure_worker() {
  local existing origin kind minted
  existing="$(ns_cursor_worker_id "$NS")"
  if [ -n "$existing" ]; then
    printf '%s' "$existing"
    return 0
  fi
  if [ -f "$MINT_FAILED" ] && [ ! -L "$MINT_FAILED" ]; then
    return 1
  fi
  origin="$(sid)"
  kind="$(ns_cursor_store_kind "$(transcript)")"
  if [ "$kind" = cli ] && [ -n "$origin" ]; then
    ns_cursor_worker_write "$NS" "$origin" || return 1
    printf '%s' "$origin"
    return 0
  fi
  if [ -n "$AGENT" ]; then
    minted="${NIGHTSHIFT_CURSOR_TEST_WORKER:-minted-cli-worker}"
    ns_cursor_worker_write "$NS" "$minted" || return 1
    printf '%s' "$minted"
    return 0
  fi
  minted="$(agent create-chat 2>/dev/null | tr -d '[:space:]')"
  if [ -z "$minted" ]; then
    log_line "watchman: could not mint a CLI worker — not passing the IDE conversation to agent --resume"
    [ -L "$MINT_FAILED" ] && rm -f "$MINT_FAILED"
    printf '%s\n' "$(date +%s)" >"$MINT_FAILED"
    return 1
  fi
  ns_cursor_worker_write "$NS" "$minted" || return 1
  printf '%s' "$minted"
}

# $1 = 1 resume the stored CLI worker · 2 fresh CLI worker (same id, fresh prompt)
spawn() {
  local worker prompt rc freshly scope open_before
  ns_ensure_work_target_link "$PROJECT" || true
  open_before="$(ns_open_boxes "$PUNCH")"
  freshly=0
  if ! ns_cursor_worker_present "$NS"; then
    freshly=1
  fi
  worker="$(ensure_worker)" || return 1
  if [ "$freshly" -eq 1 ] || [ "$1" -ge 2 ]; then
    prompt="$PROMPT_FRESH"
  else
    prompt="$PROMPT_RESUME"
  fi
  if [ -n "$AGENT" ]; then
    # shellcheck disable=SC2086
    ns_watchman_run_child "$NS" cursor "$worker" "$WORK_TARGET" \
      CURSOR_PROJECT_DIR "$PROJECT" $AGENT "$prompt"
  else
    scope="$(ns_recovery_effective_scope "$PROJECT" cursor)"
    case "$scope" in
      unavailable:*)
        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
    log_line "watchman: reviving under launch scope $scope"
    # Cursor exposes no name for a session's permissions, so there is nothing to inherit and the
    # worker takes its own launch: the broad grant is only used when the owner asked for it by
    # name, and a scope recorded for another host is never passed to this one.
    if [ "$scope" != host-grant ]; then
      ns_watchman_run_child "$NS" cursor "$worker" "$WORK_TARGET" \
        CURSOR_PROJECT_DIR "$PROJECT" \
        agent --resume="$worker" -p --workspace "$PROJECT" "$prompt"
    else
      ns_watchman_run_child "$NS" cursor "$worker" "$WORK_TARGET" \
        CURSOR_PROJECT_DIR "$PROJECT" \
        agent --resume="$worker" -p --trust --yolo --workspace "$PROJECT" "$prompt"
    fi
  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
}

notice_revival() {
  local worker cmd
  worker="$(ns_cursor_worker_id "$NS")"
  [ -n "$worker" ] || return 0
  cmd="$(ns_cursor_resume_command "$NS" "$PROJECT")" || return 0
  log_line "watchman: revived in a CLI worker — $cmd"
  [ -d "$NS" ] || return 0
  printf -- '- [notice] %s — the shift session died and the watchman revived it in a CLI worker. To see it, run this in a terminal: %s. To stop it, ask Nightshift to stop.\n' \
    "$(ts)" "$cmd" >>"$LOT"
}

log_line "watchman (cursor) armed · every ${INTERVAL_MIN}m"
BASE_SLEEP="${NIGHTSHIFT_WATCH_SLEEP:-$((INTERVAL_MIN * 60))}"
wake=0
baseline_transcript
: >"$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

  host="$(ns_session_host "$NS")"
  if [ "$host" != cursor ]; 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

  rec_rc=1
  if [ -n "$(rec_pid)" ]; then
    recorded_process_alive
    rec_rc=$?
    if [ "$rec_rc" -eq 0 ]; then
      note silent-standby
      baseline_transcript
      : >"$TICK" 2>/dev/null || true
      if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
      continue
    fi
  fi
  if transcript_grew; then
    note silent-standby
    baseline_transcript
    : >"$TICK" 2>/dev/null || true
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi
  if lease_pid_live; then
    note silent-standby
    baseline_transcript
    : >"$TICK" 2>/dev/null || true
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi
  if pulse_alive; then
    note silent-standby
    baseline_transcript
    : >"$TICK" 2>/dev/null || true
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi
  if [ "$rec_rc" -eq 3 ]; 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
  if ! ns_cursor_worker_present "$NS" && [ -f "$MINT_FAILED" ] && [ ! -L "$MINT_FAILED" ]; then
    note silent-standby
    baseline_transcript
    : >"$TICK" 2>/dev/null || true
    if [ "$MAX_WAKES" -gt 0 ] && [ "$wake" -ge "$MAX_WAKES" ]; then exit 0; fi
    continue
  fi

  attempt=0
  revived=1
  # shellcheck disable=SC2086
  set -- $RETRY_SPACING
  total=$(( $# + 1 ))
  for gap in 0 $RETRY_SPACING; do
    [ "$gap" -gt 0 ] && sleep "$gap"
    if recorded_process_alive || transcript_grew || lease_pid_live || 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
    if ns_cursor_worker_present "$NS"; then
      log_line "watchman: site dead quiet mid-shift — resume attempt $attempt (resuming CLI worker $(ns_cursor_worker_id "$NS"))"
    else
      log_line "watchman: site dead quiet mid-shift — resume attempt $attempt (minting a CLI worker)"
    fi
    if spawn "$attempt"; then
      revived=0
      if [ "$attempt" -ge 2 ]; then
        note fresh-fallback
      else
        note revived
      fi
      notice_revival
      break
    fi
    baseline_transcript
  done
  if [ "$revived" -eq 1 ] && [ "$attempt" -gt 0 ]; then
    note exhausted-retry
    # Every attempt took the lease for its own worker. 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_transcript
  : >"$TICK" 2>/dev/null || true

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

SHA-256: f8cdc381c9bf4f25fc70c81e8f65c292a3abe4bd6e3386a9342ee569076ef418