0022. Asynchronous plan generation: a persisted generating version settled by a background job
Date: 2026-07-16 Status: superseded by ADR-0033 (batch execution path retired in full)
Context
FR-04 plan generation calls the LLM, which can take seconds to tens of seconds
(especially against a local Ollama model — the default backend since ADR-0021).
Until now POST /api/findings/{id}/plan was synchronous: it ran
generate_plan inline, persisted the proposed version, and returned it. Two
problems surfaced during live testing:
- The UI state was not durable. The "Generate plan" button drove a mutation
whose only in-progress signal was React-local (
generate.isPending). Reloading the page mid-generation threw that state away: the finding showed no plan and the button was clickable again, so the same finding could be generated twice and the operator could not tell whether a generation was already running. The state did not survive a reload because nothing was persisted until the call returned. - It is inconsistent with report ingestion. Report upload (FR-01/ADR-0013)
already solved exactly this:
POST /api/reportsreturns202with a persistedextractingrow, a background task runs the LLM, and the SPA polls the report until it settles onready/failed. Plan generation — the other long LLM call in the pipeline — did it differently, so a reload behaved differently depending on which step you were on.
Forces:
- The in-progress state must survive a reload and be single-authoritative (one source of truth the server owns, not React-local).
- A finding must not have two concurrent live generations from a double-click.
- The plan lifecycle (FR-05/ADR-0012) is versioned and append-mostly: a version is immutable except to record its own decision or be superseded. Any new state must fit that model and keep the single execution chokepoint intact.
- The change should reuse the proven report-ingest pattern, not invent a second async mechanism.
Decision
Make plan generation asynchronous, mirroring report extraction: the POST
reserves a persisted generating plan version and returns 202 immediately; a
FastAPI background task runs the LLM and settles that same row in place to
proposed (with its gated actions) or failed (with the reason). The SPA polls
/plans until it settles, exactly as it polls a report.
- Two new
PlanStatusstates —generatingandfailed. A version is borngeneratingand always settles toproposedorfailed, so the plan poll is guaranteed to terminate — the same lifecycle shape asReportStatus(extracting → ready/failed).PlanRecordgains a nullableerrorcolumn (set only on afailedversion), mirroringReportRecord.error. approval.pyowns the transition, in place.start_plan_generationopens the version: it supersedes any live proposal (proposedorgenerating, so a re-generate cancels an in-flight one) and inserts ageneratingrow with empty actions, returning it for the202.finish_plan_generation(plan_id, result)settles it: toproposedwith the gated actions, orfailedwith the reason when the model produced nothing runnable. It is a no-op if the row is no longergenerating(a newer generation superseded it), so a slow stale result can never resurrect a superseded version. Settling in place (sameid, sameversion) means the poll transitions cleanly and history stays honest.app.py: arun_plan_generationbackground task, the structural twin ofrun_extraction— a sync function on Starlette's threadpool that opens its own session, callsgenerate_plan, and callsfinish_plan_generation; any unexpected exception is converted to afailedsettle so the row never gets stuckgenerating. Thecreate_planroute becomesstatus_code=202, takesBackgroundTasks, and returns the reserved version. The former422for an empty/failed generation is replaced by a persistedfailedversion the UI renders (with a retry), consistent with how a failed extraction persists afailedreport rather than erroring the upload.- Frontend: poll + render the two new states.
usePlansgains arefetchIntervalthat polls every 2 s while any version isgeneratingand stops once it settles — theuseReportpattern.FindingDetailbranches on a newcurrentPlanselector (the newest live version —generating/proposed/approved/failed; superseded and rejected are history): a spinner panel whilegenerating, the plan editor whenproposed/approved, and a failure panel with the recorded error + "Try again" whenfailed. A reload therefore always shows the real server state.
This is scoped as an implementation change to FR-04/FR-05 (generation becomes
async and gains the generating/failed states), not a new SRS requirement; this
ADR is the design of record.
Alternatives considered
- Keep it synchronous, add a client-side "generating" flag. Minimal backend change. Rejected: the flag is React-local and dies on reload — precisely the bug. Only server-persisted state survives a refresh.
- Insert a new proposed version on finish (don't settle in place). The
generatingrow would be superseded and a freshproposedrow inserted. Rejected: it burns a version number per generation and makes the poll watch one row appear while another is superseded, for no benefit over an in-place settle. - Don't persist failed generations (keep the
422). Rejected: it reintroduces the "nothing persisted, reload shows no plan" inconsistency for the failure path, and diverges from report ingestion, where a failed job persists afailedrow the UI explains. A recordedfailedversion is also better lineage. - A generic background-job table / task queue (Celery, RQ, etc.). Rejected:
over-engineered for a single-user local tool (ADR-0008). FastAPI
BackgroundTasksalready runs report ingestion; reusing it keeps one mechanism. - WebSocket/SSE push instead of polling. Rejected: polling already works for reports and is trivially correct across reloads; a push channel is complexity the tool does not need at this scale.
Consequences
- Easier: the generate button's in-progress state is durable — a reload shows the finding still generating (or the settled plan/failure), and the same finding can't be double-generated because a live proposal/generation is superseded on re-generate. Generation and ingestion now behave identically, so the SPA's poll logic and mental model are one thing.
- Harder / accepted:
POST /planno longer returns the finished plan — callers (and tests) must poll/plansfor the settled version, as they already do for reports. Two new lifecycle states widen thePlanStatussurface;failedandgeneratingversions now appear in plan history (honest audit, slightly noisier). - Concurrency: the in-place settle is guarded —
finish_plan_generationonly writes a row stillgenerating, so a re-generate that supersedes an in-flight one cannot be clobbered by the older task's late result. - Runtime semantics: an in-flight generation keeps the model it started with
(the background task captured the agent at
202time); a mid-generation settings change (ADR-0021) applies to the next generation. Consistent with report ingest. - Schema:
PlanRecordgains anerrorcolumn in the existingcreate_allmetadata — consistent with the project's "rm revalid.dbon schema change" development practice (no formal migrations yet). - Status
proposed: the new lifecycle states and the "failed generation is a persisted version, not a422" behaviour change are Álvaro's to ratify.