fix(dashboard): stop Planning Mode retry loop, make AI sessions multi-tab (#2101)

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## 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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-14 18:47:53 -07:00
committed by GitHub
parent be55d0a987
commit cdf67c1d98
49 changed files with 822 additions and 2017 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<string, unknown>): 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<T>(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<AiSessionRow> {
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<boolean> {
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<boolean> {
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<number> {
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 ──

View File

@@ -2326,11 +2326,6 @@ export {
updateThinking as updateThinkingAsync,
archiveAiSession,
unarchiveAiSession,
acquireAiSessionLock,
releaseAiSessionLock,
forceAcquireAiSessionLock,
getAiSessionLockHolder,
releaseStaleAiSessionLocks,
deleteAiSession,
deleteAiSessionByIdAndType,
recoverStaleAiSessions,

View File

@@ -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),

View File

@@ -14,7 +14,6 @@ function cliSession(overrides: Partial<AiSessionSummary> = {}): 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")

View File

@@ -4087,11 +4087,10 @@ export function respondToPlanning(
sessionId: string,
responses: Record<string, unknown>,
projectId?: string,
tabId?: string,
): Promise<PlanningSession> {
return api<PlanningSession>(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<void> {
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
return api<void>(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<void> {
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): Promise<void> {
return api<void>(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<string, unknown>,
projectId?: string,
tabId?: string,
): Promise<MissionInterviewResponse> {
return api<MissionInterviewResponse>(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<void> {
export function cancelMissionInterview(sessionId: string, projectId?: string): Promise<void> {
return api<void>(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<M
export function discardMissionInterviewDraft(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ removed: boolean }> {
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<string, unknown>,
projectId?: string,
tabId?: string,
): Promise<TargetInterviewResponse> {
return api<TargetInterviewResponse>(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<string, unknown>,
projectId?: string,
tabId?: string,
): Promise<TargetInterviewResponse> {
return api<TargetInterviewResponse>(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<AiSessionDetail | null
return res.json();
}
export async function acquireSessionLock(
sessionId: string,
tabId: string,
): Promise<{ acquired: boolean; currentHolder: string | null }> {
const result = await api<{ acquired: boolean; currentHolder?: string | null }>(
`/ai-sessions/${encodeURIComponent(sessionId)}/lock`,
{
method: "POST",
body: JSON.stringify({ tabId }),
},
);
return {
acquired: result.acquired,
currentHolder: result.currentHolder ?? null,
};
}
export function releaseSessionLock(sessionId: string, tabId: string): Promise<void> {
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock`, {
method: "DELETE",
body: JSON.stringify({ tabId }),
});
}
export function forceAcquireSessionLock(sessionId: string, tabId: string): Promise<void> {
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock/force`, {
method: "POST",
body: JSON.stringify({ tabId }),
});
}
/*
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<void> {
const url = buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`);

View File

@@ -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<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const previousSessionSignatureRef = useRef<Map<string, string>>(new Map());
const clearUpdatedTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string>();
const nextSignature = new Map<string, string>();
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({
<div className="background-tasks-indicator__session-meta">
{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")}`}
</div>
</div>
{isGenerating && (
@@ -204,20 +182,13 @@ export function BackgroundTasksIndicator({
style={{ color: "var(--color-success)" }}
/>
)}
{isAwaiting && !activeElsewhere && (
{isAwaiting && (
<HelpCircle
size={14}
className="background-tasks-indicator__session-icon"
style={{ color: "var(--triage)" }}
/>
)}
{isAwaiting && activeElsewhere && (
<Lock
size={14}
className="background-tasks-indicator__session-icon"
style={{ color: "var(--text-muted)" }}
/>
)}
<button
className="background-tasks-indicator__item-dismiss"
onClick={(e) => {

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo, useRef, type CSSProperties } from "react";
import { useState, useCallback, useEffect, useRef, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import type { PlanningQuestion } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -28,12 +28,10 @@ import {
Minimize2,
} from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import { getSessionTabId } from "../utils/getSessionTabId";
const WARNING_ICON = "⚠️";
const MILESTONE_SLICE_OTHER_RESPONSE_KEY = "_other";
@@ -106,18 +104,13 @@ export function MilestoneSliceInterviewModal({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const trackedLockSessionRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(null);
const sessionTabId = useMemo(() => getSessionTabId(), []);
useSessionLock(isOpen ? lockSessionId : 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 interview. The lock state, ownership broadcasts, heartbeat, and the
"active in another tab" banner were removed; only session-status sync remains.
*/
const { broadcastUpdate, broadcastCompleted } = useAiSessionSync();
// Select the right API functions based on targetType
const startInterview = targetType === "milestone" ? startMilestoneInterview : startSliceInterview;
@@ -143,7 +136,6 @@ export function MilestoneSliceInterviewModal({
sessionId,
status: "generating",
needsInput: false,
owningTabId: sessionTabId,
type: interviewType,
title: targetTitle,
projectId: projectId ?? null,
@@ -159,7 +151,6 @@ export function MilestoneSliceInterviewModal({
sessionId,
status: "awaiting_input",
needsInput: true,
owningTabId: sessionTabId,
type: interviewType,
title: targetTitle,
projectId: projectId ?? null,
@@ -176,7 +167,6 @@ export function MilestoneSliceInterviewModal({
sessionId,
status: "complete",
needsInput: false,
owningTabId: sessionTabId,
type: interviewType,
title: targetTitle,
projectId: projectId ?? null,
@@ -194,7 +184,6 @@ export function MilestoneSliceInterviewModal({
sessionId,
status: "error",
needsInput: false,
owningTabId: sessionTabId,
type: interviewType,
title: targetTitle,
projectId: projectId ?? null,
@@ -213,7 +202,7 @@ export function MilestoneSliceInterviewModal({
streamConnectionRef.current = connection;
},
[broadcastCompleted, broadcastUpdate, connectToStream, interviewType, projectId, sessionTabId, targetTitle],
[broadcastCompleted, broadcastUpdate, connectToStream, interviewType, projectId, targetTitle],
);
const clearSummary = () => {
@@ -232,14 +221,12 @@ export function MilestoneSliceInterviewModal({
try {
const { sessionId } = await startInterview(targetId, projectId);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
connectToInterviewStream(sessionId);
} catch (err) {
setIsReconnecting(false);
setError(getErrorMessage(err) || t("interview.error.failedToStart", "Failed to start {{targetLabel}} interview", { targetLabel: targetLabel.toLowerCase() }));
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
}, [connectToInterviewStream, projectId, startInterview, targetId, targetLabel]);
@@ -275,7 +262,6 @@ export function MilestoneSliceInterviewModal({
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setLockSessionId(session.id);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
@@ -330,57 +316,16 @@ export function MilestoneSliceInterviewModal({
useEffect(() => {
if (!isOpen) {
setIsReconnecting(false);
setLockSessionId(null);
}
}, [isOpen]);
// Session locking
useEffect(() => {
if (!isOpen || !lockSessionId) return;
if (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]);
// Keep heartbeat alive
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 on unmount
useEffect(() => {
return () => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
};
}, [broadcastUnlock, sessionTabId]);
}, []);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
@@ -395,7 +340,6 @@ export function MilestoneSliceInterviewModal({
setView({ type: "initial" });
setError(null);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [onClose]);
@@ -418,7 +362,7 @@ export function MilestoneSliceInterviewModal({
try {
connectToInterviewStream(sessionId);
await respondToInterview(sessionId, responses, projectId, sessionTabId);
await respondToInterview(sessionId, responses, projectId);
} catch (err) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
@@ -426,7 +370,7 @@ export function MilestoneSliceInterviewModal({
setView({ type: "question", sessionId, question: view.question });
}
},
[connectToInterviewStream, projectId, respondToInterview, sessionTabId, view],
[connectToInterviewStream, projectId, respondToInterview, view],
);
const handleApply = useCallback(async () => {
@@ -458,10 +402,6 @@ export function MilestoneSliceInterviewModal({
view.type === "summary" ||
view.type === "error";
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
if (!isOpen) return null;
return (
@@ -500,11 +440,6 @@ export function MilestoneSliceInterviewModal({
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("interview.reconnecting", "Reconnecting…")}</div>}
{activeInAnotherTab && (
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
{t("interview.sessionActiveAnotherTab", "Session is active in another tab.")}
</div>
)}
{view.type === "initial" && (
<div className="planning-initial">

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { PlanningQuestion, ThinkingLevel } from "@fusion/core";
import { getErrorMessage, THINKING_LEVELS } from "@fusion/core";
@@ -42,16 +42,13 @@ import {
Plus,
Trash2,
RefreshCw,
Lock,
Minimize2,
} from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { FloatingWindow } from "./FloatingWindow";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
import "./MissionInterviewModal.css";
// Helper functions for model selection
@@ -137,23 +134,15 @@ export function MissionInterviewModal({
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const streamErrorRecoverySeqRef = useRef(0);
const trackedLockSessionRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
const sessionTabId = useMemo(() => getSessionTabId(), []);
const canSendToBackground = showSendToBackgroundButton && view.type !== "initial";
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? lockSessionId : 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 interview. 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();
// Model selection state
const [modelProvider, setModelProvider] = useState<string | undefined>(undefined);
@@ -236,7 +225,6 @@ export function MissionInterviewModal({
sessionId,
status: "generating",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
@@ -254,7 +242,6 @@ export function MissionInterviewModal({
sessionId,
status: "awaiting_input",
needsInput: true,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
@@ -273,7 +260,6 @@ export function MissionInterviewModal({
sessionId,
status: "complete",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
@@ -321,7 +307,6 @@ export function MissionInterviewModal({
if (session?.type === "mission_interview") {
restoreHistoryFromSession(session);
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
setHasProgress(true);
if (session.status === "generating") {
@@ -358,8 +343,7 @@ export function MissionInterviewModal({
sessionId: session.id,
status: "complete",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
@@ -389,8 +373,7 @@ export function MissionInterviewModal({
sessionId,
status: "error",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
@@ -410,7 +393,7 @@ export function MissionInterviewModal({
streamConnectionRef.current = connection;
},
[broadcastCompleted, broadcastUpdate, missionGoal, projectId, sessionTabId],
[broadcastCompleted, broadcastUpdate, missionGoal, projectId],
);
const handleStartInterview = useCallback(
@@ -433,7 +416,6 @@ export function MissionInterviewModal({
modelOverride,
);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
clearMissionGoal(projectId);
connectToMissionInterviewStream(sessionId);
@@ -443,7 +425,6 @@ export function MissionInterviewModal({
setError(getErrorMessage(err) || "Failed to start interview session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
},
[connectToMissionInterviewStream, missionGoal, modelProvider, modelId, thinkingLevel, projectId]
@@ -486,7 +467,6 @@ export function MissionInterviewModal({
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
@@ -507,7 +487,6 @@ export function MissionInterviewModal({
} catch {
setThinkingLevel("");
}
setLockSessionId(session.id);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
@@ -564,59 +543,13 @@ export function MissionInterviewModal({
};
}, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]);
// Broadcast ownership transitions between tabs.
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]);
// Keep heartbeat alive while this tab owns an active mission interview session.
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 on unmount
useEffect(() => {
return () => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
};
}, [broadcastUnlock, sessionTabId]);
}, []);
// Unload protection
useEffect(() => {
@@ -693,7 +626,7 @@ export function MissionInterviewModal({
try {
connectToMissionInterviewStream(sessionId);
await respondToMissionInterview(sessionId, responses, projectId, sessionTabId);
await respondToMissionInterview(sessionId, responses, projectId);
setHasProgress(true);
} catch (err) {
streamConnectionRef.current?.close();
@@ -702,7 +635,7 @@ export function MissionInterviewModal({
setView({ type: "question", sessionId, question: view.question });
}
},
[view, projectId, sessionTabId, connectToMissionInterviewStream]
[view, projectId, connectToMissionInterviewStream]
);
const handleRetryFromError = useCallback(async () => {
@@ -719,8 +652,7 @@ export function MissionInterviewModal({
try {
currentSessionIdRef.current = retrySessionId;
setLockSessionId(retrySessionId);
await retryMissionInterviewSession(retrySessionId, projectId, sessionTabId);
await retryMissionInterviewSession(retrySessionId, projectId);
} catch (err) {
let retryError: unknown = err;
const retryErrorMessage = getErrorMessage(err) || "";
@@ -743,7 +675,6 @@ export function MissionInterviewModal({
);
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
setHasProgress(true);
if (session.status === "generating") {
@@ -796,7 +727,7 @@ export function MissionInterviewModal({
} finally {
setIsRetrying(false);
}
}, [connectToMissionInterviewStream, projectId, sessionTabId, view]);
}, [connectToMissionInterviewStream, projectId, view]);
const handleApprovePlan = useCallback(async () => {
if (view.type !== "summary") return;
@@ -823,7 +754,6 @@ export function MissionInterviewModal({
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create mission");
@@ -838,11 +768,6 @@ export function MissionInterviewModal({
return 6;
};
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
if (!isOpen) return null;
return (
@@ -859,7 +784,7 @@ export function MissionInterviewModal({
>
{/*
FNXC:MissionInterviewModal 2026-06-24-00:00:
The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background/session-lock controls do not duplicate FloatingWindow chrome.
The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background controls do not duplicate FloatingWindow chrome.
*/}
<div className="modal modal-lg planning-modal mission-interview-modal">
<div className="modal-header mission-interview-modal__drag-handle">
@@ -887,11 +812,6 @@ export function MissionInterviewModal({
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("missions.reconnecting", "Reconnecting…")}</div>}
{activeInAnotherTab && (
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
{t("missions.sessionActiveAnother", "Session is active in another tab.")}
</div>
)}
{view.type === "initial" && (
<div className="planning-initial">
@@ -1092,7 +1012,6 @@ export function MissionInterviewModal({
setEditedSummary(null);
setResponseHistory([]);
setConversationHistory([]);
setLockSessionId(null);
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
}}
@@ -1100,30 +1019,6 @@ export function MissionInterviewModal({
/>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>
{allowTakeover
? t("missions.sessionActiveTab", "This session is active in another tab")
: t("missions.sessionActiveHeartbeat", "This session is active in another tab (live heartbeat)")}
</span>
{allowTakeover && (
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? t("missions.takingControl", "Taking control...") : t("missions.takeControl", "Take Control")}
</button>
)}
</div>
</div>
)}
</div>
</div>
</FloatingWindow>

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(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<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [planningThinkingLevel, setPlanningThinkingLevel] = useState<ThinkingLevel | "">("");
@@ -421,7 +412,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
provider?: string;
modelId?: string;
}>({});
const trackedLockSessionRef = useRef<string | null>(null);
// Sidebar list state
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>([]);
@@ -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
)}
</div>
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>
{allowTakeover
? t("planning.sessionActiveOtherTab", "This session is active in another tab")
: t("planning.sessionActiveOtherTabLive", "This session is active in another tab (live heartbeat)")}
</span>
{allowTakeover && (
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? t("planning.takingControl", "Taking control...") : t("planning.takeControl", "Take Control")}
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -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<Array<HTMLInputElement | null>>([]);
const autoStartedRef = useRef(false);
const trackedLockSessionRef = useRef<string | null>(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
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("subtasks.reconnecting", "Reconnecting…")}</div>}
{activeInAnotherTab && (
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
{t("subtasks.sessionActiveAnotherTab", "Session is active in another tab.")}
</div>
)}
{view.type === "initial" && (
<div className="planning-loading" data-testid="subtask-progress-state">
@@ -969,30 +900,6 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</div>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>
{allowTakeover
? t("subtasks.sessionActiveAnotherTabTakeover", "This session is active in another tab")
: t("subtasks.sessionActiveAnotherTabLive", "This session is active in another tab (live heartbeat)")}
</span>
{allowTakeover && (
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? t("subtasks.takingControl", "Taking control...") : t("subtasks.takeControl", "Take Control")}
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -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",
};
}

View File

@@ -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 = {

View File

@@ -97,10 +97,8 @@ function buildMissionSession(overrides: Record<string, unknown> = {}) {
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),
);
});
});

View File

@@ -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();

View File

@@ -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", () => {

View File

@@ -9,7 +9,6 @@ function makeSession(id: string, updatedAt: string, title = id): AiSessionSummar
status: "complete",
title,
projectId: null,
lockedByTab: null,
updatedAt,
archived: false,
};

View File

@@ -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,

View File

@@ -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(
<PlanningModeModal
@@ -467,67 +457,27 @@ describe("PlanningModeModal", () => {
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(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeDefined();
});
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,
},

View File

@@ -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,
},

View File

@@ -11,7 +11,6 @@ function buildSession(overrides: Partial<AiSessionSummary>): 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>): 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",

View File

@@ -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();

View File

@@ -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(),
},
];

View File

@@ -73,7 +73,6 @@ function buildSession(overrides: Partial<AiSessionSummary> = {}): 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,
};

View File

@@ -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);
});
});

View File

@@ -35,7 +35,6 @@ function makeSession(overrides: Partial<apiModule.AiSessionSummary> & Pick<apiMo
status: overrides.status ?? "generating",
title: overrides.title ?? overrides.id,
projectId: overrides.projectId ?? null,
lockedByTab: overrides.lockedByTab ?? null,
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
};
}
@@ -299,15 +298,16 @@ describe("useBackgroundSessions", () => {
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");
});

View File

@@ -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<string, SessionSyncState>;
activeTabMap: Map<string, ActiveTabState>;
}
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<TabMessageBase>)
| ({
senderTabId?: string;
}
| {
type: "session:completed";
sessionId: string;
status?: Extract<SessionStatus, "complete" | "error">;
timestamp: number;
} & Partial<TabMessageBase>)
| ({
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<TabMessageBase>);
};
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<string, SessionSyncState>();
private readonly ownershipBySession = new Map<string, { tabId: string; timestamp: number }>();
private readonly heartbeatByTab = new Map<string, number>();
private readonly ownedSessions = new Map<string, string>();
private snapshot: StoreSnapshot;
private channel: BroadcastChannel | null = null;
private usingStorageFallback = false;
private cleanupStorageListener: (() => void) | null = null;
private cleanupBeforeUnload: (() => void) | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private staleSweepInterval: ReturnType<typeof setInterval> | 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<string, SessionSyncState>();
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<string, ActiveTabState>();
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<string, SessionSyncState>;
activeTabMap: Map<string, ActiveTabState>;
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,
};
}

View File

@@ -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`);
}
}
}

View File

@@ -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<void>;
isLoading: boolean;
}
export function useSessionLock(sessionId: string | null): SessionLockState {
const tabId = useMemo(() => getSessionTabId(), []);
const [isLockedByOther, setIsLockedByOther] = useState(false);
const [currentHolder, setCurrentHolder] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!sessionId) {
setIsLockedByOther(false);
setCurrentHolder(null);
setIsLoading(false);
return;
}
let active = true;
setIsLoading(true);
void Promise.resolve(acquireSessionLock(sessionId, tabId))
.then((result) => {
if (!active) return;
if (result.acquired) {
setIsLockedByOther(false);
setCurrentHolder(null);
return;
}
setIsLockedByOther(true);
setCurrentHolder(result.currentHolder);
})
.catch(() => {
if (!active) return;
setIsLockedByOther(false);
setCurrentHolder(null);
})
.finally(() => {
if (!active) return;
setIsLoading(false);
});
return () => {
active = false;
try {
const releaseResult = releaseSessionLock(sessionId, tabId) as Promise<void> | void;
if (releaseResult && typeof releaseResult.catch === "function") {
void releaseResult.catch(() => {
// best-effort on unmount
});
}
} catch {
// best-effort on unmount
}
};
}, [sessionId, tabId]);
useEffect(() => {
if (!sessionId || typeof window === "undefined") {
return;
}
const handleBeforeUnload = () => {
if (typeof navigator.sendBeacon !== "function") {
return;
}
const url = 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,
};
}

View File

@@ -15,7 +15,6 @@ function makeSession(overrides: Partial<AiSessionSummary> & Pick<AiSessionSummar
status: overrides.status ?? "generating",
title: overrides.title ?? overrides.id,
projectId: overrides.projectId ?? null,
lockedByTab: overrides.lockedByTab ?? null,
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
};
}

View File

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

View File

@@ -509,6 +509,75 @@ describe("agent-onboarding", () => {
);
});
/*
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([

View File

@@ -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<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<void>();
let firstQuestionSignaled = false;
let hungTurnEntered = deferred<void>();
__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<void>();
const payload = await new Promise<string>((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<typeof setupAgent>,
): Promise<string> {
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<AiSessionRow, "id" | "status"> = {
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<string, AiSessionRow>([
["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);
});
});

View File

@@ -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));

View File

@@ -159,7 +159,6 @@ describe("planning routes github tracking background dispatch", () => {
} as never,
{
store,
checkSessionLock: () => ({ allowed: true }),
parseLastEventId: () => undefined,
replayBufferedSSE: () => true,
},

View File

@@ -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();
});
});
});

View File

@@ -409,13 +409,54 @@ async function continueConversation(session: Session, message: string): Promise<
}
}
export async function respondToAgentOnboarding(sessionId: string, responses: Record<string, unknown>): Promise<void> {
/*
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<string, unknown>,
): Promise<AgentOnboardingRespondResult> {
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<void> {

View File

@@ -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<AiSessionStoreEvents> {
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<AiSessionStoreEvents> {
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<AiSessionStoreEvents> {
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<AiSessionStoreEvents> {
}
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<AiSessionStoreEvents> {
: "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<AiSessionStoreEvents> {
}
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<AiSessionStoreEvents> {
.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<boolean> {
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<void> {
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<number> {
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<string, unknown>): 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,
};

View File

@@ -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 */ });
}

View File

@@ -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 */ });
}

View File

@@ -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<string, unknown> }
*
* 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<string, unknown>, tabId?: string }
* Body: { sessionId: string, responses: Record<string, unknown> }
*
* 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<string, unknown>, tabId?: string }
* Body: { sessionId: string, responses: Record<string, unknown> }
*
* 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");

View File

@@ -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<PlanningQuestion | null>(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<string, unknown>,
): 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");

View File

@@ -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

View File

@@ -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);

View File

@@ -20,7 +20,6 @@ type SkillPluginRunner = Parameters<typeof import("@fusion/engine").buildSession
interface PlanningSubtaskRouteDeps {
store: TaskStore;
aiSessionStore?: AiSessionStore;
checkSessionLock: (sessionId: string, tabId: string | undefined, store: AiSessionStore | undefined) => 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 });

View File

@@ -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 */ });
}