mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
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:
1 parent
3f1be32bc9
commit
af6b1cbe0b
11 files changed
+277
-123
No files matched your search
+11
-63
@@ -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
@@ -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");
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user