PR Monitoring — Technical Implementation
Server-driven wake loop + a one-call MCP tool. The agent supplies judgment; the server supplies truth.
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.
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
- T3 Code can't install skills. It only reads them (Claude via
settingSourcesloading worktree.claude/; Codex via theskills/listRPC). Nothing writes skills into worktrees, so a skill-based backbone depends on every repo carrying it. - Instructions can't drive the UI. The feature's core is the thread knowing it's monitoring — pill, blockers strip, ready state. A skill produces an agent that behaves right while the UI stays blind.
- An agent loop is the fragile thing being replaced. Transcript history shows the improvised version (self-scheduled wakeups, state re-serialized into each prompt) and its failure modes: silent stops, "Are you still going?", a live session burning tokens to do what a 30s poller does for free. A hybrid (agent loops + server watches the watcher) still needs all the server-side polling to detect a dead babysitter — strictly more complexity, no benefit.
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
| Component | Status | What 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 ProviderCommandReactor → advance 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.requested → respondToUserInput 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
"Fix the flash, put up a PR, babysit it until the bots approve." Standard working turn; nothing monitoring-related is active.
monitor_start agent → MCPBearer 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.
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.
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.
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.
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).
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.
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.
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:
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.
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.
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.
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.
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
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:
- Stable event identity: comment IDs, edit timestamps, thread resolution state, check-run reruns (new run ID ≠ new failure), force-push effects on inline comments.
- Initial baseline on
monitor_start(pre-existing feedback: baselined or replayed? → baselined, with the initial turn instructed to address anything already open). - Readiness definition: head-SHA-anchored, GitHub mergeability + required checks; "No known blockers" fallback label when required-status data is unavailable.
- Pagination, rate limits, retry/backoff.
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.
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.
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).
"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
- Extending
ProviderInteractionMode(default | plan) withmonitoring— interaction modes are user-preselected UI state; monitoring is agent-initiated mid-conversation. The tool call is the right entry event. - Parsing agent output to detect monitoring intent — the structured tool call is unambiguous; prose parsing is not.
- Agent-side sleep/poll loops in any form — including "hybrid" (agent loops, server supervises), which still requires all the server-side polling and adds only token burn and a second failure mode.
- Webhooks in v1 — the 30s poller works for local/self-hosted and monitoring latency tolerance is minutes, not seconds.
- Agent self-reporting gate status — server reads GitHub directly (I3).
- cut in v2
monitor_stoptool — stop is a UI/domain action; the agent asks the user in prose when stuck. Removes an authorization surface and the completion/impossibility/cancel ambiguity. - cut in v2Settle-neutral wake turns — monitoring holds the thread unsettled instead (I2); the carve-out touched far more lifecycle machinery than v1 admitted.
- cut in v2
monitor.*asProviderRuntimeEvent— server-owned state uses orchestration domain events; runtime events belong to adapters. - cut in v2Extending
VcsStatusBroadcaster— its pollers are UI-subscriber-refcounted per cwd; a client disconnect would silently kill monitoring. Dedicated service instead. - cut in v2Server-raised
user-input.requested— responses route to a live adapter deferred that wouldn't exist; the breaker ends monitoring withneeds-attentioninstead. - cut in v2Configurable/editable gates — arbitrary bot-name → login matching is a swamp and invites vacuous or false green; fixed readiness rule in v1, named-bot narrowing revisited in P4.
- cut in v2Per-harness prompt seasoning (Codex
developer_instructionsbranch) — the wake prompt must carry everything for every harness anyway; revisit only if adherence measurably differs.
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.
ended(reason) enum instead of separate completed/stopped events.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).