@muggleai/works 5.29.0-staging.255 → 5.29.0-staging.256
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/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/scripts/guardrail-dispatch.sh +6 -0
- package/dist/plugin/scripts/guardrail-e2e-gate.sh +24 -0
- package/dist/plugin/scripts/guardrail-lib.sh +1 -0
- package/dist/plugin/scripts/guardrails.mjs +99 -14
- package/dist/plugin/scripts/pr-watch-arm.sh +14 -10
- package/dist/plugin/scripts/pr-watch-events.sh +14 -5
- package/dist/plugin/scripts/pr-watch-fetch.sh +1 -1
- package/dist/plugin/scripts/pr-watch-loop.sh +6 -5
- package/dist/plugin/scripts/pr-watch-state.jq +15 -5
- package/dist/plugin/scripts/pr-watch-verdict.mjs +22 -7
- package/dist/plugin/skills/_shared/ci-check-to-command.md +1 -1
- package/dist/plugin/skills/do/e2e-acceptance.md +1 -1
- package/dist/plugin/skills/do/fix-ci.md +9 -1
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
- package/dist/plugin/skills/muggle-pr-followup/contract.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- 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/scripts/guardrail-dispatch.sh +6 -0
- package/plugin/scripts/guardrail-e2e-gate.sh +24 -0
- package/plugin/scripts/guardrail-lib.sh +1 -0
- package/plugin/scripts/guardrails.mjs +99 -14
- package/plugin/scripts/pr-watch-arm.sh +14 -10
- package/plugin/scripts/pr-watch-events.sh +14 -5
- package/plugin/scripts/pr-watch-fetch.sh +1 -1
- package/plugin/scripts/pr-watch-loop.sh +6 -5
- package/plugin/scripts/pr-watch-state.jq +15 -5
- package/plugin/scripts/pr-watch-verdict.mjs +22 -7
- package/plugin/skills/_shared/ci-check-to-command.md +1 -1
- package/plugin/skills/do/e2e-acceptance.md +1 -1
- package/plugin/skills/do/fix-ci.md +9 -1
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
- package/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
- package/plugin/skills/muggle-pr-followup/contract.md +2 -2
- package/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
|
@@ -13,7 +13,7 @@ The same argument that forbids authoring a per-slot `watch.sh` applies to arming
|
|
|
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 loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the
|
|
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 red key (`<head_sha>@<red-check-runs>`) 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** fix-ci is finished with that head — it escalated, or spent the tick's per-head budget — and empty otherwise. A lone attempt does not floor: the count is written before the fix runs, so a cycle that recorded it and changed nothing would otherwise leave the red silent. 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
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
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.
|
|
@@ -36,7 +36,7 @@ The `DRAIN` lines it prints, in the watch's output, are the outstanding work as
|
|
|
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 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
|
|
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 **red key** — the head SHA plus each failed check's run id — because the check rollup is non-monotonic: it flips green↔red and resets on every push, so the key fires once per red verdict and a later push re-arms it on the new SHA. The run ids matter because a settled walkthrough comment or a rerun publishes a new run on the same head; when that run is red too, it is the verdict fix-ci is waiting on, and a head-only floor swallowed it. 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
|
|
|
@@ -61,6 +61,8 @@ Line-comment threads need no seeding — the watcher derives them from live thre
|
|
|
61
61
|
|
|
62
62
|
The only step that may prompt the user, and only when the PR has a testable surface. Run silent detection from [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) first. If it finds **no dev server / no testable surface** — a docs, config, skill, or library change, or a non-web repo — seed the watcher **poll-only**: no prompt, no `## Pre-flight answers` block, continue to Step 7 (same as [`auto-track`](auto-track.md)).
|
|
63
63
|
|
|
64
|
+
Detection just established why E2E does not apply, so record it on the PR before arming. If the PR's walkthrough comment is still the placeholder, run `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` from Step 3's working tree: `NO_WEB_SURFACE` when the repo serves nothing to drive, `NO_APP_LOGIC_CHANGE` (detail opening with the PR's merge-base commit) when every changed path is docs, tests, lint or CI config, else `NOT_APPLICABLE` naming why no browser run reaches the change. Never `NO_PR` — the guardrail refutes it on a PR. Then run the foreground command the walkthrough gate's block names until it prints `{"settled":true}`. Left unsettled, the walkthrough check sits red with nothing in this watch able to clear it, and the PR can merge that way.
|
|
65
|
+
|
|
64
66
|
Otherwise resolve the context per that file: reuse an existing one (gated by `autoReuseValidationContext`), else one `AskUserQuestion` (strategy, local URL, backend, project, credentials, re-auth) with detected values as defaults; record Step 3's verified working tree as `Working tree`. The user may pick `skip`/`unit-only` or decline the prompt outright — either way, seed poll-only and continue. Never abort the watcher over validation.
|
|
65
67
|
|
|
66
68
|
Capture any resolved fields for Step 7. Do **not** run E2E now — the first watcher tick that dispatches `/muggle-do` does that.
|
|
@@ -135,7 +135,7 @@ It prints one line, `VERDICT=<verdict>`. A non-zero exit is a broken install: re
|
|
|
135
135
|
|
|
136
136
|
1. A rebase is due and `catch_up_rebases >= maxCatchUpRebases` (the cap from the user's preferences; the `rebase_budget_exhausted` flag is not consulted) → `block-rebase-budget-exhausted`.
|
|
137
137
|
2. A rebase is due, `conflict_resolve_attempts[rebase_key] < 2`, and `rebase_key` ∉ `conflict_escalated_keys` → `dispatch-rebase`. With the per-key budget spent — `conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys` — it falls through to CI.
|
|
138
|
-
3. CI is `red`, `ci_fix_attempts[head_sha] < 3`, and `head_sha` ∉ `ci_escalated_shas` → `dispatch-fix-ci`. Red with `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas` dispatches nothing — the fix budget is spent and the watcher does not re-dispatch. A check still pending (`bucket == "pending"`) never dispatches: the tick idles until CI settles, and a green head idles too.
|
|
138
|
+
3. CI is `red`, `ci_fix_attempts[head_sha] < 3`, and `head_sha` ∉ `ci_escalated_shas` → `dispatch-fix-ci`. Red with `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas` dispatches nothing — the fix budget is spent and the watcher does not re-dispatch; it holds as `block-ci-escalated` (Step 4) even when fix-ci never wrote the escalation, because idling there leaves a red PR with nobody told. A check still pending (`bucket == "pending"`) never dispatches: the tick idles until CI settles, and a green head idles too.
|
|
139
139
|
4. Otherwise `block-conflict-escalated`, `block-ci-escalated`, or `block-reviews-escalated` when the matching escalation stands, else `idle`.
|
|
140
140
|
|
|
141
141
|
**An escalation counts only with an attempt behind it.** An entry in `conflict_escalated_keys` suppresses a rebase only when `conflict_resolve_attempts[rebase_key] >= 1`, and an entry in `ci_escalated_shas` suppresses fix-ci only when `ci_fix_attempts[head_sha] >= 1`: a cycle ran and gave up. An entry with no attempt is ignored, and the work dispatches. Those fields are the executor's record of what its cycles did — never write an escalation entry or an attempt counter by hand.
|
|
@@ -172,7 +172,7 @@ Act on the verdict:
|
|
|
172
172
|
Any tick that did not dispatch. The Step 6 verdict says **why** it idled. It is **blocked pending a human** on a `block-*` verdict, a durable block only the user can clear:
|
|
173
173
|
|
|
174
174
|
- `block-conflict-escalated` — a rebase `/muggle-do` attempted and gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`. This block clears on its own when the base moves: the new `base_tip_sha` yields a key the set does not contain, and the branch re-arms for a fresh rebase attempt;
|
|
175
|
-
- `block-ci-escalated` — CI the fix-ci stage
|
|
175
|
+
- `block-ci-escalated` — CI the fix-ci stage gave up on, or a red head that spent all 3 fix-ci attempts, reason `ci_escalated`;
|
|
176
176
|
- `block-reviews-escalated` — an ambiguous review in `last_seen.escalated_review_ids` awaiting the user's direction, with the actionable set empty, reason `reviews_escalated`;
|
|
177
177
|
- `block-rebase-budget-exhausted` — reason `rebase_budget_exhausted` (Step 6).
|
|
178
178
|
|
|
@@ -217,7 +217,7 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
|
|
|
217
217
|
- `REV` — highest submitted-review id already handled
|
|
218
218
|
- `COM` — highest thread-comment id already handled
|
|
219
219
|
- `THREADS` — semicolon-joined ids of threads already known unresolved
|
|
220
|
-
- `CIRED` —
|
|
220
|
+
- `CIRED` — the red key `<head_sha>@<red-check-runs>` (each failed check's `name#run-id`, sorted — the last field of [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq)) of a red verdict fix-ci is finished with: `head_sha` ∈ `ci_escalated_shas` with an attempt behind it, or `ci_fix_attempts[sha] >= 3`. Empty when the checks are green, still pending, unseen, or red with fix-ci not finished — the last so the loop reports it. A lone attempt never floors: the count is written before the fix runs, so it proves a cycle started, not that anything on the PR changed. The floor is a red key rather than a monotonic id because the rollup flips green↔red and resets on each push, and it carries run ids because a settled walkthrough comment or a rerun publishes a new run on the **same** head — a red verdict from that run is what fix-ci waits on, so it wakes the loop again ([`arm-watcher.md`](arm-watcher.md)).
|
|
221
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
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.
|
|
@@ -236,17 +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
|
|
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
|
+
## `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 key 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
|
+
|
|
250
250
|
## `watch.pid`
|
|
251
251
|
|
|
252
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:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"release": "5.26.0",
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-10-
|
|
3
|
+
"buildId": "run-256-1",
|
|
4
|
+
"commitSha": "5838b08d25aebf0dda3aee280e619ba96c5799bc",
|
|
5
|
+
"buildTime": "2026-10-30T20:31:46Z",
|
|
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.29.0-staging.
|
|
4
|
+
"version": "5.29.0-staging.256",
|
|
5
5
|
"description": "13 years of Microsoft hyperscale engineering, packaged as a harness for your AI coding agent.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
4
|
-
"version": "5.29.0-staging.
|
|
4
|
+
"version": "5.29.0-staging.256",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
package/plugin/.codex-mcp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.29.0-staging.
|
|
3
|
+
"version": "5.29.0-staging.256",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
|
-
"version": "5.29.0-staging.
|
|
5
|
+
"version": "5.29.0-staging.256",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/plugin/.mcp.json
CHANGED
|
@@ -23,8 +23,14 @@ guardrail_read_payload
|
|
|
23
23
|
firing_subcommands=()
|
|
24
24
|
for gate_script in "$@"; do
|
|
25
25
|
GUARDRAIL_SUBCOMMAND=""
|
|
26
|
+
unset -f guardrail_shell_verdict
|
|
26
27
|
. "$guardrail_script_dir/$gate_script" || continue
|
|
27
28
|
if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
|
|
29
|
+
# A gate that can answer in-shell blocks the turn here, before Node's cold start can outlast the hook's cap;
|
|
30
|
+
# the gates after it run on the turn end that follows.
|
|
31
|
+
if declare -F guardrail_shell_verdict >/dev/null && guardrail_shell_verdict; then
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
28
34
|
firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
|
|
29
35
|
fi
|
|
30
36
|
done
|
|
@@ -28,6 +28,30 @@ guardrail_wants() {
|
|
|
28
28
|
&& ! guardrail_state_has '"e2eRun": true'
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
# Returns the block guardrails.mjs rendered ahead of time (writeE2eStopVerdict), without starting Node, while the
|
|
32
|
+
# budget it shares with Node's own blocks lasts. A Stop hook that outlives its one-second cap is dropped and the turn
|
|
33
|
+
# ends unblocked, and Node's cold start alone has taken seconds on a loaded Windows machine, so a block that waits on
|
|
34
|
+
# Node is one that can silently never land. Returns 1, leaving the decision and the release to Node, when nothing is
|
|
35
|
+
# rendered or the budget is spent.
|
|
36
|
+
guardrail_shell_verdict() {
|
|
37
|
+
local verdict_file="${GUARDRAIL_STATE_FILE%.json}.e2e-stop-verdict"
|
|
38
|
+
local shell_blocks_file="${GUARDRAIL_STATE_FILE%.json}.e2e-shell-blocks"
|
|
39
|
+
[[ -f $verdict_file ]] || return 1
|
|
40
|
+
local verdict=""
|
|
41
|
+
IFS= read -r -d '' verdict <"$verdict_file" || true
|
|
42
|
+
local max_blocks="${verdict%%$'\n'*}"
|
|
43
|
+
local hook_output="${verdict#*$'\n'}"
|
|
44
|
+
[[ $max_blocks =~ ^[0-9]+$ && -n $hook_output && $hook_output != "$verdict" ]] || return 1
|
|
45
|
+
local recorded_blocks_pattern='"e2eBlockCount"[[:space:]]*:[[:space:]]*([0-9]+)'
|
|
46
|
+
local recorded_blocks=0
|
|
47
|
+
[[ $GUARDRAIL_STATE =~ $recorded_blocks_pattern ]] && recorded_blocks="${BASH_REMATCH[1]}"
|
|
48
|
+
local shell_blocks=""
|
|
49
|
+
[[ -f $shell_blocks_file ]] && { IFS= read -r -d '' shell_blocks <"$shell_blocks_file" || true; }
|
|
50
|
+
((recorded_blocks + ${#shell_blocks} < max_blocks)) || return 1
|
|
51
|
+
{ printf 'x' >>"$shell_blocks_file"; } 2>/dev/null || return 1
|
|
52
|
+
printf '%s' "$hook_output"
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
if guardrail_is_hook_script; then
|
|
32
56
|
guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
|
|
33
57
|
fi
|
|
@@ -105,6 +105,10 @@ var SUBAGENT_DISPATCH_TOOL_NAMES = ["Agent", "Task"];
|
|
|
105
105
|
var ACCEPTANCE_TESTER_SUBAGENT_TYPE = /(?:^|:)acceptance-tester$/;
|
|
106
106
|
var ACCEPTANCE_REPORT_MARKERS = ["## Test Summary", "**Overall:**"];
|
|
107
107
|
var DELEGATED_E2E_WINDOW_MS = 60 * 6e4;
|
|
108
|
+
var E2E_STOP_VERDICT_SUFFIX = ".e2e-stop-verdict";
|
|
109
|
+
var E2E_SHELL_BLOCKS_SUFFIX = ".e2e-shell-blocks";
|
|
110
|
+
var AUTO_E2E_TEST_PREFERENCE_KEY = "autoE2ETest";
|
|
111
|
+
var POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY = "postPRVisualWalkthrough";
|
|
108
112
|
|
|
109
113
|
// src/guardrails/store/fileLock.ts
|
|
110
114
|
function isProcessAlive(pid) {
|
|
@@ -220,6 +224,25 @@ function readPrStateScan(sessionId2, dirOverride) {
|
|
|
220
224
|
function writePrStateScan(sessionId2, scan, dirOverride) {
|
|
221
225
|
writeAtomically(fileFor(sessionId2, dirOverride, PR_STATE_SCAN_SUFFIX), scan, dirOverride);
|
|
222
226
|
}
|
|
227
|
+
function writeE2eStopVerdict(sessionId2, maxBlocks, hookOutput, dirOverride) {
|
|
228
|
+
mkdirSync(baseDir(dirOverride), { recursive: true });
|
|
229
|
+
const target = fileFor(sessionId2, dirOverride, E2E_STOP_VERDICT_SUFFIX);
|
|
230
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
231
|
+
writeFileSync(staging, `${maxBlocks}
|
|
232
|
+
${hookOutput}`);
|
|
233
|
+
renameSync(staging, target);
|
|
234
|
+
writeFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "", { flag: "a" });
|
|
235
|
+
}
|
|
236
|
+
function readE2eShellBlockCount(sessionId2, dirOverride) {
|
|
237
|
+
try {
|
|
238
|
+
return readFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "utf-8").length;
|
|
239
|
+
} catch {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function clearE2eShellBlocks(sessionId2, dirOverride) {
|
|
244
|
+
rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
|
|
245
|
+
}
|
|
223
246
|
var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
|
|
224
247
|
function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
|
|
225
248
|
const current = readState(sessionId2, dirOverride);
|
|
@@ -1816,10 +1839,14 @@ function applyStateMutation(state, mutation) {
|
|
|
1816
1839
|
function commitStateMutation(sessionId2, mutation, observedState) {
|
|
1817
1840
|
if (observedState && applyStateMutation(observedState, mutation) === observedState) return observedState;
|
|
1818
1841
|
const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
|
|
1819
|
-
if (committed) return committed;
|
|
1842
|
+
if (committed) return withShellBlocksReset(committed);
|
|
1820
1843
|
deferStateMutation(sessionId2, mutation);
|
|
1821
1844
|
startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
|
|
1822
|
-
return applyStateMutation(readState(sessionId2), mutation);
|
|
1845
|
+
return withShellBlocksReset(applyStateMutation(readState(sessionId2), mutation));
|
|
1846
|
+
}
|
|
1847
|
+
function withShellBlocksReset(state) {
|
|
1848
|
+
if (state.e2eRun === true) clearE2eShellBlocks(state.sessionId);
|
|
1849
|
+
return state;
|
|
1823
1850
|
}
|
|
1824
1851
|
function commitDeferredSessionState(sessionId2) {
|
|
1825
1852
|
commitDeferredStateMutations(sessionId2, applyStateMutation);
|
|
@@ -1925,19 +1952,52 @@ function isE2ERun(input2) {
|
|
|
1925
1952
|
if (MUGGLE_E2E_RUN_TOOL.test(input2.tool_name ?? "")) return true;
|
|
1926
1953
|
return MUGGLE_SKILL_EMIT_TOOL.test(input2.tool_name ?? "") && input2.tool_input?.skillName === MUGGLE_TEST_SKILL_NAME;
|
|
1927
1954
|
}
|
|
1955
|
+
|
|
1956
|
+
// src/guardrails/e2eOwedReason.ts
|
|
1957
|
+
var describePreference = (preferenceKey, configuredValue) => `${preferenceKey}=${configuredValue ?? "unset"}`;
|
|
1958
|
+
function buildE2eOwedReason(input2) {
|
|
1959
|
+
const isE2eMandated = input2.isE2eMandatedByUser;
|
|
1960
|
+
const isWalkthroughMandated = input2.postPRVisualWalkthrough === ALWAYS_PREFERENCE_VALUE;
|
|
1961
|
+
const preferenceSummary = `${describePreference(AUTO_E2E_TEST_PREFERENCE_KEY, input2.autoE2ETest)}, ${describePreference(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY, input2.postPRVisualWalkthrough)} (~/.muggle-ai/preferences.json)`;
|
|
1962
|
+
const walkthroughStep = isWalkthroughMandated ? `, then post the run's walkthrough to the PR via ${input2.walkthroughSkill}` : "";
|
|
1963
|
+
const offeredSkipCodes = isE2eMandated ? E2E_SKIP_CODES.filter((skipCode) => skipCode !== "NOT_APPLICABLE" /* NotApplicable */) : E2E_SKIP_CODES;
|
|
1964
|
+
const skipClause = `To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${offeredSkipCodes.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked.`;
|
|
1965
|
+
if (input2.blockCount > 1) {
|
|
1966
|
+
return `E2E acceptance run still owed (reminder ${input2.blockCount}/${input2.maxBlocks}; ${preferenceSummary}): run ${input2.muggleTestSkill}${walkthroughStep}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
|
|
1967
|
+
}
|
|
1968
|
+
const owedSentence = `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Preferences: ${preferenceSummary}. `;
|
|
1969
|
+
if (isE2eMandated) {
|
|
1970
|
+
return owedSentence + `That is the user's standing answer: do not ask them whether to test, and do not end the turn handing the decision back. Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} A judgment that the change is not worth a run is not a skip.`;
|
|
1971
|
+
}
|
|
1972
|
+
return owedSentence + `Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.`;
|
|
1973
|
+
}
|
|
1928
1974
|
function parseMaxCatchUpRebases(configuredValue) {
|
|
1929
1975
|
if (configuredValue === UNBOUNDED_PREFERENCE_VALUE) return null;
|
|
1930
1976
|
const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
|
|
1931
1977
|
return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
|
|
1932
1978
|
}
|
|
1979
|
+
var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
|
|
1980
|
+
function readPreferenceLayers(homeDir, pluginRoot) {
|
|
1981
|
+
return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
|
|
1982
|
+
}
|
|
1983
|
+
function pickPreferenceValue(preferenceLayers, preferenceKey) {
|
|
1984
|
+
const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
|
|
1985
|
+
return typeof configuredValue === "string" ? configuredValue : void 0;
|
|
1986
|
+
}
|
|
1987
|
+
function resolvePreferenceValue(preferenceKey, homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
|
|
1988
|
+
return pickPreferenceValue(readPreferenceLayers(homeDir, pluginRoot), preferenceKey);
|
|
1989
|
+
}
|
|
1990
|
+
function resolveUserPreferenceValue(preferenceKey, homeDir = homedir()) {
|
|
1991
|
+
return pickPreferenceValue([readUserPreferences(homeDir)], preferenceKey);
|
|
1992
|
+
}
|
|
1933
1993
|
function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
|
|
1934
|
-
const
|
|
1935
|
-
const userPreferences =
|
|
1936
|
-
const autoRebase =
|
|
1994
|
+
const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
|
|
1995
|
+
const [userPreferences, shippedDefaults] = preferenceLayers;
|
|
1996
|
+
const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
|
|
1937
1997
|
const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
|
|
1938
1998
|
const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
|
|
1939
1999
|
return {
|
|
1940
|
-
autoRebase
|
|
2000
|
+
autoRebase,
|
|
1941
2001
|
maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
|
|
1942
2002
|
};
|
|
1943
2003
|
}
|
|
@@ -2216,7 +2276,12 @@ var verifyNoAppLogicChange = (context, detail) => {
|
|
|
2216
2276
|
if (appLogicPaths.length > 0) return refuted(`app-logic paths changed since ${sha}: ${namePaths(appLogicPaths)}`);
|
|
2217
2277
|
return verified;
|
|
2218
2278
|
};
|
|
2219
|
-
var verifyNotApplicable = (
|
|
2279
|
+
var verifyNotApplicable = (context, detail) => {
|
|
2280
|
+
if (context.userAutoE2ETest === ALWAYS_PREFERENCE_VALUE) {
|
|
2281
|
+
return refuted(
|
|
2282
|
+
`autoE2ETest=always in ~/.muggle-ai/preferences.json, so a judgment that the change needs no run is not a skip the user allows \u2014 run the E2E, or cite a code Muggle can verify`
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2220
2285
|
const words = detail.trim().split(/\s+/).filter(Boolean).length;
|
|
2221
2286
|
if (words >= MIN_NOT_APPLICABLE_REASON_WORDS) return verified;
|
|
2222
2287
|
return refuted(
|
|
@@ -2261,12 +2326,14 @@ function explainRejection(judged) {
|
|
|
2261
2326
|
return `The skip cited ${judged.claimedCode}, but that could not be verified: ${judged.failure}. E2E is still owed \u2014 cite a code that holds, or run it.`;
|
|
2262
2327
|
}
|
|
2263
2328
|
function judgeE2eSkip(input2, state, probes = defaultSkipProbes) {
|
|
2329
|
+
const homeDir = homedir();
|
|
2264
2330
|
return resolveSkipDeclaration(input2.tool_input?.command ?? "", {
|
|
2265
2331
|
cwd: input2.cwd,
|
|
2266
2332
|
prsHandled: state.prsHandled,
|
|
2267
2333
|
transcriptPath: input2.transcript_path,
|
|
2268
|
-
homeDir
|
|
2269
|
-
probe: probes
|
|
2334
|
+
homeDir,
|
|
2335
|
+
probe: probes,
|
|
2336
|
+
userAutoE2ETest: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY, homeDir)
|
|
2270
2337
|
});
|
|
2271
2338
|
}
|
|
2272
2339
|
|
|
@@ -2688,6 +2755,7 @@ function prOpened() {
|
|
|
2688
2755
|
prUrl: url,
|
|
2689
2756
|
prChange
|
|
2690
2757
|
});
|
|
2758
|
+
renderE2eStopVerdict(stateAfterOpen);
|
|
2691
2759
|
const declaredSkip = isPrCoveredBySkip(stateAfterOpen, url) ? stateAfterOpen.e2eSkipDeclaration : void 0;
|
|
2692
2760
|
if (declaredSkip) {
|
|
2693
2761
|
startBackgroundJob("settle-walkthrough-skipped" /* SettleWalkthroughSkipped */, [url, declaredSkip.code, declaredSkip.detail]);
|
|
@@ -2766,6 +2834,7 @@ function recordTests() {
|
|
|
2766
2834
|
failedRunId: detectFailedRunId(input)
|
|
2767
2835
|
};
|
|
2768
2836
|
const next = commitStateMutation(sessionId, outcome);
|
|
2837
|
+
if (outcome.run.unitTestPassed) renderE2eStopVerdict(next);
|
|
2769
2838
|
startWalkthroughScanOnGateArming(stateBefore, next, walkthroughPosted);
|
|
2770
2839
|
if (acceptedSkip) recordSkipReasonOnPrs(next, acceptedSkip);
|
|
2771
2840
|
if (judgedSkip && !judgedSkip.accepted) {
|
|
@@ -2869,14 +2938,30 @@ function debugPathGate() {
|
|
|
2869
2938
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
|
|
2870
2939
|
return blockStop(reason, host);
|
|
2871
2940
|
}
|
|
2941
|
+
function e2eOwedReason(blockCount) {
|
|
2942
|
+
return buildE2eOwedReason({
|
|
2943
|
+
blockCount,
|
|
2944
|
+
maxBlocks: MAX_E2E_BLOCKS,
|
|
2945
|
+
autoE2ETest: resolvePreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY),
|
|
2946
|
+
postPRVisualWalkthrough: resolvePreferenceValue(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY),
|
|
2947
|
+
isE2eMandatedByUser: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY) === ALWAYS_PREFERENCE_VALUE,
|
|
2948
|
+
muggleTestSkill: muggleSkill("/muggle:muggle-test"),
|
|
2949
|
+
walkthroughSkill: muggleSkill("/muggle:muggle-pr-visual-walkthrough")
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
function renderE2eStopVerdict(state) {
|
|
2953
|
+
if (!shouldRunE2E(state)) return;
|
|
2954
|
+
writeE2eStopVerdict(sessionId, MAX_E2E_BLOCKS, blockStop(e2eOwedReason(1), host));
|
|
2955
|
+
}
|
|
2872
2956
|
function e2eGate() {
|
|
2873
2957
|
const state = readState(sessionId);
|
|
2874
|
-
const
|
|
2958
|
+
const shellBlockCount = readE2eShellBlockCount(sessionId);
|
|
2959
|
+
const decision = e2eGateDecision({ ...state, e2eBlockCount: (state.e2eBlockCount ?? 0) + shellBlockCount });
|
|
2875
2960
|
if (decision.action === "release" /* Release */) return releaseGate("e2eReleased");
|
|
2876
2961
|
if (decision.action === "none" /* None */) return "{}";
|
|
2877
|
-
recordGateBlock("e2eBlockCount", decision.blockCount);
|
|
2878
|
-
|
|
2879
|
-
return blockStop(
|
|
2962
|
+
recordGateBlock("e2eBlockCount", decision.blockCount - shellBlockCount);
|
|
2963
|
+
renderE2eStopVerdict(state);
|
|
2964
|
+
return blockStop(e2eOwedReason(decision.blockCount), host);
|
|
2880
2965
|
}
|
|
2881
2966
|
function watchGate() {
|
|
2882
2967
|
const state = readState(sessionId);
|
|
@@ -2928,7 +3013,7 @@ function watchFollowThroughGate() {
|
|
|
2928
3013
|
const holdInstruction = `Only the user can hold it: their own message containing \`${WATCH_HOLD_PHRASE_PREFIX}<pr-number>\` for that PR. Ask if you must; never decide it for them.`;
|
|
2929
3014
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. A muggle-pr-followup watch this session owns found work due that nothing acted on:
|
|
2930
3015
|
${owedList}
|
|
2931
|
-
autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now \u2014 if
|
|
3016
|
+
autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now: run the cycle the tick dispatches \u2014 if it finds actionable review feedback it dispatches that first. That cycle's first step records its attempt (conflict_resolve_attempts / ci_fix_attempts), which, like a new head, clears this. Writing the count without running the cycle \u2014 or for an E2E run queued for a different PR \u2014 leaves the PR red and the watch quiet. An escalation entry with no attempt behind it does not clear this, and neither does re-arming the watch. ${holdInstruction}` : `Watcher work still owed (reminder ${decision.blockCount}):
|
|
2932
3017
|
${owedList}
|
|
2933
3018
|
${holdInstruction}`;
|
|
2934
3019
|
return blockStop(reason, host);
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
# seen without ever being reported. A re-arm keeps the review, comment and thread
|
|
14
14
|
# floors the session already holds, for the same reason.
|
|
15
15
|
#
|
|
16
|
-
# A stale branch
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
16
|
+
# A stale branch is floored only when the slot proves a rebase ran for it, and a
|
|
17
|
+
# red head only once fix-ci is finished with it — escalated, or out of attempts.
|
|
18
|
+
# Flooring whatever was outstanding at arm time let a session that never rebased
|
|
19
|
+
# or fixed anything re-arm at every rollover and silence the loop each time; the
|
|
20
|
+
# drain line said "handled" and nothing checked. A bare attempt count is no
|
|
21
|
+
# better proof for CI: writing it costs nothing and changes nothing on the PR.
|
|
20
22
|
set -uo pipefail
|
|
21
23
|
|
|
22
24
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
@@ -77,6 +79,7 @@ latest_comment="${STATE_FIELDS[5]-}"
|
|
|
77
79
|
unresolved_threads="${STATE_FIELDS[6]-}"
|
|
78
80
|
pending_checks="${STATE_FIELDS[7]-}"
|
|
79
81
|
failed_checks="${STATE_FIELDS[8]-}"
|
|
82
|
+
ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
|
|
80
83
|
|
|
81
84
|
if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
|
|
82
85
|
echo "TERMINAL pr=$pr_number state=$pr_state — nothing to arm"
|
|
@@ -92,7 +95,8 @@ rebase_due_key=""
|
|
|
92
95
|
behind_count="$(watch_fetch_behind_count "$repo" "$base_branch" "$head_sha" "$slot")"
|
|
93
96
|
watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebase_key"
|
|
94
97
|
|
|
95
|
-
# Whether a
|
|
98
|
+
# Whether a rebase already ran for this key, whether fix-ci is done with this
|
|
99
|
+
# head, and the blocked
|
|
96
100
|
# fingerprint's digest. A slot re-armed while blocked keeps its resume probe
|
|
97
101
|
# live: empty is the not-blocked state, so seeding it empty would leave the loop
|
|
98
102
|
# asleep through the very CI move the block is waiting on.
|
|
@@ -105,14 +109,14 @@ watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebas
|
|
|
105
109
|
last_seen_file="${slot}/last_seen.json"
|
|
106
110
|
[ -f "$last_seen_file" ] || last_seen_file=/dev/null
|
|
107
111
|
rebase_attempted=0
|
|
108
|
-
|
|
112
|
+
ci_settled=0
|
|
109
113
|
blocked_digest_floor=""
|
|
110
114
|
rebase_budget_exhausted=0
|
|
111
115
|
while IFS= read -r evidence_line; do
|
|
112
116
|
evidence_line="${evidence_line%$'\r'}"
|
|
113
117
|
case "$evidence_line" in
|
|
114
118
|
REBASE_ATTEMPTED=*) rebase_attempted="${evidence_line#REBASE_ATTEMPTED=}" ;;
|
|
115
|
-
|
|
119
|
+
CI_SETTLED=*) ci_settled="${evidence_line#CI_SETTLED=}" ;;
|
|
116
120
|
BLOCKED_CIDIGEST=*) blocked_digest_floor="${evidence_line#BLOCKED_CIDIGEST=}" ;;
|
|
117
121
|
REBASE_BUDGET_EXHAUSTED=*) rebase_budget_exhausted="${evidence_line#REBASE_BUDGET_EXHAUSTED=}" ;;
|
|
118
122
|
esac
|
|
@@ -120,7 +124,7 @@ done < <(node "${script_dir}/pr-watch-verdict.mjs" arm-floors --pr-key "${repo}#
|
|
|
120
124
|
--head "$head_sha" --rebase-key "$rebase_key" < "$last_seen_file" 2>/dev/null)
|
|
121
125
|
|
|
122
126
|
ci_red_floor=""
|
|
123
|
-
[ -n "$ci_red_head" ] && [ "$
|
|
127
|
+
[ -n "$ci_red_head" ] && [ "$ci_settled" = "1" ] && ci_red_floor="$ci_red_key"
|
|
124
128
|
rebase_floor=""
|
|
125
129
|
[ -n "$rebase_due_key" ] && [ "$rebase_attempted" = "1" ] && rebase_floor="$rebase_due_key"
|
|
126
130
|
|
|
@@ -145,9 +149,9 @@ echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} l
|
|
|
145
149
|
echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
|
|
146
150
|
if [ -n "$ci_red_head" ]; then
|
|
147
151
|
if [ -n "$ci_red_floor" ]; then
|
|
148
|
-
echo "DRAIN ci red at arm time —
|
|
152
|
+
echo "DRAIN ci red at arm time — fix-ci escalated or spent its attempts on head ${head_sha:0:8}, floored"
|
|
149
153
|
else
|
|
150
|
-
echo "DRAIN ci red at arm time —
|
|
154
|
+
echo "DRAIN ci red at arm time — fix-ci not finished with head ${head_sha:0:8}, not floored: dispatch fix-ci"
|
|
151
155
|
fi
|
|
152
156
|
fi
|
|
153
157
|
if [ -n "$rebase_due_key" ]; then
|
|
@@ -80,15 +80,24 @@ watch_checks_settled_red() {
|
|
|
80
80
|
[ "${failed_count:-0}" -gt 0 ] 2>/dev/null
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
# The identity of one red verdict: the head plus the run ids of its failed
|
|
84
|
+
# checks (the state line's red-check-runs).
|
|
85
|
+
watch_ci_red_key() {
|
|
86
|
+
printf '%s@%s' "$1" "$2"
|
|
87
|
+
}
|
|
88
|
+
|
|
83
89
|
# The head's checks having settled red (watch_checks_settled_red).
|
|
84
90
|
#
|
|
85
|
-
# The floor is
|
|
86
|
-
# not monotonic: it flips green to red and resets on every push.
|
|
87
|
-
#
|
|
91
|
+
# The floor is a red key (watch_ci_red_key) rather than a monotonic id because a
|
|
92
|
+
# check rollup is not monotonic: it flips green to red and resets on every push.
|
|
93
|
+
# The head alone is not enough: a fix-ci cycle that settles the walkthrough or
|
|
94
|
+
# reruns a check keeps the head, and when that new run comes back red too it is
|
|
95
|
+
# news the cycle is waiting on. A head-only floor swallowed it, so the re-run's
|
|
96
|
+
# verdict never reached anyone.
|
|
88
97
|
watch_wake_ci_red() {
|
|
89
|
-
local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4"
|
|
98
|
+
local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" red_key="$5" floor="$6"
|
|
90
99
|
[ -n "$head_sha" ] || return 1
|
|
91
|
-
[ "$
|
|
100
|
+
[ "$red_key" != "$floor" ] || return 1
|
|
92
101
|
watch_checks_settled_red "$pending_count" "$failed_count" || return 1
|
|
93
102
|
echo "EVENT pr=$pr checks settled red head=$head_sha"
|
|
94
103
|
}
|
|
@@ -25,7 +25,7 @@ query($owner: String!, $name: String!, $number: Int!) {
|
|
|
25
25
|
contexts(first: 100) {
|
|
26
26
|
nodes {
|
|
27
27
|
__typename
|
|
28
|
-
... on CheckRun { name status conclusion }
|
|
28
|
+
... on CheckRun { databaseId name status conclusion }
|
|
29
29
|
... on StatusContext { context state }
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -178,6 +178,7 @@ while :; do
|
|
|
178
178
|
pending_checks="${STATE_FIELDS[7]-}"
|
|
179
179
|
failed_checks="${STATE_FIELDS[8]-}"
|
|
180
180
|
ci_digest="${STATE_FIELDS[9]-}"
|
|
181
|
+
ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
|
|
181
182
|
|
|
182
183
|
if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
|
|
183
184
|
echo "TERMINAL pr=$pr_number state=$pr_state"
|
|
@@ -206,12 +207,12 @@ while :; do
|
|
|
206
207
|
done
|
|
207
208
|
fi
|
|
208
209
|
|
|
209
|
-
# Either floor quiets the
|
|
210
|
-
# it, the reported one when a loop already told the session it
|
|
210
|
+
# Either floor quiets the red verdict: the watermark's when fix-ci settled
|
|
211
|
+
# it, the reported one when a loop already told the session about it.
|
|
211
212
|
ci_red_floor="$watermark_ci_red"
|
|
212
|
-
[ "$floor_ci_red" = "$
|
|
213
|
-
if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_floor"; then
|
|
214
|
-
floor_ci_red="$
|
|
213
|
+
[ "$floor_ci_red" = "$ci_red_key" ] && ci_red_floor="$floor_ci_red"
|
|
214
|
+
if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_key" "$ci_red_floor"; then
|
|
215
|
+
floor_ci_red="$ci_red_key"
|
|
215
216
|
has_reported=1
|
|
216
217
|
fi
|
|
217
218
|
|