authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 12:31:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 12:31:03-07:00
log36c48a6b7c03c9a9adbc3e55d4d9ae77eae6f816
tree84e28641523a79333035c21ecf59f2074e9fb593
parent9a47c7bd40522977c7df022a5b64759aae7b58e0
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep pending tasks queued when a send fails in flight (#10245)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 86 insertions(+), 7 deletions(-)

apps/mobile/src/state/thread-outbox-model.ts+23-7
......@@ -251,14 +251,30 @@ function errorMessage(error: unknown): string | null {
251251 return typeof error === "string" ? error : null;
252252}
253253
254/**
255 * Only a failure the server actually decided (`OrchestrationDispatchCommandError`,
256 * or an authorization rejection) means the payload itself is bad. The other
257 * typed failures a queued send can hit are transport-shaped: a socket that
258 * dropped mid-request (`RpcClientError` wrapping a Socket read/write/close
259 * reason), or an environment that is not connected or not registered. Those
260 * are matched by tag, not by message text, because a `SocketReadError` message
261 * is just "An error occurred during Read". A wrong answer here restores the
262 * pending task into a draft and it disappears from the list.
263 */
254264export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
255 if (
256 typeof error === "object" &&
257 error !== null &&
258 "_tag" in error &&
259 error._tag === "ConnectionTransientError"
260 ) {
261 return true;
265 if (typeof error === "object" && error !== null && "_tag" in error) {
266 switch (error._tag) {
267 case "OrchestrationDispatchCommandError":
268 case "EnvironmentAuthorizationError":
269 return false;
270 case "ConnectionTransientError":
271 case "RpcClientError":
272 case "EnvironmentRpcUnavailableError":
273 case "EnvironmentNotRegisteredError":
274 return true;
275 default:
276 break;
277 }
262278 }
263279 return isTransportConnectionErrorMessage(errorMessage(error));
264280}
apps/mobile/src/state/thread-outbox.test.ts+63
......@@ -1,13 +1,20 @@
11import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentNotRegisteredError } from "@t3tools/client-runtime/connection";
3import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
4import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc";
25import {
36 CommandId,
7 EnvironmentAuthorizationError,
48 EnvironmentId,
59 MessageId,
10 OrchestrationDispatchCommandError,
611 ProjectId,
712 ProviderInstanceId,
813 ThreadId,
914} from "@t3tools/contracts";
1015import { AtomRegistry } from "effect/unstable/reactivity";
16import * as RpcClientError from "effect/unstable/rpc/RpcClientError";
17import * as Socket from "effect/unstable/socket/Socket";
1118import { onTestFinished, vi } from "vite-plus/test";
1219
1320const outboxFiles = vi.hoisted(() => new Map<string, string | Error>());
......@@ -1399,6 +1406,62 @@ describe("thread outbox", () => {
13991406 }),
14001407 ).toBe(true);
14011408 expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);
1409 expect(
1410 shouldRetryThreadOutboxDelivery(
1411 new OrchestrationDispatchCommandError({ message: "Thread no longer exists" }),
1412 ),
1413 ).toBe(false);
1414 expect(
1415 shouldRetryThreadOutboxDelivery(
1416 new EnvironmentAuthorizationError({
1417 message: "Missing scope",
1418 requiredScope: "orchestration:operate",
1419 }),
1420 ),
1421 ).toBe(false);
1422 });
1423
1424 // A pending task created offline drains the moment the phone reconnects,
1425 // which is exactly when the socket is most likely to drop again. Every way a
1426 // request can fail in flight must retry; a restore turns the pending task
1427 // into a draft and it disappears from the list.
1428 it("retries every in-flight transport failure by tag, not by message text", () => {
1429 const socketReasons = [
1430 new Socket.SocketReadError({ cause: new Error("The network connection was lost.") }),
1431 new Socket.SocketWriteError({ cause: new Error("Broken pipe") }),
1432 new Socket.SocketCloseError({ code: 1006 }),
1433 new Socket.SocketOpenError({ kind: "Timeout", cause: new Error("timeout") }),
1434 ];
1435 for (const reason of socketReasons) {
1436 const error = new RpcClientError.RpcClientError({ reason });
1437 expect(isTransportConnectionErrorMessage(error.message)).toBe(
1438 reason._tag === "SocketCloseError" || reason._tag === "SocketOpenError",
1439 );
1440 expect(shouldRetryThreadOutboxDelivery(error)).toBe(true);
1441 }
1442 expect(
1443 shouldRetryThreadOutboxDelivery(
1444 new RpcClientError.RpcClientError({
1445 reason: new RpcClientError.RpcClientDefect({
1446 message: "Error decoding message",
1447 cause: new Error("Unexpected end of JSON input"),
1448 }),
1449 }),
1450 ),
1451 ).toBe(true);
1452 expect(
1453 shouldRetryThreadOutboxDelivery(
1454 new EnvironmentRpcUnavailableError({
1455 environmentId: "environment-1",
1456 message: "Home is not connected.",
1457 }),
1458 ),
1459 ).toBe(true);
1460 expect(
1461 shouldRetryThreadOutboxDelivery(
1462 new EnvironmentNotRegisteredError({ environmentId: EnvironmentId.make("environment-1") }),
1463 ),
1464 ).toBe(true);
14021465 });
14031466
14041467 it("retains queued messages when settings synchronization fails before startTurn", () => {