feat(source-control): detect stuck checks degrading mergeStateStatus without completing (#374) - #790
Conversation
…without completing Some checks hold a PR's mergeStateStatus at UNSTABLE without ever settling (a job stuck QUEUED on an unmatched runner label, an orphaned pending StatusContext with no backing run, a non-required check that never settles), blocking a clean merge-readiness read even when every REQUIRED check is green. Classify three stuck-check classes from data the snapshot engine already normalizes (no new fetch): orphaned_status, stuck_queued, never_settling. Emit them as a per-PR checks.stuck[] field (always present) and surface the signal as a material_findings entry -- never a blockers string, which would re-pin the PR active and re-dispatch a worker every cycle. Detection fires only under UNSTABLE and is age-gated (except orphaned_status) via a new configurable babysit_stuck_check_age_seconds threshold. New reference/stuck-checks.md routes remediation and escalation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
|
Claude finished @kyle-sexton's task in 4m 5s —— View job PR Review: feat(source-control): detect stuck checks degrading mergeStateStatus
SummarySolid, tightly-scoped change. The design rationale (stuck signal → Two real findings and a few minor observations follow. Finding 1 —
|
|
Claude finished @kyle-sexton's task in 4m 8s —— View job Security Review
No security issues found. Full analysis below. Merge-gate integrity (CRITICAL surface — clean)The core security invariant of this PR is correctly implemented and tested. Stuck-check detection writes only to
The test at Injection surfaces (clean)The new code is purely functional — no subprocess calls, no Config/CLI input validation (clean)
|
…ll-quality cap Wiring the stuck-checks reference into SKILL.md pushed it to 505 lines, over the skill-quality-gate hard cap. Compress the new References/runbook entries and collapse two adjacent multi-line reference bullets to single lines (meaning-preserving; the repo disables MD013). Now 499 lines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
|
Claude finished @kyle-sexton's task in 4m 2s —— View job Security Review — PR #790 (stuck checks detection)
No security issues found. Full analysis below. Merge-gate integrity (CRITICAL surface — clean)The load-bearing invariant is correctly implemented and tested. Stuck-check detection writes only to
This means a PR holding Injection surfaces (clean)The new code is purely functional — no subprocess calls, no UNSTABLE gate (clean)All stuck-check classification is gated on Input validation (clean)
URL passthrough (pre-existing behavior)
|
|
Claude finished @kyle-sexton's task in 2m 54s —— View job PR Review: feat(source-control): detect stuck checks degrading mergeStateStatus
SummaryThe fix commit ( Two findings from the prior review cycle are still open; no new issues found. Finding 1 —
|
…ullable age_seconds Address post-green review findings on the stuck-check detector: - Add a never_settling case for an IN_PROGRESS CheckRun aged past the threshold (only the StatusContext variant was covered). - Assert age_seconds is null for an orphaned_status entry with no inception timestamp, and document that nullable contract in reference/stuck-checks.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
|
Claude finished @kyle-sexton's task in 1m 19s —— View job PR Review: feat(source-control): detect stuck checks degrading mergeStateStatus
New commit (
|
| Path | Test |
|---|---|
Only fires under UNSTABLE |
test_only_fires_under_unstable |
orphaned_status — with createdAt |
test_orphaned_status_is_not_age_gated |
orphaned_status — no createdAt, null age |
test_orphaned_status_without_inception_has_null_age ✨ |
stuck_queued — young vs old |
test_stuck_queued_is_age_gated |
stuck_queued — no startedAt, unflagged |
test_queued_without_start_time_is_unflagged |
never_settling — StatusContext with targetUrl |
test_never_settling_pending_past_threshold |
never_settling — IN_PROGRESS CheckRun |
test_never_settling_in_progress_check_run_past_threshold ✨ |
| Settled failing/success — never stuck | test_settled_and_success_checks_are_never_stuck |
No new issues found. This PR is clean and ready to merge.
|
Claude finished @kyle-sexton's task in 2m 29s —— View job Security Review — PR #790 (stuck checks detection)
No security issues found. Full analysis below. Merge-gate integrity (CRITICAL surface — clean)The load-bearing invariant is correctly implemented and tested. Stuck-check detection writes only to Consequence: a PR holding Injection surfaces (clean)The new code is purely functional — no subprocess calls, no UNSTABLE gate (clean)All stuck classification is gated on Input validation (clean)
URL passthrough (pre-existing behavior — not a new risk)
|
|
Post-green review classification (work lane):
No DEFER-FORBIDDEN class applies (security-posture explicitly clean across 3 independent passes; the load-bearing fail-open-risk invariant — stuck signal can never re-pin No inline review threads exist on this PR (GraphQL query returned empty) — nothing to resolve. Handing off to the babysit lane for ongoing watch. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @kyle-sexton's task in 2m 4s —— View job PR Review: feat(source-control): detect stuck checks degrading mergeStateStatus
SummaryThis is a well-scoped, correctly-implemented detection signal with a clear design rationale. Five prior review passes (3 security + 2 code review) across two CI runs already audited this in depth. The two open findings from earlier cycles are both closed in No new issues found. Full verification below. Prior findings — confirmed resolvedFinding 1 — nullable
Finding 2 —
Classifier logic — verifiedThe three classification branches in
The Routing invariant — confirmed
Config threading — complete and internally consistent
Test coverage — complete
Reference doc — correct
No new issues found. This PR is clean. |
|
Claude finished @kyle-sexton's task in 2m 24s —— View job Security Review — PR #790 (stuck checks detection)
No security issues found. Full analysis below. Merge-gate integrity (CRITICAL surface — clean)The load-bearing invariant is correctly implemented and tested. Stuck-check detection writes only to
Injection surfaces (clean)The new code is purely functional — no subprocess calls, no UNSTABLE gate (clean)All stuck classification is gated on A check with a future-dated Input validation (clean)
URL passthrough (pre-existing behavior — not a new risk)
|
|
Operator basis recorded (in-session approval, 2026-07-21 ~08:4xZ): operator approved merging this This was generated by AI (control tower, session 6). |
Summary
Some checks degrade a PR's
mergeStateStatustoUNSTABLEwithout ever completing, blocking aclean merge-readiness read even when every REQUIRED check is green. Three observed classes: a job
stuck
QUEUEDforever on an unmatched self-hosted runner label; an orphanedStatusContextpostedpendingwith no backing run to cancel; and a non-required check that never settles. This was thedominant operational wall of the autopilot run — the only thing blocking literal zero — and
cancelling makes it worse (
CANCELLEDis a failure state).Per the issue's re-derived shape, this is not a skill: it is an engine-side detection signal
(zero new GitHub fetches — reuse of data the snapshot engine already normalizes) plus a routing
reference doc for judgment-call escalation.
Fix
Shared classifier (
scripts/babysit_checks.py).normalize_checknow emits acreated_atinception timestamp (
startedAtfor a CheckRun,createdAtfor a StatusContext — verifiedempirically against
gh pr view --json statusCheckRollup; a CheckRun carries nocreatedAt),distinct from the existing
_sort_keywhich preferscompletedAtfor latest-wins dedupe. Newpure function
classify_stuck_checks(...)classifies three classes, allpending-category:orphaned_status—StatusContext, pending, emptytarget_url; not age-gated (no backingrun means no start time to age against).
stuck_queued—CheckRunstillQUEUEDpast the age threshold.never_settling— any other pending check past the age threshold.A settled failing check is deliberately excluded. A pending check with no provable start time
(e.g. a QUEUED CheckRun gh reports without
startedAt) is left unflagged for the age-gatedclasses — it fails toward silence rather than a false stuck report. Detection fires only under
mergeStateStatus == UNSTABLE, whose contract ("mergeable, every REQUIRED gate satisfied, anon-required status not passing") makes every check reaching a stuck class non-required by
construction — the gate supplies the required/non-required split with no per-check flag. Because
the classifier lives in the shared module, the guarded merge gate sees the same normalization.
Per-PR classifier (
scripts/babysit_delta.py).classify_prattacheschecks.stuck[](always present, empty when none) =
{name, type, class, target_url, details_url, age_seconds},and — when non-empty — appends a single
material_findingsentry.Correctness guard (the crux of the issue): the signal is
material_findings, never ablockersstring. A sticky blocker would pinclassification == activeand re-dispatch aworker every cycle for a check no branch action can clear (the fan-out trap the
suppressible/unsuppressible delta model exists to prevent).
material_findingsis write-only forclassification — it never feeds
blockers,classification,needs_worker, any fan-out deltaarm, or persisted
prevstate (only its count is persisted). The pre-existing failing/pendingblocker buckets are left completely untouched: this change is strictly additive and never
downgrades anything already blocking, so it cannot weaken the merge gate (which correctly keeps
refusing
UNSTABLE). This is the clean escalation path, not a gate bypass.Configurable threshold, mirroring the
max_quiet_recheck_secondspattern:DEFAULT_STUCK_CHECK_AGE_SECONDS(1800s) →ClassifyConfig.stuck_check_age_seconds→validated_stuck_check_age_seconds→--stuck-check-age-secondsCLI flag →build_config→babysit_stuck_check_age_secondsuserConfig (plugin.json / SKILL.md effective-config table /README config table, all kept in sync).
Routing doc
reference/stuck-checks.md(sibling offreshness.md), wired into SKILL.mdReferences + the operational runbook step 5. It points at — never restates —
safety.md'sStop-and-Ask / Never-Do-Automatically rules, the merge wrapper's
requiredChecksJSON, and thepull-request skill's
readiness.mdUNSTABLE fact; routes CI-config fixes to the branch workflow /ci-workflowsand org/settings/orphaned-status togithub-iac/ app config; and states thenever-auto-fix rule (branch-config-fixable cases ride the normal
head_sha_changeddelta).Rejected shapes (per the issue): a standalone script (second source of truth for check
normalization), a pure-prose skill (can't detect), a subagent (no benefit for a pure function over
data in hand).
The SSOT lift of the "UNSTABLE ≠ required-check failure" fact into a plugin-scope shared reference
is deferred as a follow-up rather than done here: it would edit the
pull-requestskill too,widening the blast radius of a merge-gate-adjacent change. The new doc points at the existing
statement in
readiness.md(point-don't-copy) in the meantime.Verification
All commands run in the worktree against the babysit engine suite.
Baseline before the change: 326 tests, OK; ruff clean. After the change: 337 tests, OK
(+11 new), ruff clean, all guarded-wrapper behavior checks pass:
New test coverage:
tests/test_babysit_checks.py—ClassifyStuckChecksTests(UNSTABLE-only gating; orphaned notage-gated;
stuck_queuedage-gated young-vs-old; QUEUED-without-start-time unflagged;never_settlingpast threshold; settled failing/success never stuck) andNormalizeCreatedAtTests(created_atfromstartedAt/createdAt).tests/test_babysit_delta.py—StuckCheckSignalTests: the load-bearing guard asserts the stucksignal appears in
material_findingsand thatblockersgained no stuck-specific string(the pre-existing pending-check blocker is still present and intact); plus the not-UNSTABLE and
always-present-field cases.
CLI smoke:
Independent review: a fresh-context code-reviewer subagent audited the staged diff (rationale
withheld). It confirmed the merge-gate invariant (stuck never reaches
blockers;materialis nota classification input), the CLI/config threading, the default-value consistency (1800 = 30×60), and
the reference doc, and raised one drift finding — the README config table was missing the new key —
which is fixed in this diff.
Closes #374
Related
QUEUEDrunner-label sentinel) and medley#1597 (theorphaned
codex-reviewStatusContext)..work/handoffs/babysit-autopilot-retro.md(§1.2 / §1.3 / §1.4 /§2.1) is cited by the issue as the origin of these three classes; it is an ephemeral
working-directory artifact and is not present in this worktree.