返回 DeepSeek-Reasonix
remote_tab_multisession.go
根目录 / desktop / remote_tab_multisession.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 )
10
11 type remoteTabSessionRouting struct {
12 currentPath string
13 // rehydratingPath is the provisional route epoch (Serve has not confirmed
14 // currentPath yet). It opens when an identity is committed ahead of its
15 // /resume and closes on that resume's outcome or the generation's retirement.
16 rehydratingPath string
17 rehydratingFrames []json.RawMessage
18 running map[string]bool
19 revision uint64
20 // pathRevision changes only when foreground identity changes. Unlike
21 // revision, background running/listing refreshes do not advance it.
22 pathRevision uint64
23 }
24
25 func lockRemoteTabRoute(tab *remoteTab) func() {
26 if tab == nil {
27 return func() {}
28 }
29 tab.routeEventMu.Lock()
30 return tab.routeEventMu.Unlock
31 }
32
33 // enterRemoteSession is the compatibility wrapper used by bridge tests.
34 func enterRemoteSession(ctx context.Context, client *http.Client, base string, opts RemoteTabOpenOptions) error {
35 _, err := enterRemoteSessionTarget(ctx, client, base, opts)
36 return err
37 }
38
39 // preflightRemoteSessionTarget resolves the foreground identity without
40 // mutating Serve. Attach uses it to publish routing before the event pump can
41 // observe an immediate replay from a detached controller.
42 func preflightRemoteSessionTarget(ctx context.Context, client *http.Client, base string, opts RemoteTabOpenOptions) (serveSessionEntry, error) {
43 name := strings.TrimSpace(opts.SessionName)
44 if sessionID := strings.TrimSpace(opts.SessionID); sessionID != "" {
45 return serveSessionEntry{Name: name, SessionID: sessionID, Title: strings.TrimSpace(opts.SessionTitle), Current: true}, nil
46 }
47 if path := strings.TrimSpace(opts.SessionPath); path != "" {
48 return serveSessionEntry{Name: name, Path: path, Title: strings.TrimSpace(opts.SessionTitle), Current: true}, nil
49 }
50 if name == "" {
51 return serveCurrentSession(ctx, client, base)
52 }
53 sessions, err := serveSessions(ctx, client, base)
54 if err != nil {
55 return serveSessionEntry{}, err
56 }
57 for _, session := range sessions {
58 if session.Name == name {
59 session.Current = true
60 return session, nil
61 }
62 }
63 return serveSessionEntry{}, fmt.Errorf("remote session %q not found", name)
64 }
65
66 // remoteSessionResumeBody is the single request builder for /resume. The
67 // identity catalog deliberately leaves Path empty for canonical sessions, so
68 // callers must carry SessionID through instead of serializing an empty path.
69 // Name is retained as a compatibility hint for Serve versions that can
70 // resolve a listed session by name.
71 func remoteSessionResumeBody(target serveSessionEntry) ([]byte, error) {
72 if remoteSessionRoute(target) == "" {
73 return nil, fmt.Errorf("remote session %q has no resumable identity", strings.TrimSpace(target.Name))
74 }
75 return json.Marshal(map[string]string{
76 "path": strings.TrimSpace(target.Path),
77 "hostId": strings.TrimSpace(target.HostID),
78 "sessionId": strings.TrimSpace(target.SessionID),
79 "name": strings.TrimSpace(target.Name),
80 })
81 }
82
83 func enterRemoteSessionTarget(ctx context.Context, client *http.Client, base string, opts RemoteTabOpenOptions) (serveSessionEntry, error) {
84 name := strings.TrimSpace(opts.SessionName)
85 if opts.NewSession {
86 identity, err := servePostSessionIdentityForSession(ctx, client, serveURL(base, "/new"), nil, "")
87 if err != nil {
88 return serveSessionEntry{}, err
89 }
90 return serveSessionEntry{Path: identity.Path, SessionID: identity.SessionID, Current: true}, nil
91 }
92 if sessionID := strings.TrimSpace(opts.SessionID); sessionID != "" {
93 body, err := remoteSessionResumeBody(serveSessionEntry{Name: name, SessionID: sessionID})
94 if err != nil {
95 return serveSessionEntry{}, err
96 }
97 identity, err := servePostSessionIdentityForSession(ctx, client, serveURL(base, "/resume"), body, "")
98 if err != nil {
99 return serveSessionEntry{}, err
100 }
101 if identity.SessionID != "" {
102 sessionID = identity.SessionID
103 }
104 return serveSessionEntry{Name: name, SessionID: sessionID, Title: strings.TrimSpace(opts.SessionTitle), Current: true, TakenOver: identity.TakenOver}, nil
105 }
106 if sessionPath := strings.TrimSpace(opts.SessionPath); sessionPath != "" {
107 body, err := remoteSessionResumeBody(serveSessionEntry{Name: name, Path: sessionPath})
108 if err != nil {
109 return serveSessionEntry{}, err
110 }
111 identity, err := servePostSessionIdentityForSession(ctx, client, serveURL(base, "/resume"), body, "")
112 if err != nil {
113 return serveSessionEntry{}, err
114 }
115 return serveSessionEntry{
116 Name: name, Path: sessionPath, SessionID: identity.SessionID, Title: strings.TrimSpace(opts.SessionTitle), Current: true,
117 TakenOver: strings.TrimSpace(identity.Path) != "",
118 }, nil
119 }
120 // Focus-only attaches retain the current session; only explicit NewSession
121 // may abandon it.
122 if name == "" {
123 current, _ := serveCurrentSession(ctx, client, base)
124 return current, nil
125 }
126 sessions, err := serveSessions(ctx, client, base)
127 if err != nil {
128 return serveSessionEntry{}, err
129 }
130 for _, session := range sessions {
131 if session.Name != name {
132 continue
133 }
134 body, err := remoteSessionResumeBody(session)
135 if err != nil {
136 return serveSessionEntry{}, err
137 }
138 identity, err := servePostSessionIdentityForSession(ctx, client, serveURL(base, "/resume"), body, "")
139 if err != nil {
140 return serveSessionEntry{}, err
141 }
142 session.Current = true
143 if identity.SessionID != "" {
144 session.SessionID = identity.SessionID
145 }
146 session.TakenOver = identity.TakenOver || strings.TrimSpace(identity.Path) != ""
147 return session, nil
148 }
149 return serveSessionEntry{}, fmt.Errorf("remote session %q not found", name)
150 }
151
152 func serveCurrentSession(ctx context.Context, client *http.Client, base string) (serveSessionEntry, error) {
153 sessions, err := serveSessions(ctx, client, base)
154 if err != nil {
155 return serveSessionEntry{}, err
156 }
157 for _, session := range sessions {
158 if session.Current {
159 return session, nil
160 }
161 }
162 return serveSessionEntry{}, nil
163 }
164
165 // installRemoteTabAttachRoute fences listings both before a target is installed
166 // and after Serve commits it, preventing either stale epoch from restoring the
167 // former current session.
168 func installRemoteTabAttachRoute(tab *remoteTab, path string) {
169 path = strings.TrimSpace(path)
170 if tab.routing.currentPath != path {
171 tab.routing.currentPath = path
172 tab.routing.pathRevision++
173 }
174 tab.routing.rehydratingPath = ""
175 tab.routing.rehydratingFrames = nil
176 if sessionID, ok := strings.CutPrefix(tab.routing.currentPath, remoteSessionIDRoutePrefix); ok {
177 tab.session.path = ""
178 tab.session.sessionID = sessionID
179 } else {
180 tab.session.path = tab.routing.currentPath
181 tab.session.sessionID = ""
182 }
183 tab.routing.revision++
184 }
185
186 func commitRemoteTabAttachRoute(tab *remoteTab, path string, reset bool) {
187 path = strings.TrimSpace(path)
188 if reset || tab.routing.currentPath != path {
189 resetRemoteTabForegroundRuntimeLocked(tab)
190 }
191 installRemoteTabAttachRoute(tab, path)
192 }
193
194 // routeRemoteTabFrame tracks background runtime without leaking its frames
195 // into the foreground reducer. Untagged frames remain legacy-compatible.
196 func (a *App) routeRemoteTabFrame(tabID string, gen uint64, sessionPath, kind string) bool {
197 a.remoteTabMu.Lock()
198 tab := a.remoteTabs[tabID]
199 if tab == nil || tab.gen != gen {
200 a.remoteTabMu.Unlock()
201 return false
202 }
203 if tab.routing.running == nil {
204 tab.routing.running = map[string]bool{}
205 }
206 changed := false
207 if sessionPath != "" {
208 switch kind {
209 case "turn_started":
210 tab.routing.revision++
211 changed = !tab.routing.running[sessionPath]
212 tab.routing.running[sessionPath] = true
213 case "turn_done":
214 tab.routing.revision++
215 changed = tab.routing.running[sessionPath]
216 tab.routing.running[sessionPath] = false
217 }
218 }
219 foreground := sessionPath == "" || tab.routing.currentPath != "" && sessionPath == tab.routing.currentPath
220 _, knownBackground := tab.routing.running[sessionPath]
221 // A detached turn can finish while background jobs remain. Its later notice
222 // makes /sessions authoritative again, so refresh project-tree rows without
223 // forwarding that background notice to the foreground reducer.
224 backgroundChanged := !foreground && (changed || kind == "turn_done" || kind == "notice" && knownBackground)
225 meta := remoteTabMetaLocked(tab)
226 a.remoteTabMu.Unlock()
227 if backgroundChanged {
228 a.emitRemoteEvent("remote-tab:updated", meta)
229 }
230 return foreground
231 }
232
233 // adoptRemoteTabFrameCurrent consumes Serve's publication-time foreground
234 // marker. Unlike the running cache, this marker follows switches initiated by
235 // other HTTP clients and slash/recovery path changes while a tab stays open.
236 func (a *App) adoptRemoteTabFrameCurrent(tabID string, gen uint64, sessionPath string, reset bool) {
237 if sessionPath == "" {
238 return
239 }
240 a.remoteTabMu.Lock()
241 tab := a.remoteTabs[tabID]
242 a.remoteTabMu.Unlock()
243 if tab == nil {
244 return
245 }
246 tab.routeEventMu.Lock()
247 defer tab.routeEventMu.Unlock()
248 resetTitle := ""
249 if reset {
250 resetTitle = a.localizedDefaultTopicTitle()
251 }
252 a.remoteTabMu.Lock()
253 current := a.remoteTabs[tabID]
254 if current != tab || current.gen != gen {
255 a.remoteTabMu.Unlock()
256 return
257 }
258 // A spectator watches the session it explicitly selected; the serve's
259 // foreground keeps emitting current markers for other sessions, and
260 // adopting one silently re-routes the tab while the spectator banner
261 // (takenOver) stays pinned to the old session — reclaim and submit then
262 // target a session the tab is not displaying.
263 if sessionPath != current.routing.currentPath && current.session.takenOver {
264 a.remoteTabMu.Unlock()
265 return
266 }
267 if !adoptRemoteTabSessionPathLocked(current, sessionPath) {
268 a.remoteTabMu.Unlock()
269 return
270 }
271 if reset {
272 current.session.name = ""
273 current.session.newSession = true
274 current.session.reset = true
275 current.topicTitle = resetTitle
276 } else {
277 // The routing marker has no title. Stop displaying the previous
278 // session's title while the authoritative /sessions row is fetched.
279 current.topicTitle = remoteWorkspaceName(current.ref.Workspace)
280 }
281 meta := remoteTabMetaLocked(current)
282 ready := current.state == "ready"
283 a.remoteTabMu.Unlock()
284 a.emitRemoteEvent("remote-tab:updated", meta)
285 if !reset {
286 a.goRemoteTabSafe("remoteTabAdoptedTitle", func() { a.refreshRemoteTabTitle(tabID) })
287 }
288 if ready {
289 // The frontend treats ready -> ready as a new surface generation. Emit it
290 // before the triggering frame is forwarded so that frame is buffered until
291 // the newly current session snapshot replaces the old transcript.
292 a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready"})
293 }
294 }
295
296 // resetRemoteTabForegroundRuntimeLocked drops controller-local prompts and
297 // runtime state before a fresh session becomes visible. Caller holds
298 // remoteTabMu.
299 func resetRemoteTabForegroundRuntimeLocked(tab *remoteTab) {
300 tab.pendingEvents = nil
301 tab.ownership.readyBarrierPending = false
302 tab.runtime.revision++
303 tab.runtime.running = false
304 tab.runtime.turnStartedAt = 0
305 tab.runtime.backgroundJobs = 0
306 tab.runtime.pendingPrompt = false
307 tab.runtime.cancelRequested = false
308 tab.runtime.cancellable = false
309 }
310
311 // adoptRemoteTabSessionPathLocked moves foreground-only state to a new session.
312 // Actionable events belong to the previous controller and must never be replayed
313 // into the newly hydrated surface. Caller holds remoteTabMu.
314 func adoptRemoteTabSessionPathLocked(tab *remoteTab, sessionPath string) bool {
315 sessionPath = strings.TrimSpace(sessionPath)
316 if tab == nil || sessionPath == "" || tab.routing.currentPath == sessionPath {
317 return false
318 }
319 running := tab.routing.running[sessionPath]
320 resetRemoteTabForegroundRuntimeLocked(tab)
321 tab.routing.currentPath = sessionPath
322 tab.routing.pathRevision++
323 tab.routing.rehydratingPath = ""
324 tab.routing.rehydratingFrames = nil
325 tab.routing.revision++
326 if sessionID, ok := strings.CutPrefix(sessionPath, remoteSessionIDRoutePrefix); ok {
327 tab.session.path = ""
328 tab.session.sessionID = sessionID
329 } else {
330 tab.session.path = sessionPath
331 tab.session.sessionID = ""
332 }
333 tab.session.newSession = false
334 tab.session.reset = false
335 tab.runtime.running = running
336 tab.runtime.cancellable = running
337 return true
338 }
339
340 // remoteTabFramePathUnknown distinguishes a possible foreground recovery or
341 // slash-command rotation from a path already observed as background work.
342 func (a *App) remoteTabFramePathUnknown(tabID string, gen uint64, sessionPath, kind string) bool {
343 if sessionPath == "" {
344 return false
345 }
346 a.remoteTabMu.Lock()
347 defer a.remoteTabMu.Unlock()
348 tab := a.remoteTabs[tabID]
349 if tab == nil || tab.gen != gen || sessionPath == tab.routing.currentPath {
350 return false
351 }
352 _, knownBackground := tab.routing.running[sessionPath]
353 if !knownBackground {
354 return true
355 }
356 // Transitional frames are sparse and must remain compatible with a Serve
357 // that did not attach sessionCurrent. Revalidate them after a background
358 // cache hit; ordinary output is covered by the per-frame marker above.
359 switch kind {
360 case "turn_started", "approval_request", "ask_request":
361 return true
362 default:
363 return false
364 }
365 }
366
367 func (a *App) reconcileRemoteTabFramePath(tabID string, gen uint64, sessionPath string) bool {
368 if _, err := a.RemoteTabStatus(tabID); err != nil {
369 return false
370 }
371 a.remoteTabMu.Lock()
372 tab := a.remoteTabs[tabID]
373 if tab == nil || tab.gen != gen {
374 a.remoteTabMu.Unlock()
375 return false
376 }
377 if tab.routing.currentPath == sessionPath {
378 a.remoteTabMu.Unlock()
379 return true
380 }
381 // The authoritative status confirmed another foreground path. Remember
382 // this tag as background so a lossy detached stream does not synchronously
383 // fetch /status for every later token or notice from the same session.
384 if tab.routing.running == nil {
385 tab.routing.running = map[string]bool{}
386 }
387 var refresh *TabMeta
388 if _, known := tab.routing.running[sessionPath]; !known {
389 tab.routing.running[sessionPath] = false
390 tab.routing.revision++
391 meta := remoteTabMetaLocked(tab)
392 refresh = &meta
393 }
394 a.remoteTabMu.Unlock()
395 if refresh != nil {
396 a.emitRemoteEvent("remote-tab:updated", *refresh)
397 }
398 return false
399 }
400
401 func (a *App) routeRemoteTabFrameReconciled(tabID string, gen uint64, sessionPath, kind string) bool {
402 pathUnknown := a.remoteTabFramePathUnknown(tabID, gen, sessionPath, kind)
403 if a.routeRemoteTabFrame(tabID, gen, sessionPath, kind) {
404 return true
405 }
406 return pathUnknown && a.reconcileRemoteTabFramePath(tabID, gen, sessionPath) &&
407 a.routeRemoteTabFrame(tabID, gen, sessionPath, kind)
408 }
409
410 func (a *App) routeRemoteTabWireFrame(tabID string, gen uint64, sessionPath, kind string, current, reset bool) bool {
411 if current {
412 a.adoptRemoteTabFrameCurrent(tabID, gen, sessionPath, reset)
413 }
414 return a.routeRemoteTabFrameReconciled(tabID, gen, sessionPath, kind)
415 }
416
416 lines GO