Skip to content

Add reusable E2E AI failure-triage adjudication workflow - #3

Open
yasserfaraazkhan wants to merge 21 commits into
mainfrom
claude/e2e-ai-triage
Open

Add reusable E2E AI failure-triage adjudication workflow#3
yasserfaraazkhan wants to merge 21 commits into
mainfrom
claude/e2e-ai-triage

Conversation

@yasserfaraazkhan

Copy link
Copy Markdown

What

A reusable workflow that adjudicates E2E failures and decides a check state, plus the scripts behind it. Repo-agnostic on purpose: clustering needs a repo's spec layout and failure-signature catalogue, but adjudication and policy do not, so they are shared.

Consumers hand over an evidence bundle and get back a posted e2e-test/ai-triage status. The first consumer is mattermost-mobile (separate PR, blocked on this one merging so it can reference @main).

  • .github/workflows/e2e-ai-triage.yml — adjudicate the residue the deterministic rules could not resolve, run everything through the policy engine, post the status.
  • .github/workflows/e2e-ai-triage-override.yml/e2e-triage-override <verdict> <reason> from a maintainer comment.
  • scripts/triage-policy.js — the policy engine. Deterministic; the model proposes, this decides.
  • scripts/triage-apply.js — posts the status, applies E2E/AI-Waived, writes the ledger.
  • scripts/triage-override.js — records a human correction and realigns the checks.
  • scripts/triage-blame.js — attributes a main regression to a suspect commit range.

The invariants this enforces

These are the reason the policy engine is deterministic code rather than a prompt:

  • Fail closed. No evidence, an API error, a timeout, or low confidence all resolve red.
  • Asymmetric bars. 0.85 confidence to waive, 0.7 to keep red. Waiving is the dangerous direction, so it costs more.
  • At least two independent evidence citations, or the verdict downgrades to INCONCLUSIVE.
  • reproduced_on_rerun can never be waived. A cluster that failed every repetition is deterministic by definition, and no confidence score makes it flakiness. This is measurement overruling inference, and it is the strongest single guard against a false green — every other guard is a threshold or a heuristic.
  • One unwaived cluster keeps the whole run red.
  • MAIN / MASTER / RELEASE never auto-waive.
  • E2E/AI-Waived is not E2E/Override. Conflating an AI-granted green with a maintainer's would make the false-green metric — the number deciding whether triage is ever trusted to gate anything — impossible to compute.

Notes for review

  • Corrections are the only ground truth here. Everything else is triage grading its own homework, so the ledger write happens first and its failure is reported loudly rather than swallowed. Correcting away from a waivable verdict also withdraws the E2E/AI-Waived label, which is sticky and would otherwise keep greening later commits on the branch.
  • Blame is deliberately conservative. One commit in the suspect range is attribution and the author is named; two to eight are listed as candidates; more than that is not attributed at all. Naming the wrong author would burn the only thing a callout needs, which is being trusted enough to read.
  • Cost is bounded by cluster count, not failure count. 800 failures with one cause is one cluster, and therefore among the cheapest runs to triage.

Testing

55 tests, all passing, run in CI by this PR's own ci.yml job. They are named explicitly rather than run as a directory, because node's directory test runner also executes non-test sources and turns a plain require into a spurious failure.

Coverage is aimed at the invariants above rather than at line count: the parse forms people actually type, label withdrawal, merge-commit exclusion, the refusal to blame a flake, and the refusal to waive a failure that reproduced on every rerun.

actionlint clean.

Merge order

This must merge before the mattermost-mobile PR, which references these workflows at @main. Nothing here depends on the mattermost-test-system-io PR at merge time — the ledger calls degrade to a warning if the endpoints are absent — but that one should land first so the endpoints exist when this is first used for real.

Takes a normalized evidence.json from a caller repo, adjudicates only the
failure clusters the caller's deterministic rules could not decide, and
posts e2e-test/ai-triage.

The split is deliberate: clustering needs a repo's spec layout, artifact
names, and failure-signature catalogue, while adjudication and policy are
repo-agnostic. One JSON file is the whole contract, so any framework that
can produce it can use this.

The model never decides its own authority. It emits a verdict; the
deterministic, unit-tested policy engine decides what that means for the
merge button, and the model never touches the status API.

Policy rules that make an automated green trustworthy:
- Fail closed. No evidence, unparseable output, unknown verdict, low
  confidence, API error, timeout — all resolve red.
- Asymmetric bars: 0.85 to waive, 0.7 to keep red. A false red costs a
  rerun; a false green ships a bug.
- Two independent citations minimum, or the verdict is downgraded to
  INCONCLUSIVE before policy sees it.
- One unwaived cluster keeps the whole run red.
- MAIN and RELEASE runs never auto-waive — baseline health has to reflect
  reality, and it is the comparison every PR verdict is drawn from.
- A MAIN_REGRESSION excuses a PR only when the PR does not touch the
  failing area; overlap means ambiguity, and ambiguity is red.
