Skip to content

Commit d884a8c

Browse files
rafaelgilnRafaelclaude
authored
ci(triage): gate the agent on a usable report and pin it to Sonnet 5 (#1178) (#1179)
* ci(triage): gate the agent on a usable report and pin it to Sonnet 5 The triage agent is the largest consumer of the shared ANTHROPIC_API_KEY, and on 2026-07-31 it paid its maximum price to triage a daily that produced ZERO test results. From the action's own log (run 30624377765): "model": "claude-opus-5[1m]" "num_turns": 28, "total_cost_usd": 1.6609247499999995 28 of the 30 allowed turns, on the 1M-context Opus, for $1.66 -- reasoning over an input that did not exist. The triggering daily had all four shards fail without producing a blob, so the merge job died on "No report files found in .../all-blobs" and never uploaded results.json. The propose job printed "no results.json -- history-only" as a warning and started the agent anyway, which then reconstructed the run from scratch through Bash. That is what filled the transcript and consumed the turn budget. Two independent changes. 1. Gate the agent on a usable input. A run that recorded zero test results has no failures to group and no clusters to dedup, so the decision is cheap and deterministic and belongs before the agent. It reuses analyze() from scripts/check-run-integrity.mjs -- the daily's own runguard logic (#1012) -- rather than re-implementing report parsing, so the two cannot drift apart, and so the case that makes a naive test-count check wrong is handled: a PARTIAL run, where some shards aborted in globalSetup while others executed (#1058, measured at 205 recorded while ~184 never ran), still RUNS the agent. Absent report or zero tests skip; present-but-unparseable fails the step rather than reading as "nothing to triage" (#1035). The skip path is loud -- ::warning:: plus a run-summary line -- so an abort day is distinguishable from a quiet healthy one. It deliberately does not comment on the umbrella: on an abort there may be none (07-31 opened none), and creating one is the agent's job behind the propose-confirm gate. `execute` is NOT gated -- a human "pode abrir" means a plan already exists, so it needs no report. 2. Pin --model claude-sonnet-5 on both claude_args. The action's default is claude-opus-5[1m] and nothing here needs Opus-tier reasoning at Opus-tier price. Sonnet 5 keeps the 1M context window this job depends on (it reads CI logs) at 3/5 of the per-token price: across the three measured runs (07-29 $2.06 / 07-30 $0.99 / 07-31 $1.66), $4.71 -> ~$2.80. NOT haiku -- its window is 200K and the most expensive runs are the ones with the largest input, so it would overflow exactly where it matters; worth an A/B once this change bounds the input, with #1171 standing as the reminder that a cheaper model gets validated, not assumed. Covered by npm run test:scripts: every report state including the partial/zero lookalikes, the unparseable hard error, the single-line GITHUB_OUTPUT contract, and a structural guard that the agent step is still gated and both jobs still pin the model (both halves force-failed locally). Not fixed here, and more urgent than cost: the daily itself is aborting -- 07-31 recorded zero tests, 07-28 recorded 0/0/0/0, today's history push failed so there is no 07-31 row, and no umbrella was opened. That is lost coverage and needs its own issue. Refs #1178 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(triage): let workflow_dispatch bypass the report gate Caught while classifying the diff: a workflow_dispatch downloads no artifact at all (there is no triggering run id), so the gate as first written refused the one path that exists for re-proposing on demand — including the per-model A/B #1171 needs. The gate now binds the automatic workflow_run path only, matching the reasoning that leaves `execute` ungated: a human asking for this triage with an explicit --issue has already decided there is something to triage. Asserted in the structural guard and force-failed locally. Refs #1178 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Rafael <rafael@oriontech.me> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7e0facc commit d884a8c

3 files changed

Lines changed: 468 additions & 2 deletions

File tree

.github/workflows/triage-dispatch.yml

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,50 @@ jobs:
7373
-n "playwright-json-daily-$DAILY_RUN_ID" -D . \
7474
|| echo "::warning::results.json artifact unavailable for run $DAILY_RUN_ID — propose runs history-only."
7575
if [ -f results.json ]; then echo "results.json present ($(wc -c < results.json) bytes)."; else echo "no results.json — history-only."; fi
76+
77+
# Do not start the agent when the triggering run produced nothing to triage
78+
# (#1178). Until this gate existed, the step above degraded to
79+
# "history-only" and the agent ran anyway: on 2026-07-31 the daily's four
80+
# shards all failed without a blob, the merge job died on `No report files
81+
# found in .../all-blobs`, no results.json was uploaded, and the agent spent
82+
# 28 of its 30 turns and $1.66 (its own log reports `num_turns` and
83+
# `total_cost_usd`) reconstructing a run that had recorded ZERO test results.
84+
#
85+
# The decision reuses `analyze()` from scripts/check-run-integrity.mjs — the
86+
# daily's own runguard logic (#1012) — so the two can never disagree, and so
87+
# the case that makes a naive test-count check wrong is handled: a PARTIAL
88+
# run, where some shards aborted in globalSetup while others executed
89+
# (#1058), still RUNS the agent. Skipping is loud (::warning:: + a run-summary
90+
# line), never silent; an unparseable-but-present report fails the step
91+
# rather than reading as "nothing to triage" (#1035).
92+
- name: Gate — does the triggering run give the agent anything to triage?
93+
id: triage_input
94+
run: node scripts/triage-input-gate.mjs --results results.json
95+
96+
# The gate binds the AUTOMATIC path only. A `workflow_dispatch` is a human
97+
# asking for this triage with an explicit `--issue`, and it downloads no
98+
# artifact at all (there is no triggering run id), so gating it would refuse
99+
# the one path that exists for re-proposing on demand — including the
100+
# per-model A/B #1171 needs. Same reasoning as leaving `execute` ungated.
76101
- uses: anthropics/claude-code-action@v1
102+
if: >-
103+
github.event_name == 'workflow_dispatch' ||
104+
steps.triage_input.outputs.should_run_agent == 'true'
77105
env:
78106
GH_TOKEN: ${{ github.token }}
79107
with:
80108
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
81109
prompt: "/langflow-e2e-triage --phase propose ${{ inputs.issue && format('--issue {0}', inputs.issue) || '' }}"
82-
claude_args: '--allowedTools "Read,Bash" --max-turns 30'
110+
# --model pinned (#1178): the action's default is `claude-opus-5[1m]`, and
111+
# nothing here needs Opus-tier reasoning at Opus-tier price. Sonnet 5
112+
# keeps the 1M context window this job depends on (it reads CI logs) at
113+
# 3/5 of the per-token price — on the three measured runs, $4.71 → ~$2.80.
114+
# NOT haiku: its window is 200K, and the most expensive runs are the ones
115+
# with the largest input, so it would overflow exactly where it matters.
116+
# Worth an A/B once the input is bounded — via `workflow_dispatch --issue N`
117+
# on the same umbrella — and #1171 is the standing reminder that a cheaper
118+
# model has to be validated, not assumed.
119+
claude_args: '--allowedTools "Read,Bash" --max-turns 30 --model claude-sonnet-5'
83120

84121
execute:
85122
name: Execute approved triage
@@ -109,7 +146,10 @@ jobs:
109146
with:
110147
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
111148
prompt: "/langflow-e2e-triage --phase execute --issue ${{ github.event.issue.number }}"
112-
claude_args: '--allowedTools "Read,Bash" --max-turns 40'
149+
# Same pin as propose (#1178). NOT gated on a report: execute is triggered
150+
# by a human "pode abrir" on a plan that already exists, so it has
151+
# something to do regardless of what the daily uploaded.
152+
claude_args: '--allowedTools "Read,Bash" --max-turns 40 --model claude-sonnet-5'
113153

114154
# The contract gate for the AGENT path (#1035).
115155
#

scripts/triage-input-gate.mjs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Decides whether `triage-dispatch.yml`'s propose job should start the triage
4+
* agent at all, from the report the triggering daily produced (#1178).
5+
*
6+
* ## Why this gate exists
7+
*
8+
* The propose job downloads the daily's `results.json` "best-effort" and, when
9+
* the artifact is missing, prints `no results.json — history-only` and runs the
10+
* agent anyway. On 2026-07-31 that cost the maximum: the daily's four shards all
11+
* failed without producing a blob, the merge job died on `No report files found
12+
* in .../all-blobs`, and no `results.json` was ever uploaded. The agent then
13+
* reconstructed the run from scratch through `Bash` — 28 of its 30 allowed turns,
14+
* on `claude-opus-5[1m]`, **$1.66** (its own log reports `num_turns` and
15+
* `total_cost_usd`) — to triage a run that had recorded **zero test results**.
16+
*
17+
* There is nothing to triage in that state: no failures to group, no clusters to
18+
* dedup against open issues. The decision is cheap and deterministic, so it
19+
* belongs before the agent, not inside it.
20+
*
21+
* ## Why it reuses `analyze()` rather than parsing the report itself
22+
*
23+
* `scripts/check-run-integrity.mjs` already owns "did this run actually produce
24+
* tests" for the daily's own `runguard` step (#1012), including the case that
25+
* makes a naive test-count check wrong: a **partial** run, where some shards
26+
* aborted in `globalSetup` while others executed (#1058, measured on run
27+
* 30444299314 — 205 tests recorded while ~184 never ran). A second
28+
* implementation here would be a second thing to keep in sync, and the two
29+
* disagreeing is exactly how a guard goes quietly wrong.
30+
*
31+
* ## The decision
32+
*
33+
* | Report state | Agent | Why |
34+
* |---------------------------------------|-------|-----|
35+
* | artifact absent | no | the daily produced nothing |
36+
* | present, `testsTotal === 0` | no | infra abort, not a per-test day (#1012) |
37+
* | present, `partial` | YES | real failures alongside an abort — the #1058 case worth triaging |
38+
* | present, healthy | YES | ordinary red daily |
39+
* | present but unparseable | ERROR | an undecidable verdict must not read as "nothing to triage" (#1035) |
40+
*
41+
* The skip path is **loud** — a `::warning::` plus a job-summary line naming the
42+
* state observed — so an abort day is visible rather than silent (#1012's rule).
43+
* It deliberately does not comment on the umbrella: on an abort there may be no
44+
* umbrella at all (none was opened for 2026-07-31), and creating one is the
45+
* agent's job behind the propose-confirm gate, not this script's.
46+
*
47+
* Run:
48+
* node scripts/triage-input-gate.mjs [--results results.json]
49+
*
50+
* Output (stdout, JSON): { runAgent, verdict, reason, testsTotal }
51+
* Side effects: `should_run_agent` / `verdict` / `reason` on `$GITHUB_OUTPUT`,
52+
* one line on `$GITHUB_STEP_SUMMARY`.
53+
*/
54+
55+
import fs from "node:fs";
56+
import { analyze } from "./check-run-integrity.mjs";
57+
58+
const HELP = `usage: triage-input-gate.mjs [options]
59+
60+
--results PATH merged Playwright JSON report downloaded from the daily
61+
(default: results.json)
62+
`;
63+
64+
const DEFAULT_RESULTS = "results.json";
65+
66+
/**
67+
* Pure decision. `readReport` returns the parsed report, `null` when the file is
68+
* absent, and THROWS when the file exists but does not parse — the caller turns
69+
* that into a hard error rather than a skip.
70+
*
71+
* @returns {{ runAgent: boolean, verdict: string, reason: string, testsTotal: number }}
72+
*/
73+
export function decideTriageInput(resultsPath, readReport) {
74+
const report = readReport(resultsPath);
75+
76+
if (report === null) {
77+
return {
78+
runAgent: false,
79+
verdict: "no-report",
80+
reason:
81+
`${resultsPath} was not produced by the triggering run — the daily ` +
82+
`uploaded no merged report, so there are no failures to group and ` +
83+
`nothing to dedup. Skipping the agent instead of letting it reconstruct ` +
84+
`the run through Bash (that path cost 28 turns / $1.66 on 2026-07-31).`,
85+
testsTotal: 0,
86+
};
87+
}
88+
89+
const state = analyze(report);
90+
91+
if (state.empty) {
92+
return {
93+
runAgent: false,
94+
verdict: "zero-tests",
95+
reason:
96+
`the triggering run recorded ZERO test results` +
97+
(state.aborted
98+
? ` and carries ${state.reportErrors} top-level error(s) — the shards ` +
99+
`aborted before the first test`
100+
: ``) +
101+
`. That is an infra abort, not a per-test day (#1012), so there is ` +
102+
`nothing for the agent to triage.`,
103+
testsTotal: 0,
104+
};
105+
}
106+
107+
if (state.partial) {
108+
return {
109+
runAgent: true,
110+
verdict: "partial",
111+
reason:
112+
`${state.testsTotal} test result(s) recorded alongside ` +
113+
`${state.reportErrors} top-level error(s) — some shards aborted while ` +
114+
`others ran (#1058). The recorded failures are worth triaging, and the ` +
115+
`abort is part of what the plan must say.`,
116+
testsTotal: state.testsTotal,
117+
};
118+
}
119+
120+
return {
121+
runAgent: true,
122+
verdict: "usable",
123+
reason: `${state.testsTotal} test result(s) recorded — ordinary triage input.`,
124+
testsTotal: state.testsTotal,
125+
};
126+
}
127+
128+
/**
129+
* Absent → `null`. Present-but-unparseable → throw, so the CLI can fail loudly
130+
* instead of reporting the healthy "nothing to triage" verdict for a state it
131+
* could not read.
132+
*/
133+
export function readReportOrThrow(resultsPath, io = {}) {
134+
const exists = io.exists ?? ((p) => fs.existsSync(p));
135+
const read = io.readFile ?? ((p) => fs.readFileSync(p, "utf8"));
136+
137+
if (!exists(resultsPath)) return null;
138+
const raw = read(resultsPath);
139+
try {
140+
return JSON.parse(raw);
141+
} catch (error) {
142+
throw new Error(
143+
`${resultsPath} exists but does not parse as JSON (${error.message}) — ` +
144+
`refusing to report a verdict this gate could not derive`,
145+
);
146+
}
147+
}
148+
149+
function parseArgs(argv) {
150+
const args = { results: DEFAULT_RESULTS };
151+
for (let i = 0; i < argv.length; i++) {
152+
const flag = argv[i];
153+
if (flag === "--help" || flag === "-h") {
154+
args.help = true;
155+
continue;
156+
}
157+
const value = argv[i + 1];
158+
if (value === undefined) throw new Error(`missing value for ${flag}`);
159+
i++;
160+
if (flag === "--results") args.results = value;
161+
else throw new Error(`unknown flag: ${flag}`);
162+
}
163+
return args;
164+
}
165+
166+
function appendLine(envVar, line) {
167+
const target = process.env[envVar];
168+
if (target) fs.appendFileSync(target, `${line}\n`);
169+
}
170+
171+
if (process.argv[1] && process.argv[1].endsWith("triage-input-gate.mjs")) {
172+
let args;
173+
try {
174+
args = parseArgs(process.argv.slice(2));
175+
} catch (error) {
176+
process.stderr.write(`::error::triage-input-gate: ${error.message}\n`);
177+
process.exit(2);
178+
}
179+
if (args.help) {
180+
process.stdout.write(HELP);
181+
process.exit(0);
182+
}
183+
184+
let decision;
185+
try {
186+
decision = decideTriageInput(args.results, (p) => readReportOrThrow(p));
187+
} catch (error) {
188+
process.stderr.write(`::error::triage-input-gate: ${error.message}\n`);
189+
process.exit(2);
190+
}
191+
192+
appendLine("GITHUB_OUTPUT", `should_run_agent=${decision.runAgent}`);
193+
appendLine("GITHUB_OUTPUT", `verdict=${decision.verdict}`);
194+
appendLine("GITHUB_OUTPUT", `reason=${decision.reason.replace(/\n/g, " ")}`);
195+
196+
if (decision.runAgent) {
197+
process.stderr.write(
198+
`triage input OK (${decision.verdict}): ${decision.reason}\n`,
199+
);
200+
appendLine(
201+
"GITHUB_STEP_SUMMARY",
202+
`**Triage input:** \`${decision.verdict}\` — ${decision.reason}`,
203+
);
204+
} else {
205+
// Loud, never silent: an abort day must be distinguishable from a quiet
206+
// healthy one in the job log AND in the run summary.
207+
process.stderr.write(`::warning::triage-input-gate: ${decision.reason}\n`);
208+
appendLine(
209+
"GITHUB_STEP_SUMMARY",
210+
`**Triage agent SKIPPED** (\`${decision.verdict}\`) — ${decision.reason}`,
211+
);
212+
}
213+
214+
process.stdout.write(`${JSON.stringify(decision)}\n`);
215+
process.exit(0);
216+
}

0 commit comments

Comments
 (0)