From cdf67c1d9898a14df85fea4aaef1396ca09946d6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 18:47:53 -0700 Subject: [PATCH] fix(dashboard): stop Planning Mode retry loop, make AI sessions multi-tab (#2101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Reported: planning gets stuck in a cycle of retrying and regenerating after a response was already supplied. After the user answers a planning question, `submitResponse` pushed the answer to history but left `session.currentQuestion` pointing at the just-answered question for the whole next generation. The planning SSE route's catch-up path re-emits `currentQuestion` to every fresh connection — and each FN-7946 auto-retry (#2073) opens a fresh connection. So after any generation error: 1. Auto-retry connects a fresh stream → the server re-emits the **already-answered** question. 2. The client treats any question event as progress: it **resets the 3-attempt auto-retry budget** and re-shows the answered question. 3. The retry regenerates; if it errors again the cycle repeats with a fresh budget — an unbounded retry/regenerate loop. Re-answering the stale question also 409-collided with the in-flight generation, feeding the same loop. ## Fix Invariant: `currentQuestion` is only set while the session is genuinely awaiting user input. - `submitResponse` clears it the moment an answer is accepted (normal turns and the deepening checkpoint), while preserving the legacy 200 respond contract on generation failure (the modal ignores the body and lets the SSE error drive recovery). - `retrySession` scrubs stale questions persisted by pre-fix builds before regenerating. - `buildSessionFromRow` only restores a question when the persisted row is `awaiting_input`. - `didSubmitSameAnswer` now compares against the last history entry so the duplicate-submit 409 message survives. - Agent onboarding gets the same fix (its SSE route also re-emits `currentQuestion` on connect); retry now asks the next question instead of re-asking the answered one. Surface enumeration: mission and milestone interviews keep questions the same way but their SSE routes never re-emit on connect, and the auto-retry budget machinery is Planning-Mode-only — planning + onboarding were the two affected surfaces. ## Symptom Verification - **Original symptom:** after answering a question, Planning Mode loops between "Retrying…" and regenerating, re-showing the already-answered question, with the auto-retry budget never exhausting. - **Exact reproduction:** answer a question, have the next generation fail (stuck watchdog/provider error), let the client auto-retry open a fresh SSE connection. - **Assertion it is gone:** new regression suite `planning-answered-question-reemit.test.ts` asserts `currentQuestion` is cleared mid-generation, on generation failure, on retry, and on restore from non-`awaiting_input` rows — so the SSE catch-up path has nothing stale to re-emit. All 5 tests fail against pre-fix code and pass with the fix; an onboarding regression test covers the sibling surface. ## Verification - New regression tests: 5/5 fail on pre-fix code, pass with the fix (plus 1 onboarding test). - Existing suites: 137 planning server tests pass (3 failures in `routes-planning.test.ts` fail identically without this change — pre-existing on the branch); all 69 `PlanningModeModal.planning-flow` client tests pass; `tsc --noEmit` clean; `pnpm check:changesets` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Made Planning Mode (and related planning controls) lock-free and multi-tab—no more take-over/active-in-another-tab lock overlays. * **Bug Fixes** * Fixed Planning Mode retry/generation flows where already-answered questions could reappear. * Ensured answered questions clear immediately and aren’t re-emitted during session recovery/SSE catch-up. * Improved session restoration and preserved legacy recovery behavior when generation fails after an answer. * **Tests** * Added regression coverage for the answered-question invariant and updated existing tests to reflect lock-free behavior. --- ## Follow-up: Planning Mode is now multi-tab via DB state (lock-free) Second commit removes all cross-tab coordination from planning — the persisted session row is the single source of truth and multiple tabs can read and interact with the same session: - **Server:** `/planning/*` routes no longer run `checkSessionLock` or parse `tabId`; a stale `tabId` from an older client is ignored instead of 409'd. Subtask/mission interview routes keep their existing lock behavior. - **Client:** `PlanningModeModal` drops `useSessionLock`, the `useAiSessionSync` BroadcastChannel broadcasts, `sessionTabId`/`lockSessionId` state, and the "Take Control" overlay. Tabs stay current via the per-session SSE stream plus the global `ai_session:updated` events `useBackgroundSessions` already consumes; concurrent writes resolve via the server's generation-in-progress guard (409). - **API client:** planning functions lose their `tabId` params. - **Fix uncovered by the refactor:** the 8s stuck-poll now resolves the session id inside each tick — the removed lock state was what previously re-armed the poll after Start Planning resolved the session id. - Also fixes a pre-existing PG-cutover break in `planning-generation-cancellation.test.ts` (`getSession` is async). Verification: 144 client planning tests and 137 server planning tests pass (the 3 remaining `routes-planning.test.ts` failures are pre-existing on the branch and fail identically without these changes); `tsc --noEmit` and eslint clean on changed files; `pnpm check:changesets` passes. Lock-conflict route tests were rewritten to assert lock-free semantics, plus a new modal test proving a session stays fully interactive with no lock acquisition even when another tab is active. --- ## Follow-up 2: the per-tab session lock is gone entirely Third commit extends the multi-tab model from planning to **every** AI interview surface (planning, subtask breakdown, mission interview, milestone/slice interview) and deletes the lock machinery root and branch. **Server** - Deleted the `/ai-sessions/:id/lock`, `/lock/force`, and `/lock/beacon` routes. - Dropped `checkSessionLock` from every planning/subtask/mission/milestone route (both copies — `routes.ts` and `mission-routes.ts`). A `tabId` from an older client is ignored, never 409'd; all `tabId` body parsing is gone. - Dropped `acquireLock` / `releaseLock` / `forceAcquireLock` / `getLockHolder` / `releaseStaleLocks` from `AiSessionStore`, plus the `@fusion/core` async helpers (`acquireAiSessionLock` et al) and core's re-exports. - Removed `lockedByTab`/`lockedAt` from `AiSessionRow`/`AiSessionSummary`, the upsert SQL, and all four session producers. **Client** - Deleted `useSessionLock` and the now-orphaned `getSessionTabId` util. - Removed the Take Control overlay, the "active in another tab" banners, and `BackgroundTasksIndicator`'s active-elsewhere gate (the confirm prompt and lock badge — sessions now just open). - Reduced `useAiSessionSync` to what its own comments already called it — a low-latency *status* supplement to SSE: no `activeTabMap`, `broadcastLock/Unlock/Heartbeat`, `owningTabId`, `tab:*` messages, or stale-heartbeat sweep. - Dropped `tabId` from every session API client function; removed the lock CSS. **Deliberately kept: the two DB columns.** `ai_sessions.locked_by_tab` / `locked_at` remain as dead, always-NULL columns with a deprecation note. Dropping them is an irreversible migration, and released binaries still name those columns explicitly in their upsert — an older install pointed at the same database would fail every session write. They can be dropped once no such binary can reach it. No code reads or writes them. **Verification**: 397 client tests and 137 server planning tests pass (the same 3 `routes-planning.test.ts` failures are pre-existing — verified identical on a clean stash); `tsc --noEmit` clean for `@fusion/core` and `@fusion/dashboard`; eslint clean on all changed files; the 30 PG `schema-applier` tests pass (they exercise the retained columns); `pnpm check:changesets` passes. The lock-conflict route tests and both modal lock tests were rewritten to assert the inverse: routes and modals stay fully interactive while another tab "holds" a lock, and the lock API is never called. --------- Co-authored-by: Claude Fable 5 --- .../planning-answered-question-retry-loop.md | 7 + .changeset/planning-multi-tab-lock-free.md | 7 + packages/core/src/async-ai-session-store.ts | 112 +----- packages/core/src/index.ts | 5 - packages/core/src/postgres/schema/project.ts | 9 + .../__tests__/app-cli-action-wiring.test.tsx | 1 - packages/dashboard/app/api/legacy.ts | 87 +---- .../components/BackgroundTasksIndicator.tsx | 55 +-- .../MilestoneSliceInterviewModal.tsx | 89 +---- .../app/components/MissionInterviewModal.tsx | 141 +------ .../app/components/MissionManager.tsx | 4 +- .../app/components/PlanningModeModal.css | 27 -- .../app/components/PlanningModeModal.tsx | 236 ++---------- .../app/components/SubtaskBreakdownModal.tsx | 123 +------ .../__tests__/ExecutorStatusBar.test.tsx | 1 - .../MilestoneSliceInterviewModal.test.tsx | 25 -- .../__tests__/MissionInterviewModal.test.tsx | 39 +- .../MissionManager.delete-confirm.test.tsx | 11 +- .../PlanningModeModal.autosize.test.tsx | 4 - ...anningModeModal.dedupeSessionsById.test.ts | 1 - .../PlanningModeModal.initial.test.tsx | 2 - .../PlanningModeModal.planning-flow.test.tsx | 147 ++------ ...PlanningModeModal.ui-interactions.test.tsx | 3 - .../SessionNotificationBanner.test.tsx | 2 - .../__tests__/SubtaskBreakdownModal.test.tsx | 34 +- .../__tests__/utility-mobile.test.tsx | 4 - .../__tests__/DashboardBanners.test.tsx | 1 - .../hooks/__tests__/useAiSessionSync.test.ts | 30 +- .../__tests__/useBackgroundSessions.test.ts | 12 +- .../dashboard/app/hooks/useAiSessionSync.ts | 346 ++---------------- .../app/hooks/useBackgroundSessions.ts | 36 +- .../dashboard/app/hooks/useSessionLock.ts | 142 ------- .../app/utils/__tests__/appLifecycle.test.ts | 1 - .../dashboard/app/utils/getSessionTabId.ts | 25 -- .../src/__tests__/agent-onboarding.test.ts | 69 ++++ .../planning-answered-question-reemit.test.ts | 305 +++++++++++++++ .../planning-generation-cancellation.test.ts | 2 +- .../routes-planning-tracking.test.ts | 1 - .../src/__tests__/routes-planning.test.ts | 52 +-- packages/dashboard/src/agent-onboarding.ts | 47 ++- packages/dashboard/src/ai-session-store.ts | 179 +-------- .../src/milestone-slice-interview.ts | 2 - packages/dashboard/src/mission-interview.ts | 2 - packages/dashboard/src/mission-routes.ts | 131 +------ packages/dashboard/src/planning.ts | 61 ++- packages/dashboard/src/routes.ts | 100 +---- ...r-agent-import-export-generation-routes.ts | 19 +- .../register-planning-subtask-routes.ts | 98 +---- packages/dashboard/src/subtask-breakdown.ts | 2 - 49 files changed, 822 insertions(+), 2017 deletions(-) create mode 100644 .changeset/planning-answered-question-retry-loop.md create mode 100644 .changeset/planning-multi-tab-lock-free.md delete mode 100644 packages/dashboard/app/hooks/useSessionLock.ts delete mode 100644 packages/dashboard/app/utils/getSessionTabId.ts create mode 100644 packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts diff --git a/.changeset/planning-answered-question-retry-loop.md b/.changeset/planning-answered-question-retry-loop.md new file mode 100644 index 0000000000..09ecf4e054 --- /dev/null +++ b/.changeset/planning-answered-question-retry-loop.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Planning Mode getting stuck retrying and re-asking a question that was already answered. +category: fix +dev: Sessions now clear `currentQuestion` the moment an answer is accepted (Planning Mode and agent onboarding), `retrySession` scrubs stale questions from pre-fix rows, and restored sessions only keep a question when the persisted row is `awaiting_input`. This stops the SSE catch-up path from re-emitting answered questions to the fresh connections opened by FN-7946 auto-retries, which reset the bounded retry budget and looped forever. diff --git a/.changeset/planning-multi-tab-lock-free.md b/.changeset/planning-multi-tab-lock-free.md new file mode 100644 index 0000000000..f3cd81a41f --- /dev/null +++ b/.changeset/planning-multi-tab-lock-free.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: AI planning, subtask, and mission interviews are now multi-tab — any tab can use the same session. +category: feature +dev: Removed the per-tab session lock end to end: the `/ai-sessions/:id/lock{,/force,/beacon}` routes, `checkSessionLock` on every planning/subtask/mission/milestone route, the store's acquire/release/force/holder/stale-release methods and their `@fusion/core` async helpers, the `useSessionLock` hook, the `getSessionTabId` util, the Take Control overlay + "active in another tab" banners, and the `useAiSessionSync` tab-ownership half (activeTabMap, broadcastLock/Unlock/Heartbeat, owningTabId). `tabId` params are gone from the session API client; routes ignore any tabId older clients still send. The persisted session row is the single source of truth, with per-session SSE plus global `ai_session:updated` events keeping tabs current and each producer's generation-in-progress guard resolving concurrent writes. The `ai_sessions.locked_by_tab`/`locked_at` columns are retained as dead, always-NULL columns — dropping them is an irreversible migration that would break older installed binaries whose upsert names those columns. diff --git a/packages/core/src/async-ai-session-store.ts b/packages/core/src/async-ai-session-store.ts index 719241afa2..d12f7bccfa 100644 --- a/packages/core/src/async-ai-session-store.ts +++ b/packages/core/src/async-ai-session-store.ts @@ -24,7 +24,7 @@ * Transition context: these helpers live in @fusion/core (where the schema is * defined) and are exported so the dashboard's AiSessionStore can import them. */ -import { and, desc, eq, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; @@ -50,8 +50,6 @@ export interface AiSessionRow { projectId: string | null; createdAt: string; updatedAt: string; - lockedByTab: string | null; - lockedAt: string | null; archived?: number; } @@ -62,7 +60,6 @@ export interface AiSessionSummary { title: string; preview?: string; projectId: string | null; - lockedByTab: string | null; updatedAt: string; archived?: boolean; } @@ -109,8 +106,6 @@ function rowToSession(row: Record): AiSessionRow { projectId: (row.projectId as string | null) ?? null, createdAt: row.createdAt as string, updatedAt: row.updatedAt as string, - lockedByTab: (row.lockedByTab as string | null) ?? null, - lockedAt: (row.lockedAt as string | null) ?? null, archived: typeof row.archived === "number" ? row.archived : Number(row.archived ?? 0), }; } @@ -127,8 +122,7 @@ function safeJsonParse(value: string, fallback: T): T { /** * FNXC:AiSessionStore 2026-06-24-23:10: - * Insert or update an AI session row. lockedByTab/lockedAt are set to null on - * insert but NOT modified on conflict (locks are managed by lock methods). + * Insert or update an AI session row. */ export async function upsertAiSession(handle: QueryHandle, session: AiSessionRow): Promise { const now = new Date().toISOString(); @@ -162,8 +156,6 @@ export async function upsertAiSession(handle: QueryHandle, session: AiSessionRow ...(session.projectId ? { projectId: session.projectId } : {}), createdAt: session.createdAt || now, updatedAt: now, - lockedByTab: null, - lockedAt: null, }) .onConflictDoUpdate({ target: [schema.project.aiSessions.projectId, schema.project.aiSessions.id], @@ -216,7 +208,6 @@ export async function listActiveAiSessions( status: schema.project.aiSessions.status, title: schema.project.aiSessions.title, projectId: schema.project.aiSessions.projectId, - lockedByTab: schema.project.aiSessions.lockedByTab, updatedAt: schema.project.aiSessions.updatedAt, archived: schema.project.aiSessions.archived, }) @@ -248,7 +239,6 @@ export async function listAllAiSessions( title: schema.project.aiSessions.title, inputPayload: schema.project.aiSessions.inputPayload, projectId: schema.project.aiSessions.projectId, - lockedByTab: schema.project.aiSessions.lockedByTab, updatedAt: schema.project.aiSessions.updatedAt, archived: schema.project.aiSessions.archived, }) @@ -388,96 +378,14 @@ export async function unarchiveAiSession(handle: QueryHandle, id: string): Promi return result.length > 0; } -// ── Locks ── - -export async function acquireAiSessionLock( - handle: QueryHandle, - sessionId: string, - tabId: string, -): Promise<{ acquired: boolean; currentHolder: string | null }> { - const now = new Date().toISOString(); - const result = await handle - .update(schema.project.aiSessions) - .set({ lockedByTab: tabId, lockedAt: now }) - .where( - and( - eq(schema.project.aiSessions.id, sessionId), - or(isNull(schema.project.aiSessions.lockedByTab), eq(schema.project.aiSessions.lockedByTab, tabId)), - ), - ) - .returning({ id: schema.project.aiSessions.id }); - - if (result.length > 0) { - return { acquired: true, currentHolder: null }; - } - - const holderRows = await handle - .select({ lockedByTab: schema.project.aiSessions.lockedByTab }) - .from(schema.project.aiSessions) - .where(eq(schema.project.aiSessions.id, sessionId)) - .limit(1); - return { acquired: false, currentHolder: holderRows[0]?.lockedByTab ?? null }; -} - -export async function releaseAiSessionLock( - handle: QueryHandle, - sessionId: string, - tabId: string, -): Promise { - const result = await handle - .update(schema.project.aiSessions) - .set({ lockedByTab: null, lockedAt: null }) - .where(and(eq(schema.project.aiSessions.id, sessionId), eq(schema.project.aiSessions.lockedByTab, tabId))) - .returning({ id: schema.project.aiSessions.id }); - return result.length > 0; -} - -export async function forceAcquireAiSessionLock( - handle: QueryHandle, - sessionId: string, - tabId: string, -): Promise { - const now = new Date().toISOString(); - const result = await handle - .update(schema.project.aiSessions) - .set({ lockedByTab: tabId, lockedAt: now }) - .where(eq(schema.project.aiSessions.id, sessionId)) - .returning({ id: schema.project.aiSessions.id }); - return result.length > 0; -} - -export async function getAiSessionLockHolder( - handle: QueryHandle, - sessionId: string, -): Promise<{ tabId: string | null; lockedAt: string | null }> { - const rows = await handle - .select({ lockedByTab: schema.project.aiSessions.lockedByTab, lockedAt: schema.project.aiSessions.lockedAt }) - .from(schema.project.aiSessions) - .where(eq(schema.project.aiSessions.id, sessionId)) - .limit(1); - return { - tabId: rows[0]?.lockedByTab ?? null, - lockedAt: rows[0]?.lockedAt ?? null, - }; -} - -export async function releaseStaleAiSessionLocks( - handle: QueryHandle, - maxAgeMs = 30 * 60 * 1000, -): Promise { - const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); - const result = await handle - .update(schema.project.aiSessions) - .set({ lockedByTab: null, lockedAt: null }) - .where( - and( - isNotNull(schema.project.aiSessions.lockedByTab), - lte(schema.project.aiSessions.lockedAt, cutoff), - ), - ) - .returning({ id: schema.project.aiSessions.id }); - return result.length; -} +/* +FNXC:PlanningMultiTab 2026-07-14-00:00: +The per-tab session lock (acquire/release/force-acquire/holder/stale-release) was removed here +along with the `lockedByTab`/`lockedAt` columns. AI interview sessions (planning, subtask, +mission, milestone, slice) are multi-tab: the persisted session row is the shared source of +truth, any tab may read and interact, and concurrent writes are resolved by each producer's +generation-in-progress guard rather than by a tab-ownership lock. +*/ // ── Delete ── diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 69c98ba595..13a81dbda8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2326,11 +2326,6 @@ export { updateThinking as updateThinkingAsync, archiveAiSession, unarchiveAiSession, - acquireAiSessionLock, - releaseAiSessionLock, - forceAcquireAiSessionLock, - getAiSessionLockHolder, - releaseStaleAiSessionLocks, deleteAiSession, deleteAiSessionByIdAndType, recoverStaleAiSessions, diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 8b16937051..59bbccdadd 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1599,6 +1599,15 @@ export const aiSessions = projectSchema.table("ai_sessions", { projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + DEAD COLUMNS — no code reads or writes these. The per-tab session lock they backed was + removed when AI interview sessions became multi-tab (the persisted row is the shared source + of truth; any tab may read and interact). They are retained, nullable and always NULL, only + because dropping them is an irreversible migration that would break any still-installed + older binary, whose upsert names `locked_by_tab`/`locked_at` explicitly. Drop them (plus + `idxAiSessionsLock`) in a later migration once no such binary can reach this database. + */ lockedByTab: text("locked_by_tab"), lockedAt: text("locked_at"), archived: integer("archived").default(0), diff --git a/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx index e925c68205..9299cda14d 100644 --- a/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx +++ b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx @@ -14,7 +14,6 @@ function cliSession(overrides: Partial = {}): AiSessionSummary status: overrides.status ?? "needs_attention", title: overrides.title ?? "CLI session needs attention", projectId: overrides.projectId ?? "proj-1", - lockedByTab: overrides.lockedByTab ?? null, updatedAt: overrides.updatedAt ?? "2026-06-14T19:32:00.000Z", cliVariant: overrides.cliVariant ?? "userExited", cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId") diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 5b8e69a436..30b28bad37 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -4087,11 +4087,10 @@ export function respondToPlanning( sessionId: string, responses: Record, projectId?: string, - tabId?: string, ): Promise { return api(withProjectId("/planning/respond", projectId), { method: "POST", - body: JSON.stringify({ sessionId, responses, tabId }), + body: JSON.stringify({ sessionId, responses }), }); } @@ -4099,13 +4098,11 @@ export function respondToPlanning( export function rewindPlanningSession( sessionId: string, projectId?: string, - tabId?: string, ): Promise<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }> { return api<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }>( withProjectId(`/planning/${encodeURIComponent(sessionId)}/back`, projectId), { method: "POST", - ...(tabId ? { body: JSON.stringify({ tabId }) } : {}), }, ); } @@ -4114,13 +4111,11 @@ export function rewindPlanningSession( 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 }) } : {}), }, ); } @@ -4129,22 +4124,20 @@ export function retryPlanningSession( export function stopPlanningGeneration( sessionId: string, projectId?: string, - tabId?: string, ): Promise<{ success: boolean }> { return api<{ success: boolean }>( withProjectId(`/planning/${encodeURIComponent(sessionId)}/stop`, projectId), { method: "POST", - ...(tabId ? { body: JSON.stringify({ tabId }) } : {}), }, ); } /** Cancel an active planning session */ -export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise { +export function cancelPlanning(sessionId: string, projectId?: string): Promise { return api(withProjectId("/planning/cancel", projectId), { method: "POST", - body: JSON.stringify({ sessionId, tabId }), + body: JSON.stringify({ sessionId }), }); } @@ -6186,14 +6179,10 @@ 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 }) } : {}), - }, + { method: "POST" }, ); } @@ -6318,10 +6307,10 @@ export function createTasksFromBreakdown( }); } -export function cancelSubtaskBreakdown(sessionId: string, projectId?: string, tabId?: string): Promise { +export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): Promise { return api(withProjectId("/subtasks/cancel", projectId), { method: "POST", - body: JSON.stringify({ sessionId, tabId }), + body: JSON.stringify({ sessionId }), }); } @@ -8833,11 +8822,10 @@ export function respondToMissionInterview( sessionId: string, responses: Record, projectId?: string, - tabId?: string, ): Promise { return api(withProjectId("/missions/interview/respond", projectId), { method: "POST", - body: JSON.stringify({ sessionId, responses, tabId }), + body: JSON.stringify({ sessionId, responses }), }); } @@ -8845,22 +8833,18 @@ 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 }) } : {}), - }, + { method: "POST" }, ); } /** Cancel an active mission interview session */ -export function cancelMissionInterview(sessionId: string, projectId?: string, tabId?: string): Promise { +export function cancelMissionInterview(sessionId: string, projectId?: string): Promise { return api(withProjectId("/missions/interview/cancel", projectId), { method: "POST", - body: JSON.stringify({ sessionId, tabId }), + body: JSON.stringify({ sessionId }), }); } @@ -8873,14 +8857,10 @@ export async function fetchMissionInterviewDrafts(projectId?: string): Promise { return api<{ removed: boolean }>( withProjectId(`/missions/interview/drafts/${encodeURIComponent(sessionId)}/discard`, projectId), - { - method: "POST", - body: JSON.stringify({ tabId }), - }, + { method: "POST" }, ); } @@ -9090,11 +9070,10 @@ export function respondToMilestoneInterview( sessionId: string, responses: Record, projectId?: string, - tabId?: string, ): Promise { return api(buildMilestoneInterviewUrl(sessionId, "/respond", projectId), { method: "POST", - body: JSON.stringify({ sessionId, responses, tabId }), + body: JSON.stringify({ sessionId, responses }), }); } @@ -9226,11 +9205,10 @@ export function respondToSliceInterview( sessionId: string, responses: Record, projectId?: string, - tabId?: string, ): Promise { return api(buildSliceInterviewUrl(sessionId, "/respond", projectId), { method: "POST", - body: JSON.stringify({ sessionId, responses, tabId }), + body: JSON.stringify({ sessionId, responses }), }); } @@ -9470,7 +9448,6 @@ export interface AiSessionSummary { /** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */ preview?: string; projectId: string | null; - lockedByTab: string | null; updatedAt: string; archived?: boolean; } @@ -9489,7 +9466,6 @@ export interface AiSessionDetail extends AiSessionSummary { thinkingOutput: string; error: string | null; createdAt: string; - lockedAt: string | null; } export function parseConversationHistory(raw: string): ConversationHistoryEntry[] { @@ -9540,37 +9516,12 @@ export async function fetchAiSession(id: string): Promise { - 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 { - return api(`/ai-sessions/${encodeURIComponent(sessionId)}/lock`, { - method: "DELETE", - body: JSON.stringify({ tabId }), - }); -} - -export function forceAcquireSessionLock(sessionId: string, tabId: string): Promise { - return api(`/ai-sessions/${encodeURIComponent(sessionId)}/lock/force`, { - method: "POST", - body: JSON.stringify({ tabId }), - }); -} +/* +FNXC:PlanningMultiTab 2026-07-14-00:00: +acquireSessionLock / releaseSessionLock / forceAcquireSessionLock were removed with the rest of +the per-tab session lock; their routes no longer exist. AI interview sessions are multi-tab — +the persisted session row is the shared source of truth and any tab may read and interact. +*/ export async function deleteAiSession(id: string): Promise { const url = buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`); diff --git a/packages/dashboard/app/components/BackgroundTasksIndicator.tsx b/packages/dashboard/app/components/BackgroundTasksIndicator.tsx index 8a61885001..485d27e842 100644 --- a/packages/dashboard/app/components/BackgroundTasksIndicator.tsx +++ b/packages/dashboard/app/components/BackgroundTasksIndicator.tsx @@ -1,11 +1,8 @@ import "./BackgroundTasksIndicator.css"; import { useState, useRef, useEffect, useMemo } from "react"; -import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react"; +import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, AlertCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AiSessionSummary } from "../api"; -import { useAiSessionSync } from "../hooks/useAiSessionSync"; -import { useConfirm } from "../hooks/useConfirm"; -import { getSessionTabId } from "../utils/getSessionTabId"; interface BackgroundTasksIndicatorProps { sessions: AiSessionSummary[]; @@ -32,15 +29,12 @@ export function BackgroundTasksIndicator({ onDismissSession, }: BackgroundTasksIndicatorProps) { const { t } = useTranslation("app"); - const { confirm } = useConfirm(); const [popoverOpen, setPopoverOpen] = useState(false); const [recentlyUpdated, setRecentlyUpdated] = useState>(new Set()); const containerRef = useRef(null); const previousSessionSignatureRef = useRef>(new Map()); const clearUpdatedTimerRef = useRef | null>(null); - const { activeTabMap } = useAiSessionSync(); - const localSessionTabId = useMemo(() => getSessionTabId(), []); // Type labels that are translatable const TYPE_LABELS = useMemo( @@ -67,19 +61,19 @@ export function BackgroundTasksIndicator({ return () => document.removeEventListener("mousedown", handler); }, [popoverOpen]); - // Animate per-item changes when session status/lock/timestamp changes. + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + No tab-ownership concept remains: AI interview sessions are multi-tab, so every session opens + directly with no "active in another tab" gate, confirm prompt, or lock badge. Item animation + keys off server-derived status/timestamp only. + */ + // Animate per-item changes when session status/timestamp changes. useEffect(() => { const changed = new Set(); const nextSignature = new Map(); for (const session of sessions) { - const ownership = activeTabMap.get(session.id); - const signature = [ - session.status, - session.updatedAt, - ownership?.tabId ?? session.lockedByTab ?? "", - ownership?.stale ? "stale" : "fresh", - ].join("|"); + const signature = [session.status, session.updatedAt].join("|"); const previous = previousSessionSignatureRef.current.get(session.id); if (previous && previous !== signature) { @@ -112,7 +106,7 @@ export function BackgroundTasksIndicator({ clearUpdatedTimerRef.current = null; } }; - }, [activeTabMap, sessions]); + }, [sessions]); if (sessions.length === 0) return null; @@ -148,11 +142,6 @@ export function BackgroundTasksIndicator({ const isGenerating = session.status === "generating"; const isAwaiting = session.status === "awaiting_input"; const isError = session.status === "error"; - const activeTab = activeTabMap.get(session.id); - const owningTabId = activeTab?.tabId ?? session.lockedByTab ?? null; - const activeElsewhere = Boolean( - owningTabId && owningTabId !== localSessionTabId && !activeTab?.stale, - ); const isUpdated = recentlyUpdated.has(session.id); return ( @@ -166,17 +155,7 @@ export function BackgroundTasksIndicator({ : undefined, transform: isUpdated ? "translateY(-1px)" : undefined, }} - onClick={async () => { - if (activeElsewhere) { - const shouldOpen = await confirm({ - title: t("backgroundTasks.confirmTitle", "Open Active Session"), - message: t("backgroundTasks.confirmMessage", "This session is active in another tab. Open anyway?"), - }); - if (!shouldOpen) { - return; - } - } - + onClick={() => { onOpenSession(session); setPopoverOpen(false); }} @@ -193,8 +172,7 @@ export function BackgroundTasksIndicator({
{isError ? t("backgroundTasks.status.failed", "Failed") : TYPE_LABELS[session.type]} {isGenerating && ` — ${t("backgroundTasks.status.generating", "generating...")}`} - {isAwaiting && !activeElsewhere && ` — ${t("backgroundTasks.status.needsInput", "needs input")}`} - {isAwaiting && activeElsewhere && ` — ${t("backgroundTasks.status.activeElsewhere", "active in another tab")}`} + {isAwaiting && ` — ${t("backgroundTasks.status.needsInput", "needs input")}`}
{isGenerating && ( @@ -204,20 +182,13 @@ export function BackgroundTasksIndicator({ style={{ color: "var(--color-success)" }} /> )} - {isAwaiting && !activeElsewhere && ( + {isAwaiting && ( )} - {isAwaiting && activeElsewhere && ( - - )} - )} - - - )} diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index b9a53f7ee0..1ca0ad59d0 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -106,7 +106,6 @@ import { import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types"; import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; -import { getSessionTabId } from "../utils/getSessionTabId"; const MISSION_SIDEBAR_DEFAULT_WIDTH = 300; const MISSION_SIDEBAR_MIN_WIDTH = 220; @@ -618,7 +617,6 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, workflowId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) { const { t } = useTranslation("app"); const { confirm } = useConfirm(); - const sessionTabId = useMemo(() => getSessionTabId(), []); /* FNXC:Missions 2026-06-27-20:25: Inline Missions can stay mounted while hidden, so tab visibility must drive active state. Re-opening the tab must reset to overview instead of preserving the previously selected mission. @@ -4221,7 +4219,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr FNXC:MissionDraftDiscard 2026-06-24-02:42: The mission draft Discard confirmation must send the current browser tab id so a draft locked by this tab can be removed while a draft actively owned by another tab returns the lock warning and stays visible. */ - await discardMissionInterviewDraft(sessionId, projectId, sessionTabId); + await discardMissionInterviewDraft(sessionId, projectId); setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId)); } catch (err) { if (err instanceof ApiRequestError && err.status === 409) { diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index d09d11d632..660997ab88 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -679,33 +679,6 @@ An empty footer must NOT reserve vertical space or paint its divider band. When padding: var(--space-xs) var(--space-sm); } -.session-lock-overlay { - position: absolute; - inset: 0; - background: color-mix(in srgb, var(--bg) 55%, transparent); - 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: var(--radius-md); - padding: var(--space-lg) var(--space-xl); - display: flex; - align-items: center; - gap: var(--space-md); - box-shadow: var(--shadow-md); -} - -.session-lock-take-control { - margin-left: auto; -} - /* Initial View */ .planning-initial { display: flex; diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index ac9c2eca8f..00e0dcb9b7 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -51,19 +51,16 @@ import { clearPlanningDescription, } from "../hooks/modalPersistence"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; -import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react"; +import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { OnboardingDisclosure } from "./OnboardingDisclosure"; -import { useSessionLock } from "../hooks/useSessionLock"; -import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea"; import { useToast } from "../hooks/useToast"; -import { getSessionTabId } from "../utils/getSessionTabId"; const WARNING_ICON = "⚠️"; @@ -381,7 +378,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // identity change (e.g. typing into the textarea recreates loadSession) and // yanks the user back into the previous session's question view. const dismissedResumeRef = useRef(null); - const [lockSessionId, setLockSessionId] = useState(resumeSessionId ?? null); useEffect(() => { viewRef.current = view; @@ -393,20 +389,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setAutoRetryAttempt(0); setIsAutoRetrying(false); }, []); - const sessionTabId = useMemo(() => getSessionTabId(), []); - const { - isLockedByOther, - takeControl, - isLoading: isLockLoading, - } = useSessionLock(isOpen ? lockSessionId : null); - const { - activeTabMap, - broadcastUpdate, - broadcastCompleted, - broadcastLock, - broadcastUnlock, - broadcastHeartbeat, - } = useAiSessionSync(); + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + Planning Mode has no cross-tab coordination. The persisted session row is the single source + of truth: every tab may read and interact, the per-session SSE stream plus the global + ai_session:updated events (consumed by useBackgroundSessions) keep all tabs current, and the + server's generation-in-progress guard resolves concurrent writes. The former tab-lock + (useSessionLock, per-tab lock 409s, Take Control overlay) and BroadcastChannel tab-ownership sync + (useAiSessionSync broadcasts) were removed from planning. + */ const [planningModelProvider, setPlanningModelProvider] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); const [planningThinkingLevel, setPlanningThinkingLevel] = useState(""); @@ -421,7 +412,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat provider?: string; modelId?: string; }>({}); - const trackedLockSessionRef = useRef(null); // Sidebar list state const [planningSessions, setPlanningSessions] = useState([]); @@ -602,11 +592,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // during normal generation. useEffect(() => { if (view.type !== "loading") return; - const sessionId = currentSessionIdRef.current; - if (!sessionId) return; let cancelled = false; + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + Resolve the session id inside each tick instead of at effect setup. The loading view can + begin before the session id exists (Start Planning resolves it async), and the removed + cross-tab lock state used to be the dependency that re-armed this effect afterwards. Reading + the ref per-tick keeps the poll armed for the whole loading window with no extra state. + */ const tick = async () => { + const sessionId = currentSessionIdRef.current; + if (!sessionId) return; try { const session = await fetchAiSession(sessionId); if (cancelled || !session) return; @@ -659,16 +656,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }; }); setStreamingOutput(""); - broadcastUpdate({ - sessionId, - status: "error", - needsInput: false, - owningTabId: sessionTabId, - type: "planning", - title: initialPlan.trim() || undefined, - projectId: projectId ?? null, - }); - broadcastCompleted({ sessionId, status: "error" }); } } catch { // best-effort; keep polling @@ -680,7 +667,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat cancelled = true; clearInterval(interval); }; - }, [broadcastCompleted, broadcastUpdate, initialPlan, lockSessionId, projectId, resetPlanningAutoRetryBudget, sessionTabId, t, view.type]); + }, [projectId, resetPlanningAutoRetryBudget, t, view.type]); const resetDetailState = useCallback(() => { setInitialPlan(""); @@ -704,7 +691,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setPlanningDepth("medium"); setCustomQuestionCount(""); currentSessionIdRef.current = null; - setLockSessionId(null); }, [resetPlanningAutoRetryBudget]); const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId); @@ -795,15 +781,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamingOutputRef.current = next; return next; }); - broadcastUpdate({ - sessionId, - status: "generating", - needsInput: false, - owningTabId: sessionTabId, - type: "planning", - title: initialPlan.trim() || undefined, - projectId: projectId ?? null, - }); }, onQuestion: (question) => { if (isStaleEvent()) return; @@ -836,16 +813,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat session: { sessionId, currentQuestion: normalizedQuestion, summary: null }, }); setStreamingOutput(""); - - broadcastUpdate({ - sessionId, - status: "awaiting_input", - needsInput: true, - owningTabId: sessionTabId, - type: "planning", - title: initialPlan.trim() || undefined, - projectId: projectId ?? null, - }); }, onSummary: (summary) => { if (isStaleEvent()) return; @@ -874,16 +841,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); setEditedSummary(normalizedSummary); setStreamingOutput(""); - - broadcastUpdate({ - sessionId, - status: "complete", - needsInput: false, - owningTabId: sessionTabId, - type: "planning", - title: initialPlan.trim() || undefined, - projectId: projectId ?? null, - }); }, onError: (message) => { const errorMessage = message || t("planning.sessionFailed", "Session failed while contacting the AI."); @@ -933,17 +890,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); setStreamingOutput(""); currentSessionIdRef.current = sessionId; - - broadcastUpdate({ - sessionId, - status: "error", - needsInput: false, - owningTabId: sessionTabId, - type: "planning", - title: initialPlan.trim() || undefined, - projectId: projectId ?? null, - }); - broadcastCompleted({ sessionId, status: "error" }); })(); }, onComplete: () => { @@ -953,7 +899,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; currentSessionIdRef.current = null; - broadcastCompleted({ sessionId, status: "complete" }); }, onConnectionStateChange: (state) => { setIsReconnecting(state === "reconnecting"); @@ -962,7 +907,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current = connection; }, - [broadcastCompleted, broadcastUpdate, initialPlan, projectId, resetPlanningAutoRetryBudget, sessionTabId], + [projectId, resetPlanningAutoRetryBudget, t], ); const startPlanningRetry = useCallback( @@ -974,11 +919,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "loading" }); currentSessionIdRef.current = retryTarget.sessionId; - setLockSessionId(retryTarget.sessionId); connectToPlanningStream(retryTarget.sessionId); try { - await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId); + await retryPlanningSession(retryTarget.sessionId, projectId); } catch (err) { let retryError: unknown = err; const retryErrorMessage = getErrorMessage(err) || ""; @@ -991,7 +935,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } currentSessionIdRef.current = session.id; - setLockSessionId(session.id); if (session.status === "generating") { setStreamingOutput(session.thinkingOutput ?? ""); @@ -1065,7 +1008,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat planningAutoRetryInFlightRef.current = false; } }, - [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, sessionTabId, t], + [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t], ); const startPlanningAutoRetry = useCallback( @@ -1137,7 +1080,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ); draftSessionIdRef.current = null; currentSessionIdRef.current = sessionId; - setLockSessionId(sessionId); setSelectedSessionId(sessionId); connectToPlanningStream(sessionId); @@ -1147,7 +1089,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setError(getErrorMessage(err) || t("planning.failedStartSession", "Failed to start planning session")); setView({ type: "initial" }); currentSessionIdRef.current = null; - setLockSessionId(null); } }, [ connectToPlanningStream, @@ -1232,7 +1173,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } currentSessionIdRef.current = sessionId; - setLockSessionId(sessionId); const parsedHistory = parseConversationHistory(session.conversationHistory); setConversationHistory(parsedHistory); setResponseHistory( @@ -1330,7 +1270,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } } catch (err) { currentSessionIdRef.current = sessionId; - setLockSessionId(sessionId); setError(null); setView({ type: "error", @@ -1614,7 +1553,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // generating; for terminal sessions skip the cancel call. if (target && isActiveServerSession(target.status)) { try { - await cancelPlanning(sessionId, projectId, sessionTabId); + await cancelPlanning(sessionId, projectId); } catch { // best-effort } @@ -1629,17 +1568,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat return; } - // Broadcast completion so sibling consumers (BackgroundTasksIndicator's - // useBackgroundSessions hook, other tabs) prune this session from their - // active lists. The server-side SSE delete event covers the in-flight - // path, but the cross-tab broadcast is what keeps the footer pill in - // lockstep when this modal initiates the delete. - broadcastCompleted({ - sessionId, - status: "complete", - timestamp: Date.now(), - }); - + // The server-side ai_session:deleted SSE event prunes this session from + // every tab's useBackgroundSessions state; the local list update below + // just keeps this modal responsive without waiting for the event. setPlanningSessions((prev) => dedupeSessionsById(prev.filter((s) => s.id !== sessionId))); if (selectedSessionId === sessionId) { @@ -1651,7 +1582,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } setPendingDeleteId(null); }, - [addToast, broadcastCompleted, planningSessions, projectId, refreshSessionsList, resetDetailState, selectedSessionId, sessionTabId], + [addToast, planningSessions, projectId, refreshSessionsList, resetDetailState, selectedSessionId], ); const handleArchiveSession = useCallback( @@ -1698,51 +1629,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat hasLoadedPersistedRef.current = false; setIsReconnecting(false); setIsRetrying(false); - setLockSessionId(null); } }, [isOpen]); - // Broadcast lock ownership transitions for cross-tab awareness. - useEffect(() => { - if (!isOpen) { - if (trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - trackedLockSessionRef.current = null; - } - return; - } - - if (lockSessionId && trackedLockSessionRef.current !== lockSessionId) { - if (trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - } - broadcastLock(lockSessionId, sessionTabId); - trackedLockSessionRef.current = lockSessionId; - return; - } - - if (!lockSessionId && trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - trackedLockSessionRef.current = null; - } - }, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]); - - // Emit heartbeat while this tab actively owns the current session lock. - useEffect(() => { - if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) { - return; - } - - broadcastHeartbeat(sessionTabId); - const timer = setInterval(() => { - broadcastHeartbeat(sessionTabId); - }, 30_000); - - return () => { - clearInterval(timer); - }; - }, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]); - // Cleanup stream connection on unmount useEffect(() => { return () => { @@ -1752,13 +1641,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } streamConnectionRef.current?.close(); streamConnectionRef.current = null; - - if (trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - trackedLockSessionRef.current = null; - } }; - }, [broadcastUnlock, sessionTabId]); + }, []); // Handle browser unload while modal is open useEffect(() => { @@ -1905,14 +1789,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, sessionTabId); + await respondToPlanning(sessionId, responses, projectId); // Events (question/summary) will arrive via the existing SSE stream } catch (err) { setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response")); setView({ type: "question", session }); } }, - [projectId, resetPlanningAutoRetryBudget, sessionTabId, view] + [projectId, resetPlanningAutoRetryBudget, view] ); const handleRefineFurther = useCallback(async () => { @@ -1923,7 +1807,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const { session, summary } = view; const sessionId = session.sessionId; currentSessionIdRef.current = sessionId; - setLockSessionId(sessionId); refineSummaryInFlightRef.current = true; setIsRefiningSummary(true); @@ -1936,7 +1819,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat connectToPlanningStream(sessionId); try { - await respondToPlanning(sessionId, { refine: true }, projectId, sessionTabId); + await respondToPlanning(sessionId, { refine: true }, projectId); } catch (err) { const message = getErrorMessage(err) || t("planning.failedRefinePlan", "Failed to refine plan"); if (/generation already in progress/i.test(message)) { @@ -1949,7 +1832,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setError(message); setView({ type: "summary", session, summary: editedSummary ?? summary }); } - }, [connectToPlanningStream, editedSummary, projectId, resetPlanningAutoRetryBudget, sessionTabId, view]); + }, [connectToPlanningStream, editedSummary, projectId, resetPlanningAutoRetryBudget, view]); const handleStopGeneration = useCallback(async () => { const sessionId = currentSessionIdRef.current; @@ -1958,7 +1841,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } try { - await stopPlanningGeneration(sessionId, projectId, sessionTabId); + await stopPlanningGeneration(sessionId, projectId); } catch { // best-effort; server-side timeout/stop event may have already fired } @@ -1976,7 +1859,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat errorMessage: t("planning.generationStopped", "Generation stopped by user. You can retry or start a new session."), }); setStreamingOutput(""); - }, [projectId, sessionTabId]); + }, [projectId, t]); const handleRetryFromError = useCallback(async () => { if (view.type !== "error") { @@ -2014,18 +1897,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // only clear the active selection before closing; keep the sidebar row // in local state to match persisted server truth. setSelectedSessionId(null); - broadcastCompleted({ - sessionId: completedSessionId, - status: "complete", - timestamp: Date.now(), - }); handleClose(); } catch (err) { setError(getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task")); } finally { setIsCreatingTask(false); } - }, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, workflowId, onTaskCreated, handleClose]); + }, [baseBranch, branchMode, branchName, editedSummary, view, projectId, workflowId, onTaskCreated, handleClose]); const handleStartBreakdown = useCallback(async () => { if (view.type !== "summary") return; @@ -2037,7 +1915,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const normalizedSummary = editedSummary ? normalizePlanningSummary(editedSummary) : undefined; const result = await startPlanningBreakdown(view.session.sessionId, normalizedSummary, projectId); const normalizedSubtasks = (Array.isArray(result.subtasks) ? result.subtasks : []).map(normalizeSubtaskItem); - setLockSessionId(result.sessionId); setView({ type: "breakdown", sessionId: result.sessionId, @@ -2084,11 +1961,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // Server cleans up the planning session after task creation; mirror that // locally so reopen doesn't try to load a 404 and the footer count drops. setPlanningSessions((prev) => dedupeSessionsById(prev.filter((s) => s.id !== completedSessionId))); - broadcastCompleted({ - sessionId: completedSessionId, - status: "complete", - timestamp: Date.now(), - }); // Reset and close setInitialPlan(""); setView({ type: "initial" }); @@ -2103,7 +1975,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setPlanningDepth("medium"); setCustomQuestionCount(""); currentSessionIdRef.current = null; - setLockSessionId(null); setSelectedSessionId(null); handleClose(); } catch (err) { @@ -2111,7 +1982,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } finally { setIsCreatingFromBreakdown(false); } - }, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId, workflowId]); + }, [baseBranch, branchMode, branchName, handleClose, view, onTasksCreated, projectId, workflowId]); /* FNXC:PlanningMode 2026-07-05-00:00: @@ -2134,7 +2005,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsBackPending(true); try { - const rewound = await rewindPlanningSession(sessionId, projectId, sessionTabId); + const rewound = await rewindPlanningSession(sessionId, projectId); setResponseHistory(rewound.history.map((entry) => { if (entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)) { return entry.response as QuestionResponse; @@ -2164,7 +2035,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } finally { setIsBackPending(false); } - }, [projectId, responseHistory.length, sessionTabId, view]); + }, [projectId, responseHistory.length, t, view]); const getProgress = () => { if (view.type === "question") { @@ -2173,10 +2044,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat return 3; }; - const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null; - const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId; - const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale); - /* FNXC:PlanningMode 2026-06-21-00:00: FN-6886 keeps the existing Planning Mode workflow component but lets App mount it as an embedded main-content view. Embedded mode must not draw a full-screen overlay, close on backdrop clicks, lock mobile scrolling, or persist resizable modal dimensions. @@ -2330,7 +2197,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat title: response.title, preview: content.length > 80 ? `${content.slice(0, 79).trimEnd()}…` : content, projectId: projectId ?? null, - lockedByTab: null, updatedAt: new Date().toISOString(), archived: false, }; @@ -2626,30 +2492,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat )} - {isLockedByOther && ( -
-
- - - {allowTakeover - ? t("planning.sessionActiveOtherTab", "This session is active in another tab") - : t("planning.sessionActiveOtherTabLive", "This session is active in another tab (live heartbeat)")} - - {allowTakeover && ( - - )} -
-
- )} diff --git a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx index 1427894392..24c6a0b016 100644 --- a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx +++ b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx @@ -18,15 +18,13 @@ import { getSubtaskDescription, clearSubtaskDescription, } from "../hooks/modalPersistence"; -import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw, Lock } from "lucide-react"; +import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; -import { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useConfirm } from "../hooks/useConfirm"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useViewportMode } from "../hooks/useViewportMode"; -import { getSessionTabId } from "../utils/getSessionTabId"; const WARNING_ICON = "⚠️"; @@ -119,25 +117,18 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const titleRefs = useRef>([]); const autoStartedRef = useRef(false); - const trackedLockSessionRef = useRef(null); 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 { - activeTabMap, - broadcastUpdate, - broadcastCompleted, - broadcastLock, - broadcastUnlock, - broadcastHeartbeat, - } = useAiSessionSync(); + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + No tab lock: the persisted session row is the shared source of truth, so every tab may read + and interact with this breakdown. The lock overlay, Take Control affordance, ownership + broadcasts, heartbeat, and the "active in another tab" banner were removed; only + session-status sync remains. + */ + const { broadcastUpdate, broadcastCompleted } = useAiSessionSync(); const isInvalid = useMemo(() => { if (subtasks.length === 0) return true; @@ -147,11 +138,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT }, [branchMode, branchName, subtasks]); const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error"; - const activeLockInfo = sessionId ? activeTabMap.get(sessionId) : null; const { confirm } = useConfirm(); - const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId; - const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale); - const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale); const resetState = useCallback(() => { // Save to localStorage before cleanup (preserve for re-entry) @@ -191,12 +178,11 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT sessionId, status: resumableStatus, needsInput: resumableStatus === "awaiting_input", - owningTabId: sessionTabId, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); - }, [broadcastUpdate, localDescription, projectId, sessionId, sessionTabId, view.type]); + }, [broadcastUpdate, localDescription, projectId, sessionId, view.type]); const handleSendToBackground = useCallback(() => { keepSessionReachableInBackground(); @@ -234,7 +220,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT sessionId: activeSessionId, status: "generating", needsInput: false, - owningTabId: sessionTabId, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, @@ -252,7 +237,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT sessionId: activeSessionId, status: "awaiting_input", needsInput: true, - owningTabId: sessionTabId, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, @@ -269,7 +253,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT sessionId: activeSessionId, status: "error", needsInput: false, - owningTabId: sessionTabId, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, @@ -289,7 +272,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT broadcastUpdate, localDescription, projectId, - sessionTabId, ], ); @@ -308,8 +290,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT sessionId, status: "generating", needsInput: false, - owningTabId: sessionTabId, - type: "subtask", + type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); @@ -320,7 +301,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT } finally { setIsStartingBreakdown(false); } - }, [broadcastUpdate, connectToSubtaskStream, localDescription, projectId, sessionTabId, t]); + }, [broadcastUpdate, connectToSubtaskStream, localDescription, projectId, t]); useEffect(() => { if (!isOpen) { @@ -375,56 +356,11 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT })(); }, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]); - // Broadcast lock ownership transitions across tabs. - useEffect(() => { - if (!isOpen) { - if (trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - trackedLockSessionRef.current = null; - } - return; - } - - if (sessionId && trackedLockSessionRef.current !== sessionId) { - if (trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - } - broadcastLock(sessionId, sessionTabId); - trackedLockSessionRef.current = sessionId; - return; - } - - if (!sessionId && trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - trackedLockSessionRef.current = null; - } - }, [broadcastLock, broadcastUnlock, isOpen, sessionId, sessionTabId]); - - // Keep ownership heartbeat alive while this tab is interacting with the session. - useEffect(() => { - if (!isOpen || !sessionId || trackedLockSessionRef.current !== sessionId) { - return; - } - - broadcastHeartbeat(sessionTabId); - const timer = setInterval(() => { - broadcastHeartbeat(sessionTabId); - }, 30_000); - - return () => { - clearInterval(timer); - }; - }, [broadcastHeartbeat, isOpen, sessionId, sessionTabId]); - useEffect(() => { return () => { streamRef.current?.close(); - if (trackedLockSessionRef.current) { - broadcastUnlock(trackedLockSessionRef.current, sessionTabId); - trackedLockSessionRef.current = null; - } }; - }, [broadcastUnlock, sessionTabId]); + }, []); useEffect(() => { if (!isOpen) return; @@ -591,7 +527,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT connectToSubtaskStream(retrySessionId); try { - await retrySubtaskSession(retrySessionId, projectId, sessionTabId); + await retrySubtaskSession(retrySessionId, projectId); } catch (err) { let retryError: unknown = err; const retryErrorMessage = getErrorMessage(err) || ""; @@ -646,7 +582,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT } finally { setIsRetrying(false); } - }, [connectToSubtaskStream, projectId, sessionTabId, view]); + }, [connectToSubtaskStream, projectId, view]); if (!isOpen) return null; @@ -678,11 +614,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
{error &&
{error}
} {isReconnecting &&
{t("subtasks.reconnecting", "Reconnecting…")}
} - {activeInAnotherTab && ( -
- {t("subtasks.sessionActiveAnotherTab", "Session is active in another tab.")} -
- )} {view.type === "initial" && (
@@ -969,30 +900,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
)} - {isLockedByOther && ( -
-
- - - {allowTakeover - ? t("subtasks.sessionActiveAnotherTabTakeover", "This session is active in another tab") - : t("subtasks.sessionActiveAnotherTabLive", "This session is active in another tab (live heartbeat)")} - - {allowTakeover && ( - - )} -
-
- )}
diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx index 0dd7ebc83d..9d2ac00f2a 100644 --- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx @@ -102,7 +102,6 @@ function makeBackgroundSession(id: string, status: AiSessionSummary["status"]): status, title: `Background ${id}`, projectId: "project-1", - lockedByTab: null, updatedAt: "2026-07-03T12:00:00.000Z", }; } diff --git a/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx index 634c20093e..17bfc74477 100644 --- a/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx @@ -36,29 +36,13 @@ vi.mock("../../api", () => ({ parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args), })); -vi.mock("../../hooks/useSessionLock", () => ({ - useSessionLock: vi.fn(() => ({ - isLockedByOther: false, - takeControl: vi.fn(), - isLoading: false, - })), -})); - vi.mock("../../hooks/useAiSessionSync", () => ({ useAiSessionSync: vi.fn(() => ({ - activeTabMap: new Map(), broadcastUpdate: vi.fn(), broadcastCompleted: vi.fn(), - broadcastLock: vi.fn(), - broadcastUnlock: vi.fn(), - broadcastHeartbeat: vi.fn(), })), })); -vi.mock("../../utils/getSessionTabId", () => ({ - getSessionTabId: vi.fn(() => "test-tab-id"), -})); - const mockUseMobileKeyboard = vi.fn(); vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args), @@ -495,7 +479,6 @@ describe("MilestoneSliceInterviewModal", () => { "session-123", { _other: "Split this differently" }, "test-project", - "test-tab-id", ); }); }); @@ -546,7 +529,6 @@ describe("MilestoneSliceInterviewModal", () => { "session-123", { _other: "Define a custom scope" }, "test-project", - "test-tab-id", ); }); }); @@ -604,7 +586,6 @@ describe("MilestoneSliceInterviewModal", () => { "slice-session-123", { priorities: ["speed"] }, "test-project", - "test-tab-id", ); }); }); @@ -658,7 +639,6 @@ describe("MilestoneSliceInterviewModal", () => { "slice-session-123", { _other: "Reframe around dependencies" }, "test-project", - "test-tab-id", ); }); }); @@ -709,7 +689,6 @@ describe("MilestoneSliceInterviewModal", () => { "slice-session-123", { _other: "Ask customers first" }, "test-project", - "test-tab-id", ); }); }); @@ -762,7 +741,6 @@ describe("MilestoneSliceInterviewModal", () => { "slice-session-123", { priorities: ["speed"], _other: "Preserve manual review" }, "test-project", - "test-tab-id", ); }); }); @@ -806,7 +784,6 @@ describe("MilestoneSliceInterviewModal", () => { "session-123", expect.objectContaining({ scope: "mvp", _comment: "Keep this aligned with mission MVP" }), "test-project", - "test-tab-id", ); }); }); @@ -819,7 +796,6 @@ describe("MilestoneSliceInterviewModal", () => { status: "awaiting_input" as const, title: "Plan milestone scope", projectId: "proj-1", - lockedByTab: null, updatedAt: new Date().toISOString(), inputPayload: JSON.stringify({ targetType: "milestone", @@ -834,7 +810,6 @@ describe("MilestoneSliceInterviewModal", () => { thinkingOutput: "", error: null, createdAt: new Date().toISOString(), - lockedAt: null, }; const mockSessionGenerating = { diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx index 7cb54dec8f..5c802e53de 100644 --- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx @@ -97,10 +97,8 @@ function buildMissionSession(overrides: Record = {}) { thinkingOutput: "Continuing...", error: null, projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, ...overrides, }; } @@ -233,9 +231,15 @@ describe("MissionInterviewModal", () => { expect(mobileBlock).toContain("display: none;"); }); - it("shows lock overlay and allows take-control", async () => { - window.sessionStorage.setItem("fusion-tab-id", "tab-self"); - mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + Mission interviews are multi-tab: this tab must never acquire a lock, never render a lock + overlay or "active in another tab" banner, and must stay interactive even when another tab + is using the same session. + */ + it("never acquires a tab lock and renders no lock overlay", async () => { + // A rejecting lock API would surface an overlay if any legacy lock path survived. + mockAcquireSessionLock.mockResolvedValue({ acquired: false, currentHolder: "tab-other" }); renderModal(); @@ -245,18 +249,14 @@ describe("MissionInterviewModal", () => { fireEvent.click(screen.getByText("Start Interview")); await waitFor(() => { - expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument(); + expect(mockStartMissionInterview).toHaveBeenCalled(); }); - 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(); - }); + expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument(); + expect(screen.queryByTestId("session-active-another-tab-banner")).not.toBeInTheDocument(); + expect(screen.queryByText("Take Control")).not.toBeInTheDocument(); + expect(mockAcquireSessionLock).not.toHaveBeenCalled(); + expect(mockForceAcquireSessionLock).not.toHaveBeenCalled(); }); it("shows reconnecting indicator without clearing current question", async () => { @@ -542,7 +542,7 @@ describe("MissionInterviewModal", () => { fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => { - expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String)); + expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined); }); await waitFor(() => { expect(screen.getByText("What is the target scope?")).toBeInTheDocument(); @@ -612,7 +612,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", expect.objectContaining({ scope: "mvp", _comment: "Optimize for launch speed" }), undefined, - expect.any(String), ); }); }); @@ -648,7 +647,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", { _other: "Start with discovery instead" }, undefined, - expect.any(String), ); }); }); @@ -689,7 +687,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", { _other: "Define a custom scope" }, undefined, - expect.any(String), ); }); }); @@ -728,7 +725,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", { scope: "mvp" }, undefined, - expect.any(String), ); }); }); @@ -772,7 +768,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", { _other: "Add field research first" }, undefined, - expect.any(String), ); }); }); @@ -813,7 +808,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", { _other: "Ask customers first" }, undefined, - expect.any(String), ); }); }); @@ -856,7 +850,6 @@ describe("MissionInterviewModal", () => { "mission-session-1", { priorities: ["speed"], _other: "Preserve operator review" }, undefined, - expect.any(String), ); }); }); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx index 9caaf8eeb1..dbd93e8252 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx @@ -19,7 +19,6 @@ const mockFetchMissionInterviewDrafts = vi.fn(); const mockDiscardMissionInterviewDraft = vi.fn(); const mockDeleteMission = vi.fn(); const mockSubscribeSse = vi.fn(() => vi.fn()); -const mockGetSessionTabId = vi.fn(() => "mission-manager-tab"); vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", @@ -40,10 +39,6 @@ vi.mock("../../sse-bus", () => ({ subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args), })); -vi.mock("../../utils/getSessionTabId", () => ({ - getSessionTabId: () => mockGetSessionTabId(), -})); - vi.mock("../MissionInterviewModal", () => ({ MissionInterviewModal: () => null, })); @@ -306,7 +301,7 @@ describe("MissionManager mission delete confirmation", () => { clickConfirmPanelAction("Discard"); await waitFor(() => { - expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-duplicate-b", projectId, "mission-manager-tab"); + expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-duplicate-b", projectId); }); await waitFor(() => { expect(document.querySelector(".mission-confirm-panel")).toBeNull(); @@ -332,7 +327,7 @@ describe("MissionManager mission delete confirmation", () => { clickConfirmPanelAction("Discard"); await waitFor(() => { - expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-mobile", projectId, "mission-manager-tab"); + expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-mobile", projectId); }); expect(screen.queryByText("Mobile draft")).not.toBeInTheDocument(); expect(document.querySelector(".mission-confirm-panel")).toBeNull(); @@ -376,7 +371,7 @@ describe("MissionManager mission delete confirmation", () => { clickConfirmPanelAction("Discard"); await waitFor(() => { - expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-locked", projectId, "mission-manager-tab"); + expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-locked", projectId); }); expect(screen.getByText("Locked draft")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Discard draft" })).toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx index cfa8c926ed..e4dcc6ab80 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx @@ -88,10 +88,6 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args), })); -vi.mock("../../hooks/useSessionLock", () => ({ - useSessionLock: () => ({ isLockedByOther: false, takeControl: vi.fn(), isLoading: false }), -})); - const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight"); describe("PlanningModeModal autosize", () => { diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.dedupeSessionsById.test.ts b/packages/dashboard/app/components/__tests__/PlanningModeModal.dedupeSessionsById.test.ts index 495513edad..4dc43fb991 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.dedupeSessionsById.test.ts +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.dedupeSessionsById.test.ts @@ -9,7 +9,6 @@ function makeSession(id: string, updatedAt: string, title = id): AiSessionSummar status: "complete", title, projectId: null, - lockedByTab: null, updatedAt, archived: false, }; diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx index 428013c8e5..2d76bb06cc 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx @@ -21,8 +21,6 @@ vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), }; }); -import { useSessionLock } from "../../hooks/useSessionLock"; -import { getSessionTabId } from "../../utils/getSessionTabId"; import type { MergeResult } from "@fusion/core"; import { mockStartPlanning, diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 4017a14b1b..659f38202b 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -11,15 +11,12 @@ import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@te import * as api from "../../api"; import { PlanningModeModal, dedupeSessionsById } from "../PlanningModeModal"; import { TaskDetailModal } from "../TaskDetailModal"; -import { useSessionLock } from "../../hooks/useSessionLock"; -import { getSessionTabId } from "../../utils/getSessionTabId"; import { PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, } from "@fusion/core"; import type { MergeResult, PlanningQuestion } from "@fusion/core"; -const mockUseAiSessionSync = vi.fn(); import { mockStartPlanning, @@ -131,10 +128,6 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args), })); -vi.mock("../../hooks/useAiSessionSync", () => ({ - useAiSessionSync: (...args: any[]) => mockUseAiSessionSync(...args), -})); - describe("PlanningModeModal", () => { const mockOnClose = vi.fn(); const mockOnTaskCreated = vi.fn(); @@ -186,14 +179,6 @@ describe("PlanningModeModal", () => { mockRewindPlanningSession.mockReset(); mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); - mockUseAiSessionSync.mockReturnValue({ - activeTabMap: new Map(), - broadcastUpdate: vi.fn(), - broadcastCompleted: vi.fn(), - broadcastLock: vi.fn(), - broadcastUnlock: vi.fn(), - broadcastHeartbeat: vi.fn(), - }); // Default: simulate receiving a question after a brief delay mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { @@ -325,7 +310,6 @@ describe("PlanningModeModal", () => { _other: "Explore rollout risk", }, undefined, - expect.any(String), ); }); @@ -361,7 +345,6 @@ describe("PlanningModeModal", () => { "session-123", { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] }, undefined, - expect.any(String), ); }); @@ -447,9 +430,16 @@ describe("PlanningModeModal", () => { }); }); - it("shows locked overlay and allows take-control", async () => { + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + Planning has no cross-tab locking. Even when another tab is using the same session, this + tab must never call the lock API, never render a lock overlay, and must remain fully + interactive — the persisted session row is the shared source of truth. + */ + it("never acquires a tab lock and stays interactive even when another tab uses the session", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); - mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); + // If any legacy lock path survived, this rejection would surface an overlay. + mockAcquireSessionLock.mockResolvedValue({ acquired: false, currentHolder: "tab-other" }); render( { fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { - expect(screen.getByTestId("session-lock-overlay")).toBeDefined(); + expect(screen.getByText("What is the scope?")).toBeDefined(); }); - await act(async () => { - fireEvent.click(screen.getByText("Take Control")); - }); + expect(screen.queryByTestId("session-lock-overlay")).toBeNull(); + expect(screen.queryByRole("button", { name: "Take Control" })).toBeNull(); + expect(mockAcquireSessionLock).not.toHaveBeenCalled(); + expect(mockForceAcquireSessionLock).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("Small")); + fireEvent.click(screen.getByText("Continue")); await waitFor(() => { - expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self"); - }); - - await waitFor(() => { - expect(screen.queryByTestId("session-lock-overlay")).toBeNull(); + expect(mockRespondToPlanning).toHaveBeenCalledWith( + "session-123", + { "q-scope": "small" }, + undefined, + ); }); }); - it("does not render duplicate inline lock text while takeover overlay handles lock state", async () => { - window.sessionStorage.setItem("fusion-tab-id", "tab-self"); - mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); - mockUseAiSessionSync.mockReturnValueOnce({ - activeTabMap: new Map([ - [ - "session-123", - { - tabId: "tab-other", - stale: false, - }, - ], - ]), - broadcastUpdate: vi.fn(), - broadcastCompleted: vi.fn(), - broadcastLock: vi.fn(), - broadcastUnlock: vi.fn(), - broadcastHeartbeat: vi.fn(), - }); - - render( - , - ); - - 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(); - }); - - expect(screen.getByText("This session is active in another tab")).toBeDefined(); - expect(screen.queryByText("Session is active in another tab.")).toBeNull(); - expect(screen.getByRole("button", { name: "Take Control" })).toBeDefined(); - }); - - it("allows normal question interaction when lock is acquired", async () => { + it("allows normal question interaction", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); render( @@ -562,7 +512,6 @@ describe("PlanningModeModal", () => { "session-123", { "q-scope": "small" }, undefined, - "tab-self", ); }, // waitFor's private 1s default (independent of vitest testTimeout) has @@ -608,7 +557,6 @@ describe("PlanningModeModal", () => { "session-123", { _other: "Make this a design spike" }, undefined, - "tab-self", ); }); }); @@ -651,7 +599,6 @@ describe("PlanningModeModal", () => { "session-123", { "q-scope": "small" }, undefined, - "tab-self", ); }); }); @@ -709,7 +656,6 @@ describe("PlanningModeModal", () => { "session-123", { _other: "Challenge the premise" }, undefined, - "tab-self", ); }); }); @@ -767,7 +713,6 @@ describe("PlanningModeModal", () => { "session-123", { "q-priorities": ["speed"], _other: "Preserve operator control" }, undefined, - "tab-self", ); }); }); @@ -833,7 +778,6 @@ describe("PlanningModeModal", () => { "session-123", { _other: "Ask a different scoping question" }, undefined, - "tab-self", ); }); }); @@ -892,7 +836,6 @@ describe("PlanningModeModal", () => { "session-123", { "q-confirm-scope": false, _comment: "Keep the planner moving" }, undefined, - "tab-self", ); }); }); @@ -930,7 +873,7 @@ describe("PlanningModeModal", () => { fireEvent.click(screen.getByRole("button", { name: "Stop" })); await waitFor(() => { - expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); + expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-123", undefined); }); expect(closeSpy).toHaveBeenCalled(); await waitFor(() => { @@ -963,10 +906,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: "Rate limit exceeded", projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); render( @@ -1034,10 +975,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: "Temporary failure", projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); render( @@ -1079,10 +1018,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: null, projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); fireEvent.click(screen.getByRole("button", { name: "Retry" })); @@ -1118,10 +1055,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: "Temporary failure", projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); render( @@ -1191,10 +1126,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: "Temporary failure", projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); try { @@ -1265,10 +1198,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: "Watchdog aborted a stalled turn", projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); try { @@ -1337,10 +1268,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "Still thinking...", error: null, projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); render( @@ -1393,10 +1322,8 @@ describe("PlanningModeModal", () => { thinkingOutput: "", error: null, projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); render( @@ -2170,7 +2097,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Completed planning session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -2181,7 +2107,6 @@ describe("PlanningModeModal", () => { title: "New planning session", preview: "Draft plan from history", projectId: null, - lockedByTab: null, updatedAt: "2026-01-01T00:00:00.000Z", archived: false, }, @@ -2285,7 +2210,7 @@ describe("PlanningModeModal", () => { ); await waitFor(() => { - expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined, expect.any(String)); + expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined); }); expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); expect(screen.queryByRole("alert")).toBeNull(); @@ -2300,7 +2225,6 @@ describe("PlanningModeModal", () => { status: "error", title: "Sidebar errored session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -2340,7 +2264,7 @@ describe("PlanningModeModal", () => { await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error"); - expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined, expect.any(String)); + expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined); }); expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); expect(screen.queryByRole("alert")).toBeNull(); @@ -2355,7 +2279,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Malformed result session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -2416,7 +2339,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Reopen recover session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -2530,7 +2452,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Sidebar deleted session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -2577,7 +2498,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Resume-to-task", projectId: null, - lockedByTab: null, updatedAt: "2026-01-01T00:00:00.000Z", archived: false, }, @@ -3282,7 +3202,6 @@ describe("PlanningModeModal", () => { "session-complete-refine", { refine: true }, undefined, - expect.any(String), ); }); @@ -3366,7 +3285,6 @@ describe("PlanningModeModal", () => { `session-complete-refine-${viewportMode}`, { refine: true }, undefined, - expect.any(String), ); await waitFor(() => { @@ -3871,7 +3789,7 @@ describe("PlanningModeModal", () => { expect(screen.getByTestId("conversation-history")).toBeDefined(); expect(screen.getByText("What is the scope?")).toBeDefined(); expect(screen.getByText("Medium")).toBeDefined(); - expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); + expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined); }); it("stays on the question form and surfaces an error when the rewind request fails", async () => { @@ -3929,7 +3847,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Duplicate session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-01T00:00:00.000Z", archived: false, }, @@ -3959,7 +3876,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Duplicate session", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }); @@ -3978,7 +3894,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Delete me", projectId: null, - lockedByTab: null, updatedAt: "2026-01-01T00:00:00.000Z", archived: false, }, @@ -4016,7 +3931,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "Still here", projectId: null, - lockedByTab: null, updatedAt: "2026-01-01T00:00:00.000Z", archived: false, }, @@ -4061,7 +3975,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "older", projectId: null, - lockedByTab: null, updatedAt: "2026-01-01T00:00:00.000Z", archived: false, }, @@ -4071,7 +3984,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "peer", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -4081,7 +3993,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "newer", projectId: null, - lockedByTab: null, updatedAt: "2026-01-03T00:00:00.000Z", archived: false, }, @@ -4091,7 +4002,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "tie-first", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -4103,7 +4013,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "newer", projectId: null, - lockedByTab: null, updatedAt: "2026-01-03T00:00:00.000Z", archived: false, }, @@ -4113,7 +4022,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "peer", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, @@ -4123,7 +4031,6 @@ describe("PlanningModeModal", () => { status: "complete", title: "tie-first", projectId: null, - lockedByTab: null, updatedAt: "2026-01-02T00:00:00.000Z", archived: false, }, diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx index b7ab1d76fa..38abe9014a 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx @@ -29,8 +29,6 @@ import userEvent from "@testing-library/user-event"; import * as api from "../../api"; import { PlanningModeModal } from "../PlanningModeModal"; import { TaskDetailModal } from "../TaskDetailModal"; -import { useSessionLock } from "../../hooks/useSessionLock"; -import { getSessionTabId } from "../../utils/getSessionTabId"; import type { MergeResult } from "@fusion/core"; import { mockStartPlanning, @@ -798,7 +796,6 @@ describe("PlanningModeModal", () => { title: "Existing session", preview: "An existing planning session", projectId: null, - lockedByTab: null, updatedAt: new Date().toISOString(), archived: false, }, diff --git a/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx b/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx index afcfa49864..2107e47e6e 100644 --- a/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx @@ -11,7 +11,6 @@ function buildSession(overrides: Partial): AiSessionSummary { status: overrides.status ?? "awaiting_input", title: overrides.title ?? "Draft implementation plan", projectId: overrides.projectId ?? "proj-1", - lockedByTab: overrides.lockedByTab ?? null, updatedAt: overrides.updatedAt ?? new Date().toISOString(), }; } @@ -368,7 +367,6 @@ function buildCliSession(overrides: Partial): AiSessionSummary status: overrides.status ?? "waiting_on_input", title: overrides.title ?? "Implement FN-1", projectId: overrides.projectId ?? "proj-1", - lockedByTab: null, updatedAt: overrides.updatedAt ?? new Date().toISOString(), cliVariant: overrides.cliVariant, cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId") ? overrides.cliSessionId : "cli-1", diff --git a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx index 3d8fe93e23..d4cfc574e9 100644 --- a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx @@ -258,25 +258,27 @@ describe("SubtaskBreakdownModal", () => { expect(mockStartSubtaskBreakdown).not.toHaveBeenCalled(); }); - it("shows lock overlay and allows take-control", async () => { - window.sessionStorage.setItem("fusion-tab-id", "tab-self"); - mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + Subtask breakdowns are multi-tab: this tab must never acquire a lock, never render a lock + overlay or "active in another tab" banner, and must stay interactive even when another tab + is using the same session. + */ + it("never acquires a tab lock and renders no lock overlay", async () => { + // A rejecting lock API would surface an overlay if any legacy lock path survived. + mockAcquireSessionLock.mockResolvedValue({ acquired: false, currentHolder: "tab-other" }); renderModal(); await waitFor(() => { - expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument(); + expect(mockStartSubtaskBreakdown).toHaveBeenCalled(); }); - 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(); - }); + expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument(); + expect(screen.queryByTestId("session-active-another-tab-banner")).not.toBeInTheDocument(); + expect(screen.queryByText("Take Control")).not.toBeInTheDocument(); + expect(mockAcquireSessionLock).not.toHaveBeenCalled(); + expect(mockForceAcquireSessionLock).not.toHaveBeenCalled(); }); it("hides send to background button in initial state", () => { @@ -815,7 +817,7 @@ describe("SubtaskBreakdownModal", () => { fireEvent.click(retryButton); await waitFor(() => { - expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); + expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined); }); expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2); }); @@ -844,10 +846,8 @@ describe("SubtaskBreakdownModal", () => { thinkingOutput: "Still generating...", error: null, projectId: null, - lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - lockedAt: null, }); renderModal(); @@ -860,7 +860,7 @@ describe("SubtaskBreakdownModal", () => { fireEvent.click(retryButton); await waitFor(() => { - expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); + expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined); expect(mockFetchAiSession).toHaveBeenCalledWith("session-123"); }); expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/utility-mobile.test.tsx b/packages/dashboard/app/components/__tests__/utility-mobile.test.tsx index 1dc9a33259..e45c1ee2a0 100644 --- a/packages/dashboard/app/components/__tests__/utility-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/utility-mobile.test.tsx @@ -74,7 +74,6 @@ describe("Utility component mobile adaptations", () => { status: "generating", title: "Refine onboarding flow", projectId: "proj-1", - lockedByTab: null, updatedAt: new Date().toISOString(), }, ]; @@ -100,7 +99,6 @@ describe("Utility component mobile adaptations", () => { status: "awaiting_input", title: "Break down API tasks", projectId: "proj-1", - lockedByTab: null, updatedAt: new Date().toISOString(), }, ]; @@ -144,7 +142,6 @@ describe("Utility component mobile adaptations", () => { status: "awaiting_input", title: "Plan milestone scope", projectId: "proj-1", - lockedByTab: null, updatedAt: new Date().toISOString(), }, ]; @@ -174,7 +171,6 @@ describe("Utility component mobile adaptations", () => { status: "error", title: "Plan slice scope", projectId: "proj-1", - lockedByTab: null, updatedAt: new Date().toISOString(), }, ]; diff --git a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx index 5918a9396c..3a31093268 100644 --- a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx +++ b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx @@ -73,7 +73,6 @@ function buildSession(overrides: Partial = {}): AiSessionSumma status: overrides.status ?? "awaiting_input", title: overrides.title ?? "Draft implementation plan", projectId: overrides.projectId ?? "proj-1", - lockedByTab: overrides.lockedByTab ?? null, updatedAt: overrides.updatedAt ?? "2026-06-25T00:00:00.000Z", ...overrides, }; diff --git a/packages/dashboard/app/hooks/__tests__/useAiSessionSync.test.ts b/packages/dashboard/app/hooks/__tests__/useAiSessionSync.test.ts index 40fd2467ca..26ef233a6a 100644 --- a/packages/dashboard/app/hooks/__tests__/useAiSessionSync.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAiSessionSync.test.ts @@ -70,7 +70,7 @@ describe("AiSessionSyncStore", () => { return store; } - it("handles session updates/completion and tab ownership messages", () => { + it("handles session updates and completion", () => { const storeA = createStore(); const storeB = createStore(); @@ -88,12 +88,6 @@ describe("AiSessionSyncStore", () => { expect(syncedUpdate?.status).toBe("awaiting_input"); expect(syncedUpdate?.needsInput).toBe(true); - storeA.broadcastLock("sess-1", "tab-a"); - expect(storeB.getSnapshot().activeTabMap.get("sess-1")?.tabId).toBe("tab-a"); - - storeA.broadcastUnlock("sess-1", "tab-a"); - expect(storeB.getSnapshot().activeTabMap.has("sess-1")).toBe(false); - storeA.broadcastCompleted({ sessionId: "sess-1", status: "complete", timestamp: 20 }); const completed = storeB.getSnapshot().sessions.get("sess-1"); @@ -142,7 +136,6 @@ describe("AiSessionSyncStore", () => { title: "Mission planning", projectId: "proj-1", timestamp: 50, - owningTabId: "tab-source", }); storeB.requestSync(); @@ -197,15 +190,18 @@ describe("AiSessionSyncStore", () => { expect(state?.needsInput).toBe(true); }); - it("broadcasts tab:inactive for owned sessions during page unload cleanup", () => { - const storeA = createStore(); - const storeB = createStore(); + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + This store carries no tab-ownership concept: sessions are multi-tab, so there is no lock to + broadcast, no heartbeat to keep alive, and nothing to release on unload. A snapshot exposes + session status only. + */ + it("exposes no tab-ownership surface", () => { + const store = createStore(); - storeA.broadcastLock("sess-5", "tab-owner"); - expect(storeB.getSnapshot().activeTabMap.get("sess-5")?.tabId).toBe("tab-owner"); - - window.dispatchEvent(new Event("beforeunload")); - - expect(storeB.getSnapshot().activeTabMap.has("sess-5")).toBe(false); + expect("broadcastLock" in store).toBe(false); + expect("broadcastUnlock" in store).toBe(false); + expect("broadcastHeartbeat" in store).toBe(false); + expect("activeTabMap" in store.getSnapshot()).toBe(false); }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useBackgroundSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useBackgroundSessions.test.ts index cdee5ade7d..31b1f6257a 100644 --- a/packages/dashboard/app/hooks/__tests__/useBackgroundSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBackgroundSessions.test.ts @@ -35,7 +35,6 @@ function makeSession(overrides: Partial & Pick { await result.current.dismissSession("planning-session"); }); - expect(mockCancelPlanning).toHaveBeenCalledWith("planning-session", undefined, expect.any(String)); + // FNXC:PlanningMultiTab 2026-07-14-00:00: planning cancellation is lock-free — no tabId argument. + expect(mockCancelPlanning).toHaveBeenCalledWith("planning-session", undefined); expect(mockDeleteAiSession).toHaveBeenCalledWith("planning-session"); }); - it("force-dismisses a planning session even when cancellation is lock-conflicted", async () => { + it("force-dismisses a planning session even when cancellation fails", async () => { mockFetchAiSessions.mockResolvedValueOnce([ makeSession({ id: "planning-locked", status: "generating", type: "planning" }), ]); - mockCancelPlanning.mockRejectedValueOnce(new Error("locked by another tab")); + mockCancelPlanning.mockRejectedValueOnce(new Error("cancel failed")); const { result } = renderHook(() => useBackgroundSessions()); @@ -602,7 +602,7 @@ describe("useBackgroundSessions", () => { await result.current.dismissSession("subtask-session"); }); - expect(mockCancelSubtaskBreakdown).toHaveBeenCalledWith("subtask-session", undefined, expect.any(String)); + expect(mockCancelSubtaskBreakdown).toHaveBeenCalledWith("subtask-session", undefined); expect(mockDeleteAiSession).toHaveBeenCalledWith("subtask-session"); }); @@ -621,7 +621,7 @@ describe("useBackgroundSessions", () => { await result.current.dismissSession("interview-session"); }); - expect(mockCancelMissionInterview).toHaveBeenCalledWith("interview-session", undefined, expect.any(String)); + expect(mockCancelMissionInterview).toHaveBeenCalledWith("interview-session", undefined); expect(mockDeleteAiSession).toHaveBeenCalledWith("interview-session"); }); diff --git a/packages/dashboard/app/hooks/useAiSessionSync.ts b/packages/dashboard/app/hooks/useAiSessionSync.ts index 1cd2947fcc..ec0b306fa4 100644 --- a/packages/dashboard/app/hooks/useAiSessionSync.ts +++ b/packages/dashboard/app/hooks/useAiSessionSync.ts @@ -1,10 +1,18 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import type { AiSessionSummary } from "../api"; +/* +FNXC:PlanningMultiTab 2026-07-14-00:00: +This store syncs AI session STATUS across tabs as a low-latency supplement to the server's +authoritative `ai_session:updated` SSE events — nothing more. It carries no notion of tab +ownership: the per-tab session lock (tab:active / tab:inactive / tab:heartbeat messages, +activeTabMap, owningTabId, and the stale-heartbeat sweep) was removed when AI interview +sessions became multi-tab. The persisted session row is the shared source of truth; any tab +may read and interact with any session. +*/ + const CHANNEL_NAME = "fusion:ai-session-sync"; const STORAGE_FALLBACK_KEY = "fusion:ai-session-sync"; -const HEARTBEAT_INTERVAL_MS = 30_000; -const HEARTBEAT_STALE_THRESHOLD_MS = 60_000; type SessionStatus = AiSessionSummary["status"]; type SessionType = AiSessionSummary["type"]; @@ -14,21 +22,12 @@ export interface SessionSyncState { status: SessionStatus; needsInput: boolean; lastEventTimestamp: number; - owningTabId: string | null; type?: SessionType; title?: string; projectId?: string | null; updatedAt?: string; } -export interface ActiveTabState { - sessionId: string; - tabId: string; - lastHeartbeatTimestamp: number; - lastLockTimestamp: number; - stale: boolean; -} - interface StorageFallbackEnvelope { id: string; message: AiSessionSyncMessage; @@ -37,7 +36,6 @@ interface StorageFallbackEnvelope { interface StoreSnapshot { tabId: string; sessions: Map; - activeTabMap: Map; } interface SessionUpdatePayload { @@ -45,7 +43,6 @@ interface SessionUpdatePayload { status: SessionStatus; needsInput?: boolean; timestamp?: number; - owningTabId?: string | null; type?: SessionType; title?: string; projectId?: string | null; @@ -58,54 +55,39 @@ interface SessionCompletedPayload { timestamp?: number; } -interface TabMessageBase { - tabId: string; - timestamp: number; - senderTabId?: string; -} - type AiSessionSyncMessage = - | ({ + | { type: "session:updated"; sessionId: string; status: SessionStatus; needsInput?: boolean; - owningTabId?: string | null; sessionType?: SessionType; title?: string; projectId?: string | null; updatedAt?: string; timestamp: number; - } & Partial) - | ({ + senderTabId?: string; + } + | { type: "session:completed"; sessionId: string; status?: Extract; timestamp: number; - } & Partial) - | ({ - type: "tab:active"; - sessionId: string; - } & TabMessageBase) - | ({ - type: "tab:inactive"; - sessionId: string; - } & TabMessageBase) - | ({ - type: "tab:heartbeat"; - } & TabMessageBase) - | ({ + senderTabId?: string; + } + | { type: "sync:request"; - } & TabMessageBase) - | ({ + tabId: string; + timestamp: number; + senderTabId?: string; + } + | { type: "sync:response"; tabId: string; sessions: SessionSyncState[]; - locks?: Array<{ sessionId: string; tabId: string; timestamp: number }>; - heartbeats?: Array<{ tabId: string; timestamp: number }>; timestamp: number; senderTabId?: string; - } & Partial); + }; function now(): number { return Date.now(); @@ -141,24 +123,17 @@ export class AiSessionSyncStore { private readonly tabId: string; private readonly listeners = new Set<() => void>(); private readonly sessionStates = new Map(); - private readonly ownershipBySession = new Map(); - private readonly heartbeatByTab = new Map(); - private readonly ownedSessions = new Map(); private snapshot: StoreSnapshot; private channel: BroadcastChannel | null = null; private usingStorageFallback = false; private cleanupStorageListener: (() => void) | null = null; - private cleanupBeforeUnload: (() => void) | null = null; - private heartbeatInterval: ReturnType | null = null; - private staleSweepInterval: ReturnType | null = null; constructor() { this.tabId = createTabId(); this.snapshot = { tabId: this.tabId, sessions: new Map(), - activeTabMap: new Map(), }; if (!this.isBrowser()) { @@ -166,9 +141,6 @@ export class AiSessionSyncStore { } this.initializeTransport(); - this.startHeartbeat(); - this.startStaleSweep(); - this.setupBeforeUnloadCleanup(); } subscribe(listener: () => void): () => void { @@ -198,7 +170,6 @@ export class AiSessionSyncStore { sessionId: payload.sessionId, status: payload.status, needsInput: payload.needsInput ?? payload.status === "awaiting_input", - owningTabId: payload.owningTabId, type: payload.type, title: payload.title, projectId: payload.projectId, @@ -212,7 +183,6 @@ export class AiSessionSyncStore { sessionId: payload.sessionId, status: payload.status, needsInput: payload.needsInput, - owningTabId: payload.owningTabId, sessionType: payload.type, title: payload.title, projectId: payload.projectId, @@ -230,15 +200,10 @@ export class AiSessionSyncStore { sessionId: payload.sessionId, status, needsInput: false, - owningTabId: null, }, timestamp, ); - this.ownershipBySession.delete(payload.sessionId); - this.ownedSessions.delete(payload.sessionId); - this.emit(); - this.publish({ type: "session:completed", sessionId: payload.sessionId, @@ -247,77 +212,22 @@ export class AiSessionSyncStore { }); } - broadcastLock(sessionId: string, tabId: string): void { - const timestamp = now(); - - this.applyTabOwnership(sessionId, tabId, timestamp); - this.ownedSessions.set(sessionId, tabId); - - this.publish({ - type: "tab:active", - tabId, - sessionId, - timestamp, - }); - } - - broadcastUnlock(sessionId: string, tabId: string): void { - const timestamp = now(); - this.releaseTabOwnership(sessionId, tabId, timestamp); - this.ownedSessions.delete(sessionId); - - this.publish({ - type: "tab:inactive", - tabId, - sessionId, - timestamp, - }); - } - - broadcastHeartbeat(tabId: string): void { - const timestamp = now(); - this.updateHeartbeat(tabId, timestamp); - - this.publish({ - type: "tab:heartbeat", - tabId, - timestamp, - }); - } - destroy(): void { this.cleanupStorageListener?.(); this.cleanupStorageListener = null; - this.cleanupBeforeUnload?.(); - this.cleanupBeforeUnload = null; - if (this.channel) { this.channel.close(); this.channel = null; } - - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } - - if (this.staleSweepInterval) { - clearInterval(this.staleSweepInterval); - this.staleSweepInterval = null; - } } reset(): void { this.sessionStates.clear(); - this.ownershipBySession.clear(); - this.heartbeatByTab.clear(); - this.ownedSessions.clear(); this.snapshot = { tabId: this.tabId, sessions: new Map(), - activeTabMap: new Map(), }; this.emit(); @@ -367,44 +277,6 @@ export class AiSessionSyncStore { }; } - private startHeartbeat(): void { - this.heartbeatInterval = setInterval(() => { - const timestamp = now(); - this.updateHeartbeat(this.tabId, timestamp); - - this.publish({ - type: "tab:heartbeat", - tabId: this.tabId, - timestamp, - }); - }, HEARTBEAT_INTERVAL_MS); - } - - private startStaleSweep(): void { - this.staleSweepInterval = setInterval(() => { - this.emit(); - }, 10_000); - } - - private setupBeforeUnloadCleanup(): void { - const handleBeforeUnload = () => { - for (const [sessionId, owningTabId] of this.ownedSessions.entries()) { - const timestamp = now(); - this.publish({ - type: "tab:inactive", - tabId: owningTabId, - sessionId, - timestamp, - }); - } - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - this.cleanupBeforeUnload = () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - }; - } - private publish(message: AiSessionSyncMessage): void { const withSender: AiSessionSyncMessage = { ...message, @@ -439,7 +311,6 @@ export class AiSessionSyncStore { sessionId: message.sessionId, status: message.status, needsInput: message.needsInput, - owningTabId: message.owningTabId, type: message.sessionType, title: message.title, projectId: message.projectId, @@ -456,27 +327,9 @@ export class AiSessionSyncStore { sessionId: message.sessionId, status: message.status ?? "complete", needsInput: false, - owningTabId: null, }, message.timestamp, ); - this.ownershipBySession.delete(message.sessionId); - this.emit(); - return; - } - - case "tab:active": { - this.applyTabOwnership(message.sessionId, message.tabId, message.timestamp); - return; - } - - case "tab:inactive": { - this.releaseTabOwnership(message.sessionId, message.tabId, message.timestamp); - return; - } - - case "tab:heartbeat": { - this.updateHeartbeat(message.tabId, message.timestamp); return; } @@ -485,31 +338,10 @@ export class AiSessionSyncStore { return; } - const sessions = [...this.sessionStates.values()].map((session) => { - const ownership = this.ownershipBySession.get(session.sessionId); - return { - ...session, - owningTabId: ownership?.tabId ?? session.owningTabId ?? null, - }; - }); - - const locks = [...this.ownershipBySession.entries()].map(([sessionId, lock]) => ({ - sessionId, - tabId: lock.tabId, - timestamp: lock.timestamp, - })); - - const heartbeats = [...this.heartbeatByTab.entries()].map(([tabId, timestamp]) => ({ - tabId, - timestamp, - })); - this.publish({ type: "sync:response", tabId: message.tabId, - sessions, - locks, - heartbeats, + sessions: [...this.sessionStates.values()], timestamp: now(), }); return; @@ -526,7 +358,6 @@ export class AiSessionSyncStore { sessionId: session.sessionId, status: session.status, needsInput: session.needsInput, - owningTabId: session.owningTabId, type: session.type, title: session.title, projectId: session.projectId, @@ -537,14 +368,6 @@ export class AiSessionSyncStore { ); } - for (const lock of message.locks ?? []) { - this.applyTabOwnership(lock.sessionId, lock.tabId, lock.timestamp, false); - } - - for (const heartbeat of message.heartbeats ?? []) { - this.updateHeartbeat(heartbeat.tabId, heartbeat.timestamp, false); - } - this.emit(); return; } @@ -559,7 +382,6 @@ export class AiSessionSyncStore { sessionId: string; status: SessionStatus; needsInput?: boolean; - owningTabId?: string | null; type?: SessionType; title?: string; projectId?: string | null; @@ -573,14 +395,11 @@ export class AiSessionSyncStore { return; } - const ownership = this.ownershipBySession.get(update.sessionId); - const nextState: SessionSyncState = { sessionId: update.sessionId, status: update.status, needsInput: update.needsInput ?? update.status === "awaiting_input", lastEventTimestamp: timestamp, - owningTabId: update.owningTabId ?? ownership?.tabId ?? existing?.owningTabId ?? null, type: update.type ?? existing?.type, title: update.title ?? existing?.title, projectId: update.projectId ?? existing?.projectId, @@ -589,110 +408,15 @@ export class AiSessionSyncStore { this.sessionStates.set(update.sessionId, nextState); - if (update.owningTabId !== undefined) { - if (update.owningTabId) { - this.applyTabOwnership(update.sessionId, update.owningTabId, timestamp, false); - } else { - this.ownershipBySession.delete(update.sessionId); - } - } - - if (shouldEmit) { - this.emit(); - } - } - - private applyTabOwnership(sessionId: string, tabId: string, timestamp: number, shouldEmit = true): void { - const existing = this.ownershipBySession.get(sessionId); - if (existing && timestamp < existing.timestamp) { - return; - } - - this.ownershipBySession.set(sessionId, { tabId, timestamp }); - this.updateHeartbeat(tabId, timestamp, false); - - const existingSession = this.sessionStates.get(sessionId); - if (existingSession && timestamp >= existingSession.lastEventTimestamp) { - this.sessionStates.set(sessionId, { - ...existingSession, - owningTabId: tabId, - lastEventTimestamp: timestamp, - }); - } - - if (shouldEmit) { - this.emit(); - } - } - - private releaseTabOwnership(sessionId: string, tabId: string, timestamp: number, shouldEmit = true): void { - const existing = this.ownershipBySession.get(sessionId); - if (!existing) { - return; - } - - if (existing.tabId !== tabId || timestamp < existing.timestamp) { - return; - } - - this.ownershipBySession.delete(sessionId); - - const existingSession = this.sessionStates.get(sessionId); - if (existingSession && timestamp >= existingSession.lastEventTimestamp) { - this.sessionStates.set(sessionId, { - ...existingSession, - owningTabId: null, - lastEventTimestamp: timestamp, - }); - } - - if (shouldEmit) { - this.emit(); - } - } - - private updateHeartbeat(tabId: string, timestamp: number, shouldEmit = true): void { - const previous = this.heartbeatByTab.get(tabId); - if (previous !== undefined && timestamp < previous) { - return; - } - - this.heartbeatByTab.set(tabId, timestamp); - if (shouldEmit) { this.emit(); } } private emit(): void { - const currentTime = now(); - const sessionsSnapshot = new Map(); - - for (const [sessionId, session] of this.sessionStates.entries()) { - const ownership = this.ownershipBySession.get(sessionId); - sessionsSnapshot.set(sessionId, { - ...session, - owningTabId: ownership?.tabId ?? session.owningTabId ?? null, - }); - } - - const activeTabMap = new Map(); - for (const [sessionId, ownership] of this.ownershipBySession.entries()) { - const heartbeat = this.heartbeatByTab.get(ownership.tabId) ?? ownership.timestamp; - const stale = currentTime - heartbeat > HEARTBEAT_STALE_THRESHOLD_MS; - activeTabMap.set(sessionId, { - sessionId, - tabId: ownership.tabId, - lastHeartbeatTimestamp: heartbeat, - lastLockTimestamp: ownership.timestamp, - stale, - }); - } - this.snapshot = { tabId: this.tabId, - sessions: sessionsSnapshot, - activeTabMap, + sessions: new Map(this.sessionStates), }; for (const listener of this.listeners) { @@ -706,12 +430,8 @@ const aiSessionSyncStore = new AiSessionSyncStore(); export function useAiSessionSync(): { tabId: string; sessions: Map; - activeTabMap: Map; broadcastUpdate: (payload: SessionUpdatePayload) => void; broadcastCompleted: (payload: SessionCompletedPayload) => void; - broadcastLock: (sessionId: string, tabId: string) => void; - broadcastUnlock: (sessionId: string, tabId: string) => void; - broadcastHeartbeat: (tabId: string) => void; requestSync: () => void; } { const snapshot = useSyncExternalStore( @@ -732,18 +452,6 @@ export function useAiSessionSync(): { aiSessionSyncStore.broadcastCompleted(payload); }, []); - const broadcastLock = useCallback((sessionId: string, tabId: string) => { - aiSessionSyncStore.broadcastLock(sessionId, tabId); - }, []); - - const broadcastUnlock = useCallback((sessionId: string, tabId: string) => { - aiSessionSyncStore.broadcastUnlock(sessionId, tabId); - }, []); - - const broadcastHeartbeat = useCallback((tabId: string) => { - aiSessionSyncStore.broadcastHeartbeat(tabId); - }, []); - const requestSync = useCallback(() => { aiSessionSyncStore.requestSync(); }, []); @@ -751,12 +459,8 @@ export function useAiSessionSync(): { return { tabId: snapshot.tabId, sessions: snapshot.sessions, - activeTabMap: snapshot.activeTabMap, broadcastUpdate, broadcastCompleted, - broadcastLock, - broadcastUnlock, - broadcastHeartbeat, requestSync, }; } diff --git a/packages/dashboard/app/hooks/useBackgroundSessions.ts b/packages/dashboard/app/hooks/useBackgroundSessions.ts index 3e848ad018..d944888f5e 100644 --- a/packages/dashboard/app/hooks/useBackgroundSessions.ts +++ b/packages/dashboard/app/hooks/useBackgroundSessions.ts @@ -8,7 +8,6 @@ import { type AiSessionSummary, } from "../api"; import { useAiSessionSync } from "./useAiSessionSync"; -import { getSessionTabId } from "../utils/getSessionTabId"; import { subscribeSse } from "../sse-bus"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; @@ -169,7 +168,6 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions status: syncState.status, title: title ?? "AI Session", projectId: syncState.projectId ?? existing?.projectId ?? projectId ?? null, - lockedByTab: syncState.owningTabId ?? existing?.lockedByTab ?? null, updatedAt: syncState.updatedAt ?? existing?.updatedAt ?? new Date(incomingTimestamp).toISOString(), }; @@ -180,7 +178,6 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions previous.title !== nextSession.title || previous.type !== nextSession.type || previous.projectId !== nextSession.projectId || - previous.lockedByTab !== nextSession.lockedByTab || previous.updatedAt !== nextSession.updatedAt; if (hasChanged) { @@ -255,7 +252,6 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions type: updated.type, title: updated.title, projectId: updated.projectId, - owningTabId: updated.lockedByTab, updatedAt: updated.updatedAt, timestamp: eventTimestamp, }); @@ -335,41 +331,31 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions // Find the session to determine its type const session = sessions.find((s) => s.id === id); const sessionType = session?.type; - const sessionTabId = getSessionTabId(); - // Cancel the session based on its type to ensure proper cleanup. - // Pass tabId for lock-aware cancellation; if locked by another tab the API - // returns 409 — we still proceed to DELETE below, which has no lock check, - // because the user explicitly chose to dismiss. The dismissal tombstone - // recorded below prevents stale sync/SSE updates from resurrecting it. + // Cancel the session based on its type to ensure proper cleanup. Cancellation + // is best-effort: a failure still proceeds to the DELETE below because the + // user explicitly chose to dismiss. The dismissal tombstone recorded below + // prevents stale sync/SSE updates from resurrecting it. let cancelFailed = false; if (sessionType === "planning") { + // FNXC:PlanningMultiTab 2026-07-14-00:00: planning routes are lock-free; no tabId needed. try { - await cancelPlanning(id, projectId, sessionTabId); - } catch (err: unknown) { + await cancelPlanning(id, projectId); + } catch { cancelFailed = true; - if (err instanceof Error && err.message.includes("locked")) { - console.warn(`[useBackgroundSessions] Forcing dismiss of planning session ${id} despite lock by another tab`); - } } } else if (sessionType === "subtask") { try { - await cancelSubtaskBreakdown(id, projectId, sessionTabId); - } catch (err: unknown) { + await cancelSubtaskBreakdown(id, projectId); + } catch { cancelFailed = true; - if (err instanceof Error && err.message.includes("locked")) { - console.warn(`[useBackgroundSessions] Forcing dismiss of subtask session ${id} despite lock by another tab`); - } } } else if (sessionType === "mission_interview") { try { - await cancelMissionInterview(id, projectId, sessionTabId); - } catch (err: unknown) { + await cancelMissionInterview(id, projectId); + } catch { cancelFailed = true; - if (err instanceof Error && err.message.includes("locked")) { - console.warn(`[useBackgroundSessions] Forcing dismiss of mission interview session ${id} despite lock by another tab`); - } } } diff --git a/packages/dashboard/app/hooks/useSessionLock.ts b/packages/dashboard/app/hooks/useSessionLock.ts deleted file mode 100644 index 0106b1400a..0000000000 --- a/packages/dashboard/app/hooks/useSessionLock.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - acquireSessionLock, - forceAcquireSessionLock, - releaseSessionLock, - type AiSessionSummary, -} from "../api"; -import { getSessionTabId } from "../utils/getSessionTabId"; -import { subscribeSse } from "../sse-bus"; -import { appendTokenQuery } from "../auth"; - -interface SessionLockState { - isLockedByOther: boolean; - currentHolder: string | null; - takeControl: () => Promise; - isLoading: boolean; -} - -export function useSessionLock(sessionId: string | null): SessionLockState { - const tabId = useMemo(() => getSessionTabId(), []); - const [isLockedByOther, setIsLockedByOther] = useState(false); - const [currentHolder, setCurrentHolder] = useState(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; - 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 = appendTokenQuery( - `/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 handleUpdated = (event: MessageEvent) => { - 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 - } - }; - - return subscribeSse("/api/events", { - events: { "ai_session:updated": handleUpdated }, - }); - }, [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, - }; -} diff --git a/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts b/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts index 245424da6e..0644210f28 100644 --- a/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts +++ b/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts @@ -15,7 +15,6 @@ function makeSession(overrides: Partial & Pick { ); }); + /* + FNXC:PlanningRetry 2026-07-14-00:00: + Same invariant as Planning Mode's answered-question fix: once an answer is accepted the + session is no longer awaiting input. currentQuestion must clear immediately (so the SSE + catch-up path cannot re-emit the answered question) and retry after a failed turn must ask + the next question instead of re-asking the answered one. + + FNXC:AgentOnboarding 2026-07-14-18:20: + Generation failure after an answer must still resolve a successful respond payload (the + answered question) instead of throwing — the route used to 400 with "Session did not produce + a question", which bounced the client out of SSE-driven retry. + */ + it("clears the answered question during generation and retries with the next-question prompt", async () => { + const promptCalls: string[] = []; + const messages: Array<{ role: string; content: string }> = []; + const answeredQuestion = { id: "goal", type: "text" as const, question: "What is the agent's goal?" }; + mockCreateFnAgent.mockResolvedValue({ + session: { + state: { messages }, + prompt: vi.fn(async (message: string) => { + promptCalls.push(message); + if (promptCalls.length === 1) { + messages.push({ + role: "assistant", + content: JSON.stringify({ + type: "question", + data: answeredQuestion, + }), + }); + return; + } + if (promptCalls.length === 2) { + throw new Error("provider exploded"); + } + messages.push({ + role: "assistant", + content: JSON.stringify({ + type: "question", + data: { id: "scope", type: "text", question: "What is in scope?" }, + }), + }); + }), + dispose: vi.fn(), + }, + }); + + const sessionId = await startAgentOnboardingSession( + "127.0.0.1", + { intent: "answered-question invariant", existingAgents: [], templates: [] }, + process.cwd(), + ); + await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)?.currentQuestion)); + + const result = await respondToAgentOnboarding(sessionId, { goal: "Keep CI green" }); + + // Successful respond contract even on generation failure (Planning Mode parity). + expect(result).toEqual({ type: "question", data: answeredQuestion }); + + const session = getAgentOnboardingSession(sessionId); + expect(session?.error).toMatch(/provider exploded/); + // Regression: the answered question used to linger here and get re-emitted/re-asked. + expect(session?.currentQuestion).toBeUndefined(); + + await retryAgentOnboardingSession(sessionId); + expect(promptCalls[2]).toContain("ask the next best onboarding question"); + expect(promptCalls[2]).not.toContain("continue from the last question"); + expect(getAgentOnboardingSession(sessionId)?.currentQuestion?.id).toBe("scope"); + }); + it("cancels session and subsequent access fails", async () => { mockCreateFnAgent.mockResolvedValueOnce( createMockAgent([ diff --git a/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts b/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts new file mode 100644 index 0000000000..24696685e9 --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts @@ -0,0 +1,305 @@ +// @vitest-environment node + +/* +FNXC:PlanningRetry 2026-07-14-00:00: +Regression tests for the reported retry/regenerate loop: after the user answered a planning +question, session.currentQuestion kept the answered question through the next generation, the +SSE stream route's catch-up path re-emitted it to every fresh connection (each FN-7946 +auto-retry opens one), and the client's question handler reset the bounded auto-retry budget — +an unbounded loop. Invariant under test: currentQuestion is only set while the session is +genuinely awaiting user input — cleared on answer accept, on retry, and never restored from +non-awaiting_input persisted rows. +*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { TaskStore } from "@fusion/core"; +import { PLANNING_DEEPEN_CHECKPOINT_ID } from "@fusion/core"; + +vi.mock("@fusion/engine", () => ({ + listCliAdapterDescriptors: () => [], + resolveMcpServersForStore: async () => ({ servers: [] }), + buildSessionSkillContextSync: () => ({ + skillSelectionContext: undefined, + resolvedSkillNames: ["fusion"], + skillSource: "role-fallback" as const, + }), + createFnAgent: vi.fn(), + createWorkflowAuthoringTools: () => [], + createChatTaskDocumentTools: () => [], +})); + +import type { AiSessionRow, AiSessionStore } from "../ai-session-store.js"; +import { + __resetPlanningState, + __setCreateFnAgent, + createSessionWithAgent, + getSession, + planningStreamManager, + retrySession, + setAiSessionStore, + submitResponse, +} from "../planning.js"; + +const MOCK_TASK_STORE = { + listTasks: vi.fn(async () => []), + getTask: vi.fn(async () => { + throw new Error("not found"); + }), +} as unknown as TaskStore; + +const Q1 = { id: "q1", type: "text", question: "What should the plan prioritize first?" } as const; +const Q2 = { id: "q2", type: "text", question: "Any constraints to respect?" } as const; + +function questionPayload(question: { id: string; type: string; question: string }): string { + return JSON.stringify({ type: "question", data: question }); +} + +const COMPLETE_PAYLOAD = JSON.stringify({ + type: "complete", + data: { + title: "Plan title", + description: "Plan description", + suggestedSize: "M", + keyDeliverables: ["deliverable"], + }, +}); + +type TurnBehavior = + | { kind: "respond"; payload: string } + | { kind: "reject"; error: Error } + | { kind: "hang" }; + +/* +FNXC:PlanningRetry 2026-07-14-18:20: +Repo test policy forbids real polling loops (5ms timers) in unit tests. setupAgent exposes +deterministic promises for "first question emitted" and "hung turn entered" so callers await +agent seams instead of wall-clock polls. +*/ +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** + * Fake agent whose Nth prompt call follows the Nth behavior. Hung turns are + * released via the returned controls so tests can assert mid-generation state. + */ +function setupAgent(behaviors: TurnBehavior[]) { + const messages: Array<{ role: string; content: string }> = []; + const promptCalls: string[] = []; + let releaseHungTurn: ((payload: string) => void) | undefined; + let firstQuestionEmitted = deferred(); + let firstQuestionSignaled = false; + let hungTurnEntered = deferred(); + + __setCreateFnAgent(vi.fn(async () => ({ + session: { + state: { messages }, + prompt: vi.fn(async (message: string) => { + const behavior = behaviors[promptCalls.length] ?? behaviors[behaviors.length - 1]; + promptCalls.push(message); + if (behavior.kind === "reject") { + throw behavior.error; + } + if (behavior.kind === "hang") { + // Capture/resolve the "entered" signal before parking so callers that + // grabbed hungTurnEntered before submit never race a replaced promise. + const entered = hungTurnEntered; + hungTurnEntered = deferred(); + const payload = await new Promise((resolve) => { + releaseHungTurn = resolve; + entered.resolve(); + }); + messages.push({ role: "assistant", content: payload }); + return; + } + messages.push({ role: "assistant", content: behavior.payload }); + if (!firstQuestionSignaled) { + firstQuestionSignaled = true; + // Resolve after continueAgentConversation finishes its post-prompt sync + // work (parse + set currentQuestion) on the next microtask turn. + queueMicrotask(() => { + queueMicrotask(() => firstQuestionEmitted.resolve()); + }); + } + }), + dispose: vi.fn(), + }, + })) as never); + + return { + promptCalls, + /** Resolves once the first respond turn has produced a session.currentQuestion. */ + firstQuestionEmitted: firstQuestionEmitted.promise, + /** + * Promise for the next hung turn entering. Capture before triggering the + * hang-producing call (submit/retry), then await. + */ + get hungTurnEntered() { + return hungTurnEntered.promise; + }, + releaseHungTurn: (payload: string) => { + if (!releaseHungTurn) throw new Error("no hung turn to release"); + releaseHungTurn(payload); + releaseHungTurn = undefined; + }, + }; +} + +async function startSessionAtFirstQuestion( + agent: ReturnType, +): Promise { + const sessionId = await createSessionWithAgent( + "10.0.2.20", + "Plan a feature", + "/tmp/project", + MOCK_TASK_STORE, + ); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await agent.firstQuestionEmitted; + const session = await getSession(sessionId); + if (!session?.currentQuestion) { + throw new Error("first question never arrived"); + } + return sessionId; +} + +describe("answered planning questions are never re-emittable", () => { + beforeEach(() => { + __resetPlanningState(); + }); + + it("clears currentQuestion the moment an answer is accepted, for the whole next generation", async () => { + const agent = setupAgent([ + { kind: "respond", payload: questionPayload(Q1) }, + { kind: "hang" }, + ]); + const sessionId = await startSessionAtFirstQuestion(agent); + + const hungEntered = agent.hungTurnEntered; + const submitPromise = submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE); + submitPromise.catch(() => {}); + await hungEntered; + + // The regression: this used to still be Q1 while generating, and the SSE + // stream route's catch-up emit hands currentQuestion to fresh connections. + const midGeneration = await getSession(sessionId); + expect(midGeneration?.currentQuestion).toBeUndefined(); + expect(midGeneration?.history).toHaveLength(1); + + agent.releaseHungTurn(questionPayload(Q2)); + const result = await submitPromise; + expect(result).toEqual({ type: "question", data: Q2 }); + expect((await getSession(sessionId))?.currentQuestion).toEqual(Q2); + }); + + it("keeps currentQuestion cleared when generation fails, while preserving the legacy 200 respond contract", async () => { + const agent = setupAgent([ + { kind: "respond", payload: questionPayload(Q1) }, + { kind: "reject", error: new Error("provider exploded") }, + ]); + const sessionId = await startSessionAtFirstQuestion(agent); + + const result = await submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE); + + // The modal ignores this body and lets the SSE error event drive recovery; + // it must stay a resolved response, not a thrown InvalidSessionStateError. + expect(result).toEqual({ type: "question", data: Q1 }); + + const session = await getSession(sessionId); + expect(session?.error).toMatch(/provider exploded/); + expect(session?.currentQuestion).toBeUndefined(); + }); + + it("retrySession scrubs a stale answered question before regenerating (pre-fix persisted rows)", async () => { + const agent = setupAgent([ + { kind: "respond", payload: questionPayload(Q1) }, + { kind: "reject", error: new Error("provider exploded") }, + { kind: "hang" }, + ]); + const sessionId = await startSessionAtFirstQuestion(agent); + await submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE); + + // Simulate a row persisted by a pre-fix build where the answered question lingered. + const session = await getSession(sessionId); + session!.currentQuestion = Q1; + + const hungEntered = agent.hungTurnEntered; + const retryPromise = retrySession(sessionId, "/tmp/project", undefined, MOCK_TASK_STORE); + retryPromise.catch(() => {}); + await hungEntered; + + expect((await getSession(sessionId))?.currentQuestion).toBeUndefined(); + + agent.releaseHungTurn(questionPayload(Q2)); + await retryPromise; + expect((await getSession(sessionId))?.currentQuestion).toEqual(Q2); + }); + + it("clears the deepening checkpoint question while a deepening turn generates", async () => { + const agent = setupAgent([ + { kind: "respond", payload: questionPayload(Q1) }, + { kind: "respond", payload: COMPLETE_PAYLOAD }, + { kind: "hang" }, + ]); + const sessionId = await startSessionAtFirstQuestion(agent); + + const checkpointResult = await submitResponse(sessionId, { q1: "ship auth first" }, "/tmp/project", undefined, MOCK_TASK_STORE); + expect(checkpointResult.type).toBe("question"); + expect((checkpointResult as { data: { id: string } }).data.id).toBe(PLANNING_DEEPEN_CHECKPOINT_ID); + + const hungEntered = agent.hungTurnEntered; + const submitPromise = submitResponse( + sessionId, + { [PLANNING_DEEPEN_CHECKPOINT_ID]: [], _other: "explore security hardening" }, + "/tmp/project", + undefined, + MOCK_TASK_STORE, + ); + submitPromise.catch(() => {}); + await hungEntered; + + expect((await getSession(sessionId))?.currentQuestion).toBeUndefined(); + + agent.releaseHungTurn(questionPayload(Q2)); + const result = await submitPromise; + expect(result).toEqual({ type: "question", data: Q2 }); + }); + + it("does not restore currentQuestion from persisted rows that are not awaiting input", async () => { + const baseRow: Omit = { + type: "planning", + title: "Restored session", + inputPayload: JSON.stringify({ ip: "10.0.2.20", initialPlan: "Plan a feature" }), + conversationHistory: JSON.stringify([{ question: Q1, response: { q1: "answered" }, thinkingOutput: "" }]), + currentQuestion: JSON.stringify(Q1), + result: null, + thinkingOutput: "", + error: "AI generation appears stuck with no new output.", + projectId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const rows = new Map([ + ["restored-error", { ...baseRow, id: "restored-error", status: "error" }], + ["restored-generating", { ...baseRow, id: "restored-generating", status: "generating", error: null }], + ["restored-awaiting", { ...baseRow, id: "restored-awaiting", status: "awaiting_input", error: null }], + ]); + + const fakeStore = Object.assign(new EventEmitter(), { + get: vi.fn(async (id: string) => rows.get(id) ?? null), + upsert: vi.fn(async () => {}), + }) as unknown as AiSessionStore; + setAiSessionStore(fakeStore); + + expect((await getSession("restored-error"))?.currentQuestion).toBeUndefined(); + expect((await getSession("restored-generating"))?.currentQuestion).toBeUndefined(); + expect((await getSession("restored-awaiting"))?.currentQuestion).toEqual(Q1); + }); +}); diff --git a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts index ed9474f74a..34a8d9c921 100644 --- a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts @@ -73,7 +73,7 @@ describe("planning generation cancellation", () => { expect(promptSignal?.aborted).toBe(true); expect(dispose).toHaveBeenCalledTimes(1); - expect(getSession(sessionId)?.error).toMatch(/stopped by user/i); + expect((await getSession(sessionId))?.error).toMatch(/stopped by user/i); resolveHungPrompt?.(); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts index 44b2fcbdb4..0f59a677a9 100644 --- a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts @@ -159,7 +159,6 @@ describe("planning routes github tracking background dispatch", () => { } as never, { store, - checkSessionLock: () => ({ allowed: true }), parseLastEventId: () => undefined, replayBufferedSSE: () => true, }, diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index c839680464..693a26f6e7 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -2253,7 +2253,6 @@ describe("Planning Mode Routes", () => { status: storedSession.status, title: storedSession.title, projectId: storedSession.projectId, - lockedByTab: null, updatedAt: storedSession.updatedAt, archived: false, }, @@ -3636,8 +3635,12 @@ describe("Saturated-slot regression: utility AI routes", () => { expect(res.body.type).toBe("question"); }); - it("preserves lock-conflict 409 semantics when task-lane is saturated", async () => { - // Create mock aiSessionStore that returns conflict on acquire + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + Planning routes are lock-free: a lock held by another tab must never block a respond. + Multiple tabs read and interact with the same DB-backed session. + */ + it("ignores tab locks — respond succeeds even when another tab holds the session lock", async () => { const mockAiSessionStore = { acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "tab-a" }), releaseLock: vi.fn(), @@ -3656,8 +3659,8 @@ describe("Saturated-slot regression: utility AI routes", () => { expect(startRes.status).toBe(201); const sessionId = startRes.body.sessionId; - // Respond with conflicting tabId - mock returns conflict - const conflictRes = await REQUEST( + // A stale tabId from an old client must be ignored, not 409'd. + const res = await REQUEST( app, "POST", "/api/planning/respond", @@ -3665,11 +3668,9 @@ describe("Saturated-slot regression: utility AI routes", () => { { "Content-Type": "application/json" }, ); - expect(conflictRes.status).toBe(409); - expect(conflictRes.body).toEqual({ - error: "Session locked by another tab", - lockedByTab: "tab-a", - }); + expect(res.status).toBe(200); + expect(res.body.type).toBe("question"); + expect(mockAiSessionStore.acquireLock).not.toHaveBeenCalled(); }); }); @@ -3686,8 +3687,9 @@ describe("Saturated-slot regression: utility AI routes", () => { expect(retrySpy).toHaveBeenCalled(); }); - it("preserves lock-conflict 409 semantics when task-lane is saturated", async () => { - // Create mock that returns conflict + // FNXC:PlanningMultiTab 2026-07-14-00:00: planning retry is lock-free; another tab's lock never 409s. + it("ignores tab locks — retry succeeds even when another tab holds the session lock", async () => { + const retrySpy = vi.spyOn(planningModule, "retrySession").mockResolvedValue(); const mockAiSessionStore = { acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "tab-x" }), releaseLock: vi.fn(), @@ -3695,7 +3697,7 @@ describe("Saturated-slot regression: utility AI routes", () => { const { app } = buildSaturatedApp({ aiSessionStore: mockAiSessionStore }); - const conflictRes = await REQUEST( + const res = await REQUEST( app, "POST", "/api/planning/session-locked-retry/retry", @@ -3703,11 +3705,10 @@ describe("Saturated-slot regression: utility AI routes", () => { { "Content-Type": "application/json" }, ); - expect(conflictRes.status).toBe(409); - expect(conflictRes.body).toEqual({ - error: "Session locked by another tab", - lockedByTab: "tab-x", - }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, sessionId: "session-locked-retry" }); + expect(retrySpy).toHaveBeenCalled(); + expect(mockAiSessionStore.acquireLock).not.toHaveBeenCalled(); }); }); @@ -3750,8 +3751,9 @@ describe("Saturated-slot regression: utility AI routes", () => { expect(retrySpy).toHaveBeenCalled(); }); - it("preserves lock-conflict 409 semantics when task-lane is saturated", async () => { - // Create mock that returns conflict + // FNXC:PlanningMultiTab 2026-07-14-00:00: subtask retry is lock-free; another tab's lock never 409s. + it("ignores tab locks — retry succeeds even when another tab holds the session lock", async () => { + const retrySpy = vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockResolvedValue(); const mockAiSessionStore = { acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "tab-locked" }), releaseLock: vi.fn(), @@ -3759,7 +3761,7 @@ describe("Saturated-slot regression: utility AI routes", () => { const { app } = buildSaturatedApp({ aiSessionStore: mockAiSessionStore }); - const conflictRes = await REQUEST( + const res = await REQUEST( app, "POST", "/api/subtasks/subtask-locked-retry/retry", @@ -3767,11 +3769,9 @@ describe("Saturated-slot regression: utility AI routes", () => { { "Content-Type": "application/json" }, ); - expect(conflictRes.status).toBe(409); - expect(conflictRes.body).toEqual({ - error: "Session locked by another tab", - lockedByTab: "tab-locked", - }); + expect(res.status).toBe(200); + expect(retrySpy).toHaveBeenCalled(); + expect(mockAiSessionStore.acquireLock).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/dashboard/src/agent-onboarding.ts b/packages/dashboard/src/agent-onboarding.ts index 200c087767..2fb36c8631 100644 --- a/packages/dashboard/src/agent-onboarding.ts +++ b/packages/dashboard/src/agent-onboarding.ts @@ -409,13 +409,54 @@ async function continueConversation(session: Session, message: string): Promise< } } -export async function respondToAgentOnboarding(sessionId: string, responses: Record): Promise { +/* +FNXC:AgentOnboarding 2026-07-14-18:20: +When generation fails after an answer is accepted, summary and currentQuestion are both empty. +The respond route used to return HTTP 400 "Session did not produce a question", which bounced +the client back to the already-answered view. Mirror Planning Mode's submitResponse contract: +return a successful { type:"question"|"complete" } payload (including the just-answered question +on generation failure) so SSE-driven retry remains the recovery path instead of a 400. +*/ +export type AgentOnboardingRespondResult = + | { type: "question"; data: PlanningQuestion } + | { type: "complete"; data: AgentOnboardingSummary }; + +export async function respondToAgentOnboarding( + sessionId: string, + responses: Record, +): Promise { const session = sessions.get(sessionId); if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`); if (!session.currentQuestion) throw new InvalidSessionStateError("No active question in session"); - session.history.push({ question: session.currentQuestion, response: responses }); - const formatted = `Question: ${session.currentQuestion.question}\nAnswer: ${JSON.stringify(responses)}`; + const answeredQuestion = session.currentQuestion; + session.history.push({ question: answeredQuestion, response: responses }); + /* + FNXC:PlanningRetry 2026-07-14-00:00: + Same invariant as Planning Mode: once an answer is accepted the session is no longer awaiting + input, so clear currentQuestion before generating. This stops the onboarding SSE catch-up from + re-emitting the answered question to fresh connections and stops retry from prompting the agent + to "continue from" a question the user already answered. + + FNXC:AgentOnboarding 2026-07-14-18:20: + answeredQuestion is retained for the generation-error 200 body below; currentQuestion stays + cleared so SSE catch-up never re-emits the answered question while the modal retries via SSE. + */ + session.currentQuestion = undefined; + const formatted = `Question: ${answeredQuestion.question}\nAnswer: ${JSON.stringify(responses)}`; await continueConversation(session, formatted); + + if (session.summary) { + return { type: "complete", data: session.summary }; + } + if (session.currentQuestion) { + return { type: "question", data: session.currentQuestion }; + } + // Generation failed after the answer was accepted (session.error set + broadcast via SSE). + // Preserve the successful respond contract like Planning Mode; do not throw 400. + if (session.error && answeredQuestion) { + return { type: "question", data: answeredQuestion }; + } + throw new InvalidSessionStateError("AI agent did not return a question or summary"); } export async function retryAgentOnboardingSession(sessionId: string, store?: TaskStore): Promise { diff --git a/packages/dashboard/src/ai-session-store.ts b/packages/dashboard/src/ai-session-store.ts index 17a518bf27..abf5878ed0 100644 --- a/packages/dashboard/src/ai-session-store.ts +++ b/packages/dashboard/src/ai-session-store.ts @@ -26,11 +26,6 @@ import { updateThinkingAsync, archiveAiSession, unarchiveAiSession, - acquireAiSessionLock, - releaseAiSessionLock, - forceAcquireAiSessionLock, - getAiSessionLockHolder, - releaseStaleAiSessionLocks, deleteAiSession, deleteAiSessionByIdAndType, recoverStaleAiSessions, @@ -58,8 +53,6 @@ export interface AiSessionRow { projectId: string | null; createdAt: string; updatedAt: string; - lockedByTab: string | null; - lockedAt: string | null; /** 1 if archived (hidden from planning sidebar), 0 otherwise. */ archived?: number; } @@ -79,7 +72,6 @@ export interface AiSessionSummary { */ preview?: string; projectId: string | null; - lockedByTab: string | null; updatedAt: string; archived?: boolean; } @@ -212,8 +204,8 @@ export class AiSessionStore extends EventEmitter { this.db .prepare( - `INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + `INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, @@ -563,7 +555,6 @@ export class AiSessionStore extends EventEmitter { status: row.status as AiSessionStatus, title: row.title as string, projectId: (row.projectId as string | null) ?? null, - lockedByTab: (row.lockedByTab as string | null) ?? null, updatedAt: row.updatedAt as string, archived: Number(row.archived ?? 0) === 1, })); @@ -571,7 +562,7 @@ export class AiSessionStore extends EventEmitter { if (projectId) { return this.db .prepare( - `SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + `SELECT id, type, status, title, projectId, updatedAt, archived FROM ai_sessions WHERE status IN ('generating', 'awaiting_input', 'error') AND COALESCE(archived, 0) = 0 AND projectId = ? @@ -581,7 +572,7 @@ export class AiSessionStore extends EventEmitter { } return this.db .prepare( - `SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + `SELECT id, type, status, title, projectId, updatedAt, archived FROM ai_sessions WHERE status IN ('generating', 'awaiting_input', 'error') AND COALESCE(archived, 0) = 0 ORDER BY updatedAt DESC`, @@ -613,7 +604,7 @@ export class AiSessionStore extends EventEmitter { : "WHERE projectId = ? AND COALESCE(archived, 0) = 0"; const rows = this.db .prepare( - `SELECT id, type, status, title, inputPayload, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + `SELECT id, type, status, title, inputPayload, projectId, updatedAt, archived FROM ai_sessions ${where} ORDER BY updatedAt DESC`, ) @@ -622,7 +613,7 @@ export class AiSessionStore extends EventEmitter { } const rows = this.db .prepare( - `SELECT id, type, status, title, inputPayload, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + `SELECT id, type, status, title, inputPayload, projectId, updatedAt, archived FROM ai_sessions ${archivedClause} ORDER BY updatedAt DESC`, ) @@ -716,150 +707,13 @@ export class AiSessionStore extends EventEmitter { .all() as unknown as AiSessionRow[]; } - async acquireLock(sessionId: string, tabId: string): Promise<{ acquired: boolean; currentHolder: string | null }> { - if (this.backendMode) { - const result = await acquireAiSessionLock(this.dbAsync, sessionId, tabId); - if (result.acquired) { - const row = await this.get(sessionId); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return result; - } - 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 = await 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, - }; - } - - async releaseLock(sessionId: string, tabId: string): Promise { - if (this.backendMode) { - const released = await releaseAiSessionLock(this.dbAsync, sessionId, tabId); - if (released) { - const row = await this.get(sessionId); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return released; - } - 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 = await this.get(sessionId); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - - return true; - } - - async forceAcquireLock(sessionId: string, tabId: string): Promise { - if (this.backendMode) { - const changed = await forceAcquireAiSessionLock(this.dbAsync, sessionId, tabId); - if (changed) { - const row = await this.get(sessionId); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return; - } - 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 = await this.get(sessionId); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - } - - async getLockHolder(sessionId: string): Promise<{ tabId: string | null; lockedAt: string | null }> { - if (this.backendMode) { - return getAiSessionLockHolder(this.dbAsync, sessionId); - } - 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, - }; - } - - async releaseStaleLocks(maxAgeMs = 30 * 60 * 1000): Promise { - if (this.backendMode) { - return releaseStaleAiSessionLocks(this.dbAsync, maxAgeMs); - } - 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 = await this.get(rowInfo.id); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - } - - return Number(result.changes ?? 0); - } + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + acquireLock / releaseLock / forceAcquireLock / getLockHolder / releaseStaleLocks were removed + here with the rest of the per-tab session lock. AI interview sessions are multi-tab: this + persisted row is the shared source of truth and every tab may read and interact. See the dead + `lockedByTab`/`lockedAt` columns in core's project schema for why they still exist in the DB. + */ /** * Delete a session by ID. Emits `ai_session:deleted`. @@ -1179,12 +1033,9 @@ function toSidebarSummaryAsync(row: Record): AiSessionSummary { projectId: (row.projectId as string | null) ?? null, createdAt: "", updatedAt: row.updatedAt as string, - lockedByTab: (row.lockedByTab as string | null) ?? null, - lockedAt: null, archived: typeof row.archived === "number" ? row.archived : 0, }), projectId: (row.projectId as string | null) ?? null, - lockedByTab: (row.lockedByTab as string | null) ?? null, updatedAt: row.updatedAt as string, archived: Number(row.archived ?? 0) === 1, }; @@ -1198,7 +1049,6 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary { title: session.title, preview: extractDraftPreview(session), projectId: session.projectId, - lockedByTab: session.lockedByTab ?? null, updatedAt, archived: Number(session.archived ?? 0) === 1, }; @@ -1227,8 +1077,6 @@ function toSidebarSummary( projectId: row.projectId ?? null, createdAt: "", updatedAt: row.updatedAt, - lockedByTab: row.lockedByTab ?? null, - lockedAt: row.lockedAt ?? null, archived: row.archived, }; return { @@ -1238,7 +1086,6 @@ function toSidebarSummary( title: row.title, preview: extractDraftPreview(previewSource), projectId: row.projectId ?? null, - lockedByTab: row.lockedByTab ?? null, updatedAt: row.updatedAt, archived: Number(row.archived ?? 0) === 1, }; diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index cfeaded78f..292dd7f1d3 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -433,8 +433,6 @@ function persistSession(session: TargetInterviewSession, status: "generating" | projectId: null, createdAt: session.createdAt.toISOString(), updatedAt: new Date().toISOString(), - lockedByTab: null, - lockedAt: null, }; _aiSessionStore.upsert(row).catch(() => { /* best-effort persistence */ }); } diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index 6485bfb56f..55b7f788d8 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -373,8 +373,6 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera projectId: session.projectId, createdAt: session.createdAt.toISOString(), updatedAt: new Date().toISOString(), - lockedByTab: null, - lockedAt: null, }; _aiSessionStore.upsert(row).catch(() => { /* best-effort persistence */ }); } diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index f62a379a9c..08322fcb49 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -255,23 +255,6 @@ function replayBufferedSSE( return true; } -async function checkSessionLock( - sessionId: string, - tabId: string | undefined, - store: AiSessionStore | undefined, -): Promise<{ allowed: true } | { allowed: false; currentHolder: string | null }> { - if (!tabId || !store) { - return { allowed: true }; - } - - const result = await store.acquireLock(sessionId, tabId); - if (result.acquired) { - return { allowed: true }; - } - - return { allowed: false, currentHolder: result.currentHolder }; -} - export function createMissionRouter( store: TaskStore, missionAutopilot?: { @@ -500,7 +483,7 @@ export function createMissionRouter( // // UTILITY PATH: All interview routes (mission, milestone, slice) are on a separate // control-plane lane. They must NOT be gated on task-lane saturation (maxConcurrent, - // semaphore, queue depth). Session-lock 409 with { error, lockedByTab } is preserved. + // semaphore, queue depth). Lock-free: any tab may interact (see FNXC:PlanningMultiTab). /** * Helper to resolve scoped store for the current request's project scope. @@ -598,12 +581,12 @@ export function createMissionRouter( * Body: { sessionId: string, responses: Record } * * UTILITY PATH: Independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free: any tab may interact (see FNXC:PlanningMultiTab). */ router.post( "/interview/respond", catchTypedHandler(async (req, res) => { - const { sessionId, responses, tabId } = req.body; + const { sessionId, responses } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); @@ -613,16 +596,6 @@ 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 = await checkSessionLock(sessionId, normalizedTabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { store: scopedStore } = await getProjectContext(req); const rootDir = scopedStore.getRootDir(); @@ -659,7 +632,7 @@ export function createMissionRouter( * Retry a failed interview session by replaying the last user interaction. * * UTILITY PATH: Independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free: any tab may interact (see FNXC:PlanningMultiTab). */ router.post( "/interview/:sessionId/retry", @@ -670,18 +643,6 @@ 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { store: scopedStore } = await getProjectContext(req); const rootDir = scopedStore.getRootDir(); @@ -715,22 +676,12 @@ export function createMissionRouter( router.post( "/interview/cancel", catchTypedHandler(async (req, res) => { - const { sessionId, tabId } = req.body; + const { sessionId } = 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 = await checkSessionLock(sessionId, normalizedTabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { cancelMissionInterviewSession } = await import("./mission-interview.js"); @@ -765,9 +716,6 @@ export function createMissionRouter( "/interview/drafts/:sessionId/discard", catchTypedHandler(async (req, res) => { const { sessionId } = req.params; - const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0 - ? req.body.tabId.trim() - : undefined; // FNXC:CentralProjectIdentity 2026-07-14-00:15: // Discard against the SAME resolved id writes stamped (request id → launch id), // matching GET /interview/drafts, so a launch-dir discard finds the session. @@ -777,15 +725,6 @@ export function createMissionRouter( throw badRequest("sessionId is required"); } - const lockCheck = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { discardMissionInterviewSession } = await import("./mission-interview.js"); const result = await discardMissionInterviewSession(sessionId, projectId); if (!result.removed) { @@ -3355,15 +3294,15 @@ export function createMissionRouter( /** * POST /milestones/:milestoneId/interview/respond * Submit response to milestone interview question. - * Body: { sessionId: string, responses: Record, tabId?: string } + * Body: { sessionId: string, responses: Record } * * UTILITY PATH: Independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free: any tab may interact (see FNXC:PlanningMultiTab). */ router.post( "/milestones/:milestoneId/interview/respond", catchTypedHandler(async (req, res) => { - const { sessionId, responses, tabId } = req.body; + const { sessionId, responses } = req.body; if (!validateMilestoneId(req.params.milestoneId)) { throw badRequest("Invalid milestone ID format"); @@ -3377,16 +3316,6 @@ 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 = await checkSessionLock(sessionId, normalizedTabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js"); @@ -3526,7 +3455,7 @@ export function createMissionRouter( * Retry a failed milestone interview session. * * UTILITY PATH: Independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free: any tab may interact (see FNXC:PlanningMultiTab). */ router.post( "/milestones/:milestoneId/interview/:sessionId/retry", @@ -3541,18 +3470,6 @@ 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js"); @@ -3708,15 +3625,15 @@ export function createMissionRouter( /** * POST /slices/:sliceId/interview/respond * Submit response to slice interview question. - * Body: { sessionId: string, responses: Record, tabId?: string } + * Body: { sessionId: string, responses: Record } * * UTILITY PATH: Independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free: any tab may interact (see FNXC:PlanningMultiTab). */ router.post( "/slices/:sliceId/interview/respond", catchTypedHandler(async (req, res) => { - const { sessionId, responses, tabId } = req.body; + const { sessionId, responses } = req.body; if (!validateSliceId(req.params.sliceId)) { throw badRequest("Invalid slice ID format"); @@ -3730,16 +3647,6 @@ 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 = await checkSessionLock(sessionId, normalizedTabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js"); @@ -3879,7 +3786,7 @@ export function createMissionRouter( * Retry a failed slice interview session. * * UTILITY PATH: Independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free: any tab may interact (see FNXC:PlanningMultiTab). */ router.post( "/slices/:sliceId/interview/:sessionId/retry", @@ -3894,18 +3801,6 @@ 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - try { const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js"); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index a5701d0c68..62be9987cc 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -788,8 +788,6 @@ function persistSession(session: Session, status: "generating" | "awaiting_input projectId: session.projectId ?? null, createdAt: session.createdAt.toISOString(), updatedAt: new Date().toISOString(), - lockedByTab: null, - lockedAt: null, }; _aiSessionStore.upsert(row).catch(() => { /* best-effort persistence */ }); } @@ -827,7 +825,13 @@ function buildSessionFromRow(row: AiSessionRow): Session { throw new Error("Invalid session timestamps"); } - const currentQuestion = row.currentQuestion + /* + FNXC:PlanningRetry 2026-07-14-00:00: + Only an awaiting_input row has a live question. Rows persisted while generating/error by + pre-fix builds still carry the already-answered question; restoring it would let the SSE + catch-up path re-emit it and re-trigger the answered-question retry loop after a restart. + */ + const currentQuestion = row.status === "awaiting_input" && row.currentQuestion ? (safeParseJson(row.currentQuestion, null, { throwOnError: true, fieldName: "currentQuestion", @@ -2567,15 +2571,18 @@ function formatRefineRequestForAgent(summary: PlanningSummary): string { ].join("\n\n"); } +/* +FNXC:PlanningRetry 2026-07-14-00:00: +currentQuestion is cleared once an answer is accepted, so a duplicate re-submit during the +in-flight generation is detected against the last history entry (the turn being generated) +instead of the now-cleared currentQuestion. +*/ function didSubmitSameAnswer( session: Session, responses: Record, ): boolean { - if (!session.currentQuestion) { - return false; - } const lastEntry = session.history[session.history.length - 1]; - if (!lastEntry || lastEntry.question.id !== session.currentQuestion.id) { + if (!lastEntry) { return false; } return JSON.stringify(lastEntry.response) === JSON.stringify(responses); @@ -2606,6 +2613,21 @@ export async function submitResponse( throw new GenerationInProgressError("Generation already in progress"); } + /* + FNXC:PlanningRetry 2026-07-14-00:00: + Reported bug: planning got stuck cycling retry/regeneration after the user had already answered. + Root cause: session.currentQuestion kept the just-answered question all through the next + generation, and the SSE stream route's catch-up path re-emits currentQuestion to every fresh + connection. Each FN-7946 auto-retry opens a fresh SSE connection, so the client was handed the + already-answered question again, which reset the bounded auto-retry budget and re-showed a + stale question — an unbounded retry/regenerate loop. Invariant: currentQuestion is only set + while the session is genuinely awaiting user input; it is cleared the moment an answer is + accepted (below), on retry (retrySession), and never restored from non-awaiting_input rows + (buildSessionFromRow). answeredQuestion preserves the legacy 200 response body for the + generation-error case so the modal's submit path keeps its existing SSE-driven recovery. + */ + let answeredQuestion: PlanningQuestion | undefined; + if (!session.currentQuestion) { if (!isRefineRequest(responses) || !session.summary) { throw new InvalidSessionStateError("No active question in session"); @@ -2632,6 +2654,7 @@ export async function submitResponse( Persist the user's answered planning turn before the agent generates the next question or errors. AiSessionStore snapshots happen inside continueAgentConversation, so history must already include the submitted answer for retry replay and SQLite round-trip tests to observe durable state. */ session.history.push(historyEntry); + answeredQuestion = currentQuestion; if (isDeepeningCheckpointQuestion(currentQuestion)) { const pendingSummary = session.pendingSummary; @@ -2648,6 +2671,8 @@ export async function submitResponse( throw new InvalidSessionStateError("Select a topic to explore or proceed to the final plan"); } session.pendingSummary = undefined; + // FNXC:PlanningRetry 2026-07-14-00:00: answer accepted — the checkpoint is no longer awaiting input. + session.currentQuestion = undefined; persistSession(session, "generating"); if (!session.agent) { await ensureSessionAgent(session, rootDir, session.history.slice(0, -1), promptOverrides, store); @@ -2655,6 +2680,8 @@ export async function submitResponse( await continueAgentConversation(session, formatDeepeningRequestForAgent(decision, pendingSummary)); } } else { + // FNXC:PlanningRetry 2026-07-14-00:00: answer accepted — clear before generating so SSE catch-up cannot re-emit the answered question. + session.currentQuestion = undefined; persistSession(session, "generating"); if (!session.agent) { @@ -2674,6 +2701,18 @@ export async function submitResponse( return { type: "question", data: session.currentQuestion }; } + /* + FNXC:PlanningRetry 2026-07-14-00:00: + Generation failed after the answer was accepted (session.error was set and broadcast via SSE). + Historically this path returned the answered question with a 200 because currentQuestion was + never cleared; the modal ignores the body and lets the SSE error drive auto-retry. Preserve + that contract explicitly instead of throwing a 400 that would bounce the client back to the + already-answered question view. + */ + if (session.error && answeredQuestion) { + return { type: "question", data: answeredQuestion }; + } + // Should not reach here, but handle gracefully throw new InvalidSessionStateError("AI agent did not return a question or summary"); } @@ -2707,6 +2746,14 @@ export async function retrySession( session.error = undefined; session.summary = undefined; session.pendingSummary = undefined; + /* + FNXC:PlanningRetry 2026-07-14-00:00: + A retry regenerates the last turn, so no question is awaiting input. Clearing here also + scrubs stale answered questions persisted by pre-fix builds; without this, the fresh SSE + connection the retry path opens would be handed the answered question by the stream route's + catch-up emit, resetting the FN-7946 auto-retry budget and looping forever. + */ + session.currentQuestion = undefined; session.updatedAt = new Date(); persistSession(session, "generating"); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 3ef298fcbc..0521b86a44 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -39,7 +39,7 @@ import { } from "@fusion/core"; import type { ServerOptions } from "./server.js"; import { verifyWebhookSignature } from "./github-webhooks.js"; -import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js"; +import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js"; import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession, normalizePlanningSummaryPayload } from "./planning.js"; import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js"; import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js"; @@ -1049,23 +1049,6 @@ function replayBufferedSSE( return true; } -async function checkSessionLock( - sessionId: string, - tabId: string | undefined, - store: AiSessionStore | undefined, -): Promise<{ allowed: true } | { allowed: false; currentHolder: string | null }> { - if (!tabId || !store) { - return { allowed: true }; - } - - const result = await store.acquireLock(sessionId, tabId); - if (result.acquired) { - return { allowed: true }; - } - - return { allowed: false, currentHolder: result.currentHolder }; -} - /** * Public API route entrypoint used by server.ts. * @@ -1164,7 +1147,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout registerPlanningSubtaskRoutes(routeContext, { store, aiSessionStore, - checkSessionLock, parseLastEventId, replayBufferedSSE, }); @@ -4211,79 +4193,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout res.json({ archived: Number(after?.archived ?? 0) === 1 }); }); - router.post("/ai-sessions/:id/lock", async (req, res) => { - if (!aiSessionStore) { - throw notFound("AI sessions not available"); - } - - const { id } = req.params; - const session = await 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 = await 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", async (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"); - } - - await aiSessionStore.releaseLock(id, tabId); - res.json({ success: true }); - }); - - router.post("/ai-sessions/:id/lock/force", async (req, res) => { - if (!aiSessionStore) { - throw notFound("AI sessions not available"); - } - - const { id } = req.params; - const session = await 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"); - } - - await aiSessionStore.forceAcquireLock(id, tabId); - res.json({ success: true }); - }); - - router.delete("/ai-sessions/:id/lock/beacon", async (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) { - await aiSessionStore.releaseLock(id, tabId); - } - - res.status(200).end(); - }); + /* + FNXC:PlanningMultiTab 2026-07-14-00:00: + The /ai-sessions/:id/lock, /lock/force, and /lock/beacon endpoints were removed along with + the whole per-tab session-lock machinery. AI interview sessions (planning, subtask, mission, + milestone) are multi-tab: the persisted session row is the shared source of truth and any + tab may read and interact. + */ /** * POST /api/ai-sessions/:id/ping diff --git a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts index 001c06de00..ba4d7a60ef 100644 --- a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts +++ b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts @@ -994,16 +994,15 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void { if (!sessionId || typeof sessionId !== "string") throw badRequest("sessionId is required"); if (!responses || typeof responses !== "object") throw badRequest("responses is required and must be an object"); - const { respondToAgentOnboarding, getAgentOnboardingSummary, getAgentOnboardingSession } = await import("../agent-onboarding.js"); - await respondToAgentOnboarding(sessionId, responses); - const summary = getAgentOnboardingSummary(sessionId); - if (summary) { - res.json({ type: "complete", data: summary }); - return; - } - const session = getAgentOnboardingSession(sessionId); - if (!session?.currentQuestion) throw badRequest("Session did not produce a question"); - res.json({ type: "question", data: session.currentQuestion }); + /* + FNXC:AgentOnboarding 2026-07-14-18:20: + respondToAgentOnboarding now returns the Planning Mode-shaped respond contract, including a + successful question payload when generation fails after an answer (so this route no longer + 400s with "Session did not produce a question" while SSE drives retry). + */ + const { respondToAgentOnboarding } = await import("../agent-onboarding.js"); + const result = await respondToAgentOnboarding(sessionId, responses); + res.json(result); } catch (err: unknown) { if (err instanceof ApiError) throw err; if (err instanceof Error && err.name === "SessionNotFoundError") throw notFound(err.message); diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index d17464c254..b0688e6599 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -20,7 +20,6 @@ type SkillPluginRunner = Parameters Promise<{ allowed: true } | { allowed: false; currentHolder: string | null | undefined }>; parseLastEventId: (req: import("express").Request) => number | undefined; replayBufferedSSE: (res: import("express").Response, bufferedEvents: SessionBufferedEvent[]) => boolean; } @@ -48,7 +47,7 @@ function rethrowPlanningWorkflowCreateError( export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void { const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx; - const { aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps; + const { aiSessionStore, parseLastEventId, replayBufferedSSE } = deps; // ── Planning Mode Routes ────────────────────────────────────────────────── // UTILITY PATH: Planning and subtask session routes are on a separate control-plane lane. @@ -409,21 +408,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann router.post("/subtasks/cancel", async (req, res) => { try { - const { sessionId, tabId } = req.body; + const { sessionId } = 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 = await 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 }); @@ -444,7 +433,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann * Retry a failed subtask breakdown session. * * UTILITY PATH: This route is independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free (see FNXC:PlanningMultiTab on /planning/respond). */ router.post("/subtasks/:sessionId/retry", async (req, res) => { try { @@ -453,18 +442,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const { retrySubtaskSession } = await import("../subtask-breakdown.js"); @@ -839,11 +816,16 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann * Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary } * * UTILITY PATH: This route is independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved (multi-tab coordination, not saturation). + * + * FNXC:PlanningMultiTab 2026-07-14-00:00: + * Planning routes are deliberately lock-free. Multiple tabs may read and interact with the + * same planning session; the persisted session row plus the activeGenerations guard in + * planning.ts (409 GenerationInProgressError) are the only coordination. The former + * per-tab session lock (checkSessionLock / lock-conflict 409s) was removed entirely. */ router.post("/planning/respond", async (req, res) => { try { - const { sessionId, responses, tabId } = req.body; + const { sessionId, responses } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); @@ -853,16 +835,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("responses is required and must be an object"); } - const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined; - const lockCheck = await checkSessionLock(sessionId, normalizedTabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const { submitResponse, SessionNotFoundError: _SessionNotFoundError, InvalidSessionStateError: _InvalidSessionStateError } = await import("../planning.js"); @@ -897,18 +869,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const { rewindSession } = await import("../planning.js"); @@ -938,7 +898,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann * Retry a failed planning session. * * UTILITY PATH: This route is independent of task-lane saturation. - * Session-lock 409 with { error, lockedByTab } is preserved. + * Lock-free like all /planning/* routes (see FNXC:PlanningMultiTab on /planning/respond). */ router.post("/planning/:sessionId/retry", async (req, res) => { try { @@ -947,18 +907,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const { retrySession } = await import("../planning.js"); @@ -985,18 +933,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann 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 = await checkSessionLock(sessionId, tabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { stopGeneration } = await import("../planning.js"); const stopped = stopGeneration(sessionId); if (!stopped) { @@ -1019,22 +955,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann */ router.post("/planning/cancel", async (req, res) => { try { - const { sessionId, tabId } = req.body; + const { sessionId } = 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 = await checkSessionLock(sessionId, normalizedTabId, aiSessionStore); - if (!lockCheck.allowed) { - res.status(409).json({ - error: "Session locked by another tab", - lockedByTab: lockCheck.currentHolder, - }); - return; - } - const { cancelSession, SessionNotFoundError: _SessionNotFoundError2 } = await import("../planning.js"); await cancelSession(sessionId); res.json({ success: true }); diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index 38b5b9f58e..14eedcf99a 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -251,8 +251,6 @@ function persistSubtaskSession(session: SubtaskInternalSession, status: "generat projectId: session.projectId ?? null, createdAt: session.createdAt.toISOString(), updatedAt: new Date().toISOString(), - lockedByTab: null, - lockedAt: null, }; _aiSessionStore.upsert(row).catch(() => { /* best-effort persistence */ }); }