# Files changed · PR \#10245 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10245/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): keep pending tasks queued when a send fails in flight](https://git.cafe/pingdotgg/t3code/pull/10245.md)

Version: 2

Head commit: 9d738dab392e70bad8083163f8214de4c14f2980

Base commit: 9a47c7bd40522977c7df022a5b64759aae7b58e0

Comparison base: 9a47c7bd40522977c7df022a5b64759aae7b58e0

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/state/thread-outbox-model.ts

Status: modified; +23; −7

```diff
@@ -251,14 +251,30 @@
   return typeof error === "string" ? error : null;
 }
 
+/**
+ * Only a failure the server actually decided (`OrchestrationDispatchCommandError`,
+ * or an authorization rejection) means the payload itself is bad. The other
+ * typed failures a queued send can hit are transport-shaped: a socket that
+ * dropped mid-request (`RpcClientError` wrapping a Socket read/write/close
+ * reason), or an environment that is not connected or not registered. Those
+ * are matched by tag, not by message text, because a `SocketReadError` message
+ * is just "An error occurred during Read". A wrong answer here restores the
+ * pending task into a draft and it disappears from the list.
+ */
 export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
-  if (
-    typeof error === "object" &&
-    error !== null &&
-    "_tag" in error &&
-    error._tag === "ConnectionTransientError"
+  if (typeof error === "object" && error !== null && "_tag" in error) {
+    switch (error._tag) {
+      case "OrchestrationDispatchCommandError":
+      case "EnvironmentAuthorizationError":
+        return false;
+      case "ConnectionTransientError":
+      case "RpcClientError":
+      case "EnvironmentRpcUnavailableError":
+      case "EnvironmentNotRegisteredError":
+        return true;
+      default:
+        break;
-  ) {
+    }
-    return true;
   }
   return isTransportConnectionErrorMessage(errorMessage(error));
 }
```

## apps/mobile/src/state/thread-outbox.test.ts

Status: modified; +63; −0

```diff
@@ -1,13 +1,20 @@
 import { describe, expect, it } from "@effect/vitest";
+import { EnvironmentNotRegisteredError } from "@t3tools/client-runtime/connection";
+import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
+import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc";
 import {
   CommandId,
+  EnvironmentAuthorizationError,
   EnvironmentId,
   MessageId,
+  OrchestrationDispatchCommandError,
   ProjectId,
   ProviderInstanceId,
   ThreadId,
 } from "@t3tools/contracts";
 import { AtomRegistry } from "effect/unstable/reactivity";
+import * as RpcClientError from "effect/unstable/rpc/RpcClientError";
+import * as Socket from "effect/unstable/socket/Socket";
 import { onTestFinished, vi } from "vite-plus/test";
 
 const outboxFiles = vi.hoisted(() => new Map<string, string | Error>());
@@ -1399,8 +1406,64 @@
       }),
     ).toBe(true);
     expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);
+    expect(
+      shouldRetryThreadOutboxDelivery(
+        new OrchestrationDispatchCommandError({ message: "Thread no longer exists" }),
+      ),
+    ).toBe(false);
+    expect(
+      shouldRetryThreadOutboxDelivery(
+        new EnvironmentAuthorizationError({
+          message: "Missing scope",
+          requiredScope: "orchestration:operate",
+        }),
+      ),
+    ).toBe(false);
   });
 
+  // A pending task created offline drains the moment the phone reconnects,
+  // which is exactly when the socket is most likely to drop again. Every way a
+  // request can fail in flight must retry; a restore turns the pending task
+  // into a draft and it disappears from the list.
+  it("retries every in-flight transport failure by tag, not by message text", () => {
+    const socketReasons = [
+      new Socket.SocketReadError({ cause: new Error("The network connection was lost.") }),
+      new Socket.SocketWriteError({ cause: new Error("Broken pipe") }),
+      new Socket.SocketCloseError({ code: 1006 }),
+      new Socket.SocketOpenError({ kind: "Timeout", cause: new Error("timeout") }),
+    ];
+    for (const reason of socketReasons) {
+      const error = new RpcClientError.RpcClientError({ reason });
+      expect(isTransportConnectionErrorMessage(error.message)).toBe(
+        reason._tag === "SocketCloseError" || reason._tag === "SocketOpenError",
+      );
+      expect(shouldRetryThreadOutboxDelivery(error)).toBe(true);
+    }
+    expect(
+      shouldRetryThreadOutboxDelivery(
+        new RpcClientError.RpcClientError({
+          reason: new RpcClientError.RpcClientDefect({
+            message: "Error decoding message",
+            cause: new Error("Unexpected end of JSON input"),
+          }),
+        }),
+      ),
+    ).toBe(true);
+    expect(
+      shouldRetryThreadOutboxDelivery(
+        new EnvironmentRpcUnavailableError({
+          environmentId: "environment-1",
+          message: "Home is not connected.",
+        }),
+      ),
+    ).toBe(true);
+    expect(
+      shouldRetryThreadOutboxDelivery(
+        new EnvironmentNotRegisteredError({ environmentId: EnvironmentId.make("environment-1") }),
+      ),
+    ).toBe(true);
+  });
+
   it("retains queued messages when settings synchronization fails before startTurn", () => {
     const deterministicFailure = new Error("Thread no longer exists");
 
```
