13-ai-condorwave: W6filled9 citations

Condor Session Lifecycle, Run Spine and Telemetry

Audiences: developer, internal

Condor Session Lifecycle, Run Spine and Telemetry

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/:

DirOwnsLifetime
session/AI-session CRUD, history, rolling memory, resolved-entity linkage, pinned + same-run cardsconversation (many turns)
run-spine/ONE durable RunRecord FSM per turn, its legal transitions, and the idle/TTL sweepone turn
telemetry/per-dispatch rows (engine split) + per-leaf OTel spansobservation 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.

RouteHandlerPurpose
POST /:125create session
GET / · GET :id:155 · :235list (filtered/paged) · fetch one
PATCH :id · DELETE :id:265 · :250rename/re-link · delete
DELETE :id/messages/:messageId:305drop one message
GET :id/history:377replayable message view
GET :id/usage-rollup · GET :id/runs-summary:387 · :408per-session cost · run outcomes
GET :id/memory:420the rolling working-memory projection
GET :id/events:447persisted run-event log
GET/POST/PATCH/DELETE :id/pinned-cards…:470:508pinned-card CRUD + reorder

Store disciplineai-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

ServiceContract
session-memory.service.ts:50read-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:78promoteLatest (: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:46the 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:81in-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.

MethodLineEffect
open:146create the record in pending
transition:179FSM-checked state move
suspend · resume · approve:220 · :250 · :264HITL gate edges
close:278terminal write with a RunTerminalReason
findActive · get:318 · :330thread-keyed lookup
sweep:339close 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).

Rendering diagram…

Three vocabulary facts that bite:

FactDetail
executing ⇄ prisma runningthe 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 cyclemultiple approval gates in one run are legal; the FSM does not cap the loop.
terminal reason ≠ terminal state13 RunTerminalReason values collapse onto 3 terminal states via TERMINAL_STATE_FOR_REASON (:91) — e.g. approval_deniedaborted, degraded_partialfailed, direct_answercompleted. 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:

SurfaceSinkContract
dispatch-telemetry.service.ts:50prisma-loggingONE 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.tsOpenTelemetry (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

Rendering diagram…

Reading it as three ownership bands:

  1. Open + observe — the pump opens the run and the funnel records one telemetry row; neither is optional and neither may fail the turn.
  2. 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).
  3. 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.
  • 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's AiSession is a distinct table with the same conversation role.
  • Condor HITL Approval — what executing → suspended → executing means at the policy layer.
  • Condor SSE Pump & AG-UI — the frame transport that owns run-state writes and replays from the persisted event log.