PR Monitoring — Technical Implementation

Server-driven wake loop + a one-call MCP tool. The agent supplies judgment; the server supplies truth.

Plan · July 23, 2026 · v2 — revised after adversarial review by gpt-5.6-sol (Codex) · companion to the UX plan (v2), which fixes terminology, mocks, and decisions D1–D6 · v1→v2 changelog

1 · Architecture in one picture

Monitoring is not an agent that stays alive polling. A dedicated server-side PullRequestMonitor service owns a per-thread monitor registration, polls GitHub on a 30s cadence, and on new actionable events dispatches a normal turn to the thread with a self-contained prompt. Between wakes the thread's session is genuinely idle — zero tokens, no live activity.

GitHub monitor snapshot: reviews, comments, checks, head SHA T3 CODE SERVER PullRequestMonitor dedicated 30s loop, per registration Registration generation · cursor · wakeCount explicit teardown hooks Wake dispatch claim → revalidate → send → ack cursor MCP: monitor_start single tool, idempotent Domain events → projections orchestration decider/projector → websocket (not runtime events) Agent session Claude · Codex · Cursor … judgment only UI pill · blockers strip · notify poll 30s diff new event wake turn (self-contained prompt) monitor_start websocket git push · gh

Division of labor: facts about GitHub → server (blockers, new events, readiness — a poller reading check runs can't hallucinate green). Judgments about code → agent (is the bot right, how to fix, when to dismiss with a reply). UI state → structured events only (tool call in, projections out — never parse prose, never trust the agent to keep a promise).

2 · Why not a skill / agent-side loop

The one skill-shaped artifact that survives: the babysitting policy text. It lives server-side as a template injected into every wake turn (§5) — versioned with the product, harness-agnostic, compaction-proof, identical for every harness (no per-harness seasoning in v1).

3 · Components

ComponentStatusWhat it is
MCP monitor_start tool new One tool, registered in McpHttpServer.ts alongside the preview toolkit, added to the token capability scopes in McpSessionRegistry.ts (currently preview-only — small but real wiring, not zero). Already injected into every harness per-thread with bearer auth. Idempotent: re-calling for the same PR is a no-op; calling with a different PR is rejected unless the user asked. The tool description is the natural-language entry: "Call this after creating or identifying the PR when the user asked you to monitor / babysit / watch it." No monitor_stop: stop is a UI/domain action; an agent that believes the goal is impossible asks the user normally.
Monitor registration new One authoritative record per thread inside the PullRequestMonitor service: { threadId, prNumber, generation, cursor, wakeCount, status }. Explicit teardown on every path — session exit/replacement, thread deletion/archive, terminal PR state, user stop, server shutdown. (Review finding: nothing inherits lifecycle from McpProviderSession automatically; it's a plain map with explicit setters/clearers.) Session-scoped per D2: session death tears it down → "Monitoring stopped" marker.
PullRequestMonitor service new A dedicated registration-driven 30s poll loop (reusing interval/backoff helpers only). Not an extension of VcsStatusBroadcaster — that poller is per-cwd, reference-counted, and created/destroyed by UI stream subscribers (VcsStatusBroadcaster.ts:445-503); a disconnected web client would silently kill monitoring, recreating the exact failure this feature exists to fix.
GitHub monitor snapshot new A GitHub-specific getPullRequestMonitorSnapshot(pr): reviews, review comments + resolution state, check runs, head SHA, mergeability, draft state, with explicit pagination. Deliberately not generalized into the cross-provider source-control abstraction in v1 — the existing normalized PR record carries only identity/refs/state/updatedAt (gitHubPullRequests.ts:10), and generalizing before GitHub semantics are settled would balloon P0. GitHub-only in v1.
Wake dispatch protocol new The racy parts of v1's design, made explicit — see §6 invariants I1/I5. Claim → revalidate terminal state and generation immediately before sendTurn → dispatch through ProviderCommandReactoradvance the cursor only after the turn actually starts. User messages always win: a pending wake is discarded (events stay un-acked and re-diff next poll); an in-flight wake follows existing provider interrupt/supersede behavior.
Domain events extend Orchestration domain events, not ProviderRuntimeEvent variants (runtime events originate in adapters; monitor state originates in the server — review finding 6): monitor.started, monitor.snapshot-updated, monitor.ended(reason: ready | stopped | terminal | session-ended | needs-attention). Flow decider → projector → projections → websocket like thread.settled does today.
UI: pill, strip, ready state extend Projection carries monitoring state; sidebar pill "Monitoring PR #n" (steady through fixes, D6), header strip with blockers + Stop, "Ready to merge / No known blockers" prominent state with confirm-to-merge (D1). "Edit gates" cut from v1. Per the UX plan mocks.
Escalation (agent-side) reuse The existing user-input.requestedrespondToUserInput round-trip — used only by the agent mid-turn ("CI failed 3×, same assertion — your call"). It requires a live adapter-side deferred, so the server cannot inject into it (review finding 4). Server-side escalation instead ends monitoring with needs-attention (§4 step 7b).
Settle / warm logic reuse Untouched — including for wake turns. A monitoring thread simply stays unsettled for the duration (§6 I2); no settle-neutral carve-out. Hand-off to the existing #4309 merged/closed → warm → settled flow on exit.
Notifications reuse AgentAwarenessRelay phases; two push-worthy transitions only: ready, and needs-attention.

4 · Step-by-step lifecycle

1 · User prompts normally agent

"Fix the flash, put up a PR, babysit it until the bots approve." Standard working turn; nothing monitoring-related is active.

2 · Agent opens the PR, calls monitor_start agent → MCP

Bearer token resolves to the thread via McpSessionRegistry. Handler validates the PR is open (a draft is reported as a blocker, not silently accepted), creates the registration with a fresh generation, baselines the cursor from an initial snapshot (pre-existing comments are baselined, not replayed), and emits monitor.started.

3 · UI flips to Monitoring projection → WS

Pill "Monitoring PR #4412", blockers strip renders. The thread remains unsettled and in the active area, receded — per the UX plan's inbox-zero treatment. Completed/unread suppression applies to wake turns; the turn ends normally.

4 · Poller watches; mostly does nothing server

Every 30s: fetch the monitor snapshot; diff against the cursor. No diff → no action. The strip's "waiting on Bugbot · 2 checks pending" comes from monitor.snapshot-updated, not from the agent.

5 · New actionable event → wake turn server → agent

New review-bot comment, red check on the current head, PR behind main (debounced), re-review. Dispatch follows the protocol: claim, revalidate (registration live? generation unchanged? PR still open? no user turn active or queued?), send the self-contained wake prompt (§5), then advance the cursor once the turn starts. Agent verifies the claim against source, fixes or dismisses-with-reply, pushes. Pill stays "Monitoring" (D6); turn completes quietly.

6 · Poller re-evaluates after the push server

Approvals and check results are always evaluated against the current head SHA — a stale approval of an older commit is not green. Loop back to step 4. Events arriving while a wake runs coalesce into one combined wake after it completes (I5).

7a · No known blockers → ready server decides

Readiness is GitHub's own truth, not a checklist the server invents: mergeable, no failing or pending required checks for the head SHA, no unresolved change-requests from bots, no unresolved review threads the agent was woken for. If required-status data isn't reliably obtainable, the state is labeled "No known blockers" rather than over-promising "Ready to merge" (review finding 9: vacuous approvals, not-yet-created checks, and stale approvals are the false-green traps). Registration closes, monitor.ended(ready) fires: prominent state, unread, the one push notification, confirm-to-merge button (D1). User merges → #4309 warm/settle takes over untouched.

7b · Stuck → needs attention agent or server

Agent mid-turn uses the existing user-input.requested channel (live session, works today). If wakeCount hits the breaker (~10) or wakes keep failing, the server ends monitoring with monitor.ended(needs-attention) and a prominent activity — no server-invented input request (there's no adapter deferred to answer it). Restarting is one user message.

7c · Terminal exits server

PR merged/closed externally → monitor.ended(terminal), silent, auto-settle proceeds (I1). User stop button → monitor.ended(stopped). User manually settles the thread → treated as stop (I2). Session death/replacement, thread deletion, server shutdown → explicit teardown → "Monitoring stopped" marker (D2 floor).

5 · The wake prompt (where the "skill" actually lives)

Every wake turn is self-contained — no reliance on conversation memory surviving compaction, and identical across harnesses (per-harness seasoning cut in review; the prompt must be sufficient for all providers anyway):

New activity on PR #4412 (monitoring, wake 3/10).

Bugbot commented on src/thread.ts:141: "possible null deref when …"

Status: CI ✓ · Macroscope ✓ · Bugbot ● changes requested · CodeRabbit ○ pending
Head: a41c2f9 (approvals/checks are evaluated against this commit)

Policy:
- Verify bot claims against the source before acting.
- Fix legitimate findings and push.
- Dismiss false positives with a brief reply — never silently ignore or comply.
- For CI failures: compare against main; re-run suspected flakes; if the same
  real failure repeats, ask the user via a question rather than guessing.
- Rebase if the PR is behind main.
- If the goal has become impossible, say so and ask the user.

The PR-creation policy (in the initial-turn instructions and monitor_start's description) still says real PR, never draft — but as agent guidance, not monitoring architecture: the monitor validates PR state and reports "draft" as a blocker instead of assuming.

6 · Invariants (the regression guards)

These exist because of a live bug in today's improvised flow: merge PR → thread settles → monitor sees "something changed" → work restarts → settled thread pops back into the active list. That must be impossible by construction:

I1
No wake after terminal state — revalidated at the send boundary, not just per poll

The poller checks PR state first each cycle and discards all other events in a terminal cycle. But ordering within one poll is not enough (review finding 10: a wake queued in poll A can start after a merge that poll B would have seen). So every wake carries the registration's generation and is revalidated immediately before sendTurn: registration absent, generation changed, or PR merged/closed → the wake is discarded. Structurally, monitoring normally ends at all-green — one step before merge — so by merge time the registration is usually already gone and post-merge noise has nothing to match against.

I2
Monitoring holds the thread unsettled; user settle means stop

Replaces v1's "settle-neutral wake turns." Review finding 1 (verified in decider.ts): turn starts and session-starting transitions each independently emit thread.unsettled when an override exists — a "marker checked in one decider branch" would actually thread through commands, messages, session lifecycle, and settle eligibility. Instead: a monitoring thread simply stays unsettled until monitor.ended — which matches the UX plan anyway (monitoring rows live in the active area, receded). The merge-resurrection bug is prevented by I1, not by settle gymnastics. One rule remains: the user manually settling a monitoring thread ends monitoring — the user's file-away always wins.

I3
The server declares readiness; the agent never does

Readiness is computed from the GitHub snapshot — head-SHA-anchored, mergeability-based (§4 7a). A confused agent cannot produce a false green; there is no agent-facing stop/complete tool at all in v2.

I4
Monitoring never swallows a blocked state

Approval and input requests raised during a wake turn surface with full prominence, exactly as in a normal turn. The mode only suppresses success-shaped noise (Completed pill, unread-on-completion) — never needs-a-human signals. Wake turns inherit the thread's runtime mode, so an approval-required babysit will surface approvals mid-wake — correct, if chatty.

I5
One wake at a time; user messages always win; no event is lost

Dispatch is claim-based, not check-then-send (review finding 2: the decider accepts concurrent turn starts and the reactor forks sendTurn asynchronously). A user message discards any pending wake — its events stay un-acked in the cursor and re-diff next poll (review finding 3: never mark events seen before the turn actually starts, or a failed dispatch loses them forever). Events arriving mid-wake coalesce into one combined wake after completion, keeping wakeCount honest as a breaker.

7 · Build phases

P0De-risk: monitor snapshot + readiness + cursor semantics

Build getPullRequestMonitorSnapshot (GitHub-specific) and settle the correctness core before any architecture lands — review findings 9 and 12 moved all of this from "details to settle later" into P0:

P1Registration + tool + domain events

monitor_start tool (+ capability scope in McpSessionRegistry), PullRequestMonitor service skeleton with explicit teardown hooks (session exit/replacement, thread deletion, shutdown), monitor.* domain events through decider/projector to projections. At the end of P1 an agent can call monitor_start and the thread state reflects it.

P2Poll loop + wake dispatch protocol

The 30s registration-driven loop, diffing, the wake-prompt template, and the full dispatch protocol (claim → revalidate → send → ack cursor; user-priority; coalescing; generation checks). Circuit breaker ending in needs-attention. This phase implements I1/I2/I5 and is where the review's race findings get their tests.

P3UI: pill, strip, ready state, stop

Sidebar pill + sub-line, header blockers strip with Stop, "Ready to merge / No known blockers" prominent state with confirm-to-merge dialog, "Monitoring stopped" marker, the two push notifications. Per the UX plan mocks (minus Edit gates, cut).

P4Polish

"Monitor after creating" checkbox on the PR dialog (appends a sentence to the outgoing prompt — no new plumbing); attach-to-existing-PR path ("Bugbot left comments, babysit this" → agent calls monitor_start on the open PR); one-tap "resume monitoring" after session restart (re-calls monitor_start). Re-evaluate the v2 cuts (named-bot gates, per-harness prompt tuning) against real usage.

8 · Explicitly rejected

9 · v1 → v2 changelog (from the gpt-5.6-sol review)

Adversarial review by Codex (gpt-5.6-sol) against the actual codebase; the two highest-impact findings were independently re-verified in source before adopting. Full report: /tmp/codex-review.YtEhdY/report.md.

Extend VcsStatusBroadcaster with per-thread fetches
Dedicated PullRequestMonitor service. Broadcaster pollers are UI-refcounted per cwd; a client disconnect would silently kill monitoring. (verified)
Wake turns settle-neutral via a marker in one decider branch
Monitoring holds the thread unsettled. Turn-start and session-starting each independently un-settle; the carve-out would span the whole lifecycle. Matches the UX anyway. (verified)
monitor.* as ProviderRuntimeEvent through ingestion
Orchestration domain events (decider/projector), with an ended(reason) enum instead of separate completed/stopped events.
"Check idle then dispatch"; mark events seen before dispatch; terminal check per poll cycle
Wake dispatch protocol: claim → generation + terminal revalidation at the send boundary → ack cursor only after turn start; user messages always win; failed dispatches never lose events.
Server escalation reuses user-input.requested
Breaker ends monitoring with needs-attention + prominent activity; that channel needs a live adapter deferred the server can't fake. Agent-side use is unchanged.
monitor_start + monitor_stop; configurable gates + Edit gates UI; per-harness seasoning
One idempotent tool, fixed readiness rule, one prompt for all harnesses. Stop is a UI action; gates config revisited in P4.
"Ready to merge" = all requested reviewers + all checks green; readiness details settled during build
Head-SHA-anchored, mergeability-based readiness defined in P0, with "No known blockers" as the honest fallback label. Traps: vacuous approvals, stale approvals after push, not-yet-created required checks, re-run check IDs.
Registration "dies with the session by construction" (McpProviderSession pattern)
Explicit teardown on every path — session exit/replacement, thread deletion, terminal PR, user stop/settle, shutdown. Nothing is automatic.

Summary: three genuinely new constructions (GitHub monitor snapshot, PullRequestMonitor service with the wake-dispatch protocol, one MCP tool) plus a handful of domain-event/projection extensions — everything else reuses the input-request round-trip, the MCP injection already present in every harness, and the untouched #4309 settle logic. v2 is smaller than v1 where it counts (one tool, no gates config, no settle carve-out, no new request protocol) and stricter where it counts (dispatch races, cursor acking, head-SHA readiness — the parts that decide whether "merged means quiet" and "green means green" actually hold).