diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index d2defd8d59..4c8c7bfb3c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -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))) }) } diff --git a/apps/web/components/task/task-classify.ts b/apps/web/components/task/task-classify.ts new file mode 100644 index 0000000000..c8b9505822 --- /dev/null +++ b/apps/web/components/task/task-classify.ts @@ -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([ + "WAITING_FOR_INPUT", + "COMPLETED", + "FAILED", + "CANCELLED", +]); +const IN_PROGRESS_STATES = new Set(["RUNNING"]); +const TASK_STATE_REVIEW = new Set(["REVIEW", "COMPLETED"]); +const TASK_STATE_IN_PROGRESS = new Set(["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"; +} diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index b53fc25096..819f9c2221 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -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, 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 ( 0 || diffStats.deletions > 0); return ( diff --git a/apps/web/components/task/task-switcher.test.ts b/apps/web/components/task/task-switcher.test.ts new file mode 100644 index 0000000000..394cc54952 --- /dev/null +++ b/apps/web/components/task/task-switcher.test.ts @@ -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 { + 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", + ]); + }); +}); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 00137124d8..0091d2c0c8 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -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([ - "WAITING_FOR_INPUT", - "COMPLETED", - "FAILED", - "CANCELLED", -]); -const IN_PROGRESS_STATES = new Set(["RUNNING"]); -const TASK_STATE_REVIEW = new Set(["REVIEW", "COMPLETED"]); -const TASK_STATE_IN_PROGRESS = new Set(["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 ?? "") ); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 7f28c518a0..a37b4ed356 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -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 { + await this.request("PATCH", `/api/v1/tasks/${taskId}`, { state }); + } + async listAgents(): Promise<{ agents: Agent[]; total: number }> { return this.request("GET", "/api/v1/agents"); } diff --git a/apps/web/e2e/tests/session/session-resume-keeps-review-state.spec.ts b/apps/web/e2e/tests/session/session-resume-keeps-review-state.spec.ts new file mode 100644 index 0000000000..1106700533 --- /dev/null +++ b/apps/web/e2e/tests/session/session-resume-keeps-review-state.spec.ts @@ -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); + }); +}); diff --git a/apps/web/e2e/tests/session/session-resume-sidebar-order.spec.ts b/apps/web/e2e/tests/session/session-resume-sidebar-order.spec.ts new file mode 100644 index 0000000000..ec2bb4d292 --- /dev/null +++ b/apps/web/e2e/tests/session/session-resume-sidebar-order.spec.ts @@ -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 => { + 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); + }); +});