diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index e6718b63541..edeed33a515 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -106,6 +106,10 @@ export async function sendGroupMessage(args: { const { terminals } = await runtime.listTerminals(undefined, undefined, { includeVisualLayouts: false }) + // Immediately after the only await: a coordinator taken over during terminal discovery is + // read-only, and must be told that whatever else is wrong with its recipient set. Everything + // below is synchronous, so no takeover can interleave between here and the insert. + revalidateLegacyCoordinator?.() // Structured workers are on no PTY surface, so `listTerminals` cannot see them and a broadcast // silently missed every one. Composed here rather than inside `listTerminals`, whose result is // published to paired clients and to consumers that assume a summary is writable. @@ -165,7 +169,6 @@ export async function sendGroupMessage(args: { ) } - revalidateLegacyCoordinator?.() const threadId = params.threadId ?? `thread_${Date.now()}` const messages = db.insertMessages( uniqueRecipients.map((resolution) => ({ diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 71daf09b0e3..8ef1825f558 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -315,13 +315,26 @@ describe('legacy coordinator takeover races', () => { it('partitions a coordinator group send by legacy recipient contract', async () => { const harness = createHarness() + // A second worker on the CURRENT contract in the same adopted Run. Group addresses reach a + // Run's Dispatches, so the partition needs two Dispatches, not a Dispatch and a loose pane. + const currentTask = harness.db.createTask({ + runId: harness.adoptedRunId, + spec: 'current-contract assignment', + createdByTerminalHandle: COORDINATOR_HANDLE + }) + const currentDispatch = createRootDispatch( + harness.db, + currentTask.id, + 'term_current_worker', + 'tab_current_worker:22222222-2222-4222-8222-222222222222' + ) vi.mocked(harness.runtime.getTerminalPaneKey).mockImplementation((handle) => handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : handle === 'term_current_worker' - ? 'tab_current_worker:leaf_current_worker' + ? 'tab_current_worker:22222222-2222-4222-8222-222222222222' : null ) vi.spyOn(harness.runtime, 'listTerminals').mockResolvedValue({ @@ -355,7 +368,7 @@ describe('legacy coordinator takeover races', () => { }), expect.objectContaining({ run_id: harness.adoptedRunId, - to_handle: 'term_current_worker', + to_handle: `dispatch:${currentDispatch.id}`, delivery_contract: 'current_delivery' }) ])