Skip to content

refactor(agents)!: replace agent.event replay with canonical recovery - #1779

Merged
chuks-qua merged 14 commits into
mainfrom
feat/delete-legacy-agent-event
Sep 29, 2026
Merged

chuks-qua merged 14 commits into
mainfrom
feat/delete-legacy-agent-event

Conversation

@chuks-qua

@chuks-qua chuks-qua commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

What

Deletes the legacy agent.event transport end to end: the subscription-scoped channel, the bounded server journal and cursor replay, the renderer listener, and the client-side sequence/epoch acceptance machinery. agent.canonical is now the only renderer-facing live event channel, and reconnect recovery runs through canonical revisions (delta or snapshot) instead of journal cursors.

Stacked on #1778. Two commits:

  • fix(web) repairs a regression from fix(web): derive thread runtime phase from canonical turn state #1772: canonical runtime reconcile stamped runtimePhase without correlating turnExecutionId, so a canonically owned running phase suppressed the provider-owned child lifecycle and could strand runningThreadIds. Reconcile now stamps the execution identity on live claims and the lifecycle gate distinguishes canonical-owned from locally-owned phases.
  • refactor(agents)! removes the legacy path: publishAgentEvent, the journal, cursors/hydrationRequiredThreadIds/replayedThrough in the subscription contract, sequence/epoch on AgentEvent, and the client's drop-preflight and cursor state.

Why

Composer, project tree, and chat could disagree on whether a thread was active: two transports carried overlapping authority, and a dropped or reordered legacy event left the UI stuck in streaming. One canonical channel with revision-based recovery removes the duplicated authority; publicationId dedup still covers replays.

UI Changes

None. No user-visible UI changes; event delivery semantics are internal.

Config Changes

None.

