ci: enrich scheduled-failure issues with an LLM triage pass - #2663
Open
tonyandrewmeyer wants to merge 9 commits into
Open
ci: enrich scheduled-failure issues with an LLM triage pass#2663tonyandrewmeyer wants to merge 9 commits into
tonyandrewmeyer wants to merge 9 commits into
Conversation
When a scheduled workflow fails, notify-scheduled-failure.yaml opens an issue whose title and body are a generic one-liner plus a link to the job. A human then reads the logs, works out whether it duplicates an existing issue, and rewrites the title and body before it is useful to anyone. This adds a second workflow that does that first pass: extract a deterministic failure signature from the failing jobs' logs, dedupe against real candidate issues, ask an LLM to draft a descriptive issue or comment, validate the result against a schema, and apply it -- falling back to the plain notice at every layer if anything goes wrong. The notifier keeps its guarantee of always producing a notification with no secrets and no LLM; its only change is a coarse dedupe by workflow name. The enricher is a separate workflow rather than being spliced into the notifier, so an unprovisioned or broken enricher cannot affect whether a notification happens. It subscribes via workflow_run to the seven scheduled workflows that call the notifier, because a reusable workflow invoked via workflow_call gets no independent run to subscribe to. The script keeps its pure logic -- log parsing, marker handling, prompt building, schema validation -- separate from everything that talks to `gh` or OpenRouter, so the logic is testable without mocking the network. It needs nothing outside the standard library. Two things reviewers should look at: - The `# zizmor: ignore[dangerous-triggers]` on the workflow_run trigger is the first zizmor suppression in this repository, and canonical#2612 removed the only zizmor config three weeks ago by fixing the underlying issue instead. The reasoning for treating this as a false positive is written out at the trigger; the precedent is a decision for reviewers, and the alternative is splicing the enrichment call into all seven callers. - Nothing is provisioned yet: without the `ai-failure-triage` environment and an OPENROUTER_API_KEY, every run takes the no-API-key path and produces today's plain notice, with the coarse dedupe as the only visible change. That makes this safe to merge ahead of the secret. The `gh` read path has been exercised against this repository using run 29847889218; on that run the extractor independently produced `traceback_top_error: "KeyError: 'loki/0'"`, matching the diagnosis a maintainer had written by hand on the issue it opened (canonical#2658). The write calls are covered only by mocks, since exercising them means posting here; they want a workflow_dispatch run after this merges.
tonyandrewmeyer
force-pushed
the
ai-failure-notifications-step-5
branch
from
July 25, 2026 00:55
9371af3 to
41325bb
Compare
Both found by dogfooding in the fork. The `Workflow: <name>` footer that the notifier's coarse search depends on was never written. It existed only in the prompt template, while the notifier's comment, the design and the applier all assumed the applier appended it. Enriched issue #24 in the fork went out without it, and the coarse search kept working only because the model happened to leave the workflow name in the title -- one different title and the issue thread would split permanently. Add render_body(), use it on every create, comment and in-place edit, and cover it with tests. Also make write_step_summary report to stderr as well as the summary file. Every fallback in this script reports through it, so a fallback was invisible in the job log and over the API -- which is exactly what made the second fork run hard to diagnose: it produced a plain-fallback comment with no way to tell whether OpenRouter had errored or the output had failed schema validation.
The LLM path was falling back to the plain body on almost every run. The step summary said `envelope: unknown field(s) ['also']`. `validate_envelope` calls `validate_entry` for the top-level envelope, and `validate_entry` checks unknown keys against a set that does not contain `also`, so the error was appended before `validate_envelope` reached its own unknown-field check, which did exclude `also`. That exclusion was dead code. Since the JSON schema handed to the model declares `also`, the model emits it routinely, so this was the normal path rather than an edge case. Tell validate_entry whether it is looking at the top-level envelope, where `also` is legal, and drop the now genuinely redundant second check. The three existing `also` tests did not catch this because they assert invalidity and match on the substring "also", which the spurious error contained -- they passed for the wrong reason. They now match on the specific message, and there are tests for a valid envelope with `also`, with an empty `also`, and for a genuinely unknown field still being rejected. Verified the new tests fail against the unfixed validator.
Two more from dogfooding, the first of which defeated the whole point of the dedup path. The origin issue was excluded from the candidate pool unconditionally. That is right for a placeholder this run just created, but wrong when the notifier commented on an issue that already existed -- the case for every recurrence after the first. That issue is the most likely duplicate, and removing it left the model with an empty candidate list, so it answered "new" and a duplicate issue was opened with a pointer comment: exactly what this path exists to prevent. Only exclude the origin when we created it. The validator also rejected an envelope for merely containing an inapplicable key, even when its value was null. The schema sent to OpenRouter is `strict`, so models return every declared property and null what does not apply; this discarded good output and fell back to the plain body. Treat null as absent, while still rejecting a real conflicting value. Verified all three new tests fail against the unfixed script.
The model chose `action: comment` -- correctly, now that it can see the matched issue again -- and returned a title, labels and issue_type alongside. Those mean nothing for a comment and were never going to be applied, but the validator treated their presence as an error, so the whole response was discarded and the plain notice went out instead. Drop them before validating and report what was ignored, rather than failing. This is a deliberate loosening: the schema handed to the model is `strict`, so it returns every declared property and fills whatever does not apply to the branch it chose. Policing that costs us the enrichment and buys nothing, since the applier only ever reads the fields for the action. A conflicting value that we *would* act on is still an error. Applies to `also` entries as well as the top-level envelope.
`locate_run_markers` looked the marker up with `gh search issues`. The issue search index is not read-your-writes, and the notifier stamps its marker moments before the enricher runs, so an unindexed marker reads as "no notifier marker found" -- at which point main() takes its missing-marker fallback and opens a *second* issue for a run that already has one. Two threads for one failure, CI green, nothing in the log to say why. Scan the most recently updated issues first instead. The list endpoint has no index lag, and the artefact the notifier just touched is by construction among the most recently updated issues in the repo. Search stays as a fallback for the one case a bounded listing cannot cover: more than RECENT_ISSUE_SCAN issues updated in between, where a stale index still beats no lookup at all. Not passing the number forward from the notifier: the two stages are separate workflow runs, so the only channels are an artefact or the triggering run's log. Downloading an artefact from the triggering run is precisely the thing the enricher's zizmor suppression argues it does not do, and is not worth trading that argument away for. The five new tests all fail against the unfixed lookup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`plain-fallback` created an issue unconditionally whenever `enrich` did not set `handled`. But by the time it can run, the notifier has always already notified: `workflow_run: completed` only fires once the caller's run, including its `open-issue` job, has finished. So any `enrich` crash -- network down, `uv run` failing, an unhandled exception -- produced two issues for one failure. It also caught `enrich` having applied its result and then died before setting the output. Look for `ai-failure-notifications:run=<id>:` and comment on that issue instead, creating one only when the marker is genuinely absent, which is the one case that means the notifier itself failed. The trailing colon matches the notifier's `:origin=` and the enricher's `:sig=` alike, so both cases above are covered. Lists recently updated issues rather than searching, for the same read-your-writes reason written out at locate_run_markers(): the marker is minutes old, and reading a stale index as "no issue exists" is exactly the duplicate this removes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tonyandrewmeyer
marked this pull request as ready for review
July 26, 2026 23:41
tonyandrewmeyer
requested review from
benhoyt,
james-garner-canonical and
tromai
July 26, 2026 23:42
Collaborator
Author
|
@benhoyt adding you as a reviewer specifically around the Zizmor/workflow question (feel free to review anything else as well, of course). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Today, when a scheduled workflow fails,
notify-scheduled-failure.yamlopens an issue whose title and body are a generic one-liner plus a link to the job. A human then has to read the logs, check whether it duplicates an existing issue, and rewrite the title and body before it is useful to anyone.This adds a second stage that does that first pass automatically: extract a deterministic failure signature from the failing jobs' logs, dedupe against real candidate issues, ask an LLM to draft a descriptive issue or comment, validate its output against a schema, and apply it — falling back to today's plain notice at every layer if anything goes wrong.
Two stages, not one
Stage 1 (
notify-scheduled-failure.yaml) keeps its current guarantee: no secrets, no LLM, no network beyondgh, always produces a notification. The only change is a coarse dedupe — it searches open issues for the workflow name and comments on a match instead of opening a duplicate.Stage 2 (
ai-failure-enrich.yaml, new) does the enrichment. It is a separateworkflow_run-triggered workflow rather than being spliced into the notifier, so that a broken or unprovisioned stage 2 cannot affect whether a notification happens at all.Note that
workflow_runcannot target the notifier directly — a reusable workflow invoked viaworkflow_calldoes not get an independent run to subscribe to. So the enricher lists the seven scheduled workflows that call the notifier, by their exactname:, and gates onconclusion == 'failure' && event == 'schedule', mirroring the callers' ownif: failure() && github.event_name == 'schedule'.Dedupe ladder
:sig=marker for this exact run id already exists anywhere in the repo's issues, this is a re-run of the same failing jobs. Comment "re-run attempt still failing" and skip signature extraction and the LLM call entirely. On a ten-run calibration corpus this caught 2 of the 4 real duplicate pairs — the single highest-value rung, and the reason both real July duplicates existed at all was re-runs rather than recurring signatures.newenvelope on a fresh placeholder →gh issue editthe placeholder in place, rather than opening a second issue.newenvelope where stage 1 had commented on an older issue → the coarse match was wrong: open a genuinely new issue and leave a pointer comment on the older one.The marker format is
<!-- ai-failure-notifications:run=<id>:origin=new|comment -->from stage 1 and<!-- ai-failure-notifications:run=<id>:sig=<hash> -->from stage 2. Rung 1 keys specifically off:sig=, so a same-run re-fire arriving before enrichment finishes is not mistaken for "already handled".The coarse search matches on workflow name alone rather than the full "Scheduled workflow 'X' failed" sentence, because enrichment rewrites the title and would otherwise break its own future matching. The applier always appends a
Workflow: <name>footer so the match survives.Failure handling
A notification is never lost, and a lost enrichment does not cost a duplicate issue:
ghsearch failures degrade to "no marker" / "no candidates" rather than raising.enrichcrashes outright, theplain-fallbackjob fires. It is marker-aware for the same reason everything else here is: by the time it can run, stage 1 has already notified, becauseworkflow_run: completedonly fires once the caller's run —open-issuejob included — has finished. Creating an issue unconditionally therefore meant two issues for one failure wheneverenrichdied. It now looks forai-failure-notifications:run=<id>:and comments on that issue instead, creating one only when the marker is genuinely absent, which is the one case that means stage 1 itself failed. The trailing colon matches the notifier's:origin=and the enricher's:sig=alike, so it also coversenrichhaving applied its result and then died before settinghandled. It lists recently updated issues rather than searching, for the read-your-writes reason described at rung 1.Please look at the zizmor suppression
This adds the first
# zizmor: ignore[...]in the repository. zizmor raisesdangerous-triggers(high, confidence medium) on the enricher'sworkflow_runtrigger.The two exploitable
workflow_runpatterns the audit exists to catch are downloading an artifact from the triggering — potentially fork-controlled — run and trusting it, and checking outgithub.event.workflow_run.head_shato execute it with this workflow's secrets and write token. Neither is present:actions/checkouttakes noref, so it checks outGITHUB_SHA, which forworkflow_runis the default branch tip and never the triggering run's ref, withpersist-credentials: false;github.event.workflow_run.*value reaches the script throughenv:, never interpolated into arun:block, so there is no template injection surface;github.event.workflow_run.event == 'schedule'. A fork pull request producesevent == 'pull_request', so a fork cannot trigger this at all — every run that reaches it originates fromschedule:on the default branch.workflow_runis also not substitutable here, for the reusable-workflow reason described above: the alternative is splicing an enrichment call into all seven callers, which is the coupling the two-stage design exists to avoid.If you would rather not take the suppression precedent in a feature PR, the alternative is that seven-caller restructure, and I am happy to do it instead.
Testing
The read-only
ghlayer has been exercised for real against this repository, using run 29847889218 (the 2026-07-21 "Example Charm Integration Tests" scheduled failure, which opened #2658). On that run the extractor independently producedtraceback_top_error: "KeyError: 'loki/0'"along with the three failing tests and their timeout messages, which matches the hand-written diagnosis left on #2658. That is the first check of the extractor against a log it was not calibrated on.The write calls have now been exercised too, in my fork, against a deliberately-failing stand-in workflow with a real OpenRouter key.
Before this does anything visible
ai-failure-triageenvironment and itsOPENROUTER_API_KEYneed provisioning. Until then every run takes the "no API key" path — which is still better than today's baseline, since the coarse dedupe now works, and makes this safe to merge behind.OPENROUTER_MODELrepo variable — the script defaults todeepseek/deepseek-chatif unset; worth pinning explicitly once the model choice is confirmed against live traffic.