You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[FEATURE]: A session's event envelope carries run lifecycle alongside agent activity #778
AgentEvent says what an agent did. Nothing says what happened to the run it belongs to: that it started, who attached or detached, that its last claim went away, that it finished, was cancelled and why, or parked. Observers infer those from the transport — an SSE stream closing, an A2A status update, a [DONE] — so each seam reconstructs the run's story its own way and no two agree.
Give the session an envelope that carries both. A session is an agent's identity over time; a run is one unit of work within it — a prompt or headless start, through every inference and tool turn, to [DONE] or another terminal state. The runtime emits run lifecycle into the same ordered stream the agent's events travel on, one stream per session, so a projection reads one sequence and never correlates two, and an agent's whole history loads as one stream rather than as a composition of runs.
Goals
A SessionEvent envelope: session id, the run id when the event belongs to a run, a sequence number, a timestamp, and either an AgentEvent or a lifecycle event. The correlation [FEATURE]: Agent event schema and broker adapter #618 kept off AgentEvent — session, run — lives here, applied once by the runtime rather than by every observer.
A lifecycle vocabulary: started (agent, prompt, timeout, liveness), observer attached and detached (kind, presence, and why it left), claims exhausted, liveness decided, parked (checkpoint reference), finished (usage), cancelled (RunCancelReason plus the caller's message), failed.
Sequence numbers dense per session, across its runs, so a consumer detects a gap rather than silently missing an event and a run boundary is a position in the stream, not a second stream. a2a::bus_bridge already numbers its fan-out frames for this reason; this becomes the one place the number is minted.
Serializable, internally tagged, #[non_exhaustive], living in aura-events beside agent.rs, so a seam with no agent dependency parses it.
The run id and session id branded string newtypes in aura-events.
As implemented in crates/aura-events/src/run.rs on PR #794, abbreviated:
// aura_events (lib.rs)pubstructRunId(uuid::Uuid);// one per run; v7 when minted; refuses the nil UUIDstring_newtype!{SessionId}// the stream key; a client-supplied string// aura_events::runpubstructSessionEvent{pubsession_id:SessionId,// the stream this event belongs topubrun_id:Option<RunId>,// the run, for events that belong to onepubseq:SequenceNumber,// dense per session, across its runs, from 1pubat:Timestamp,// Unix millisecondspubpayload:SessionEventPayload,}#[serde(tag = "kind", content = "event", rename_all = "snake_case")]// adjacent: no event field can collide with the tag#[non_exhaustive]pubenumSessionEventPayload{Agent(AgentEvent),Lifecycle(LifecycleEvent)}#[serde(tag = "type", rename_all = "snake_case")]#[non_exhaustive]pubenumLifecycleEvent{Started{agent:String,prompt:String,timeout:Option<Duration>,liveness:Liveness,continues:Option<RunId>},ObserverAttached{observer:Observer},ObserverDetached{observer:Observer,because:DetachCause},ClaimsExhausted,LivenessDecided{policy:LivenessPolicy},Parked{checkpoint:CheckpointRef,usage:TokenUsage},Finished{usage:TokenUsage},// usage flattened on every terminal eventCancelled{reason:RunCancelReason,message:Option<String>,usage:TokenUsage},Failed{error:String,usage:TokenUsage},#[serde(other, skip_serializing)]Unknown,// a tag this version does not know: read, never written// #786 adds ApprovalRouted; #785's Blocked state wants a Retrying signal — see below}pubstructObserver{pubid:ObserverId,pubkind:ObserverKind,pubpresence:bool}#[non_exhaustive]pubenumObserverKind{Collecting,Claiming,Unknown}#[non_exhaustive]pubenumDetachCause{Released,Expired,Displaced,Unknown}#[non_exhaustive]pubenumLivenessPolicy{Cancel,Continue,Park,Unknown}pubstructLiveness{pubpolicy:LivenessPolicy,pubgrace:Duration}#[serde(tag = "reason", rename_all = "snake_case")]#[non_exhaustive]pubenumRunCancelReason{Deadline{after:Duration},External,ClientTool,Unclaimed,Shutdown,Unknown}
Observer, ObserverKind, and LivenessPolicy are defined here because the lifecycle events carry them; #781 and #784 give them behavior. ends_run() reports whether an event ends its run: a park, a finish, a cancel, or a failure. Every enum above reads a tag it does not know as Unknown, so a reader older than its producer keeps the event and its seq; SessionEventPayload is the exception, since serde cannot fall back on an adjacent tag that carries content. RunCancelReason moves here from aura::hooks (on nightly since PR #710) and is re-exported there.
Additional Context
Vocabulary, because the drafts have been loose with it: an agent is a config entry (AgentInfo::id); a session is that agent's identity over time, the stream key, and the thing history and memory hang off; a run is one unit of work in a session, from a prompt to a terminal state, made of many turns — inference frames and tool calls — that are rig's and are not addressable. A user's input enters a run only through HITL decisions and the turn nudge, both of which land between turns; a new user prompt cannot, which is why a clarifying question ends the run and the answer starts the next one.
A run has one id. The task-local scope on nightly carries RunContext::id, an Arc<str> that is the HTTP request id today; orchestration persistence mints its own UUID run_id that the park checkpoint and HITL registry key on (run_owner_id is run:{run_id}). The envelope names one RunId, minted by whatever starts the run. RunContext::id becomes that newtype, and orchestration persistence and the park owner adopt it, when the runtime (#780) starts minting; until then the envelope is unused and the two ids coexist as they do today.
The epic's "unique agent_id for that run" is this run id. It is not AgentContext::agent_id, which is CONVERSATION_AGENT_ID ("main") or a worker name and attributes an event within a run.
RunParked exists today as an AgentEventPayload variant because the orchestrator emits it from inside the run, carrying its iteration state. It stays there. The lifecycle Parked event is the runtime's acknowledgement and carries the checkpoint reference. Both appear in the stream; a projection decides which it surfaces.
The started event carries the prompt, not the history. History is the run's input, readable from its summary (#785) if a consumer needs it, and too large to replay to every late observer.
Settle on the rebase, because each is cheap before a producer exists and not after (the reasoning is in runtime design, Persistence and resumability):
The stream is the session's.session_id becomes required, run_id optional, seq dense per session, and the types are named for what they are: SessionEvent, SessionEventPayload, LifecycleEvent. An observer attaching to an idle session, or a session claim ([FEATURE]: Atomic Fence for VFS Claims #581), is a lifecycle event with no run. Reifying an agent is then one stream load by session id, never a composition of per-run streams in the right order.
RunId is a UUID. HITL already parses the orchestration run id as one and the park owner key is run:{run_id}; a RunId minted as anything else breaks both when [FEATURE]: A runtime that owns sessions and their runs #780 unifies the ids. UUIDv7 sorts by time, which GET /v1/runs wants. SessionId stays a client-supplied string (chat_session_id, an A2A context id); stores key it through a v5 UUID as e54d2bc6 does.
Consider adjacent tagging on the envelope — #[serde(tag = "kind", content = "event")] on the payload enum. Internal tagging put Observer.kind in collision with the envelope's kind once already (nested to fix); every future variant with a field named kind, type, or reason is the same bug. Adjacent tagging removes the class at the cost of one nesting level. If internal tagging stays, add a test that asserts no variant field uses a tag name.
Started carries the run's Liveness ([FEATURE]: Liveness policy when a run's last claim detaches #784's type, which lives beside LivenessPolicy here), #[serde(default)] so an older payload still parses. Without it a replaying observer learns the policy only at LivenessDecided, which may never come.
A retry signal is wanted, not yet placed.[FEATURE]: Endpoints to list, inspect, attach to, and control sessions and runs #785's Blocked state needs the run to say it is waiting on a provider retry. That is an AgentEvent the provider layer emits (Retrying { attempt, reason }), and it lands with the provider-retry work rather than here; the envelope only has to admit it, which #[non_exhaustive] does.
Making RunContext::id the RunId newtype is PR #730's rebase, not #780's: it touches run_context, the MCP binding, the HITL gate, the park owner key, and begin_run's signature, and it is the one change that makes the envelope's id and the task-local id the same value.
Summary
AgentEventsays what an agent did. Nothing says what happened to the run it belongs to: that it started, who attached or detached, that its last claim went away, that it finished, was cancelled and why, or parked. Observers infer those from the transport — an SSE stream closing, an A2A status update, a[DONE]— so each seam reconstructs the run's story its own way and no two agree.Give the session an envelope that carries both. A session is an agent's identity over time; a run is one unit of work within it — a prompt or headless start, through every inference and tool turn, to
[DONE]or another terminal state. The runtime emits run lifecycle into the same ordered stream the agent's events travel on, one stream per session, so a projection reads one sequence and never correlates two, and an agent's whole history loads as one stream rather than as a composition of runs.Goals
SessionEventenvelope: session id, the run id when the event belongs to a run, a sequence number, a timestamp, and either anAgentEventor a lifecycle event. The correlation [FEATURE]: Agent event schema and broker adapter #618 kept offAgentEvent— session, run — lives here, applied once by the runtime rather than by every observer.RunCancelReasonplus the caller's message), failed.a2a::bus_bridgealready numbers its fan-out frames for this reason; this becomes the one place the number is minted.#[non_exhaustive], living inaura-eventsbesideagent.rs, so a seam with no agent dependency parses it.aura-events.Data structures
As implemented in
crates/aura-events/src/run.rson PR #794, abbreviated:Observer,ObserverKind, andLivenessPolicyare defined here because the lifecycle events carry them; #781 and #784 give them behavior.ends_run()reports whether an event ends its run: a park, a finish, a cancel, or a failure. Every enum above reads a tag it does not know asUnknown, so a reader older than its producer keeps the event and itsseq;SessionEventPayloadis the exception, since serde cannot fall back on an adjacent tag that carries content.RunCancelReasonmoves here fromaura::hooks(on nightly since PR #710) and is re-exported there.Additional Context
Vocabulary, because the drafts have been loose with it: an agent is a config entry (
AgentInfo::id); a session is that agent's identity over time, the stream key, and the thing history and memory hang off; a run is one unit of work in a session, from a prompt to a terminal state, made of many turns — inference frames and tool calls — that are rig's and are not addressable. A user's input enters a run only through HITL decisions and the turn nudge, both of which land between turns; a new user prompt cannot, which is why a clarifying question ends the run and the answer starts the next one.A run has one id. The task-local scope on nightly carries
RunContext::id, anArc<str>that is the HTTP request id today; orchestration persistence mints its own UUIDrun_idthat the park checkpoint and HITL registry key on (run_owner_idisrun:{run_id}). The envelope names oneRunId, minted by whatever starts the run.RunContext::idbecomes that newtype, and orchestration persistence and the park owner adopt it, when the runtime (#780) starts minting; until then the envelope is unused and the two ids coexist as they do today.The epic's "unique agent_id for that run" is this run id. It is not
AgentContext::agent_id, which isCONVERSATION_AGENT_ID("main") or a worker name and attributes an event within a run.RunParkedexists today as anAgentEventPayloadvariant because the orchestrator emits it from inside the run, carrying its iteration state. It stays there. The lifecycleParkedevent is the runtime's acknowledgement and carries the checkpoint reference. Both appear in the stream; a projection decides which it surfaces.The started event carries the prompt, not the history. History is the run's input, readable from its summary (#785) if a consumer needs it, and too large to replay to every late observer.
Settle on the rebase, because each is cheap before a producer exists and not after (the reasoning is in runtime design, Persistence and resumability):
session_idbecomes required,run_idoptional,seqdense per session, and the types are named for what they are:SessionEvent,SessionEventPayload,LifecycleEvent. An observer attaching to an idle session, or a session claim ([FEATURE]: Atomic Fence for VFS Claims #581), is a lifecycle event with no run. Reifying an agent is then one stream load by session id, never a composition of per-run streams in the right order.RunIdis a UUID. HITL already parses the orchestration run id as one and the park owner key isrun:{run_id}; aRunIdminted as anything else breaks both when [FEATURE]: A runtime that owns sessions and their runs #780 unifies the ids. UUIDv7 sorts by time, whichGET /v1/runswants.SessionIdstays a client-supplied string (chat_session_id, an A2A context id); stores key it through a v5 UUID ase54d2bc6does.#[serde(tag = "kind", content = "event")]on the payload enum. Internal tagging putObserver.kindin collision with the envelope'skindonce already (nested to fix); every future variant with a field namedkind,type, orreasonis the same bug. Adjacent tagging removes the class at the cost of one nesting level. If internal tagging stays, add a test that asserts no variant field uses a tag name.#[serde(flatten)]onFinishedandCancelled, and internal tagging throughout, do not work with non-self-describing formats (bincode,postcard). A durable journal ([EPIC] State Management #210 / [Epic] Aura session storage and persistance #325) inherits that.RunCancelReasonis#[non_exhaustive]and gainsUnclaimedandShutdown. It is a closed enum on PR feat(events): give a session an event envelope carrying run lifecycle #730, mirroringhooks::RunCancelReason, which hooks match exhaustively — a variant added later is a breaking change. And two cancels the runtime performs have no reason today: a liveness cancel ([FEATURE]: Liveness policy when a run's last claim detaches #784) would read asExternal, indistinguishable from an operator's/cancel, and a shutdown cancel ([FEATURE]: A runtime that owns sessions and their runs #780) would too.ObserverDetachedsays why.Releasedis the observer letting go;Expiredis a lease at a process boundary running out (the cross-instance relay in [FEATURE]: Endpoints to list, inspect, attach to, and control sessions and runs #785, the session claim in [FEATURE]: Atomic Fence for VFS Claims #581);Displacedis a take-over ([FEATURE]: Observers declare whether they collect, claim, or attend #781). Without it a consumer cannot tell a clean disconnect from a dead relay.Startedcarries the run'sLiveness([FEATURE]: Liveness policy when a run's last claim detaches #784's type, which lives besideLivenessPolicyhere),#[serde(default)]so an older payload still parses. Without it a replaying observer learns the policy only atLivenessDecided, which may never come.Blockedstate needs the run to say it is waiting on a provider retry. That is anAgentEventthe provider layer emits (Retrying { attempt, reason }), and it lands with the provider-retry work rather than here; the envelope only has to admit it, which#[non_exhaustive]does.Making
RunContext::idtheRunIdnewtype is PR #730's rebase, not #780's: it touchesrun_context, the MCP binding, the HITL gate, the park owner key, andbegin_run's signature, and it is the one change that makes the envelope's id and the task-local id the same value.Searched Issues
Code of Conduct