@muggleai/works 5.20.0-staging.139 → 5.20.0-staging.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
4
- "version": "5.20.0-staging.139",
4
+ "version": "5.20.0-staging.141",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
5
- "version": "5.20.0-staging.139",
5
+ "version": "5.20.0-staging.141",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -114,7 +114,7 @@ echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behin
114
114
  # owner.json as "not foreign", so a slot armed without one leases itself to a
115
115
  # live PID nothing can classify: once the arming session dies its watcher polls
116
116
  # into a closed pipe, and every later arm skips the slot as already owned until
117
- # the lifetime cap expires. An unset id writes nothing rather than a placeholder
117
+ # the loop's monitor window ends it. An unset id writes nothing rather than a placeholder
118
118
  # — reconcile reads any id it does not recognise as another session's claim, and
119
119
  # a bogus one would strand the slot exactly as an absent file does.
120
120
  if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then
@@ -6,23 +6,33 @@
6
6
  #
7
7
  # A bare `while true` monitor leaks on Windows: the OS does not stop a detached
8
8
  # Git Bash loop when the Claude session that launched it ends, so orphaned
9
- # watchers accumulate and each keeps spawning gh calls forever. Two guards bound
9
+ # watchers accumulate and each keeps spawning gh calls forever. Three guards bound
10
10
  # that, and together with arm-watcher's pre-arm dedup keep at most one live
11
11
  # watcher per PR:
12
12
  #
13
13
  # watcher_superseded — <slot>/watch.pid holds the PID of the watcher that
14
14
  # owns the slot. A loop whose PID no longer matches has
15
15
  # been replaced by a newer arm and must exit.
16
+ # watcher_window_elapsed — a loop exits once it has run for its monitor
17
+ # window, before the Monitor that owns its stdout ends.
18
+ # It announces the exit so the owning session re-arms.
16
19
  # watcher_lifetime_exceeded — a loop exits after MUGGLE_PR_WATCH_MAX_LIFETIME
17
20
  # regardless, so an orphan nothing supersedes still dies
18
21
  # on its own; reconcile re-arms an open PR inside a live
19
22
  # session.
20
23
 
21
24
  # Seconds a watch loop may live. 0 means unbounded — the `never` setting of the
22
- # watcherLifetime preference, which removes the only time-based reaper for a
23
- # loop whose session has gone. `watcher_superseded` is then the sole guard.
25
+ # watcherLifetime preference. Under a Monitor the window below ends a loop long
26
+ # before this does; this bounds a loop run with the window disabled.
24
27
  MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-604800}"
25
28
  MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
29
+ # Seconds one loop process runs before handing the watch back for a fresh arm.
30
+ # The Monitor tool ends a background task after 1800s but does not stop the
31
+ # process it launched, so a loop that outlives its monitor polls into a closed
32
+ # pipe: every event it prints reaches nobody while its lease and heartbeat read
33
+ # healthy. 1740 closes the window one poll interval inside that cap. 0 means
34
+ # unbounded, for a loop run outside a Monitor.
35
+ MUGGLE_PR_WATCH_MONITOR_WINDOW="${MUGGLE_PR_WATCH_MONITOR_WINDOW:-1740}"
26
36
  # Consecutive failed fetches before a loop gives up. A watcher must ride through
27
37
  # a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
28
38
  # leave the PR unwatched until its owning session next starts. With the backoff, 60
@@ -68,6 +78,27 @@ watcher_lifetime_exceeded() {
68
78
  [ $((now - started)) -ge "$max" ]
69
79
  }
70
80
 
