fix(ui): remove stale parked session note (#3900)

This commit is contained in:
Carlos Florêncio
2026-09-24 10:41:26 +01:00
committed by GitHub
parent 25271b6e7f
commit 2173543c04
23 changed files with 860 additions and 121 deletions
+5
View File
@@ -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()
@@ -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{
@@ -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<string, unknown> | undefined)).toBe(
expected,
);
});
it("renders the note only when the selected session is parked", () => {
const { rerender } = render(<ParkedSessionNote visible={false} />);
expect(screen.queryByTestId("task-parked-session-note")).toBeNull();
rerender(<ParkedSessionNote visible />);
expect(screen.getByTestId("task-parked-session-note")).toBeTruthy();
});
});
@@ -167,33 +167,3 @@ function LaunchQueueStatusContent({
</section>
);
}
export function hasWorkflowParkingMarker(
metadata: Record<string, unknown> | null | undefined,
): boolean {
const marker = metadata?.workflow_parking;
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false;
const value = marker as Record<string, unknown>;
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 (
<div
data-testid="task-parked-session-note"
role="note"
className="min-w-0 shrink-0 border-b border-border/70 bg-muted/10 px-3 py-2 text-xs text-muted-foreground"
>
{t("task:parkedSessionNote")}
</div>
);
}
+1 -6
View File
@@ -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 && <LaunchQueueStatus queue={launchStatusSummary?.launch_queue} />}
{!hideWipQueueStatus && <WipQueueStatus taskId={summaryTaskId} />}
<ParkedSessionNote visible={hasWorkflowParkingMarker(session?.metadata)} />
<PanelBody
padding={false}
scroll={false}
+2 -1
View File
@@ -376,7 +376,8 @@ test.describe("shared panel toolbars", () => {
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 });
@@ -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,
@@ -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),
),
);
}
}
});
});
@@ -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,
@@ -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<void> {
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<void> {
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<WorkflowPeerResumeScenario> {
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<string> {
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<number> {
const { messages } = await apiClient.listSessionMessages(sessionId);
return messages.filter(
(message) => message.author_type === "user" && message.content.includes(marker),
).length;
}
@@ -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),
),
);
}
}
});
});
-1
View File
@@ -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",
-1
View File
@@ -1360,7 +1360,6 @@
"wipQueueConfigure": "ワークフロー WIP 上限を設定",
"wipQueueUnavailable": "ワークフロー WIP 詳細なし。",
"wipQueueConfigurationUnavailable": "ワークフロー設定リンクなし。",
"parkedSessionNote": "このセッションはワークフロー用に待機中。開いても再開しない。続けるにはメッセージ送信。",
"quorumAwaiting": "{{role}} の決定待ち、{{received}} / {{required}} 件",
"quorumStuck": "停止中",
"quorumStuckPendingReevaluation": "ブロック中: このステップを再評価するアクティブ セッションなし",
-1
View File
@@ -1360,7 +1360,6 @@
"wipQueueConfigure": "Ćōńƒĩĝũŕē ŵōŕķƒĺōŵ ŴĨƤ ĺĩḿĩţ",
"wipQueueUnavailable": "Ŵōŕķƒĺōŵ ŴĨƤ ďēţàĩĺś àŕē ũńàvàĩĺàƀĺē.",
"wipQueueConfigurationUnavailable": "Ţĥē ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń ĺĩńķ ĩś ũńàvàĩĺàƀĺē.",
"parkedSessionNote": "Ţĥĩś śēśśĩōń ĩś ƥàŕķēď ƒōŕ ţĥē ŵōŕķƒĺōŵ. Ōƥēńĩńĝ ĩţ ďōēś ńōţ ŕēśũḿē ĩţ. Śēńď à ḿēśśàĝē ţō ćōńţĩńũē ĥēŕē.",
"quorumAwaiting": "Àŵàĩţĩńĝ {{received}} ōƒ {{required}} {{role}} ďēćĩśĩōńś",
"quorumStuck": "Śţũćķ",
"quorumStuckPendingReevaluation": "Ɓĺōćķēď: ńō àćţĩvē śēśśĩōń ţō ŕē-ēvàĺũàţē ţĥĩś śţēƥ",
-1
View File
@@ -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",
-1
View File
@@ -1293,7 +1293,6 @@
"wipQueueConfigure": "配置工作流 WIP 限制",
"wipQueueUnavailable": "工作流 WIP 详情不可用。",
"wipQueueConfigurationUnavailable": "工作流配置链接不可用。",
"parkedSessionNote": "此会话已为工作流暂停。打开它不会恢复会话。发送消息即可在此继续。",
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定",
"quorumStuck": "受阻",
"quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤",
-1
View File
@@ -1293,7 +1293,6 @@
"wipQueueConfigure": "設定工作流程 WIP 限制",
"wipQueueUnavailable": "工作流程 WIP 詳情不可用。",
"wipQueueConfigurationUnavailable": "工作流程設定連結不可用。",
"parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。",
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
"quorumStuck": "受阻",
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
-1
View File
@@ -1293,7 +1293,6 @@
"wipQueueConfigure": "設定工作流程 WIP 限制",
"wipQueueUnavailable": "工作流程 WIP 詳情不可用。",
"wipQueueConfigurationUnavailable": "工作流程設定連結不可用。",
"parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。",
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
"quorumStuck": "受阻",
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
+3 -1
View File
@@ -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.
@@ -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(<JSON>)` 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
@@ -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-<id>` 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.
+4 -2
View File
@@ -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
+5 -1
View File
@@ -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