Review Notes

  • BREAKING: agent.event channel and the subscription cursors, hydrationRequiredThreadIds, and replayedThrough fields are removed; clients must use canonical revisions.
  • agent.listRunning stays the runtime snapshot source; startup reconciliation (TurnRecoveryService.reconcileOnStartup) makes the runtime registry authoritative before hydration.
  • Recovery retention accounting was corrected to measure persisted narrative items rather than serialized envelope overhead (regression introduced in feat(server): stream parent narrative recovery as canonical item events #1775).
  • Verified: server 459/459, contracts suite green, web focused suites green, full web suite green except a slow virtualization test that passes at its real 5.2s runtime when the timeout is raised (pre-existing flake, unrelated).

Depends on #1778


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

A half-open socket answers nothing and never fires close, so every
recovery mechanism (reconnect, hydration, subscription replay) waited
forever while the UI showed stale streaming state. A 30s interval now
issues the existing app.version RPC with a 10s deadline; an unanswered
probe closes the socket locally and the established onclose ladder owns
recovery. agent.send gains a 20s timeout so a composer submit cannot
park forever on a dead socket.

The wedged-socket test now answers the liveness probe, since a socket
that ignores it is exactly what the watchdog closes.
The reducer now writes routing.executionId onto the stored turn so
clients can correlate a canonical turn to the local runtime without
threading the event envelope through state. A payload-supplied
executionId that disagrees with routing is rejected as a routing
conflict.
Composer, project tree, and running-thread indicators now follow the
canonical replica once a turn correlates by execution identity, so a
dropped legacy terminal event can no longer strand runningThreadIds.
The optimistic window before turnStarted and the finalizing phase stay
on the legacy path until a canonical turn proves correlation. The
narrative projection keeps its existing child-only lifecycle gate.
The relay opened one socket and never recovered: a dropped connection
silently degraded the renderer to WebSocket-only delivery for the rest
of the session. Reconnect in the main process with bounded exponential
backoff, reset on successful connect, and stop when the window dies or
the relay is torn down. The renderer re-suppresses push channels as
frames resume, so no reconnect handshake is needed.
No caller remains: consumers read runningThreadIds directly.
Parent narrative recovery wrote items directly into canonical_agent_items,
so subscribed replicas only learned about them through snapshots. Each
mutation now commits an item.recorded envelope inside the existing
elapsed-bounded batch writer, discards become narrativeRecoveryDiscarded
tombstones, and the semantic writer flushes the envelopes through its
buffered publication channel. The web store reruns recoverParentNarrative
when a canonical batch touches narrative items, letting parent tool calls
and segments reach the chat through the canonical stream.
Numbered livePublication intents are the enriched, sanitized events the
agent.event broadcast sends today. Recording each one as a canonical
publication.recorded envelope inside the same operation transaction makes
the canonical stream carry the same renderer-facing payload, so replay and
gap recovery deliver identical effects without the legacy channel.

The reducer treats the envelope as a passthrough: identity and dedup
bookkeeping apply, but no thread or item state mutates. On the client,
canonical pushes and reconnect deltas dispatch the embedded event through
handleAgentEvent with its publicationId, so the existing stable
publication cursor accepts whichever copy arrives first and drops the
other.

Envelopes committed by unbuffered operation paths (control,
stage-terminal, assistant text, terminal batches) queue on the writer and
flush after the outer transaction commits, matching the buffered flush
used by live operations.
Live publication intents embed provider events before the public
enrichment stage, so canonical copies would persist raw tool inputs that
the legacy wire path strips. Sanitize the embedded event the same way
AgentEventPublicationService does before recording publication.recorded.
Goals, provider-unavailable notices, post-terminal hooks, control
replies, and materialized assistant messages bypassed canonical
durability and reached clients only through fire-and-forget agent.event
broadcasts. Route them through recordSynthesizedPublications, which
commits publication.recorded envelopes on the shared thread-scoped
publication head so replay and reconnect recovery deliver them.

Synthesized events carry no execution identity, so they publish under a
reserved sentinel execution with persistCheckpoint disabled, and the
event store seeds accepted sequences from prior events when no
checkpoint exists. The client publication cursor dedups on the
thread-scoped publicationId alone since turnExecutionId never
participated in reserve. The legacy agent.event copy still emits with
the same publicationId so whichever copy arrives first wins while the
client migrates.
…hase

The runtime reconcile stamps runtimePhase from a correlated canonical turn
but never correlated the record's execution identity, so a canonically owned
"running" phase made getCanonicalLifecycleTurn treat the provider-owned child
turn as a local execution and suppress its projection. A later terminal
canonical event could then no longer clear the stamped phase either.

reconcileCanonicalRuntime now stamps turnExecutionId from the claiming turn's
executionId on live claims, and the lifecycle gate only treats runtimePhase
as locally owned when the canonical runtime turn is not the latest turn.
The bounded agent.event journal, subscription cursors, and client-side
sequence acceptance duplicated authority the canonical channel already owns.
A dropped or reordered legacy event could leave composer, project tree, and
chat disagreeing on whether a thread was still streaming.

- Server publishes only agent.canonical; subscription-scoped replay is gone.
- push.setThreadSubscriptions takes canonical revisions and returns
  canonicalRecoveries (delta or snapshot) instead of journal cursors.
- Renderer drops the agent.event listener, event sequence/epoch fields, and
  background-drop preflight; publicationId dedup still covers replays.
- AgentEvent loses sequence/epoch; publicationId remains the receipt key.
- Recovery retention accounts for persisted narrative items again instead of
  serialized envelope overhead.

BREAKING CHANGE: the agent.event channel and the cursors,
hydrationRequiredThreadIds, and replayedThrough fields are removed from the
subscription contract; reconnect recovery now runs through canonical
revisions.
# Conflicts:
#	apps/web/src/stores/threadStore.ts
@chuks-qua
chuks-qua changed the base branch from feat/canonical-synthesized-publications to main September 29, 2026 22:12
The merged signature drops the agent.event publish dep; the tests still
passed a stale enriched-event argument and asserted a removed broadcast.
They now exercise only the status transitions the function still owns.
@chuks-qua
chuks-qua merged commit dc220dd into main Sep 29, 2026
9 checks passed
@chuks-qua
chuks-qua deleted the feat/delete-legacy-agent-event branch September 29, 2026 23:19
chuks-qua added a commit that referenced this pull request Oct 6, 2026
* fix(verify-mcode): read live turn events from agent.canonical

Since #1779 the server publishes live turn events only on the
agent.canonical push channel. The runtime live harness and the
composer-queue proof still listened for agent.event, so every runtime
live scenario timed out waiting for a terminal event even when the
assistant reply was saved.

canonicalFrameToLiveEvents reads the publication.recorded payloads of
accepted, saved, and recovery frames and applies the web client's
publication route. Callers record each event ID once because saved and
recovery frames repeat accepted events. A stopped turn now publishes
only ended with an interrupted outcome, so ended counts as completion
only with a completed outcome and counts as a stop otherwise.

* fix(verify-mcode): wait for runtime readiness after the OpenCode resume restart

agent:up returns after it writes ports.json, before the server answers
health. The resume proof called health with a 15-second budget right
after agent:up and failed when startup took longer. On a loaded host
the server took up to 15.9 seconds to open its listener, Vite once took
19.9 seconds, and one restart also failed the agent:ready CLI's fixed
30-second budget. Call agentReady in-process, bounded by the proof
deadline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant