fix(desktop): fence clear-session identity against mode-switch races

Problem
After Clear Context, immediately switching Plan/layout/token mode could
briefly re-show the destroyed transcript. ClearSession rotated the backend
session path but the frontend only wiped the view; meta, TranscriptStore
resident cache, and hydrate identity still pointed at the old session.

Root cause
ClearSessionForTab returned no replacement identity. clearSession did not
evict TranscriptStore or apply a new sessionPath/generation. Hydrate only
checked request sequence numbers, so a post-clear load started with stale
meta.sessionPath could still preferResident-serve session A.

Fix
- ClearSession/ClearSessionForTab return SessionClearResult with path,
  revision, digest, and a bumped tab SessionGeneration.
- clearSession applies that identity atomically, resets transcript state,
  and evicts the tab from TranscriptStore.
- loadSessionDataForTab rejects results when meta sessionPath or
  sessionGeneration no longer match the load's identity.
- On MCP extension generation race, resetSharedHostMCP drops all shared-host
  clients (including same-name still-enabled servers) before rebuild.
- gofmt app.go; keep repolint clean.

Verification
- gofmt -l . (clean)
- go run ./tools/repolint
- go test -run 'TestClearSessionForTabReturnsReplacementIdentity|TestResetSharedHostMCPClearsStillEnabledFailure|TestClearSession|TestTabScoped' ./desktop
- npx tsx desktop/frontend/src/__tests__/clear-session-identity-contract.test.ts
This commit is contained in:
SivanCola committed 2026-08-11 09:28:15 +08:00
1 parent 3f1be32bc9
commit af6b1cbe0b
11 files changed
+277 -123

No files matched your search

+11 -63
View File
@@ -2301,63 +2301,9 @@ func messagesHaveConversationContent(messages []provider.Message) bool {
return false
}
// ClearSession discards the current conversation and rotates to a fresh unsaved one.
func (a *App) ClearSession() error {
return a.ClearSessionForTab("")
}
// ClearSessionForTab clears the requested tab regardless of later focus changes.
func (a *App) ClearSessionForTab(tabID string) error {
tab, ctrl := a.tabAndCtrlByID(tabID)
if a.tabIsReadOnly(tab) {
return readOnlyChannelErr()
}
if ctrl == nil {
return a.workspaceNotReadyErr(tab)
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
}
ctrl = a.controllerForTab(tab)
if ctrl == nil {
return a.workspaceNotReadyErr(tab)
}
if controllerHasActiveRuntimeWork(ctrl) {
return a.clearActiveSessionRuntime(tab, ctrl)
}
if err := ctrl.ClearSession(); err != nil {
return err
}
if err := a.ensureTabSessionLeaseForRebuild(tab, ctrl.SessionPath(), ""); err != nil {
// Wails bridge return: a raw lease error would carry the session path
// and holder id across to the frontend.
return userFacingSessionLeaseError("", err)
}
tab.resetTelemetry(ctrl.SessionPath())
// Mirror the controller: ClearSession cleared the active goal.
a.clearTabGoal(tab)
a.persistTabSessionPath(tab, ctrl.SessionPath())
a.invalidatePromptHistoryCache()
return nil
}
// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
// cannot re-seed a goal the controller has already cleared on session rotation.
func (a *App) clearTabGoal(tab *WorkspaceTab) {
if tab == nil {
return
}
a.mu.Lock()
tab.goal = ""
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
a.mu.Unlock()
}
func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) (SessionClearResult, error) {
if tab == nil || oldCtrl == nil {
return fmt.Errorf("workspace is still starting")
return SessionClearResult{}, fmt.Errorf("workspace is still starting")
}
// This is a build+swap of the tab's controller; serialize with the other
// rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings
@@ -2389,7 +2335,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
if oldCtrl.RuntimeStatus().Cancellable {
oldCtrl.Cancel()
if err := waitControllerStopped(oldCtrl); err != nil {
return err
return SessionClearResult{}, err
}
}
destroy := oldCtrl.BeginDestroySession(oldPath)
@@ -2397,7 +2343,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
teardownTimedOut := waitDestroyHandles(destroys)
if teardownTimedOut {
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
return err
return SessionClearResult{}, err
}
}
@@ -2432,7 +2378,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
oldSink.setBinding(tab.ID, nil)
oldSink.setContext(a.ctx)
}
return err
return SessionClearResult{}, err
}
if teardownTimedOut {
go delayedDesktopSessionCleanup(oldPath, destroys)
@@ -2440,7 +2386,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
if err := removeDesktopSessionArtifacts(oldPath); err != nil {
finishDestroyHandles(destroys)
newCtrl.Close()
return err
return SessionClearResult{}, err
}
finishDestroyHandles(destroys)
}
@@ -2458,7 +2404,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
newCtrl.Close()
// Surfaces through ClearSession's Wails return; keep the holder's
// path/pid/writer id out of it.
return userFacingSessionLeaseError("", err)
return SessionClearResult{}, userFacingSessionLeaseError("", err)
}
newCtrl.SetFreshSessionPath(path)
@@ -2472,7 +2418,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
tab.releaseSessionLease()
oldCtrl.CloseAfterDestroy()
a.emitProjectTreeChangedForSessionDirs(newCtrl.SessionDir())
return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
return SessionClearResult{}, fmt.Errorf("tab %q changed while clearing the session", tab.ID)
}
tab.Ctrl = newCtrl
tab.sink = newSink
@@ -2494,7 +2440,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
oldCtrl.CloseAfterDestroy()
a.emitProjectTreeChangedForSessionDirs(newCtrl.SessionDir())
a.notifyTabRuntimeRebuilt(tab)
return nil
return a.bumpAndSnapshotSessionClear(tab), nil
}
func removeDesktopSessionArtifacts(path string) error {
@@ -8949,6 +8895,7 @@ func persistMCPInstallActivation(entry config.PluginEntry, root string) error {
}
return store.SetServerEnabled(entry, root, true)
}
// AddMCPServer is retained for old generated Wails clients. New clients use
// InstallMCPServer so authentication and retry states remain structured.
func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
@@ -8961,6 +8908,7 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
}
return result.ToolCount, nil
}
// UpdateMCPServer edits a persisted external MCP server. The name is the stable
// identity; callers must remove + add if they want to rename a server.
func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
+5 -5
View File
@@ -4695,7 +4695,7 @@ func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)
if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
if _, err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
t.Fatalf("clearActiveSessionRuntime: %v", err)
}
if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
@@ -4755,7 +4755,7 @@ func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T)
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)
err := app.clearActiveSessionRuntime(tab, oldCtrl)
_, err := app.clearActiveSessionRuntime(tab, oldCtrl)
if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
}
@@ -5897,7 +5897,7 @@ func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {
oldCtrl.Submit("work")
<-runner.started
if err := app.ClearSession(); err != nil {
if _, err := app.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
waitNotRunning(t, oldCtrl)
@@ -5953,7 +5953,7 @@ func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
t.Fatalf("job sidecar should exist before clear: %v", err)
}
if err := app.ClearSession(); err != nil {
if _, err := app.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
@@ -10333,7 +10333,7 @@ func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
if err := app.NewSession(); err == nil {
t.Error("NewSession with no controller must surface an error, not silently no-op")
}
if err := app.ClearSession(); err == nil {
if _, err := app.ClearSession(); err == nil {
t.Error("ClearSession with no controller must surface an error")
}
@@ -0,0 +1,28 @@
// Run: npx tsx src/__tests__/clear-session-identity-contract.test.ts
//
// clearSession must fence transcript identity so "clear → immediately switch
// mode/layout" cannot re-paint the destroyed session from TranscriptStore or
// a hydrate started with stale meta.sessionPath.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
const store = readFileSync(join(root, "lib/transcriptStore.ts"), "utf8");
const types = readFileSync(join(root, "lib/types.ts"), "utf8");
assert.match(types, /export interface SessionClearResult/, "backend clear result is typed");
assert.match(types, /sessionGeneration/, "sessionGeneration is part of identity");
assert.match(controller, /ClearSessionForTab/, "clear uses tab-scoped clear API");
assert.match(controller, /evictTab\(tabId\)/, "clear evicts TranscriptStore for the tab");
assert.match(controller, /sessionGeneration:\s*cleared\.sessionGeneration/, "clear applies returned generation");
assert.match(controller, /sessionPath:\s*cleared\.sessionPath/, "clear applies returned path");
assert.match(controller, /optimistic_meta[\s\S]*reset/, "meta is updated before reset so identity survives");
assert.match(controller, /meta\.sessionPath !== sessionPath/, "hydrate rejects path identity drift");
assert.match(controller, /meta\.sessionGeneration !== sessionGeneration/, "hydrate rejects generation drift");
assert.match(store, /evictTab\(tabId/, "TranscriptStore can drop a tab's resident sessions");
console.log(" PASS clear-session identity contract");
+5 -6
View File
@@ -1,10 +1,8 @@
// Wails and the browser mock share this React-to-Go contract.
// @ts-ignore `wails generate module` creates this locally; fresh checkouts keep
// typecheck green by falling back to a disabled drift check below.
import type * as GeneratedApp from "../../wailsjs/go/main/App";
import type { InvocationRequest } from "./invocationDisplay";
import { addBreadcrumb } from "./breadcrumbs";
import { maybeShare } from "./queryCoalesce";
import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge";
@@ -120,6 +118,7 @@ import type {
GitCommitView,
GitCommitDetailView,
WorkspaceView,
SessionClearResult,
} from "./types";
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
@@ -279,8 +278,8 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
CompactForTab(tabID: string): Promise<void>;
NewSession(): Promise<void>;
NewSessionForTab(tabID: string): Promise<void>;
ClearSession(): Promise<void>;
ClearSessionForTab(tabID: string): Promise<void>;
ClearSession(): Promise<SessionClearResult>;
ClearSessionForTab(tabID: string): Promise<SessionClearResult>;
History(): Promise<HistoryMessage[]>;
HistoryForTab(tabID: string): Promise<HistoryMessage[]>;
HistoryPage(beforeTurn: number, limit: number): Promise<HistoryPage>;
@@ -3166,8 +3165,8 @@ function makeMockApp(): AppBindings {
async CompactForTab() {},
async NewSession() {},
async NewSessionForTab() {},
async ClearSession() {},
async ClearSessionForTab() {},
async ClearSession() { return { sessionPath: "", sessionGeneration: 0 }; },
async ClearSessionForTab() { return { sessionPath: "", sessionGeneration: 0 }; },
async Checkpoints() {
return [
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], fileCount: 1, turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
+4 -6
View File
@@ -1,11 +1,9 @@
// Wire contract — mirrors desktop/wire.go (itself mirroring internal/serve/wire.go).
// One event channel carries every kind; `kind` discriminates the payload.
import type { Todo } from "./tools";
import type { ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes";
export type { ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes";
export type { ProjectTopicKey, ProjectTopicPage, ProjectTopicPageRequest, ProjectTreeChangedV2, ProjectTreeSnapshot, SessionCatalogBindings, SessionCatalogStatus, SessionReference } from "./sessionCatalogTypes";
export type EventKind =
| "turn_started"
| "reasoning"
@@ -31,9 +29,7 @@ export type EventKind =
| "stream_attempt"
| "context_maintenance"
| "workspace_changed";
export type StreamAttemptAction = "begin" | "discard" | "commit";
export interface WireStreamAttempt {
id: string;
action: StreamAttemptAction;
@@ -42,14 +38,12 @@ export interface WireStreamAttempt {
/** Fixed enum only: connection_reset | premature_eof | idle_timeout */
reason?: string;
}
export interface WireCompaction {
trigger?: string; // "auto" | "manual"
messages?: number; // done: how many messages were folded into the summary
summary?: string; // done: the briefing (empty on an aborted pass)
archive?: string; // done: archive path, if any
}
export interface WireProfile {
model?: string;
effort?: string;
@@ -386,6 +380,7 @@ export interface TabMeta {
sessionPath?: string;
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration?: number;
readOnly?: boolean;
filePath?: string;
projectColor?: string;
@@ -646,6 +641,8 @@ export interface HistoryEntry {
refs: HistoryContentRef[];
}
export interface SessionClearResult { sessionPath: string; sessionRevision?: number; sessionDigest?: string; sessionGeneration: number }
export interface HistorySlice {
entries: HistoryEntry[];
nextCursor: string; // toward older; empty when none
@@ -830,6 +827,7 @@ export interface Meta {
sessionPath?: string;
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration?: number;
cwd: string;
workspaceRoot?: string;
workspaceName?: string;
+29 -33
View File
@@ -198,7 +198,6 @@ export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "o
type SyncActiveTabOptions = {
preserveCachedHistory?: boolean;
};
// A ticketed StartTopicActivation in flight. Only the latest one is tracked:
// superseded requests get "cancelled" from the backend and are ignored.
type PendingTopicActivation = {
@@ -209,23 +208,18 @@ type PendingTopicActivation = {
/** Terminal event that arrived before the ticket resolved. */
terminal?: TopicActivationEvent;
};
type ModelSwitchQueueResult = "applied" | "superseded";
type ModelSwitchQueueRequest = {
name: string;
resolve: (result: ModelSwitchQueueResult) => void;
reject: (err: unknown) => void;
};
type ModelSwitchQueueState = {
running: boolean;
pending?: ModelSwitchQueueRequest;
fallbackBalance?: BalanceInfo;
};
const HISTORY_PAGE_TURNS = 60;
export type Item =
| { kind: "user"; id: string; submissionId?: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; memoryCitations?: MemoryCitation[] }
@@ -275,10 +269,8 @@ export type Item =
generation?: number;
card: WireExtensionCard;
};
type ToolItem = Extract<Item, { kind: "tool" }>;
export type ExtensionItem = Extract<Item, { kind: "extension" }>;
// Extension UI surfaces (stage 8b2) — per-tab state fed by extension_surface /
// extension_status wire events. Statuses and generations key on
// "<pluginId>:<surfaceId>"; the form is the single pending form surface (a new
@@ -293,14 +285,12 @@ export interface ExtensionStatusEntry {
progress?: number;
generation?: number;
}
export interface ExtensionFormState {
pluginId: string;
surfaceId: string;
generation?: number;
form: WireExtensionForm;
}
export interface ExtensionNotificationEntry {
id: string;
pluginId: string;
@@ -308,13 +298,11 @@ export interface ExtensionNotificationEntry {
body?: string;
severity?: string;
}
// extensionSurfaceKey is the identity a sidecar re-publishes under to replace
// one of its surfaces.
export function extensionSurfaceKey(surface: Pick<WireExtensionSurface, "pluginId" | "surfaceId">): string {
return `${surface.pluginId}:${surface.surfaceId}`;
}
// acceptsExtensionGeneration drops a re-ordered surface publication: within
// one runtime, a sidecar's generation is monotonic, so anything older than the
// last accepted generation for the same surface is stale. Events without a
@@ -322,17 +310,14 @@ export function extensionSurfaceKey(surface: Pick<WireExtensionSurface, "pluginI
export function acceptsExtensionGeneration(stored: number | undefined, incoming: number | undefined): boolean {
return incoming === undefined || stored === undefined || incoming >= stored;
}
// Mid-turn steer messages are recorded as info notices carrying this prefix —
// both live (the "steer" event below) and in replayed history (desktop/app.go
// prefixes persisted steers the same way). The prefix is the only durable
// marker, so display code identifies steers by it.
export const STEER_NOTICE_PREFIX = "↪ ";
export function isSteerNoticeText(text: string): boolean {
return text.startsWith(STEER_NOTICE_PREFIX);
}
interface State {
items: Item[];
running: boolean;
@@ -463,7 +448,6 @@ interface State {
// Host-local only; never hydrated from history.
streamAttemptJournal?: StreamAttemptJournal;
}
export const initialState: State = {
items: [],
running: false,
@@ -513,14 +497,12 @@ export const initialState: State = {
extensionNotifications: [],
extensionGenerations: {},
};
function usageTotalTokens(usage?: WireUsage): number {
if (!usage) return 0;
if (usage.totalTokens > 0) return usage.totalTokens;
const promptTokens = usage.promptTokens || usage.cacheHitTokens + usage.cacheMissTokens;
return Math.max(0, promptTokens + usage.completionTokens);
}
type RuntimeMetaSnapshot = {
running: boolean;
pendingPrompt?: boolean;
@@ -528,13 +510,11 @@ type RuntimeMetaSnapshot = {
cancelRequested?: boolean;
cancellable?: boolean;
};
export function foregroundRunningFromRuntimeMeta(meta: RuntimeMetaSnapshot): boolean {
if (typeof meta.cancellable === "boolean") return meta.cancellable;
if ((meta.backgroundJobs ?? 0) > 0 && !meta.pendingPrompt) return false;
return Boolean(meta.running);
}
// Clock used to order live prompt events against runtime snapshot fetches.
// Monotonic (immune to wall-clock jumps) with sub-millisecond resolution, so
// an event and a snapshot initiated in the same millisecond still order
@@ -542,7 +522,6 @@ export function foregroundRunningFromRuntimeMeta(meta: RuntimeMetaSnapshot): boo
export function promptEventClock(): number {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
// True when a runtime snapshot was fetched before the tab's live approval/ask
// event arrived. Such a snapshot reports the tab idle only because it predates
// the prompt (pre-attach ListTabs, activation-time metas); applying it would
@@ -558,7 +537,6 @@ export function runtimeSnapshotPredatesPrompt(
if (snapshotAt === undefined || state.promptArrivedAt === undefined) return false;
return snapshotAt <= state.promptArrivedAt;
}
function runtimeSnapshotPredatesRetry(
state: Pick<State, "retry"> | undefined,
snapshotAt: number | undefined,
@@ -566,12 +544,10 @@ function runtimeSnapshotPredatesRetry(
if (snapshotAt === undefined || state?.retry?.observedAt === undefined) return false;
return snapshotAt <= state.retry.observedAt;
}
function updatesContextGauge(usage?: WireUsage): boolean {
const source = usage?.source?.trim();
return !source || source === "executor";
}
export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
const cwd = tab.cwd || tab.workspaceRoot || existing?.cwd || "";
const toolApprovalMode = normalizeToolApprovalMode(
@@ -594,23 +570,21 @@ export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
sessionPath: tab.sessionPath !== undefined ? tab.sessionPath : existing?.sessionPath,
sessionRevision: tab.sessionRevision !== undefined ? tab.sessionRevision : existing?.sessionRevision,
sessionDigest: tab.sessionDigest !== undefined ? tab.sessionDigest : existing?.sessionDigest,
sessionGeneration: tab.sessionGeneration !== undefined ? tab.sessionGeneration : existing?.sessionGeneration,
gitBranch: tab.gitBranch || existing?.gitBranch,
autoApproveTools,
bypass: autoApproveTools,
collaborationMode: tab.collaborationMode ?? existing?.collaborationMode ?? "normal",
toolApprovalMode,
tokenMode: tab.tokenMode ?? existing?.tokenMode ?? "full",
goal: tab.goal ?? existing?.goal,
goalStatus: tab.goalStatus ?? existing?.goalStatus,
canonicalTodos: existing?.canonicalTodos,
};
}
function countsTowardCurrentTurn(state: State): boolean {
return state.turnActive || state.running;
}
export function sameMeta(a?: Meta, b?: Meta): boolean {
if (a === b) return true;
if (!a || !b) return false;
@@ -2841,6 +2815,7 @@ export function useController() {
sessionPath?: string;
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration?: number;
cancelHydrateGeneration?: number;
deferResetUntilHistory?: boolean;
} = {},
@@ -2849,6 +2824,7 @@ export function useController() {
const sessionPath = (options.sessionPath ?? stateMeta?.sessionPath ?? "").trim();
const sessionRevision = options.sessionRevision ?? stateMeta?.sessionRevision;
const sessionDigest = options.sessionDigest ?? stateMeta?.sessionDigest;
const sessionGeneration = options.sessionGeneration ?? stateMeta?.sessionGeneration;
const canJoinInFlight = !reset && !options.skipHistory;
const shouldTrackInFlight = !options.skipHistory;
if (canJoinInFlight) {
@@ -2868,9 +2844,16 @@ export function useController() {
(options.preserveCachedHistory && !reset && hasReusableCachedTranscript(statesRef.current.get(tabId), sessionPath, sessionRevision, sessionDigest)),
);
const deferResetUntilHistory = Boolean((options.deferResetUntilHistory ?? true) && reset && !skipHistory);
const stillCurrent = () =>
sessionLoadCurrent(tabId, seq) &&
(cancelHydrateGeneration === undefined || cancelHydrateCurrent(tabId, cancelHydrateGeneration));
// Request seq alone cannot stop clear→mode-switch races: a load started
// after clear with stale meta.sessionPath must also be rejected.
const stillCurrent = () => {
if (!sessionLoadCurrent(tabId, seq)) return false;
if (cancelHydrateGeneration !== undefined && !cancelHydrateCurrent(tabId, cancelHydrateGeneration)) return false;
const meta = statesRef.current.get(tabId)?.meta;
if (sessionPath && meta?.sessionPath && meta.sessionPath !== sessionPath) return false;
if (sessionGeneration !== undefined && meta?.sessionGeneration !== undefined && meta.sessionGeneration !== sessionGeneration) return false;
return true;
};
if (!stillCurrent()) return;
addBreadcrumb("tab.hydrate", `start ${reason} ${tabId}`);
ensureTranscriptSubscription(tabId);
@@ -4011,10 +3994,11 @@ export function useController() {
if (tabId) {
bumpCheckpointRefreshSeq(tabId);
bumpSessionLoadSeq(tabId);
sessionLoadInFlight.current.delete(tabId);
}
let cleared: { sessionPath: string; sessionRevision?: number; sessionDigest?: string; sessionGeneration: number };
try {
if (tabId) await app.ClearSessionForTab(tabId);
else await app.ClearSession();
cleared = tabId ? await app.ClearSessionForTab(tabId) : await app.ClearSession();
} catch {
if (tabId) void loadSessionDataForTab(tabId);
return;
@@ -4022,8 +4006,20 @@ export function useController() {
if (tabId) bumpSessionLoadSeq(tabId);
invalidateCache();
if (tabId) {
// Retire every resident projection for this tab so a mode switch cannot
// preferResident-serve the destroyed transcript.
getTranscriptStore().evictTab(tabId);
const existing = statesRef.current.get(tabId)?.meta;
const nextMeta = {
...(existing ?? { label: "", ready: true, eventChannel: "agent:event", cwd: "" }),
sessionPath: cleared.sessionPath || "",
sessionRevision: cleared.sessionRevision,
sessionDigest: cleared.sessionDigest,
sessionGeneration: cleared.sessionGeneration,
};
// Meta first so reset preserves the replacement identity.
dispatchTo(tabId, { type: "optimistic_meta", meta: nextMeta });
dispatchTo(tabId, { type: "reset" });
// Clear placeholder items since no history action follows.
dispatchTo(tabId, { type: "history", messages: [] });
}
}, [activeTabId, bumpCheckpointRefreshSeq, bumpSessionLoadSeq, dispatchTo, loadSessionDataForTab, waitForTabReady]);
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"reasonix/internal/agent"
)
// SessionClearResult is the post-clear session identity the frontend must apply
// atomically so hydrate/mode-switch cannot re-bind to the destroyed transcript.
type SessionClearResult struct {
SessionPath string `json:"sessionPath"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
SessionGeneration uint64 `json:"sessionGeneration"`
}
// ClearSession discards the current conversation and rotates to a fresh unsaved one.
func (a *App) ClearSession() (SessionClearResult, error) {
return a.ClearSessionForTab("")
}
// ClearSessionForTab clears the requested tab regardless of later focus changes.
// On success it returns the replacement session identity (path/revision/digest
// and a tab-local generation) so the frontend can retire the old transcript
// without waiting for a later MetaForTab round trip.
func (a *App) ClearSessionForTab(tabID string) (SessionClearResult, error) {
tab, ctrl := a.tabAndCtrlByID(tabID)
if a.tabIsReadOnly(tab) {
return SessionClearResult{}, readOnlyChannelErr()
}
if ctrl == nil {
return SessionClearResult{}, a.workspaceNotReadyErr(tab)
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return SessionClearResult{}, err
}
ctrl = a.controllerForTab(tab)
if ctrl == nil {
return SessionClearResult{}, a.workspaceNotReadyErr(tab)
}
if controllerHasActiveRuntimeWork(ctrl) {
return a.clearActiveSessionRuntime(tab, ctrl)
}
if err := ctrl.ClearSession(); err != nil {
return SessionClearResult{}, err
}
if err := a.ensureTabSessionLeaseForRebuild(tab, ctrl.SessionPath(), ""); err != nil {
// Wails bridge return: a raw lease error would carry the session path
// and holder id across to the frontend.
return SessionClearResult{}, userFacingSessionLeaseError("", err)
}
tab.resetTelemetry(ctrl.SessionPath())
// Mirror the controller: ClearSession cleared the active goal.
a.clearTabGoal(tab)
a.persistTabSessionPath(tab, ctrl.SessionPath())
a.invalidatePromptHistoryCache()
return a.bumpAndSnapshotSessionClear(tab), nil
}
func (a *App) bumpAndSnapshotSessionClear(tab *WorkspaceTab) SessionClearResult {
if tab == nil {
return SessionClearResult{}
}
a.mu.Lock()
tab.SessionGeneration++
gen := tab.SessionGeneration
path := tab.currentSessionPath()
if path == "" && tab.Ctrl != nil {
path = tab.Ctrl.SessionPath()
}
a.mu.Unlock()
var revision int64
var digest string
if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok {
revision = meta.Revision
digest = meta.ContentDigest
}
return SessionClearResult{
SessionPath: path, SessionRevision: revision, SessionDigest: digest, SessionGeneration: gen,
}
}
// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
// cannot re-seed a goal the controller has already cleared on session rotation.
func (a *App) clearTabGoal(tab *WorkspaceTab) {
if tab == nil {
return
}
a.mu.Lock()
tab.goal = ""
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
a.mu.Unlock()
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"path/filepath"
"testing"
"reasonix/internal/plugin"
"reasonix/internal/provider"
)
func TestClearSessionForTabReturnsReplacementIdentity(t *testing.T) {
app := historySliceTestApp(t)
dir := t.TempDir()
sess, oldPath := saveHistorySliceSession(t, dir, "old.jsonl", []provider.Message{
{Role: provider.RoleUser, Content: "old content that must not reappear"},
{Role: provider.RoleAssistant, Content: "old assistant reply"},
})
tab := newLiveHistoryTab(t, app, dir, oldPath, sess)
beforeGen := tab.SessionGeneration
result, err := app.ClearSessionForTab(tab.ID)
if err != nil {
t.Fatalf("ClearSessionForTab: %v", err)
}
if result.SessionPath == "" {
t.Fatal("sessionPath empty after clear")
}
if sameDesktopPath(result.SessionPath, oldPath) {
t.Fatalf("sessionPath stayed %q; want a rotated path", result.SessionPath)
}
if result.SessionGeneration <= beforeGen {
t.Fatalf("sessionGeneration = %d, want > %d", result.SessionGeneration, beforeGen)
}
if tab.SessionGeneration != result.SessionGeneration {
t.Fatalf("tab generation = %d, result = %d", tab.SessionGeneration, result.SessionGeneration)
}
if !sameDesktopPath(tab.currentSessionPath(), result.SessionPath) {
t.Fatalf("tab path = %q, result = %q", tab.currentSessionPath(), result.SessionPath)
}
meta := app.tabMeta(tab, true)
if meta.SessionGeneration != result.SessionGeneration || !sameDesktopPath(meta.SessionPath, result.SessionPath) {
t.Fatalf("tabMeta identity = path:%q gen:%d, want path:%q gen:%d",
meta.SessionPath, meta.SessionGeneration, result.SessionPath, result.SessionGeneration)
}
// Replacement path must not share the old file identity.
if filepath.Base(result.SessionPath) == filepath.Base(oldPath) && sameDesktopPath(result.SessionPath, oldPath) {
t.Fatal("replacement path collided with destroyed session")
}
}
func TestResetSharedHostMCPClearsStillEnabledFailure(t *testing.T) {
host := plugin.NewHost()
// Same-name server remains "enabled" after ClearAuth; a stale build must
// not keep the pre-clear Host failure/client around.
host.RecordFailure(plugin.Spec{Name: "time"}, assertError("auth cleared"))
if len(host.Failures()) == 0 {
t.Fatal("expected recorded failure before reset")
}
(&App{}).resetSharedHostMCP(host)
if got := host.Failures(); len(got) != 0 {
t.Fatalf("failures after reset = %+v, want empty", got)
}
if names := host.ServerNames(); len(names) != 0 {
t.Fatalf("servers after reset = %v, want empty", names)
}
}
type assertError string
func (e assertError) Error() string { return string(e) }
+25 -4
View File
@@ -28,10 +28,31 @@ func (a *App) currentExtensionGeneration() uint64 {
return a.extensionGeneration.Load()
}
// purgeUnwantedSharedHostServers removes Host clients that are no longer
// enabled. Used when an off-lock controller build mutates the shared Host and
// is later abandoned due to a configuration generation bump: controller cleanup
// is a no-op for SharedHost, so deleted MCP servers would otherwise stick.
// resetSharedHostMCP drops SharedHost MCP clients after a stale build loses the
// extension generation race (SharedHost cleanup is a no-op on controller close).
func (a *App) resetSharedHostMCP(host *plugin.Host) {
if host == nil {
return
}
names := map[string]bool{}
for _, name := range host.ServerNames() {
names[name] = true
}
for _, name := range host.ConnectingServers() {
names[name] = true
}
for _, failure := range host.Failures() {
if name := strings.TrimSpace(failure.Name); name != "" {
names[name] = true
}
}
for name := range names {
host.Remove(name)
host.ClearFailure(name)
}
}
// purgeUnwantedSharedHostServers removes Host clients no longer enabled.
func (a *App) purgeUnwantedSharedHostServers(root string, host *plugin.Host) {
if a == nil || host == nil {
return
+1 -1
View File
@@ -111,7 +111,7 @@ func TestTabScopedSessionActionsIgnoreFocusedTab(t *testing.T) {
}
// Restore content so ClearSession exercises its non-blank path too.
targetCtrl.history = []provider.Message{{Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "clear me"}}
if err := app.ClearSessionForTab("target"); err != nil {
if _, err := app.ClearSessionForTab("target"); err != nil {
t.Fatalf("ClearSessionForTab: %v", err)
}
if err := app.RewindForTab("target", 1, "conversation"); err != nil {
+5 -5
View File
@@ -164,6 +164,7 @@ type WorkspaceTab struct {
TopicTitle string // display title
topicTitleSource string // auto or manual; controls localization at API boundaries
SessionPath string // exact .jsonl file this tab continues
SessionGeneration uint64 // bumps on session rotation (clear/new); frontend hydrate identity
ReadOnly bool // true for external channel transcripts opened for browsing
Ctrl control.SessionAPI // nil while booting / on error
Label string // model label (for the tab badge)
@@ -2178,6 +2179,7 @@ type TabMeta struct {
SessionPath string `json:"sessionPath,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
SessionGeneration uint64 `json:"sessionGeneration,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
ProjectColor string `json:"projectColor,omitempty"`
Label string `json:"label"`
@@ -2240,6 +2242,7 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
SessionPath: sessionPath,
SessionRevision: sessionRevision,
SessionDigest: sessionDigest,
SessionGeneration: tab.SessionGeneration,
ReadOnly: tab.ReadOnly,
Label: tab.Label,
Ready: runtimeView.Phase == sessionRuntimeReady && tab.Ctrl != nil,
@@ -3898,7 +3901,6 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession
extensionGen := a.currentExtensionGeneration()
sharedHost := a.acquireSharedHost(rootKey)
sink := a.desktopControllerSink(buildSink, cfg.Notifications)
ctrl, err := boot.Build(buildCtx, boot.Options{
Model: model,
RequireKey: false,
@@ -3948,10 +3950,8 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession
return
}
if a.currentExtensionGeneration() != extensionGen {
// Plugin/MCP configuration changed during the off-lock build. SharedHost
// cleanup is a no-op, so drop any Host clients the stale build may have
// re-registered after a concurrent Remove/Update before abandoning.
a.purgeUnwantedSharedHostServers(root, sharedHost)
// Stale build lost the MCP config race; reset SharedHost then rebuild.
a.resetSharedHostMCP(sharedHost)
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
a.scheduleDeferredStartupBuild(tab.ID)
return