Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .claude/commands/ci-queue-times.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
description: Show CI cluster pressure right now — per-pool runners online/busy vs jobs active/queued, plus the live queue with wait-so-far
argument-hint: [pool-filter-regex] # optional, e.g. "h200" or "b200|mi355x" to restrict output
---

GitHub has no queue-metrics API for self-hosted runners, so this derives cluster
pressure from the Actions API: a job's **wait so far = now − `created_at`** (queued
jobs) and **runtime so far = now − `started_at`** (active jobs). Pool membership
comes from the repo's own `configs/runners.yaml` (the authoritative pool →
runner-name mapping), so run this **from the repo root**.

`$ARGUMENTS` (optional) is a regex restricting which pools are shown in Steps 2–4
(e.g. `h200`). Run all steps and report the tables as-is.

**Key facts for interpreting the numbers:**

- A self-hosted runner agent executes **exactly one job at a time**, so per pool
`jobs_active` should always be ≤ `runners_busy`. Parallelism comes from multiple
registered agents per pool (`h100-dgxc-slurm_00` … `_19`).
- Pools overlap **by design** in `configs/runners.yaml` (e.g. `h100-multinode` ⊂
`h100`, and the same `b200-dgxc_*` machines serve `b200`, `b200-dsv4`, and
`b200-dgxc` jobs), so one physical runner can count toward several pool rows.
- A multi-node SLURM job still occupies exactly one agent (the orchestrator) — the
nodes it allocates inside SLURM are invisible here.

## Step 1 — Take one snapshot of jobs (active + queued) and runners

A run can be `in_progress` while its matrix jobs are still `queued` waiting for
runners (the sweep fan-out), so scan both statuses. Every list call uses
`--paginate` — without it the runs lists are silently capped at the first page.

```bash
SNAP=$(mktemp /tmp/ci_snapshot.XXXXXX.ndjson)
RUNNERS=$(mktemp /tmp/ci_runners.XXXXXX.ndjson)
POOLS=$(mktemp /tmp/ci_pools.XXXXXX.tsv)

# Pool → runner-name membership, from the repo's own config (bare pool keys and
# cluster:* keys are normalized to the same bucket: cluster:b200-dgxc → b200-dgxc).
awk '/^ [A-Za-z0-9:-]+:$/ { k=$1; sub(":$","",k); sub("^cluster:","",k); pool=k; next }
/^ - / { print pool "\t" $2 }' configs/runners.yaml > "$POOLS"

# Jobs, bucketed by the pool label they requested (demand side).
{ gh api "repos/SemiAnalysisAI/InferenceX/actions/runs?status=queued&per_page=100" --paginate --jq '.workflow_runs[].id'
gh api "repos/SemiAnalysisAI/InferenceX/actions/runs?status=in_progress&per_page=100" --paginate --jq '.workflow_runs[].id'
} | sort -u | while read -r RUN; do
gh api "repos/SemiAnalysisAI/InferenceX/actions/runs/$RUN/jobs?per_page=100" --paginate \
--jq '.jobs[] | select(.status=="in_progress" or .status=="queued")
| {c: ([.labels[] | select(test("^(cluster:)?(b200|b300|h100|h200|gb200|gb300|mi300x|mi325x|mi355x)")) | sub("^cluster:";"") | sub("_\\d+$";"")] | first // "other"),
status, name, created_at, started_at, run_id}
| select(.c != "other")'
done > "$SNAP"

# Runners by name (capacity side) — names match configs/runners.yaml entries.
gh api "repos/SemiAnalysisAI/InferenceX/actions/runners?per_page=100" --paginate \
--jq '.runners[] | {name, status, busy}' > "$RUNNERS"
```

## Step 2 — Per-pool summary: runners online/busy vs jobs active/queued

Joins demand (job labels) against capacity (`configs/runners.yaml` membership):

