From e84270fa04898c19b00441646b51b11b5c196dc7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 13 Sep 2026 16:56:51 -0400 Subject: [PATCH] fix(mobile): preserve raw RPC rejection timing Return the transport promise directly and interpret replies separately so sibling Promise.all rejection order cannot change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../MobileAgentSessionHistoryPanel.tsx | 2 +- .../use-new-workspace-create-submit.ts | 2 +- mobile/src/home/mobile-home-host-requests.ts | 2 +- .../src/host-screen/use-host-repo-metadata.ts | 4 +++- .../session/mobile-new-tab-agent-loader.ts | 2 +- .../session/use-pr-bot-author-overrides.ts | 2 +- .../use-mobile-tasks-runtime-hydration.tsx | 2 +- ...-mobile-tasks-workspace-create-actions.tsx | 2 +- .../src/transport/rpc-operation-contract.ts | 3 --- mobile/src/transport/rpc-operation.ts | 16 ++++++------- .../settings-read-operations.test.ts | 24 +++++++++---------- 11 files changed, 29 insertions(+), 32 deletions(-) diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index a8e41664f8f..bc511602f3d 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -406,7 +406,7 @@ async function loadMobileResumeMetadata(client: Pick): projectGroupResponse?.ok === true ? (projectGroupResponse.result as { groups?: MobileAiVaultResumeProjectGroup[] }) : null - const settingsResult = settingsResponse?.interpret() + const settingsResult = settingsResponse ? optionalSettingsRead.interpret(settingsResponse) : null const settings = settingsResult?.accepted ? (settingsResult.value as MobileAiVaultResumeSettings | null | undefined) : null diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 94eb97f3dbc..0b260e1dd74 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -89,7 +89,7 @@ export function useNewWorkspaceCreateSubmit(args: { let latestRuntimeSettings = args.runtimeSettings try { const settingsReply = await settingsRead.request(client) - const settings = settingsReply.interpret() + const settings = settingsRead.interpret(settingsReply) if (settings.accepted) { latestRuntimeSettings = settings.value as NewWorktreeRuntimeSettings args.setRuntimeSettings(latestRuntimeSettings) diff --git a/mobile/src/home/mobile-home-host-requests.ts b/mobile/src/home/mobile-home-host-requests.ts index a5845227058..61ce2626101 100644 --- a/mobile/src/home/mobile-home-host-requests.ts +++ b/mobile/src/home/mobile-home-host-requests.ts @@ -82,7 +82,7 @@ export function fetchMobileHomeTaskProviders( if (disposed()) { return } - const settingsResult = settingsResponse.interpret() + const settingsResult = settingsRead.interpret(settingsResponse) const settings = settingsResult.accepted ? ((settingsResult.value ?? {}) as HomeTaskSettings) : {} diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 4a9e7072f38..b63199e9676 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -133,7 +133,9 @@ export function useHostRepoMetadata(args: { if (clientRef.current !== requestClient || hostId !== requestHostId) { return } - const hostSettingsResult = hostSettings?.interpret() + const hostSettingsResult = hostSettings + ? optionalSettingsRead.interpret(hostSettings) + : null setHostLabelById( buildHostLabelById({ sshTargets: readSshTargets(sshTargets?.ok ? sshTargets.result : null), diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 03a324ec69e..0767cca3f13 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -27,7 +27,7 @@ export async function loadMobileNewTabAgentOptions(args: { newTabSettingsRead.request(client), detectedAgentsRequest ]) - const readSettings = settingsResponse.interpret() + const readSettings = newTabSettingsRead.interpret(settingsResponse) if (!detectedResponse.ok) { throw new Error((detectedResponse as RpcFailure).error.message) } diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts index 5a89ebaee70..5bce59d6d3c 100644 --- a/mobile/src/session/use-pr-bot-author-overrides.ts +++ b/mobile/src/session/use-pr-bot-author-overrides.ts @@ -39,7 +39,7 @@ export function usePRBotAuthorOverrides( if (stale) { return } - const overrides = response.interpret() + const overrides = botOverridesRead.interpret(response) if (overrides.accepted) { setLogins(overrides.value) } diff --git a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx index 692cba4d5b7..7074011e86c 100644 --- a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx +++ b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx @@ -260,7 +260,7 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel return } - const settingsResult = settingsResponse.interpret() + const settingsResult = settingsRead.interpret(settingsResponse) const settings = settingsResult.accepted ? ((settingsResult.value ?? {}) as RuntimeTaskSettings) : {} diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx index b9c890399a9..6e0ebd05f30 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx @@ -74,7 +74,7 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod let latestRuntimeTaskSettings = runtimeTaskSettings try { const settingsReply = await settingsRead.request(client) - const settingsResult = settingsReply.interpret() + const settingsResult = settingsRead.interpret(settingsReply) if (settingsResult.accepted) { latestRuntimeTaskSettings = (settingsResult.value ?? {}) as RuntimeTaskSettings setRuntimeTaskSettings(latestRuntimeTaskSettings) diff --git a/mobile/src/transport/rpc-operation-contract.ts b/mobile/src/transport/rpc-operation-contract.ts index b45693edaba..23bc012319c 100644 --- a/mobile/src/transport/rpc-operation-contract.ts +++ b/mobile/src/transport/rpc-operation-contract.ts @@ -178,6 +178,3 @@ export type LegacyResultRpcDefinition< acceptance: Acceptance read: RpcCompatibleReader } - -/** Only the bound policy can turn this opaque reply into a value, after caller guards. */ -export type DeferredRpcInterpretation = { readonly interpret: () => Value } diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts index a5e0231d962..d50d60699a8 100644 --- a/mobile/src/transport/rpc-operation.ts +++ b/mobile/src/transport/rpc-operation.ts @@ -25,8 +25,7 @@ import type { RequireResultRpcDefinition, StreamOpenerRpcDefinition, RpcVerdict, - LegacyResultRpcDefinition, - DeferredRpcInterpretation + LegacyResultRpcDefinition } from './rpc-operation-contract' type RpcOperationDefinitionInput = @@ -253,7 +252,7 @@ type RpcSendArguments = ? [params?: RpcSendParams, options?: SendRequestOptions] : [params: RpcSendParams, options?: SendRequestOptions] -/** Binds sending and interpretation without exposing envelopes or capturing transport failures. */ +/** Binds sending and interpretation while preserving the transport promise identity. */ export function bindDeferredRpcOperation< Method extends RpcMethodName, Acceptance extends RpcAcceptanceName, @@ -261,14 +260,10 @@ export function bindDeferredRpcOperation< Value >(operation: RpcOperation) { type Verdict = RpcVerdict - const defer = (response: RpcResponse): DeferredRpcInterpretation => ({ - interpret: () => - interpretRpcOutcome(operation, classifyRpcReply(operation, response)) as Verdict - }) return Object.freeze({ operation, request(client: UnvalidatedRpcRequestPort, ...args: RpcSendArguments) { - return client.sendRequest(operation.method, ...args).then(defer) + return client.sendRequest(operation.method, ...args) }, requestSingleFlight( client: RpcClient, @@ -277,7 +272,10 @@ export function bindDeferredRpcOperation< ? [params?: RpcSendParams] : [params: RpcSendParams] ) { - return sendSingleFlightRequest(client, hostId, operation.method, args[0]).then(defer) + return sendSingleFlightRequest(client, hostId, operation.method, args[0]) + }, + interpret(response: RpcResponse): Verdict { + return interpretRpcOutcome(operation, classifyRpcReply(operation, response)) as Verdict } }) } diff --git a/mobile/src/transport/settings-read-operations.test.ts b/mobile/src/transport/settings-read-operations.test.ts index 99356b64308..3a81da9942f 100644 --- a/mobile/src/transport/settings-read-operations.test.ts +++ b/mobile/src/transport/settings-read-operations.test.ts @@ -51,14 +51,14 @@ describe('settings historical acceptance', () => { it('distinguishes a skipped refusal from an accepted absent settings member', async () => { const skipped = await settingsRead.request(replyWith(refusal())) const missing = await settingsRead.request(replyWith(success({}))) - expect(skipped.interpret()).toEqual({ accepted: false }) - expect(missing.interpret()).toEqual({ accepted: true, value: undefined }) + expect(settingsRead.interpret(skipped)).toEqual({ accepted: false }) + expect(settingsRead.interpret(missing)).toEqual({ accepted: true, value: undefined }) }) it('retains opaque settings fields and reference identity without tightening acceptance', async () => { const value = { futureField: { nested: ['kept'] }, disabledTuiAgents: 'legacy-value' } const reply = await settingsRead.request(replyWith(success({ settings: value }))) - const result = reply.interpret() + const result = settingsRead.interpret(reply) expect(result.accepted && result.value).toBe(value) }) @@ -66,29 +66,29 @@ describe('settings historical acceptance', () => { 'preserves the unguarded settings read for %s only when interpreted', async (value) => { const reply = await settingsRead.request(replyWith(success(value))) - expect(() => reply.interpret()).toThrow(TypeError) - expect(() => reply.interpret()).toThrow( + expect(() => settingsRead.interpret(reply)).toThrow(TypeError) + expect(() => settingsRead.interpret(reply)).toThrow( `Cannot read properties of ${String(value)} (reading 'settings')` ) const optional = await optionalSettingsRead.request(replyWith(success(value))) - expect(optional.interpret()).toEqual({ accepted: true, value: undefined }) + expect(optionalSettingsRead.interpret(optional)).toEqual({ accepted: true, value: undefined }) } ) it.each([true, false, 0, 'text', []])('preserves property boxing for %j', async (value) => { const reply = await settingsRead.request(replyWith(success(value))) - expect(reply.interpret()).toEqual({ accepted: true, value: undefined }) + expect(settingsRead.interpret(reply)).toEqual({ accepted: true, value: undefined }) }) it('filters bot logins while distinguishing a refused refresh', async () => { const reply = await botOverridesRead.request( replyWith(success({ settings: { prBotAuthorOverrides: ['bot', 3, null, ''] } })) ) - expect(reply.interpret()).toEqual({ accepted: true, value: ['bot', ''] }) + expect(botOverridesRead.interpret(reply)).toEqual({ accepted: true, value: ['bot', ''] }) const refused = await botOverridesRead.request(replyWith(refusal())) - expect(refused.interpret()).toEqual({ accepted: false }) + expect(botOverridesRead.interpret(refused)).toEqual({ accepted: false }) const empty = await botOverridesRead.request(replyWith(success(null))) - expect(empty.interpret()).toEqual({ accepted: true, value: [] }) + expect(botOverridesRead.interpret(empty)).toEqual({ accepted: true, value: [] }) }) it('does not read a stale payload until its caller permits interpretation', async () => { @@ -103,7 +103,7 @@ describe('settings historical acceptance', () => { ) ) expect(read).not.toHaveBeenCalled() - reply.interpret() + settingsRead.interpret(reply) expect(read).toHaveBeenCalledOnce() }) @@ -194,7 +194,7 @@ describe('new-tab settlement barriers', () => { ) await expect(load(client)).rejects.toThrow('agents refused') const reply = await newTabSettingsRead.request(replyWith(success(null))) - const readSettings = reply.interpret() + const readSettings = newTabSettingsRead.interpret(reply) expect(() => readSettings()).toThrow(TypeError) }) })