0039 โ A PR's lifecycle reaches the session that opened it by push, not by poll
- Status: Accepted. Built and live 2026-10-02: the PR lifecycle section of
~/.claude/services/review-webhook/listener.js(synced to gera-propflow/dotclaude#145, #146), thepr-autosubhook (dotclaude#144, #146),review-sub arm --auto(local-bin#344) andreview-sub status(local-bin#345). The repo webhook on propflowai gained thedeployment_statusevent. - Date: 2026-10-02
- Deciders: Gera approved the change ("ok") after the risk was named: the listener is the one process that tells every session in the fleet about reviews, so a broken edit deafens all of them. Recorded by the worker that built it, per ADR-0004.
The problem
A session that opened a propflowai PR learned two things about it without polling, and only if it had armed review-sub by hand: the review verdict and the merge. Everything between and after was invisible. Did checks fail? Did auto-merge arm? Did the PR enter the merge queue, and was it kicked out of it, and why? And nothing in the fleet tracked the last stage at all: is this PR live in production? Under batched promotes (one production deploy per 30-minute window) that is 30 to 90 minutes after the merge, longer behind a red main. A session that wanted any of it had to poll gh, which review-turns forbids for good reason (shared API rate limits, and turns spent asking).
The decision
The review-webhook listener follows every propflowai PR through its whole life and writes one status file per PR,
~/.claude/review-subs/status/<owner>-<repo>-<pr>.json, with the stage history and timestamps, whether or not anyone subscribed.review-sub status <pr>reads it. Stages and their sources:Stage Webhook Wakes the subscriber? OPENED,CHECKS_RUNNINGpull_requestopened / reopened / synchronizeno, status file only CHECKS_FAILEDcheck_suitecompleted, failure, whosehead_shais the PR's current headyes, once per head REVIEWthe existing verdict gate the existing verdict wake, not a second one AUTO_MERGE_ARMEDpull_requestauto_merge_enabledyes QUEUEDpull_requestenqueuedyes KICKED_OUT_OF_QUEUEpull_requestdequeued, reason notMERGE/ALREADY_MERGEDyes, with GitHub's reason MERGEDpull_requestclosed + mergedthe existing merge wake DEPLOYED_TO_PRODUCTIONdeployment_statussuccess, environmentProductionyes: "PR #N is live in production as of "Live in production" is the GitHub Deployment vercel[bot] records for ci.yml's
vercel-promote. Measured 2026-10-02: db36814abc'sProductiondeployment gotsuccessat 19:05:59Z and the promote job loggedREADY โ production serves db36814abcat 19:06:11Z, same Vercel URL. It is alsoprod-freshness-alarm.yml's own fallback definition of what production serves. The PRs that went live are the(#N)squash subjects between the previous production SHA and the new one, from one compare call per deploy. A deployment that is not ahead of the last one (a late status, a rollback) moves nothing. Only the web is tracked; the Lambdas and renewal worker deploy after it in the same job.A session is subscribed by its own
gh pr create. A PostToolUse hook sees the PR URL in the command's output and runsreview-sub arm --auto(36 h TTL, because the production stage lands long after the 2 h default would expire). Inside a sub-agent it recordsagentIdandwakeIsProxy: true: the wake lands on the Driver's pane, which is the only pane there is. A later manual re-arm never shortens it.A sub-agent's PR wakes its PARENT, and that is the decision, not a defect (settled 2026-10-02 after the first live run: #15707's ๐ก and CHECKS_FAILED wakes landed in the coordinator that spawned the lane). A sub-agent has no pane and no turn to resume, so nothing can type into it, and the parent is the session that owns its lanes' PRs (ADR-0030: wake the Driver that owns it). What was missing was the routing fact, so the wake text now ends
(opened by your sub-agent <id>: forward this to it with SendMessage)(dotclaude#146). Rejected: arming nothing inside a sub-agent (the lane's PR would then be heard by nobody) and waking the lane directly (there is no channel into an Agent-tool sub-agent except its parent's SendMessage).One wake per stage change, never per tick. Dedupe keys live in the status file (stage plus head sha, merge sha or the PR's
updated_at), so a GitHub redelivery or a listener restart cannot re-announce a stage. Stage wakes for one PR are coalesced over 30 s, because enqueue and auto-merge-armed arrive seconds apart. The production wake bypasses the per-PR wake cap, as the merge wake already does.
Why this shape
- Extend, do not add a system. The listener already terminates the tunnel, verifies the signature, holds the subscriptions and owns the wake path. A second daemon would be a second process to keep alive and a second place for the same subscription to drift.
- Fail-safe like
triggerCiFactoryMq. Every new entry point is wrapped in a try/catch that only logs; nothing is awaited on the request path; the stage recorder runs before the "not a verdict" / "not merged" skips but never betweenclassifyVerdictand the verdict'sschedule(). Proven before the restart by replaying real payloads (fetched from the hook's deliveries API, re-signed) through the old and new file in a sandbox: identical review wake text, identical ci-factory-mq trigger, and propflowai'slistener.test.jsat 25 pass / the same 7 fail on both. - No new event volume where none was needed. The queue stages were already in
pull_request(enqueued, dequeued withreason, auto_merge_enabled).check_suitewas already subscribed. Onlydeployment_statuswas added, about two deliveries per deploy.workflow_runandmerge_groupwere considered and not added:workflow_runwould roughly triple a stream that already runs ~40 deliveries a minute, for a "checks started" stage thatsynchronizealready gives.
Relation to other ADRs
- ADR-0002. The wake is the listener's existing
tmux send-keyspath, reused unchanged. This ADR adds kinds of event to it, not a new channel. If the listener's wake ever moves to SendMessage /msg, these stages move with it. - ADR-0003. It is a reflex. It reports stages; it never acts on the PR, and no Driver logic was added.
- ADR-0030 principle 2, and the gap this leaves. The status file is triggered by the artifact: every propflowai PR gets one. The subscription is triggered by the producer (the hook on
gh pr create), because only the producer knows which session and pane it is. A PR opened any other way (the GitHub UI, a script that calls the API directly, a Slack-Smith turn) still gets a status file but wakes nobody unless something armsreview-subfor it. Closing that would mean mapping a PR to a live session from the outside, whichwt-merged-notify's owner index already does for merged worktrees; reuse that if the gap ever matters. - ADR-0009. The catalog signals for the new paths match words written only on success:
pr_stage.recorded(a stage was recorded, dozens per hour) andprod_deploy.live(written only after the compare succeeded and the production state was moved). Neither keys on a wake, which is rare by design and would read stale through every quiet hour.
Consequences
review-sub arm --selfremains for PRs this hook cannot see; nothing it did is superseded.- The status directory prunes files older than 7 days hourly.
_production.jsonholds the last production SHA; a fresh one records a baseline and wakes nobody. - A listener restart can drop a stage wake still inside its 30 s coalescing window. The status file keeps the stage; the wake is lost. The same is true today of the 45 s verdict debounce.
propflowai/scripts/review-webhook/listener.jsis a third copy and has diverged both ways from the live file (it has a lane clause the live file does not). Not reconciled by this change.
Amendment 2026-10-07: wake only when there is something to do
Gera, 2026-10-07: "turn off all of them except for merge โฆ or if it failed and we need to make some edits." WAKE_STAGES is now CHECKS_FAILED and KICKED_OUT_OF_QUEUE only. AUTO_MERGE_ARMED, QUEUED and DEPLOYED_TO_PRODUCTION are still recorded in the status file (review-sub status <pr>) but wake nobody. The merge wake and the review-verdict wake are separate paths and unchanged. Live in ~/.claude/services/review-webhook/listener.js (local commit 1e17648); the stage table above reads "yes" for those three stages and is superseded by this amendment.
Amendment 2026-10-08: a passing review wakes nobody
Gera, 2026-10-08, on still being pinged: "we only want to have when there's an error we need to fix something, or when we merge." A review verdict of APPROVED or NO_REVIEW_NEEDED no longer wakes a session (QUIET_VERDICTS); the signal file is still written. A review asking for changes (๐ก, ๐ด, CHANGES_REQUESTED) still wakes, as "need to fix something". Because an approval no longer wakes, a session arms pr-merge when it opens the PR rather than on the approval; the merge wake follows. Live in ~/.claude/services/review-webhook/listener.js (local commit 731453a).
Amendment 2026-10-22 โ a repeat crash wakes again, and a silent PR re-wakes
propflowai #21732 and #21733 sat ~4h: the review job crashed twice at one head and only the first crash woke anyone. A GitHub Actions rerun reports under the SAME check_suite id (run 39752869336, run_attempt 3, one suite), and the CHECKS_FAILED key was head-only, so the second failure was already in history. Gera approved three fixes:
- Key (dotclaude#285):
CHECKS_FAILED@<head12>#<suiteId>@<updated_at>. A repeat failure is fresh; a redelivery of the same completion is still deduped; older heads still ignored; the 30 s coalescing still makes a burst one wake. - The PR says so (propflowai#21751): ci-followups'
handle-review-infra-failure(retry once, then a check-run plus one marker comment) was gated onworkflow_run.name, which is the run-name (the PR title) for claude-code-review.yml, so it had skipped every crash since 2026-10-01. Now gated onworkflow_run.path. - Silence (dotclaude#286): a webhook cannot see a PR that stops moving, so a 10-minute sweep over the status files re-wakes subscribers with
STUCK(<stage>)when a PR has sat on failed checks or a fixes-needed verdict for 60 min, GitHub confirms it is open at the same head with nothing running, at most hourly and 6 times per episode. This is the one place the listener polls anything, and it polls its own files; GitHub is asked only for a PR that would otherwise wake.
Amendment โ 2026-10-29: superseded in part by ADR-0076
ADR-0076
supersedes ยง3's "a sub-agent's PR wakes its PARENT, and that is the decision"
and the typed delivery mechanics of the 2026-10-07/08 amendments: a worker now
waits on its own signal file and the harness resumes it; owners get coalesced
notices (one per owner per 30 s across PRs; a deploy is one line); nothing is
typed into a pane; quiet holds everything. The stage table and the dedupe rule
(keys in the status file, ยง4) stand. Appended rather than inserted so every
line-number citation into this file stays true (ADR-0023 decision 6).