Skip to article
PlanToCodeDocsGet the app

HandbookAndroid app

Android: projections, batch reconciliation, and custody

How the Android app caches timeline projections, fences requests with a visibility generation, reconciles up to 16 timelines per request, keeps its LazyColumn anchored, and holds encrypted custody of every send.

Checked against the source on 17 September 2026

On this page

A cache of timeline projections

A TimelineTarget is an app session, a project directory, an optional Codex thread, and a desktop. Projections for parent sessions and subagent threads live in the ViewModel’s memory, keyed by target, and the cache is pruned after every reducer action. The visible parent, the selected thread, and targets that are pending or running in reconciliation are protected. Of the rest, a projection idle for 12 hours is evicted, and beyond 64 projections the least recently accessed go first, with ties broken by session, desktop, project, and thread.

A snapshot requested while its target was visible may finish after you navigated away. It still updates that target’s cached projection, and it cannot touch the visible timeline or schedule more work.

One gate for content-bearing requests

WorkspaceTimelineSyncGate decides whether timeline work may run at all. Its scope is the Workspace destination with a session, a project directory, a desktop, and the selected thread, and it exists only while the Activity is visible. Every change of that scope, including hiding and showing the Activity, increments a generation. A request captures the generation and the live event revision, and a latest-window request also takes a number from a global snapshot admission sequence. A result applies only while its request still owns the visible scope, which also rejects a response that comes back after you went from chat A to chat B and back to A.

Up to 16 targets per request, split on failure

The desktop fails a batch as a whole, so the phone splits it until the problem stands alone
The desktop fails a batch as a whole, so the phone splits it until the problem stands aloneAn example with a parent timeline of 2 MiB and a thread timeline of 9 MiB, drawn to scale. A reconcile request succeeds only if every session loads and all results fit in 8 MiB, so the first try fails. The phone halves the batch and sends each half again. The parent is applied, and the thread alone is still too large. After about 0.75 s the thread is asked for 12 rows instead of 100, and it fits.
example sizes, drawn to scale
8 MiB limit for the whole response
first try100 rows each
split in halfeach half again
smaller pageafter about 0.75 s
parent
thread · 100 rows
whole batch fails as too large
parent
applied
thread · 100 rows
still too large
thread at 12 rows, applied
0 MiB
4 MiB
8 MiB
12 MiB
An error in any session, an unreadable or mismatched answer, or no answer splits a batch the same way. A target that fails alone turns stale and keeps its rows.
Only the visible parent and its selected thread pass the gate, so a batch holds one or two targets. The desktop accepts up to 16.
  • Batchrun.codexChatReconcileSessions

    Up to 16 targets from one project directory, answered all or nothing. One failing session, or results that pass 8 MiB together, fail the whole request.

  • SplitisolateBatchFailure

    A failed batch is halved and each half runs again, recursively. Only a target that fails alone turns stale, and its rows stay visible. When the desktop doesn’t answer at all, every target ends up stale.

  • Smaller pageTIMELINE_SNAPSHOT_TOO_LARGE

    A single target that is still too large retries with 12 rows instead of 100, up to 3 times, after about 0.75 s, 1.5 s, and 3 s (±20%, never above 3 s).

  • GateWorkspaceTimelineSyncGate

    Only the visible parent timeline and its selected thread are sent. Other queued targets stay in the queue.

Stale targets queue in the reconciliation coordinator and drain in batches of up to 16 targets for one project directory through run.codexChatReconcileSessions. Targets that no longer own the visible scope are cancelled before the request goes out. The response must contain exactly one session for each requested target, matched by exact identity.

A failed request, a decoding error, or a response that does not match splits the batch in half and runs each half again, recursively, until a single target fails alone. That target is marked stale with its error while its rows stay visible. A TIMELINE_SNAPSHOT_TOO_LARGE error splits the batch the same way, and a single target then retries up to 3 times at the initial page size of 12, after 750 milliseconds doubling to at most 3 seconds, with 20 percent jitter.

Live overlays and latest windows

A blocks event applies only when its settlement epoch equals the current one and its revision is at least the current revision, and an equal revision must carry identical blocks. A newer epoch, or an equal revision with different blocks, marks the target stale and queues reconciliation, and anything older is dropped. The parent overlay and the selected thread’s overlay are tracked separately.

A new latest window keeps the older rows the phone already loaded when their exact identities line up with the new window. The first page holds 12 rows with a 115-second timeout and up to 3 retries, and older pages hold 100 rows.

LazyColumn keys, anchors, and the live edge

Timeline rows use the key type::id and a content type per row kind, next to fixed items for the error state, a conversation anchor, and the load-older row. Before older rows are inserted, the list records the first visible index, its offset, and its key. Afterwards it restores by key, or shifts by the number of inserted rows when the key is gone. Loading older rows needs a real scroll toward the top, starts within an 8-item band, and has to re-arm after each load.

