返回 DeepSeek-Reasonix
session_takeover_promote.go
根目录 / desktop / session_takeover_promote.go
1 package main
2
3 import (
4 "fmt"
5 "net/http"
6
7 "reasonix/internal/agent"
8 )
9
10 var takeoverBuildLocalSpectatorCandidateForTest func(*App, *WorkspaceTab, tabRuntimeSnapshot, string, *agent.Session) (*sessionRebindCandidate, error)
11
12 type takeoverTabState uint8
13
14 const (
15 takeoverTabUnavailable takeoverTabState = iota
16 takeoverTabStartupBlocked
17 takeoverTabLocalSpectator
18 )
19
20 func (a *App) takeoverTabState(tab *WorkspaceTab) takeoverTabState {
21 if a == nil || tab == nil {
22 return takeoverTabUnavailable
23 }
24 a.mu.RLock()
25 defer a.mu.RUnlock()
26 return a.takeoverTabStateLocked(tab)
27 }
28
29 func (a *App) takeoverTabStateAt(tab *WorkspaceTab, epoch, path string) takeoverTabState {
30 if a == nil || tab == nil {
31 return takeoverTabUnavailable
32 }
33 a.mu.RLock()
34 defer a.mu.RUnlock()
35 if a.runtimeEpochForTabLocked(tab) != epoch || sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(path) {
36 return takeoverTabUnavailable
37 }
38 return a.takeoverTabStateLocked(tab)
39 }
40
41 func (a *App) takeoverTabStateLocked(tab *WorkspaceTab) takeoverTabState {
42 if a.tabs[tab.ID] != tab || tab.removed {
43 return takeoverTabUnavailable
44 }
45 if tab.Ctrl == nil && tab.StartupErrLeaseHeld {
46 return takeoverTabStartupBlocked
47 }
48 if tab.Ctrl != nil && tab.ReadOnly && tab.Takeover.Spectator {
49 return takeoverTabLocalSpectator
50 }
51 return takeoverTabUnavailable
52 }
53
54 func (a *App) markLocalTakeoverSpectator(tab *WorkspaceTab) {
55 if a == nil || tab == nil {
56 return
57 }
58 marked := false
59 a.mu.Lock()
60 if a.tabs[tab.ID] == tab && !tab.removed {
61 tab.ReadOnly = true
62 tab.Takeover.Spectator = true
63 a.saveTabsLocked()
64 marked = true
65 }
66 a.mu.Unlock()
67 if marked {
68 a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: tab.ID, Meta: a.MetaForTab(tab.ID)})
69 }
70 }
71
72 func (a *App) failLocalSpectatorTakeover(
73 key string,
74 lease *agent.SessionLease,
75 record takeoverServeRecord,
76 client *http.Client,
77 grant takeoverGrant,
78 cause error,
79 ) error {
80 if lease == nil {
81 return cause
82 }
83 if mirror := a.takeoverMirrorForKey(key); mirror != nil {
84 mirror.returnLeaseAfterFailedTakeover(lease)
85 return cause
86 }
87 if err := lease.ReleaseForHandoff(grant.SourceWriterID, grant.ReturnHandoffID); err != nil {
88 lease.Release()
89 return fmt.Errorf("%w (return reclaimed lease: %w)", cause, err)
90 }
91 a.endFailedTakeover(record, client, grant)
92 return cause
93 }
94
95 // promoteLocalTakeoverSpectator completes the A -> B -> A handoff. The old
96 // read-only controller stays published until a freshly loaded replacement is
97 // fully built and authorized, so any failure leaves a usable spectator rather
98 // than a half-promoted writer.
99 //
100 // The caller holds runtimeRebuildMu and has already received grant from Serve.
101 func (a *App) promoteLocalTakeoverSpectator(
102 tab *WorkspaceTab,
103 path, sourceEpoch string,
104 record takeoverServeRecord,
105 client *http.Client,
106 grant takeoverGrant,
107 ) error {
108 a.runtimeAdmissionMu.Lock()
109 defer a.runtimeAdmissionMu.Unlock()
110 tab.turnStartMu.Lock()
111 defer tab.turnStartMu.Unlock()
112
113 a.mu.RLock()
114 valid := a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl != nil && tab.ReadOnly && tab.Takeover.Spectator &&
115 a.runtimeEpochForTabLocked(tab) == sourceEpoch &&
116 sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(path)
117 source := snapshotTabRuntimeLocked(tab)
118 a.mu.RUnlock()
119 if !valid || tab.sessionLeaseRuntimeKey() != "" || controllerHasActiveRuntimeWork(source.ctrl) {
120 a.endFailedTakeover(record, client, grant)
121 return fmt.Errorf("tab changed while reclaiming the session; retry")
122 }
123
124 lease, err := agent.TryAcquireSessionLeaseWithHandoff(path, grant.SourceWriterID, grant.HandoffID)
125 if err != nil {
126 a.endFailedTakeover(record, client, grant)
127 return userFacingSessionLeaseError("", err)
128 }
129 key := sessionRuntimeKey(path)
130 a.registerTakeoverMirror(key, tab.ID, path, record, client, grant)
131
132 // Reload only after targeted acquisition: Serve may have appended the last
133 // remote turn immediately before publishing the handoff reservation.
134 loaded, err := loadResumableSession(path)
135 if err != nil {
136 return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("reload reclaimed session: %w", err))
137 }
138 var candidate *sessionRebindCandidate
139 if build := takeoverBuildLocalSpectatorCandidateForTest; build != nil {
140 candidate, err = build(a, tab, source, path, loaded)
141 } else {
142 candidate, err = a.buildSessionRebindCandidate(tab, source, path, loaded, loadTabSessionProfile(path), false)
143 }
144 if err != nil {
145 return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("rebuild reclaimed session: %w", err))
146 }
147 committed := false
148 defer func() {
149 if !committed {
150 candidate.close()
151 }
152 }()
153 desiredRuntime := source.normalizedRuntime()
154 configureControllerRuntime(candidate.ctrl, source.ctrl, desiredRuntime)
155 restoredRuntime, err := normalizeRestoredControllerRuntime(candidate.ctrl, desiredRuntime)
156 if err != nil {
157 return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("restore reclaimed runtime: %w", err))
158 }
159 candidate.runtime = restoredRuntime
160
161 a.mu.Lock()
162 valid = a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == source.ctrl && tab.ReadOnly && tab.Takeover.Spectator &&
163 a.runtimeEpochForTabLocked(tab) == sourceEpoch &&
164 sessionRuntimeKey(tab.currentSessionPath()) == key
165 if !valid || tab.sessionLeaseRuntimeKey() != "" {
166 a.mu.Unlock()
167 return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("tab changed while reclaiming the session; retry"))
168 }
169 if err := bindCandidateWriteAuthority(candidate.ctrl, lease); err != nil {
170 a.mu.Unlock()
171 return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("bind reclaimed session authority: %w", err))
172 }
173 oldCtrl, oldSink := tab.Ctrl, tab.sink
174 tab.adoptSessionLease(lease)
175 tab.Ctrl = candidate.ctrl
176 tab.sink = candidate.sink
177 tab.SessionPath = path
178 tab.model = candidate.model
179 tab.Label = candidate.ctrl.Label()
180 applyNormalizedRuntimeToTabLocked(tab, candidate.runtime)
181 tab.Takeover.Spectator = false
182 tab.Ready = true
183 clearTabStartupError(tab)
184 tab.ActivityStatus = ""
185 tab.replaceTelemetry(candidate.telemetry, key)
186 if tab.sink != nil {
187 tab.sink.setBinding(tab.ID, a, tab.SessionGeneration)
188 tab.sink.setContext(a.ctx)
189 }
190 a.supersedeTabBuildLocked(tab)
191 newEpoch := a.advanceSessionRuntimeEpochLocked(tab)
192 a.saveTabsLocked()
193 candidate.ctrl = nil
194 candidate.sink = nil
195 committed = true
196 a.mu.Unlock()
197
198 // Reopen the terminal/input capability gate only after the replacement and
199 // its write authority are visible as one committed runtime.
200 a.setTabReadOnly(tab.ID, false)
201 a.attachTakeoverMirror(tab.ID, path)
202 if oldSink != nil {
203 oldSink.setBinding("", nil)
204 oldSink.clearContext()
205 }
206 if oldCtrl != nil {
207 oldCtrl.Close()
208 }
209 a.persistTabSessionPath(tab, path)
210 a.notifyTabRuntimeRebuiltAtEpoch(tab, newEpoch)
211 a.emitReady(a.ctx, tab.ID)
212 return nil
213 }
214
214 lines GO