From 2173543c0469db5ececd672b28c05aad163aaa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 24 Sep 2026 10:41:26 +0100 Subject: [PATCH] fix(ui): remove stale parked session note (#3900) --- apps/backend/cmd/mock-agent/main.go | 5 + .../cmd/mock-agent/session_config_test.go | 35 +++ .../task/launch-queue-status.test.tsx | 55 +---- .../components/task/launch-queue-status.tsx | 30 --- apps/web/components/task/task-chat-panel.tsx | 7 +- apps/web/e2e/tests/panel-toolbars.spec.ts | 3 +- .../mobile-queued-session-ownership.spec.ts | 5 +- .../mobile-workflow-peer-resume.spec.ts | 218 ++++++++++++++++++ .../workflow/queued-session-ownership.spec.ts | 5 +- .../workflow/workflow-peer-resume-helpers.ts | 218 ++++++++++++++++++ .../workflow/workflow-peer-resume.spec.ts | 185 +++++++++++++++ apps/web/src/locales/en/task.json | 1 - apps/web/src/locales/ja/task.json | 1 - apps/web/src/locales/pseudo/task.json | 1 - apps/web/src/locales/pt-pt/task.json | 1 - apps/web/src/locales/zh-cn/task.json | 1 - apps/web/src/locales/zh-hk/task.json | 1 - apps/web/src/locales/zh-tw/task.json | 1 - docs/plans/queued-session-ownership/plan.md | 4 +- .../session-open-recovery-eligibility/plan.md | 125 +++++++++- .../task-02-remove-parking-presentation.md | 67 +++++- docs/public/agents-and-profiles.md | 6 +- docs/public/tasks-and-workflows.md | 6 +- 23 files changed, 860 insertions(+), 121 deletions(-) create mode 100644 apps/web/e2e/tests/workflow/mobile-workflow-peer-resume.spec.ts create mode 100644 apps/web/e2e/tests/workflow/workflow-peer-resume-helpers.ts create mode 100644 apps/web/e2e/tests/workflow/workflow-peer-resume.spec.ts diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 599b5674c7..99e77b745d 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -314,6 +314,7 @@ func (a *mockAgent) LoadSession(ctx context.Context, req acp.LoadSessionRequest) return acp.LoadSessionResponse{}, ctx.Err() } } + sessionMCPServers := registerACPMcpServers(req.McpServers) a.mu.Lock() if a.sessions == nil { a.sessions = make(map[acp.SessionId]bool) @@ -324,7 +325,11 @@ func (a *mockAgent) LoadSession(ctx context.Context, req acp.LoadSessionRequest) if a.commandsEmitted == nil { a.commandsEmitted = make(map[acp.SessionId]bool) } + if a.sessionMCPServers == nil { + a.sessionMCPServers = make(map[acp.SessionId]map[string]mcpServerDef) + } a.sessions[req.SessionId] = true + a.sessionMCPServers[req.SessionId] = cloneMCPServerDefs(sessionMCPServers) configOptions, ok := a.sessionConfig[req.SessionId] if !ok { a.sessionConfig[req.SessionId] = mockSessionConfigOptions() diff --git a/apps/backend/cmd/mock-agent/session_config_test.go b/apps/backend/cmd/mock-agent/session_config_test.go index 49309bb32c..24ca3acbb8 100644 --- a/apps/backend/cmd/mock-agent/session_config_test.go +++ b/apps/backend/cmd/mock-agent/session_config_test.go @@ -73,6 +73,41 @@ func TestLoadSessionReturnsCapabilitiesForResumedSession(t *testing.T) { } } +func TestLoadSessionRetainsMCPServersForResumedPrompt(t *testing.T) { + sessionID := acp.SessionId("session-load-mcp-test") + priorServers := mcpServers + mcpServers = nil + t.Cleanup(func() { mcpServers = priorServers }) + agent := &mockAgent{ + conn: &promptCancelUpdater{started: make(chan struct{})}, + sessions: make(map[acp.SessionId]bool), + sessionConfig: make(map[acp.SessionId][]acp.SessionConfigOption), + sessionMCPServers: make(map[acp.SessionId]map[string]mcpServerDef), + commandsEmitted: make(map[acp.SessionId]bool), + } + request := acp.LoadSessionRequest{ + SessionId: sessionID, + McpServers: []acp.McpServer{{ + Sse: &acp.McpServerSseInline{ + Name: "resumed-test", + Type: "sse", + Url: "http://127.0.0.1:1/sse", + }, + }}, + } + + if _, err := agent.LoadSession(context.Background(), request); err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + server, ok := agent.sessionMCPServers[sessionID]["resumed-test"] + if !ok { + t.Fatal("LoadSession() did not retain MCP server for the resumed session") + } + if server.URL != "http://127.0.0.1:1/sse" { + t.Fatalf("resumed MCP server URL = %q, want %q", server.URL, "http://127.0.0.1:1/sse") + } +} + func TestSetSessionConfigOptionRejectsUnknownValue(t *testing.T) { sessionID := acp.SessionId("session-config-test") agent := &mockAgent{ diff --git a/apps/web/components/task/launch-queue-status.test.tsx b/apps/web/components/task/launch-queue-status.test.tsx index 7a27b58c03..194bb436fa 100644 --- a/apps/web/components/task/launch-queue-status.test.tsx +++ b/apps/web/components/task/launch-queue-status.test.tsx @@ -2,11 +2,7 @@ import { act, cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary"; -import { - hasWorkflowParkingMarker, - LaunchQueueStatus, - ParkedSessionNote, -} from "./launch-queue-status"; +import { LaunchQueueStatus } from "./launch-queue-status"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -175,52 +171,3 @@ describe("LaunchQueueStatus retry copy", () => { expect(screen.queryByTestId("launch-queue-session-capacity-link")).toBeNull(); }); }); - -describe("workflow parking presentation", () => { - it.each([ - [undefined, false], - [{}, false], - [ - { - workflow_parking: { - stamp: "", - parked_at: "2026-09-16T20:00:00Z", - source_session_id: "source", - }, - }, - false, - ], - [{ workflow_parking: { stamp: "stamp", parked_at: "", source_session_id: "source" } }, false], - [ - { - workflow_parking: { - stamp: "stamp", - parked_at: "2026-09-16T20:00:00Z", - source_session_id: "", - }, - }, - false, - ], - [ - { - workflow_parking: { - stamp: "stamp", - parked_at: "2026-09-16T20:00:00Z", - source_session_id: "source", - }, - }, - true, - ], - ])("validates %j as %s", (metadata, expected) => { - expect(hasWorkflowParkingMarker(metadata as Record | undefined)).toBe( - expected, - ); - }); - - it("renders the note only when the selected session is parked", () => { - const { rerender } = render(); - expect(screen.queryByTestId("task-parked-session-note")).toBeNull(); - rerender(); - expect(screen.getByTestId("task-parked-session-note")).toBeTruthy(); - }); -}); diff --git a/apps/web/components/task/launch-queue-status.tsx b/apps/web/components/task/launch-queue-status.tsx index 68b94d5796..bd5bd89744 100644 --- a/apps/web/components/task/launch-queue-status.tsx +++ b/apps/web/components/task/launch-queue-status.tsx @@ -167,33 +167,3 @@ function LaunchQueueStatusContent({ ); } - -export function hasWorkflowParkingMarker( - metadata: Record | null | undefined, -): boolean { - const marker = metadata?.workflow_parking; - if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false; - const value = marker as Record; - return ( - typeof value.stamp === "string" && - value.stamp.trim() !== "" && - typeof value.parked_at === "string" && - value.parked_at.trim() !== "" && - typeof value.source_session_id === "string" && - value.source_session_id.trim() !== "" - ); -} - -export function ParkedSessionNote({ visible }: { visible: boolean }) { - const { t } = useTranslation(); - if (!visible) return null; - return ( -
- {t("task:parkedSessionNote")} -
- ); -} diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 694fedabd5..15a794d6d7 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -59,11 +59,7 @@ import { useTaskLaunchErrorContext } from "./task-launch-error-context"; import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider"; import { statusSummaryTaskError } from "@/lib/task-status-summary"; -import { - hasWorkflowParkingMarker, - LaunchQueueStatus, - ParkedSessionNote, -} from "./launch-queue-status"; +import { LaunchQueueStatus } from "./launch-queue-status"; import { WipQueueStatus } from "./wip-queue-status"; import { useLateClarificationMessage } from "@/hooks/use-late-clarification-message"; @@ -1253,7 +1249,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({ > {!hideLaunchQueueStatus && } {!hideWipQueueStatus && } - { await session.clickTab("Files"); await expect(session.files).toBeVisible(); - await session.fileTreeNode("walkthrough_base.txt").click(); + const baseFile = await session.fileTree.waitForFileTreeNode("walkthrough_base.txt"); + await baseFile.click(); const editor = testPage.locator(".monaco-editor:visible").first(); await expect(editor).toBeVisible({ timeout: 20_000 }); diff --git a/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts b/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts index 4eb9bb0250..89c18a04db 100644 --- a/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts @@ -67,9 +67,10 @@ test.describe("mobile: queued session ownership", () => { await expect(sessionPicker).not.toBeVisible(); await expect(queueStatus).toBeVisible(); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); await expect( - testPage.locator('[data-testid="task-parked-session-note"]:visible'), - ).toBeVisible(); + sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(), + ).toBeEditable(); await expectSessionMessagesUnchanged( apiClient, scenario.sourceSessionId, diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-peer-resume.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-peer-resume.spec.ts new file mode 100644 index 0000000000..c1c1343356 --- /dev/null +++ b/apps/web/e2e/tests/workflow/mobile-workflow-peer-resume.spec.ts @@ -0,0 +1,218 @@ +import { expect, test } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { SessionPage } from "../../pages/session-page"; +import { + attachSessionLaunchCapture, + countPeerDeliveries, + createWorkflowPeerResumeScenario, + waitForPeerSessionState, + type WorkflowPeerResumeScenario, +} from "./workflow-peer-resume-helpers"; + +test.describe("mobile: workflow peer resume", () => { + test("mobile review peer message resumes implement without parking UI", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(240_000); + let scenario: WorkflowPeerResumeScenario | undefined; + + try { + scenario = await createWorkflowPeerResumeScenario(apiClient, seedData, "Mobile peer resume"); + const beforeMessage = await apiClient.getTask(scenario.taskId); + expect(beforeMessage.workflow_step_id).toBe(scenario.reviewStepId); + expect(beforeMessage.primary_session_id).toBe(scenario.initialSessionId); + + const sessionsBefore = await apiClient.listTaskSessions(scenario.taskId); + const implementationBefore = sessionsBefore.sessions.find( + (session) => session.id === scenario!.implementationSessionId, + ); + expect(implementationBefore?.metadata?.workflow_parking).toBeDefined(); + + await waitForPeerSessionState( + apiClient, + scenario.taskId, + scenario.implementationSessionId, + "RUNNING", + ); + + const sessionPage = new SessionPage(testPage); + await testPage.goto(`/t/${scenario.taskId}`); + await sessionPage.waitForLoad(); + const mobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible'); + await mobileLayout.getByTestId("mobile-sessions-pill").tap(); + const sessionPicker = testPage.getByRole("dialog", { name: "Sessions" }); + const implementationRow = sessionPicker.getByTestId( + `mobile-session-row-${scenario.implementationSessionId}`, + ); + await expect(implementationRow).toBeVisible(); + const implementationRowBox = await implementationRow.boundingBox(); + expect(implementationRowBox).not.toBeNull(); + expect(implementationRowBox!.height).toBeGreaterThanOrEqual(44); + await implementationRow.tap(); + await expect(sessionPicker).not.toBeVisible(); + await sessionPage.expectChatResponseVisible(scenario.outputMarker); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + await expect( + sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(), + ).toBeEditable(); + await assertNoDocumentHorizontalOverflow(testPage, "active peer-resumed mobile session"); + await expect + .poll(() => + countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker), + ) + .toBe(1); + + const activeTask = await apiClient.getTask(scenario.taskId); + expect(activeTask.workflow_step_id).toBe(scenario.reviewStepId); + expect(activeTask.primary_session_id).toBe(scenario.initialSessionId); + const activeSessions = await apiClient.listTaskSessions(scenario.taskId); + expect( + activeSessions.sessions.find((session) => session.id === scenario!.implementationSessionId) + ?.metadata?.workflow_parking, + ).toBeDefined(); + + await testPage.reload(); + await sessionPage.waitForLoad(); + const reloadedMobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible'); + await reloadedMobileLayout.getByTestId("mobile-sessions-pill").tap(); + const reloadedSessionPicker = testPage.getByRole("dialog", { name: "Sessions" }); + await reloadedSessionPicker + .getByTestId(`mobile-session-row-${scenario.implementationSessionId}`) + .tap(); + await expect(reloadedSessionPicker).not.toBeVisible(); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + await sessionPage.expectChatResponseVisible(scenario.outputMarker); + await assertNoDocumentHorizontalOverflow( + testPage, + "reloaded active peer-resumed mobile session", + ); + + await waitForPeerSessionState( + apiClient, + scenario.taskId, + scenario.implementationSessionId, + "WAITING_FOR_INPUT", + ); + await sessionPage.expectChatResponseVisible(scenario.completionMarker); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + const finalTask = await apiClient.getTask(scenario.taskId); + expect(finalTask.workflow_step_id).toBe(scenario.reviewStepId); + expect(finalTask.primary_session_id).toBe(scenario.initialSessionId); + await expect + .poll(() => + countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker), + ) + .toBe(1); + } finally { + if (scenario) { + await Promise.all( + [scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) => + apiClient + .stopSession({ + session_id: sessionId, + reason: "mobile workflow peer resume e2e cleanup", + force: true, + }) + .catch(() => undefined), + ), + ); + } + } + }); + + test("mobile opens parked implement and resumes without changing Review ownership", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(240_000); + let scenario: WorkflowPeerResumeScenario | undefined; + const capture = attachSessionLaunchCapture(testPage); + + try { + scenario = await createWorkflowPeerResumeScenario(apiClient, seedData, "Mobile open resume", { + sendPeerMessage: false, + }); + expect( + await countPeerDeliveries( + apiClient, + scenario.implementationSessionId, + scenario.implementationMarker, + ), + ).toBe(1); + const beforeOpen = await apiClient.listTaskSessions(scenario.taskId); + expect( + beforeOpen.sessions.find((session) => session.id === scenario!.implementationSessionId) + ?.metadata?.workflow_parking, + ).toBeDefined(); + + const sessionPage = new SessionPage(testPage); + await testPage.goto(`/t/${scenario.taskId}`); + await sessionPage.waitForLoad(); + const mobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible'); + await mobileLayout.getByTestId("mobile-sessions-pill").tap(); + const sessionPicker = testPage.getByRole("dialog", { name: "Sessions" }); + const implementationRow = sessionPicker.getByTestId( + `mobile-session-row-${scenario.implementationSessionId}`, + ); + await expect(implementationRow).toBeVisible(); + const implementationRowBox = await implementationRow.boundingBox(); + expect(implementationRowBox).not.toBeNull(); + expect(implementationRowBox!.height).toBeGreaterThanOrEqual(44); + await implementationRow.tap(); + await expect(sessionPicker).not.toBeVisible(); + await expect + .poll(() => + capture.requests.some( + (request) => + request.taskId === scenario!.taskId && + request.sessionId === scenario!.implementationSessionId && + request.intent === "resume" && + request.activationSource === "session_open", + ), + ) + .toBe(true); + await expect( + sessionPage.activeChat().getByText("Resumed agent", { exact: false }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + await expect( + sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(), + ).toBeEditable(); + await assertNoDocumentHorizontalOverflow(testPage, "mobile session-open recovery"); + await waitForPeerSessionState( + apiClient, + scenario.taskId, + scenario.implementationSessionId, + "WAITING_FOR_INPUT", + ); + expect( + await countPeerDeliveries( + apiClient, + scenario.implementationSessionId, + scenario.implementationMarker, + ), + ).toBe(1); + + const task = await apiClient.getTask(scenario.taskId); + expect(task.workflow_step_id).toBe(scenario.reviewStepId); + expect(task.primary_session_id).toBe(scenario.initialSessionId); + } finally { + if (scenario) { + await Promise.all( + [scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) => + apiClient + .stopSession({ + session_id: sessionId, + reason: "mobile workflow session-open recovery e2e cleanup", + force: true, + }) + .catch(() => undefined), + ), + ); + } + } + }); +}); diff --git a/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts b/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts index d764bf7f41..96ea2c7a83 100644 --- a/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts +++ b/apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts @@ -47,9 +47,10 @@ test.describe("Queued session ownership", () => { await sessionPage.sessionTabBySessionId(scenario.sourceSessionId).click(); await expect(queueStatus).toBeVisible(); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); await expect( - testPage.locator('[data-testid="task-parked-session-note"]:visible'), - ).toBeVisible(); + sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(), + ).toBeEditable(); await expectSessionMessagesUnchanged( apiClient, scenario.sourceSessionId, diff --git a/apps/web/e2e/tests/workflow/workflow-peer-resume-helpers.ts b/apps/web/e2e/tests/workflow/workflow-peer-resume-helpers.ts new file mode 100644 index 0000000000..dd00fee261 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-peer-resume-helpers.ts @@ -0,0 +1,218 @@ +import { expect } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { createWorkflowAgentProfiles } from "./workflow-agent-switch-helpers"; + +const PEER_OUTPUT_DELAY_MS = 12_000; + +export type WorkflowPeerResumeScenario = { + taskId: string; + taskTitle: string; + workflowId: string; + reviewStepId: string; + initialSessionId: string; + implementationSessionId: string; + implementationMarker: string; + outputMarker: string; + completionMarker: string; + reviewMarker: string; +}; + +export async function waitForPeerSessionState( + apiClient: ApiClient, + taskId: string, + sessionId: string, + expectedState: string, + timeoutMs = 30_000, +): Promise { + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + return sessions.find((session) => session.id === sessionId)?.state ?? ""; + }, + { + timeout: timeoutMs, + message: `session ${sessionId} did not become ${expectedState}`, + }, + ) + .toBe(expectedState); +} + +async function waitForAgentMarker( + apiClient: ApiClient, + sessionId: string, + marker: string, +): Promise { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some( + (message) => message.author_type === "agent" && message.content.includes(marker), + ); + }, + { timeout: 30_000, message: `session ${sessionId} did not emit ${marker}` }, + ) + .toBe(true); +} + +export async function createWorkflowPeerResumeScenario( + apiClient: ApiClient, + seedData: SeedData, + name: string, + options: { sendPeerMessage?: boolean } = {}, +): Promise { + const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, `${name} workflow`); + const initial = await apiClient.createWorkflowStep(workflow.id, "Plan", 0, { + is_start_step: true, + agent_profile_id: profileA.id, + profile_session_start_policy: "new", + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + const implementation = await apiClient.createWorkflowStep(workflow.id, "Implement", 1, { + agent_profile_id: profileB.id, + profile_session_start_policy: "new", + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 2, { + session_target: { kind: "initial" }, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.createWorkflowStep(workflow.id, "PR", 3, { + session_target: { kind: "step", step_id: implementation.id }, + profile_session_end_policy: "park", + }); + + const initialMarker = `${name} initial ready`; + const implementationMarker = `${name} implementation ready`; + const reviewMarker = `${name} review ready`; + const outputMarker = `${name} peer output`; + const completionMarker = `${name} peer turn complete`; + await apiClient.updateWorkflowStep(initial.id, { prompt: `e2e:message("${initialMarker}")` }); + await apiClient.updateWorkflowStep(implementation.id, { + prompt: `e2e:message("${implementationMarker}")`, + }); + await apiClient.updateWorkflowStep(review.id, { prompt: `e2e:message("${reviewMarker}")` }); + + const taskTitle = `${name} target`; + const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profileA.id, { + workflow_id: workflow.id, + workflow_step_id: initial.id, + repository_ids: [seedData.repositoryId], + }); + const initialSessionId = await waitForProfileSession(apiClient, task.id, profileA.id); + await waitForAgentMarker(apiClient, initialSessionId, initialMarker); + await waitForPeerSessionState(apiClient, task.id, initialSessionId, "WAITING_FOR_INPUT"); + + await apiClient.moveTask(task.id, workflow.id, implementation.id); + const implementationSessionId = await waitForProfileSession(apiClient, task.id, profileB.id); + await waitForAgentMarker(apiClient, implementationSessionId, implementationMarker); + await waitForPeerSessionState(apiClient, task.id, implementationSessionId, "WAITING_FOR_INPUT"); + + if (options.sendPeerMessage !== false) { + const peerPrompt = [ + `e2e:message("${outputMarker}")`, + `e2e:delay(${PEER_OUTPUT_DELAY_MS})`, + `e2e:message("${completionMarker}")`, + ].join("\n"); + const reviewPrompt = [ + `e2e:mcp:kandev:message_task_kandev(${JSON.stringify({ + task_id: task.id, + session_id: implementationSessionId, + prompt: peerPrompt, + })})`, + `e2e:message("${reviewMarker}")`, + ].join("\n"); + await apiClient.updateWorkflowStep(review.id, { prompt: reviewPrompt }); + } + await apiClient.moveTask(task.id, workflow.id, review.id); + await waitForAgentMarker(apiClient, initialSessionId, reviewMarker); + await waitForPeerSessionState(apiClient, task.id, initialSessionId, "WAITING_FOR_INPUT"); + + return { + taskId: task.id, + taskTitle, + workflowId: workflow.id, + reviewStepId: review.id, + initialSessionId, + implementationSessionId, + implementationMarker, + outputMarker, + completionMarker, + reviewMarker, + }; +} + +export type SessionLaunchCapture = { + taskId?: string; + sessionId?: string; + intent?: string; + activationSource?: string; +}; + +export function attachSessionLaunchCapture(page: Page): { requests: SessionLaunchCapture[] } { + const requests: SessionLaunchCapture[] = []; + page.on("websocket", (socket) => { + socket.on("framesent", (event) => { + if (typeof event.payload !== "string" || !event.payload.includes('"session.launch"')) return; + try { + const message = JSON.parse(event.payload) as { + action?: string; + payload?: { + task_id?: string; + session_id?: string; + intent?: string; + activation_source?: string; + }; + }; + if (message.action !== "session.launch") return; + requests.push({ + taskId: message.payload?.task_id, + sessionId: message.payload?.session_id, + intent: message.payload?.intent, + activationSource: message.payload?.activation_source, + }); + } catch { + // Ignore non-JSON websocket frames. + } + }); + }); + return { requests }; +} + +async function waitForProfileSession( + apiClient: ApiClient, + taskId: string, + profileId: string, +): Promise { + let sessionId = ""; + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + const session = sessions.find((item) => item.agent_profile_id === profileId); + sessionId = session?.id ?? ""; + return session?.state === "WAITING_FOR_INPUT"; + }, + { timeout: 30_000, message: `profile ${profileId} did not become answerable` }, + ) + .toBe(true); + return sessionId; +} + +export async function countPeerDeliveries( + apiClient: ApiClient, + sessionId: string, + marker: string, +): Promise { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.filter( + (message) => message.author_type === "user" && message.content.includes(marker), + ).length; +} diff --git a/apps/web/e2e/tests/workflow/workflow-peer-resume.spec.ts b/apps/web/e2e/tests/workflow/workflow-peer-resume.spec.ts new file mode 100644 index 0000000000..250a778c19 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-peer-resume.spec.ts @@ -0,0 +1,185 @@ +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { + attachSessionLaunchCapture, + countPeerDeliveries, + createWorkflowPeerResumeScenario, + waitForPeerSessionState, + type WorkflowPeerResumeScenario, +} from "./workflow-peer-resume-helpers"; + +test.describe("Workflow peer resume", () => { + test("review peer message resumes implement without parking UI", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(240_000); + let scenario: WorkflowPeerResumeScenario | undefined; + + try { + scenario = await createWorkflowPeerResumeScenario(apiClient, seedData, "Desktop peer resume"); + const beforeMessage = await apiClient.getTask(scenario.taskId); + expect(beforeMessage.workflow_step_id).toBe(scenario.reviewStepId); + expect(beforeMessage.primary_session_id).toBe(scenario.initialSessionId); + + const sessionsBefore = await apiClient.listTaskSessions(scenario.taskId); + const implementationBefore = sessionsBefore.sessions.find( + (session) => session.id === scenario!.implementationSessionId, + ); + expect(implementationBefore?.metadata?.workflow_parking).toBeDefined(); + + await waitForPeerSessionState( + apiClient, + scenario.taskId, + scenario.implementationSessionId, + "RUNNING", + ); + const sessionPage = new SessionPage(testPage); + await testPage.goto(`/t/${scenario.taskId}`); + await sessionPage.waitForLoad(); + await sessionPage.sessionTabBySessionId(scenario.implementationSessionId).click(); + await sessionPage.expectChatResponseVisible(scenario.outputMarker); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + await expect( + sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(), + ).toBeEditable(); + await expect + .poll(() => + countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker), + ) + .toBe(1); + + const activeTask = await apiClient.getTask(scenario.taskId); + expect(activeTask.workflow_step_id).toBe(scenario.reviewStepId); + expect(activeTask.primary_session_id).toBe(scenario.initialSessionId); + const activeSessions = await apiClient.listTaskSessions(scenario.taskId); + expect( + activeSessions.sessions.find((session) => session.id === scenario!.implementationSessionId) + ?.metadata?.workflow_parking, + ).toBeDefined(); + + await testPage.reload(); + await sessionPage.waitForLoad(); + await sessionPage.sessionTabBySessionId(scenario.implementationSessionId).click(); + await sessionPage.expectChatResponseVisible(scenario.outputMarker); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + + await waitForPeerSessionState( + apiClient, + scenario.taskId, + scenario.implementationSessionId, + "WAITING_FOR_INPUT", + ); + await sessionPage.expectChatResponseVisible(scenario.completionMarker); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + const finalTask = await apiClient.getTask(scenario.taskId); + expect(finalTask.workflow_step_id).toBe(scenario.reviewStepId); + expect(finalTask.primary_session_id).toBe(scenario.initialSessionId); + await expect + .poll(() => + countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker), + ) + .toBe(1); + } finally { + if (scenario) { + await Promise.all( + [scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) => + apiClient + .stopSession({ + session_id: sessionId, + reason: "workflow peer resume e2e cleanup", + force: true, + }) + .catch(() => undefined), + ), + ); + } + } + }); + + test("opening parked implement resumes without changing Review ownership", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(240_000); + let scenario: WorkflowPeerResumeScenario | undefined; + const capture = attachSessionLaunchCapture(testPage); + + try { + scenario = await createWorkflowPeerResumeScenario( + apiClient, + seedData, + "Desktop open resume", + { sendPeerMessage: false }, + ); + expect( + await countPeerDeliveries( + apiClient, + scenario.implementationSessionId, + scenario.implementationMarker, + ), + ).toBe(1); + const beforeOpen = await apiClient.listTaskSessions(scenario.taskId); + expect( + beforeOpen.sessions.find((session) => session.id === scenario!.implementationSessionId) + ?.metadata?.workflow_parking, + ).toBeDefined(); + + const sessionPage = new SessionPage(testPage); + await testPage.goto(`/t/${scenario.taskId}`); + await sessionPage.waitForLoad(); + await sessionPage.sessionTabBySessionId(scenario.implementationSessionId).click(); + await expect + .poll(() => + capture.requests.some( + (request) => + request.taskId === scenario!.taskId && + request.sessionId === scenario!.implementationSessionId && + request.intent === "resume" && + request.activationSource === "session_open", + ), + ) + .toBe(true); + await expect( + sessionPage.activeChat().getByText("Resumed agent", { exact: false }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0); + await expect( + sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(), + ).toBeEditable(); + await waitForPeerSessionState( + apiClient, + scenario.taskId, + scenario.implementationSessionId, + "WAITING_FOR_INPUT", + ); + expect( + await countPeerDeliveries( + apiClient, + scenario.implementationSessionId, + scenario.implementationMarker, + ), + ).toBe(1); + + const task = await apiClient.getTask(scenario.taskId); + expect(task.workflow_step_id).toBe(scenario.reviewStepId); + expect(task.primary_session_id).toBe(scenario.initialSessionId); + } finally { + if (scenario) { + await Promise.all( + [scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) => + apiClient + .stopSession({ + session_id: sessionId, + reason: "workflow session-open recovery e2e cleanup", + force: true, + }) + .catch(() => undefined), + ), + ); + } + } + }); +}); diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index afcd56ae54..a19f31c03b 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1360,7 +1360,6 @@ "wipQueueConfigure": "Configure workflow WIP limit", "wipQueueUnavailable": "Workflow WIP details are unavailable.", "wipQueueConfigurationUnavailable": "The workflow configuration link is unavailable.", - "parkedSessionNote": "This session is parked for the workflow. Opening it does not resume it. Send a message to continue here.", "quorumAwaiting": "Awaiting {{received}} of {{required}} {{role}} decisions", "quorumStuck": "Stuck", "quorumStuckPendingReevaluation": "Blocked: no active session to re-evaluate this step", diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index 95e89ecdec..b99022e264 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -1360,7 +1360,6 @@ "wipQueueConfigure": "ワークフロー WIP 上限を設定", "wipQueueUnavailable": "ワークフロー WIP 詳細なし。", "wipQueueConfigurationUnavailable": "ワークフロー設定リンクなし。", - "parkedSessionNote": "このセッションはワークフロー用に待機中。開いても再開しない。続けるにはメッセージ送信。", "quorumAwaiting": "{{role}} の決定待ち、{{received}} / {{required}} 件", "quorumStuck": "停止中", "quorumStuckPendingReevaluation": "ブロック中: このステップを再評価するアクティブ セッションなし", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 99be210e40..4d67ca164b 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1360,7 +1360,6 @@ "wipQueueConfigure": "Ćōńƒĩĝũŕē ŵōŕķƒĺōŵ ŴĨƤ ĺĩḿĩţ", "wipQueueUnavailable": "Ŵōŕķƒĺōŵ ŴĨƤ ďēţàĩĺś àŕē ũńàvàĩĺàƀĺē.", "wipQueueConfigurationUnavailable": "Ţĥē ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń ĺĩńķ ĩś ũńàvàĩĺàƀĺē.", - "parkedSessionNote": "Ţĥĩś śēśśĩōń ĩś ƥàŕķēď ƒōŕ ţĥē ŵōŕķƒĺōŵ. Ōƥēńĩńĝ ĩţ ďōēś ńōţ ŕēśũḿē ĩţ. Śēńď à ḿēśśàĝē ţō ćōńţĩńũē ĥēŕē.", "quorumAwaiting": "Àŵàĩţĩńĝ {{received}} ōƒ {{required}} {{role}} ďēćĩśĩōńś", "quorumStuck": "Śţũćķ", "quorumStuckPendingReevaluation": "Ɓĺōćķēď: ńō àćţĩvē śēśśĩōń ţō ŕē-ēvàĺũàţē ţĥĩś śţēƥ", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 8af7853349..d389bccd9d 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1294,7 +1294,6 @@ "wipQueueConfigure": "Configurar o limite WIP do fluxo de trabalho", "wipQueueUnavailable": "Os detalhes do WIP do fluxo de trabalho não estão disponíveis.", "wipQueueConfigurationUnavailable": "A ligação para a configuração do fluxo de trabalho não está disponível.", - "parkedSessionNote": "Esta sessão está parada para o fluxo de trabalho. Abri-la não a retoma. Envie uma mensagem para continuar aqui.", "quorumAwaiting": "A aguardar {{received}} de {{required}} decisões de {{role}}", "quorumStuck": "Bloqueado", "quorumStuckPendingReevaluation": "Bloqueado: sem sessão ativa para reavaliar esta etapa", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index b12d983648..9aed805616 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1293,7 +1293,6 @@ "wipQueueConfigure": "配置工作流 WIP 限制", "wipQueueUnavailable": "工作流 WIP 详情不可用。", "wipQueueConfigurationUnavailable": "工作流配置链接不可用。", - "parkedSessionNote": "此会话已为工作流暂停。打开它不会恢复会话。发送消息即可在此继续。", "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定", "quorumStuck": "受阻", "quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index c512563467..8482925c34 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1293,7 +1293,6 @@ "wipQueueConfigure": "設定工作流程 WIP 限制", "wipQueueUnavailable": "工作流程 WIP 詳情不可用。", "wipQueueConfigurationUnavailable": "工作流程設定連結不可用。", - "parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。", "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定", "quorumStuck": "受阻", "quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index f131a1863d..ed4b19c7d1 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1293,7 +1293,6 @@ "wipQueueConfigure": "設定工作流程 WIP 限制", "wipQueueUnavailable": "工作流程 WIP 詳情不可用。", "wipQueueConfigurationUnavailable": "工作流程設定連結不可用。", - "parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。", "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定", "quorumStuck": "受阻", "quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟", diff --git a/docs/plans/queued-session-ownership/plan.md b/docs/plans/queued-session-ownership/plan.md index ae069567cd..eb29bbafbf 100644 --- a/docs/plans/queued-session-ownership/plan.md +++ b/docs/plans/queued-session-ownership/plan.md @@ -313,4 +313,6 @@ The [recovery eligibility repair](../session-open-recovery-eligibility/plan.md) owns the historical-stop and settled-deferral regressions found after restart. It replaces the parking suppression policy and removes the parking note, with separate and combined recovery cases on desktop and phone. Existing results and PostgreSQL prerequisites here -remain unchanged. The follow-up work order is pending implementation. +remain unchanged. The follow-up UI work order is complete. Its 2026-09-23 update also +covers Review-to-Implement peer resumption with legacy parking metadata. The recovery +package remains the single owner for removing parking presentation and its E2E coverage. diff --git a/docs/plans/session-open-recovery-eligibility/plan.md b/docs/plans/session-open-recovery-eligibility/plan.md index bfb0f1b3f7..d1b6411921 100644 --- a/docs/plans/session-open-recovery-eligibility/plan.md +++ b/docs/plans/session-open-recovery-eligibility/plan.md @@ -1,6 +1,6 @@ --- created: 2026-09-18 -status: in_progress +status: completed requirements: - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001 - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002 @@ -45,6 +45,43 @@ explicitly superseded by 001.9 and 003.10. Criteria 001.7/8 retain restart and settled-queue coverage. Queue identity, automatic admission, callback correlation, and the deadlock fixes remain compatibility constraints. +## Confirmed peer-resume regression (2026-09-23) + +This package also owns the fix requested for task +`31c82921-af54-465c-aa1d-adf033fe9b2a`. Task 02 remains the single implementation +owner for banner removal. This update adds evidence and regression coverage. +It does not reopen completed Task 01 or create a competing parking policy. + +The investigation used read-only session queries, SQLite metadata, and retained +backend logs. Times below use UTC on 2026-09-23. + +| Time | Evidence | +| --- | --- | +| 21:08:25 | Review entry parked implementation session `2576d990-c94e-4291-85bb-64b4684cc3dc`. | +| 21:13:46 | Initial session `2e76057d-653a-4120-9af6-37e0d1048acb` sent review findings through `message_task_kandev`. | +| 21:13:53 | The implementation session resumed successfully. | +| Investigation | The implementation session was RUNNING and emitted output. Its original `workflow_parking` marker remained stored. | + +The Feature workflow uses a new Luna session for Implement. Review reuses the +initial Sol session. Its prompt directs Sol to message Luna with findings. +PR later targets the Implement session. A peer message does not itself move the +workflow out of Review. + +The prompt-owned resume path uses `ensureSessionRunning`, `runResumeAttempt`, +and `coldResumeSession` in `task_operations.go`. It does not call the parking-clear +helper used by `session_launch.go:launchResume`. The chat panel renders +`ParkedSessionNote` from `hasWorkflowParkingMarker(session.metadata)` alone. +The resulting banner contradicts the active session state. + +This is an implementation gap against AC 003.10 and the accepted recovery ADR. +The requirement document retains its existing draft status. The accepted ADR +settles the intended behavior. No new requirement or architectural decision is needed. + +Remove the presentation according to the existing design. Do not add a RUNNING-only +exception or a metadata deletion prerequisite. Legacy metadata must remain inert. +Keep execution-stamped stop intents for delayed callbacks. Do not change peer +message delivery, workflow routing, or the agent completion-signal contract. + ## Scope ### In scope @@ -144,6 +181,7 @@ AC suffixes use `AC-TASKS-QUEUED-SESSION-OWNERSHIP-`. | Archive, explicit cancellation, completion, authorization failure | Existing lifecycle restrictions remain | 001.9 | | Allowed status followed by conflicting queue or route change | Guarded admission rejects stale execution | 001.2, 001.6; 002.6 | | Restart after switch or queue settlement | Conversation context survives; opening requests normal recovery | 001.7, 001.8, 001.9 | +| Review(initial) messages Implement session | One peer delivery, active output without parking UI, Review recipient unchanged | 001.3; 003.10 | | Desktop/phone with stored parking marker | No parked note; ordinary controls and real queue status remain | 003.10 | ## E2E tests @@ -169,10 +207,58 @@ parking note, no document horizontal overflow, and usable existing controls. Restore settings and runtime overrides in `finally`. No developer-instance restart or direct production database mutation is authorized. +### Peer-message handoff regression + +Add `workflow-peer-resume.spec.ts` and `mobile-workflow-peer-resume.spec.ts` under +`apps/web/e2e/tests/workflow/`. Share setup in `workflow-peer-resume-helpers.ts`. +Use the shipped workflow-switch helpers and the phone Sessions picker. + +Name the desktop test `review peer message resumes implement without parking UI`. +Name the phone test `mobile review peer message resumes implement without parking UI`. +Both cover AC 001.3 and 003.10. The desktop test must fail on the current banner +before Task 02 removes it. Repeat the same behavior on the configured phone project. + +Add desktop and phone session-open flows for selecting the stopped Implement +conversation while capacity is available. Capture the `session.launch` request +and require `activation_source: session_open`. Confirm the agent resumes, the +composer is usable, and the original workflow prompt is not replayed. On phone, +also assert the session row remains at least 44px and the page has no horizontal +overflow. Both flows preserve Review and its initial primary session. + +1. Create an isolated workflow with initial, Implement, Review, and PR recipients. +2. Enter Implement, then Review, through actual workflow transitions. +3. Keep the implementation page closed until the peer message starts its turn. +4. From the initial mock agent, call `message_task_kandev` with the exact implementation session ID. +5. Use `e2e:mcp:kandev:message_task_kandev()` from the existing mock script engine. +6. Include a unique output marker and a bounded mock delay in the recipient prompt. +7. Await provider output and RUNNING state before selecting the implementation conversation. +8. Assert one peer delivery, the same session ID, no parked note, and usable ordinary controls. +9. Reload during the observed turn and repeat the absence assertion after hydration. +10. Let the turn settle without a completion signal, then assert Review and its initial recipient remain unchanged. + +Use event or state waits in Playwright. The mock delay only holds a real turn open +for observation. It is not a browser synchronization sleep. Do not inject RUNNING +state or synthetic transcript messages to replace the peer-delivery path. + +The defect fixture must expose legacy parking metadata before the UI assertion. +If the workflow no longer writes it, use a scoped fixture on the isolated backend. +Keep that fixture out of production APIs. A test without the stale marker does +not reproduce the reported failure. Restore fixture state and stop owned sessions +in `finally`. Genuine queue coverage remains in Task 02's existing two suites. + +The resumed Review mock-agent initially lost its injected MCP server definitions +on `LoadSession`, so `message_task_kandev` failed before reaching Implement. +Task 02 now owns the small mock-agent fixture repair that carries those supplied +definitions into the resumed session. This does not change the application MCP path. + +For the active handoff, UI-01 becomes `[Sol] [Luna: running] [Plan]` above the +conversation and composer. UI-02 uses `[Luna: running v]` in the existing phone +picker. Neither view adds a parking row before, during, or after the turn. + ## Work orders - [x] [Task 01: Restore ordinary recovery for workflow-stopped sessions](task-01-repair-session-open-eligibility.md) -- [ ] [Task 02: Remove parking presentation and verify user flows](task-02-remove-parking-presentation.md) +- [x] [Task 02: Remove parking presentation and verify user flows](task-02-remove-parking-presentation.md) Order: 01 then 02. No subagents are authorized. @@ -190,18 +276,41 @@ remain compatibility inputs. Do not change their lock order or claim fences. ## Documentation impact Task 02 updates `docs/public/tasks-and-workflows.md` and -`docs/public/agents-and-profiles.md`: opening restores the selected conversation -under normal controls without changing workflow ownership. Remove instructions -that require a message solely because the workflow stopped the conversation. -No public behavior claim ships during this design-only turn. +`docs/public/agents-and-profiles.md`: opening a workflow-stopped session can +resume it under normal recovery eligibility and capacity. It does not change the +selected workflow step or primary session. The docs no longer require a message +solely because the workflow stopped the conversation. ## Verification results Task 01 backend implementation is complete. Focused recovery, race, model/SQLite, build, lint, and the full orchestrator package pass. The complete backend test target still reports unrelated environment-sensitive failures in probe process -detection and home configuration discovery. Task 02 remains pending for the -frontend presentation removal and desktop/mobile E2E coverage. +detection and home configuration discovery. Task 02 completed the frontend +presentation removal and desktop/mobile E2E coverage. The 2026-09-23 +peer-resume and session-open scenarios now pass on desktop and mobile. + +Task 02's desktop peer-resume test first failed as expected with one parked note +during active output. After removing the presentation, the desktop suite passed +3 tests and the mobile suite passed 3 tests. The direct session-open flows +captured `activation_source: session_open` and preserved the original prompt and +Review ownership. Targeted UI tests passed 41 tests; +typecheck, i18n checks, the focused mock-agent test, and `git diff --check` passed. +Public-doc validation passed 62 tests and 47 pages. Spec validation passed for +301 decisions and 1134 specifications, including all 36 linter tests. The peer +message was delivered once; Implement retained its legacy marker without parking +UI, and Review ownership remained unchanged. + +### Design validation, 2026-09-23 + +- `python3 scripts/list-docs.py validate`: passed (301 decisions, 1134 specifications). +- `python3 scripts/lint-spec-files.py --all`: passed. +- `python3 scripts/lint-spec-files.test.py`: passed (36 tests). +- `git diff --check`: passed. +- `git status --short -- docs/plans/session-open-recovery-eligibility docs/plans/queued-session-ownership`: three modified plan files, unstaged. + +At package preparation time, implementation checks remained pending and only the +fix package changed. The completed implementation results are recorded above. ## Risks diff --git a/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md b/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md index abaf776813..f53df68354 100644 --- a/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md +++ b/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md @@ -1,7 +1,7 @@ --- id: "02-remove-parking-presentation" title: "Remove parking presentation and verify user flows" -status: pending +status: completed wave: 2 depends_on: ["01-repair-session-open-eligibility"] plan: "plan.md" @@ -11,6 +11,7 @@ requirements: - REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003 acceptance_criteria: - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.3 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7 @@ -33,10 +34,13 @@ conversation recovery on desktop and phone without adding another parking afford ## In scope - Remove `ParkedSessionNote`, `hasWorkflowParkingMarker`, and their chat-panel use. -- Remove `parkedSessionNote` from en, pseudo, pt-pt, zh-cn, zh-hk, and zh-tw catalogs. +- Remove `parkedSessionNote` from en, pseudo, pt-pt, zh-cn, zh-hk, zh-tw, and ja catalogs. - Replace parked-note tests with absence and ordinary-conversation-control assertions. - Update desktop/mobile queued-session E2E scenarios according to the plan matrix. - Verify restart, capacity, prevention preference, conversation identity, and no prompt replay. +- Capture `session_open` recovery on desktop and phone when capacity permits. +- Cover the confirmed Review-to-Implement peer resume described in the plan. +- Preserve legacy metadata compatibility without a live-data rewrite. - Update both public recovery explanations and affected companion-plan references. ## Out of scope @@ -49,7 +53,11 @@ queue status remains. No new layout, navigation hierarchy, or automatic prompt. 1. No parking presentation appears on desktop or phone, including with persisted legacy markers. 2. Opening the selected stopped session resumes it when normal controls permit, without clicking Resume. Queue ownership and workflow primary remain unchanged. -3. Both E2E projects and targeted UI/i18n checks pass. Public docs describe the new behavior. +3. The reviewer messages the implementation session exactly once through MCP. Active output, reload, and turn settlement show no parking UI. + Review ownership stays unchanged. +4. Selecting a stopped session launches normal `session_open` recovery when capacity permits, + without replaying its workflow prompt or changing Review ownership. +5. Both E2E projects and targeted UI/i18n checks pass. Public docs describe the new behavior. ## ASCII UI preview @@ -65,17 +73,41 @@ Keep the existing desktop tabs and phone task drawer/session picker. The chat remains the single conversation scroll owner. No new touch targets or hover-only behavior are introduced. Cover AC 001.9 and 003.10. +## Regression implementation + +Follow [the peer-message scenario](plan.md#peer-message-handoff-regression). +The existing `cmd/mock-agent/script.go:executeMCPCommand` supports the real MCP call. +The mock-agent fixture must retain the MCP server definitions supplied to `LoadSession` +so the resumed Review session can issue that call. Do not change the application MCP path. + +Add the named desktop and phone regression tests before banner removal. +Record a RED result caused by the visible parked note during active output. +Then remove the component, marker predicate, imports, obsolete tests, and locale key. +Retain queue component tests. Use rendered chat/E2E absence checks instead of tests +that only assert a removed export or file no longer exists. + +Confirm the no-banner result with stored legacy markers. Cover STARTING, RUNNING, +and WAITING_FOR_INPUT where the fixture exposes those states. The fix removes +parking presentation in every state, rather than changing the state predicate. +Do not delete stop-intent tombstones or add a parking-clear requirement to peer delivery. + +The nearest phone exemplar is `components/task/mobile/session-task-switcher-sheet.tsx`. +Use `mobile-sessions-pill` and `mobile-session-row-` from the existing mobile E2E. +Keep the dedicated phone layout, chat scroll owner, safe-area navigation, and composer. +Assert no document horizontal overflow and a session-row hit target of at least 44px. + ## Verification Run from repository root; install dependencies once for a fresh worktree. ```bash +(cd apps/backend && go test ./cmd/mock-agent -run 'TestLoadSession(RetainsMCPServersForResumedPrompt|ReturnsCapabilitiesForResumedSession)' -count=1) (cd apps && pnpm install --frozen-lockfile) (cd apps/web && pnpm exec vitest run components/task/launch-queue-status.test.tsx hooks/domains/session/use-session-resumption.test.ts) (cd apps/web && pnpm run typecheck) (cd apps/web && pnpm run i18n:check) -(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts) -(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-queued-session-ownership.spec.ts) +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts tests/workflow/workflow-peer-resume.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-queued-session-ownership.spec.ts tests/workflow/mobile-workflow-peer-resume.spec.ts) python3 scripts/list-docs.py validate python3 scripts/lint-spec-files.test.py python3 scripts/lint-spec-files.py --all @@ -92,10 +124,14 @@ Assert provider readiness independently of workspace-only readiness. - `apps/web/components/task/launch-queue-status.tsx` and its test. - `apps/web/components/task/task-chat-panel.tsx`. - `apps/web/hooks/domains/session/use-session-resumption.test.ts`. -- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`. +- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw,ja}/task.json`. - `apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts`. - `apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts`. - `apps/web/e2e/tests/workflow/queued-session-ownership-helpers.ts`. +- `apps/web/e2e/tests/workflow/workflow-peer-resume.spec.ts` (new). +- `apps/web/e2e/tests/workflow/mobile-workflow-peer-resume.spec.ts` (new). +- `apps/web/e2e/tests/workflow/workflow-peer-resume-helpers.ts` (new). +- `apps/backend/cmd/mock-agent/main.go` and `session_config_test.go` for resumed MCP fixture support. - `docs/public/tasks-and-workflows.md` and `docs/public/agents-and-profiles.md`. - This package's status/results and companion references. @@ -122,4 +158,21 @@ Restore fixture capacity and user settings even when assertions fail. ## Results -Pending. No frontend, E2E, or implementation check ran during planning. +Complete. The desktop peer-resume test first failed at the expected assertion: +the implementation session was RUNNING with active output and one parked note. +After presentation removal: + +- Mock-agent LoadSession MCP retention test: passed. +- Targeted UI tests: 41 passed; typecheck and i18n check: passed. +- Chromium queued-session, peer-resume, and session-open E2E: 3 passed. +- Mobile queued-session, peer-resume, and session-open E2E: 3 passed. +- Public-doc validation: 62 tests and 47 published pages passed. +- Specification validation: 301 decisions, 1134 specifications, and 36 linter tests passed. +- `git diff --check`: passed. + +The peer delivery occurred once. The active Implement session kept its legacy +parking marker while the banner stayed absent before and after reload and turn +settlement. Review remained the workflow step and the initial session remained +primary. The phone session row met the 44px target and showed no horizontal overflow. +The direct session-open flows captured `activation_source: session_open` and +confirmed the original workflow prompt was not replayed. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 5fcdd4e4ff..69b91b733b 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -291,8 +291,10 @@ candidate policies are configured on dynamic profiles. When a task launch waits for session capacity, Kandev keeps the selected destination and retries it automatically. Inspecting another session does not -resume a parked predecessor. Use an explicit **Resume** action or send a -message for manual recovery. These actions can override the automatic ceiling. +change the workflow's selected step or primary session. You can open another +session and use its normal controls. Kandev resumes it when the session can run; +the automatic session ceiling still applies unless you explicitly start, +resume, or message the session. Provider errors that occur before a result can use the configured action, such as retrying the current candidate or trying the next candidate. A started turn diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 5f43dcd410..20bed943d2 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -655,7 +655,11 @@ The session limit is off by default. Manual **Start**, **Resume**, or sending a If session capacity blocks a start, Kandev keeps the selected destination and retries automatically. See [Agents and profiles](agents-and-profiles.md) for profile compatibility and recovery. -Parked sessions share the task workspace. Resuming one while a destination session is active can create concurrent writers. Wait until the active session finishes first. +Sessions parked by workflow transitions share the task workspace. Opening one +can resume it when recovery rules and session capacity allow. This does not +change the selected workflow step or primary session. Multiple sessions can +write to the same workspace at the same time, so review their activity before +resuming a session while another session is active. ### Change session options by workflow step