From 4959d57a87d267dfef2a430885af63bf3fc50f9c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 7 Jun 2026 17:49:36 +0200 Subject: [PATCH 1/6] Split cloud workspace key broker work --- ...26-06-06-multi-instance-stage1-observer.md | 200 ++++++++++++++++++ src/main/broker.test.ts | 137 ++++++++++-- src/main/broker.ts | 130 +++++++++--- src/main/cloud-agent.test.ts | 119 ++++++++++- src/main/cloud-agent.ts | 41 +++- 5 files changed, 558 insertions(+), 69 deletions(-) create mode 100644 docs/specs/2026-06-06-multi-instance-stage1-observer.md diff --git a/docs/specs/2026-06-06-multi-instance-stage1-observer.md b/docs/specs/2026-06-06-multi-instance-stage1-observer.md new file mode 100644 index 00000000..b9ea43b8 --- /dev/null +++ b/docs/specs/2026-06-06-multi-instance-stage1-observer.md @@ -0,0 +1,200 @@ +# Multi-Instance Stage 1: Read-Only Observer — Design Spec + +Issue: #127 (Stage 1 of 3). Prerequisites: #125 (explicit workspace join + named +broker instances — pear side landed `435d78c`, relay/cloud halves in flight), +#126 (remote host support — not started). + +Status: DESIGN ONLY. Written 2026-06-06 during the #125 build-out; contract +references below are to the locked #125 naming contract. + +## Goal + +A second Pear instance (same user, different machine — multi-user comes with +invite scoping later in this stage) can open a project that is "hosted" +elsewhere, see the same agent graph, and watch live terminal output. It cannot +send PTY input, spawn/stop agents, or mutate project settings. + +## Non-goals (Stage 1) + +- No write path of any kind from the observer (Stage 2). +- No per-user permission levels beyond owner/observer (Stage 3 adds editor). +- No presence avatars/notifications UI beyond a minimal "N instances connected" + indicator (Stage 3). +- No CRDT/merge for project definitions: single-writer (the host instance), + observers treat shared state as read-only snapshots. +- No web observer; Electron only. + +## Foundations this builds on (all #125 vintage) + +| Primitive | Where | Why it matters here | +|---|---|---| +| Explicit workspace join | relay `--workspace-key` / `AGENT_RELAY_WORKSPACE_KEY`, fail-loud on invalid key | Observer joins the project workspace; MUST hard-fail rather than silently create a fresh one (the #125 failure mode) | +| Named broker instances | `--instance-name` / `AGENT_RELAY_BROKER_NAME`; `RuntimeSpawnOptions.workspaceKey?/brokerName?` | Instance identity. Observers are addressable, and ownership checks key off the name | +| Workspace key seam | `brokerManager.workspaceKeyForProject(projectId)` (broker.ts) | The host-side source of truth an invite token wraps | +| Remote attach primitive | `attachCloudSandbox()` connects by `execUrl + apiKey` (broker.ts:1467) | The observer's connection path to the host broker is the same shape (#126 generalizes it) | +| Event dedupe discipline | `slackLogicalInjections` canonical-identity claims (integration-event-bridge.ts) | Fan-out to N instances multiplies the duplicate-delivery surface; reuse logical-identity claims, never per-copy revisions | + +## Instance naming + +Local broker names are currently `pear-${project.relayWorkspaceId}` +(project-store.ts:58) — project-stable but NOT instance-unique; two instances +on one project collide. This was explicitly deferred out of #125. Stage 1 is +where it lands: + +- Name = `pear--` where machineSlug = + hostname-derived, 8 chars, persisted in local app config on first run + (NOT regenerated per session — names must be stable for ownership checks). +- The HOST instance keeps the legacy un-suffixed name working via PEAR + METADATA, not broker-side aliasing (relay-worker ruling, 2026-06-06): the + shared project definition publishes both `brokerName` (canonical suffixed) + and `legacyBrokerName`; consumers resolve through the definition. Broker-side + aliasing would disturb the just-stabilized strict-name registration + semantics and is rejected. + +## 1. Shared project definitions + +Authoritative project definition moves to the relay workspace as a single +relayfile document: `/pear/project.json` (channels, integration scopes, roots, +host assignment, schema version). Rationale for relayfile over a new relay +cloud API: sync, change events, and conflict surface already exist; observers +already need relayfile access for mirrors. + +- Host instance: writes `/pear/project.json` on every local mutation + (debounced). Local `projects.json` stays the cache/bootstrap copy. +- Observer instance: subscribes to `/pear/project.json` change events + (the same `subscribe()` machinery the integration-event bridge uses); applies + snapshots read-only; never writes. +- Conflict rule (Stage 1): host wins, always — observers don't write, so the + only conflict is host vs stale cache, resolved by `revision` compare on open. +- Schema versioned (`schemaVersion: 1`); observer with unknown newer version + shows "upgrade required" instead of guessing. + +## 2. Invite / join flow + +Stage 1 keeps tokens same-account (multi-user scoping is the second half of +this stage, gated on relay-side scoped tokens): + +``` +InviteToken = base64url(JSON{ + v: 1, + workspaceKey, // from workspaceKeyForProject(projectId) + relayWorkspaceId, // account workspace (cloud API URL construction) + hostBrokerName, // addressing + ownership root + brokerUrl?, // #126 remote-host URL when available; absent = cloud-relay discovery + role: 'observer' +}) +``` + +- Generate: host instance, new IPC `workspace.invite(projectId)` → token string + (UI: copy button in project settings). +- Join: `workspace.join(token)` → validates schema/version → spawns/joins + observer-side broker session with `workspaceKey` + its own instance name → + fetches `/pear/project.json` → materializes a read-only project entry in the + local store (flagged `origin: 'shared'`, `role: 'observer'`). +- Fail-loud inheritance: a bad/expired key surfaces the broker's strict-join + error verbatim. No fallback to create. The broker distinguishes fatal + rejection (401/403 — "rejected") from rate-limiting (429 — "rate-limited", + HTTP status preserved through AuthHttpError): the join UI treats the former + as a bad invite and the latter as retryable with backoff. +- Token carries no bearer secret beyond the workspace key in Stage 1 + (same-account); the multi-user variant swaps `workspaceKey` for a relay-issued + scoped token and is EXPLICITLY out of scope until relay exposes one. + +## 3. Read-only enforcement + +Two layers, because UI-only enforcement is not enforcement: + +1. **Pear layer (UX):** project entries with `role: 'observer'` get + permission-aware guards in the renderer stores — spawn/stop/input/settings + actions disabled with tooltips. IPC handlers for mutating calls check the + role flag and reject (`ROLE_OBSERVER_READONLY` error) so a buggy renderer + can't mutate either. +2. **Broker layer (authority):** per relay-worker (2026-06-06) the right home + is a readonly capability on the CONNECTION/API layer — a flag in the local + HTTP/WS connection/session context that rejects mutating REST endpoints and + delivery/spawn/release/write actions, while the host connection keeps + normal identity. The lease API is explicitly the wrong layer (leases govern + broker lifetime, not caller authority). Effort: moderate; scheduled with + relay, not in Stage 1's critical path. Stage 1 ships with pear-layer + enforcement only; the trust boundary is then "same account", acceptable for + same-user Stage 1, NOT for multi-user invites (hard gate: multi-user waits + for the broker-side capability). + +## 4. PTY fan-out + +The broker already supports multiple clients; #125 makes both instances land in +one workspace. Stage 1 needs verification + hardening, not new plumbing: + +- Test matrix: 2 instances × (local host, remote host) × (agent spawn before / + after observer join) — observer must receive output chunks for agents + spawned both before and after it connected. Catch-up contract per + relay-worker (2026-06-06): current visible-screen PTY snapshot (existing + snapshot/state machinery) + live stream from join. There is NO durable + per-observer scrollback contract — historical replay is out of scope and the + UI labels the point where the observer's view begins. +- Duplicate-event hardening per AGENTS.md guidance: PTY chunks are + sequence-numbered per (agentName, ptyId); pear-side consumer drops + already-seen sequence numbers — same canonical-identity discipline as the + slack dedupe, trivially cheaper (monotonic seq, not content hashes). +- Broker events (`agent_spawned`, `agent_exited`, …) fan out to all instances; + observer applies them to its read-only graph. Event `instanceName` field + (from the #125 named-instance work) distinguishes "who did that" for the + Stage 3 presence layer — carried but unused in Stage 1. + +## 5. Minimal presence (Stage 1 slice) + +- Each instance publishes `{instanceName, role, joinedAt}` on a relaycast + channel `#pear-presence-` on join, tombstone on clean exit, + TTL-expired by peers on silence (heartbeat every 60s). +- UI: "2 instances connected" pill on the project header. Nothing else. +- This channel is also Stage 2's coordination root (ownership claims), so the + message schema gets a `kind` discriminator from day one. + +## IPC / type additions + +- `workspace.invite(projectId) → string` +- `workspace.join(token) → { projectId }` +- `workspace.onPresenceUpdate(projectId, instances[])` +- Project record: `origin: 'local' | 'shared'`, `role: 'owner' | 'observer'`, + `hostBrokerName?`, `sharedRevision?` +- Mutating IPC handlers gain the role guard (single helper, applied at the + handler boundary, not per-store). + +## Dependencies / sequencing + +``` +#125 relay strict-join + named instances [in flight, T3] +#125 cloud verbatim env injection [in flight, T4] +#126 remote host (broker URL for observer) [not started — Stage 1 can demo + against a cloud-sandbox host first] +relay: scoped invite tokens [needed for multi-user only] +relay: broker-side readonly capability [needed for multi-user; stub OK same-user] +``` + +Buildable order inside Stage 1: instance-name uniqueness → shared +project.json (host write path, observer read path) → invite/join IPC + UI → +PTY fan-out verification → presence slice. Each lands behind a +`PEAR_MULTI_INSTANCE` flag until the stage is coherent. + +## Open questions (for #general before implementation) + +1. relay-worker: name alias for the legacy un-suffixed host broker name vs + pear publishing both names — which is cheaper broker-side? +2. relay-worker: per-connection readonly capability — connection API or + lease API? Effort estimate decides whether same-user Stage 1 waits for it. +3. cloud-lead: does `/pear/project.json` in the account workspace collide with + any cloud-side relayfile path conventions/reserved prefixes? +4. PTY backfill: does the broker keep enough scrollback per PTY to replay on + attach, or is "live from join" the Stage 1 contract? + +## Test plan sketch + +- Unit: invite token round-trip (incl. version/role rejection), role guard on + every mutating IPC handler (table-driven), project.json snapshot apply + + revision conflict. +- Integration: two BrokerManager instances against one broker (the + broker.test.ts harness already mocks spawn; extend with a second client), + PTY seq dedupe under interleaved chunks, observer join while agent mid-run. +- Manual/e2e (needs T2-style debug logging): second machine joins via token, + watches a live agent, kill -9 the host instance → observer survives in + read-only state with stale-host indicator. diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index df8e1f7f..54f9606c 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -112,6 +112,7 @@ const mock = vi.hoisted(() => { connectedClients: [] as MockClient[], nextLocalAgents: [] as string[], nextCloudAgents: [] as string[], + nextCloudSessionMetadata: [] as Array>, nextConnectedAgents: [] as string[], nextConnectedSessionErrors: [] as Error[] } @@ -135,6 +136,10 @@ const mock = vi.hoisted(() => { constructor() { const client = createMockClient(state.nextCloudAgents.splice(0)) + const metadata = state.nextCloudSessionMetadata.shift() + if (metadata) { + client.getSession.mockResolvedValueOnce(metadata) + } state.constructedClients.push(client) // Re-key `this` as the mock client. return client as unknown as HarnessDriverClient @@ -398,14 +403,13 @@ describe('resolveAgentRelayMcpCommand', () => { }) describe('BrokerManager local + cloud coexistence', () => { - let personaTempDir: string | null = null - beforeEach(() => { mock.state.spawnedClients.length = 0 mock.state.constructedClients.length = 0 mock.state.connectedClients.length = 0 mock.state.nextLocalAgents = [] mock.state.nextCloudAgents = [] + mock.state.nextCloudSessionMetadata = [] mock.state.nextConnectedAgents = [] mock.state.nextConnectedSessionErrors = [] mock.HarnessDriverClient.spawn.mockClear() @@ -457,6 +461,115 @@ describe('BrokerManager local + cloud coexistence', () => { await manager.shutdown() }) + it('passes an explicit workspace key env pin to local broker spawn options', async () => { + const previousWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY + process.env.AGENT_RELAY_WORKSPACE_KEY = 'rk_live_pinned' + const manager = new BrokerManager() + + try { + await manager.start(PROJECT_ID, '/tmp/project-1', 'pear-project-1', undefined as never, []) + + expect(mock.HarnessDriverClient.spawn).toHaveBeenCalledWith(expect.objectContaining({ + brokerName: 'pear-project-1', + workspaceKey: 'rk_live_pinned' + })) + } finally { + if (previousWorkspaceKey === undefined) { + delete process.env.AGENT_RELAY_WORKSPACE_KEY + } else { + process.env.AGENT_RELAY_WORKSPACE_KEY = previousWorkspaceKey + } + await manager.shutdown() + } + }) + + it('reads the local broker workspace key for cloud provisioning', async () => { + const manager = new BrokerManager() + const local = await startLocal(manager) + local.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_project' }) + + await expect(manager.workspaceKeyForProject(PROJECT_ID)).resolves.toBe('rk_live_project') + + await manager.shutdown() + }) + + it('omits the project workspace key when no local broker exposes one', async () => { + const manager = new BrokerManager() + await startLocal(manager) + + await expect(manager.workspaceKeyForProject(PROJECT_ID)).resolves.toBeUndefined() + await expect(manager.workspaceKeyForProject('missing-project')).resolves.toBeUndefined() + + await manager.shutdown() + }) + + it('emits a cloud workspace mismatch event when the sandbox ignores the sent key', async () => { + const manager = new BrokerManager() + const win = createMockWindow() + mock.state.nextCloudSessionMetadata.push({ workspace_key: 'rk_sand_456' }) + + await manager.attachCloudSandbox(PROJECT_ID, { + sandboxId: 'sandbox-1', + execUrl: 'https://sandbox.example', + sentWorkspaceKey: 'rk_sent_123' + }, win) + + expect(win.webContents.send).toHaveBeenCalledWith( + 'broker:event', + expect.objectContaining({ + kind: 'cloud_workspace_key_mismatch', + projectId: PROJECT_ID, + cloudSandboxId: 'sandbox-1', + sentWorkspaceKeyPrefix: 'rk_sent_', + sandboxWorkspaceKeyPrefix: 'rk_sand_', + detail: expect.stringContaining('stale broker binary') + }) + ) + + await manager.shutdown() + }) + + it('does not emit a cloud workspace mismatch event when the sandbox joins the sent key', async () => { + const manager = new BrokerManager() + const win = createMockWindow() + mock.state.nextCloudSessionMetadata.push({ workspace_key: 'rk_live_same' }) + + await manager.attachCloudSandbox(PROJECT_ID, { + sandboxId: 'sandbox-1', + execUrl: 'https://sandbox.example', + sentWorkspaceKey: 'rk_live_same' + }, win) + + const mismatchEvents = (win.webContents.send as ReturnType).mock.calls + .filter(([channel, payload]) => + channel === 'broker:event' && + (payload as { kind?: string }).kind === 'cloud_workspace_key_mismatch' + ) + expect(mismatchEvents).toHaveLength(0) + + await manager.shutdown() + }) + + it('does not emit a cloud workspace mismatch event on keyless legacy attaches', async () => { + const manager = new BrokerManager() + const win = createMockWindow() + mock.state.nextCloudSessionMetadata.push({ workspace_key: 'rk_live_sandbox' }) + + await manager.attachCloudSandbox(PROJECT_ID, { + sandboxId: 'sandbox-1', + execUrl: 'https://sandbox.example' + }, win) + + const mismatchEvents = (win.webContents.send as ReturnType).mock.calls + .filter(([channel, payload]) => + channel === 'broker:event' && + (payload as { kind?: string }).kind === 'cloud_workspace_key_mismatch' + ) + expect(mismatchEvents).toHaveLength(0) + + await manager.shutdown() + }) + it('reuses current harness-driver connection files instead of spawning another broker', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'pear-current-connection-')) const connectionPath = join(tempDir, '.agentworkforce', 'relay', 'connection.json') @@ -599,26 +712,6 @@ describe('BrokerManager local + cloud coexistence', () => { await manager.shutdown() }) - it('normalizes spawn results to structured-clone-safe data', async () => { - const manager = new BrokerManager() - const local = await startLocal(manager, []) - local.spawnPty.mockImplementationOnce(async (input: { name: string }) => { - local.agentNames.push(input.name) - return { - name: input.name, - runtime: 'pty', - client: () => undefined - } - }) - - const spawned = await manager.spawnAgent(PROJECT_ID, { name: 'worker', cli: 'fake-cli' }) - - expect(spawned).toEqual({ name: 'worker', runtime: 'pty' }) - expect(() => structuredClone(spawned)).not.toThrow() - - await manager.shutdown() - }) - it('coalesces concurrent duplicate spawn requests', async () => { const manager = new BrokerManager() const local = await startLocal(manager, []) diff --git a/src/main/broker.ts b/src/main/broker.ts index 5597258b..7d317802 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -139,6 +139,18 @@ export function resolveAgentRelayMcpCommand(): string | undefined { }) } +// Strict-join failures from the broker (#125): an explicitly pinned workspace +// key never falls back to creating a fresh workspace. Auth rejection (401/403, +// "was rejected") is fatal — the key is bad or revoked; rate limiting (429, +// "was rate-limited") is retryable. Contract strings from agent-relay-broker +// relaycast/auth.rs, verified in the T3 review. +export function classifyWorkspaceJoinFailure(err: unknown): 'rejected' | 'rate-limited' | null { + const message = toErrorMessage(err) + if (/explicit workspace key .* was rate-limited/iu.test(message)) return 'rate-limited' + if (/explicit workspace key .* was rejected/iu.test(message)) return 'rejected' + return null +} + function parseAgentWorkforceJson(output: string, label: string): T { try { return JSON.parse(output) as T @@ -301,6 +313,13 @@ export interface CloudAgentSandboxHandle { execUrl: string apiKey?: string relayfileMountPath?: string + /** + * The relay workspace key provisioning actually sent on POST /box (#125). + * Set only when the warm path resolved a local key — its presence arms the + * attach-time tripwire that detects a sandbox broker silently ignoring + * AGENT_RELAY_WORKSPACE_KEY (stale, pre-strict-join binary). + */ + sentWorkspaceKey?: string } type BrokerEventObserver = (projectId: string, event: BrokerEvent) => void @@ -391,13 +410,6 @@ function spawnRequestKey( }) } -function personaSpawnRequestKey(projectId: string, personaId: string): string { - return JSON.stringify({ - projectId, - personaId - }) -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -1244,7 +1256,7 @@ export class BrokerManager { private sessions = new Map() private startPromises = new Map>() private revivePromises = new Map>() - private inFlightSpawnRequests = new Map>() + private inFlightSpawnRequests = new Map>() // Which broker sessions (by session key) an agent name is registered on. // Both a project's local and cloud brokers join the same relay workspace, // so agent names are project-unique in practice — the set tracks which @@ -1373,12 +1385,20 @@ export class BrokerManager { console.warn('[broker] Agent Relay MCP command could not be resolved; broker will use its default MCP command') } - const opts: AgentRelaySpawnOptions = { + // Phase 1 of #125: the local broker stays the workspace creator, so the + // key is only threaded when explicitly pinned via env. The intersection + // type is the single cast site until @agent-relay/harness-driver + // PUBLISHES workspaceKey in RuntimeSpawnOptions (landed relay-side in + // 6419d59c; verified against the built 8.3.0+T3 dist locally) — the + // intersection erases to a no-op then and drops with the version bump. + const explicitWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined + const opts: AgentRelaySpawnOptions & { workspaceKey?: string } = { cwd, brokerName: name, channels: nextChannels, binaryArgs: { persist: true }, binaryPath: resolveBundledBrokerBinary(), + ...(explicitWorkspaceKey ? { workspaceKey: explicitWorkspaceKey } : {}), env: { PATH: augmentedPath(), ...(agentRelayMcpCommand ? { AGENT_RELAY_MCP_COMMAND: agentRelayMcpCommand } : {}) @@ -1425,7 +1445,13 @@ export class BrokerManager { return await startPromise } catch (err) { console.error(`[broker] Failed to start for project ${normalizedProjectId}:`, err) - this.sendStatusToWindow(win, normalizedProjectId, 'error', String(err)) + const joinFailure = classifyWorkspaceJoinFailure(err) + const statusMessage = joinFailure === 'rate-limited' + ? `Workspace join rate-limited (retryable): ${String(err)}` + : joinFailure === 'rejected' + ? `Workspace key rejected — broker refused to create a fresh workspace: ${String(err)}` + : String(err) + this.sendStatusToWindow(win, normalizedProjectId, 'error', statusMessage) throw err } finally { if (this.startPromises.get(normalizedProjectId) === startPromise) { @@ -1528,6 +1554,27 @@ export class BrokerManager { return { removed } } + /** + * The local broker creates the project's relay workspace; its workspace_key + * is what cloud sandbox brokers must join so local and cloud agents share + * one workspace (#125). Non-throwing: resolves undefined until a local + * session exists and exposes a key, so provisioning can proceed without it. + */ + async workspaceKeyForProject(projectId: string): Promise { + const normalizedProjectId = projectId.trim() + if (!normalizedProjectId) return undefined + const startPromise = this.startPromises.get(normalizedProjectId) + if (startPromise) await startPromise.catch(() => undefined) + const session = this.sessions.get(normalizedProjectId) + if (!session) return undefined + try { + const metadata = await session.client.getSession() + return metadata.workspace_key || undefined + } catch { + return undefined + } + } + /** * Attach to an already-provisioned cloud sandbox (used by CloudAgentManager * which warms the box via the cloud-agents/{id}/box endpoint). connectCloud @@ -1584,7 +1631,28 @@ export class BrokerManager { baseUrl: execUrl, ...(apiKey ? { apiKey } : {}) }) - await client.getSession() + const sessionMetadata = await client.getSession() + + // #125 tripwire: provisioning asked this sandbox broker to JOIN an + // explicit workspace. A different key in the session means the broker + // ignored AGENT_RELAY_WORKSPACE_KEY (a pre-strict-join binary, e.g. a + // stale snapshot bake) and silently created an isolated workspace — + // exactly the failure #125 fixed. Compare only when a key was actually + // sent; prefixes keep the event diagnosable from logs without leaking + // whole keys. + const sentWorkspaceKey = handle.sentWorkspaceKey?.trim() || undefined + const sandboxWorkspaceKey = sessionMetadata.workspace_key || undefined + const workspaceKeyMismatch = !!sentWorkspaceKey && sandboxWorkspaceKey !== sentWorkspaceKey + if (workspaceKeyMismatch) { + console.error( + `[broker] Cloud sandbox broker ignored workspace key for project ${normalizedProjectId} — stale broker binary?`, + { + sandboxId, + sentWorkspaceKeyPrefix: sentWorkspaceKey?.slice(0, 8), + sandboxWorkspaceKeyPrefix: sandboxWorkspaceKey?.slice(0, 8) ?? '(none)' + } + ) + } const eventStreamGeneration = this.nextEventStreamGeneration() const unsubEvent = this.attachClient(sessionKey, client, win, eventStreamGeneration) @@ -1613,6 +1681,15 @@ export class BrokerManager { }) client.connectEvents() + if (workspaceKeyMismatch) { + this.publishBrokerEvent(sessionKey, normalizedProjectId, win, { + kind: 'cloud_workspace_key_mismatch', + cloudSandboxId: sandboxId, + sentWorkspaceKeyPrefix: sentWorkspaceKey?.slice(0, 8) ?? null, + sandboxWorkspaceKeyPrefix: sandboxWorkspaceKey?.slice(0, 8) ?? null, + detail: 'sandbox broker ignored AGENT_RELAY_WORKSPACE_KEY — stale broker binary?' + }) + } this.publishBrokerEvent(sessionKey, normalizedProjectId, win, { kind: 'broker_initialized', name: `cloud-${normalizedProjectId}`, @@ -2391,12 +2468,12 @@ export class BrokerManager { projectId: string, spawnInput: SpawnPtyInput & { broker?: 'local' | 'cloud' }, options: { parentAgentName?: string } = {} - ): Promise { + ): Promise<{ name: string; runtime: string }> { const requestKey = spawnRequestKey(projectId, spawnInput, options) const inFlight = this.inFlightSpawnRequests.get(requestKey) if (inFlight) return inFlight - let promise!: Promise + let promise!: Promise<{ name: string; runtime: string }> promise = this.spawnAgentOnce(projectId, spawnInput, options).finally(() => { if (this.inFlightSpawnRequests.get(requestKey) === promise) { this.inFlightSpawnRequests.delete(requestKey) @@ -2410,7 +2487,7 @@ export class BrokerManager { projectId: string, spawnInput: SpawnPtyInput & { broker?: 'local' | 'cloud' }, options: { parentAgentName?: string } = {} - ): Promise { + ): Promise<{ name: string; runtime: string }> { // `broker` selects which of the project's sessions the agent spawns on. // Default: local-first via getSessionForProject (cloud only when no local // broker is running, preserving the cloud-only flow). @@ -2462,8 +2539,7 @@ export class BrokerManager { ) } const spawned = await session.client.spawnPty(nextInput) - const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name) - const spawnedName = safeSpawned.name + const spawnedName = spawned.name || nextInput.name this.rememberAgentSession(spawnedName, sessionKeyFor(session)) const burnInput = { ...nextInput, name: spawnedName } const lineage = session.pearLineage.get(spawnedName) @@ -2479,7 +2555,7 @@ export class BrokerManager { ).catch((err) => { console.warn('[burn-spawn-hook] post-spawn burn stamp failed:', err) }) - return safeSpawned + return spawned } catch (err) { if (!isAgentNameConflict(err)) { throw buildSpawnFailureError(err, nextInput, session.cloudSandboxId ? 'cloud' : 'local') @@ -2506,29 +2582,13 @@ export class BrokerManager { } } - async spawnPersona(projectId: string, personaId: string): Promise { + async spawnPersona(projectId: string, personaId: string): Promise<{ name: string; runtime: string; cli?: string }> { + const session = this.getSessionForProject(projectId) const trimmedPersonaId = personaId.trim() if (!trimmedPersonaId) { throw new Error('Persona id is required') } - const requestKey = personaSpawnRequestKey(projectId, trimmedPersonaId) - const inFlight = this.inFlightSpawnRequests.get(requestKey) - if (inFlight) return inFlight - - let promise!: Promise - promise = this.spawnPersonaOnce(projectId, trimmedPersonaId).finally(() => { - if (this.inFlightSpawnRequests.get(requestKey) === promise) { - this.inFlightSpawnRequests.delete(requestKey) - } - }) - this.inFlightSpawnRequests.set(requestKey, promise) - return promise - } - - private async spawnPersonaOnce(projectId: string, trimmedPersonaId: string): Promise { - const session = this.getSessionForProject(projectId) - const command = resolveAgentWorkforceCommand(session.cwd) const persona = findWorkforcePersona(session.cwd, trimmedPersonaId, command) diff --git a/src/main/cloud-agent.test.ts b/src/main/cloud-agent.test.ts index acab2828..fe52e63b 100644 --- a/src/main/cloud-agent.test.ts +++ b/src/main/cloud-agent.test.ts @@ -65,7 +65,8 @@ const mock = vi.hoisted(() => { brokerManager: { onBrokerEvent: vi.fn(), attachCloudSandbox: vi.fn(async () => undefined), - detachCloudSandbox: vi.fn(async () => undefined) + detachCloudSandbox: vi.fn(async () => undefined), + workspaceKeyForProject: vi.fn(async () => undefined) }, fetch: vi.fn(async (url: string | URL | Request, init?: RequestInit) => { const normalizedUrl = String(url) @@ -221,6 +222,8 @@ describe('CloudAgentManager', () => { mock.brokerManager.onBrokerEvent.mockClear() mock.brokerManager.attachCloudSandbox.mockClear() mock.brokerManager.detachCloudSandbox.mockClear() + mock.brokerManager.workspaceKeyForProject.mockClear() + mock.brokerManager.workspaceKeyForProject.mockResolvedValue(undefined) mock.loadStore.mockClear() mock.saveStore.mockClear() mock.resolveCloudAuth.mockClear() @@ -261,6 +264,26 @@ describe('CloudAgentManager', () => { await Promise.resolve() } + function boxRequest(method: string): { url: string; init?: RequestInit } | undefined { + return mock.fetchCalls.find((call) => + call.init?.method === method && + call.url.includes('/cloud-agents/cloud-agent-1/box') + ) + } + + function boxRequestBody(method: string): Record { + const request = boxRequest(method) + if (!request?.init?.body) throw new Error(`missing ${method} box request body`) + return JSON.parse(String(request.init.body)) as Record + } + + function expectBoxPostBody(expected: Record): void { + expect(boxRequestBody('POST')).toEqual({ + brokerName: 'cloud-cloud-ag', + ...expected + }) + } + it('keeps a newly created cloud agent visible while the cloud list catches up', async () => { const manager = new CloudAgentManager() @@ -302,14 +325,84 @@ describe('CloudAgentManager', () => { expect(boxPost?.url).toBe( 'https://cloud.example/api/v1/workspaces/account-workspace-id/cloud-agents/cloud-agent-1/box?async=true' ) - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github', '/workspace'] }) + expect(boxRequestBody('POST')).not.toHaveProperty('workspaceKey') expect(boxPost?.url).not.toContain('relay-workspace-id') expect(mock.fetchCalls.filter((call) => call.url.endsWith('/api/v1/auth/whoami'))).toHaveLength(1) expect(mock.mountInputs[0]?.workspaceId).toBe('relay-workspace-id') }) + it('passes the local relay workspace key and stable cloud broker name when warming a box', async () => { + mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project') + const manager = new CloudAgentManager() + + await manager.attach('project-1', 'cloud-agent-1') + + expect(mock.brokerManager.workspaceKeyForProject).toHaveBeenCalledWith('project-1') + expectBoxPostBody({ + relayfileMountPaths: ['/integrations/github', '/workspace'], + workspaceKey: 'rk_live_project' + }) + expect(mock.brokerManager.attachCloudSandbox).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ + sandboxId: 'sandbox-1', + sentWorkspaceKey: 'rk_live_project' + }), + undefined + ) + }) + + it('does not pass a sent workspace key for keyless warms', async () => { + const manager = new CloudAgentManager() + + await manager.attach('project-1', 'cloud-agent-1') + + expect(mock.brokerManager.attachCloudSandbox).toHaveBeenCalledWith( + 'project-1', + expect.not.objectContaining({ + sentWorkspaceKey: expect.anything() + }), + undefined + ) + }) + + it('clears the sent workspace key when a cloud agent detaches', async () => { + mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project') + const manager = new CloudAgentManager() + await manager.attach('project-1', 'cloud-agent-1') + await manager.detach('project-1') + mock.brokerManager.attachCloudSandbox.mockClear() + + await (manager as unknown as { + connectBroker: (projectId: string, sandbox: { + sandboxId: string + execUrl: string + filesUrl: string + relayfileToken: string + relayfileMountPath: string + status: 'ready' + }) => Promise + }).connectBroker('project-1', { + sandboxId: 'sandbox-2', + execUrl: 'https://sandbox-2.example', + filesUrl: 'https://sandbox-2.example/files', + relayfileToken: 'relayfile-token-2', + relayfileMountPath: '/remote/project-1', + status: 'ready' + }) + + expect(mock.brokerManager.attachCloudSandbox).toHaveBeenCalledWith( + 'project-1', + expect.not.objectContaining({ + sentWorkspaceKey: expect.anything() + }), + undefined + ) + }) + it('reuses a warm-on-intent box when attach is clicked', async () => { const manager = new CloudAgentManager() @@ -373,8 +466,7 @@ describe('CloudAgentManager', () => { await manager.attach('project-1', 'cloud-agent-1') - const boxPost = mock.fetchCalls.find((call) => call.init?.method === 'POST') - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github', '/workspace'], workspaceSource: expect.objectContaining({ kind: 'git-overlay', @@ -428,8 +520,7 @@ describe('CloudAgentManager', () => { await manager.attach('project-1', 'cloud-agent-1') - const boxPost = mock.fetchCalls.find((call) => call.init?.method === 'POST') - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github', '/workspace'], workspaceSource: { kind: 'git-overlay', @@ -449,8 +540,7 @@ describe('CloudAgentManager', () => { await manager.attach('project-1', 'cloud-agent-1') - const boxPost = mock.fetchCalls.find((call) => call.init?.method === 'POST') - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github'], workspaceSource: expect.objectContaining({ kind: 'git', @@ -534,4 +624,17 @@ describe('CloudAgentManager', () => { await expect(manager.attach('project-1', 'cloud-agent-1')).rejects.toThrow('broker failed to start') }) + + it('keeps mount-path PATCH bodies scoped to mount paths only', async () => { + mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project') + const manager = new CloudAgentManager() + await manager.attach('project-1', 'cloud-agent-1') + mock.fetchCalls.length = 0 + + await manager.updateMountPaths('project-1', ['/integrations/slack']) + + expect(boxRequestBody('PATCH')).toEqual({ + relayfileMountPaths: ['/integrations/slack', '/workspace'] + }) + }) }) diff --git a/src/main/cloud-agent.ts b/src/main/cloud-agent.ts index 7d5f2e12..2f711b7c 100644 --- a/src/main/cloud-agent.ts +++ b/src/main/cloud-agent.ts @@ -88,6 +88,7 @@ type CloudBrokerAdapter = { connectCloudSandbox?: (projectId: string, sandbox: CloudAgentSandbox, win?: BrowserWindow) => Promise detachCloudSandbox?: (projectId: string) => Promise onBrokerEvent?: (handler: (projectId: string, event: BrokerEvent) => void) => () => void + workspaceKeyForProject?: (projectId: string) => Promise } type CloudBrokerSystemMessageAdapter = { @@ -469,6 +470,10 @@ export class CloudAgentManager { private appliedConflictPolicies = new Map() private mountRestartPromises = new Map>() private workspaceSources = new Map() + // Relay workspace keys actually sent on POST /box, per project — arms the + // attach-time stale-broker tripwire (#125). Tracked here because the + // sandbox object is replaced by warm-poll GETs between warm and attach. + private sentWorkspaceKeys = new Map() private prewarms = new Map() private canceledAttaches = new Set() private eventHandlers = new Set<(event: CloudAgentEvent) => void>() @@ -658,6 +663,7 @@ export class CloudAgentManager { this.lastSettledAt.delete(normalizedProjectId) this.appliedConflictPolicies.delete(normalizedProjectId) this.workspaceSources.delete(normalizedProjectId) + this.sentWorkspaceKeys.delete(normalizedProjectId) this.persistCloudAgent(normalizedProjectId, null) this.emit({ type: 'mount-status', projectId: normalizedProjectId, mount: toMountStatus(null) }) } @@ -1077,7 +1083,23 @@ export class CloudAgentManager { ? integrationMountPaths : [SANDBOX_WORKSPACE_PATH, ...integrationMountPaths] const url = `${auth.apiUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/cloud-agents/${encodeURIComponent(cloudAgentId)}/box` - let sandbox = await this.fetchBox(url, auth.accessToken, 'POST', mountPaths, workspaceSource) + // #125: the sandbox broker joins the project's local relay workspace + // under a name pear knows ahead of time (cloud injects both verbatim as + // AGENT_RELAY_WORKSPACE_KEY / AGENT_RELAY_BROKER_NAME). The key is + // best-effort: without a local session the sandbox keeps creating its own + // workspace, exactly as before. + const workspaceKey = await (brokerManager as unknown as CloudBrokerAdapter) + .workspaceKeyForProject?.(projectId) + if (workspaceKey) { + this.sentWorkspaceKeys.set(projectId, workspaceKey) + } else { + this.sentWorkspaceKeys.delete(projectId) + } + const relayBroker = { + ...(workspaceKey ? { workspaceKey } : {}), + brokerName: `cloud-${cloudAgentId.slice(0, 8)}` + } + let sandbox = await this.fetchBox(url, auth.accessToken, 'POST', mountPaths, workspaceSource, relayBroker) options.onSandbox?.(sandbox) if (options.isCancelled?.()) { await this.deleteBox(toBinding(projectId, cloudAgentId, sandbox)).catch(() => undefined) @@ -1139,12 +1161,17 @@ export class CloudAgentManager { accessToken: string, method: 'GET' | 'POST', mountPaths?: string[], - workspaceSource?: CloudAgentWorkspaceSource + workspaceSource?: CloudAgentWorkspaceSource, + relayBroker?: { workspaceKey?: string; brokerName?: string } ): Promise { + // Broker identity is provision-time only: POST carries it, PATCH/GET never + // do (cloud preserves the injected env across mount-path rewrites). const body = method === 'POST' ? JSON.stringify({ relayfileMountPaths: normalizeMountPaths(mountPaths || []), - ...(workspaceSource && workspaceSource.kind !== 'relayfile' ? { workspaceSource } : {}) + ...(workspaceSource && workspaceSource.kind !== 'relayfile' ? { workspaceSource } : {}), + ...(relayBroker?.workspaceKey ? { workspaceKey: relayBroker.workspaceKey } : {}), + ...(relayBroker?.brokerName ? { brokerName: relayBroker.brokerName } : {}) }) : undefined const requestUrl = method === 'POST' ? withAsyncWarm(url) : url @@ -1264,7 +1291,13 @@ export class CloudAgentManager { const broker = brokerManager as unknown as CloudBrokerAdapter const attach = broker.attachCloudSandbox || broker.connectCloudSandbox if (attach) { - await attach.call(brokerManager, projectId, sandbox, win) + const sentWorkspaceKey = this.sentWorkspaceKeys.get(projectId) + await attach.call( + brokerManager, + projectId, + sentWorkspaceKey ? { ...sandbox, sentWorkspaceKey } : sandbox, + win + ) return } From 3a670d9ed0977c6cc41ad911cfd9e1e6fd356aa3 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Sun, 7 Jun 2026 16:11:57 +0000 Subject: [PATCH 2/6] chore: apply pr-reviewer fixes for #146 --- src/main/broker.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++ src/main/broker.ts | 30 +++++++++++++++++++++----- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 54f9606c..8c3a5abe 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -114,6 +114,7 @@ const mock = vi.hoisted(() => { nextCloudAgents: [] as string[], nextCloudSessionMetadata: [] as Array>, nextConnectedAgents: [] as string[], + nextConnectedSessionMetadata: [] as Array>, nextConnectedSessionErrors: [] as Error[] } @@ -126,6 +127,10 @@ const mock = vi.hoisted(() => { static connect = vi.fn(() => { const client = createMockClient(state.nextConnectedAgents.splice(0)) + const metadata = state.nextConnectedSessionMetadata.shift() + if (metadata) { + client.getSession.mockResolvedValueOnce(metadata) + } const sessionError = state.nextConnectedSessionErrors.shift() if (sessionError) { client.getSession.mockRejectedValueOnce(sessionError) @@ -411,6 +416,7 @@ describe('BrokerManager local + cloud coexistence', () => { mock.state.nextCloudAgents = [] mock.state.nextCloudSessionMetadata = [] mock.state.nextConnectedAgents = [] + mock.state.nextConnectedSessionMetadata = [] mock.state.nextConnectedSessionErrors = [] mock.HarnessDriverClient.spawn.mockClear() mock.HarnessDriverClient.connect.mockClear() @@ -464,6 +470,7 @@ describe('BrokerManager local + cloud coexistence', () => { it('passes an explicit workspace key env pin to local broker spawn options', async () => { const previousWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY process.env.AGENT_RELAY_WORKSPACE_KEY = 'rk_live_pinned' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) const manager = new BrokerManager() try { @@ -473,7 +480,11 @@ describe('BrokerManager local + cloud coexistence', () => { brokerName: 'pear-project-1', workspaceKey: 'rk_live_pinned' })) + const logged = logSpy.mock.calls.map((call) => call.join(' ')).join('\n') + expect(logged).toContain('rk_live_…') + expect(logged).not.toContain('rk_live_pinned') } finally { + logSpy.mockRestore() if (previousWorkspaceKey === undefined) { delete process.env.AGENT_RELAY_WORKSPACE_KEY } else { @@ -597,6 +608,43 @@ describe('BrokerManager local + cloud coexistence', () => { } }) + it('does not reuse an existing connection with a mismatched explicit workspace key', async () => { + const previousWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY + process.env.AGENT_RELAY_WORKSPACE_KEY = 'rk_live_pinned' + const tempDir = await mkdtemp(join(tmpdir(), 'pear-pinned-connection-')) + const connectionPath = join(tempDir, '.agentworkforce', 'relay', 'connection.json') + await mkdir(dirname(connectionPath), { recursive: true }) + await writeFile(connectionPath, JSON.stringify({ + url: 'http://127.0.0.1:43210', + apiKey: 'test-key', + pid: 4242 + })) + + try { + const manager = new BrokerManager() + mock.state.nextConnectedSessionMetadata.push({ workspace_key: 'rk_live_other' }) + + const started = await manager.start(PROJECT_ID, tempDir, 'pear-project-1', undefined as never, []) + + expect(started).toBe(true) + expect(mock.HarnessDriverClient.connect).toHaveBeenCalledWith({ cwd: tempDir, connectionPath }) + expect(mock.state.connectedClients[0]?.disconnect).toHaveBeenCalled() + expect(mock.HarnessDriverClient.spawn).toHaveBeenCalledWith(expect.objectContaining({ + brokerName: 'pear-project-1', + workspaceKey: 'rk_live_pinned' + })) + + await manager.shutdown() + } finally { + if (previousWorkspaceKey === undefined) { + delete process.env.AGENT_RELAY_WORKSPACE_KEY + } else { + process.env.AGENT_RELAY_WORKSPACE_KEY = previousWorkspaceKey + } + await rm(tempDir, { recursive: true, force: true }) + } + }) + it('reports the matching connection file when a stale current file and matching legacy file coexist', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'pear-connection-status-')) const currentConnectionPath = join(tempDir, '.agentworkforce', 'relay', 'connection.json') diff --git a/src/main/broker.ts b/src/main/broker.ts index 7d317802..8dc9a2df 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1347,7 +1347,8 @@ export class BrokerManager { } const startBroker = async (): Promise => { - const existingClient = await this.connectExistingBroker(normalizedProjectId, cwd) + const explicitWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined + const existingClient = await this.connectExistingBroker(normalizedProjectId, cwd, explicitWorkspaceKey) if (existingClient) { const eventStreamGeneration = this.nextEventStreamGeneration() const unsubEvent = this.attachClient(normalizedProjectId, existingClient, win, eventStreamGeneration) @@ -1391,7 +1392,6 @@ export class BrokerManager { // PUBLISHES workspaceKey in RuntimeSpawnOptions (landed relay-side in // 6419d59c; verified against the built 8.3.0+T3 dist locally) — the // intersection erases to a no-op then and drops with the version bump. - const explicitWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined const opts: AgentRelaySpawnOptions & { workspaceKey?: string } = { cwd, brokerName: name, @@ -1408,7 +1408,11 @@ export class BrokerManager { } } - console.log('[broker] Starting with opts:', JSON.stringify({ ...opts, projectId: normalizedProjectId })) + console.log('[broker] Starting with opts:', JSON.stringify({ + ...opts, + ...(explicitWorkspaceKey ? { workspaceKey: `${explicitWorkspaceKey.slice(0, 8)}…` } : {}), + projectId: normalizedProjectId + })) const client = await AgentRelayClient.spawn(opts) console.log('[broker] Started successfully for project:', normalizedProjectId) const eventStreamGeneration = this.nextEventStreamGeneration() @@ -1460,7 +1464,11 @@ export class BrokerManager { } } - private async connectExistingBroker(projectId: string, cwd: string): Promise { + private async connectExistingBroker( + projectId: string, + cwd: string, + expectedWorkspaceKey?: string + ): Promise { const connectionPaths = brokerConnectionPathCandidates(cwd).filter((candidate) => existsSync(candidate)) if (connectionPaths.length === 0) { return null @@ -1470,7 +1478,19 @@ export class BrokerManager { let client: AgentRelayClient | undefined try { client = AgentRelayClient.connect({ cwd, connectionPath }) - await client.getSession() + const metadata = await client.getSession() + if (expectedWorkspaceKey && metadata.workspace_key !== expectedWorkspaceKey) { + console.warn( + `[broker] Existing broker connection workspace key does not match explicit pin for project ${projectId}; starting a pinned broker instead`, + { + connectionPath, + expectedWorkspaceKeyPrefix: expectedWorkspaceKey.slice(0, 8), + actualWorkspaceKeyPrefix: metadata.workspace_key?.slice(0, 8) ?? '(none)' + } + ) + client.disconnect() + continue + } console.log(`[broker] Reusing existing broker for project ${projectId}: ${connectionPath}`) return client } catch (err) { From 02ae15767d65707ad985f4c955d6cf4ec0ab1a88 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 8 Jun 2026 02:28:02 +0200 Subject: [PATCH 3/6] test: isolate broker workspace key env --- src/main/broker.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 8c3a5abe..2abada13 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -408,7 +408,11 @@ describe('resolveAgentRelayMcpCommand', () => { }) describe('BrokerManager local + cloud coexistence', () => { + let personaTempDir: string | null = null + const inheritedWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY + beforeEach(() => { + delete process.env.AGENT_RELAY_WORKSPACE_KEY mock.state.spawnedClients.length = 0 mock.state.constructedClients.length = 0 mock.state.connectedClients.length = 0 @@ -430,6 +434,11 @@ describe('BrokerManager local + cloud coexistence', () => { } if (personaTempDir) await rm(personaTempDir, { recursive: true, force: true }) personaTempDir = null + if (inheritedWorkspaceKey === undefined) { + delete process.env.AGENT_RELAY_WORKSPACE_KEY + } else { + process.env.AGENT_RELAY_WORKSPACE_KEY = inheritedWorkspaceKey + } }) it('keeps the local session alive when a cloud sandbox attaches', async () => { From b83db4e6f5d4ce6c29437855bff25016ab479f9c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 8 Jun 2026 08:39:24 +0200 Subject: [PATCH 4/6] Pin harness driver prerelease for workspace key --- package-lock.json | 141 +++++++++++++++++++++++++++++++++++++-------- package.json | 2 +- src/main/broker.ts | 8 +-- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 99a24a79..42d4658d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "dependencies": { "@agent-relay/cloud": "^8.1.2", - "@agent-relay/harness-driver": "^8.1.2", + "@agent-relay/harness-driver": "8.3.1-beta.0", "@agent-relay/sdk": "^8.1.2", "@agentworkforce/deploy": "^3.0.50", "@relayburn/sdk": "^3.2.0", @@ -140,9 +140,9 @@ "integrity": "sha512-h6YkyIl0DZSIeVTqgPZGgN+E2I0COcF7XjljVKGzZGLUKzmBKhLROeyiy9efz/YIpApat3xjLZ4Ac20+EsydTA==" }, "node_modules/@agent-relay/broker-darwin-arm64": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-8.1.2.tgz", - "integrity": "sha512-zNJOPYqfoeedo9dxcXY8OCT/kOO6RMC/FQ4mb9jy0jvwaks8HRGRm4H7XE7TeW2oEW5LZ5QxF/Q8Err/dBbkQw==", + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-8.3.1-beta.0.tgz", + "integrity": "sha512-YaqznIIi/4+7wGpA9knrZE0TeELhjW0nwQyiNNVSQ7tTzn9i2ZJtPgbmcD4jpTbuqIgYcLWzuCF/4eo2XdPZgQ==", "cpu": [ "arm64" ], @@ -153,9 +153,9 @@ ] }, "node_modules/@agent-relay/broker-darwin-x64": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-8.1.2.tgz", - "integrity": "sha512-ePkEFks2gwwIZb1409qExYRXc+JMnNck6eyrBmAY3GNUdoFao46QNzs3QwqKX11PsJcuaGbkZLxkZWrgReMCOw==", + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-8.3.1-beta.0.tgz", + "integrity": "sha512-LhQnuudNb/sdOuqExEvifwY9dudGZXYaxtagES7BzQneKqj2QM+k8WPEiBoq5BdaW9PCtLJ49BMCwhwxzdJK/A==", "cpu": [ "x64" ], @@ -166,9 +166,9 @@ ] }, "node_modules/@agent-relay/broker-linux-arm64": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-8.1.2.tgz", - "integrity": "sha512-/dEJ57innCtWQxi9jPFvlInf0G0oL436kk0OnZGCPIiA/fFOB5Iheg/r8dVzQWZoWkcD2TOUqUdFYomtICbIBg==", + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-8.3.1-beta.0.tgz", + "integrity": "sha512-V4WFr+/cCJxKldqsK5MfSpvY8kmMpqaiqJUaXM/h57fzdamnagLpCOKkT+TzBuDhjkSrxh96XpN7Xj9VlIClqA==", "cpu": [ "arm64" ], @@ -179,9 +179,9 @@ ] }, "node_modules/@agent-relay/broker-linux-x64": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-8.1.2.tgz", - "integrity": "sha512-7bnYYkdt881ZwVIYbP4gf4L+WCfEig+PTQW/NJfbe+fo/KhVjctPyoEdONCrYS7Vrft9UgfhqvrTEwTdjgp2bw==", + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-8.3.1-beta.0.tgz", + "integrity": "sha512-IJt6WTLjomMntMyudw7b8+NP/CW0jPi7krfCQWjHwBsqk7C5KhgzIxt0y44Dmyf9wiY+58N45wUvq+A55CR0sQ==", "cpu": [ "x64" ], @@ -192,9 +192,9 @@ ] }, "node_modules/@agent-relay/broker-win32-x64": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-8.1.2.tgz", - "integrity": "sha512-XcvEz3D4aTcCV3jAGVZVOmzf2m/n/tWulXOFgF+2+YeSw0EPMzPQza5EXXu4sGR2/6xSyL0OlEkLmI1nQD6KHg==", + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-8.3.1-beta.0.tgz", + "integrity": "sha512-84a5aPgtifCaq3kRC1xOuSa0pIojyfdOcrNYWjrrw/GEC0wi9Q/8w6Zu2S9hrZQH+IkrnNovnOa4p4Q1ngwryg==", "cpu": [ "x64" ], @@ -228,21 +228,29 @@ } }, "node_modules/@agent-relay/harness-driver": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-8.1.2.tgz", - "integrity": "sha512-Fiy1OswaTOZoVRvLJlrggXLJibbLUKuP/1s4JQ+joflDo0DAGC8KK6I9Im+cvRVK8DdTDI3RVFppVcV3aO5Hug==", + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-8.3.1-beta.0.tgz", + "integrity": "sha512-HrnEoD2qhEmg0zZWldQ8OZfuVFVlALQ3NGWLZqyyjkjlaT46IiyoLO0a47mc4rQIzOo1HmOvEl7VU6WgqzwoYQ==", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "8.1.2", + "@agent-relay/sdk": "8.3.1-beta.0", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "8.1.2", - "@agent-relay/broker-darwin-x64": "8.1.2", - "@agent-relay/broker-linux-arm64": "8.1.2", - "@agent-relay/broker-linux-x64": "8.1.2", - "@agent-relay/broker-win32-x64": "8.1.2" + "@agent-relay/broker-darwin-arm64": "8.3.1-beta.0", + "@agent-relay/broker-darwin-x64": "8.3.1-beta.0", + "@agent-relay/broker-linux-arm64": "8.3.1-beta.0", + "@agent-relay/broker-linux-x64": "8.3.1-beta.0", + "@agent-relay/broker-win32-x64": "8.3.1-beta.0" + } + }, + "node_modules/@agent-relay/harness-driver/node_modules/@agent-relay/sdk": { + "version": "8.3.1-beta.0", + "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-8.3.1-beta.0.tgz", + "integrity": "sha512-XZe9KCv+Xf47N0eWcHLPKBI55+qR8G++iDk7mYDN4Y2k4X0Mha2sGBp6vu+P9Bg/WMA9YGFjNVDwJYKiUGv9Og==", + "dependencies": { + "@relaycast/sdk": "^2.5.1" } }, "node_modules/@agent-relay/sdk": { @@ -6746,6 +6754,89 @@ "node": ">=20.9.0" } }, + "node_modules/agent-relay/node_modules/@agent-relay/broker-darwin-arm64": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-8.1.2.tgz", + "integrity": "sha512-zNJOPYqfoeedo9dxcXY8OCT/kOO6RMC/FQ4mb9jy0jvwaks8HRGRm4H7XE7TeW2oEW5LZ5QxF/Q8Err/dBbkQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/agent-relay/node_modules/@agent-relay/broker-darwin-x64": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-8.1.2.tgz", + "integrity": "sha512-ePkEFks2gwwIZb1409qExYRXc+JMnNck6eyrBmAY3GNUdoFao46QNzs3QwqKX11PsJcuaGbkZLxkZWrgReMCOw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/agent-relay/node_modules/@agent-relay/broker-linux-arm64": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-8.1.2.tgz", + "integrity": "sha512-/dEJ57innCtWQxi9jPFvlInf0G0oL436kk0OnZGCPIiA/fFOB5Iheg/r8dVzQWZoWkcD2TOUqUdFYomtICbIBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/agent-relay/node_modules/@agent-relay/broker-linux-x64": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-8.1.2.tgz", + "integrity": "sha512-7bnYYkdt881ZwVIYbP4gf4L+WCfEig+PTQW/NJfbe+fo/KhVjctPyoEdONCrYS7Vrft9UgfhqvrTEwTdjgp2bw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/agent-relay/node_modules/@agent-relay/broker-win32-x64": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-8.1.2.tgz", + "integrity": "sha512-XcvEz3D4aTcCV3jAGVZVOmzf2m/n/tWulXOFgF+2+YeSw0EPMzPQza5EXXu4sGR2/6xSyL0OlEkLmI1nQD6KHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-relay/node_modules/@agent-relay/harness-driver": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/harness-driver/-/harness-driver-8.1.2.tgz", + "integrity": "sha512-Fiy1OswaTOZoVRvLJlrggXLJibbLUKuP/1s4JQ+joflDo0DAGC8KK6I9Im+cvRVK8DdTDI3RVFppVcV3aO5Hug==", + "license": "Apache-2.0", + "dependencies": { + "@agent-relay/sdk": "8.1.2", + "ws": "^8.18.3", + "zod": "^3.23.8" + }, + "optionalDependencies": { + "@agent-relay/broker-darwin-arm64": "8.1.2", + "@agent-relay/broker-darwin-x64": "8.1.2", + "@agent-relay/broker-linux-arm64": "8.1.2", + "@agent-relay/broker-linux-x64": "8.1.2", + "@agent-relay/broker-win32-x64": "8.1.2" + } + }, "node_modules/agent-relay/node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", diff --git a/package.json b/package.json index a190beb1..c35ad4ce 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@agent-relay/cloud": "^8.1.2", - "@agent-relay/harness-driver": "^8.1.2", + "@agent-relay/harness-driver": "8.3.1-beta.0", "@agent-relay/sdk": "^8.1.2", "@agentworkforce/deploy": "^3.0.50", "@relayburn/sdk": "^3.2.0", diff --git a/src/main/broker.ts b/src/main/broker.ts index 8dc9a2df..9eeb8c99 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1387,12 +1387,8 @@ export class BrokerManager { } // Phase 1 of #125: the local broker stays the workspace creator, so the - // key is only threaded when explicitly pinned via env. The intersection - // type is the single cast site until @agent-relay/harness-driver - // PUBLISHES workspaceKey in RuntimeSpawnOptions (landed relay-side in - // 6419d59c; verified against the built 8.3.0+T3 dist locally) — the - // intersection erases to a no-op then and drops with the version bump. - const opts: AgentRelaySpawnOptions & { workspaceKey?: string } = { + // key is only threaded when explicitly pinned via env. + const opts: AgentRelaySpawnOptions = { cwd, brokerName: name, channels: nextChannels, From 2a14395c0c3cb9fb37ec531a2d26d5a9e69755c4 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 8 Jun 2026 08:45:12 +0200 Subject: [PATCH 5/6] Restore persona spawn coalescing --- src/main/broker.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/broker.ts b/src/main/broker.ts index 9eeb8c99..2054a75c 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -410,6 +410,13 @@ function spawnRequestKey( }) } +function personaSpawnRequestKey(projectId: string, personaId: string): string { + return JSON.stringify({ + projectId, + personaId + }) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -1257,6 +1264,7 @@ export class BrokerManager { private startPromises = new Map>() private revivePromises = new Map>() private inFlightSpawnRequests = new Map>() + private inFlightPersonaSpawnRequests = new Map>() // Which broker sessions (by session key) an agent name is registered on. // Both a project's local and cloud brokers join the same relay workspace, // so agent names are project-unique in practice — the set tracks which @@ -2598,13 +2606,29 @@ export class BrokerManager { } } - async spawnPersona(projectId: string, personaId: string): Promise<{ name: string; runtime: string; cli?: string }> { - const session = this.getSessionForProject(projectId) + async spawnPersona(projectId: string, personaId: string): Promise { const trimmedPersonaId = personaId.trim() if (!trimmedPersonaId) { throw new Error('Persona id is required') } + const requestKey = personaSpawnRequestKey(projectId, trimmedPersonaId) + const inFlight = this.inFlightPersonaSpawnRequests.get(requestKey) + if (inFlight) return inFlight + + let promise!: Promise + promise = this.spawnPersonaOnce(projectId, trimmedPersonaId).finally(() => { + if (this.inFlightPersonaSpawnRequests.get(requestKey) === promise) { + this.inFlightPersonaSpawnRequests.delete(requestKey) + } + }) + this.inFlightPersonaSpawnRequests.set(requestKey, promise) + return promise + } + + private async spawnPersonaOnce(projectId: string, trimmedPersonaId: string): Promise { + const session = this.getSessionForProject(projectId) + const command = resolveAgentWorkforceCommand(session.cwd) const persona = findWorkforcePersona(session.cwd, trimmedPersonaId, command) From 82cde91b10194b4bf874f75d4a9c39586c308830 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 8 Jun 2026 09:15:21 +0200 Subject: [PATCH 6/6] Refresh MCP packaged resources --- electron-builder.mcp-resources.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/electron-builder.mcp-resources.yml b/electron-builder.mcp-resources.yml index 3fe913e8..de240acc 100644 --- a/electron-builder.mcp-resources.yml +++ b/electron-builder.mcp-resources.yml @@ -14,14 +14,8 @@ extraResources: - from: node_modules to: agent-relay-mcp/node_modules filter: - - '@agent-relay/broker-darwin-arm64/**' - - '@agent-relay/broker-darwin-x64/**' - - '@agent-relay/broker-linux-arm64/**' - - '@agent-relay/broker-linux-x64/**' - - '@agent-relay/broker-win32-x64/**' - '@agent-relay/cloud/**' - '@agent-relay/config/**' - - '@agent-relay/harness-driver/**' - '@agent-relay/sdk/**' - '@agent-relay/utils/**' - '@aws-crypto/crc32/**' @@ -120,6 +114,12 @@ extraResources: - 'accepts/node_modules/mime-db/**' - 'accepts/node_modules/mime-types/**' - 'agent-relay/**' + - 'agent-relay/node_modules/@agent-relay/broker-darwin-arm64/**' + - 'agent-relay/node_modules/@agent-relay/broker-darwin-x64/**' + - 'agent-relay/node_modules/@agent-relay/broker-linux-arm64/**' + - 'agent-relay/node_modules/@agent-relay/broker-linux-x64/**' + - 'agent-relay/node_modules/@agent-relay/broker-win32-x64/**' + - 'agent-relay/node_modules/@agent-relay/harness-driver/**' - 'agent-relay/node_modules/@esbuild/aix-ppc64/**' - 'agent-relay/node_modules/@esbuild/android-arm/**' - 'agent-relay/node_modules/@esbuild/android-arm64/**'