symfonic.services.conversation.recovery¶
recovery ¶
Restart recovery and state overrides.
Two things the legacy engine did inline, extracted because both are decisions rather than plumbing:
Restart recovery answers "what should happen to this thread after a restart?" — and answers it against the registry, not against the backend. A checkpoint the registry cannot account for is quarantined rather than resumed: unaccounted-for durable state is exactly what an authoritative registry exists to notice.
"Cannot account for" is not the same as "has not yet heard of", and the
difference is :meth:RestartRecoveryService.adopt. The registry is
process-local and starts empty, and the legacy engine never knew it existed —
so without a rehydrate step every restart would quarantine both the legacy
path's checkpoints and this package's own. Adoption enumerates the thread's
durable rows and derives each ref's provenance from its envelope (absent
envelope → legacy, format version 0), which is the one place where the backend
informs the registry. Rows it cannot read, and rows the backend reports as
latest but does not list, stay unaccounted for and still quarantine.
Finalization is adopted the same way — read off the envelope, never assumed — so a write that was registered unfinalized and interrupted by a crash that ended the process comes back unfinalized and is visible to crash-expiry.
State overrides keep the run-config keys (_thread_id,
_checkpoint_id) out of graph state. They look like state, they are passed
alongside state, and putting them in state silently changes what the graph
sees on every resume.
RecoveryDecision
dataclass
¶
RecoveryDecision(action: RecoveryAction, thread_id: str, reason: str, checkpoint_id: str | None = None, safe_boundary: SafeBoundaryMarker | None = None)
What to do with a thread's durable state after a restart.
RestartRecoveryService ¶
RestartRecoveryService(*, adapter: CheckpointAdapterPort | None, registry: CheckpointRegistry, clock: object | None = None)
Decides resume / migrate / quarantine / fresh for a restarted thread.
Source code in src/symfonic/services/conversation/recovery.py
adopt
async
¶
Rehydrate the registry from a thread's durable state.
Provenance is read, never assumed: each row's envelope says which
line wrote it, and a row with no envelope is legacy at format version
0 — the positive statement :func:decode_envelope makes, not a guess.
A row whose envelope is corrupt or from a newer format is skipped, so
it stays unaccounted for and quarantines rather than resuming under an
invented provenance. Already-registered rows are left exactly as they
are: adoption never overwrites the registry's own record.
Safe boundaries are adopted only under the id the writer recorded in the envelope. A row that claims to be a boundary but names none leaves nothing to replay from, and inventing an id from its listing position would be worse than having none — the migrator would hand a replay port a boundary that never existed.
Returns the refs this call adopted, so a caller can log what a restart took ownership of.
Source code in src/symfonic/services/conversation/recovery.py
StateOverrides ¶
Splits run-config keys out of graph-state overrides.
split
classmethod
¶
Return (configurable, graph_state) without mutating the input.
A None for a configurable key is refused rather than dropped: the
caller meant to pass a thread id and computed nothing, and silently
continuing starts a brand-new thread under the same session.