Skip to content

ci-health: detect checks that degrade mergeStateStatus without completing (stuck runner-label / orphaned status / never-settling) #374

Description

@kyle-sexton

Problem

Some checks degrade mergeStateStatus to UNSTABLE without ever completing, blocking a
merge-readiness read even when every REQUIRED check is green. Three observed classes:

  1. A job stuck QUEUED forever on an unmatched self-hosted runner label (post-migration sentinel).
  2. An orphaned StatusContext: posted pending with null creator / empty target_url / no backing
    run — never resolves, no branch-side run to cancel.
  3. A non-required check that never settles but still holds UNSTABLE.

Dominant operational wall of the autopilot run — the only thing that blocked reaching literal zero.
Cancelling makes it worse (CANCELLED is a failure state). Evidence: medley#1567 (sentinel),
medley#1597 (orphaned codex-review); retrospective
.work/handoffs/babysit-autopilot-retro.md §1.2 / §1.3 / §1.4 / §2.1.

Recommended shape (re-derived blind to the original "skill" framing)

NOT a skill. Engine signal (detect) + a routing reference doc. Detection is the engine's job;
routing/escalation is a judgment call prose owns.

Detection — in the engine, zero new fetches

mergeStateStatus == UNSTABLE is itself the trigger: it already means "mergeable, required gates
satisfied, a non-required commit status not passing" (per the pull-request skill's
reference/readiness.md). The snapshot engine already captures mergeStateStatus and normalizes
every check with type (StatusContext vs CheckRun), effective_state, target_url/details_url,
and a timestamp — so all three classes are detectable with no new fetch:

  • (b) orphaned StatusContext: type==StatusContext + pending + empty target_url.
  • (a) stuck QUEUED: type==CheckRun + QUEUED + age past a threshold (age = snapshot
    generated_at − check createdAt/startedAt). The root cause (unmatched runner label) needs an
    Actions-jobs API call — that is remediation diagnosis, fetched on demand by the playbook, not
    baked into every cycle.
  • (c) non-required never-settler: under UNSTABLE, anything still pending/failing that isn't (a)/(b)
    past the age threshold.

Emit as a new snapshot per-PR JSON field (e.g. checks.stuck[] = {name, type, class, target_url, details_url, age_seconds}). Formalize the internal sort timestamp into a public age_seconds;
consumers cite the snapshot CLI + JSON field name, never the internal classifier module.

Correctness guard (critical)

The classified stuck signal must surface as a material_findings entry (report/escalate), never a
blockers string
— a sticky blocker would pin classification==active and re-dispatch a worker
every cycle (the fan-out trap the suppressible/unsuppressible delta model exists to prevent).
Branch-config-fixable cases (wrong runs-on: in the branch's own workflow YAML) ride the normal
head_sha_changed delta; org/settings-class (runner pool, orphaned status, branch protection) stay
material + escalate. Fire only under UNSTABLE + age threshold (configurable, mirroring
max_quiet_recheck_seconds) so normal in-flight CI and pending required checks are never flagged.

Routing — in prose

New reference/stuck-checks.md (sibling of freshness.md), wired into SKILL.md References +
operational runbook. Points at — never restates — safety.md's Stop-and-Ask / Never-Do-Automatically
rules (runners/settings/branch-protection), the merge wrapper's requiredChecks JSON (confirm
required-green), and the new checks.stuck field. Route CI-config fixes upstream (branch CI /
ci-workflows select-runner); orphaned-status/settings to github-iac / app config.

Files

  • scripts/babysit_checks.py — per-check stuck-class from type/state/target_url/timestamp (the
    shared classifier, so the merge gate benefits too)
  • scripts/babysit_delta.py — compute the signal in the per-PR classifier that already derives
    blockers/material_findings and holds mergeStateStatus
  • reference/stuck-checks.md (new) + SKILL.md (References + runbook step)

Rejected shapes

Standalone script (re-implements check normalization = a 2nd source of truth), pure prose skill
(can't detect → reintroduces the survey-without-classifying failure), subagent (no benefit for a pure
function over data in hand).

SSOT note

The fact "UNSTABLE from a stuck non-required check ≠ a required-check failure" currently lives in the
pull-request skill's readiness reference. Optionally lift it to the plugin-scope shared reference/
(where review-discipline.md already serves both skills) and point both at one copy.

Related

Merge-gate stuck-check policy (retro §1.4): the gate correctly refuses UNSTABLE and forbids an
--admin/gh pr merge bypass — this auditor is the clean path, not a gate-bypass mode.

Metadata

Metadata

Assignees

Labels

priority: mediumReal value, no hard deadline; normal backlog flow.status: readyTriaged, unblocked, and fully specified; eligible to pick up.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions