What happened
I was steering an active thread from the iOS app over T3 Connect. The first turn finished, and when I went to write a follow-up, the composer let me draft it but showed "This connection cannot control this task. You can still edit your draft." and Send was disabled. The socket was connected and my grant hadn't changed. About two minutes later the app reconnected and sending worked again.
Diagnosis
A single failed GET /api/auth/session revalidation makes the client treat a connected, fully-scoped session as having no scopes, and nothing retries it.
sessionStateAtom (packages/client-runtime/src/state/session.ts:155) is Atom.swr({ staleTime: 30_000, revalidateOnMount: true }). It re-fetches only on mount (when stale) or when the prepared connection changes. It has no retry and no focus revalidation.
- When a revalidation fails (here the client aborted the request mid-flight, which looks like iOS suspending or dropping the request while the app was backgrounded), the atom becomes
Failure. That Failure still carries previousSuccess with the grant fetched 3 seconds earlier.
- Both permission checks throw that cached grant away:
apps/mobile/src/state/session.ts:25 sessionHasScope: result._tag !== "Failure" && ...
packages/client-runtime/src/state/commandPermissions.ts:32 permissionAtom: the same check. authorize (line 54) also fails closed on a Failure.
ThreadRouteScreen gets canOperateThread = false. Because connectionState is still "connected", ThreadComposer.tsx:1176 shows a message that blames permissions, not the network.
- The state stays wrong until the next WebSocket reconnect swaps the prepared connection and re-runs the fetch, or until the screen remounts after the 30s stale time.
The comment above useEnvironmentScope says it uses "cached scopes during a refresh". That's true while the atom is Waiting, but not after it reaches Failure.
Possible directions (not prescriptive): use AsyncResult.value (which already returns previousSuccess) for availability and treat only an authoritative response (authenticated: false / 401 / 403) as revoking scopes. Retry or revalidate the session after a transient fetch failure, for example on app foreground. If the failure is transport-level, the composer copy should say "can't verify access right now", not "cannot control".
The same _tag !== "Failure" gate is in shared commandPermissions, so web clients get the same false negative after a transient session fetch failure. It's just much less likely on a stable desktop connection.
Steps to reproduce
- Connect the iOS app to an environment over T3 Connect and open a thread.
- Background the app and foreground it again (this triggers the reconnect and session revalidation), then background it again right away, within a few seconds, so the in-flight
/api/auth/session request is cancelled.
- Foreground the app and open the composer. It says "This connection cannot control this task" and Send is disabled, even though the socket is connected.
Deterministic unit-level repro: pass AsyncResult.failure(cause, { previousSuccess: Option.some(AsyncResult.success(sessionWithOperateScope)) }) to sessionHasScope / permissionAtom. Both return false.
Version
Server t3 v0.0.46-nightly.20261006.2735 (linuxbox); iOS app build 114; repo at 08e18b7
Environment
Ubuntu 26.04 server, iOS 26 client (CFNetwork/3860.700.2 Darwin/25.6.0), connected via T3 Connect relay
Evidence
# Phone (T3Code/114) HTTP requests from the server trace, local time
23:26:23 turn 1 completes
23:26:28.208 POST /api/auth/websocket-ticket 200 # app foregrounds, reconnects
23:26:28.407 GET /api/auth/session 200 # grant fetched OK
23:26:31.807 GET /api/orchestration/shell 499 Interrupted # client aborts both requests
23:26:31.825 GET /api/auth/session 499 Interrupted # session state -> Failure
23:26:39.879 GET /api/orchestration/shell 200 # shell retries...
(no session retry; composer shows "cannot control this task")
23:28:27.966 POST /api/auth/websocket-ticket 200 # next reconnect
23:28:28.487 GET /api/auth/session 200 # grant restored
23:28:31 turn 2 requested from mobile (follow-up finally sent)
Related issues
#16804 (iOS Provider accounts missing providers:manage) is a real scope mismatch. In this issue, the grant is correct and a transient fetch failure is read as a missing grant.
Fix applied or workaround
None applied. Workaround: background and foreground the app again (or navigate away and back after 30s) to force a reconnect or session refetch.
Filed by
Claude Code (claude-opus-5-5) via T3 Code
What happened
I was steering an active thread from the iOS app over T3 Connect. The first turn finished, and when I went to write a follow-up, the composer let me draft it but showed "This connection cannot control this task. You can still edit your draft." and Send was disabled. The socket was connected and my grant hadn't changed. About two minutes later the app reconnected and sending worked again.
Diagnosis
A single failed
GET /api/auth/sessionrevalidation makes the client treat a connected, fully-scoped session as having no scopes, and nothing retries it.sessionStateAtom(packages/client-runtime/src/state/session.ts:155) isAtom.swr({ staleTime: 30_000, revalidateOnMount: true }). It re-fetches only on mount (when stale) or when the prepared connection changes. It has no retry and no focus revalidation.Failure. ThatFailurestill carriespreviousSuccesswith the grant fetched 3 seconds earlier.apps/mobile/src/state/session.ts:25sessionHasScope:result._tag !== "Failure" && ...packages/client-runtime/src/state/commandPermissions.ts:32permissionAtom: the same check.authorize(line 54) also fails closed on aFailure.ThreadRouteScreengetscanOperateThread = false. BecauseconnectionStateis still"connected",ThreadComposer.tsx:1176shows a message that blames permissions, not the network.The comment above
useEnvironmentScopesays it uses "cached scopes during a refresh". That's true while the atom isWaiting, but not after it reachesFailure.Possible directions (not prescriptive): use
AsyncResult.value(which already returnspreviousSuccess) for availability and treat only an authoritative response (authenticated: false/ 401 / 403) as revoking scopes. Retry or revalidate the session after a transient fetch failure, for example on app foreground. If the failure is transport-level, the composer copy should say "can't verify access right now", not "cannot control".The same
_tag !== "Failure"gate is in sharedcommandPermissions, so web clients get the same false negative after a transient session fetch failure. It's just much less likely on a stable desktop connection.Steps to reproduce
/api/auth/sessionrequest is cancelled.Deterministic unit-level repro: pass
AsyncResult.failure(cause, { previousSuccess: Option.some(AsyncResult.success(sessionWithOperateScope)) })tosessionHasScope/permissionAtom. Both returnfalse.Version
Server t3 v0.0.46-nightly.20261006.2735 (linuxbox); iOS app build 114; repo at 08e18b7
Environment
Ubuntu 26.04 server, iOS 26 client (CFNetwork/3860.700.2 Darwin/25.6.0), connected via T3 Connect relay
Evidence
Related issues
#16804 (iOS Provider accounts missing
providers:manage) is a real scope mismatch. In this issue, the grant is correct and a transient fetch failure is read as a missing grant.Fix applied or workaround
None applied. Workaround: background and foreground the app again (or navigate away and back after 30s) to force a reconnect or session refetch.
Filed by
Claude Code (claude-opus-5-5) via T3 Code