Skip to content

DO-NOT-MERGE: Split #132 cloud workspace key work#146

Merged
kjgbot merged 6 commits into
mainfrom
split/132-cloud-workspace-key
Jun 8, 2026
Merged

DO-NOT-MERGE: Split #132 cloud workspace key work#146
kjgbot merged 6 commits into
mainfrom
split/132-cloud-workspace-key

Conversation

@kjgbot

@kjgbot kjgbot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

DO-NOT-MERGE: merge hold remains in force; this PR is opened for review only.

Split from #132 / fix/slack-integration-event-context-retry.

Summary:

Restart-branch decision note:

Verification:

  • npx --yes vitest run src/main/broker.test.ts src/main/cloud-agent.test.ts (57/57)
  • npm run build

Cross-link: #132

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR implements workspace key coordination infrastructure across broker, cloud-agent, and test layers to support relay workspace management for multi-instance observers. It includes a design specification, workspace key classification, broker connection reuse validation, cloud sandbox mismatch detection, and comprehensive test coverage.

Changes

Multi-Instance Observer Stage 1 Implementation

Layer / File(s) Summary
Stage 1 design specification for read-only observer
docs/specs/2026-06-06-multi-instance-stage1-observer.md
New specification document defining multi-instance observer Stage 1 goals, instance naming scheme with persisted machine slugs, shared project definitions at /pear/project.json, invite/join token format, read-only enforcement split between Pear-layer and broker-layer, PTY observation with sequence-number deduplication, minimal presence protocol, proposed IPC/type additions, and build plan.
Workspace key classification and broker contracts
src/main/broker.ts
Adds classifyWorkspaceJoinFailure helper to distinguish join errors as rejected (fatal) vs rate-limited (retryable), extends CloudAgentSandboxHandle with sentWorkspaceKey field for mismatch detection, and adds workspaceKeyForProject method to expose local broker workspace key.
Broker workspace key pinning and connection reuse validation
src/main/broker.ts
Threads AGENT_RELAY_WORKSPACE_KEY environment variable through broker startup into both connectExistingBroker and local broker spawn, validates existing broker workspace key against pinned key and disconnects on mismatch, conditionally includes workspace key in spawn options with redacted logging.
Cloud sandbox workspace key mismatch detection
src/main/broker.ts
attachCloudSandbox fetches session metadata, compares sentWorkspaceKey against sandbox workspace_key, logs errors and emits new cloud_workspace_key_mismatch event to detect stale broker behavior.
Cloud agent workspace key capture and forwarding
src/main/cloud-agent.ts
CloudAgentManager tracks sent workspace keys in sentWorkspaceKeys map, extends CloudBrokerAdapter with optional workspaceKeyForProject method, warmBox queries broker for workspace key and passes relayBroker info to fetchBox POST payload, connectBroker augments sandbox with sentWorkspaceKey for later attachment validation.
Spawn concurrency and result normalization improvements
src/main/broker.ts
spawnAgent and spawnAgentOnce return types narrowed to { name; runtime } removing full BrokerSpawnResult, persona spawning deduplication uses separate inFlightPersonaSpawnRequests map, spawnAgentOnce removes normalization and returns raw spawned object.
Broker test infrastructure for workspace key validation
src/main/broker.test.ts
Test harness extended with nextCloudSessionMetadata and nextConnectedSessionMetadata queues enabling per-test control of getSession() return values, captures/restores AGENT_RELAY_WORKSPACE_KEY environment variable across test suite with per-test reset of metadata queues.
Broker workspace key test coverage
src/main/broker.test.ts
Comprehensive new test cases covering local broker spawn pinning, workspace key lookup for cloud provisioning with omit-when-missing case, cloud workspace mismatch event emission across matching/mismatching/keyless scenarios, and connection reuse rejection on key mismatch with respawn; removes normalization test.
Cloud agent test helpers and workspace key assertions
src/main/cloud-agent.test.ts
Introduces helpers to locate and parse box HTTP requests, refactors warm/attach/detach tests to verify workspace-key behavior distinguishing workspace token from relay workspace, keyless warms, and detach clearing of sent keys; validates updateMountPaths payload scoping to mount paths only.
Build configuration and dependency updates
electron-builder.mcp-resources.yml, package.json
Reorganizes electron-builder resource globs to scope @agent-relay broker/harness-driver under agent-relay/node_modules paths instead of top-level entries; pins @agent-relay/harness-driver to 8.3.1-beta.0.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #125 — The PR directly implements the workspace key tracking and validation needed to prevent cloud and local brokers from joining different relay workspaces, addressing the core issue via workspaceKeyForProject, sentWorkspaceKey field, and mismatch detection.
  • #127 — The PR implements Stage 1 multi-instance observer prerequisites including broker workspace key coordination, join-failure classification, and the broker-layer infrastructure specified in the design document.

