返回 DeepSeek-Reasonix
session_canonical_retarget.go
根目录 / desktop / session_canonical_retarget.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "strings"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/sessioncatalog"
13 )
14
15 func (a *App) resolveOpenTopicSessionPath(scope, workspaceRoot, sessionPath string) (string, string) {
16 actualRoot := workspaceRoot
17 if scope == "global" {
18 actualRoot = globalWorkspaceRoot()
19 }
20 // Keep a live controller on this path (including paused). Opening a
21 // different ordinary session of the same topic must still switch.
22 if continued := a.continuePathForOpen(sessionPath); continued != "" {
23 if a.sessionHasLiveController(sessionPath) {
24 return actualRoot, sessionPath
25 }
26 sessionPath = continued
27 }
28 return actualRoot, sessionPath
29 }
30
31 func (a *App) sessionHasLiveController(path string) bool {
32 if a == nil {
33 return false
34 }
35 a.mu.RLock()
36 defer a.mu.RUnlock()
37 return a.liveRuntimeTabMatchingLocked(nil, path) != nil
38 }
39
40 func (a *App) continuePathForOpen(path string) string {
41 path = strings.TrimSpace(path)
42 if path == "" {
43 return ""
44 }
45 catalog := a.sessionCatalog.Load()
46 if catalog == nil {
47 return ""
48 }
49 ctx := context.Background()
50 rec, ok, err := catalog.GetSession(ctx, path)
51 if err != nil || !ok {
52 return a.continuePathForMissingParent(ctx, catalog, path)
53 }
54 if rec.TopicID == "" {
55 return ""
56 }
57 topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: rec.Scope, WorkspaceRoot: rec.WorkspaceRoot, TopicID: rec.TopicID})
58 if err != nil || !ok {
59 return ""
60 }
61 return sessioncatalog.OrdinaryContinuePath(topic.Sessions, path)
62 }
63
64 func (a *App) continuePathForMissingParent(ctx context.Context, catalog *sessioncatalog.Catalog, path string) string {
65 parentID := agent.BranchID(path)
66 if parentID == "" {
67 return ""
68 }
69 // desktop-tabs.json may still name a parent that lineage folded off the
70 // ordinary row. Look up the topic by the filename id.
71 for _, target := range a.sessionCatalogTargets() {
72 page, err := catalog.ListTopics(ctx, sessioncatalog.TopicPageRequest{
73 Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot, Limit: sessioncatalog.MaxLimit,
74 })
75 if err != nil {
76 continue
77 }
78 for _, topic := range page.Items {
79 for _, session := range topic.Sessions {
80 if session.ParentID == parentID || strings.TrimSpace(session.RecoveryGroupID) == parentID {
81 if next := sessioncatalog.OrdinaryContinuePath(topic.Sessions, path); next != "" {
82 return next
83 }
84 }
85 }
86 }
87 }
88 return ""
89 }
90
91 func (a *App) resumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
92 return a.resumeSessionForTranscript(tabID, path, limit, true)
93 }
94
95 func (a *App) resumeSessionForTranscript(tabID, path string, limit int, includeHistory bool) (HistoryPage, error) {
96 if ref, adopted, err := a.legacyCanonicalRef(a.bootContext(), path); err != nil {
97 return HistoryPage{}, err
98 } else if adopted {
99 path = sessionRoute(ref.SessionID)
100 }
101 started := time.Now()
102 phases := HistorySwitchPhases{Outcome: "ok"}
103 defer func() { logSessionSwitchPhases(phases, started) }()
104 tab, ctrl := a.tabAndCtrlByID(tabID)
105 if tab == nil || ctrl == nil {
106 phases.Outcome = "tab_not_ready"
107 return HistoryPage{}, fmt.Errorf("tab is not ready")
108 }
109 if _, isV3 := parseSessionRoute(path); isV3 {
110 page, err := a.resumeCanonicalSessionForTranscript(tab, ctrl, path, limit, includeHistory)
111 if err != nil {
112 phases.Outcome = "v3_rebind_failed"
113 return HistoryPage{}, err
114 }
115 phases.TotalMs = elapsedMs(started)
116 page.Switch = &phases
117 return page, nil
118 }
119 // Resolve the continuation before the first read so the loaded session, the
120 // rebound path, and the returned fingerprint all name the same file.
121 resolveStarted := time.Now()
122 if continued := a.continuePathForOpen(path); continued != "" {
123 current := tab.currentSessionPath()
124 if !tab.hasActiveRuntimeWork() || sessionRuntimeKey(current) != sessionRuntimeKey(path) {
125 path = continued
126 }
127 }
128 sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
129 if err != nil {
130 phases.Outcome = "invalid_path"
131 return HistoryPage{}, err
132 }
133 phases.ResolveMs = elapsedMs(resolveStarted)
134 loadStarted := time.Now()
135 phases.DurableReads++
136 loaded, err := loadResumableSession(sessionPath)
137 if err != nil {
138 phases.Outcome = "load_failed"
139 return HistoryPage{}, err
140 }
141 phases.LoadMs = elapsedMs(loadStarted)
142 phases.LoadedCount = loaded.Len()
143 phases.LoadedBytes = sessionFileBytes(sessionPath)
144
145 page, err := a.switchToLoadedSessionPage(tab, loaded, sessionPath, false, includeHistory, limit, &phases)
146 if err != nil {
147 return HistoryPage{}, err
148 }
149 phases.TotalMs = elapsedMs(started)
150 page.Switch = &phases
151 return page, nil
152 }
153
154 // switchToLoadedSessionPage commits tab onto a session that is already loaded
155 // and optionally builds a legacy page from a matching preload. Modern callers
156 // take their first screen from the authoritative transcript snapshot instead.
157 func (a *App) switchToLoadedSessionPage(tab *WorkspaceTab, loaded *agent.Session, sessionPath string, readOnly, includeHistory bool, limit int, phases *HistorySwitchPhases) (HistoryPage, error) {
158 rebindStarted := time.Now()
159 if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
160 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
161 phases.Outcome = "rebind_failed"
162 return HistoryPage{}, err
163 }
164 }
165 a.setTabReadOnly(tab.ID, readOnly)
166 // The rebind republishes tab.Ctrl; a nil controller here means the switch did
167 // not commit, and the caller must keep the previous surface recoverable.
168 _, reboundCtrl := a.tabAndCtrlByID(tab.ID)
169 if reboundCtrl == nil {
170 phases.Outcome = "controller_missing"
171 return HistoryPage{}, fmt.Errorf("tab is not ready after session rebind")
172 }
173 phases.RebindMs = elapsedMs(rebindStarted)
174 if !includeHistory {
175 return HistoryPage{Messages: []HistoryMessage{}}, nil
176 }
177
178 buildStarted := time.Now()
179 page, durableRead := historyPageForController(tab, reboundCtrl, loaded, sessionPath, 0, limit)
180 phases.HistoryMs = elapsedMs(buildStarted)
181 if durableRead {
182 phases.DurableReads++
183 }
184 phases.HistoryCount = len(page.Messages)
185 return page, nil
186 }
187
188 func elapsedMs(started time.Time) int64 {
189 return time.Since(started).Milliseconds()
190 }
191
192 // sessionFileBytes reports the durable log size for switch diagnostics. Only the
193 // size leaves this function; the path is never logged with it.
194 func sessionFileBytes(path string) int64 {
195 if info, err := os.Stat(path); err == nil && !info.IsDir() {
196 return info.Size()
197 }
198 return 0
199 }
200
201 func logSessionSwitchPhases(phases HistorySwitchPhases, started time.Time) {
202 slog.Debug("desktop: session switch",
203 "outcome", phases.Outcome,
204 "resolve_ms", phases.ResolveMs,
205 "load_ms", phases.LoadMs,
206 "rebind_ms", phases.RebindMs,
207 "history_ms", phases.HistoryMs,
208 "total_ms", elapsedMs(started),
209 "loaded_messages", phases.LoadedCount,
210 "loaded_bytes", phases.LoadedBytes,
211 "history_entries", phases.HistoryCount,
212 "durable_reads", phases.DurableReads,
213 )
214 }
215
216 func (a *App) retargetOpenTabsToContinuations() {
217 if a == nil {
218 return
219 }
220 type candidate struct {
221 tab *WorkspaceTab
222 current string
223 }
224 a.mu.RLock()
225 items := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
226 collect := func(tab *WorkspaceTab) {
227 if tab == nil || tab.hasActiveRuntimeWork() {
228 return
229 }
230 items = append(items, candidate{tab: tab, current: tab.currentSessionPath()})
231 }
232 for _, tab := range a.tabs {
233 collect(tab)
234 }
235 for _, tab := range a.detachedSessions {
236 collect(tab)
237 }
238 a.mu.RUnlock()
239 type pending struct {
240 tab *WorkspaceTab
241 next string
242 }
243 ready := make([]pending, 0, len(items))
244 for _, item := range items {
245 next := a.continuePathForOpen(item.current)
246 if next == "" || sessionRuntimeKey(next) == sessionRuntimeKey(item.current) {
247 continue
248 }
249 ready = append(ready, pending{tab: item.tab, next: next})
250 }
251 for _, item := range ready {
252 if item.tab.hasActiveRuntimeWork() {
253 continue
254 }
255 if item.tab.Ctrl == nil {
256 a.mu.Lock()
257 if !item.tab.hasActiveRuntimeWork() && (a.tabs[item.tab.ID] == item.tab || a.detachedSessions[sessionRuntimeKey(item.tab.currentSessionPath())] == item.tab) {
258 item.tab.SessionPath = item.next
259 a.saveTabsLocked()
260 }
261 a.mu.Unlock()
262 continue
263 }
264 if err := a.rebindTabToSessionPath(item.tab, item.next); err != nil {
265 continue
266 }
267 }
268 }
269
269 lines GO