- AI waivers use E2E/AI-Waived, never the human E2E/Override. Conflating
  them makes the false-green metric uncomputable.

Ships in shadow mode: posts a verdict, never waives. Promotion to assist is
a repo-variable change, gated on false_greens being zero over a real
sample, so rollback is instant.

21 unit tests cover the policy engine and verdict assembly.
End-to-end trace of the PR and main paths found four defects, one of which
would have made the check actively harmful.

A passing E2E run was posting RED. decideRun treated an empty decision list
as "nothing decided → fail closed", but there are three very different
reasons the list can be empty and they were collapsed into one: the suite
passed (green — there was nothing to triage), no reports were produced
(red — nothing can be concluded), or failures exist that nothing explained
(red — fail closed). Only the middle two are failures. As written, every
green PR would have gained a red e2e-test/ai-triage, which is the fastest
way to train everyone to ignore the check.

A stale E2E/AI-Waived label stayed applied across pushes. The status
reporter honours the label unconditionally, so a waiver granted for one
commit would have kept greening every later commit on the branch —
including one introducing a real regression. Any run that does not waive
now clears it.

The ledger write could never have succeeded. It sent a static secret as a
bearer, but TSIO accepts only an X-API-Key or an OIDC bearer it verifies
against the GitHub Actions issuer. It now mints an OIDC token the same way
tsio-report-status.js does, so no shared secret is needed at all.

A missing evidence artifact killed the job before any status was posted.
The download step had no continue-on-error, so the documented "post red
when there is no evidence" path was unreachable and the check would simply
never appear — worse than a red, because a required check that never
arrives blocks forever.

Also: the run-level decision now carries its confidence (statuses read
"(?)" without it), the waiver sentence only appears on an actual waiver,
and a clean run clears a stale comment instead of posting a new one.

26 policy and assembly tests, including one per defect above.
The policy engine decides whether an E2E failure blocks a merge for every
repo that calls the reusable workflow. Without CI a regression in "when is
a red allowed to turn green" would reach consumers silently — the exact
failure mode the design exists to prevent.

Also lints the workflows: the reusable workflow is consumed by SHA/ref from
other repos, so a syntax error here breaks their pipelines, not this one's.
The rerun stage is the one experiment that turns "flaky" from an inference
into a measurement, so its result has to outrank the model's reading of the
error text. A cluster that failed every repetition is deterministic by
definition; no confidence score makes it flakiness.

decideCluster now takes reproducedOnRerun and rejects every waivable verdict
when it is set. Red verdicts are unaffected — only waivers are blocked, and
only by evidence, never by interpretation.

This is the strongest single guard against a false green in the design: the
other guards are thresholds and heuristics, this one is a repeated
measurement.
Two gaps that made the design incomplete in opposite directions.

Every triage comment advertised `/e2e-triage-override <verdict> <reason>`, and
nothing implemented it. Maintainers would have typed it and watched nothing
happen. It now records the correction in the ledger and brings the checks into
line with the human's decision.

Corrections are the only ground truth this system gets — everything else is
triage grading its own homework — so the ledger write comes first and its
failure is reported loudly rather than swallowed. Correcting away from a
waivable verdict also withdraws the E2E/AI-Waived label, because the label is
sticky and would otherwise keep greening later commits on the branch.

MAIN_REGRESSION told the PR author "not your fault" and told the person who
actually broke main nothing at all. Blame closes that: TSIO already knows the
last passing and first failing commit, so the suspect range is whatever landed
between, which is usually one commit and costs one compare call rather than a
bisect. One commit is attribution and the author is named; two to eight are
listed as candidates; more than that is not attributed at all. Naming the wrong
author would burn the only thing the callout needs, which is being trusted
enough to read.

24 new tests (55 total), covering the parse forms people actually type, the
label withdrawal, merge-commit exclusion, and the refusal to blame a flake.
The ledger credential was renamed to TSIO_API_KEY when the write moved to a
minted OIDC token; the copy-pasteable example still said TSIO_TOKEN, which
would have silently resolved to an empty string in every consumer that
copied it.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This change adds fail-closed E2E triage policy and automation. It evaluates model verdicts, attributes baseline regressions, updates GitHub and TSIO, supports human overrides, documents operation, and adds CI validation.

Changes

E2E triage automation

