Snogrammer

Closing the Loop: Automated Production Error Fixes

14 min read

In the last post I gave Claude Code a Gitea account and a dispatcher that turns assigned issues into merged pull requests. It works a ticket queue like a junior engineer: pick up the next issue, open a PR, fix whatever CI complains about, merge, repeat all while I'm sleeping.

One input still came through me, though: production errors. Every project on my self-hosted PaaS reports to a self-hosted Bugsink, a lightweight Sentry-compatible error tracker. My routine was to see an alert, open Bugsink, read the stacktrace, write a Gitea issue that said more or less "here's the stacktrace, fix it," and assign it to the bot. I was a copy-paste step between two systems that both have APIs.

So I cut myself out. Bugsink alerts now become Gitea issues assigned to the bot, and when the fix merges, the dispatcher resolves the error in Bugsink. This post covers the design, the calls I'd defend in a design review, and what happened in the first nine days. Spoiler: the bot fixed 3 of the 33 issues it was handed, and that turned out to be the most useful thing I learned.

The rules

Same as last time, I wrote the behavior down before writing code:

  1. A new error in Bugsink becomes exactly one Gitea issue, in the repo that owns the project, assigned to the bot.
  2. From there it's ordinary work. No special path through the engine.
  3. The Bugsink issue is resolved only when the bot's fix merges. If I close the Gitea issue by hand, Bugsink is left alone.
  4. If the error comes back after a fix, the bot gets one more try.
  5. If it comes back again, the bot stops and a human takes over.

Rule 2 kept the change small. The engine already knows how to pick up assigned issues, run Claude, handle CI and merge. The intake only has to produce a well-formed issue and watch for the merge.

The architecture

flowchart LR
    subgraph Prod["Production services"]
        SVC["project-1 / project-2 / project-3"]
    end
    SVC -- "errors (Sentry SDK)" --> BS[(Bugsink)]
    BS -- "custom webhook<br/>POST /hooks/bugsink/{secret}" --> IN

    subgraph D["claude-dispatcher"]
        IN["Intake<br/>202 + background work"] --> DB[("SQLite<br/>bugsink_link")]
        ENG["Engine<br/>(existing reconcile loop)"]
        SW["Sweep<br/>(after each poll)"] --> DB
    end

    IN -- "file issue, label bugsink,<br/>assign bot" --> GI["Gitea issue"]
    GI --> ENG
    ENG -- "claude -p" --> PR["PR + CI"]
    PR -- "green → merge" --> ENG
    ENG -- "work state: done" --> SW
    SW -- "resolve + comment" --> BS

There are three moving parts, and only one of them is new.

The intake is a webhook handler. Bugsink's "Custom webhook" alert posts the issue as JSON with an alert_reason (NEW, REGRESSED, TEST, and so on). The handler validates it, answers 202 right away and does the real work in the background, because filing an issue takes several Gitea calls and Bugsink is sitting there waiting for a response.

The engine is unchanged apart from one optional hook, since it already picks up issues assigned to the bot. The sweep runs at the end of every poll: for each open link it checks whether the engine marked that issue done, and if so it resolves the Bugsink issue.

Each filed issue has a Bugsink: <title> title, the error type and value, where it happened, first and last seen, the event count, the latest stacktrace, a link back to Bugsink, and a hidden <!-- bugsink:<uuid> --> marker. The dispatcher also comments the Gitea link on the Bugsink issue, so either side leads to the other.

The new code is about 380 lines of TypeScript in src/bugsink/intake.ts plus a thin API client, and one new SQLite table.

The lifecycle

Each Bugsink issue gets one row in bugsink_link, and that row is a small state machine:

stateDiagram-v2
    [*] --> open: NEW alert<br/>file Gitea issue, assign bot
    open --> resolved: bot's fix merges<br/>(sweep resolves in Bugsink)
    resolved --> open: REGRESSED and<br/>regressions ≤ max<br/>reopen, reassign bot
    resolved --> frozen: REGRESSED and<br/>regressions > max<br/>label, unassign, mute, ping human
    frozen --> resolved: human unfreezes,<br/>fix merges, count resets
    open --> open: alerts while a fix<br/>is in progress are ignored

BUGSINK_MAX_REGRESSIONS defaults to 1, which gives one reopen. On the next regression the issue gets bugsink:frozen, the bot is unassigned, the issue is muted in Bugsink so it stops paging, and HUMAN_REVIEWER (me) is mentioned with the new stacktrace. To unfreeze it, I remove the label and assign the bot again. When that fix merges, the regression count starts over, because a fix after a freeze was a human decision.

The design calls

None of this is hard. Most of the work went into a handful of spots where the obvious implementation does the wrong thing without telling you.