Poem

🐰 A second instance joins the dance,
With workspace keys as safety gates—
Read-only eyes, no write's advance,
Stage One's observer celebrates!
Relay workspace, now coordinated,
Together, yet appropriately gated.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately identifies the PR as a split of work from #132 focusing on cloud workspace key functionality, which matches the substantial changes in broker.ts and cloud-agent.ts.
Description check ✅ Passed The description clearly explains the PR's purpose, links to the originating issue (#132/#125), summarizes key changes (workspace key threading, validation, design spec), and provides verification steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/132-cloud-workspace-key

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 and usage tips.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements workspace key propagation from local brokers to cloud sandboxes to ensure they share a single relay workspace, adding a tripwire to detect stale sandbox brokers. Key feedback points out that workspaceKeyForProject should await revivePromises to avoid returning undefined during session revivals, and classifyWorkspaceJoinFailure should robustly extract messages from plain objects to handle serialized IPC errors. Additionally, request coalescing for spawnPersona should be restored using a dedicated map, and defensive optional chaining should be used on spawned agent results to prevent potential runtime TypeError crashes.

Important

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

Comment thread src/main/broker.ts
Comment on lines +1501 to +1514
async workspaceKeyForProject(projectId: string): Promise<string | undefined> {
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
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In workspaceKeyForProject, only startPromises is awaited. However, if a local broker session is currently being revived (i.e., there is an active promise in revivePromises), this.sessions.get(normalizedProjectId) will return undefined because reviveSession drops the session immediately before restarting it.

This causes workspaceKeyForProject to prematurely return undefined instead of waiting for the revive to complete. Consequently, any concurrent cloud agent provisioning/warming will fail to obtain the local workspace key and will fallback to creating an isolated, keyless workspace.

We should await revivePromises just like we do in getOrAwaitSessionsForProject.

  async workspaceKeyForProject(projectId: string): Promise<string | undefined> {
    const normalizedProjectId = projectId.trim()
    if (!normalizedProjectId) return undefined
    const revivePromise = this.revivePromises.get(normalizedProjectId)
    if (revivePromise) await revivePromise.catch(() => 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
    }
  }

Comment thread src/main/broker.ts
Comment on lines +147 to +152
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In Electron applications, errors passed across IPC or serialized/deserialized boundaries often lose their prototype and are no longer instances of Error, but plain objects containing a message property. Since toErrorMessage only extracts .message from instanceof Error and falls back to String(err) (which yields "[object Object]" for plain objects), the regex checks in classifyWorkspaceJoinFailure will fail for these serialized errors.

We should robustly extract the message property from plain objects if it exists.

export function classifyWorkspaceJoinFailure(err: unknown): 'rejected' | 'rate-limited' | null {
  const message = err && typeof err === 'object' && 'message' in err && typeof err.message === 'string'
    ? err.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
}

Comment thread src/main/broker.ts
private startPromises = new Map<string, Promise<boolean | void>>()
private revivePromises = new Map<string, Promise<boolean>>()
private inFlightSpawnRequests = new Map<string, Promise<BrokerSpawnResult>>()
private inFlightSpawnRequests = new Map<string, Promise<{ name: string; runtime: string }>>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To support restoring the concurrent request coalescing/deduplication for spawnPersona (which was removed in this PR), we should declare a separate map specifically for in-flight persona spawn requests. This keeps the types clean and prevents duplicate concurrent spawns of the same persona.

  private inFlightSpawnRequests = new Map<string, Promise<{ name: string; runtime: string }>>()
  private inFlightPersonaSpawnRequests = new Map<string, Promise<{ name: string; runtime: string; cli?: string }>>()

Comment thread src/main/broker.ts
const spawned = await session.client.spawnPty(nextInput)
const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name)
const spawnedName = safeSpawned.name
const spawnedName = spawned.name || nextInput.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Since the robust normalizeSpawnPtyResult helper was removed in this PR, we are now directly accessing spawned.name. If the broker returns an unexpected payload or if spawned is null/undefined at runtime, this will throw a TypeError and crash the spawn flow.