Layer / File(s) Summary
Triage policy and verdict validation
scripts/triage-policy.js, scripts/triage-policy.test.js
Adds fail-closed verdict validation, confidence thresholds, run aggregation, model-output parsing, status formatting, and policy tests.
Triage application and regression attribution
scripts/triage-apply.js, scripts/triage-apply.test.js, scripts/triage-blame.js, scripts/triage-blame.test.js
Adds verdict assembly, regression attribution, GitHub status and comment updates, waiver labels, TSIO recording, outputs, and tests.
Reusable E2E triage workflow
.github/workflows/e2e-ai-triage.yml, .github/workflows/e2e-ai-triage.md
Adds evidence retrieval, deterministic gating, Claude adjudication, verdict application, summaries, notifications, and workflow documentation.
Human triage override path
scripts/triage-override.js, scripts/triage-override.test.js, .github/workflows/e2e-ai-triage-override.yml
Adds override command parsing, correction recording, GitHub state synchronization, sender authorization, reusable workflow inputs, and tests.
Workflow and script validation
.github/workflows/ci.yml
Adds Node.js test execution and pinned actionlint validation for workflow files.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant E2EWorkflow
  participant Claude
  participant TriageApply
  participant TriagePolicy
  participant GitHub
  participant TSIO

  E2EWorkflow->>Claude: Submit normalized evidence
  Claude-->>E2EWorkflow: Return model verdicts
  E2EWorkflow->>TriageApply: Pass evidence and model output
  TriageApply->>TriagePolicy: Evaluate cluster and run decisions
  TriageApply->>GitHub: Update status, label, and PR comment
  TriageApply->>TSIO: Record verdict batch
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: adding a reusable E2E AI failure-triage adjudication workflow.
Description check ✅ Passed The description directly explains the reusable workflow, supporting scripts, policy safeguards, tests, and integration requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/e2e-ai-triage

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (3)
.github/workflows/e2e-ai-triage.md (1)

161-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a comma after the condition clause.

Line 161 reads "If the ledger write fails the checks are still updated". Place a comma after "fails" so the condition and the action separate clearly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-ai-triage.md at line 161, Update the sentence near the
ledger write discussion to insert a comma after “fails” in “If the ledger write
fails, the checks are still updated.”

Source: Linters/SAST tools

scripts/triage-override.js (1)

27-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Import VERDICTS and WAIVABLE from triage-policy.js.

These two sets are duplicated from scripts/triage-policy.js, which exports both. The comment at line 42 records the coupling but does not enforce it. If a verdict class is added to the policy engine and not here, the override command rejects a verdict the engine accepts, and the two waivable sets can diverge silently.

AI_WAIVED_LABEL and STATUS_CONTEXT are also duplicated from scripts/triage-apply.js, which exports both.

♻️ Proposed refactor
-const AI_WAIVED_LABEL = 'E2E/AI-Waived';
-const STATUS_CONTEXT = 'e2e-test/ai-triage';
-
-const VERDICTS = new Set([
-    'PR_REGRESSION',
-    'MAIN_REGRESSION',
-    'FLAKY_TEST',
-    'FLAKY_INFRA',
-    'FLAKY_SERVER',
-    'BUILD_OR_ENV_ERROR',
-    'TEST_DEBT',
-    'INCONCLUSIVE',
-]);
-
-// Verdicts whose meaning is "not attributable to this change", i.e. the ones that
-// justify a green. Mirrors WAIVABLE in triage-policy.js.
-const WAIVABLE = new Set(['FLAKY_TEST', 'FLAKY_INFRA', 'FLAKY_SERVER', 'MAIN_REGRESSION']);
+const {VERDICTS, WAIVABLE} = require('./triage-policy');
+const {AI_WAIVED_LABEL, STATUS_CONTEXT} = require('./triage-apply');

Note: scripts/triage-apply.js guards its entry point with require.main === module, so importing it has no side effect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/triage-override.js` around lines 27 - 43, Import VERDICTS and
WAIVABLE from triage-policy.js and remove their local Set definitions in
scripts/triage-override.js, preserving all existing validation and waiving
behavior. Also import AI_WAIVED_LABEL and STATUS_CONTEXT from triage-apply.js
and remove the duplicated local constants, relying on the existing guarded entry
point to avoid side effects.
scripts/triage-apply.js (1)

47-62: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a 30-second timeout to all outbound requests.

Apply signal: AbortSignal.timeout(30000) to gh, mintOidcToken, and recordLedger. Without a timeout, an unresponsive endpoint can block the workflow until the job timeout, including the last-ditch status update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/triage-apply.js` around lines 47 - 62, Update the outbound request
implementations in gh, mintOidcToken, and recordLedger to pass signal:
AbortSignal.timeout(30000) in each fetch request’s options, including the final
status-update request, so every request aborts after 30 seconds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 54: Update the actionlint installation command in the workflow to fetch
the installer from a reviewed immutable commit instead of the mutable main
branch, and verify the fixed release archive’s checksum before extracting or
executing it. Preserve the existing actionlint installation behavior while
ensuring both the installer and downloaded archive are integrity-pinned.

In @.github/workflows/e2e-ai-triage-override.yml:
- Around line 34-37: Update the reusable workflow around the sender input and
override execution to query sender’s collaborator permission for target_repo,
then stop before posting the commit status or applying E2E/AI-Waived when the
permission is below write. Use the workflow’s existing authentication and
repository context, and preserve the current behavior for authorized senders.

In @.github/workflows/e2e-ai-triage.md:
- Around line 196-198: Update the documented Node test command in the E2E AI
triage instructions to include scripts/triage-blame.test.js and
scripts/triage-override.test.js alongside the existing policy and apply suites,
so all four tests run.

