| name | babysit-pr |
| description | Watch a single PR on a persistent Monitor and auto-dispatch /fixpr (recoverable blockers) or /wrap (merge-ready). Reads PR state via pr-state.sh + merge-gate.sh each tick, classifies into {merged, merge-ready, conflicting, has-recoverable-blockers, waiting-on-bots, hard-blocked}, tracks in-flight dispatches in session-state for idempotency, applies stable-state backoff, emits a timestamped heartbeat per tick, and hard-terminates on merge/hard-blocked/N blocker ticks/user stop. Stop with /babysit-pr-stop. Invoke as `/babysit-pr <PR> [--cadence Nm] [--max-iter N] [--silent] [--auto-resolve-conflicts] [--max-conflict-rounds N]`. |
| triggers | ["babysit pr","babysit this pr","watch this pr","keep an eye on pr"] |
| argument-hint | <PR> [--cadence Nm] [--max-iter N] [--silent] [--auto-resolve-conflicts] [--max-conflict-rounds N] |
Watch one PR and drive it toward merge without looping forever. Each tick reads the PR's state through the shared scripts, classifies it, and dispatches the right skill — /fixpr to fix recoverable blockers, /wrap to merge when the gate is met — then re-arms the poll until a terminal condition fires.
/babysit-pr is a thin orchestrator: it never re-implements PR-state aggregation, the merge gate, fix logic, or the merge flow. It reads pr-state.sh + merge-gate.sh, and it dispatches /fixpr / /wrap. All code-verification, thread resolution, CI fixing, and merge-gate enforcement stay inside those skills (fixpr/SKILL.md, wrap/SKILL.md, cr-merge-gate.md). This skill only decides which to call and when to stop.
This is reused by /pr-monitor-and-manage (issue #460): that skill invokes /babysit-pr per discovered PR rather than re-implementing the per-PR decision tree.
Safety boundaries (HARD STOPS — non-negotiable, safety.md / #450)
/babysit-pr is read-only plus the dispatches below. It MUST NOT:
- Never babysit a PR you did not author (issue #733,
safety.md). Enrolling a PR in a watch loop is a "touch". Before the first tick, gate the target with "$PR_AUTHORSHIP_SH" <PR> (exit 0 = yours). Not yours / undetermined → refuse with one line naming the authorship guard, and do NOT enrol — unless the user named this specific PR in chat this session (per-PR override; say you are operating under it). polling-state-gate.sh --ensure-session also refuses non-author enrolment as a fail-safe (bypass only with --allow-nonauthor under that override).
- Never modify branch protection — no calls to
.../branches/.../protection.
- Never dismiss human-authored reviews. Only
/fixpr's dismiss-stale-bot-changes.sh (bot allowlist, wrong commit_id) may dismiss, and only bot reviews.
- Never resolve a review thread itself — thread resolution happens only inside
/fixpr Steps 1–4 after code-verification. /babysit-pr does not call resolveReviewThread.
- Never bypass
/fixpr's code-verification step — it dispatches the full /fixpr workflow, never a shortcut.
- Never post
@coderabbitai full review without cr-review-hourly.sh --check passing first. The only sanctioned trigger path in this skill is the T1b pre-flight (pr-preflight.sh, issue #493), which gates CR on cr-review-hourly.sh (--check + atomic --record-explicit) automatically, never triggers Greptile, and never flips another user's draft. /fixpr owns any further triggers after a push.
A human CHANGES_REQUESTED on HEAD is hard-blocked → record and exit. Never auto-dismiss it.
- Auto-resolve mode (
--auto-resolve-conflicts) performs unattended rebases and force-pushes. The resolver (.claude/skills/merge-conflict/resolve_merge_conflicts.py) only applies mechanically-simple hunks per its "when in doubt, complex" contract; any complex hunk aborts the rebase and reports the specific file + line range + reason instead of applying it. Because unattended force-pushes are a bigger authorization step than the rest of the dispatch table, this mode is opt-in — the default on CONFLICTING is a conservative stop that recommends /merge-conflict.
Arguments & knobs
| Argument | Default | Meaning |
|---|
<PR> (required) | — | PR number to watch. Must be an open PR. |
--cadence Nm | 5m | Base poll cadence. Floor 1m (60s) — clamp anything lower. |
--max-iter N | 6 | Hard termination after N consecutive blocker-state ticks (≈90 min once backoff widens to 15m). |
--silent | off | Suppress the per-tick heartbeat except on state change, dispatch, or termination (those always print). |
--auto-resolve-conflicts | off | Opt-in: on CONFLICTING, dispatch /fixpr in safe-only mode (BABYSIT_SAFE_CONFLICT_MODE=1) to rebase and auto-resolve mechanically-simple hunks. Any complex hunk aborts the rebase and terminates with a per-hunk report. Off by default — performs unattended rebases and force-pushes. |
--durable | removed | Accepted and ignored (issue #827). It swapped the session watcher for a CronCreate job to buy cross-session continuity that never existed. Issue #914 measured cron producing zero ticks; issue #924 found dynamic /loop unreliable too. The watcher is always a persistent Monitor. Why this is not re-implemented on the durable scheduler the harness does provide: .claude/reference/cross-session-durability.md. |
--max-conflict-rounds N | 3 | Hard termination after N consecutive conflict rounds. Each round that enters the auto-resolve path increments conflict_streak, which does not reset on SHA change — only on a non-conflicting tick. |
Parse from $ARGUMENTS. The first bare integer is <PR>. Validate --cadence matches ^[0-9]+m$; clamp < 1m to 1m. Validate --max-iter is a positive integer. --auto-resolve-conflicts is a boolean flag (present = true, absent = false; stored as AUTO_RESOLVE_CONFLICTS=true|false). --max-conflict-rounds N must be a positive integer; default 3 (stored as MAX_CONFLICT_ROUNDS).
--monitor-generation <token> is runtime-only. Parse it only for an internal --tick; never
accept it as user configuration or copy it across a re-arm. Every Monitor arm generates a fresh
token and embeds it in its emitted command so an event already queued by a stopped Monitor cannot
act as the replacement generation.
Parse the internal fields explicitly before the ordinary argument parser consumes the remaining
flags. A direct invocation carrying --monitor-generation is invalid; only a Monitor-emitted tick
may present it:
BABYSIT_INTERNAL_TICK=false
[[ " $ARGUMENTS " == *" --tick "* ]] && BABYSIT_INTERNAL_TICK=true
TICK_GENERATION=""
_BABYSIT_EXPECT_GENERATION=false
for _BABYSIT_ARG in $ARGUMENTS; do
if [[ "$_BABYSIT_EXPECT_GENERATION" == true ]]; then
TICK_GENERATION="$_BABYSIT_ARG"
_BABYSIT_EXPECT_GENERATION=false
continue
fi
[[ "$_BABYSIT_ARG" == "--monitor-generation" ]] && _BABYSIT_EXPECT_GENERATION=true
done
if [[ "$_BABYSIT_EXPECT_GENERATION" == true ]]; then
echo "ERROR: --monitor-generation requires a token." >&2
exit 2
fi
if [[ "$BABYSIT_INTERNAL_TICK" != true && -n "$TICK_GENERATION" ]]; then
echo "ERROR: --monitor-generation is runtime-only and requires --tick." >&2
exit 2
fi
The ordinary parser must ignore --tick, --monitor-generation, and the generation token already
captured above. It must never reinterpret the token as the PR number or persist it as watcher
configuration.
--durable is still accepted so a saved chip payload or muscle memory does not hard-error; print one line and carry on with Monitor:
[babysit] --durable was removed (issue #827) — it never provided cross-session continuity. Watching on Monitor.
Two modes: arm vs tick
This skill runs in one of two modes, disambiguated by the internal --tick flag:
- Arm mode (
/babysit-pr <PR> …, no --tick): validate, initialize session-state.json, arm the recurring poll, run one tick immediately, then end the turn.
- Tick mode (
/babysit-pr <PR> --tick): the body the poll re-invokes each cycle. Runs exactly one tick of classification + dispatch + bookkeeping. Never re-arms the monitor except to change cadence on a backoff threshold crossing.
The persistent Monitor command armed in arm mode emits /babysit-pr <PR> --tick (plus the resolved cadence flags) once per cadence, so every emitted event enters tick mode — see A3.
Resolve the shared scripts (once, both modes)
Use the standard three-candidate lookup (same pattern as fixpr/SKILL.md). Prefer the global install; fall back to the in-repo copy when developing the skill itself.
resolve_script() {
# $1 = script basename; echoes the first executable path or empty
local name="$1" candidate
for candidate in \
"$HOME/.claude/skills-worktree/.claude/scripts/$name" \
"$HOME/.claude/scripts/$name" \
".claude/scripts/$name"; do
if [[ -x "$candidate" ]]; then echo "$candidate"; return 0; fi
done
return 1
}
PR_STATE_SH=$(resolve_script pr-state.sh) || { echo "ERROR: pr-state.sh not found" >&2; exit 1; }
MERGE_GATE_SH=$(resolve_script merge-gate.sh) || { echo "ERROR: merge-gate.sh not found" >&2; exit 1; }
SESSION_STATE_SH=$(resolve_script session-state.sh) || { echo "ERROR: session-state.sh not found" >&2; exit 1; }
CR_HOURLY_SH=$(resolve_script cr-review-hourly.sh) || true # optional; degrade gracefully
GREPTILE_SH=$(resolve_script greptile-budget.sh) || true # optional; degrade gracefully
PREFLIGHT_SH=$(resolve_script pr-preflight.sh) || true # optional; degrade gracefully (#493)
PR_AUTHORSHIP_SH=$(resolve_script pr-authorship.sh) || { echo "ERROR: pr-authorship.sh not found (checked all three paths) — babysit authorship gate unavailable" >&2; exit 1; }
ADMIN_MERGE_SH=$(resolve_script admin-merge.sh) || { echo "ERROR: admin-merge.sh not found (checked all three paths) — clean-BEHIND recovery unavailable" >&2; exit 1; }
to_epoch() {
# Portable ISO-8601 UTC (e.g. 2026-07-21T17:13:05Z) -> epoch seconds.
# Tries GNU date, then BSD/macOS date. Returns non-zero (no stdout) if both
# fail -- callers MUST check the exit code, never assume a numeric result
# (a silently fabricated epoch previously corrupted TTL/age math -- #634).
local iso="$1"
date -u -d "$iso" +%s 2>/dev/null && return 0
date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$iso" +%s 2>/dev/null && return 0
return 1
}
bump_parse_failure_counter() {
# $1 = field name under .prs["<PR>"].babysit, $2 = limit. Echoes the new
# count on stdout (always -- callers can log it regardless of outcome).
# Returns 0 while still under the limit (caller keeps its safe default).
# Returns 1 once the count reaches the limit, OR if persisting the
# increment itself fails -- an untrackable counter can never be trusted to
# self-heal, so a write failure is treated the same as "limit reached"
# (#634: a bounded counter that can silently fail to persist is really an
# unbounded one).
local field="$1" limit="$2" raw count
raw=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.$field" 2>/dev/null)
[[ "$raw" =~ ^[0-9]+$ ]] || raw=0 # missing/null/corrupt -> 0, never feed raw jq output to arithmetic
count=$(( raw + 1 ))
echo "$count"
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.$field=$count" >/dev/null 2>&1 || return 1
(( count < limit ))
}
Always read/write session-state.json via session-state.sh --get/--set (atomic, sibling-preserving) — never raw jq writes (handoff-files.md). State for this skill lives under .prs["<N>"]:
- Backoff fields reuse the shared schema fields
.prs["<N>"].digest and .prs["<N>"].digest_streak (per scheduling-reliability.md / session-state-schema.json) so the backoff watchdog sees a consistent streak.
- Babysit-specific fields live under a nested
.prs["<N>"].babysit object so they don't collide with phase/reviewer fields other skills write.
// .prs["<N>"].babysit
{
"active": true,
"started_at": "2026-06-25T19:00:00Z",
"last_tick_at": "2026-06-25T19:00:00Z", // refreshed every tick — freshness signal for A2
"last_tick_parse_failures": 0, // consecutive A2 to_epoch() failures on last_tick_at; treated as stale (reclaimed) at BABYSIT_PARSE_FAIL_LIMIT (default 3)
"cadence_base_minutes": 5,
"cadence_effective_minutes": 5,
"tick_count": 0,
"blocker_streak": 0, // consecutive blocker-state ticks (drives --max-iter)
"max_blocker_ticks": 6,
"silent": false,
"stop_requested": false, // set true by /babysit-pr-stop
"dispatch_in_flight": null, // {"skill":"fixpr"|"wrap","started_at":"…"} while a dispatch runs
"dispatch_parse_failures": 0, // consecutive T0 to_epoch() failures on dispatch_in_flight.started_at; force-reclaimed at BABYSIT_PARSE_FAIL_LIMIT (default 3)
"last_dispatch": null, // {"skill":"…","started_at":"…","completed_at":"…","status":"…"}
"auto_resolve_conflicts": false, // from --auto-resolve-conflicts; enables unattended rebase+force-push on simple conflicts
"max_conflict_rounds": 3, // from --max-conflict-rounds; hard cap on consecutive conflict rounds
"conflict_streak": 0 // consecutive conflict rounds entered this watcher run; does not reset on SHA change — only on a non-conflicting tick
}
ARM MODE
Run only when invoked without --tick.
A1. Validate the PR
PR_JSON=$(gh pr view "$PR" --json number,state,headRefOid 2>&1) || {
echo "ERROR: PR #$PR not found or gh failed: $PR_JSON" >&2; exit 1; }
PR_PR_STATE=$(jq -r '.state' <<<"$PR_JSON") # OPEN | MERGED | CLOSED
if [[ "$PR_PR_STATE" != "OPEN" ]]; then
echo "PR #$PR is $PR_PR_STATE — nothing to babysit."; exit 0
fi
A2. Refuse duplicate watchers (idempotent setup)
A live watcher writes babysit.last_tick_at every tick (T5). Treat active == true as a duplicate only when the watcher is actually fresh — otherwise a crashed/aborted session would leave active=true forever and brick re-arm. The freshness window is generous: 3 × the effective cadence, floored at the dispatch TTL (BABYSIT_DISPATCH_TTL_MIN, default 30m) so a long in-flight /fixpr never looks dead.
ALREADY=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.active"); RC=$?
if [[ "$RC" -eq 3 ]]; then ALREADY="null"; elif [[ "$RC" -ne 0 ]]; then
echo "ERROR: session-state.sh --get failed (exit $RC) — aborting arm to avoid double-watch." >&2; exit "$RC"
fi
if [[ "$ALREADY" == "true" ]]; then
RETAINED_TASK_ID=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.monitor_task_id" 2>/dev/null || echo "null")
RETAINED_GENERATION=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.monitor_generation" 2>/dev/null || echo "null")
STOP_PENDING=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.stop_requested" 2>/dev/null || echo "false")
if [[ "$STOP_PENDING" == "true" ]]; then
echo "ERROR: babysit-pr teardown incomplete for PR #$PR (retained Monitor task ID: $RETAINED_TASK_ID) — retry /babysit-pr-stop $PR or repair the runtime task before re-arming." >&2
exit 1
fi
if [[ -z "$RETAINED_TASK_ID" || "$RETAINED_TASK_ID" == "null" ||
-z "$RETAINED_GENERATION" || "$RETAINED_GENERATION" == "null" ]]; then
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.stop_requested=true" >/dev/null 2>&1 || true
echo "ERROR: active babysit-pr watcher for PR #$PR lacks a complete Monitor identity (task ID + generation) — refusing blind stale reclaim; inspect the runtime task and repair teardown before re-arming." >&2
exit 1
fi
LAST_TICK=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.last_tick_at" 2>/dev/null || echo "")
EFF_MIN=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.cadence_effective_minutes" 2>/dev/null || echo 5)
[[ "$EFF_MIN" =~ ^[0-9]+$ ]] || EFF_MIN=5
FRESH_MIN=$(( EFF_MIN * 3 )); (( FRESH_MIN < ${BABYSIT_DISPATCH_TTL_MIN:-30} )) && FRESH_MIN=${BABYSIT_DISPATCH_TTL_MIN:-30}
PARSE_FAIL_LIMIT="${BABYSIT_PARSE_FAIL_LIMIT:-3}"
AGE_MIN=999999
if [[ -n "$LAST_TICK" && "$LAST_TICK" != "null" ]]; then
if LAST_TICK_EPOCH=$(to_epoch "$LAST_TICK"); then
AGE_MIN=$(( ( $(date -u +%s) - LAST_TICK_EPOCH ) / 60 ))
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.last_tick_parse_failures=0" >/dev/null 2>&1 || true
else
if A2_FAIL_COUNT=$(bump_parse_failure_counter last_tick_parse_failures "$PARSE_FAIL_LIMIT"); then
echo "WARNING: could not parse babysit.last_tick_at ('$LAST_TICK') as epoch on this platform's date (failure ${A2_FAIL_COUNT}/${PARSE_FAIL_LIMIT}) — treating watcher as fresh (safe default) instead of reclaiming it blind. If this persists, run /babysit-pr-stop $PR." >&2
AGE_MIN=0
else
echo "WARNING: babysit.last_tick_at ('$LAST_TICK') failed to parse ${A2_FAIL_COUNT} arm attempts in a row, or the failure counter itself could not be persisted — treating watcher slot as corrupted and reclaiming it rather than blocking re-arm forever." >&2
# AGE_MIN stays at its 999999 default above -> falls through to the stale/reclaim path below.
fi
fi
fi
if (( AGE_MIN < FRESH_MIN )); then
echo "Already babysitting PR #$PR (last tick ${AGE_MIN}m ago) — not arming a second watcher. Use /babysit-pr-stop $PR to stop."
exit 0
fi
echo "[babysit] stale watcher for PR #$PR (last tick ${AGE_MIN}m ago ≥ ${FRESH_MIN}m) — reclaiming and re-arming."
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.stop_requested=true" || {
echo "ERROR: could not publish the stale-watch teardown guard — leaving the recorded Monitor untouched." >&2; exit 1; }
fi
Before A3, a stale reclaim must stop the exact $RETAINED_TASK_ID with TaskStop. If that
stop fails, retain active=true, the exact task ID+generation pair, and stop_requested=true;
report incomplete teardown and abort. Only after exact TaskStop succeeds may A3 replace the
babysit object and arm one new Monitor. Never infer that a silent Monitor died, and never arm beside
it.
Execute that teardown before the A3 initialization block. Call TaskStop with
task_id="$RETAINED_TASK_ID", then bind its tool result to TASK_STOP_SUCCEEDED; do not simulate a
successful stop from silence or age:
if [[ "$ALREADY" == "true" ]]; then
# TaskStop tool call: task_id="$RETAINED_TASK_ID"
# Set TASK_STOP_SUCCEEDED=true only when that exact tool call succeeds.
if [[ "${TASK_STOP_SUCCEEDED:-false}" != "true" ]]; then
echo "ERROR: stale babysit watcher teardown failed for PR #$PR (Monitor task $RETAINED_TASK_ID, generation $RETAINED_GENERATION) — retaining the complete identity and stop_requested=true; not re-arming." >&2
exit 1
fi
fi
After success, leave stop_requested=true and the old identity pair recorded until A3 replaces the
whole babysit object atomically. That keeps any already-queued old-generation event inert during
the stop-to-publication gap.
A3. Initialize state and arm the poll
Write the babysit object (one atomic --set batch), seeding backoff fields to a neutral start:
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit={\"active\":true,\"started_at\":\"$NOW\",\"last_tick_at\":\"$NOW\",\"last_tick_parse_failures\":0,\"cadence_base_minutes\":$BASE_MIN,\"cadence_effective_minutes\":$BASE_MIN,\"tick_count\":0,\"blocker_streak\":0,\"max_blocker_ticks\":$MAX_ITER,\"silent\":$SILENT,\"stop_requested\":false,\"monitor_task_id\":null,\"monitor_generation\":null,\"dispatch_in_flight\":null,\"dispatch_parse_failures\":0,\"last_dispatch\":null,\"auto_resolve_conflicts\":$AUTO_RESOLVE_CONFLICTS,\"max_conflict_rounds\":$MAX_CONFLICT_ROUNDS,\"conflict_streak\":0}" \
--set ".prs[\"$PR\"].digest_streak=0"
Generate a fresh non-secret generation token, then arm a persistent Monitor with description
babysit PR #<PR> and this command:
MONITOR_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-${RANDOM:-0}"
while sleep "$(( BASE_MIN * 60 ))"; do
printf '%s\n' "/babysit-pr $PR --tick --monitor-generation $MONITOR_GENERATION --cadence ${BASE_MIN}m"
done
Each stdout line becomes an out-of-turn chat event. sleep comes first because arm mode runs one
tick immediately. Pass persistent: true, capture the returned task ID, and persist it as
.prs["$PR"].babysit.monitor_task_id. The watcher stays session-scoped by design — one that
outlived its session would be auto-dispatching /wrap merges into an empty room.
Never substitute either /loop mode or CronCreate. Issue #914 measured listed cron jobs
producing zero ticks across an 11-minute idle window; issue #924 recorded two dynamic /loop
watchers stopping until a manual turn. Monitor is the only recurring primitive with positive
out-of-turn liveness evidence.
Arming is not ticking. A watcher can be active in state, listed by the runtime, and silent — that is Pattern 7 in .claude/reference/scheduling-failure-modes.md, first observed on this very skill (PR #908, where all six ticks were driven by hand). Two things now catch it: babysit-tick-watchdog.sh warns once last_tick_at exceeds 2 × the effective cadence, and the pre-exit checklist asks for liveness rather than presence.
The silence ceiling is a backstop, not this watcher's cadence. bgwork-ceiling.sh trips on a silence budget far wider than any poll interval, so a run where the ceiling is producing the only ticks is a broken poll, not a working watch — in #908 it surfaced each stall roughly 18 minutes late, after the branch had gone BEHIND twice. If ticks are only appearing alongside ceiling breaches, stop and re-arm rather than riding the backstop.
Roll back if arming fails. The --set above already published active=true with a fresh last_tick_at, so a failed arm leaves a watcher that A2 reads as live for the whole freshness window (30m by default) while nothing is ticking — re-arm is blocked precisely when it is needed. A Monitor call that errors or returns no task ID is a failed arm. Treat init+arm as one transaction:
if [[ -z "${MONITOR_TASK_ID:-}" || "$MONITOR_TASK_ID" == "null" ]]; then
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.active=false" \
--set ".prs[\"$PR\"].babysit.last_tick_at=null" \
--set ".prs[\"$PR\"].babysit.monitor_task_id=null" \
--set ".prs[\"$PR\"].babysit.monitor_generation=null"
echo "ERROR: could not arm the Monitor for PR #$PR — watcher state rolled back, re-run /babysit-pr $PR." >&2
exit 1
fi
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.monitor_task_id=$MONITOR_TASK_ID" \
--set ".prs[\"$PR\"].babysit.monitor_generation=\"$MONITOR_GENERATION\""
If that task-ID publication fails, do not continue to the immediate tick: the Monitor is live but
not discoverable from durable state. Call TaskStop for the exact returned $MONITOR_TASK_ID. If
that rollback stop succeeds, reopen A2 with this atomic rollback:
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.active=false" \
--set ".prs[\"$PR\"].babysit.last_tick_at=null" \
--set ".prs[\"$PR\"].babysit.monitor_task_id=null" \
--set ".prs[\"$PR\"].babysit.monitor_generation=null"
If rollback TaskStop fails, retain active=true, best-effort set stop_requested=true and publish
the exact task ID plus generation so A2 refuses a later re-arm independent of tick age. Report both
identifiers and exit non-zero. Do not claim that the watcher was cleaned up.
Then run one tick immediately (fall through to TICK MODE below) so the user gets instant feedback, and emit the initial heartbeat. Per the scheduling-reliability.md pre-exit checklist, before ending the arm turn confirm: (1) the Monitor task is active, (2) a timestamped heartbeat was sent, (3) state was recorded.
TICK MODE
Run on every poll cycle (and once at the end of arm mode). One tick = classify → maybe dispatch → bookkeep → maybe re-arm cadence → heartbeat.
T0. Stop / terminal short-circuit (check FIRST)
if [[ "$BABYSIT_INTERNAL_TICK" == true ]]; then
RECORDED_GENERATION=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.monitor_generation" 2>/dev/null || echo "null")
if [[ -z "$TICK_GENERATION" || "$TICK_GENERATION" == "null" ||
"$TICK_GENERATION" != "$RECORDED_GENERATION" ]]; then
echo "[babysit] Ignoring a stale or unidentified Monitor tick for PR #$PR."
exit 0
fi
fi
STOP=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.stop_requested" 2>/dev/null || echo "false")
ACTIVE=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.active" 2>/dev/null || echo "false")
if [[ "$STOP" == "true" || "$ACTIVE" != "true" ]]; then
# user ran /babysit-pr-stop, or state was cleared — terminate cleanly
goto TERMINATE with reason="user-stop"
fi
If dispatch_in_flight is set, decide whether it is still running or stale using an explicit TTL (BABYSIT_DISPATCH_TTL_MIN, default 30 — comfortably longer than /fixpr's 20-min wait cap plus /wrap recovery, so a live dispatch is never mistaken for stale).
A started_at that fails to parse on both GNU and BSD date (see to_epoch() above) is treated as still-running for that tick — the safe direction, since this check runs every tick unattended and wrongly reclaiming a live dispatch causes a duplicate /fixpr//wrap run. But "safe for one tick" must not mean "stuck forever" if the value is genuinely corrupted (not just a transient hiccup): dispatch_parse_failures counts consecutive parse failures for the current dispatch_in_flight, and once it reaches BABYSIT_PARSE_FAIL_LIMIT (default 3) the entry is force-reclaimed as corrupted rather than left blocking indefinitely:
TTL_MIN="${BABYSIT_DISPATCH_TTL_MIN:-30}"
PARSE_FAIL_LIMIT="${BABYSIT_PARSE_FAIL_LIMIT:-3}"
IN_FLIGHT=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.dispatch_in_flight" 2>/dev/null || echo "null")
DISPATCH_BLOCKING=0
if [[ "$IN_FLIGHT" != "null" && -n "$IN_FLIGHT" ]]; then
STARTED=$(jq -r '.started_at // empty' <<<"$IN_FLIGHT")
RECLAIM_REASON=""
if STARTED_EPOCH=$(to_epoch "$STARTED"); then
AGE_MIN=$(( ( $(date -u +%s) - STARTED_EPOCH ) / 60 ))
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.dispatch_parse_failures=0" >/dev/null 2>&1 || true
(( AGE_MIN >= TTL_MIN )) && RECLAIM_REASON="stale (age ${AGE_MIN}m >= ${TTL_MIN}m TTL)"
else
if FAIL_COUNT=$(bump_parse_failure_counter dispatch_parse_failures "$PARSE_FAIL_LIMIT"); then
echo "WARNING: could not parse dispatch_in_flight.started_at ('$STARTED') as epoch on this platform's date (failure ${FAIL_COUNT}/${PARSE_FAIL_LIMIT}) — treating dispatch as still in-flight this tick. If this persists, run /babysit-pr-stop $PR." >&2
else
echo "WARNING: dispatch_in_flight.started_at ('$STARTED') failed to parse ${FAIL_COUNT} ticks in a row, or the failure counter itself could not be persisted — treating as corrupted and force-reclaiming rather than blocking forever." >&2
RECLAIM_REASON="corrupted (unparseable started_at, parse-failure counter reached ${PARSE_FAIL_LIMIT} or could not be persisted)"
fi
fi
if [[ -n "$RECLAIM_REASON" ]]; then
# Stale (TTL exceeded) or corrupted (parse-fail limit exceeded): reclaim so it never blocks forever.
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.last_dispatch=$(jq -c '. + {completed_at: (now|todate), status: "stale-reclaimed"}' <<<"$IN_FLIGHT")" \
--set ".prs[\"$PR\"].babysit.dispatch_in_flight=null" \
--set ".prs[\"$PR\"].babysit.dispatch_parse_failures=0"
echo "[babysit] reclaimed $RECLAIM_REASON dispatch — proceeding"
else
DISPATCH_BLOCKING=1 # a prior tick's /fixpr or /wrap is genuinely still running (or unparseable, within grace)
fi
fi
If DISPATCH_BLOCKING == 1, skip this tick's dispatch (idempotency — see T4): emit a "dispatch in progress" heartbeat and finish the tick without classifying-and-dispatching on top of the running dispatch. Otherwise continue normally (a stale or corrupted in-flight has been reclaimed above).
T1. Read PR state (the two shared scripts — never re-implement)
# Authoritative merge-readiness + blocker breakdown.
GATE_JSON=$("$MERGE_GATE_SH" "$PR"); GATE_EXIT=$?
Fail closed before classifying — merge-gate.sh exit codes are: 0 met (valid JSON), 1 not-met (valid JSON), 2 usage error, 3 PR not found/closed/merged, 4 gh/jq error. Only 0/1 produce real gate JSON. Do not let a transient tooling failure (2/4) or a bad gh pr view fall through into the jq parsing below — that would manufacture a bogus classification or dispatch:
case "$GATE_EXIT" in
0|1) ;; # valid gate JSON — proceed to classify
3) # PR not found / closed / merged — let T3's terminal handling decide (merged vs closed)
PR_NOW=$(gh pr view "$PR" --json state --jq '{state}' 2>/dev/null || echo '{}')
# fall through to T3 with GATE_JSON unused; T3 row 1/2 classify merged/closed
SKIP_STATE_READ=1 ;;
*) # exit 2/4/other — tooling/transient error. Heartbeat + skip this tick (no classify, no dispatch).
TS=$(TZ='America/New_York' date +'%a %b %-d %I:%M %p ET')
echo "[$TS] #$PR tick: merge-gate.sh failed (exit $GATE_EXIT) — skipping classification this tick, retrying next cadence."
# update last_tick_at so A2 freshness still reflects a live watcher, then end the tick
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.last_tick_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" 2>/dev/null || true
return 0 2>/dev/null || exit 0 ;;
esac
When SKIP_STATE_READ is unset, fetch the full state bundle (findings/threads/CI/SHA). Guard gh pr view and pr-state.sh the same way — a non-zero exit means skip the tick, never classify on empty data:
if [[ -z "${SKIP_STATE_READ:-}" ]]; then
PR_CREATED=$(gh pr view "$PR" --json createdAt --jq '.createdAt') || {
echo "[babysit] gh pr view failed — skipping tick, retry next cadence." >&2; exit 0; }
BUNDLE=$("$PR_STATE_SH" --pr "$PR" --since "$PR_CREATED") || {
echo "[babysit] pr-state.sh failed — skipping tick, retry next cadence." >&2; exit 0; }
fi
T1b. Pre-flight — draft→ready + four-reviewer trigger (issue #493)
Run after the gate-exit check (which sets SKIP_STATE_READ), then re-fetch GATE_JSON and BUNDLE so that T1c field extraction and T3 classification operate on post-trigger snapshots. A reviewer just engaged by pre-flight is not misclassified as "missing" and does not cause a spurious /fixpr dispatch on the same tick.
Run the shared pr-preflight.sh so a draft PR you own is flipped ready and all four conditionally-triggered reviewers (CodeAnt, CodeRabbit, Cursor, Graphite) are engaged on the current SHA before T2/T3 decide anything. This is the same script /fixpr Step 0c and /pr-monitor-and-manage use — /babysit-pr never re-implements the draft flip or trigger logic. Since #576 "engaged on the current SHA" is enforced rather than assumed: a reviewer whose only artifact is on a superseded commit is re-triggered, so a PR does not sit in waiting-on-bots relying on each bot's own auto-review-on-push. It is idempotent (a PR whose reviewers are all fresh on HEAD is a no-op), rate-cap safe (skips only @coderabbitai full review when cr-review-hourly.sh reports the cap hit, posting the other three), never flips another user's draft, and never triggers Greptile.
Run only on a valid state read (skip when SKIP_STATE_READ=1 — a gone/merged/closed PR needs no pre-flight):
PREFLIGHT_SUMMARY_JSON=""
if [[ -z "${SKIP_STATE_READ:-}" && -n "$PREFLIGHT_SH" ]]; then
PREFLIGHT_OUT=$("$PREFLIGHT_SH" "$PR") || echo "[babysit] pr-preflight.sh exited non-zero (exit $?) — continuing tick" >&2
echo "$PREFLIGHT_OUT" # its timestamped action lines double as part of this tick's heartbeat
PREFLIGHT_SUMMARY_JSON=$(sed -n 's/^PREFLIGHT_SUMMARY: //p' <<<"$PREFLIGHT_OUT")
elif [[ -z "$PREFLIGHT_SH" && -z "${SKIP_STATE_READ:-}" ]]; then
echo "[babysit] pr-preflight.sh not found — skipping draft/reviewer pre-flight this tick"
fi
Re-fetch GATE_JSON and BUNDLE after pre-flight so that any reviewer or draft-state change made by pre-flight is captured before T3 classifies. Guard the same way as T1 — skip the tick on tooling error:
if [[ -z "${SKIP_STATE_READ:-}" ]]; then
GATE_JSON=$("$MERGE_GATE_SH" "$PR"); GATE_EXIT=$?
if [[ "$GATE_EXIT" != "0" && "$GATE_EXIT" != "1" ]]; then
TS=$(TZ='America/New_York' date +'%a %b %-d %I:%M %p ET')
echo "[$TS] #$PR tick: merge-gate.sh post-preflight re-fetch failed (exit $GATE_EXIT) — skipping classification this tick."
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.last_tick_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" 2>/dev/null || true
return 0 2>/dev/null || exit 0
fi
PR_CREATED=$(gh pr view "$PR" --json createdAt --jq '.createdAt') || {
echo "[babysit] gh pr view failed (post-preflight re-fetch) — skipping tick, retry next cadence." >&2; exit 0; }
BUNDLE=$("$PR_STATE_SH" --pr "$PR" --since "$PR_CREATED") || {
echo "[babysit] pr-state.sh failed (post-preflight re-fetch) — skipping tick, retry next cadence." >&2; exit 0; }
fi
T1c. Extract fields from post-preflight snapshots
Pull the fields the classifier needs (only when GATE_EXIT was 0/1 and the bundle was read). These snapshots were taken after pre-flight ran, so they reflect the post-trigger bot state:
HEAD_SHA=$(jq -r '.head_sha // ""' <<<"$GATE_JSON")
GATE_MET=$(jq -r '.met' <<<"$GATE_JSON")
HUMAN_CR=$(jq -r '.human_changes_requested | length' <<<"$GATE_JSON")
MERGE_STATE=$(jq -r '.merge_state // ""' <<<"$GATE_JSON")
MERGEABLE=$(jq -r '.mergeable // ""' <<<"$GATE_JSON")
CI_FAILING=$(jq -r '.ci_status.failing // 0' <<<"$GATE_JSON")
CI_INCOMPLETE=$(jq -r '.ci_status.in_progress // 0' <<<"$GATE_JSON")
STALE_BOT_CR=$(jq -r '.stale_bot_changes_requested_count // 0' <<<"$GATE_JSON")
MISSING=$(jq -r '.missing | join(" | ")' <<<"$GATE_JSON")
# Sorted blocking CI conclusions — the `ci_blocking_conclusions_sorted` field of
# the T5 digest tuple. merge-gate.sh exposes blocking runs as .ci_status.blocking[].
CI_BLOCKING_SORTED=$(jq -r '[.ci_status.blocking[].conclusion] | sort | join(",")' <<<"$GATE_JSON")
UNRESOLVED=$(jq -r '.threads.unresolved_count' < "$BUNDLE")
NEW_FINDINGS=$(jq -r '.new_since_baseline.finding_count // 0' < "$BUNDLE")
CR_STATE=$(jq -r '.bot_statuses.CodeRabbit.state // "none"' < "$BUNDLE")
GREP_STATE=$(jq -r '.bot_statuses.Greptile.state // "none"' < "$BUNDLE")
# BugBot reports via check-run, not commit status; derive a coarse state.
BUGBOT_STATE=$(jq -r '[.check_runs.all[] | select((.name//""|ascii_downcase)|contains("cursor") or contains("bugbot"))] | (if length==0 then "none" elif any(.[]; .status!="completed") then "pending" else "done" end)' < "$BUNDLE" 2>/dev/null || echo "none")
(When SKIP_STATE_READ=1 was set above — gate exit 3 — skip straight to T3's terminal handling, which classifies merged vs closed-unmerged from PR_NOW.)
Record the draft→ready action and any triggered reviewers in the T7 heartbeat / final summary from $PREFLIGHT_SUMMARY_JSON.
T2. Rate-cap snapshot (MUST run before classification)
/babysit-pr never posts review triggers itself — /fixpr owns triggers and their caps. But the classifier (T3) must already know the budget state, so this snapshot runs first: it decides whether "missing fresh bot review" is a recoverable gap (a reviewer can still be triggered) or a hard-blocked budget-exhaustion (no path to the gate this window).
CR_BUDGET_OK=1; GREP_BUDGET_OK=1
if [[ -n "$CR_HOURLY_SH" ]]; then "$CR_HOURLY_SH" --check >/dev/null 2>&1 || CR_BUDGET_OK=0; fi
if [[ -n "$GREPTILE_SH" ]]; then "$GREPTILE_SH" --check >/dev/null 2>&1 || GREP_BUDGET_OK=0; fi
- CodeRabbit:
cr-review-hourly.sh --check exit 1 ⇒ CR_BUDGET_OK=0 (hourly budget exhausted). /babysit-pr only consults --check, never consumes — /fixpr's own --record-explicit enforces the ≤2/PR/hour cap atomically when it actually posts @coderabbitai full review.
- Greptile:
greptile-budget.sh --check exit 1 ⇒ GREP_BUDGET_OK=0 (daily budget exhausted).
- BugBot (
@cursor review): per-seat and spend-metered (#1199), but with no budget helper to consult — so never gate the classifier on it. Its own refusal comment is the signal: do not re-nudge a HEAD BugBot already refused (bugbot.md).
The classifier (T3) consumes CR_BUDGET_OK / GREP_BUDGET_OK: a PR whose only gap is a fresh review becomes hard-blocked (budget exhaustion) only when CR and Greptile are both exhausted and there is no other recoverable work; otherwise it stays waiting-on-bots (a trigger path still exists). Re-running /babysit-pr after the window resets resumes cleanly.
T3. Classify (first match wins, in this order)
Re-fetch the authoritative open/merged state once:
PR_NOW=$(gh pr view "$PR" --json state --jq '{state}')
PR_STATE_NOW=$(jq -r '.state' <<<"$PR_NOW")
PR_MERGED=$(jq -r '(.state == "MERGED")' <<<"$PR_NOW")
| # | Class | Condition | Action |
|---|
| 1 | merged | PR_MERGED == true (or gate exit 3 because merged) | Exit — terminal success. |
| 2 | hard-blocked | HUMAN_CR > 0 (human CHANGES_REQUESTED on HEAD), or PR CLOSED unmerged, or the only gap is a fresh review and both budgets are exhausted (CR_BUDGET_OK == 0 && GREP_BUDGET_OK == 0, from T2), or a Greptile P0 needing design input persists | Record blocker, exit. Never auto-dismiss. |
| 3 | merge-ready | GATE_MET == true (gate exit 0) | Dispatch /wrap (T4). |
| 4 | conflicting | MERGEABLE == CONFLICTING (checked before other recoverable signals; first-match priority) | See T4 conflicting dispatch — branches on --auto-resolve-conflicts flag and conflict_streak cap. |
| 5 | has-recoverable-blockers | gate exit 1 and any of: UNRESOLVED > 0, NEW_FINDINGS > 0, CI_FAILING > 0, STALE_BOT_CR > 0, MERGE_STATE ∈ {BEHIND,DIRTY} | When MERGE_STATE == BEHIND is the only blocker, first complete cr-merge-gate.md Step 2 (per-criterion Test Plan verification at this SHA — ticked boxes alone are a proxy), then run "$ADMIN_MERGE_SH" <PR> --auto-plain --ac-verified (issue #754): exit 0 → merged, relay its evidence block and exit (terminal, as class 1); exit 8 → record the printed bypass as a blocker and exit; exit 1 → fall through. Otherwise dispatch /fixpr (T4). |
| 6 | waiting-on-bots | gate exit 1 and the only gaps are pending review/CI: , or a missing-but-pending bot approval / with no findings/threads/failing-CI (and at least one review budget remains per T2) |
waiting-on-bots is the "do nothing, just wait" state — the gate isn't met but there is nothing actionable yet (no findings to fix, no threads to resolve, CI still running, a bot hasn't posted its verdict). Do not dispatch /fixpr here — that would burn CR/CI cycles on a PR that just needs time. /fixpr owns its own bounded post-push wait (#454); /babysit-pr simply tolerates the gap across ticks.
T4. Dispatch with idempotency (session-state.json is the source of truth)
Only classes merge-ready (→ /wrap), conflicting (→ /fixpr in safe-only mode or terminate), and has-recoverable-blockers (→ /fixpr) dispatch.
T4: conflicting dispatch
Read the watcher's config from session-state:
AUTO_RESOLVE=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.auto_resolve_conflicts" 2>/dev/null || echo "false")
MAX_ROUNDS=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.max_conflict_rounds" 2>/dev/null || echo 3)
[[ "$MAX_ROUNDS" =~ ^[0-9]+$ ]] || MAX_ROUNDS=3
CONFLICT_STREAK=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.conflict_streak" 2>/dev/null || echo 0)
[[ "$CONFLICT_STREAK" =~ ^[0-9]+$ ]] || CONFLICT_STREAK=0
Branch:
-
If AUTO_RESOLVE != "true" (flag not set — default): terminate. Record blocker merge-conflict and emit:
[babysit] PR #$PR is CONFLICTING — stopping (default). Run /merge-conflict to classify hunks, then re-arm babysit. Use --auto-resolve-conflicts to enable unattended rebase.
→ go to T-END with reason hard-blocked, blocker merge-conflict.
-
If AUTO_RESOLVE == "true" and CONFLICT_STREAK >= MAX_ROUNDS: terminate (churn cap hit). Emit:
[babysit] conflict-round-cap: $CONFLICT_STREAK consecutive conflict rounds on PR #$PR (cap=$MAX_ROUNDS). Surfacing to a human.
→ go to T-END with reason conflict-round-cap.
-
Otherwise (AUTO_RESOLVE == "true" and CONFLICT_STREAK < MAX_ROUNDS): increment conflict_streak, then dispatch /fixpr in safe-only mode:
NEW_STREAK=$((CONFLICT_STREAK + 1))
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.conflict_streak=$NEW_STREAK" >/dev/null
# Mark in-flight (standard idempotency guard — step 2 of the shared dispatch protocol below)
# Pass BABYSIT_SAFE_CONFLICT_MODE=1 so /fixpr Step 6 invokes the resolver directly
TARGET="fixpr"
EXTRA_ENV="BABYSIT_SAFE_CONFLICT_MODE=1"
After dispatch completes, branch on DISPATCH_STATUS:
-
Idempotency guard (already evaluated in T0). DISPATCH_BLOCKING from T0 is authoritative: it is 1 only when a prior dispatch_in_flight is within the BABYSIT_DISPATCH_TTL_MIN window (genuinely still running) — a stale entry was already reclaimed to null there. If DISPATCH_BLOCKING == 1, refuse to re-dispatch: emit "dispatch in progress (), skipping" and finish the tick. This prevents two overlapping /fixpr (or /wrap) runs racing on the same PR when a dispatch outlives the cadence, while the TTL guarantees a crashed dispatch cannot wedge the watcher forever.
-
Mark in-flight before invoking:
START=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.dispatch_in_flight={\"skill\":\"$TARGET\",\"started_at\":\"$START\"}" \
--set ".prs[\"$PR\"].babysit.dispatch_parse_failures=0"
-
Invoke the full skill. Execute the complete .claude/skills/$TARGET/SKILL.md workflow inline (or via a Phase A subagent in bypassPermissions mode with the safety.md block when the parent is in monitor mode — subagent-orchestration.md). /fixpr runs Steps 0–7 including its Step 4d post-push wait; /wrap runs its merge-gate recovery + AC + squash-merge + main-sync. Do not shortcut either.
-
Clear in-flight after it returns:
DONE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.last_dispatch={\"skill\":\"$TARGET\",\"started_at\":\"$START\",\"completed_at\":\"$DONE\",\"status\":\"$DISPATCH_STATUS\"}" \
--set ".prs[\"$PR\"].babysit.dispatch_in_flight=null"
DISPATCH_STATUS = the parsed Status: / FIXPR_WRAP_STATUS: from /fixpr, or merged/blocked/stopped from /wrap.
-
On dispatch error: record status: "error", clear dispatch_in_flight, do not retry within this tick — the next tick re-classifies from scratch.
After a /wrap dispatch that reports the PR merged, the next tick (T3 #1) sees merged and terminates. After a /fixpr dispatch, the next tick re-reads state on the new SHA.
T5. Backoff + bookkeeping (scheduling-reliability.md stable-state backoff)
Compute the per-tick digest over the canonical tuple (free-text blockers excluded):
digest = sha256( head_sha | cr_state | bugbot_state | greptile_state | ci_blocking_conclusions_sorted | blocker_kind )
where blocker_kind is the classification name (merge-ready / has-recoverable-blockers / waiting-on-bots / hard-blocked) and ci_blocking_conclusions_sorted is the sorted list of blocking CI conclusions from the gate JSON.
DIGEST=$(printf '%s|%s|%s|%s|%s|%s' "$HEAD_SHA" "$CR_STATE" "$BUGBOT_STATE" "$GREP_STATE" "$CI_BLOCKING_SORTED" "$CLASS" | sha256sum | awk '{print "sha256:"$1}')
PREV_DIGEST=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].digest" 2>/dev/null || echo "null")
PREV_STREAK=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].digest_streak" 2>/dev/null || echo 0)
[[ "$PREV_STREAK" =~ ^[0-9]+$ ]] || PREV_STREAK=0
if [[ "$DIGEST" == "$PREV_DIGEST" ]]; then STREAK=$((PREV_STREAK + 1)); else STREAK=1; fi
Cadence tiers (scheduling-reliability.md widens a stable state and stops a frozen one). The widened cadence is derived from the configured base, never hard-coded, so it is always slower than the base regardless of --cadence:
# Widened cadence: at least 15m, and at least 3× the base — whichever is larger.
# base 5m → 15m (satisfies the AC); base 1m → 15m; base 20m → 60m (still slower).
WIDE_MIN=$(( BASE_MIN * 3 )); (( WIDE_MIN < 15 )) && WIDE_MIN=15
(( WIDE_MIN < BASE_MIN )) && WIDE_MIN=$BASE_MIN # guard: never faster than base
PREV_EFFECTIVE_MIN=$("$SESSION_STATE_SH" --get ".prs[\"$PR\"].babysit.cadence_effective_minutes" 2>/dev/null || echo "$BASE_MIN")
[[ "$PREV_EFFECTIVE_MIN" =~ ^[0-9]+$ ]] || PREV_EFFECTIVE_MIN=$BASE_MIN
if (( STREAK >= 3 )); then EFFECTIVE_MIN=$WIDE_MIN; else EFFECTIVE_MIN=$BASE_MIN; fi
digest_streak | Effective cadence |
|---|
< 3 | base (--cadence, default 5m) |
>= 3 | WIDE_MIN = max(15m, 3 × base) — for the default 5m base this is 15m (satisfies the AC) |
>= 9 | terminate (truly frozen — stop the Monitor) |
Revert to base cadence on any state change (digest differs → STREAK reset to 1 → cadence_effective_minutes returns to BASE_MIN).
Blocker-streak (drives --max-iter termination): a tick is a blocker-state tick when CLASS ∈ {has-recoverable-blockers, waiting-on-bots} and the digest did not change (no forward progress). Increment blocker_streak on such ticks; reset to 0 on merge-ready/merged or on any digest change. A conflicting tick that successfully auto-resolved and pushed counts as forward progress — reset blocker_streak to 0 (the new SHA will change the digest on the next tick); conflict_streak is NOT reset by a SHA change, only by a non-conflicting tick (any class other than conflicting).
Conflict-streak bookkeeping per tick:
CLASS == conflicting and dispatch status was CONFLICTS (complex hunk, aborted): conflict_streak was already incremented in T4 dispatch; tick terminates via T-END (no T5 persist needed).
CLASS == conflicting and dispatch succeeded (rebase+push): conflict_streak was already incremented in T4; blocker_streak reset to 0; do NOT reset conflict_streak.
CLASS != conflicting (any other class, including after a successful rebase): reset conflict_streak to 0.
Compute CONFLICT_STREAK_NEW for the persist block:
# If T4 incremented conflict_streak (conflicting dispatch), it already wrote the new value
# and stored it in $NEW_STREAK. Otherwise (non-conflicting tick), reset to 0.
if [[ "$CLASS" == "conflicting" ]]; then
CONFLICT_STREAK_NEW="${NEW_STREAK:-$CONFLICT_STREAK}" # T4 set NEW_STREAK on a conflicting dispatch
else
CONFLICT_STREAK_NEW=0
fi
Persist all non-cadence counters atomically, then increment the tick count. $DIGEST (the sha256:… string computed above) is not valid JSON, so session-state.sh --set stores it as a string literal. Cadence and task identity are committed together below; publishing the desired cadence here would make a failed TaskStop look like a successful tier crossing:
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].digest=$DIGEST" \
--set ".prs[\"$PR\"].digest_streak=$STREAK" \
--set ".prs[\"$PR\"].babysit.blocker_streak=$BLOCKER_STREAK" \
--set ".prs[\"$PR\"].babysit.conflict_streak=$CONFLICT_STREAK_NEW" \
--set ".prs[\"$PR\"].babysit.tick_count=$NEW_TICK_COUNT" \
--set ".prs[\"$PR\"].babysit.last_tick_at=$NOW"
Re-arm cadence only when it crosses a tier boundary. Before comparing cadences, read the exact
current babysit.monitor_task_id on every tick. A missing ID while active is degraded teardown: set
stop_requested=true, report it, and terminate without retaining or arming a task. Then compare the
computed $EFFECTIVE_MIN to the captured $PREV_EFFECTIVE_MIN; this comparison is mandatory on
every tick. When they are equal, retain the identified Monitor and cadence. When they differ, stop
the exact current Monitor. Before that stop, atomically set stop_requested=true; this makes every
already-emitted old-generation event exit at T0 throughout the stop-to-publication gap. In other
words, cadence changes must guard, then stop the exact current Monitor and complete this transaction:
-
Publish stop_requested=true, then stop the exact current Monitor task with TaskStop. A failed
old-task stop retains its complete ID+generation pair, $PREV_EFFECTIVE_MIN, and the stop guard
in state and aborts the re-arm as incomplete teardown.
-
Generate a fresh $NEW_MONITOR_GENERATION, then arm a replacement whose sleep and emitted
--cadence use $EFFECTIVE_MIN and whose event includes
--monitor-generation $NEW_MONITOR_GENERATION.
-
Publish the replacement ID, generation, cadence, and cleared stop guard in one atomic state write:
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.monitor_task_id=$NEW_MONITOR_TASK_ID" \
--set ".prs[\"$PR\"].babysit.monitor_generation=\"$NEW_MONITOR_GENERATION\"" \
--set ".prs[\"$PR\"].babysit.cadence_effective_minutes=$EFFECTIVE_MIN" \
--set ".prs[\"$PR\"].babysit.stop_requested=false"
If replacement arming fails, the old task is already stopped: atomically set active=false,
stop_requested=false, monitor_task_id=null, monitor_generation=null, and restore
cadence_effective_minutes=$PREV_EFFECTIVE_MIN, then report that the watcher terminated. If the
publication write fails, stop the exact replacement task. After a successful rollback stop, clear
the known-stopped old identity and set active=false, stop_requested=false; after a failed rollback stop, best-effort
publish stop_requested=true, active=true, and the exact $NEW_MONITOR_TASK_ID plus
$NEW_MONITOR_GENERATION so a later /babysit-pr-stop can retry the potentially-live task. Never
retain the already-stopped old identity as if it were the replacement, and never claim the cadence
changed until the ID+generation+cadence publication succeeds. A queued event from the stopped old
generation is rejected at T0 before it can classify, dispatch, terminate, or re-arm the replacement.
On an actual widen, record it so polling-backoff-warn.sh stops re-emitting its widen advisory (it dedupes on type == "update" at the new interval):
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].last_cron_action={\"type\":\"update\",\"interval\":\"${WIDE_MIN}m\",\"at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
T6. Termination check
Terminate (→ T-END) when any hold:
CLASS == merged → reason merged.
CLASS == hard-blocked → reason hard-blocked (record the specific blocker: human reviewer login(s), conflict, budget exhaustion, persistent P0).
CLASS == conflicting and AUTO_RESOLVE != "true" → reason hard-blocked, blocker merge-conflict (terminated from T4; see T-END for recommended command).
CLASS == conflicting and conflict_streak >= max_conflict_rounds → reason conflict-round-cap (churn cap hit; see T-END for round count and blocker report).
CLASS == conflicting and dispatch returned CONFLICTS (complex hunk) → reason hard-blocked, blocker merge-conflict-complex (see T-END for per-hunk report).
- PR
CLOSED unmerged → reason closed-unmerged.
blocker_streak >= max_blocker_ticks (default 6) → reason blocker-tick-cap (≈90 min once backed off to 15m).
digest_streak >= 9 → reason stable-frozen (scheduling-reliability.md ≥9 stop).
stop_requested == true / active != true → reason user-stop.
Otherwise the Monitor continues — its next stdout event re-enters tick mode.
T7. Heartbeat (per tick — never silent by default)
Always run a date for the timestamp (never estimate — monitor-mode.md):
TS=$(TZ='America/New_York' date +'%a %b %-d %I:%M %p ET')
One-liner format:
[<TS>] #<PR> tick <n>: state=<class>, action=<dispatch /fixpr | dispatch /wrap | no-op | waiting | dispatch-in-progress>, next in <effective-cadence>
Append context: blocker reason on hard-blocked/waiting-on-bots; dispatch target on a dispatch; (backoff: stable ×<streak>, widened to <WIDE_MIN>m) when cadence widened; the final summary on termination.
--silent: suppress the line on plain waiting-on-bots/no-change ticks, but always print on state change, any dispatch, backoff transitions, and termination. (Default — no --silent — prints every tick, satisfying the per-tick heartbeat AC.)
T-END. Terminate cleanly
Set the stop flag first so any already-emitted tick's T0 short-circuit exits cleanly. Do not
clear active until the recorded Monitor task has stopped; otherwise A2 can admit a duplicate
watcher while the old task is still emitting:
"$SESSION_STATE_SH" --set ".prs[\"$PR\"].babysit.stop_requested=true"
Cancelling the poll is a required terminal action — a persistent Monitor does not lapse on its own. Read babysit.monitor_task_id, stop that exact task with TaskStop, and do not re-arm it. If the ID is missing or TaskStop fails, keep both active=true and the ID for diagnosis, report degraded teardown, and do not claim completion; stop_requested=true still makes an already-emitted tick exit at T0. A2 treats that stop-requested active state as an incomplete teardown and refuses re-arm regardless of tick age, so the ordinary stale-watcher reclaim can never revive an old Monitor alongside a replacement.
One idempotent exception: when T0 observed active != true and the task ID is already null,
another teardown path (normally /babysit-pr-stop) completed the exact stop before this queued tick
ran. Treat that pair as already stopped, not degraded, and do not call TaskStop without an ID.
Record the stop. polling-backoff-warn.sh suppresses a repeated STOP advisory by reading .prs["<N>"].last_cron_action.type == "delete". The field name is historical — it records the poll-lifecycle action, whatever primitive backs the poll:
"$SESSION_STATE_SH" \
--set ".prs[\"$PR\"].babysit.active=false" \
--set ".prs[\"$PR\"].babysit.monitor_task_id=null" \
--set ".prs[\"$PR\"].babysit.monitor_generation=null" \
--set ".prs[\"$PR\"].last_cron_action={\"type\":\"delete\",\"interval\":\"paused\",\"at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
Run that atomic cleanup only after TaskStop succeeds. Clear dispatch_in_flight in the same
call only when it is null; if a user stop raced an existing /fixpr or /wrap, retain the marker
until that dispatch completes or T0's TTL reclaim owns it.
Belt-and-suspenders: even if cancellation is delayed, the T0 short-circuit (stop_requested /
active != true) makes every subsequent tick an immediate no-op terminate — but cancellation is
still mandatory so the runtime stops invoking the watcher.
If exact teardown failed, emit a babysit-pr teardown incomplete report with the retained task ID
and generation instead of the successful babysit-pr complete summary below.
Emit the final summary:
=== babysit-pr complete ===
PR: #<PR>
Pre-flight: <last tick's draft→ready + reviewers triggered, from $PREFLIGHT_SUMMARY_JSON; "clean" when no-op across ticks>
Final state: <class>
Reason: merged | hard-blocked | conflict-round-cap | closed-unmerged | blocker-tick-cap | stable-frozen | user-stop
Ticks: <tick_count>
Dispatches: <count> (/fixpr ×N, /wrap ×M)
Last dispatch: <skill> → <status>
Blocker: <named blocker when hard-blocked / blocker-tick-cap / conflict-round-cap, else "none">
Conflict rounds: <conflict_streak> of <max_conflict_rounds> (omit when conflict_streak == 0)
Conflict-specific terminal output:
-
conflict-round-cap (churn cap hit): after the standard summary, print:
Conflict rounds attempted: <conflict_streak> (cap=<max_conflict_rounds>)
Branch kept re-conflicting — resolve manually, then re-arm babysit.
-
hard-blocked with blocker merge-conflict (default behavior, flag not set): after the standard summary, print:
PR is CONFLICTING. Run /merge-conflict to classify hunks and resolve manually,
or re-arm with --auto-resolve-conflicts to enable unattended simple-hunk resolution.
-
hard-blocked with blocker merge-conflict-complex (auto-resolve mode, complex hunk found): after the standard summary, print the per-hunk report from the resolver verbatim — one entry per line:
Complex conflict hunks (require human resolution):
<file>: <location> — <reason>
<file>: <location> — <reason>
...
Rebase was aborted. Resolve the above hunks manually, then re-arm babysit.
The per-hunk entries come from complex_report stored in .babysit.last_dispatch.complex_report (set in T4 on CONFLICTS return). Parse via jq -r '.complex_report[] | " \(.file): \(.location) — \(.reason)"'.
Notes
- Stop anytime:
/babysit-pr-stop <PR> sets stop_requested=true; the next tick's T0 terminates cleanly. See .claude/skills/babysit-pr-stop/SKILL.md.
- One watcher per PR — arm mode refuses a duplicate (A2).
- Session-scoped by design. The watcher dies with the session. A watcher left
active by a session that ended is cleared at the next session start by session-scheduling-reconcile.sh, so /status never reports a watcher that is not running.
- One persistent Monitor per PR — arm mode refuses a duplicate and stores the task ID for stop/re-arm.
- Monitor mode: while a dispatch subagent is in flight, the parent follows
monitor-mode.md (orchestration only, ≤5-min heartbeat). The per-tick heartbeat satisfies the heartbeat requirement.
- Post-merge install: after this skill lands on
main, symlink it globally via the skills worktree per skill-symlinks.md.