fix(mobile): keep pending tasks queued when a send fails in flight#10245
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 thedispatchCommandRPC 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.
| 1 | apps/mobile: vp test run src/state/thread-outbox.test.ts 43 passed |
| 2 | apps/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
ConnectionTransientErrorcheck in thread-outbox-model.ts with tag-based classification so pending tasks stay queued on transient failures instead of being dropped OrchestrationDispatchCommandErrorandEnvironmentAuthorizationErrorare now non-retryable;RpcClientError,EnvironmentRpcUnavailableError, andEnvironmentNotRegisteredErrorjoinConnectionTransientErroras 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.
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.
Thread transfer impact
ā Thread transfer remains within every enforced ceiling.
| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |
|---|---|---|---|---|---|---|
| Codex | Total thread wire | 13.5 KiB | 13.6 KiB | +69 B (+0.5%) | 15.1 KiB | ā |
| Codex | Thread snapshot wire | 7.0 KiB | 7.0 KiB | +5 B (+0.1%) | 7.3 KiB | ā |
| Codex | Live turn WebSocket wire | 6.5 KiB | 6.6 KiB | +64 B (+1.0%) | 7.8 KiB | ā |
| Codex | Live turn WebSocket decoded | 57.0 KiB | 57.1 KiB | +88 B (+0.2%) | 66.4 KiB | ā |
| Codex | Live turn messages | 8 | 10 | +2 (+25.0%) | 21 | ā |
| Claude | Total thread wire | 13.6 KiB | 13.6 KiB | +74 B (+0.5%) | 15.1 KiB | ā |
| Claude | Thread snapshot wire | 7.1 KiB | 7.0 KiB | ā4 B (ā0.1%) | 7.3 KiB | ā |
| Claude | Live turn WebSocket wire | 6.5 KiB | 6.6 KiB | +78 B (+1.2%) | 7.8 KiB | ā |
| Claude | Live turn WebSocket decoded | 57.8 KiB | 57.9 KiB | +88 B (+0.1%) | 66.4 KiB | ā |
| Claude | Live turn messages | 8 | 10 | +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