Skip to content

fix(nodes): adopt node provider and commit agent move atomically on bind - #443

Merged
AgentRelayBot merged 3 commits into
mainfrom
fix/1794-bind-agent-location
Sep 19, 2026
Merged

AgentRelayBot merged 3 commits into
mainfrom
fix/1794-bind-agent-location

Conversation

@AgentRelayBot

@AgentRelayBot AgentRelayBot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes AgentWorkforce/relay#1794 — subscription recipients (and any fallback-bound agent) never wake because deliveries are pushed to a provider connection that does not exist.

Bug chain: broker spawn flows HTTP-register the agent first (providerName='default', implicit direct-* location), then the create-only agent.register fails → broker falls back to POST /v1/nodes/:name/agents → bindAgentToNode. The binding upsert moved locationType/locationNodeId but left providerName='default', so sendToProvider(node, 'default') found no connection on a node whose live provider is e.g. broker — deliver frames never sent, verified live (events sat unread in the agent's inbox with pending_messages: 0).

Fix:

  • bindAgentToNode now adopts a provider the node actually serves — precedence: keep the agent's provider if the node serves it → sole provider → default → keep — and stamps originNodeId.
  • Live-location guard: refuses agent_location_conflict (409) when the agent is active on another live node; dead/missing/implicit-direct locations are adopted (mirrors reconcileInventory).
  • The binding row, location/provider/origin move, old-binding retirement, and old-node slot refund commit as one atomic unit via runAtomicWrites — interactive transaction on Node, batch on D1, loud refusal otherwise. No failure prefix can leave a node charged for a retired binding or holding an unreserved active binding.

Evidence

  • 61 conformance tests in agentLifecycle.test.ts (+17 new): location move, provider-adoption table, live/dead/implicit guard boundaries, delivery push to the real provider connection, and a 2×4 failure matrix (transactional × D1-batch × each failure point) asserting nothing commits and retries complete cleanly.
  • Full engine suite: 1136 tests, 99 files — green. Typecheck + lint clean.
  • Regression-verified: reverting the atomic-writes call fails the injected-failure tests; pre-fix, provider-adoption tests fail.
  • Produced by a Relayflows run (fix-1794.flow.ts): implementer agent → deterministic verify → two fresh-context codex review rounds → fixer rounds, each finding addressed.

Known follow-ups (out of scope — systemic, pre-existing)

Recorded by the final review round; they exist in every reserveNodeAgentSlot→mutate→compensate caller (register, recover, inventory), not just this path:

  • Reservation commits before the atomic move; a failure of the compensating release itself can leave a node over/under-charged until reconciled.
  • Concurrent binds to different nodes can both pass the live-location check (TOCTOU without locking).

Test plan

  • npx vitest run src/__tests__/conformance/agentLifecycle.test.ts — 61 pass (Node 22; better-sqlite3 ABI)
  • npm run typecheck — clean
  • Full npx vitest run — 1136 pass
  • npm run lint — clean

Generated with Devin

Review in cubic


Note

High Risk
Changes core agent placement, provider routing, and multi-table bind atomicity; misbehavior would strand agents or corrupt node capacity counters, though behavior is heavily regression-tested.

Overview
Fixes fallback-bound agents (e.g. broker spawn after HTTP register) that never received pushes because bind only created a binding row while the agent stayed on its implicit direct-* node with provider_name: default.

Bind is now a full location move: bindAgentToNode updates location_node_id, stamps origin_node_id, adopts a live node provider via clear precedence rules, and returns delivery_ack_seq on the bind response (OpenAPI updated). It rejects agent_location_conflict (409) when the agent is active on another live node; stale, pruned, or implicit-direct locations can still be adopted.

Atomicity: binding insert, agent move, old-binding retirement, and old-node slot refund commit in one unit (runAtomicWrites / transaction); handles without atomic capability are refused. After the cursor-bearing HTTP response, completeBoundAgentDelivery runs in the background to mark cursor-aware providers delivery-ready and drain queued deliveries.

Conformance coverage adds bind routing, provider adoption, conflict boundaries, and failure-matrix atomicity tests; shared DB failure helpers move into the test harness.

Reviewed by Cursor Bugbot for commit c9c7f70. Bugbot is set up for automated code reviews on this repo. Configure here.

A broker-spawned agent is HTTP-registered first (provider "default",
implicit direct-* location), then the broker's create-only agent.register
fails and it falls back to POST /v1/nodes/:name/agents. upsertAgentNodeBinding
moved locationType/locationNodeId but providerName stayed "default", so
deliveries routed to the right node yet were pushed to a provider connection
that does not exist — the spawned agent was never woken
(AgentWorkforce/relay#1794).

bindAgentToNode now adopts a provider the node actually serves (served ->
sole -> "default" -> keep), stamps origin, and refuses to steal an agent
active on another live node. The binding row, location move, old-binding
retirement and old-node slot refund commit as one unit via runAtomicWrites
(interactive transaction, D1 batch, or loud refusal), so no failure prefix
can leave a node charged for a retired binding or holding an unreserved one.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: d9b3a1e4-7fc9-4452-bc92-1b113588bad0


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

...agentNodeBindingWrites(writeDb, workspaceId, agent, node.id, {
sessionRef: opts.session_ref ?? null,
priority: opts.priority ?? 0,
providerName: adoptedProviderName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Cursor-aware fallback binds stay gated

When bindAgentToNode adopts an agent onto a cursor-aware provider, it never adds that identity to the provider's ready set. The readiness initialization starts that set empty, so delivery remains queued until a later agent announcement.

Learn more

Cursor-aware providers negotiate per-agent delivery readiness during node.register. The registry then rejects live and replay delivery for every identity until markProviderAgentsDeliveryReady adds it. Successful agent.register, agent.recover, and inventory handling perform that transition, but this HTTP bind path has no registry dependency and only changes database routing. The fallback-bound agent therefore points at the correct provider socket but remains gated from using it.

Example: A broker provider advertises relay:delivery-cursor-v1, then an HTTP-created agent causes agent.register to return agent_already_exists. The broker calls the bind endpoint successfully. A message for that agent remains queued because (node_broker, broker, agent_id) is absent from the provider's ready set.

Recommended fix: Extend the bind flow to establish readiness through NodeConnectionRegistry only after the database move commits, or define a provider control handshake that announces the adopted identity and its authoritative cursor. Preserve the cursor contract: do not mark readiness before the provider has received the identity's cursor state.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/engine/src/engine/node.ts Outdated
Comment on lines +1462 to +1467
const nodeProviderNames = (await tx
.select({ name: nodeProviders.name })
.from(nodeProviders)
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, node.id))))
.map((row) => row.name);
const adoptedProviderName = adoptNodeProviderName(agent.providerName, nodeProviderNames);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale providers defeat provider adoption

When a node retains an offline provider row, adoptedProviderName can select it instead of the live provider. Provider disconnect preserves that row, so the moved agent becomes unreachable.

Learn more

Provider rows persist after a provider disconnect so the node can retain its capability manifest. This query treats those historical rows exactly like providers with live sockets. adoptNodeProviderName prefers the agent's current provider first, then the sole row, then default, so any of those choices can target an offline provider even while another provider keeps the node live.

Example: A node has an offline persisted default row and a live broker row. An HTTP-created agent currently has provider default. Binding keeps default, but only the broker socket exists, so deliveries cannot reach the agent.

Recommended fix: Select the provider liveness fields and run adoption against providers satisfying isProviderLive, or otherwise derive candidates from the registry's connected provider set. Keep historical rows for display, but exclude them from routing decisions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

…iders

Two gaps in the bind path left adopted agents unable to receive pushes:

- Cursor-aware (agent_scoped) providers gate deliveries per identity until
  it is announced. The bind moved the agent onto the provider but never
  marked it ready, so deliveries queued forever. The bind now marks the
  adopted identity delivery-ready after the move commits and drains its
  pending queue, and returns delivery_ack_seq so the caller learns the
  authoritative cursor — the same contract agent.register/recover carry.
- Provider adoption read every persisted provider row including offline
  ones kept for the capability manifest. An agent whose own provider had
  gone stale could be kept on (or moved to) a provider with no live socket.
  Adoption now considers only live providers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@AgentRelayBot

Copy link
Copy Markdown
Contributor Author

Both Devin Review flags addressed in de75a31:

  • Cursor-aware binds stay gated (🔴): bindAgentToNode now marks the adopted identity delivery-ready via NodeConnectionRegistry strictly after the move commits, then drains that agent's pending deliveries (scoped {providerName, agentIds}). The bind response also returns delivery_ack_seq so the caller learns the identity's authoritative cursor — the same contract agent.register/agent.recover replies carry. Covered by a test that queues a delivery pre-bind and asserts the frame reaches the provider socket post-bind.
  • Stale providers defeat adoption (🟡): adoption now selects liveness fields and filters through isProviderLive, so persisted-but-offline rows (kept for the capability manifest) are never adopted while a live sibling serves the node. Covered by a test with an offline default row + live broker provider.

Focused suite: 63 tests green; typecheck and lint clean.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit de75a31. Configure here.

Comment thread packages/engine/src/engine/node.ts Outdated
…onse

Cursor Bugbot: marking the adopted agent ready and draining its queue inside
bindAgentToNode let deliver frames reach the provider socket before the HTTP
response carrying delivery_ack_seq reached the caller. agent.register and
agent.recover order their cursor-bearing reply ahead of the ready mark and
replay; the bind path now does the same — bindAgentToNode returns the binding
plus the resolved move, the route sends the response first, then runs the
ready mark and scoped replay in the request background.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@AgentRelayBot
AgentRelayBot merged commit 4a7c2a7 into main Sep 19, 2026
9 checks passed
@AgentRelayBot
AgentRelayBot deleted the fix/1794-bind-agent-location branch September 19, 2026 02:59
khaliqgant pushed a commit that referenced this pull request Sep 19, 2026
…ct (#445)

* fix(engine): replay every session an inventory.sync certifies on reconnect

A node whose delivery socket dropped got nothing back after reconnecting:
messages fanned out during the outage stayed queued in the mailbox until
their TTL and the agents were never woken.

A cursor-negotiated `node.register` deliberately does not replay (PR #443:
no identity is cursor-ready yet), so `inventory.sync` is the node's only
reconnect-replay trigger. It scoped that replay to identities whose delivery
readiness or provider routing *changed* during the sync, which is empty
whenever the socket owner already reports the certified sessions as
delivery-ready — an owner whose ready-set is keyed per node+provider rather
than per connection, or a registry that omits the optional readiness hooks,
where the shared helper defaults to ready. The drain then ran with an empty
scope and returned 0. The in-process adapter resets its ready-set on every
new connection, which is why no in-tree test caught it.

Replay the full certified set on a cursor-negotiated connection instead,
matching `agent.register` / `agent.recover` and the documented contract that
an `inventory.sync` certifies sessions that retained their cursors and may
replay. Legacy immediate-delivery connections are unchanged: `node.register`
already flushed the node to them, so only newly routed identities replay and
the register-time flush is not duplicated. The handshake mode is recovered
from the engine's own registration record through a new
`providerAdvertisesDeliveryCursor()` helper, which also replaces the two
duplicated inline capability lookups in agent register/recover.

Dedupe, ordering and gating are unchanged: the cumulative delivery cursor
still suppresses acked rows, pages still drain oldest-first under a bounded
high-water mark, and every frame is still gated on per-identity delivery
readiness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(engine): take reconnect replay scope from the connection's negotiated mode

`inventory.sync` inferred the cursor handshake from the provider's persisted
capabilities, which `node.heartbeat` rewrites whenever a roster snapshot rides
along. A heartbeat that advertised only spawn capacity demoted a live
cursor-gated connection to the legacy branch, so a certification that had just
readied the identities replayed nothing and the outage backlog stayed queued
until its TTL; the inverse promoted an immediate connection and re-sent the
frames `node.register` had already flushed.

Recover the mode from the connection it was negotiated on: the registry now
exposes `providerDeliveryReadinessMode()`, the per-connection mode
`node.register` configures. Registries on the older contract fall back to the
persisted advertisement, which a heartbeat can no longer change — capabilities
negotiated at registration (`relay:delivery-cursor-v1`) are carried over a
heartbeat roster refresh instead of being replaced by it, so the fallback
answers for the registration rather than the latest snapshot. Either fix alone
holds the behaviour; out-of-process socket owners get the second for free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: flow review notes and trajectories for reconnect replay fix

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(engine): treat a stale connection's missing delivery mode as non-cursor

A superseded connection's queued inventory.sync could inherit the
replacement registration's persisted cursor advertisement: the registry
mode lookup returns undefined for a connection that no longer owns the
provider, but the fallback then read capabilities written by whichever
registration owns it now. With an out-of-process owner whose ready-set
survives reconnect, the stale certification replayed the outage backlog
to sessions the replacement never certified.

When providerDeliveryReadinessMode exists its undefined is the answer;
the persisted-capability fallback now only serves registries that cannot
answer at all.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: agentrelaybot <agentrelaybot@agentrelay.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.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.

Subscription recipients spawned via integration subscribe --spawn never wake: deliveries never injected

1 participant