Skip to content

fix(fleet): accept an unnamed placement instead of killing the worker - #407

Merged
miyaontherelay merged 2 commits into
mainfrom
fix/placement-accept-unnamed-node
Aug 27, 2026
Merged

fix(fleet): accept an unnamed placement instead of killing the worker#407
miyaontherelay merged 2 commits into
mainfrom
fix/placement-accept-unnamed-node

Conversation

@miyaontherelay

Copy link
Copy Markdown
Contributor

The bug

@agent-relay/sdk 11.8.5 (relay#1619) made RelaySpawnPlacementAck.node and placement.node optional — verified by diffing the published tarballs 11.8.4 → 11.8.5. The SDK documents the latter as "absent when acknowledgment metadata is missing or not yet visible". That was deliberate and correct: #1619 exists so an accepted placement never becomes a failure just because the roster could not be read.

Factory had not caught up. spawn() passes only ack.placement?.node into assertNamedRemotePlacement — no fallback at that call site — and the guard was:

if (!node || node === 'self') { throw ... }

so an absent name was treated identically to an explicit self-placement. The catch does not merely reject:

await this.release(result.name, 'unverified-placement')

It tears the worker down. factory.ts:13860 sets requireNode whenever placementLocality === 'remote', which is the production configuration. So a successful remote spawn whose roster metadata was not yet visible would cause Factory to kill the agent it had just launched — reproducing the dispatch outage under a new stack trace, and looking exactly like "the placement fix did not work".

Before #1619 this was safe: placement.node was required, so !node could only mean something had gone wrong. #1619 removed the only non-nullable link in that chain (dispatchedNodeId was already string | null in 11.8.4).

The fix

Split the two cases that had been sharing one condition:

acknowledgement before after
named remote node accept accept (unchanged)
absent / empty throw + release worker accept, retain worker, claim no node name
explicit 'self' throw + release worker throw + release worker (unchanged)

'self' is a positive assertion that the work did not go remote and must still fail closed. "No name yet" is not that.

On not inheriting the output-derived node. spawnResultFromInvocation builds a node from action output, and the existing comment explains why that is untrusted: "Action output may name the node executing the spawn handler even when Relay acknowledged self". The old code deliberately discarded it in favour of the authoritative acknowledgement. So when the acknowledgement is silent, this strips the name rather than falling back to the guess — otherwise "accepted but unidentified" would launder an unverified name into a trusted result. An unidentified placement is tracked without a node; roster reconciliation attributes it later.

Also narrows dispatchedNodeId (string | null) to undefined at the preview call site, so a null cannot masquerade as a node name.

Ablation

GREEN  (full change)      Tests  89 passed (89)
ABLATE (source reverted,  Tests   3 failed | 86 passed (89)
        tests kept)

The three failures are exactly the three new acceptance tests, and they fail for the predicted reason:

FAIL > accepts an unnamed placement (an absent node) without releasing the worker
Error: Relay placement did not prove a named remote node for ar-1-impl; refusing to accept the spawn result
 ❯ assertNamedRemotePlacement src/fleet/relay-fleet-client.ts:1699:11

All 8 self-guard tests still PASS on reverted source, which is what proves the self guard was not weakened in the process. The error message is deliberately left unchanged so the ablation isolates behaviour rather than a string rename.

Checks

  • Build gate tsconfig.build.json: 0 errors.
  • tsconfig.json: 207 errors on this branch, 207 on unmodified main — no delta, none in the changed file. (Note: the "14 pre-existing errors" figure circulating yesterday was wrong against both configs.)
  • src/cli/fleet.test.ts: 1 failed | 169 passed — the failure is keeps relay dispatch ownership until the remote PR is published and the issue is parked (5037 ms timeout), which reproduces identically on unmodified origin/main. Pre-existing flake, not from this change.

Note on the changed test

The previous it.each asserted that self, empty, and absent all "fail closed". That was right under the old required-field contract and is wrong under the new one. It is split rather than deleted: self keeps its assertion verbatim, and empty/absent become explicit acceptance cases with a comment recording why the contract changed.

Context

This is the last blocker before a Factory release that also carries #402 (the candidates: 0 fix) and the complete gh → GitHub App migration (#396#401), all currently unreleased on main. No dep bump is needed: ^11.6.9 already resolves to 11.8.5.

@agent-relay/sdk 11.8.5 (relay#1619) made `RelaySpawnPlacementAck.node` and
`placement.node` OPTIONAL, documenting the latter as "absent when
acknowledgment metadata is missing or not yet visible". That was deliberate:
an accepted placement must not become a failure merely because the roster
could not be read.

Factory had not caught up. `spawn()` passed only `ack.placement?.node` into
`assertNamedRemotePlacement`, whose `if (!node || node === 'self')` treated an
absent name identically to an explicit self-placement — and the catch does not
merely reject, it calls `release(name, 'unverified-placement')`. Under
`requireNode` (set whenever placementLocality === 'remote', i.e. production),
a successful remote spawn whose roster metadata was not yet visible would
therefore TEAR DOWN the worker it had just launched.

Split the two cases:
  - absent/empty name -> accepted, worker retained, no node name claimed
  - explicit 'self'   -> still refused, worker still released

When the acknowledgement carries no name, the output-derived node is stripped
rather than inherited. Action output can name the node running the spawn
handler, which is exactly the untrusted source the `self` guard exists to
defeat; letting an unnamed placement adopt it would launder an unverified name
into a trusted result. An accepted-but-unidentified placement is tracked
without a node.

Also narrows `dispatchedNodeId` (`string | null`) to `undefined` at the preview
call site rather than letting a null masquerade as a node name.

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
@coderabbitai

coderabbitai Bot commented Aug 27, 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: Pro Plus

Run ID: 81ca9bce-4048-4224-842a-c4a97587156f


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.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 3fa3f04ecd807565843bb6bce75635dcbb3e02c3.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 2 files

You’re at about 91% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/fleet/relay-fleet-client.ts
Review raised a P1: that a worker tracked without a node would be skipped by
the registration probe and torn down after the registration timeout. It is not.

The liveness probe keys on AGENT NAME (`onlineAgentNames.has(name)`), so an
unnamed worker is checked exactly like a named one. The `!entry.node` skip
bypasses only the node-offline check, which is meaningless without a node name
and is safer skipped than guessed. Nothing on that path releases: after the
grace an absent agent reaches `#emitExit`, which deletes tracking and notifies
listeners.

Two tests rather than an argument:
  - an unnamed worker that stays online survives both grace windows, is never
    exited and never released;
  - an unnamed worker that actually leaves the roster still exits, so skipping
    the node check does not make it immortal.

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 679aaa04591771997e4fd7afc6489d8f027744a9.

@miyaontherelay
miyaontherelay merged commit d0d510c into main Aug 27, 2026
12 of 13 checks passed
@miyaontherelay
miyaontherelay deleted the fix/placement-accept-unnamed-node branch August 27, 2026 16:48
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.

2 participants