Authentication is a secret in the URL

Bugsink 2.6 can't sign its webhooks or add custom headers, so HMAC verification was off the table. The only thing I control is the URL, so the URL is the secret: /hooks/bugsink/<64 hex chars>. It's compared in constant time, and a wrong secret answers 404, exactly like a path that doesn't exist, so you can't tell whether you guessed the route right.

I don't love it. A secret in a path ends up in access logs and proxy logs. But the threat model is "someone files fake error issues on my homelab," the blast radius is a Gitea issue the bot will look at and probably find nothing to do, and the webhook still runs over TLS. I treat the URL like a password and rotate it if it leaks. If Bugsink adds signing later, I'll switch.

Ack fast, work later

The handler returns 202 before it touches Gitea. Webhook senders have timeouts and retry policies you don't control, and the worst outcome is a sender that times out, retries, and leaves you with two issues for one error. Answer fast, and you avoid that at the source.

Idempotency comes from write order

Fast acks don't give you exactly-once delivery, so the intake has to tolerate repeats. A keyed lock per Bugsink issue ID stops two alerts for the same error from racing each other into two Gitea issues. And the link row is saved right after the Gitea issue is created, before labeling, before commenting on Bugsink, before anything else that can fail. If labeling fails and the alert is retried, the link already exists and nothing is filed twice.

The rule I followed: anything after the link write is allowed to fail, and anything before it has to be safe to repeat.

Critical writes vs. breadcrumbs

Not every call deserves the same error handling. Creating the issue and saving the link are critical: if they fail, the alert fails and gets logged. Fetching the stacktrace, commenting on Bugsink and muting a frozen issue are best effort. An issue without its stacktrace is still worth filing, and a missing breadcrumb comment isn't worth losing the whole alert over. Deciding up front which bucket each call goes in did more for reliability than any retry logic would have.

Resolve on merge, not on deploy

The textbook answer is to resolve the error once the fix is deployed. I don't track deploys yet. Nothing in the dispatcher knows when a merged change is actually running in production. So the sweep resolves on merge, and I accept the race: if an old event arrives between merge and deploy, Bugsink calls it a regression. Regressions have their own problem, though.

The attempt_at guard

The naive sweep says: "if the linked Gitea issue is done, resolve it in Bugsink." Now follow a regression. The fix merged, Bugsink resolved, the error came back, and the issue was reopened and reassigned. On the next poll the sweep looks at the work record, sees the previous merge marked done, and resolves the Bugsink issue again before the bot has even started the second attempt.

So each link stores attempt_at, the time the current attempt began, and the sweep only counts work finished after it:

for (const link of this.store.bugsinkLinks(["open", "frozen"])) {
  const work = this.store.getWork(link.repo, link.issue);
  if (work?.state !== "done" || work.updatedAt <= link.attemptAt) continue;
  await this.lock.run(link.bugsinkIssue, () => this.resolve(link));
}

It's a one-line condition that's easy to drop in a refactor, so I deleted the guard to make sure the tests would notice. The regression test failed, which is what I wanted. I don't trust a test I've never seen fail.

The sweep also reads only SQLite until it actually has something to resolve. It runs every poll, and I didn't want a tight loop of Bugsink API calls just to learn that nothing changed. If Bugsink is down when the time comes, the resolve waits until the next poll.

The regression budget is a circuit breaker

An agent that auto-merges its own fixes with no human review, fed by a system that reopens issues on its own, is a loop. Most of the time that loop converges. When it doesn't, say the bot's "fix" only suppresses one code path and the error comes back through another, it will happily cycle until someone notices the token bill.

BUGSINK_MAX_REGRESSIONS is the breaker. This is one regression round trip, followed by the breaker tripping:

sequenceDiagram
    participant S as Service
    participant B as Bugsink
    participant D as Dispatcher
    participant G as Gitea
    participant C as Claude
    participant H as Human

    S->>B: same error again
    B->>D: webhook (REGRESSED)
    D->>G: reopen issue, assign bot,<br/>comment new stacktrace
    D->>D: link: open, regressions=1,<br/>attempt_at=now
    G->>C: engine picks it up
    C->>G: PR → CI green → merge
    D->>B: sweep: resolve
    S->>B: error comes back again
    B->>D: webhook (REGRESSED)
    D->>G: label bugsink:frozen, unassign bot
    D->>B: mute
    D->>H: @mention with stacktrace

If the same error survives two automated fixes, I'd rather look at it myself. Usually that means the problem is in the design, and the bot shouldn't be making design calls unreviewed.

The stacktrace is untrusted input

Last time I wrote that issue text goes straight into the prompt, so anyone who can file an issue can instruct the bot. This feature adds a new way to file issues. Error messages often contain user input: a bad request body, a query string, a filename. In theory, someone who can trigger an error in production can now put text in front of Claude.

