clickhouse/investigate-ci
Investigate a ClickHouse CI failure end-to-end from a PR or S3 report URL. Fetches the failed tests and their output, classifies each as flaky vs a real regression using play.clickhouse.com master history, and for every failure searches for both an existing tracking GitHub issue and an existing fix (open/merged PR) — reporting, per failure, whether an issue still needs to be created and whether a fix exists with its status (WIP, merged, already in this branch or not). Downloads and reads the harness artifacts only for failures that history does not explain, and reports a root-cause hypothesis. Read-only first pass — never commits, pushes, or edits.
npx skills add https://github.com/ClickHouse/ClickHouse --skill investigate-ci
A read-only first pass over a CI failure: turn a single URL into a per-test verdict
(flaky vs real) plus a root-cause hypothesis, with no copy-paste and no manual wget.
$0 (required): one ofhttps://github.com/ClickHouse/ClickHouse/pull/NNNNN),https://s3.amazonaws.com/.../json.html?PR=...&sha=...), orhttps://github.com/ClickHouse/ClickHouse/issues/NNNNN) — typicallya bot-generated flaky test issue. Resolved to its report URL in step 0.
$1 (optional): master-history window in days for the flaky-vs-real verdict. Default 14.git commit, git push, edit source, switch branches, close issues, orcomment on the PR. This skill diagnoses; the human decides what to do. Surface findings, do not
act on them. To read source at the report's commit, use git show <sha>:<path> rather than
checking it out; only fetch/switch after asking the user (see step 4).
tmp/investigate/<sha>/ for all working files, never /tmp (per CLAUDE.md). The <sha> is the first 7 characters of the report commit SHA — one subdirectory per investigation so artifacts from different PRs or branches never collide and you can return to an earlier investigation without re-downloading.gh read through .claude/tools/gh-ro.sh (same args as gh). It drops a poisonedGH_CONFIG_DIR — some agent/CI runners set it to a config dir with no working auth, which makes
raw gh fail — and refuses any non-read-only subcommand, so it can never create/close/edit/merge/
comment. The examples below use it for this reason. (fetch_ci_report.js clears GH_CONFIG_DIR
internally, so drive it as node .claude/tools/fetch_ci_report.js … directly.)
fetch_ci_report.js accepts only PR, S3 json.html, and direct result_*.json URLs — not
issue links. If $0 is .../issues/NNNNN, read the issue body and extract the report URL first:
.claude/tools/gh-ro.sh issue view <NNNNN> --repo ClickHouse/ClickHouse --json title,body
Read the issue from the command output — do not redirect to a file. A
.claude/tools/gh-ro.sh issue view … > tmp/investigate/$SHA/issue.json redirect is a file write that
rides the wildcard Bash(.claude/tools/gh-ro.sh:*) allow (not the hook), so a symlinked
tmp/tmp/investigate could land it outside the scratch dir without a prompt. (Step 1 creates
tmp/investigate/$SHA.)
Bot-generated flaky test issues use this body format:
Test name: <test_name>
Failure reason: <reason>
CI report: <S3 json.html url> ← use this as the report URL for the steps below
Failing test history: <play.clickhouse.com link>
Extract the CI report: URL and use it as $0 for the rest of the skill. Also keep the
Test name: value — for a flaky-test issue the test to classify is already named, so step 3 can
run even if the S3 artifacts have expired (CI report links are retained only for a limited time;
if the report 404s, fall back to the named test plus the issue's Failing test history link).
If the issue is not a flaky-test report (no CI report: line), ask the user for the report or
PR URL rather than guessing.
When the input is a flaky-test issue, step 3 is the decisive signal — run it first. The
issue already names the test and the bot only files it after a failure, so master history settles
flaky-vs-real directly. Treat step 1 (the S3 report) and the optional step-4 artifact download as
best-effort enrichment: bot-filed issues are often weeks old and their artifacts have already
expired, so do not block on them.
fetch_ci_report.js needs node on PATH. Run these as separate commands (not one compound
block) so each matches an allowed shape under the investigate profile — a combined
mkdir … ; if … node … string matches neither the exact mkdir allow nor the node-fetch hook and
would prompt. Primary inputs (PR/S3) skip step 0, so create the parent working dir first:
mkdir -p tmp/investigate
Probe for node (command -v is allowed; do not wrap the fetch in an if/; one-liner, which
would hide the node command from the hook):
command -v node
If node is present, fetch the failed tests and their output:
node .claude/tools/fetch_ci_report.js "$0" --failed --cidb 2>&1
For a single HTML report URL (including sha=latest, which resolves to the actual build
commit) the tool prints a SHA: <40-hex-sha> line — read it and set $SHA to the first 7
characters. For a PR URL the tool prints a multi-report summary without a SHA: line;
extract $SHA from the ?sha=<hex> query-string parameter in any 🔗 Report: URL printed in
the summary, or re-run with --report N on the relevant job to get single-report output that
does print SHA:. For a direct result_*.json S3 URL (e.g. https://s3.amazonaws.com/clickhouse-test-reports/PRs/111528/<sha>/result_fast_test_arm_darwin.json) the tool also prints a SHA: line extracted from the URL path. Either way, $SHA is always a concrete commit hash, never a PR-number fallback. Then create the
working directory:
mkdir -p tmp/investigate/$SHA
This prints the failed tests and their output straight from the praktika result_*.json (no
copy-paste), with a CIDB link per failed test. Read it from the command output — do not add a
> tmp/investigate/$SHA/… redirect (a redirect is a file write the hook won't auto-approve, since it
can't be made symlink-safe, so it would prompt); the harness persists large output to a file you
can re-read or grep. If node is absent, the fallback depends on the input type:
Test name: and the Failing test history link) → proceedwithout the report: run step 3 on that named test; the S3 report and step-4 artifacts are
best-effort enrichment. A missing node is not fatal here.
node is required. Without the report you have no failed testnames, job names, or labels, so steps 2–3 (issue/fix search and the test_name IN (...) history
query) cannot run. Do not limp on with a partial investigation — stop and tell the user to
install node (or re-run where node is on PATH).
Per failure the tool prints a 🏷️ labels: line (CI's non-CIDB labels — the issue match link and
flags like retry_ok; see step 2a), the CIDB link, and the failure reason section extracted
from result.info as follows: the bash debug-trace section (.debuglog: path header or
+ [timestamp] xtrace lines) is stripped entirely as pure noise; then from what remains, up to 40
lines are shown as head + tail (first 20, --- (N lines omitted) ---, last 20), so the
Reason: ... at the top of stateless-test output and the ninja: build stopped / compiler
errors at the bottom of build logs are both visible. If the meaningful section is ≤ 40 lines,
it appears in full. CI's matchers test Failure reason against the whole result.info;
when the full output matters (e.g. an issue with a Failure reason: field deep in the trace),
drill via the CIDB link or the full artifact (step 4).
$0 is a PR URL with many reports and the noise is high, narrow with --report <n>after listing reports (run the tool with no --failed to see the index).
match checks.test_name for step 3 (and feed the issue search in step 2).
⚙️ JOB: <job name> (instead of ❌ FAIL:). These aresynthetic entries — the job name is not a real checks.test_name value. Skip the
checks.test_name history lookup in step 3 for them; go directly to step 4 (artifact download)
to find the root cause from logs and harness output.
a large count changes how step 3 scopes the expensive deep-dive — see "Scope the deep-dive".
For every failed test, run two searches and record a per-test answer to two questions that go
into the final report:
or not)?
Do both before the deep-dive — a tracked failure with a merged fix often short-circuits the rest
of the investigation.
Search issues (open and closed) by test name — a hit often names the tracking flaky-test issue,
and its comments may already carry the root cause, a fix PR, or a "known flaky" note.
.claude/tools/gh-ro.sh issue list --repo ClickHouse/ClickHouse --state all --limit 10 \
--search "<distinctive test-name fragment> in:title,body" \
--json number,title,state,stateReason,url,labels,closedAt
Search on a distinctive fragment (the function or test_* name without the parametrization
suffix), not the full parametrized string — GitHub search tokenizes on punctuation and the full
name rarely matches. If a candidate looks relevant, read it with its comments — they often
already provide the diagnosis:
.claude/tools/gh-ro.sh issue view <NNNNN> --repo ClickHouse/ClickHouse \
--json number,title,state,stateReason,body,comments,labels,closedAt
How CI decides a failure is already tracked (so you can answer "needs an issue?" the same way
CI's matcher does — see ci/praktika/issue.py, Issue.check_result): CI builds a catalog from
issues labeled testing (IssueLabels.CI_ISSUE) that are **open, or were closed within the
last ~8 hours, and routes each by whether it also carries the infrastructure** label:
infrastructure label) → Issue._check_flaky_test_match. Matches whenthe issue's Test name: body field is a suffix of the failing test's name
(result.name.endswith(test_name); pytest parametrization/module rules apply), and if the
issue sets a Failure reason:, that text is a substring of the failure output.
testing + infrastructure label) → Issue._check_infrastructure_match.These do not match by Test name:. They match a failure when all of the present fields
hold: Failure reason: is a substring of the output; every Failure flags: value (e.g.
retry_ok) is a label on the result; Test pattern: matches the test name; and Job pattern:
matches the job name. Note the pattern matching is not true SQL LIKE: the pattern is
split on %, empty fragments are dropped, and it matches if any remaining fragment is a plain
substring (so it is OR-across-fragments, order is not enforced, and a bare % — like an empty
field — is treated as no constraint). Examples:
#87123 (Job pattern: Unit%),
#92089 (Job pattern: Stateless tests (amd_msan%,
Failure reason: DB::Exception: Timeout exceeded).
Fast path — read the labels fetch_ci_report.js prints. The tool surfaces each failure's
non-CIDB labels on a 🏷️ labels: line. Two are decisive:
issue label gives you the matched issue number for free — the printed link is theissue CI matched at run time (e.g. Server died → issue (…/issues/107487), Hung check …
→ …/107941). It saves the *search*, but it is not by itself tracked #N: the label reflects
the catalog when the report was produced, not the issue's state now. Always .claude/tools/gh-ro.sh issue view
the linked issue and classify from its current state/closedAt — an issue closed after the
run and aged past the ~8 h window is stale #N (reopen candidate), not tracked. The label
shortcuts the lookup; it does not replace the tracked-vs-stale decision below.
retry_ok) appear here too — these are exactly the labels aninfrastructure issue matches on via Failure flags: (below), so this line is how you verify that
constraint.
And absence of an issue label is not proof of "untracked": CI stamps it from the catalog
*as it was at that run* (open + closed-within-8h then), so a tracking issue filed or reopened
after the run won't show. So: an issue label → look up that issue and classify by current
state; no label → still run the GitHub search below before concluding needs issue/untracked.
For an INFRA/BUILD or timeout/harness-level failure, also run the infrastructure path. A
test-name search alone will miss these, so a pre-existing, already-tracked infra failure would be
mis-reported as needs issue. List the infra issues — --state all, because CI's catalog
includes not just open ones but every testing issue closed in the last ~8 h
(TestCaseIssueCatalog.from_gh), and a just-closed infra issue is still auto-matched — and match
by Job pattern / Failure reason against the failing job and its output:
.claude/tools/gh-ro.sh issue list --repo ClickHouse/ClickHouse --state all --label testing --label infrastructure \
--limit 100 --json number,title,url,body,state,closedAt
Compare each candidate's fields the way _check_infrastructure_match does: Failure reason: is a
substring of the output; Job pattern:/Test pattern: match the failing job/test name; and every
Failure flags: value must appear on the failure's 🏷️ labels: line from step 1 (that line is
the only place these flags are visible — e.g. test_dns_cache … → retry_ok). All present fields
must hold. A match on an open issue — or one closed within ~8 h (check closedAt) — is tracked #N
exactly as CI would attribute it; a match on an issue closed longer ago is a stale #N reopen
candidate (same
same-failure/recurrence check as the flaky case).
Always run the issue search for every failed name — it is one cheap gh issue list and is the
only way to mirror CI's attribution. Generic, harness-level names get tracked too: Server died,
Hung check failed, possible deadlock found, and the upgrade `Error message in
clickhouse-server.log check frequently *do* have a testing issue (e.g. Server died` →
#107487, Test name: Server died), and
Issue._check_flaky_test_match will mark such a result with the issue label. So do not skip
the search for them. The generic failure / untracked value is only for a generic bucket or
anonymized error *class* (e.g. Logical error: Bad cast from type A to B, where the harness
replaces concrete types with A/B and groups by stack hash STID) after the search finds no
matching testing issue — because filing a *new* per-failure issue for such a shifting bucket
makes no sense. It is a "searched, nothing matched, and not worth filing" verdict, never a
"didn't look" one.
Determine, per test:
testing issue matches (by the rule above, or reached via the report's issuelabel) and is currently open or closed within ~8 h (.claude/tools/gh-ro.sh issue view → state/closedAt). No
new issue needed; CI will keep auto-matching it. Applies to generic-bucket names too when such an
issue exists (e.g. Server died → #107487). If the linked/ matched issue is closed longer ago,
it is stale #N, not tracked.
step 3), names a specific test/crash (a NNNNN_*/test_* case, or an identifiable crash/race
with a stable STID mapping to one code site), and has no matching testing issue. Flag it
as "issue needed" in the report.
the search found no matching testing issue, and filing a new per-failure issue makes no
sense. Rely on the step-3 frequency for the verdict.
a tracking issue: a testing issue would mask a real bug. The recommendation is to fix the code.
more than ~8 h ago, so CI's catalog no longer contains it and will not auto-match: the next
failing run gets treated as unknown and check_ci.py would file a duplicate. Before treating
it as *the* tracking issue, run two checks:
Test name: match is not proof. Read the issueand confirm the failure mode matches — same error/exception text, same assertion or stack
site, same STID, same job/config scope. A broader issue (e.g. a generic Server died, or one
covering a whole job) or a different error under the same test name is not a match for the
specific failure you are exploring — treat that as needs issue, do not reopen the wrong or
broader issue.
closedAt? (step 3 already has the history; if not, run a datedquery bounded by check_start_time > <closedAt> on master / across PRs):
closedAt (and same failure mode) → the close was premature or thefix regressed. Recommend reopening #N — preferred over a fresh issue, since it avoids a
duplicate and CI re-matches it once open again. This is the case the "already fixed → retry"
line below must not swallow.
closedAt and the fix commit post-dates the failing run → genuinelyalready fixed; recommend retry/rebase, not reopen.
Report it as stale #N with the recommendation (reopen vs retry vs needs-new) spelled out.
Never label a cell with an asserted fact you did not verify (e.g. "(known)" implying a tracking
issue exists). generic failure / untracked requires that you actually ran the search and it
returned no match — it is not a substitute for searching.
Record the matching issue number, its state (open vs closed/completed), closedAt (needed for
the ~8 h window and the stale/reopen check), labels, and any root cause or fix PR mentioned. A
closed completed issue whose fix post-dates the failing run points
at "retry, already fixed" — **but only if the same failure has not recurred on master since the
issue's closedAt** (see the stale/reopen check above); if it has, the fix regressed and the issue
is a reopen candidate, not an "already fixed". If the input was itself an issue (step 0) you already
have it, but still scan for duplicate or related issues and read its comments.
Independently of whether an issue exists, search for a fix — a PR that addresses this failure.
Three complementary sources, cheapest first:
closedByPullRequestsReferencesnames the PR(s) that closed it, and comments often mention the fix (gh issue view --json does
not support a timelineItems field — it errors Unknown JSON field):
.claude/tools/gh-ro.sh issue view <issue-number> --repo ClickHouse/ClickHouse --json number,state,stateReason,closedByPullRequestsReferences,comments
This surfaces PRs that closed the issue and any fix named in comments — but not a PR that
merely cross-references it (Related #<issue>) without closing it. The issue timeline would show
those, but the investigate profile denies gh api (it can POST). So look for them with a
read-only PR search by issue number — scoped to title/body, and treated as **candidates
only**:
.claude/tools/gh-ro.sh pr list --repo ClickHouse/ClickHouse --state all --limit 20 \
--search "<issue-number> in:title,body" \
--json number,title,state,isDraft,mergedAt,mergeCommit,headRefName,url
A bare number is ambiguous — every hit is a candidate, never a confirmed fix. GitHub matches
the number as free text, so this returns PRs that merely *mention* it (e.g. searching 75982
returns this docs PR, which only cites #75982). Before using any hit in the Fix column,
verify it genuinely references this issue — its body has a relationship line naming the issue,
in either ClickHouse's full-URL convention (Closes:/Fixes:/Related:
https://github.com/ClickHouse/ClickHouse/issues/<n> — the form repo PRs are supposed to use) or
the short Closes/Fixes/Related #<n> form, or it plainly addresses the same failing test/symptom.
Discard bare prose mentions that don't. Never emit WIP/merged from an unverified number match.
Do not rely on the by-test-name search below to catch cross-references — a fix PR that
references the issue but never names the test is invisible to it, which would emit a false
Fix: none.
The by-issue-number pr list above already returns the classification fields. But
closedByPullRequestsReferences and comment mentions give only a PR number/url — so for any
fix PR you learned of only as a bare number (and that the search above did not already return),
fetch the classification fields explicitly before scoring the Fix column:
.claude/tools/gh-ro.sh pr view <pr> --repo ClickHouse/ClickHouse --json number,title,state,isDraft,mergedAt,mergeCommit,headRefName,url
fragment:
.claude/tools/gh-ro.sh pr list --repo ClickHouse/ClickHouse --state all --limit 20 \
--search "<distinctive test-name fragment>" \
--json number,title,state,isDraft,mergedAt,mergeCommit,headRefName,url
REAL/UNCERTAIN failure, once step 5 names the suspect file:line,search PRs touching that file or the error string the same way.
Every search here (by issue number, by test name, by symptom) returns candidates only. GitHub
matches the term as free text, so a PR that merely *mentions* the test/symptom comes back too — e.g.
searching test_dns_cache returns this very docs PR because its text names that test. Before a hit
may fill the Fix column you must confirm it actually addresses this failure: open its diff
(.claude/tools/gh-ro.sh pr diff <pr>) and check the change targets the failing test/code, not just
a passing mention. Ignore the PR under investigation itself (the $0 PR) unless its own diff
genuinely fixes the failure. Discard unconfirmed hits — a none is correct when nothing verifiably
addresses the failure; never emit WIP/merged from an unverified name/symptom match.
For each verified fix PR, classify its status — this is what goes in the report's Fix column:
isDraft). Not yet protecting any run.state == MERGED, with a mergeCommit. Then decide **whether it is already in thefailing run** using the containment rule below (never from mergedAt alone), which determines the
recommendation:
Incomplete or unrelated — do not treat as resolved; keep investigating (step 5).
commit → rebase/retry. The common "already fixed on master — rebase and retry" case.
REF/non-master) → cannot confirm containment; keepthe failure open, do not call it fixed.
Deciding "already in this branch or not". Compare the fix's merge against the report commit
(SHA from step 1). If the fix's merge commit is in the local object store, check ancestry:
git cat-file -e <mergeCommit> && git merge-base --is-ancestor <mergeCommit> <report-sha> \
&& echo "fix commit IS in the failing run" || echo "fix commit is NOT in the failing run history (or absent locally)"
This check is asymmetric — trust only the positive. A negative never concludes on its own,
on *any* report type: the exact merge commit being absent does not prove the fix *logic* is absent,
because the branch may carry it under a different SHA (a cherry-pick, a copy merged from elsewhere,
or the same patch authored directly in the PR). So:
is-ancestor true → the fix is definitely present. If the test still failed, the fix isincomplete/unrelated → keep investigating; do not report "already fixed".
is-ancestor false (or the merge commit is absent locally) → **do not conclude yet, and donot conclude from git log alone.** Establish what the fix actually changed, then check that
content against the failing branch:
.claude/tools/gh-ro.sh pr diff <fix-pr> --repo ClickHouse/ClickHouse
git log only to *find candidate* commits on the branch — never as the verdict (a--grep hit can be a coincidental subject; a -- <file> hit only proves some commit touched
that path, not that it carries the fix; and a miss proves nothing — the fix may be present
after conflict resolution, a refactor, or manual transcription):
git log --oneline <report-sha> --grep "<fix PR title or key phrase>" # candidates only
git log --oneline <report-sha> -- <file the fix touched> # candidates only
branch's own version of the file at the report commit (and/or a candidate commit), e.g.
git show <report-sha>:<path> and look for the specific guard/line/logic the fix added, or
git show <candidate> to confirm it is the same change.
Then:
merged #N — in branch(if the test still failed, it is incomplete → keep digging). Holds regardless of report type.
master→ merged #N — not in this branch → rebase/retry.
backport/REF/non-master base, the commit absent locally, or an ambiguous/refactored match →
merged #N — presence unverified; keep the failure open for step 5. Never emit in branch or
rebase/retry from a git log subject/path hit-or-miss alone.
Time order is a weak last resort, not a substitute: a fix mergedAt after the run's
commit/check_start_time cannot be in the run, but "before" does not prove presence (the branch
may predate or not contain it). Use it only to rule *out*, and say you relied on it.
This is the cheap, decisive triage — run it before downloading any artifacts (step 4),
since a FLAKY verdict usually makes the heavy download unnecessary.
Run one batch query against play.clickhouse.com for all failed test names. The checks
table is publicly readable via the play user. The query below always computes the same fixed
7/14/30/90-day buckets; $1 (default 14) does not change the SQL — it selects **which
precomputed bucket is the gate** you read for the verdict (fail_<$1>d). If $1 is not one of
7/14/30/90, round to the nearest bucket (or add that column).
Guard the empty case: if no test names were extracted, test_name IN () is invalid SQL —
skip and report "no named tests to classify" (the failure may be a build/infra error; go to
steps 4–5 to pull and read the build log).
Escape single quotes in every test name (s/'/''/g) before joining — parametrized
integration tests like test_foo[a'b] otherwise produce invalid SQL.
curl -sS 'https://play.clickhouse.com/?user=play' --data-binary "
SELECT test_name,
countIf(test_status IN ('FAIL','ERROR') AND check_start_time >= now() - INTERVAL 90 DAY) AS fail_90d,
countIf(test_status IN ('FAIL','ERROR') AND check_start_time >= now() - INTERVAL 30 DAY) AS fail_30d,
countIf(test_status IN ('FAIL','ERROR') AND check_start_time >= now() - INTERVAL 14 DAY) AS fail_14d,
countIf(test_status IN ('FAIL','ERROR') AND check_start_time >= now() - INTERVAL 7 DAY) AS fail_7d,
maxIf(check_start_time, test_status IN ('FAIL','ERROR')) AS last_fail
FROM checks
WHERE check_start_time >= now() - INTERVAL 90 DAY
AND test_name IN ( '<test1>', '<test2>', ... )
AND pull_request_number = 0 AND head_ref = 'master'
GROUP BY test_name
ORDER BY fail_7d DESC
FORMAT TabSeparatedWithNames
"
Gate on direct master rows only — pull_request_number = 0 AND head_ref = 'master'.
pull_request_number = 0 alone is not enough: it also matches release branches
(head_ref = '26.5') and merge-queue refs (head_ref = 'gh-readonly-queue/master/pr-...'), so a
failure from an unrelated merge-queue PR or a release branch would inflate fail_<window>d.
And do *not* widen to base_ref IN ('master',''): that matches every PR targeting master, so any
unrelated PR's failure — including a real regression that PR introduced — would make
fail_<window>d >= 1 and send the investigated PR's own regression down the FLAKY path,
skipping root-cause analysis. Only direct master HEAD rows show the test is flaky independent of
any PR. (Add AND head_repo = 'ClickHouse/ClickHouse' if fork rows are a concern.)
Cross-PR failures are a separate, secondary signal. If master rows are sparse, you may widen
to other PRs as *corroboration* — never as the gate — and only after comparing failure modes (a
flaky repeats the *same* error across unrelated PRs; distinct errors mean distinct bugs). Exclude
the investigated PR so its own failures never feed back in:
AND base_ref IN ('master','') AND pull_request_number NOT IN (0, <investigated PR>)
Never GROUP BY over or filter with LIKE on test_context_raw — that column holds the
full test output and a 90-day scan over it times out (60 s limit). The aggregate query above is
safe because it touches only test_status/check_start_time. When you need per-failure detail
(dates, PRs, failure mode), the failing rows are few — select them directly and only
substring(test_context_raw, 1, 200) for a preview:
curl -sS 'https://play.clickhouse.com/?user=play' --data-binary "
SELECT toStartOfDay(check_start_time) AS day, pull_request_number AS pr, check_name,
substring(test_context_raw, 1, 200) AS reason_head
FROM checks
WHERE check_start_time >= now() - INTERVAL 90 DAY
AND test_name = '<test>'
AND test_status IN ('FAIL','ERROR')
AND pull_request_number = 0 AND head_ref = 'master'
ORDER BY day DESC
FORMAT TabSeparatedWithNames
"
Use this to confirm the master failures share the same failure mode as the report, and to
see which check_name configs are affected (e.g. only the heavily-loaded arm_binary, parallel
shard points to a load/timing race). In the optional cross-PR corroboration query, the same-error
test is the giveaway: a flaky fails identically across *unrelated* PRs, whereas distinct errors
mean distinct bugs.
Classify each failed test:
fail_<window>d >= 1 on master → likely FLAKY (pre-existing instability, not caused bythis PR). Note the recent frequency.
0 rows returned, or fail_90d == 0 on the master gate → does not fail on direct master.The gate is tight (head_ref = 'master' only) and master runs are far less frequent than PR
runs, so a low-rate *fleet-wide* flaky can legitimately show 0 here. **Do not jump to REAL —
run the cross-PR corroboration query first**, then:
rarely lands on a direct-master run), not a regression.
.claude/tools/gh-ro.sh pr diff shows the test file as new) → new test, judge on itsown output, not history.
likely a REAL regression introduced by this PR.
fail_90d small but fail_14d == 0) → uncertain;rely more heavily on the step-5 root-cause read.
Cross-check the verdict against the issue found in step 2: a known tracking issue corroborates a
FLAKY verdict (and may already give the root cause), while no issue plus no failures on master
*or* across other PRs strengthens REAL.
Always keep the per-test CIDB link from step 1 in the final report for manual drill-down.
Tests classified FLAKY need no artifacts — go straight to the report. Only REAL,
UNCERTAIN, or INFRA/BUILD failures need the step-4 download.
Scope the deep-dive (many failures). Steps 2–3 are cheap and always classify *every* failed
test — never silently drop any. The classification itself is the first filter: it usually leaves
only a handful of non-FLAKY tests, and steps 4–5 deep-dive only those. Before fanning out, check
two things:
(server won't start, a build/link error, an early-setup or infra failure), they almost always
share one cause. Investigate it once, not per-test — group them in the report under a single
hypothesis. The step-3 per-failure detail query (failure-mode preview) is how you spot this.
non-FLAKY failures remain, do not auto-spawn a subagent per test. List them with their
one-line classifications and ask the user which to investigate (e.g. "the test this PR adds",
"the 3 REAL ones", or a named subset). Report the full classification table regardless; only the
expensive root-cause read is gated on the user's choice.
Download applies only to the tests step 3 left as REAL, UNCERTAIN, or INFRA/BUILD. If
every failed test is FLAKY, skip this step entirely.
Read the error and the source before downloading anything. The step-1 failure output usually
already contains the decisive evidence — an assertion message, an exception, a result diff, or a
stack trace. Read it, then open the referenced code (the stack-trace file:line, and
.claude/tools/gh-ro.sh pr diff <PR> for the suspect change). For a large category of failures —
logical-error / assertion aborts with a symbolized stack, exceptions with a clear message, simple
stateless-test result diffs — that is enough to root-cause, and **no artifacts need to be
downloaded at all** (this investigation root-caused a KeeperStateMachine.cpp assertion straight
from the stack trace plus the source at the report commit).
Read the source at the report's commit, or file:line will be wrong. Stack-trace line
numbers are only accurate against the exact commit that produced the report. Step 1 prints the
report SHA; compare it to the checkout before trusting any line number:
git rev-parse HEAD # compare against the report SHA from step 1
git cat-file -e <sha> succeeds)→ read the exact version without switching, e.g. git show <sha>:src/path/File.cpp and look
around the reported line. This is read-only and leaves the working tree untouched — prefer it.
file:line cannot be resolved. **Ask theuser** whether to fetch and switch to the PR branch/commit; do not fetch or switch unilaterally
(switching mutates the working tree, and the read-only investigate profile denies
git checkout/git switch).
Download only when reading the error and source leaves a real gap — e.g. you need server
logs to see ordering/timing across nodes, the actual values behind a truncated diff, a core dump,
or the build log for an INFRA/BUILD failure. Then pull only the specific files that close
the gap, not the whole bundle reflexively (it is large and sometimes truncates).
--download-logs <path> takes a file path (not a directory) and downloads the single
logs.tar.gz/logs.tar.zst bundle to it — this replaces hand-supplying a base dir. It works
only against a single concrete report (an S3 json.html/result_*.json URL). A bare PR URL
takes the multi-report path and returns after the summary without downloading, so substitute
<report-url> — the S3 report URL for the failing check, or narrow the PR with --report <n>
first (list reports by running the tool with no --failed). This writes a file, so the hook does
not auto-approve it — it prompts under the investigate profile; approve it (this is the one
expected write, and it's the rare artifact-needed path):
node .claude/tools/fetch_ci_report.js "<report-url>" --failed --download-logs "tmp/investigate/$SHA/logs.tar.gz"
The tool prints the saved path and lists the archive's pytest logs. The compression may be zstd
despite the .gz name; extract with auto-detection (-xf, not -xzf). **Do not swallow the
tar error** — a failed extraction (expired/corrupt bundle, no zstd support, or the member
absent) is itself a finding; surface it instead of letting the later grep | jq silently yield
nothing and look "inconclusive":
tar -xf "tmp/investigate/$SHA/logs.tar.gz" -C "tmp/investigate/$SHA/" ci/tmp/pytest_parallel.jsonl
test -f "tmp/investigate/$SHA/ci/tmp/pytest_parallel.jsonl" \
|| { echo "extraction FAILED — report the artifact problem (bundle expired/corrupt, missing zstd, or member absent), do not proceed as inconclusive"; false; }
--binary prints binary and package URLs (one per line: clickhouse executable, .deb, .rpm).
It only works with a concrete Build (...) report URL (name_1=Build%20(amd_binary) etc.).
Test-job report URLs (name_1=Stateless tests (...), name_1=Fast%20test, etc.) carry no binary
artifacts and will exit 1. PR URLs and top-level index URLs also fail fast. To get the binary,
construct the Build report URL by replacing name_1=<test-job> with the build variant you need:
# From a test-job report URL, swap name_1 to the build you want:
BUILD_URL="...json.html?PR=...&sha=...&name_0=PR&name_1=Build%20(amd_binary)"
node .claude/tools/fetch_ci_report.js "$BUILD_URL" --binary 2>/dev/null
# grab just the executable (basename has no dots):
node .claude/tools/fetch_ci_report.js "$BUILD_URL" --binary 2>/dev/null | grep -E '/clickhouse(-stripped)?$'
For other artifacts, list available URLs with --links and try to find the logs and artifacts
helping to narrow the issue down. The files alongside this skill describe the layout and
signal-bearing members for each job family:
artifacts-integration.md — integration tests (logs.tar.gz archive structure,pytest_parallel.jsonl schema, per-node server logs)
artifacts-stateless.md — stateless and fast tests (individual log files, .zst fetching)artifacts-stress.md — stress tests (clickhouse-server.initial.log, fatal.log,hung_check.log, compressed logs)
artifacts-build.md — build failures (full build log, truncation, tidy vs compiler errors)For an integration-test failure, pull the relevant longrepr:
grep -F -- "<test_name>" "tmp/investigate/$SHA/ci/tmp/pytest_parallel.jsonl" \
| jq -r 'select((.longrepr // "") != "") | .longrepr'
Use grep -F -- (fixed-string): parametrized names like test_foo[a] contain regex
metacharacters that a basic-regex grep would not match literally. (jq is allowed by the
locked-down profile; avoid python3 -c, which is not.)
If reading the error and source in step 4 already settled the root cause, just write it up — this
step is only for failures that still need digging. Dig **only into the failures step 3 scoped for
deep-dive** — the non-FLAKY ones the user confirmed, or a single shared-root-cause group — not
every failed test. When delegating, and especially when there are artifacts to wade through, use
subagents that return a concise summary, not raw dumps (per CLAUDE.md). Launch one subagent per
selected failure (or per shared-cause group) in parallel. Give each subagent:
.claude/tools/gh-ro.sh pr diff <PR> and pass its output (or have thesubagent run it); do not redirect to a file (same symlink-write reason as step 0),
SHA, instructing it to read source at that commit (git show <sha>:<path>) foraccurate file:line, per step 4.
Ask each subagent to return: the proximate failure (assertion / exception / diff), the likely
root cause with file:line evidence from the logs or the source, and — if it looks real —
the suspect change in the PR diff. Tell it to read only; it must not modify anything.
Print one verdict table, then a short narrative per real/uncertain failure:
| Test | Verdict | Master freq (7/14/30/90d) | Last master fail | Issue | Fix | Root-cause hypothesis | Suspect | CIDB |
|------|---------|---------------------------|------------------|-------|-----|-----------------------|---------|------|
FLAKY, REAL, UNCERTAIN, NEW-TEST, INFRA/BUILD}.tracked #N, needs issue, generic failure / untracked (genericbucket or anonymized error class — searched, no testing issue matched, and not worth filing),
fix instead (REAL — don't mask), stale #N (same-failure issue closed >~8 h ago → CI no longer
auto-matches; give the recommendation: reopen if it still fails after closedAt, retry if truly
fixed, or needs-new if the closed issue is broader/a different failure mode)}. The search runs for
every failure (generic buckets included — e.g. Server died is often tracked); never write a
tracking claim like "(known)", and never use untracked as a stand-in for not searching.
none, WIP #N (open; note draft), merged #N — in branch (fix waspresent yet test still failed → keep digging), merged #N — rebase/retry (landed after the run),
merged #N — presence unverified (ancestry inconclusive on a backport/release/REF report and no
cherry-pick confirmed — do not call it fixed; keep the failure open for step 5)}.
REAL/UNCERTAIN, give the file:line evidence and the suspect PR change.FLAKY, state the master frequency and the tracking flaky-test issue from step 2 (number,state, and any root cause / fix PR its comments revealed).
<file>, see <commit> — fix the PR, do not file a tracking issue";REF report is unverified — do not assume fixed;check for a cherry-pick, keep investigating".
Include the original report URL (and PR link) so the human can confirm. Do not take any action.
Take clickhouse/investigate-ci from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.