fix: recover delayed session entry (#3631)

This commit is contained in:
Carlos Florêncio
2026-09-13 12:14:45 +01:00
committed by GitHub
parent a5fe42777b
commit 9e9d3b1fb0
39 changed files with 2684 additions and 194 deletions
@@ -28,49 +28,27 @@ A fast task session can change state or persist a reply while the browser is wai
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001.7:** **GIVEN** one client-side consumer is already registering a session and a second consumer mounts, **WHEN** the second consumer requests hydration, **THEN** it waits for the shared registration acknowledgement and does not race an earlier `message.list` request ahead of registration.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001.8:** **GIVEN** a session subscription is restored after a WebSocket reconnect, **WHEN** the restored registration is acknowledged, **THEN** message reconciliation can run against the restored subscription and later events are delivered normally.
## Migrated source detail
### REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002: Recover session entry from temporary delays
## Why
**Intent:** Users can open an existing conversation during a temporary connection delay without a false startup failure or lost history.
A fast task session can change state or persist a reply while the browser is
waiting for the backend to register its `session.subscribe` request. The live
notification is then unavailable to that client, leaving the task looking busy
or the transcript missing a reply until the user reloads the page.
#### Acceptance criteria
## What
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.1:** A successful subscription or status response delayed by seven seconds shall complete session entry without a startup-failure alert.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.2:** After a temporary entry timeout, the visible session shall retry automatically without a reload, navigation, or visibility change.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.3:** On a continuously connected socket, unsuccessful automatic recovery shall stop within 45 seconds. The user shall then have an explicit Retry action.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.4:** Until history loads successfully, an uncached conversation shall show loading, retrying, or unavailable feedback. It shall never show the empty-conversation invitation.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.5:** A failed refresh shall preserve cached messages, drafts, and scroll position. Successful recovery shall clear its stale feedback.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.6:** A status timeout shall identify a status-check problem, not a failed session start. Technical details shall remain collapsed by default.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.7:** Recovery shall not duplicate agent launches or bypass archive, permission, or manual-start restrictions. Changing sessions shall cancel obsolete recovery effects.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.8:** Desktop, phone, and task preview shall expose equivalent recovery outcomes. Feedback shall preserve chat access and keyboard focus without horizontal overflow.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.9:** Phone and coarse-pointer recovery controls shall have touch targets of at least 44 pixels. Progress shall have a localized status announcement.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.10:** Permission and missing-session failures shall stop automatic retries. A successful empty history response shall still show the normal empty-conversation invitation.
- A session subscription exposes a readiness point that resolves only after the
backend has acknowledged registration.
- Transcript reconciliation for a newly mounted or reconnected session starts
after that readiness point, so the persisted message view covers the interval
before registration and live notifications cover the interval after it.
- Ref-counted consumers share an in-flight registration readiness point and do
not unregister one another's subscription.
- Reconnect and delayed-session retry paths use the same acknowledgement-aware
registration behavior.
- A failed or disconnected registration does not create an unhandled client
rejection or leave a stale readiness promise that prevents the next
connection from hydrating the session.
## Implementation plans
## Scenarios
- **GIVEN** a browser sends `session.subscribe` while a session state change or
reply is persisted before the backend registers the subscription, **WHEN** the
backend acknowledges the subscription and the client reconciles messages,
**THEN** the client displays the authoritative state and persisted reply
without a page reload.
- **GIVEN** one client-side consumer is already registering a session and a
second consumer mounts, **WHEN** the second consumer requests hydration,
**THEN** it waits for the shared registration acknowledgement and does not
race an earlier `message.list` request ahead of registration.
- **GIVEN** a session subscription is restored after a WebSocket reconnect,
**WHEN** the restored registration is acknowledged, **THEN** message
reconciliation can run against the restored subscription and later events are
delivered normally.
- **GIVEN** the registration fails or the socket disconnects before the
acknowledgement, **WHEN** the client retries on the next connected socket,
**THEN** the retry can establish a fresh readiness point and no stale promise
or unhandled rejection blocks hydration.
- [Acknowledgement ordering](../../../plans/session-subscription-recovery/plan.md)
- [Delayed session entry](../../../plans/session-entry-recovery/plan.md)
## Out of scope
@@ -0,0 +1,95 @@
---
status: draft
system: platform
requirements:
- REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001
- REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002
---
# Session subscription recovery
## Ownership and mapping
Platform owns shared session subscription readiness and recovery. This design extends that contract across chat, task preview, and session-status feedback.
| Requirement | Design |
| --- | --- |
| REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001 | Registration ordering and ownership |
| REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002 | Entry recovery, history state, and presentation |
## Registration ordering and ownership
The gateway registers membership before acknowledging `session.subscribe` in `apps/backend/internal/gateway/websocket/client.go`.
The frontend `WebSocketClient` owns reference counts and a shared readiness promise per session and connection generation.
`fetchAndStoreMessages` in `use-session-messages.ts` waits for readiness before requesting history.
This ordering remains mandatory. Recovery must not fetch history ahead of registration.
## Entry recovery
Use explicit 10-second deadlines for subscription registration and entry status checks. Keep the general request default unchanged.
Add a typed timeout error in `apps/web/lib/ws/request-error.ts`, retaining its existing diagnostic message.
Only this typed timeout permits the new automatic retry. Backend rejection codes retain their current meaning.
The client owns subscription retries within one shared readiness promise: two attempts, with one second between attempts.
Do not increase reference counts for a retry. Concurrent consumers and unknown-session retries join an active registration episode.
Only the current connection generation can settle readiness. Last unsubscribe cancels its timer and invalidates pending results.
A disconnect cancels the episode; existing reconnect registration starts a fresh generation after reconnection.
Do not queue episode retries while disconnected. Existing inactive-thread subscription limits remain unchanged.
The resumption hook separately retries only the `task.session.status` request with the same two-attempt policy.
Keep `processResumeStatus` outside that retry operation. It runs once after a current successful status response.
Never wrap resume, restore, launch, or prompt mutations in a retry loop.
Preserve archive-state checks, request-identity guards, and existing resume eligibility policy.
After registration, history uses its existing 10-second request deadline and at most one additional timeout retry after one second.
A continuously connected initial episode therefore takes at most 42 seconds: 21 for registration and 21 for history.
Status progresses independently and takes at most 21 seconds. Automatic failure feedback appears within the 45-second requirement.
Use timers with cleanup and fake-timer tests. Background throttling is outside the continuously visible timing guarantee.
Foreground refresh and reconnect remain recovery triggers, but they join an active episode instead of resetting its budget.
After exhaustion, explicit Retry starts a new bounded episode. Disable it while that episode is active.
## History state
Extend the existing message hooks with explicit load outcome and retry action: loading, retrying, ready, or unavailable.
Keep this state associated with session identity and hydration generation. Share in-flight fetches across mounted consumers.
The hook result supplies the state to `use-chat-panel-state.ts`, `task-chat-panel.tsx`, and the shared message-list components.
A successful snapshot alone establishes history initialization, including a successful empty snapshot.
`doFetchMessages` in `use-session-message-fetch.ts` must not clear messages on rejection or mark failed hydration as successful.
Preserve message reconciliation, prompt-history independence, draft state, and scroll anchoring.
An obsolete response cannot overwrite another session or clear a newer episode's error.
Partial live messages remain visible while initial reconciliation is pending.
## Presentation
Use the existing dedicated phone chat layout in `components/task/task-layout.tsx` as the mobile composition exemplar.
Reuse the inline loading region in `components/task/chat/message-list-shared.tsx`.
History feedback lives inside the chat region. Cached messages remain below a compact notice during recovery.
The transcript remains the scroll owner. Do not add an overlay, drawer, or separate nested scroller for short feedback.
The existing dynamic viewport and composer safe-area behavior remain intact.
Use localized copy such as `Loading conversation...`, `Taking longer than usual. Retrying...`, and `Conversation could not load.`
For exhausted status checks, use `Session status is unavailable.` with Retry and a collapsed Details disclosure.
Route entry-status feedback through a typed failure kind in `SessionRecoveryFeedback`, including task page and preview consumers.
Do not globally rename actual launch failures in `describeEnsureError` or suppress provider, profile, and workspace recovery actions.
A status-only failure must not replace the transcript loading result or hide usable cached chat.
When both fail, show one history notice in chat and retain status details without a duplicate large banner.
Desktop actions use the normal 28-pixel control size. Phone or coarse-pointer actions use at least 44-pixel targets.
Phone notices place actions below the message; desktop notices keep actions on the same row when space permits.
Use one polite `role="status"` per visible operation and keep Retry's accessible name stable.
Details use a keyboard-accessible disclosure with wrapped text. Do not move focus on automatic progress.
All new copy uses existing i18n catalogs in five languages, with generated Traditional Chinese variants.
## Persistence, security, and observability
No database, protocol, public API, or permission changes are required.
Existing server-side authorization and mutation admission remain authoritative.
Retries preserve typed backend errors and expose only existing transport diagnostics in Details.
Tests correlate request action and ID; they do not record chat payloads or credentials.
The cause of the observed seven-second delivery delay is unconfirmed. This design does not claim to remove that underlying latency.
## Implementation plans
- [Original ordering repair](../../../plans/session-subscription-recovery/plan.md)
- [Delayed session entry](../../../plans/session-entry-recovery/plan.md)