Repository navigation
fix(ci): unhang the required frontend check, and make an unhung one detectable (#13341, #13045, #13286) - #13346
Merged
Merged
Conversation
…ner (#13341) `Check TypeScript error delta` ran over three hours against a declared `timeout-minutes: 30`, holding a required status check and the singleton self-hosted runner. Its sibling `Run type checking` — the same vue-tsc, reached through `npm run` — finished in about a minute in the same job. The difference was `npx`. Every extra thing npx does before running a binary is an unbounded wait: it resolves the package (network), prompts on stdin when resolution misses (a CI step's stdin never delivers a line and never closes), and takes locks under the npm cache, which on the self-hosted runner is shared with every concurrently executing job rather than being a fresh VM per job. vue-tsc is a devDependency and is already on disk after `npm ci`, so there was nothing for a resolver to do. The script now calls the installed binary directly with stdin closed. Fixing that exposed a fail-open in the same lines: `|| true` swallowed the compiler's exit code, so a vue-tsc that CRASHED without emitting diagnostics counted zero errors and reported PASS. Verified against the pre-change script with a stubbed compiler exiting 134 on an OOM message: "PASS: 0 errors", exit 0. That is the documented OOM mode of this very step (#10298), so the gate was open in exactly its known failure case. Non-zero exit with zero diagnostics is now a hard failure. The step is additionally wrapped in `timeout --kill-after=60s 600`, because GitHub enforces `timeout-minutes` from the runner side and a runner that stops reporting progress may never receive the cancellation. That bound guards the next unknown cause; it is not the fix for this one.
…lth (#13045, #13341) An in-progress required check is rendered exactly like a healthy one, so the only thing separating "running" from "wedged" was a human noticing the clock — which on 2026-08-03 took three hours while every merge in the repository was blocked. Worse, the starvation probe added for #13045 was actively defeated by this. A wedged job is `status=in_progress` carrying a `self-hosted` label, which is precisely the signal `self_hosted_pool_is_serving` treated as proof the pool was alive. Work queued behind the hung job was therefore reported as "contention, not an outage" — a false negative in the one scenario the probe exists for. `inspect_self_hosted_pool` now separates liveness from wedging in the same job listing, at no extra API cost. A self-hosted job still executing after 45 minutes is excluded from the liveness verdict and returned as an `OverdueJob`. 45 is derived, not chosen: every self-hosted job in `.github/workflows` declares `timeout-minutes` of 30 or less, so nothing that is merely slow can reach it while inside its own limit. The signal is published where it is actionable. `--check dispatch`, which runs on every PR event and every base push, writes a failing `ci-dispatch-watchdog` commit status naming the wedged job on the head it is blocking, so the fault is visible on the pull request rather than only in the Actions tab. `--check runner-starvation` exits non-zero on a wedged job even with an empty queue, because a job holding a required context is a fault the moment it happens, not once a queue has built behind it. Also documents two limits rather than papering over them: queued runs cannot be attributed to a runner pool from the API (labels live on jobs, and a starved run has no jobs — that absence is the condition being detected), and the half-hourly schedule still executes the pre-#13045 copy of the workflow that lives on the default branch. A push trigger on the integration branch gives the repaired probe live coverage in the meantime. The fork-PR approval guard is byte-unchanged: `is_approvable`, `run_is_same_repo` and `is_parked` hash identically to base.
…ire (#13286) `marker-tests.yml` landed in #13304 to run the tests `ci.yml` excludes with `-m "not integration and not slow and not distributed and not performance"`. It has never run. Not "run and failed" — produced zero runs of any kind: $ gh run list --workflow=marker-tests.yml failed to get runs: could not find any workflows named marker-tests.yml GitHub registers `schedule` and `workflow_dispatch` from the DEFAULT branch only. This file exists on the integration branch and not on the default branch, which is 67 commits behind, so neither declared trigger can dispatch. A nightly that cannot be scheduled runs exactly as many marker-excluded tests as no workflow at all — the defect #13286 is about, unchanged. Adds `push` and `pull_request` triggers scoped to the workflow's own file, so a change to it is exercised on the PR that makes it and the suite can never again ship unexecuted. The cron is kept, not removed: it is correct and starts working on promotion. That dependency is now stated in the file instead of being implied. `ci-dispatch-watchdog.yml` and `coverage.yml` are absent from the default branch for the same reason and carry the same dormancy.
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
…s first (#13341) Review amendments. Both findings are cases where the previous commits' own stated intent was not actually implemented. F1 — the fail-open fix gated on the DIAGNOSTIC COUNT, which is exactly the number that is unreliable when the compiler does not complete. Two real failures still passed, both reproduced against the real vue-tsc: * tsconfig.app.json missing -> `error TS5058`, raw exit 1, count 1 -> "PASS: 1 errors (187 below baseline)", exit 0. Zero source files were checked. The comment above the check named "bad tsconfig" as a case it caught; it did not. * a crash after partial diagnostics (5 errors, then exit 134) -> "PASS: 5 errors", exit 0. vue-tsc returns TypeScript's ExitStatus — measured 2 for a real type error, 1 for a configuration diagnostic, 3/4 for an invalid project. The gate is now `status > 2`, or non-zero with no diagnostics, plus an explicit TS5xxx/TS6xxx check for configuration diagnostics reported INSTEAD of a type check. Verified: tsconfig-missing and crash-after-partial both exit 1, a genuine 300-error regression still exits 1, and a real type error at exit status 2 is still counted rather than misread as a crash. F2 — the wedged-job detector could not have caught the incident it was written for. It truncated to the 10 newest in-progress runs, but `GET /actions/runs` returns newest-first and a wedged run is by definition the OLDEST one. Measured live: 12 runs in progress, the wedging `Frontend Testing Suite` run the oldest of the 12 — outside the budget. The unit tests passed throughout because a synthetic listing has nothing to truncate. Now the full page is listed (one call either way) and sorted oldest-first before the job-lookup budget is spent, and the budget is raised from 10 to 25, above the observed steady state. The new test fails against the pre-fix implementation. Also folded in: * frontend-typecheck-regression.yml carried the identical fail-open, untested and untouched: `set +e` plus a bare `grep -c` meant a crashing compiler produced ERRORS=0, which against BASELINE=0 is a pass. Confirmed on base with an OOM stub: "No regression: 0 == 0", exit 0. Same fix shape, and npx dropped there too. * `timeout --verbose`, so a bound step prints "sending signal TERM to command" instead of leaving the operator to decode a bare exit code 124. * The missing blank line before `Run linting`.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Three issues, one shape: CI can fail in a way that produces no signal, and every
guard added so far has itself been unobserved.
runner that stopped responding.
job as proof of health, so ci: required frontend check hung 3h past its 30-minute timeout, blocking every merge #13341 would have made it report green.
So the three are not adjacent, they are the same defect at three layers: fix the hang,
make the hang detectable, and make the detectors actually run.
What Changed
#13341 — root cause of the hang
Check TypeScript error deltaran over three hours againsttimeout-minutes: 30.Its sibling
Run type checking— the samevue-tsc, same job, reached throughnpm run— finished in about a minute.The difference was
npx.check-ts-delta.shinvokednpx vue-tsc;type-checkinvokes the binary npm already put on PATH. Everything npx adds before running a
binary is an unbounded wait:
Ok to proceed?) and blocks on stdin, and aCI step's stdin is a pipe that never delivers a line and never closes;
every concurrently executing job instead of being a fresh VM per job.
vue-tscis a devDependency and is on disk afternpm ci, so there was nothing for aresolver to do. The script now calls the installed binary directly with
</dev/null,removing the network path, the prompt and the cache lock together. That accounts for
the intermittency (a lock or a fetch, not a code path) and for why only this one of
the two
vue-tscsteps was affected.A fail-open surfaced while fixing it.
|| trueswallowed the compiler's exitcode, so a
vue-tscthat crashed without emitting diagnostics counted zero errorsand reported PASS. That is the documented OOM mode of this exact step, so the gate was
open in precisely its known failure case. Non-zero exit with zero diagnostics is now a
hard failure.
The step is additionally wrapped in
timeout --kill-after=60s 600. GitHub enforcestimeout-minutesfrom the runner side, so a runner that stops reporting progress maynever receive the cancellation;
timeoutruns inside the step's own process tree andfires regardless. This bounds the next unknown cause — it is not the fix for this
one.
#13341 + #13045 — making a wedged job visible
The starvation probe on base was not merely blind to #13341, it was inverted by
it. A wedged job is
status=in_progresson aself-hostedlabel, which is exactlywhat
self_hosted_pool_is_servingread as proof the pool was alive. Work queuedbehind the hung job was reported as "contention, not an outage". The reviewer's
false-negative concern was correct, and this is the concrete mechanism.
inspect_self_hosted_poolnow separates liveness from wedging in the same joblisting, at no extra API cost. A self-hosted job still executing after 45 minutes is
excluded from the liveness verdict and returned as an
OverdueJob. 45 is derived, notchosen: every self-hosted job in
.github/workflowsdeclarestimeout-minutesof 30or less, so nothing merely slow can reach it while still inside its own limit.
Published where it is actionable:
--check dispatch(every PR event, every base push) writes a failing commitstatus naming the wedged job on the head it is blocking — the fault appears on the
pull request, not only in the Actions tab.
--check runner-starvationexits non-zero on a wedged job even with an emptyqueue. A job holding a required context is a fault when it happens, not once a
queue has built behind it. Waiting for the queue would reproduce the three-hour
silence.
Two limits are documented rather than papered over: queued runs cannot be
attributed to a runner pool from the API (labels live on jobs, and a starved run has
no jobs — that absence is the condition being detected), and the half-hourly
schedule still executes the pre-#13045 copy of this workflow that lives on the default
branch. A push trigger on the integration branch gives the repaired probe live
coverage meanwhile.
#13286 — the nightly that never ran
Not "ran and failed" — zero runs of any kind:
GitHub registers
scheduleandworkflow_dispatchfrom the default branch only.The file exists on the integration branch and not on the default branch, which is 72
commits behind at time of writing (it drifts as PRs merge; the integration branch is
in turn 1 commit behind the default branch), so neither declared trigger can dispatch. A nightly that cannot be
scheduled runs exactly as many marker-excluded tests as no workflow at all.
Added
push+pull_requesttriggers scoped to the workflow's own file, so it isexercised on the PR that changes it and can never again ship unexecuted. The cron is
kept — it is correct, and starts working on promotion.
Runner move: recommended, deliberately NOT done here
Unit & Integration Testsis a required status check, and moving it changes whichrunner that requirement depends on — so it is not a silent change.
Recommendation: move
unit-tests,security-scanandbuild-testtoubuntu-latest. They arenpm/vue-tsc/viteststeps with no self-hosteddependency; the shim job proving the required context already runs on hosted runners,
so the required context is demonstrably satisfiable there. It removes the entire class
of "one hung frontend job blocks all backend merges", it removes the shared-
$HOMEnpm-cache contention that is the most probable trigger of this very hang, and it is
the reasoning that moved the Python gate to hosted runners.
Why not in this PR: the file's opening line states self-hosted was chosen to
avoid GitHub Actions quota limits. Spending billable minutes on the largest job in
the repository is a cost decision for the owner, not a reviewer-level one, and it is
separable from the correctness fixes here. Left as an owner decision with the
reasoning recorded.
Review amendments
Both review findings were cases where an earlier commit's stated intent was not
actually implemented.
F1 — the fail-open fix gated on the diagnostic count, which is precisely the number
that is unreliable when the compiler does not complete. Two failures still passed,
reproduced against the real
vue-tsc: a missingtsconfig.app.jsonemits ONEdiagnostic (
TS5058) having checked nothing, givingPASS: 1 errors (187 below baseline); and a crash after partial diagnostics gavePASS: 5 errors. The gate isnow the exit status —
vue-tscreturns TypeScript'sExitStatus, measured 2 for areal type error, 1 for a configuration diagnostic, 3/4 for an invalid project — so
> 2, or non-zero with no diagnostics, plus an explicitTS5xxx/TS6xxxcheck forconfiguration diagnostics reported instead of a type check.
F2 — the wedged-job detector could not have caught the incident it was written for.
It truncated to the 10 newest in-progress runs, but
GET /actions/runsreturnsnewest-first and a wedged run is by definition the oldest. The full page is now
listed (one call either way) and sorted oldest-first before the per-run job-lookup
budget is spent; the budget is raised from 10 to 25, above the observed steady state.
The unit tests passed throughout because a synthetic listing has nothing to truncate —
the new test fails against the pre-fix implementation.
frontend-typecheck-regression.ymlcarried the identical fail-open, untouched anduntested:
set +eplus a baregrep -cmeant a crashing compiler producedERRORS=0,which against
BASELINE: 0is a pass. Same fix shape applied, andnpxdropped theretoo. (Its config-error case happened to fail for the wrong reason —
TS5058counts asone error against a baseline of zero — so the fail-open there is specifically the crash
path, which is this project's documented OOM mode.)
Also
timeout --verbose, so a bound step printstimeout: sending signal TERM to command 'npm'rather than leaving the operator to decode a bare exit code 124.Verification
Ran locally:
actionlint(whole repo, shellcheck enabled)shellcheck autobot-frontend/scripts/check-ts-delta.shbash -non the scriptpytest repo_tests/ci_dispatch_watchdog_test.pyblack --line-length=120 --checkflake8 --config=.flake8check-ts-delta.shbehaviour, against a stubbed compiler (all five observed):PASS: 0 errors, exit 0timeout --kill-after=60sverified to terminate the whole child process group(exit 124, no leaked processes).
Amendment evidence —
check-ts-delta.shagainst the REALvue-tsc5.9.3:vue-tsctsconfig.app.jsonmissingTS5058PASS: 1 errors, exit 0PASS: 5 errors, exit 0PASS: 0 errors, exit 0frontend-typecheck-regression.yml, step extracted from the YAML and executedagainst the same real
vue-tsc: clean → exit 0; one real type error vsBASELINE: 0→exit 1; crashing compiler → base gives
✅ No regression: 0 == 0, exit 0, fixed givesexit 1.
F2 ordering,
test_the_wedged_run_is_found_even_when_it_is_the_oldest_of_many:fails against the pre-fix implementation (
Right contains one more item: 'wedged'),passes after. 92 tests total (75 on base, 17 added).
Fork-PR guard byte-unchanged — AST-sliced SHA-256 against base:
New tests pin the behaviour that matters, including the regression itself —
test_a_queue_behind_a_wedged_job_is_no_longer_dismissed_as_contentionfails againstthe base implementation.
Not verifiable without a live run — stated plainly:
same commit passed the step cleanly), so a single green run proves nothing; only an
absence over many runs will. The
timeoutbound means the next occurrence fails ata known point with a diagnosable message instead of hanging, which is the property
actually being delivered.
superseded), so the measurement is taken from the issue rather than re-derived.
marker-tests.ymlgoes green. This PR touches it, so the newpull_requesttrigger fires it here — that run is the point, and its outcome isthe first observation this workflow has ever produced.
against synthetic API responses; the write path needs a live occurrence.
workflow until promotion, so its red runs will continue regardless of this PR.
Model Used
Claude Opus 4.5 (
claude-opus-4-5-20251101)Closes #13341
Refs #13045 — the probe is repaired and regression-tested, but the issue's user-visible
symptom is a health check that never reports, and after this merges the
*/30schedule still runs the default branch's broken copy and still fails every time. The
repaired probe gets push-time coverage only.
Refs #13286 — the workflow now has a trigger that fires and is exercised on this PR,
but the nightly cadence cannot dispatch until the file reaches the default branch.