mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 16:52:40 +00:00
* fix(app): keep the draft-create handoff until the new chat is current Creating an agent from the draft composer on web flashed the "Updating messages" panel toast for 40-200ms. That toast means "this chat may be out of date, we are catching up" — a chat this client just created cannot be. The timeline reducer released the create handoff at the first synchronized page, but the visibility catch-up flag only clears once catch-up settles, and a fresh chat always has a parked follow-up "after" page because live rows arrive during the tail fetch. Between the two releases the state machine saw a hydrated chat with no handoff and a pending catch-up, which resolves to the resume toast. #4863 turned that branch from silent to status, which is what made the window visible. Both signals now flip in one synchronous call: createViewedTimelineSync gains an onTimelineCurrent port, called from setVisibilityCatchUpReady — the single place the sync declares a chat current, already covering the parked follow-up and the fetchLatestTail overflow path. The owner implements it by clearing the handoff. The optimistic "running" override therefore lasts one extra round trip. The state machine and sync-status rules are unchanged; they were correct. * fix(app): release the create handoff when a chat leaves timeline demand Closing a new chat's tab before catch-up settled left its sent attempt in useCreateFlowStore forever. Reopening it in the same session then supplied optimistic-create continuity for an authoritative chat: the panel showed it as running and suppressed "Updating messages" while the real catch-up ran — the state this fix exists to protect. An offline or slow host widens that window from milliseconds to minutes. Leaving the demanded set ends the owner's obligation to a chat exactly as reaching current does, so onTimelineCurrent becomes onCatchUpEnded and fires on both. It reports after the membership commit, so the sync never describes itself mid-transition. Disconnect and backgrounding preserve demand and therefore preserve the handoff.
286 lines
19 KiB
Markdown
286 lines
19 KiB
Markdown
# Timeline sync
|
|
|
|
Agent chat delivery has two paths:
|
|
|
|
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
|
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
|
|
|
The daemon retains projected items in memory. Each source event advances the stream sequence,
|
|
then replaces the previous tool state or merges into the current text item. Intermediate payloads
|
|
are never retained for history or catch-up. Provider history is the durable transcript authority
|
|
and rebuilds the projection when an agent resumes.
|
|
|
|
The invariants are:
|
|
|
|
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
|
> agent establishes the daemon's current tail in one bounded request, with older history reachable
|
|
> through backward pagination.
|
|
|
|
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
|
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
|
|
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
|
tool payload.
|
|
|
|
## Presence is not delivery
|
|
|
|
Client heartbeat reports presence:
|
|
|
|
- device type
|
|
- app visibility
|
|
- focused agent
|
|
- last activity time
|
|
|
|
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
|
|
|
|
## Gap recovery is paged but complete
|
|
|
|
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
|
|
|
|
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
|
|
|
|
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
|
|
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
|
|
recovery is complete only when `hasNewer: false`.
|
|
|
|
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
|
|
|
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
|
user-driven by scrolling upward.
|
|
|
|
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
|
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
|
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
|
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
|
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
|
|
|
|
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
|
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
|
boolean, so the button reports in-flight and the callout returns to `error` when the attempt fails.
|
|
|
|
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
|
|
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
|
|
the anchored page still leaves the viewport at history start, as with short or compacted content; in
|
|
that case pagination continues as one loading operation until the page fills the viewport or history
|
|
is exhausted.
|
|
|
|
## Durable item anchors
|
|
|
|
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
|
|
|
|
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
|
|
|
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
|
includes projected items through that checkpoint. If an item spans the checkpoint and changed
|
|
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
|
|
cannot be reconstructed from sequence metadata. Forking the current context remains available.
|
|
|
|
## Resume behavior
|
|
|
|
Opening, reconnecting, and returning from app background establish the current timeline through
|
|
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
|
|
Focus alone does not mutate timeline state; the response is compared with the local
|
|
authoritative range first.
|
|
|
|
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
|
|
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
|
|
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
|
view renders it without a toast timer or a separate resume workflow.
|
|
|
|
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
|
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
|
first page leaves a chat this client just created looking hydrated but still catching up, which
|
|
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
|
|
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
|
|
one; disconnect and backgrounding keep it.
|
|
|
|
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
|
|
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
|
|
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
|
|
|
|
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
|
|
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
|
|
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
|
|
that cursor are applied. Already-covered rows are not replayed.
|
|
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
|
|
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
|
|
local submissions; it never retains two discontiguous canonical ranges.
|
|
|
|
The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through
|
|
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
|
|
history start; a response requested from a pre-replacement range is stale and is discarded.
|
|
|
|
A plan approval keeps the original proposal's tool-call identity through resolution and provider
|
|
history replay. The pending approval UI can hide that tool from presentation, but the client model
|
|
must retain its position. Creating a new history card on rejection places it after the prompt that
|
|
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
|
|
|
|
## Provider child history
|
|
|
|
Child transcripts use the same projected-page reconciliation as the main conversation. The client
|
|
retains rendered items and sequence cursors, never a second cache of source events. Pagination
|
|
uses the projected display anchor; live updates advance the source cursor without moving tools.
|
|
|
|
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
|
|
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
|
|
an update-host notice for that pane.
|
|
Older clients retain child names/status and receive an upgrade message when opening a child
|
|
conversation. This gate affects only the child transcript; it does not gate the connection,
|
|
main conversation, or current-context forks. A legacy `canonical` root request still receives
|
|
projected items.
|
|
|
|
## Client replica lifetime
|
|
|
|
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
|
owner wraps cached preparation, network catch-up, accepted timeline application, and persistence
|
|
behind one interface. React supplies transport and projection operations without selecting a cache
|
|
path or issuing a separate persistence notification.
|
|
|
|
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
|
|
history response can arrive before the active-list response that omits it. Clearing history there
|
|
would discard accepted rows while the viewed owner still considers them synchronized. Entity
|
|
deletion clears the transcript; list membership changes do not.
|
|
|
|
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
|
session and host-scoped setup state together.
|
|
|
|
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
|
|
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
|
|
range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward
|
|
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
|
history. Code outside the owner does not distinguish cached and network timelines.
|
|
|
|
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
|
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
|
|
|
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
|
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
|
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
|
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
|
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
|
|
|
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
|
the authoritative range through the existing forward-page path. The cache does not persist sync
|
|
generation or unreconciled local submissions.
|
|
|
|
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
|
|
replacement keeps only positioned rows newer than the page it installs, while unresolved local
|
|
user presentations identify themselves by having client identity without a timeline position. This
|
|
prevents a page from duplicating rows it already covers without coupling display continuity to the
|
|
shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable
|
|
replica cache.
|
|
|
|
## Selective and legacy delivery
|
|
|
|
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
|
|
|
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
|
|
enters that set the first time the user opens it and leaves when its tab closes or the session
|
|
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
|
|
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
|
|
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
|
|
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
|
|
subscribing from layout spawns a provider session per historical tab at every launch and stamps
|
|
every one of those workspaces as just used (see
|
|
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
|
|
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
|
|
does not starve background recovery. Split panes catch up together. Hidden chats update the
|
|
replica; on web their retained presentation stays suspended until revealed, on native it keeps
|
|
rendering. Revealing a chat reads the current store and preserves its local UI state.
|
|
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
|
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
|
|
|
This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon
|
|
version.
|
|
|
|
## Projected pages reconcile with live presentation
|
|
|
|
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
|
|
rows already received live—for example, a tool call retained at its original display position while
|
|
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
|
|
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
|
|
remaining page through the existing stream reducer. It must not append full projected text to a
|
|
live prefix.
|
|
|
|
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
|
|
send-now, and the host runtime's automatic queue drain — goes through
|
|
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
|
|
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
|
|
footer, and permanently drops attachments because the daemon does not echo them back.
|
|
|
|
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
|
|
its stable identity, text, timestamp, images, and attachments do not change when the provider
|
|
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape.
|
|
The transaction registry records two independent settlement facts: canonical acknowledgement and RPC
|
|
settlement. Canonical acknowledgement retires optimistic activity immediately. When both facts are
|
|
known, whichever arrives second deletes the record. A canonical acknowledgement that arrives first
|
|
prevents a later transport error from rolling back a prompt already observed.
|
|
|
|
The daemon's accepted response waits for the correlated run start and guarantees that the canonical
|
|
submitted row has been recorded. It publishes the accepted turn's liveness before that row, so the
|
|
client applies authoritative activity before canonical acknowledgement retires optimistic activity.
|
|
Timeline render batching does not delay lifecycle application. Directory status never settles a
|
|
submission. Overlapping sends settle independently rather than collapsing to one newest pending
|
|
message.
|
|
|
|
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
|
|
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
|
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
|
|
row is recorded before handler output. The app tracks submission transactions only for hosts with this
|
|
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
|
|
rejection.
|
|
|
|
Turn activity has one client-side replica. Lifecycle events can attach `turnId`, and agent snapshots can
|
|
expose `activeTurn: { turnId, startedAt } | null`. An identified terminal cannot close a different
|
|
identified turn; an unnamed legacy terminal can close the current turn. Snapshots, stream events,
|
|
cancellation requests, and every visible surface update the same per-agent record. The snapshot covers
|
|
both user-started foreground turns and autonomous provider turns;
|
|
foreground control ownership remains a separate daemon concern. Cancellation request identity is stored
|
|
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
|
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
|
|
|
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
|
|
the Paseo timeline, so one visible response can span several canonical turns without a user message
|
|
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
|
|
fork positions retain the canonical `turnId` boundaries.
|
|
|
|
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
|
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
|
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
|
second running state. Disconnect preserves the last replicated turn until cache or network hydration
|
|
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
|
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
|
|
|
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
|
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
|
the wire. A correlated provider echo records the provider's native identity internally without
|
|
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
|
|
the provider boundary. Daemon-handled prompts follow the same identity rule.
|
|
|
|
Content matching is limited to the dated compatibility path for daemon timelines created before
|
|
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
|
|
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
|
|
arrived first. Generic reducers and consumers do not reimplement message identity matching.
|
|
|
|
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
|
|
rows because a provider may never echo them. A known epoch change or rewind replaces history and
|
|
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
|
|
acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold
|
|
reset without an existing epoch is destructive because the client has no continuity anchor.
|
|
|
|
Tail rows are positioned history, so an unmatched local presentation is appended after the
|
|
canonical replacement rather than ordered by timestamps from different machines. The head is a
|
|
live overlay: cursorless items stay there during continuity replacement until canonical positions
|
|
arrive, while a destructive replacement retains only active submission transactions.
|
|
|
|
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
|
|
payload while taking the canonical row's ordered position. If a live assistant head is the
|
|
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
|
|
|
|
## Relevant code
|
|
|
|
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
|
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
|
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
|
|
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
|
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|