fix(backend): prevent task reorder during silent session resume (#555)

This commit is contained in:
Carlos Florêncio
2026-04-08 07:55:24 +01:00
committed by GitHub
parent 73e6ef64b4
commit 41b405c64f
8 changed files with 361 additions and 66 deletions
@@ -550,26 +550,14 @@ func (e *Executor) persistResumeState(ctx context.Context, taskID string, sessio
}
}
// startAgentProcessOnResume starts the agent process asynchronously after a session resume,
// syncing the task state (REVIEW or unchanged) on success.
// startAgentProcessOnResume starts the agent process asynchronously after a session resume.
// Task state is managed by workflow triggers and stream handlers elsewhere; this callback
// just logs successful process start.
func (e *Executor) startAgentProcessOnResume(ctx context.Context, taskID string, session *models.TaskSession, agentExecutionID string) {
e.runAgentProcessAsync(ctx, taskID, session.ID, agentExecutionID, func(updCtx context.Context) {
// Agent resumed successfully - sync task state with session state.
if session.State == models.TaskSessionStateWaitingForInput {
if updateErr := e.updateTaskState(updCtx, taskID, v1.TaskStateReview); updateErr != nil {
e.logger.Warn("failed to update task state to REVIEW after resume",
zap.String("task_id", taskID),
zap.Error(updateErr))
} else {
e.logger.Debug("task state synced to REVIEW after resume (session waiting for input)",
zap.String("task_id", taskID),
zap.String("session_id", session.ID))
}
} else {
e.logger.Debug("agent resumed successfully, task state unchanged",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.String("session_state", string(session.State)))
}
e.logger.Debug("agent resumed successfully",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.String("session_state", string(session.State)))
})
}
+39
View File
@@ -0,0 +1,39 @@
import type { TaskState, TaskSessionState } from "@/lib/types/http";
// Single source of truth for bucketing a task into review / in_progress /
// backlog. Used by the sidebar sort in task-switcher and the per-item
// spinner icon in task-item so the two views always agree.
const REVIEW_STATES = new Set<TaskSessionState>([
"WAITING_FOR_INPUT",
"COMPLETED",
"FAILED",
"CANCELLED",
]);
const IN_PROGRESS_STATES = new Set<TaskSessionState>(["RUNNING"]);
const TASK_STATE_REVIEW = new Set<TaskState | undefined>(["REVIEW", "COMPLETED"]);
const TASK_STATE_IN_PROGRESS = new Set<TaskState | undefined>(["IN_PROGRESS", "SCHEDULING"]);
export type TaskBucket = "review" | "in_progress" | "backlog";
export function classifyTask(
sessionState: TaskSessionState | undefined,
taskState?: TaskState,
): TaskBucket {
if (!sessionState) {
if (TASK_STATE_REVIEW.has(taskState)) return "review";
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
return "backlog";
}
// STARTING is a transient session state (e.g. during auto-resume after a
// backend restart). Defer to the workflow task state so a task in REVIEW
// does not flicker out of the review bucket while the agent reattaches.
if (sessionState === "STARTING" && taskState) {
if (TASK_STATE_REVIEW.has(taskState)) return "review";
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
return "backlog";
}
if (REVIEW_STATES.has(sessionState)) return "review";
if (IN_PROGRESS_STATES.has(sessionState)) return "in_progress";
return "backlog";
}
+12 -13
View File
@@ -12,6 +12,7 @@ import { useAppStore } from "@/components/state-provider";
import { cn } from "@/lib/utils";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
import { classifyTask } from "./task-classify";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
type DiffStats = {
@@ -57,6 +58,15 @@ function formatRelativeTime(dateString: string): string {
return date.toLocaleDateString();
}
// Delegates to the shared classifier in task-switcher so the sidebar bucket
// and the per-task running spinner always agree. A task whose workflow state
// is REVIEW or COMPLETED must not render as "running" when its session
// transiently cycles through STARTING/RUNNING (e.g. during an agent auto-
// resume after a backend restart).
function computeIsInProgress(state?: TaskState, sessionState?: TaskSessionState): boolean {
return classifyTask(sessionState, state) === "in_progress";
}
function handleTaskItemKeyDown(e: React.KeyboardEvent<HTMLDivElement>, onClick?: () => void): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
@@ -80,14 +90,7 @@ function TaskStateIcon({
/>
);
}
const isReview =
sessionState === "WAITING_FOR_INPUT" ||
sessionState === "COMPLETED" ||
sessionState === "FAILED" ||
sessionState === "CANCELLED" ||
state === "REVIEW" ||
state === "COMPLETED";
if (isReview) {
if (classifyTask(sessionState, state) === "review") {
return (
<IconCircleCheck
data-testid="task-state-review"
@@ -234,11 +237,7 @@ export const TaskItem = memo(function TaskItem({
prInfo,
}: TaskItemProps) {
const effectiveMenuOpen = menuOpen || isDeleting === true;
const isInProgress =
state === "IN_PROGRESS" ||
state === "SCHEDULING" ||
sessionState === "STARTING" ||
sessionState === "RUNNING";
const isInProgress = computeIsInProgress(state, sessionState);
const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
return (
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { classifyTask } from "./task-classify";
import { statePriority, sortByStateThenCreated, type TaskSwitcherItem } from "./task-switcher";
const EARLY = "2026-01-01T00:00:00Z";
function task(overrides: Partial<TaskSwitcherItem>): TaskSwitcherItem {
return { id: "t", title: "t", ...overrides };
}
describe("classifyTask", () => {
it("buckets WAITING_FOR_INPUT as review", () => {
expect(classifyTask("WAITING_FOR_INPUT")).toBe("review");
});
it("buckets RUNNING as in_progress", () => {
expect(classifyTask("RUNNING")).toBe("in_progress");
});
it("buckets STARTING with REVIEW task state as review", () => {
expect(classifyTask("STARTING", "REVIEW")).toBe("review");
});
});
describe("sortByStateThenCreated (regression: silent resume reorder)", () => {
it("sorts review (turn finished) above in_progress (running)", () => {
// Regression: when a task is auto-resumed after backend restart, its
// session transiently cycles WAITING_FOR_INPUT -> STARTING -> RUNNING ->
// WAITING_FOR_INPUT. The sidebar must NOT promote it above other
// turn-finished tasks during the RUNNING flicker.
const running = task({
id: "running",
sessionState: "RUNNING",
createdAt: EARLY,
});
const waiting = task({
id: "waiting",
sessionState: "WAITING_FOR_INPUT",
createdAt: EARLY,
});
expect(statePriority(waiting)).toBeLessThan(statePriority(running));
expect([running, waiting].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
"waiting",
"running",
]);
});
it("does not move a transiently-RUNNING task above older turn-finished tasks", () => {
// Older turn-finished task A and a newer turn-finished task B that gets
// resumed (so its sessionState briefly flips to RUNNING). With the
// pre-fix priority (in_progress=0), B would jump to the top. With the
// fix (review=0), B stays in its original position relative to A.
const a = task({
id: "a-old-waiting",
sessionState: "WAITING_FOR_INPUT",
createdAt: EARLY,
});
const b = task({
id: "b-newer-resuming",
sessionState: "RUNNING",
createdAt: "2026-01-02T00:00:00Z",
});
const sorted = [a, b].sort(sortByStateThenCreated).map((t) => t.id);
expect(sorted[0]).toBe("a-old-waiting");
expect(sorted[1]).toBe("b-newer-resuming");
});
it("orders backlog after both review and in_progress", () => {
const backlog = task({ id: "bk", sessionState: undefined });
const running = task({ id: "ru", sessionState: "RUNNING" });
const review = task({ id: "rv", sessionState: "WAITING_FOR_INPUT" });
expect([backlog, running, review].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
"rv",
"ru",
"bk",
]);
});
it("breaks ties within a bucket by createdAt descending", () => {
const older = task({
id: "older",
sessionState: "WAITING_FOR_INPUT",
createdAt: EARLY,
});
const newer = task({
id: "newer",
sessionState: "WAITING_FOR_INPUT",
createdAt: "2026-02-01T00:00:00Z",
});
expect([older, newer].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
"newer",
"older",
]);
});
});
+6 -34
View File
@@ -22,6 +22,7 @@ import {
} from "@kandev/ui/context-menu";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
import { cn } from "@/lib/utils";
import { classifyTask } from "./task-classify";
import { TaskItem } from "./task-item";
export type TaskSwitcherItem = {
@@ -71,44 +72,15 @@ type RepoGroup = {
tasks: TaskSwitcherItem[];
};
const REVIEW_STATES = new Set<TaskSessionState>([
"WAITING_FOR_INPUT",
"COMPLETED",
"FAILED",
"CANCELLED",
]);
const IN_PROGRESS_STATES = new Set<TaskSessionState>(["RUNNING"]);
const TASK_STATE_REVIEW = new Set<TaskState | undefined>(["REVIEW", "COMPLETED"]);
const TASK_STATE_IN_PROGRESS = new Set<TaskState | undefined>(["IN_PROGRESS"]);
function classifyTask(
sessionState: TaskSessionState | undefined,
taskState?: TaskState,
): "review" | "in_progress" | "backlog" {
if (!sessionState) {
if (TASK_STATE_REVIEW.has(taskState)) return "review";
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
return "backlog";
}
if (sessionState === "STARTING" && taskState) {
if (TASK_STATE_REVIEW.has(taskState)) return "review";
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
return "backlog";
}
if (REVIEW_STATES.has(sessionState)) return "review";
if (IN_PROGRESS_STATES.has(sessionState)) return "in_progress";
return "backlog";
}
function statePriority(task: TaskSwitcherItem): number {
export function statePriority(task: TaskSwitcherItem): number {
const bucket = classifyTask(task.sessionState, task.state);
if (bucket === "in_progress") return 0;
if (bucket === "review") return 1;
if (bucket === "review") return 0;
if (bucket === "in_progress") return 1;
return 2;
}
function sortByStateThenCreated(a: TaskSwitcherItem, b: TaskSwitcherItem): number {
// State first, then newest createdAt first (descending)
export function sortByStateThenCreated(a: TaskSwitcherItem, b: TaskSwitcherItem): number {
// Review (turn finished) first, then in_progress, then backlog; newest createdAt within bucket
return (
statePriority(a) - statePriority(b) || (b.createdAt ?? "").localeCompare(a.createdAt ?? "")
);
+7
View File
@@ -209,6 +209,13 @@ export class ApiClient {
return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts));
}
async updateTaskState(
taskId: string,
state: "BACKLOG" | "IN_PROGRESS" | "REVIEW" | "COMPLETED",
): Promise<void> {
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { state });
}
async listAgents(): Promise<{ agents: Agent[]; total: number }> {
return this.request("GET", "/api/v1/agents");
}
@@ -0,0 +1,87 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
// Regression: when the backend restarts and auto-resumes a task whose
// session was WAITING_FOR_INPUT, the task must NEVER transition into the
// in_progress / Running sidebar bucket. Before the fix, the resume flow
// cycled the session through STARTING/RUNNING states which briefly
// displaced the task into the Running bucket at the top of the sidebar.
// This test polls the sidebar continuously across the full resume window
// to catch any transient Running flicker.
test.describe("Session resume — task state stability", () => {
test.describe.configure({ retries: 1 });
test("resuming after backend restart does not flicker task into Running bucket", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
// 1. Create task and run it to WAITING_FOR_INPUT.
await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Resume Stable",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardByTitle("Resume Stable");
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 30_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await expect(session.taskInSection("Resume Stable", "Turn Finished")).toBeVisible({
timeout: 15_000,
});
// 2. Restart backend and reload — triggers auto-resume via SSR + WS.
await backend.restart();
await testPage.reload();
await session.waitForLoad();
// 3. Continuously assert the task NEVER appears in the Running bucket
// across a fixed 10s window after reload. The composer stays visible
// throughout a silent resume (the fix intentionally keeps the
// workflow state stable), so we cannot use idleInput visibility as
// a stop signal — we must poll for a fixed duration to catch any
// transient flicker the backend may still emit while reconnecting.
const runningLocator = session.taskInSection("Resume Stable", "Running");
const probeWindowMs = 10_000;
const deadline = Date.now() + probeWindowMs;
let polls = 0;
while (Date.now() < deadline) {
const runningCount = await runningLocator.count();
expect(runningCount, `task flickered into Running bucket during resume (poll ${polls})`).toBe(
0,
);
polls++;
await testPage.waitForTimeout(100);
}
// Ensure we actually polled meaningfully across the window.
expect(polls).toBeGreaterThanOrEqual(50);
// 4. Final settled state: still Turn Finished, not Running.
await expect(session.idleInput()).toBeVisible({ timeout: 60_000 });
await expect(session.taskInSection("Resume Stable", "Turn Finished")).toBeVisible({
timeout: 15_000,
});
await expect(session.taskInSection("Resume Stable", "Running")).toHaveCount(0);
});
});
@@ -0,0 +1,104 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
// Regression: the sidebar statePriority must sort "Turn Finished" (review)
// tasks ABOVE "Running" (in_progress) tasks. Before this fix the priority
// was inverted, which caused a task whose session transiently cycled
// through RUNNING during auto-resume (after a backend restart) to jump to
// the top of the sidebar and displace other turn-finished tasks.
//
// This test asserts the sort order deterministically by seeding two tasks
// with explicit states via the API: one REVIEW, one IN_PROGRESS. The
// review task must render above the in_progress task in the session
// sidebar regardless of createdAt order.
test.describe("Session sidebar — sort order", () => {
test("review (turn finished) tasks sort above in_progress (running) tasks", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Create an anchor task with an agent so we can open a session page
// and view the sidebar in its normal SSR-hydrated context.
await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Anchor Session",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// Create the two ordering fixtures. Create the REVIEW one FIRST so it
// has an older createdAt — this rules out createdAt-DESC as the reason
// for any correct ordering we observe. The only way the newer
// IN_PROGRESS task ends up BELOW the older REVIEW task is the state
// bucket priority being review < in_progress.
const review = await apiClient.createTask(seedData.workspaceId, "Older Review Fixture", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.updateTaskState(review.id, "REVIEW");
const inProgress = await apiClient.createTask(seedData.workspaceId, "Newer Running Fixture", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.updateTaskState(inProgress.id, "IN_PROGRESS");
// Open the anchor session so the sidebar is visible.
const kanban = new KanbanPage(testPage);
await kanban.goto();
const anchorCard = kanban.taskCardByTitle("Anchor Session");
await expect(anchorCard).toBeVisible({ timeout: 15_000 });
await anchorCard.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// Wait for both fixture tasks to appear in the sidebar.
await expect(session.taskInSidebar("Older Review Fixture")).toBeVisible({ timeout: 15_000 });
await expect(session.taskInSidebar("Newer Running Fixture")).toBeVisible({ timeout: 15_000 });
// Resolve DOM order of the two fixture items in the sidebar.
const sidebarItems = session.sidebar.getByTestId("sidebar-task-item");
const indexOf = async (title: string): Promise<number> => {
const count = await sidebarItems.count();
for (let i = 0; i < count; i++) {
const text = await sidebarItems.nth(i).innerText();
if (text.includes(title)) return i;
}
return -1;
};
const reviewIdx = await indexOf("Older Review Fixture");
const runningIdx = await indexOf("Newer Running Fixture");
expect(reviewIdx).not.toBe(-1);
expect(runningIdx).not.toBe(-1);
expect(
reviewIdx,
`review task must sort above in_progress task (got reviewIdx=${reviewIdx}, runningIdx=${runningIdx})`,
).toBeLessThan(runningIdx);
// Persistence check — reload the page and verify the order holds via
// SSR hydration, not just client-side state.
await testPage.reload();
await session.waitForLoad();
await expect(session.taskInSidebar("Older Review Fixture")).toBeVisible({ timeout: 15_000 });
await expect(session.taskInSidebar("Newer Running Fixture")).toBeVisible({ timeout: 15_000 });
const reviewIdxAfter = await indexOf("Older Review Fixture");
const runningIdxAfter = await indexOf("Newer Running Fixture");
expect(reviewIdxAfter).toBeLessThan(runningIdxAfter);
});
});