Second-round Codex review of the PR-A taxonomy change found four issues, all verified against the code: - The /goal regression guard asserted only that the steered content reached requests[1], which passes even with the hasSteering override (loop.ts) disabled: leftover steering is re-enqueued as a next-turn queued message and also lands in requests[1], one turn later. The guard now asserts the same-turn shape — ONE turn, TWO steps, a steering/message recorded before step 2 — which is the mechanism the override drives. Proven to fail red with the override disabled. - The event-domain-semantics RFC's consequence list still described the pre-fix behavior (step marked open AFTER step/start, so no step/end owed). It now states the shipped behavior: the loop marks the step open BEFORE the append, so a throwing step/start listener gets a balancing step/end via closeStep(). - architecture.md's loop pseudocode said only continuation listeners force continuation; step/end session-event listeners (the /goal pattern) do too. - The agent/turn-end JSDoc listed a `rejected` TurnEndReason that does not exist on this branch (it belongs to the later interception work). Removed it and regenerated the cordis catalog; `interrupted` (a real variant) stays.
5.6 KiB
RFC: Event-domain semantics — session is the fact log, agent is the live surface
Status: implemented (accepted 2026-06-30)
Context
The harness extends the agent loop through a Cordis event taxonomy (see the microkernel event-taxonomy RFC). As that taxonomy grew, the line between the three event domains blurred:
session/*carries the durable, event-sourced log (SessionEventMap).agent/*carries live runtime signals that hand a plugin theAgenthandle.tools/*carries the tool registry + execution seam.
Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable SessionEvent (turn/start, turn/end, step/start, step/end) AND as a mirrored agent/* emit (agent/turn-start, agent/turn-end, agent/step-start, agent/step-end). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why.
This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the hook/* durable log, the bridges) build on.
Decision
Three domains, one job each, with a single boundary rule.
session/*— the durable, replayable FACT log. OwnsSessionEventMap; every entry is JSON-only (no live objects). Onesession/eventemit per append, plus thesession/flushparallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering andsession/loadreplay share one path.agent/*— the LIVE runtime surface. Always carries the liveAgent. Two shapes: INTERCEPTION waterfalls (agent/request,agent/step-result,agent/turn-continuation) that mutate or veto, and TRANSIENT emits (agent/status,agent/stream-chunk,agent/error,agent/created/agent/disposed,agent/queued,agent/steering, and the turn boundaries) that notify with theAgentin hand.tools/*— the tool registry + execution seam.
The boundary rule: a durable, replayable fact is a SessionEvent; a live interception or a transient/live-object signal is an agent/tools Cordis event. A datum that is BOTH — a turn or step boundary — lives in the session log, and is mirrored as an agent/* emit ONLY where a live consumer provably needs the Agent handle at that instant.
Applying the rule to the boundary twins (prune case-by-case):
agent/turn-start— KEPT. The stdio UI (dsh-ui-stdio) labels turn output byagent.id, which theturn/startsession event does not carry. A genuine live-object need.agent/turn-end— KEPT. The stdio UI listens to print the next-prompt glyph. (Note: the ACP bridge does NOT settle on this event — it settles fromsession/eventturn/endplusagent/status; the surviving justification is the stdio UI alone.)agent/step-start,agent/step-end— REMOVED. No production consumer needs the liveAgentat a step boundary; a consumer that wants per-step boundaries reads the durablestep/start/step/endsession events. Removing the two emits also simplifies the loop'scloseStep(one append, no paired emit).
Consequences
- The loop no longer emits
agent/step-start/agent/step-end;closeStepappendsstep/endonly, and a throwingstep/endsession-event listener is the surviving step-boundary-listener failure path (contained bycloseStep→failTurn, the turn closes balanced). - Tests that observed step boundaries via the removed emits now observe the durable
step/start/step/endsession events — the behavior they pin (boundary ordering, step counting, a throwing boundary listener failing the turn balanced) is unchanged; only the feed they read moved to the canonical one. Per AGENTS.md "tests document behavior, not golden truth", the behavior and its test moved together. - The loop marks the step open (
stepOpen = true) BEFORE appendingstep/start, becauseSession.appendpushes the event to the log before notifyingsession/eventlisteners (validation throws happen earlier, before the push — see the session append contract). So a throwingstep/startsession-event listener runs with the step already open and the event already in the log: the loop's outer catch then callscloseStep(), which appends the balancingstep/end, and the turn closes balanced with an error (turn/start → step/start → step/end → turn/end— verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. - This is a partial, conservative realization of the broader proposed simplification "Stop mirroring durable boundaries as agent events": that RFC proposes removing ALL boundary mirrors (including the turn boundaries and
agent/steering) and migrating the stdio UI's turn rendering ontosession/event. This RFC removes only the two step mirrors that have no live consumer; the turn mirrors stay until the stdio UI is migrated. The proposed RFC remains the home for finishing that migration. - The cordis catalog (
docs/cordis-catalog/events-and-services.md) is regenerated to drop the two events.