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