81
+ # True once a loop started at `started` has run for its monitor window.
82
+ watcher_window_elapsed() {
83
+ local started="$1" now="$2" window="${3:-$MUGGLE_PR_WATCH_MONITOR_WINDOW}"
84
+ [ "$window" -eq 0 ] 2>/dev/null && return 1
85
+ [ $((now - started)) -ge "$window" ]
86
+ }
87
+
88
+ # Seconds to sleep: `desired`, cut short so the loop wakes at the end of its
89
+ # window rather than sleeping past its monitor — a 5-minute fetch backoff taken
90
+ # near the end would otherwise outlast the pipe. Never below 1.
91
+ watcher_window_sleep() {
92
+ local started="$1" now="$2" desired="$3" window="${4:-$MUGGLE_PR_WATCH_MONITOR_WINDOW}" remaining
93
+ if [ "$window" -eq 0 ] 2>/dev/null; then
94
+ echo "$desired"
95
+ return
96
+ fi
97
+ remaining=$((started + window - now))
98
+ [ "$remaining" -lt 1 ] && remaining=1
99
+ if [ "$desired" -lt "$remaining" ]; then echo "$desired"; else echo "$remaining"; fi
100
+ }
101
+
71
102
  # True when the slot has gone unseeded longer than the cap allows. 0 is
72
103
  # unbounded, matching `watcher_lifetime_exceeded`, for a caller that seeds the
73
104
  # watermark out of band.
@@ -87,9 +87,23 @@ read_watermark_value() {
87
87
  printf '%s' "$value"
88
88
  }
89
89
 
90
+ # Every wait goes through here so that none, however long, can carry the loop
91
+ # past the end of its monitor window.
92
+ pause() {
93
+ sleep "$(watcher_window_sleep "$started" "$(date +%s)" "$1")"
94
+ }
95
+
90
96
  while :; do
91
97
  watcher_superseded "$slot" "$$" && exit 0
92
98
  watcher_lifetime_exceeded "$started" "$(date +%s)" && exit 0
99
+ # Exit while the Monitor that owns this stdout is still reading, and say so:
100
+ # the line is the owning session's cue to re-arm, and ending here is what
101
+ # frees the lease that re-arm needs. A loop that ran on past its monitor would
102
+ # keep that lease and heartbeat fresh while reporting to no one.
103
+ if watcher_window_elapsed "$started" "$(date +%s)"; then
104
+ echo "ROLLOVER pr=$pr_number monitor window of ${MUGGLE_PR_WATCH_MONITOR_WINDOW}s elapsed — re-arm to keep watching"
105
+ exit 0
106
+ fi
93
107
  touch "${slot}/watch-heartbeat" 2>/dev/null
94
108
 
95
109
  # No watermark yet means the arming session has not finished seeding. Wait
@@ -107,7 +121,7 @@ while :; do
107
121
  echo "WATCH-FAIL pr=$pr_number no watch-watermark.env after ${unseeded}s — arming never seeded it, so this watch can detect nothing (arm-watcher.md steps 1-2)"
108
122
  exit 1
109
123
  fi
110
- sleep "$MUGGLE_PR_WATCH_POLL_INTERVAL"
124
+ pause "$MUGGLE_PR_WATCH_POLL_INTERVAL"
111
125
  continue
112
126
  fi
113
127
  unseeded=0
@@ -131,7 +145,7 @@ while :; do
131
145
  echo "WATCH-FAIL pr=$pr_number ${fails} consecutive fetch failures — see watch-fetch.log"
132
146
  exit 1
133
147
  fi
134
- sleep "$(watcher_fetch_backoff "$fails")"
148
+ pause "$(watcher_fetch_backoff "$fails")"
135
149
  continue
136
150
  fi
137
151
 
@@ -158,7 +172,7 @@ while :; do
158
172
  echo "WATCH-FAIL pr=$pr_number unreadable state after ${fails} tries"
159
173
  exit 1
160
174
  fi
161
- sleep "$(watcher_fetch_backoff "$fails")"
175
+ pause "$(watcher_fetch_backoff "$fails")"
162
176
  continue
163
177
  fi
164
178
  fails=0
@@ -204,5 +218,5 @@ while :; do
204
218
  floor_blocked_digest="$ci_digest"
205
219
  fi
206
220
 
207
- sleep "$MUGGLE_PR_WATCH_POLL_INTERVAL"
221
+ pause "$MUGGLE_PR_WATCH_POLL_INTERVAL"
208
222
  done
@@ -18,7 +18,7 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
18
18
 
19
19
  **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.
20
20
 
21
- 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.
21
+ 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 still reads as owned, and nothing on disk distinguishes it — so the signal comes from the reader instead. **When this arm answers the slot's own `ROLLOVER` line or its monitor's expiry notice, skip this dedup and arm.** Either notice means the previous loop is gone or no longer heard: a rolled-over loop has already exited, and one that outlived an expired monitor is writing into a closed pipe. The new loop writes its PID to `watch.pid`, and `watcher_superseded` retires any survivor at its next iteration. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
22
22
 