```bash
jq -n -r --slurpfile jobs "$SNAP" --slurpfile runners "$RUNNERS" --rawfile pools "$POOLS" '
($pools | split("\n") | map(select(contains("\t")) | split("\t"))
| group_by(.[1]) | map({key: .[0][1], value: [.[].[0]]}) | from_entries) as $name2pools
| ($jobs | group_by(.c) | map({key: .[0].c, value: {
active: ([.[] | select(.status=="in_progress")] | length),
queued: ([.[] | select(.status=="queued")] | length)}}) | from_entries) as $j
| ([ $runners[] as $r | ($name2pools[$r.name] // [])[] as $p | {c: $p, status: $r.status, busy: $r.busy} ]
| group_by(.c) | map({key: .[0].c, value: {
online: ([.[] | select(.status=="online")] | length),
busy: ([.[] | select(.busy==true)] | length)}}) | from_entries) as $r
| [([$j, $r] | map(keys) | add | unique | .[]) as $c
| {c: $c, on: ($r[$c].online // 0), busy: ($r[$c].busy // 0),
active: ($j[$c].active // 0), queued: ($j[$c].queued // 0)}]
| map(select(.on + .busy + .active + .queued > 0))
| sort_by(-.queued, -.active)
| .[] | [.c, (.on|tostring), (.busy|tostring), (.active|tostring), (.queued|tostring)] | @tsv' \
| (printf "pool\trunners_online\trunners_busy\tjobs_active\tjobs_queued\n"; cat) | column -t -s$'\t'
```

How to read it (one job per runner agent, so these patterns are diagnostic):

| pattern | meaning |
|---|---|
| `busy == active`, `queued == 0` | healthy, pool has spare capacity if `busy < online` |
| `busy == online`, `queued > 0` | pool saturated — jobs waiting for a free runner (normal queue) |
| `online == 0`, `queued > 0` | **pool is down** — no live runners; investigate the fleet, not the workflow |
| `busy > active` | the pool's runners are busy serving **another pool's** jobs (shared membership in `configs/runners.yaml`, e.g. `b200` vs `b200-dgxc`), stuck "busy" after a cancellation, or busy with another repo's jobs (runners may be org-scoped) |
| `active > busy` | impossible in steady state — treat as snapshot skew between the API calls |
| pool absent entirely | no online runners and no jobs — either idle-and-offline, or (if jobs *do* target it) the pool is not defined in `configs/runners.yaml` |

## Step 3 — Active jobs (what is running, and for how long)

```bash
jq -s -r '[.[] | select(.status=="in_progress")] | sort_by(.c, .started_at) | .[]
| [.c, (((now - (.started_at|fromdateiso8601))/60 | floor | tostring) + " min"), (.name[0:58]), (.run_id|tostring)] | @tsv' "$SNAP" \
| column -t -s$'\t'
```

## Step 4 — Queued jobs (what is waiting, and for how long)

