A GitHub Actions job stays queued or hangs with no output
A job that never leaves Queued is waiting on something: a concurrency group, a required environment approval, or a runner label with no capacity. A job that is running but silent is usually a hung step with no timeout-minutes, or a fixed wait for a service container. The fix depends on which one it is.
AI agents find the cause
StarSling agents run this diagnosis against your workflows and run history, identify which cause is yours, and open the fix as a reviewable PR.
Symptoms
GitHub only starts a job once it has both an eligible runner and a clear path through any concurrency group and environment gates you have configured. When a job sits at Queued or Waiting for a runner, the run history and the Actions UI tell you which of those it is stuck on, the workflow file alone will not, because none of these are visible as YAML defects: a concurrency group is working exactly as configured, a runner label with a small pool is a capacity fact about your account, and a required environment approval is a policy, not a bug. A job that has already started and shows no new log lines is a different problem: it has a runner, but a step is blocked on something that never returns, most often because there is no timeout-minutes to kill it, or because it is waiting a fixed amount of time for a database or cache container that was not actually ready yet. The five causes below are ordered by how each one presents, starting with the two queue-blocking causes you rule in or out from the run list, then the running-but-silent causes you rule in or out from the job log and its runs-on label.
The job has said Queued or Waiting for a runner for several minutes and the progress bar has not moved.
Other jobs for the same branch or pull request are running or queued at the same time you expected this one to start.
The job shows as In progress, but the log panel has not printed a new line in a long time.
The run eventually times out at exactly 6 hours with no clear failure message.
The job only gets stuck on a specific runner label, such as macOS, Windows, arm64, or a self-hosted label, while jobs on the default Linux runner start immediately.
How to diagnose it
Open the run in the Actions UI first. If it shows a
Review pending deploymentsbanner or a padlock icon next to an environment name, the job is waiting on a required environment approval, not a queue or capacity problem, resolve it from the environment's required reviewers, not from any cause below.If there is no approval gate, pull the job list for the run:
gh api "repos/{owner}/{repo}/actions/runs/{run_id}/jobs" --jq '.jobs[] | {name, status, runner_name}'. A job with"status": "queued"and a nullrunner_namehas not attached to a runner yet, move to the queue-side causes. Do not discriminate onstarted_at: GitHub stamps it on queued jobs too, so it is never null and a check waiting for null will never fire.For a queued job, list other runs on the same branch with
gh api "repos/{owner}/{repo}/actions/runs?branch=<branch>"- quoted, because an unquoted?is a shell glob character - and check for an in-progress or queued run that overlaps it, that points to the concurrency-group or superseded-run causes.Check the queued job's
runs-onlabel in the same jobs response. A non-default label (Windows, macOS, arm64, or a self-hosted label) with no online runner registered explains the wait on its own, independent of concurrency.If the job's
statusis"in_progress"instead, open its log and note the timestamp of the last printed line. A large gap with no new output means it is not queued at all, move to the timeout and container-healthcheck causes.For an in-progress, silent job, check whether the current step is a service container step or a step immediately after one; a fixed
sleepbefore that step is the most common source of unexplained silence.
Likely causes
Ordered by how often each one turns out to be the answer. Confirm a cause with its check before you change anything.
The run is queued behind a concurrency group
A
concurrency:block withcancel-in-progress: false(or no cancel setting) lets one workflow run at a time per group, so a new run for the same branch waits for the current one to finish rather than replacing it. If the group key is scoped too broadly, unrelated branches can end up sharing one slot and queuing behind each other.Fix: Concurrency groupsConfirm it
In the Actions UI, open the run and look for a banner naming the concurrency group it is waiting on. Or list runs for the branch:
gh api repos/{owner}/{repo}/actions/runs --jq '.workflow_runs[] | select(.head_branch=="<branch>") | {id,status,run_started_at}'and see if an earlier run is stillin_progressorqueued.Superseded runs from rapid pushes are backed up in the queue
Pushing several commits to the same branch in quick succession without
cancel-in-progress: trueleaves every earlier run occupying a runner slot instead of being cancelled. The newest run then queues behind runs whose results nobody needs anymore, which reads as the same job being stuck even though nothing is actually broken.Fix: Superseded runs cancelledConfirm it
gh api repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs --jq '.workflow_runs[] | select(.head_branch=="<branch>") | {id,status,created_at,run_started_at}'and check whether multiple runs for the branch overlap in time instead of one finishing before the next starts.The job is running but a step is hung with no timeout to kill it
A job without
timeout-minutesdefaults to a 6-hour ceiling, so a step that never returns (a process waiting on input, a network call with no client-side timeout, a deadlocked test) can occupy the runner for hours while producing no new log output. This looks identical to a queueing problem from the PR checks list, but the job has already started.Fix: Job timeoutsConfirm it
Open the job's log and compare the timestamp of the last printed line to the current time. If the job status is
in_progresswith a large gap and no new lines, pull the job definition and check fortimeout-minutes:gh api repos/{owner}/{repo}/actions/jobs/{job_id}and look atstatus,started_at, and how long it has run relative to any timeout set in the workflow file.The job is waiting on a service container that never signalled ready
A workflow that starts a Postgres, Redis, or similar service container and waits with a fixed
sleep Ninstead of a healthcheck can stall silently if the container takes longer than N seconds to accept connections. The job keeps running, but produces no output while the sleep or the first dependent step retries against a port that is not open yet.Fix: Container healthchecksConfirm it
grep -n 'sleep [0-9]' .github/workflows/*.ymlfor the workflow in question, and check whether theservices:block for that container definesoptions: --health-cmd. If there is a sleep instead of a healthcheck, that is the wait you are seeing in the silent log.The job is queued on a runner label with a small capacity pool
Non-default
runs-onlabels, Windows, macOS, arm64, or a specific self-hosted label, draw from a much smaller pool of available runners than the default Linux hosted runner. A job pinned to one of those labels can queue for a long time even with no concurrency group involved, and a self-hosted label with no online runner registered queues indefinitely.Fix: Right-sized runnersConfirm it
gh api "repos/{owner}/{repo}/actions/runs/{run_id}/jobs" --jq '.jobs[] | {name, status, labels, runner_name}'to see the exactruns-onlabel and whetherrunner_nameis null whilestatusisqueued- that pair is the unassigned-runner signal, notstarted_at, which GitHub populates on queued jobs as well. For self-hosted labels, cross-checkgh api "repos/{owner}/{repo}/actions/runners" --jq '.runners[] | {name,status,labels}'for an online runner carrying that label.
Hand it to an agent
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this diagnosis against your repository and report which cause it found.
A GitHub Actions job in this repository is either stuck at Queued/Waiting for a runner, or shows In progress with no new log output. Diagnose which of the following it is, using the run history and Actions REST API, not just the workflow YAML. Report the cause before changing anything. First, check the run in the Actions UI for a required-environment-approval banner, if present, stop there, it is not one of the causes below. Otherwise call `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` for the stuck job. If `status` is `queued` with a null `runner_name` (not a null `started_at` - GitHub stamps that on queued jobs too): check for an overlapping in-progress or queued run on the same branch (points to a missing or overly narrow `concurrency:` block, or missing `cancel-in-progress: true`), and separately check the job's `runs-on` label against `GET /repos/{owner}/{repo}/actions/runners` for online capacity. If `status` is `in_progress`: read the job log's last timestamp, then check the workflow file for a missing `timeout-minutes` and for a `sleep` before a service-container-dependent step. Report which single cause the evidence supports.Go further
Find the cause, fix them all, or keep them fixed.
You have the prompt that works out which cause applies. Here is how much further you can take it, each step doing more for you than the last.
Find the cause
Copy the diagnosis prompt above
Hand it to your coding agent to confirm which cause applies in your repo. It reports back what it found and changes nothing.
Fix them all, once
Install the ci-speedup skill
One prompt audits your whole repo against all 73 ci-speedup patterns (these 7 plus 66 more) and hands your agent every fix at once. Open source, MIT, runs locally.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
Sources
1REST API: List jobs for a workflow run (opens in new tab)
2Control the concurrency of workflows and jobs (opens in new tab)
3Using environments for deployment (required reviewers) (opens in new tab)
4Monitoring and troubleshooting self-hosted runners (opens in new tab)
Last updated 2026-08-31