The list follows the live edge while you are within 72 pixels of the bottom. Following moves at the remaining distance divided by 1.5 seconds, clamped between 80 and 2,200 pixels per second, and settles after 2 stable frames within 1 pixel.

MarkdownPreparationWorker parses Markdown on Dispatchers.Default. Composables waiting for the same text share one job, and the job is cancelled when the last of them leaves. Results are cached, up to 256 documents and 16 MiB, and a document of 64 KiB or more is split into at most 24 top-level chunks that render lazily. Compose reads the cache through volatile snapshots without waiting for the worker’s lock, and when a streaming row is replaced by its settled row the prepared frame carries over, so the text does not flash.

Encrypted custody of every send

A message leaves the phone’s disk only when the desktop shows it has it
A message leaves the phone’s disk only when the desktop shows it has itThe intent is committed to encrypted storage before the submit. In this example the desktop stores the message, but its receipt is lost and the app is killed. The intent survives on disk. Once the desktop answers system.ping after the relaunch, the phone reads the desktop outbox, finds the operation ID, and removes the intent without sending it again.
app killed
Phone diskencrypted custody
Phone
Desktopvia the relay
commit()
encrypted intent, one operation ID throughout
removed
submit
stored under its operation ID
no receipt: waits up to 60 s
receipt lost
a receipt would remove it here
queued again
desktop ready again
reads its outbox
lists the operation ID
If the desktop doesn’t list it and the timeline doesn’t show it, the phone sends it again with the same operation ID, and the desktop doesn’t store it twice.
An intent written while the desktop was unreachable waits on disk and is first sent once the desktop is ready again.
  • Encrypted intentSharedPreferencesWorkspaceOutboxStore

    One AES-GCM blob for every waiting intent, bound to the SHA-256 of region and principal, and written with commit() before the RPC. A wrong owner or an unreadable blob makes writes fail instead of replacing it.

  • Receiptrun.codexChatOutboxSubmit

    Submits for one session run one at a time. A receipt that echoes the queue and operation IDs removes the intent at once. With no receipt within 60 s, the entry is queued again.

  • Reconcilerun.codexChatOutboxLoad

    Runs for every target on the selected desktop once it answers system.ping. Intents the desktop lists or the timeline shows are removed, and a removal the desktop still lists is sent again with its stored key.

  • Operation IDandroid-<uuid>

    Every resend reuses it, and the desktop matches it before storing anything. Editing a queued entry creates a new operation ID. Send now keeps it and renews only the idempotency key.

Every send, edit, and removal is written to the outbox store before an RPC leaves the phone. The store keeps one encrypted blob for all waiting intents, each with its desktop, project, and session, the queue and operation IDs, the idempotency key, the prompt, attachments with their retained local path, the model and reasoning snapshot, the delivery mode, and a removal mutation ID when one is pending. Writes use commit() under a mutex and return only after the data is on disk.

The blob is bound to its owner, the SHA-256 of the region and the principal, where the principal is the JWT subject or else the lowercased email. When the owner does not match or the blob cannot be decrypted, the store reports unreadable custody, and writes fail instead of replacing it. A late submission write also cannot replace a removal that was already recorded.

An operation ID looks like android-<uuid>. The submit idempotency key, run.codexChatOutboxSubmit:<uuid>, is regenerated when you send an entry now or edit it, because the desktop deduplicates delivery by the unchanging queue and operation IDs. Submits for one session run in a lane, one at a time. Each waits for pending chat-settings saves, stops when the entry was removed in the meantime, and requires the receipt to echo the same queue ID and operation ID.

When the desktop is ready again, the waiting coordinator reads run.codexChatOutboxLoad for each target. Intents the desktop already has are finished. Intents it lacks, which are not visible in the timeline either, are submitted again with the same operation ID, and a removal the desktop still lists is sent again with its stored key.

Attachments come from the system document picker and are copied into the app cache, up to 1 GiB. A PNG of at most 16 megapixels is re-encoded as JPEG at quality 88. The upload runs begin, chunks at the desktop’s chunk size capped at 1 MiB and base64-encoded, then finish, with deterministic keys of the form files.chatAttachmentUpload:<session>:<name UUID> followed by :begin, :chunk:<n>, :finish, or :cancel. Cancel is sent only after a failure that must not be retried.

Tests that pin this behavior

  • WorkspaceChatReconciliationRetentionTest: exactly 64 retained targets, idle eviction before the cap, and visible, pending, and running targets kept beyond it.
  • WorkspaceChatReconciliationAdmissionTest and WorkspaceChatReconciliationProjectionTest: a failing target turns stale without losing its rows, and repeated prompts with the same text are never merged by text or timestamp.
  • PlanToCodeAndroidWorkspaceReconciliationCoordinatorTest: the parent and thread in one request, bisection of a failed batch, and the bounded retries of an oversized timeline.
  • TimelineLiveGrowthTest, an instrumented parity test: a sent message and rows that grow later move the list forward in bounded steps.
From mobile/android
./gradlew :app:testDebugUnitTest
./gradlew :app:pixel2api35DebugAndroidTest