返回 DeepSeek-Reasonix
session_takeover_canonical.go
根目录 / internal / cli / session_takeover_canonical.go
1 package cli
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 "reasonix/internal/control"
11 "reasonix/internal/i18n"
12 "reasonix/internal/session"
13 )
14
15 // cliCanonicalRoutePrefix marks a takeover/resume target as a final-format
16 // identity instead of a legacy transcript path. It matches the serve and
17 // desktop routing prefix for exclusive identity sessions.
18 const cliCanonicalRoutePrefix = "session-id:"
19
20 func isCLICanonicalRoute(target string) bool {
21 return strings.HasPrefix(strings.TrimSpace(target), cliCanonicalRoutePrefix)
22 }
23
24 func cliCanonicalRouteID(target string) (string, bool) {
25 id, ok := strings.CutPrefix(strings.TrimSpace(target), cliCanonicalRoutePrefix)
26 return id, ok && id != ""
27 }
28
29 func cliCanonicalRoute(sessionID string) string {
30 return cliCanonicalRoutePrefix + strings.TrimSpace(sessionID)
31 }
32
33 // sessionWriterHeldNotice is the friendly refusal for a final-format session
34 // whose writer another runtime owns.
35 func sessionWriterHeldNotice() string {
36 return "this session is written by another Reasonix runtime on this machine; run /takeover to take it over"
37 }
38
39 // cliServeUnreachableError marks a takeover attempt that never obtained a
40 // serve's verdict: the dial, the token exchange, or the request itself failed
41 // at the transport. Only this kind of failure justifies a re-discovery pass.
42 // An HTTP verdict — a refusal, "still running; retry with mode=interrupt", an
43 // invalid grant — is the serve's answer and repeating the round would only
44 // repeat the same bounded wait.
45 type cliServeUnreachableError struct{ err error }
46
47 func (e *cliServeUnreachableError) Error() string { return e.err.Error() }
48 func (e *cliServeUnreachableError) Unwrap() error { return e.err }
49
50 func cliServeUnreachable(err error) bool {
51 var unreachable *cliServeUnreachableError
52 return errors.As(err, &unreachable)
53 }
54
55 // cliTakeoverIdentityHeldSession asks every resident serve to hand the
56 // final-format identity over. The writer lock carries no PID, so discovery is
57 // exhaustive rather than PID-matched; the serve that actually holds the
58 // identity grants, the others refuse.
59 func cliTakeoverIdentityHeldSession(route string, manager *cliTakeoverManager) (*cliTakeoverBinding, error) {
60 if manager != nil && manager.Reclaiming() {
61 return nil, fmt.Errorf("the remote side is reclaiming the current session")
62 }
63 // One re-discovery pass, and only when no serve could be reached: a
64 // desktop reconnect respawns the serve and rewrites its state file, so an
65 // all-unreachable round may simply have raced the restart. PIDs of dead
66 // serves are pruned during discovery.
67 var lastErr error
68 for range 2 {
69 records := discoverCLIServesForTakeover()
70 if len(records) == 0 {
71 break
72 }
73 answered := false
74 lastErr = nil
75 for i := range records {
76 binding, err := cliTakeoverIdentityFromServe(route, &records[i])
77 if err == nil {
78 return binding, nil
79 }
80 if !cliServeUnreachable(err) {
81 // A serve that answered is the one worth reporting.
82 answered = true
83 lastErr = err
84 } else if lastErr == nil {
85 lastErr = err
86 }
87 }
88 if answered {
89 break
90 }
91 }
92 if lastErr == nil {
93 return nil, fmt.Errorf("no resident serve on this machine holds this session; check that the desktop is connected and retry")
94 }
95 return nil, lastErr
96 }
97
98 // cliTakeoverIdentityFromServe performs one serve's identity handoff. The
99 // grant must name the exact route this process asked for.
100 func cliTakeoverIdentityFromServe(route string, record *cliServeRecord) (*cliTakeoverBinding, error) {
101 ctx, cancel := context.WithTimeout(context.Background(), cliTakeoverTimeout+15*time.Second)
102 defer cancel()
103 client, err := cliServeClient(ctx, *record)
104 if err != nil {
105 return nil, fmt.Errorf("takeover from local serve: %w", err)
106 }
107 grant, err := postCLITakeoverHandoff(ctx, client, record.base, route, "takeover from local serve")
108 if err != nil {
109 return nil, err
110 }
111 if strings.TrimSpace(grant.SessionPath) != route {
112 return nil, fmt.Errorf("invalid handoff grant")
113 }
114 return &cliTakeoverBinding{path: route, canonical: true, record: *record, client: client, grant: grant}, nil
115 }
116
117 // ctrlOpenCanonicalSession attaches the controller to a final-format identity.
118 func ctrlOpenCanonicalSession(ctrl control.SessionAPI, ref session.SessionRef) error {
119 identity, ok := ctrl.(control.IdentityLifecycle)
120 if !ok || !identity.UsesExclusiveSession() {
121 return errors.New("final-format session resume requires the session engine")
122 }
123 if _, err := identity.OpenSession(context.Background(), ref); err != nil {
124 return err
125 }
126 return nil
127 }
128
129 // cliCanonicalRouteRef resolves the identity a route names against the
130 // workspace's session service, so the takeover opens the same identity the
131 // serve released.
132 func cliCanonicalRouteRef(sessionDir, route string) (session.SessionRef, error) {
133 id, ok := cliCanonicalRouteID(route)
134 if !ok {
135 return session.SessionRef{}, errors.New("invalid session identity")
136 }
137 service := cliSessionService(sessionDir)
138 if service == nil {
139 return session.SessionRef{}, errors.New("session service unavailable")
140 }
141 ref := session.SessionRef{HostID: service.HostID(), SessionID: id}
142 if _, err := service.SessionDir(context.Background(), ref); err != nil {
143 return session.SessionRef{}, fmt.Errorf("unknown session: %w", err)
144 }
145 return ref, nil
146 }
147
148 // runCanonicalTakeoverCommand handles "/takeover" for a final-format identity:
149 // every resident serve is asked to hand the writer over; on grant the
150 // controller attaches through OpenSession and the mirror manager forwards
151 // frames so the remote tab keeps rendering read-only. When no runtime holds
152 // the identity any more there is nothing to hand over and it is resumed
153 // directly, which is what the reclaim notice promises after the desktop has
154 // closed the session it took back.
155 func (m *chatTUI) runCanonicalTakeoverCommand(route string) {
156 if m.ctrl.Running() {
157 m.notice(i18n.M.ResumeBusy)
158 return
159 }
160 ref, err := cliCanonicalRouteRef(m.ctrl.SessionDir(), route)
161 if err != nil {
162 m.notice("takeover: " + err.Error())
163 return
164 }
165 if resumeEntryIsActive(m.ctrl, resumeEntry{target: cliResumeTarget{ref: ref}}) {
166 m.notice(i18n.M.ResumeAlreadyActive)
167 return
168 }
169 detached := m.sessionDetached()
170 if !detached {
171 if err := m.ctrl.Snapshot(); err != nil {
172 m.notice("takeover: snapshot current session: " + err.Error())
173 return
174 }
175 m.followSessionLease()
176 }
177 binding, takeoverErr := cliTakeoverIdentityHeldSession(route, m.takeover)
178 if takeoverErr != nil {
179 m.resumeUnheldCanonicalSession(ref, takeoverErr, detached)
180 return
181 }
182 if err := m.commitCanonicalSessionSwitch(ref); err != nil {
183 cliEndFailedHandoff(binding)
184 if !detached {
185 m.restoreSessionLease()
186 }
187 m.notice("takeover: " + err.Error())
188 return
189 }
190 m.pendingTakeoverPath = ""
191 if m.takeover != nil {
192 m.takeover.AttachController(m.ctrl)
193 m.takeover.Activate(binding)
194 }
195 m.resumeAfterReclaim()
196 m.replayActiveBranch(i18n.M.ResumedTitle)
197 m.notice("session taken over; the remote side is now read-only and can take it back")
198 }
199
200 // resumeUnheldCanonicalSession runs after no serve granted the identity. The
201 // writer lock is the authority on whether the refusal mattered: a session
202 // nobody holds (the desktop closed it after reclaiming, or the holder exited)
203 // is simply resumed, while a still-held one reports why the handoff failed
204 // rather than the generic ownership error.
205 func (m *chatTUI) resumeUnheldCanonicalSession(ref session.SessionRef, takeoverErr error, detached bool) {
206 if err := m.commitCanonicalSessionSwitch(ref); err != nil {
207 if !detached {
208 m.restoreSessionLease()
209 }
210 if errors.Is(err, session.ErrWriterOwned) {
211 m.notice("takeover: " + takeoverErr.Error())
212 return
213 }
214 m.notice("takeover: " + err.Error())
215 return
216 }
217 m.pendingTakeoverPath = ""
218 m.resumeAfterReclaim()
219 m.replayActiveBranch(i18n.M.ResumedTitle)
220 m.notice("session resumed; no other runtime holds it")
221 }
222
223 // cliStartupCanonicalTakeover is the startup counterpart: after a confirmed
224 // prompt (or --takeover), the resident serve releases the identity and this
225 // process attaches as the new writer.
226 func cliStartupCanonicalTakeover(ctrl control.SessionAPI, manager *cliTakeoverManager, target cliResumeTarget) error {
227 route := cliCanonicalRoute(target.ref.SessionID)
228 binding, err := cliTakeoverIdentityHeldSession(route, manager)
229 if err != nil {
230 return err
231 }
232 if err := ctrlOpenCanonicalSession(ctrl, target.ref); err != nil {
233 cliEndFailedHandoff(binding)
234 return err
235 }
236 if manager != nil {
237 manager.AttachController(ctrl)
238 manager.Activate(binding)
239 }
240 return nil
241 }
242
242 lines GO