In @.github/workflows/e2e-ai-triage.yml:
- Around line 285-298: Replace direct GitHub Actions expression interpolation in
all four command sites with step-level env variables and quoted shell-variable
references: in .github/workflows/e2e-ai-triage.yml lines 285-298 pass
target_repo, commit_sha, pr_number, branch, run_type, mode, tsio_url,
evidence_run_id, and diff_overlaps_failure via env; lines 300-314 pass mode,
run_type, gate reason, AI outcome, apply state, and apply description via env;
lines 331-333 pass pr_number, target_repo, commit_sha, and github.server_url via
env. In .github/workflows/e2e-ai-triage-override.yml lines 77-83 pass
target_repo, pr_number, sender, comment_id, tsio_url, and the run URL via env,
following the existing COMMENT_BODY pattern and preserving the commands’
behavior.
- Around line 143-164: Configure the ci/decide-whether-to-adjudicate step to
continue on error, matching the protection on ci/download-evidence, so malformed
evidence.json does not prevent ci/apply-verdicts from running. Preserve the
existing jq validation and fail-closed handling in triage-apply.js.

In `@scripts/triage-apply.js`:
- Around line 455-468: Sanitize all values written by the GITHUB_OUTPUT block
before appending them, especially the free-form description returned by
statusDescription(runDecision), by removing line breaks and other control
characters or using a collision-safe heredoc delimiter. Keep the existing output
keys and fallback values unchanged, and ensure model-derived text cannot create
additional output assignments.
- Around line 186-199: Update the reason formatting in the verdicts table
generation within the decisions loop to remove or normalize newline characters
before escaping pipe characters and truncating the text. Preserve the existing
fallback and Markdown pipe escaping so each d.reason value remains a single
valid table-cell paragraph.

In `@scripts/triage-blame.js`:
- Around line 157-172: Prevent blameCandidates from pairing a suite-level
decision with an individual evidence cluster. When evidence.suite_verdict is
set, ensure resolveBlame supplies cluster-aligned verdict data or bypasses
cluster blame entirely, so a MAIN_REGRESSION suite verdict cannot produce author
or notification blame from any cluster history; preserve existing per-cluster
behavior when no suite verdict is present.

In `@scripts/triage-override.js`:
- Around line 288-299: Update recordCorrections and its caller so the result
includes a structured success flag based on whether at least one correction was
applied, rather than inferring success by testing ledgerNote with
recordedCleanly. Use that flag when composing the triage override comment,
ensuring a zero-correction result takes the warning path and does not claim the
correction counts toward accuracy metrics.
- Around line 160-178: Update recordCorrections to pass the constructed headers
when fetching listUrl, then select the latest verdict run by explicitly ordering
verdicts using the endpoint’s ordering field rather than assuming verdicts[0] is
newest. Preserve filtering targets to only the selected commit_sha.

In `@scripts/triage-override.test.js`:
- Around line 91-96: Update the test around decideAfterOverride to assert the
full description contains the human reason, without slicing it before
validation. Add a focused assertion for main() that verifies its returned or
submitted description is truncated to the 140-character status limit, covering
the truncation performed there.

In `@scripts/triage-policy.js`:
- Around line 59-75: Bound the confidence validation in decideCluster before
applying GREEN_CONFIDENCE_BAR or RED_CONFIDENCE_BAR: require confidence to be
finite and within the inclusive range 0–1. Return the existing red INCONCLUSIVE
result for any out-of-range value, including values copied through
assembleVerdicts or produced by parseModelOutput.
- Around line 266-282: Update the verdicts mapping in parseModelOutput to handle
null and other non-object entries before reading evidence, verdict, or
cluster_signature. Treat such entries as invalid and downgrade them to the
existing INCONCLUSIVE fallback without throwing, while preserving current
validation for object entries.

---

Nitpick comments:
In @.github/workflows/e2e-ai-triage.md:
- Line 161: Update the sentence near the ledger write discussion to insert a
comma after “fails” in “If the ledger write fails, the checks are still
updated.”

In `@scripts/triage-apply.js`:
- Around line 47-62: Update the outbound request implementations in gh,
mintOidcToken, and recordLedger to pass signal: AbortSignal.timeout(30000) in
each fetch request’s options, including the final status-update request, so
every request aborts after 30 seconds.

In `@scripts/triage-override.js`:
- Around line 27-43: Import VERDICTS and WAIVABLE from triage-policy.js and
remove their local Set definitions in scripts/triage-override.js, preserving all
existing validation and waiving behavior. Also import AI_WAIVED_LABEL and
STATUS_CONTEXT from triage-apply.js and remove the duplicated local constants,
relying on the existing guarded entry point to avoid side effects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d1da96c0-2ae8-47eb-9888-077bcc1536a5

📥 Commits

Reviewing files that changed from the base of the PR and between 93d73f4 and 817a3f1.

