fix(web): preserve executor fields across kanban task cache merges (#2705)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-22 14:46:25 +00:00
committed by GitHub
parent ecda06748e
commit 4e04d09f67
11 changed files with 962 additions and 19 deletions
@@ -297,6 +297,191 @@ describe("useAllWorkflowSnapshots — snapshot mapping", () => {
});
});
const REMOTE_EXECUTOR_NAME = "Remote box";
function seedExecutor(executor: Record<string, unknown>) {
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
@@ -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(
+48 -1
View File
@@ -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);
});
});
+20
View File
@@ -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),
@@ -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<AppState>): void {
hydrateState(draft, {
kanban: {
tasks: [
{
id: taskId,
updatedAt,
...knownExecutor,
},
],
},
} as unknown as Partial<AppState>);
}
function hydrateFrozenIncoming(
incoming: Readonly<{ id: string; updatedAt: string; title: string }>,
): AppState {
return produce(makeAppDraft(), (draft: Draft<AppState>) => {
seedKnownExecutorTask(draft);
hydrateState(draft, {
kanban: {
tasks: [incoming],
},
} as unknown as Partial<AppState>);
});
}
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<AppState>) => {
seedKnownExecutorTask(draft);
hydrateState(draft, {
kanban: {
tasks: [
{
id: taskId,
updatedAt,
title: "Refreshed title",
},
],
},
} as unknown as Partial<AppState>);
});
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<AppState>) => {
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<AppState>);
});
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,
});
});
});
+14 -4
View File
@@ -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<any>,
@@ -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<KanbanTask>[];
const existingById = new Map<string, Draft<KanbanTask>>(
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);
}
@@ -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.
@@ -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).
@@ -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.
+1
View File
@@ -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 |
@@ -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)