feat(FN-1153): add cross-tab locks for AI planning sessions

- Bump SQLite schema to v19 with ai_sessions lock columns and lock index, and align migration coverage in core DB tests
- Extend AiSessionStore with acquire/release/force lock APIs, stale lock cleanup, and lock metadata in ai_session update summaries
- Enforce lock checks on planning, subtask, and mission interview mutation routes with 409 conflict responses while keeping stream reads unaffected
- Add frontend tab identity + useSessionLock hook and wire Planning, Subtask, and Mission modals to pass tabId, show lock overlay, and support Take Control
- Expand dashboard route/e2e and modal tests to validate lock enforcement, lock handoff, and lock-aware session reentry behavior
This commit is contained in:
gsxdsm
2026-04-08 16:14:32 -07:00
parent 833c7eb15a
commit 4aa1cbdba8
23 changed files with 1540 additions and 58 deletions

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
const index = db
.prepare(

View File

@@ -91,6 +91,7 @@ describe("Database", () => {
expect(indexNames).toContain("idxAgentHeartbeatsRunId");
expect(indexNames).toContain("idxAiSessionsStatus");
expect(indexNames).toContain("idxAiSessionsType");
expect(indexNames).toContain("idxAiSessionsLock");
expect(indexNames).toContain("idxMessagesCreatedAt");
expect(indexNames).toContain("idxMessagesFrom");
expect(indexNames).toContain("idxMessagesTo");
@@ -105,7 +106,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
});
it("seeds lastModified", () => {
@@ -128,7 +129,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
});
it("does not overwrite existing config on re-init", () => {
@@ -735,7 +736,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -760,11 +761,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
db.close();
});
@@ -780,7 +781,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -804,7 +805,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -908,7 +909,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1118,7 +1119,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(18);
expect(db.getSchemaVersion()).toBe(19);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 18;
const SCHEMA_VERSION = 19;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -731,6 +731,17 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTaskDocumentRevisionsTaskKey ON task_document_revisions(taskId, key)`);
});
}
if (version < 19) {
this.applyMigration(19, () => {
if (!this.hasTable("ai_sessions")) {
return;
}
this.addColumnIfMissing("ai_sessions", "lockedByTab", "TEXT");
this.addColumnIfMissing("ai_sessions", "lockedAt", "TEXT");
this.db.exec("CREATE INDEX IF NOT EXISTS idxAiSessionsLock ON ai_sessions(lockedByTab)");
});
}
}
/**

View File

@@ -1245,11 +1245,12 @@ export function startPlanningStreaming(
export function respondToPlanning(
sessionId: string,
responses: Record<string, unknown>,
projectId?: string
projectId?: string,
tabId?: string,
): Promise<PlanningSession> {
return api<PlanningSession>(withProjectId("/planning/respond", projectId), {
method: "POST",
body: JSON.stringify({ sessionId, responses }),
body: JSON.stringify({ sessionId, responses, tabId }),
});
}
@@ -1257,20 +1258,22 @@ export function respondToPlanning(
export function retryPlanningSession(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ success: boolean; sessionId: string }> {
return api<{ success: boolean; sessionId: string }>(
withProjectId(`/planning/${encodeURIComponent(sessionId)}/retry`, projectId),
{
method: "POST",
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
},
);
}
/** Cancel an active planning session */
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
return api<void>(withProjectId("/planning/cancel", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
body: JSON.stringify({ sessionId, tabId }),
});
}
@@ -1819,11 +1822,13 @@ export function startSubtaskBreakdown(description: string, projectId?: string):
export function retrySubtaskSession(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ success: boolean; sessionId: string }> {
return api<{ success: boolean; sessionId: string }>(
withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/retry`, projectId),
{
method: "POST",
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
},
);
}
@@ -1933,10 +1938,10 @@ export function createTasksFromBreakdown(
});
}
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): Promise<void> {
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
return api<void>(withProjectId("/subtasks/cancel", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
body: JSON.stringify({ sessionId, tabId }),
});
}
@@ -3252,11 +3257,12 @@ export function startMissionInterview(missionTitle: string, projectId?: string):
export function respondToMissionInterview(
sessionId: string,
responses: Record<string, unknown>,
projectId?: string
projectId?: string,
tabId?: string,
): Promise<MissionInterviewResponse> {
return api<MissionInterviewResponse>(withProjectId("/missions/interview/respond", projectId), {
method: "POST",
body: JSON.stringify({ sessionId, responses }),
body: JSON.stringify({ sessionId, responses, tabId }),
});
}
@@ -3264,20 +3270,22 @@ export function respondToMissionInterview(
export function retryMissionInterviewSession(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ success: boolean; sessionId: string }> {
return api<{ success: boolean; sessionId: string }>(
withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/retry`, projectId),
{
method: "POST",
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
},
);
}
/** Cancel an active mission interview session */
export function cancelMissionInterview(sessionId: string, projectId?: string): Promise<void> {
export function cancelMissionInterview(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
return api<void>(withProjectId("/missions/interview/cancel", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
body: JSON.stringify({ sessionId, tabId }),
});
}
@@ -3392,6 +3400,7 @@ export interface AiSessionSummary {
status: "generating" | "awaiting_input" | "complete" | "error";
title: string;
projectId: string | null;
lockedByTab: string | null;
updatedAt: string;
}
@@ -3409,6 +3418,7 @@ export interface AiSessionDetail extends AiSessionSummary {
thinkingOutput: string;
error: string | null;
createdAt: string;
lockedAt: string | null;
}
export function parseConversationHistory(raw: string): ConversationHistoryEntry[] {
@@ -3436,6 +3446,38 @@ export async function fetchAiSession(id: string): Promise<AiSessionDetail | null
return res.json();
}
export async function acquireSessionLock(
sessionId: string,
tabId: string,
): Promise<{ acquired: boolean; currentHolder: string | null }> {
const result = await api<{ acquired: boolean; currentHolder?: string | null }>(
`/ai-sessions/${encodeURIComponent(sessionId)}/lock`,
{
method: "POST",
body: JSON.stringify({ tabId }),
},
);
return {
acquired: result.acquired,
currentHolder: result.currentHolder ?? null,
};
}
export function releaseSessionLock(sessionId: string, tabId: string): Promise<void> {
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock`, {
method: "DELETE",
body: JSON.stringify({ tabId }),
});
}
export function forceAcquireSessionLock(sessionId: string, tabId: string): Promise<void> {
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock/force`, {
method: "POST",
body: JSON.stringify({ tabId }),
});
}
export async function deleteAiSession(id: string): Promise<void> {
await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { method: "DELETE" });
}

View File

@@ -10,6 +10,9 @@ const mockCreateMissionFromInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
vi.mock("../api", () => ({
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
@@ -20,6 +23,9 @@ vi.mock("../api", () => ({
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
}));
vi.mock("../hooks/modalPersistence", () => ({
@@ -65,6 +71,9 @@ describe("MissionInterviewModal", () => {
isConnected: vi.fn().mockReturnValue(true),
};
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
});
function renderModal() {
@@ -77,6 +86,32 @@ describe("MissionInterviewModal", () => {
);
}
it("shows lock overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Take Control"));
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("mission-session-1", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
});
});
it("shows reconnecting indicator without clearing current question", async () => {
renderModal();
@@ -189,7 +224,7 @@ describe("MissionInterviewModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import type { PlanningQuestion } from "@fusion/core";
import {
startMissionInterview,
@@ -38,8 +38,11 @@ import {
Trash2,
Minimize2,
RefreshCw,
Lock,
} from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface MissionInterviewModalProps {
isOpen: boolean;
@@ -92,6 +95,13 @@ export function MissionInterviewModal({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
const sessionTabId = useMemo(() => getSessionTabId(), []);
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? lockSessionId : null);
const connectToMissionInterviewStream = useCallback(
(sessionId: string) => {
@@ -157,6 +167,7 @@ export function MissionInterviewModal({
try {
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
clearMissionGoal(projectId);
connectToMissionInterviewStream(sessionId);
@@ -166,6 +177,7 @@ export function MissionInterviewModal({
setError(err.message || "Failed to start interview session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
},
[connectToMissionInterviewStream, missionGoal, projectId]
@@ -201,6 +213,7 @@ export function MissionInterviewModal({
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
@@ -215,6 +228,7 @@ export function MissionInterviewModal({
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setLockSessionId(session.id);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
@@ -318,7 +332,7 @@ export function MissionInterviewModal({
if (view.type === "question" || view.type === "summary" || view.type === "error") {
try {
await cancelMissionInterview(view.sessionId, projectId);
await cancelMissionInterview(view.sessionId, projectId, sessionTabId);
} catch {
// Ignore errors on cancel
}
@@ -336,8 +350,9 @@ export function MissionInterviewModal({
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [missionGoal, hasProgress, view, onClose, projectId]);
}, [missionGoal, hasProgress, view, onClose, projectId, sessionTabId]);
// Escape key handler
useEffect(() => {
@@ -377,14 +392,14 @@ export function MissionInterviewModal({
setStreamingOutput("");
try {
await respondToMissionInterview(sessionId, responses, projectId);
await respondToMissionInterview(sessionId, responses, projectId, sessionTabId);
setHasProgress(true);
} catch (err: any) {
setError(err.message || "Failed to submit response");
setView({ type: "question", sessionId, question: view.question });
}
},
[view, projectId]
[view, projectId, sessionTabId]
);
const handleRetryFromError = useCallback(async () => {
@@ -401,7 +416,8 @@ export function MissionInterviewModal({
try {
currentSessionIdRef.current = retrySessionId;
await retryMissionInterviewSession(retrySessionId, projectId);
setLockSessionId(retrySessionId);
await retryMissionInterviewSession(retrySessionId, projectId, sessionTabId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
@@ -414,7 +430,7 @@ export function MissionInterviewModal({
} finally {
setIsRetrying(false);
}
}, [connectToMissionInterviewStream, projectId, view]);
}, [connectToMissionInterviewStream, projectId, sessionTabId, view]);
const handleApprovePlan = useCallback(async () => {
if (view.type !== "summary") return;
@@ -441,6 +457,7 @@ export function MissionInterviewModal({
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err: any) {
setError(err.message || "Failed to create mission");
@@ -628,12 +645,32 @@ export function MissionInterviewModal({
setEditedSummary(null);
setResponseHistory([]);
setConversationHistory([]);
setLockSessionId(null);
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
}}
isCreating={isCreating}
/>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>This session is active in another tab</span>
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? "Taking control..." : "Take Control"}
</button>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { PlanningModeModal } from "./PlanningModeModal";
import { TaskDetailModal } from "./TaskDetailModal";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
// Mock the API functions
@@ -17,6 +19,9 @@ const mockCreateTasksFromPlanning = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockFetchModels = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
const mockUploadAttachment = vi.fn();
const mockDeleteAttachment = vi.fn();
const mockUpdateTask = vi.fn();
@@ -40,6 +45,9 @@ vi.mock("../api", () => ({
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
updateTask: (...args: any[]) => mockUpdateTask(...args),
@@ -126,12 +134,55 @@ const mockTaskDetail = {
paused: false,
} as TaskDetail;
class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
closed = false;
private listeners = new Map<string, Set<(event: MessageEvent) => void>>();
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(event: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(event) ?? new Set();
set.add(listener);
this.listeners.set(event, set);
}
removeEventListener(event: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(event);
if (!set) return;
set.delete(listener);
}
close(): void {
this.closed = true;
}
emit(event: string, data: unknown): void {
const listeners = this.listeners.get(event);
if (!listeners) return;
const message = { data: JSON.stringify(data) } as MessageEvent;
listeners.forEach((listener) => listener(message));
}
static reset(): void {
MockEventSource.instances = [];
}
}
describe("PlanningModeModal", () => {
const mockOnClose = vi.fn();
const mockOnTaskCreated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
vi.spyOn(window, "confirm").mockReturnValue(true);
// Default mock for streaming
@@ -153,6 +204,9 @@ describe("PlanningModeModal", () => {
favoriteProviders: [],
favoriteModels: [],
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
// Default: simulate receiving a question after a brief delay
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -401,6 +455,80 @@ describe("PlanningModeModal", () => {
});
});
it("shows locked overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByText("Take Control"));
});
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
});
});
it("allows normal question interaction when lock is acquired", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
fireEvent.click(screen.getByText("Small"));
fireEvent.click(screen.getByText("Continue"));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ "q-scope": "small" },
undefined,
"tab-self",
);
});
});
it("shows error message when planning fails", async () => {
// Override the default mock to simulate an error
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -472,7 +600,7 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined);
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
@@ -1599,3 +1727,118 @@ describe("PlanningModeModal", () => {
});
});
});
describe("getSessionTabId", () => {
it("creates and persists a per-tab id in sessionStorage", () => {
window.sessionStorage.clear();
const first = getSessionTabId();
const second = getSessionTabId();
expect(first).toBeTruthy();
expect(second).toBe(first);
expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first);
});
});
describe("useSessionLock", () => {
beforeEach(() => {
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
});
it("acquires on mount and releases on unmount", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const { unmount } = renderHook(() => useSessionLock("session-1"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
unmount();
await waitFor(() => {
expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
});
it("exposes locked state and allows taking control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
const { result } = renderHook(() => useSessionLock("session-2"));
await waitFor(() => {
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
});
await act(async () => {
await result.current.takeControl();
});
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self");
expect(result.current.isLockedByOther).toBe(false);
expect(result.current.currentHolder).toBeNull();
});
it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const sendBeaconSpy = vi.fn(() => true);
vi.stubGlobal("navigator", {
...window.navigator,
sendBeacon: sendBeaconSpy,
} as Navigator);
const { result } = renderHook(() => useSessionLock("session-3"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self");
});
const source = MockEventSource.instances[0];
expect(source).toBeDefined();
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-other",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-self",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(false);
act(() => {
window.dispatchEvent(new Event("beforeunload"));
});
expect(sendBeaconSpy).toHaveBeenCalledWith(
"/api/ai-sessions/session-3/lock/beacon?tabId=tab-self",
);
});
});

View File

@@ -21,9 +21,11 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface PlanningModeModalProps {
isOpen: boolean;
@@ -96,6 +98,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
const sessionTabId = useMemo(() => getSessionTabId(), []);
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? lockSessionId : null);
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
@@ -225,6 +234,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
connectToPlanningStream(sessionId);
setResponseHistory([]);
@@ -233,6 +243,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(err.message || "Failed to start planning session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
@@ -280,6 +291,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (cancelled || !session) return;
currentSessionIdRef.current = resumeSessionId;
setLockSessionId(resumeSessionId);
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
@@ -325,6 +337,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
@@ -378,6 +391,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [initialPlan, onClose, projectId]);
@@ -428,14 +442,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
// Submit response - AI will broadcast events via the already-connected stream
await respondToPlanning(sessionId, responses, projectId);
await respondToPlanning(sessionId, responses, projectId, sessionTabId);
// Events (question/summary) will arrive via the existing SSE stream
} catch (err: any) {
setError(err.message || "Failed to submit response");
setView({ type: "question", session });
}
},
[projectId, view]
[projectId, sessionTabId, view]
);
const handleRetryFromError = useCallback(async () => {
@@ -453,7 +467,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
currentSessionIdRef.current = retryTarget.sessionId;
await retryPlanningSession(retryTarget.sessionId, projectId);
setLockSessionId(retryTarget.sessionId);
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
@@ -466,7 +481,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally {
setIsRetrying(false);
}
}, [connectToPlanningStream, projectId, view]);
}, [connectToPlanningStream, projectId, sessionTabId, view]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
@@ -492,6 +507,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const result = await startPlanningBreakdown(view.session.sessionId, projectId);
setLockSessionId(result.sessionId);
setView({
type: "breakdown",
sessionId: result.sessionId,
@@ -524,6 +540,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err: any) {
setError(err.message || "Failed to create tasks");
@@ -826,6 +843,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}}
/>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>This session is active in another tab</span>
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? "Taking control..." : "Take Control"}
</button>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -7,6 +7,9 @@ const mockRetrySubtaskSession = vi.fn();
const mockConnectSubtaskStream = vi.fn();
const mockCreateTasksFromBreakdown = vi.fn();
const mockCancelSubtaskBreakdown = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
vi.mock("../api", () => ({
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
@@ -14,6 +17,9 @@ vi.mock("../api", () => ({
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
}));
vi.mock("../hooks/modalPersistence", () => ({
@@ -49,6 +55,9 @@ describe("SubtaskBreakdownModal", () => {
});
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "FN-101" }, { id: "FN-102" }] });
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
vi.stubGlobal("confirm", vi.fn(() => true));
});
@@ -73,6 +82,27 @@ describe("SubtaskBreakdownModal", () => {
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
});
it("shows lock overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
renderModal();
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Take Control"));
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
});
});
it("hides send to background button in initial state", () => {
render(
<SubtaskBreakdownModal
@@ -495,7 +525,7 @@ describe("SubtaskBreakdownModal", () => {
fireEvent.click(retryButton);
await waitFor(() => {
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined);
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
});

View File

@@ -16,8 +16,10 @@ import {
getSubtaskDescription,
clearSubtaskDescription,
} from "../hooks/modalPersistence";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw, Lock } from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface SubtaskBreakdownModalProps {
isOpen: boolean;
@@ -91,6 +93,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
? view.sessionId
: null;
const sessionTabId = useMemo(() => getSessionTabId(), []);
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? sessionId : null);
const isInvalid = useMemo(() => {
if (subtasks.length === 0) return true;
@@ -131,14 +139,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
}
if (sessionId) {
try {
await cancelSubtaskBreakdown(sessionId, projectId);
await cancelSubtaskBreakdown(sessionId, projectId, sessionTabId);
} catch {
// ignore cancel errors
}
}
resetState();
onClose();
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
}, [dirty, onClose, resetState, sessionId, sessionTabId, view.type, projectId]);
const connectToSubtaskStream = useCallback(
(activeSessionId: string) => {
@@ -389,7 +397,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
connectToSubtaskStream(retrySessionId);
try {
await retrySubtaskSession(retrySessionId, projectId);
await retrySubtaskSession(retrySessionId, projectId, sessionTabId);
} catch (err: any) {
streamRef.current?.close();
streamRef.current = null;
@@ -402,7 +410,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
} finally {
setIsRetrying(false);
}
}, [connectToSubtaskStream, projectId, view]);
}, [connectToSubtaskStream, projectId, sessionTabId, view]);
if (!isOpen) return null;
@@ -678,6 +686,25 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</div>
</div>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>This session is active in another tab</span>
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? "Taking control..." : "Take Control"}
</button>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -14,6 +14,9 @@ vi.mock("../../api", () => ({
connectMissionInterviewStream: vi.fn(),
fetchAiSession: vi.fn(),
parseConversationHistory: vi.fn(),
acquireSessionLock: vi.fn(),
releaseSessionLock: vi.fn(),
forceAcquireSessionLock: vi.fn(),
}));
vi.mock("../../hooks/modalPersistence", () => ({
@@ -29,6 +32,9 @@ const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview)
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
const mockFetchAiSession = vi.mocked(api.fetchAiSession);
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
const sampleQuestionSingle: PlanningQuestion = {
@@ -96,6 +102,9 @@ describe("MissionInterviewModal", () => {
}
});
mockGetMissionGoal.mockReturnValue("");
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
@@ -236,6 +245,7 @@ describe("MissionInterviewModal", () => {
"mission-session-1",
{ scope: "mvp" },
undefined,
expect.any(String),
);
});
});
@@ -367,7 +377,7 @@ describe("MissionInterviewModal", () => {
await user.click(await screen.findByLabelText("Close"));
await waitFor(() => {
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined);
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
});
});

View File

@@ -51,6 +51,9 @@ const {
mockConnectMissionInterviewStream,
mockCancelMissionInterview,
mockCreateMissionFromInterview,
mockAcquireSessionLock,
mockReleaseSessionLock,
mockForceAcquireSessionLock,
} = vi.hoisted(() => ({
mockStartPlanningStreaming: vi.fn(),
mockConnectPlanningStream: vi.fn(),
@@ -65,6 +68,9 @@ const {
mockConnectMissionInterviewStream: vi.fn(),
mockCancelMissionInterview: vi.fn(),
mockCreateMissionFromInterview: vi.fn(),
mockAcquireSessionLock: vi.fn(),
mockReleaseSessionLock: vi.fn(),
mockForceAcquireSessionLock: vi.fn(),
}));
vi.mock("../../api", () => ({
@@ -81,6 +87,9 @@ vi.mock("../../api", () => ({
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
@@ -131,6 +140,9 @@ describe("ModalReentry", () => {
slices: [],
features: [],
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
vi.stubGlobal("confirm", vi.fn(() => true));
});

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
acquireSessionLock,
forceAcquireSessionLock,
releaseSessionLock,
type AiSessionSummary,
} from "../api";
import { getSessionTabId } from "../utils/getSessionTabId";
interface SessionLockState {
isLockedByOther: boolean;
currentHolder: string | null;
takeControl: () => Promise<void>;
isLoading: boolean;
}
export function useSessionLock(sessionId: string | null): SessionLockState {
const tabId = useMemo(() => getSessionTabId(), []);
const [isLockedByOther, setIsLockedByOther] = useState(false);
const [currentHolder, setCurrentHolder] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!sessionId) {
setIsLockedByOther(false);
setCurrentHolder(null);
setIsLoading(false);
return;
}
let active = true;
setIsLoading(true);
void Promise.resolve(acquireSessionLock(sessionId, tabId))
.then((result) => {
if (!active) return;
if (result.acquired) {
setIsLockedByOther(false);
setCurrentHolder(null);
return;
}
setIsLockedByOther(true);
setCurrentHolder(result.currentHolder);
})
.catch(() => {
if (!active) return;
setIsLockedByOther(false);
setCurrentHolder(null);
})
.finally(() => {
if (!active) return;
setIsLoading(false);
});
return () => {
active = false;
try {
const releaseResult = releaseSessionLock(sessionId, tabId) as Promise<void> | void;
if (releaseResult && typeof releaseResult.catch === "function") {
void releaseResult.catch(() => {
// best-effort on unmount
});
}
} catch {
// best-effort on unmount
}
};
}, [sessionId, tabId]);
useEffect(() => {
if (!sessionId || typeof window === "undefined") {
return;
}
const handleBeforeUnload = () => {
if (typeof navigator.sendBeacon !== "function") {
return;
}
const url = `/api/ai-sessions/${encodeURIComponent(sessionId)}/lock/beacon?tabId=${encodeURIComponent(tabId)}`;
navigator.sendBeacon(url);
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [sessionId, tabId]);
useEffect(() => {
if (!sessionId || typeof EventSource === "undefined") {
return;
}
const eventSource = new EventSource("/api/events");
const handleUpdated = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as AiSessionSummary;
if (payload.id !== sessionId) {
return;
}
const holder = payload.lockedByTab ?? null;
setCurrentHolder(holder);
setIsLockedByOther(Boolean(holder && holder !== tabId));
} catch {
// ignore malformed events
}
};
eventSource.addEventListener("ai_session:updated", handleUpdated as EventListener);
return () => {
eventSource.removeEventListener("ai_session:updated", handleUpdated as EventListener);
eventSource.close();
};
}, [sessionId, tabId]);
const takeControl = useCallback(async () => {
if (!sessionId) {
return;
}
setIsLoading(true);
try {
await Promise.resolve(forceAcquireSessionLock(sessionId, tabId));
setIsLockedByOther(false);
setCurrentHolder(null);
} finally {
setIsLoading(false);
}
}, [sessionId, tabId]);
return {
isLockedByOther,
currentHolder,
takeControl,
isLoading,
};
}

View File

@@ -13605,6 +13605,7 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.planning-error {
@@ -13630,6 +13631,33 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
flex-shrink: 0;
}
.session-lock-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20%;
z-index: 10;
backdrop-filter: blur(2px);
}
.session-lock-banner {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.session-lock-take-control {
margin-left: auto;
}
/* Initial View */
.planning-initial {
display: flex;

View File

@@ -0,0 +1,25 @@
const SESSION_TAB_ID_KEY = "fusion-tab-id";
function createTabId(): string {
const cryptoApi = globalThis.crypto;
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
return cryptoApi.randomUUID();
}
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function getSessionTabId(): string {
if (typeof window === "undefined") {
return "server-tab";
}
const existing = window.sessionStorage.getItem(SESSION_TAB_ID_KEY);
if (existing) {
return existing;
}
const next = createTabId();
window.sessionStorage.setItem(SESSION_TAB_ID_KEY, next);
return next;
}

View File

@@ -32,6 +32,8 @@ export interface AiSessionRow {
projectId: string | null;
createdAt: string;
updatedAt: string;
lockedByTab: string | null;
lockedAt: string | null;
}
/** Summary returned by listActive (omits large fields) */
@@ -41,6 +43,7 @@ export interface AiSessionSummary {
status: AiSessionStatus;
title: string;
projectId: string | null;
lockedByTab: string | null;
updatedAt: string;
}
@@ -81,8 +84,8 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
this.db
.prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
title = excluded.title,
@@ -112,7 +115,10 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
// Cancel any pending thinking debounce for this session
this.clearThinkingTimer(session.id);
this.emit("ai_session:updated", toSummary(session, now));
const row = this.get(session.id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
}
/**
@@ -194,7 +200,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
if (projectId) {
return this.db
.prepare(
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input', 'error') AND projectId = ?
ORDER BY updatedAt DESC`,
)
@@ -202,7 +208,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
}
return this.db
.prepare(
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input', 'error')
ORDER BY updatedAt DESC`,
)
@@ -233,6 +239,121 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
.all() as unknown as AiSessionRow[];
}
acquireLock(sessionId: string, tabId: string): { acquired: boolean; currentHolder: string | null } {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = ?, lockedAt = ?
WHERE id = ? AND (lockedByTab IS NULL OR lockedByTab = ?)`,
)
.run(tabId, now, sessionId, tabId) as { changes?: number };
const acquired = Number(result.changes ?? 0) > 0;
if (acquired) {
const row = this.get(sessionId);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return { acquired: true, currentHolder: null };
}
const holder = this.db
.prepare("SELECT lockedByTab FROM ai_sessions WHERE id = ?")
.get(sessionId) as { lockedByTab: string | null } | undefined;
return {
acquired: false,
currentHolder: holder?.lockedByTab ?? null,
};
}
releaseLock(sessionId: string, tabId: string): boolean {
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = NULL, lockedAt = NULL
WHERE id = ? AND lockedByTab = ?`,
)
.run(sessionId, tabId) as { changes?: number };
const released = Number(result.changes ?? 0) > 0;
if (!released) {
return false;
}
const row = this.get(sessionId);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return true;
}
forceAcquireLock(sessionId: string, tabId: string): void {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = ?, lockedAt = ?
WHERE id = ?`,
)
.run(tabId, now, sessionId) as { changes?: number };
if (Number(result.changes ?? 0) === 0) {
return;
}
const row = this.get(sessionId);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
}
getLockHolder(sessionId: string): { tabId: string | null; lockedAt: string | null } {
const row = this.db
.prepare("SELECT lockedByTab, lockedAt FROM ai_sessions WHERE id = ?")
.get(sessionId) as { lockedByTab: string | null; lockedAt: string | null } | undefined;
return {
tabId: row?.lockedByTab ?? null,
lockedAt: row?.lockedAt ?? null,
};
}
releaseStaleLocks(maxAgeMs = 30 * 60 * 1000): number {
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
const staleRows = this.db
.prepare(
`SELECT id FROM ai_sessions
WHERE lockedByTab IS NOT NULL
AND lockedAt < ?`,
)
.all(cutoff) as Array<{ id: string }>;
if (staleRows.length === 0) {
return 0;
}
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = NULL, lockedAt = NULL
WHERE lockedByTab IS NOT NULL
AND lockedAt < ?`,
)
.run(cutoff) as { changes?: number };
for (const rowInfo of staleRows) {
const row = this.get(rowInfo.id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
}
return Number(result.changes ?? 0);
}
/**
* Delete a session by ID. Emits `ai_session:deleted`.
*/
@@ -392,6 +513,7 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary {
status: session.status,
title: session.title,
projectId: session.projectId,
lockedByTab: session.lockedByTab ?? null,
updatedAt,
};
}

View File

@@ -447,11 +447,14 @@ function createMockMissionAutopilot() {
function buildApp(options?: {
missionAutopilot?: ReturnType<typeof createMockMissionAutopilot>;
withErrorHandler?: boolean;
aiSessionStore?: {
acquireLock(sessionId: string, tabId: string): { acquired: boolean; currentHolder: string | null };
};
}) {
const app = express();
app.use(express.json());
const store = createMockStore();
app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot));
app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot, options?.aiSessionStore as any));
if (options?.withErrorHandler) {
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
@@ -1796,6 +1799,131 @@ describe("Mission API", () => {
expect(res.body.error).toContain("sessionId");
});
it("returns 409 when interview respond is locked by another tab", async () => {
const submitSpy = vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse");
const { app } = buildApp({
aiSessionStore: {
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
},
});
const res = await request(
app,
"POST",
"/api/missions/interview/respond",
JSON.stringify({
sessionId: "session-locked",
responses: { "q-1": "answer" },
tabId: "tab-other",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-owner",
});
expect(submitSpy).not.toHaveBeenCalled();
});
it("returns 409 when interview cancel is locked by another tab", async () => {
const cancelSpy = vi.spyOn(missionInterviewModule, "cancelMissionInterviewSession");
const { app } = buildApp({
aiSessionStore: {
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
},
});
const res = await request(
app,
"POST",
"/api/missions/interview/cancel",
JSON.stringify({
sessionId: "session-locked",
tabId: "tab-other",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-owner",
});
expect(cancelSpy).not.toHaveBeenCalled();
});
it("returns 409 when interview retry is locked by another tab", async () => {
const retrySpy = vi.spyOn(missionInterviewModule, "retryMissionInterviewSession");
const { app } = buildApp({
aiSessionStore: {
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
},
});
const res = await request(
app,
"POST",
"/api/missions/interview/session-locked/retry",
JSON.stringify({ tabId: "tab-other" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-owner",
});
expect(retrySpy).not.toHaveBeenCalled();
});
it("allows interview respond/cancel/retry when tabId is omitted", async () => {
vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse").mockResolvedValueOnce({
type: "question",
data: {
id: "q-next",
type: "text",
question: "next",
description: "next",
},
} as any);
vi.spyOn(missionInterviewModule, "cancelMissionInterviewSession").mockResolvedValueOnce(undefined);
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockResolvedValueOnce(undefined);
const { app } = buildApp({
aiSessionStore: {
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
},
});
const respondRes = await request(
app,
"POST",
"/api/missions/interview/respond",
JSON.stringify({ sessionId: "session-open", responses: { "q-1": "answer" } }),
{ "content-type": "application/json" },
);
expect(respondRes.status).toBe(200);
const cancelRes = await request(
app,
"POST",
"/api/missions/interview/cancel",
JSON.stringify({ sessionId: "session-open" }),
{ "content-type": "application/json" },
);
expect(cancelRes.status).toBe(200);
expect(cancelRes.body).toEqual({ success: true });
const retryRes = await request(app, "POST", "/api/missions/interview/session-open/retry");
expect(retryRes.status).toBe(200);
expect(retryRes.body).toEqual({ success: true, sessionId: "session-open" });
});
it("retries a failed interview session", async () => {
const retrySpy = vi
.spyOn(missionInterviewModule, "retryMissionInterviewSession")

View File

@@ -262,6 +262,8 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
projectId: null,
createdAt: session.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
lockedByTab: null,
lockedAt: null,
};
_aiSessionStore.upsert(row);
}

View File

@@ -49,6 +49,7 @@ import {
notFound,
rateLimited,
} from "./api-error.js";
import type { AiSessionStore } from "./ai-session-store.js";
// ── Validation Utilities ────────────────────────────────────────────────────
@@ -175,6 +176,23 @@ function replayBufferedSSE(
return true;
}
function checkSessionLock(
sessionId: string,
tabId: string | undefined,
store: AiSessionStore | undefined,
): { allowed: true } | { allowed: false; currentHolder: string | null } {
if (!tabId || !store) {
return { allowed: true };
}
const result = store.acquireLock(sessionId, tabId);
if (result.acquired) {
return { allowed: true };
}
return { allowed: false, currentHolder: result.currentHolder };
}
export function createMissionRouter(
store: TaskStore,
missionAutopilot?: {
@@ -186,6 +204,7 @@ export function createMissionRouter(
start(): void;
stop(): void;
},
aiSessionStore?: AiSessionStore,
): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>();
@@ -344,7 +363,7 @@ export function createMissionRouter(
router.post(
"/interview/respond",
catchTypedHandler(async (req, res) => {
const { sessionId, responses } = req.body;
const { sessionId, responses, tabId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
@@ -354,6 +373,16 @@ export function createMissionRouter(
throw badRequest("responses is required and must be an object");
}
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
try {
const {
submitMissionInterviewResponse,
@@ -389,6 +418,18 @@ export function createMissionRouter(
throw badRequest("sessionId is required");
}
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
? req.body.tabId.trim()
: undefined;
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
try {
const {
retryMissionInterviewSession,
@@ -419,12 +460,22 @@ export function createMissionRouter(
router.post(
"/interview/cancel",
catchTypedHandler(async (req, res) => {
const { sessionId } = req.body;
const { sessionId, tabId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
try {
const {
cancelMissionInterviewSession,

View File

@@ -1,5 +1,11 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import { Database, TaskStore } from "@fusion/core";
import {
createSession,
createSessionWithAgent,
@@ -25,8 +31,10 @@ import {
formatInterviewQA,
SESSION_TTL_MS,
} from "./planning.js";
import { createApiRoutes } from "./routes.js";
import { request, get } from "./test-request.js";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { AiSessionRow } from "./ai-session-store.js";
import { AiSessionStore, type AiSessionRow } from "./ai-session-store.js";
// ── Mock Agent Factory ──────────────────────────────────────────────────────
@@ -1384,3 +1392,346 @@ describe("planning module", () => {
});
});
});
describe("AiSessionStore locking", () => {
let tmpRoot: string;
let db: Database;
let store: AiSessionStore;
function makeSessionRow(
id: string,
status: AiSessionRow["status"] = "awaiting_input",
): AiSessionRow {
const now = new Date().toISOString();
return {
id,
type: "planning",
status,
title: `Session ${id}`,
inputPayload: JSON.stringify({ initialPlan: "Locking test" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
};
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-lock-"));
db = new Database(join(tmpRoot, ".fusion"));
db.init();
store = new AiSessionStore(db);
store.upsert(makeSessionRow("session-lock-1"));
});
afterEach(async () => {
store.stopScheduledCleanup();
try {
db.close();
} catch {
// no-op
}
await rm(tmpRoot, { recursive: true, force: true });
});
it("acquires lock, detects conflicts, and allows re-entrant acquire", () => {
const firstAcquire = store.acquireLock("session-lock-1", "tab-a");
expect(firstAcquire).toEqual({ acquired: true, currentHolder: null });
const holderAfterAcquire = store.getLockHolder("session-lock-1");
expect(holderAfterAcquire.tabId).toBe("tab-a");
expect(holderAfterAcquire.lockedAt).toBeTruthy();
const conflict = store.acquireLock("session-lock-1", "tab-b");
expect(conflict).toEqual({ acquired: false, currentHolder: "tab-a" });
const reentrant = store.acquireLock("session-lock-1", "tab-a");
expect(reentrant).toEqual({ acquired: true, currentHolder: null });
expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-a");
});
it("releases locks only for the current owner", () => {
store.acquireLock("session-lock-1", "tab-a");
const nonOwnerRelease = store.releaseLock("session-lock-1", "tab-b");
expect(nonOwnerRelease).toBe(false);
expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-a");
const ownerRelease = store.releaseLock("session-lock-1", "tab-a");
expect(ownerRelease).toBe(true);
expect(store.getLockHolder("session-lock-1")).toEqual({ tabId: null, lockedAt: null });
});
it("force acquires lock and clears stale locks", () => {
store.acquireLock("session-lock-1", "tab-a");
store.forceAcquireLock("session-lock-1", "tab-b");
expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-b");
const staleTimestamp = new Date(Date.now() - 35 * 60 * 1000).toISOString();
db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "session-lock-1");
const releasedCount = store.releaseStaleLocks();
expect(releasedCount).toBe(1);
expect(store.getLockHolder("session-lock-1")).toEqual({ tabId: null, lockedAt: null });
});
it("emits ai_session:updated events on lock changes", () => {
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
store.acquireLock("session-lock-1", "tab-a");
store.releaseLock("session-lock-1", "tab-a");
store.forceAcquireLock("session-lock-1", "tab-b");
const staleTimestamp = new Date(Date.now() - 35 * 60 * 1000).toISOString();
db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "session-lock-1");
store.releaseStaleLocks();
expect(onUpdated).toHaveBeenCalled();
const emittedLocks = onUpdated.mock.calls
.map(([summary]) => summary.lockedByTab)
.filter((value) => value !== undefined);
expect(emittedLocks).toContain("tab-a");
expect(emittedLocks).toContain("tab-b");
expect(emittedLocks).toContain(null);
});
it("preserves lock state in upsert update events", () => {
store.acquireLock("session-lock-1", "tab-a");
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
store.upsert({
...makeSessionRow("session-lock-1", "generating"),
lockedByTab: null,
lockedAt: null,
});
const latestSummary = onUpdated.mock.calls.at(-1)?.[0];
expect(latestSummary?.lockedByTab).toBe("tab-a");
});
});
describe("planning routes lock enforcement", () => {
let tmpRoot: string;
let taskStore: TaskStore;
let db: Database;
let aiSessionStore: AiSessionStore;
let app: express.Express;
function makePersistedRow(id: string, type: AiSessionRow["type"] = "planning"): AiSessionRow {
const now = new Date().toISOString();
return {
id,
type,
status: "awaiting_input",
title: `Session ${id}`,
inputPayload: JSON.stringify({ initialPlan: "Route lock test" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
};
}
beforeEach(async () => {
__resetPlanningState();
setupMockAgent();
tmpRoot = mkdtempSync(join(tmpdir(), "kb-planning-lock-routes-"));
taskStore = new TaskStore(tmpRoot);
await taskStore.init();
db = new Database(join(tmpRoot, ".fusion-locks"));
db.init();
aiSessionStore = new AiSessionStore(db);
setAiSessionStore(aiSessionStore as any);
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(taskStore, { aiSessionStore }));
});
afterEach(async () => {
__setCreateKbAgent(undefined as any);
__resetPlanningState();
try {
taskStore.close();
} catch {
// no-op
}
try {
db.close();
} catch {
// no-op
}
await rm(tmpRoot, { recursive: true, force: true });
});
it("acquires and releases locks via API routes", async () => {
aiSessionStore.upsert(makePersistedRow("session-route-lock"));
const acquire = await request(
app,
"POST",
"/api/ai-sessions/session-route-lock/lock",
JSON.stringify({ tabId: "tab-a" }),
{ "content-type": "application/json" },
);
expect(acquire.status).toBe(200);
expect(acquire.body).toEqual({ acquired: true });
const conflictAcquire = await request(
app,
"POST",
"/api/ai-sessions/session-route-lock/lock",
JSON.stringify({ tabId: "tab-b" }),
{ "content-type": "application/json" },
);
expect(conflictAcquire.status).toBe(200);
expect(conflictAcquire.body).toEqual({ acquired: false, currentHolder: "tab-a" });
const release = await request(
app,
"DELETE",
"/api/ai-sessions/session-route-lock/lock",
JSON.stringify({ tabId: "tab-a" }),
{ "content-type": "application/json" },
);
expect(release.status).toBe(200);
expect(release.body).toEqual({ success: true });
const forceAcquire = await request(
app,
"POST",
"/api/ai-sessions/session-route-lock/lock/force",
JSON.stringify({ tabId: "tab-c" }),
{ "content-type": "application/json" },
);
expect(forceAcquire.status).toBe(200);
expect(forceAcquire.body).toEqual({ success: true });
const beaconRelease = await request(
app,
"DELETE",
"/api/ai-sessions/session-route-lock/lock/beacon?tabId=tab-c",
);
expect(beaconRelease.status).toBe(200);
});
it("returns 409 for planning/respond when another tab holds the lock and allows legacy requests without tabId", async () => {
const { sessionId } = await createSession(getUniqueIp(), "Route lock planning", taskStore, tmpRoot);
aiSessionStore.acquireLock(sessionId, "tab-owner");
const conflictResponse = await request(
app,
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { "q-scope": "small" }, tabId: "tab-other" }),
{ "content-type": "application/json" },
);
expect(conflictResponse.status).toBe(409);
expect(conflictResponse.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-owner",
});
const legacyResponse = await request(
app,
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { "q-scope": "small" } }),
{ "content-type": "application/json" },
);
expect(legacyResponse.status).toBe(200);
expect((legacyResponse.body as { type: string }).type).toBe("question");
});
it("returns 409 for subtasks/cancel when lock is held by another tab", async () => {
aiSessionStore.upsert(makePersistedRow("subtask-route-lock", "subtask"));
aiSessionStore.acquireLock("subtask-route-lock", "tab-a");
const response = await request(
app,
"POST",
"/api/subtasks/cancel",
JSON.stringify({ sessionId: "subtask-route-lock", tabId: "tab-b" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(409);
expect(response.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-a",
});
});
it("returns 409 for retry endpoints when lock is held by another tab", async () => {
aiSessionStore.upsert(makePersistedRow("planning-route-retry", "planning"));
aiSessionStore.acquireLock("planning-route-retry", "tab-a");
const planningRetry = await request(
app,
"POST",
"/api/planning/planning-route-retry/retry",
JSON.stringify({ tabId: "tab-b" }),
{ "content-type": "application/json" },
);
expect(planningRetry.status).toBe(409);
expect(planningRetry.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-a",
});
aiSessionStore.upsert(makePersistedRow("subtask-route-retry", "subtask"));
aiSessionStore.acquireLock("subtask-route-retry", "tab-a");
const subtaskRetry = await request(
app,
"POST",
"/api/subtasks/subtask-route-retry/retry",
JSON.stringify({ tabId: "tab-b" }),
{ "content-type": "application/json" },
);
expect(subtaskRetry.status).toBe(409);
expect(subtaskRetry.body).toEqual({
error: "Session locked by another tab",
lockedByTab: "tab-a",
});
});
it("keeps planning SSE stream read-only and unaffected by locks", async () => {
const { sessionId } = await createSession(getUniqueIp(), "SSE lock check", taskStore, tmpRoot);
await submitResponse(sessionId, { "q-scope": "small" }, tmpRoot);
await submitResponse(sessionId, { "q-requirements": "Need auth" }, tmpRoot);
await submitResponse(sessionId, { "q-confirm": true }, tmpRoot);
aiSessionStore.acquireLock(sessionId, "tab-owner");
const streamResponse = await get(app, `/api/planning/${sessionId}/stream`);
expect(streamResponse.status).toBe(200);
expect(String(streamResponse.body)).toContain("event: summary");
expect(String(streamResponse.body)).toContain("event: complete");
});
});

View File

@@ -236,6 +236,8 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
projectId: projectId ?? null,
createdAt: session.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
lockedByTab: null,
lockedAt: null,
};
_aiSessionStore.upsert(row);
}

View File

@@ -1350,6 +1350,23 @@ function replayBufferedSSE(
return true;
}
function checkSessionLock(
sessionId: string,
tabId: string | undefined,
store: AiSessionStore | undefined,
): { allowed: true } | { allowed: false; currentHolder: string | null } {
if (!tabId || !store) {
return { allowed: true };
}
const result = store.acquireLock(sessionId, tabId);
if (result.acquired) {
return { allowed: true };
}
return { allowed: false, currentHolder: result.currentHolder };
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
@@ -1424,6 +1441,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// HeartbeatMonitor for triggering agent execution runs
const heartbeatMonitor = options?.heartbeatMonitor;
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
const aiSessionStore = options?.aiSessionStore;
// Scheduler config (includes persisted settings)
router.get("/config", async (req, res) => {
@@ -5843,11 +5861,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/subtasks/cancel", async (req, res) => {
try {
const { sessionId } = req.body;
const { sessionId, tabId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const { cancelSubtaskSession } = await import("./subtask-breakdown.js");
await cancelSubtaskSession(sessionId);
res.json({ success: true });
@@ -5870,6 +5898,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("sessionId is required");
}
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
? req.body.tabId.trim()
: undefined;
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const scopedStore = await getScopedStore(req);
const { retrySubtaskSession } = await import("./subtask-breakdown.js");
await retrySubtaskSession(sessionId, scopedStore.getRootDir());
@@ -5987,7 +6027,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/planning/respond", async (req, res) => {
try {
const { sessionId, responses } = req.body;
const { sessionId, responses, tabId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
@@ -5997,6 +6037,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("responses is required and must be an object");
}
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js");
const result = await submitResponse(sessionId, responses, store.getRootDir());
res.json(result);
@@ -6021,6 +6071,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("sessionId is required");
}
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
? req.body.tabId.trim()
: undefined;
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const scopedStore = await getScopedStore(req);
const { retrySession } = await import("./planning.js");
await retrySession(sessionId, scopedStore.getRootDir());
@@ -6046,12 +6108,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/planning/cancel", async (req, res) => {
try {
const { sessionId } = req.body;
const { sessionId, tabId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const { cancelSession, SessionNotFoundError } = await import("./planning.js");
await cancelSession(sessionId);
res.json({ success: true });
@@ -8942,12 +9014,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
// ── Mission Routes ─────────────────────────────────────────────────────────
// Mount mission routes at /api/missions
router.use("/missions", createMissionRouter(store, options?.missionAutopilot));
router.use("/missions", createMissionRouter(store, options?.missionAutopilot, aiSessionStore));
// ── AI Session Routes (Background Tasks) ─────────────────────────────────
const aiSessionStore = options?.aiSessionStore;
/**
* GET /api/ai-sessions
* List active background AI sessions (generating or awaiting_input).
@@ -8978,6 +9048,80 @@ Output ONLY the prompt text (no markdown, no explanations).`;
res.json(session);
});
router.post("/ai-sessions/:id/lock", (req, res) => {
if (!aiSessionStore) {
throw notFound("AI sessions not available");
}
const { id } = req.params;
const session = aiSessionStore.get(id);
if (!session) {
throw notFound("Session not found");
}
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
if (!tabId) {
throw badRequest("tabId is required");
}
const result = aiSessionStore.acquireLock(id, tabId);
if (!result.acquired) {
res.json({ acquired: false, currentHolder: result.currentHolder });
return;
}
res.json({ acquired: true });
});
router.delete("/ai-sessions/:id/lock", (req, res) => {
if (!aiSessionStore) {
throw notFound("AI sessions not available");
}
const { id } = req.params;
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
if (!tabId) {
throw badRequest("tabId is required");
}
aiSessionStore.releaseLock(id, tabId);
res.json({ success: true });
});
router.post("/ai-sessions/:id/lock/force", (req, res) => {
if (!aiSessionStore) {
throw notFound("AI sessions not available");
}
const { id } = req.params;
const session = aiSessionStore.get(id);
if (!session) {
throw notFound("Session not found");
}
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
if (!tabId) {
throw badRequest("tabId is required");
}
aiSessionStore.forceAcquireLock(id, tabId);
res.json({ success: true });
});
router.delete("/ai-sessions/:id/lock/beacon", (req, res) => {
if (!aiSessionStore) {
throw notFound("AI sessions not available");
}
const { id } = req.params;
const tabId = typeof req.query.tabId === "string" ? req.query.tabId.trim() : "";
if (tabId) {
aiSessionStore.releaseLock(id, tabId);
}
res.status(200).end();
});
/**
* POST /api/ai-sessions/:id/ping
* Lightweight keep-alive touch for active AI sessions.

View File

@@ -196,6 +196,8 @@ function persistSubtaskSession(session: SubtaskInternalSession, status: "generat
projectId: null,
createdAt: session.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
lockedByTab: null,
lockedAt: null,
};
_aiSessionStore.upsert(row);
}