返回 DeepSeek-Reasonix
remote_tab_spectator.go
根目录 / desktop / remote_tab_spectator.go
1 package main
2
3 import (
4 "context"
5 "log/slog"
6 "net/http"
7 "strings"
8 "time"
9
10 "reasonix/internal/agent"
11 )
12
13 // markRemoteTabSpectatorIfLocalOwned reconciles ownership when a mid-view
14 // status/notice needs an authoritative probe, or when an older Serve omitted
15 // the synchronous ownership header used by attach and resume responses.
16 func (a *App) markRemoteTabSpectatorIfLocalOwned(ctx context.Context, tabID string, client *http.Client, base string, gen uint64) {
17 a.remoteTabMu.Lock()
18 tab := a.remoteTabs[tabID]
19 path := ""
20 selectionRevision, reclaimRevision := uint64(0), uint64(0)
21 if tab != nil && tab.gen == gen {
22 path = strings.TrimSpace(tab.routing.currentPath)
23 selectionRevision = tab.selectionRevision
24 reclaimRevision = tab.ownership.reclaimRevision
25 }
26 a.remoteTabMu.Unlock()
27 if path == "" {
28 return
29 }
30 probeCtx, probeCancel := context.WithTimeout(ctx, 15*time.Second)
31 view, probeErr := takeoverOwnership(probeCtx, client, base, path)
32 probeCancel()
33 // A transport failure says nothing about ownership. Preserve the current
34 // spectator pin and let the next status/notice probe retry instead of
35 // briefly reopening input against an ownership state we could not prove.
36 if probeErr != nil {
37 return
38 }
39 // Serve mounts both holders — "external" (mirrored local writer) and
40 // "other" (lease without a registered mirror) — as read-only spectator
41 // surfaces, so the banner and take-back button must appear for either.
42 locallyOwned := takeoverViewLocallyOwned(view)
43 a.remoteTabMu.Lock()
44 current := a.remoteTabs[tabID]
45 if current != tab || current == nil || current.gen != gen || current.client != client ||
46 current.selectionRevision != selectionRevision ||
47 agent.CanonicalSessionPath(current.routing.currentPath) != agent.CanonicalSessionPath(path) {
48 a.remoteTabMu.Unlock()
49 return
50 }
51 if !locallyOwned {
52 // The probed session is not locally owned (e.g. a fresh /new or a
53 // free session). Clear any stale spectator pin left over from the
54 // previous session so the banner and read-only composer go away.
55 current.session.takenOver = false
56 a.remoteTabMu.Unlock()
57 return
58 }
59 // A reclaim completed while this probe was in flight: its answer describes
60 // the ownership that reclaim ended. Same fence in-flight status gets.
61 if current.ownership.reclaimRevision != reclaimRevision {
62 a.remoteTabMu.Unlock()
63 return
64 }
65 current.session.takenOver = true
66 a.remoteTabMu.Unlock()
67 // No remote-tab:updated emit: racing hydration re-renders mid-fetch and
68 // looks like a retry loop. The /status poll picks takenOver up instead.
69 slog.Info("desktop: remote tab switched to spectator on local-owned session",
70 "tab", tabID, "session", path, "holder", view.Holder)
71 }
72
73 // probeSpectatorAfterNotice re-probes spectator state when Serve broadcasts a
74 // takeover or reclaim notice for a session. The entry-time probe cannot see
75 // mid-view transitions, so without this the banner and the composer lock
76 // drift from the real ownership until the next session switch.
77 func (a *App) probeSpectatorAfterNotice(tabID string, gen uint64, client *http.Client, base, framePath string) {
78 a.remoteTabMu.Lock()
79 tab := a.remoteTabs[tabID]
80 viewing := tab != nil && tab.gen == gen && tab.routing.currentPath != "" &&
81 agent.CanonicalSessionPath(tab.routing.currentPath) == agent.CanonicalSessionPath(framePath)
82 a.remoteTabMu.Unlock()
83 if !viewing {
84 return
85 }
86 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
87 defer cancel()
88 a.markRemoteTabSpectatorIfLocalOwned(ctx, tabID, client, base, gen)
89 }
90
91 // clearRemoteTabSpectator drops the read-only spectator pin when the route it
92 // was pinned to lost the publication fence (or after reclaim lands the
93 // foreground on the session).
94 func (a *App) clearRemoteTabSpectator(tabID string, gen uint64) {
95 a.remoteTabMu.Lock()
96 current := a.remoteTabs[tabID]
97 // gen 0 means "any generation" — used by failure cleanups where the
98 // caller doesn't track the exact generation.
99 if current == nil || (gen != 0 && current.gen != gen) {
100 a.remoteTabMu.Unlock()
101 return
102 }
103 if !current.session.takenOver {
104 a.remoteTabMu.Unlock()
105 return
106 }
107 current.session.takenOver = false
108 a.remoteTabMu.Unlock()
109 // No emit: let the /status poll propagate the change.
110 }
111
111 lines GO