返回 DeepSeek-Reasonix
topic_activation.go
根目录 / desktop / topic_activation.go
1 package main
2
3 import (
4 "crypto/rand"
5 "encoding/hex"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 )
14
15 // topic_activation.go implements the two-phase topic activation used by the
16 // single-conversation-surface layout.
17 //
18 // Phase 1 (synchronous, inside StartTopicActivation): the tab is opened or
19 // reused, becomes the active tab, and tabs are persisted — the visible surface
20 // switches immediately, exactly as ActivateTopic behaves today. The caller
21 // gets a ticket with the tab meta right away.
22 //
23 // Phase 2 (background completion): the controller build started by the open
24 // path finishes (the completion waits on the tab's build-done channel), then
25 // — only if this activation is still the latest request — the other visible
26 // tabs are pruned (keepOnlyVisibleTab) and a terminal "ready"/"failed" event
27 // is emitted on the "topic:activation" channel. A superseded activation's
28 // completion emits nothing further: the "cancelled" event is emitted up front,
29 // at supersede time, by whichever activation or legacy surface switch replaced
30 // it.
31 //
32 // Generation protocol: activationGen bumps every time a new ticketed
33 // activation starts or a legacy surface switch (ActivateTopic,
34 // EnsureBlankSurface, SetActiveTab to another tab) supersedes the pending one.
35 // The completion re-checks gen+requestID under singleSurfaceMu immediately
36 // before pruning, so a stale completion can never prune tabs a newer
37 // activation just created. Lock order note: the completion waits on the
38 // build-done channel BEFORE taking singleSurfaceMu, and the synchronous phase
39 // never waits on a build-done channel, so the two cannot deadlock.
40
41 const (
42 topicActivationEventChannel = "topic:activation"
43
44 topicActivationPhaseStarting = "starting"
45 topicActivationPhaseReady = "ready"
46 topicActivationPhaseFailed = "failed"
47 topicActivationPhaseCancelled = "cancelled"
48 )
49
50 // TopicActivationRequest is the input of StartTopicActivation. Scope is
51 // "project" (WorkspaceRoot required) or "global". SessionPath, when set,
52 // selects a concrete saved session like OpenTopicSession; otherwise the topic
53 // resolves to its latest session. RequestID is optional — the backend
54 // generates one when empty.
55 type TopicActivationRequest struct {
56 Selector *SessionSelector `json:"selector,omitempty"`
57 Scope string `json:"scope"`
58 WorkspaceRoot string `json:"workspaceRoot"`
59 TopicID string `json:"topicId"`
60 SessionPath string `json:"sessionPath"`
61 RequestID string `json:"requestId"`
62 }
63
64 // TopicActivationTicket is returned synchronously by StartTopicActivation. The
65 // frontend switches its visible surface from Meta immediately and tracks the
66 // background completion through "topic:activation" events keyed by RequestID.
67 type TopicActivationTicket struct {
68 RequestID string `json:"requestId"`
69 TabID string `json:"tabId"`
70 Meta TabMeta `json:"meta"`
71 }
72
73 // TopicActivationEvent is emitted on the "topic:activation" channel. Per
74 // requestId, "starting" is always emitted (synchronously, before the ticket is
75 // returned) and is followed by exactly one terminal event: "ready" or "failed"
76 // for the activation that wins, "cancelled" for one superseded before its
77 // completion ran. A superseded activation never emits "ready"/"failed".
78 // Ordering across requestIds follows request order for "starting"/"cancelled"
79 // (emitted under singleSurfaceMu); terminal events may lag arbitrarily since
80 // they depend on the controller build.
81 type TopicActivationEvent struct {
82 RequestID string `json:"requestId"`
83 TabID string `json:"tabId"`
84 Phase string `json:"phase"` // "starting" | "ready" | "failed" | "cancelled"
85 Error string `json:"error,omitempty"`
86 }
87
88 func newTopicActivationRequestID() string {
89 var b [8]byte
90 if _, err := rand.Read(b[:]); err == nil {
91 return "act_" + hex.EncodeToString(b[:])
92 }
93 now := time.Now().UTC()
94 return fmt.Sprintf("act_%s_%09d", now.Format("20060102150405"), now.Nanosecond())
95 }
96
97 // emitTopicActivation delivers an activation lifecycle event. The test hook
98 // (when installed) replaces emission so tests observe events synchronously;
99 // production goes through the async runtime emitter and never blocks a build.
100 func (a *App) emitTopicActivation(ev TopicActivationEvent) {
101 a.mu.RLock()
102 hook := a.activationEventHook
103 a.mu.RUnlock()
104 if hook != nil {
105 hook(ev)
106 return
107 }
108 a.emitRuntimeEvent(topicActivationEventChannel, ev)
109 }
110
111 // supersedePendingTopicActivationLocked invalidates the pending ticketed
112 // activation, if any, and bumps the activation generation so its background
113 // completion becomes a no-op. Callers must hold a.mu. Returns the superseded
114 // requestID/tabID so the caller can emit "cancelled" after unlocking (""
115 // when nothing was pending or the pending activation already completed).
116 //
117 // When cancelBuild is true the pending activation's in-flight tab build is
118 // cancelled through the standard superseded-build mechanics — unless the new
119 // activation targets the same tab (exceptTabID), in which case the build is
120 // still needed. SetActiveTab passes false: a direct tab click does not prune
121 // the pending tab, so its build may legitimately finish.
122 func (a *App) supersedePendingTopicActivationLocked(exceptTabID string, cancelBuild bool) (string, string) {
123 reqID := a.latestActivationRequestID
124 tabID := a.pendingActivationTabID
125 a.activationGen++
126 a.latestActivationRequestID = ""
127 a.pendingActivationTabID = ""
128 if cancelBuild && tabID != "" && tabID != exceptTabID {
129 if prev := a.tabs[tabID]; prev != nil {
130 a.supersedeTabBuildLocked(prev)
131 }
132 }
133 return reqID, tabID
134 }
135
136 func (a *App) supersedePendingTopicActivation(exceptTabID string) (string, string) {
137 a.mu.Lock()
138 reqID, tabID := a.supersedePendingTopicActivationLocked(exceptTabID, true)
139 a.mu.Unlock()
140 return reqID, tabID
141 }
142
143 // finishTopicActivation clears the pending marker after a completion ran, but
144 // only when this activation is still the latest — a newer request's marker
145 // must survive an older completion's cleanup.
146 func (a *App) finishTopicActivation(gen uint64, requestID string) {
147 a.mu.Lock()
148 if a.activationGen == gen && a.latestActivationRequestID == requestID {
149 a.latestActivationRequestID = ""
150 a.pendingActivationTabID = ""
151 }
152 a.mu.Unlock()
153 }
154
155 // StartTopicActivation activates a topic on the single visible conversation
156 // surface and returns a ticket immediately after the surface switch; the
157 // controller build, old-session snapshot/lease handling, and visible-tab
158 // pruning complete in the background and are reported through
159 // "topic:activation" events.
160 //
161 // Starting a new activation cancels the previous pending one (generation bump
162 // + build cancel through the existing superseded-build path). Legacy surface
163 // switches (ActivateTopic, EnsureBlankSurface, SetActiveTab) participate in
164 // the same generation, so interleaved legacy and ticketed calls resolve
165 // deterministically to the last call.
166 func (a *App) StartTopicActivation(req TopicActivationRequest) (TopicActivationTicket, error) {
167 // Claim intent before source adoption can block. A later request must not
168 // be displaced by this request finishing its I/O last.
169 intent := a.desktopSessions.navigationSeq.Add(1)
170 if req.Selector != nil {
171 target, err := a.resolveSessionMutationTarget(*req.Selector)
172 if err != nil {
173 return TopicActivationTicket{}, err
174 }
175 req.Scope, req.WorkspaceRoot, req.TopicID, req.SessionPath = target.Scope, target.WorkspaceRoot, target.TopicID, target.SessionPath
176 if target.SessionRef.SessionID != "" {
177 req.SessionPath = sessionRoute(target.SessionRef.SessionID)
178 }
179 }
180 a.singleSurfaceMu.Lock()
181 defer a.singleSurfaceMu.Unlock()
182 if a.desktopSessions.navigationSeq.Load() != intent {
183 return TopicActivationTicket{}, errSessionNavigationSuperseded
184 }
185
186 var meta TabMeta
187 var err error
188 if strings.TrimSpace(req.SessionPath) != "" {
189 meta, err = a.openTopicSessionWithNavigation(req.Scope, req.WorkspaceRoot, req.TopicID, req.SessionPath, intent)
190 } else if strings.TrimSpace(req.Scope) == "project" {
191 meta, err = a.openProjectTab(req.WorkspaceRoot, req.TopicID)
192 } else {
193 meta, err = a.openGlobalTab(req.TopicID)
194 }
195 if err != nil {
196 // The open failed before anything changed hands: leave a previously
197 // pending activation untouched, same as ActivateTopic leaves state
198 // untouched on error.
199 return TopicActivationTicket{}, err
200 }
201
202 requestID := strings.TrimSpace(req.RequestID)
203 if requestID == "" {
204 requestID = newTopicActivationRequestID()
205 }
206
207 // The open succeeded and may already have started this tab's build (new
208 // tab) inside the call above. Register this activation as the latest and
209 // cancel the previous pending activation's build when it targets a
210 // different tab — its completion is guarded off by the generation bump
211 // either way.
212 a.mu.Lock()
213 prevReqID, prevTabID := a.supersedePendingTopicActivationLocked(meta.ID, true)
214 gen := a.activationGen
215 a.latestActivationRequestID = requestID
216 a.pendingActivationTabID = meta.ID
217 a.mu.Unlock()
218
219 if prevReqID != "" {
220 a.emitTopicActivation(TopicActivationEvent{RequestID: prevReqID, TabID: prevTabID, Phase: topicActivationPhaseCancelled})
221 }
222 a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: meta.ID, Phase: topicActivationPhaseStarting})
223
224 a.goSafe("topic-activation-completion", func() {
225 a.runTopicActivationCompletion(gen, requestID, meta.ID)
226 })
227
228 return TopicActivationTicket{RequestID: requestID, TabID: meta.ID, Meta: meta}, nil
229 }
230
231 // runTopicActivationCompletion is phase 2 of a ticketed activation. It waits
232 // for the tab's in-flight controller build (if any), then — only when the
233 // activation is still the latest — prunes the other visible tabs exactly once
234 // and emits the terminal event. Superseded completions return silently; their
235 // "cancelled" event was already emitted at supersede time.
236 func (a *App) runTopicActivationCompletion(gen uint64, requestID, tabID string) {
237 // Wait for the in-flight build first, WITHOUT holding singleSurfaceMu:
238 // the synchronous phase of a newer activation needs that mutex and never
239 // waits on a build, so this ordering cannot deadlock. A nil channel means
240 // no build is in flight (reuse/fast path, or the synchronous test build
241 // already finished) and the completion proceeds immediately.
242 a.mu.RLock()
243 tab := a.tabs[tabID]
244 var buildDone chan struct{}
245 if tab != nil {
246 buildDone = tab.buildDone
247 }
248 a.mu.RUnlock()
249 if buildDone != nil {
250 <-buildDone
251 }
252
253 // A reused/reattached runtime is already usable. Publish ready before
254 // prune: keepOnlyVisibleTab takes runtimeRebuildMu, which an in-flight
255 // MCP rebuild on the previous tab can hold for a long time.
256 emittedReady := a.emitTopicActivationReadyIfCurrent(gen, requestID, tabID)
257
258 // The generation check and the prune serialize against new activations
259 // through singleSurfaceMu: either this completion runs entirely before the
260 // next activation's synchronous phase (its tabs are not there to prune),
261 // or after it (the generation no longer matches and nothing is pruned).
262 a.singleSurfaceMu.Lock()
263 defer a.singleSurfaceMu.Unlock()
264
265 a.mu.RLock()
266 latest := a.activationGen == gen &&
267 a.latestActivationRequestID == requestID &&
268 a.pendingActivationTabID == tabID &&
269 a.tabs[tabID] != nil
270 a.mu.RUnlock()
271 if !latest {
272 return
273 }
274
275 if _, err := a.keepOnlyVisibleTab(tabID); err != nil {
276 a.finishTopicActivation(gen, requestID)
277 if !emittedReady {
278 a.emitTopicActivation(TopicActivationEvent{
279 RequestID: requestID,
280 TabID: tabID,
281 Phase: topicActivationPhaseFailed,
282 // keepOnlyVisibleTab errors can wrap snapshot/path details; the
283 // event stays generic, the slog entry keeps the specifics.
284 Error: "failed to switch the visible session",
285 })
286 }
287 return
288 }
289
290 if emittedReady {
291 a.finishTopicActivation(gen, requestID)
292 a.scheduleTabMetaExtrasRefresh(tabID)
293 return
294 }
295
296 // Preserve today's failure semantics: a failed build leaves the tab
297 // visible with StartupErr and a failed/lease_blocked runtime phase; the
298 // prune still happened (the surface switched), only the terminal event
299 // differs.
300 a.mu.RLock()
301 tab = a.tabs[tabID]
302 ready := tab != nil && tab.Ready && tab.Ctrl != nil
303 startupErr, leaseHeld := "", false
304 if tab != nil {
305 startupErr = tab.StartupErr
306 leaseHeld = tab.StartupErrLeaseHeld
307 }
308 a.mu.RUnlock()
309
310 a.finishTopicActivation(gen, requestID)
311 switch {
312 case ready:
313 a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: tabID, Phase: topicActivationPhaseReady})
314 // The activation just made this tab visible: refresh the expensive
315 // meta fields off-lock and push them to the frontend.
316 a.scheduleTabMetaExtrasRefresh(tabID)
317 default:
318 a.emitTopicActivation(TopicActivationEvent{
319 RequestID: requestID,
320 TabID: tabID,
321 Phase: topicActivationPhaseFailed,
322 Error: sanitizedTopicActivationError(startupErr, leaseHeld),
323 })
324 }
325 }
326
327 func (a *App) emitTopicActivationReadyIfCurrent(gen uint64, requestID, tabID string) bool {
328 a.mu.RLock()
329 tab := a.tabs[tabID]
330 ok := a.activationGen == gen &&
331 a.latestActivationRequestID == requestID &&
332 a.pendingActivationTabID == tabID &&
333 tab != nil && tab.Ready && tab.Ctrl != nil
334 a.mu.RUnlock()
335 if !ok {
336 return false
337 }
338 a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: tabID, Phase: topicActivationPhaseReady})
339 return true
340 }
341
342 // sanitizedTopicActivationError keeps local paths and lease-holder writer IDs
343 // out of the activation event. The lease-busy message is already sanitized;
344 // everything else degrades to a generic summary — the full detail remains
345 // available to the frontend through Meta.StartupErr, same as today.
346 func sanitizedTopicActivationError(startupErr string, leaseHeld bool) string {
347 if leaseHeld && strings.TrimSpace(startupErr) != "" {
348 return startupErr
349 }
350 if strings.TrimSpace(startupErr) != "" {
351 return "session failed to start"
352 }
353 return "session is not ready"
354 }
355
356 // --- MetaForTab fast-path cache --------------------------------------------
357
358 // tabMetaRefreshEventChannel carries TabMetaRefreshEvent after a background
359 // refresh of the expensive Meta fields (git branch, image input capability).
360 const tabMetaRefreshEventChannel = "tab:meta"
361
362 // TabMetaRefreshEvent pushes a freshly recomputed Meta to the frontend after
363 // the cached expensive fields changed. The frontend should treat it like a
364 // MetaForTab response for TabID.
365 type TabMetaRefreshEvent struct {
366 TabID string `json:"tabId"`
367 Meta Meta `json:"meta"`
368 }
369
370 // tabMetaExtras is the per-tab cached snapshot of the MetaForTab fields that
371 // are too expensive to compute on the request path. It is keyed conservatively
372 // by the workspace root the values were computed for: a root mismatch serves
373 // empty values rather than another root's branch/capability. model keys the
374 // image-input computation so a model switch invalidates it without
375 // invalidating the (root-scoped) git branch or fallback setting.
376 type tabMetaExtras struct {
377 controller control.SessionAPI
378 modelSettingsPending bool
379 workspaceRoot string
380 model string
381 gitBranch string
382 imageInputEnabled bool
383 visionFallbackEnabled bool
384 fetchedAt time.Time
385 }
386
387 // tabMetaExtrasFor returns the cached extras valid for (root, model) and
388 // whether a background refresh should be scheduled. A stale-but-root-matching
389 // git branch is served while refreshing (same policy the old
390 // workspaceGitBranchForMeta cache used); a model mismatch hides only the
391 // image-input flag, and a root mismatch hides both.
392 func tabMetaExtrasFor(tab *WorkspaceTab, root, model string) (tabMetaExtras, bool) {
393 var zero tabMetaExtras
394 if tab == nil || root == "" {
395 return zero, false
396 }
397 extras := tab.metaExtras.Load()
398 if extras == nil {
399 return zero, true
400 }
401 if extras.workspaceRoot != root {
402 return zero, true
403 }
404 out := *extras
405 refresh := time.Since(extras.fetchedAt) > workspaceGitBranchCacheTTL
406 if extras.model != model {
407 out.imageInputEnabled = false
408 refresh = true
409 }
410 return out, refresh
411 }
412
413 // scheduleTabMetaExtrasRefresh starts a background refresh unless one is
414 // already in flight for this tab. Safe to call from request paths.
415 func (a *App) scheduleTabMetaExtrasRefresh(tabID string) {
416 a.mu.RLock()
417 tab := a.tabs[tabID]
418 a.mu.RUnlock()
419 if tab == nil || !tab.metaExtrasRefreshing.CompareAndSwap(false, true) {
420 return
421 }
422 a.goSafe("tab-meta-extras-refresh", func() {
423 a.refreshTabMetaExtras(tab)
424 })
425 }
426
427 // refreshTabMetaExtras recomputes the expensive Meta fields off-lock (a cheap
428 // `git rev-parse` plus one config load per refresh is fine in the background),
429 // publishes them into the tab's cache, and pushes the refreshed Meta to the
430 // frontend on "tab:meta". The tab-identity re-check after the off-lock stretch
431 // keeps a pruned/replaced tab from receiving another tab's values.
432 func (a *App) refreshTabMetaExtras(tab *WorkspaceTab) {
433 if tab == nil {
434 return
435 }
436 defer tab.metaExtrasRefreshing.Store(false)
437 a.mu.RLock()
438 if a.tabs[tab.ID] != tab {
439 a.mu.RUnlock()
440 return
441 }
442 root := tab.WorkspaceRoot
443 model := tab.model
444 ctrl := tab.Ctrl
445 snapshotModel, snapshotRoot := model, root
446 a.mu.RUnlock()
447 if root == "" {
448 root, _ = os.Getwd()
449 }
450
451 gitBranch := ""
452 if root != "" {
453 gitBranch = workspaceGitBranch(root)
454 }
455 imageInputEnabled := false
456 visionFallbackEnabled := false
457 if cfg, err := a.loadConfigForVision(root); err == nil && cfg != nil {
458 if model == "" {
459 model = cfg.DefaultModel
460 }
461 if entry, ok := cfg.ResolveModel(model); ok {
462 imageInputEnabled = config.EffectiveVision(entry)
463 }
464 visionFallbackEnabled = strings.TrimSpace(cfg.Agent.VisionModel) != ""
465 }
466 if snapshot, ok := ctrl.(interface{ ImageInputSnapshot() (bool, bool, bool) }); ok {
467 if enabled, fallback, available := snapshot.ImageInputSnapshot(); available {
468 imageInputEnabled, visionFallbackEnabled = enabled, fallback
469 }
470 }
471
472 pending, _ := modelSettingsNeedApply(ctrl)
473 a.mu.Lock()
474 if a.tabs[tab.ID] != tab || tab.Ctrl != ctrl || tab.model != snapshotModel || tab.WorkspaceRoot != snapshotRoot {
475 a.mu.Unlock()
476 return
477 }
478 tab.metaExtras.Store(&tabMetaExtras{
479 controller: ctrl,
480 modelSettingsPending: pending,
481 workspaceRoot: root,
482 model: model,
483 gitBranch: gitBranch,
484 imageInputEnabled: imageInputEnabled,
485 visionFallbackEnabled: visionFallbackEnabled,
486 fetchedAt: time.Now(),
487 })
488 a.mu.Unlock()
489
490 meta := a.MetaForTab(tab.ID)
491 a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: tab.ID, Meta: meta})
492 }
493
493 lines GO