I layered a few mitigations, and none of them is perfect on its own:

  • The filed issue tells Claude outright to treat the error text and stacktrace as data, not instructions.
  • The stacktrace is capped at 8,000 characters and wrapped in a ~~~~ fence. Any ~~~~ inside it is broken up, so the trace can't close its own code block and start writing Markdown into the issue.
  • The bot's existing limits still apply: it can't push to main, weaken CI or delete tests, and its token only reaches repos where it's a collaborator.
  • The new Bugsink token and webhook secret are stripped from Claude's environment and redacted from logs.

What actually happened

The intake went live on October 27. Over the next nine days it filed 33 issues across 4 repos. The bot fixed and merged 3 of them on its own:

  • A Polymarket ETL job treated a Postgres deadlock as fatal. The bot ordered the lock acquisition and added a deadlock retry (sportslux #191).
  • An intentional Deferred signal from a quota-aware backfill was being reported as an error (#212).
  • The run scheduler only retried deadlocks, not other transient database errors (#243).

Two of them went from alert to merged PR in under 45 minutes, and the third overnight, all with no input from me. That part works exactly as designed.

I closed the other 30 myself. Sorting them by why they didn't need (or couldn't get) a bot fix taught me more than the three wins did:

Why it wasn't a bot fixCount
Data-quality checks (missing venue coordinates, unrostered players, games missing results, a stale source)12
Configuration, not code (a wrong relay URL, a bad wallet key in the environment)5
Four symptoms of one root cause (Postgres partition and shared-memory pressure)4
Already fixed by an earlier merge; the events predated it2
Error from code the TypeScript migration had already deleted1
Transient network errors, cancellations, permissions and one-offs6

On top of that, five of the bot's runs stopped because the repo's own checks wouldn't pass inside the agent container, so nothing was pushed. The dispatcher did the right thing there: it @-mentioned me with the failing command instead of pushing a red branch. No issue regressed far enough to freeze.

An alert is not a bug report

It's obvious in hindsight. An error tracker reports symptoms, and the bot can only fix defects in code. Those overlap, but less than I assumed.

Data-quality checks are alarms by design. sportslux raises a Sentry event when a league has venues without coordinates, so I notice. Seven of them arrived in the same minute and queued up behind each other, since the bot works one issue per repo at a time. The "fix" is a data backfill or an upstream source change, not a PR. On the ones it did reach, the bot found nothing to change in code and told me so. That was correct, and also a waste of a run.

Config errors look exactly like code errors from inside a stacktrace. cannot connect to MLB_STATS_BASE_URL was a wrong environment variable in production. The repo is fine. The bot can't see production config, and I don't want it to.

Then there's one root cause showing up as many issues. Bugsink groups by stack and message, which is right for an error tracker. But "out of shared memory," "connection was closed," "the database system is…" and "can't reconnect until invalid transaction is rolled back" were all one Postgres problem. Four issues, four bot runs, one real fix, and that fix needed a human looking at the database, not at a stacktrace.

Put in architecture terms, the intake made Bugsink's grouping into Gitea's unit of work, and those are different units. Bugsink groups by what failed. The bot needs work grouped by what to change.

What I'd change next

  • Triage before filing. Have the intake skip or reroute events it can classify cheaply: anything tagged as a data-quality check, and errors that match known config failure messages. Those should go to me as a notification, not to the bot as a ticket.
  • Cluster by root cause. A burst of database errors from the same service within a few minutes should become one issue with several linked Bugsink issues, not four separate ones.
  • Make local checks pass in the agent image. Five runs stopped on the repo's own checks inside the container, which points at the environment (toolchains, test databases, permissions) more than the bug. That's on me, and it's the cheapest win on this list.
  • Resolve on deploy once the dispatcher knows when a deploy finishes. That closes the merge-to-deploy race and makes "regressed" mean something again.

Takeaways

  • Wire the loop, then measure it. Three of 33 sounds bad until you see that most of the other 30 were never bot work. I wouldn't have known that without running it.
  • Decide which calls are critical and which are breadcrumbs. Getting the write order right and making the side calls best effort is most of what makes a webhook integration safe to retry.
  • Give every autonomous loop a way out. Here it's a regression budget and a freeze label, so a fix that keeps failing ends up with me instead of on the API bill.
  • Error text is user input. If a stacktrace reaches an agent's prompt, treat it with the same suspicion as a form field.

Next up: triage in front of the intake, so the bot only gets the errors it can actually fix. Happy debugging 🤘

Comments (0)

Your name shows as the part of your email before the @. Comments are moderated and appear once approved.

No comments yet. Be the first to share your thoughts!