Skip to content

feat(agents): publish synthesized agent events through canonical - #1778

Merged
chuks-qua merged 14 commits into
mainfrom
feat/canonical-synthesized-publications
Sep 29, 2026
Merged

chuks-qua merged 14 commits into
mainfrom
feat/canonical-synthesized-publications

Conversation

@chuks-qua

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

Copy link
Copy Markdown
Contributor

What

Server-synthesized AgentEvents (goal updates and clears, /goal control replies, provider-unavailable notices, post-terminal hook completions, and materialized assistant message fallbacks) reached clients only through fire-and-forget agent.event broadcasts with no replay or reconnect recovery.

They now publish through CanonicalAgentBoundary.recordSynthesizedPublications, which commits publication.recorded envelopes on the same thread-scoped canonical_writer_live_publication_heads sequence the semantic writer uses, so writer and synthesized publication ids cannot collide. A legacy agent.event copy still emits with the same publicationId; the client dedups on whichever arrives first.

Synthesized events belong to no provider execution, so:

  • They commit under a reserved sentinel execution (CANONICAL_SYNTHESIZED_EXECUTION_ID) with persistCheckpoint: false; the event store seeds the accepted sequence from prior event rows when no checkpoint exists.
  • The embedded AgentEvent is stamped with publicationId only. A sentinel turnExecutionId would fail the client's execution-correlation preflight and drop the event mid-turn.
  • StableAgentEventPublications.accept no longer requires turnExecutionId; the thread-scoped publicationId was already the sole dedup key in reserve.

Why

Every renderer-facing publication must flow through the durable canonical stream so a lost or replayed connection rebuilds the same state the legacy broadcast would have shown. Synthesized events were the last emitters with no canonical counterpart; without this, deleting agent.event would lose goals, banners, and control replies on any dropped event.

Stacked on feat/canonical-publication-events (#1777), which adds the publication.recorded variant and client dispatch this unit relies on.

UI Changes

None.

Config Changes

None.

Review Notes

  • Verified: apps/server orchestration + turns suites (33 files, 459 tests), canonical boundary suite including a new synthesized-sequencing test, bun run typecheck clean in apps/server and apps/web, and web cursor/dispatch tests (12 tests).
  • The sentinel execution never persists a checkpoint row; persistCheckpoint: false keeps commit bookkeeping out of turn state.
  • Execution-bearing synthesized events (post-terminal HookCompleted) keep their real turnExecutionId when available.
  • Legacy agent.event removal stays in the final stack PR once all emitters are canonical.

Generated with Devin


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.
# Conflicts:
#	apps/web/src/stores/threadStore.ts
assertActiveTurnRecoveryRetention measured the serialized canonical
envelope drafts, letting transport overhead trip the cap before the
retained narrative itself did. Measure the persisted item payloads and
discarded item ids again, matching the pre-envelope accounting.
The byte-size computation pushed prepareParentNarrativeRecovery over
the complexity cap. Hoisting it keeps the retention accounting intact.
@chuks-qua
chuks-qua changed the base branch from feat/canonical-publication-events to main September 29, 2026 21:53
@chuks-qua
chuks-qua merged commit 39426fd into main Sep 29, 2026
10 of 11 checks passed
@chuks-qua
chuks-qua deleted the feat/canonical-synthesized-publications branch September 29, 2026 22:12
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