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