DO-NOT-MERGE: Split #132 cloud workspace key work#146
Conversation
📝 WalkthroughWalkthroughThe 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. ChangesMulti-Instance Observer Stage 1 Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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
}
}| 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 | ||
| } |
There was a problem hiding this comment.
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
}| 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 }>>() |
There was a problem hiding this comment.
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 }>>()| const spawned = await session.client.spawnPty(nextInput) | ||
| const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name) | ||
| const spawnedName = safeSpawned.name | ||
| const spawnedName = spawned.name || nextInput.name |
There was a problem hiding this comment.
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| const safeSpawned = normalizeSpawnPtyResult(spawned, nextInput.name, input.resolvedHarness) | ||
| this.rememberAgentSession(safeSpawned.name, sessionKeyFor(session)) | ||
| return safeSpawned | ||
| this.rememberAgentSession(spawned.name || nextInput.name, sessionKeyFor(session)) |
There was a problem hiding this comment.
|
Reviewed PR #146 from 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:
I also tried |
|
✅ pr-reviewer applied fixes — committed and pushed Reviewed PR #146 and fixed a validated regression in the changed broker spawn path. Changes made:
Verification run:
All passed locally. I did not verify remote CI status or GitHub mergeability from here. |
|
✅ pr-reviewer applied fixes — committed and pushed Reviewed PR #146 and fixed two validated issues in the current checkout:
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:
Build passed with existing Vite dynamic/static import chunk warnings. I’m not printing |
8ecb1cc to
2a14395
Compare
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (7)
docs/specs/2026-06-06-multi-instance-stage1-observer.mdelectron-builder.mcp-resources.ymlpackage.jsonsrc/main/broker.test.tssrc/main/broker.tssrc/main/cloud-agent.test.tssrc/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). |
There was a problem hiding this comment.
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 -->
| 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 |
There was a problem hiding this comment.
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.
| 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.
| 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 | ||
| ) | ||
| }) |
There was a problem hiding this comment.
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
| // 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>() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| // 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>() |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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>
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 buildCross-link: #132