Skip to content

[FEATURE]: A session's event envelope carries run lifecycle alongside agent activity #778

Description

@justintime4tea

Summary

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.
  • No producer emits it yet. This is a type and its tests; [FEATURE]: A session's event journal and late attach #779 is the first thing that writes one.

Data structures

As implemented in crates/aura-events/src/run.rs on PR #794, abbreviated:

// aura_events (lib.rs)
pub struct RunId(uuid::Uuid);  // one per run; v7 when minted; refuses the nil UUID
string_newtype! { SessionId }  // the stream key; a client-supplied string

// aura_events::run
pub struct SessionEvent {
    pub session_id: SessionId,            // the stream this event belongs to
    pub run_id: Option<RunId>,            // the run, for events that belong to one
    pub seq: SequenceNumber,              // dense per session, across its runs, from 1
    pub at: Timestamp,                    // Unix milliseconds
    pub payload: SessionEventPayload,
}

#[serde(tag = "kind", content = "event", rename_all = "snake_case")]  // adjacent: no event field can collide with the tag
#[non_exhaustive]
pub enum SessionEventPayload { Agent(AgentEvent), Lifecycle(LifecycleEvent) }

#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LifecycleEvent {
    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 event
    Cancelled { 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
}

pub struct Observer { pub id: ObserverId, pub kind: ObserverKind, pub presence: bool }
#[non_exhaustive] pub enum ObserverKind { Collecting, Claiming, Unknown }
#[non_exhaustive] pub enum DetachCause { Released, Expired, Displaced, Unknown }
#[non_exhaustive] pub enum LivenessPolicy { Cancel, Continue, Park, Unknown }
pub struct Liveness { pub policy: LivenessPolicy, pub grace: Duration }

#[serde(tag = "reason", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunCancelReason { 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):

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.

Searched Issues

  • No similar issues found

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

  1. added 6 commits that reference this issue on Oct 7, 2026
    ad82c06
    c2e2337
    ab55a86
    6500e85
    921232c
    ae7b128
  2. 41 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions