Skip to content

fix(placement): single-flight the placement requester registration#420

Merged
khaliqgant merged 1 commit into
mainfrom
fix/placement-requester-single-flight
Jul 18, 2026
Merged

fix(placement): single-flight the placement requester registration#420
khaliqgant merged 1 commit into
mainfrom
fix/placement-requester-single-flight

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jul 18, 2026

Copy link
Copy Markdown
Member

Fixes the operator-reported RelayError: Invalid agent token on "Any available node" spawn, first try after a fresh Pear launch.

Root cause — confirmed from source, not inferred

Relaycast stores exactly one token_hash per agent row and authenticates by looking the agent up on that column:

  • relaycast/packages/engine/src/db/schema.ts:63tokenHash: text('token_hash').notNull().unique()
  • relaycast/packages/engine/src/auth/index.ts:51-53 — no matching row → unauthorized('Invalid agent token', 'agent_token_invalid')

Only two writers exist: createAgent (plain INSERT, 409s on an existing name) and rotateAgentToken (the sole overwriter, request-scoped). So registerOrRotate = register → 409 → rotate, and a second registration of a name overwrites the first caller's token rather than adding one. There is no expiry check anywhere in that path — a token dies only when something re-registers its name.

getPlacementRelay cached the resolved client at broker.ts:2595, two awaits after the cache check at :2571, keyed on the deterministic identity pear-requester-<projectId>. Concurrent callers — the spawn dialog's listNodes effect racing placeAgent — all missed the cache and each registered that one identity. Last write wins; earlier callers hold a freshly-minted, already-superseded token → 401 on the first placement.

This corrects two premises in the original diagnosis: the failure is supersession, not expiry (so re-mint-on-401 would not have helped), and the 401 originates in the relaycast repo — relay/crates/broker/src/relaycast/auth.rs:953, previously cited as the source, is a string inside mod tests.

Fix

Cache the in-flight promise rather than the resolved client, so N concurrent callers await one registration and exactly one token is minted per session. Evict on failure so a transient registration error can't poison placement for the session's lifetime, comparing identity before evicting so a late rejection can't clear a newer healthy entry.

Verification

Reproduced RED-first before the fix — three concurrent listNodes calls produced three registrations against one identity:

expected: [ "pear-requester-project-1" ]
received: [ "pear-requester-project-1", "pear-requester-project-1", "pear-requester-project-1" ]

Both tests are wired into the suite as regressions:

  • registers the placement requester once under concurrent callers
  • evicts a failed registration without re-registering per caller, and the next caller retries cleanly

broker.test.ts 109/109 pass, full suite with placement-helpers.test.ts 120/120, typecheck:node clean.

Note: this reproduces with plain concurrency and no StrictMode double-invoke, so the defect is real in packaged builds — the dev-vs-packaged question affects only how easily it fires, not whether.

Scope

getPlacementRelay + its tests. No behavior change beyond the dedupe. dropSession needs no change (already sets undefined, still correct for a promise).

🤖 Generated with Claude Code

Review in cubic

)

"Any available node" spawn failed with `RelayError: Invalid agent token`
on the first try after a fresh Pear launch.

Relaycast stores exactly one `token_hash` per agent row
(relaycast packages/engine/src/db/schema.ts:63) and authenticates by
looking an agent up on that column (packages/engine/src/auth/index.ts:51).
`registerOrRotate` is register -> 409 -> rotate, so a second registration
of a name OVERWRITES the first caller's token rather than adding one.
There is no expiry check anywhere in that path: a token dies only when
something re-registers its name.

`getPlacementRelay` cached the RESOLVED client after two awaits, keyed on
the deterministic identity `pear-requester-<projectId>`. Concurrent
callers (the spawn dialog's listNodes effect racing placeAgent) all
missed the cache and each registered that one identity; the last write
won and the earlier callers were left holding a freshly-minted,
already-superseded token -> 401 on the first placement.

Cache the in-flight promise instead, so N concurrent callers await a
single registration and exactly one token is minted per session. Evict
on failure so a transient registration error doesn't poison placement
for the session's lifetime, and compare identity before evicting so a
late rejection can't clear a newer healthy entry.

Reproduced RED-first: three concurrent listNodes calls produced three
registrations against one identity. Needs no StrictMode double-invoke —
plain concurrency is sufficient, so the defect is real in packaged
builds too.

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

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 25b0030c-2522-4f14-8cba-0c3f08896108

📥 Commits

Reviewing files that changed from the base of the PR and between 14a8b54 and 9503cd1.

📒 Files selected for processing (2)
  • src/main/broker.test.ts
  • src/main/broker.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/placement-requester-single-flight

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request resolves a race condition where concurrent placement callers would register the same deterministic identity and rotate each other's tokens, leading to 401 errors. It addresses this by caching the in-flight Promise<AgentRelay> instead of the resolved client, ensuring a single-flight registration, and evicting the cache on failure. Unit tests have been added to verify this behavior. The review feedback suggests avoiding a potential linter warning by not referencing a variable within its own initializer, and using setImmediate instead of setTimeout in the test mock to yield to the event loop more robustly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/broker.ts
Comment on lines +2581 to +2586
const pending = this.buildPlacementRelay(session).catch((error: unknown) => {
if (session.placementRelay === pending) session.placementRelay = undefined
throw error
})
session.placementRelay = pending
return pending

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Referencing pending inside its own initializer/declaration can trigger static analysis or linter warnings (such as no-use-before-define). We can avoid this by assigning session.placementRelay first, and then attaching a .catch handler to the promise for the side effect of clearing the cache on failure. Since .catch returns a new promise but does not mutate the original, the returned pending promise will still reject and propagate the error to the callers as expected.

    const pending = this.buildPlacementRelay(session)
    session.placementRelay = pending
    pending.catch(() => {
      if (session.placementRelay === pending) session.placementRelay = undefined
    })
    return pending

Comment thread src/main/broker.test.ts
placementSdkMock.registerCalls.push(input.name)
// Await a macrotask so concurrent callers overlap here — this is the
// window between getPlacementRelay's cache check and its cache write.
await new Promise((resolve) => setTimeout(resolve, 0))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using setTimeout with a delay of 0 to yield to the event loop can be fragile in test environments if fake timers are enabled (either globally or in other tests within the suite). Using setImmediate is more robust and standard in Node.js test environments to schedule a macrotask without relying on timer mocks.

Suggested change
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setImmediate(resolve))

@khaliqgant
khaliqgant marked this pull request as ready for review July 18, 2026 14:07
@khaliqgant
khaliqgant merged commit 89480e1 into main Jul 18, 2026
4 checks passed
@cursor

cursor Bot commented Jul 18, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@khaliqgant
khaliqgant deleted the fix/placement-requester-single-flight branch July 18, 2026 14:07
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