```bash
jq -s -r '[.[] | select(.status=="queued")] | sort_by(.c, .created_at) | .[]
| [.c, (((now - (.created_at|fromdateiso8601))/60 | floor | tostring) + " min"), (.name[0:58]), (.run_id|tostring)] | @tsv' "$SNAP" \
| column -t -s$'\t'
```
Comment on lines +95 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Step 3's runner-availability query fans out over ALL matching normalized labels (unique | .[]) instead of picking one canonical pool label per runner, so a single runner with multiple qualifying labels (pool, cluster:* alias, multi-node subset, and its own per-runner name label) gets counted in 3–4 different pool rows in the output table. This inflates total/online/busy counts and fabricates bogus rows (e.g. h100-dgxc-slurm) that don't correspond to any real pool; fix by picking a single canonical label per runner (e.g. first // empty, as Step 2 already does).

Extended reasoning...

The bug: Step 3's jq pipeline computes, per runner, the set of all normalized labels that match the broad prefix regex ^(cluster:)?(b200|b300|h100|h200|gb200|gb300|mi300x|mi325x|mi355x), then does unique | .[] — emitting one {c, status, busy} record for every surviving label rather than choosing a single canonical pool label. The subsequent group_by(.c) then treats each of those records as an independent runner-in-a-pool, so one physical runner ends up counted in several different "pool" rows of the final table.

Why this actually happens: configs/runners.yaml shows real runners are registered with multiple simultaneous labels — a pool label (h100), a multi-node subset label for some runners (h100-multinode), a cluster alias (cluster:h100-dgxc), and the runner's own unique name label (h100-dgxc-slurm_16). All of these pass the regex above because it's a substring/prefix test, not a full match. After the two sub() normalizations (sub("^cluster:";""), sub("_\\d+$";"")), these collapse to four distinct strings for the same runner: h100, h100-dgxc, h100-dgxc-slurm, and h100-multinode. Two of those (h100-dgxc and h100-dgxc-slurm) are not real pool names at all — they're artifacts of stripping the cluster: prefix and the runner's numeric suffix.

Step-by-step proof (using runner h100-dgxc-slurm_16, which is online and carries labels h100, h100-multinode, cluster:h100-dgxc, h100-dgxc-slurm_16 per configs/runners.yaml):

  1. .labels[].name yields: h100, h100-multinode, cluster:h100-dgxc, h100-dgxc-slurm_16 (plus default labels like self-hosted which fail the regex and are dropped).
  2. select(test(...)) keeps all four SKU-prefixed labels.
  3. sub("^cluster:";"") turns cluster:h100-dgxc into h100-dgxc.
  4. sub("_\\d+$";"") turns h100-dgxc-slurm_16 into h100-dgxc-slurm.
  5. unique now holds 4 distinct strings: ["h100", "h100-dgxc", "h100-dgxc-slurm", "h100-multinode"].
  6. .[] | {c: ., status: $r.status, busy: $r.busy} emits 4 separate records for this single physical runner.
  7. After jq -s | group_by(.c), this one runner contributes +1 to the total/online count in FOUR rows of the output table: h100, h100-dgxc, h100-dgxc-slurm, and h100-multinode — two of which (h100-dgxc, h100-dgxc-slurm) are not real pools in configs/runners.yaml at all.

I reproduced this exact behavior by piping the runner's label set through the Step 3 jq expression, confirming it yields 4 rows for one runner.

Contrast with the rest of the file: Step 1 (line 24) explicitly filters out per-runner name labels via select(test("...|_\\d+$") | not). Step 2 (line 42) picks a single canonical label per job via | first // empty. Step 3 is the outlier — it neither filters out the per-runner name label nor reduces to one label, so it's the only one of the three queries that multi-counts. This also directly contradicts the file's own Caveats section (lines 71-75), which states the sub() calls exist to "normalize" label shapes, not to multiply-count a runner across several pool buckets.

Impact: Step 3 is described as "the why behind queue times" — its whole purpose is giving an operator accurate total/online/busy counts per pool to diagnose queue pressure. As written, it will, on every invocation, inflate counts for any runner carrying more than one qualifying label (which per configs/runners.yaml is most runners — anything with a cluster:* alias or multi-node subset label) and fabricate nonexistent pool rows like h100-dgxc-slurm. That's actively misleading for its stated diagnostic purpose.

Fix: Change unique | .[] to pick one canonical label per runner, e.g. mirror Step 2's | first // empty, or add the same per-runner-name-label exclusion Step 1 uses (select(test("_\\d+$") | not)) before taking first.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a355cdd — runner bucketing now comes from configs/runners.yaml pool membership (matched by runner name) instead of label fan-out: one runner counts once per configured pool, bogus rows like h100-dgxc-slurm are gone, and all configured pools now appear. / 已在 a355cdd 修复:runner 侧的池归属改为以 configs/runners.yaml 的成员配置为准(按 runner 名称匹配),不再按标签扇出 —— 同一 runner 只按配置计入对应池,h100-dgxc-slurm 之类的伪池行已消除,所有已配置的池都会显示。


To apply `$ARGUMENTS`, pipe Steps 2–4 through `grep -E "<pattern>"` (keep the
header line of Step 2).

## Caveats

- **Demand vs capacity bucketing:** jobs are bucketed by the label they *request*
(single-node jobs use pool labels like `b200`, multi-node jobs use `cluster:*`
labels — the `sub()` normalization merges them). Runners are bucketed strictly by
`configs/runners.yaml` membership, so bogus rows from per-runner name labels
(e.g. `h100-dgxc-slurm`) cannot appear.
- **`gh api --jq` does not accept jq `--arg`** — extra arguments are parsed as API
parameters. Interpolate values into the `--jq` string via the shell instead.
- **Re-run artifacts:** re-run jobs can report `started_at` from an earlier
attempt, inflating runtime in Step 3. Cross-check an outlier against the job's
page before believing it.
- **SLURM second queue:** for SLURM-backed pools (`*-dgxc`, `*-nv`), once the
GitHub job starts there is a second queue inside SLURM that GitHub-side numbers
do not capture. Check it on the cluster with `squeue -u <runner-user>` (see
`.claude/skills/debug-runs/SKILL.md`).
- Queue time measured this way excludes time spent behind the sweep canary gate:
matrix jobs are only *created* after the gate passes, so their `created_at` marks
genuine runner-wait start.
- Snapshot skew: jobs and runners are fetched a few seconds apart; a job that
starts/ends in between can briefly violate `active <= busy`.