Repository navigation
Phase 5: Add dashboard, resume path, and v0.1.0 release - #9
Merged
Merged
Conversation
Both claim paths hardcoded `WHERE status = 'detected'`, which left two
rows unreachable.
An incident parked at `fix_proposed` by --no-validate has a stored diff
that nothing will ever validate: the status was terminal in Phase 3, and
Phase 4 put a gate behind it without widening the claim. And a process
SIGKILLed between claim() and any writer strands its row in `diagnosing`
forever — the CLI's broad excepts guarantee a terminal status on every
path it survives, but SIGKILL is not one of them.
Both claim methods now take an accepted-status set, defaulting to the
old behaviour so no existing caller changes meaning. The exclusion
guarantee is unchanged: the UPDATE still names the exact set it selected
against, so two racing agents still produce exactly one winner.
reclaim_stale_diagnosing() is the reaper for the crash case, and the
first code to read `updated_at` back. That column has mixed semantics —
Go writes event time, Python writes wall clock — and reading it as wall
clock is only sound because Go never writes `diagnosing`. Recorded in
the docstring, because the next reader of that column will not be so
lucky.
read_fix_record() is a narrow reader rather than more fields on
Incident: only the resume path needs the stored diff back, and every
other call site would otherwise carry it. It decodes '' to {}, since
every post-v1 column is NOT NULL DEFAULT '' and json.loads('') raises.
Terminal statuses stay terminal. A diff the gate rejected does not
become valid on a second reading.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Wires the widened claim set into the CLI. --resume adds `fix_proposed` to what a run will pick up, and sweeps for claims held past --lease-age by a process that died. The important half is what happens after the claim. A resumed incident that already carries a diff does not re-run the diagnosis loop: it goes straight to _ship with the stored diagnosis and fix. Re-deriving them would spend a real API call reproducing work sitting in the row, and would overwrite the diagnosis that produced the diff with a second, possibly different one. The test asserts this by making any model or replay turn raise. --resume --no-validate puts the status back to `fix_proposed` before returning. The claim already moved the row to `diagnosing`, so without that the flag which created this resting state would, on a rerun, strand the incident one status further along — a worse version of the bug being fixed here. Ordering in _claim is deliberate: uncontested work first, stale sweep last, so a reaper never races a live run for an incident when there was something else to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Scaffolds the Next.js app and the layer that reads `incidents.db`, with no UI yet. Three constraints shape lib/db.ts, each learned elsewhere in this repo. The handle is opened `readOnly: true`, so "the dashboard never advances the pipeline" is enforced by SQLite rather than by reviewer attention; reruns stay a CLI flag. Every read goes through withStore(), which opens and closes around one query — the store is rollback-journal, not WAL, and a long-lived reader is what killed the monitor's startup migration once and wedged the agent's test suite once. And the schema version is asserted with the same message the Python agent uses, because Go owns migrations and a v3 database should say so rather than render blanks. lib/incidents.ts is where this schema's sharp edges get paid for once. Nothing is SQL NULL — every post-v1 column is NOT NULL DEFAULT '' — so "not reached this stage" arrives as "" and JSON.parse would throw on it. `diagnosis` has four shapes (model result, error, refusal, heuristic) and `validation` has three, so both are discriminated unions a component cannot destructure wrongly. Malformed JSON degrades to a `malformed` arm carrying the raw text: three languages write this file, and one bad blob must not 500 a page that would otherwise show six good incidents. isModelOutput() exists because `replay-scripted` and `heuristic` are not model output and the CLI already shouts about it. It fails closed on an unrecognised source. statusDetail() keeps `fix_validated` split in two: with `pr_error` the fix is verified and the PR failed, without it no PR was attempted. Flattening those would undo the Phase 4 decision that a network error is not a failed fix. node:sqlite over better-sqlite3: it is the standard library, so there is no native module to build in CI, and readOnly is a real SQLite mode. The cost is an experimental API and a Node >=22.6 floor, both contained by keeping every reference in one file and pinning Node in .nvmrc. Tests run under `node --test` with type stripping — no test framework at all — against fixtures copied from what the Go and Python writers actually produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
The list shows every incident with its pipeline position; the detail view shows the evidence, the tool trace, the diff, and the gate record. The pipeline widget draws five stages, not nine. The other four statuses are ways of stopping, so a terminal one is drawn *at the stage it stopped at* — `fix_failed` marks the validation step in red rather than appearing as a sixth box, because that is what a rejected gate is. Provenance is a banner above the diagnosis, not a badge beside it. `replay-scripted` and `heuristic` drove the real tool loop against the real repository, but no model produced those turns, and the CLI already shouts about it. Anything quieter here would let a scripted transcript read as a model diagnosis on the way past. The evidence panel is shaped per detector, because a generic key/value dump would put `p95_ms: 243` next to `slope_per_sec: 0.67` with no hint that one breached a threshold and the other is a rate. The gate gets its own panel, deliberately separated from the diagnosis: one is what the model claimed, the other is what this machine checked. `proves_a_fix` is the headline because red-before-green-after is the only assertion in the record that means the fix works. A validated fix whose PR failed says so in its own line rather than being coloured like a failure. The diff renderer is ~15 lines and hand-written. The format is line-oriented, so classifying a line is a switch on its first character — with `---`/`+++` checked before `-`/`+`, or every diff opens with a phantom deleted line. A dependency would be bigger than the parser. Verified against a real Go-created v4 database seeded with all seven outcomes, screenshotted through a real browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Fills in the placeholder Phase 0 left here, now that there is a Next.js app behind it. Node's version comes from dashboard/.nvmrc for the same reason Go's comes from go.mod: one source of truth, living in the project rather than copied into this file where it would silently drift. It is a real constraint here — the dashboard reads SQLite through node:sqlite, so an older Node does not merely warn, it fails to import. `npm ci` rather than `npm install`, so the lockfile decides. That is the ruff lesson from Phase 4 applied to a second ecosystem: a build that resolves its own dependency versions goes red on someone else's schedule. The build step runs with no incidents.db present, which is what CI actually has. Both routes are force-dynamic, so nothing reads the store at build time — verified rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
The dedup query matched only rows in `detected`, so an incident the agent had claimed became invisible to it. Nothing noticed while nothing ran the agent automatically: the row stayed `detected` forever and every re-firing merged into it, exactly as Phase 2 intended. Running the agent in the demo loop broke that. The agent moves an incident to `diagnosing` within seconds, the condition is still firing, and the monitor — finding no `detected` row for that key — opens a new incident every detector cycle. A single injected bug produced 28 incidents in two minutes, which is precisely the "one incident, not fifty" property Phase 2 exists to provide, failing in a way its own tests could not see. The lookup no longer filters by status, and a firing whose incident is already claimed is now Suppressed: not merged, because rewriting evidence under an agent mid-diagnosis is the reason the status filter was there in the first place; not inserted, because the condition is already on record and being worked. Suppression is bounded by the same dedup window as merging, so a failure that recurs long after the old incident closed still opens a new one. Staleness is now measured on window_end rather than updated_at. window_end is written only by this package and is always event time; updated_at is shared with the Python agent, which writes wall clock into it on every state change. Comparing a detector's event clock against that mixes two timebases — a latent trap the old code avoided only by never reading a row the agent had touched. The bool return became a three-valued UpsertOutcome, since the daemon's log should distinguish a new problem from an ongoing one from one already in hand. Verified live: same injected bug, same sustained failure, same auto- diagnosis — one incident row, 41 suppressed firings logged quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
…with DEFAULT_TEST_COMMAND began with a bare "python", resolved from PATH. Whoever launches the agent has chosen an environment where the demo app is importable; PATH resolves to whatever comes first, which need not be that environment at all. Surfaced by the one-command demo, where the app and the agent live in demo-app/.venv while PATH still points at a system interpreter with no fastapi. The gate applied the diff correctly, ran pytest, and reported "tests failed after applying: ModuleNotFoundError: No module named 'fastapi'" — a verdict about the environment wearing the costume of a verdict about the diff. It would have marked a working fix fix_failed. sys.executable removes the ambiguity: the tests run under the same interpreter as the agent. --test-command still overrides it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Phase 5's gate is that a stranger clones the repo, runs one command, and watches the loop happen. The script stopped at detection: diagnosis needed a separate pip install and a second command the script never even mentioned. It now installs the agent into the same virtualenv as the demo app and polls for detected incidents, diagnosing each one offline as it appears. --no-agent keeps the detection-only demo from Phases 1 and 2; --live swaps the replay for a real model call. The poller passes --no-validate deliberately. The gate cuts a git worktree and runs pytest in it, which is right for a real run and wrong to do unattended every few seconds. Incidents therefore rest at fix_proposed — which is exactly what --resume was built to pick up, so the banner prints the command that drives one through the gate for real. Cleanup kills the poller's children before the poller. Killing the subshell alone orphans the python it spawned, which then keeps a handle on incidents.db after Ctrl-C — observed, not theorised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Nothing here is drawn, mocked, or staged. demo.cast is a real recording of a real session: inject_bug.sh commits the bug, the monitor's detector fires, the agent diagnoses it offline through its real tool chain, and --resume drives the stored diff through the validation gate to red-before-green-after. The pauses in it are waits for work to actually finish. record_cast.py is the ~60 lines of pty that asciicast v2 needs. Adding an asciinema dependency to produce one README image was the worse trade; the output is the standard format and renders with svg-term-cli. The SVG is animated, stays diffable text, and commits no binary blob. The screenshots come from Chromium driven against a real Next server reading the SQLite file that session produced — the log samples, timestamps, tool-call sizes and gate record in them are that run's, not fixtures. capture_dashboard.mjs discovers which incident to open from the list rather than hardcoding an id, so it cannot silently screenshot a 404 against a differently-populated database. Playwright is resolved from wherever it happens to be installed rather than added to any package.json: it is a screenshot tool, and `npm ci` in CI should not pull a browser driver to build a dashboard. seed_dashboard_db.py fills a store with one incident per pipeline outcome. A demo run produces the happy path; it does not produce a refusal, a rejected gate, or a validated fix whose pull request failed — and those are the arms a dashboard is most likely to render wrongly. Every row goes through the agent's own Store, so a schema change breaks the script instead of quietly producing rows the pipeline could never create. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
design-decisions.md gets its Phase 5 section: why a resumed fix must not re-run the model, why node:sqlite over a native module, why the dashboard stayed read-only after Phase 4 predicted it wouldn't, why the row mapper carries four diagnosis shapes and three validation shapes, why provenance is a banner rather than a badge, and why there is still no index. It also records the two defects the one-command demo surfaced, and restates what this phase did not verify. Both credential gaps were re-checked rather than assumed carried forward: the model keys are unset, and api.github.com/repos returns 403 through the proxy while /user returns 200 — scoping, not a dead token. v0.1.0 ships saying so. README gains the recorded demo at the top, the dashboard section with both screenshots, and a one-paragraph-each summary of the design decisions for readers who won't open the long document. The quickstart now reflects that diagnosis happens on its own, and documents --resume. The two caveats stay exactly where they were; they are still true. PLAN.md ticks F5 and records the estimate miss — planned at 4-6 commits, landed at ten, because the deferred resume path and the run_demo.sh gate were folded in and two defects had to be fixed before the demo was honest. The working agreements ask for what actually happened versus what was planned, which includes the phase that overran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
What ships per layer, the MVP gate with PR #6 as its evidence, what Phase 5 added, and the limitations — led by the one that has been open since Phase 3: neither outward-facing API call has ever been made for real. The tag itself is not cut here. A release tag on an unmerged feature branch would point at history main has never had, so `chore(release): tag v0.1.0` belongs on the merge commit. PLAN.md's F5 checklist is corrected to say so rather than claiming a tag that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
1816x
marked this pull request as ready for review
August 2, 2026 02:01
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.
Closes the incident-response loop with a web dashboard and adds the ability to resume incidents left in intermediate states.
Summary
This PR completes Phase 5 of the project, delivering:
fix_proposedstatusKey Changes
Dashboard (Next.js)
dashboard/directory with a full Next.js applicationlib/db.ts,lib/queries.ts) that reads the SQLite store in read-only modelib/incidents.ts) that handles malformed JSON gracefully and models diagnosis/validation as discriminated unionsResume Path
fix_proposedstatus via--resumeflagreclaim_stale_diagnosingmethod reclaims incidents whose owner process died (15-minute lease default)claim_next_detected()toclaim_next(claimable_statuses)for flexibilityMonitor & Store
UpsertIncidentnow returnsUpsertOutcomeenum (Inserted/Merged/Suppressed) instead of boolean, providing clearer semantics in logsDocumentation & Tooling
docs/release-notes-v0.1.0.mddocumenting all five phasesseed_dashboard_db.py(fills store with one incident per pipeline outcome),record_cast.py(records terminal sessions as asciicast v2),capture_dashboard.mjs(screenshots dashboard),demo_script.sh(automated demo sequence)docs/assets/Testing
node --testwith no frameworkNotable Implementation Details
readOnly: trueenforced by SQLite; reruns are a CLI flag, not a buttonNOT NULL DEFAULT '') are treated as "absent" rather than parsed as JSON, preventing throws on unset columnsupdated_atcolumn has dual meaning (Go writes event time, Python writes wall clock); documented in code since Go never writesdiagnosing