Using optional chaining spawned?.name is a safer, more defensive approach.

        const spawnedName = spawned?.name || nextInput.name

Comment thread src/main/broker.ts Outdated
const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name, input.resolvedHarness)
this.rememberAgentSession(safeSpawned.name, sessionKeyFor(session))
return safeSpawned
this.rememberAgentSession(spawned.name || nextInput.name, sessionKeyFor(session))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to spawnAgentOnce, we should use optional chaining spawned?.name here to defensively guard against potential null/undefined values or unexpected payloads returned by the broker.

        this.rememberAgentSession(spawned?.name || nextInput.name, sessionKeyFor(session))

@agent-relay-code

Copy link
Copy Markdown
Contributor

⚠️ pr-reviewer push failed (exit 128) — fixes were not applied to the PR. The notes below are advisory and were not pushed.

Reviewed PR #146 from .workforce/pr.diff and fixed a regression in the PR changes.

I restored clone-safe broker spawn results and persona spawn coalescing in broker.ts. The PR had removed normalization, which could return raw broker objects with functions across IPC. I also restored regression coverage in broker.test.ts, including duplicate persona spawn coalescing.

Validation run:

  • npm ci
  • npx vitest run src/main/broker.test.ts passed: 42 tests
  • npx vitest run src/main/cloud-agent.test.ts passed: 18 tests
  • npx electron-vite build passed

I also tried npx tsc -p tsconfig.node.json --noEmit, but that command is not currently a clean repo check in this checkout: it pulls in many existing test/config/type errors unrelated to this PR. I did not use git or gh, so I cannot claim remote CI or mergeability is verified.

agent-relay-code Bot added a commit that referenced this pull request Jun 7, 2026
@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer applied fixes — committed and pushed cda07de to this PR. The notes below describe what changed.

Reviewed PR #146 and fixed a validated regression in the changed broker spawn path.

Changes made:

  • Restored clone-safe normalization for BrokerManager.spawnAgent() and persona spawns in src/main/broker.ts.
  • Restored keyed in-flight coalescing for duplicate spawnPersona() requests.
  • Re-added regression coverage in src/main/broker.test.ts for clone-safe spawn payloads and duplicate persona spawn suppression.

Verification run:

  • npx vitest run src/main/broker.test.ts src/main/cloud-agent.test.ts
  • npx tsc --noEmit
  • npm test
  • npx vitest run

All passed locally. I did not verify remote CI status or GitHub mergeability from here.

agent-relay-code Bot added a commit that referenced this pull request Jun 7, 2026
@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer applied fixes — committed and pushed 83dc517 to this PR. The notes below describe what changed.

Reviewed PR #146 and fixed two validated issues in the current checkout:

  • src/main/broker.ts: pinned AGENT_RELAY_WORKSPACE_KEY now participates in existing-connection reuse. If a connection file points at a broker with a different workspace key, Pear disconnects it and starts through the pinned spawn path instead of silently joining the wrong workspace.
  • src/main/broker.ts: redacted the explicit workspace key in the broker startup log while still passing the full key to HarnessDriverClient.spawn.

Added regression coverage in src/main/broker.test.ts for mismatched pinned connection reuse, and extended the existing explicit-key test to assert the key is not logged in full.

Verification run:

  • npx vitest run src/main/broker.test.ts
  • npx vitest run src/main/cloud-agent.test.ts
  • npx tsc --noEmit
  • npm run build
  • npm run verify:mcp-resources-drift

Build passed with existing Vite dynamic/static import chunk warnings. I’m not printing READY because I cannot verify GitHub-side required checks, mergeability, or pending CI state from this sandbox.

