Skip to content

Fleet placement requester: spawn an agent on any available node (#411)#414

Merged
khaliqgant merged 5 commits into
mainfrom
feat/placement-requester
Jul 17, 2026
Merged

Fleet placement requester: spawn an agent on any available node (#411)#414
khaliqgant merged 5 commits into
mainfrom
feat/placement-requester

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jul 17, 2026

Copy link
Copy Markdown
Member

Closes #411 (requester side). Likely closes #126 into this — lead's call (this is the generalized form of "spawn/connect to a broker on any machine": placement is the connection/UI layer, generalized from a specific host to any eligible node).

What this adds

Pear was already a fleet node (serves spawn:<cli>) but never asked the relay placement engine to place an agent for itself. This wires the requester side:

  • BrokerManager.placeAgent() — dispatches a spawn onto an eligible fleet node via messaging.placement.spawn, using a dedicated agent-scoped pear-requester-<projectId> identity (placement invoke needs agent scope; workspace-key alone is read-only). Maps all four RelayPlacementError codes to clear messages; any-node placement fails fast so "no eligible node" is an instant message, never a silent hang.
  • BrokerManager.listNodes() — fleet-node roster for the picker (name/live/load/caps, isSelf).
  • IPC broker:place-agent (structured outcome — expected placement failures return data, don't throw) + broker:list-nodes; preload + PearAPI + mock parity.
  • Spawn dialog "Run on" selector — "This Mac" (default, proven direct path), "Any available node (may include this Mac)", and live remote nodes. Remote landings show a chat-first notice and never open a broken terminal pane.

No execUrl/box path is used or added. Helpers live in an electron-free placement.ts so the E2E harness can import them.

Acceptance → evidence (Rung-1 gate, tests/placement/rung1-cross-node.mts, 9/9)

Real claude, two isolated brokers sharing one workspace, agent placed on node B:

Acceptance Evidence
#1 lands on a different node (not local fallback) ack.node==B, handlerNodeId set, invocation completed
#2a placed agent reachable over relay (chat) placed agent in relay roster + relay accepts/routes a DM (msgId returned) — delivered
#2b terminal I/O "identically to a local agent" Upstream gap — B owns the PTY (HTTP 200), requester broker A does NOT (HTTP 404). Raw PTY has no relay transport today (no producers for terminal.output/terminal.screen). Filed AgentWorkforce/relay#1327. Remote agents get the chat-first surface until it lands.
#3 node death → reschedule or clear error targeted placement after B death → clear placement_ttl_expired "No node advertises claude right now." (documented; no silent hang)
#4 no eligible node → clear message fails fast in 363ms: "No node advertises … right now."
error matrix capability_mismatch (targeted node lacking cap) verified

Cleanup verified after every run; the live dev broker (instance pear, port 3889) was never touched.

Isolation finding (for the record)

The hermetic local-workspace variant (cloud-auth-invisible brokers self-provisioning a local workspace) was attempted and does not hold: a spawned broker resolves cloud auth from outside $HOME and joins the operator's default workspace, where the live pear node is eligible. A mandatory failsafe was added — the gate refuses a no-host placement whenever any foreign spawn:claude node is present (so a real spawn can never land on a live broker) and degrades to safe targeted placement on B. The no-host least-loaded selection is therefore proven deterministically only in a dedicated workspace; the cross-node mechanics are proven via targeted placement here. (Also: a bare v10 broker advertises spawn:*, so even a clean two-broker workspace makes no-host nondeterministic between requester and target.)

Product note

"Any available node" includes the user's own Mac (usually least-loaded → often resolves local); the option is labelled accordingly.

Rung-2 — real Mac minis over SSH (tests/placement/rung2/)

Enrolls each mini as an additive, isolated fleet node into the live operator workspace (the workspace the running Pear app's broker pear is in), then places a real agent from this Mac targeted at that mini and proves it lands + is reachable. Deliverables:

  • enroll-mini.sh — idempotent, additive-only enrollment run over ssh -o BatchMode=yes: enumerates existing brokers first and never touches them; unique instance pear-fleet-<host>, non-default port (AGENT_RELAY_BROKER_PORT), dedicated --state-dir; workspace-key join via env, never argv (kept out of ps); before/after state capture; documented, state-dir-scoped teardown (node down --state-dir …, never --all).
  • place-from-mac.mts — requester exactly as BrokerManager.placeAgent (workspace key pulled from the live pear broker /api/session, agent-scoped pear-requester), placing targeted at the mini node.

Both minis — PROVEN (all core proofs green on finn AND sf)

Enrolled pear-fleet-finn (10.6.3 headless) and pear-fleet-sf (10.6.2 headless), each on a non-default port with a dedicated state dir, joined the operator workspace, advertising spawn:{claude,codex,gemini,opencode}. Existing brokers/agents on both minis left untouched (finn: ~50 codex agents + 4 brokers on :3889–3892; sf: sandbox-swap:3889 + sf-mini:3890).

Proof (targeted node:pear-fleet-<host>, spawn:codex) finn sf
target mini advertises spawn:codex in operator ws
workspace-scope guard: foreign nodes present → targeted placement (never no-host)
targeted placement lands on the mini (ack.node == the mini)
mini owns the placed PTY (its broker snapshot 200/non-empty) ✅ 124 B ✅ 124 B
requester's live pear broker does not own the PTY (#2b, cross-machine) ✅ 404 ✅ 404
placed agent reachable over relay chat (#2a: roster + accepted delivery) + BONUS full LLM round-trip via PTY + BONUS full LLM round-trip via PTY
placed agent released — mini broker no longer runs it (no live leak) agent_not_found agent_not_found

The #2a round-trip is stronger than Rung-1: both minis' codex is authenticated, so the placed agent actually surfaced the probe token in its PTY (not just roster+accept). #2b is reconfirmed cross-machine on both: the mini owns the terminal, the requester's live pear broker 404s it. After release, each mini's broker reports agent_not_found for the placed agent (no live leak). Workspace-roster de-registration (relay.agents.delete) is best-effort: sf's row deleted cleanly; finn's row hit a backend FK constraint (Failed query: delete from "agents") and lingered as a stopped roster record — a backend bookkeeping quirk, not a running-agent leak or a placement defect.

Version-skew findings

  • finn headless (non-interactive SSH) resolves the self-contained installer binary ~/.agentworkforce/relay/bin/agent-relay = 10.6.3 — matches the requester, no skew. (/opt/homebrew/bin/agent-relay = 10.6.0 is a node-script that only runs in a login shell; mise shims are dead — no global version set. Documented in the script's binary-detection.)
  • sf headless resolves the mise shim = 10.6.2 (minor skew vs 10.6.3).

sf-mini — incident + mandatory failsafe #2 (this is why the enroll script is what it is)

The first sf enroll attempt killed sf's live production brokers. Root cause (filed upstream: AgentWorkforce/relay#1328): agent-relay node up runs killOrphanedBrokerProcesses(projectRoot), which terminates every broker whose CWD == projectRoot; findProjectRoot() walks up for markers including .agentworkforce/relay, so a markerless workdir under $HOME resolves projectRoot=$HOME and reaps every $HOME-rooted broker. Unique instance/port/state-dir do not isolate this — it keys off project root.

Fix (shipped in enroll-mini.sh), two layers:

  1. Pin AGENT_RELAY_PROJECT to the unique per-instance dir (findProjectRoot honors it first) + drop a marker so the walk-up stops there even if the env is stripped → projectRoot can never be $HOME.
  2. Hard preflight reaper guard (mandatory failsafe Fix dev build: externalize ssh2 native deps #2, alongside the workspace guard): refuses to start node up unless the effective projectRoot is strictly inside the dedicated sandbox — never $HOME, never an ancestor of $HOME, never any existing broker's CWD.

Fix validated on this Mac (live pear broker present throughout): guard PASSes on a pinned root; guard REFUSES a markerless $HOME subdir (reproduces the incident); a real pinned enroll (throwaway instance, port 39160) produced zero "Killing orphaned" lines, came up isolated, and left the live pear broker (:3889) alive + responsive; clean teardown after.

Supervised sf re-attempt with the fixed script — additive, verified by strict bracketing. With sf's brokers restored, the sf enroll was bracketed BEFORE/AFTER on process inventory:

  • BEFORE: brokers sandbox-swap@3889 (pid 2992) + sf-mini@3890 (pid 3015); 7 relay procs.
  • Enroll: PREFLIGHT guard → OK (isolated); zero "Killing orphaned" lines; pear-fleet-sf came up RUNNING + CONNECTED.
  • AFTER: brokers sandbox-swap@3889 (pid 2992) + sf-mini@3890 (pid 3015) still alive, plus pear-fleet-sf@39151 (pid 6953). PIDs {2992,3015}{2992,3015,6953}.
  • The only delta is pear-fleet-sf + its broker process — nothing removed, nothing else changed. The fix holds on the exact machine the incident occurred on.

Rung-3

Rung-3 (cloud sandbox) deferred to AgentWorkforce/cloud#2683 — pear side is ready: the enroll script is surface-agnostic and any sandbox that can run it + reach relaycast becomes a placement target.

🤖 Generated with Claude Code

Review in cubic

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@khaliqgant khaliqgant added the no-agent-relay-review Disable agent-relay automated PR review/fixes label Jul 17, 2026
@coderabbitai

coderabbitai Bot commented Jul 17, 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: 50 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: 267c78e2-e76d-40df-9f1e-7c428f731ac6

📥 Commits

Reviewing files that changed from the base of the PR and between 26f42d9 and 6baf646.

📒 Files selected for processing (15)
  • src/main/broker.test.ts
  • src/main/broker.ts
  • src/main/ipc-handlers.ts
  • src/main/pear-fleet-node.ts
  • src/main/placement-helpers.test.ts
  • src/main/placement.ts
  • src/preload/index.ts
  • src/renderer/src/components/sidebar/SpawnAgentDialog.tsx
  • src/renderer/src/lib/ipc-mock.ts
  • src/renderer/src/lib/spawn-agent.ts
  • src/shared/types/ipc.ts
  • tests/placement/README.md
  • tests/placement/rung1-cross-node.mts
  • tests/placement/rung2/enroll-mini.sh
  • tests/placement/rung2/place-from-mac.mts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/placement-requester

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.

khaliqgant and others added 5 commits July 18, 2026 00:39
Pear already serves spawn:<cli> as a fleet node but never asks the relay
placement engine to place an agent for itself. Add the requester path:

- BrokerManager.placeAgent(): dispatch a spawn onto an eligible fleet node
  via messaging.placement.spawn, using a dedicated agent-scoped
  pear-requester-<projectId> identity (placement invoke requires agent
  scope; workspace-key alone is read-only). Maps the four
  RelayPlacementError codes to clear messages; any-node placement fails
  fast so "no eligible node" is an instant message, never a silent hang.
- BrokerManager.listNodes(): fleet-node roster for the picker UI
  (name/live/load/capabilities), flagging this machine's own node.
- IPC broker:place-agent (structured outcome, no thrown error for the
  expected placement failures) + broker:list-nodes; preload + PearAPI +
  mock parity.

A placed remote agent is reachable over relay chat; its raw terminal is
owned by the target node's broker and has no relay transport yet — the
result carries `local` so the UI can present a chat-first surface for
remote landings (acceptance #2b, tracked as an upstream relay gap).

Unit coverage for the error-message + node-summary mapping;
existing broker suite green (105).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rs (#411)

Add tests/placement/rung1-cross-node.mts — a repeatable Rung-1 gate that
stands up two isolated brokers, enrolls B as a pear fleet node, and places
a REAL claude agent on B, proving: placement lands on B, B owns the PTY,
the requester broker does NOT (the empirical #2b proof that a placed
agent's raw terminal has no relay transport), chat reachability, and the
full error matrix (no-eligible / capability_mismatch / node-death) — each
a clear message, never a hang.

Mandatory failsafe: the script refuses a no-host placement whenever any
foreign spawn:claude node (e.g. the live `pear` node) is in the target
workspace, degrading to targeted placement on B (which can only resolve to
B) or hard-aborting under PLACEMENT_E2E_REQUIRE_HERMETIC=1 — so a real
spawn can never land on someone else's broker.

Move the pure placement helpers (BrokerPlacementError, buildPlacementMessage,
placementRequesterName, toBrokerNodeSummary) into placement.ts so the Node
gate script can import them without pulling in electron; broker.ts imports
and re-exports them. Unit test retargeted; 12/12 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#411)

Add a "Run on" selector to the Spawn Agent dialog:
- "This Mac" (default) → the proven direct local spawn path, unchanged.
- "Any available node (may include this Mac)" and any live remote node →
  the placement engine via placeProjectAgent → broker.placeAgent.

A local landing integrates exactly like a direct spawn (terminal attached +
tracked). A remote landing has no local PTY — its raw terminal has no relay
transport yet (acceptance #2b) — so the dialog shows a chat-first notice
("reachable via chat; terminal view for remote nodes isn't available yet")
and never opens a broken terminal pane. Placement failures surface the
main-process per-code message verbatim.

Rung-1 #2a: prove relay chat REACHABILITY deterministically (placed agent in
the relay roster + relay accepts/routes a DM to it) instead of depending on a
fresh claude composing an autonomous reply from behind its trust prompt — the
full LLM round-trip is kept as a logged bonus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Idempotent additive fleet-node enrollment for real Mac minis over BatchMode
SSH (enroll-mini.sh) + a from-Mac targeted placement proof (place-from-mac.mts).

The enroll script is additive-only: unique instance pear-fleet-<host>, non-default
port, dedicated state dir, workspace-key join via env (never argv), before/after
capture, documented state-dir-scoped teardown.

MANDATORY FAILSAFE #2 (project-root reaper guard): agent-relay node up runs
killOrphanedBrokerProcesses(projectRoot), which reaps every broker whose CWD is
projectRoot; findProjectRoot() walks up for markers incl .agentworkforce/relay,
so a markerless dir under $HOME resolves projectRoot=$HOME and kills every
$HOME-rooted broker (real cross-tenant incident on a busy mini -> relay#1328).
Fix: pin AGENT_RELAY_PROJECT to the unique per-instance dir + marker, and a hard
preflight that refuses to start node up unless the effective project root is
strictly inside our sandbox (never $HOME/an ancestor/an existing broker's CWD).

Proven on finn-mini: 6/7 (placement lands on mini, mini owns PTY, live broker
404s, chat #2a roster+accepted delivery + BONUS full LLM round-trip via PTY).
Fix validated end-to-end on this Mac: guard refuses $HOME; a real pinned enroll
leaves the live pear broker untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ffort roster de-reg (#411)

The authoritative no-leak proof is the mini broker reporting agent_not_found for
the placed agent (its worker/PTY is gone). Workspace-roster de-registration via
relay.agents.delete is best-effort: eventually-consistent and the backend can
refuse on an FK constraint even though the agent is stopped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant
khaliqgant force-pushed the feat/placement-requester branch from fa86dba to 6baf646 Compare July 17, 2026 22:43
@khaliqgant
khaliqgant marked this pull request as ready for review July 17, 2026 22:49
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@cursor

cursor Bot commented Jul 17, 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 merged commit 8e8b2e7 into main Jul 17, 2026
5 checks passed
@khaliqgant
khaliqgant deleted the feat/placement-requester branch July 17, 2026 22:49

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6baf64683c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +148 to +152
const outcome = await pear.broker.placeAgent(project.id, {
cli,
...(node && node.trim() ? { node: node.trim() } : {}),
...(customName?.trim() ? { name: customName.trim() } : {}),
...(customModel?.trim() ? { model: customModel.trim() } : {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the selected checkout when placing remotely

When a remote or any-node placement is selected, the validated root is never included in this request, nor is a repo constraint supplied. The fleet spawn handler consequently defaults to the target node's own advertised options.cwd, and listNodes exposes every capable node in the shared workspace. With multiple project roots or a node serving another checkout, the agent can therefore start in a different repository despite the dialog's “Spawn into” selection, risking work against the wrong codebase.

Useful? React with 👍 / 👎.

Comment on lines +167 to +170
} else {
// Remote landing — no local PTY. Reachable over relay chat; do NOT open a
// (would-be broken) terminal tab. The caller surfaces a chat-first message.
useUIStore.getState().openTab({ kind: 'agents', projectId: project.id })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Expose remote placements to the chat UI

When result.local is false, this branch neither adds the placed agent to any renderer state nor creates/selects a direct-message conversation; it only opens the agents tab. ComposeBar builds its recipient choices exclusively from useAgentStore().agents, while the remote worker is absent from the requester's local broker snapshot, so after the notice the user cannot select or message the supposedly chat-reachable agent through Pear.

Useful? React with 👍 / 👎.

Comment thread src/main/broker.ts
Comment on lines +2570 to +2571
private async getPlacementRelay(session: BrokerSession): Promise<AgentRelay> {
if (session.placementRelay) return session.placementRelay

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Coalesce placement requester registration

If two listNodes/placeAgent calls arrive before the first cache fill—such as the doubled roster effect under the existing React StrictMode or calls from multiple windows—both pass this check and invoke registerOrRotate for the same identity. The second rotation invalidates the first token, so one caller can immediately issue its roster or placement request with a revoked token and fail intermittently. Cache a keyed in-flight promise and clear it safely with the session, as required by the repository's duplicate-event lifecycle guidance.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-agent-relay-review Disable agent-relay automated PR review/fixes

Projects

None yet

1 participant