📒 Files selected for processing (12)
  • .github/workflows/ci.yml
  • .github/workflows/e2e-ai-triage-override.yml
  • .github/workflows/e2e-ai-triage.md
  • .github/workflows/e2e-ai-triage.yml
  • scripts/triage-apply.js
  • scripts/triage-apply.test.js
  • scripts/triage-blame.js
  • scripts/triage-blame.test.js
  • scripts/triage-override.js
  • scripts/triage-override.test.js
  • scripts/triage-policy.js
  • scripts/triage-policy.test.js

Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/e2e-ai-triage-override.yml
Comment thread .github/workflows/e2e-ai-triage.md
Comment thread .github/workflows/e2e-ai-triage.yml Outdated
Comment thread .github/workflows/e2e-ai-triage.yml Outdated
Comment thread scripts/triage-override.js Outdated
Comment thread scripts/triage-override.js Outdated
Comment thread scripts/triage-override.test.js Outdated
Comment thread scripts/triage-policy.js Outdated
Comment thread scripts/triage-policy.js Outdated
Most of these are the same shape — text the system does not control reaching
somewhere that acts on it.

The policy engine accepted any finite confidence. Number.isFinite rejects NaN
and Infinity but admits 5, which clears the 0.85 green bar, so a model emitting
a 0-100 confidence would have waived its own failures. Confidence is a
probability; anything outside [0,1] is now unusable rather than merely low, in
decideCluster and in parseModelOutput.

The status description reached GITHUB_OUTPUT as `description=<text>` and is
built from the model's root_cause. GITHUB_OUTPUT is parsed one key=value per
line and the last assignment wins, so a root_cause containing
"\nstate=success\nwaived=true" would have overwritten the run's own state and
turned a red run green — comfortably inside the 140-character limit. Every
value written there is now flattened to one line, including the blame suspect
author, which comes from git.

The same description was interpolated straight into the job-summary shell
script, where a backtick or $(...) in it would have executed on the runner. It
and every other caller-supplied value in both workflows now travel as
environment variables. `branch` is the one that makes this mandatory rather
than tidy: on a fork PR the branch name is attacker-chosen.

parseModelOutput threw on a null entry in the verdicts array, so one malformed
element cost the whole adjudication instead of one verdict.

blameCandidates zipped decisions[i] against clusters[i], but a suite verdict
collapses to a single decision covering the whole run. A suite-level
MAIN_REGRESSION therefore read its suspect range out of an unrelated cluster
and named an author picked essentially at random — the exact false accusation
the blame design exists to avoid. A whole suite dying is infrastructure, not one
commit, so it now attributes nothing.

The override reply claimed "Correction recorded ... It counts toward the triage
accuracy metrics" whenever the note matched /verdict\(s\) corrected/ — which
"0/5 verdict(s) corrected" does. A run where every correction failed announced
success. recordCorrections now returns an explicit ok flag. It also resolves the
newest verdict by created_at instead of trusting response order, sends its
credentials on the list read, and no longer sends corrected_by, which TSIO now
derives from the authenticated principal.

The override workflow trusted `sender` as a plain string. It is reusable, so
whether the caller gated on author_association is a property of each caller; a
caller that forgets would let an outside contributor overturn a red verdict and
waive their own PR. It now checks the sender's permission on the target repo
before writing anything.

ci/decide-whether-to-adjudicate ran under `set -e`, so a malformed evidence.json
failed the step and skipped ci/apply-verdicts with it, posting no status at all.
No check is not fail-closed, it is silence; the step now continues and the
policy engine resolves the run red as intended.

The actionlint installer was piped into bash from a mutable branch on a runner
holding the workflow token. Pinned to a commit and a fixed version.

Six tests added for the new guards (61 total). Also fixes a table row that a
newline in a reason would have broken, and two documentation slips.

Not changed: sharing constants between triage-override and the other two
modules, batching the ledger insert, and adding fetch timeouts are refactors and
tuning on paths with no observed problem.
`assert.ok(d.description.slice(0, 140).length <= 140)` is true of every string,
so the test asserted nothing — and it asserted it about decideAfterOverride,
which is the one function that deliberately does not truncate. The maintainer's
reason is returned in full there because the PR comment prints all of it; only
the commit status is capped, in main().

Split into the two things that were meant: the description keeps the verdict and
the whole reason, and the status description is capped at GitHub's silent
140-character limit. The cap is now a named helper rather than a bare slice at
the network call, so it can be asserted directly.

Checked that both new assertions can actually fail: with the truncation removed,
the clamped length is 400 against an expected 140.
`actions/checkout` with no `repository:` clones `github.repository`, and inside a
reusable workflow that is the *caller* — mattermost-mobile. Both workflows then
ran `node scripts/*.js` against a tree with no such file. MODULE_NOT_FOUND kills
the job before triage-apply can post its red fallback, so a run ended with no
`e2e-test/ai-triage` status at all. Absent is not fail-closed, it is silence, and
it meant adjudication had never actually worked.

