@muggleai/works 5.27.0-staging.246 → 5.27.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/{chunk-PT5EGMBA.js → chunk-B5BWBZZ5.js} +2 -5
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.codex-mcp.json +1 -1
- package/dist/plugin/.codex-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/.mcp.json +1 -1
- package/dist/plugin/hooks/README.md +2 -2
- package/dist/plugin/scripts/pr-watch-arm.sh +27 -10
- package/dist/plugin/scripts/pr-watch-floors.sh +62 -0
- package/dist/plugin/scripts/pr-watch-guards.sh +11 -11
- package/dist/plugin/scripts/pr-watch-loop.sh +63 -50
- package/dist/plugin/scripts/reconcile-stale-watchers.sh +7 -30
- package/dist/plugin/skills/_shared/host-tool-equivalents.md +1 -1
- package/dist/plugin/skills/do/next-step.md +1 -1
- package/dist/plugin/skills/do/open-prs/forward.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
- package/dist/plugin/skills/muggle-pr-followup/adopt.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
- package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
- package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/contract.md +9 -7
- package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
- package/dist/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
- package/dist/plugin/skills/muggle-pr-followup/stop.md +3 -3
- package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
- package/dist/release-manifest.json +3 -3
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-mcp.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/hooks/README.md +2 -2
- package/plugin/scripts/pr-watch-arm.sh +27 -10
- package/plugin/scripts/pr-watch-floors.sh +62 -0
- package/plugin/scripts/pr-watch-guards.sh +11 -11
- package/plugin/scripts/pr-watch-loop.sh +63 -50
- package/plugin/scripts/reconcile-stale-watchers.sh +7 -30
- package/plugin/skills/_shared/host-tool-equivalents.md +1 -1
- package/plugin/skills/do/next-step.md +1 -1
- package/plugin/skills/do/open-prs/forward.md +1 -1
- package/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
- package/plugin/skills/muggle-pr-followup/adopt.md +2 -2
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
- package/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
- package/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
- package/plugin/skills/muggle-pr-followup/contract.md +9 -7
- package/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
- package/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
- package/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
- package/plugin/skills/muggle-pr-followup/stop.md +3 -3
- package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
|
@@ -10,49 +10,54 @@ bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --p
|
|
|
10
10
|
|
|
11
11
|
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.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The `DRAIN` lines it prints, in the watch's output, are the outstanding work as of the arm. Review ids and threads the drain read are floored, so the loop will *not* tell you about those again. A stale branch or a red head is floored only when `last_seen.json` records a rebase or fix-ci cycle already attempted for that key or head ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)); otherwise the drain line says `not floored: dispatch …` and the loop's first poll reports it as an event. A PR that spent its catch-up rebase budget is the exception: its drain line says the rebase is `held for the owner`, and nothing is dispatched — the tick would refuse it — until the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md). Re-arming is never a way to set work aside — it neither handles nor silences anything. Steps 1-3 below are what the script does, and why.
|
|
14
14
|
|
|
15
15
|
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.
|
|
16
|
-
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
|
|
17
|
-
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
|
|
16
|
+
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 loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA only 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)) **and** `last_seen.json` records a fix-ci attempt for that head — a cycle already ran for that red, so its escalation or retry is not news — and empty otherwise. 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): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). 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. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
|
|
17
|
+
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 task 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.
|
|
18
18
|
|
|
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
|
|
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 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
|
|
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 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 loop ending — its `EXPIRED` or `ROLLOVER` line, or a monitor's expiry notice — skip this dedup and arm.** Each means the previous loop is gone or no longer heard. 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
|
|
|
25
25
|
The same claim **registers the slot with the muggle watch service**: `pr-watch-arm.sh` writes [`watch-registration.json`](state-schemas.md#watch-registrationjson) beside `owner.json`. The service is a per-user scheduled job that runs outside every session. When a registered slot's loop is gone while its owner session is down, it resumes that owner in the background (`claude --resume <owner> --bg`), and the owner's own reconcile re-arms the watch. A slot armed any other way is registered with `muggle watch register --slot <slot>`. `muggle watch unregister --slot <slot>` keeps a slot session-scoped. The service still reports on it, but never resumes its owner.
|
|
26
26
|
|
|
27
|
-
Then start the watch as a **
|
|
27
|
+
Then start the watch as a **background shell command** in the orchestrating session — the `Bash` tool with `run_in_background: true`, never `Monitor` — 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 command is the arm script:
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
30
|
bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
**A quiet PR costs the session nothing.** A background command has no time cap and wakes the session only when it exits, and the loop exits only when there is something to hear: the first poll that finds work, the PR going terminal, persistent fetch failure, or the lifetime cap. A `Monitor` is the wrong tool: it stops reading after 30 minutes, so a loop under it must hand the watch back for a fresh arm every half hour, and every hand-back and every line it prints is a model turn re-reading the whole session — about two per PR per half hour, whether or not anything changed.
|
|
34
34
|
|
|
35
35
|
Pass `--no-exec` to seed a slot without starting a loop; it is otherwise the same sequence.
|
|
36
36
|
|
|
37
37
|
**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.
|
|
38
38
|
|
|
39
|
-
The
|
|
39
|
+
The task's description is `PR #<n> — <title>`. Description 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 background task 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 after each report. 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 per finding and **exits**, recording what it reported in `watch-reported.env` ([`state-schemas.md`](state-schemas.md#watch-reportedenv)) so no later loop reports the same occurrence again. 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.
|
|
40
40
|
|
|
41
41
|
Each poll also rewrites the slot's `watch-due.env` whenever the due work changes — the rebase key found due and the head whose checks settled red, or an empty file when neither — so the session's turn-end gate can see work the watch reported without a provider call ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
|
|
42
42
|
|
|
43
|
-
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
|
|
43
|
+
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 silently when a newer arm takes the slot, and `watcher_lifetime_exceeded` retires it with an `EXPIRED` line so a still-open PR's owner re-arms it. `watcher_window_elapsed` acts only on a loop given a `MUGGLE_PR_WATCH_MONITOR_WINDOW`, for running under a `Monitor`: it ends the loop with a `ROLLOVER` line before the monitor stops reading, and every wait is clamped to that window. 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`.
|
|
44
44
|
|
|
45
|
-
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
|
|
45
|
+
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 command's environment; an already-set value wins and is never overwritten. `never` exports 0, which the guard reads as unbounded. A quiet PR's loop runs until this cap, so it also sets how often such a PR costs its owner a turn: once per lifetime, to re-arm. It is also what retires an orphan — a loop that outlived its session, which on Windows a detached Git Bash loop can — that nothing supersedes.
|
|
46
46
|
|
|
47
|
-
So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches, when a newer arm supersedes it,
|
|
48
|
-
4. **On
|
|
47
|
+
So it exits on its first report, 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.
|
|
48
|
+
4. **On exit.** Polling never enters the session: the cadence lives entirely in the detached loop (step 3), and the session hears it only when the loop exits. Read the tail of the task's output — its last lines say why it ended:
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
- `EVENT` lines — run the tick (step 1) with `--wake=<event>`. A tick that dispatches a cycle leaves re-arming to the cycle's settle; a tick that idles re-arms per [`contract.md`](contract.md) Step 7.5.
|
|
51
|
+
- `TERMINAL` — run the tick, which finalizes the slot.
|
|
52
|
+
- `WATCH-FAIL` — surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot at the next start of the session that owns it.
|
|
53
|
+
- `EXPIRED` (or `ROLLOVER`, from a loop given a monitor window) — not an event to tick on: re-arm per Step 3, without its dedup. The new arm's `DRAIN` read covers anything that landed between the old loop's last poll and the new one's first.
|
|
54
|
+
- No line — a newer arm superseded the loop, and that arm's loop owns the slot. Nothing to do.
|
|
51
55
|
|
|
56
|
+
The wake turn runs the tick 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 loop exits on its own.
|
|
52
57
|
**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.
|
|
53
58
|
|
|
54
|
-
**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
|
|
59
|
+
**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 loop's next iteration wakes on it and the next tick re-surfaces it. The watermark only ever moves over threads that were actually handled.
|
|
55
60
|
|
|
56
|
-
Arm a fresh
|
|
61
|
+
Arm a fresh loop 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 loop ending (Step 3).
|
|
57
62
|
|
|
58
|
-
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
|
|
63
|
+
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 loop only needs to see what arrives after it.
|
|
@@ -4,7 +4,7 @@ The watcher's conditional path for a PR **blocked pending a human** — a durabl
|
|
|
4
4
|
|
|
5
5
|
A block is entered only on a `block-*` verdict from the Step 6 verdict script. An escalation entry no cycle attempted is not a block — the verdict dispatches the work instead — and neither is the cost of running it.
|
|
6
6
|
|
|
7
|
-
**Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the
|
|
7
|
+
**Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the loop stays visible, and ticks still run at their normal cadence (historically `1m`) whenever a wake or recovery cron fires — with fingerprint-based auto-resume clearing the block the moment external state moves. No repeat nagging, no cadence swap, no separate `reminded` flag: `blocked` present means the reminder has already been sent.
|
|
8
8
|
|
|
9
9
|
## The fingerprint
|
|
10
10
|
|
|
@@ -21,7 +21,7 @@ Any component moving means the block may have cleared — a new push (`head_sha`
|
|
|
21
21
|
When an idle tick is a durable human-block and `last_seen.blocked` is not already set:
|
|
22
22
|
|
|
23
23
|
1. Increment `last_seen.idle_tick_count`.
|
|
24
|
-
2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick). Mirror the fingerprint's `ci_digest` into the watch-watermark's `BLOCKED_CIDIGEST` ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) — a whole-file rewrite of `watch-watermark.env` that leaves the other floors untouched. That non-empty value is what arms the
|
|
24
|
+
2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick). Mirror the fingerprint's `ci_digest` into the watch-watermark's `BLOCKED_CIDIGEST` ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) — a whole-file rewrite of `watch-watermark.env` that leaves the other floors untouched. That non-empty value is what arms the loop's blocked-resume probe, so a block resumes on any CI move ([`arm-watcher.md`](arm-watcher.md)), not just red — without it the probe stays dormant.
|
|
25
25
|
3. **Remind the owner** — emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md): the pending act plus a reference back to the decision context. This is the block's **only** reminder.
|
|
26
26
|
4. Append a `blocked reason=<reason>` line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md); emit a `tick` event with `idle: true`, `blocked: true`, and the same other fields as a transient idle. Exit.
|
|
27
27
|
|
|
@@ -32,7 +32,7 @@ Every subsequent tick while `last_seen.blocked` is present: recompute the finger
|
|
|
32
32
|
- **Unchanged** → still blocked. Stay **silent** — the reminder went out when the block was flagged. Increment `last_seen.idle_tick_count`, append a `blocked reason=<reason>` line to `followup.log`, emit a `tick` event with `idle: true`, `blocked: true`. Exit.
|
|
33
33
|
`reason: "rebase_budget_exhausted"` is the one exception to the gate: treat it as **Unchanged** whatever the fingerprint says. The budget was spent by the base advancing, so resuming on a fingerprint move would hand the PR straight back into the unbounded rebase loop the cap exists to end. It clears only when the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md) or the PR goes terminal.
|
|
34
34
|
|
|
35
|
-
- **Changed** → clear `last_seen.blocked`, clear the watch-watermark's `BLOCKED_CIDIGEST` to empty (whole-file rewrite — disarms the
|
|
35
|
+
- **Changed** → clear `last_seen.blocked`, clear the watch-watermark's `BLOCKED_CIDIGEST` to empty (whole-file rewrite — disarms the loop's blocked-resume probe now that the watch is unblocked), and **fall through to [`contract.md`](contract.md) Step 3** to re-evaluate against the moved state this same tick: a dispatch hands the PR to the cycle (its exit settles the watch); a transient idle changes nothing; idling back into a block re-flags per Step 7 — a new block, which sends its own single reminder (and re-arms `BLOCKED_CIDIGEST` afresh).
|
|
36
36
|
|
|
37
37
|
## Invariants
|
|
38
38
|
|
|
@@ -85,7 +85,7 @@ Create `iterations/` subdir (empty) for future caller use.
|
|
|
85
85
|
|
|
86
86
|
### Step 8 — Arm the watch
|
|
87
87
|
|
|
88
|
-
Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a
|
|
88
|
+
Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a background watch loop keeps watch — visible until the PR terminates. The cron path stays as the recovery substrate ([`reconcile.md`](reconcile.md)), so `cron.json` is still seeded in Step 7.
|
|
89
89
|
|
|
90
90
|
### Step 9 — Print the success summary
|
|
91
91
|
|
|
@@ -178,20 +178,22 @@ Any tick that did not dispatch. The Step 6 verdict says **why** it idled. It is
|
|
|
178
178
|
|
|
179
179
|
`idle` is **transient** — green and waiting for the next review, CI still pending, or `mergeable == UNKNOWN` — and must keep the responsive `1m` cadence; those turn a state on their own and the watcher should catch it promptly.
|
|
180
180
|
|
|
181
|
-
**Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit through Step 7.5. The next tick fires from the
|
|
181
|
+
**Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit through Step 7.5. The next tick fires from the loop that step leaves running ([`arm-watcher.md`](arm-watcher.md)).
|
|
182
182
|
|
|
183
183
|
**Blocked pending a human** (a durable block, and `last_seen.blocked` not already set): enter the blocked path per [`blocked-tick.md`](blocked-tick.md) — flag `last_seen.blocked` and emit the one-line owner reminder, **once per block**. From the next tick on, the Step 2.5 gate carries the block silently. Exit through Step 7.5.
|
|
184
184
|
|
|
185
|
-
### Step 7.5 —
|
|
185
|
+
### Step 7.5 — Leave one loop polling
|
|
186
186
|
|
|
187
|
-
Runs on every tick that idles — both Step 7 branches and a Step 2.5 held block
|
|
187
|
+
Runs on every tick that idles — both Step 7 branches and a Step 2.5 held block. A loop exits on every report, so after a wake nothing polls the PR until this step arms one; a dispatching tick skips it, because the cycle's settle re-arms when the cycle ends.
|
|
188
188
|
|
|
189
|
-
Check the slot's `watch
|
|
189
|
+
Check the slot's `watch.pid` lease ([`state-schemas.md`](state-schemas.md#watchpid)):
|
|
190
190
|
|
|
191
|
-
- **
|
|
192
|
-
- **
|
|
191
|
+
- **Dead or missing** — arm per [`arm-watcher.md`](arm-watcher.md) and append a `re-armed` line to `followup.log`. The arm carries the slot's review, comment and thread floors forward, so anything that landed after this tick's read still reaches the session.
|
|
192
|
+
- **Live** — a loop already owns the cadence (this tick was not its wake). Nothing to arm.
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
When a **recovery cron** delivered the tick (`/loop` fired it), also cancel the cron per [`cancel-cron.md`](cancel-cron.md). Its job was to deliver *this* tick, never to become the poller: every cron fire is a full model turn, while the loop polls token-free at the same `1m` cadence.
|
|
195
|
+
|
|
196
|
+
Either way exactly one poller remains — the loop — and this tick was the only model turn spent.
|
|
195
197
|
|
|
196
198
|
## Output
|
|
197
199
|
|
|
@@ -137,37 +137,37 @@
|
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
139
|
"id": 11,
|
|
140
|
-
"eval_name": "cron-delivered-idle-with-live-
|
|
141
|
-
"prompt": "Tick muggle-ai-works-pr520 520 — this fire was delivered by a /loop recovery cron, not a
|
|
140
|
+
"eval_name": "cron-delivered-idle-with-live-loop-cancels-duplicate",
|
|
141
|
+
"prompt": "Tick muggle-ai-works-pr520 520 — this fire was delivered by a /loop recovery cron, not a loop's wake. The slot is open, watch.pid names a live loop process, there are no unresolved review threads, CI is green, and the branch is level with master. Walk me through the plan for this tick.",
|
|
142
142
|
"files": [],
|
|
143
143
|
"assertions": [
|
|
144
|
-
{ "name": "recognises_cron_delivered_fire", "text": "Plan recognises this tick was delivered by a /loop recovery cron
|
|
145
|
-
{ "name": "
|
|
144
|
+
{ "name": "recognises_cron_delivered_fire", "text": "Plan recognises this tick was delivered by a /loop recovery cron, so besides leaving one loop polling it must cancel the cron." },
|
|
145
|
+
{ "name": "reads_lease_as_live_loop", "text": "Plan checks the slot's watch.pid lease, finds a live loop, and concludes it already owns the 1m cadence." },
|
|
146
146
|
{ "name": "cancels_duplicate_cron", "text": "Plan cancels this cron per cancel-cron.md (recorded-id first) because it is a duplicate poller — there is nothing to arm." },
|
|
147
|
-
{ "name": "never_recreates_cron_or_double_arms", "text": "Plan does NOT CronCreate a recurring cron and does NOT start a second
|
|
147
|
+
{ "name": "never_recreates_cron_or_double_arms", "text": "Plan does NOT CronCreate a recurring cron and does NOT start a second loop over the live one; exactly one poller (the loop) remains." }
|
|
148
148
|
]
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"id": 12,
|
|
152
|
-
"eval_name": "cron-delivered-idle-with-dead-
|
|
153
|
-
"prompt": "Tick muggle-ai-works-pr521 521 — this fire was delivered by a /loop recovery cron. The slot is open, watch
|
|
152
|
+
"eval_name": "cron-delivered-idle-with-dead-loop-rearms-loop",
|
|
153
|
+
"prompt": "Tick muggle-ai-works-pr521 521 — this fire was delivered by a /loop recovery cron. The slot is open, watch.pid is missing (the last loop is gone), there are no unresolved review threads, CI is green, and the branch is level with master. Walk me through the plan for this tick.",
|
|
154
154
|
"files": [],
|
|
155
155
|
"assertions": [
|
|
156
|
-
{ "name": "
|
|
157
|
-
{ "name": "
|
|
158
|
-
{ "name": "cancels_cron_after_arming", "text": "Plan
|
|
156
|
+
{ "name": "detects_dead_loop_from_lease", "text": "Plan checks the slot's watch.pid lease, finds it missing or naming a dead process, and concludes nothing is polling the PR." },
|
|
157
|
+
{ "name": "rearms_a_background_loop", "text": "Plan re-arms per arm-watcher.md, running pr-watch-arm.sh as a background shell command (Bash run_in_background), never a Monitor, and appends a re-armed line to followup.log." },
|
|
158
|
+
{ "name": "cancels_cron_after_arming", "text": "Plan also cancels this cron per cancel-cron.md, so the 1m cadence lives in the token-free loop rather than a per-minute model turn." },
|
|
159
159
|
{ "name": "never_rearms_with_a_cron", "text": "Plan does NOT restore the poller by creating a recurring /loop cron; the cron is only ever a one-shot recovery delivery." }
|
|
160
160
|
]
|
|
161
161
|
},
|
|
162
162
|
{
|
|
163
163
|
"id": 13,
|
|
164
|
-
"eval_name": "reconcile-rearms-silent-open-watcher-with-
|
|
164
|
+
"eval_name": "reconcile-rearms-silent-open-watcher-with-loop",
|
|
165
165
|
"prompt": "Reconcile the slots. muggle-ai-works-pr522 522 is open (no result.md), but its watch-heartbeat is 40 minutes stale and the newest followup.log line is 40 minutes old — the watcher stopped silently (a crashed cycle that never respawned). The global kill file is absent. Walk me through the plan for this slot.",
|
|
166
166
|
"files": [],
|
|
167
167
|
"assertions": [
|
|
168
168
|
{ "name": "classifies_silent_watcher_from_freshest_beacon", "text": "Plan judges the watcher dead because the freshest beacon (watch-heartbeat mtime or newest followup.log line) is older than the staleness window." },
|
|
169
|
-
{ "name": "
|
|
170
|
-
{ "name": "never_rearms_with_cron", "text": "Plan does NOT re-arm by creating a recurring /loop cron; the 1m cadence belongs in the
|
|
169
|
+
{ "name": "rearms_via_arm_watcher_loop", "text": "Plan re-arms per arm-watcher.md — drain tick, watermark seed, a background loop (Bash run_in_background, never a Monitor) — restoring a token-free poller." },
|
|
170
|
+
{ "name": "never_rearms_with_cron", "text": "Plan does NOT re-arm by creating a recurring /loop cron; the 1m cadence belongs in the loop and a cron fire would be a full model turn." },
|
|
171
171
|
{ "name": "would_skip_under_killfile", "text": "Plan notes it would skip re-arming entirely if polling.disabled existed — here it is absent, so it proceeds." }
|
|
172
172
|
]
|
|
173
173
|
},
|
|
@@ -284,13 +284,13 @@
|
|
|
284
284
|
{
|
|
285
285
|
"id": 23,
|
|
286
286
|
"eval_name": "arm-runs-the-shipped-loop-never-authors-one",
|
|
287
|
-
"prompt": "Arm the watch for muggle-ai-works-pr600 600 after a clean drain. The slot has no watch.pid. Walk me through exactly how you start the
|
|
287
|
+
"prompt": "Arm the watch for muggle-ai-works-pr600 600 after a clean drain. The slot has no watch.pid. Walk me through exactly how you start the watch, including the command you run and the tool you run it with.",
|
|
288
288
|
"files": [],
|
|
289
289
|
"assertions": [
|
|
290
290
|
{ "name": "runs_the_shipped_loop", "text": "Plan starts the shipped scripts/pr-watch-loop.sh, passing --slot, --repo and --pr, with the script path resolved to an absolute path under CLAUDE_PLUGIN_ROOT." },
|
|
291
291
|
{ "name": "does_not_author_a_watch_sh", "text": "Plan does NOT write, generate, or hand-author a per-slot watch.sh, and does NOT copy one from another slot." },
|
|
292
|
-
{ "name": "
|
|
293
|
-
{ "name": "claims_owner_first", "text": "Plan writes owner.json with this session's $CLAUDE_CODE_SESSION_ID before starting the
|
|
292
|
+
{ "name": "background_shell_not_monitor", "text": "Plan starts it as a background shell command (the Bash tool with run_in_background) described 'PR #<n> — <title>', one per PR, and NOT as a Monitor, whose 30-minute cap would cost the session a model turn per re-arm." },
|
|
293
|
+
{ "name": "claims_owner_first", "text": "Plan writes owner.json with this session's $CLAUDE_CODE_SESSION_ID before starting the loop." }
|
|
294
294
|
]
|
|
295
295
|
},
|
|
296
296
|
{
|
|
@@ -16,7 +16,7 @@ Two invariants hold on every trigger, however the sweep was reached: it never se
|
|
|
16
16
|
|
|
17
17
|
## Watching is session-scoped
|
|
18
18
|
|
|
19
|
-
A watcher lives and dies with its session:
|
|
19
|
+
A watcher lives and dies with its session: watch loops and `/loop` crons are both session-bound, so a session that ends or hits its usage limit takes every watch down with it. No poller outlives its session to act on a PR. This is deliberate: a review is addressed only inside a session that carries the context to address it, never by a headless process that would reply without that context.
|
|
20
20
|
|
|
21
21
|
What does run out of session is the muggle watch service (`muggle watch`, a per-user scheduled job). It addresses nothing on any PR. For a slot registered in [`watch-registration.json`](state-schemas.md#watch-registrationjson) whose loop is gone while its owner is down, or idle in the background between turns, it resumes the **owning** session in the background, stopping an idle one first. That session's reconcile, this procedure, re-arms the watch with its own context. Restarting an idle owner onto a newly installed build goes the same way — stop, then resume on reconcile — so the watchers come back with it. A slot whose PR went terminal while neither its loop nor its owner is running it finalizes itself, as Step 3 would, leaving the post-merge cleanup unrun. Everything else it finds (a dead watcher under an owner mid-turn, waiting on its user, or open in a terminal; red CI; a stale branch) it only reports.
|
|
22
22
|
|
|
@@ -71,11 +71,11 @@ The recovery net for a **dropped respawn**: a `/muggle-do` cycle cancels the wat
|
|
|
71
71
|
|
|
72
72
|
Consider only the **owned** candidates still `open` after Step 3. A foreign slot is skipped here outright, before any beacon is read — its staleness is not evidence this session should take it, and counting it here is what let a fresh session inherit every watcher on the machine. Collect the foreign ones for Step 4 instead.
|
|
73
73
|
|
|
74
|
-
For each owned candidate, first check the slot's `watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"`), a
|
|
74
|
+
For each owned candidate, first check the slot's `watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"`), a watch loop already owns the slot — **leave it, do not re-arm**. Arming a recovery cron on top of a live monitor is exactly the duplicate poller this sweep must avoid, and the PID lease is a direct signal a stale beacon is not. Only when no live watcher holds the lease, fall back to the liveness beacons: the `watch-heartbeat` file's mtime (a live loop touches it every iteration, even when quiet — [`arm-watcher.md`](arm-watcher.md)) and the newest **tick line** in `followup.log` — a line whose timestamp is followed by `tick` or `stale-tick` (a live `1m` recovery cron logs a tick every fire; fall back to `cron.json.recorded_at` if both are absent). Non-tick lines are **not** beacons: arming announcements (`armed …` / `re-armed …`), cycle notes, and error lines record activity by a session that may already be dead — logging is not polling, and counting them masks a dead watcher for the whole window. If the **freshest beacon is older than 15 minutes**, the poller is gone → re-arm:
|
|
75
75
|
|
|
76
|
-
- Re-arm per [`arm-watcher.md`](arm-watcher.md) — drain tick, watermark seed,
|
|
76
|
+
- Re-arm per [`arm-watcher.md`](arm-watcher.md) — drain tick, watermark seed, background loop. Append a `re-armed (silent watcher)` line to the slot's `followup.log`. **Never re-arm with a recurring cron**: every cron fire is a full model turn, and the `1m` cadence already lives token-free in the watch loop. A cron's only legitimate job is delivering a single recovery tick, and [`contract.md`](contract.md) Step 7.5 converts even that back to a loop. Skip re-arming entirely while the global kill file `~/.muggle-ai/muggle-do/polling.disabled` exists ([`stop.md`](stop.md)).
|
|
77
77
|
|
|
78
|
-
A fresh beacon (within the window) means the poller is alive — a quiet
|
|
78
|
+
A fresh beacon (within the window) means the poller is alive — a quiet loop still touching its heartbeat, or a cron `CronList` has gone blind to (which hands itself back to a loop on its next tick — [`contract.md`](contract.md) Step 7.5) — so this step leaves it untouched; re-arming can never double an already-live poller. This recovers only a slot that was **already being watched**; a PR that never had a watcher is seeded by [`auto-track.md`](auto-track.md) / bootstrap, not here.
|
|
79
79
|
|
|
80
80
|
### Step 4 — Report
|
|
81
81
|
|
|
@@ -59,7 +59,7 @@ The slot's opt-in to recovery by the muggle watch service (`muggle watch`, a per
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Recovery requires all of: the slot is registered, the PR is open on GitHub, no loop process carries the slot's `--slot` path, the heartbeat is older than the
|
|
62
|
+
Recovery requires all of: the slot is registered, the PR is open on GitHub, no loop process carries the slot's `--slot` path, the heartbeat is older than the re-arm hand-off, `polling.disabled` is absent, and the `owner.json` session either has no running process or is a background session idle between turns. The service then resumes that session in the background from the project dir its transcript records, starting it on `/muggle:muggle-pr-followup reconcile`. An idle running owner is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. Reconcile re-arms only the slots the session owns, so recovery never moves a PR to another session. One resume per owner covers every slot it owns. The service caps resumes per pass and per owner, then falls back to a desktop alert. A resume whose turn ended on the usage-limit notice doesn't count toward the per-owner cap.
|
|
63
63
|
|
|
64
64
|
**Absent ⇒ observed only.** The service still reports on the slot's watcher, CI and branch standing, but never resumes its owner. Slots armed before this file existed stay that way until their next arm. The file lives in the slot, so a `.stopped` rename or a `result.md` retires it with the slot.
|
|
65
65
|
|
|
@@ -208,7 +208,7 @@ Written exactly once when the PR's watcher exits terminally (PR merged or closed
|
|
|
208
208
|
|
|
209
209
|
## `watch-heartbeat`
|
|
210
210
|
|
|
211
|
-
Touched (mtime refreshed) by the watch loop every iteration — content irrelevant. The slot's liveness beacon: a quiet
|
|
211
|
+
Touched (mtime refreshed) by the watch loop every iteration — content irrelevant. The slot's liveness beacon: a quiet loop writes no log lines, so mtime is the only proof it is still polling. Read by [`reconcile.md`](reconcile.md) Step 3.6; a beacon older than 15 minutes means the poller is dead.
|
|
212
212
|
|
|
213
213
|
## `watch-watermark.env`
|
|
214
214
|
|
|
@@ -218,11 +218,11 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
|
|
|
218
218
|
- `COM` — highest thread-comment id already handled
|
|
219
219
|
- `THREADS` — semicolon-joined ids of threads already known unresolved
|
|
220
220
|
- `CIRED` — head SHA whose settled-red CI a fix-ci cycle already attempted (`ci_fix_attempts[sha] >= 1`); empty when the checks are green, still pending, unseen, or red with no attempt — the last so the loop reports it. The CI floor is a SHA rather than a monotonic id because the check rollup flips green↔red and resets on each push — keying on the head SHA fires the loop once per red head and re-arms on the next push ([`arm-watcher.md`](arm-watcher.md)).
|
|
221
|
-
- `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state a rebase cycle already attempted (`conflict_resolve_attempts[key] >= 1`); empty when the branch is current with its base, unseen, or stale with no attempt. Keyed on the pair, not a monotonic id, because staleness depends on both sides — the loop fires once per newly-due pair and re-arms when the head or the base moves. Same key [`contract.md`](contract.md) Steps 5–6 dedup rebase dispatch on; the
|
|
221
|
+
- `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state a rebase cycle already attempted (`conflict_resolve_attempts[key] >= 1`); empty when the branch is current with its base, unseen, or stale with no attempt. Keyed on the pair, not a monotonic id, because staleness depends on both sides — the loop fires once per newly-due pair and re-arms when the head or the base moves. Same key [`contract.md`](contract.md) Steps 5–6 dedup rebase dispatch on; the loop only signals a due rebase, the tick's verdict applies the budgets.
|
|
222
222
|
- `BLOCKED_CIDIGEST` — the head's `ci_digest` at the moment a block was flagged (the same signature `blocked.fingerprint.ci_digest` carries); empty whenever the watch is not blocked. Non-empty arms the loop's blocked-resume probe: while set, the loop wakes on any move of the live digest away from this value, so a block resumes on a green pass, a rerun, or an external deploy check — not only on red. Written by [`blocked-tick.md`](blocked-tick.md) when a block is flagged and cleared to empty when it resumes.
|
|
223
|
-
- `REBASE_HELD` — `1` when the PR has spent its catch-up rebase budget (`catch_up_rebases` has reached [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md)), empty otherwise. While set, the loop raises no rebase wake and records no rebase as due however far the base moves: the tick's verdict refuses every rebase until the owner raises the cap, so a wake would only ask for work it turns down. Every arm recomputes it, so a raised cap lifts the hold at the next
|
|
223
|
+
- `REBASE_HELD` — `1` when the PR has spent its catch-up rebase budget (`catch_up_rebases` has reached [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md)), empty otherwise. While set, the loop raises no rebase wake and records no rebase as due however far the base moves: the tick's verdict refuses every rebase until the owner raises the cap, so a wake would only ask for work it turns down. Every arm recomputes it, so a raised cap lifts the hold at the next re-arm.
|
|
224
224
|
|
|
225
|
-
Written whole-file by the orchestrating session — the review/comment/CI/rebase floors seeded at arm time (`REV`/`COM` to the **drain's own observed max
|
|
225
|
+
Written whole-file by the orchestrating session — the review/comment/CI/rebase floors seeded at arm time (`REV`/`COM`/`THREADS` to the **drain's own observed max** on a first arm, and carried forward from this file on a re-arm, dropping threads since resolved, so a review that landed after the session's last read stays above them; `CIRED`/`REBASED` to the red head and stale key a cycle already attempted, `REBASE_HELD` from the rebase budget) and advanced after every cycle to the **handled wave's snapshot** (the max review-id and comment-id the cycle read at the start of handling, captured before replies were posted); `BLOCKED_CIDIGEST` is set and cleared by the blocked-tick flag/resume path, and every arm re-seeds it from `last_seen.blocked.fingerprint.ci_digest` so a re-arm keeps the resume probe live. **Never** derived from a fresh fetch taken after the drain or after replies land: a later fetch includes ids that arrived in the handling window and were never read, and marking them seen swallows them ([`arm-watcher.md`](arm-watcher.md)). The advance also never moves any floor past an unresolved thread the cycle left unaddressed. Read by the watch loop each iteration; the loop never writes it. A stale watermark makes the next reported event the loop's own reply.
|
|
226
226
|
|
|
227
227
|
**Never `source` this file, and quote or extract values.** `THREADS` holds bare semicolons: sourced unquoted, the shell splits the line at the first `;` and silently drops every id after it — the loop then re-reports known threads as new. The watch loop must extract values verbatim (e.g. `grep '^THREADS=' | cut -d= -f2- | tr -d '"\r'`), tolerating quotes and CRLF; writers should quote the value anyway.
|
|
228
228
|
|
|
@@ -236,6 +236,17 @@ The work the watch last saw due on its PR, for the owning session's turn-end gat
|
|
|
236
236
|
|
|
237
237
|
The file is **empty** when nothing is due. Written by [`../../scripts/pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) from the drain's read and rewritten by the loop whenever what is due changes; the session never writes it. The follow-through Stop gate ([`../../hooks/README.md`](../../hooks/README.md)) blocks the turn end while a non-empty record in a slot this session owns names work with no matching attempt in `last_seen.json`: a `REBASE_DUE` key with no `conflict_resolve_attempts` entry (under `autoRebase=always`, budget left), or a `CI_RED_HEAD` with no `ci_fix_attempts` entry. A later tick's `prs.json` naming another head supersedes the record.
|
|
238
238
|
|
|
239
|
+
## `watch-reported.env`
|
|
240
|
+
|
|
241
|
+
What the slot's loops already reported to the session — `KEY="value"` lines like the watermark's, written only by the loop, and only on the poll that ends it with a report:
|
|
242
|
+
|
|
243
|
+
- `REV` / `COM` — the newest review and comment ids that poll saw; each one at or below them was reported or already floored.
|
|
244
|
+
- `THREADS` — the threads unresolved at that poll.
|
|
245
|
+
- `CIRED` / `REBASED` — the red head and the rebase key a loop reported.
|
|
246
|
+
- `BLOCKED_CIDIGEST` / `BLOCKED_FOR` — the CI digest a blocked-resume wake reported, and the watermark's `BLOCKED_CIDIGEST` it was reported under. It quiets only that block, so the same digest can resume a later one.
|
|
247
|
+
|
|
248
|
+
A loop fires only on what sits above both this file and [`watch-watermark.env`](#watch-watermarkenv). Every report ends a loop, so no floor can live in its memory: without this file, anything the session saw and chose not to act on — a comment needing no reply, a red check under `HOLD WATCH` — would end every fresh loop on its first poll. Read it as the watermark is read, never by `source`.
|
|
249
|
+
|
|
239
250
|
## `watch.pid`
|
|
240
251
|
|
|
241
252
|
A single line: the process id of the watch loop that currently owns this slot. Written once by the loop itself on start (`echo "$$" > watch.pid`); read by two parties, never rewritten in place:
|
|
@@ -243,4 +254,4 @@ A single line: the process id of the watch loop that currently owns this slot. W
|
|
|
243
254
|
- **arm-watcher's pre-arm dedup** ([`arm-watcher.md`](arm-watcher.md) Step 3) reads it and, if the PID is live (`kill -0`), skips arming — one live watcher per slot.
|
|
244
255
|
- **the loop's own supersede guard** (`watcher_superseded`, [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh)) compares it to `$$` each iteration and exits when they differ — a newer arm that overwrote the file steps the old loop down.
|
|
245
256
|
|
|
246
|
-
This is the lease that keeps orphaned watchers from accumulating across sessions: the in-session
|
|
257
|
+
This is the lease that keeps orphaned watchers from accumulating across sessions: the in-session task dying does not stop the detached OS loop it launched (notably on Windows), so slot ownership is tracked by a durable PID on disk, not by the live task list. It is not seeded, migrated, or cleaned up — a stale PID (loop already dead) simply fails the `kill -0` liveness check and the next arm proceeds.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Stop Procedure
|
|
2
2
|
|
|
3
|
-
The procedure for the **stop mode** of `muggle-pr-followup` — the owner's kill switch. One command tears down every polling substrate a slot has (
|
|
3
|
+
The procedure for the **stop mode** of `muggle-pr-followup` — the owner's kill switch. One command tears down every polling substrate a slot has (loop, cron) and leaves the slot in a state where anything that survives teardown — an orphaned cron in an unreachable scheduler, a queued fire still draining — arrives as an inert one-line absorb ([`contract.md`](contract.md) Step 0). Routing is in [`SKILL.md`](SKILL.md#routing).
|
|
4
4
|
|
|
5
5
|
Stopping is not finalizing: the PR stays open on the provider, and no `result.md` is written. A stopped slot can be resumed later by renaming `<slug>.stopped` back to `<slug>` and re-arming per [`arm-watcher.md`](arm-watcher.md).
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ Stopping is not finalizing: the PR stays open on the provider, and no `result.md
|
|
|
12
12
|
|
|
13
13
|
Run per slot in scope (every dir under `~/.muggle-ai/muggle-do/sessions/` with a `prs.json` and no `result.md`, or just the named one):
|
|
14
14
|
|
|
15
|
-
1. **Stop the
|
|
15
|
+
1. **Stop the loop.** `TaskStop` the background task whose command references this slot — `pr-watch-arm.sh --slot <slot>`, or a legacy `<slot>/watch.sh`. On Windows also kill surviving grandchildren: `TaskStop` reaps the wrapper but the inner loop process can outlive it and keep touching `watch-heartbeat` — enumerate processes whose command line carries this slot's `--slot` path (or a legacy `<slug>/watch.sh`) and kill them. A survivor here also blocks the Step 3 rename (open handles inside the dir).
|
|
16
16
|
2. **Cancel the cron.** Per [`cancel-cron.md`](cancel-cron.md) — recorded-id first, `CronList` match as fallback. A cron neither can reach is orphaned; it stays inert via the absorb and dies with its session.
|
|
17
17
|
3. **Mark the slot stopped.** Rename `~/.muggle-ai/muggle-do/sessions/<slug>/` → `<slug>.stopped/`. This single rename is the load-bearing act: the tick absorb keys on it, [`reconcile.md`](reconcile.md) skips `*.stopped` dirs, and the muggle watch service ignores a stopped slot's [`watch-registration.json`](state-schemas.md#watch-registrationjson), so no recovery path of any kind re-arms the slot. To keep a watch running but stop the service from resuming its owner, use `muggle watch unregister --slot <slot>` instead.
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ Then, for a no-slug (stop-everything) invocation only:
|
|
|
22
22
|
|
|
23
23
|
## Output
|
|
24
24
|
|
|
25
|
-
One line per slot (`stopped: <slug> —
|
|
25
|
+
One line per slot (`stopped: <slug> — loop <killed|none>, cron <cancelled|orphaned|none>`), plus `polling disabled globally` when the kill file was written. Report an orphaned cron honestly: it may keep firing one-line absorbs until its host session ends — that is the harness's floor, not a teardown failure.
|
|
26
26
|
|
|
27
27
|
## Invariants
|
|
28
28
|
|
|
@@ -14,9 +14,9 @@ How long a `muggle-pr-followup` watch loop polls before retiring itself.
|
|
|
14
14
|
|
|
15
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
|
-
|
|
17
|
+
Arming runs the loop as a background shell, which has no time cap, so this value is the bound. It also sets how often a quiet PR costs its owning session a turn: the loop announces the end of its lifetime with an `EXPIRED` line, and the session re-arms it.
|
|
18
18
|
|
|
19
|
-
**`never` removes this bound.**
|
|
19
|
+
**`never` removes this bound.** An abandoned loop then polls until the machine restarts or someone kills it.
|
|
20
20
|
|
|
21
21
|
## Applying it
|
|
22
22
|
|