# Arming the Watch

How an orchestrating session starts the watch on one PR. Every arming point runs this same sequence: [`bootstrap.md`](bootstrap.md) Step 8, [`auto-track.md`](auto-track.md) Step 6, and the executor's post-cycle settle.

**Run [`../../scripts/pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh); do not perform the steps by hand.** It performs the drain of [`contract.md`](contract.md) — reading the PR once and printing what is already outstanding as `DRAIN` lines — then seeds the watermark from that same read and starts the loop:

```sh
bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
```

The same argument that forbids authoring a per-slot `watch.sh` applies to arming itself. Performed from prose, the middle step — writing `watch-watermark.env` — is the one that gets dropped, and dropping it is silent: the loop gates every wake condition on that file, so it polls nothing, reports nothing and never reaches its terminal check, while still holding its PID lease and touching its heartbeat. Eleven such watchers once ran for hours across one session, missed nine merges and a red CI run between them, and every health signal said they were fine. Running one command cannot half-happen.

Read the `DRAIN` lines it prints: they are the outstanding work the monitor will *not* tell you about, because the floors it just wrote mark that state as already seen. Steps 1-3 below are what the script does, and why.

1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads (`gitlab`: discussions), body-only reviews past the watermark (GitHub-only — GitLab has no review envelope), a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant.
3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.

   **A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed monitor pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.

   This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session's monitor dying still reads as owned, and nothing on disk distinguishes it — that needs a signal from the reader, which no slot file carries. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.

   Otherwise **claim the slot for this session** before starting anything: write `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)) with `session_id` from `$CLAUDE_CODE_SESSION_ID` and `claimed_at` now. Arming is what establishes ownership, so every arming point records it here rather than each caller remembering to. If `$CLAUDE_CODE_SESSION_ID` is unset, write no `owner.json` — an unidentifiable owner is worse than none, since [`reconcile.md`](reconcile.md) would read a bogus id as some other session's claim and could never recover the slot.

   Then start the watch as a **persistent background monitor** in the orchestrating session, with the script path resolved to an absolute path at arm time (from `${CLAUDE_PLUGIN_ROOT}/scripts/`) so it still resolves after the arming session is gone. `pr-watch-arm.sh` performs Steps 1-2 and then execs the loop, so the monitor's command is the arm script:

   ```sh
   bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
   ```

   Pass `--no-exec` to seed a slot without starting a loop; it is otherwise the same sequence.

   **Never author a per-slot `watch.sh`.** The loop ships as [`../../scripts/pr-watch-loop.sh`](../../scripts/pr-watch-loop.sh), with its wake conditions in [`../../scripts/pr-watch-events.sh`](../../scripts/pr-watch-events.sh) and its state projection in [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq); arming runs it and passes arguments. Writing the loop from this prose was how it drifted — each arm produced an independent derivation, and a derivation that quietly dropped a wake still ran, still heartbeat, still logged, and simply never fired for the signal it lost. Two slots on one machine ended up without the behind-base wake, which left their PRs unmergeable under watchers that looked healthy. The prose below says *why* each wake exists; the shipped files are the only definition of *what* fires. A slot holding a legacy generated `watch.sh` keeps it until re-armed, at which point the supersede guard retires the old loop.

   The label is `PR #<n> — <title>`. Label and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR, alive from arm to terminal: it is the watch's visible handle, showing as a running task the entire time the PR is polled. Its loop checks about every 60 seconds, re-reading the watermark and touching the slot's `watch-heartbeat` file each iteration — the liveness beacon that tells [`reconcile.md`](reconcile.md) a quiet watch is still alive; on a newer submitted review, a newer thread comment (`gitlab`: a newer discussion note), a thread newly unresolved (`gitlab`: discussion), **the head SHA's checks settling red** (no check pending and one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)), **the branch falling behind or conflicting with its base** (`behind_by > 0` or the conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md)), or — **only while the watch is blocked** (`BLOCKED_CIDIGEST` non-empty) — **the head's CI digest changing in any way** (not just to red) — it prints one line and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. The review and thread floors are monotonic ids; the other three are not. The CI-red floor is the **head SHA**, because the check rollup is non-monotonic — it flips green↔red and resets on every push — so recording the red head SHA fires CI once per red head, and a later push re-arms it on the new SHA. The rebase floor (`REBASED`) is the **`rebase_key`** — `<head_sha>..<base_tip_sha>` — because staleness is a function of both sides: keying on the pair fires once per newly-due pair and re-arms when either the head or the base moves, where a head-only key would wedge permanently the first time the base advances (the head cannot change while nobody pushes). A head whose checks are still **pending** is never a red wake, and a branch with `behind_by == 0` and `mergeable == UNKNOWN` is never a rebase wake: pending checks may yet go green and conflict state is still computing, and the tick would idle on either (Steps 5–6) regardless. The blocked-CI signal is different in kind — a **resume** probe, live only while the watch is blocked: it wakes on any move of the head's CI digest (the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint records — [`blocked-tick.md`](blocked-tick.md)) away from `BLOCKED_CIDIGEST`, so a block waiting on a green pass, a rerun, or an external deploy check resumes as promptly as one waiting on red. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.

   The loop is **self-terminating**, so it never becomes one of those orphans. It claims the slot's `watch.pid`, then checks the guards from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh) at the top of every iteration before any `gh` call: `watcher_superseded` steps it down when a newer arm takes the slot, and `watcher_lifetime_exceeded` retires an orphan nothing supersedes. A failed fetch is never mistaken for a terminal PR — it counts toward `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` (60) with `watcher_fetch_backoff` between tries, so hours of outage are ridden out and the error lands in `<slot>/watch-fetch.log`. Terminal exit stays reserved for an observed `MERGED`/`CLOSED`.

   The lifetime cap comes from the [`watcherLifetime`](../muggle-preferences/preference-gates/watcherLifetime.md) preference — `7d` by default, `1d`, or `never` for unbounded. The loop cannot read preferences, so resolve the value at arm time, convert it to seconds, and export `MUGGLE_PR_WATCH_MAX_LIFETIME` into the monitor's environment; an already-set value wins and is never overwritten. `never` exports 0, which the guard reads as unbounded — leaving `watcher_superseded` as the only thing that can retire an orphan.

   So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches, when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
