-
-
- {t("chat:queued")}
-
- {capacityText}
-
-
-
-
- {!isMobile && (
-
- )}
+
+
+
+ {t("chat:queued")}
+
+ {capacityText}
+
+
+
+
+
+ {!isMobile && (
-
-
-
-
-
+ )}
+
);
diff --git a/apps/web/components/task/chat/use-queue-panel-escape.ts b/apps/web/components/task/chat/use-queue-panel-escape.ts
new file mode 100644
index 0000000000..47e4c28350
--- /dev/null
+++ b/apps/web/components/task/chat/use-queue-panel-escape.ts
@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+
+function isEditableTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
+ return target.isContentEditable;
+}
+
+export function useQueuePanelEscape(open: boolean, onClose: () => void): void {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || isEditableTarget(event.target)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ };
+ window.addEventListener("keydown", onKey, true);
+ return () => window.removeEventListener("keydown", onKey, true);
+ }, [open, onClose]);
+}
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts
index c2b0c08e65..8041e34835 100644
--- a/apps/web/e2e/helpers/api-client.ts
+++ b/apps/web/e2e/helpers/api-client.ts
@@ -54,6 +54,12 @@ import type {
import { loadInterimSettingsInterlockToken } from "./interim-settings-interlock";
import { dwell } from "./causal-waits";
+export type QueueSessionIdentityInput = {
+ taskId: string;
+ sessionId: string;
+ sessionIncarnationId: string;
+};
+
// --- GitHub Mock Types ---
export type MockPR = {
@@ -2357,6 +2363,7 @@ export class ApiClient {
sessions: Array<{
id: string;
task_id: string;
+ queue_incarnation_id: string;
agent_profile_id?: string;
executor_id?: string;
executor_profile_id?: string;
@@ -2378,6 +2385,22 @@ export class ApiClient {
return this.request("GET", `/api/v1/tasks/${taskId}/sessions`);
}
+ async getQueueSessionIdentity(
+ taskId: string,
+ sessionId: string,
+ ): Promise
{
+ const { sessions } = await this.listTaskSessions(taskId);
+ const session = sessions.find((candidate) => candidate.id === sessionId);
+ if (!session?.queue_incarnation_id) {
+ throw new Error(`Queue identity is unavailable for session ${sessionId}`);
+ }
+ return {
+ taskId,
+ sessionId,
+ sessionIncarnationId: session.queue_incarnation_id,
+ };
+ }
+
/**
* Read a task's dependency projection. `blocked_reason` is `pending`,
* `failed`, or `unknown`; the last one means the store could not be read and
@@ -2720,34 +2743,46 @@ export class ApiClient {
}
async queueMessage(
- taskId: string,
- sessionId: string,
+ identity: QueueSessionIdentityInput,
content: string,
attachments?: MessageAttachmentInput[],
): Promise {
await this.wsRequest("message.queue.add", {
- task_id: taskId,
- session_id: sessionId,
+ task_id: identity.taskId,
+ session_id: identity.sessionId,
+ session_incarnation_id: identity.sessionIncarnationId,
content,
attachments,
});
}
- /** Removes every pending queued message for a session (message.queue.cancel). */
- async clearQueue(sessionId: string): Promise {
- await this.wsRequest("message.queue.cancel", { session_id: sessionId });
+ /** Removes every pending queued message for an immutable session identity. */
+ async clearQueue(identity: QueueSessionIdentityInput): Promise {
+ await this.wsRequest("message.queue.cancel", {
+ task_id: identity.taskId,
+ session_id: identity.sessionId,
+ session_incarnation_id: identity.sessionIncarnationId,
+ });
}
- async getQueueStatus(sessionId: string): Promise<{ count: number; auto_run: boolean }> {
- return this.wsRequest("message.queue.get", { session_id: sessionId });
+ async getQueueStatus(
+ identity: QueueSessionIdentityInput,
+ ): Promise<{ count: number; auto_run: boolean; auto_merge_enabled: boolean }> {
+ return this.wsRequest("message.queue.get", {
+ task_id: identity.taskId,
+ session_id: identity.sessionId,
+ session_incarnation_id: identity.sessionIncarnationId,
+ });
}
async setQueueAutoRun(
- sessionId: string,
+ identity: QueueSessionIdentityInput,
enabled: boolean,
): Promise<{ session_id: string; auto_run: boolean; dispatched: boolean }> {
return this.wsRequest("message.queue.auto_run.set", {
- session_id: sessionId,
+ task_id: identity.taskId,
+ session_id: identity.sessionId,
+ session_incarnation_id: identity.sessionIncarnationId,
enabled,
});
}
diff --git a/apps/web/e2e/tests/chat/message-queue-scroll-helpers.ts b/apps/web/e2e/tests/chat/message-queue-scroll-helpers.ts
index f4f19885a0..eb7fdf97ca 100644
--- a/apps/web/e2e/tests/chat/message-queue-scroll-helpers.ts
+++ b/apps/web/e2e/tests/chat/message-queue-scroll-helpers.ts
@@ -28,11 +28,11 @@ export async function seedFullQueueTask(
state: "IDLE",
agentProfileId: seedData.agentProfileId,
});
+ const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, sessionId);
for (let index = 0; index < FULL_QUEUE_SIZE; index++) {
await apiClient.queueMessage(
- task.id,
- sessionId,
+ queueIdentity,
`Queued item ${index + 1}\nSecond line keeps every queue row realistically tall.`,
);
}
diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts
index bacbf80464..54a54eb843 100644
--- a/apps/web/e2e/tests/chat/message-queue.spec.ts
+++ b/apps/web/e2e/tests/chat/message-queue.spec.ts
@@ -196,8 +196,9 @@ async function queueMessages(
sessionId: string,
messages: string[],
): Promise {
+ const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
for (const message of messages) {
- await apiClient.queueMessage(taskId, sessionId, message);
+ await apiClient.queueMessage(identity, message);
}
}
@@ -257,10 +258,11 @@ test.describe("Task session queue", () => {
state: "RUNNING",
agentProfileId: seedData.agentProfileId,
});
+ const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, sessionId);
await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true });
- await apiClient.queueMessage(task.id, sessionId, "automatic first");
- await apiClient.queueMessage(task.id, sessionId, "automatic second");
+ await apiClient.queueMessage(queueIdentity, "automatic first");
+ await apiClient.queueMessage(queueIdentity, "automatic second");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
@@ -268,12 +270,19 @@ test.describe("Task session queue", () => {
const panel = session.activeChat().getByTestId("queued-ghost-list");
const entries = panel.getByTestId("queue-entry-text");
await expect(entries).toHaveCount(1);
+ const autoMerge = panel.getByTestId("queue-auto-merge");
+ await expect(autoMerge).toHaveAttribute("data-state", "checked");
+ await autoMerge.click();
+ await expect(autoMerge).toHaveAttribute("data-state", "unchecked");
+
await expect(entries.first()).toContainText("automatic first");
await expect(entries.first()).toContainText("automatic second");
await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: false });
- await apiClient.queueMessage(task.id, sessionId, "separate third");
- await apiClient.queueMessage(task.id, sessionId, "separate fourth");
+ await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true });
+ await expect(autoMerge).toHaveAttribute("data-state", "unchecked");
+ await apiClient.queueMessage(queueIdentity, "separate third");
+ await apiClient.queueMessage(queueIdentity, "separate fourth");
await expect(entries).toHaveCount(3, { timeout: 10_000 });
await expect(entries.nth(1)).toHaveText("separate third");
await expect(entries.nth(2)).toHaveText("separate fourth");
@@ -295,7 +304,8 @@ test.describe("Task session queue", () => {
// isolation). Enabling automatic merge must fold the next compatible
// message into the tail instead of rejecting it as "queue full".
await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true });
- await apiClient.queueMessage(taskId, sessionId, "folded while full");
+ const queueIdentity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
+ await apiClient.queueMessage(queueIdentity, "folded while full");
const chat = session.activeChat();
const chip = chat.getByTestId("queue-chip");
@@ -789,12 +799,13 @@ test.describe("Queued row controls", () => {
.first();
await expect(chatMaximize).toBeVisible();
await chatMaximize.click();
- const autoRunResponse = await apiClient.setQueueAutoRun(sessionId, false);
+ const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
+ const autoRunResponse = await apiClient.setQueueAutoRun(identity, false);
expect(autoRunResponse).toMatchObject({ session_id: sessionId, auto_run: false });
await waitForComposerQueueMode(testPage);
- await apiClient.queueMessage(taskId, sessionId, fixture);
+ await apiClient.queueMessage(identity, fixture);
await expect
- .poll(() => apiClient.getQueueStatus(sessionId))
+ .poll(() => apiClient.getQueueStatus(identity))
.toMatchObject({ count: 1, auto_run: false });
const chat = session.activeChat();
diff --git a/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts b/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts
index 60978f2e78..0710b7e26c 100644
--- a/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts
+++ b/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts
@@ -88,7 +88,8 @@ test.describe.serial("Claude mid-turn steering experiment", () => {
);
await waitForActiveSessionSupportsSteering(testPage, true);
- await apiClient.queueMessage(taskId, sessionId, "already queued");
+ const queueIdentity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
+ await apiClient.queueMessage(queueIdentity, "already queued");
const chat = session.activeChat();
await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 });
diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts
index 783c366ea9..5248647338 100644
--- a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts
@@ -141,12 +141,13 @@ test("mobile Send Now resumes Auto-run in targeted order without overflow", asyn
const markerA = "mobile targeted A response";
const markerB = "mobile targeted B response";
const markerC = "mobile targeted C response";
+ const queueIdentity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
for (const message of [
scriptedQueueMessage(markerA),
scriptedQueueMessage(markerB, 1_000),
scriptedQueueMessage(markerC),
]) {
- await apiClient.queueMessage(taskId, sessionId, message);
+ await apiClient.queueMessage(queueIdentity, message);
}
await chat.getByTestId("queue-chip").tap();
@@ -154,11 +155,23 @@ test("mobile Send Now resumes Auto-run in targeted order without overflow", asyn
await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3);
const rowSendNow = panel.getByTestId("queue-entry-send-now").nth(1);
const autoRun = panel.getByTestId("queue-auto-run");
+ const autoMerge = panel.getByTestId("queue-auto-merge");
await expectTouchTarget(rowSendNow);
await expectEffectiveTouchTarget(autoRun);
+ await expectEffectiveTouchTarget(autoMerge);
await expect(autoRun).toHaveAttribute("data-state", "checked");
await autoRun.tap();
await expect(autoRun).toHaveAttribute("data-state", "unchecked");
+ await expect(autoMerge).toHaveAttribute("data-state", "unchecked");
+ await expect(autoMerge).toBeEnabled();
+ await autoMerge.tap();
+ await expect
+ .poll(async () => (await apiClient.getQueueStatus(queueIdentity)).auto_merge_enabled)
+ .toBe(true);
+ await expect(autoMerge).toHaveAttribute("data-state", "checked");
+ await expect(autoMerge).toBeEnabled();
+ await autoMerge.tap();
+ await expect(autoMerge).toHaveAttribute("data-state", "unchecked");
await assertNoDocumentHorizontalOverflow(testPage);
@@ -198,6 +211,7 @@ test("mobile queue panel hides the desktop-only pin and keeps its controls", asy
// queue panel, while the other header controls stay touch-sized.
await expect(panel.getByTestId("queue-pin")).toHaveCount(0);
await expectEffectiveTouchTarget(panel.getByTestId("queue-auto-run"));
+ await expectEffectiveTouchTarget(panel.getByTestId("queue-auto-merge"));
await expectTouchTarget(panel.getByTestId("queue-clear-all"));
await expectTouchTarget(panel.getByTestId("queue-close"));
});
@@ -233,15 +247,16 @@ test.describe("Mobile queued row controls", () => {
await session.sendMessageViaButton("/sleep 60");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionForegroundActivity(testPage, "generating");
- const autoRunResponse = await apiClient.setQueueAutoRun(task.session_id, false);
+ const identity = await apiClient.getQueueSessionIdentity(task.id, task.session_id);
+ const autoRunResponse = await apiClient.setQueueAutoRun(identity, false);
expect(autoRunResponse).toMatchObject({
session_id: task.session_id,
auto_run: false,
});
await waitForComposerQueueMode(testPage);
- await apiClient.queueMessage(task.id, task.session_id, fixture);
+ await apiClient.queueMessage(identity, fixture);
await expect
- .poll(() => apiClient.getQueueStatus(task.session_id!))
+ .poll(() => apiClient.getQueueStatus(identity))
.toMatchObject({ count: 1, auto_run: false });
const chat = session.activeChat();
diff --git a/apps/web/e2e/tests/ssh/launch-task.spec.ts b/apps/web/e2e/tests/ssh/launch-task.spec.ts
index 59dba48866..d4f10bdf5a 100644
--- a/apps/web/e2e/tests/ssh/launch-task.spec.ts
+++ b/apps/web/e2e/tests/ssh/launch-task.spec.ts
@@ -347,7 +347,8 @@ printf 'prepared\\n' > "$workspace/custom-prepare-marker"
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
- await apiClient.queueMessage(task.id, task.session_id, "Inspect the follow-up file.", [
+ const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, task.session_id);
+ await apiClient.queueMessage(queueIdentity, "Inspect the follow-up file.", [
{
type: "resource",
data: Buffer.from(content, "utf8").toString("base64"),
diff --git a/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts
index a25c669d2d..837ade2931 100644
--- a/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts
@@ -24,7 +24,8 @@ test.describe("Mobile sidebar — queued prompt count", () => {
state: "IDLE",
agentProfileId: seedData.agentProfileId,
});
- await apiClient.queueMessage(task.id, sessionId, "Mobile queued prompt");
+ const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, sessionId);
+ await apiClient.queueMessage(queueIdentity, "Mobile queued prompt");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
diff --git a/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts b/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts
index ebe39b9e47..fb3c5f68f5 100644
--- a/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts
+++ b/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts
@@ -39,8 +39,9 @@ test.describe("Sidebar queued prompt count", () => {
state: "IDLE",
agentProfileId: seedData.agentProfileId,
});
+ const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, sessionId);
for (let index = 0; index < 3; index++) {
- await apiClient.queueMessage(task.id, sessionId, `Queued prompt ${index + 1}`);
+ await apiClient.queueMessage(queueIdentity, `Queued prompt ${index + 1}`);
}
await testPage.goto(`/t/${task.id}`);
@@ -60,7 +61,7 @@ test.describe("Sidebar queued prompt count", () => {
// Clearing the queue must remove the badge without a reload (live path
// through the status-summary broadcast).
- await apiClient.clearQueue(sessionId);
+ await apiClient.clearQueue(queueIdentity);
await expect(badge).not.toBeVisible({ timeout: 10_000 });
});
@@ -95,7 +96,8 @@ test.describe("Sidebar queued prompt count", () => {
state: "IDLE",
agentProfileId: seedData.agentProfileId,
});
- await apiClient.queueMessage(child.id, sessionId, "Subtask queued prompt");
+ const queueIdentity = await apiClient.getQueueSessionIdentity(child.id, sessionId);
+ await apiClient.queueMessage(queueIdentity, "Subtask queued prompt");
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
diff --git a/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts b/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts
index 02bed95a2e..cc1d4fcf1e 100644
--- a/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts
+++ b/apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts
@@ -11,7 +11,7 @@ test.describe("Workflow queued messages", () => {
apiClient,
seedData,
}) => {
- const { session, sessionId } = await seedQueuedWorkflowMessageScenario(
+ const { session, queueIdentity } = await seedQueuedWorkflowMessageScenario(
testPage,
apiClient,
seedData,
@@ -19,6 +19,6 @@ test.describe("Workflow queued messages", () => {
);
await expectWorkflowQueueBadge(session);
- await expectDeliveredWorkflowMessage(apiClient, session, sessionId);
+ await expectDeliveredWorkflowMessage(apiClient, session, queueIdentity);
});
});
diff --git a/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts b/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts
index 3fb4d72a81..929c1d94e2 100644
--- a/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts
+++ b/apps/web/e2e/tests/workflow/workflow-queue-helpers.ts
@@ -1,11 +1,12 @@
import { type Page, expect } from "@playwright/test";
-import type { ApiClient } from "../../helpers/api-client";
+import type { ApiClient, QueueSessionIdentityInput } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
type QueuedWorkflowScenario = {
session: SessionPage;
sessionId: string;
+ queueIdentity: QueueSessionIdentityInput;
};
const WORKFLOW_REVIEW_STEP = "Review";
@@ -61,7 +62,8 @@ export async function seedQueuedWorkflowMessageScenario(
await apiClient.moveTask(task.id, workflow.id, reviewStep.id);
- return { session, sessionId: task.session_id };
+ const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, task.session_id);
+ return { session, sessionId: task.session_id, queueIdentity };
}
export async function expectWorkflowQueueBadge(session: SessionPage) {
@@ -79,15 +81,15 @@ export async function expectWorkflowQueueBadge(session: SessionPage) {
export async function expectDeliveredWorkflowMessage(
apiClient: ApiClient,
session: SessionPage,
- sessionId: string,
+ queueIdentity: QueueSessionIdentityInput,
) {
const chat = session.activeChat();
await expect
.poll(
async () => {
const [queue, { messages }] = await Promise.all([
- apiClient.getQueueStatus(sessionId),
- apiClient.listSessionMessages(sessionId),
+ apiClient.getQueueStatus(queueIdentity),
+ apiClient.listSessionMessages(queueIdentity.sessionId),
]);
const workflowMessageExists = messages.some(
(message) =>
diff --git a/apps/web/hooks/domains/comments/use-run-comment.test.ts b/apps/web/hooks/domains/comments/use-run-comment.test.ts
index 8666bdcccf..876b1d8297 100644
--- a/apps/web/hooks/domains/comments/use-run-comment.test.ts
+++ b/apps/web/hooks/domains/comments/use-run-comment.test.ts
@@ -55,8 +55,11 @@ function makeStoreState(sessionState: string, planMode = false, foregroundActivi
return {
taskSessions: {
items: {
- "sess-1": { state: sessionState, foreground_activity: foregroundActivity },
- "other-session": { state: "RUNNING", foreground_activity: "generating" },
+ "sess-1": {
+ state: sessionState,
+ foreground_activity: foregroundActivity,
+ queue_incarnation_id: "inc-1",
+ },
},
},
chatInput: {
diff --git a/apps/web/hooks/domains/comments/use-run-comment.ts b/apps/web/hooks/domains/comments/use-run-comment.ts
index fde457be7d..e212060ffa 100644
--- a/apps/web/hooks/domains/comments/use-run-comment.ts
+++ b/apps/web/hooks/domains/comments/use-run-comment.ts
@@ -68,6 +68,7 @@ type UseRunCommentParams = {
type QueuePayload = {
session_id: string;
task_id: string;
+ session_incarnation_id: string;
content: string;
plan_mode?: boolean;
};
@@ -84,10 +85,16 @@ type MessagePayload = {
function buildQueuePayload(
sessionId: string,
taskId: string,
+ sessionIncarnationId: string,
content: string,
planModeEnabled: boolean,
): QueuePayload {
- const payload: QueuePayload = { session_id: sessionId, task_id: taskId, content };
+ const payload: QueuePayload = {
+ session_id: sessionId,
+ session_incarnation_id: sessionIncarnationId,
+ task_id: taskId,
+ content,
+ };
if (planModeEnabled) payload.plan_mode = true;
return payload;
}
@@ -141,7 +148,18 @@ export function useRunComment({ sessionId, taskId }: UseRunCommentParams) {
throw new Error("Session is not available for input");
}
if (inputMode === "queue") {
- await appendToQueue(buildQueuePayload(sessionId, taskId, content, planModeEnabled));
+ if (!activeSession?.queue_incarnation_id) {
+ throw new Error("Session is not available for input");
+ }
+ await appendToQueue(
+ buildQueuePayload(
+ sessionId,
+ taskId,
+ activeSession.queue_incarnation_id,
+ content,
+ planModeEnabled,
+ ),
+ );
} else {
const client = getWebSocketClient();
if (!client) throw new Error("WebSocket client unavailable");
diff --git a/apps/web/hooks/domains/review/use-send-finding-to-agent.ts b/apps/web/hooks/domains/review/use-send-finding-to-agent.ts
index 064e595cd5..6765855624 100644
--- a/apps/web/hooks/domains/review/use-send-finding-to-agent.ts
+++ b/apps/web/hooks/domains/review/use-send-finding-to-agent.ts
@@ -7,7 +7,7 @@ import { useToast } from "@/components/toast-provider";
import { appendToQueue } from "@/lib/api/domains/queue-api";
import { formatFindingAsMarkdown } from "@/lib/review/format";
import type { TaskReviewFinding } from "@/lib/types/review";
-import type { Message } from "@/lib/types/http";
+import type { Message, TaskSession } from "@/lib/types/http";
import { getWebSocketClient } from "@/lib/ws/connection";
import { generateUUID } from "@/lib/utils";
@@ -20,6 +20,42 @@ function isAgentBusy(state: string | undefined): boolean {
return state === "STARTING" || state === "RUNNING";
}
+async function queueFinding(
+ taskId: string,
+ session: TaskSession | null,
+ sessionId: string,
+ content: string,
+ planMode: boolean,
+) {
+ if (!session?.queue_incarnation_id) {
+ throw new Error("Session is not available for input");
+ }
+ await appendToQueue({
+ session_id: sessionId,
+ session_incarnation_id: session.queue_incarnation_id,
+ task_id: taskId,
+ content,
+ ...(planMode ? { plan_mode: true } : {}),
+ });
+}
+
+async function sendFinding(taskId: string, sessionId: string, content: string, planMode: boolean) {
+ const client = getWebSocketClient();
+ if (!client) throw new Error("WebSocket client unavailable");
+ return client.request(
+ "message.add",
+ {
+ task_id: taskId,
+ session_id: sessionId,
+ client_message_id: generateUUID(),
+ content,
+ has_review_comments: true,
+ ...(planMode ? { plan_mode: true } : {}),
+ },
+ 10000,
+ );
+}
+
/**
* Sends a finding to the active session as follow-up context.
*
@@ -42,31 +78,11 @@ export function useSendFindingToAgent({ taskId, sessionId }: Params) {
try {
if (isAgentBusy(session?.state)) {
- await appendToQueue({
- session_id: sessionId,
- task_id: taskId,
- content,
- ...(planMode ? { plan_mode: true } : {}),
- });
+ await queueFinding(taskId, session, sessionId, content, planMode);
toast({ title: t("review:findingQueuedForAgent"), variant: "success" });
return;
}
- const client = getWebSocketClient();
- if (!client) throw new Error("WebSocket client unavailable");
- const created = await client.request(
- "message.add",
- {
- task_id: taskId,
- session_id: sessionId,
- client_message_id: generateUUID(),
- content,
- has_review_comments: true,
- ...(planMode ? { plan_mode: true } : {}),
- },
- 10000,
- );
- // Add the returned message directly so the chat updates even if the
- // broadcast is missed; addMessage is idempotent on id.
+ const created = await sendFinding(taskId, sessionId, content, planMode);
if (created?.id && created.session_id) storeApi.getState().addMessage(created);
toast({ title: t("review:findingSentToAgent"), variant: "success" });
} catch (error) {
diff --git a/apps/web/hooks/domains/session/use-queue-admission.ts b/apps/web/hooks/domains/session/use-queue-admission.ts
new file mode 100644
index 0000000000..65645394a7
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-queue-admission.ts
@@ -0,0 +1,73 @@
+import { useCallback } from "react";
+import { queueMessage } from "@/lib/api/domains/queue-api";
+import type { QueueMessageParams, QueueSessionIdentity } from "@/lib/api/domains/queue-api";
+import type { QueueOperationToken, SessionSlice } from "@/lib/state/slices/session/types";
+import type { QueueRefetch } from "./use-queue-refetch";
+import type { QueueMessageInput } from "./use-queue-types";
+
+type BeginQueueOperation = SessionSlice["beginQueueOperation"];
+type FinishQueueOperation = SessionSlice["finishQueueOperation"];
+
+async function queueWithTerminalRefetch(
+ params: QueueMessageParams,
+ refetch: QueueRefetch,
+ token: QueueOperationToken,
+): Promise {
+ let mutationError: unknown;
+ try {
+ await queueMessage(params);
+ } catch (error) {
+ mutationError = error;
+ }
+ try {
+ await refetch(params.session_id, token);
+ } catch (reconcileError) {
+ if (mutationError) throw mutationError;
+ throw reconcileError;
+ }
+ if (mutationError) throw mutationError;
+}
+
+export function useQueueAdmissionAction(
+ identity: QueueSessionIdentity | null,
+ beginQueueOperation: BeginQueueOperation,
+ finishQueueOperation: FinishQueueOperation,
+ refetch: QueueRefetch,
+) {
+ return useCallback(
+ async ({
+ taskId,
+ content,
+ model,
+ planMode,
+ attachments,
+ entityReferences,
+ contextFilesMeta,
+ }: QueueMessageInput) => {
+ if (!identity || identity.task_id !== taskId) return;
+ const { session_id: sessionId, session_incarnation_id: incarnationId } = identity;
+ const token = beginQueueOperation(sessionId, incarnationId);
+ if (!token) return;
+ try {
+ await queueWithTerminalRefetch(
+ {
+ session_id: sessionId,
+ session_incarnation_id: incarnationId,
+ task_id: taskId,
+ content,
+ model,
+ plan_mode: planMode,
+ attachments,
+ entity_references: entityReferences,
+ ...(contextFilesMeta ? { context_files: contextFilesMeta } : {}),
+ },
+ refetch,
+ token,
+ );
+ } finally {
+ finishQueueOperation(sessionId, token);
+ }
+ },
+ [beginQueueOperation, finishQueueOperation, identity, refetch],
+ );
+}
diff --git a/apps/web/hooks/domains/session/use-queue-refetch.ts b/apps/web/hooks/domains/session/use-queue-refetch.ts
new file mode 100644
index 0000000000..29f6c06fbb
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-queue-refetch.ts
@@ -0,0 +1,85 @@
+import { useCallback, useRef } from "react";
+import { getQueueStatus } from "@/lib/api/domains/queue-api";
+import type { QueueSessionIdentity } from "@/lib/api/domains/queue-api";
+import type {
+ QueueOperationToken,
+ QueueStatus,
+ SessionSlice,
+} from "@/lib/state/slices/session/types";
+
+type BeginQueueOperation = SessionSlice["beginQueueOperation"];
+type FinishQueueOperation = SessionSlice["finishQueueOperation"];
+type SetQueueEntries = SessionSlice["setQueueEntries"];
+
+export type QueueRefetch = (sessionId: string, token?: QueueOperationToken) => Promise;
+
+function queueStatusMatchesIdentity(status: QueueStatus, identity: QueueSessionIdentity): boolean {
+ return (
+ status.task_id === identity.task_id &&
+ status.session_id === identity.session_id &&
+ status.session_incarnation_id === identity.session_incarnation_id
+ );
+}
+
+function queueStatusMeta(status: QueueStatus) {
+ return {
+ count: status.count,
+ max: status.max,
+ mergeEnabled: status.merge_enabled ?? true,
+ autoRun: status.auto_run ?? true,
+ taskId: status.task_id,
+ sessionIncarnationId: status.session_incarnation_id,
+ ...(status.status_epoch !== undefined ? { statusEpoch: status.status_epoch } : {}),
+ ...(status.status_generation !== undefined
+ ? { statusGeneration: status.status_generation }
+ : {}),
+ ...(status.auto_merge_available !== undefined
+ ? { autoMergeAvailable: status.auto_merge_available }
+ : {}),
+ ...(status.auto_merge_enabled !== undefined
+ ? { autoMergeEnabled: status.auto_merge_enabled }
+ : {}),
+ ...(status.auto_merge_source !== undefined
+ ? { autoMergeSource: status.auto_merge_source }
+ : {}),
+ ...(status.auto_merge_revision !== undefined
+ ? { autoMergeRevision: status.auto_merge_revision }
+ : {}),
+ };
+}
+
+/** Fetch and reconcile one immutable session's authoritative queue snapshot. */
+export function useQueueRefetch(
+ identity: QueueSessionIdentity | null,
+ setQueueEntries: SetQueueEntries,
+ beginQueueOperation: BeginQueueOperation,
+ finishQueueOperation: FinishQueueOperation,
+) {
+ const refetchVersion = useRef>({});
+ const invalidate = useCallback((sessionId: string) => {
+ refetchVersion.current[sessionId] = (refetchVersion.current[sessionId] ?? 0) + 1;
+ }, []);
+ const refetch = useCallback(
+ async (sessionId, callerToken) => {
+ if (!identity || identity.session_id !== sessionId) return;
+ const token =
+ callerToken ?? beginQueueOperation(identity.session_id, identity.session_incarnation_id);
+ if (!token) return;
+ const ownsToken = callerToken === undefined;
+ const version = (refetchVersion.current[sessionId] ?? 0) + 1;
+ refetchVersion.current[sessionId] = version;
+ try {
+ const status = await getQueueStatus(identity);
+ if (refetchVersion.current[sessionId] !== version) return;
+ if (!queueStatusMatchesIdentity(status, identity)) return;
+ setQueueEntries(sessionId, status.entries ?? [], queueStatusMeta(status), {
+ establishStatusEpoch: true,
+ });
+ } finally {
+ if (ownsToken) finishQueueOperation(sessionId, token);
+ }
+ },
+ [beginQueueOperation, finishQueueOperation, identity, setQueueEntries],
+ );
+ return { refetch, invalidate };
+}
diff --git a/apps/web/hooks/domains/session/use-queue-reorder.test.ts b/apps/web/hooks/domains/session/use-queue-reorder.test.ts
new file mode 100644
index 0000000000..f756a8b7c7
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-queue-reorder.test.ts
@@ -0,0 +1,200 @@
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Mock } from "vitest";
+import type { QueuedMessage, QueueOperationToken } from "@/lib/state/slices/session/types";
+
+const queueApiMock = vi.hoisted(() => {
+ class QueueEntryNotFoundError extends Error {}
+ class QueueSendNowError extends Error {}
+ class QueueReorderError extends Error {}
+ return {
+ QueueEntryNotFoundError,
+ QueueSendNowError,
+ QueueReorderError,
+ queueMessage: vi.fn(),
+ clearQueue: vi.fn(),
+ getQueueStatus: vi.fn(),
+ updateQueuedMessage: vi.fn(),
+ removeQueuedEntry: vi.fn(),
+ mergeQueuedEntry: vi.fn(),
+ reorderQueuedEntries: vi.fn(),
+ sendQueuedNow: vi.fn(),
+ setQueueAutoRun: vi.fn(),
+ setQueueAutoMerge: vi.fn(),
+ };
+});
+
+type MockQueueState = {
+ queue: {
+ bySessionId: Record;
+ metaBySessionId: Record<
+ string,
+ { count: number; max: number; mergeEnabled?: boolean; autoRun?: boolean }
+ >;
+ activeOperationBySessionId: Record;
+ };
+ connection: { status: string };
+ taskSessions: {
+ items: Record;
+ };
+ setQueueEntries: Mock;
+ removeQueueEntry: Mock;
+ beginQueueOperation: Mock;
+ finishQueueOperation: Mock;
+};
+
+let mockState: MockQueueState;
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: MockQueueState) => unknown) => selector(mockState),
+}));
+
+vi.mock("@/lib/api/domains/queue-api", () => queueApiMock);
+
+import { useQueue } from "./use-queue";
+
+const SESSION_ID = "sess-1";
+const TASK_ID = "task-1";
+const IDENTITY = {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ session_incarnation_id: "incarnation-1",
+};
+
+function entry(overrides: Partial = {}): QueuedMessage {
+ return {
+ id: "q-1",
+ session_id: SESSION_ID,
+ task_id: TASK_ID,
+ content: "queued prompt",
+ plan_mode: false,
+ queued_at: "2026-06-27T00:00:00Z",
+ queued_by: "user",
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mockState = {
+ queue: {
+ bySessionId: {},
+ metaBySessionId: {},
+ activeOperationBySessionId: {},
+ },
+ connection: { status: "connected" },
+ taskSessions: {
+ items: {
+ [SESSION_ID]: {
+ task_id: TASK_ID,
+ queue_incarnation_id: IDENTITY.session_incarnation_id,
+ },
+ },
+ },
+ setQueueEntries: vi.fn(),
+ removeQueueEntry: vi.fn(),
+ beginQueueOperation: vi
+ .fn()
+ .mockReturnValue({ sessionIncarnationId: IDENTITY.session_incarnation_id, generation: 1 }),
+ finishQueueOperation: vi.fn(),
+ };
+ queueApiMock.getQueueStatus.mockResolvedValue({ ...IDENTITY, entries: [], count: 0, max: 10 });
+ queueApiMock.reorderQueuedEntries.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("useQueue reorderEntries", () => {
+ it("optimistically reorders the store and refetches the authoritative queue", async () => {
+ const first = entry({ id: "q-1", content: "first" });
+ const second = entry({ id: "q-2", content: "second" });
+ mockState.queue.bySessionId[SESSION_ID] = [first, second];
+ mockState.queue.metaBySessionId[SESSION_ID] = { count: 2, max: 10 };
+ queueApiMock.getQueueStatus.mockResolvedValue({
+ ...IDENTITY,
+ entries: [first, second],
+ count: 2,
+ max: 10,
+ });
+ queueApiMock.reorderQueuedEntries.mockResolvedValue({
+ session_id: SESSION_ID,
+ reordered: 2,
+ });
+
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+
+ await act(async () => {
+ await result.current.reorderEntries([second.id, first.id]);
+ });
+
+ expect(queueApiMock.reorderQueuedEntries).toHaveBeenCalledWith({
+ session_id: SESSION_ID,
+ task_id: TASK_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
+ ordered_ids: [second.id, first.id],
+ });
+ expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [second, first], {
+ count: 2,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ });
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ });
+
+ it("refetches and rethrows when the reorder fails", async () => {
+ const first = entry({ id: "q-1", content: "first" });
+ const second = entry({ id: "q-2", content: "second" });
+ queueApiMock.getQueueStatus.mockResolvedValue({
+ ...IDENTITY,
+ entries: [first, second],
+ count: 2,
+ max: 10,
+ });
+ queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new Error("reorder failed"));
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+ queueApiMock.getQueueStatus.mockResolvedValueOnce({
+ ...IDENTITY,
+ entries: [first, second],
+ count: 2,
+ max: 10,
+ });
+
+ await act(async () => {
+ await expect(result.current.reorderEntries([second.id, first.id])).rejects.toThrow(
+ "reorder failed",
+ );
+ });
+
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ });
+
+ it("refetches and rethrows a QueueReorderError so the panel can swallow it", async () => {
+ const first = entry({ id: "q-1", content: "first" });
+ queueApiMock.getQueueStatus.mockResolvedValue({
+ ...IDENTITY,
+ entries: [first],
+ count: 1,
+ max: 10,
+ });
+ queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new queueApiMock.QueueReorderError());
+
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+
+ await act(async () => {
+ await expect(result.current.reorderEntries([first.id])).rejects.toThrow(
+ queueApiMock.QueueReorderError,
+ );
+ });
+
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-queue-types.ts b/apps/web/hooks/domains/session/use-queue-types.ts
new file mode 100644
index 0000000000..278c356e1b
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-queue-types.ts
@@ -0,0 +1,22 @@
+import type { QueueMessageParams } from "@/lib/api/domains/queue-api";
+import type { EntityReference } from "@/lib/types/entity-reference";
+
+export type MessageAttachment = {
+ type: string;
+ data?: string;
+ attachment_id?: string;
+ mime_type: string;
+ name?: string;
+ size_bytes?: number;
+ delivery_mode?: "prompt" | "path";
+};
+
+export type QueueMessageInput = {
+ taskId: string;
+ content: string;
+ model?: string;
+ planMode?: boolean;
+ attachments?: MessageAttachment[];
+ entityReferences?: EntityReference[];
+ contextFilesMeta?: QueueMessageParams["context_files"];
+};
diff --git a/apps/web/hooks/domains/session/use-queue.test.ts b/apps/web/hooks/domains/session/use-queue.test.ts
index 9a568c5ce5..d3c14ca415 100644
--- a/apps/web/hooks/domains/session/use-queue.test.ts
+++ b/apps/web/hooks/domains/session/use-queue.test.ts
@@ -1,6 +1,6 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { QueuedMessage } from "@/lib/state/slices/session/types";
+import type { QueuedMessage, QueueOperationToken } from "@/lib/state/slices/session/types";
import type { EntityReference } from "@/lib/types/entity-reference";
const queueApiMock = vi.hoisted(() => {
@@ -20,6 +20,7 @@ const queueApiMock = vi.hoisted(() => {
reorderQueuedEntries: vi.fn(),
sendQueuedNow: vi.fn(),
setQueueAutoRun: vi.fn(),
+ setQueueAutoMerge: vi.fn(),
};
});
@@ -30,13 +31,23 @@ type MockQueueState = {
string,
{ count: number; max: number; mergeEnabled?: boolean; autoRun?: boolean }
>;
- isLoading: Record;
+ activeOperationBySessionId: Record;
};
connection: { status: string };
- taskSessions: { items: Record };
+ taskSessions: {
+ items: Record<
+ string,
+ {
+ task_id?: string;
+ queue_incarnation_id?: string;
+ cancellation_pending?: boolean;
+ }
+ >;
+ };
setQueueEntries: ReturnType;
removeQueueEntry: ReturnType;
- setQueueLoading: ReturnType;
+ beginQueueOperation: ReturnType;
+ finishQueueOperation: ReturnType;
};
let mockState: MockQueueState;
@@ -51,6 +62,11 @@ import { useQueue } from "./use-queue";
const SESSION_ID = "sess-1";
const TASK_ID = "task-1";
+const IDENTITY = {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ session_incarnation_id: "incarnation-1",
+};
const reference: EntityReference = {
version: 1,
ref: "mention:v1:github:issue:acme%2Frepo:42",
@@ -88,28 +104,38 @@ function resetMockState() {
queue: {
bySessionId: {},
metaBySessionId: {},
- isLoading: {},
+ activeOperationBySessionId: {},
},
connection: { status: "connected" },
- taskSessions: { items: {} },
+ taskSessions: {
+ items: {
+ [SESSION_ID]: {
+ task_id: TASK_ID,
+ queue_incarnation_id: IDENTITY.session_incarnation_id,
+ },
+ },
+ },
setQueueEntries: vi.fn(),
removeQueueEntry: vi.fn(),
- setQueueLoading: vi.fn(),
+ beginQueueOperation: vi
+ .fn()
+ .mockReturnValue({ sessionIncarnationId: IDENTITY.session_incarnation_id, generation: 1 }),
+ finishQueueOperation: vi.fn(),
};
}
+beforeEach(() => {
+ resetMockState();
+ setDocumentVisibility("visible");
+ queueApiMock.getQueueStatus.mockResolvedValue({ ...IDENTITY, entries: [], count: 0, max: 10 });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
describe("useQueue", () => {
- beforeEach(() => {
- resetMockState();
- setDocumentVisibility("visible");
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
- });
-
- afterEach(() => {
- cleanup();
- vi.clearAllMocks();
- });
-
it("refetches the queue snapshot when the WebSocket reconnects", async () => {
mockState.connection.status = "disconnected";
const { rerender } = renderHook(() => useQueue(SESSION_ID));
@@ -120,19 +146,40 @@ describe("useQueue", () => {
mockState.connection.status = "connected";
rerender();
- await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID));
- expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [], {
- count: 0,
- max: 10,
- mergeEnabled: true,
- autoRun: true,
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
+ expect(mockState.setQueueEntries).toHaveBeenCalledWith(
+ SESSION_ID,
+ [],
+ {
+ count: 0,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: IDENTITY.session_incarnation_id,
+ },
+ { establishStatusEpoch: true },
+ );
+ });
+
+ it("rejects an identity-less queue snapshot after session identity is known", async () => {
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ mockState.setQueueEntries.mockClear();
+ queueApiMock.getQueueStatus.mockResolvedValueOnce({ entries: [entry()], count: 1, max: 10 });
+
+ await act(async () => {
+ await result.current.refetch();
});
+
+ expect(mockState.setQueueEntries).not.toHaveBeenCalled();
});
it("refetches a stale queue snapshot when a suspended tab becomes visible again", async () => {
mockState.queue.bySessionId[SESSION_ID] = [entry()];
mockState.queue.metaBySessionId[SESSION_ID] = { count: 1, max: 10 };
queueApiMock.getQueueStatus.mockResolvedValueOnce({
+ ...IDENTITY,
entries: [entry()],
count: 1,
max: 10,
@@ -143,17 +190,29 @@ describe("useQueue", () => {
queueApiMock.getQueueStatus.mockClear();
mockState.setQueueEntries.mockClear();
- queueApiMock.getQueueStatus.mockResolvedValueOnce({ entries: [], count: 0, max: 10 });
+ queueApiMock.getQueueStatus.mockResolvedValueOnce({
+ ...IDENTITY,
+ entries: [],
+ count: 0,
+ max: 10,
+ });
document.dispatchEvent(new Event("visibilitychange"));
- await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID));
- expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [], {
- count: 0,
- max: 10,
- mergeEnabled: true,
- autoRun: true,
- });
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
+ expect(mockState.setQueueEntries).toHaveBeenCalledWith(
+ SESSION_ID,
+ [],
+ {
+ count: 0,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: IDENTITY.session_incarnation_id,
+ },
+ { establishStatusEpoch: true },
+ );
});
it("refetches a stale queue snapshot when the Kandev window regains focus", async () => {
@@ -163,7 +222,7 @@ describe("useQueue", () => {
window.dispatchEvent(new Event("focus"));
- await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
});
it("does not refetch on foreground visibility while disconnected", async () => {
@@ -175,7 +234,9 @@ describe("useQueue", () => {
expect(queueApiMock.getQueueStatus).not.toHaveBeenCalled();
});
+});
+describe("useQueue message metadata", () => {
it("queues structured references with busy-agent messages", async () => {
queueApiMock.queueMessage.mockResolvedValue(entry());
const { result } = renderHook(() => useQueue(SESSION_ID));
@@ -192,6 +253,7 @@ describe("useQueue", () => {
expect(queueApiMock.queueMessage).toHaveBeenCalledWith({
session_id: SESSION_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
task_id: TASK_ID,
content: "queued reference",
model: undefined,
@@ -201,6 +263,22 @@ describe("useQueue", () => {
});
});
+ it("refetches authoritative status after a queue admission error", async () => {
+ const mutationError = new Error("queue failed");
+ queueApiMock.queueMessage.mockRejectedValueOnce(mutationError);
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+
+ await act(async () => {
+ await expect(result.current.queue({ taskId: TASK_ID, content: "rejected" })).rejects.toBe(
+ mutationError,
+ );
+ });
+
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ });
+
it("replaces queued reference metadata with an explicit empty array", async () => {
queueApiMock.updateQueuedMessage.mockResolvedValue({ entry_id: "q-1" });
const { result } = renderHook(() => useQueue(SESSION_ID));
@@ -212,6 +290,8 @@ describe("useQueue", () => {
expect(queueApiMock.updateQueuedMessage).toHaveBeenCalledWith({
session_id: SESSION_ID,
+ task_id: TASK_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
entry_id: "q-1",
content: "reference removed",
attachments: undefined,
@@ -221,17 +301,6 @@ describe("useQueue", () => {
});
describe("useQueue context file metadata and Send Now", () => {
- beforeEach(() => {
- resetMockState();
- setDocumentVisibility("visible");
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
- });
-
- afterEach(() => {
- cleanup();
- vi.clearAllMocks();
- });
-
it("forwards context file metadata with queued messages", async () => {
queueApiMock.queueMessage.mockResolvedValue(entry());
const { result } = renderHook(() => useQueue(SESSION_ID));
@@ -248,6 +317,7 @@ describe("useQueue context file metadata and Send Now", () => {
expect(queueApiMock.queueMessage).toHaveBeenCalledWith({
session_id: SESSION_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
task_id: TASK_ID,
content: "queued context",
model: undefined,
@@ -274,12 +344,16 @@ describe("useQueue context file metadata and Send Now", () => {
expect(queueApiMock.sendQueuedNow).toHaveBeenCalledWith({
session_id: SESSION_ID,
+ task_id: TASK_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
scope: "entry",
entry_id: "q-2",
});
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
+});
+describe("useQueue automation controls", () => {
it("refetches after an Auto-run mutation failure and preserves its error", async () => {
const mutationError = new Error("policy update failed");
queueApiMock.setQueueAutoRun.mockRejectedValueOnce(mutationError);
@@ -291,12 +365,16 @@ describe("useQueue context file metadata and Send Now", () => {
await expect(result.current.setAutoRun(false)).rejects.toBe(mutationError);
});
- expect(queueApiMock.setQueueAutoRun).toHaveBeenCalledWith(SESSION_ID, false);
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.setQueueAutoRun).toHaveBeenCalledWith(IDENTITY, false);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
it("exposes authoritative cancellation progress for disabling controls", async () => {
- mockState.taskSessions.items[SESSION_ID] = { cancellation_pending: true };
+ mockState.taskSessions.items[SESSION_ID] = {
+ task_id: TASK_ID,
+ queue_incarnation_id: IDENTITY.session_incarnation_id,
+ cancellation_pending: true,
+ };
const { result } = renderHook(() => useQueue(SESSION_ID));
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
@@ -323,18 +401,53 @@ describe("useQueue context file metadata and Send Now", () => {
await setAutoRun!(false);
});
- expect(queueApiMock.setQueueAutoRun).toHaveBeenCalledWith(SESSION_ID, false);
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.setQueueAutoRun).toHaveBeenCalledWith(IDENTITY, false);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ });
+ it("sets Auto-merge for the immutable session and refetches policy", async () => {
+ queueApiMock.getQueueStatus.mockResolvedValue({
+ ...IDENTITY,
+ entries: [],
+ count: 0,
+ max: 10,
+ ...IDENTITY,
+ merge_enabled: true,
+ auto_run: true,
+ auto_merge_available: true,
+ auto_merge_enabled: true,
+ auto_merge_source: "global",
+ auto_merge_revision: 4,
+ });
+ queueApiMock.setQueueAutoMerge.mockResolvedValue({
+ ...IDENTITY,
+ auto_merge_enabled: false,
+ auto_merge_source: "session",
+ auto_merge_revision: 1,
+ });
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(mockState.setQueueEntries).toHaveBeenCalled());
+ expect(mockState.setQueueEntries).toHaveBeenLastCalledWith(
+ SESSION_ID,
+ [],
+ expect.objectContaining({
+ autoMergeAvailable: true,
+ autoMergeEnabled: true,
+ autoMergeSource: "global",
+ autoMergeRevision: 4,
+ }),
+ { establishStatusEpoch: true },
+ );
+
+ await act(async () => {
+ await result.current.setAutoMerge(false);
+ });
+
+ expect(queueApiMock.setQueueAutoMerge).toHaveBeenCalledWith(IDENTITY, false);
+ expect(queueApiMock.getQueueStatus).toHaveBeenLastCalledWith(IDENTITY);
});
});
describe("useQueue mergeEntry", () => {
- beforeEach(() => {
- resetMockState();
- setDocumentVisibility("visible");
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
- });
-
it("merges an entry and refetches the queue", async () => {
queueApiMock.mergeQueuedEntry.mockResolvedValue({ entry_id: "q-1" });
const { result } = renderHook(() => useQueue(SESSION_ID));
@@ -347,9 +460,12 @@ describe("useQueue mergeEntry", () => {
expect(queueApiMock.mergeQueuedEntry).toHaveBeenCalledWith({
session_id: SESSION_ID,
+ task_id: TASK_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
entry_id: "q-2",
+ user_id: undefined,
});
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
it("forwards an explicit user_id on merge", async () => {
@@ -363,6 +479,8 @@ describe("useQueue mergeEntry", () => {
expect(queueApiMock.mergeQueuedEntry).toHaveBeenCalledWith({
session_id: SESSION_ID,
+ task_id: TASK_ID,
+ session_incarnation_id: IDENTITY.session_incarnation_id,
entry_id: "q-2",
user_id: "alice",
});
@@ -380,16 +498,13 @@ describe("useQueue mergeEntry", () => {
);
});
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
});
describe("useQueue clearAll", () => {
beforeEach(() => {
- resetMockState();
- setDocumentVisibility("visible");
queueApiMock.clearQueue.mockReset();
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
queueApiMock.clearQueue.mockResolvedValue(undefined);
});
@@ -402,8 +517,20 @@ describe("useQueue clearAll", () => {
await result.current.clearAll();
});
- expect(queueApiMock.clearQueue).toHaveBeenCalledWith(SESSION_ID);
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.clearQueue).toHaveBeenCalledWith(IDENTITY);
+ expect(mockState.setQueueEntries.mock.calls).toContainEqual([
+ SESSION_ID,
+ [],
+ {
+ count: 0,
+ max: 0,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: IDENTITY.session_incarnation_id,
+ },
+ ]);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
it("refetches authoritative status and rethrows when clear fails", async () => {
@@ -413,6 +540,7 @@ describe("useQueue clearAll", () => {
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
queueApiMock.getQueueStatus.mockClear();
queueApiMock.getQueueStatus.mockResolvedValueOnce({
+ ...IDENTITY,
entries: [authoritative],
count: 1,
max: 10,
@@ -422,13 +550,20 @@ describe("useQueue clearAll", () => {
await expect(result.current.clearAll()).rejects.toThrow("clear failed");
});
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
- expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [authoritative], {
- count: 1,
- max: 10,
- mergeEnabled: true,
- autoRun: true,
- });
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ expect(mockState.setQueueEntries).toHaveBeenCalledWith(
+ SESSION_ID,
+ [authoritative],
+ {
+ count: 1,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: IDENTITY.session_incarnation_id,
+ },
+ { establishStatusEpoch: true },
+ );
});
it("discards an in-flight refetch that resolves after the clear", async () => {
@@ -471,10 +606,7 @@ describe("useQueue clearAll", () => {
describe("useQueue removeEntry", () => {
beforeEach(() => {
- resetMockState();
- setDocumentVisibility("visible");
queueApiMock.removeQueuedEntry.mockReset();
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
});
it("optimistically removes then refetches authoritative status after success", async () => {
@@ -488,7 +620,7 @@ describe("useQueue removeEntry", () => {
});
expect(mockState.removeQueueEntry).toHaveBeenCalledWith(SESSION_ID, "q-1");
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
it("refetches after a drain race without surfacing a benign error", async () => {
@@ -503,7 +635,7 @@ describe("useQueue removeEntry", () => {
await expect(result.current.removeEntry("q-1")).resolves.toBeUndefined();
});
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
});
it("refetches and rethrows a failed removal", async () => {
@@ -513,6 +645,7 @@ describe("useQueue removeEntry", () => {
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
queueApiMock.getQueueStatus.mockClear();
queueApiMock.getQueueStatus.mockResolvedValueOnce({
+ ...IDENTITY,
entries: [authoritative],
count: 1,
max: 10,
@@ -522,104 +655,19 @@ describe("useQueue removeEntry", () => {
await expect(result.current.removeEntry("q-1")).rejects.toThrow("remove failed");
});
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
- expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [authoritative], {
- count: 1,
- max: 10,
- mergeEnabled: true,
- autoRun: true,
- });
- });
-});
-
-describe("useQueue reorderEntries", () => {
- beforeEach(() => {
- resetMockState();
- setDocumentVisibility("visible");
- queueApiMock.reorderQueuedEntries.mockReset();
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
- });
-
- it("optimistically reorders the store and refetches the authoritative queue", async () => {
- const first = entry({ id: "q-1", content: "first" });
- const second = entry({ id: "q-2", content: "second" });
- // The test harness's setQueueEntries is a no-op spy, so seed the store
- // directly: the hook's optimistic reorder reads the current entries.
- mockState.queue.bySessionId[SESSION_ID] = [first, second];
- mockState.queue.metaBySessionId[SESSION_ID] = { count: 2, max: 10 };
- queueApiMock.getQueueStatus.mockResolvedValue({
- entries: [first, second],
- count: 2,
- max: 10,
- });
- queueApiMock.reorderQueuedEntries.mockResolvedValue({
- session_id: SESSION_ID,
- reordered: 2,
- });
-
- const { result } = renderHook(() => useQueue(SESSION_ID));
- await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
- queueApiMock.getQueueStatus.mockClear();
-
- await act(async () => {
- await result.current.reorderEntries([second.id, first.id]);
- });
-
- expect(queueApiMock.reorderQueuedEntries).toHaveBeenCalledWith({
- session_id: SESSION_ID,
- ordered_ids: [second.id, first.id],
- });
- expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [second, first], {
- count: 2,
- max: 10,
- mergeEnabled: true,
- autoRun: true,
- });
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
- });
-
- it("refetches and rethrows when the reorder fails", async () => {
- const first = entry({ id: "q-1", content: "first" });
- const second = entry({ id: "q-2", content: "second" });
- queueApiMock.getQueueStatus.mockResolvedValue({
- entries: [first, second],
- count: 2,
- max: 10,
- });
- queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new Error("reorder failed"));
- const { result } = renderHook(() => useQueue(SESSION_ID));
- await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
- queueApiMock.getQueueStatus.mockClear();
- queueApiMock.getQueueStatus.mockResolvedValueOnce({
- entries: [first, second],
- count: 2,
- max: 10,
- });
-
- await act(async () => {
- await expect(result.current.reorderEntries([second.id, first.id])).rejects.toThrow(
- "reorder failed",
- );
- });
-
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
- });
-
- it("refetches and rethrows a QueueReorderError so the panel can swallow it", async () => {
- const first = entry({ id: "q-1", content: "first" });
- queueApiMock.getQueueStatus.mockResolvedValue({ entries: [first], count: 1, max: 10 });
- queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new queueApiMock.QueueReorderError());
-
- const { result } = renderHook(() => useQueue(SESSION_ID));
- await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
- queueApiMock.getQueueStatus.mockClear();
-
- await act(async () => {
- await expect(result.current.reorderEntries([first.id])).rejects.toThrow(
- queueApiMock.QueueReorderError,
- );
- });
-
- expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
+ expect(mockState.setQueueEntries).toHaveBeenCalledWith(
+ SESSION_ID,
+ [authoritative],
+ {
+ count: 1,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: IDENTITY.session_incarnation_id,
+ },
+ { establishStatusEpoch: true },
+ );
});
});
diff --git a/apps/web/hooks/domains/session/use-queue.ts b/apps/web/hooks/domains/session/use-queue.ts
index 7f3619c128..a1b53ba7da 100644
--- a/apps/web/hooks/domains/session/use-queue.ts
+++ b/apps/web/hooks/domains/session/use-queue.ts
@@ -1,10 +1,8 @@
-import { useEffect, useCallback, useRef } from "react";
+import { useEffect, useCallback, useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import {
- queueMessage,
clearQueue,
- getQueueStatus,
updateQueuedMessage,
removeQueuedEntry,
mergeQueuedEntry,
@@ -12,33 +10,22 @@ import {
QueueEntryNotFoundError,
sendQueuedNow,
setQueueAutoRun,
+ setQueueAutoMerge,
} from "@/lib/api/domains/queue-api";
-import type { QueueMessageParams } from "@/lib/api/domains/queue-api";
-import type { QueuedMessage } from "@/lib/state/slices/session/types";
+import type { QueueSessionIdentity } from "@/lib/api/domains/queue-api";
+import type {
+ QueueMeta,
+ QueueOperationToken,
+ QueuedMessage,
+} from "@/lib/state/slices/session/types";
+import type { MessageAttachment } from "./use-queue-types";
+export type { MessageAttachment, QueueMessageInput } from "./use-queue-types";
+import { useQueueAdmissionAction } from "./use-queue-admission";
+import { useQueueRefetch, type QueueRefetch } from "./use-queue-refetch";
import type { EntityReference } from "@/lib/types/entity-reference";
const EMPTY_ENTRIES: QueuedMessage[] = [];
-export type MessageAttachment = {
- type: string;
- data?: string;
- attachment_id?: string;
- mime_type: string;
- name?: string;
- size_bytes?: number;
- delivery_mode?: "prompt" | "path";
-};
-
-export type QueueMessageInput = {
- taskId: string;
- content: string;
- model?: string;
- planMode?: boolean;
- attachments?: MessageAttachment[];
- entityReferences?: EntityReference[];
- contextFilesMeta?: QueueMessageParams["context_files"];
-};
-
/** Selectors over the queue slice for one session. */
function useQueueState(sessionId: string | null) {
const entries = useAppStore((state) =>
@@ -47,195 +34,142 @@ function useQueueState(sessionId: string | null) {
const meta = useAppStore((state) =>
sessionId ? state.queue.metaBySessionId[sessionId] : undefined,
);
- const isLoading = useAppStore((state) =>
- sessionId ? (state.queue.isLoading[sessionId] ?? false) : false,
- );
const setQueueEntries = useAppStore((state) => state.setQueueEntries);
const removeQueueEntry = useAppStore((state) => state.removeQueueEntry);
- const setQueueLoading = useAppStore((state) => state.setQueueLoading);
+ const activeOperation = useAppStore((state) =>
+ sessionId ? state.queue.activeOperationBySessionId[sessionId] : undefined,
+ );
+ const beginQueueOperation = useAppStore((state) => state.beginQueueOperation);
+ const finishQueueOperation = useAppStore((state) => state.finishQueueOperation);
const cancellationPending = useAppStore(
(state) =>
(sessionId ? state.taskSessions.items[sessionId]?.cancellation_pending : false) === true,
);
+ const taskSession = useAppStore((state) =>
+ sessionId ? state.taskSessions.items[sessionId] : undefined,
+ );
return {
entries,
meta,
- isLoading,
+ isLoading: activeOperation !== undefined,
cancellationPending,
+ taskSession,
setQueueEntries,
removeQueueEntry,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
};
}
type QueueActionsArgs = {
- sessionId: string | null;
+ identity: QueueSessionIdentity | null;
entries: QueuedMessage[];
setQueueEntries: ReturnType["setQueueEntries"];
removeQueueEntry: ReturnType["removeQueueEntry"];
- setQueueLoading: ReturnType["setQueueLoading"];
+ beginQueueOperation: ReturnType["beginQueueOperation"];
+ finishQueueOperation: ReturnType["finishQueueOperation"];
metaMax: number | undefined;
metaMergeEnabled: boolean | undefined;
metaAutoRun: boolean | undefined;
};
+type BeginQueueOperation = ReturnType["beginQueueOperation"];
+type FinishQueueOperation = ReturnType["finishQueueOperation"];
+
+async function runQueueMutation(
+ identity: QueueSessionIdentity | null,
+ beginQueueOperation: BeginQueueOperation,
+ finishQueueOperation: FinishQueueOperation,
+ refetch: QueueRefetch,
+ mutate: (identity: QueueSessionIdentity) => Promise,
+): Promise {
+ if (!identity) return;
+ const { session_id: sessionId, session_incarnation_id: incarnationId } = identity;
+ const token = beginQueueOperation(sessionId, incarnationId);
+ if (!token) return;
+ let mutationError: unknown;
+ try {
+ try {
+ await mutate(identity);
+ } catch (error) {
+ mutationError = error;
+ }
+ try {
+ await refetch(sessionId, token);
+ } catch (reconcileError) {
+ if (mutationError) throw mutationError;
+ throw reconcileError;
+ }
+ if (mutationError) throw mutationError;
+ } finally {
+ finishQueueOperation(sessionId, token);
+ }
+}
+
function useSendNowAction(
- sessionId: string | null,
- setQueueLoading: ReturnType["setQueueLoading"],
- refetch: (sid: string) => Promise,
+ identity: QueueSessionIdentity | null,
+ beginQueueOperation: BeginQueueOperation,
+ finishQueueOperation: FinishQueueOperation,
+ refetch: QueueRefetch,
) {
return useCallback(
- async (entryId: string) => {
- if (!sessionId) return;
- setQueueLoading(sessionId, true);
- let mutationFailed = false;
- let mutationError: unknown;
- try {
- try {
- await sendQueuedNow({
- session_id: sessionId,
- scope: "entry",
- entry_id: entryId,
- });
- } catch (err) {
- mutationFailed = true;
- mutationError = err;
- }
-
- try {
- await refetch(sessionId);
- } catch (reconcileError) {
- if (mutationFailed) throw mutationError;
- throw reconcileError;
- }
- if (mutationFailed) throw mutationError;
- } finally {
- setQueueLoading(sessionId, false);
- }
- },
- [sessionId, refetch, setQueueLoading],
+ (entryId: string) =>
+ runQueueMutation(identity, beginQueueOperation, finishQueueOperation, refetch, (current) =>
+ sendQueuedNow({ ...current, scope: "entry", entry_id: entryId }),
+ ),
+ [beginQueueOperation, finishQueueOperation, identity, refetch],
);
}
-function useSetAutoRunAction(
- sessionId: string | null,
- setQueueLoading: ReturnType["setQueueLoading"],
- refetch: (sid: string) => Promise,
+type BooleanQueueMutation = (identity: QueueSessionIdentity, enabled: boolean) => Promise;
+
+function useBooleanQueueAction(
+ identity: QueueSessionIdentity | null,
+ beginQueueOperation: BeginQueueOperation,
+ finishQueueOperation: FinishQueueOperation,
+ refetch: QueueRefetch,
+ mutate: BooleanQueueMutation,
) {
return useCallback(
- async (enabled: boolean) => {
- if (!sessionId) return;
- setQueueLoading(sessionId, true);
- let mutationFailed = false;
- let mutationError: unknown;
- try {
- try {
- await setQueueAutoRun(sessionId, enabled);
- } catch (err) {
- mutationFailed = true;
- mutationError = err;
- }
-
- try {
- await refetch(sessionId);
- } catch (reconcileError) {
- if (mutationFailed) throw mutationError;
- throw reconcileError;
- }
- if (mutationFailed) throw mutationError;
- } finally {
- setQueueLoading(sessionId, false);
- }
- },
- [sessionId, refetch, setQueueLoading],
+ (enabled: boolean) =>
+ runQueueMutation(identity, beginQueueOperation, finishQueueOperation, refetch, (current) =>
+ mutate(current, enabled),
+ ),
+ [beginQueueOperation, finishQueueOperation, identity, mutate, refetch],
);
}
-/** Fetches the authoritative queue snapshot and writes it into the slice,
- * discarding a response that arrives after a newer request for the same
- * session was already issued (out-of-order network resolution). */
-function useQueueRefetch(
- setQueueEntries: ReturnType["setQueueEntries"],
- setQueueLoading: ReturnType["setQueueLoading"],
-) {
- const refetchVersion = useRef>({});
- const invalidate = useCallback((sid: string) => {
- refetchVersion.current[sid] = (refetchVersion.current[sid] ?? 0) + 1;
- }, []);
- const refetch = useCallback(
- async (sid: string) => {
- const version = (refetchVersion.current[sid] ?? 0) + 1;
- refetchVersion.current[sid] = version;
- try {
- setQueueLoading(sid, true);
- const status = await getQueueStatus(sid);
- if (refetchVersion.current[sid] !== version) return;
- setQueueEntries(sid, status.entries ?? [], {
- count: status.count,
- max: status.max,
- mergeEnabled: status.merge_enabled ?? true,
- autoRun: status.auto_run ?? true,
- });
- } finally {
- if (refetchVersion.current[sid] === version) setQueueLoading(sid, false);
- }
- },
- [setQueueEntries, setQueueLoading],
- );
- return { refetch, invalidate };
-}
-
/** Build an action set bound to the supplied session + slice setters. */
function useQueueActions({
- sessionId,
+ identity,
entries,
setQueueEntries,
removeQueueEntry,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
metaMax,
metaMergeEnabled,
metaAutoRun,
}: QueueActionsArgs) {
const { refetch, invalidate: invalidateRefetch } = useQueueRefetch(
+ identity,
setQueueEntries,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
);
- const queue = useCallback(
- async ({
- taskId,
- content,
- model,
- planMode,
- attachments,
- entityReferences,
- contextFilesMeta,
- }: QueueMessageInput) => {
- if (!sessionId) return;
- setQueueLoading(sessionId, true);
- try {
- await queueMessage({
- session_id: sessionId,
- task_id: taskId,
- content,
- model,
- plan_mode: planMode,
- attachments,
- entity_references: entityReferences,
- ...(contextFilesMeta ? { context_files: contextFilesMeta } : {}),
- });
- await refetch(sessionId);
- } finally {
- setQueueLoading(sessionId, false);
- }
- },
- [sessionId, refetch, setQueueLoading],
+ const queue = useQueueAdmissionAction(
+ identity,
+ beginQueueOperation,
+ finishQueueOperation,
+ refetch,
);
const clearAll = useClearAllAction({
- sessionId,
+ identity,
setQueueEntries,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
metaMax,
metaMergeEnabled,
metaAutoRun,
@@ -243,20 +177,41 @@ function useQueueActions({
invalidateRefetch,
});
- const sendEntryNow = useSendNowAction(sessionId, setQueueLoading, refetch);
- const setAutoRun = useSetAutoRunAction(sessionId, setQueueLoading, refetch);
+ const sendEntryNow = useSendNowAction(
+ identity,
+ beginQueueOperation,
+ finishQueueOperation,
+ refetch,
+ );
+ const setAutoRun = useBooleanQueueAction(
+ identity,
+ beginQueueOperation,
+ finishQueueOperation,
+ refetch,
+ setQueueAutoRun,
+ );
+ const setAutoMerge = useBooleanQueueAction(
+ identity,
+ beginQueueOperation,
+ finishQueueOperation,
+ refetch,
+ setQueueAutoMerge,
+ );
const { editEntry, removeEntry, mergeEntry } = useEntryMutations({
- sessionId,
+ identity,
removeQueueEntry,
refetch,
+ beginQueueOperation,
+ finishQueueOperation,
});
const reorderEntries = useReorderEntriesAction({
- sessionId,
+ identity,
entries,
setQueueEntries,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
metaMax,
metaMergeEnabled,
metaAutoRun,
@@ -269,6 +224,7 @@ function useQueueActions({
clearAll,
sendEntryNow,
setAutoRun,
+ setAutoMerge,
editEntry,
removeEntry,
mergeEntry,
@@ -277,33 +233,36 @@ function useQueueActions({
}
type ReorderEntriesArgs = {
- sessionId: string | null;
+ identity: QueueSessionIdentity | null;
entries: QueuedMessage[];
setQueueEntries: ReturnType["setQueueEntries"];
- setQueueLoading: ReturnType["setQueueLoading"];
+ beginQueueOperation: BeginQueueOperation;
+ finishQueueOperation: FinishQueueOperation;
metaMax: number | undefined;
metaMergeEnabled: boolean | undefined;
metaAutoRun: boolean | undefined;
- refetch: (sid: string) => Promise;
+ refetch: QueueRefetch;
};
type ClearAllArgs = {
- sessionId: string | null;
+ identity: QueueSessionIdentity | null;
setQueueEntries: ReturnType["setQueueEntries"];
- setQueueLoading: ReturnType["setQueueLoading"];
+ beginQueueOperation: BeginQueueOperation;
+ finishQueueOperation: FinishQueueOperation;
metaMax: number | undefined;
metaMergeEnabled: boolean | undefined;
metaAutoRun: boolean | undefined;
- refetch: (sid: string) => Promise;
+ refetch: QueueRefetch;
invalidateRefetch: (sid: string) => void;
};
/** Clears every pending entry: optimistic empty state, then the backend's
* authoritative result. Reconcile errors preserve the mutation error. */
function useClearAllAction({
- sessionId,
+ identity,
setQueueEntries,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
metaMax,
metaMergeEnabled,
metaAutoRun,
@@ -311,44 +270,43 @@ function useClearAllAction({
invalidateRefetch,
}: ClearAllArgs) {
return useCallback(async () => {
- if (!sessionId) return;
- setQueueLoading(sessionId, true);
+ if (!identity) return;
+ const { session_id: sessionId, session_incarnation_id: incarnationId } = identity;
+ const token = beginQueueOperation(sessionId, incarnationId);
+ if (!token) return;
let mutationFailed = false;
let mutationError: unknown;
try {
try {
- await clearQueue(sessionId);
- // Invalidate any pre-clear status request before publishing the
- // optimistic empty state. The explicit refetch below then owns the
- // newest version and replaces it with the backend's exact result.
+ await clearQueue(identity);
invalidateRefetch(sessionId);
setQueueEntries(sessionId, [], {
count: 0,
max: metaMax ?? 0,
mergeEnabled: metaMergeEnabled ?? true,
autoRun: metaAutoRun ?? true,
+ taskId: identity.task_id,
+ sessionIncarnationId: incarnationId,
});
} catch (err) {
mutationFailed = true;
mutationError = err;
}
-
try {
- await refetch(sessionId);
+ await refetch(sessionId, token);
} catch (reconcileError) {
- // Preserve the mutation error when both operations fail; it best
- // explains why the requested action did not complete.
if (mutationFailed) throw mutationError;
throw reconcileError;
}
if (mutationFailed) throw mutationError;
} finally {
- setQueueLoading(sessionId, false);
+ finishQueueOperation(sessionId, token);
}
}, [
- sessionId,
+ beginQueueOperation,
+ finishQueueOperation,
+ identity,
setQueueEntries,
- setQueueLoading,
metaMax,
metaMergeEnabled,
metaAutoRun,
@@ -362,10 +320,11 @@ function useClearAllAction({
* (drain/remove/merge raced the drag) is refetched and rethrown as
* QueueReorderError so the panel can swallow it without a toast. */
function useReorderEntriesAction({
- sessionId,
+ identity,
entries,
setQueueEntries,
- setQueueLoading,
+ beginQueueOperation,
+ finishQueueOperation,
metaMax,
metaMergeEnabled,
metaAutoRun,
@@ -373,9 +332,10 @@ function useReorderEntriesAction({
}: ReorderEntriesArgs) {
return useCallback(
async (orderedIds: string[]) => {
- if (!sessionId) return;
- // Optimistic: reflect the drop immediately; the refetch below reconciles
- // to the backend's authoritative order and positions.
+ if (!identity) return;
+ const { session_id: sessionId, session_incarnation_id: incarnationId } = identity;
+ const token = beginQueueOperation(sessionId, incarnationId);
+ if (!token) return;
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const reordered = orderedIds
.map((id) => byId.get(id))
@@ -388,128 +348,194 @@ function useReorderEntriesAction({
autoRun: metaAutoRun ?? true,
});
}
- setQueueLoading(sessionId, true);
let mutationFailed = false;
let mutationError: unknown;
try {
try {
- await reorderQueuedEntries({ session_id: sessionId, ordered_ids: orderedIds });
+ await reorderQueuedEntries({ ...identity, ordered_ids: orderedIds });
} catch (err) {
mutationFailed = true;
mutationError = err;
}
-
try {
- await refetch(sessionId);
+ await refetch(sessionId, token);
} catch (reconcileError) {
- // Preserve the mutation error when both operations fail; it best
- // explains why the requested order did not land.
if (mutationFailed) throw mutationError;
throw reconcileError;
}
if (mutationFailed) throw mutationError;
} finally {
- setQueueLoading(sessionId, false);
+ finishQueueOperation(sessionId, token);
}
},
[
- sessionId,
+ beginQueueOperation,
entries,
- setQueueEntries,
- setQueueLoading,
+ finishQueueOperation,
+ identity,
+ metaAutoRun,
metaMax,
metaMergeEnabled,
- metaAutoRun,
refetch,
+ setQueueEntries,
],
);
}
type EntryMutationsArgs = {
- sessionId: string | null;
+ identity: QueueSessionIdentity | null;
removeQueueEntry: ReturnType["removeQueueEntry"];
- refetch: (sid: string) => Promise;
+ beginQueueOperation: BeginQueueOperation;
+ finishQueueOperation: FinishQueueOperation;
+ refetch: QueueRefetch;
};
/** Entry-level mutations (edit / remove / merge) that refetch on success and
* resync on a drain race (QueueEntryNotFoundError). */
-function useEntryMutations({ sessionId, removeQueueEntry, refetch }: EntryMutationsArgs) {
+function useEntryMutations({
+ identity,
+ removeQueueEntry,
+ beginQueueOperation,
+ finishQueueOperation,
+ refetch,
+}: EntryMutationsArgs) {
+ const run = useCallback(
+ async (operation: (token: QueueOperationToken) => Promise) => {
+ if (!identity) return;
+ const token = beginQueueOperation(identity.session_id, identity.session_incarnation_id);
+ if (!token) return;
+ try {
+ await operation(token);
+ } finally {
+ finishQueueOperation(identity.session_id, token);
+ }
+ },
+ [beginQueueOperation, finishQueueOperation, identity],
+ );
const editEntry = useCallback(
async (
entryId: string,
content: string,
attachments?: MessageAttachment[],
entityReferences: EntityReference[] = [],
- ) => {
- if (!sessionId) return;
- try {
- await updateQueuedMessage({
- session_id: sessionId,
- entry_id: entryId,
- content,
- attachments,
- entity_references: entityReferences,
- });
- await refetch(sessionId);
- } catch (err) {
- if (err instanceof QueueEntryNotFoundError) {
- await refetch(sessionId);
+ ) =>
+ run(async (token) => {
+ if (!identity) return;
+ try {
+ await updateQueuedMessage({
+ ...identity,
+ entry_id: entryId,
+ content,
+ attachments,
+ entity_references: entityReferences,
+ });
+ await refetch(identity.session_id, token);
+ } catch (err) {
+ if (err instanceof QueueEntryNotFoundError) {
+ await refetch(identity.session_id, token);
+ }
+ throw err;
}
- throw err;
- }
- },
- [sessionId, refetch],
+ }),
+ [identity, refetch, run],
);
-
const removeEntry = useCallback(
- async (entryId: string) => {
- if (!sessionId) return;
- removeQueueEntry(sessionId, entryId);
- let mutationFailed = false;
- let mutationError: unknown;
- try {
- await removeQueuedEntry({ session_id: sessionId, entry_id: entryId });
- } catch (err) {
- mutationFailed = true;
- mutationError = err;
- }
-
- try {
- await refetch(sessionId);
- } catch (reconcileError) {
+ async (entryId: string) =>
+ run(async (token) => {
+ if (!identity) return;
+ removeQueueEntry(identity.session_id, entryId);
+ let mutationFailed = false;
+ let mutationError: unknown;
+ try {
+ await removeQueuedEntry({ ...identity, entry_id: entryId });
+ } catch (err) {
+ mutationFailed = true;
+ mutationError = err;
+ }
+ try {
+ await refetch(identity.session_id, token);
+ } catch (reconcileError) {
+ if (mutationFailed && !(mutationError instanceof QueueEntryNotFoundError)) {
+ throw mutationError;
+ }
+ throw reconcileError;
+ }
if (mutationFailed && !(mutationError instanceof QueueEntryNotFoundError)) {
throw mutationError;
}
- throw reconcileError;
- }
-
- // A drain/remove race is already reflected by the authoritative status,
- // so it is a successful UI outcome. Other failures restore state above
- // and remain actionable to the caller.
- if (mutationFailed && !(mutationError instanceof QueueEntryNotFoundError)) {
- throw mutationError;
- }
- },
- [sessionId, refetch, removeQueueEntry],
+ }),
+ [identity, refetch, removeQueueEntry, run],
);
-
const mergeEntry = useCallback(
- async (entryId: string, userId?: string) => {
- if (!sessionId) return;
- try {
- await mergeQueuedEntry({ session_id: sessionId, entry_id: entryId, user_id: userId });
- await refetch(sessionId);
- } catch (err) {
- if (err instanceof QueueEntryNotFoundError) {
- await refetch(sessionId);
+ async (entryId: string, userId?: string) =>
+ run(async (token) => {
+ if (!identity) return;
+ try {
+ await mergeQueuedEntry({ ...identity, entry_id: entryId, user_id: userId });
+ await refetch(identity.session_id, token);
+ } catch (err) {
+ if (err instanceof QueueEntryNotFoundError) {
+ await refetch(identity.session_id, token);
+ }
+ throw err;
}
- throw err;
- }
+ }),
+ [identity, refetch, run],
+ );
+ return { editEntry, removeEntry, mergeEntry };
+}
+
+function useCurrentQueueIdentity(
+ sessionId: string | null,
+ taskSession: ReturnType["taskSession"],
+) {
+ return useMemo(() => {
+ if (!sessionId || !taskSession?.task_id || !taskSession.queue_incarnation_id) return null;
+ return {
+ task_id: taskSession.task_id,
+ session_id: sessionId,
+ session_incarnation_id: taskSession.queue_incarnation_id,
+ };
+ }, [sessionId, taskSession?.task_id, taskSession?.queue_incarnation_id]);
+}
+
+function useQueueRefresh(
+ sessionId: string | null,
+ connectionStatus: string,
+ refetch: QueueRefetch,
+) {
+ useEffect(() => {
+ if (!sessionId || connectionStatus !== "connected") return;
+ void refetch(sessionId).catch((error) => {
+ console.error("Failed to fetch queue status:", error);
+ });
+ }, [sessionId, connectionStatus, refetch]);
+ useForegroundRefresh(
+ () => {
+ if (!sessionId || connectionStatus !== "connected") return;
+ return refetch(sessionId).catch((error) => {
+ console.error("Failed to fetch queue status after foreground refresh:", error);
+ });
},
+ Boolean(sessionId),
+ sessionId,
+ );
+ return useCallback(
+ () => (sessionId ? refetch(sessionId) : Promise.resolve()),
[sessionId, refetch],
);
+}
- return { editEntry, removeEntry, mergeEntry };
+function queueSummary(meta: QueueMeta | undefined, entries: QueuedMessage[]) {
+ return {
+ count: meta?.count ?? entries.length,
+ max: meta?.max ?? 0,
+ isFull: meta ? meta.count >= meta.max && meta.max > 0 : false,
+ mergeEnabled: meta?.mergeEnabled ?? true,
+ autoRun: meta?.autoRun ?? true,
+ autoMerge: meta?.autoMergeEnabled ?? false,
+ autoMergeAvailable: meta?.autoMergeAvailable ?? false,
+ };
}
/**
@@ -522,67 +548,44 @@ function useEntryMutations({ sessionId, removeQueueEntry, refetch }: EntryMutati
*/
export function useQueue(sessionId: string | null) {
const state = useQueueState(sessionId);
- const { entries, meta, isLoading, cancellationPending } = state;
+ const { entries, meta, isLoading, cancellationPending, taskSession } = state;
+ const identity = useCurrentQueueIdentity(sessionId, taskSession);
const connectionStatus = useAppStore((appState) => appState.connection.status);
const {
refetch,
queue,
clearAll,
sendEntryNow,
+ setAutoMerge,
setAutoRun,
editEntry,
removeEntry,
mergeEntry,
reorderEntries,
} = useQueueActions({
- sessionId,
+ identity,
entries,
setQueueEntries: state.setQueueEntries,
removeQueueEntry: state.removeQueueEntry,
- setQueueLoading: state.setQueueLoading,
+ beginQueueOperation: state.beginQueueOperation,
+ finishQueueOperation: state.finishQueueOperation,
metaMax: meta?.max,
metaMergeEnabled: meta?.mergeEnabled,
metaAutoRun: meta?.autoRun,
});
- useEffect(() => {
- if (!sessionId) return;
- if (connectionStatus !== "connected") return;
- void refetch(sessionId).catch((err) => {
- console.error("Failed to fetch queue status:", err);
- });
- }, [sessionId, connectionStatus, refetch]);
-
- useForegroundRefresh(
- () => {
- if (!sessionId) return;
- if (connectionStatus !== "connected") return;
- return refetch(sessionId).catch((err) => {
- console.error("Failed to fetch queue status after foreground refresh:", err);
- });
- },
- Boolean(sessionId),
- sessionId,
- );
-
- const refetchBound = useCallback(
- () => (sessionId ? refetch(sessionId) : Promise.resolve()),
- [sessionId, refetch],
- );
+ const refetchBound = useQueueRefresh(sessionId, connectionStatus, refetch);
return {
entries,
- count: meta?.count ?? entries.length,
- max: meta?.max ?? 0,
- isFull: meta ? meta.count >= meta.max && meta.max > 0 : false,
- mergeEnabled: meta?.mergeEnabled ?? true,
- autoRun: meta?.autoRun ?? true,
+ ...queueSummary(meta, entries),
isLoading,
cancellationPending,
queue,
clearAll,
sendEntryNow,
setAutoRun,
+ setAutoMerge,
editEntry,
removeEntry,
mergeEntry,
diff --git a/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts b/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts
index cb176f28cd..6f177badf6 100644
--- a/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts
+++ b/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts
@@ -36,8 +36,11 @@ function storeState(sessionState: string, planMode = false, foregroundActivity?:
return {
taskSessions: {
items: {
- "session-1": { state: sessionState, foreground_activity: foregroundActivity },
- "other-session": { state: "RUNNING", foreground_activity: "generating" },
+ "session-1": {
+ state: sessionState,
+ foreground_activity: foregroundActivity,
+ queue_incarnation_id: "inc-1",
+ },
},
},
chatInput: { planModeBySessionId: { "session-1": planMode } },
diff --git a/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts b/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts
index 16309b9fa0..42a3794153 100644
--- a/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts
+++ b/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts
@@ -39,11 +39,13 @@ async function loadChangesWalkthroughPromptTemplate(): Promise {
async function queueWalkthroughRequest(params: {
taskId: string;
sessionId: string;
+ sessionIncarnationId: string;
content: string;
planModeEnabled: boolean;
}) {
await queueMessage({
session_id: params.sessionId,
+ session_incarnation_id: params.sessionIncarnationId,
task_id: params.taskId,
content: params.content,
...planModePayload(params.planModeEnabled),
@@ -108,9 +110,13 @@ export function useRequestChangesWalkthrough({
const template = await loadChangesWalkthroughPromptTemplate();
const content = buildChangesWalkthroughPrompt(template);
if (inputMode === "queue") {
+ if (!activeSession?.queue_incarnation_id) {
+ throw new Error("Session is not available for input");
+ }
await queueWalkthroughRequest({
taskId,
sessionId,
+ sessionIncarnationId: activeSession.queue_incarnation_id,
content,
planModeEnabled,
});
diff --git a/apps/web/lib/api/domains/queue-api.test.ts b/apps/web/lib/api/domains/queue-api.test.ts
index b4543b0963..e92a845d11 100644
--- a/apps/web/lib/api/domains/queue-api.test.ts
+++ b/apps/web/lib/api/domains/queue-api.test.ts
@@ -2,6 +2,8 @@ import { beforeEach, describe, it, expect, vi } from "vitest";
import type { EntityReference } from "@/lib/types/entity-reference";
const getWebSocketClientMock = vi.hoisted(() => vi.fn());
+const SESSION_ID = `session-1`;
+const INCARNATION_ID = `incarnation-1`;
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: getWebSocketClientMock,
@@ -133,14 +135,16 @@ describe("queue reference payloads", () => {
getWebSocketClientMock.mockReturnValue({ request });
await queueMessage({
- session_id: "session-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
task_id: "task-1",
content: "queued reference",
entity_references: [reference],
});
expect(request).toHaveBeenCalledWith("message.queue.add", {
- session_id: "session-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
task_id: "task-1",
content: "queued reference",
entity_references: [reference],
@@ -152,14 +156,16 @@ describe("queue reference payloads", () => {
getWebSocketClientMock.mockReturnValue({ request });
await queueMessage({
- session_id: "session-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
task_id: "task-1",
content: "queued context",
context_files: [{ path: "src/components", name: "components", is_directory: true }],
});
expect(request).toHaveBeenCalledWith("message.queue.add", {
- session_id: "session-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
task_id: "task-1",
content: "queued context",
context_files: [{ path: "src/components", name: "components", is_directory: true }],
@@ -171,13 +177,17 @@ describe("queue reference payloads", () => {
getWebSocketClientMock.mockReturnValue({ request });
await updateQueuedMessage({
- session_id: "session-1",
+ session_id: SESSION_ID,
+ task_id: "task-1",
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-1",
content: "reference removed",
- } as never);
+ });
expect(request).toHaveBeenCalledWith("message.queue.update", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-1",
content: "reference removed",
entity_references: [],
@@ -189,14 +199,18 @@ describe("queue reference payloads", () => {
getWebSocketClientMock.mockReturnValue({ request });
await updateQueuedMessage({
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-1",
content: "reference kept",
entity_references: [reference],
});
expect(request).toHaveBeenCalledWith("message.queue.update", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-1",
content: "reference kept",
entity_references: [reference],
@@ -210,13 +224,17 @@ describe("mergeQueuedEntry", () => {
getWebSocketClientMock.mockReturnValue({ request });
await mergeQueuedEntry({
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-b",
user_id: "user-1",
});
expect(request).toHaveBeenCalledWith("message.queue.merge", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-b",
user_id: "user-1",
});
@@ -226,10 +244,17 @@ describe("mergeQueuedEntry", () => {
const request = vi.fn().mockResolvedValue({ entry_id: "q-a" });
getWebSocketClientMock.mockReturnValue({ request });
- await mergeQueuedEntry({ session_id: "session-1", entry_id: "q-b" });
+ await mergeQueuedEntry({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ entry_id: "q-b",
+ });
expect(request).toHaveBeenCalledWith("message.queue.merge", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
entry_id: "q-b",
});
});
@@ -241,7 +266,12 @@ describe("mergeQueuedEntry", () => {
getWebSocketClientMock.mockReturnValue({ request });
await expect(
- mergeQueuedEntry({ session_id: "session-1", entry_id: "q-b" }),
+ mergeQueuedEntry({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ entry_id: "q-b",
+ }),
).rejects.toBeInstanceOf(QueueEntryNotFoundError);
});
@@ -253,7 +283,12 @@ describe("mergeQueuedEntry", () => {
getWebSocketClientMock.mockReturnValue({ request });
await expect(
- mergeQueuedEntry({ session_id: "session-1", entry_id: "q-b" }),
+ mergeQueuedEntry({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ entry_id: "q-b",
+ }),
).rejects.toBeInstanceOf(MergeReferenceOverflowError);
});
});
@@ -261,20 +296,24 @@ describe("mergeQueuedEntry", () => {
describe("sendQueuedNow", () => {
it("sends an exact entry scope", async () => {
const request = vi.fn().mockResolvedValue({
- session_id: "session-1",
+ session_id: SESSION_ID,
dispatched: true,
sent_count: 1,
});
getWebSocketClientMock.mockReturnValue({ request });
await sendQueuedNow({
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
scope: "entry",
entry_id: "q-2",
});
expect(request).toHaveBeenCalledWith("message.queue.send_now", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
scope: "entry",
entry_id: "q-2",
});
@@ -282,16 +321,23 @@ describe("sendQueuedNow", () => {
it("omits entry_id for an all scope snapshot", async () => {
const request = vi.fn().mockResolvedValue({
- session_id: "session-1",
+ session_id: SESSION_ID,
dispatched: true,
sent_count: 3,
});
getWebSocketClientMock.mockReturnValue({ request });
- await sendQueuedNow({ session_id: "session-1", scope: "all" });
+ await sendQueuedNow({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ scope: "all",
+ });
expect(request).toHaveBeenCalledWith("message.queue.send_now", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
scope: "all",
});
});
@@ -303,19 +349,31 @@ describe("sendQueuedNow", () => {
});
getWebSocketClientMock.mockReturnValue({ request });
- await expect(sendQueuedNow({ session_id: "session-1", scope: "all" })).rejects.toMatchObject({
+ await expect(
+ sendQueuedNow({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ scope: "all",
+ }),
+ ).rejects.toMatchObject({
code: "send_now_conflict",
});
- await expect(sendQueuedNow({ session_id: "session-1", scope: "all" })).rejects.toBeInstanceOf(
- QueueSendNowError,
- );
+ await expect(
+ sendQueuedNow({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ scope: "all",
+ }),
+ ).rejects.toBeInstanceOf(QueueSendNowError);
});
});
describe("setQueueAutoRun", () => {
it("sets the per-session queue policy through its exact WebSocket action", async () => {
const request = vi.fn().mockResolvedValue({
- session_id: "session-1",
+ session_id: SESSION_ID,
auto_run: false,
dispatched: false,
});
@@ -323,17 +381,68 @@ describe("setQueueAutoRun", () => {
const setQueueAutoRun = (
queueApi as typeof queueApi & {
setQueueAutoRun?: (
- sessionId: string,
+ identity: {
+ task_id: string;
+ session_id: string;
+ session_incarnation_id: string;
+ },
enabled: boolean,
) => Promise<{ session_id: string; auto_run: boolean; dispatched: boolean }>;
}
).setQueueAutoRun;
expect(setQueueAutoRun).toBeTypeOf("function");
- await setQueueAutoRun!("session-1", false);
+ await setQueueAutoRun!(
+ { task_id: "task-1", session_id: SESSION_ID, session_incarnation_id: INCARNATION_ID },
+ false,
+ );
expect(request).toHaveBeenCalledWith("message.queue.auto_run.set", {
- session_id: "session-1",
+ session_id: SESSION_ID,
+ task_id: "task-1",
+ session_incarnation_id: INCARNATION_ID,
+ enabled: false,
+ });
+ });
+});
+describe("setQueueAutoMerge", () => {
+ it("sets the session override with the complete immutable identity", async () => {
+ const request = vi.fn().mockResolvedValue({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ auto_merge_enabled: false,
+ auto_merge_source: "session",
+ auto_merge_revision: 1,
+ });
+ getWebSocketClientMock.mockReturnValue({ request });
+ const setQueueAutoMerge = (
+ queueApi as typeof queueApi & {
+ setQueueAutoMerge?: (
+ identity: {
+ task_id: string;
+ session_id: string;
+ session_incarnation_id: string;
+ },
+ enabled: boolean,
+ ) => Promise;
+ }
+ ).setQueueAutoMerge;
+
+ expect(setQueueAutoMerge).toBeTypeOf("function");
+ await setQueueAutoMerge!(
+ {
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ },
+ false,
+ );
+
+ expect(request).toHaveBeenCalledWith("message.queue.auto_merge.set", {
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
enabled: false,
});
});
@@ -341,16 +450,20 @@ describe("setQueueAutoRun", () => {
describe("reorderQueuedEntries", () => {
it("sends the full ordered id list through message.queue.reorder", async () => {
- const request = vi.fn().mockResolvedValue({ session_id: "session-1", reordered: 3 });
+ const request = vi.fn().mockResolvedValue({ session_id: SESSION_ID, reordered: 3 });
getWebSocketClientMock.mockReturnValue({ request });
await reorderQueuedEntries({
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
ordered_ids: ["q-c", "q-a", "q-b"],
});
expect(request).toHaveBeenCalledWith("message.queue.reorder", {
- session_id: "session-1",
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
ordered_ids: ["q-c", "q-a", "q-b"],
});
});
@@ -363,7 +476,12 @@ describe("reorderQueuedEntries", () => {
getWebSocketClientMock.mockReturnValue({ request });
await expect(
- reorderQueuedEntries({ session_id: "session-1", ordered_ids: ["q-a"] }),
+ reorderQueuedEntries({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ ordered_ids: ["q-a"],
+ }),
).rejects.toBeInstanceOf(QueueReorderError);
});
@@ -372,7 +490,12 @@ describe("reorderQueuedEntries", () => {
getWebSocketClientMock.mockReturnValue({ request });
await expect(
- reorderQueuedEntries({ session_id: "session-1", ordered_ids: ["q-a"] }),
+ reorderQueuedEntries({
+ task_id: "task-1",
+ session_id: SESSION_ID,
+ session_incarnation_id: INCARNATION_ID,
+ ordered_ids: ["q-a"],
+ }),
).rejects.toBeInstanceOf(QueueEntryNotFoundError);
});
});
diff --git a/apps/web/lib/api/domains/queue-api.ts b/apps/web/lib/api/domains/queue-api.ts
index 48547e7a2f..f68de9f23f 100644
--- a/apps/web/lib/api/domains/queue-api.ts
+++ b/apps/web/lib/api/domains/queue-api.ts
@@ -136,8 +136,15 @@ export function rethrowQueueError(err: unknown): never {
throw err instanceof Error ? err : new Error(String(err));
}
+export type QueueSessionIdentity = {
+ task_id: string;
+ session_id: string;
+ session_incarnation_id: string;
+};
+
export type QueueMessageParams = {
session_id: string;
+ session_incarnation_id: string;
task_id: string;
content: string;
model?: string;
@@ -170,25 +177,26 @@ export async function queueMessage(params: QueueMessageParams): Promise {
+export async function clearQueue(identity: QueueSessionIdentity): Promise<{ removed: number }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
}
- return client.request<{ removed: number }>("message.queue.cancel", { session_id: sessionId });
+ return client.request<{ removed: number }>("message.queue.cancel", identity);
}
/** Dispatch one queued entry now when the session is ready for input. */
-export async function drainQueuedMessage(sessionId: string): Promise<{ drained: boolean }> {
+export async function drainQueuedMessage(
+ identity: QueueSessionIdentity,
+): Promise<{ drained: boolean }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
}
- return client.request<{ drained: boolean }>("message.queue.drain", { session_id: sessionId });
+ return client.request<{ drained: boolean }>("message.queue.drain", identity);
}
-export type SendQueuedNowParams = {
- session_id: string;
+export type SendQueuedNowParams = QueueSessionIdentity & {
scope: "entry" | "all";
entry_id?: string;
};
@@ -214,7 +222,7 @@ export async function sendQueuedNow(
/** Persist the per-session queue Auto-run policy and start the queue when enabling it. */
export async function setQueueAutoRun(
- sessionId: string,
+ identity: QueueSessionIdentity,
enabled: boolean,
): Promise<{ session_id: string; auto_run: boolean; dispatched: boolean }> {
const client = getWebSocketClient();
@@ -223,28 +231,48 @@ export async function setQueueAutoRun(
}
return client.request<{ session_id: string; auto_run: boolean; dispatched: boolean }>(
"message.queue.auto_run.set",
- { session_id: sessionId, enabled },
+ { ...identity, enabled },
);
}
+export type QueueAutoMergePolicyResponse = QueueSessionIdentity & {
+ auto_merge_enabled: boolean;
+ auto_merge_source: "global" | "session";
+ auto_merge_revision: number;
+};
-/** Fetch the full queue snapshot (entries + capacity). */
-export async function getQueueStatus(sessionId: string): Promise {
+/** Persist an explicit automatic-merge policy for one immutable session. */
+export async function setQueueAutoMerge(
+ identity: QueueSessionIdentity,
+ enabled: boolean,
+): Promise {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
}
- return client.request("message.queue.get", { session_id: sessionId });
+ return client.request("message.queue.auto_merge.set", {
+ ...identity,
+ enabled,
+ });
+}
+
+/** Fetch the full queue snapshot for one immutable session identity. */
+export async function getQueueStatus(identity: QueueSessionIdentity): Promise {
+ const client = getWebSocketClient();
+ if (!client) {
+ throw new Error(WS_CLIENT_UNAVAILABLE);
+ }
+ return client.request("message.queue.get", identity);
}
/** Append content onto the tail entry when the same caller authored it; otherwise insert a new entry. */
-export async function appendToQueue(params: {
- session_id: string;
- task_id: string;
- content: string;
- model?: string;
- plan_mode?: boolean;
- user_id?: string;
-}): Promise<{ entry_id: string; was_append: boolean }> {
+export async function appendToQueue(
+ params: QueueSessionIdentity & {
+ content: string;
+ model?: string;
+ plan_mode?: boolean;
+ user_id?: string;
+ },
+): Promise<{ entry_id: string; was_append: boolean }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
@@ -260,22 +288,23 @@ export async function appendToQueue(params: {
}
/** Replace the content/attachments of a queued entry. Throws QueueEntryNotFoundError if drained. */
-export async function updateQueuedMessage(params: {
- session_id: string;
- entry_id: string;
- content: string;
- attachments?: Array<{
- type: string;
- data?: string;
- attachment_id?: string;
- mime_type: string;
- name?: string;
- size_bytes?: number;
- delivery_mode?: "prompt" | "path";
- }>;
- entity_references: EntityReference[];
- user_id?: string;
-}): Promise<{ entry_id: string }> {
+export async function updateQueuedMessage(
+ params: QueueSessionIdentity & {
+ entry_id: string;
+ content: string;
+ attachments?: Array<{
+ type: string;
+ data?: string;
+ attachment_id?: string;
+ mime_type: string;
+ name?: string;
+ size_bytes?: number;
+ delivery_mode?: "prompt" | "path";
+ }>;
+ entity_references?: EntityReference[];
+ user_id?: string;
+ },
+): Promise<{ entry_id: string }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
@@ -291,10 +320,9 @@ export async function updateQueuedMessage(params: {
}
/** Remove a single queued entry by id. Throws QueueEntryNotFoundError if drained. */
-export async function removeQueuedEntry(params: {
- session_id: string;
- entry_id: string;
-}): Promise<{ entry_id: string }> {
+export async function removeQueuedEntry(
+ params: QueueSessionIdentity & { entry_id: string },
+): Promise<{ entry_id: string }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
@@ -307,11 +335,9 @@ export async function removeQueuedEntry(params: {
}
/** Fold a queued entry into the entry directly above it. Throws QueueEntryNotFoundError if drained. */
-export async function mergeQueuedEntry(params: {
- session_id: string;
- entry_id: string;
- user_id?: string;
-}): Promise<{ entry_id: string }> {
+export async function mergeQueuedEntry(
+ params: QueueSessionIdentity & { entry_id: string; user_id?: string },
+): Promise<{ entry_id: string }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
@@ -326,10 +352,9 @@ export async function mergeQueuedEntry(params: {
/** Rewrite the visible pending order of a session's queue. Throws
* QueueReorderError when the queue changed since the client's snapshot; the
* reorder was rejected atomically and the caller should refetch. */
-export async function reorderQueuedEntries(params: {
- session_id: string;
- ordered_ids: string[];
-}): Promise<{ session_id: string; reordered: number }> {
+export async function reorderQueuedEntries(
+ params: QueueSessionIdentity & { ordered_ids: string[] },
+): Promise<{ session_id: string; reordered: number }> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
diff --git a/apps/web/lib/state/app-state-types.ts b/apps/web/lib/state/app-state-types.ts
index 552a1cdb08..eceb33fd8d 100644
--- a/apps/web/lib/state/app-state-types.ts
+++ b/apps/web/lib/state/app-state-types.ts
@@ -88,6 +88,12 @@ import type {
TodoEntry,
UserShellInfo,
} from "./slices/session-runtime/types";
+import type {
+ QueueMeta,
+ QueueMetaUpdateOptions,
+ QueueOperationToken,
+ QueuedMessage,
+} from "./slices/session/types";
// Combined AppState type
export type AppState = KanbanSlice & {
// Workspace slice
@@ -526,11 +532,16 @@ export type AppState = KanbanSlice & {
// Queue actions
setQueueEntries: (
sessionId: string,
- entries: import("./slices/session/types").QueuedMessage[],
- meta: import("./slices/session/types").QueueMeta,
+ entries: QueuedMessage[],
+ meta: QueueMeta,
+ options?: QueueMetaUpdateOptions,
) => void;
removeQueueEntry: (sessionId: string, entryId: string) => void;
- setQueueLoading: (sessionId: string, loading: boolean) => void;
+ beginQueueOperation: (
+ sessionId: string,
+ sessionIncarnationId: string,
+ ) => QueueOperationToken | null;
+ finishQueueOperation: (sessionId: string, token: QueueOperationToken) => void;
clearQueueStatus: (sessionId: string) => void;
// Available commands actions
setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
diff --git a/apps/web/lib/state/slices/session/remove-task-session.test.ts b/apps/web/lib/state/slices/session/remove-task-session.test.ts
index f3d801c466..6f63cb092c 100644
--- a/apps/web/lib/state/slices/session/remove-task-session.test.ts
+++ b/apps/web/lib/state/slices/session/remove-task-session.test.ts
@@ -23,6 +23,7 @@ function makeStore() {
const TASK_ID = "task-1";
const SESSION_ID = "session-1";
+const INCARNATION_ID = `incarnation-1`;
describe("removeTaskSession cleanup cascade", () => {
let store: ReturnType;
@@ -86,4 +87,41 @@ describe("removeTaskSession cleanup cascade", () => {
expect(after.shell.outputs["env-1"]).toBeUndefined();
expect(after.environmentIdBySessionId[SESSION_ID]).toBeUndefined();
});
+
+ it("clears queue metadata and protects a replacement operation token", () => {
+ store.setState((draft) => {
+ draft.taskSessions.items[SESSION_ID] = {
+ id: SESSION_ID,
+ task_id: TASK_ID,
+ queue_incarnation_id: INCARNATION_ID,
+ } as never;
+ });
+ expect(store.getState().beginQueueOperation(SESSION_ID, "stale-incarnation")).toBeNull();
+ const first = store.getState().beginQueueOperation(SESSION_ID, INCARNATION_ID);
+ expect(first).not.toBeNull();
+ expect(store.getState().beginQueueOperation(SESSION_ID, INCARNATION_ID)).toBeNull();
+ store.getState().setQueueEntries(SESSION_ID, [], {
+ count: 0,
+ max: 10,
+ autoRun: true,
+ mergeEnabled: true,
+ sessionIncarnationId: INCARNATION_ID,
+ });
+
+ store.getState().removeTaskSession(TASK_ID, SESSION_ID);
+ expect(store.getState().queue.metaBySessionId[SESSION_ID]).toBeUndefined();
+ expect(store.getState().queue.activeOperationBySessionId[SESSION_ID]).toBeUndefined();
+
+ store.setState((draft) => {
+ draft.taskSessions.items[SESSION_ID] = {
+ id: SESSION_ID,
+ task_id: TASK_ID,
+ queue_incarnation_id: "incarnation-2",
+ } as never;
+ });
+ const replacement = store.getState().beginQueueOperation(SESSION_ID, "incarnation-2");
+ expect(replacement).not.toBeNull();
+ store.getState().finishQueueOperation(SESSION_ID, first!);
+ expect(store.getState().queue.activeOperationBySessionId[SESSION_ID]).toEqual(replacement);
+ });
});
diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts
index b01a2ca8b0..d48ec821da 100644
--- a/apps/web/lib/state/slices/session/session-slice.ts
+++ b/apps/web/lib/state/slices/session/session-slice.ts
@@ -2,7 +2,7 @@
import type { StateCreator } from "zustand";
import { original } from "immer";
import type { Message, TaskSession } from "@/lib/types/http";
-import type { SessionSlice, SessionSliceState } from "./types";
+import type { QueueMeta, QueueOperationToken, SessionSlice, SessionSliceState } from "./types";
import { buildTurnActions, isSettledSessionState, parseTurnTimestamp } from "./turn-actions";
import {
buildTaskSessionProjectionActions,
@@ -214,7 +214,12 @@ export const defaultSessionState: SessionSliceState = {
activeStepByTaskId: {},
lastSeenUpdatedAtByTaskId: {},
},
- queue: { bySessionId: {}, metaBySessionId: {}, isLoading: {} },
+ queue: {
+ bySessionId: {},
+ metaBySessionId: {},
+ activeOperationBySessionId: {},
+ nextOperationGeneration: 0,
+ },
};
type ImmerSet = Parameters[0];
@@ -534,6 +539,9 @@ function buildRemoveTaskSessionAction(set: ImmerSet) {
);
}
delete draft.pendingActionProjectionsBySessionId[sessionId];
+ delete draft.queue.bySessionId[sessionId];
+ delete draft.queue.metaBySessionId[sessionId];
+ delete draft.queue.activeOperationBySessionId[sessionId];
// Drop the conversation history owned by this session.
delete draft.messages.bySession[sessionId];
delete draft.messages.metaBySession[sessionId];
@@ -552,6 +560,23 @@ function buildRemoveTaskSessionAction(set: ImmerSet) {
});
}
+function resetQueueStateForReincarnation(
+ draft: Pick,
+ existing: TaskSession | undefined,
+ incoming: Pick,
+): void {
+ if (
+ !existing ||
+ incoming.queue_incarnation_id === undefined ||
+ existing.queue_incarnation_id === incoming.queue_incarnation_id
+ ) {
+ return;
+ }
+ delete draft.queue.bySessionId[incoming.id];
+ delete draft.queue.metaBySessionId[incoming.id];
+ delete draft.queue.activeOperationBySessionId[incoming.id];
+}
+
/** Build actions that reconcile complete session snapshots with partial live events. */
function buildTaskSessionReconciliationActions(set: ImmerSet) {
return {
@@ -563,6 +588,7 @@ function buildTaskSessionReconciliationActions(set: ImmerSet) {
set((draft) => {
const merged = sessions.map((session) => {
const existing = draft.taskSessions.items[session.id];
+ resetQueueStateForReincarnation(draft, existing, session);
const snapshot = mergeTaskSessionSnapshot(
existing,
session,
@@ -595,6 +621,7 @@ function buildTaskSessionReconciliationActions(set: ImmerSet) {
epochs[session.id] = (epochs[session.id] ?? 0) + 1;
}
const existing = draft.taskSessions.items[session.id];
+ resetQueueStateForReincarnation(draft, existing, session);
if (!existing && draft.taskSessionsByTask.loadedByTaskId[taskId]) {
// State events intentionally carry partial session rows. When one
// introduces a new session, let useTaskSessions hydrate fields such
@@ -626,6 +653,7 @@ function buildTaskSessionActions(set: ImmerSet) {
setTaskSession: (session: Parameters[0]) =>
set((draft) => {
const existingSession = draft.taskSessions.items[session.id];
+ resetQueueStateForReincarnation(draft, existingSession, session);
const mergedSession = mergeOrphanPendingActionProjection(
draft.pendingActionProjectionsBySessionId,
existingSession ? mergeTaskSession(existingSession, session) : session,
@@ -664,6 +692,157 @@ function buildTaskSessionActions(set: ImmerSet) {
};
}
+type QueueMetaInput = Parameters[2];
+
+function queueMetaIdentityMatches(currentIncarnationId: string | undefined, meta: QueueMetaInput) {
+ return !meta.sessionIncarnationId || currentIncarnationId === meta.sessionIncarnationId;
+}
+
+function canCarryPreviousQueueMeta(
+ previous: QueueMeta | undefined,
+ currentIncarnationId: string | undefined,
+ meta: QueueMetaInput,
+) {
+ return (
+ previous !== undefined &&
+ previous.sessionIncarnationId === currentIncarnationId &&
+ (!meta.sessionIncarnationId || meta.sessionIncarnationId === previous.sessionIncarnationId)
+ );
+}
+
+function isStaleQueueSnapshot(
+ previous: QueueMeta | undefined,
+ meta: QueueMetaInput,
+ establishStatusEpoch: boolean,
+) {
+ if (
+ meta.statusEpoch !== undefined &&
+ previous?.statusEpoch !== undefined &&
+ meta.statusEpoch !== previous.statusEpoch
+ ) {
+ return !establishStatusEpoch;
+ }
+ return (
+ meta.statusEpoch !== undefined &&
+ meta.statusEpoch === previous?.statusEpoch &&
+ meta.statusGeneration !== undefined &&
+ previous.statusGeneration !== undefined &&
+ meta.statusGeneration <= previous.statusGeneration
+ );
+}
+
+function shouldPreserveSessionPolicy(previous: QueueMeta | undefined, meta: QueueMetaInput) {
+ return (
+ previous?.sessionIncarnationId === meta.sessionIncarnationId &&
+ previous?.autoMergeSource === "session" &&
+ meta.autoMergeSource === "global"
+ );
+}
+
+function shouldPreservePolicyRevision(previous: QueueMeta | undefined, meta: QueueMetaInput) {
+ return (
+ previous?.sessionIncarnationId === meta.sessionIncarnationId &&
+ previous?.autoMergeSource === meta.autoMergeSource &&
+ previous?.autoMergeRevision !== undefined &&
+ meta.autoMergeRevision !== undefined &&
+ meta.autoMergeRevision < previous.autoMergeRevision
+ );
+}
+
+function copyAutoMergePolicy(target: QueueMetaInput, source: QueueMeta) {
+ target.autoMergeAvailable = source.autoMergeAvailable;
+ target.autoMergeEnabled = source.autoMergeEnabled;
+ target.autoMergeSource = source.autoMergeSource;
+ target.autoMergeRevision = source.autoMergeRevision;
+}
+
+function resolveQueueMeta(
+ currentIncarnationId: string | undefined,
+ previous: QueueMeta | undefined,
+ meta: QueueMetaInput,
+ establishStatusEpoch: boolean,
+): QueueMetaInput | null {
+ if (!queueMetaIdentityMatches(currentIncarnationId, meta)) return null;
+ const nextMeta = { ...meta };
+ if (canCarryPreviousQueueMeta(previous, currentIncarnationId, meta)) {
+ Object.assign(nextMeta, previous, meta);
+ }
+ if (isStaleQueueSnapshot(previous, meta, establishStatusEpoch)) return null;
+ if (
+ previous &&
+ (shouldPreserveSessionPolicy(previous, meta) || shouldPreservePolicyRevision(previous, meta))
+ ) {
+ copyAutoMergePolicy(nextMeta, previous);
+ }
+ return nextMeta;
+}
+
+function buildQueueActions(set: ImmerSet) {
+ return {
+ setQueueEntries: (
+ sessionId: Parameters[0],
+ entries: Parameters[1],
+ meta: QueueMetaInput,
+ options?: Parameters[3],
+ ) =>
+ set((draft) => {
+ const nextMeta = resolveQueueMeta(
+ draft.taskSessions.items[sessionId]?.queue_incarnation_id,
+ draft.queue.metaBySessionId[sessionId],
+ meta,
+ options?.establishStatusEpoch === true,
+ );
+ if (!nextMeta) return;
+ draft.queue.bySessionId[sessionId] = entries;
+ draft.queue.metaBySessionId[sessionId] = nextMeta;
+ }),
+ removeQueueEntry: (sessionId: string, entryId: string) =>
+ set((draft) => {
+ const list = draft.queue.bySessionId[sessionId];
+ if (!list) return;
+ draft.queue.bySessionId[sessionId] = list.filter((entry) => entry.id !== entryId);
+ const meta = draft.queue.metaBySessionId[sessionId];
+ if (meta) meta.count = draft.queue.bySessionId[sessionId].length;
+ }),
+ beginQueueOperation: (sessionId: string, sessionIncarnationId: string) => {
+ let token: QueueOperationToken | null = null;
+ set((draft) => {
+ const session = draft.taskSessions.items[sessionId];
+ if (
+ session?.queue_incarnation_id !== sessionIncarnationId ||
+ draft.queue.activeOperationBySessionId[sessionId]
+ ) {
+ return;
+ }
+ token = {
+ sessionIncarnationId,
+ generation: ++draft.queue.nextOperationGeneration,
+ };
+ draft.queue.activeOperationBySessionId[sessionId] = token;
+ });
+ return token;
+ },
+ finishQueueOperation: (sessionId: string, token: QueueOperationToken) =>
+ set((draft) => {
+ const current = draft.queue.activeOperationBySessionId[sessionId];
+ const session = draft.taskSessions.items[sessionId];
+ if (
+ current?.generation === token.generation &&
+ current.sessionIncarnationId === token.sessionIncarnationId &&
+ session?.queue_incarnation_id === token.sessionIncarnationId
+ ) {
+ delete draft.queue.activeOperationBySessionId[sessionId];
+ }
+ }),
+ clearQueueStatus: (sessionId: string) =>
+ set((draft) => {
+ delete draft.queue.bySessionId[sessionId];
+ delete draft.queue.metaBySessionId[sessionId];
+ delete draft.queue.activeOperationBySessionId[sessionId];
+ }),
+ };
+}
+
/** Create the session slice, combining the default state with all session action builders wired to the store's Immer set/get. */
export const createSessionSlice: StateCreator<
SessionSlice,
@@ -704,29 +883,5 @@ export const createSessionSlice: StateCreator<
}),
...buildTaskPlanActions(set, get),
...buildWalkthroughActions(set, get),
- setQueueEntries: (sessionId, entries, meta) =>
- set((draft) => {
- draft.queue.bySessionId[sessionId] = entries;
- draft.queue.metaBySessionId[sessionId] = meta;
- }),
- removeQueueEntry: (sessionId, entryId) =>
- set((draft) => {
- const list = draft.queue.bySessionId[sessionId];
- if (!list) return;
- draft.queue.bySessionId[sessionId] = list.filter((entry) => entry.id !== entryId);
- const meta = draft.queue.metaBySessionId[sessionId];
- if (meta) {
- meta.count = draft.queue.bySessionId[sessionId].length;
- }
- }),
- setQueueLoading: (sessionId, loading) =>
- set((draft) => {
- draft.queue.isLoading[sessionId] = loading;
- }),
- clearQueueStatus: (sessionId) =>
- set((draft) => {
- delete draft.queue.bySessionId[sessionId];
- delete draft.queue.metaBySessionId[sessionId];
- delete draft.queue.isLoading[sessionId];
- }),
+ ...buildQueueActions(set),
});
diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
index e364cdcfdc..32712d4a3b 100644
--- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
+++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
@@ -557,8 +557,10 @@ describe("queue actions", () => {
mergeEnabled: false,
autoRun: false,
});
- expect(store.getState().queue.metaBySessionId[SESSION_ID]?.mergeEnabled).toBe(false);
- expect(store.getState().queue.metaBySessionId[SESSION_ID]?.autoRun).toBe(false);
+ expect(store.getState().queue.metaBySessionId[SESSION_ID]).toMatchObject({
+ mergeEnabled: false,
+ autoRun: false,
+ });
});
it("removeQueueEntry drops a single entry by id and refreshes meta.count", () => {
@@ -574,8 +576,7 @@ describe("queue actions", () => {
store.getState().removeQueueEntry(SESSION_ID, "e2");
expect(store.getState().queue.bySessionId[SESSION_ID].map((e) => e.id)).toEqual(["e1", "e3"]);
- expect(store.getState().queue.metaBySessionId[SESSION_ID].count).toBe(2);
- expect(store.getState().queue.metaBySessionId[SESSION_ID].max).toBe(10);
+ expect(store.getState().queue.metaBySessionId[SESSION_ID]).toMatchObject({ count: 2, max: 10 });
});
it("removeQueueEntry is a no-op when the session has no entries", () => {
@@ -583,6 +584,42 @@ describe("queue actions", () => {
store.getState().removeQueueEntry(SESSION_ID, "missing");
expect(store.getState().queue.bySessionId[SESSION_ID]).toBeUndefined();
});
+});
+
+describe("queue snapshot actions", () => {
+ it("requires an authoritative snapshot to replace the accepted status epoch", () => {
+ const store = makeStore();
+ store.getState().setTaskSession(makeSession({ queue_incarnation_id: "incarnation-1" }));
+ const currentEntries = [makeEntry({ id: "current" })];
+ store.getState().setQueueEntries(SESSION_ID, currentEntries, {
+ count: 1,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: "incarnation-1",
+ statusEpoch: "epoch-1",
+ });
+ const replacementEntries = [makeEntry({ id: "replacement" })];
+ const replacementMeta = {
+ count: 1,
+ max: 10,
+ mergeEnabled: false,
+ autoRun: false,
+ sessionIncarnationId: "incarnation-1",
+ statusEpoch: "epoch-2",
+ };
+
+ store.getState().setQueueEntries(SESSION_ID, replacementEntries, replacementMeta);
+ expect(store.getState().queue.bySessionId[SESSION_ID]).toEqual(currentEntries);
+ expect(store.getState().queue.metaBySessionId[SESSION_ID]?.statusEpoch).toBe("epoch-1");
+
+ store.getState().setQueueEntries(SESSION_ID, replacementEntries, replacementMeta, {
+ establishStatusEpoch: true,
+ });
+ expect(store.getState().queue.bySessionId[SESSION_ID]).toEqual(replacementEntries);
+ expect(store.getState().queue.metaBySessionId[SESSION_ID]?.statusEpoch).toBe("epoch-2");
+ });
it("clearQueueStatus removes both entries and meta", () => {
const store = makeStore();
@@ -599,10 +636,6 @@ describe("queue actions", () => {
expect(store.getState().queue.metaBySessionId[SESSION_ID]).toBeUndefined();
});
- // AC-75: a queued-but-unadmitted prompt is a backend-owned reading (task-05's
- // D8 table decides whether parked_on_background_work stays true); the queue
- // actions here are purely local UI state and must never locally clear the
- // rendered parked/foreground affordance as a side effect.
it("does not clear parked_on_background_work or foreground_activity as a side effect of queue actions", () => {
const store = makeStore();
store.getState().upsertTaskSessionFromEvent(
@@ -622,7 +655,6 @@ describe("queue actions", () => {
autoRun: true,
});
store.getState().removeQueueEntry(SESSION_ID, "missing-entry");
- store.getState().setQueueLoading(SESSION_ID, true);
store.getState().clearQueueStatus(SESSION_ID);
const session = store.getState().taskSessions.items[SESSION_ID];
@@ -631,3 +663,61 @@ describe("queue actions", () => {
expect(session.parked_epoch).toBe(100);
});
});
+
+describe("queue state reincarnation", () => {
+ function seedOldIncarnation() {
+ const store = makeStore();
+ store.getState().setTaskSession(makeSession({ queue_incarnation_id: "old-incarnation" }));
+ store.getState().setQueueEntries(SESSION_ID, [makeEntry()], {
+ count: 1,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: TASK_ID,
+ sessionIncarnationId: "old-incarnation",
+ });
+ expect(store.getState().beginQueueOperation(SESSION_ID, "old-incarnation")).not.toBeNull();
+ return store;
+ }
+
+ function expectQueueStateCleared(store: ReturnType) {
+ expect(store.getState().queue.bySessionId[SESSION_ID]).toBeUndefined();
+ expect(store.getState().queue.metaBySessionId[SESSION_ID]).toBeUndefined();
+ expect(store.getState().queue.activeOperationBySessionId[SESSION_ID]).toBeUndefined();
+ }
+
+ it("clears stale queue state on an event upsert", () => {
+ const store = seedOldIncarnation();
+
+ store
+ .getState()
+ .upsertTaskSessionFromEvent(
+ TASK_ID,
+ makeSession({ queue_incarnation_id: "new-incarnation" }),
+ );
+
+ expectQueueStateCleared(store);
+ });
+
+ it("clears stale queue state on a session snapshot", () => {
+ const store = seedOldIncarnation();
+
+ store
+ .getState()
+ .setTaskSessionsForTask(
+ TASK_ID,
+ [makeSession({ queue_incarnation_id: "new-incarnation" })],
+ {},
+ );
+
+ expectQueueStateCleared(store);
+ });
+
+ it("clears stale queue state on a direct session set", () => {
+ const store = seedOldIncarnation();
+
+ store.getState().setTaskSession(makeSession({ queue_incarnation_id: "new-incarnation" }));
+
+ expectQueueStateCleared(store);
+ });
+});
diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts
index 35452fcd6a..71c453fcef 100644
--- a/apps/web/lib/state/slices/session/types.ts
+++ b/apps/web/lib/state/slices/session/types.ts
@@ -192,17 +192,42 @@ export type QueueMeta = {
max: number;
/** Backend-owned queue motion policy. Missing server state defaults to on. */
autoRun: boolean;
- /** Mirrors the server's message queue merge_enabled setting; hides the
- * "Merge with above" affordance without a separate settings fetch. */
+ /** Mirrors the server's manual merge setting. */
mergeEnabled: boolean;
+ taskId?: string;
+ sessionIncarnationId?: string;
+ statusEpoch?: string;
+ statusGeneration?: number;
+ retiredStatusEpochs?: string[];
+ autoMergeAvailable?: boolean;
+ autoMergeEnabled?: boolean;
+ autoMergeSource?: "global" | "session";
+ autoMergeRevision?: number;
+};
+
+export type QueueMetaUpdateOptions = {
+ establishStatusEpoch?: boolean;
};
export type QueueStatus = {
entries: QueuedMessage[];
count: number;
max: number;
+ task_id?: string;
+ session_id?: string;
+ session_incarnation_id?: string;
+ status_epoch?: string;
+ status_generation?: number;
merge_enabled: boolean;
auto_run?: boolean;
+ auto_merge_available?: boolean;
+ auto_merge_enabled?: boolean;
+ auto_merge_source?: "global" | "session";
+ auto_merge_revision?: number;
+};
+export type QueueOperationToken = {
+ sessionIncarnationId: string;
+ generation: number;
};
export type QueueState = {
@@ -210,7 +235,8 @@ export type QueueState = {
bySessionId: Record;
/** Per-session capacity snapshot from the latest server response. */
metaBySessionId: Record;
- isLoading: Record;
+ activeOperationBySessionId: Record;
+ nextOperationGeneration: number;
};
export type SessionSliceState = {
@@ -376,9 +402,18 @@ export type SessionSliceActions = {
setWalkthroughActiveStep: (taskId: string, stepIndex: number) => void;
markWalkthroughSeen: (taskId: string) => void;
// Queue actions
- setQueueEntries: (sessionId: string, entries: QueuedMessage[], meta: QueueMeta) => void;
+ setQueueEntries: (
+ sessionId: string,
+ entries: QueuedMessage[],
+ meta: QueueMeta,
+ options?: QueueMetaUpdateOptions,
+ ) => void;
removeQueueEntry: (sessionId: string, entryId: string) => void;
- setQueueLoading: (sessionId: string, loading: boolean) => void;
+ beginQueueOperation: (
+ sessionId: string,
+ sessionIncarnationId: string,
+ ) => QueueOperationToken | null;
+ finishQueueOperation: (sessionId: string, token: QueueOperationToken) => void;
clearQueueStatus: (sessionId: string) => void;
};
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 384c149284..a3bab8e96b 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -554,6 +554,8 @@ export type TaskSessionWorktree = {
export type TaskSession = ActiveSubagentCountFields & {
id: SessionId;
task_id: TaskId;
+ /** Immutable queue ownership identity; changes when a textual session ID is recreated. */
+ queue_incarnation_id?: string;
/** Optional user-supplied label shown on the session tab. */
name?: string;
agent_profile_id?: AgentProfileId;
diff --git a/apps/web/lib/types/session-events.ts b/apps/web/lib/types/session-events.ts
index ea75f4172c..ab807ac864 100644
--- a/apps/web/lib/types/session-events.ts
+++ b/apps/web/lib/types/session-events.ts
@@ -204,12 +204,20 @@ export type ProcessStatusPayload = {
};
export type QueueStatusChangedPayload = {
+ task_id?: string;
session_id: string;
+ session_incarnation_id?: string;
+ status_epoch?: string;
+ status_generation?: number;
entries?: QueuedMessage[] | null;
count?: number;
max?: number;
merge_enabled?: boolean;
auto_run?: boolean;
+ auto_merge_available?: boolean;
+ auto_merge_enabled?: boolean;
+ auto_merge_source?: "global" | "session";
+ auto_merge_revision?: number;
};
export type AvailableCommandPayload = {
diff --git a/apps/web/lib/ws/client.test.ts b/apps/web/lib/ws/client.test.ts
index 43cf742cf5..e4c30b981a 100644
--- a/apps/web/lib/ws/client.test.ts
+++ b/apps/web/lib/ws/client.test.ts
@@ -169,6 +169,54 @@ describe("session subscription readiness", () => {
});
});
+describe("connection generations", () => {
+ it("ignores notifications delivered by a replaced socket", () => {
+ vi.useFakeTimers();
+ const { client, socket } = connectClient({ enabled: true, initialDelay: 0, maxAttempts: 1 });
+ const handler = vi.fn();
+ client.on("message.queue.status_changed", handler);
+
+ socket.close();
+ vi.advanceTimersByTime(0);
+ const reconnectedSocket = FakeWebSocket.latest();
+ reconnectedSocket.open();
+
+ socket.receive({
+ id: "stale-status",
+ type: "notification",
+ action: "message.queue.status_changed",
+ payload: {
+ task_id: "task-1",
+ session_id: "session-1",
+ session_incarnation_id: "incarnation-1",
+ status_epoch: "old-backend",
+ status_generation: 99,
+ count: 1,
+ max: 10,
+ merge_enabled: true,
+ },
+ });
+ expect(handler).not.toHaveBeenCalled();
+
+ reconnectedSocket.receive({
+ id: "current-status",
+ type: "notification",
+ action: "message.queue.status_changed",
+ payload: {
+ task_id: "task-1",
+ session_id: "session-1",
+ session_incarnation_id: "incarnation-1",
+ status_epoch: "current-backend",
+ status_generation: 1,
+ count: 0,
+ max: 10,
+ merge_enabled: true,
+ },
+ });
+ expect(handler).toHaveBeenCalledOnce();
+ });
+});
+
describe("request errors", () => {
it("retains the backend code and details when a request fails", async () => {
const { client, socket } = connectClient();
diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts
index ed255f72af..56eb9b7589 100644
--- a/apps/web/lib/ws/client.ts
+++ b/apps/web/lib/ws/client.ts
@@ -103,16 +103,19 @@ export class WebSocketClient {
this.intentionalClose = false;
this.clearReconnectTimer();
this.setStatus("connecting");
- this.socket = new WebSocket(this.url);
+ const socket = new WebSocket(this.url);
+ this.socket = socket;
- this.socket.onopen = () => {
+ socket.onopen = () => {
+ if (this.socket !== socket) return;
this.reconnectAttempts = 0;
this.setStatus("connected");
this.resubscribe();
this.flushQueue();
};
- this.socket.onmessage = (event) => {
+ socket.onmessage = (event) => {
+ if (this.socket !== socket) return;
const parts = (event.data as string).split("\n");
for (const part of parts) {
const trimmed = part.trim();
@@ -126,11 +129,10 @@ export class WebSocketClient {
}
};
- this.socket.onerror = () => {
- this.setStatus("error");
- };
+ socket.onerror = () => (this.socket === socket ? this.setStatus("error") : undefined);
- this.socket.onclose = (event) => {
+ socket.onclose = (event) => {
+ if (this.socket !== socket) return;
this.socket = null;
this.handleDisconnect(event);
};
diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts
index 5a2354ccc4..2bb5b59f2f 100644
--- a/apps/web/lib/ws/handlers/agent-session.test.ts
+++ b/apps/web/lib/ws/handlers/agent-session.test.ts
@@ -11,6 +11,9 @@ import type {
TaskSessionCancellationChangedPayload,
TaskSessionStateChangedPayload,
} from "@/lib/types/backend";
+const QUEUE_STATUS_ACTION = `message.queue.status_changed`;
+const CURRENT_INCARNATION = `incarnation-2`;
+const CURRENT_STATUS_EPOCH = `status-epoch-1`;
function makeStore(overrides: Record = {}) {
const state: Record = {
@@ -55,12 +58,12 @@ describe("message.queue.status_changed handler", () => {
it("stores the backend-owned Auto-run policy", () => {
const setQueueEntries = vi.fn();
const store = makeStore({ setQueueEntries });
- const handler = registerTaskSessionHandlers(store)["message.queue.status_changed"]!;
+ const handler = registerTaskSessionHandlers(store)[QUEUE_STATUS_ACTION]!;
handler({
id: "queue-status-1",
type: "notification",
- action: "message.queue.status_changed",
+ action: QUEUE_STATUS_ACTION,
payload: {
session_id: "s-1",
entries: [],
@@ -90,12 +93,12 @@ describe("message.queue.status_changed handler", () => {
},
},
});
- const handler = registerTaskSessionHandlers(store)["message.queue.status_changed"]!;
+ const handler = registerTaskSessionHandlers(store)[QUEUE_STATUS_ACTION]!;
handler({
id: "queue-status-compat",
type: "notification",
- action: "message.queue.status_changed",
+ action: QUEUE_STATUS_ACTION,
payload: { session_id: "s-1", entries: [], count: 0, max: 10 },
} as never);
@@ -107,6 +110,201 @@ describe("message.queue.status_changed handler", () => {
});
});
});
+function makeIncarnationPolicyStore(setQueueEntries: ReturnType) {
+ return makeStore({
+ setQueueEntries,
+ taskSessions: {
+ items: {
+ "s-1": {
+ id: "s-1",
+ task_id: "task-1",
+ queue_incarnation_id: CURRENT_INCARNATION,
+ },
+ },
+ },
+ queue: {
+ bySessionId: { "s-1": [] },
+ metaBySessionId: {
+ "s-1": {
+ count: 0,
+ max: 10,
+ mergeEnabled: true,
+ autoRun: true,
+ taskId: "task-1",
+ sessionIncarnationId: CURRENT_INCARNATION,
+ statusEpoch: CURRENT_STATUS_EPOCH,
+ statusGeneration: 4,
+ autoMergeAvailable: true,
+ autoMergeEnabled: false,
+ autoMergeSource: "session",
+ autoMergeRevision: 1,
+ },
+ },
+ },
+ });
+}
+
+it("rejects delayed legacy queue status after an incarnated session refetch", () => {
+ const setQueueEntries = vi.fn();
+ const store = makeStore({
+ setQueueEntries,
+ taskSessions: {
+ items: {
+ "s-1": {
+ id: "s-1",
+ task_id: "task-1",
+ queue_incarnation_id: CURRENT_INCARNATION,
+ },
+ },
+ },
+ queue: { bySessionId: {}, metaBySessionId: {} },
+ });
+ const handler = registerTaskSessionHandlers(store)[QUEUE_STATUS_ACTION]!;
+
+ handler({
+ id: "delayed-legacy-status",
+ type: "notification",
+ action: QUEUE_STATUS_ACTION,
+ payload: {
+ session_id: "s-1",
+ entries: [{ id: "stale-entry", content: "stale" }],
+ count: 1,
+ max: 10,
+ auto_run: false,
+ },
+ } as never);
+
+ expect(setQueueEntries).not.toHaveBeenCalled();
+});
+
+it("applies only the current incarnation's newest Auto-merge policy", () => {
+ const setQueueEntries = vi.fn();
+ const store = makeIncarnationPolicyStore(setQueueEntries);
+ const handler = registerTaskSessionHandlers(store)[QUEUE_STATUS_ACTION]!;
+
+ handler({
+ id: "stale-incarnation",
+ type: "notification",
+ action: QUEUE_STATUS_ACTION,
+ payload: {
+ task_id: "task-1",
+ session_id: "s-1",
+ session_incarnation_id: "incarnation-1",
+ status_epoch: "incarnation-1",
+ status_generation: 99,
+ entries: [],
+ count: 0,
+ max: 10,
+ auto_merge_available: true,
+ auto_merge_enabled: true,
+ auto_merge_source: "global",
+ auto_merge_revision: 8,
+ },
+ } as never);
+ expect(setQueueEntries).not.toHaveBeenCalled();
+
+ handler({
+ id: "new-status",
+ type: "notification",
+ action: QUEUE_STATUS_ACTION,
+ payload: {
+ task_id: "task-1",
+ session_id: "s-1",
+ session_incarnation_id: CURRENT_INCARNATION,
+ status_epoch: CURRENT_STATUS_EPOCH,
+ status_generation: 5,
+ entries: [],
+ count: 0,
+ max: 10,
+ merge_enabled: true,
+ auto_run: true,
+ auto_merge_available: true,
+ auto_merge_enabled: true,
+ auto_merge_source: "session",
+ auto_merge_revision: 2,
+ },
+ } as never);
+ expect(setQueueEntries).toHaveBeenCalledWith(
+ "s-1",
+ [],
+ expect.objectContaining({
+ sessionIncarnationId: CURRENT_INCARNATION,
+ statusGeneration: 5,
+ autoMergeAvailable: true,
+ autoMergeEnabled: true,
+ autoMergeSource: "session",
+ autoMergeRevision: 2,
+ }),
+ );
+});
+
+it("establishes a new backend status epoch and rejects delayed retired events", () => {
+ const setQueueEntries = vi.fn();
+ const store = makeIncarnationPolicyStore(setQueueEntries);
+ const handler = registerTaskSessionHandlers(store)[QUEUE_STATUS_ACTION]!;
+
+ handler({
+ id: "new-backend-epoch",
+ type: "notification",
+ action: QUEUE_STATUS_ACTION,
+ payload: {
+ task_id: "task-1",
+ session_id: "s-1",
+ session_incarnation_id: CURRENT_INCARNATION,
+ status_epoch: "status-epoch-2",
+ status_generation: 1,
+ entries: [{ id: "current-entry", content: "current" }],
+ count: 1,
+ max: 10,
+ merge_enabled: true,
+ auto_run: false,
+ auto_merge_available: true,
+ auto_merge_enabled: true,
+ auto_merge_source: "session",
+ auto_merge_revision: 2,
+ },
+ } as never);
+ expect(setQueueEntries).toHaveBeenCalledWith(
+ "s-1",
+ [{ id: "current-entry", content: "current" }],
+ expect.objectContaining({
+ statusEpoch: "status-epoch-2",
+ statusGeneration: 1,
+ retiredStatusEpochs: [CURRENT_STATUS_EPOCH],
+ }),
+ { establishStatusEpoch: true },
+ );
+
+ store.getState().queue.metaBySessionId["s-1"] = {
+ ...store.getState().queue.metaBySessionId["s-1"],
+ statusEpoch: "status-epoch-2",
+ statusGeneration: 1,
+ retiredStatusEpochs: [CURRENT_STATUS_EPOCH],
+ };
+ handler({
+ id: "delayed-retired-backend-epoch",
+ type: "notification",
+ action: QUEUE_STATUS_ACTION,
+ payload: {
+ task_id: "task-1",
+ session_id: "s-1",
+ session_incarnation_id: CURRENT_INCARNATION,
+ status_epoch: CURRENT_STATUS_EPOCH,
+ status_generation: 99,
+ entries: [{ id: "stale-entry", content: "stale" }],
+ count: 1,
+ max: 10,
+ merge_enabled: true,
+ auto_run: false,
+ auto_merge_available: true,
+ auto_merge_enabled: true,
+ auto_merge_source: "session",
+ auto_merge_revision: 2,
+ },
+ } as never);
+
+ expect(setQueueEntries).toHaveBeenCalledTimes(1);
+});
const STATE_CHANGED_EVENT = "session.state_changed";
const ACTIVITY_EVENT = "session.activity_changed";
diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts
index a837097186..39ba2fbb65 100644
--- a/apps/web/lib/ws/handlers/agent-session.ts
+++ b/apps/web/lib/ws/handlers/agent-session.ts
@@ -2,6 +2,7 @@
import type { StoreApi } from "zustand";
import { createDebugLogger } from "@/lib/debug/log";
import type { AppState } from "@/lib/state/store";
+import type { QueueMeta } from "@/lib/state/slices/session/types";
import type { WsHandlers } from "@/lib/ws/handlers/types";
import {
sessionId as toSessionId,
@@ -635,8 +636,130 @@ function handleCancellationPendingMessage(
applyCancellationPending(store, payload);
}
+function isCompleteQueueStatus(payload: QueueStatusChangedPayload): boolean {
+ return (
+ payload.task_id !== undefined &&
+ payload.session_incarnation_id !== undefined &&
+ payload.status_epoch !== undefined &&
+ payload.status_generation !== undefined &&
+ payload.entries !== undefined &&
+ typeof payload.count === "number" &&
+ typeof payload.max === "number" &&
+ typeof payload.merge_enabled === "boolean" &&
+ typeof payload.auto_run === "boolean"
+ );
+}
+
+// eslint-disable-next-line complexity -- incarnation, epoch, and generation form one ordering boundary.
+function rejectsQueueStatus(
+ state: AppState,
+ payload: QueueStatusChangedPayload,
+ previousMeta: QueueMeta | undefined,
+): boolean {
+ const currentSession = state.taskSessions.items[payload.session_id];
+ if (currentSession?.queue_incarnation_id && payload.session_incarnation_id === undefined) {
+ return true;
+ }
+ if (
+ payload.session_incarnation_id !== undefined &&
+ (!currentSession ||
+ currentSession.task_id !== payload.task_id ||
+ currentSession.queue_incarnation_id !== payload.session_incarnation_id)
+ ) {
+ return true;
+ }
+ const statusEpochChanged =
+ payload.status_epoch !== undefined &&
+ previousMeta?.statusEpoch !== undefined &&
+ payload.status_epoch !== previousMeta.statusEpoch;
+ if (statusEpochChanged && payload.status_epoch !== undefined) {
+ if (previousMeta.retiredStatusEpochs?.includes(payload.status_epoch)) {
+ return true;
+ }
+ return !isCompleteQueueStatus(payload);
+ }
+ return (
+ payload.status_generation !== undefined &&
+ previousMeta?.statusGeneration !== undefined &&
+ payload.status_generation <= previousMeta.statusGeneration
+ );
+}
+
+// eslint-disable-next-line complexity -- status-epoch replacement retains every prior epoch.
+function queueIdentityMeta(
+ payload: QueueStatusChangedPayload,
+ previousMeta: QueueMeta | undefined,
+) {
+ if (
+ payload.session_incarnation_id === undefined &&
+ previousMeta?.sessionIncarnationId === undefined
+ ) {
+ return {};
+ }
+ const statusEpoch = payload.status_epoch ?? previousMeta?.statusEpoch;
+ const statusEpochChanged =
+ payload.status_epoch !== undefined &&
+ previousMeta?.statusEpoch !== undefined &&
+ payload.status_epoch !== previousMeta.statusEpoch;
+ const retiredStatusEpochs = statusEpochChanged
+ ? [...(previousMeta?.retiredStatusEpochs ?? []), previousMeta?.statusEpoch]
+ .filter((epoch): epoch is string => epoch !== undefined && epoch !== statusEpoch)
+ .filter((epoch, index, epochs) => epochs.indexOf(epoch) === index)
+ : previousMeta?.retiredStatusEpochs;
+ return {
+ taskId: payload.task_id ?? previousMeta?.taskId,
+ sessionIncarnationId: payload.session_incarnation_id ?? previousMeta?.sessionIncarnationId,
+ statusEpoch,
+ statusGeneration: payload.status_generation ?? previousMeta?.statusGeneration,
+ retiredStatusEpochs,
+ };
+}
+
+function resolveQueuePolicyValue(
+ preserveSessionPolicy: boolean,
+ previousValue: T | undefined,
+ incomingValue: T | undefined,
+): T | undefined {
+ return preserveSessionPolicy ? previousValue : (incomingValue ?? previousValue);
+}
+
+function queueAutoMergeMeta(
+ payload: QueueStatusChangedPayload,
+ previousMeta: QueueMeta | undefined,
+) {
+ if (
+ payload.auto_merge_available === undefined &&
+ previousMeta?.autoMergeAvailable === undefined
+ ) {
+ return {};
+ }
+ const preserveSessionPolicy =
+ previousMeta?.sessionIncarnationId === payload.session_incarnation_id &&
+ previousMeta?.autoMergeSource === "session" &&
+ payload.auto_merge_source === "global";
+ return {
+ autoMergeAvailable: payload.auto_merge_available ?? previousMeta?.autoMergeAvailable,
+ autoMergeEnabled: resolveQueuePolicyValue(
+ preserveSessionPolicy,
+ previousMeta?.autoMergeEnabled,
+ payload.auto_merge_enabled,
+ ),
+ autoMergeSource: resolveQueuePolicyValue(
+ preserveSessionPolicy,
+ previousMeta?.autoMergeSource,
+ payload.auto_merge_source,
+ ),
+ autoMergeRevision: resolveQueuePolicyValue(
+ preserveSessionPolicy,
+ previousMeta?.autoMergeRevision,
+ payload.auto_merge_revision,
+ ),
+ };
+}
+
/** Writes a message.queue.status_changed broadcast into the queue slice,
* preserving known policy values when an older publisher omits them. */
+// eslint-disable-next-line complexity -- one handler atomically establishes a queue snapshot.
function handleQueueStatusChangedMessage(
store: StoreApi,
payload: QueueStatusChangedPayload,
@@ -645,19 +768,28 @@ function handleQueueStatusChangedMessage(
console.warn("[Queue] Missing session_id in queue status change event");
return;
}
+ const state = store.getState();
+ const previousMeta = state.queue.metaBySessionId[payload.session_id];
+ if (rejectsQueueStatus(state, payload, previousMeta)) return;
+
const entries = payload.entries ?? [];
- const count = typeof payload.count === "number" ? payload.count : entries.length;
- const max = typeof payload.max === "number" ? payload.max : 0;
- const previousMeta = store.getState().queue.metaBySessionId[payload.session_id];
- const mergeEnabled =
- typeof payload.merge_enabled === "boolean"
- ? payload.merge_enabled
- : (previousMeta?.mergeEnabled ?? true);
- const autoRun =
- typeof payload.auto_run === "boolean" ? payload.auto_run : (previousMeta?.autoRun ?? true);
- store
- .getState()
- .setQueueEntries(payload.session_id, entries, { count, max, mergeEnabled, autoRun });
+ const meta = {
+ count: typeof payload.count === "number" ? payload.count : entries.length,
+ max: typeof payload.max === "number" ? payload.max : 0,
+ mergeEnabled: payload.merge_enabled ?? previousMeta?.mergeEnabled ?? true,
+ autoRun: payload.auto_run ?? previousMeta?.autoRun ?? true,
+ ...queueIdentityMeta(payload, previousMeta),
+ ...queueAutoMergeMeta(payload, previousMeta),
+ };
+ const establishesStatusEpoch =
+ payload.status_epoch !== undefined &&
+ previousMeta?.statusEpoch !== undefined &&
+ payload.status_epoch !== previousMeta.statusEpoch;
+ if (establishesStatusEpoch) {
+ state.setQueueEntries(payload.session_id, entries, meta, { establishStatusEpoch: true });
+ return;
+ }
+ state.setQueueEntries(payload.session_id, entries, meta);
}
/** Registers the task-session WebSocket handlers (state, messages, workspace sources, queue). */
diff --git a/apps/web/lib/ws/handlers/tasks-archive.test.ts b/apps/web/lib/ws/handlers/tasks-archive.test.ts
index a93eca914c..c72d346cae 100644
--- a/apps/web/lib/ws/handlers/tasks-archive.test.ts
+++ b/apps/web/lib/ws/handlers/tasks-archive.test.ts
@@ -25,12 +25,14 @@ function makeStore(initial: Partial = {}) {
lastSessionByTaskId: {},
},
taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} },
+ taskSessions: { items: {} },
environmentIdBySessionId: {},
setActiveSessionAuto: vi.fn(),
removeTaskFromSidebarPrefs: vi.fn(),
setTaskDeletedNotification: vi.fn(),
upsertQuickChatSessionFromEvent: vi.fn(),
removeQuickChatSessionsForTask: vi.fn(),
+ clearQueueStatus: vi.fn(),
setOfficeRefetchTrigger: vi.fn(),
...initial,
} as unknown as AppState;
diff --git a/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts b/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts
index 9871e7dd2b..0e6c239ea2 100644
--- a/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts
+++ b/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts
@@ -20,6 +20,7 @@ function makeStore() {
lastSessionByTaskId: {},
},
taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} },
+ taskSessions: { items: {} },
environmentIdBySessionId: {},
walkthroughs: {
byTaskId: {
@@ -41,6 +42,7 @@ function makeStore() {
setTaskDeletedNotification: vi.fn(),
upsertQuickChatSessionFromEvent: vi.fn(),
removeQuickChatSessionsForTask: vi.fn(),
+ clearQueueStatus: vi.fn(),
} as unknown as AppState;
const listeners = new Set();
diff --git a/apps/web/lib/ws/handlers/tasks.deleted.test.ts b/apps/web/lib/ws/handlers/tasks.deleted.test.ts
index c90d764681..5cff505e1e 100644
--- a/apps/web/lib/ws/handlers/tasks.deleted.test.ts
+++ b/apps/web/lib/ws/handlers/tasks.deleted.test.ts
@@ -62,6 +62,27 @@ describe("task.deleted cleanup", () => {
expect(state.tasks.pinnedSessionId).toBeNull();
expect(state.tasks.lastSessionByTaskId).not.toHaveProperty("t1");
expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", SESS_OTHER);
+ expect(state.clearQueueStatus).toHaveBeenCalledWith(SESS_PINNED);
+ });
+
+ it("clears normalized sessions even when no queue metadata was fetched", () => {
+ const store = makeStore({
+ taskSessions: {
+ items: {
+ "sess-normalized": {
+ id: "sess-normalized",
+ task_id: "t1",
+ queue_incarnation_id: "inc-normalized",
+ },
+ },
+ },
+ } as unknown as Partial);
+
+ registerTasksHandlers(store)["task.deleted"]!(
+ makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" }),
+ );
+
+ expect(store.getState().clearQueueStatus).toHaveBeenCalledWith("sess-normalized");
});
it("removes deleted tasks from the archived sidebar projection", () => {
diff --git a/apps/web/lib/ws/handlers/tasks.test-helpers.ts b/apps/web/lib/ws/handlers/tasks.test-helpers.ts
index 98d29d277d..0f1f380ac7 100644
--- a/apps/web/lib/ws/handlers/tasks.test-helpers.ts
+++ b/apps/web/lib/ws/handlers/tasks.test-helpers.ts
@@ -22,6 +22,7 @@ export function makeStore(initial: Partial = {}) {
resumeSkippedSessionIds: {},
},
taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} },
+ taskSessions: { items: {} },
environmentIdBySessionId: {},
setActiveSession: vi.fn((taskId: string, sessionId: string | null) => {
state = {
@@ -51,6 +52,7 @@ export function makeStore(initial: Partial = {}) {
setTaskDeletedNotification: vi.fn(),
upsertQuickChatSessionFromEvent: vi.fn(),
removeQuickChatSessionsForTask: vi.fn(),
+ clearQueueStatus: vi.fn(),
...initial,
} as unknown as AppState;
diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts
index 20057e8055..cce9dbe76e 100644
--- a/apps/web/lib/ws/handlers/tasks.ts
+++ b/apps/web/lib/ws/handlers/tasks.ts
@@ -397,14 +397,21 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers {
"task.updated": (message) => handleTaskUpdated(store, message),
"task.deleted": (message) => {
const deletedId = message.payload.task_id;
+ const currentState = store.getState();
removeRecentTask(deletedId);
// A quick chat closed on another device must not linger here as a tab
// pointing at a task the backend already deleted.
store.getState().removeQuickChatSessionsForTask(deletedId);
- const currentState = store.getState();
- const sessionIds = (currentState.taskSessionsByTask.itemsByTaskId[deletedId] ?? []).map(
- (s) => s.id,
+ const sessionIds = Array.from(
+ new Set([
+ ...(currentState.taskSessionsByTask.itemsByTaskId[deletedId] ?? []).map(
+ (session) => session.id,
+ ),
+ ...Object.values(currentState.taskSessions?.items ?? {})
+ .filter((session) => session.task_id === deletedId)
+ .map((session) => session.id),
+ ]),
);
const task = currentState.kanban.tasks.find((t) => t.id === deletedId);
if (task?.primarySessionId) {
@@ -425,6 +432,7 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers {
currentState.removeTaskFromSidebarPrefs(deletedId);
for (const sid of sessionIds) {
useContextFilesStore.getState().clearSession(sid);
+ currentState.clearQueueStatus?.(sid);
}
const wasActive = currentState.tasks.activeTaskId === deletedId;
diff --git a/apps/web/lib/ws/handlers/workspaces.test.ts b/apps/web/lib/ws/handlers/workspaces.test.ts
index e6c86e71a2..bf1ecedea6 100644
--- a/apps/web/lib/ws/handlers/workspaces.test.ts
+++ b/apps/web/lib/ws/handlers/workspaces.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { createStore } from "zustand";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
@@ -74,3 +74,49 @@ describe("workspace.updated placement", () => {
expect(store.getState().workspaces.items[0].unit_id).toBe("unit-new");
});
});
+
+describe("workspace.deleted queue cleanup", () => {
+ const WORKSPACE_ID = "workspace-1";
+
+ it("clears normalized task sessions without relying on queue metadata", () => {
+ const clearQueueStatus = vi.fn();
+ let state = {
+ workspaces: {
+ items: [{ id: WORKSPACE_ID }, { id: "workspace-2" }],
+ activeId: WORKSPACE_ID,
+ },
+ workflows: { items: [], activeId: null },
+ kanban: {
+ workflowId: "workflow-1",
+ steps: [],
+ tasks: [{ id: "task-1", workspaceId: WORKSPACE_ID }],
+ },
+ kanbanMulti: { snapshots: {} },
+ taskSessions: {
+ items: {
+ "session-1": {
+ id: "session-1",
+ task_id: "task-1",
+ queue_incarnation_id: "incarnation-1",
+ },
+ },
+ },
+ clearQueueStatus,
+ } as unknown as AppState;
+ const store = {
+ getState: () => state,
+ setState: (updater: AppState | Partial | ((value: AppState) => AppState)) => {
+ state = (
+ typeof updater === "function" ? updater(state) : { ...state, ...updater }
+ ) as AppState;
+ },
+ } as StoreApi;
+
+ registerWorkspacesHandlers(store)["workspace.deleted"]!({
+ payload: { id: WORKSPACE_ID },
+ } as never);
+
+ expect(clearQueueStatus).toHaveBeenCalledOnce();
+ expect(clearQueueStatus).toHaveBeenCalledWith("session-1");
+ });
+});
diff --git a/apps/web/lib/ws/handlers/workspaces.ts b/apps/web/lib/ws/handlers/workspaces.ts
index 0b55a549ee..7b05b85f50 100644
--- a/apps/web/lib/ws/handlers/workspaces.ts
+++ b/apps/web/lib/ws/handlers/workspaces.ts
@@ -4,6 +4,38 @@ import type { WsHandlers } from "@/lib/ws/handlers/types";
type WorkspaceItem = WorkspaceState["items"][number];
+function handleWorkspaceDeleted(store: StoreApi, workspaceId: string): void {
+ const currentState = store.getState();
+ const workspaceTaskIds = new Set(
+ [
+ ...currentState.kanban.tasks,
+ ...Object.values(currentState.kanbanMulti.snapshots).flatMap((snapshot) => snapshot.tasks),
+ ]
+ .filter((task) => task.workspaceId === workspaceId)
+ .map((task) => task.id),
+ );
+ const sessionIds = Object.values(currentState.taskSessions.items)
+ .filter((session) => workspaceTaskIds.has(session.task_id))
+ .map((session) => session.id);
+ for (const sessionId of sessionIds) {
+ currentState.clearQueueStatus(sessionId);
+ }
+ store.setState((state) => {
+ const items = state.workspaces.items.filter((item) => item.id !== workspaceId);
+ const activeId =
+ state.workspaces.activeId === workspaceId
+ ? (items[0]?.id ?? null)
+ : state.workspaces.activeId;
+ const clearBoards = state.workspaces.activeId === workspaceId;
+ return {
+ ...state,
+ workspaces: { items, activeId },
+ workflows: clearBoards ? { items: [], activeId: null } : state.workflows,
+ kanban: clearBoards ? { workflowId: null, steps: [], tasks: [] } : state.kanban,
+ };
+ });
+}
+
export function registerWorkspacesHandlers(store: StoreApi): WsHandlers {
return {
"workspace.created": (message) => {
@@ -72,24 +104,6 @@ export function registerWorkspacesHandlers(store: StoreApi): WsHandler
},
}));
},
- "workspace.deleted": (message) => {
- store.setState((state) => {
- const items = state.workspaces.items.filter((item) => item.id !== message.payload.id);
- const activeId =
- state.workspaces.activeId === message.payload.id
- ? (items[0]?.id ?? null)
- : state.workspaces.activeId;
- const clearBoards = state.workspaces.activeId === message.payload.id;
- return {
- ...state,
- workspaces: {
- items,
- activeId,
- },
- workflows: clearBoards ? { items: [], activeId: null } : state.workflows,
- kanban: clearBoards ? { workflowId: null, steps: [], tasks: [] } : state.kanban,
- };
- });
- },
+ "workspace.deleted": (message) => handleWorkspaceDeleted(store, message.payload.id),
};
}
diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json
index 7e9b92cc02..8c8bd59dad 100644
--- a/apps/web/src/locales/en/chat.json
+++ b/apps/web/src/locales/en/chat.json
@@ -45,6 +45,7 @@
"failedToRemoveQueuedMessage": "Failed to remove queued message.",
"failedToReorderQueuedMessages": "Failed to reorder queued messages.",
"failedToSetQueueAutoRun": "Failed to update queue Auto-run.",
+ "failedToSetQueueAutoMerge": "Failed to update queue Auto-merge.",
"failedToStartQuickChat": "Failed to start quick chat",
"fileTreeActions": "File actions",
"loadingRepositories": "Loading repositories",
@@ -89,6 +90,7 @@
"queueCapacityFull_one": "{{count}} of {{max}} · full",
"queueCapacityFull_other": "{{count}} of {{max}} · full",
"queueAutoRun": "Auto-run",
+ "queueAutoMerge": "Auto-merge",
"queueAutoRunOffHelp": "Finishes the current response, then queued messages wait.",
"queueAutoRunOnHelp": "Runs queued messages one at a time.",
"queueChipExpand_one": "{{count}} queued message, click to expand",
diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json
index b62679449b..9abdb87c94 100644
--- a/apps/web/src/locales/en/system.json
+++ b/apps/web/src/locales/en/system.json
@@ -165,7 +165,7 @@
"licensesTitle": "Third-party licenses",
"licensesToggleLabel": "Show license text for {{name}}",
"messageQueueAdminOnly": "Only administrators can change this setting.",
- "messageQueueAutoMergeDescription": "Combines consecutive compatible queued messages from the same source into one turn before delivery.",
+ "messageQueueAutoMergeDescription": "Sets the default for all sessions. A session follows this value until you change Auto-merge in that session's queue.",
"messageQueueAutoMergeNotice": "If a message has a different source or incompatible task, model, mode, metadata, or shared limits, it stays as a separate queued message. This applies only to new admissions and does not change the manual merge control.",
"messageQueueAutoMergeToggleLabel": "Automatically merge consecutive messages",
"messageQueueConfigured": "Configured:",
diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json
index 21ec2d4516..1cf69ba7f9 100644
--- a/apps/web/src/locales/pseudo/chat.json
+++ b/apps/web/src/locales/pseudo/chat.json
@@ -45,6 +45,7 @@
"failedToRemoveQueuedMessage": "Ƒàĩĺēď ţō ŕēḿōvē qũēũēď ḿēśśàĝē.",
"failedToReorderQueuedMessages": "Ƒàĩĺēď ţō ŕēōŕďēŕ qũēũēď ḿēśśàĝēś.",
"failedToSetQueueAutoRun": "Ƒàĩĺēď ţō ũƥďàţē qũēũē Àũţō-ŕũń.",
+ "failedToSetQueueAutoMerge": "Ƒàĩĺēď ţō ũƥďàţē qũēũē Àũţō-ḿēŕĝē.",
"failedToStartQuickChat": "Ƒàĩĺēď ţō śţàŕţ qũĩćķ ćĥàţ",
"fileTreeActions": "Ƒĩĺē àćţĩōńś",
"loadingRepositories": "Ĺōàďĩńĝ ŕēƥōśĩţōŕĩēś",
@@ -89,6 +90,7 @@
"queueCapacityFull_one": "{{count}} ōƒ {{max}} · ƒũĺĺ",
"queueCapacityFull_other": "{{count}} ōƒ {{max}} · ƒũĺĺ",
"queueAutoRun": "Àũţō-ŕũń",
+ "queueAutoMerge": "Àũţō-ḿēŕĝē",
"queueAutoRunOffHelp": "Ƒĩńĩśĥēś ţĥē ćũŕŕēńţ ŕēśƥōńśē, ţĥēń qũēũēď ḿēśśàĝēś ŵàĩţ.",
"queueAutoRunOnHelp": "Ŕũńś qũēũēď ḿēśśàĝēś ōńē àţ à ţĩḿē.",
"queueChipExpand_one": "{{count}} qũēũēď ḿēśśàĝē, ćĺĩćķ ţō ēxƥàńď",
diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json
index 68630107b3..c84a22d8c4 100644
--- a/apps/web/src/locales/pseudo/system.json
+++ b/apps/web/src/locales/pseudo/system.json
@@ -165,7 +165,7 @@
"licensesTitle": "Ţĥĩŕď-ƥàŕţŷ ĺĩćēńśēś",
"licensesToggleLabel": "Śĥōŵ ĺĩćēńśē ţēxţ ƒōŕ {{name}}",
"messageQueueAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē ţĥĩś śēţţĩńĝ.",
- "messageQueueAutoMergeDescription": "Ćōḿƀĩńēś ćōńśēćũţĩvē ćōḿƥàţĩƀĺē qũēũēď ḿēśśàĝēś ƒŕōḿ ţĥē śàḿē śōũŕćē ĩńţō ōńē ţũŕń ƀēƒōŕē ďēĺĩvēŕŷ.",
+ "messageQueueAutoMergeDescription": "Śēţś ţĥē ďēƒàũĺţ ƒōŕ àĺĺ śēśśĩōńś. À śēśśĩōń ƒōĺĺōŵś ţĥĩś vàĺũē ũńţĩĺ ŷōũ ćĥàńĝē Àũţō-ḿēŕĝē ĩń ţĥàţ śēśśĩōń'ś qũēũē.",
"messageQueueAutoMergeNotice": "Ĩƒ à ḿēśśàĝē ĥàś à ďĩƒƒēŕēńţ śōũŕćē ōŕ ĩńćōḿƥàţĩƀĺē ţàśķ, ḿōďēĺ, ḿōďē, ḿēţàďàţà, ōŕ śĥàŕēď ĺĩḿĩţś, ĩţ śţàŷś àś à śēƥàŕàţē qũēũēď ḿēśśàĝē. Ţĥĩś àƥƥĺĩēś ōńĺŷ ţō ńēŵ àďḿĩśśĩōńś àńď ďōēś ńōţ ćĥàńĝē ţĥē ḿàńũàĺ ḿēŕĝē ćōńţŕōĺ.",
"messageQueueAutoMergeToggleLabel": "Àũţōḿàţĩćàĺĺŷ ḿēŕĝē ćōńśēćũţĩvē ḿēśśàĝēś",
"messageQueueConfigured": "Ćōńƒĩĝũŕēď:",
diff --git a/apps/web/src/locales/pt-pt/chat.json b/apps/web/src/locales/pt-pt/chat.json
index 1a80135a31..82f03ddcba 100644
--- a/apps/web/src/locales/pt-pt/chat.json
+++ b/apps/web/src/locales/pt-pt/chat.json
@@ -45,6 +45,7 @@
"failedToRemoveQueuedMessage": "Não foi possível remover a mensagem em fila.",
"failedToReorderQueuedMessages": "Não foi possível reordenar as mensagens em fila.",
"failedToSetQueueAutoRun": "Não foi possível atualizar a execução automática da fila.",
+ "failedToSetQueueAutoMerge": "Não foi possível atualizar a fusão automática da fila.",
"failedToStartQuickChat": "Não foi possível iniciar o chat rápido",
"fileTreeActions": "Ações de ficheiro",
"loadingRepositories": "A carregar os repositórios",
@@ -73,6 +74,7 @@
"queueCapacityFull_one": "{{count}} de {{max}} · cheia",
"queueCapacityFull_other": "{{count}} de {{max}} · cheia",
"queueAutoRun": "Execução automática",
+ "queueAutoMerge": "Fusão automática",
"queueAutoRunOffHelp": "Termina a resposta atual e depois as mensagens em fila aguardam.",
"queueAutoRunOnHelp": "Executa as mensagens em fila, uma de cada vez.",
"queueChipExpand_one": "{{count}} mensagem em fila, clique para expandir",
diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json
index eb56b54fbb..6eac1b82a4 100644
--- a/apps/web/src/locales/pt-pt/system.json
+++ b/apps/web/src/locales/pt-pt/system.json
@@ -533,7 +533,7 @@
"versionTitle": "Versão",
"versionViewUpdates": "Ver as atualizações",
"dataStoragePageDescription": "Estatísticas da base de dados, cópias de segurança e registos do servidor.",
- "messageQueueAutoMergeDescription": "Junta mensagens consecutivas compatíveis em fila da mesma origem num único turno antes da entrega.",
+ "messageQueueAutoMergeDescription": "Define a predefinição para todas as sessões. Uma sessão segue este valor até alterar a Junção automática na fila dessa sessão.",
"messageQueueAutoMergeNotice": "Se uma mensagem tiver outra origem ou tarefa, modelo, modo, metadados ou limites partilhados incompatíveis, permanece como mensagem em fila separada. Isto aplica-se apenas a novas admissões e não altera o controlo de junção manual.",
"messageQueueAutoMergeToggleLabel": "Juntar automaticamente mensagens consecutivas",
"navDataStorage": "Dados e registos"
diff --git a/apps/web/src/locales/zh-cn/chat.json b/apps/web/src/locales/zh-cn/chat.json
index 8a8097f3ca..cdd0479846 100644
--- a/apps/web/src/locales/zh-cn/chat.json
+++ b/apps/web/src/locales/zh-cn/chat.json
@@ -45,6 +45,7 @@
"failedToRemoveQueuedMessage": "移除排队中的消息失败。",
"failedToReorderQueuedMessages": "无法重新排序队列中的消息。",
"failedToSetQueueAutoRun": "更新队列自动运行设置失败。",
+ "failedToSetQueueAutoMerge": "更新队列自动合并设置失败。",
"failedToStartQuickChat": "启动快速聊天失败",
"fileTreeActions": "文件操作",
"loadingRepositories": "正在加载仓库",
@@ -80,6 +81,7 @@
"queueCapacityFull_one": "{{count}} / {{max}} · 已满",
"queueCapacityFull_other": "{{count}} / {{max}} · 已满",
"queueAutoRun": "自动运行",
+ "queueAutoMerge": "自动合并",
"queueAutoRunOffHelp": "完成当前回复后,排队中的消息将等待。",
"queueAutoRunOnHelp": "逐条运行排队中的消息。",
"queueChipExpand_one": "{{count}} 条排队消息,点击展开",
diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json
index b0b84a72fa..c70e59d7e9 100644
--- a/apps/web/src/locales/zh-cn/system.json
+++ b/apps/web/src/locales/zh-cn/system.json
@@ -534,7 +534,7 @@
"versionLatest": "最新",
"versionTitle": "版本",
"versionViewUpdates": "查看更新",
- "messageQueueAutoMergeDescription": "在投递前,把来自同一来源的连续且兼容的排队消息合并为一轮。",
+ "messageQueueAutoMergeDescription": "设置所有会话的默认值。会话会沿用此值,直到您在该会话的队列中更改自动合并。",
"messageQueueAutoMergeNotice": "如果某条消息的来源不同,或任务、模型、模式、元数据或共享限制不兼容,它会保持为单独的排队消息。这只适用于新接收的消息,不会改变手动合并控制。",
"messageQueueAutoMergeToggleLabel": "自动合并连续消息"
}
diff --git a/apps/web/src/locales/zh-hk/chat.json b/apps/web/src/locales/zh-hk/chat.json
index 366b5d66d8..540bf01a9a 100644
--- a/apps/web/src/locales/zh-hk/chat.json
+++ b/apps/web/src/locales/zh-hk/chat.json
@@ -45,6 +45,7 @@
"failedToRemoveQueuedMessage": "移除排隊中的訊息失敗。",
"failedToReorderQueuedMessages": "無法重新排序佇列中的訊息。",
"failedToSetQueueAutoRun": "更新佇列自動執行設定失敗。",
+ "failedToSetQueueAutoMerge": "更新佇列自動合併設定失敗。",
"failedToStartQuickChat": "啓動快速聊天失敗",
"fileTreeActions": "檔案操作",
"loadingRepositories": "正在載入儲存庫",
@@ -80,6 +81,7 @@
"queueCapacityFull_one": "{{count}} / {{max}} · 已滿",
"queueCapacityFull_other": "{{count}} / {{max}} · 已滿",
"queueAutoRun": "自動執行",
+ "queueAutoMerge": "自動合併",
"queueAutoRunOffHelp": "完成目前回覆後,排隊中的訊息將等待。",
"queueAutoRunOnHelp": "逐條執行排隊中的訊息。",
"queueChipExpand_one": "{{count}} 條排隊訊息,點擊展開",
diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json
index 54af93bcaf..5762ad3822 100644
--- a/apps/web/src/locales/zh-hk/system.json
+++ b/apps/web/src/locales/zh-hk/system.json
@@ -534,7 +534,7 @@
"versionLatest": "最新",
"versionTitle": "版本",
"versionViewUpdates": "查看更新",
- "messageQueueAutoMergeDescription": "在投遞前,把來自同一來源的連續且兼容的排隊訊息合併為一輪。",
+ "messageQueueAutoMergeDescription": "設定所有工作階段的預設值。工作階段會沿用此值,直到您在該工作階段的佇列中變更自動合併。",
"messageQueueAutoMergeNotice": "如果某條訊息的來源不同,或任務、模型、模式、元數據或共享限制不兼容,它會保持為單獨的排隊訊息。這隻適用於新接收的訊息,不會改變手動合併控制。",
"messageQueueAutoMergeToggleLabel": "自動合併連續訊息"
}
diff --git a/apps/web/src/locales/zh-tw/chat.json b/apps/web/src/locales/zh-tw/chat.json
index 981919aaac..bcb2b04b6e 100644
--- a/apps/web/src/locales/zh-tw/chat.json
+++ b/apps/web/src/locales/zh-tw/chat.json
@@ -45,6 +45,7 @@
"failedToRemoveQueuedMessage": "移除排隊中的訊息失敗。",
"failedToReorderQueuedMessages": "無法重新排序佇列中的訊息。",
"failedToSetQueueAutoRun": "更新佇列自動執行設定失敗。",
+ "failedToSetQueueAutoMerge": "更新佇列自動合併設定失敗。",
"failedToStartQuickChat": "啟動快速聊天失敗",
"fileTreeActions": "檔案操作",
"loadingRepositories": "正在載入儲存庫",
@@ -80,6 +81,7 @@
"queueCapacityFull_one": "{{count}} / {{max}} · 已滿",
"queueCapacityFull_other": "{{count}} / {{max}} · 已滿",
"queueAutoRun": "自動執行",
+ "queueAutoMerge": "自動合併",
"queueAutoRunOffHelp": "完成目前回覆後,排隊中的訊息將等待。",
"queueAutoRunOnHelp": "逐條執行排隊中的訊息。",
"queueChipExpand_one": "{{count}} 條排隊訊息,點選展開",
diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json
index e1c6dd643f..a5d3029d9a 100644
--- a/apps/web/src/locales/zh-tw/system.json
+++ b/apps/web/src/locales/zh-tw/system.json
@@ -534,7 +534,7 @@
"versionLatest": "最新",
"versionTitle": "版本",
"versionViewUpdates": "檢視更新",
- "messageQueueAutoMergeDescription": "在投遞前,把來自同一來源的連續且相容的排隊訊息合併為一輪。",
+ "messageQueueAutoMergeDescription": "設定所有工作階段的預設值。工作階段會沿用此值,直到您在該工作階段的佇列中變更自動合併。",
"messageQueueAutoMergeNotice": "如果某條訊息的來源不同,或任務、模型、模式、後設資料或共享限制不相容,它會保持為單獨的排隊訊息。這隻適用於新接收的訊息,不會改變手動合併控制。",
"messageQueueAutoMergeToggleLabel": "自動合併連續訊息"
}
diff --git a/docs/decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md b/docs/decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md
new file mode 100644
index 0000000000..e9eb6c26da
--- /dev/null
+++ b/docs/decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md
@@ -0,0 +1,159 @@
+# ADR-2026-09-04-inherit-queue-auto-merge-until-overridden: Inherit Queue Auto-merge Until Overridden
+
+**Status:** accepted
+**Date:** 2026-09-04
+**Area:** backend, frontend, protocol
+
+## Context
+
+Automatic queued-message merging has one install-wide setting. The queue panel
+needs a per-session switch without making every session a frozen copy of the
+global value when it is created.
+
+A binary switch cannot also expose a third "follow global" state. The product
+contract therefore needs a precedence rule that lets untouched sessions follow
+administrator changes while preserving an explicit session choice.
+
+## Decision
+
+Resolve automatic-merge policy for each message admission and queue-status read
+with this precedence:
+
+1. an explicit per-session override, when present;
+2. otherwise the current install-wide automatic-merge value.
+
+New sessions have no override. Existing sessions that have never changed the
+queue-panel switch continue to inherit later global changes. The first user
+change to the session switch persists an explicit boolean, and that session
+remains independent of later global changes for its lifetime.
+
+The message-queue domain stores the nullable override beside its existing
+per-session Auto-run state. The global setting remains owned by the system
+message-queue settings service. Successful queue status projects availability,
+the effective boolean, and transport-only source and revision metadata. The
+panel still renders a binary value and does not expose inherited state or a
+reset action.
+
+An override is attached to the exact session identity. Workflow queue transfer
+does not copy it to the destination session; the destination keeps its own
+override or inherits the current global value, and the source state is removed.
+Deleting a session, task, or workspace removes state for the deleted session
+identities atomically with that lifecycle operation. Archiving preserves state
+because its sessions survive and may be unarchived. Reusing a deleted identity
+therefore starts without an override.
+
+Session deletion relies on queue repository transactional cleanup instead of a
+post-delete queue mutation. A separate deletion-only post-commit callback
+carries authoritative session/incarnation pairs to ephemeral memory storage;
+the archive task purger never invokes it. The later recount is task-scoped with
+an empty session ID, remains available to the internal status-summary
+projector, and is consumed by gateway routing without browser broadcast.
+
+Each task-session row also owns an opaque server-generated queue incarnation
+UUID. It is immutable for that row and changes when a deleted textual session
+ID is recreated. Its migration runs after every existing task-session table
+rebuild, backfills distinct UUIDs replay-safely, and future rebuilds must
+preserve it. Every session-scoped queue status carries this identity so the
+client can reject delayed status from a removed incarnation before applying
+entries or policy.
+
+Ordinary admissions and Auto-merge mutations carry the expected incarnation
+into persistence. A mutation request cannot be silently retargeted: a missing or
+changed incarnation returns the existing non-enumerating session-not-found
+response and writes nothing.
+
+An override read failure fails closed for that admission: Kandev does not merge
+messages when it cannot prove that an explicit OFF override is absent. A failed
+write reports failure and leaves the previously persisted value authoritative.
+
+An override read failure during queue-status construction does not synthesize an
+effective OFF value. Session status reconciles independent queue data, marks
+Auto-merge unavailable, omits its value/source/revision, and retains the status
+epoch/generation allocated before the failed read. Clients apply availability
+only from the newest status generation, retain any last authoritative tuple,
+and disable the control until a newer successful resolution.
+
+Global and session values each carry a persisted monotonic revision. The system
+settings service commits the global value and revision together, then publishes
+one immutable `{Enabled, Revision}` runtime snapshot through an atomic pointer.
+Backend startup hydrates that pointer from the persisted pair before admissions;
+it never reconstructs revision `0` from the boolean alone. Admissions and status
+reads load the pair once, so they cannot observe a value from one generation
+with another generation's revision. A global revision advances only when the
+global value changes; a session revision advances with each successful override
+write.
+
+The repository owns the durable policy and lifecycle linearization point. SQL
+admission, override mutation, and session deletion lock the owning task row
+before the per-session queue lock, then validate the exact
+task/session/incarnation. Admission reads the override and inserts or directly
+folds in that transaction. This lock order matches task/workspace purge and
+avoids both orphan insertion and lock inversion. The memory repository validates
+session authority under an incarnation-tagged bucket mutex and deletion CAS
+purges only matching buckets. The service admission mutex remains useful for
+local ordering but is not the cross-connection correctness boundary. The
+immutable global snapshot is process-local under Kandev's
+single-orchestrator-owner deployment invariant.
+
+Session queue status carries the immutable session incarnation plus a process
+epoch and generation allocated before policy resolution. Clients first require
+the status incarnation to match the current task-session row. A new matching
+incarnation resets the prior queue-policy ordering state; a mismatch is
+discarded. Within an incarnation, clients order available and unavailable
+results by epoch/generation, then use policy source and revision to order
+effective values. They accept the irreversible global-to-session transition,
+reject a later global-source event for that incarnation, and compare revisions
+within one source. A process restart changes the epoch and startup preserves
+the persisted global policy revision. This prevents delayed failure,
+pre-mutation status, or a deleted incarnation from replacing newer
+authoritative value without scanning sessions when the global setting changes.
+
+## Consequences
+
+- Administrator changes immediately govern later admissions for every
+ unmodified session without writing or scanning session rows.
+- A user can make one session permanently differ from the install default with
+ a compact binary switch.
+- The UI cannot tell whether the displayed effective value is inherited or
+ explicit, and it cannot restore inheritance after the first change.
+- Successful session queue status, WebSocket events, admission logic, and
+ full-capacity candidate folding resolve the same effective value, source, and
+ revision. Failed session status resolution carries ordering plus an
+ unavailable marker. Task-only status-summary signals are explicitly scoped
+ and carry no session policy.
+- Session-state storage requires a nullable override, monotonic revision, and
+ replay-safe migration in both supported SQL dialects.
+- The persisted global settings envelope gains an internal monotonic revision;
+ public settings responses do not expose it as editable configuration.
+- The queue service replaces its independent atomic boolean with one atomic
+ immutable value/revision snapshot and hydrates it from persistence at startup.
+- Session status gains process epoch/generation ordering so delayed unavailable
+ results cannot disable newer successful state.
+- Override writes and policy-aware admissions share the durable session lock,
+ so a write through another database connection is ordered before or after the
+ admission policy decision.
+- Destructive lifecycle cleanup must distinguish session identity removal from
+ task archive; only the former deletes queue policy state.
+- Every task-session creation and legacy backfill needs a stable non-empty queue
+ incarnation UUID. The migration runs after existing task-session rebuilds,
+ and future rebuilds preserve it. Frontend deletion clears queue metadata,
+ workspace deletion derives affected sessions before removing ownership
+ indexes, and status apply compares against the current session incarnation.
+- Task-scoped queue recounts remain internal; the gateway never broadcasts them
+ through a session or empty-workspace route.
+- Queue mutation requests and ordinary admissions require the expected
+ incarnation; stale work cannot target a recreated textual session ID.
+- Memory cleanup needs a deletion-only incarnation-aware callback distinct from
+ archive queue-row purging.
+
+## Alternatives Considered
+
+1. **Snapshot the global value when each session is created.** Rejected because
+ untouched active sessions would not inherit later administrator changes.
+2. **Add a tri-state control with Follow global.** Rejected because the requested
+ queue header control is a compact toggle, and no reset workflow is required.
+3. **Store the override on the task or workspace.** Rejected because the user
+ asked to isolate behavior per session and tasks can have several sessions.
+4. **Copy the override during workflow session transfer.** Rejected because an
+ override belongs to the exact session; the destination already has an
+ independent policy identity.
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index f617cc580e..a8082f8c95 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -253,6 +253,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-09-06-opt-in-external-e2e-runners | [Opt in selected Linux CI jobs to external runners](2026-09-06-opt-in-external-e2e-runners.md) | accepted | infra, workflow, security | 2026-09-06 |
| 2026-09-06-explicit-pr-auto-fix-outcomes | [Require Explicit Outcomes for PR Auto-Fix Attempts](2026-09-06-explicit-pr-auto-fix-outcomes.md) | proposed | backend, frontend, protocol, workflow, GitHub | 2026-09-06 |
| 2026-09-05-bounded-progressive-storage-analysis | [Use bounded progressive storage analysis](2026-09-05-bounded-progressive-storage-analysis.md) | accepted | backend, frontend, protocol | 2026-09-05 |
+| 2026-09-04-inherit-queue-auto-merge-until-overridden | [Inherit Queue Auto-merge Until Overridden](2026-09-04-inherit-queue-auto-merge-until-overridden.md) | accepted | backend, frontend, protocol | 2026-09-04 |
| 2026-09-07-separate-runtime-permissions-and-advisory-actions | [Keep MCP availability separate from runtime permissions](2026-09-07-separate-runtime-permissions-and-advisory-actions.md) | accepted | backend, protocol | 2026-09-07 |
| 2026-09-07-isolate-fork-preview-builds | [Isolate contributor preview builds from deployment credentials](2026-09-07-isolate-fork-preview-builds.md) | accepted | infra, workflow, security | 2026-09-07 |
| 2026-09-05-script-capable-html-preview-isolation | [Capability-Free Runtime for Script-Capable HTML Preview](2026-09-05-script-capable-html-preview-isolation.md) | proposed | frontend, desktop, security | 2026-09-05 |
diff --git a/docs/plans/message-queue-session-automation/plan.md b/docs/plans/message-queue-session-automation/plan.md
new file mode 100644
index 0000000000..7005e5d8fb
--- /dev/null
+++ b/docs/plans/message-queue-session-automation/plan.md
@@ -0,0 +1,318 @@
+---
+created: 2026-09-04
+status: completed
+requirements:
+ - REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001
+ - REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001
+system_design:
+ - ../../specs/ui/system-design/message-queue-automation-controls.md
+legacy_specs:
+ - ../../specs/ui/requirements/message-queue-run.md
+ - ../../specs/ui/requirements/message-queue-auto-merge.md
+---
+
+# Implementation Plan: Message Queue Session Automation
+
+## Overview
+
+Add the per-session Auto-merge policy before exposing it in the queue panel, so
+every rendered switch is backed by authoritative persistence and event
+projection. Then recompose Auto-run and Auto-merge as compact header pills,
+prove the same workflow on desktop and mobile, and reconcile public queue and
+WebSocket documentation.
+
+## Scope
+
+### In scope
+
+- Preserve current install-wide automatic-merge configuration as the fallback.
+- Persist an explicit automatic-merge override only after a session switch is
+ changed.
+- Resolve untouched sessions against later global changes and keep overridden
+ sessions independent.
+- Project status epoch/generation and policy availability plus the effective
+ value, source, and revision through queue reads and every session-scoped live
+ status producer.
+- Move Auto-run into a compact pill beside the queue summary and add the
+ matching Auto-merge pill in the same responsive header region.
+- Preserve queue actions, touch sizing, one scroll owner, and mobile capability.
+- Define future eligible direct folding at or above the limit and
+ staged-attachment rejection in the draft replacement contract. Keep active
+ capacity requirements unchanged until verified implementation and lifecycle
+ cutover.
+
+### Out of scope
+
+- A third Follow global state or reset-override action.
+- Per-task or per-workspace automatic-merge policy.
+- Retroactive compaction of pending rows.
+- Changes to compatibility, content/entity combination, manual merge, Send Now,
+ Auto-run delivery, capacity configuration, or workflow-coalescing semantics.
+ Coalescing only gains the common session-identity guard. Automatic fold
+ queued time remains the shipped later target/source value.
+- Copying an override to a different session during workflow session transfer.
+
+## Technical approach
+
+### Backend policy and protocol
+
+- Extend `queue_session_state` in
+ `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go` with
+ nullable `auto_merge_override` and monotonic `auto_merge_revision` columns in
+ fresh schema and replay-safe migration paths.
+- Extend `Repository` plus memory and SQL implementations with nullable
+ override reads and atomic explicit writes that increment and return the
+ session revision.
+- Require `QueueSessionIdentity` on every service and repository operation that
+ can add, append, replace, restore, reserve, acknowledge, consume, update,
+ remove, merge, reorder, clear, transfer, or change session policy. Browser,
+ plugin, MCP, clarification, workflow, retry, pending-move, drain, and Send Now
+ paths migrate without textual-ID mutating overloads.
+- Define required SQL and memory session-authority seams in `messagequeue`.
+ Besides task locks and session validation, their guarded boundary claims
+ typed staged attachments. Production injects the task repository; SQL
+ fixtures seed task/session/attachment rows and memory fixtures seed a locked
+ authority. Remove task-table probing, nil authority, and fail-open identity
+ validation.
+- Use one repository transaction helper: lock sorted unique task rows through
+ the authority, then sorted repository-local and durable session locks, then
+ validate exact identities. Admission resolves policy, inserts, claims staged
+ attachments, and folds inside that boundary; claim failure rolls everything
+ back. Override, pending-move, and destructive lifecycle mutations use the same
+ order. Remove the post-commit after-insert callback and compensation delete.
+- Transfer requires exact source and destination identities in one transaction,
+ using sorted task rows then sorted session locks before validation. It removes
+ source state without replacing destination policy. Deterministic
+ two-connection tests cover both race orders with session/task/workspace
+ delete/recreate and opposite-direction transfer.
+- Add queue-owned session-state cleanup to destructive transactions. Preserve
+ state on archive. A separate deletion-only post-commit callback carries
+ authoritative identities; memory buckets CAS-purge only matching deleted
+ incarnations, while the existing archive/delete purger and recount notifier
+ stay separate.
+- Add `pending_moves.session_incarnation_id`. Deferred apply CASes identity,
+ move/source/target, increments task transition generation, and returns an
+ immutable effect token. Post-commit task effects CAS generation/target;
+ session/queue effects validate token identity/generation. Recreation or a
+ successor move makes every stale effect fail closed.
+- Add an immutable server-generated `queue_incarnation_id` to each task-session
+ row and expose it with `TaskSession`. Register its replay-safe distinct UUID
+ backfill after every existing task-session table rebuild, require future
+ rebuilds to preserve it, and carry it as `session_incarnation_id` on every
+ session-scoped queue status.
+- Persist an internal global automatic-merge revision with the existing system
+ message-queue settings value. Increment it only when the global boolean
+ changes, commit both together, and keep the revision out of editable public
+ settings responses.
+- Replace the queue service's independent automatic boolean with one immutable
+ value/revision snapshot published through an atomic pointer after settings
+ commit. Hydrate the same pair from persisted queue settings during backend
+ startup before admissions become available.
+- Update `messagequeue.Service` admission and status paths to resolve the
+ override against one loaded global snapshot. Use the repository-linearized
+ effective snapshot for ordinary and eligible direct folds; fail closed on an
+ override read error, preserve the shipped newest queued time, and reject
+ staged-attachment admissions at or above the row limit without claiming an
+ attachment.
+- Add status ordering, availability, and effective policy to `QueueStatus`, plus
+ `message.queue.auto_merge.set`. Every browser queue request, including get
+ and all mutations, requires the task/session/incarnation triplet. Gateway and
+ handler authorize the supplied pair and pass the triplet unchanged.
+- Replace separate queue/session reads with
+ `Snapshot(expected QueueSessionIdentity)`. Browser requests/mutations supply
+ expected identity; internal work retains it. Persistence revalidates and
+ snapshots under task-then-session locks. Responses/events use that identity
+ and publish nothing on mismatch, including mutation-commit/recreation races.
+- Tag reservations, `SendNowClaim`, queued dispatch/cancellation, deferred
+ transition dispatch, and in-flight ownership with identity plus operation
+ generation. Revalidate before cancel, prompt, restore, ack, and CAS cleanup
+ without locks over agent I/O. Post-claim races cover manual drain, Auto-run,
+ and both Send Now scopes.
+- Include the identity, ordering, and available tuple or unavailable marker in
+ get/mutation responses and every session-scoped producer.
+- After session deletion, rely on repository-transaction cleanup and its
+ deletion-only memory callback rather than post-delete `CancelAll`, then
+ publish only an empty-session explicit task-scoped recount. Gateway routing
+ retains that signal for internal status-summary projection but drops it
+ before browser broadcast; invalid empty-session queue events are also
+ dropped.
+- Preserve current snapshot/restore semantics and Auto-run transfer behavior.
+
+### Frontend state and controls
+
+- Preserve backend JSON naming in frontend wire-derived types:
+ `TaskSession.task_id`, `TaskSession.queue_incarnation_id`, and queue payload
+ `session_incarnation_id`. Reject session-scoped queue payloads whose
+ incarnation differs from the current `TaskSession`, and reset policy ordering
+ when a matching new incarnation replaces stored metadata.
+- Replace textual-session loading booleans with active operation tokens carrying
+ session incarnation and a monotonically allocated client operation
+ generation. All terminal paths compare-and-clear only the exact token while
+ its incarnation is still current, so delayed completion cannot clear a
+ replacement session's mutation.
+- Clear queue metadata/loading on every session and task removal. For
+ `workspace.deleted`, snapshot affected session IDs by joining normalized
+ current `TaskSession.task_id` values to locally cached workspace tasks before
+ removing indexes, then invoke the same session cleanup.
+- Add status epoch/generation, policy availability, and the known Auto-merge
+ value/source/revision to `QueueMeta` and queue read/event contracts, with
+ preservation logic in every queue metadata replacement.
+- Apply newest-status availability, process-epoch retirement, source precedence,
+ and same-source policy revision comparison within one incarnation so failed
+ resolution or a delayed pre-mutation event cannot disable, invent, or
+ overwrite newer authoritative state.
+- Require current task/session/incarnation in every queue request in
+ `queue-api.ts`; `useQueue` supplies `TaskSession` identity, refetches after
+ stale outcomes, and never retries. Raw E2E WebSocket helpers accept and emit
+ the same complete triplet for get, add, and cancel.
+- Update `QueueAffordance`, `QueuePanel`, and `QueuePanelHeader` wiring for the
+ effective value and mutation.
+- Replace the dedicated Auto-run row in
+ `queued-ghost-panel-header.tsx` with one responsive automation region. Wide
+ layouts keep the pills beside the summary on the first visual line; narrow
+ layouts may wrap logical groups. Each compact bordered pill contains a
+ localized visible label and shared `Switch`; Auto-run retains state-specific
+ accessible help.
+- Update the global Message Queue settings description and component test to
+ explain inheritance until a per-session change.
+- Add complete English, Portuguese, Simplified Chinese, and generated
+ Traditional Chinese locale entries for the pills, mutation error, and global
+ inheritance description.
+
+### Browser coverage
+
+- Extend `apps/web/e2e/tests/chat/message-queue.spec.ts` with a desktop flow that
+ verifies both pills share the summary header, an untouched session follows a
+ changed global value, a user change persists across reload, and a later
+ global change no longer affects that session.
+- Extend
+ `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` with the
+ same user value on `mobile-chrome`: both controls are reachable by touch,
+ mutation persists, the wrapping header remains contained, and the document
+ has no horizontal overflow.
+- Use API setup only for global-setting preconditions; all session-override
+ changes and assertions go through the rendered queue controls.
+- Update `apps/web/e2e/helpers/api-client.ts` and every queue helper caller.
+ Focused sidebar and workflow scenarios cover raw get/add/cancel triplets;
+ typecheck enforces the clean helper-signature cutover across chat, task,
+ workflow, and SSH suites.
+
+### Public documentation
+
+- Update `docs/public/coordination.md`, `docs/public/operations.md`,
+ `docs/public/sessions-and-review.md`, `docs/public/configuration.md`, and
+ `docs/public/websocket-api.md` where they describe queue Auto-run,
+ install-wide automatic merging, full-capacity behavior, or queue protocol
+ fields.
+- After production and behavioral checks pass, activate the replacement
+ requirements, deprecate and forward-link the unchanged superseded contracts,
+ amend the active management capacity text to the delivered exceptions, mark
+ the system design current, and update the UI specification indexes.
+
+## Tests
+
+- `AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.2`, `.3`, `.7`, `.8`, `.9`,
+ `.11`, `.12`, `.13`, `.15`, `.16`, `.17`, `.18`, `.19`, `.21`, `.22`, `.23`,
+ `.24`, and `.25` map to settings, startup, migrations, authority, claims,
+ deferred transactions/effects, every request/mutator, async dispatch
+ ownership, lifecycle races, expected-identity snapshots, and producer tests.
+- `AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.2`, `.7`, and `.8` plus
+ `AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.4`, `.12`, `.13`, `.15`, `.16`,
+ `.17`, `.18`, `.19`, `.20`, and `.24` map to queue API, hook,
+ incarnation/generation operation tokens, slice, removal, connection epoch,
+ WebSocket-handler, settings, and header tests under `apps/web`.
+- Existing Auto-run, automatic-merge compatibility, manual merge,
+ full-capacity, and Send Now tests remain regression coverage for unchanged
+ behavior.
+
+## E2E tests
+
+- `apps/web/e2e/tests/chat/message-queue.spec.ts` covers
+ `AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.1`, `.2`, `.7` and
+ `AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.2`, `.3`, `.4`, `.11`, `.12`,
+ `.13` on Chromium.
+- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` covers
+ `AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.9` and
+ `AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.14` on `mobile-chrome`.
+- `apps/web/e2e/tests/task/sidebar-queued-count.spec.ts` covers complete raw
+ queue get/add/cancel identities for
+ `AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.19` and `.24`.
+
+## Work orders
+
+- [ ] [Task 01: Persist Session Auto-merge Overrides](task-01-persist-session-auto-merge-overrides.md)
+- [ ] [Task 02: Deliver Queue Automation Pills](task-02-deliver-queue-automation-pills.md)
+- [ ] [Task 03: Update Queue Automation Documentation](task-03-update-queue-automation-documentation.md)
+
+## Verification results
+
+Pending.
+
+After all work-order checks pass, run the user-requested repository gates from
+the repository root in this order:
+
+```bash
+make fmt
+make typecheck test lint
+```
+
+## Risks
+
+- Collapsing nullable inheritance into a boolean during persistence would freeze
+ untouched sessions and violate later global changes.
+- Missing one synthesized `QueueMeta` path could make Auto-merge visually reset
+ after clear, reorder, reconnect, an unavailable status, or an older event.
+- A compact desktop header can become an unusable compressed toolbar on mobile;
+ the control group must wrap while preserving action visibility and one scroll
+ owner.
+- Queue Auto-merge terminology can be confused with pull-request Auto-merge;
+ copy and documentation must keep the message-queue context explicit.
+- Every browser read/mutator and internal operation carries expected identity.
+ A textual-ID path lets delayed work target a replacement; compile-time
+ signatures plus typed and raw-client stale-token tests enforce inventory.
+- Queue repositories require injected SQL/memory authority. Typed arguments
+ alone cannot validate current task-session rows inside the lock boundary.
+- Transfer locks both task rows before sorted session locks and revalidates both
+ endpoints; another order can deadlock deletion or move stale rows.
+- Attachment claims execute through authority inside admission; a post-commit
+ callback leaves a deletion/recreation gap.
+- Deferred apply returns an immutable transition effect token. Task effects CAS
+ generation/target and session effects validate identity/generation; tests
+ race both recreation and successor moves after commit.
+- Reusing archive's task purger for memory policy cleanup either resets archive
+ state or leaks deleted overrides. A separate deletion-only callback carries
+ authoritative incarnation pairs and CAS-purges matching memory buckets.
+- Queue metadata keyed only by textual session ID can accept delayed policy
+ from a deleted incarnation. Status must match the current immutable
+ task-session incarnation, and every removal path must clear queue state.
+- A status publisher that re-resolves after authorization or mutation commit
+ can expose replacement state. Producers use
+ `Snapshot(expected QueueSessionIdentity)` and publish nothing on mismatch.
+- Untagged async claims let delayed drain, Auto-run, deferred dispatch, or Send
+ Now work target or clear replacement ownership. Identity/generation guards
+ survive claim through prompt, restore, acknowledgement, and cleanup.
+- A raw helper signature change without migrating every caller leaves hidden
+ WebSocket payloads stale. The work order inventories chat, task, workflow, and
+ SSH callers; focused add/cancel/get E2E plus typecheck enforces the cutover.
+- An incarnation migration placed before an existing `task_sessions` rebuild
+ is silently dropped; it must run after all current rebuilds and future
+ projections must retain it.
+- Workspace deletion loses empty-queue metadata unless it derives affected
+ sessions before removing task/workspace indexes.
+- A delayed status result can regress or disable the new switch unless every
+ session-scoped producer emits epoch/generation and
+ availability/source/revision metadata and both refetch and event paths use
+ the same comparisons.
+- Independent runtime atomics for the global boolean and revision could expose
+ a policy generation that never existed; all reads use one immutable snapshot
+ hydrated from the persisted revision at startup.
+- The global settings description becomes part of the precedence contract; all
+ locales and its component test must change together.
+- The replacement requirements must remain draft until Tasks 01 and 02 pass,
+ then cut over atomically with deprecation links from the prior contracts.
+- Task-only status-summary signals cannot carry session policy or reach browser
+ routing; explicit scope, an empty session ID, early gateway consumption, and
+ producer/gateway tests enforce the boundary.
+- PostgreSQL verification must include both changed DSN-gated repository
+ packages; running only message-queue tests misses task-session migration and
+ lifecycle behavior.
diff --git a/docs/plans/message-queue-session-automation/task-01-persist-session-auto-merge-overrides.md b/docs/plans/message-queue-session-automation/task-01-persist-session-auto-merge-overrides.md
new file mode 100644
index 0000000000..214801ebc0
--- /dev/null
+++ b/docs/plans/message-queue-session-automation/task-01-persist-session-auto-merge-overrides.md
@@ -0,0 +1,364 @@
+---
+id: "01-persist-session-auto-merge-overrides"
+title: "Persist Session Auto-merge Overrides"
+status: completed
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001
+acceptance_criteria:
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.2
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.3
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.5
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.7
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.8
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.11
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.12
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.13
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.15
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.16
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.17
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.18
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.19
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.21
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.22
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.23
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.24
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.25
+system_design:
+ - ../../specs/ui/system-design/message-queue-automation-controls.md
+---
+
+# Task 01: Persist Session Auto-merge Overrides
+
+## Summary
+
+Add a nullable, queue-owned per-session automatic-merge override with a
+monotonic revision and resolve it against the revisioned live install-wide
+fallback. Give every task-session row an immutable queue incarnation UUID.
+Project that incarnation, status epoch/generation, policy availability, value,
+source, and revision through every session-scoped producer before any frontend
+control consumes it.
+
+## In scope
+
+- Write failing queue-settings, startup wiring, service, repository, handler,
+ orchestrator, gateway, and MCP producer tests before production code.
+- Persist absent, explicit OFF, and explicit ON as distinct states with a
+ monotonic session revision in memory, SQLite, and PostgreSQL-compatible
+ storage.
+- Introduce required `QueueSessionIdentity{TaskID, SessionID,
+SessionIncarnationID}` arguments across every service and repository
+ operation that inserts, appends, replaces, restores, reserves, acknowledges,
+ consumes, updates, removes, merges, reorders, clears, transfers, or changes
+ session policy. Admission, coalescing, retry, pending-move, drain, Send Now,
+ Auto-run, and Auto-merge paths retain captured identities; remove every
+ textual-ID mutating overload and `QueueMessageWithMetadataAfterInsert`.
+- Require `task_id`, `session_id`, and `session_incarnation_id` on every browser
+ queue read or mutation. Gateway and handler authorize the supplied pair, then
+ pass the unchanged triplet to persistence. Stale identity returns the existing
+ non-enumerating session-not-found response without mutation, claim, dispatch,
+ attachment, response-state, or status-publication effects.
+- Define `SQLSessionAuthority` and `MemorySessionAuthority` in `messagequeue`.
+ The SQL seam locks tasks, validates sessions, and claims a typed
+ `QueueAttachmentClaim` on the queue transaction; the memory seam supplies an
+ equivalent claim-capable guarded boundary. Backend wiring injects the task
+ repository into the queue repository. Remove `tasksTablePresent` and every
+ missing-schema or nil-authority bypass. SQL fixtures create task, session, and
+ attachment authority rows; memory fixtures seed a lock-protected authority
+ whose guard encloses bucket mutation.
+- Add one repository policy-aware admission operation carrying the identity.
+ SQL locks the active task row, then the durable `queue_session_locks` row,
+ revalidates that exact session incarnation, and reads the override plus
+ inserts or directly folds in one transaction. Route override writes and
+ lifecycle-coalescing inserts through the same identity check and lock order.
+ The memory repository validates through task-session authority while holding
+ its incarnation-tagged bucket mutex.
+- Resolve authenticated attachment owner/workspace and staged IDs before queue
+ admission. Pass the claim value into the policy-aware repository operation;
+ identity validation, insert, attachment claim, and eligible fold share one
+ transaction or memory critical section. Any failure rolls back every queue
+ and attachment change. A two-connection test pauses inside the claim seam and
+ races session deletion/recreation in both orders.
+- Add deterministic two-connection tests for every mutation category and
+ identity-bound status publication against session/task/workspace recreation,
+ including a pause after mutation commit before status snapshot. Cover
+ opposite transfer ordering and deferred-transition pauses before writes and
+ after commit before effects. Channel-controlled tests recreate after a drain,
+ Auto-run, or Send Now claim and before prompt/restore. Prove stale work cannot
+ mutate, dispatch to, publish, restore into, block, or clear replacement state.
+- Persist an internal global revision with the automatic-merge setting, advance
+ it atomically only when that boolean changes, publish one immutable
+ value/revision runtime snapshot after commit, and hydrate the same pair during
+ backend startup.
+- Preserve inheritance for untouched sessions across later global changes and
+ isolate explicit overrides by session.
+- Preserve the shipped automatic-fold timestamp behavior: the target row
+ survives while its queued time becomes the later target/source time.
+- Apply one effective policy tuple to ordinary and eligible direct folds.
+- Keep staged-attachment admissions at or above capacity out of the direct fold
+ path, with no attachment claim or tail mutation.
+- Add queue-owned policy cleanup under a common transaction helper that locks
+ sorted unique task rows through the injected authority, then sorted
+ repository-local and durable queue-session locks, then revalidates every
+ identity. Session, task, and workspace deletion remove state atomically;
+ archive preserves it. Transfer validates exact source and destination
+ identities in that order, removes source state, and leaves the destination
+ override unchanged.
+- Add a separate deletion-only post-commit callback carrying complete
+ task/session/incarnation identities to the memory repository. Keep archive's
+ purger/recount notifier separate. Memory cleanup CAS-purges only exact
+ incarnation-tagged buckets.
+- Add `task_sessions.queue_incarnation_id`, generate it once during session
+ creation, and expose it on `TaskSession`. Register its replay-safe ALTER and
+ distinct empty-row UUID backfill after both existing `task_sessions` rebuild
+ migrations; require future rebuild projections to preserve it. Test legacy
+ rebuild order, partial-backfill restart, stable reread, and textual ID reuse.
+- Add the authorized `message.queue.auto_merge.set` mutation with required
+ `session_incarnation_id` plus status epoch/generation,
+ `auto_merge_available`, and the effective policy tuple. Compare the expected
+ incarnation again under the persistence locks and return the existing
+ non-enumerating session-not-found response on mismatch.
+- Add `pending_moves.session_incarnation_id`; backfill it by joining the owning
+ task session and delete unmatched legacy rows. Carry `QueueSessionIdentity`
+ through set/read/take, retry, reaper CAS, snapshot/restore, and transfer.
+- Make deferred apply one transaction that CASes captured identity, pending move,
+ expected source, and target; applies direct writes; increments task workflow
+ transition generation; and returns an immutable effect token. Same-target
+ uses the same operation.
+- After commit, task effects CAS token generation/target and session/queue
+ effects validate token identity/generation. Publication, history, exit/enter,
+ prompt cleanup, drain, and pull-next fail closed after recreation or a
+ successor move.
+- Replace separate `GetStatus`/`SessionTaskID` assembly with
+ `Snapshot(QueueSessionIdentity)`. Browser requests supply expected identity;
+ internal work retains it. Responses/events snapshot that same identity after
+ commit and publish nothing on mismatch.
+- Put identity plus operation generation on queue reservations, `SendNowClaim`,
+ queued dispatch/cancellation, deferred dispatch, and in-flight ownership.
+ Restore/ack revalidates through the repository; cancellation, prompt, and
+ cleanup verify identity/generation against task-session and execution
+ ownership without holding database locks across agent I/O.
+- Update queue handler, session-scoped orchestrator lifecycle, and MCP producers
+ and tests for ordered available and unavailable shapes. Give task-only
+ status-summary signals an explicit scope and keep them out of frontend
+ session reconciliation.
+- Replace the post-delete `CancelAll` path in `task_operations.go` with the
+ repository-owned transactional cleanup and memory callback. Publish only
+ `publishTaskQueueStatusEvent(taskID, "")` after deletion.
+- Make gateway queue routing consume explicit task-scoped status internally
+ without browser broadcast. Drop invalid empty-session queue events rather
+ than falling through to an empty-workspace broadcast.
+- Preserve user-visible snapshot/restore, manual merge, Auto-run, and Send Now
+ semantics while migrating their internal ownership to exact identities.
+
+## Out of scope
+
+- Frontend state or queue-panel controls.
+- A clear-override API.
+- Changing automatic-merge compatibility, content/entity combination, or
+ retroactively folding queued rows. Queued-time behavior stays aligned with
+ the existing `latestQueuedAt` implementation.
+- Changing the global settings endpoint or administrator permissions.
+
+## Acceptance
+
+- Tests first fail on missing inheritance, authority/claim injection, complete
+ request and internal identities, task-row/session-lock ordering, attachment
+ rollback, atomic deferred writes, guarded post-commit effects, identity-bound
+ publication, async drain/Auto-run/Send Now ownership, stale mutation/status/
+ transfer/pending-move rejection, lifecycle cleanup, migrations, task-only
+ routing, startup snapshots, unavailable status, capacity, or producer shape;
+ minimum implementation makes them pass.
+- Untouched sessions use every later committed global snapshot; restart retains
+ its persisted revision, and an explicitly changed session retains its value
+ and monotonic revision across queue drain and repository reconstruction
+ without changing another session.
+- Every queue read/mutation uses exact typed identity and task-then-session
+ revalidation. Ordinary admission uses one policy snapshot; staged claim,
+ insert, and fold share the transaction. Claim failure rolls back all writes;
+ mismatch publishes no status; task-only signals never reach browsers.
+- Deferred writes CAS identity/move/source/target in one transaction and return
+ an effect token. Task effects CAS transition generation/target; session
+ effects validate identity/generation. Pre-write, post-commit, recreation, and
+ successor-move races cannot redirect an effect.
+- Drain, Auto-run, deferred dispatch, and Send Now reservations/claims retain
+ identity/generation through cancel, prompt, restore, acknowledgement, and
+ cleanup. Delayed old work cannot dispatch to, block, or clear a replacement.
+- Session/task/workspace deletion removes policy; archive preserves it;
+ transfer removes source state and preserves destination policy. Session
+ delete performs no second `CancelAll` and emits only a task recount.
+- Every browser read/mutator and internal producer carries expected identity.
+ Responses/events snapshot that identity and publish nothing after mismatch.
+ A recreated textual ID receives a new incarnation; task-only status has none.
+- Automatic fold tests retain the target identity and assert that its queued
+ time is the later target/source time.
+
+## Verification
+
+```bash
+(cd apps/backend && go test -race ./internal/system/queuesettings ./internal/backendapp ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers ./internal/orchestrator ./internal/mcp/handlers ./internal/gateway/websocket ./internal/task/repository/sqlite)
+(cd apps/backend && go test ./pkg/websocket)
+if [ -n "${KANDEV_TEST_POSTGRES_DSN:-}" ]; then (cd apps/backend && go test -race ./internal/task/repository/sqlite ./internal/orchestrator/messagequeue); else printf '%s\n' 'SKIP: KANDEV_TEST_POSTGRES_DSN unavailable'; fi
+git ls-files --cached --others --exclude-standard -- '*.go' | xargs -r gofmt -l
+```
+
+The final formatting command must print no files. The conditional PostgreSQL
+command reruns every changed package containing DSN-gated coverage: task-session
+ALTER/rebuild/backfill and destructive lifecycle tests in
+`internal/task/repository/sqlite`, plus queue policy and cross-connection tests
+in `internal/orchestrator/messagequeue`. Its explicit output records an
+environment skip when the DSN is unavailable.
+
+## Files likely touched
+
+- `apps/backend/internal/system/queuesettings/types.go`
+- `apps/backend/internal/system/queuesettings/store.go`
+- `apps/backend/internal/system/queuesettings/service.go`
+- `apps/backend/internal/system/queuesettings/queuesettings_test.go`
+- `apps/backend/internal/backendapp/orchestrator.go`
+- `apps/backend/internal/backendapp/orchestrator_wiring_test.go`
+- `apps/backend/internal/backendapp/adapters_plugin_messenger.go`
+- `apps/backend/internal/backendapp/adapters_plugin_messenger_test.go`
+- `apps/backend/internal/task/models/models.go`
+- `apps/backend/internal/task/dto/dto.go`
+- `apps/backend/internal/task/repository/sqlite/base_schema.go`
+- `apps/backend/internal/task/repository/sqlite/base_migrations.go`
+- `apps/backend/internal/task/repository/sqlite/session_test.go`
+- `apps/backend/internal/task/repository/sqlite/session_incarnation_migration_test.go`
+- `apps/backend/internal/task/repository/sqlite/base.go`
+- `apps/backend/internal/task/repository/sqlite/session.go`
+- `apps/backend/internal/task/repository/sqlite/attachment.go`
+- `apps/backend/internal/task/repository/sqlite/attachment_postgres_test.go`
+- `apps/backend/internal/task/repository/sqlite/task.go`
+- `apps/backend/internal/task/repository/sqlite/workspace.go`
+- `apps/backend/internal/task/repository/sqlite/task_queue_purge_notify_test.go`
+- `apps/backend/internal/task/repository/sqlite/session_queue_state_cleanup_test.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_memory.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go`
+- `apps/backend/internal/orchestrator/messagequeue/types.go`
+- `apps/backend/internal/orchestrator/messagequeue/service.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_override_test.go`
+- `apps/backend/internal/orchestrator/messagequeue/service_auto_merge_test.go`
+- `apps/backend/internal/orchestrator/messagequeue/pending_move_identity_test.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_postgres_pending_move_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_auto_merge_policy_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_publish_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_merge_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_reorder_test.go`
+- `apps/backend/internal/orchestrator/event_handlers_agent.go`
+- `apps/backend/internal/orchestrator/event_handlers_clarification.go`
+- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
+- `apps/backend/internal/orchestrator/queue_admission_identity_test.go`
+- `apps/backend/internal/orchestrator/workflow_store.go`
+- `apps/backend/internal/orchestrator/workflow_store_deferred_identity_test.go`
+- `apps/backend/internal/orchestrator/queued_dispatch.go`
+- `apps/backend/internal/orchestrator/queued_dispatch_identity_test.go`
+- `apps/backend/internal/orchestrator/queue_send_now.go`
+- `apps/backend/internal/orchestrator/queue_send_now_test.go`
+- `apps/backend/internal/orchestrator/queue_auto_run_test.go`
+- `apps/backend/internal/orchestrator/queue_auto_run_review_test.go`
+- `apps/backend/internal/orchestrator/event_handlers_queue_test.go`
+- `apps/backend/internal/orchestrator/event_handlers_queue_general_test.go`
+- `apps/backend/internal/orchestrator/event_handlers_queue_lifecycle_test.go`
+- `apps/backend/internal/orchestrator/queue_purge_status_test.go`
+- `apps/backend/internal/orchestrator/task_operations.go`
+- `apps/backend/internal/orchestrator/session_delete_contract_test.go`
+- `apps/backend/internal/orchestrator/pending_move_identity_test.go`
+- `apps/backend/internal/mcp/handlers/handlers.go`
+- `apps/backend/internal/mcp/handlers/queue_status_event_test.go`
+- `apps/backend/internal/gateway/websocket/task_notifications.go`
+- `apps/backend/internal/gateway/websocket/task_notifications_test.go`
+- `apps/backend/internal/gateway/websocket/dispatch_scope.go`
+- `apps/backend/internal/gateway/websocket/dispatch_scope_test.go`
+- `apps/backend/internal/mcp/handlers/config_task_handlers.go`
+- `apps/backend/pkg/websocket/actions.go`
+
+## Dependencies
+
+None.
+
+## Risks
+
+- Converting a missing override to `false` would stop untouched sessions from
+ following the global value; converting it to `true` would ignore a global OFF
+ value.
+- An upsert that replaces the whole state row could reset the independent
+ Auto-run value.
+- A service-only admission gate does not serialize another repository
+ connection. Admission, override writes, and deletion must all acquire task
+ row before session lock and validate expected incarnation; reversing the
+ order deadlocks task/workspace purge, while omitting validation lets a stale
+ waiter target a recreated ID.
+- Reusing the archive task purger for policy cleanup either erases surviving
+ archive policy or leaves deleted memory overrides. A separate deletion-only,
+ incarnation-aware callback owns memory policy cleanup.
+- Reusing a textual session ID without a distinct stable incarnation lets a
+ delayed status from the deleted row restore its irreversible session-source
+ tuple. Creation and legacy backfill must always supply a non-empty UUID, and
+ every session-scoped producer must echo it.
+- Adding the incarnation migration before a legacy `task_sessions` rebuild
+ silently drops the column. It runs after all current rebuilds, and every
+ future rebuild must project it.
+- A status producer that re-resolves identity after mutation commit can publish
+ replacement state. Responses/events snapshot the operation's expected
+ identity; browser reads carry the same triplet through authorization.
+- A textual-ID mutator or async ownership record lets delayed work target or
+ clear a replacement. Compile-time signatures, identity/generation-tagged
+ claims, and post-claim race tests enforce the complete inventory.
+- Letting the queue repository probe for task tables or accept a nil authority
+ makes production identity validation optional and isolated tests
+ unrepresentative. Constructors require injected authorities, and fixtures
+ explicitly seed them.
+- Transfer that locks session rows before both task rows in deterministic order
+ can deadlock deletion or move rows across recreated identities. It uses the
+ common task-then-sorted-session transaction helper and validates both ends.
+- The current after-insert callback runs after the queue transaction commits,
+ so compensation cannot prevent a delete/recreate claim race. Attachment claim
+ becomes a typed authority operation inside the admission transaction; the
+ callback and rollback delete are removed.
+- Atomic deferred writes do not protect effects after commit. Task effects CAS
+ committed move/target while session and queue effects retain identity, with
+ deterministic recreation tests on both sides of commit.
+- PostgreSQL and SQLite represent nullable booleans differently at the driver
+ boundary; tests must exercise the repository contract rather than raw SQL
+ text.
+- Running only message-queue PostgreSQL tests misses task-session migration and
+ lifecycle code; the conditional DSN pass covers both repository packages plus
+ orchestrator and gateway integration.
+- Missing one session-scoped producer leaves clients vulnerable to value
+ regression even if the primary queue handler is correct; task-only signals
+ need an explicit exemption.
+- The persisted global value and revision must share one write, and the runtime
+ service must publish, hydrate, and read them as one immutable snapshot; a
+ boolean-only startup path or two atomics can create a policy generation that
+ never existed.
+- Treating a status-read failure as authoritative OFF would replace valid client
+ state; an unsequenced unavailable marker could also disable newer success.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- `REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001`
+- `docs/specs/ui/system-design/message-queue-automation-controls.md`
+- `docs/decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md`
+- `apps/backend/internal/orchestrator/messagequeue/service.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_auto_run_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers.go`
+
+## Results
+
+Completed. Added durable task-session queue incarnations, revisioned global and
+per-session Auto-merge policy, identity-bound browser and internal queue
+producers, pending-move identity retention, destructive lifecycle cleanup, and
+ordered status snapshots. Admission skips folding when policy reads fail;
+status reads preserve entries while reporting policy unavailable. SQLite,
+orchestrator, handler, gateway, MCP, migration, and race-focused tests pass.
diff --git a/docs/plans/message-queue-session-automation/task-02-deliver-queue-automation-pills.md b/docs/plans/message-queue-session-automation/task-02-deliver-queue-automation-pills.md
new file mode 100644
index 0000000000..066f3a166a
--- /dev/null
+++ b/docs/plans/message-queue-session-automation/task-02-deliver-queue-automation-pills.md
@@ -0,0 +1,248 @@
+---
+id: "02-deliver-queue-automation-pills"
+title: "Deliver Queue Automation Pills"
+status: completed
+wave: 2
+depends_on:
+ - "01-persist-session-auto-merge-overrides"
+plan: "plan.md"
+requirements:
+ - REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001
+ - REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001
+acceptance_criteria:
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.1
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.2
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.7
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.8
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.9
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.10
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.1
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.2
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.3
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.4
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.11
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.12
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.13
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.14
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.15
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.16
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.17
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.18
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.19
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.20
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.24
+system_design:
+ - ../../specs/ui/system-design/message-queue-automation-controls.md
+---
+
+# Task 02: Deliver Queue Automation Pills
+
+## Summary
+
+Drive session incarnation, status epoch/generation, policy availability, and
+the effective tuple through frontend API, store, live-event, and mutation paths
+with stale-update protection. Recompose the expanded queue header into compact
+Auto-run and Auto-merge pills, with desktop and mobile Playwright scenarios
+written and observed failing before UI implementation.
+
+## In scope
+
+- Bootstrap workspace dependencies once if this worktree has no
+ `apps/node_modules`.
+- Write failing queue API, store/event ordering, settings, header, desktop/mobile,
+ and raw WebSocket helper/browser coverage before production frontend code.
+- Preserve the backend JSON names `queue_incarnation_id` and `task_id` on
+ `TaskSession`, and `session_incarnation_id` on every session-scoped queue
+ payload; do not introduce a partial camel-case mapping layer. Reject the
+ whole status when it does not match the current session, and reset prior
+ policy ordering only when a new matching incarnation replaces stored queue
+ metadata.
+- Preserve matching-incarnation status epoch/generation, policy availability,
+ and the known Auto-merge value/source/revision through fetch, clear, reorder,
+ WebSocket notification, and older-payload compatibility paths.
+- Apply availability only from the newest generation in the current process
+ epoch, retire prior epochs, reject stale same-source revisions, and reject
+ global-source status after the irreversible first session override within
+ that incarnation.
+- Replace `QueueState.isLoading[sessionId]` booleans with
+ `activeOperationBySessionId` tokens containing the current session
+ incarnation and a monotonically allocated client operation generation.
+ `beginQueueOperation` verifies the current incarnation, rejects a conflict,
+ installs a fresh token, and returns it; `finishQueueOperation`
+ compare-and-clears only that token while the incarnation remains current.
+ Independent refetches own a token, while a mutation's terminal refetch retains
+ its caller token. Route every success, error, cancellation, early return,
+ refetch, and `finally` path through these APIs.
+- Make session and task removal clear queue entries, metadata, and the active
+ operation token for every removed session. In `workspace.deleted`, snapshot
+ affected session IDs before state removal by joining normalized
+ `TaskSession.task_id` records to locally cached tasks for that workspace,
+ then call the same session cleanup for each ID. Incarnation validation
+ guarantees cached queue metadata has a corresponding current `TaskSession`.
+- Add same-process delete/recreate coverage with delayed old-incarnation status
+ success and unavailable events plus mutation success, error, refetch, and
+ `finally` cleanup while a new-incarnation mutation remains pending. Also
+ cover workspace deletion with empty-queue metadata and an active operation
+ token.
+- Require `task_id`, `session_id`, and `session_incarnation_id` in every
+ frontend queue request, including status get and all mutations. `useQueue`
+ supplies current identity. Raw E2E helpers require the same triplet, and the
+ clean signature cutover migrates every caller rather than inferring identity
+ from textual session ID. Stale responses refetch but never retry. One
+ matching-token conflict predicate covers both switches, Send Now, and reorder;
+ Auto-merge also requires policy availability.
+- Place both labeled switch pills beside the queue summary in one wrapping
+ header composition and remove the dedicated Auto-run row.
+- Preserve the desktop-only pin, all queue actions, one internal scroll owner,
+ keyboard semantics, and coarse-pointer hit areas.
+- Update the global settings description and test to explain inheritance until
+ a session override.
+- Add complete English, Portuguese, Simplified Chinese, Hong Kong Chinese, and
+ Traditional Chinese locale entries using the repository's generation flow.
+
+## Out of scope
+
+- Backend storage, protocol handlers, and global settings behavior or permissions.
+- A Follow global control or inherited/explicit visual badge.
+- Changes to row rendering, ordering, manual merge, Send Now, or queue capacity.
+- A mobile-only drawer or alternate queue route.
+
+## Acceptance
+
+- RED evidence covers incarnation isolation, complete get/mutation wire
+ payloads from typed and raw clients, status ordering, policy availability,
+ generation-scoped cleanup, delayed unavailable protection, shared disabling,
+ settings explanation, and compact composition.
+- Both pills display authoritative state and disable during conflicting work.
+ Every queue request sends current task/session/incarnation, never retargets
+ after textual ID reuse, and never retries stale work. Focused tests reject
+ lower-generation terminal updates, recover from unavailable state, survive
+ reload, and preserve global precedence.
+- Session/task/workspace removal leaves no queue metadata or operation token.
+ Recreating the same textual session ID resets source ordering only for its new
+ matching incarnation; delayed status from the removed incarnation is ignored
+ before and after the new status. Delayed terminal paths from an old mutation
+ cannot clear the active new-incarnation token or re-enable controls.
+- Desktop preserves dense pill sizing; `mobile-chrome` proves touch-effective
+ controls, wrapping containment, no document horizontal overflow, and the same
+ session override outcome.
+
+## Verification
+
+```bash
+(cd apps && pnpm install --frozen-lockfile)
+(cd apps && pnpm --filter @kandev/web test -- --run lib/api/domains/queue-api.test.ts hooks/domains/session/use-queue.test.ts hooks/use-task-removal.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/slices/session/remove-task-session.test.ts lib/ws/handlers/agent-session.test.ts lib/ws/handlers/tasks.deleted.test.ts lib/ws/handlers/workspaces.test.ts components/settings/system/message-queue-settings.test.tsx components/task/chat/queued-ghost-panel-header.test.tsx components/task/chat/queued-ghost-list.test.tsx)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm run i18n:check)
+(cd apps/web && pnpm exec eslint components/settings/system/message-queue-settings.tsx components/task/chat/queued-ghost-panel-header.tsx components/task/chat/queued-ghost-list.tsx hooks/domains/session/use-queue.ts lib/api/domains/queue-api.ts lib/state/app-state-types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/types.ts lib/ws/handlers/agent-session.ts lib/types/session-events.ts e2e/helpers/api-client.ts e2e/tests/chat/message-queue-scroll-helpers.ts e2e/tests/chat/message-queue.spec.ts e2e/tests/chat/mid-turn-steering.spec.ts e2e/tests/chat/mobile-message-queue-management.spec.ts e2e/tests/ssh/launch-task.spec.ts e2e/tests/task/mobile-sidebar-queued-count.spec.ts e2e/tests/task/sidebar-queued-count.spec.ts e2e/tests/workflow/workflow-queue-helpers.ts e2e/tests/workflow/workflow-manual-move-queue.spec.ts e2e/tests/workflow/mobile-workflow-manual-move-queue.spec.ts)
+(cd apps/web && pnpm e2e:run tests/chat/message-queue.spec.ts tests/task/sidebar-queued-count.spec.ts tests/workflow/workflow-manual-move-queue.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts)
+```
+
+The Playwright tests run through the managed production build. Record the
+expected RED assertion before production UI changes and the final discovered
+and passing test counts after implementation.
+
+## Files likely touched
+
+- `apps/web/lib/types/http.ts`
+- `apps/web/lib/state/app-state-types.ts`
+- `apps/web/lib/state/slices/session/types.ts`
+- `apps/web/lib/state/slices/session/session-slice.upsert.test.ts`
+- `apps/web/lib/state/slices/session/session-slice.ts`
+- `apps/web/lib/state/slices/session/remove-task-session.test.ts`
+- `apps/web/lib/types/session-events.ts`
+- `apps/web/lib/ws/handlers/agent-session.ts`
+- `apps/web/lib/ws/handlers/agent-session.test.ts`
+- `apps/web/lib/ws/handlers/tasks.ts`
+- `apps/web/lib/ws/handlers/tasks.deleted.test.ts`
+- `apps/web/hooks/use-task-removal.ts`
+- `apps/web/hooks/use-task-removal.test.ts`
+- `apps/web/lib/ws/handlers/workspaces.ts`
+- `apps/web/lib/ws/handlers/workspaces.test.ts`
+- `apps/web/lib/api/domains/queue-api.ts`
+- `apps/web/lib/api/domains/queue-api.test.ts`
+- `apps/web/hooks/domains/session/use-queue.ts`
+- `apps/web/hooks/domains/session/use-queue.test.ts`
+- `apps/web/components/task/chat/queued-ghost-list.tsx`
+- `apps/web/components/task/chat/queued-ghost-list.test.tsx`
+- `apps/web/components/task/chat/queued-ghost-panel-header.tsx`
+- `apps/web/components/task/chat/queued-ghost-panel-header.test.tsx`
+- `apps/web/components/settings/system/message-queue-settings.tsx`
+- `apps/web/components/settings/system/message-queue-settings.test.tsx`
+- `apps/web/src/locales/en/chat.json`
+- `apps/web/src/locales/pt-pt/chat.json`
+- `apps/web/src/locales/zh-cn/chat.json`
+- `apps/web/src/locales/zh-hk/chat.json`
+- `apps/web/src/locales/zh-tw/chat.json`
+- `apps/web/src/locales/en/system.json`
+- `apps/web/src/locales/pt-pt/system.json`
+- `apps/web/src/locales/zh-cn/system.json`
+- `apps/web/src/locales/zh-hk/system.json`
+- `apps/web/src/locales/zh-tw/system.json`
+- `apps/web/e2e/tests/chat/message-queue.spec.ts`
+- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`
+- `apps/web/e2e/helpers/api-client.ts`
+- `apps/web/e2e/tests/task/sidebar-queued-count.spec.ts`
+- `apps/web/e2e/tests/chat/message-queue-scroll-helpers.ts`
+- `apps/web/e2e/tests/chat/mid-turn-steering.spec.ts`
+- `apps/web/e2e/tests/ssh/launch-task.spec.ts`
+- `apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts`
+- `apps/web/e2e/tests/workflow/workflow-queue-helpers.ts`
+- `apps/web/e2e/tests/workflow/workflow-manual-move-queue.spec.ts`
+- `apps/web/e2e/tests/workflow/mobile-workflow-manual-move-queue.spec.ts`
+
+## Dependencies
+
+- Task 01 must provide the queue status field and mutation action before the
+ final frontend and browser checks can pass.
+
+## Risks
+
+- Older queue events may omit the new fields; replacing instead of preserving
+ metadata would reset the visible switch. Complete tuples and unavailable
+ results can also arrive late, so every apply path must enforce status
+ epoch/generation, availability, source, and revision semantics.
+- A delayed payload from a deleted session can restore stale source precedence
+ after textual ID reuse unless queue status is checked against the current
+ immutable session incarnation and every removal path clears queue metadata.
+- Clearing workspace records before deriving affected sessions loses the only
+ local ownership join for empty-queue metadata; snapshot session IDs first and
+ reuse the session cleanup action.
+- Omitting identity from get, a mutation, or a raw WebSocket helper lets delayed
+ work read or change a replacement. Typed/raw clients and every raw-helper
+ caller migrate to the server-issued triplet and never retry stale work.
+- An unavailable status must reconcile entries without presenting an invented
+ OFF value or leaving the control actionable; a lower-generation unavailable
+ status must not disable newer success.
+- A separate Auto-merge disabled expression can drift from Auto-run and leave
+ policy mutation clickable during queue or cancellation work; both use one
+ shared conflict predicate.
+- Two compact pills plus queue actions may fit desktop but overlap at the mobile
+ breakpoint unless the logical groups wrap independently.
+- Enlarging the shared switch itself could make desktop controls visually too
+ large; coarse-pointer sizing belongs on the effective hit area.
+- Global-setting setup in E2E can leak between worker-scoped tests unless the
+ original value is restored in teardown.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- `REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001`
+- `REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001`
+- `docs/specs/ui/system-design/message-queue-automation-controls.md`
+- `apps/web/components/task/chat/queued-ghost-panel-header.tsx`
+- `apps/web/hooks/domains/session/use-queue.ts`
+- `apps/web/e2e/tests/chat/message-queue.spec.ts`
+- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`
+
+## Results
+
+Completed. Queue clients and store state now retain task/session/incarnation,
+status ordering, policy availability, and operation generations. The expanded
+header uses wrapping Auto-run and Auto-merge pills with localized accessible
+state descriptions and coarse-pointer targets. Focused frontend tests,
+typecheck, lint, desktop Playwright, and mobile-chrome Playwright pass.
diff --git a/docs/plans/message-queue-session-automation/task-03-update-queue-automation-documentation.md b/docs/plans/message-queue-session-automation/task-03-update-queue-automation-documentation.md
new file mode 100644
index 0000000000..80b58baeb2
--- /dev/null
+++ b/docs/plans/message-queue-session-automation/task-03-update-queue-automation-documentation.md
@@ -0,0 +1,147 @@
+---
+id: "03-update-queue-automation-documentation"
+title: "Update Queue Automation Documentation"
+status: completed
+wave: 3
+depends_on:
+ - "01-persist-session-auto-merge-overrides"
+ - "02-deliver-queue-automation-pills"
+plan: "plan.md"
+requirements:
+ - REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001
+ - REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001
+acceptance_criteria:
+ - AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.1
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.1
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.2
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.3
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.7
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.8
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.11
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.15
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.17
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.18
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.19
+ - AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.24
+system_design:
+ - ../../specs/ui/system-design/message-queue-automation-controls.md
+---
+
+# Task 03: Update Queue Automation Documentation
+
+## Summary
+
+Reconcile public queue guides, configuration reference, and WebSocket reference
+with the delivered session override and compact header controls. Remove stale
+claims that capacity is always rejected before a compatible automatic fold.
+
+## In scope
+
+- Explain global inheritance and permanent per-session override precedence in
+ user-facing queue guidance.
+- Describe the compact Auto-run and Auto-merge pills in the expanded queue
+ header without confusing message merging with pull-request Auto-merge.
+- Correct capacity automatic-fold behavior wherever public docs describe queue
+ admission, including the staged-attachment exception.
+- Add `queue_incarnation_id` to task-session fields. Document required
+ `task_id`, `session_id`, and `session_incarnation_id` on status get and every
+ queue mutation; the gateway/handler authorize that pair and pass the exact
+ triplet unchanged. Document stale non-retargeting errors, operation-bound
+ response/status snapshots, epoch/generation, availability, and effective
+ Auto-merge value/source/revision. Task-only recounts remain internal.
+- Keep configuration, operations, coordination, and session guidance consistent
+ without copying detailed protocol text between pages.
+- After implementation and behavioral checks pass, perform one lifecycle
+ cutover: activate both replacement requirements; deprecate the two superseded
+ requirements without changing their IDs or acceptance text; amend
+ `message-queue-management.md` with the delivered compatible-fold and
+ staged-attachment capacity exceptions; mark the system design current; and
+ update the UI specification indexes.
+
+## Out of scope
+
+- New screenshots, diagrams, or a new public page.
+- Changes to GitHub or GitLab pull-request automation documentation.
+- Production implementation details already owned by Tasks 01 and 02.
+
+## Acceptance
+
+- Public docs state that untouched sessions follow later global changes and an
+ explicitly changed session remains independent for that session's lifetime.
+- Queue guidance names both compact pills, eligible direct folding at or above
+ capacity, and staged-attachment rejection, with no contradictory claim in
+ affected pages.
+- The WebSocket reference documents exact action/field names, pair
+ authorization, required task/session/incarnation payloads for get and every
+ mutation, non-retargeting errors, operation-bound status snapshots,
+ unavailable ordering, task-only scope, incarnation matching,
+ source/revision, and effective-value semantics. Task-session responses expose
+ immutable queue incarnation. The final cutover leaves
+ replacement requirements active, superseded requirements deprecated with
+ forward links, the management capacity text aligned to delivered behavior,
+ and the system design current.
+
+## Verification
+
+```bash
+python3 scripts/lint-spec-files.test.py
+python3 scripts/lint-spec-files.py --all
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+git diff --check -- docs
+git diff --cached --check -- docs
+git ls-files -z --others --exclude-standard -- docs \
+ | xargs -0 -r -n1 sh -c 'test -z "$(git diff --no-index --check /dev/null "$1" 2>&1)"' sh
+```
+
+Run all commands from the repository root.
+
+## Files likely touched
+
+- `docs/public/coordination.md`
+- `docs/public/operations.md`
+- `docs/public/sessions-and-review.md`
+- `docs/public/configuration.md`
+- `docs/specs/ui/requirements/message-queue-automation-controls.md`
+- `docs/specs/ui/requirements/message-queue-auto-merge-session-overrides.md`
+- `docs/specs/ui/requirements/message-queue-run.md`
+- `docs/specs/ui/requirements/message-queue-management.md`
+- `docs/specs/ui/requirements/message-queue-auto-merge.md`
+- `docs/specs/ui/system-design/message-queue-automation-controls.md`
+- `docs/specs/ui/README.md`
+- `docs/public/websocket-api.md`
+
+## Dependencies
+
+- Task 01 defines the final protocol and persistence behavior.
+- Task 02 defines the final user-facing labels and responsive composition.
+- This task performs the lifecycle cutover only after Tasks 01 and 02 have
+ passed their behavioral verification; drafts must not become active earlier.
+
+## Risks
+
+- Existing pages describe both queued-message Auto-merge and pull-request
+ Auto-merge; edits must remain scoped to message queues.
+- Repeating the complete precedence contract across every guide would create
+ another set of drift-prone sources instead of linking to the owning guidance.
+- Activating replacements without deprecating and forward-linking the old
+ contracts would leave implementers with conflicting authoritative behavior.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- `REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001`
+- `REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001`
+- `docs/specs/ui/system-design/message-queue-automation-controls.md`
+- `docs/decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md`
+- Current public queue sections in the files listed above.
+
+## Results
+
+Completed. Activated the replacement requirements and design, deprecated the
+superseded queue contracts with forward links, recorded the inheritance
+decision, and updated public configuration, coordination, operations, session,
+and WebSocket documentation. Specification and public-doc validation pass.
diff --git a/docs/public/coordination.md b/docs/public/coordination.md
index 2728c60b4e..fa34163a9c 100644
--- a/docs/public/coordination.md
+++ b/docs/public/coordination.md
@@ -141,15 +141,16 @@ For predictable top-level creation, pass `repository_url`, `repository_id`, or `
| Created but not started | Starts the session with the message. |
| Failed or cancelled | Returns an error. |
-The default delivery mode is queued. Each session accepts 10 queued messages by default. An admin can change the install-wide limit under **Settings > Task Behavior > Message Queue**; `0` means unlimited. The saved value applies immediately to new admissions without removing messages already waiting. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence, locks only the capacity field, and requires a restart when changed; zero or a negative value means unlimited. With **Auto-run** ON, one queued message runs per agent turn. When the cap is reached, the sender receives a structured `queue_full` error and should retry after space becomes available.
+The default delivery mode is queued. Each session accepts 10 queued messages by default. An admin can change the install-wide limit under **Settings > Task Behavior > Message Queue**; `0` means unlimited. The saved value applies immediately to new admissions without removing messages already waiting. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence, locks only the capacity field, and requires a restart when changed; zero or a negative value means unlimited. With **Auto-run** ON, one queued message runs per agent turn. At or above the cap, an eligible direct automatic fold into the pending tail may still succeed without creating another row. Other admissions return `queue_full`; staged attachments are rejected before any fold or attachment claim.
-**Automatically merge consecutive messages** is enabled by default on the same settings card. After capacity admission succeeds, a new message may fold into the immediately preceding pending entry when both have the same strict source and compatible task, model, mode, metadata, attachments, and references. Otherwise it remains a separate FIFO entry. The earlier entry survives, so a successful admission may return an older queue-entry ID. The switch affects only later admissions and never sweeps rows already waiting. It is independent from **Enable queued message merging**, which controls the manual **Merge with above** action.
+**Automatically merge consecutive messages** is enabled by default on the same settings card. Each session follows later changes to this install-wide value until its **Auto-merge** queue pill is changed. That first session change creates an explicit override that remains independent for the lifetime of that session, including across an empty queue and backend restart. For each new admission, Kandev resolves the effective session or global setting once. A compatible message may fold into the immediately preceding pending entry when both have the same strict source and compatible task, model, mode, metadata, attachments, and references. Otherwise it remains a separate FIFO entry. The earlier entry survives, so a successful admission may return an older queue-entry ID. The setting affects only later admissions and never sweeps rows already waiting. It is independent from **Enable queued message merging**, which controls the manual **Merge with above** action.
-In the task workbench, expand the queue chip to manage pending messages. Every visible pending row has **Remove**, whether it came from a user, another agent, workflow automation, or a server action. **Clear all** removes all visible pending rows in that session and releases their capacity. After removal, merge, or drain, displayed positions compact to `#1` through `#N` while FIFO order stays unchanged. Provenance still matters for editing and merging: only user-origin content can be edited. A row already reserved for delivery is hidden from the panel and cannot be cancelled there.
+In the task workbench, expand the queue chip to manage pending messages. The compact header places the **Auto-run** and **Auto-merge** pills beside the queue count; these control queued-message delivery and folding, not pull-request Auto-merge. Every visible pending row has **Remove**, whether it came from a user, another agent, workflow automation, or a server action. **Clear all** removes all visible pending rows in that session and releases their capacity. After removal, merge, or drain, displayed positions compact to `#1` through `#N` while FIFO order stays unchanged. Provenance still matters for editing and merging: only user-origin content can be edited. A row already reserved for delivery is hidden from the panel and cannot be cancelled there.
Use the queue controls according to the outcome you want:
- **Auto-run** is ON by default. ON runs eligible messages one at a time in FIFO order. OFF lets the current response finish, then holds every later message. The per-session setting survives an empty queue, reload, and backend restart. Turning it ON starts the head immediately when the session is promptable; clarification, workflow transitions, cancellation, and other lifecycle guards can defer delivery without changing the displayed ON state.
+- **Auto-merge** shows the effective automatic-fold policy for this session. Changing it creates a permanent session override; untouched sessions continue to inherit the install-wide setting.
- Every row's **Send Now** targets that message. It sends directly when the session is promptable; when an agent turn is active, it waits for backend cancellation acknowledgement and replaces that captured turn. A successful Send Now turns Auto-run ON, runs the selected row first, then continues the preserved remainder as separate FIFO turns. It does not record ordinary Cancel side effects, complete the cancelled workflow step, or move the task to review.
- **Clear all** removes every visible pending row without sending a prompt.
- **Cancel** in the chat toolbar stops the active turn immediately as a user cancellation. It may record the cancellation, complete an eligible workflow step, and move the task to review. It does not send queued content; when pending rows remain, Auto-run becomes OFF so they stay parked.
diff --git a/docs/public/operations.md b/docs/public/operations.md
index 21f3743575..b6ebf611de 100644
--- a/docs/public/operations.md
+++ b/docs/public/operations.md
@@ -120,13 +120,13 @@ Add `--system` to both commands for a system service.
## Message queue settings
-Open **Settings > Task Behavior > Message Queue** to manage install-wide queue behavior. The default capacity is `10`; `0` means unlimited. Admin saves apply immediately to later admissions. Lowering the limit does not prune rows already waiting, so a queue at or above the new limit rejects new work until messages run or are removed. Delivery retries for work accepted before the change are not discarded by the lower cap.
+Open **Settings > Task Behavior > Message Queue** to manage install-wide queue behavior. The default capacity is `10`; `0` means unlimited. Admin saves apply immediately to later admissions. Lowering the limit does not prune rows already waiting. At or above the new limit, only an eligible direct automatic fold into the existing tail can still succeed; other work is rejected until messages run or are removed. Staged attachments are rejected before a fold or claim. Delivery retries for work accepted before the change are not discarded by the lower cap.
`KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence than the saved capacity. A valid environment value makes only that field read-only; zero or a negative value means unlimited. Invalid text is logged and ignored in favor of the saved setting or default. Environment changes require a backend restart, while UI changes do not.
-**Automatically merge consecutive messages** is on by default. Capacity is checked before any fold, so a full queue still rejects a compatible message. After admission, a new row folds only into its immediate pending predecessor when both rows have the same strict source and compatible task, model, mode, metadata, attachments, and references. Any mismatch or combined limit leaves the new row separate. The earlier row survives and its ID is returned. Turning the switch on does not compact existing rows. This setting is independent from **Enable queued message merging**, which controls the manual queue action.
+**Automatically merge consecutive messages** is on by default. It is the fallback for every session that has not changed its compact **Auto-merge** queue pill. Untouched sessions follow later global changes. The first per-session change creates an explicit override that survives empty queues and restarts and remains independent for that session's lifetime. An eligible direct admission may fold into the immediate pending predecessor even at capacity because it does not create another row; staged-attachment admissions cannot use this path. Both rows must have the same strict source and compatible task, model, mode, metadata, attachments, and references. Any mismatch or combined limit leaves the new row separate when capacity permits. The earlier row survives and its ID is returned. Turning either setting on does not compact existing rows. This behavior is independent from **Enable queued message merging**, which controls the manual queue action.
-To recover capacity in one session, expand its queue chip in the task workbench. **Remove** deletes one visible pending row and **Clear all** deletes all visible pending rows, including user-, agent-, workflow-, and server-origin work. After removal, merge, or drain, displayed positions immediately compact to `#1` through `#N`; durable FIFO ordering is unchanged. A row already reserved for delivery is hidden and is not cancelled by either action.
+To recover capacity in one session, expand its queue chip in the task workbench. The compact header contains **Auto-run** and **Auto-merge** pills beside the count. **Remove** deletes one visible pending row and **Clear all** deletes all visible pending rows, including user-, agent-, workflow-, and server-origin work. After removal, merge, or drain, displayed positions immediately compact to `#1` through `#N`; durable FIFO ordering is unchanged. A row already reserved for delivery is hidden and is not cancelled by either action.
## State and storage
diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md
index 1e45a8cc44..ff8cf943b2 100644
--- a/docs/public/sessions-and-review.md
+++ b/docs/public/sessions-and-review.md
@@ -63,11 +63,11 @@ Stopping is not deletion. Resume succeeds only while the executor still has the
Stopping a turn does not itself run the next queued message. If pending rows remain, Kandev sets their session's **Auto-run** switch to OFF. Expand the queue and turn Auto-run ON when you want FIFO processing to continue.
-The expanded queue also lets you pause or discard stale work. **Remove** is available for every visible pending row, including messages from users, peer agents, workflows, and server actions; **Clear all** removes all visible pending rows in that session. Only user-origin rows remain editable. A message already reserved for delivery is hidden from the queue and cannot be cancelled with these controls.
+The expanded queue also lets you pause or discard stale work. Its compact header places the **Auto-run** and **Auto-merge** pills beside the queue count. **Remove** is available for every visible pending row, including messages from users, peer agents, workflows, and server actions; **Clear all** removes all visible pending rows in that session. Only user-origin rows remain editable. A message already reserved for delivery is hidden from the queue and cannot be cancelled with these controls.
-Use **Auto-run** for normal queue motion. ON runs one eligible row per turn in FIFO order; OFF lets the current response finish and holds later rows. The setting belongs to the session and survives an empty queue, reload, and backend restart. A pending clarification or another lifecycle guard can leave the queue waiting while the switch remains ON.
+Use **Auto-run** for normal queue motion. ON runs one eligible row per turn in FIFO order; OFF lets the current response finish and holds later rows. The setting belongs to the session and survives an empty queue, reload, and backend restart. A pending clarification or another lifecycle guard can leave the queue waiting while the switch remains ON. **Auto-merge** controls automatic folding of later compatible admissions. It initially follows the install-wide value and follows later global changes until you change the pill. That first change creates a session override that remains independent for the session's lifetime.
-Every row has **Send Now** for targeted priority. It sends that row directly when the session is promptable or replaces the captured active turn after backend cancellation acknowledgement. A successful Send Now turns Auto-run ON, runs the selected row first, then continues the remaining rows as separate FIFO turns without ordinary Cancel side effects. **Clear all** discards the visible queue. The chat toolbar's **Cancel** immediately stops the active turn, sends no queued prompt, parks any pending backlog with Auto-run OFF, and may complete an eligible workflow step or move the task to review.
+Every row has **Send Now** for targeted priority. It sends that row directly when the session is promptable or replaces the captured active turn after backend cancellation acknowledgement. A successful Send Now turns Auto-run ON, runs the selected row first, then continues the remaining rows as separate FIFO turns without ordinary Cancel side effects. **Clear all** discards the visible queue. The chat toolbar's **Cancel** immediately stops the active turn, sends no queued prompt, parks any pending backlog by turning Auto-run OFF, and can complete the workflow step or move the task to review.
A CLI-passthrough profile displays the agent's native terminal interface in a PTY. It still belongs to the task, but it does not provide Kandev's structured chat messages and tool-call presentation.
@@ -101,9 +101,9 @@ Delivery follows the target state:
Without an explicit session ID the message goes to the primary session, and falls back to the newest session that can still take a message when the primary is cancelled or failed. A session named explicitly is never redirected. When every session is terminal the call fails and names `spawn_session_kandev`.
-The default pending-message limit is 10 per session. An admin can change it live under **Settings > Task Behavior > Message Queue**; `0` removes the cap. A valid `KANDEV_QUEUE_MAX_PER_SESSION` value takes precedence and makes only the capacity field read-only; changing the environment still requires a restart. Malformed environment values are logged and ignored, so the saved setting or default applies instead. Lowering the saved limit does not delete entries already waiting.
+The default pending-message limit is 10 per session. An admin can change it live under **Settings > Task Behavior > Message Queue**; `0` removes the cap. A valid `KANDEV_QUEUE_MAX_PER_SESSION` value takes precedence and makes only the capacity field read-only; changing the environment still requires a restart. Malformed environment values are logged and ignored, so the saved setting or default applies instead. Lowering the saved limit does not delete entries already waiting. An eligible direct automatic fold may still succeed at or above capacity because it does not add a row; other admissions are rejected, and staged attachments are rejected before any fold or claim.
-The same card enables **Automatically merge consecutive messages** by default. Once capacity admission succeeds, compatible consecutive messages from the same strict source fold into the earlier pending entry; incompatible messages remain separate. The earlier entry's ID survives, and only admissions after the setting is read are eligible. This behavior is independent from the manual **Enable queued message merging** switch. Interrupt delivery is restricted to a direct parent task messaging its child. Other senders must queue.
+The same card enables **Automatically merge consecutive messages** by default. Untouched sessions inherit this value and later changes to it. Changing a session's **Auto-merge** pill creates an explicit override for that session's lifetime. Compatible consecutive messages from the same strict source fold into the earlier pending entry; incompatible messages remain separate when capacity permits. The earlier entry's ID survives, and only admissions after the effective setting is read are eligible. This behavior is independent from the manual **Enable queued message merging** switch. Interrupt delivery is restricted to a direct parent task messaging its child. Other senders always queue, and only user-origin rows may be edited or manually merged.
For urgent replacement work, the parent should use `message_task_kandev` with `delivery_mode: "interrupt"`; this cancels the current approach and immediately tries to dispatch the new prompt, with a safe queued fallback. Use `stop_task_kandev` only for halt-only intent. A successful stop marks every accepted live child session `CANCELLED` and schedules graceful teardown asynchronously. Kandev then attempts to move an eligible unarchived, non-Office task from `IN_PROGRESS` or `SCHEDULING` to `REVIEW`; other task states remain unchanged. A child with no live execution returns idempotent `not_running`, and its worktrees, environment, commits, task record, descendants, and queued messages are preserved. See [Coordination](coordination.md) for the complete authority and lifecycle contract.
diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md
index fab6b6c474..cc3794ab2c 100644
--- a/docs/public/websocket-api.md
+++ b/docs/public/websocket-api.md
@@ -226,6 +226,8 @@ If `intent` is omitted, the backend infers it from those fields. That inference
A successful response contains `success`, `task_id`, `state`, and usually `session_id`; it can also contain `agent_execution_id`, `worktree_path`, and `worktree_branch`. Session states use uppercase values such as `CREATED`, `STARTING`, `RUNNING`, `WAITING_FOR_INPUT`, `COMPLETED`, `FAILED`, and `CANCELLED`.
+Every task-session response includes immutable `queue_incarnation_id`. Kandev generates a new value when a session is created, including when a deleted textual session ID is reused. Queue clients must retain this value with the task and session IDs rather than looking up a replacement after starting an operation.
+
### Search work-item references over HTTP
The structured chat composer's `#` search calls `GET /api/v1/workspaces/:workspaceId/mentions/search?q=&limit=&exclude_task_id=`. `q` is required after trimming and accepts 1–200 Unicode characters. `limit` defaults to 5 and is clamped to 1–10. When supplied, `exclude_task_id` must belong to the requested workspace.
@@ -253,9 +255,13 @@ A successful response returns the normalized query and an ordered `groups` array
`message.queue.add` accepts the same optional array. `message.queue.update` also accepts it and treats the array as a replacement: send `[]` after removing every generated reference link so stale metadata is cleared. Queue status and message responses return validated entries under `metadata.entity_references`.
-Ordinary `message.queue.add` admissions use the default-on automatic-merge setting. Capacity is enforced first. The newly admitted row folds only into its immediate pending predecessor when both rows have the same strict source and compatible task, model, plan mode, metadata, attachments, context files, and entity references; the merged context-file union is capped at 200 descriptors. Otherwise it remains separate. A successful fold returns the surviving earlier row's `entry_id`, while a fallback returns the new row's ID. The final state is published through `message.queue.status_changed`. Automatic merging applies only to later ordinary admissions, not existing rows, explicit append/coalesce/restore/retry/send-now operations, or the independent manual merge action.
+Every browser queue read or mutation requires the exact `task_id`, `session_id`, and `session_incarnation_id` triplet. This applies to `message.queue.get`, `message.queue.add`, `message.queue.append`, `message.queue.cancel`, `message.queue.drain`, `message.queue.update`, `message.queue.remove`, `message.queue.merge`, `message.queue.reorder`, `message.queue.send_now`, `message.queue.auto_run.set`, and `message.queue.auto_merge.set`. The gateway authorizes the task/session pair and the queue layer compares the supplied incarnation before acting. A deleted or replaced session returns the same non-enumerating not-found response as an inaccessible session; the request is not redirected to a new session with the same textual ID and clients must not retry it against a freshly looked-up identity.
-`message.queue.merge` folds a queued entry into the entry directly above it. The payload requires `session_id` and `entry_id` (the source entry being merged away); `user_id` is optional and defaults to `user`. On success the response carries the surviving merged entry's `entry_id` (the target's id) and the server broadcasts an updated `message.queue.status_changed`. The request is rejected with `entry_not_found` when the entry was already drained or is not owned by the caller, with a validation error when no mergeable entry exists above it, and with `merge_reference_overflow` when the combined entity-reference lists would exceed the per-message cap (the merge is rejected atomically; neither row changes).
+Ordinary `message.queue.add` admissions use one effective automatic-merge policy snapshot. An untouched session inherits the current install-wide value and its global revision. `message.queue.auto_merge.set` accepts required `enabled` and creates a persistent explicit session override; later global changes no longer affect that session. The newly admitted row folds only into its immediate pending predecessor when both rows have the same strict source and compatible task, model, plan mode, metadata, attachments, context files, and entity references; the merged context-file union is capped at 200 descriptors. An eligible direct fold can succeed at or above capacity because it adds no row. A staged-attachment admission at or above capacity is rejected before a fold or attachment claim. Otherwise, capacity or incompatibility leaves the admission rejected or separate as applicable. A successful fold returns the surviving earlier row's `entry_id`, while a separate admission returns the new row's ID. Automatic merging applies only to later ordinary admissions, not existing rows, explicit append/coalesce/restore/retry/send-now operations, or the independent manual merge action.
+
+`message.queue.get`, the Auto-merge mutation response, and `message.queue.status_changed` expose `task_id`, `session_id`, `session_incarnation_id`, `status_epoch`, `status_generation`, `auto_merge_available`, `auto_merge_enabled`, `auto_merge_source`, and `auto_merge_revision` with the queue entries and existing Auto-run/manual-merge fields. `status_epoch` is the session incarnation. Within an epoch, clients apply only increasing status generations. `auto_merge_available:false` means the backend could not establish authoritative policy and the control must remain disabled; it is not an OFF value. A `session` source is an explicit override; a `global` source remains inherited. Session and global revisions are monotonic within their own source. Internal task-only recount events have explicit task scope and are not session status.
+
+`message.queue.merge` folds a queued entry into the entry directly above it. In addition to the required identity triplet, its payload requires `entry_id` (the source entry being merged away); `user_id` is optional and defaults to `user`. On success the response carries the surviving merged entry's `entry_id` (the target's ID) and the server broadcasts an updated `message.queue.status_changed`. The request is rejected with `entry_not_found` when the entry was already drained or is not owned by the caller, with a validation error when no mergeable entry exists above it, and with `merge_reference_overflow` when the combined entity-reference lists would exceed the per-message cap. The overflow rejection is atomic; neither row changes.
If the agent is busy, use the `message.queue.*` operations rather than retrying `message.add`. Permission prompts are represented in persisted/session message data; answer one with `permission.respond`. Its payload requires `session_id` and `pending_id`, plus `option_id` unless `cancelled:true`; optional `rejected:true` distinguishes an explicit denial from dismissing the prompt.
@@ -354,11 +360,15 @@ message.add
message.list
message.queue.add
message.queue.append
+message.queue.auto_merge.set
+message.queue.auto_run.set
message.queue.cancel
message.queue.drain
message.queue.get
message.queue.merge
message.queue.remove
+message.queue.reorder
+message.queue.send_now
message.queue.update
message.search
diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md
index 115a162e17..ca735dd063 100644
--- a/docs/specs/ui/README.md
+++ b/docs/specs/ui/README.md
@@ -61,12 +61,14 @@ UI owns responsive behavior; other systems own behavior/state.
- [Mermaid Rendering](requirements/mermaid-rendering.md)
- [Message favorite star mobile sizing](requirements/message-favorite-star-mobile-size.md)
- [Metadata dialog](requirements/message-metadata-overflow.md)
-- [Queue auto-merge](requirements/message-queue-auto-merge.md)
+- [Queue auto-merge (deprecated)](requirements/message-queue-auto-merge.md)
+- [Queue auto-merge session overrides (active)](requirements/message-queue-auto-merge-session-overrides.md)
+- [Message queue automation controls (active)](requirements/message-queue-automation-controls.md)
- [Message queue management](requirements/message-queue-management.md)
- [Merge queued](requirements/message-queue-merge.md)
- [Pin the Message Queue Panel](requirements/message-queue-pin.md)
- [Reorder Queued Messages](requirements/message-queue-reorder.md)
-- [Pending auto-run](requirements/message-queue-run.md)
+- [Pending auto-run (deprecated)](requirements/message-queue-run.md)
- [Send Queued Messages Now](requirements/message-queue-send-now.md)
- [Mobile topbar actions](requirements/mobile-quick-chat-topbar.md)
- [Mobile Task Chrome](requirements/mobile-task-chrome.md)
@@ -165,6 +167,7 @@ UI owns responsive behavior; other systems own behavior/state.
- [File Tree Chat](system-design/file-tree-chat-context.md)
- [MCP results](system-design/kandev-mcp-tool-results.md)
- [Message queue row controls](system-design/message-queue-row-controls.md)
+- [Message Queue Automation Controls (current)](system-design/message-queue-automation-controls.md)
- [Native HTML File Preview](system-design/native-html-preview.md)
- [Mobile Task Chrome](system-design/mobile-task-chrome.md)
- [Persistent status motion](system-design/persistent-status-motion.md)
diff --git a/docs/specs/ui/requirements/message-queue-auto-merge-session-overrides.md b/docs/specs/ui/requirements/message-queue-auto-merge-session-overrides.md
new file mode 100644
index 0000000000..cbcaffd955
--- /dev/null
+++ b/docs/specs/ui/requirements/message-queue-auto-merge-session-overrides.md
@@ -0,0 +1,189 @@
+---
+status: active
+system: ui
+created: 2026-09-04
+owners:
+ - kandev
+---
+
+# Per-session Automatic Message Merge Override Requirements
+
+## Overview
+
+Automatic message merging reduces avoidable agent turns by folding compatible
+consecutive prompts from one source. Install administrators set the default,
+while each task session can retain an explicit override from the expanded queue
+panel.
+
+This requirement supersedes
+`REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-001` when activated. It preserves the existing
+compatibility and content/entity fold semantics while replacing the
+install-wide-only policy, aligning queued-time behavior with the shipped
+automatic fold, adding a session control, and allowing a compatible direct fold
+at or above the persisted-row capacity.
+
+## Terminology
+
+- **Global value:** The current install-wide automatic-merge setting.
+- **Session override:** An explicit ON or OFF value saved after a user changes
+ the Auto-merge switch for one task session.
+- **Effective value:** The session override when one exists; otherwise the
+ current global value.
+- **Staged-attachment admission:** An admission that must create a source row
+ before it can claim uploaded attachments into session ownership.
+
+## Requirements
+
+### REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001: Per-session Automatic Message Merge Override
+
+**Intent:** Let users override the install default for the session they are
+actively managing without making untouched sessions stale copies of that
+default.
+
+#### Acceptance criteria
+
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.1:** \*\*Settings > Task Behavior
+ > Message Queue** shall expose an **Automatically merge consecutive
+ > messages\*\* switch whose install-wide default is ON. Its localized description
+ > shall explain that sessions inherit this value until changed in the session's
+ > queue panel.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.2:** A session without an
+ explicit override shall use the current global value. When the global value
+ changes, existing sessions that have never changed Auto-merge shall use the
+ new value for later admissions.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.3:** The first successful
+ Auto-merge change in a session shall persist an explicit ON or OFF override
+ for that session. Later global changes shall not alter its effective value.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.4:** The expanded queue header
+ shall expose the effective value through the labeled Auto-merge switch pill
+ defined by `REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001`.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.5:** The effective value shall
+ apply only to messages admitted after it is resolved. Changing it shall not
+ compact or split entries already pending.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.6:** When effective Auto-merge
+ is ON, a newly admitted message shall fold into the pending tail only when the
+ entries are consecutive and compatible under
+ `REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-001`.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.7:** At or above a positive
+ persisted-row limit, a compatible admission that does not require a staged
+ attachment claim shall fold directly into the tail without increasing the
+ row count. An incompatible admission or an admission with effective
+ Auto-merge OFF shall fail with `queue_full` and leave the queue unchanged.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.8:** At or above a positive
+ persisted-row limit, a staged-attachment admission shall fail with
+ `queue_full` before claiming the attachment or changing the prior tail. It
+ shall not use the direct candidate-fold path.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.9:** A successful automatic fold
+ shall preserve the earlier tail's identity, FIFO position, task, model,
+ plan-mode value, and sender identity while appending compatible content and
+ additive context in target-then-source order. Its queued time shall become
+ the later of the target and source queued times so pending activity reflects
+ the newest accepted prompt.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.10:** Automatic merging shall
+ remain independent from manual **Merge with above**. The two effective
+ switches may differ without enabling or disabling each other's behavior.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.11:** A saved session override
+ shall survive an empty queue, navigation, reload, and backend restart. A new
+ task session shall have no override and shall inherit the current global
+ value until changed.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.12:** A failed override mutation
+ shall restore authoritative state and show a localized error. If the backend
+ cannot read a session override during admission, that admission shall fail
+ closed by skipping automatic merge rather than falling back to ON.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.13:** A delayed queue snapshot,
+ successful status, or unavailable-status result shall not replace or disable
+ newer authoritative Auto-merge state for the same task-session incarnation.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.14:** Desktop and mobile shall
+ expose the same effective state, mutation, persistence, error, and global
+ precedence outcomes without document horizontal overflow or nested queue
+ scrolling.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.15:** If the backend cannot
+ resolve Auto-merge while reading queue status, it shall still reconcile queue
+ entries but shall report the policy as unavailable rather than inventing an
+ effective value. The client shall apply availability only from the newest
+ status read, preserve any last authoritative value, and disable Auto-merge
+ until a newer successful status restores availability.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.16:** An override shall belong
+ to the exact task-session identity. Deleting that session, deleting its task,
+ or deleting its workspace shall remove its queue-owned state atomically with
+ the destructive lifecycle operation, so later reuse of the identity starts
+ without an override. Archiving a task shall preserve the state because its
+ session identity remains; workflow transfer shall remove the source state
+ without copying its override over the destination's independent state. A
+ post-delete queue recount shall be task-scoped, shall not carry the deleted
+ session ID, and shall not be delivered to browser session reconciliation.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.17:** Every created task session
+ shall receive an opaque server-generated incarnation identity that remains
+ stable for that row's lifetime and changes if the same session ID is reused.
+ Legacy rows shall receive distinct identities through a replay-safe migration
+ that preserves them through every task-session table rebuild.
+ Session-scoped queue status shall carry that identity. The client shall
+ discard status for a different incarnation, clear queue metadata when the
+ session is removed, and reset source/revision ordering when the current
+ session ID is recreated with a new incarnation.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.18:** Every ordinary queue
+ admission and Auto-merge override mutation shall carry the caller's expected
+ session incarnation into the persistence boundary. Browser
+ `message.queue.add` and `message.queue.append` requests shall require that
+ token, and every internal ordinary or lifecycle-coalescing producer capable
+ of inserting a row shall use the same typed identity contract. The backend
+ shall acquire the owning task lock before the queue session lock, confirm the
+ exact task/session/incarnation still exists, and reject a missing or changed
+ incarnation through the existing non-enumerating session-not-found response
+ without appending, inserting, replacing, folding, claiming attachments, or
+ writing policy. Session deletion shall use the same lock order, validation,
+ and atomic queue-state cleanup.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.19:** A browser queue read shall
+ carry its expected task/session/incarnation through authorization and the
+ identity-bound snapshot. Every session-scoped status shall label entries and
+ policy with only that validated identity. Deletion or recreation during the
+ read shall yield an old-incarnation snapshot or no payload, never replacement
+ state or old rows labeled with the replacement incarnation.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.20:** Queue mutation/loading
+ state shall belong to an exact session incarnation and operation generation.
+ Completion or cleanup from an older incarnation or operation shall not clear
+ a newer operation or re-enable controls for a replacement session that
+ reused the same textual ID.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.21:** Queue persistence shall
+ fail closed unless an injected task-session authority validates identities
+ inside its mutation transaction or memory critical section. A session
+ transfer shall validate exact source and destination task/session/incarnation
+ identities after locking sorted task rows and then sorted queue-session locks
+ in one transaction. A mismatch shall move no rows or policy, and concurrent
+ transfer and destructive lifecycle operations shall not deadlock or leave
+ old-incarnation rows.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.22:** An admission that claims
+ staged attachments shall validate its exact session identity, insert the
+ queue row, claim attachments, and perform any automatic fold in one
+ task-row-then-session-lock transaction or memory critical section. Any claim
+ or identity failure shall roll back every queue and attachment change.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.23:** Deferred transition apply
+ shall atomically CAS the captured task/session/incarnation, pending move,
+ expected source, and target, then return an immutable effect token containing
+ the committed task transition generation and queue-session identity.
+ Post-commit task effects shall CAS that generation; session/queue effects
+ shall validate the token's identity and generation. Recreation or a successor
+ move shall leave its state unchanged.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.24:** Every session-scoped queue
+ mutation and its resulting response or status shall retain the caller's
+ expected task/session/incarnation. A stale operation shall not add, append,
+ reserve, acknowledge, drain, send, update, remove, merge, reorder, clear,
+ restore, change policy, or publish replacement-session state.
+- **AC-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001.25:** Queue work, including
+ deferred-transition work, that continues after a claim or transaction shall
+ retain immutable session identity and operation generation through
+ cancellation, prompt dispatch, restore, acknowledgement, and terminal
+ cleanup. Delayed old work shall not prompt, restore into, block, or clear
+ in-flight ownership for a replacement session.
+
+## Exclusions
+
+- No workspace-level, task-level, or user-level override.
+- No Follow global or reset-to-global control.
+- No inherited-versus-explicit badge in the queue header.
+- No retroactive compaction or reconstruction of pending messages.
+- No transfer of an override from one task session to another.
+
+## Related decision
+
+[Inherit Queue Auto-merge Until Overridden](../../../decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md)
diff --git a/docs/specs/ui/requirements/message-queue-auto-merge.md b/docs/specs/ui/requirements/message-queue-auto-merge.md
index f7a15bd82a..7a920baa2c 100644
--- a/docs/specs/ui/requirements/message-queue-auto-merge.md
+++ b/docs/specs/ui/requirements/message-queue-auto-merge.md
@@ -1,16 +1,19 @@
---
-status: active
+status: deprecated
system: ui
created: 2026-08-12
owners:
- kandev
---
+
# Automatically Merge Consecutive Queued Messages Requirements
## Overview
Users and coordinating agents often send several short follow-ups while a session is busy. Delivering every follow-up as a separate turn adds latency and queue noise even when the messages came from the same source and can safely be handled together.
+Deprecated by [Per-session Automatic Message Merge Override](message-queue-auto-merge-session-overrides.md), which preserves compatibility rules while adding inheritance, session overrides, and the direct-fold capacity exception.
+
## Requirements
### REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-001: Automatically Merge Consecutive Queued Messages
diff --git a/docs/specs/ui/requirements/message-queue-automation-controls.md b/docs/specs/ui/requirements/message-queue-automation-controls.md
new file mode 100644
index 0000000000..a0aff6c900
--- /dev/null
+++ b/docs/specs/ui/requirements/message-queue-automation-controls.md
@@ -0,0 +1,81 @@
+---
+status: active
+system: ui
+created: 2026-09-04
+owners:
+ - kandev
+---
+
+# Message Queue Automation Controls Requirements
+
+## Overview
+
+The expanded queue panel must present queue progress and both session automation
+policies without spending a second row on one control. Auto-run retains its
+backend-owned behavior while Auto-merge gains a session-scoped control.
+
+This requirement supersedes `REQ-UI-MESSAGE-QUEUE-RUN-001` when activated. It
+preserves that requirement's queue execution behavior while replacing its
+header composition and visible-help contract.
+
+## Requirements
+
+### REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001: Message Queue Automation Controls
+
+**Intent:** Keep queue automation visible, compact, accessible, and equivalent
+on desktop and mobile.
+
+#### Acceptance criteria
+
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.1:** When the queue panel is
+ expanded, one responsive header composition shall contain the queue summary,
+ labeled Auto-run and Auto-merge switch pills, and the existing queue actions.
+ Layouts with enough width shall keep the groups on the first visual line;
+ narrower layouts may wrap groups but shall not render a dedicated second
+ Auto-run row.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.2:** The Auto-run switch shall
+ expose its checked state, disabled state, keyboard focus, and localized ON or
+ OFF behavior description to assistive technology. Its state-specific
+ description need not remain visually present after the compact header
+ replaces the prior helper row.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.3:** Auto-run ON shall allow the
+ backend to start the FIFO head whenever the session is eligible and continue
+ with one distinct turn per entry after each preceding turn completes.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.4:** Auto-run OFF shall prevent
+ every later automatic FIFO take without cancelling, truncating, or altering
+ an active turn or a queued handoff accepted before OFF won the race.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.5:** Turning Auto-run ON while
+ the session is promptable shall attempt the FIFO head immediately. When the
+ session is busy or another lifecycle guard applies, ON shall persist and
+ delivery shall resume at the next eligible trigger.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.6:** Auto-run shall remain
+ backend-owned per session, default to ON, survive an empty queue, navigation,
+ reload, and backend restart, and remain OFF until an explicit resume changes
+ it.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.7:** Every visible queue row
+ shall retain Send Now. A successful Send Now shall enable Auto-run, dispatch
+ the selected entry first, and preserve the relative FIFO order of the
+ remainder. The first-party header shall not add a bulk Send Now action.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.8:** A switch shall be disabled
+ while its mutation or another conflicting queue or cancellation operation is
+ pending. A failed mutation shall restore authoritative state and show a
+ localized error.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.9:** Desktop and mobile shall
+ expose the same controls and outcomes. Header groups shall wrap without
+ document horizontal overflow; coarse-pointer effective targets shall be at
+ least 44 CSS pixels in each dimension.
+- **AC-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001.10:** Reorder, edit, manual
+ merge, Remove, Clear all, pin, collapse, and the queue panel's single internal
+ scroll owner shall retain their existing behavior.
+
+## Exclusions
+
+- No mobile-only drawer or alternate queue route.
+- No aggregate first-party Send Now control.
+- No inherited-versus-explicit badge or reset-to-global control.
+
+## Related requirements
+
+- [Control Pending Message Auto-run](message-queue-run.md)
+- [Per-session Automatic Message Merge Overrides](message-queue-auto-merge-session-overrides.md)
+- [Send Queued Messages Now](message-queue-send-now.md)
diff --git a/docs/specs/ui/requirements/message-queue-management.md b/docs/specs/ui/requirements/message-queue-management.md
index a891ee565f..f99fa60e03 100644
--- a/docs/specs/ui/requirements/message-queue-management.md
+++ b/docs/specs/ui/requirements/message-queue-management.md
@@ -5,6 +5,7 @@ created: 2026-08-03
owners:
- kandev
---
+
# Manage Pending Message Queues Requirements
## Overview
@@ -75,9 +76,11 @@ remains full. Those same rows have no individual remove action.
automatic merge switches.
- The default is `10`. A positive integer sets a cap; `0` means unlimited.
- A saved setting applies immediately to later admissions. Existing entries
- are never trimmed. If a queue already exceeds a newly lowered limit, new
- messages are rejected with `queue_full` until its persisted count is below
- the limit.
+ are never trimmed. At or above a positive cap, an eligible direct automatic
+ fold into the pending tail may still succeed because it does not add a row.
+ Other admissions are rejected with `queue_full` until the persisted count is
+ below the limit. A staged-attachment admission cannot use the direct-fold
+ exception and is rejected before attachment claim or tail mutation.
- Previously accepted work may be restored or retried after a delivery
failure even when the new cap is lower. Capacity limits new work; it does not
turn a failed delivery into message loss.
diff --git a/docs/specs/ui/requirements/message-queue-run.md b/docs/specs/ui/requirements/message-queue-run.md
index 849807ba43..885ab06ded 100644
--- a/docs/specs/ui/requirements/message-queue-run.md
+++ b/docs/specs/ui/requirements/message-queue-run.md
@@ -1,16 +1,19 @@
---
-status: active
+status: deprecated
system: ui
created: 2026-08-16
owners:
- kandev
---
+
# Control Pending Message Auto-run Requirements
## Overview
The queue header presents **Run next** and bulk **Send Now** as separate ways to move pending work. **Run next** directly takes only one FIFO head, but normal turn completion then keeps taking later entries. The label describes one backend step rather than the user's real outcome: putting the queue in motion.
+Deprecated by [Message Queue Automation Controls](message-queue-automation-controls.md), which preserves the execution behavior and replaces the expanded-header contract.
+
## Requirements
### REQ-UI-MESSAGE-QUEUE-RUN-001: Control Pending Message Auto-run
diff --git a/docs/specs/ui/system-design/message-queue-automation-controls.md b/docs/specs/ui/system-design/message-queue-automation-controls.md
new file mode 100644
index 0000000000..d025bba8eb
--- /dev/null
+++ b/docs/specs/ui/system-design/message-queue-automation-controls.md
@@ -0,0 +1,562 @@
+---
+status: current
+system: ui
+requirements:
+ - REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001
+ - REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001
+---
+
+# Message Queue Automation Controls System Design
+
+## Purpose and boundaries
+
+This design owns the task-workbench Auto-run and Auto-merge controls and their
+end-to-end queue policy projection. The UI owns the operator outcomes; the
+backend message queue remains the source of truth.
+
+It changes no compatibility rule, manual merge operation, capacity value, Send
+Now behavior, or Auto-run state machine. An eligible message can fold into the
+tail at or above the positive row limit; staged attachments still require
+insertion capacity.
+
+## Requirement mapping
+
+| Requirement | Design sections |
+| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| `REQ-UI-MESSAGE-QUEUE-AUTOMATION-CONTROLS-001` | [Queue header composition](#queue-header-composition), [Frontend state and mutation flow](#frontend-state-and-mutation-flow) |
+| `REQ-UI-MESSAGE-QUEUE-AUTO-MERGE-OVERRIDE-001` | [Policy resolution](#policy-resolution), [Persistence](#persistence), [Admission flow](#admission-flow), [Status ordering](#status-ordering) |
+
+## Components and responsibilities
+
+| Component | Responsibility |
+| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `internal/system/queuesettings.Service` | Persists the install-wide automatic-merge value and revision together, then applies that committed policy to the queue service. |
+| `internal/orchestrator/messagequeue.Service` | Owns one atomic immutable global-policy snapshot and one process status epoch/generation source, resolves the effective session value, serializes session override writes with admissions, and applies the snapshotted value to automatic folding. |
+| `internal/orchestrator/messagequeue.Repository` | Persists the nullable session override and its monotonic revision for memory, SQLite, and PostgreSQL implementations. |
+| Session-scoped queue-status producers | Receive an expected `QueueSessionIdentity` and publish only its identity-bound snapshot. |
+| `internal/orchestrator/handlers.QueueHandlers` | Authorizes the supplied task/session pair, passes the unchanged triplet to queue operations, and publishes status for that identity. |
+| `pkg/websocket` | Defines queue action names. |
+| `useQueue` and the queue session slice | Supply current identities and order, reconcile, preserve, and disable queue state. |
+| `QueuePanelHeader` | Renders the compact automation pills with responsive and accessible interaction. |
+
+## Policy resolution
+
+Automatic merge has two inputs:
+
+```text
+explicit session override present -> use override
+no session override -> use current global value
+session override read fails -> disable merge for this admission
+```
+
+`messagequeue.Service` replaces its independent automatic-merge boolean with one
+immutable `GlobalAutoMergePolicy { Enabled, Revision }` stored through an
+`atomic.Pointer`. `SetGlobalAutoMergePolicy(enabled, revision)` constructs and
+publishes one new snapshot only after the settings value and revision commit.
+Admissions and status reads load that pointer once; they cannot observe a value
+paired with a revision from another settings generation. Reads allocate
+nothing, while the infrequent settings update allocates one immutable snapshot.
+
+At process startup, `backendapp/orchestrator.go` receives the persisted enabled
+value and global revision from `queuesettings.Resolution` and calls
+`SetGlobalAutoMergePolicy` before queue admissions become available. Startup
+never reconstructs the runtime snapshot from the boolean alone. Reconnect tests
+restart with a persisted nonzero revision and verify that the first status uses
+the same pair.
+
+Kandev has one orchestrator owner per installation, so the immutable global
+snapshot is process-local under that deployment invariant. Each ordinary
+admission carries `{task_id, session_id, expected_incarnation}` and loads the
+global snapshot once before calling the repository operation that owns the
+durable policy linearization point. SQL first locks the active task row, then
+the `queue_session_locks` row, revalidates the exact task/session/incarnation,
+and reads the nullable override plus inserts or directly folds in that
+transaction. Override writes receive the same identity tuple and use the same
+lock order and CAS validation. The result carries the one effective snapshot
+used by later post-insert work. The service admission gate preserves
+in-process call order but is not the cross-connection correctness boundary.
+
+A session override committed before the repository admission lock is acquired
+governs that admission. An override committed after the policy-and-insert
+linearization point governs the next admission, even when the current admission
+still has a staged-attachment claim and post-insert fold to complete. A
+concurrent in-process global update is likewise ordered by the single atomic
+load; it never changes policy halfway through one admission.
+
+Updating the global snapshot does not scan or write session rows. Consequently
+every session without an override uses the new committed global policy on its
+next admission and successful queue-status read, while an overridden session
+stays independent.
+
+A user mutation always writes an explicit boolean. There is no clear-override
+WebSocket operation or third UI state.
+
+## Persistence
+
+Queue-owned state gains a nullable override and revision:
+
+```sql
+CREATE TABLE queue_session_state (
+ session_id TEXT PRIMARY KEY,
+ auto_run INTEGER NOT NULL DEFAULT 1,
+ auto_merge_override INTEGER NULL,
+ auto_merge_revision INTEGER NOT NULL DEFAULT 0
+);
+```
+
+`NULL` or a missing row means inherit. `0` means explicit OFF and `1` means
+explicit ON. `auto_merge_revision` increments atomically on every successful
+session override write. Existing rows migrate to `NULL` and revision `0`, so
+upgrades preserve global inheritance. Fresh schema creation and the replay-safe
+`ALTER TABLE` path define both columns using the repository's established
+cross-dialect binding and duplicate-column handling.
+
+`task_sessions` gains an opaque `queue_incarnation_id`. `CreateTaskSession`
+generates a UUID once; it never changes while that row exists. The migration is
+registered after both existing `task_sessions` table-rebuild migrations and
+then adds the column and backfills every empty legacy row with a distinct
+server-generated UUID. A restart resumes empty-row backfill without replacing
+completed values. Fresh schema requires a non-empty value, and any future table
+rebuild must project the column unchanged. Recreating a deleted textual session
+ID therefore produces a different incarnation without retaining queue policy
+state.
+
+The persisted install-wide message-queue settings envelope also gains an
+internal `auto_merge_revision`. It defaults to `0` for legacy values and
+increments atomically only when `auto_merge_enabled` changes. Public settings
+responses retain their existing shape; the revision is queue-status ordering
+metadata, not an editable setting.
+
+The memory repository stores override presence, value, and revision under its
+existing mutex. SQL override writes increment and return the revision while
+holding the durable session lock used by policy-aware admission. The settings
+store saves the global value and revision in one write. Only after that commit
+does the settings service publish the same immutable pair to
+`messagequeue.Service`; a failed save leaves the prior runtime snapshot
+unchanged.
+
+`queuesettings.Resolution` carries the internal global revision alongside its
+public configured and effective views. The backend application startup wiring
+hydrates `messagequeue.Service` with that persisted pair. Live settings saves
+and startup therefore use the same typed application boundary rather than a
+boolean-only initialization path.
+
+The queue package defines required authority seams instead of querying task
+tables itself. `SQLSessionAuthority` exposes
+`LockQueueTaskRows(ctx, tx, taskIDs)`,
+`ValidateQueueSessions(ctx, tx, identities)`, and
+`ClaimQueueAttachments(ctx, tx, identity, claim)`.
+`QueueAttachmentClaim` carries authenticated owner/workspace and staged IDs.
+`MemorySessionAuthority.WithQueueSessionIdentities` supplies an equivalent
+claim-capable boundary to its callback. The task SQLite repository implements
+the SQL seam on the queue transaction; production injects it into
+`NewSQLiteRepository`. SQL fixtures use task/session/attachment rows, while
+memory fixtures inject a seeded locked authority. Neither constructor accepts
+nil; `tasksTablePresent` and missing-schema fail-open behavior are removed.
+
+The common SQL transaction helper locks sorted unique task rows through the
+authority, then sorted repository-local and durable queue-session locks, then
+revalidates every exact identity before queue access. PostgreSQL uses ordered
+`FOR UPDATE`; SQLite uses its writer fence. The service's outer admission mutex
+is never acquired by lifecycle transactions and does not replace this order.
+For memory, the authority read guard encloses validation and sorted bucket
+locks; deletion removes identity under its write guard before post-commit
+cleanup. Admission therefore finishes before deletion or sees a missing
+incarnation.
+
+The handler resolves authenticated claim metadata before admission and passes a
+`QueueAttachmentClaim`, never a side-effect callback.
+`QueueMessageWithMetadataAfterInsert` is removed. The policy-aware repository
+operation validates identity, inserts, claims through the authority on the same
+transaction, then folds if eligible. Claim failure rolls back queue and
+attachment writes. Memory performs the same sequence inside its authority/bucket
+critical section. At capacity, a staged claim still fails before insert or
+claim. A two-connection test pauses inside the claim seam: deletion either wins
+before validation and no claim occurs, or waits and removes the committed row
+and claim without orphaning replacement state.
+
+Queue policy state belongs to the exact task-session incarnation. SQL session
+deletion receives the task ID and expected incarnation, locks the owning task
+row before the durable session lock, revalidates the exact row, and atomically
+deletes the task session, queued rows, and `queue_session_state`. Task and
+workspace deletion already lock their authoritative task rows before acquiring
+affected session locks in stable order and apply the same cleanup before
+cascade removal. Admissions retain task-row-then-session-lock order and reject
+a missing or mismatched session, preventing a waiter from inserting after
+deletion or into a recreated textual ID.
+
+The task repository adds a deletion-only post-commit callback carrying complete
+`[]QueueSessionIdentity{TaskID, SessionID, SessionIncarnationID}` values. It is
+separate from archive's queue purger/recount notifier. The memory repository
+tags each bucket with the complete identity and CAS-purges only exact matches.
+Its policy-aware admission validates the expected identity through the task
+session authority while holding the bucket mutex, so validation and the
+deletion callback order the old bucket. Archive never calls the deletion-only
+callback and therefore preserves Auto-run and Auto-merge policy.
+
+`DeleteSession` relies on this repository cleanup instead of calling
+`messageQueue.CancelAll` after the session row is gone. After commit it
+publishes only `publishTaskQueueStatusEvent(taskID, "")`, an internal
+task-scoped recount. It never publishes a session-scoped queue status for the
+deleted textual ID.
+
+Workflow transfer supplies exact source and destination identities. In one
+transaction it locks sorted unique task rows, then sorted source/destination
+repository-local and durable session locks, and revalidates both incarnations
+before reading or moving queue state. The move preserves existing Auto-run
+semantics and the destination Auto-merge override/revision, while deleting the
+source state. Source or destination mismatch returns session-not-found with no
+row or policy change. Opposite-direction transfer and transfer versus
+session/task/workspace delete/recreate tests use two connections to prove this
+ordering is deadlock-free and leaves no old-incarnation rows. Snapshot/restore
+also validates captured identities and never overwrites either policy.
+
+`pending_moves` gains non-null `session_incarnation_id`; migration backfills
+current identities and deletes unmatched legacy rows. `PendingMoveRecord` and
+every set/read/take/CAS, retry, reaper, snapshot/restore, and transfer operation
+retain `QueueSessionIdentity` and use the common authority/lock boundary.
+Transfer writes the validated destination incarnation.
+
+`applyPendingMove` passes captured identity, pending move ID, expected source,
+and target to one deferred-transition transaction. It locks task then session,
+revalidates, applies every direct write, increments the task workflow-transition
+generation, and returns an immutable `TransitionEffectToken` containing that
+generation and identity. The same-target marker uses this operation.
+
+Every post-commit task effect CASes the token generation and committed target;
+session/queue effects also validate the token identity and operation generation.
+Publication, step history, exit/enter, prompt cleanup, drain, and pull-next fail
+closed after recreation or a successor move. Tests pause before commit and
+after commit before each effect, then race session/task/workspace recreation and
+successor moves in both orders.
+
+## WebSocket contract
+
+The dispatcher adds:
+
+```text
+message.queue.auto_merge.set
+```
+
+Request:
+
+```json
+{
+ "task_id": "task-id",
+ "session_id": "session-id",
+ "session_incarnation_id": "incarnation-uuid",
+ "enabled": false
+}
+```
+
+Successful response:
+
+```json
+{
+ "session_id": "session-id",
+ "session_incarnation_id": "incarnation-uuid",
+ "queue_status_epoch": "server-epoch",
+ "queue_status_generation": 42,
+ "auto_merge_available": true,
+ "auto_merge": false,
+ "auto_merge_source": "session",
+ "auto_merge_revision": 1
+}
+```
+
+`task_id`, `session_id`, `session_incarnation_id`, and `enabled` are required.
+The gateway and handler authorize the supplied task/session pair; persistence
+revalidates the unchanged triplet under task-row-then-session-lock order.
+
+Every browser queue request requires the triplet: `get`, `add`, `append`,
+`cancel`, `update`, `remove`, `merge`, `reorder`, `drain`, `send_now`,
+`auto_run.set`, and `auto_merge.set`. A stale identity returns the same
+non-enumerating session-not-found response, changes nothing, and publishes no
+status. The frontend refetches current state but never retries the operation.
+
+`message.queue.get` and every `message.queue.status_changed` producer report
+policy availability. A successful resolution carries the complete tuple:
+
+```json
+{
+ "session_incarnation_id": "incarnation-uuid",
+ "queue_status_epoch": "server-epoch",
+ "queue_status_generation": 43,
+ "auto_merge_available": true,
+ "auto_merge": true,
+ "auto_merge_source": "global",
+ "auto_merge_revision": 4
+}
+```
+
+`auto_merge_source` is `global` while the session has no override and `session`
+after its first successful override write. The revision belongs to that source:
+the current persisted global revision for inherited values, or the session
+override revision for explicit values. The transport exposes source only for
+ordering; the UI does not display inheritance state or offer reset.
+
+Every session-scoped response/event uses an expected identity captured before
+the operation: browser requests supply it; internal work retains it. The
+producer allocates a generation, then calls the identity-bound snapshot. SQL
+locks task then session, revalidates, and reads entries and policies together;
+memory validates and reads under its bucket lock. The payload uses only the
+expected identity. Mismatch returns session-not-found and publishes nothing, so
+mutation commit followed by recreation cannot expose replacement state.
+
+The payload receives a `queue_status_epoch` from the random process-start
+identity and the allocated generation. The generation comes from one
+process-local atomic counter and increases across sessions; it need not persist
+because a restart changes the epoch.
+
+If override resolution fails, the payload carries the same status ordering
+fields plus `"auto_merge_available": false` and omits `auto_merge`,
+`auto_merge_source`, and `auto_merge_revision`. Queue entries and independent
+metadata still publish. The unavailable marker is not an authoritative OFF
+value, and its generation lets clients reject a delayed older failure.
+
+Older notifications without status ordering or `auto_merge_available` use a
+complete policy tuple, when present, as an available rolling-upgrade payload;
+otherwise they preserve prior frontend policy state. Current session-scoped
+producers always encode status ordering and either a complete available tuple
+or the unavailable marker.
+
+Task-only `message.queue.status_changed` events used to trigger status-summary
+recounts have no `session_id` and cannot resolve a session policy. They carry
+`"queue_status_scope": "task"` and `task_id`, are exempt from session status
+ordering and policy fields, and remain on the internal event bus for the
+status-summary projector. `routeBroadcast` detects this scope and returns
+without sending the event to a session or workspace browser channel. A
+session-scoped publisher must provide a non-empty `session_id`, matching
+`session_incarnation_id`, and the complete status encoder; an empty-session
+queue event without explicit task scope is invalid and is dropped rather than
+falling through to `BroadcastToWorkspace`. Producer and gateway tests cover all
+three branches.
+
+## Admission flow
+
+`QueueSessionIdentity` is mandatory on every service/repository operation that
+can insert, append, replace, restore, reserve, acknowledge, consume, update,
+remove, merge, reorder, clear, transfer, or change policy. No mutating overload
+accepts textual IDs. Browser callers supply current task-session identity;
+handlers authorize its pair and persistence revalidates it. Internal and
+deferred callers retain captured identity.
+
+Reservations, `SendNowClaim`, queued-dispatch/cancellation records, and deferred
+transition dispatch carry identity plus operation generation. Queue restore/ack
+uses the common locked identity boundary. In-flight cleanup CASes identity and
+generation. Before cancel or prompt, orchestration verifies task session,
+execution incarnation, and operation ownership without holding a database lock
+across agent I/O. Stale drain, Auto-run, or Send Now work cannot prompt, restore
+into, block, or clear a replacement. Channel-controlled tests recreate after
+claim and before prompt/restore for manual drain, Auto-run ON, and both Send Now
+scopes. Restore/retry/transfer retains both endpoint identities.
+
+1. Resolve the authoritative task session and carry its task ID, session ID,
+ and immutable incarnation in `QueueSessionIdentity`. Load the current global
+ policy snapshot once.
+2. Call the repository's policy-aware admission operation. SQL locks the active
+ task row, then the durable session lock, and confirms the exact
+ task/session/incarnation still exists before reading policy or queue rows.
+ The memory implementation performs identity validation and admission under
+ its incarnation-tagged bucket mutex.
+3. Under that boundary, read the nullable override, resolve it against the
+ loaded global snapshot, and validate and insert the candidate under the
+ current queue-capacity rules.
+4. If insertion reports `queue_full`, effective Auto-merge is ON, and admission
+ does not require a staged attachment claim, use the same transaction to try
+ the existing direct candidate fold into the compatible tail. This applies
+ when the row count is equal to or greater than the positive limit. If the
+ fold skips, retain `queue_full`.
+5. If override resolution fails, roll back that policy-aware attempt and retry
+ only ordinary insertion with automatic merge disabled. The fallback repeats
+ task/session/incarnation validation and the same lock order, never enters the
+ direct-fold path, and still returns `queue_full` at capacity.
+6. A staged-attachment admission at or above the positive limit returns
+ `queue_full` before claiming the attachment or changing the prior tail.
+7. For a successful ordinary insert, claim staged attachments in the same
+ transaction, then apply the compatibility fold only when its policy snapshot
+ is ON. Claim or identity failure rolls back both queue and attachment writes.
+ A later override applies only to the next admission.
+8. A successful fold preserves the target row identity and FIFO position, but
+ assigns the later target/source queued time, matching the shipped
+ `latestQueuedAt` behavior so pending activity reflects the newest prompt.
+9. Publish final authoritative queue status.
+
+Changing either global or session policy never scans or rewrites pending rows.
+Lifecycle coalescing, retry, restore, and transfer keep their existing
+coalesce, lifecycle-generation, FIFO, claim, and policy-copy semantics but pass
+through the same required session-identity guard before replacing, restoring,
+moving, or inserting a row; only ordinary admissions participate in automatic
+merge. Manual merge and Send Now retain their existing explicit behavior.
+
+## Frontend state and mutation flow
+
+Wire-derived frontend types preserve the backend JSON names:
+`TaskSession.queue_incarnation_id`, `TaskSession.task_id`, and
+`session_incarnation_id` on queue payloads. There is no camel-case mapping
+layer. Local function parameter names may be camel case, but every normalized
+session ownership join and status comparison reads the snake-case
+`TaskSession` properties.
+
+`QueueStatus`, `QueueStatusChangedPayload`, and `QueueMeta` gain the
+incarnation, status epoch/generation, policy availability, and effective
+Auto-merge value, source, and revision. Every `setQueueEntries` call that
+synthesizes metadata preserves the complete known incarnation, ordering, and
+policy state alongside `autoRun` and `mergeEnabled` so optimistic clear and
+reorder operations cannot reset or regress the control.
+
+`queue-api.ts` requires `taskId` and `sessionIncarnationId` for every queue
+request, including status reads. `useQueue` supplies the current `TaskSession`
+identity. Stale outcomes refetch current state but never retry old work.
+
+`QueueState` replaces the textual-session boolean loading entry with
+`activeOperationBySessionId[sessionId]`, whose value contains
+`sessionIncarnationId` and a monotonically allocated client
+`operationGeneration`. `beginQueueOperation(sessionId, incarnation)` verifies
+the current `TaskSession.queue_incarnation_id`, rejects a conflicting active
+operation, installs a fresh token, and returns it.
+`finishQueueOperation(sessionId, token)` deletes state only when both token
+fields and the current session incarnation still match.
+
+An independent status refetch begins and finishes its own token. A refetch
+inside a mutation receives and retains the mutation's token instead of
+installing another one, so the mutation and its authoritative refetch are one
+terminal operation. Success, failure, cancellation, and early returns finish
+through the same compare-and-clear API. Session removal deletes the token; a
+replacement operation receives a new generation. A delayed old success, error,
+refetch, or `finally` therefore cannot clear the replacement's token or
+re-enable its controls.
+
+One shared per-session predicate is true only for the active token matching the
+current incarnation. It disables Auto-run, Auto-merge, row Send Now, and
+reorder together. Auto-merge additionally disables when policy availability is
+false.
+
+The WebSocket handler applies the status-ordering rules below when an available
+tuple is present. An unavailable payload marks the control unavailable but
+preserves its last authoritative tuple; entries and unrelated metadata still
+reconcile. This keeps refresh, reconnect, and live mutation paths consistent.
+
+## Status ordering
+
+Each producer allocates `queue_status_generation` and snapshots its expected
+`QueueSessionIdentity`. Available and unavailable results use only that
+incarnation; mismatch publishes nothing.
+
+Frontend policy ordering is:
+
+1. Reject the whole payload when current `TaskSession.queue_incarnation_id`
+ differs, including queue entries.
+2. A matching incarnation that differs from `QueueMeta` resets prior epoch,
+ generation, availability, source, and revision.
+3. Within one epoch/incarnation, apply availability only at or above the highest
+ generation. A new non-retired process epoch retires the prior epoch; ignore
+ later payloads from retired epochs.
+4. Accept the first complete tuple, same-source revisions at or above the
+ current revision, and the irreversible `global` to `session` transition.
+ Never accept `session` back to `global` for that incarnation.
+5. An accepted unavailable status preserves the tuple and disables Auto-merge;
+ a newer complete tuple restores availability.
+
+All session- and task-removal reducers delete queue entries, `QueueMeta`, and
+the incarnation/generation-tagged operation token for each removed session.
+`workspace.deleted` first snapshots affected session IDs by joining the
+normalized current `TaskSession.task_id` map to locally cached tasks for that
+workspace, then calls the same session cleanup before removing workspace/task
+indexes. Queue metadata can exist only for a session present in that normalized
+map because incarnation validation precedes status application, making this the
+authoritative local cleanup set even for an empty queue.
+
+A newly created `TaskSession` establishes `queue_incarnation_id` before queue
+status is requested. Mutation responses stay narrow, and a post-mutation
+refetch uses the same incarnation comparison instead of unconditionally
+overwriting event state. Focused tests cover failure before success, a
+lower-generation delayed failure after success, restart/reconnect with a new
+epoch, session/task/workspace removal, and delete/recreate of the same textual
+session ID while old-incarnation status, mutation success, error, refetch, and
+`finally` completion are delayed. In the cleanup-race case a new-incarnation
+mutation remains pending and all controls stay disabled until its own matching
+operation generation completes.
+
+## Queue header composition
+
+`QueuePanelHeader` keeps one responsive header region with three logical groups:
+
+1. queue icon, **Queued**, and capacity text;
+2. compact bordered Auto-run and Auto-merge pills, each containing its visible
+ label and shared `Switch`;
+3. Clear all, the desktop-only pin, and collapse.
+
+The groups use wrapping rather than truncating or horizontally scrolling the
+controls. The former dedicated Auto-run help row is removed. Its localized ON
+and OFF explanations remain available as the Auto-run switch's accessible
+description, while Auto-merge has a localized accessible name. Both labels are
+clickable.
+
+Desktop retains the current dense header sizing. Coarse pointers enlarge each
+pill's effective target to at least 44 CSS pixels without inflating desktop
+controls. The existing inline panel remains the presentation on mobile; it owns
+one internal message-list scroll region, uses dynamic viewport height, and does
+not introduce a drawer, fixed footer, safe-area control, or document-level
+horizontal overflow.
+
+Mobile reuses `QueueChip` pill geometry and the current Auto-run coarse-pointer
+target in this inline surface, not a compressed desktop workbench.
+
+The global settings switch remains admin-only. Its localized description and
+five locale catalogs explain that sessions inherit the default until changed.
+
+## Failure and recovery
+
+- **Write or identity failure:** publish no success; stale identity returns
+ non-enumerating session-not-found with no mutation or retry. Refetch and show
+ the localized error when applicable.
+- **Admission policy read fails:** log and admit separately only when capacity
+ permits; never auto-merge.
+- **Status policy read fails:** publish ordered
+ `auto_merge_available: false` without a tuple. Reconcile entries, preserve the
+ last tuple, and disable Auto-merge until a newer success.
+- **Global change:** advance persisted revision; later admission/status uses it
+ without session migration or notification fan-out.
+- **Delayed/legacy status:** epoch, generation, source, and revision reject
+ regressions; compatibility preservation retains unrelated queue state.
+
+## Security
+
+The gateway and handler authorize the supplied task/session pair for every
+browser queue request. They pass that pair plus the server-issued expected
+incarnation unchanged to the identity-bound read or mutation. Persistence
+revalidates under task-row-then-session-lock order, so authorization cannot
+retarget a replacement. Existing admin-only global-setting permissions remain.
+
+## Observability
+
+Queue mutation failures and override-read failures use structured logs with
+`session_id` and the operation. Successful status publication continues through
+`message.queue.status_changed`. No new metric is required because this is a user
+preference, not a background retry or availability loop.
+
+## Verification strategy
+
+- Queue service/repository tests cover policy, capacity, claims, timestamps,
+ fail-closed reads, and required identities for every mutation category.
+- Deterministic SQLite/PostgreSQL tests cover admission, policy, status,
+ transfer, and deferred transition against lifecycle recreation. They pause
+ before writes and after commit before every effect, race successor moves,
+ assert task-then-session lock order, and prove no stale state or deadlock.
+- Drain, Auto-run, deferred dispatch, and Send Now tests pause after claim and
+ before prompt/restore. They prove identity/generation CAS protects replacement
+ execution, queue, and in-flight ownership.
+- Migration/lifecycle tests cover incarnation backfill, deletion cleanup,
+ archive, transfer, task-only recount routing, and global-policy hydration.
+- Handler and producer tests cover triplet authorization, expected-identity
+ status snapshots, stale errors, and task-only browser suppression.
+- Frontend tests cover wire fields, status ordering/removal, and operation-token
+ CAS. Raw E2E helpers and sidebar, desktop, and mobile Playwright cover
+ complete get/add/cancel payloads, mutation, persistence, layout, and
+ stale-token rejection.
+
+## Related decisions
+
+- [Keep Queue Auto-run Server Owned](../../../decisions/2026-08-16-server-owned-queue-auto-run.md)
+- [Inherit Queue Auto-merge Until Overridden](../../../decisions/2026-09-04-inherit-queue-auto-merge-until-overridden.md)