The ref comes from github.workflow_ref, whose tail is the ref the caller pinned,
so the scripts always match the workflow version being invoked — including while
a caller tests against an unmerged branch.
Four review findings, all in the direction that matters.

A run where every shard died was waived green. The catalogue calls that shape
FLAKY_INFRA at 0.95, and a suite verdict is one decision — so decideRun's
"no reports produced -> red" guard, which sat inside a decisions.length === 0
branch, was walked straight past. A change that broke the build well enough to
stop the tests running got a green check with no test evidence in existence.
The guard now applies whatever the decision count.

The same path discarded the two facts allowed to overrule a waiver: a suite
verdict has no cluster to line up with by index, so amnesty_exhausted and
reproduced_on_rerun were passed as false unconditionally, and invariant 4 was
not enforced for suite verdicts at any confidence. Reading clusters[0] would
have been worse — an arbitrary cluster — so the suite case now aggregates: if
any cluster reproduced on rerun or has spent its amnesty, that applies to the
verdict covering them all.

Suite verdicts were also unrecordable. TSIO requires external_test_id or
cluster_signature and the suite row supplied neither, so it 400'd and the
failure was swallowed as a log line: the one verdict class that can waive a
whole run never reached the ledger the false-green metric is computed from.
Keyed on the rule id now, so re-triage updates rather than appends.

Number(true) is 1, which clears the 0.85 green bar outright. A confidence that
is not a number is a malformed record, not a confident one, so the type is
checked before any coercion.

The two-citation rule lived only in parseModelOutput, which by construction
only sees model verdicts — rule-decided and suite verdicts reached decideCluster
having never been checked, and the invariant read as absolute while being
model-only. It is enforced in the policy engine now, and citations must be
distinct: two copies of one reference is a single observation written twice.

Three tests added, two updated. One of those updates is itself a finding: the
fixture asserted a rule verdict waiving with matched_signatures: [], which the
citation bar now correctly refuses.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
.github/workflows/e2e-ai-triage-override.yml (2)

100-120: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Authenticate the sender instead of authorizing a caller-supplied name.

The permission request proves only that the named login has write access. It does not prove that the login authored COMMENT_ID or initiated the run. A caller that forwards attacker-controlled input can pass any write-capable login, and the same spoofed value then reaches --actor. The override can update the status and label with false attribution.

Require a non-empty COMMENT_ID. Fetch the comment from TARGET_REPO. Verify its author, issue or pull request, and body against SENDER, PR_NUMBER, and COMMENT_BODY before applying the override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-ai-triage-override.yml around lines 100 - 120, Update
the ci/verify-sender-can-write step to require a non-empty COMMENT_ID and fetch
that comment from TARGET_REPO before authorizing the override. Validate the
fetched comment’s author, issue or pull-request association, and body against
SENDER, PR_NUMBER, and COMMENT_BODY; only continue when all values match, and
use the verified author for subsequent attribution rather than trusting
caller-supplied SENDER.

136-146: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Restrict TSIO_URL before sending TSIO credentials.

TSIO_URL passes directly from the reusable-workflow input to requests that include TSIO_API_KEY or an OIDC bearer token. Keep the endpoint in trusted configuration, or enforce an exact HTTPS origin allowlist before invoking scripts/triage-override.js.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-ai-triage-override.yml around lines 136 - 146,
Validate TSIO_URL from the reusable-workflow input against the trusted exact
HTTPS origin allowlist before invoking scripts/triage-override.js. Reject the
workflow before any request can send TSIO_API_KEY or an OIDC bearer token when
the URL is missing, non-HTTPS, or not an approved origin; preserve the existing
invocation for valid URLs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/e2e-ai-triage-override.yml:
- Around line 68-84: Update ci/checkout-toolkit to use the called workflow’s
repository and exact commit by setting repository from job.workflow_repository
and ref from job.workflow_sha; remove the ci/resolve-toolkit-ref step and its
toolkit-ref output usage.

---

Outside diff comments:
In @.github/workflows/e2e-ai-triage-override.yml:
- Around line 100-120: Update the ci/verify-sender-can-write step to require a
non-empty COMMENT_ID and fetch that comment from TARGET_REPO before authorizing
the override. Validate the fetched comment’s author, issue or pull-request
association, and body against SENDER, PR_NUMBER, and COMMENT_BODY; only continue
when all values match, and use the verified author for subsequent attribution
rather than trusting caller-supplied SENDER.
- Around line 136-146: Validate TSIO_URL from the reusable-workflow input
against the trusted exact HTTPS origin allowlist before invoking
scripts/triage-override.js. Reject the workflow before any request can send
TSIO_API_KEY or an OIDC bearer token when the URL is missing, non-HTTPS, or not
an approved origin; preserve the existing invocation for valid URLs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 47d54c84-9c70-4b5c-a9b4-90f205220eac

📥 Commits

Reviewing files that changed from the base of the PR and between 7a4934e and 19e8c14.