4. **On event.** Polling never enters the session: the cadence lives entirely in the detached monitor loop (step 3), and the only thing the session ever receives is the monitor's one event line. That line wakes the session: the wake turn runs the tick (step 1) with `--wake=<event>` — the flag that tells routing's live-watcher gate this poll was prompted, not idle curiosity ([`SKILL.md`](SKILL.md#routing)) — and whatever cycle the tick produces runs **inline in the owning session** — never in a subagent. The session's full history is part of the cycle's context (decisions, review nuance, owner phrasing); a subagent only knows its briefing plus disk/provider state, and anything the briefing omits is silently missing from the cycle. The token cost of inline cycles is accepted — context beats cost. The tick still derives everything from live provider state; a terminal PR finalizes there while the monitor exits on its own. Persistent fetch failure → surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot at the next start of the session that owns it.

**After a cycle** — advance the watermark to the **handled wave's snapshot**, in the same dispatched context that ran the cycle. Capture the max review-id and comment-id **at the start of handling** — the ids the cycle actually read when it derived the wave, before any reply is posted — and advance the watermark to exactly those. **Never** re-derive it from a live-max fetched after the replies land: a reviewer comment that arrives during the handling window — between reading the wave and posting the replies — lands below that post-reply max and is marked seen without ever being read. Snapshotting at the start-of-handling read keeps such a comment above the watermark, so the next tick surfaces it. That advance is also what stops the loop from reporting its own reply; a cycle that skips it leaves the watermark stale, and the next event is an echo.

**Scan before advancing — a hard invariant.** Before writing the advanced watermark, confirm every unresolved review thread in the handled wave has been addressed: a reply posted, or an explicit deferral recorded. If any unresolved thread is still unaddressed, do **not** advance past it — leave its newest-comment id above the `COM` floor and its thread id out of the known-unresolved `THREADS` set, so the monitor's next iteration wakes on it and the next tick re-surfaces it. The watermark only ever moves over threads that were actually handled.

Arm a fresh monitor only when none is alive — judged by the `<slot>/watch.pid` lease (a live PID means a watcher already owns the slot), not by the in-session task list, since an orphaned OS loop outlives the session that launched it; the settle path never duplicates a running watch.

Drain-then-watch is the invariant: the tick handles everything up to the watermark with the real semantics — marker rule, watermark, attempt budgets — that the watch deliberately lacks, so the monitor only needs to see what arrives after it.
