Skip to content

@relaycast/sdk: registerOrRotate no longer rotates (409 on existing name); relay CLI still calls it - #512

Draft
agent-relay-code[bot] wants to merge 1 commit into
mainfrom
relayflow/relaycast-garden-relaycast-9af1606b
Draft

agent-relay-code[bot] wants to merge 1 commit into
mainfrom
relayflow/relaycast-garden-relaycast-9af1606b

Conversation

@agent-relay-code

@agent-relay-code agent-relay-code Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

@relaycast/sdk: make registerOrRotate's fail-closed conflict actionable, and stop silent dual-casing collisions

Context

The ticket reported that registerOrRotate "no longer rotates" in 9.1.1 and that a
caller built against SDK 8.0.7 breaks on upgrade, plus that register seemed to
"ignore" auto_join_general while only autoJoinGeneral worked.

Investigation on this branch found:

  • registerOrRotate's fail-closed behavior (409 name_conflict on an existing
    name, no silent token rotation) is already the intended, tested contract
    in this repo — src/__tests__/strict-identity.test.ts locks it in explicitly
    ("is a fail-closed compatibility alias on name conflict", "fails closed
    without a suffixed-name retry on conflict"). Silently rotating/taking over an
    existing agent name on conflict is exactly the identity-spoofing hole the
    strict-identity model (register, recover, takeOver, revokeToken) was
    built to close. Reverting to silent rotation would reintroduce that hole and
    contradict the committed test suite, so that part of the ticket is not
    actionable as "restore rotation" — the other option the ticket offered,
    "migrate callers," is the right direction.
  • The auto_join_general / autoJoinGeneral claim doesn't reproduce as a
    simple "snake_case is ignored" bug — decamelizeKeys() already converts
    either casing to the correct wire key (auto_join_general) on its own,
    verified with a throwaway probe test against the real request path. What
    does reproduce is a silent collision: if a caller's options object ends up
    with both autoJoinGeneral and auto_join_general (e.g. from merging a
    legacy snake_case config with the typed camelCase field), whichever key
    enumerates last in the object silently wins — so depending on construction
    order, it looks like "only autoJoinGeneral works" or the opposite. Per
    CLAUDE.md's "do not introduce mixed-case field fallbacks," the fix is to
    flag this loudly rather than add precedence/alias rules.

Changes

  • src/casing.ts: decamelizeKeys() now throws a clear error
    (Ambiguous request fields "..." and "..." both map to "...") when two
    differently-cased keys in the same object would collide into one wire field,
    instead of letting object key order decide silently.
  • src/relay.ts: registerOrRotate() still fails closed exactly as before
    (same code/retryable/statusCode), but on a name_conflict it now
    raises a message that names agents.recover() as the real replacement,
    instead of just repeating the server's generic "already exists" message.
    This is the actionable form of "migrate callers" available from inside this
    repo — the actual external caller (AgentWorkforce/relay's
    agent-relay-mcp.ts) isn't part of this repository.
  • CHANGELOG.md (sdk-typescript): documented both fixes under [Unreleased].

Tests

  • src/__tests__/casing.test.ts: new regression tests for the dual-casing
    collision (both key orders) and confirmation that either casing alone still
    works.
  • src/__tests__/strict-identity.test.ts: extended the existing
    registerOrRotate conflict test to assert code/retryable/statusCode
    are unchanged and the message now mentions agents.recover(); the
    non-conflict rethrow test now also pins the message is passed through
    verbatim.

Ran the full sdk-typescript, mcp, openclaw, and react package test
suites (all depend on this SDK) plus tsc --noEmit for sdk-typescript and
mcp — all green.

Checks

The checks failed, and there was no time left in the run to check the base commit (base not checked), so it is not known whether this change caused them. This pull request is a draft until someone looks.

What ran (.relayflow/check.sh)
#!/bin/sh
# Mirrors the "ci" job in .github/workflows/ci.yml (the PR gate) as closely
# as a single machine without docker/cargo allows.
set -e

cd "$(dirname "$0")/.."

npm ci

npx turbo build

npm run test:engine:regression
npm run test:release
npx turbo lint
DO_NOT_TRACK=1 npx turbo test
npm run test:container

# Skipped, with reasons:
#
# - "container" job (ci.yml): builds and smoke-tests the real Docker image
#   for linux/amd64 and linux/arm64. This machine has no `docker` CLI.
#
# - test:container-image (test/container-image-integration.test.mjs, also
#   run by .github/workflows/container-integration.yml): builds the real
#   Dockerfile and drives it as a running container. Also requires `docker`;
#   the test file already self-skips (not fails) when docker is unavailable,
#   so it is left out here rather than invoked for a guaranteed skip.
#
# - "rust-sdk" job (ci.yml): cargo build/test/clippy for packages/sdk-rust.
#   This machine has no `cargo` toolchain.
#
# - npm run e2e (AGENTS.md "Core Commands"): smoke-tests a running engine
#   server (defaults to http://localhost:8787); not part of ci.yml and
#   requires a live service this script does not start.
#
# - .github/workflows/deploy.yml, preview.yml, preview-env-audit.yml,
#   publish-*.yml, repair-npm-release.yml: deployment/publish workflows that
#   need cloud credentials (npm/crates.io/PyPI tokens, Cloudflare, etc.) and
#   are not part of the PR check gate.
Output on this branch (last 80 lines)
@relaycast/engine:test:   workspace_id: �[32m'ws-1'�[39m,
@relaycast/engine:test:   event_type: �[32m'action.completed'�[39m,
@relaycast/engine:test:   error: �[32m'skip durable outbox in test'�[39m
@relaycast/engine:test: }
@relaycast/engine:test: 
@relaycast/engine:test:  �[32m✓�[39m src/engine/__tests__/invocationCompletion.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[32m 18�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/adapters/node/__tests__/a2a-recovery.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[32m 198�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/lib/__tests__/settlePool.test.ts �[2m(�[22m�[2m8 tests�[22m�[2m)�[22m�[32m 124�[2mms�[22m�[39m
@relaycast/engine:test: �[90mstdout�[2m | src/lib/__tests__/logger.test.ts�[2m > �[22m�[2mengine logger PostHog export�[2m > �[22m�[2mexports logs through the hosted ingestion proxy by default
@relaycast/engine:test: �[22m�[39m[test] hello {
@relaycast/engine:test:   app_version: �[32m'1.2.3'�[39m,
@relaycast/engine:test:   sdk_version: �[32m'unknown'�[39m,
@relaycast/engine:test:   environment: �[32m'test'�[39m,
@relaycast/engine:test:   source: �[32m'test'�[39m
@relaycast/engine:test: }
@relaycast/engine:test: 
@relaycast/engine:test: �[90mstdout�[2m | src/lib/__tests__/logger.test.ts�[2m > �[22m�[2mengine logger PostHog export�[2m > �[22m�[2mallows a custom PostHog host override
@relaycast/engine:test: �[22m�[39m[test] hello {
@relaycast/engine:test:   app_version: �[32m'0.1.0'�[39m,
@relaycast/engine:test:   sdk_version: �[32m'unknown'�[39m,
@relaycast/engine:test:   environment: �[32m'test'�[39m,
@relaycast/engine:test:   source: �[32m'test'�[39m
@relaycast/engine:test: }
@relaycast/engine:test: 
@relaycast/engine:test:  �[32m✓�[39m src/lib/__tests__/logger.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[32m 10�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/__tests__/conformance/triggerDispatchLog.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[32m 213�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/routes/__tests__/deliveryExpirySweep.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[32m 8�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/__tests__/conformance/workspaceTelemetry.test.ts �[2m(�[22m�[2m4 tests�[22m�[2m)�[22m�[33m 510�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/__tests__/observerEventExport.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[32m 4�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/__tests__/conformance/nodeLiveness.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[32m 4�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/lib/__tests__/crypto.test.ts �[2m(�[22m�[2m3 tests�[22m�[2m)�[22m�[32m 9�[2mms�[22m�[39m
@relaycast/engine:test:  �[32m✓�[39m src/__tests__/conformance/smoke.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[33m 301�[2mms�[22m�[39m
@relaycast/engine:test: 
@relaycast/engine:test: �[31m⎯⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Tests 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m
@relaycast/engine:test: 
@relaycast/engine:test: �[41m�[1m FAIL �[22m�[49m src/adapters/node/__tests__/event-queue.test.ts�[2m > �[22mDurableEventQueue�[2m > �[22msettles a due delivery when its subscription becomes inactive
@relaycast/engine:test: �[31m�[1mAssertionError�[22m: expected { …(10) } to match object { status: 'failed', attempts: 1, …(1) }
@relaycast/engine:test: (7 matching properties omitted from actual)�[39m
@relaycast/engine:test: 
@relaycast/engine:test: �[32m- Expected�[39m
@relaycast/engine:test: �[31m+ Received�[39m
@relaycast/engine:test: 
@relaycast/engine:test: �[2m  {�[22m
@relaycast/engine:test: �[32m-   "attempts": 1,�[39m
@relaycast/engine:test: �[31m+   "attempts": 0,�[39m
@relaycast/engine:test: �[2m    "lastError": "subscription inactive or no longer matches this event",�[22m
@relaycast/engine:test: �[2m    "status": "failed",�[22m
@relaycast/engine:test: �[2m  }�[22m
@relaycast/engine:test: 
@relaycast/engine:test: �[36m �[2m❯�[22m src/adapters/node/__tests__/event-queue.test.ts:�[2m421:22�[22m�[39m
@relaycast/engine:test:     �[90m419|�[39m
@relaycast/engine:test:     �[90m420|�[39m     �[35mconst�[39m [delivery] �[33m=�[39m �[35mawait�[39m db�[33m.�[39m�[34mselect�[39m()�[33m.�[39m�[35mfrom�[39m(webhookDeliveries)�[33m;�[39m
@relaycast/engine:test:     �[90m421|�[39m     �[34mexpect�[39m(delivery)�[33m.�[39m�[34mtoMatchObject�[39m({
@relaycast/engine:test:     �[90m   |�[39m                      �[31m^�[39m
@relaycast/engine:test:     �[90m422|�[39m       status�[33m:�[39m �[32m'failed'�[39m�[33m,�[39m
@relaycast/engine:test:     �[90m423|�[39m       attempts�[33m:�[39m �[34m1�[39m�[33m,�[39m
@relaycast/engine:test: 
@relaycast/engine:test: �[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m
@relaycast/engine:test: 
@relaycast/engine:test: 
@relaycast/engine:test: �[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[2m | �[22m�[1m�[32m115 passed�[39m�[22m�[90m (116)�[39m
@relaycast/engine:test: �[2m      Tests �[22m �[1m�[31m1 failed�[39m�[22m�[2m | �[22m�[1m�[32m1365 passed�[39m�[22m�[90m (1366)�[39m
@relaycast/engine:test: �[2m   Start at �[22m 10:09:50
@relaycast/engine:test: �[2m   Duration �[22m 101.93s�[2m (transform 14.07s, setup 0ms, import 101.49s, tests 180.10s, environment 14ms)�[22m
@relaycast/engine:test: 
@relaycast/engine:test: npm error Lifecycle script `test` failed with error:
@relaycast/engine:test: npm error code 1
@relaycast/engine:test: npm error path /home/user/.relayflow-v2-supervisor/durable/repository/packages/engine
@relaycast/engine:test: npm error workspace @relaycast/engine@9.2.0
@relaycast/engine:test: npm error location /home/user/.relayflow-v2-supervisor/durable/repository/packages/engine
@relaycast/engine:test: npm error command failed
@relaycast/engine:test: npm error command sh -c vitest run
@relaycast/engine#test:  ERROR  command (/home/user/.relayflow-v2-supervisor/durable/repository/packages/engine) /usr/local/bin/npm run test exited (1)

 Tasks:    17 successful, 18 total
Cached:    9 cached, 18 total
  Time:    1m44.029s 
Failed:    @relaycast/engine#test

 ERROR  run failed: command  exited (1)

Fixes #493


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

View guided diff

…ag mixed-case field collisions

registerOrRotate's fail-closed behavior on a name conflict is intentional
(strict-identity hardening against silent takeover) and stays as-is, but the
thrown error now names agents.recover() as the replacement instead of just
repeating the server's generic "already exists" message, so callers
upgrading from the old rotating behavior have a clear migration path.

decamelizeKeys() now throws when a request object mixes both casings of the
same field (e.g. autoJoinGeneral and auto_join_general) instead of letting
whichever key enumerates last silently win.

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

coderabbitai Bot commented Oct 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 837a990e-caaa-4f4b-9570-d2f36bdb7c86

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@agent-relay-code

Copy link
Copy Markdown
Contributor Author

Relayflow: the adversarial review did not pass. This branch is not approved: the flow stopped here and did not mark it ready to merge.

Review: PR #512 — registerOrRotate fail-closed message + mixed-casing collision guard

Scope reviewed: PR diff (packages/sdk-typescript/src/casing.ts, src/relay.ts,
src/__tests__/casing.test.ts, src/__tests__/strict-identity.test.ts,
CHANGELOG.md), the PR description, and all PR comments (only an automated
CodeRabbit "review skipped" comment exists — no human or inline review
comments to address). Verified by running the full sdk-typescript test
suite (490/490 passing) and tsc --noEmit (clean).

Summary

The PR's framing is sound: the ticket's "restore rotation" ask is correctly
rejected (it's intentionally fail-closed per the existing
strict-identity.test.ts contract), and the dual-casing bug is correctly
root-caused as a silent-collision issue rather than a one-way snake_case bug.
Both fixes are small, targeted, and covered by new regression tests that
pass. One real (if narrow) bug remains in the error-rewrapping code.

Findings

1. registerOrRotate's rewrapped error silently drops retryAfterMs

src/relay.ts, the new catch block:

throw new RelayError(
  err.code,
  `${err.message} ...`,
  { statusCode: err.statusCode, retryable: err.retryable, rawCode: err.rawCode, cause: err },
);

RelayErrorOptions also has retryAfterMs ("authoritative delay supplied by
the server, when present"), and client.ts populates it generically from the
response's Retry-After header for any error code, not just
rate_limited/backpressure — see client.ts:356
(relayErrorFromApi(code, message, res.status, parseRetryAfterMs(res) ?? undefined)).
If a server ever attaches Retry-After to a 409 name_conflict response (a
plausible backoff hint — "someone else is registering this name, retry
later"), the original err.retryAfterMs is silently discarded when
registerOrRotate rewraps the error, even though every other field
(statusCode, retryable, rawCode, plus cause for the chain) is
deliberately preserved. A caller inspecting retryAfterMs on the thrown
error to pace a retry would see undefined instead of the server's actual
value.

Fix: add retryAfterMs: err.retryAfterMs to the rewrap options, consistent
with the other preserved fields.

This is low severity in practice (name-conflict responses are marked
non-retryable, so no production code path currently reads retryAfterMs off
this particular error), but it's a real behavioral gap relative to the stated
intent of preserving the original error's shape, and it's a one-line fix.

Non-issues considered and ruled out

  • Dead-looking guard in casing.ts: existingSourceKey !== key in the
    collision check is always true whenever existingSourceKey !== undefined
    (a plain object's Object.entries can't yield the same key twice), so the
    check is effectively just existingSourceKey !== undefined. Harmless and
    arguably more explicit about intent, not a correctness bug — not flagged as
    a finding.
  • **registerOrGet doesn't get the new "use agents.recover()" message**: agents.registerOrGet(a separate, older deprecated alias) callsthis.agents.register(data)directly and bypassesregisterOrRotate's new catch block entirely, so a name conflict through registerOrGetstill gets the generic server message. This predates this PR (untouched by the diff) and the ticket's repro is specifically aboutregisterOrRotate (agent-relay-mcp.ts` calls that method), so it's out of scope here, not a
    regression introduced by this change.
  • Throwing a plain Error (not RelayError) from decamelizeKeys:
    consistent with existing client-side input-validation throws elsewhere in
    this SDK (relay.ts: "RelayCast apiKey is required",
    "idempotencyKey is required for exact agent release", etc.), so not an
    inconsistency.
  • CI failure noted in the PR description (event-queue.test.ts in
    packages/engine, a timing-sensitive durable-queue assertion): the diff
    touches only packages/sdk-typescript; that test file was last changed by
    the unrelated chore(release): v9.2.0 commit, not this PR. Unrelated to
    this change.
  • No inline PR review comments exist (gh api .../pulls/512/comments
    returned []); the only PR comment is CodeRabbit's automated "review
    skipped — bot user detected" notice, which requires no action.

Verdict

One real but low-severity/low-likelihood bug (missing retryAfterMs in the
rewrapped error). Not creating review.clean until it's addressed or
explicitly waived.

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.

@relaycast/sdk: registerOrRotate no longer rotates (409 on existing name); relay CLI still calls it

0 participants