Condor Session Lifecycle, Run Spine and Telemetry
Audiences: developer, internal
Condor separates what a conversation is (the session — durable identity, rolling memory, resolved entities, pinned cards) from what one turn does (the run — a persisted FSM opened, transitioned and closed by the pump). Two stores, two lifetimes, one telemetry surface over both. Session state survives the run; run state is the thing HITL suspends into and reconnect replays from.
Purpose
Three co-located subsystems under ddx-api/src/ai/condor/:
| Dir | Owns | Lifetime |
|---|---|---|
session/ | AI-session CRUD, history, rolling memory, resolved-entity linkage, pinned + same-run cards | conversation (many turns) |
run-spine/ | ONE durable RunRecord FSM per turn, its legal transitions, and the idle/TTL sweep | one turn |
telemetry/ | per-dispatch rows (engine split) + per-leaf OTel spans | observation of both |
The split matters because Condor's HITL gate suspends a run, not a session (condor-hitl-approval) — while "the current patient" must outlive every run in the thread. Session-level conversation concepts that Condor inherits from the TUCAN estate are in AI Chat Sessions; this topic covers only the Condor-specific surface.
Session lifecycle
session/ai-sessions-lifecycle.controller.ts:109 is @Controller('ai/condor/sessions')
— the non-streaming surface. Dispatch/streaming stays on CondorController
(POST :id/messages), deliberately not re-exposed here.
| Route | Handler | Purpose |
|---|---|---|
POST / | :125 | create session |
GET / · GET :id | :155 · :235 | list (filtered/paged) · fetch one |
PATCH :id · DELETE :id | :265 · :250 | rename/re-link · delete |
DELETE :id/messages/:messageId | :305 | drop one message |
GET :id/history | :377 | replayable message view |
GET :id/usage-rollup · GET :id/runs-summary | :387 · :408 | per-session cost · run outcomes |
GET :id/memory | :420 | the rolling working-memory projection |
GET :id/events | :447 | persisted run-event log |
GET/POST/PATCH/DELETE :id/pinned-cards… | :470–:508 | pinned-card CRUD + reorder |
Store discipline — ai-sessions-lifecycle.service.ts:115 is pure
prisma-ai (AiSession/AiMessage). Zero FHIR: the port invariant
(plans/tucan-condor-port/_invariants.md NEVER #1 / AC-6, cited in the file
header) forbids FHIR_CLIENT/ResourceFacade anywhere in this tree.
The four session-state services
| Service | Contract |
|---|---|
session-memory.service.ts:50 | read-only projection of the thread-scoped working-memory doc (getSessionMemory, :62). Consumer of the run-memory-writeback round-trip; parses run-summary blocks via the SHARED parseSessionMemoryBlocks (never a second parser). Backs GET :id/memory → the Context panel. |
session-entity-state.service.ts:78 | promoteLatest (:94) writes the latest entity a run actually resolved (found patient, opened document, booked appointment) into the session linkage columns, so the next turn's planner sees "the current patient" without restatement. Generalises the PATIENT-only _session-linkage writer to all 5 link types; routes every write through it rather than reimplementing persistence. |
pinned-cards.service.ts:46 | the SINGLE write path for AiSession.pinnedCards. Both lifecycle endpoints and the card.pin/card.unpin tools funnel here, so dense-rank + spec validation can never diverge. Null column reads as []; order is re-densified to 0..n-1 on every mutating write. |
same-run-rendered-cards.service.ts:81 | in-memory buffer of cards rendered earlier in the same run. Exists because card persistence is fire-and-forget (persistDataChunk is not awaited), so a Prisma read from a later step in the same turn returns empty (DEF-189 cause #3) and card.pin would answer "no matching card". cardIdentityKey (:54) is the dedup key. |
Trap — the same-run buffer and the Prisma card reader are both required. Reading only Prisma is a same-turn false-empty; reading only the buffer misses prior turns.
The run spine
run-spine/run-record.service.ts:134 (RunSpineService) is the ONE run store:
a durable RunRecord in prisma-ai (active_runs), opened, transitioned and
closed by the pump — lifecycle is a mandatory side effect of running, not an
optional service a caller may forget.
Every mutating method is persist-then-publish: the FSM state write and the
EventSpine outbox append commit in the SAME prisma-ai transaction (via
EventStoreService.append). A crash between them loses only the publish (healed
by the outbox drainer), never the state fact.
| Method | Line | Effect |
|---|---|---|
open | :146 | create the record in pending |
transition | :179 | FSM-checked state move |
suspend · resume · approve | :220 · :250 · :264 | HITL gate edges |
close | :278 | terminal write with a RunTerminalReason |
findActive · get | :318 · :330 | thread-keyed lookup |
sweep | :339 | close idle + TTL-expired suspends |
The FSM
run-spine/run-spine.fsm.ts is a static edge table, transcribed from the
blueprint state diagram — it does not re-invent the machine. transition() looks
up (from → to) there; an unlisted edge throws RunStateTransitionError
(:156) rather than silently overwriting state. A second terminal write is an
idempotent no-op (isTerminal, :57).
Three vocabulary facts that bite:
| Fact | Detail |
|---|---|
executing ⇄ prisma running | the spine's canonical label is executing; the persisted ActiveRunState enum kept the pre-spine running value (additive migration, no rename). toPrismaState/fromPrismaState (:70/:75) are the ONLY mapping seam — this is the single axis of divergence. |
suspended → executing may cycle | multiple approval gates in one run are legal; the FSM does not cap the loop. |
| terminal reason ≠ terminal state | 13 RunTerminalReason values collapse onto 3 terminal states via TERMINAL_STATE_FOR_REASON (:91) — e.g. approval_denied → aborted, degraded_partial → failed, direct_answer → completed. Read the reason for why, the state for whether. |
Active states are pending/planning/executing/suspended
(ACTIVE_STATES, :43); terminal + immutable are completed/failed/aborted
(TERMINAL_STATES, :51).
The sweep (:339) is the liveness guarantee: runs left in
pending|planning|running past the idle timeout close with sweep_idle; runs
suspended past the suspend TTL close with sweep_suspend_ttl. Each close is
individually .catch-guarded so one bad row cannot abort the sweep pass.
Errors the spine raises: RunRecordNotFoundError (:168) and
RunNotSuspendedError (:175) — a resume/approve against a non-suspended run
is rejected, never coerced. run-record.idor.spec.ts guards owner scoping on
every lookup.
Telemetry
Two independent surfaces, both non-blocking by design:
| Surface | Sink | Contract |
|---|---|---|
dispatch-telemetry.service.ts:50 | prisma-logging | ONE row per dispatched run, written at the chat funnel where AgenticFlowRouter resolved the engine. record() (:61) is fire-and-forget-safe — errors are caught and logged, never rethrown, so a telemetry write can't surface a 500 on the hot path. splitByFlowKey (:92) + zeroTrafficCount (:109) are the evidence base for the per-flow Tucan→Condor cutover and its zero-traffic decommission gate. |
agentic-loop-spans.ts | OpenTelemetry (or fallback) | one span per executed leaf, with the BFF-propagated trace-id threaded on so a Condor run stitches into the caller's distributed trace. |
Why the OTel import is dynamic: @opentelemetry/api is NOT a direct
dependency of ddx-api — the only copy on disk is an unlinked transitive
.pnpm/@opentelemetry+api@1.9.0 pulled by Mastra. A static import fails tsc
with TS2307 and would break the build gate, so loadOtelApi()
(agentic-loop-spans.ts:99) resolves it at runtime behind a structural
OtelApiLike/OtelSpanLike interface pair (:82/:86). LeafSpanResult.sink
reports which path actually served: 'otel' | 'log-fallback' | 'error'
(:67) — check sink before concluding spans are missing; a log-fallback
run is instrumented, just not in the collector.
How a turn flows
Reading it as three ownership bands:
- Open + observe — the pump opens the run and the funnel records one telemetry row; neither is optional and neither may fail the turn.
- Execute — every state move goes through the FSM edge table; leaf spans and AG-UI frames are emitted as the run advances. The pump is the only writer of run state (condor-sse-pump-agui covers the frame side; reconnect replay is served from the persisted run-event log, which is why the run record must be durable rather than in-process).
- Close + promote — a terminal write carries a reason, then the writeback promotes the run's resolved entity and memory blocks into the session, so the NEXT turn starts with context it never had to ask for.
Related Topics
- Condor Engine — the runtime this spine serves: flow router, transport, Mastra plan compiler.
- AI Chat Sessions — the sibling TUCAN session topic
(
ChatSession/ChatMessage, cost + billing shape). Condor'sAiSessionis a distinct table with the same conversation role. - Condor HITL Approval — what
executing → suspended → executingmeans at the policy layer. - Condor SSE Pump & AG-UI — the frame transport that owns run-state writes and replays from the persisted event log.