Skip to content

docs(eve): research - event consumers plan#1172

Closed
ruiconti wants to merge 15 commits into
mainfrom
rui/notification-reactors-research
Closed

docs(eve): research - event consumers plan#1172
ruiconti wants to merge 15 commits into
mainfrom
rui/notification-reactors-research

Conversation

@ruiconti

@ruiconti ruiconti commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Research plan for #1170. Design only — no runtime changes, no new public API.

What this proposes

Normalize event consumption. Today the flow an event takes depends on who consumes it: followers (TUI, web clients, evals) read the durable session stream at their own pace, while channels and the remote-callback forwarder run inline in the producer's emit path, alive only while the producer's compute is awake.

The proposal inverts that split. The harness funnels every event a single way, and every consumer — channels included — consumes from a durable delivery step. An accepted delivery survives crashes and redelivers until consumed (queue retry, journaled side effects). The price is one extra hop per event; v1 is scoped so that hop replaces a wake the caller already pays.

"Notification" appears in the design only as wire vocabulary: the status: "notification" class on #1167's callback envelope, one of the event classes a lane can carry. The unit this plan normalizes is the event.

Why

The first events to need this — a child's authorization.* surfaced on the caller's stream (#1167) — show the inline weld failing concretely. Receipts pinned to #1167's head 413ce48d and main 0a8b63c5:

  • The delivery hook rotates; producers hold a stale token. The engine buffers deliveries on hooks: resumeHook durably appends a hook_received event to the owning run's log and re-queues the run — no one needs to be awaiting the hook yet — and it throws only for a missing or disposed hook or a terminal run (@workflow/core@5.0.0-beta.36, dist/runtime/resume-hook.js). But a session exposes no long-lived hook. Its delivery hook is keyed by the current turn's continuation token: the callee's callback URL captures that token at dispatch (remote-agent-dispatch.ts:30), the entry workflow rekeys the hook to a fresh token every time a turn parks (workflow-entry.ts:251), and the retired hook is disposed immediately, so a delivery to its token gets HookNotFoundError (session-delivery-hook.ts:37). The route surfaces that as a 404 (session-callback-route.ts:67) and the callee logs and moves on. Why it matters: these events are how a person gets unblocked. Expected path: a remote child suspends on sign-in, the forwarded authorization.required becomes the sign-in prompt in the caller's channel, the person clicks, the child resumes. Divergence: the caller's turn also holds a pending approval — the feat(eve): propagate remote-callee authorization events to the caller #1167 hitl-collision shape — and that approval rekeyed the delivery hook, disposing the one whose token the child's callback URL still carries; the prompt 404s, and the only person who can unblock the child is never asked. Same failure on the way out: after the terminal callback resolves the call and the session rekeys for its next turn, the racing authorization.completed hits the disposed hook, and a card that did render stays frozen on "waiting". One curl verifies it: POST a well-formed notification to a retired callback token and read back {"error":"Session callback not pending."}.
  • Nothing redelivers. Producers do emit from journaled steps, but a delivery failure never reaches the journal — deliberately, so an unreachable caller cannot fail the producing step: the remote POST failure is swallowed in the forwarder, and a throwing local forward is swallowed by callAdapterEventHandler. A producer-side retry couldn't fix it anyway: the POST is addressed to a retired token and rotation is one-way, so the address never becomes valid again — while the retry re-runs the step's other side effects. The child's own stream write is durable; the delivery isn't.
  • Delivery is heavyweight. A rendering-only event — one whose entire effect is presentation, e.g. a child's authorization.required becoming the channel's sign-in prompt, never touching model history or session state — costs a queue wake of the parked turn workflow (turn-workflow.ts:297) plus a journaled step that re-reads the durable session and deserializes the full context to run one adapter handler and one stream write.
  • The same forwarding is implemented twice. Local children forward through the subagent adapter's journaled hook step, remote callees through the callback POST plus route projection (self-described as "the callback-URL analog" of the former). Two transports, two implementations, divergent failure semantics (the local step rethrows, the remote swallows), converging only at the caller's inbox. Note: an earlier draft claimed channels render twice — the trace shows rendering converges on one proxy step; the duplication is producer-side forwarding.
  • Channels are locked to the workflow. Adapter state rides the workflow step's serialized context, so nothing outside the session workflow can safely invoke a channel.

Comparison artifacts

  • feat(eve): propagate remote-callee authorization events to the caller #1167 (reopened) — the baseline: child authorization events piped through today's continuation entry point, resumeHook into the session workflow's turn inbox. It works end to end and hits every failure above. To keep it a clean baseline for the delivery path alone, it sheds its eve/tools authorization API exposure.
  • A prototype of this design — the per-session consumer run below, exercised against the same fixture evals.

v1 shape

Scoped by per-lane wake accounting so v1 adds zero new queue wakes:

  • Caller side: one consumer run per session (workflow//eve//eventConsumer, same compiled bundle and deployment as the session's other runs), awaiting <sessionId>:events — a hook whose token is stable for the session's lifetime, so producers stop chasing the rotating turn token and the engine's own hook buffering applies — holding the stream writable. It replaces the queue wake the caller already pays today — but hydrates only channel context. Each delivery is one journaled write step (event-consumer-write-step): invoke the channel's existing adapter event handler (channels change nothing) and append the wrapped subagent.event for followers. Adapter state persists in the consumer's own run state, which buys Slack edit-in-place on authorization.completed.
  • Callee side: no consumer run. Forwarding keeps its direct POST — the only lane where a consumer would add a new wake — just deferred off the emitting step's critical path.
  • Frequency admission rule: higher-frequency event types (working, proxied HITL) enter only with cursor-based coalescing.

Why this shape

Three drafts died on the way: route-side streams.write-by-id + per-consumer enqueue (replaced by resumeHook against a companion run — the hook queue already provides ordering, at-least-once, exactly-once side effects, duplicate-run safety); consumers on both sides (wake accounting moved the callee's behind the admission rule); a new channel notification member (unnecessary — adapters already implement the handler these events arrive through).

Relation to #1167: the consumer replaces its caller-side resumeHook-into-the-session-workflow arm; the callee-side forwarding gets deferred; the wire envelope and termination lane stay.

Producers append; consumers react. Each session gets one companion
reactor run parked on a notify hook: producers (the remote callback
route, the local subagent proxy, the callee's emit path) deliver
curated child events with one fire-and-forget resumeHook, and the
reactor appends the wrapped subagent.event to the session stream and
performs the side effects (channel rendering, callback forwarding) in
journaled steps. The session workflow is never woken for notifications,
which closes the parked-for-input delivery hole and unifies the local
and remote child-event paths into one shape.

Backed by #1170.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
eve-docs Ready Ready Preview, Comment, Open in v0 Jul 24, 2026 10:48pm
eve-docs-1644 Ready Ready Preview, Comment, Open in v0 Jul 24, 2026 10:48pm
eve-docs-4759 Ready Ready Preview, Comment, Open in v0 Jul 24, 2026 10:48pm

Per-lane wake accounting: the caller already pays a queue wake per
notification (resumeHook into the session workflow), so the reactor
there replaces a wake rather than adding one. The callee's outbound
forwarding is the only lane where a reactor would introduce a new
per-event wake, so v1 keeps the direct POST and merely defers it off
the emitting step's critical path (today the caller's RTT is awaited
inside the emit). Higher-frequency event types are admitted to the
lane only with cursor-based coalescing, so N events during one
invocation cost one wake.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
…tract

No new channel authoring surface. The reactor invokes the same adapter
event handler the session workflow's proxy step invokes for these
events today, so channels render notifications with zero changes; the
run doing the invoking is the only difference. Adapter state mutated
while handling a notification persists in the reactor's own run state,
which also resolves the edit-in-place rendering question without a
stateless-hook contract. The subagent.event wrapper stays a
stream-representation concern for followers and clients.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
The reactor is a framework-registered workflow function
(workflow//eve//notificationReactor) in the same compiled bundle as the
entry and turn workflows, executing as queue-driven invocations of the
caller's own deployment — no new compute surface. Started plainly by
the entry run (no latest-deployment routing): it holds the entry's
stream writable and channel context, so it is pinned to the entry's
deployment for the session's lifetime.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
…nsumers

Same design; the run is a consumer, and the doc now says so everywhere,
including the workflow id (workflow//eve//notificationConsumer) and the
file name.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
@ruiconti ruiconti changed the title docs(eve): research - notification reactors plan docs(eve): research - notification consumers plan Jul 24, 2026
…tion

Lead the summary with the model rather than the bug: the durable
stream is the propagation backbone, producers append, and consumption
is a separately scheduled concern — followers already work this way,
and the notification consumer is the first non-follower consumer moved
onto it. The failure list stays as the evidence, and the lane is named
as the on-ramp for future event classes (progress, proxied HITL) and
for converging the remaining emit-coupled consumers.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
Same design, a third shorter: cut repeated rationale and filler; every
section now states its point once.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
Every failure bullet now carries commit-pinned code references
(413ce48, the #1167 baseline) plus a concrete example: the drop window
is the authorization.completed POST racing the terminal callback,
verifiable with one curl against an unparked session's callback URL;
rendering-only is defined by example (a child's authorization.required
becoming the channel's sign-in prompt).

Two claims changed under the trace. "Channels implement the same
rendering twice" was wrong: both transports converge on one proxy step
at the caller's inbox (turn-workflow.ts:297), so rendering happens once
— what exists twice is the forwarding (subagent-adapter.ts vs
session-callback-notification.ts, self-described as its "callback-URL
analog"), with divergent failure semantics. "Nothing redelivers" holds,
but the reason is subtler than "dropped at the producer": producers do
emit from journaled steps, yet delivery failures are deliberately
swallowed before the journal sees them (adapter.ts:246,
session-callback-notification.ts:61), and a producer-side retry could
not fix a deterministic "not pending" anyway.

Also adds an event-indexing open question: the consumer lane is where an
indexer would attach, but v1's best-effort callee wire means a
parent-stream index cannot claim cross-deployment completeness.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
Matches the prototype's event-consumer-write-step: the run owns
receiving, the step owns one delivery's durable effects.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
The dropped-events example named the mechanism (independent POSTs, dead
inbox token) but not the stakes. Tell it as the sign-in story: the
forwarded authorization.required is the sign-in prompt in the caller's
channel, so dropping it while the caller is parked for input (the
hitl-collision shape) means the only person who can unblock the parked
child is never asked; on the completion side the card freezes on
"waiting" after the child already finished.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
…rmed inbox

The dropped-events bullet claimed delivery requires a turn parked at its
inbox. The engine says otherwise: resumeHook durably appends
hook_received to the owning run's log and re-queues it, throwing only
for a missing/disposed hook or a terminal run
(@workflow/core@5.0.0-beta.36, dist/runtime/resume-hook.js) — hook
payloads buffer like any mid-turn delivery.

What actually kills delivery is the address. The callee's callback URL
embeds the caller's continuation token as of dispatch
(remote-agent-dispatch.ts:30), the session rekeys to a fresh token at
every park (workflow-entry.ts:251), and retired hooks are disposed
immediately (session-delivery-hook.ts:37) — so any park between dispatch
and the event 404s the POST, and rotation being one-way means no retry
can ever land it. Rename the bullet to what it is: deliveries are
addressed to a park, not to the session. The consumer's <sessionId>:events
hook is the fix stated in those terms — a session-lifetime address under
which the engine's own buffering finally applies.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
A park is not addressable — it is what a run does when it awaits a hook.
Deliveries are addressed to hooks by token. Restate the drop bullet in
those terms: the session exposes no long-lived hook; its delivery hook is
keyed by the current turn's continuation token, rekeyed at every turn
park, retired hooks disposed. The consumer bullet follows: a hook whose
token is stable for the session's lifetime.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
Name the entity precisely: a sidecar run — auxiliary, 1:1 with the
session, lifecycle-coupled to the entry, deployment-pinned, with its own
journal. State the reason it must be its own run rather than the entry
or a concurrent branch inside it: a workflow wake replays the run's
journal, the entry's journal grows with the session, and the sidecar's
holds only its own deliveries — the extra run is the replay-cost
firewall between event consumption and session history.

Signed-off-by: Rui Conti <ruiconti@gmail.com>
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