Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
13 KiB
created, status, requirements, system_design, legacy_specs
| created | status | requirements | system_design | legacy_specs | ||
|---|---|---|---|---|---|---|
| 2026-08-03 | completed |
|
|
Implementation Plan: Backend-owned cancel-turn progress
Overview
Promote the orchestrator's existing per-session cancellation registry into the authoritative runtime projection described by ADR-2026-08-03. Publish the projection through live session events and every session hydration path, then make the shared cancel control combine that backend value with its existing short-lived optimistic request state. Replace the request-held browser regression with backend-accepted task-switch and reload coverage on desktop and mobile.
Root cause and superseded direction
SubmitButton originally owned cancellation progress in component-local state, so a task-route
remount lost it. Tasks 01 and 02 moved that state into the application UI store and proved SPA
navigation, but the store is still scoped to one loaded browser page. It cannot hydrate a reload or
second tab and it treats the request promise, rather than the orchestrator operation, as the source
of truth.
The existing Service.cancelOperations registry already tracks actual cancellation intent and the
existing cancelInFlight guard already deduplicates lifecycle work. The revised implementation
exposes that fact instead of adding another lifecycle state or a durable database marker. The Task
01 store remains useful only as immediate optimistic feedback before backend acceptance. Runtime
transitions carry a process-local per-session revision so independent REST, boot, and WebSocket
delivery can be ordered without relying on the database timestamp.
Backend
Cancellation operation ownership
apps/backend/internal/orchestrator/service.go: document the cancellation registry as the source of the public runtime projection; keep its reference-counted, per-session ownership separate from the broadercancelInFlightmutex.apps/backend/internal/orchestrator/task_operations.go: exposeCancellationPending(sessionID string) booland the atomicCancellationPendingSnapshot(sessionID string) (bool, uint64)form; makebeginCancelInFlightpublish only the0 -> 1and1 -> 0transitions; increment the process-local revision and append the transition under the same critical section; drain each session's queue outside the lock; retain idempotent release and duplicate-request reference counting.- In
Service.CancelAgent, authorize with the caller context and then usecontext.WithoutCancel(ctx)for the accepted cancellation, reconciliation, message, turn, and transition publications. This preserves page-disconnect behavior without removing the lifecycle manager's own ten-second cancel wait and escalation bounds. - Publish
{session_id, cancellation_pending, cancellation_revision}onevents.TaskSessionCancellationChanged. Event-publication failure is logged but never changes the cancellation result; the final deferred release must clear pending on every error path.
Session wire contract and routing
apps/backend/internal/events/types.goandapps/backend/pkg/websocket/actions.go: add the semantic event/action pairtask_session.cancellation_changed/session.cancellation_changed.apps/backend/internal/gateway/websocket/task_notifications.go: subscribe to the event and route it only throughHub.BroadcastToSession. It is semantic session traffic, not replaceable stream traffic and not a task-summary update.apps/backend/internal/task/dto/dto.go: add non-omitemptyCancellationPending boolandCancellationRevision uint64fields to bothTaskSessionDTOandTaskSessionSummaryDTO; add a narrow atomic snapshot provider alongsideCancellationPendingProviderand enrichers that always write the provider result.apps/backend/internal/task/handlers/task_handlers.goandtask_http_handlers.go: discover the provider from the orchestrator and enrich list/get session responses alongside foreground activity.
Boot, REST, and subscription reconciliation
apps/backend/internal/backendapp/boot_state.go: enrich every task-detail and quick-chat session DTO before inserting it intotaskSessions.itemsandtaskSessionsByTask.apps/backend/internal/backendapp/helpers.go: pass a cancellation provider intobuildSessionDataProviderand add explicitcancellation_pendingto the authoritativesession.state_changedsubscription snapshot.apps/backend/internal/backendapp/main.go: wireorchestratorSvcinto the session data provider. A fresh page or a client that missed a live transition must therefore reconcile without waiting for another cancel event.- No repository, schema, migration, or session-metadata change is planned. A backend restart resets the runtime projection and leaves existing coarse session/execution recovery authoritative.
Frontend
Session contracts and live updates
apps/web/lib/types/http.ts: add the backend-ownedcancellation_pendingand optionalcancellation_revisionfields toTaskSession; backend wire boundaries emit both explicitly.apps/web/lib/types/backend.ts: add both cancellation fields to the state-snapshot payload, define the revision-bearingTaskSessionCancellationChangedPayload, and registersession.cancellation_changedin the backend message map.apps/web/lib/ws/handlers/agent-session.ts: merge explicit true and false values carried bysession.state_changed; handle revision-bearingsession.cancellation_changedby updating the existing session row and its per-task list without changing coarse state or triggering task/Office lifecycle work; reject lower-revision snapshots/events.apps/web/lib/ws/handlers/agent-session.test.ts: cover explicit-false clearing, session isolation, an event arriving for an unloaded session, and state-snapshot/live-event convergence.
Shared cancel control
apps/web/components/task/chat/chat-input-toolbar-primitives.tsx: derive effective progress fromtaskSessions.items[sessionId].cancellation_pending || chatInput.cancellingBySessionId[sessionId]. Keep the current UI-slice value as immediate optimistic feedback and a same-page duplicate-click guard; it must not override an authoritative backendtrueafter the request promise settles.apps/web/components/task/chat/chat-input-toolbar.test.tsx: retain desktop/mobile remount coverage and add a new-store hydration case proving a backend-pending session renders the spinner after a page boundary. Prove explicit backend false plus cleared optimistic state returns to idle.- The existing
chatInputstore action remains transient and is not added to boot state or browser persistence.
Mobile design contract
Desktop and mobile continue using the same inline SubmitButton; there is no new surface, copy,
hierarchy, safe-area behavior, scroll owner, or gesture. The backend session field drives both
layouts. Because reload persistence changes user-visible behavior on compact screens, a dedicated
mobile-chrome Playwright regression is required in addition to the shared component coverage.
Tests
- Backend operation lifecycle:
apps/backend/internal/orchestrator/task_operations_test.goblocks the mock manager, observes pending true, cancels the initiating context, and proves the accepted operation remains pending until release and clears on success/error. Concurrent requests must still invoke the manager once and publish one true/false pair. - DTO projection: add focused tests under
apps/backend/internal/task/dto/for full and summary serialization of explicit true and false values. - REST and boot hydration: extend
apps/backend/internal/task/handlers/task_http_handlers_test.goandapps/backend/internal/backendapp/helpers_test.goto assert current cancellation state in list, get, task-detail boot, and initial session-subscription payloads. - WebSocket routing: extend
apps/backend/internal/gateway/websocket/task_notifications_test.goto prove cancellation events reach only the subscribed session and preserve both transition values. - Frontend reconciliation: extend
apps/web/lib/ws/handlers/agent-session.test.tsandapps/web/components/task/chat/chat-input-toolbar.test.tsxfor hydration, live updates, revision-aware delayed hydration, optimistic/backend union semantics, session isolation, and desktop/mobile rendering.
E2E Tests
- Task switch and reload: revise
apps/web/e2e/tests/chat/cancel-progress-task-switch.spec.ts. Start the mock agent's/e2e:cancel-holdprompt, send cancellation through to the backend, wait until the exposed session store reportscancellation_pending=true, switch away and back, reload the page, and assert the same disabled animated control until the backend settles. This replaces the current helper that holds the request before backend acceptance. - Compact reload parity: add
apps/web/e2e/tests/chat/mobile-cancel-progress-reload.spec.ts, limited tomobile-chrome. Tap cancel during the held mock turn, wait for backend-owned pending state, reload, and assert the shared control remains disabled and animated without desktop-only navigation assumptions. - Test support: add a cancellation-pending reader/waiter to
apps/web/e2e/helpers/session-store.ts; removerouteMainWebSocketWithHeldCancelRequestfromapps/web/e2e/helpers/ws-drop.tsonce no test uses it. The mock-agent hold gives the acknowledged cancellation a single predictable boundary in every mock run. Managed E2E startup carries the profile's three-second baseline throughAgentctlStartupConfigto ACP; the two cancel-progress regressions scope a 12-second override, while normal startup retains ACP's three-second default. No browser request hold, test timeout increase, or production lifecycle change is required.
Verification Results
The backend-owned package is complete. Superseded Tasks 01 and 02 below remain as historical records; revised Tasks 03-06 are the authoritative implementation evidence.
- Backend lifecycle-focused tests: 3 passed; cancellation regression set: 16 passed; race set: 4 passed.
- Backend projection packages (
internal/task/dto,internal/task/handlers,internal/backendapp,internal/gateway/websocket): 681 passed. - Frontend-focused contract/control/store tests: 104 passed; typecheck, lint, and i18n ratchet passed.
make -C apps/backend testandmake -C apps/backend buildcompleted without failures.- Desktop backend-owned switch/reload E2E: 1 passed; mobile-chrome reload E2E: 1 passed.
git diff --check: passed.- No schema migration, durable cancellation marker, cross-workspace broadcast, held WebSocket frame, or external side effect was added.
Review remediation results
- RED: deterministic successor-generation ordering, waiting-state caller-cancellation, and delayed REST hydration tests failed against the pre-fix implementation.
- Green backend:
go test ./internal/orchestrator ./internal/task/dto ./internal/task/handlers ./internal/backendapp ./internal/gateway/websocket -count=1— 2,138 passed. - Race: full
go test -race ./internal/orchestrator -count=1— 1,453 passed; cancellation-focused race set — 5 passed. - Frontend: the five focused session/control suites — 134 passed;
pnpm run typecheck, web lint, and the i18n ratchet passed. git diff --check— passed.
The remediation adds a process-local per-session revision to every cancellation snapshot/event, atomically queues transitions with count changes, rejects lower-revision client merges, and routes the waiting-state retry through the detached accepted-operation context. No new mobile composition was introduced; the existing mobile reload E2E covers the shared control, so a new mobile E2E was not needed for this data-ordering-only change.
Implementation Waves And Parallel Candidates
Previous implementation record (completed, partially retained as optimistic behavior):
- task-01-session-scoped-cancel-state — frontend navigation state; superseded as the authoritative owner by Tasks 03-05.
- task-02-task-switch-regression-e2e — request-held regression; replaced by Task 06's backend-accepted navigation/reload coverage.
Revised implementation, sequential dependency chain:
- task-03-backend-cancellation-lifecycle — completed; establishes runtime ownership and operation transitions.
- task-04-cancellation-projection-contract — completed; depends on Task 03 and exposes hydration/live contracts.
- task-05-backend-owned-cancel-control — completed; depends on Task 04 and consumes the backend projection.
- task-06-cancel-reload-regression-e2e — completed; depends on Task 05 and proves desktop/mobile navigation and reload behavior.
These tasks are not parallel-safe: each changes or validates the contract established by the preceding task. The wave list does not authorize subagents.