@kjgbot kjgbot force-pushed the split/132-cloud-workspace-key branch from 8ecb1cc to 2a14395 Compare June 8, 2026 06:45
@kjgbot kjgbot marked this pull request as ready for review June 8, 2026 07:16

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/specs/2026-06-06-multi-instance-stage1-observer.md`:
- Line 5: The line starting with "`#126` (remote host support — not started)" is
being parsed as a Markdown heading and the fenced code blocks lack language
annotations; update the spec so the literal issue numbers and lists are not
treated as headings by either escaping the leading hash (e.g., "\`#126` ...") or
wrapping them in a fenced code block, and add language tags (e.g., ```text) to
both fenced blocks that contain the InviteToken JSON and the issue list; apply
the same fix to the other occurrences that start with hashes in the doc (the
blocks containing "InviteToken = base64url(JSON{...})" and the list starting
"`#125` relay strict-join + named instances") so parsers and linters render them
consistently.
- Around line 181-183: Remove the reopened aliasing decision that conflicts with
the previously rejected option and make the spec unambiguous: locate the
paragraph that re-raises "relay-worker: name alias for the legacy un-suffixed
host broker name vs pear publishing both names" and either delete it or update
it to explicitly state the same final choice declared earlier (the rejected
aliasing option), then ensure any references to per-connection readonly
capability ("relay-worker: per-connection readonly capability — connection API
or") remain consistent with that final aliasing decision and update surrounding
wording to reflect a single authoritative contract for implementers.

In `@src/main/cloud-agent.test.ts`:
- Around line 337-404: Add a regression test that simulates two concurrent calls
to CloudAgentManager.attach('project-1','cloud-agent-1') and asserts that
brokerManager.workspaceKeyForProject is called only once and only one box POST
is made; implement by mocking
mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project'),
invoking Promise.all([manager.attach(...), manager.attach(...)]) (or otherwise
firing two attaches before the first resolves), then assert
mock.brokerManager.workspaceKeyForProject was called once, expectBoxPostBody was
invoked once for the relay/relayfile POST, and
mock.brokerManager.attachCloudSandbox was invoked only once with an
objectContaining({ sentWorkspaceKey: 'rk_live_project' }). Ensure the test
references CloudAgentManager.attach, mock.brokerManager.workspaceKeyForProject,
expectBoxPostBody, and mock.brokerManager.attachCloudSandbox so the
duplicate/replay behavior is validated.

In `@src/main/cloud-agent.ts`:
- Around line 473-476: sentWorkspaceKeys is keyed only by projectId which allows
races when multiple warmBox() calls for the same project exist; change the
design to store the sent workspace key on the prewarm/sandbox entry (the object
tracked in prewarms keyed by (projectId, cloudAgentId)) instead of using the
global Map<string,string>. Update the places that set and read the key (where
sentWorkspaceKeys.set(...) is called during warmBox() and where connectBroker()
currently reads sentWorkspaceKeys.get(projectId)) to write/read the key from the
corresponding prewarm/sandbox object (attach a field like sentWorkspaceKey to
that entry) so each warm instance has its own stored key and connectBroker()
uses the matching prewarm/sandbox identity to validate against
cloud_workspace_key_mismatch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e06bc22-01d1-45b5-a4ba-2a6d95eaa767

📥 Commits

Reviewing files that changed from the base of the PR and between c660556 and 82cde91.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • docs/specs/2026-06-06-multi-instance-stage1-observer.md
  • electron-builder.mcp-resources.yml
  • package.json
  • src/main/broker.test.ts
  • src/main/broker.ts
  • src/main/cloud-agent.test.ts
  • src/main/cloud-agent.ts


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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix markdown parsing/lint issues in this spec.

Line 5 starts with #126, which markdown parsers treat as a heading. Also, both fenced blocks should declare a language for consistent rendering and lint cleanliness.

Suggested doc patch
-#126 (remote host support — not started).
+\`#126` (remote host support — not started).
-```
+```text
 InviteToken = base64url(JSON{
   v: 1,
   workspaceKey,          // from workspaceKeyForProject(projectId)
@@
 })

```diff
-```
+```text
 `#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
@@
 relay: broker-side readonly capability     [needed for multi-user; stub OK same-user]
</details>


Also applies to: 77-86, 165-172

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 5-5: No space after hash on atx style heading

(MD018, no-missing-space-atx)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/specs/2026-06-06-multi-instance-stage1-observer.md at line 5, The line
starting with "#126 (remote host support — not started)" is being parsed as a
Markdown heading and the fenced code blocks lack language annotations; update
the spec so the literal issue numbers and lists are not treated as headings by
either escaping the leading hash (e.g., "`#126 ...") or wrapping them in a fenced code block, and add language tags (e.g., ```text) to both fenced blocks that contain the InviteToken JSON and the issue list; apply the same fix to the other occurrences that start with hashes in the doc (the blocks containing "InviteToken = base64url(JSON{...})" and the list starting "#125` relay
strict-join + named instances") so parsers and linters render them consistently.


</details>

<!-- fingerprinting:phantom:poseidon:hawk -->

<!-- cr-comment:v1:b167d3e1f56aa5f041098a1e -->

_Source: Linters/SAST tools_

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +181 to +183
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Resolve the aliasing decision conflict in the spec.

Line 181 reopens a decision that Lines 47-52 already mark as rejected, which makes the contract ambiguous for implementers.

Suggested doc patch
-1. relay-worker: name alias for the legacy un-suffixed host broker name vs
-   pear publishing both names — which is cheaper broker-side?
+1. (Resolved 2026-06-06) Keep broker-side aliasing out of scope; Pear publishes
+   both `brokerName` and `legacyBrokerName` in shared project metadata.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
1. (Resolved 2026-06-06) Keep broker-side aliasing out of scope; Pear publishes
both `brokerName` and `legacyBrokerName` in shared project metadata.
2. relay-worker: per-connection readonly capability — connection API or
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/specs/2026-06-06-multi-instance-stage1-observer.md` around lines 181 -
183, Remove the reopened aliasing decision that conflicts with the previously
rejected option and make the spec unambiguous: locate the paragraph that
re-raises "relay-worker: name alias for the legacy un-suffixed host broker name
vs pear publishing both names" and either delete it or update it to explicitly
state the same final choice declared earlier (the rejected aliasing option),
then ensure any references to per-connection readonly capability ("relay-worker:
per-connection readonly capability — connection API or") remain consistent with
that final aliasing decision and update surrounding wording to reflect a single
authoritative contract for implementers.

Comment on lines +337 to +404
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<void>
}).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
)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a duplicate-attach regression for the workspace-key path.

These tests cover the happy path and detach cleanup, but they still leave the keyed in-flight behavior unpinned. Please add a case where two attach('project-1', 'cloud-agent-1') calls race and assert there's only one workspaceKeyForProject lookup and one POST /box; that's the duplicate/replay path this change now depends on.

As per coding guidelines, "Add regression tests when touching broker start, event streaming, PTY buffering, spawned personas, or integration notifications. Include duplicate/replay cases, not just the happy path." Based on learnings, "Coalesce concurrent starts or attaches with keyed in-flight promises."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/cloud-agent.test.ts` around lines 337 - 404, Add a regression test
that simulates two concurrent calls to
CloudAgentManager.attach('project-1','cloud-agent-1') and asserts that
brokerManager.workspaceKeyForProject is called only once and only one box POST
is made; implement by mocking
mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project'),
invoking Promise.all([manager.attach(...), manager.attach(...)]) (or otherwise
firing two attaches before the first resolves), then assert
mock.brokerManager.workspaceKeyForProject was called once, expectBoxPostBody was
invoked once for the relay/relayfile POST, and
mock.brokerManager.attachCloudSandbox was invoked only once with an
objectContaining({ sentWorkspaceKey: 'rk_live_project' }). Ensure the test
references CloudAgentManager.attach, mock.brokerManager.workspaceKeyForProject,
expectBoxPostBody, and mock.brokerManager.attachCloudSandbox so the
duplicate/replay behavior is validated.

Sources: Coding guidelines, Learnings

Comment thread src/main/cloud-agent.ts
Comment on lines +473 to +476
// 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<string, string>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Key sentWorkspaceKeys by the warm instance, not just projectId.

prewarms are keyed by (projectId, cloudAgentId), so the same project can have multiple overlapping warmBox() calls. This cache is keyed only by projectId, and connectBroker() later reads only that slot. If warm A and warm B race around a local-broker restart, warm B can overwrite warm A's key and you'll attach sandbox A with sandbox B's sentWorkspaceKey, which false-trips the new cloud_workspace_key_mismatch validation downstream. Carry the sent key on the prewarm entry / sandbox identity instead of a per-project map.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/cloud-agent.ts` around lines 473 - 476, sentWorkspaceKeys is keyed
only by projectId which allows races when multiple warmBox() calls for the same
project exist; change the design to store the sent workspace key on the
prewarm/sandbox entry (the object tracked in prewarms keyed by (projectId,
cloudAgentId)) instead of using the global Map<string,string>. Update the places
that set and read the key (where sentWorkspaceKeys.set(...) is called during
warmBox() and where connectBroker() currently reads
sentWorkspaceKeys.get(projectId)) to write/read the key from the corresponding
prewarm/sandbox object (attach a field like sentWorkspaceKey to that entry) so
each warm instance has its own stored key and connectBroker() uses the matching
prewarm/sandbox identity to validate against cloud_workspace_key_mismatch.

@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.

2 issues found across 8 files

You’re at about 90% 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.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/specs/2026-06-06-multi-instance-stage1-observer.md">

<violation number="1" location="docs/specs/2026-06-06-multi-instance-stage1-observer.md:147">
P2: Presence channel keying uses local `projectId`, which is not stable across instances; this will split presence into different channels for the same shared project.</violation>
</file>

<file name="src/main/cloud-agent.ts">

<violation number="1" location="src/main/cloud-agent.ts:476">
P1: `sentWorkspaceKeys` is keyed only by `projectId`, but `prewarms` are keyed by `(projectId, cloudAgentId)` — the same project can have multiple overlapping `warmBox()` calls. If two warm operations race (e.g. around a local-broker restart), the second overwrites the first's entry and `connectBroker()` will later read the wrong key for sandbox A, false-tripping the `cloud_workspace_key_mismatch` validation. Key the sent workspace key by the prewarm entry or sandbox identity (e.g. `${projectId}:${cloudAgentId}`) instead of a flat per-project map.</violation>
</file>

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

Re-trigger cubic

Comment thread src/main/cloud-agent.ts
// 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<string, string>()

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: sentWorkspaceKeys is keyed only by projectId, but prewarms are keyed by (projectId, cloudAgentId) — the same project can have multiple overlapping warmBox() calls. If two warm operations race (e.g. around a local-broker restart), the second overwrites the first's entry and connectBroker() will later read the wrong key for sandbox A, false-tripping the cloud_workspace_key_mismatch validation. Key the sent workspace key by the prewarm entry or sandbox identity (e.g. ${projectId}:${cloudAgentId}) instead of a flat per-project map.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/cloud-agent.ts, line 476:

<comment>`sentWorkspaceKeys` is keyed only by `projectId`, but `prewarms` are keyed by `(projectId, cloudAgentId)` — the same project can have multiple overlapping `warmBox()` calls. If two warm operations race (e.g. around a local-broker restart), the second overwrites the first's entry and `connectBroker()` will later read the wrong key for sandbox A, false-tripping the `cloud_workspace_key_mismatch` validation. Key the sent workspace key by the prewarm entry or sandbox identity (e.g. `${projectId}:${cloudAgentId}`) instead of a flat per-project map.</comment>

<file context>
@@ -469,6 +470,10 @@ export class CloudAgentManager {
+  // 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<string, string>()
   private prewarms = new Map<string, PrewarmEntry>()
   private canceledAttaches = new Set<string>()
</file context>

## 5. Minimal presence (Stage 1 slice)

- Each instance publishes `{instanceName, role, joinedAt}` on a relaycast
channel `#pear-presence-<projectId>` on join, tombstone on clean exit,

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: Presence channel keying uses local projectId, which is not stable across instances; this will split presence into different channels for the same shared project.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/specs/2026-06-06-multi-instance-stage1-observer.md, line 147:

<comment>Presence channel keying uses local `projectId`, which is not stable across instances; this will split presence into different channels for the same shared project.</comment>

<file context>
@@ -0,0 +1,200 @@
+## 5. Minimal presence (Stage 1 slice)
+
+- Each instance publishes `{instanceName, role, joinedAt}` on a relaycast
+  channel `#pear-presence-<projectId>` 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.
</file context>

@kjgbot kjgbot merged commit a8a32d6 into main Jun 8, 2026
6 of 8 checks passed
@kjgbot kjgbot deleted the split/132-cloud-workspace-key branch June 8, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant