fix(engine): fail fast when a host adapter cannot deliver an agent-handled action (#368)
* refactor(engine): declare node delivery class in @relaycast/types and route events through one dispatcher
The durable/ephemeral split for node pushes was a hard-coded Set in
routes/fanout.ts, and the fan-out to the workspace stream, the durable
workspace event log and the node context push was hand-assembled in
fanout.ts, deliveryRouting.ts and agent.ts.
- @relaycast/types now declares NODE_DURABLE_EVENT_TYPES,
NodeDeliveryClassSchema, isNodeDurableEventType and nodeDeliveryClassFor.
- engine/eventDispatch.ts is the single place that decides which sinks an
event reaches, keyed off that declaration plus the dispatch scope
(workspace / channel / agents / presence).
- fanout.ts, deliveryRouting.ts and agent.ts call the dispatcher instead of
assembling sinks by hand; nodeContext.ts stays the transport layer.
Behavior preserving: same sinks, same payloads, same wire frames, sinks stay
independent on failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MB3K5bK7Jc5HM92fsZRUyS
* refactor(engine): classify node events by frame kind and report sink failures
The `durable`/`ephemeral` naming was wrong: `message.read` and
`message.reacted` do ride the `deliver` frame, but as synthetic `seq: 0`
sends with no delivery row that are dropped when the provider is not ready
(`deliverEventToRecipient` in engine/nodeDeliver.ts). The real split is
which node frame carries the event.
- @relaycast/types now exports NODE_DELIVER_FRAME_EVENT_TYPES,
NodeFrameKindSchema ('deliver' | 'context'), isNodeDeliverFrameEventType
and nodeFrameKindFor; the durable/ephemeral names are gone (the package is
unreleased on this branch, so no aliases).
- eventDispatch.ts reports a rejected workspace-log append through
onSinkError('workspace_stream') instead of letting Promise.allSettled
swallow it, in both publishEvent and publishEventsToAgents.
- nodeContext.ts keeps per-node sends independent but throws an
AggregateError when any of them rejects, so the dispatcher's
onSinkError('node_context', ...) can actually fire.
- JSDoc on the new/modified top-level functions in eventDispatch.ts,
nodeContext.ts, fanout.ts, deliveryRouting.ts and agent.ts.
- Filled in the trajectory record's commits, filesChanged, trace refs and
verification summary.
Behavior otherwise unchanged: same sinks, same payloads, same wire frames.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MB3K5bK7Jc5HM92fsZRUyS
* fix(types): declare the caller-addressed deliver-frame event types
`sendNodeDeliveriesToAgents` sends `action.completed`, `action.failed`,
`action.denied`, `agent.exited`, `node.status.online`, and
`node.status.offline` to one agent's mailbox as synthetic seq-0 `deliver`
frames, the same path as the channel receipts, so `nodeFrameKindFor` must
report `deliver` for them rather than `context`. None of these reach the
event dispatcher with a node audience, so dispatch behaviour is unchanged.
Also aligns the trajectory record's commits and endRef with the review
round it describes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MB3K5bK7Jc5HM92fsZRUyS
* fix(engine): fail fast when a host adapter cannot deliver an agent-handled action
An agent-handled invoke whose adapter send returns false fell into the
takeover-race replay wait and surfaced a misleading, retryable-looking 503
`idempotency_unavailable`. Hosted adapters that cannot observe socket state
synchronously hit this for every offline direct handler.
After the wait, a row that is still open with no recorded attempt and no
dispatched node is now resolved the way the pre-dispatch liveness gate would:
failed with `handler_unavailable` (503), or left `pending` when the action
opted into `queue`. Takeover races and rows that gained an attempt keep their
current behavior via the new `onDeadline` option's default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MB3K5bK7Jc5HM92fsZRUyS
* fix(engine): fail an undeliverable agent-handled invoke only when it was never dispatched
The post-send classification failed the invocation with an unconditional
`failOpenInvocationRows`, whose WHERE only requires an open status. A handler
that reconnected after the final replay poll could have this very row
dispatched by `drainNodeInvocations` between that poll and the update, so a
live dispatch was overwritten with `failed` / `handler_unavailable` while the
handler executed the action.
The failure is now one conditional UPDATE that also requires
`dispatch_attempts = 0` and no dispatched node, and the 503 is raised only when
that update claimed the row. Otherwise the invocation is reloaded and reported
from its own state. The in-process node adapter records the dispatch attempt in
the same conditional mutation that authorizes it, before the socket send, so a
racing dispatcher is always visible to that predicate — matching how the hosted
NodeDO orders its send boundary.
Drops the `queue` branch: `actions.queue` is only set by node-provider
capability registration, which never targets an agent handler, so no supported
path could select it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MB3K5bK7Jc5HM92fsZRUyS
* docs(changelog): one bullet per user-visible fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MB3K5bK7Jc5HM92fsZRUyS
---------
Co-authored-by: Claude <noreply@anthropic.com> W
Will Washburn committed
234ca1cabd964d3c2171e96abd990f7bf4e4b60b
Parent: f4d00fc
Committed by GitHub <noreply@github.com>
on 9/3/2026, 2:18:00 PM