📒 Files selected for processing (2)
  • .github/workflows/e2e-ai-triage-override.yml
  • .github/workflows/e2e-ai-triage.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/e2e-ai-triage.yml

Comment thread .github/workflows/e2e-ai-triage-override.yml Outdated
The checkout ref was wrong. It resolved from github.workflow_ref, but inside a
called reusable workflow the whole github context describes the *caller* — so it
asked for this repository at mattermost-mobile's branch, which does not exist
here, and the checkout would have failed. There is no context that names the
called workflow's own ref, so it is an input now, defaulting to main. Callers
testing an unmerged toolkit change pass the same ref they pinned `uses:` to.

sender was believed on the caller's word. The permission check added earlier
keeps outsiders out, but sender is still a caller-supplied string, so one
collaborator could be recorded as having overturned a verdict another
collaborator actually overturned. The comment is now fetched and checked: its
author must be sender, and it must belong to the PR being overridden. Same
principle as taking corrected_by from the authenticated principal — attribution
the caller can choose is not attribution.

tsio_url went unvalidated into a step that sends TSIO_API_KEY, or a minted OIDC
token, to whatever host it names. That is a credential-exfiltration primitive,
not merely a wrong endpoint. Exact origins only: suffix matching would accept
evil-mattermost.com.
yasserfaraazkhan and others added 9 commits August 8, 2026 23:19
Add a separate operational-outcome layer on top of the stored verdict enum:
FLAKY_CONFIRMED (success), REGRESSION (failure), TRIAGE_FAILED (failure).
The outcome is the user-facing headline; confidence-bar and tier jargon no
longer lead the status.

Policy:
- Stored verdict enums preserved; the outcome is computed from verdict + context.
- FLAKY_TEST/FLAKY_INFRA/FLAKY_SERVER → FLAKY_CONFIRMED only with confidence
  >= 0.85, two distinct citations, complete evidence, not reproduced on every
  rerun, and amnesty not exhausted. A reproduced or out-of-budget flake is a
  REGRESSION.
- PR: confirmed flakes succeed and apply E2E/AI-Waived; genuine failures and
  triage failures block.
- MAIN/MASTER/RELEASE/CMT: confirmed flakes succeed with no label (ledger
  recorded); regressions and triage failures fail.
- MAIN_REGRESSION excuses an unrelated PR only; on a baseline branch it is a
  REGRESSION; with diff overlap it is TRIAGE_FAILED.
- Missing report, API failure, malformed model response, low confidence,
  missing/incomplete citation, ledger failure, or unknown run type →
  TRIAGE_FAILED. One REGRESSION or TRIAGE_FAILED cluster fails the run.

Workflow contract:
- Add status_context input (default e2e-test/ai-triage); used for every status
  write in both the triage and override workflows.
- Expose outputs: state, waived, verdict, operational_outcome, description,
  triage_url (step, job, and workflow_call levels).
- PR waivers require E2E/AI-Waived; baseline/CMT flaky success requires ledger
  recording but no label.

Defect fixes in triage-apply.js:
- Remove the undefined clusterByIndex ledger lookup; map ledger rows to clusters
  by signature.
- Require successful ledger recording before green authority; a ledger failure
  turns the run into TRIAGE_FAILED.
- Verify the PR head before and after applying a waiver; withdraw the label and
  fail closed if it moved.
- Sanitize every GITHUB_OUTPUT value; add operational_outcome and triage_url.
- Keep AI and human overrides distinguishable: human corrections apply
  E2E/Override, never E2E/AI-Waived; withdrawing removes both.

Tests: 80 pass. actionlint clean. git diff --check clean.
Three fixes found by tracing mattermost-mobile#9996 against its live run
history. The pipeline waived two flakes green on 2026-08-08 and reported
every flake as a regression from 2026-08-11; the difference was a staging
redeploy that replaced the TSIO build carrying the triage routes.

recordLedger checked only res.ok. A TSIO deployment without these routes
serves its single-page app on every unmatched path, so the miss arrives as
200 text/html rather than 404: res.ok is true, res.json() dies on the
doctype, and the run reports `Unexpected token '<'`. That reads as a bug in
triage rather than a missing endpoint, and it took three repositories to
trace. Check the content type and say which endpoint is absent.

markTriageFailed wrote the TRIAGE_FAILED headline into the reason, and
statusDescription prefixes the same headline. The status therefore read
"triage could not complete safely: triage could not complete safely: …",
spending 35 of GitHub's 140 characters on a repeat and truncating the
actual cause mid-word.

