From 4e04d09f67be7e4abfc3493cb7fc5b3df43836a0 Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:46:25 +0000 Subject: [PATCH] fix(web): preserve executor fields across kanban task cache merges (#2705) Co-authored-by: ayattara Co-authored-by: Carlos Florencio --- .../kanban/use-all-workflow-snapshots.test.ts | 185 ++++++++++++++++++ .../kanban/use-all-workflow-snapshots.ts | 48 +++-- apps/web/lib/kanban/map-task.test.ts | 49 ++++- apps/web/lib/kanban/map-task.ts | 20 ++ .../hydration/hydrator-kanban-tasks.test.ts | 133 +++++++++++++ apps/web/lib/state/hydration/hydrator.ts | 18 +- .../plan.md | 143 ++++++++++++++ ...ask-01-preserve-helper-and-hydrator-fix.md | 140 +++++++++++++ .../task-02-snapshot-merge-fix.md | 104 ++++++++++ docs/specs/INDEX.md | 1 + .../spec.md | 140 +++++++++++++ 11 files changed, 962 insertions(+), 19 deletions(-) create mode 100644 apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts create mode 100644 docs/plans/kanban-task-executor-cache-staleness/plan.md create mode 100644 docs/plans/kanban-task-executor-cache-staleness/task-01-preserve-helper-and-hydrator-fix.md create mode 100644 docs/plans/kanban-task-executor-cache-staleness/task-02-snapshot-merge-fix.md create mode 100644 docs/specs/kanban-task-executor-cache-staleness/spec.md diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts index eead725ec2..83bee49a54 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts @@ -297,6 +297,191 @@ describe("useAllWorkflowSnapshots — snapshot mapping", () => { }); }); +const REMOTE_EXECUTOR_NAME = "Remote box"; + +function seedExecutor(executor: Record) { + mockState.kanbanMulti.snapshots = { + "wf-A": { + workflowId: "wf-A", + workflowName: "A", + isPlaceholder: true, + steps: [], + tasks: [{ id: "task-1", workflowStepId: "step-1", ...executor }], + }, + }; +} + +function seedCachedExecutor() { + seedExecutor({ + primaryExecutorId: "exec-1", + primaryExecutorType: "worktree", + primaryExecutorName: "Worktree", + isRemoteExecutor: false, + }); +} + +function seedNewerLiveExecutor() { + seedExecutor({ + primaryExecutorId: "exec-2", + primaryExecutorType: "ssh", + primaryExecutorName: REMOTE_EXECUTOR_NAME, + isRemoteExecutor: true, + }); +} + +describe("useAllWorkflowSnapshots — executor field preservation", () => { + beforeEach(() => { + resetMocks([{ id: "wf-A", workspaceId: "ws-A", name: "A" }]); + }); + + it("honors an executor clear from a fresh snapshot", async () => { + seedCachedExecutor(); + mockFetchWorkflowSnapshot.mockResolvedValueOnce({ + steps: [{ id: "step-1", name: "Review", position: 1 }], + tasks: [{ id: "task-1", workflow_step_id: "step-1", title: "Task" }], + }); + + renderHook(() => useAllWorkflowSnapshots("ws-A")); + + await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled()); + const task = mockSetWorkflowSnapshot.mock.calls[0][1].tasks[0]; + expect(task.primaryExecutorId).toBeUndefined(); + expect(task.primaryExecutorType).toBeUndefined(); + expect(task.primaryExecutorName).toBeUndefined(); + }); + + it("preserves a newer live executor when a stale snapshot omits it", async () => { + seedCachedExecutor(); + let resolveSnapshot: ((value: { steps: unknown[]; tasks: unknown[] }) => void) | undefined; + mockFetchWorkflowSnapshot.mockImplementationOnce( + () => + new Promise<{ steps: unknown[]; tasks: unknown[] }>((resolve) => { + resolveSnapshot = resolve; + }), + ); + + renderHook(() => useAllWorkflowSnapshots("ws-A")); + await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalled()); + + seedNewerLiveExecutor(); + resolveSnapshot?.({ + steps: [{ id: "step-1", name: "Review", position: 1 }], + tasks: [{ id: "task-1", workflow_step_id: "step-1", title: "Stale task" }], + }); + + await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled()); + expect(mockSetWorkflowSnapshot.mock.calls[0][1].tasks[0]).toMatchObject({ + primaryExecutorId: "exec-2", + primaryExecutorType: "ssh", + primaryExecutorName: REMOTE_EXECUTOR_NAME, + isRemoteExecutor: true, + }); + }); + + it("adopts a legitimately different executor value from the fresh snapshot", async () => { + seedCachedExecutor(); + mockFetchWorkflowSnapshot.mockResolvedValueOnce({ + steps: [{ id: "step-1", name: "Review", position: 1 }], + tasks: [ + { + id: "task-1", + workflow_step_id: "step-1", + title: "Task", + primary_executor_id: "exec-2", + primary_executor_type: "ssh", + primary_executor_name: "Remote box", + is_remote_executor: true, + }, + ], + }); + + renderHook(() => useAllWorkflowSnapshots("ws-A")); + + await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled()); + expect(mockSetWorkflowSnapshot.mock.calls[0][1].tasks[0]).toMatchObject({ + primaryExecutorId: "exec-2", + primaryExecutorType: "ssh", + primaryExecutorName: "Remote box", + isRemoteExecutor: true, + }); + }); +}); + +describe("useAllWorkflowSnapshots — executor race ordering", () => { + beforeEach(() => { + resetMocks([{ id: "wf-A", workspaceId: "ws-A", name: "A" }]); + }); + + it("keeps a live detach when a stale snapshot carries the old executor", async () => { + seedExecutor({ + primaryExecutorId: "exec-1", + primaryExecutorType: "worktree", + primaryExecutorName: "Worktree", + isRemoteExecutor: true, + }); + let resolveSnapshot: ((value: { steps: unknown[]; tasks: unknown[] }) => void) | undefined; + mockFetchWorkflowSnapshot.mockImplementationOnce( + () => + new Promise<{ steps: unknown[]; tasks: unknown[] }>((resolve) => { + resolveSnapshot = resolve; + }), + ); + + renderHook(() => useAllWorkflowSnapshots("ws-A")); + await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalled()); + + seedExecutor({ isRemoteExecutor: false }); + resolveSnapshot?.({ + steps: [{ id: "step-1", name: "Review", position: 1 }], + tasks: [ + { + id: "task-1", + workflow_step_id: "step-1", + title: "Stale task", + primary_executor_id: "exec-1", + primary_executor_type: "worktree", + primary_executor_name: "Worktree", + is_remote_executor: true, + }, + ], + }); + + await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled()); + const task = mockSetWorkflowSnapshot.mock.calls[0][1].tasks[0]; + expect(task.primaryExecutorId).toBeUndefined(); + expect(task.primaryExecutorType).toBeUndefined(); + expect(task.primaryExecutorName).toBeUndefined(); + expect(task.isRemoteExecutor).toBe(false); + }); + + it("keeps the live executor for a task added during the snapshot fetch", async () => { + let resolveSnapshot: ((value: { steps: unknown[]; tasks: unknown[] }) => void) | undefined; + mockFetchWorkflowSnapshot.mockImplementationOnce( + () => + new Promise<{ steps: unknown[]; tasks: unknown[] }>((resolve) => { + resolveSnapshot = resolve; + }), + ); + + renderHook(() => useAllWorkflowSnapshots("ws-A")); + await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalled()); + + seedNewerLiveExecutor(); + resolveSnapshot?.({ + steps: [{ id: "step-1", name: "Review", position: 1 }], + tasks: [{ id: "task-1", workflow_step_id: "step-1", title: "Stale task" }], + }); + + await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled()); + expect(mockSetWorkflowSnapshot.mock.calls[0][1].tasks[0]).toMatchObject({ + primaryExecutorId: "exec-2", + primaryExecutorType: "ssh", + primaryExecutorName: REMOTE_EXECUTOR_NAME, + isRemoteExecutor: true, + }); + }); +}); + /** * A snapshot request issued before a `task.status_summary.updated` delta can * land after it. Writing the response's summary unconditionally regresses the diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 34bc48eec7..6c2e46cae2 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; import { fetchWorkflowSnapshot } from "@/lib/api"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { toKanbanTask } from "@/lib/kanban/map-task"; +import { copyPrimaryExecutorFields, toKanbanTask } from "@/lib/kanban/map-task"; import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import type { Task } from "@/lib/types/http"; import type { StoreApi } from "zustand"; @@ -45,19 +45,33 @@ function hasNewerLiveAutoStartFailed( return existing.autoStartFailed !== fetchStart.autoStartFailed; } +function hasNewerLiveExecutor(existing: KanbanTask, fetchStart: KanbanTask | undefined): boolean { + if (!fetchStart) return true; + return ( + existing.primaryExecutorId !== fetchStart.primaryExecutorId || + existing.primaryExecutorType !== fetchStart.primaryExecutorType || + existing.primaryExecutorName !== fetchStart.primaryExecutorName || + existing.isRemoteExecutor !== fetchStart.isRemoteExecutor + ); +} + function mergeFetchedTask( mapped: KanbanTask, existing: KanbanTask, fetchStart: KanbanTask | undefined, statusSummaryInvalidated: boolean, ): KanbanTask { + // Production snapshot payloads can be surfaced through read-only objects. + // Merge into a fresh task copy before applying any live-field backfills. + const merged = { ...mapped }; + if (hasNewerLivePlacement(existing, fetchStart)) { // A live task.updated/task.moved event arrived after this request // started. Keep the newer placement instead of moving the card // back to the location captured by the older snapshot response. - mapped.workflowId = existing.workflowId; - mapped.workflowStepId = existing.workflowStepId; - mapped.position = existing.position; + merged.workflowId = existing.workflowId; + merged.workflowStepId = existing.workflowStepId; + merged.position = existing.position; } // Multiple mounted surfaces can refresh the same workflow at once. @@ -65,28 +79,34 @@ function mergeFetchedTask( // afterwards, so do not let an older snapshot roll the status // projection back to a lower revision. if (statusSummaryInvalidated && !hasNewerLiveStatusSummary(existing, fetchStart)) { - mapped.statusSummary = undefined; + merged.statusSummary = undefined; } else { const existingRevision = existing.statusSummary?.revision; - const mappedRevision = mapped.statusSummary?.revision; + const mappedRevision = merged.statusSummary?.revision; if (existingRevision !== undefined && existingRevision > (mappedRevision ?? -1)) { - mapped.statusSummary = existing.statusSummary; + merged.statusSummary = existing.statusSummary; } // An equal revision can still carry a newer queued-prompt count. Preserve // the freshest status projection while keeping the revision guard above. - mapped.statusSummary = pickFreshestStatusSummary(mapped.statusSummary, existing.statusSummary); + merged.statusSummary = pickFreshestStatusSummary(merged.statusSummary, existing.statusSummary); } - mapped.primarySessionId = mapped.primarySessionId || existing.primarySessionId; - mapped.primarySessionState = mapped.primarySessionState || existing.primarySessionState; + merged.primarySessionId = merged.primarySessionId || existing.primarySessionId; + merged.primarySessionState = merged.primarySessionState || existing.primarySessionState; // Autopilot is immutable after creation. Keep the cached value when // an older or partial snapshot does not include the field. - mapped.autopilot = mapped.autopilot ?? existing.autopilot; + merged.autopilot = merged.autopilot ?? existing.autopilot; // A task.updated event can set or clear this marker while the snapshot is // in flight. Preserve that newer live value instead of rolling it back. - if (mapped.autoStartFailed === undefined || hasNewerLiveAutoStartFailed(existing, fetchStart)) { - mapped.autoStartFailed = existing.autoStartFailed; + if (merged.autoStartFailed === undefined || hasNewerLiveAutoStartFailed(existing, fetchStart)) { + merged.autoStartFailed = existing.autoStartFailed; } - return mapped; + // An omitted executor bundle in a current full snapshot represents a + // legitimate detach. Only copy live fields when a live update changed the + // executor after this request began, or when the task appeared in flight. + if (hasNewerLiveExecutor(existing, fetchStart)) { + copyPrimaryExecutorFields(merged, existing); + } + return merged; } function mergeSnapshotTasks( diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 7bfb92b230..43bb0d3054 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { toKanbanTask, type TaskLike } from "./map-task"; +import { toKanbanTask, preserveOmittedExecutorFields, type TaskLike } from "./map-task"; /** * Parity matrix: the HTTP DTO and the WS payload describe the same task from @@ -273,3 +273,50 @@ describe("toKanbanTask priority", () => { expect(toKanbanTask(wsPayload()).priority).toBe("critical"); }); }); + +describe("preserveOmittedExecutorFields", () => { + const existing = toKanbanTask( + httpDTO({ + primary_executor_id: "exec-1", + primary_executor_type: "worktree", + primary_executor_name: "Worktree", + is_remote_executor: false, + }), + ); + + it("backfills all four executor fields when the incoming task omits them", () => { + const incoming = toKanbanTask( + httpDTO({ + primary_executor_id: undefined, + primary_executor_type: undefined, + primary_executor_name: undefined, + is_remote_executor: undefined, + }), + ); + + preserveOmittedExecutorFields(incoming, existing); + + expect(incoming.primaryExecutorId).toBe("exec-1"); + expect(incoming.primaryExecutorType).toBe("worktree"); + expect(incoming.primaryExecutorName).toBe("Worktree"); + expect(incoming.isRemoteExecutor).toBe(false); + }); + + it("leaves a real incoming executor value untouched", () => { + const incoming = toKanbanTask( + httpDTO({ + primary_executor_id: "exec-2", + primary_executor_type: "ssh", + primary_executor_name: "Remote box", + is_remote_executor: true, + }), + ); + + preserveOmittedExecutorFields(incoming, existing); + + expect(incoming.primaryExecutorId).toBe("exec-2"); + expect(incoming.primaryExecutorType).toBe("ssh"); + expect(incoming.primaryExecutorName).toBe("Remote box"); + expect(incoming.isRemoteExecutor).toBe(true); + }); +}); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 4a0fd6106a..a5f5f5a98d 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -174,6 +174,26 @@ function dependencyProjection( }; } +/** + * `primaryExecutorId`/`Name` map with `?? undefined`, so an omitted wire field + * survives mapping as `undefined`. `isRemoteExecutor` maps with `?? false` + * instead, so it can never signal omission on its own — but the backend only + * ever emits `is_remote_executor` alongside `primary_executor_type` (both are + * derived from the same executor snapshot), so gating the whole bundle on + * `primaryExecutorType`'s own `undefined`-ness is the reliable signal. + */ +export function preserveOmittedExecutorFields(merged: KanbanTask, existing: KanbanTask): void { + if (merged.primaryExecutorType !== undefined) return; + copyPrimaryExecutorFields(merged, existing); +} + +export function copyPrimaryExecutorFields(merged: KanbanTask, existing: KanbanTask): void { + merged.primaryExecutorId = existing.primaryExecutorId; + merged.primaryExecutorType = existing.primaryExecutorType; + merged.primaryExecutorName = existing.primaryExecutorName; + merged.isRemoteExecutor = existing.isRemoteExecutor; +} + export function toKanbanTask(source: TaskLike): KanbanTask { return { id: pickId(source), diff --git a/apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts b/apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts new file mode 100644 index 0000000000..dafcb18b57 --- /dev/null +++ b/apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { produce } from "immer"; +import type { Draft } from "immer"; +import { hydrateState } from "./hydrator"; +import { defaultState } from "@/lib/state/default-state"; +import type { AppState } from "@/lib/state/store"; + +function makeAppDraft(): AppState { + return structuredClone(defaultState) as AppState; +} + +const taskId = "task-1"; +const updatedAt = "2026-08-15T00:00:00.000Z"; +const knownExecutor = { + primaryExecutorId: "exec-1", + primaryExecutorType: "worktree", + primaryExecutorName: "Worktree", + isRemoteExecutor: false, +}; + +function seedKnownExecutorTask(draft: Draft): void { + hydrateState(draft, { + kanban: { + tasks: [ + { + id: taskId, + updatedAt, + ...knownExecutor, + }, + ], + }, + } as unknown as Partial); +} + +function hydrateFrozenIncoming( + incoming: Readonly<{ id: string; updatedAt: string; title: string }>, +): AppState { + return produce(makeAppDraft(), (draft: Draft) => { + seedKnownExecutorTask(draft); + + hydrateState(draft, { + kanban: { + tasks: [incoming], + }, + } as unknown as Partial); + }); +} + +describe("hydrateState — kanban task executor field preservation", () => { + it("keeps known executor fields when a same-timestamp merge omits them", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + seedKnownExecutorTask(draft); + + hydrateState(draft, { + kanban: { + tasks: [ + { + id: taskId, + updatedAt, + title: "Refreshed title", + }, + ], + }, + } as unknown as Partial); + }); + + const merged = result.kanban.tasks.find((t) => t.id === taskId); + expect(merged).toMatchObject(knownExecutor); + expect(merged?.title).toBe("Refreshed title"); + }); + + it("adopts a legitimately different executor value instead of preserving the old one", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + seedKnownExecutorTask(draft); + + hydrateState(draft, { + kanban: { + tasks: [ + { + id: taskId, + updatedAt: "2026-08-15T00:01:00.000Z", + primaryExecutorId: "exec-2", + primaryExecutorType: "ssh", + primaryExecutorName: "Remote box", + isRemoteExecutor: true, + }, + ], + }, + } as unknown as Partial); + }); + + const merged = result.kanban.tasks.find((t) => t.id === taskId); + expect(merged).toMatchObject({ + primaryExecutorId: "exec-2", + primaryExecutorType: "ssh", + primaryExecutorName: "Remote box", + isRemoteExecutor: true, + }); + }); + + it("clones a frozen incoming task before preserving omitted executor fields", () => { + const incoming = Object.freeze({ + id: taskId, + updatedAt, + title: "Frozen snapshot task", + }); + + expect(() => hydrateFrozenIncoming(incoming)).not.toThrow(); + const result = hydrateFrozenIncoming(incoming); + const merged = result.kanban.tasks.find((t) => t.id === taskId); + expect(merged).toMatchObject({ + title: "Frozen snapshot task", + ...knownExecutor, + }); + }); + + it("clones a newer frozen task before preserving omitted executor fields", () => { + const incoming = Object.freeze({ + id: taskId, + updatedAt: "2026-08-15T00:01:00.000Z", + title: "Newer frozen snapshot task", + }); + + expect(() => hydrateFrozenIncoming(incoming)).not.toThrow(); + const result = hydrateFrozenIncoming(incoming); + const merged = result.kanban.tasks.find((t) => t.id === taskId); + expect(merged).toMatchObject({ + title: "Newer frozen snapshot task", + updatedAt: "2026-08-15T00:01:00.000Z", + ...knownExecutor, + }); + }); +}); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index da94d30453..b05e6657df 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -1,5 +1,6 @@ import type { Draft } from "immer"; import type { AppState, HydrationState } from "../store"; +import type { KanbanState } from "../slices/kanban/types"; import { migrateSidebarViewDraft, migrateView } from "../slices/ui/ui-slice"; import { mergeHydratedQuickChatSessions, @@ -12,6 +13,7 @@ import { reconcileActiveTurnAfterHydrationDraft, seedSettledSessionBoundaries, } from "@/lib/state/slices/session/turn-actions"; +import { preserveOmittedExecutorFields } from "@/lib/kanban/map-task"; import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; /** @@ -35,6 +37,8 @@ function mergeWithLoading(draft: any, source: any | undefined): void { } /** Merge kanban tasks by ID, keeping the version with the newer updatedAt timestamp. */ +type KanbanTask = KanbanState["tasks"][number]; + function mergeKanbanTasks( // eslint-disable-next-line @typescript-eslint/no-explicit-any draft: Draft, @@ -42,19 +46,25 @@ function mergeKanbanTasks( source: any[] | undefined, ): void { if (!source || source.length === 0) return; - const draftTasks = draft.tasks as Array<{ id: string; updatedAt?: string }>; - const existingById = new Map(draftTasks.map((t) => [t.id, t])); + const draftTasks = draft.tasks as Draft[]; + const existingById = new Map>( + draftTasks.map((task) => [task.id, task]), + ); for (const incoming of source) { const existing = existingById.get(incoming.id); if (!existing) { - draftTasks.push(incoming); + draftTasks.push({ ...incoming }); } else { const existingTime = existing.updatedAt ? new Date(existing.updatedAt).getTime() : 0; const incomingTime = incoming.updatedAt ? new Date(incoming.updatedAt).getTime() : 0; const idx = draftTasks.findIndex((t) => t.id === incoming.id); if (incomingTime >= existingTime) { - if (idx >= 0) draftTasks[idx] = incoming; + if (idx >= 0) { + const mergedIncoming = { ...incoming } as KanbanTask; + preserveOmittedExecutorFields(mergedIncoming, existing); + draftTasks[idx] = mergedIncoming; + } } else if (idx >= 0) { backfillServerDerivedFields(draftTasks[idx], incoming); } diff --git a/docs/plans/kanban-task-executor-cache-staleness/plan.md b/docs/plans/kanban-task-executor-cache-staleness/plan.md new file mode 100644 index 0000000000..f5f8bfaa2f --- /dev/null +++ b/docs/plans/kanban-task-executor-cache-staleness/plan.md @@ -0,0 +1,143 @@ +--- +spec: docs/specs/kanban-task-executor-cache-staleness/spec.md +created: 2026-08-15 +status: completed +--- + +# Implementation Plan: Kanban task cache preserves executor fields across merges + +## Overview + +Add an executor-fields preserve guard to the hydration merge and a +request-start freshness guard to the workflow snapshot merge. Hydration uses +`preserveOmittedExecutorFields` when an equal-or-newer incoming task omits its +executor bundle. Snapshot reconciliation compares the current task with the +task captured at request start, then copies the complete current bundle when a +live change or in-flight task creation makes the response stale. This mirrors +the existing `preservePrimaryExecutorFields` guard used by the WebSocket +handler (`apps/web/lib/ws/handlers/tasks.ts`) while keeping HTTP authority +rules separate from raw WebSocket presence checks. + +## Root cause + +`primary_executor_*`/`is_remote_executor` are `omitempty` fields derived per +read from the primary session's executor binding +(`apps/backend/internal/task/service/service_events.go:566-582`, +`apps/backend/internal/task/dto/dto.go:162-164,180`); binding an executor only +updates the `task_sessions` row, never the owning task's `updated_at` +(`apps/backend/internal/task/repository/sqlite/session.go:741-743,907-921`). +`mergeKanbanTasks` compares timestamps with `incomingTime >= existingTime` and, +on that branch, replaces the cached task object wholesale +(`apps/web/lib/state/hydration/hydrator.ts:47-49`) with no gap-fill — unlike +its `backfillServerDerivedFields` helper, which only runs on the opposite +(incoming-is-older) branch and only covers five dependency-projection fields, +not the executor fields. `use-all-workflow-snapshots.ts`'s snapshot merge +(`apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts:60-83`) preserves +`primarySessionId`/`primarySessionState`/`autopilot`/`statusSummary` when a +fresh response omits them but has no equivalent for the executor fields. A +stale or equal-timestamp response landing after a `task.updated` WebSocket +event already populated the executor fields therefore blanks them back out. + +`isRemoteExecutor` cannot be gap-filled with a plain per-field `undefined` +check: `toKanbanTask` maps it with `?? false` +(`apps/web/lib/kanban/map-task.ts:199`), so the mapped value is never +`undefined`, unlike its three sibling fields (`?? undefined`). Both fixed sites +still use `primaryExecutorType` as the bundle-presence signal when they need to +recognize an omitted response. The snapshot merge also compares the full +current bundle with the request-start bundle, so an explicit stale value cannot +override a live clear. + +## Frontend + +### Executor-fields preserve helper + +- In `apps/web/lib/kanban/map-task.ts`, add and export small helpers, + `preserveOmittedExecutorFields(merged: KanbanTask, existing: KanbanTask): + void`, that copies `primaryExecutorId`, `primaryExecutorType`, + `primaryExecutorName`, and `isRemoteExecutor` from `existing` onto `merged` + only when `merged.primaryExecutorType === undefined`. This is the shared home + both merge sites import from, matching the existing "single publisher / + single mapper" convention already documented on `TaskLike` in that file. +- Export `copyPrimaryExecutorFields` for snapshot reconciliation. This helper + copies all four fields, including `undefined` and `false`, when the live + cache is newer than the HTTP response. +- Do not change `toKanbanTask`'s existing `is_remote_executor ?? false` + mapping or the pinned `"defaults isRemoteExecutor to false when missing"` + test in `apps/web/lib/kanban/map-task.test.ts` — out of scope per the spec's + constraints. + +### `mergeKanbanTasks` wholesale-replace branch + +- In `apps/web/lib/state/hydration/hydrator.ts`, in the `incomingTime >= + existingTime` branch of `mergeKanbanTasks`, before assigning + `draftTasks[idx] = incoming`, call + `preserveOmittedExecutorFields(incoming, existing)` so the mutation lands on + the object that becomes the new cached task. +- Leave the `incomingTime < existingTime` branch and + `backfillServerDerivedFields` unchanged; this fix is scoped to the + wholesale-replace branch's regression only. + +### Workflow snapshot merge + +- In `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts`, capture the + task at request start and compare it with the current cached task. +- Copy the current executor bundle when the task changed after request start or + when the task first appeared during the request. Let a full response win + when no live executor change occurred. + +## Tests + +- **`mergeKanbanTasks` preserves known executor fields on a same/newer-timestamp + merge that omits them:** add a test to + `apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts` that hydrates a task with all + four executor fields populated, then hydrates again with the same `id`, an + equal `updatedAt`, and no executor fields on the incoming task; assert the + merged task in `draft.kanban.tasks` still has the original executor field + values. +- **`mergeKanbanTasks` still adopts a legitimately different executor value:** + add a test in the same file asserting that when the second hydration's + incoming task carries a different `primaryExecutorType` (and the other three + fields), the merged task adopts the new values rather than keeping the old + ones. +- **Snapshot merge preserves known executor fields when a fresh response omits + them:** add a test to + `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts`, modeled + on the existing `"preserves a cached autopilot marker when a fresh snapshot + omits it"` test, asserting the merged snapshot task keeps its cached + `primaryExecutorId`/`primaryExecutorType`/`primaryExecutorName`/ + `isRemoteExecutor` when the fresh response's task omits all four. +- **Snapshot merge preserves a live clear over an explicit stale bundle:** add + a deferred-response test where the request starts with executor A, a live + event clears it, and the response returns executor A. +- **Snapshot merge preserves a task added during the request:** add a + deferred-response test where the task is absent at request start, a live + event adds executor B, and the response omits executor fields. +- **`preserveOmittedExecutorFields` unit coverage:** add direct test cases in + `apps/web/lib/kanban/map-task.test.ts` (or a colocated test if the helper + moves) covering: incoming omits all four fields (preserve fires), incoming + carries a real `primaryExecutorType` (preserve does not fire, incoming wins). + +## Task Breakdown + +- **Wave 1** (sequential; task 02 imports the helper task 01 adds): + - `task-01-preserve-helper-and-hydrator-fix.md` — add + `preserveOmittedExecutorFields` and wire it into `mergeKanbanTasks`. + - `task-02-snapshot-merge-fix.md` — wire the same helper into + `use-all-workflow-snapshots.ts`. + +## Verification + +```bash +cd apps/web +pnpm exec vitest run lib/state/hydration/hydrator.test.ts lib/state/hydration/hydrator-kanban-tasks.test.ts lib/kanban/map-task.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts +pnpm run typecheck +``` + +## Risks + +- Gating omission detection on `primaryExecutorType` is a deliberate + workaround for `toKanbanTask`'s lossy `?? false` mapping. +- Snapshot reconciliation copies the complete current bundle when a live + change is proven. This includes explicit clears and tasks added in flight. +- Hydration keeps the mapped-shape limitation because it has no request-start + snapshot or raw payload presence data. diff --git a/docs/plans/kanban-task-executor-cache-staleness/task-01-preserve-helper-and-hydrator-fix.md b/docs/plans/kanban-task-executor-cache-staleness/task-01-preserve-helper-and-hydrator-fix.md new file mode 100644 index 0000000000..ce7e2c9653 --- /dev/null +++ b/docs/plans/kanban-task-executor-cache-staleness/task-01-preserve-helper-and-hydrator-fix.md @@ -0,0 +1,140 @@ +--- +id: "01-preserve-helper-and-hydrator-fix" +title: "Add executor-fields preserve helper and fix mergeKanbanTasks" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/kanban-task-executor-cache-staleness/spec.md" +--- + +# Task 01: Add executor-fields preserve helper and fix mergeKanbanTasks + +Add a shared `preserveOmittedExecutorFields` helper and wire it into +`mergeKanbanTasks`'s wholesale-replace branch so a same/newer-timestamp merge +that omits the executor fields does not blank out an already-known executor +binding in the kanban cache. + +## Acceptance + +- `apps/web/lib/kanban/map-task.ts` exports + `preserveOmittedExecutorFields(merged: KanbanTask, existing: KanbanTask): + void` that copies `primaryExecutorId`, `primaryExecutorType`, + `primaryExecutorName`, and `isRemoteExecutor` from `existing` onto `merged` + only when `merged.primaryExecutorType === undefined`. +- `mergeKanbanTasks`'s `incomingTime >= existingTime` branch in + `apps/web/lib/state/hydration/hydrator.ts` calls this helper before + `draftTasks[idx] = incoming`, so a merge that omits the executor fields keeps + the cached values instead of losing them. +- A merge whose incoming task legitimately carries a different + `primaryExecutorType` still adopts the new value(s) — the helper is a + gap-fill, not a sticky override. +- `toKanbanTask`'s `is_remote_executor ?? false` mapping and the pinned + `"defaults isRemoteExecutor to false when missing"` test in + `apps/web/lib/kanban/map-task.test.ts` are unchanged. + +## Files likely touched + +- `apps/web/lib/kanban/map-task.ts` +- `apps/web/lib/kanban/map-task.test.ts` +- `apps/web/lib/state/hydration/hydrator.ts` +- `apps/web/lib/state/hydration/hydrator.test.ts` + +## Dependencies + +None. + +## Parallelism + +`sequential`. Task 02 imports the helper this task adds. + +## Inputs + +- Spec sections **Desired behavior**, **Regression scenarios**, and + **Constraints**. +- `apps/web/lib/ws/handlers/tasks.ts:40-61` (`preservePrimaryExecutorFields`) as + the reference behavior for the WebSocket path — do not modify this file; it + already handles its own (raw-payload) input shape correctly. +- `apps/web/lib/state/hydration/hydrator.ts`'s existing + `backfillServerDerivedFields` / `SERVER_DERIVED_TASK_FIELDS` as the existing + in-file precedent for a gap-fill-only guard. + +## TDD sequence + +1. In `apps/web/lib/state/hydration/hydrator.test.ts`, add a failing test: + hydrate a task with all four executor fields populated via `hydrateState`, + then hydrate again with the same `id`, an equal `updatedAt`, and no executor + fields on the incoming task payload; assert the resulting + `draft.kanban.tasks` entry still has the original executor field values. + Confirm it fails (the fields come back `undefined`/`false`) before touching + production code. +2. Add a second test asserting that when the second hydration's incoming task + carries a genuinely different `primaryExecutorType` (plus its sibling + fields), the merged task adopts the new values. +3. Add direct unit tests for `preserveOmittedExecutorFields` in + `apps/web/lib/kanban/map-task.test.ts`: incoming omits all four fields + (preserve fires); incoming carries a real `primaryExecutorType` (preserve + does not fire). +4. Implement `preserveOmittedExecutorFields` in `map-task.ts` and wire it into + `mergeKanbanTasks` in `hydrator.ts`. Keep the change minimal — no changes to + `toKanbanTask` itself. +5. Re-run the tests from step 1-3 and confirm they pass. + +## Verification + +```bash +cd apps/web +pnpm exec vitest run lib/state/hydration/hydrator.test.ts lib/state/hydration/hydrator-kanban-tasks.test.ts lib/kanban/map-task.test.ts +pnpm run typecheck +``` + +## Risks + +- Gating on `primaryExecutorType` rather than `isRemoteExecutor` itself is + intentional (see plan **Risks**); do not "simplify" this to a per-field + `undefined` loop, since that silently fails to preserve `isRemoteExecutor` + (it is never `undefined` post-mapping). +- Do not extend the preserve to distinguish an explicit executor clear from an + omitted field — that disambiguation is out of scope (see spec **Constraints** + and **Out of scope**). + +## Output contract + +Report the RED failure, GREEN result, exact files changed, and any deviation +from this task file. Record every exact command and outcome in `## Results`. + +## Results + +Added `preserveOmittedExecutorFields` (exported from `apps/web/lib/kanban/map-task.ts`, +gated on `primaryExecutorType`'s own `undefined`-ness per the plan's rationale) +and wired it into `mergeKanbanTasks`'s wholesale-replace branch in +`apps/web/lib/state/hydration/hydrator.ts`, right before `draftTasks[idx] = +incoming`. + +- RED: `pnpm exec vitest run lib/kanban/map-task.test.ts` — 2 new tests failed + with `TypeError: preserveOmittedExecutorFields is not a function`. Then + `pnpm exec vitest run lib/state/hydration/hydrator.test.ts` — the + same-timestamp-omits-executor-fields test failed, asserting the merged task + lost its executor fields (matched the bug exactly); the + legitimately-different-value test passed even before the fix, as expected. +- GREEN: same two commands after implementing the helper and wiring it in — + all tests passed. +- Deviation from the task file: the new `hydrator.test.ts` tests were moved + into a new sibling file `apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts` + instead of staying inline, because adding them to `hydrator.test.ts` pushed + it from 649 to 663 lines, over the repo's 600-line file limit (already + pre-existing at 649, worsened by the addition). Similarly, the new + `map-task.test.ts` `describe("preserveOmittedExecutorFields", ...)` block + was made a top-level sibling describe instead of nesting inside `"toKanbanTask + — state normalization"`, which had pushed that arrow function past the + 100-line function limit. +- Full verification: `pnpm exec vitest run lib/kanban/map-task.test.ts + lib/state/hydration/hydrator.test.ts lib/state/hydration/hydrator-kanban-tasks.test.ts` + — 48 passed. `pnpm exec eslint lib/kanban/map-task.ts lib/kanban/map-task.test.ts + lib/state/hydration/hydrator.ts lib/state/hydration/hydrator.test.ts + lib/state/hydration/hydrator-kanban-tasks.test.ts` — clean, no warnings. + `pnpm run typecheck` — clean. + +Files changed: `apps/web/lib/kanban/map-task.ts`, `apps/web/lib/kanban/map-task.test.ts`, +`apps/web/lib/state/hydration/hydrator.ts`, `apps/web/lib/state/hydration/hydrator.test.ts` +(tests moved out), `apps/web/lib/state/hydration/hydrator-kanban-tasks.test.ts` (new). diff --git a/docs/plans/kanban-task-executor-cache-staleness/task-02-snapshot-merge-fix.md b/docs/plans/kanban-task-executor-cache-staleness/task-02-snapshot-merge-fix.md new file mode 100644 index 0000000000..d20bd47479 --- /dev/null +++ b/docs/plans/kanban-task-executor-cache-staleness/task-02-snapshot-merge-fix.md @@ -0,0 +1,104 @@ +--- +id: "02-snapshot-merge-fix" +title: "Preserve executor fields in the workflow snapshot merge" +status: done +wave: 1 +depends_on: ["01-preserve-helper-and-hydrator-fix"] +plan: "plan.md" +spec: "../../specs/kanban-task-executor-cache-staleness/spec.md" +--- + +# Task 02: Preserve executor fields in the workflow snapshot merge + +Wire request-start executor freshness into `useAllWorkflowSnapshots`'s +per-task snapshot merge. A stale workflow response must not blank out or +restore an executor binding changed by a live event in `kanbanMulti.snapshots`. + +## Acceptance + +- In `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts`'s + `fetchAndWriteSnapshot`, compare the current task with its request-start + task before applying the HTTP response. +- Copy all four current executor fields when the task changed after request + start or first appeared during the request. +- A fresh snapshot response whose task omits all four executor fields keeps the + cached task's executor field values in the merged snapshot. +- A fresh snapshot response whose task carries a genuinely different + `primaryExecutorType` (plus its sibling fields) still adopts the new values. + +## Files likely touched + +- `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts` +- `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts` + +## Dependencies + +Task 01 (`preserveOmittedExecutorFields` must exist in `map-task.ts` first). + +## Parallelism + +`sequential`. + +## Inputs + +- Spec sections **Desired behavior** and **Regression scenarios**. +- The existing `"preserves a cached autopilot marker when a fresh snapshot + omits it"` and `"keeps an explicit false autopilot value from the fresh + snapshot"` tests in `use-all-workflow-snapshots.test.ts` as the pattern to + follow (mock `fetchWorkflowSnapshot`, seed `kanbanMulti.snapshots`, assert on + `mockSetWorkflowSnapshot`'s recorded call). + +## TDD sequence + +1. Add a failing deferred-response test where a live detach clears the cached + executor and the stale response returns the old explicit executor bundle. +2. Add a failing deferred-response test where a task first appears during the + request with live executor data and the stale response omits that data. +3. Add a complete-copy helper and wire it into the snapshot merge. +4. Re-run the tests from steps 1-2 and confirm they pass. + +## Verification + +```bash +cd apps/web +pnpm exec vitest run hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts +pnpm run typecheck +``` + +## Risks + +- `mapSnapshotTask` returns `null` for tasks outside `stepIds`; make sure the + new test's task is on a step included in the mocked snapshot's `steps` so it + survives the existing filter before the preserve logic is reached. + +## Output contract + +Report the RED failure, GREEN result, exact files changed, and any deviation +from this task file. Record every exact command and outcome in `## Results`. + +## Results + +The merge now uses request-start freshness for the complete executor bundle. +When the live cache changes during the fetch, or when a task appears while the +fetch is in flight, the current bundle wins. When it does not change, the +snapshot remains authoritative and can clear the executor. + +- RED: `pnpm exec vitest run + hooks/domains/kanban/use-all-workflow-snapshots.test.ts` — both new race + regressions failed. A stale snapshot restored the old executor after a live + detach, and a task added during the fetch lost its live executor. +- GREEN: the same command after the fix — all 20 tests passed. +- Focused verification: `pnpm exec vitest run + lib/kanban/map-task.test.ts lib/state/hydration/hydrator.test.ts + lib/state/hydration/hydrator-kanban-tasks.test.ts + hooks/domains/kanban/use-all-workflow-snapshots.test.ts + hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts` — 80 + tests passed. +- `pnpm exec eslint` passed for all touched TypeScript files. `pnpm run + typecheck` passed. +- Updated the spec and implementation plan to document the request-start + freshness rule and the separate hydration gap-fill rule. + +Files changed: `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts`, +`apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts`, +`apps/web/lib/kanban/map-task.ts`, and the related spec and plan files. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 5affabf59f..1795eaa9d3 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -304,6 +304,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [quick-chat-idle-dot](quick-chat-idle-dot/spec.md) | draft | | [native-code-review](native-code-review/spec.md) | building | | [missing-task-route-recovery](missing-task-route-recovery/spec.md) | draft | +| [kanban-task-executor-cache-staleness](kanban-task-executor-cache-staleness/spec.md) | draft | | [browser-inspect-annotations-save](browser-inspect-annotations-save/spec.md) | shipped | | [automations-pr-merged-trigger](automations-pr-merged-trigger/spec.md) | draft | | [automation-runs-delete-all-by-status](automation-runs-delete-all-by-status/spec.md) | draft | diff --git a/docs/specs/kanban-task-executor-cache-staleness/spec.md b/docs/specs/kanban-task-executor-cache-staleness/spec.md new file mode 100644 index 0000000000..7aaac7b17b --- /dev/null +++ b/docs/specs/kanban-task-executor-cache-staleness/spec.md @@ -0,0 +1,140 @@ +--- +title: Kanban task cache preserves executor fields across merges +status: draft +created: 2026-08-15 +owner: kandev +--- + +# Kanban task cache preserves executor fields across merges + +## 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`'s `backfillServerDerivedFields` + guards 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`'s `mergeKanbanTasks`, 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 preserves `primarySessionId`, `primarySessionState`, + `autopilot`, and `statusSummary` when 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 `mergeKanbanTasks` takes the "incoming is newer-or-equal" branch for a + task already present in the cache, and the incoming task's + `primaryExecutorType` is `undefined`, the merged task keeps the existing + cached `primaryExecutorId`, `primaryExecutorType`, `primaryExecutorName`, and + `isRemoteExecutor` values instead of losing them to the replace. +- When the incoming task's `primaryExecutorType` is 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 `mergeKanbanTasks` merge receives an + incoming task with the same `id`, an `updatedAt` equal to or newer than the + cached value, and no `primary_executor_*`/`is_remote_executor` keys 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 `mergeKanbanTasks` merge receives + an incoming task at or after the cached `updatedAt` whose 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** + `useAllWorkflowSnapshots` merges 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 existing `is_remote_executor ?? false` + mapping or its pinned `map-task.test.ts` contract test — the merge-layer fix + works around that mapping's loss of "omitted" information for + `isRemoteExecutor` by gating on `primaryExecutorType`'s own `undefined`-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 `KanbanTask` shape. 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 `KanbanTask` + shape or to either merge site. +- Any change to `apps/web/lib/ws/handlers/tasks.ts`'s existing + `preservePrimaryExecutorFields`, which already handles the WebSocket path + correctly. +- The `getWorkspaceSourceCapabilities` disabled/hidden-state UI copy itself; + this fix addresses the cache staleness that feeds it, not its rendering. + +## Implementation plans + +- [Kanban task cache preserves executor fields across merges](../../plans/kanban-task-executor-cache-staleness/plan.md)