10 KiB
status, system, created, owners
| status | system | created | owners | |
|---|---|---|---|---|
| draft | tasks | 2026-08-15 |
|
Kanban task cache preserves executor fields across merges Requirements
Overview
Preserve the observable behavior documented for Kanban task cache preserves executor fields across merges.
Requirements
REQ-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001: Kanban task cache preserves executor fields across merges
Intent: Preserve the observable behavior documented for Kanban task cache preserves executor fields across merges.
Acceptance criteria
- AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.1: When
mergeKanbanTaskstakes the "incoming is newer-or-equal" branch for a task already present in the cache, and the incoming task'sprimaryExecutorTypeisundefined, the merged task keeps the existing cachedprimaryExecutorId,primaryExecutorType,primaryExecutorName, andisRemoteExecutorvalues instead of losing them to the replace. - AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.2: When the incoming task's
primaryExecutorTypeis defined (a real value, from a payload that legitimately carries executor state), the incoming value(s) win outright — this is not a sticky/permanent preserve, only a gap-fill for a payload that omits the field. - AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.3: In
use-all-workflow-snapshots.ts, a current cached executor bundle wins when it changed after the snapshot request started, even when the response contains an older explicit executor bundle. - AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.4: When the executor bundle did not change after the request started, the full snapshot is authoritative. An omitted executor bundle can clear the cache.
- AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.5: When a task first appears while the request is active, its current cached executor bundle wins over a response captured before that live event.
- AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.6: Every other field's existing merge behavior (timestamp comparison, the dependency-projection backfill on the older-incoming branch, unrelated fields' wholesale replace) is unchanged.
- AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.7: GIVEN a cached kanban task whose executor is already known (all four executor fields populated), WHEN a
mergeKanbanTasksmerge receives an incoming task with the sameid, anupdatedAtequal to or newer than the cached value, and noprimary_executor_*/is_remote_executorkeys in its source payload, THEN the merged task keeps its previously known executor fields instead of losing them to the wholesale replace. - AC-TASKS-KANBAN-TASK-EXECUTOR-CACHE-STALENESS-001.8: GIVEN the same cached task, WHEN a
mergeKanbanTasksmerge receives an incoming task at or after the cachedupdatedAtwhose payload legitimately carries a different executor type, THEN the merged task adopts the new executor value(s); the preserve never overrides a real value.
Migrated source detail
Problem
The kanban task cache learns a task's primary executor (primaryExecutorId,
primaryExecutorType, primaryExecutorName, isRemoteExecutor) from whichever
task payload arrives with it populated. primary_executor_* are omitempty on
the backend task DTO and are derived per read from the primary session's
executor binding (apps/backend/internal/task/dto/dto.go:162-164,180,
apps/backend/internal/task/service/service_events.go:566-582); binding an
executor updates only the task_sessions row, not the owning task's
updated_at (apps/backend/internal/task/repository/sqlite/session.go:741-743, 907-921).
Two of the three client code paths that merge a fresh task payload into the
cache already guard the resulting gap explicitly — an omitempty field can be
absent on a payload that is otherwise the newest or an equal-timestamp
duplicate, and an absent key must not erase an already-known value:
apps/web/lib/ws/handlers/tasks.ts:40-61(preservePrimaryExecutorFields, keyed off raw WebSocket payload key presence).apps/web/lib/state/hydration/hydrator.ts'sbackfillServerDerivedFieldsguards five dependency-projection fields the same way, but only on its "incoming is older" branch, and does not cover executor fields.
Two merge sites have no such guard at all for the executor fields:
apps/web/lib/state/hydration/hydrator.ts'smergeKanbanTasks, on the "incoming is newer-or-equal" branch, replaces the cached task object wholesale (draftTasks[idx] = incoming) with no gap-fill.apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts's per-workflow snapshot merge preservesprimarySessionId,primarySessionState,autopilot, andstatusSummarywhen a fresh response omits them, but has no equivalent guard for any of the four executor fields.
Because mergeKanbanTasks compares updatedAt with >= (not >), and
because an executor binding never bumps the task row's updated_at, a stale or
same-timestamp response that lands after a task.updated WebSocket event
already populated the executor fields silently blanks them back out in the
cache. Kanban card executor badges, the task switcher, and
getWorkspaceSourceCapabilities-gated affordances (e.g. the workspace "Add
folder" action, which needs a known non-undefined executor type to render)
regress to their unknown-executor state even though the executor is already
known.
isRemoteExecutor needs its own handling: toKanbanTask
(apps/web/lib/kanban/map-task.ts:199) maps the wire's is_remote_executor
with ?? false, so the mapped field is never undefined — unlike its three
sibling executor fields, which map with ?? undefined. A plain
"preserve-when-undefined" rule therefore cannot detect that
isRemoteExecutor was actually omitted from a given payload. is_remote_executor
is only ever emitted alongside primary_executor_type (both are derived from
the same executor snapshot; see service_events.go:566-582 and
dto.go:775-782), so primaryExecutorType's own undefined-ness is the
reliable signal for whether the whole four-field executor bundle was omitted
from a given payload.
Desired behavior
- When
mergeKanbanTaskstakes the "incoming is newer-or-equal" branch for a task already present in the cache, and the incoming task'sprimaryExecutorTypeisundefined, the merged task keeps the existing cachedprimaryExecutorId,primaryExecutorType,primaryExecutorName, andisRemoteExecutorvalues instead of losing them to the replace. - When the incoming task's
primaryExecutorTypeis defined (a real value, from a payload that legitimately carries executor state), the incoming value(s) win outright — this is not a sticky/permanent preserve, only a gap-fill for a payload that omits the field. - In
use-all-workflow-snapshots.ts, a current cached executor bundle wins when it changed after the snapshot request started, even when the response contains an older explicit executor bundle. - When the executor bundle did not change after the request started, the full snapshot is authoritative. An omitted executor bundle can clear the cache.
- When a task first appears while the request is active, its current cached executor bundle wins over a response captured before that live event.
- Every other field's existing merge behavior (timestamp comparison, the dependency-projection backfill on the older-incoming branch, unrelated fields' wholesale replace) is unchanged.
Regression scenarios
- GIVEN a cached kanban task whose executor is already known (all four
executor fields populated), WHEN a
mergeKanbanTasksmerge receives an incoming task with the sameid, anupdatedAtequal to or newer than the cached value, and noprimary_executor_*/is_remote_executorkeys in its source payload, THEN the merged task keeps its previously known executor fields instead of losing them to the wholesale replace. - GIVEN the same cached task, WHEN a
mergeKanbanTasksmerge receives an incoming task at or after the cachedupdatedAtwhose payload legitimately carries a different executor type, THEN the merged task adopts the new executor value(s); the preserve never overrides a real value. - GIVEN a workflow snapshot response whose task list omits the executor
fields for a task the cache already knows the executor for, WHEN
useAllWorkflowSnapshotsmerges that response, THEN the merged snapshot task keeps the cached executor fields. - GIVEN a cached task whose executor is known at request start, WHEN a live event clears the executor and a stale response returns the old executor bundle, THEN the merged task stays clear.
- GIVEN a task that is absent at request start, WHEN a live event adds the task with executor data and a stale response omits that data, THEN the merged task keeps the live executor bundle.
Constraints
- No backend, API, or persistence contract changes; this is a client-side cache merge correctness fix using data already delivered on the wire.
- Do not change
toKanbanTask's existingis_remote_executor ?? falsemapping or its pinnedmap-task.test.tscontract test — the merge-layer fix works around that mapping's loss of "omitted" information forisRemoteExecutorby gating onprimaryExecutorType's ownundefined-ness instead, rather than changing the shared mapper's output contract. - The snapshot merge uses the request-start cache as its freshness boundary. It copies the complete current executor bundle when the task changed after request start or first appeared during the request. It does not use omission alone as proof of a stale response.
- The hydration merge still uses the mapped
KanbanTaskshape. It cannot distinguish an explicit clear from an omitted key in that path, so it keeps the existing gap-fill rule and its known limitation.
Out of scope
- Changing
toKanbanTask/map-task.ts's field mapping. - Adding a "session detached" disambiguation signal to the mapped
KanbanTaskshape or to either merge site. - Any change to
apps/web/lib/ws/handlers/tasks.ts's existingpreservePrimaryExecutorFields, which already handles the WebSocket path correctly. - The
getWorkspaceSourceCapabilitiesdisabled/hidden-state UI copy itself; this fix addresses the cache staleness that feeds it, not its rendering.