🚨 Incident detected: INC-74 · sncf-mcp
- User impact: 56 of 604 next_departures calls failed (21.47%) in the last 5 min.
- Last deploy a80001e8c303 was at 19:43:01 UTC, about 1 minute before the alert.
Veilleur /vɛ.jœʁ/ · French for night watchman
An always-on on-call agent for small teams without a dedicated SRE. It investigates production incidents, reproduces them in a sandbox, and brings you a verified fix to approve.
From a 16-run chaos eval on a live stack. How it was measured
🚨 Incident detected: INC-74 · sncf-mcp
- User impact: 56 of 604 next_departures calls failed (21.47%) in the last 5 min.
- Last deploy a80001e8c303 was at 19:43:01 UTC, about 1 minute before the alert.
🔎 INC-74 · sncf-mcp: NextSummary indexes an empty departures slice
Suspect: a80001e8c303 "next_departures: lead with the next train" by Camille Martin, deployed 19:43 UTC
- 68 next_departures calls failed on a80001e8c303; 0 of 152 calls failed on previous 26d2a883a3c3.
🔐 INC-74 · sncf-mcp: approve merging PR #38?
transform: handle empty departures in NextSummary
Commit 30ec08cb64ec: the new test fails on the deployed a80001e8c303 and the full suite passes with the fix.
Approving merges it and deploys to production. Expires at 20:14 UTC.
🚀 INC-74 · sncf-mcp: PR #38 merged and deployed as bb629fdcee72
Approved by the on-call engineer; merge bb629fdcee72.
The 3 AM problem
Small teams rarely have a dedicated SRE, so a night page lands on whoever is on call. Veilleur doesn't just retell the logs. It takes the incident to a verified fix: it investigates, reproduces the failure with a failing test, brings a PR you approve with one tap, and learns from every incident.
“I've spent years as the engineering manager getting paged at 3 AM — the hard part is never the fix, it's the first hour of figuring out what broke.”
The host itself checks that the new test fails before the fix and the whole suite is green after it.
How it worksTool permissions are enforced in code, nothing reaches production without a human tap, and an upstream outage gets no fix and no rollback.
SafetyRedaction runs on the host, and the lab where it reproduces failures has no network.
PrivacyWhat it is not: not an observability platform, not an autonomous production deployer, not multi-cloud. One narrow loop: incident → verified fix → lesson.
How it works
01 / 04
Correlates the alert with deploys, diffs, logs and metrics, checks what it has seen before, and names a suspect commit with evidence.
Under the hood
Read-only tools: logs_signatures, logs_query, metrics_query, deploys_list, git_diff, memory_search.
🚨 Incident detected: INC-74 · sncf-mcp
- User impact: 56 of 604 next_departures calls failed (21.47%) in the last 5 min.
- find_station, plan_journey, and get_disruptions had 0 failures.
- No non-2xx Navitia responses reported.
- Last deploy a80001e8c303 was at 19:43:01 UTC, about 1 minute before the alert.
Investigating.
🔎 INC-74 · sncf-mcp: NextSummary indexes an empty departures slice
Suspect: a80001e8c303 "next_departures: lead with the next train" by Camille Martin, deployed 19:43 UTC
Evidence:
- 68 next_departures calls failed on a80001e8c303; 0 of 152 calls failed on previous 26d2a883a3c3.
- Diff adds NextSummary(deps); it indexes deps[0] at internal/transform/transform.go:168.
Failing call: next_departures {"station":"Culmont-Chalindrey"} (request lg-368862)
Next: reproduce this call against a80001e8c303 with a failing test before proposing a fix.
hypothesis recorded at 19 s
02 / 04
Replays the failing call from the logs against the deployed commit in a container with no network, then writes a Go test that fails the same way.
Under the hood
sandbox_replay (~1 s) and tests_run (go test, 0.5–1 s) in Docker with --network none.
package transform
import "testing"
func TestNextSummaryEmpty(t *testing.T) {
got := NextSummary(nil)
if got != "No upcoming departures" {
t.Fatalf("NextSummary(nil) = %q, want %q", got, "No upcoming departures")
}
}+ if len(deps) == 0 {+ return "No upcoming departures"+ } next := deps[0]03 / 04
Opens a PR only after the host itself checked it: the new test fails on the deployed commit and the whole suite passes with the fix. You approve with one tap; nothing ships without it.
Under the hood
fix_propose re-runs both checks on the host; github_merge_pr is tier T3 and needs a single-use approval.
🛠 INC-74 · sncf-mcp: fix proposed, PR #38
Reproduced: next_departures for Culmont-Chalindrey → panic: index out of range [0] with length 0 on a80001e8c303; TestNextSummaryEmpty fails there with the same panic.
Fix: NextSummary returns “No upcoming departures” for an empty slice.
Checks: TestNextSummaryEmpty passes and the full suite is green (52 tests) on 30ec08cb64ec.
PR: #38
Approval requested: merging deploys the fix; use the buttons in the approval message.
PR opened at 47 s
🔐 INC-74 · sncf-mcp: approve merging PR #38?
transform: handle empty departures in NextSummary
Commit 30ec08cb64ec: the new test fails on the deployed a80001e8c303 and the full suite passes with the fix.
Approving merges it and deploys to production. Expires at 20:14 UTC.
🚀 INC-74 · sncf-mcp: PR #38 merged and deployed as bb629fdcee72
Approved by the on-call engineer; merge bb629fdcee72.
Next: the alerts should resolve within a few minutes; then the postmortem.
04 / 04
Writes the postmortem and updates its runbook with a lesson for the whole class of failure. When the same failure comes back, it recognizes it and proposes the known fix in one turn.
Under the hood
postmortem_write; the runbook is Markdown in its own git repository; memory_search matches by failure signature.
📝 INC-74 · sncf-mcp: postmortem written, incident closed
Root cause: a80001e8c303 indexed the first departure from an empty upstream result; the fix was deployed as bb629fdcee72.
Impact: 19:43–19:46 UTC (2 min), 140 failed calls
Took: diagnosis 19 s, reproduction 31 s, PR 47 s from the page; 21 tool calls
Runbook: added R-1 "Assuming a collection returned by an upstream is non-empty" (03d6b0616f1b)
Next time: Check logs_signatures, compare failures across deploys, inspect the top frame, and confirm whether the upstream returned an empty collection.
Demo video
Proof
16 runs on the live stack: real alerts, real Telegram messages, real PRs. Each of 4 chaos scenarios ran twice with an empty memory (cold) and twice with what earlier incidents taught it (warm).
Times are seconds from the moment the incident opens (the Alertmanager webhook), medians of 2 runs. From the bad deploy to the alert takes another 60–86 s: that is the Prometheus rule window, not the agent.
Diagnosis / PR in seconds from the page, then tool calls. Medians of 2 runs.
| Scenario | What breaks | Cold memory | Warm memory | Memory found |
|---|---|---|---|---|
| 1 · Empty departures board | A teammate's commit reads the first train from a list that can be empty → panic | 18 s / 78 s / 21 | 21 s / 37 s / 7 | same failure ♻️ |
| 2 · Tighter upstream timeout | A config change cuts the Navitia timeout from 15 s to 1.5 s; long journeys fail. Red herring: it looks like the upstream | 21 s / 176 s / 26 | 24 s / 118 s / 22 | — |
| 4 · Upstream outage after an innocent deploy | README-only deploy, then Navitia answers 503 for 5 min | 20 s / no PR / 8 | 22 s / no PR / 9 | — |
| 5 · Same class, other place | Panic on a journey leg without stop times (same mistake as 1, different code) | 23 s / 88 s / 21 | 21 s / 80 s / 19 | same class (runbook R-1) |
Memory wiped, then three incidents, each approved in Telegram. Seconds from the alert.
| Incident | Memory | Page | Diagnosis | PR | Approve → deployed | Closed |
|---|---|---|---|---|---|---|
| INC-74 · scenario 1 from scratch | empty | 6 s | 19 s | 47 s | 54 → 81 s | 5 min 11 s |
| INC-75 · scenario 5, same class | R-1 | 6 s | 20 s | 101 s | 124 → 145 s | 5 min 10 s |
| INC-76 · scenario 1 again ♻️ | INC-74, R-1 | 6 s | 19 s | 28 s | 45 → 64 s | 4 min 10 s |
INC-77 (26 Sep, scenario 4 after an innocent deploy): EXTERNAL_CAUSE 22 s after the alert, no rollback; it closed by itself with a postmortem when the upstream recovered, 7.6 min after the alert.
Safety
| Tier | Meaning | Tools |
|---|---|---|
| T0 | Read, automatic |
|
| T1 | Isolated actions, automatic (containers without network) |
|
| T2 | Visible, reversible, automatic with notice |
|
| T3 | Production impact, only with a human approval |
|
The approval is a single-use row tied to the incident and the exact commit sha, with a 30-minute TTL, spent atomically on merge. A T3 tool cannot even be registered without the approval gate.
Approve and Reject come from a separate approval bot that only the host polls. The agent knows only a request id, and a request id alone grants nothing. Approve pressed after the incident was closed does nothing.
Before pushing, the host re-runs both checks itself: the new test fails on the deployed commit, and the full suite passes on the fix.
Hypotheses (3), replays, test runs, fix attempts and total incident time are budgeted in code. When a budget is spent, the incident goes to ESCALATED with the evidence for a human. Reject sends it back to hypothesis and costs one of the three.
An upstream outage is EXTERNAL_CAUSE: no fix, no rollback. The host refuses EXTERNAL_CAUSE when the logs show a Go change broke calls right after a deploy.
State lives in SQLite. Every step is idempotent, and a restart mid-incident resumes where it stopped.
INC-77: a teammate's deploy changes only the README, then the upstream starts answering 503. The deploy is the obvious suspect. Veilleur names the upstream, proposes no fix and does not roll back, then closes the incident with a postmortem when the upstream recovers.
Verdict 22 s after the alert; closed by itself 7.6 min after the alert.
🚨 Incident detected: INC-77 · sncf-mcp
47.26% of tool calls are failing; next_departures failed 128/651 calls in the last 5 minutes.
- Failures are isolated to next_departures; find_station, plan_journey, and get_disruptions had 0 failures.
- Navitia departures returned 540 HTTP 503 responses in the last 5 minutes.
- Deploy f5aeed296103 by Léa Dubois occurred at 07:15:12 UTC, 48 seconds before the alert.
Investigating.
🌐 INC-77 · sncf-mcp: Navitia departures is failing, not our code
Evidence:
- Navitia returned HTTP 503 for departures ~705 times in the last 5 minutes; it also returned HTTP 200 ~476 times.
- next_departures had 165 failures on f5aeed296103; the previous commit 4fa4d56d7a12 had 0 failures across 244 calls.
- Request lg-337 failed with Navitia’s explicit unexpected status 503; f5aeed296103 changed only README.md.
No rollback: the deploy changed only documentation, and the logs show the upstream returned HTTP 503.
Next: I confirm recovery and close the incident when its alerts resolve.
verdict recorded at 22 s
✅ INC-77 · sncf-mcp: Navitia departures recovered, incident closed
- Departures returned ~260 HTTP 200s and 0 HTTP 503s in the last 2 min.
- 0 failed tool calls (~479 successful) in the last 2 min.
Impact: 07:16–07:21 UTC (5 min). No change on our side.
Runbook: added R-2 “Intermittent upstream 5xx responses” (45ecd53a4e53).
Privacy
{
"time": "2026-09-26T12:35:20.559143894Z",
"level": "INFO",
"msg": "tool call",
"service": "sncf-mcp",
"commit": "fc4caadef538",
"request_id": "lg-74672",
"tool": "next_departures",
"args": { "station": "Marseille Saint-Charles" },
"client_ip": "203.0.113.24",
"user": "[email protected]",
"api_key": "sncf_live_d450f297b385e17061c23dca",
"outcome": "ok",
"duration_ms": 1
}{
"entries": [
{
"time": "2026-09-26T12:35:20.559143894Z",
"level": "INFO",
"msg": "tool call",
"service": "sncf-mcp",
"commit": "fc4caadef538",
"request_id": "lg-74672",
"tool": "next_departures",
"args": { "station": "Marseille Saint-Charles" },
"client_ip": "[ip]",
"user": "[email]",
"outcome": "ok",
"duration_ms": 1,
"dropped": ["api_key"]
}
],
"redactions": { "ipv4": 1, "email": 1 }
}Real output of make redaction, 26 Sep. The addresses are from documentation ranges, the domains are example ones, the key is made up.
A field the service starts logging tomorrow is dropped by name until someone adds it together with a redaction test.
Values under secret-looking keys are replaced wholesale. Go stack traces shrink to the service's own frames: build paths, goroutines and addresses stay home. Every rule and every allowed field has its own test.
Replays run on an internal Docker network; tests run with --network none.
Any OpenAI-compatible endpoint, including a local model, can be plugged in. The demo uses OpenAI gpt-6-luna. The API key stays on the host, in llm-proxy; the sandbox never sees it.
Architecture
Docker on one Mac, via OrbStack
Demo stack
host · TypeScript
ingress
node:sqlite
SQLite
NemoClaw / OpenShell sandbox
the harness
OpenClaw agent
the on-call engineer
Telegram
host
llm-proxy → OpenAI gpt-6-luna
host · MCP server, 16 tools
veilleur-mcp
Reads the stack's logs, metrics and deploys; the model sees them redacted.
the demo repository that mirrors what is deployed
GitHub
Markdown in its own git repository
Runbook
State changes go only through it, and every step is recorded in the timeline.
It learns
The runbook is Markdown in its own git repository: every lesson is a commit, and git log -p shows what each incident taught.
postmortem_write builds the facts on the host: times, impact, tool calls, the fix. The agent adds the judgement. An incident closes only when the service is healthy again.45ecd53 INC-77: Navitia departures returned intermittent HTTP 503s
e8eb681 INC-76: …
de12c2e INC-75: plan_journey panics on an empty stop date-time list
03d6b06 INC-74: Empty departures slice crashes next-departure summaries## R-1 · sncf-mcp: Assuming an upstream collection is non-empty
Matches: panic: runtime error: index out of range [N] with length N
- When: A tool panics with an index-out-of-range signature after reading the first item from an upstream collection; the upstream may return HTTP 200 with an empty list while other tools remain healthy.
- Check first: Use `logs_signatures` to identify the affected tool and top in-repo frame; compare failure counts across deploys, inspect the relevant code, then check the failing request for an empty upstream collection.
- Reproduce: Replay a failing request from `logs_signatures` against the deployed revision; add a focused Go test in the affected package with an empty upstream collection and confirm it fails on that revision.
- Fix: Handle an empty collection explicitly before indexing or deriving a summary; return a valid empty-result response, then run the focused regression test and the full Go suite.
- Avoid: Do not infer that a successful HTTP status means the response collection is non-empty; check empty-result cases before investigating unrelated tools.
- Seen: INC-74, INC-75, INC-76When the same failure comes back, Veilleur recognizes it and proposes the known fix in one turn. The host still re-verifies the test and the suite.
Eval medians, scenario 1. Memory speeds up the same failure only: for the same class elsewhere the runbook guides the investigation, but the fix is not faster (88 → 80 s).
♻️ INC-76 · sncf-mcp: known failure from INC-74 is back, fix proposed, PR #40
Suspect: 2f77c49a2121 "next_departures: lead with the next train" by Camille Martin, deployed 20:00 UTC
Evidence:
- 89 next_departures panics on 2f77c49a2121; 0 failures in 594 calls on 26d2a883a3c3.
- Same failure as INC-74: empty departures slice causes NextSummary to index deps[0].
Checks: TestNextSummaryEmpty fails on 2f77c49a2121; the full suite passes with the fix (52 tests), verified by the host.
PR opened at 28 s
📝 INC-76 · sncf-mcp: postmortem written, incident closed
Root cause: Commit 2f77c49a2121 reintroduced an unchecked first-departure index; a valid empty Navitia response triggered next_departures panics.
Impact: 20:01–20:02 UTC (2 min), 133 failed calls
Took: diagnosis 19 s, reproduction 23 s, PR 28 s from the page; 12 tool calls
Runbook: updated R-1 "Assuming an upstream collection is non-empty" (e8eb681a1949)
Next time: Check logs_signatures, compare failures across deploys, then confirm whether the failing upstream collection is empty.
Built with
Ilya Seliverstov built Veilleur alone for the NVIDIA Paris Claw Agent Challenge, in the OpenClaw harness category: OpenClaw inside NemoClaw / OpenShell.