reproducedOnRerun was consulted only inside waiveOrConfirm, so the
strongest evidence the pipeline produces could only ever push toward red
within the waivable branch. A PR_REGRESSION at 0.6 that reproduced on both
fresh-device repetitions was reported as "triage could not complete
safely", when triage had completed and measured the failure twice. The
confidence bar guards the assertion of a cause; REGRESSION only claims a
genuine failure exists, which reproduction establishes without the model.
Both outcomes were already state: failure, so this cannot green a run — it
changes the headline a reviewer reads and the platform classification from
TRIAGE_FAILED to PRODUCT_BUG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mattermost-mobile#9996 run 31874108751 reported "genuine test or product
failure: 3 unwaived cluster(s); most confident: FLAKY_INFRA at 0.75 is
below the green bar of 0.85" and labelled the iOS context "verified to be
a product bug". The run held one regression and two clusters triage could
not classify. Two separate defects made it read as three product bugs.

decideCluster accepted PR_REGRESSION without consulting the diff.
PR_REGRESSION means "this change broke it" — a claim about the diff, not
about the error text — and #9996 changes only .github/ and detox/triage/.
A CI-config diff cannot reach a markdown-table scroll gesture, but the
verdict was taken at face value, so the author was told their change broke
a test it could not touch. The failure was real: it reproduced on both
reruns. The attribution was what triage got wrong, so an unsupported
PR_REGRESSION now resolves TRIAGE_FAILED — still red, no longer a bug
report against the PR.

The signal is read off context directly rather than the destructured
binding, which defaults to false. That default is permissive for the
MAIN_REGRESSION branch and would be the opposite here: absent would read
as "proven unrelated" and downgrade every PR_REGRESSION from a caller that
never supplied the field. Only an explicit false is evidence.

decideRun chose its headline from any cluster carrying REGRESSION but
quoted whichever red ranked highest on confidence, so a REGRESSION
headline was explained using a TRIAGE_FAILED cluster's sub-threshold
flake. The quoted cluster is now drawn from the deciding outcome, and the
count names its composition — "1 regression, 2 unclassified" rather than
"3 unwaived cluster(s)", which invited the reader to assume three of
whatever the headline said.

Replaying that run's five clusters through the fix yields TRIAGE_FAILED
with "3 unclassified" and no product-bug claim. It stays red, correctly:
one failure reproduced on every rerun, and the two Maestro clusters have
no rerun path to acquire evidence from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both prompts documented only the true case of `all_failing_on_baseline` and
`any_failing_elsewhere`. A model reading false therefore concluded the
negative had been established, when false also covers "never measured" —
no baseline runs recorded, a failed lookup, or a test with no stable ID.

They now name the tri-state fields the bundle carries and define all three
values, stating explicitly that the unknown value is not a synonym for the
negative and supports no conclusion in either direction. `determinism` is
called out hardest: `not_measured` is the permanent state of every Maestro
cluster, because Maestro has no per-flow rerun, and reading it as `cleared`
is the difference between "the rerun passed" and "nobody ran one".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attribution has two independent routes and the pipeline only walks one.
"Was it already broken?" needs baseline history. "Could this change have
broken it?" needs only the diff — and either answer clears the pull
request, because both mean the failure is not this author's to fix.

Resting everything on the first route is why a deterministic failure with
no recorded baseline stalls at TRIAGE_FAILED and waits for a human even
when the diff is plainly incapable of causing it. Run 31883153145 is the
worked example: four clusters auto-waived on rerun evidence, and the fifth
sat red with the model explicitly declining to attribute it.

This commit adds the outcome and the fact it needs, not the rule:

- OUTCOMES.NOT_ATTRIBUTABLE and its headline. Distinct from
  FLAKY_CONFIRMED, which claims the failure is not real, and from
  MAIN_REGRESSION, which cannot be established without history.
- isolatedFailure, from member_count === 1 with no shard or platform
  spread. This is what would make the diff argument safe rather than
  merely appealing: a diff with no product code can still change how tests
  execute — a workflow input, a device flag, a server URL — but those break
  tests broadly. One failing assertion beside hundreds of passes on the
  same platform is not a harness change expressing itself.

The rule that produces the outcome is deliberately absent. It is the first
code in this system that would turn a red required check green on the
strength of a diff rather than a measurement, and it wants review before it
exists rather than after. Nothing emits NOT_ATTRIBUTABLE yet, so behaviour
is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notification step ran on every triage invocation. That was harmless
only because WEBHOOK_URL is not a secret any caller actually defines, so
the step was skipped on every run to date — mattermost-mobile logs
HAS_WEBHOOK: false in every adjudication. Wiring a real channel in makes
the condition load-bearing rather than decorative.

A message per PR E2E run, most of them "confirmed flaky, nothing to see",
is how a channel learns to ignore the one message that mattered. Two cases
earn one:

  - A confident blame. Triage narrowed the breakage to a single commit and
    can name its author, so the callout reaches the person who can act on
    it. This is the case that gets a PR author unstuck from somebody else's
    regression, and it is the reason the blame module exists.
  - A baseline run that went red. Main or release is broken, which is the
    channel's business whether or not anyone can be named yet.

A routine PR run is neither. Its outcome already lives on the pull request,
which is where the person who cares is looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adjudication, clustering, and check greening live in TSIO's
test-system-io-ai-triage action. This repo retains /e2e-triage-override.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant