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 55e8b6fd94
commit 02148d79b6
23 changed files with 1540 additions and 58 deletions

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")