Skip to content

ci: enrich scheduled-failure issues with an LLM triage pass - #2663

Open
tonyandrewmeyer wants to merge 9 commits into
canonical:mainfrom
tonyandrewmeyer:ai-failure-notifications-step-5
Open

ci: enrich scheduled-failure issues with an LLM triage pass#2663
tonyandrewmeyer wants to merge 9 commits into
canonical:mainfrom
tonyandrewmeyer:ai-failure-notifications-step-5

Conversation

@tonyandrewmeyer

@tonyandrewmeyer tonyandrewmeyer commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Today, 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 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 beyond gh, 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 separate workflow_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_run cannot target the notifier directly — a reusable workflow invoked via workflow_call does not get an independent run to subscribe to. So the enricher lists the seven scheduled workflows that call the notifier, by their exact name:, and gates on conclusion == 'failure' && event == 'schedule', mirroring the callers' own if: failure() && github.event_name == 'schedule'.

Dedupe ladder

  1. Marker match — if a :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.
  2. No API key → plain fallback body, still marker-stamped.
  3. Candidate search — open issues plus recently-closed ones (≤14 days, capped at 3, and explicitly labelled "(closed …)" in the block the model sees, with an instruction never to auto-treat a closed issue as a strong match) feed the LLM's comment-vs-new decision.
  4. Schema-invalid output → plain fallback body, with the validation errors written to the job's step summary.
  5. Valid new envelope on a fresh placeholdergh issue edit the placeholder in place, rather than opening a second issue.
  6. Valid new envelope 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:

  1. Inside the script — no API key, OpenRouter error, or schema-invalid output falls back to the plain body through the same marker-aware applier. gh search failures degrade to "no marker" / "no candidates" rather than raising.
  2. Inside the workflow — if enrich crashes outright, the plain-fallback job fires. It is marker-aware for the same reason everything else here is: by the time it can run, stage 1 has already notified, because workflow_run: completed only fires once the caller's run — open-issue job included — has finished. Creating an issue unconditionally therefore meant two issues for one failure whenever enrich died. It now looks for ai-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 covers enrich having applied its result and then died before setting handled. It lists recently updated issues rather than searching, for the read-your-writes reason described at rung 1.
  3. Stage 1 never depends on stage 2 at all.

Please look at the zizmor suppression

This adds the first # zizmor: ignore[...] in the repository. zizmor raises dangerous-triggers (high, confidence medium) on the enricher's workflow_run trigger.

The two exploitable workflow_run patterns the audit exists to catch are downloading an artifact from the triggering — potentially fork-controlled — run and trusting it, and checking out github.event.workflow_run.head_sha to execute it with this workflow's secrets and write token. Neither is present:

  • no artifact is downloaded;
  • actions/checkout takes no ref, so it checks out GITHUB_SHA, which for workflow_run is the default branch tip and never the triggering run's ref, with persist-credentials: false;
  • every github.event.workflow_run.* value reaches the script through env:, never interpolated into a run: block, so there is no template injection surface;
  • both jobs gate on github.event.workflow_run.event == 'schedule'. A fork pull request produces event == 'pull_request', so a fork cannot trigger this at all — every run that reaches it originates from schedule: on the default branch.

workflow_run is 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 gh layer 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 produced traceback_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

  • The ai-failure-triage environment and its OPENROUTER_API_KEY need 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_MODEL repo variable — the script defaults to deepseek/deepseek-chat if unset; worth pinning explicitly once the model choice is confirmed against live traffic.

Comment thread .github/workflows/ai-failure-enrich.yaml Fixed
Comment thread .github/workflows/ai-failure-enrich.yaml Fixed
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
tonyandrewmeyer force-pushed the ai-failure-notifications-step-5 branch from 9371af3 to 41325bb Compare July 25, 2026 00:55
tonyandrewmeyer and others added 8 commits July 25, 2026 13:51
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
tonyandrewmeyer marked this pull request as ready for review July 26, 2026 23:41
@tonyandrewmeyer

Copy link
Copy Markdown
Collaborator Author

@benhoyt adding you as a reviewer specifically around the Zizmor/workflow question (feel free to review anything else as well, of course).

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.

2 participants