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

merged1
main
t3code/fix-offline-pending-tasks
openedSep 6, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago

Problem

A pending task queued on the phone while offline was gone from the Pending section once the phone reconnected, and its text showed up as the project's new-task draft instead.

The outbox drain sends a queued creation as soon as the environment reconnects, which is exactly when the socket is most likely to drop again. When a startTurn fails in flight, Effect fails the request with an RpcClientError wrapping the socket reason. For a socket that dies after opening, that reason is SocketReadError, whose message is just "An error occurred during Read". shouldRetryThreadOutboxDelivery classified failures by message text and only special-cased the ConnectionTransientError tag, so this read as a deterministic server rejection. The drain then took the restore path, which merges the queued text into the new-task:<project> draft and deletes the outbox entry.

The server side confirms it never rejected anything: no non-accepted command receipts, no bootstrap-thread-delete cleanup, and every mobile dispatchCommand span in the trace succeeded. The failure was client-side transport.

Fix

Classify by error tag first, and only fall back to the message matcher for untagged errors:

  • Restore (payload is bad): OrchestrationDispatchCommandError, EnvironmentAuthorizationError — the two errors the dispatchCommand RPC contract can return.
  • Retry (transport): ConnectionTransientError, RpcClientError (any socket reason or protocol defect), EnvironmentRpcUnavailableError, EnvironmentNotRegisteredError.

Verification

Added a test that constructs real RpcClientError instances for all four socket reasons plus a protocol defect and asserts each retries. It also pins that SocketReadError/SocketWriteError messages are not recognised by the text matcher, which documents why tag matching is required. The test fails on main and passes here.

1apps/mobile: vp test run src/state/thread-outbox.test.ts 43 passed
2apps/mobile: vpr typecheck ok

No screenshots: the change is a classification function with no UI. Follow-up work to surface drafts and pending tasks together in the mobile sidebar is stacked on top of this PR.

Model: Claude Fable 5 Ā· Harness: Claude Code in T3 Code

šŸ¤– Generated with Claude Code

Note

Fix shouldRetryThreadOutboxDelivery to stop retrying deterministic failures and retry RPC errors

  • Replaces the single ConnectionTransientError check in thread-outbox-model.ts with tag-based classification so pending tasks stay queued on transient failures instead of being dropped
  • OrchestrationDispatchCommandError and EnvironmentAuthorizationError are now non-retryable; RpcClientError, EnvironmentRpcUnavailableError, and EnvironmentNotRegisteredError join ConnectionTransientError as retryable
  • Untagged errors still fall through to the existing message-based transport detection
  • Adds tests in thread-outbox.test.ts covering typed retryable and non-retryable cases
  • Behavioral Change: errors previously retried only via message matching are now classified by tag; untagged errors with unrecognized messages may stop retrying

Macroscope summarized 9d738da.

Synced from GitHub

Approvability

Verdict: Approved at 3169c8b

Macroscope's review found this PR approvable — This is a focused mobile bug fix that preserves queued messages for typed transport failures while retaining restore behavior for deterministic server and authorization failures. The implementation is small, its outbox runtime impact is localized, and targeted tests cover the new error classifications.

No code changes detected at 9d738da. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Thread transfer impact

āœ… Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.5 KiB13.6 KiB+69 B (+0.5%)15.1 KiBāœ…
CodexThread snapshot wire7.0 KiB7.0 KiB+5 B (+0.1%)7.3 KiBāœ…
CodexLive turn WebSocket wire6.5 KiB6.6 KiB+64 B (+1.0%)7.8 KiBāœ…
CodexLive turn WebSocket decoded57.0 KiB57.1 KiB+88 B (+0.2%)66.4 KiBāœ…
CodexLive turn messages810+2 (+25.0%)21āœ…
ClaudeTotal thread wire13.6 KiB13.6 KiB+74 B (+0.5%)15.1 KiBāœ…
ClaudeThread snapshot wire7.1 KiB7.0 KiBāˆ’4 B (āˆ’0.1%)7.3 KiBāœ…
ClaudeLive turn WebSocket wire6.5 KiB6.6 KiB+78 B (+1.2%)7.8 KiBāœ…
ClaudeLive turn WebSocket decoded57.8 KiB57.9 KiB+88 B (+0.1%)66.4 KiBāœ…
ClaudeLive turn messages810+2 (+25.0%)21āœ…

Baseline: 9a47c7b Ā· PR result: 9d738da Ā· Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.9 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Added 1 commit

  • fix(mobile): keep pending tasks queued when a send fails in flight9d738da