fix(timeline): preserve activity through turn handoff

Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state.
This commit is contained in:
Mohamed Boudra
2026-08-01 00:43:38 +02:00
parent d17194c611
commit 4e34668a19
64 changed files with 2513 additions and 638 deletions
+9 -1
View File
@@ -228,6 +228,11 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events
optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's
status once at the directory boundary rather than maintaining a second activity model.
- Terminal subscribe/input/capture commands
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
@@ -293,7 +298,10 @@ initializing → idle ⇄ running
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one
permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted
prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for
client-side dedup; the default fetch page is 200 items.
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
+3 -1
View File
@@ -42,7 +42,9 @@ OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add`
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager uses it to enrich the existing row with the provider `messageId` without allocating another sequence. If an adapter emits the echo before `startTurn` resolves, the manager carries its provider identity into the row it records at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
When the provider echo arrives after acceptance, the manager publishes the enriched canonical row again at its original sequence. This is an in-place row revision, not another timeline append; consumers may apply it only to the already-covered row with the matching `clientMessageId`.
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
+33 -28
View File
@@ -138,41 +138,46 @@ footer, and permanently drops attachments because the daemon does not echo them
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 RPC acceptance and provider acknowledgement independently for
agent-scoped sends. Provider acknowledgement exists solely so a later transport error cannot roll
back a prompt already observed canonically; it does not own whether the local presentation remains
visible.
The transaction registry exists only until the daemon accepts or rejects the send. An accepted RPC
settles it because capable daemons guarantee the prompt is already durable. Canonical acknowledgement
can arrive first and prevents a later transport error from rolling back a prompt already observed.
The daemon's accepted response already waits for the correlated run start, but its response and the
canonical submitted row reach client state independently and may arrive in either order. The
transaction therefore records RPC acceptance and provider acknowledgement as separate phases. RPC
acceptance marks the record accepted unless provider acknowledgement already arrived; canonical
acknowledgement marks the record acknowledged unless RPC acceptance already arrived. Only the second
authority removes the transaction. Directory status never settles a submission. Overlapping sends
settle independently rather than collapsing to one newest pending message.
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 retiring optimistic activity on RPC acceptance. 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 creates two-phase submission records only for hosts with
this capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back
on RPC rejection.
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 liveness has one client-side owner. `turn_started` opens the turn; ordered `turn_completed`,
`turn_failed`, and `turn_canceled` events close it. A running directory transition may seed an open
turn during hydration or after a selective stream subscription lapses, but an ordinary idle directory
update does not close one or settle a submission because it can arrive before the terminal stream
event. A completed timeline resume commits the response's coherent agent snapshot and closes any open
turn not backed by newer sequence evidence. An accepted running-to-idle directory transition requests
that resume for a viewed agent; it does not close liveness itself. This gives missed terminal events and
canonical prompt acknowledgements one convergence boundary without making directory delivery order an
authority for the footer. Disconnect and replica removal are the destructive close boundaries. Footer
activity is the owned turn state unioned with unsettled submissions, and its running start time comes
from lifecycle/submission evidence rather than whichever timeline rows happen to be mounted.
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 submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider
identity to the existing row, and keeps the local presentation in its original timeline slot.
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 and replica removal remain destructive close boundaries. Running time
comes from the daemon snapshot/event or the pending submission, never from whichever timeline rows happen
to be mounted.
The daemon records one canonical submitted user row with Paseo's `clientMessageId` as soon as the
provider accepts the turn. A correlated provider echo enriches that same sequence with its provider
`messageId`; it never appends a second source row. The row's identity and pagination position are
therefore independent of provider notification timing. A late echo is streamed as a revision at the
original sequence; clients apply it only to the already-covered row with the same client identity.
An immediately interrupted turn remains
canonical even when the provider never emits its echo.
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