23
23
  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.
24
24
 
@@ -28,23 +28,27 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
28
28
  bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
29
29
  ```
30
30
 
31
+ Give the monitor the tool's maximum timeout, 1800000 ms. The loop ends itself one poll inside that, at `MUGGLE_PR_WATCH_MONITOR_WINDOW` (1740s), because the tool stops reading a background task at its timeout without stopping the process — a loop left running past it would poll into a closed pipe.
32
+
31
33
  Pass `--no-exec` to seed a slot without starting a loop; it is otherwise the same sequence.
32
34
 
33
35
  **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.
34
36
 
35
- 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.
37
+ 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 at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one each time the loop rolls over. 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.
36
38
 
37
- 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`.
39
+ 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; `watcher_window_elapsed` ends it one poll inside its monitor's timeout, printing a `ROLLOVER` line so the owning session re-arms; and `watcher_lifetime_exceeded` retires an orphan nothing supersedes. Every wait, a fetch backoff included, is clamped to the window, so no sleep carries the loop past its monitor. 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`.
38
40
 
39
- 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.
41
+ 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. Under a monitor the window ends each loop long before either cap, so it is the window, not this value, that retires a loop once its session is gone — within about 29 minutes. The lifetime bounds a loop run with the window disabled (`MUGGLE_PR_WATCH_MONITOR_WINDOW=0`).
40
42
 
41
- 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.
43
+ So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches, when a newer arm supersedes it, when its monitor window elapses, or once it passes the lifetime cap — never `while true` unbounded.
42
44
  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.
43
45
 
46
+ A `ROLLOVER` line is not an event to tick on: re-run [`pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) for the slot as a new monitor, without Step 3's dedup. Its `DRAIN` lines cover anything that landed between the old loop's last poll and the new one's first. Answer a monitor expiry notice for the slot the same way — it should not occur, since the window closes first, but if it does the loop is unheard and must be replaced.
47
+
44
48
  **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.
45
49
 
46
50
  **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.
47
51
 
48
- 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.
52
+ 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. The one exception is answering the slot's own `ROLLOVER` or monitor-expiry notice (Step 3).
49
53
 
50
54
  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.
@@ -12,9 +12,11 @@ How long a `muggle-pr-followup` watch loop polls before retiring itself.
12
12
 
13
13
  ## Why a bound exists
14
14
 
15
- A watch loop is a detached process. On Windows it survives the session that launched it, so an abandoned loop keeps polling the provider indefinitely. This cap is the only **time-based** reaper for such a loop — `watcher_superseded` retires one only when a *newer* arm claims the same slot, which never happens if nothing re-arms.
15
+ A watch loop is a detached process. On Windows it survives the session that launched it, so an abandoned loop keeps polling the provider indefinitely. `watcher_superseded` retires one only when a *newer* arm claims the same slot, which never happens if nothing re-arms, so a time-based bound is needed.
16
16
 
17
- **`never` removes that reaper.** It is a legitimate choice, since it ends the re-arm cycle entirely, but an orphaned loop then polls until the machine restarts or someone kills it.
17
+ Under a monitor that bound is `MUGGLE_PR_WATCH_MONITOR_WINDOW`, not this value. Arming runs the loop under a monitor, and the loop ends itself after the window (1740s) so it never outlives the monitor reading its output; an abandoned loop therefore retires within about 29 minutes, well before `1d` or `7d`. This lifetime bounds a loop run with the window disabled.
18
+
19
+ **`never` removes this bound.** Under a monitor the window still retires the loop; a loop run outside one then polls until the machine restarts or someone kills it.
18
20
 
19
21
  ## Applying it
20
22
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.18.0",
3
- "buildId": "run-139-1",
4
- "commitSha": "319a4296a38bb2bad8dfb77cb45d5884fc5434ac",
5
- "buildTime": "2026-09-30T20:06:26Z",
3
+ "buildId": "run-141-1",
4
+ "commitSha": "eafb39ac0abc2bf4f3158a0d0321f034f5d65d72",
5
+ "buildTime": "2026-10-01T04:42:24Z",
6
6
  "serviceName": "muggle-ai-works-mcp"
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muggleai/works",
3
3
  "mcpName": "io.github.multiplex-ai/muggle",
4
- "version": "5.20.0-staging.139",
4
+ "version": "5.20.0-staging.141",
5
5
  "description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
4
- "version": "5.20.0-staging.139",
4
+ "version": "5.20.0-staging.141",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
5
- "version": "5.20.0-staging.139",
5
+ "version": "5.20.0-staging.141",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -114,7 +114,7 @@ echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behin
114
114
  # owner.json as "not foreign", so a slot armed without one leases itself to a
115
115
  # live PID nothing can classify: once the arming session dies its watcher polls
116
116
  # into a closed pipe, and every later arm skips the slot as already owned until
117
- # the lifetime cap expires. An unset id writes nothing rather than a placeholder
117
+ # the loop's monitor window ends it. An unset id writes nothing rather than a placeholder
118
118
  # — reconcile reads any id it does not recognise as another session's claim, and
119
119
  # a bogus one would strand the slot exactly as an absent file does.
120
120
  if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then
@@ -6,23 +6,33 @@
6
6
  #
7
7
  # A bare `while true` monitor leaks on Windows: the OS does not stop a detached
8
8
  # Git Bash loop when the Claude session that launched it ends, so orphaned
9
- # watchers accumulate and each keeps spawning gh calls forever. Two guards bound
9
+ # watchers accumulate and each keeps spawning gh calls forever. Three guards bound
10
10
  # that, and together with arm-watcher's pre-arm dedup keep at most one live
11
11
  # watcher per PR:
12
12
  #
13
13
  # watcher_superseded — <slot>/watch.pid holds the PID of the watcher that
14
14
  # owns the slot. A loop whose PID no longer matches has
15
15
  # been replaced by a newer arm and must exit.
16
+ # watcher_window_elapsed — a loop exits once it has run for its monitor
17
+ # window, before the Monitor that owns its stdout ends.
18
+ # It announces the exit so the owning session re-arms.
16
19
  # watcher_lifetime_exceeded — a loop exits after MUGGLE_PR_WATCH_MAX_LIFETIME
17
20
  # regardless, so an orphan nothing supersedes still dies
18
21
  # on its own; reconcile re-arms an open PR inside a live
19
22
  # session.
20
23
 
21
24
  # Seconds a watch loop may live. 0 means unbounded — the `never` setting of the
22
- # watcherLifetime preference, which removes the only time-based reaper for a
23
- # loop whose session has gone. `watcher_superseded` is then the sole guard.
25
+ # watcherLifetime preference. Under a Monitor the window below ends a loop long
26
+ # before this does; this bounds a loop run with the window disabled.
24
27
  MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-604800}"
25
28
  MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
29
+ # Seconds one loop process runs before handing the watch back for a fresh arm.
30
+ # The Monitor tool ends a background task after 1800s but does not stop the
31
+ # process it launched, so a loop that outlives its monitor polls into a closed
32
+ # pipe: every event it prints reaches nobody while its lease and heartbeat read
33
+ # healthy. 1740 closes the window one poll interval inside that cap. 0 means
34
+ # unbounded, for a loop run outside a Monitor.
35
+ MUGGLE_PR_WATCH_MONITOR_WINDOW="${MUGGLE_PR_WATCH_MONITOR_WINDOW:-1740}"
26
36
  # Consecutive failed fetches before a loop gives up. A watcher must ride through
27
37
  # a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
28
38
  # leave the PR unwatched until its owning session next starts. With the backoff, 60
@@ -68,6 +78,27 @@ watcher_lifetime_exceeded() {
68
78
  [ $((now - started)) -ge "$max" ]
69
79
  }
70
80
 
81
+ # True once a loop started at `started` has run for its monitor window.
82
+ watcher_window_elapsed() {
83
+ local started="$1" now="$2" window="${3:-$MUGGLE_PR_WATCH_MONITOR_WINDOW}"
84
+ [ "$window" -eq 0 ] 2>/dev/null && return 1
85
+ [ $((now - started)) -ge "$window" ]
86
+ }
87
+
88
+ # Seconds to sleep: `desired`, cut short so the loop wakes at the end of its
89
+ # window rather than sleeping past its monitor — a 5-minute fetch backoff taken
90
+ # near the end would otherwise outlast the pipe. Never below 1.
91
+ watcher_window_sleep() {
92
+ local started="$1" now="$2" desired="$3" window="${4:-$MUGGLE_PR_WATCH_MONITOR_WINDOW}" remaining
93
+ if [ "$window" -eq 0 ] 2>/dev/null; then
94
+ echo "$desired"
95
+ return
96
+ fi
97
+ remaining=$((started + window - now))
98
+ [ "$remaining" -lt 1 ] && remaining=1
99
+ if [ "$desired" -lt "$remaining" ]; then echo "$desired"; else echo "$remaining"; fi
100
+ }
101
+
71
102
  # True when the slot has gone unseeded longer than the cap allows. 0 is
72
103
  # unbounded, matching `watcher_lifetime_exceeded`, for a caller that seeds the
73
104
  # watermark out of band.
@@ -87,9 +87,23 @@ read_watermark_value() {
87
87
  printf '%s' "$value"
88
88
  }
89
89
 
90
+ # Every wait goes through here so that none, however long, can carry the loop
91
+ # past the end of its monitor window.
92
+ pause() {
93
+ sleep "$(watcher_window_sleep "$started" "$(date +%s)" "$1")"
94
+ }
95
+
90
96
  while :; do
91
97
  watcher_superseded "$slot" "$$" && exit 0
92
98
  watcher_lifetime_exceeded "$started" "$(date +%s)" && exit 0
99
+ # Exit while the Monitor that owns this stdout is still reading, and say so:
100
+ # the line is the owning session's cue to re-arm, and ending here is what
101
+ # frees the lease that re-arm needs. A loop that ran on past its monitor would
102
+ # keep that lease and heartbeat fresh while reporting to no one.
103
+ if watcher_window_elapsed "$started" "$(date +%s)"; then
104
+ echo "ROLLOVER pr=$pr_number monitor window of ${MUGGLE_PR_WATCH_MONITOR_WINDOW}s elapsed — re-arm to keep watching"
105
+ exit 0
106
+ fi
93
107
  touch "${slot}/watch-heartbeat" 2>/dev/null
94
108
 
95
109
  # No watermark yet means the arming session has not finished seeding. Wait
@@ -107,7 +121,7 @@ while :; do
107
121
  echo "WATCH-FAIL pr=$pr_number no watch-watermark.env after ${unseeded}s — arming never seeded it, so this watch can detect nothing (arm-watcher.md steps 1-2)"
108
122
  exit 1
109
123
  fi
110
- sleep "$MUGGLE_PR_WATCH_POLL_INTERVAL"
124
+ pause "$MUGGLE_PR_WATCH_POLL_INTERVAL"
111
125
  continue
112
126
  fi
113
127
  unseeded=0
@@ -131,7 +145,7 @@ while :; do
131
145
  echo "WATCH-FAIL pr=$pr_number ${fails} consecutive fetch failures — see watch-fetch.log"
132
146
  exit 1
133
147
  fi
134
- sleep "$(watcher_fetch_backoff "$fails")"
148
+ pause "$(watcher_fetch_backoff "$fails")"
135
149
  continue
136
150
  fi
137
151
 
@@ -158,7 +172,7 @@ while :; do
158
172
  echo "WATCH-FAIL pr=$pr_number unreadable state after ${fails} tries"
159
173
  exit 1
160
174
  fi
161
- sleep "$(watcher_fetch_backoff "$fails")"
175
+ pause "$(watcher_fetch_backoff "$fails")"
162
176
  continue
163
177
  fi
164
178
  fails=0
@@ -204,5 +218,5 @@ while :; do
204
218
  floor_blocked_digest="$ci_digest"
205
219
  fi
206
220
 
207
- sleep "$MUGGLE_PR_WATCH_POLL_INTERVAL"
221
+ pause "$MUGGLE_PR_WATCH_POLL_INTERVAL"
208
222
  done
@@ -18,7 +18,7 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
18
18
 
19
19
  **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.
20
20
 
21
- 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.
21
+ 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 still reads as owned, and nothing on disk distinguishes it — so the signal comes from the reader instead. **When this arm answers the slot's own `ROLLOVER` line or its monitor's expiry notice, skip this dedup and arm.** Either notice means the previous loop is gone or no longer heard: a rolled-over loop has already exited, and one that outlived an expired monitor is writing into a closed pipe. The new loop writes its PID to `watch.pid`, and `watcher_superseded` retires any survivor at its next iteration. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
22
22
 
23
23
  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.
24
24
 
@@ -28,23 +28,27 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
28
28
  bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
29
29
  ```
30
30
 
31
+ Give the monitor the tool's maximum timeout, 1800000 ms. The loop ends itself one poll inside that, at `MUGGLE_PR_WATCH_MONITOR_WINDOW` (1740s), because the tool stops reading a background task at its timeout without stopping the process — a loop left running past it would poll into a closed pipe.
32
+
31
33
  Pass `--no-exec` to seed a slot without starting a loop; it is otherwise the same sequence.
32
34
 
33
35
  **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.
34
36
 
35
- 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.
37
+ 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 at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one each time the loop rolls over. 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.
36
38
 
37
- 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`.
39
+ 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; `watcher_window_elapsed` ends it one poll inside its monitor's timeout, printing a `ROLLOVER` line so the owning session re-arms; and `watcher_lifetime_exceeded` retires an orphan nothing supersedes. Every wait, a fetch backoff included, is clamped to the window, so no sleep carries the loop past its monitor. 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`.
38
40
 
39
- 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.
41
+ 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. Under a monitor the window ends each loop long before either cap, so it is the window, not this value, that retires a loop once its session is gone — within about 29 minutes. The lifetime bounds a loop run with the window disabled (`MUGGLE_PR_WATCH_MONITOR_WINDOW=0`).
40
42
 
41
- 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.
43
+ So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches, when a newer arm supersedes it, when its monitor window elapses, or once it passes the lifetime cap — never `while true` unbounded.
42
44
  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.
43
45
 
46
+ A `ROLLOVER` line is not an event to tick on: re-run [`pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) for the slot as a new monitor, without Step 3's dedup. Its `DRAIN` lines cover anything that landed between the old loop's last poll and the new one's first. Answer a monitor expiry notice for the slot the same way — it should not occur, since the window closes first, but if it does the loop is unheard and must be replaced.
47
+
44
48
  **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.
45
49
 
46
50
  **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.
47
51
 
48
- 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.
52
+ 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. The one exception is answering the slot's own `ROLLOVER` or monitor-expiry notice (Step 3).
49
53
 
50
54
  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.
@@ -12,9 +12,11 @@ How long a `muggle-pr-followup` watch loop polls before retiring itself.
12
12
 
13
13
  ## Why a bound exists
14
14
 
15
- A watch loop is a detached process. On Windows it survives the session that launched it, so an abandoned loop keeps polling the provider indefinitely. This cap is the only **time-based** reaper for such a loop — `watcher_superseded` retires one only when a *newer* arm claims the same slot, which never happens if nothing re-arms.
15
+ A watch loop is a detached process. On Windows it survives the session that launched it, so an abandoned loop keeps polling the provider indefinitely. `watcher_superseded` retires one only when a *newer* arm claims the same slot, which never happens if nothing re-arms, so a time-based bound is needed.
16
16
 
17
- **`never` removes that reaper.** It is a legitimate choice, since it ends the re-arm cycle entirely, but an orphaned loop then polls until the machine restarts or someone kills it.
17
+ Under a monitor that bound is `MUGGLE_PR_WATCH_MONITOR_WINDOW`, not this value. Arming runs the loop under a monitor, and the loop ends itself after the window (1740s) so it never outlives the monitor reading its output; an abandoned loop therefore retires within about 29 minutes, well before `1d` or `7d`. This lifetime bounds a loop run with the window disabled.
18
+
19
+ **`never` removes this bound.** Under a monitor the window still retires the loop; a loop run outside one then polls until the machine restarts or someone kills it.
18
20
 
19
21
  ## Applying it
20
22