返回 DeepSeek-Reasonix
session_ownership.go
根目录 / internal / serve / session_ownership.go
1 package serve
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "log/slog"
11 "net/http"
12 "os"
13 "path/filepath"
14 "strings"
15 "time"
16
17 "reasonix/internal/agent"
18 "reasonix/internal/control"
19 "reasonix/internal/event"
20 "reasonix/internal/eventwire"
21 "reasonix/internal/provider"
22 "reasonix/internal/session"
23 )
24
25 // This file answers who owns a session and renders the read-only views a
26 // spectator sees; session_handoff.go and session_reclaim.go move ownership.
27 // doc.go states the single-writer protocol all three implement.
28
29 // handoffMode selects how a takeover deals with a turn still running on the
30 // side that is losing the session.
31 type handoffMode string
32
33 const (
34 handoffModeWait handoffMode = "wait" // drain: wait for the active turn to finish
35 handoffModeInterrupt handoffMode = "interrupt" // cancel the active turn, then hand off
36 )
37
38 const (
39 // handoffDefaultTimeout bounds a drain-mode takeover and a reclaim's wait
40 // for the local writer to yield.
41 handoffDefaultTimeout = 60 * time.Second
42 handoffPollInterval = 200 * time.Millisecond
43 // mirrorStaleAfter is how long a mirrored session goes without writer
44 // contact (frames or heartbeats) before Serve is willing to probe whether
45 // the writer is gone and auto-reclaim. Generous against laptop sleeps and
46 // GC pauses; the lease probe is the real authority.
47 mirrorStaleAfter = 30 * time.Second
48 externalFramesMaxBody = 8 << 20
49 externalFramesMaxCount = 512
50 )
51
52 // leaseHeldByForeignRuntime probes whether a runtime other than this Serve
53 // process holds a session's lease. It is a variable only so tests can model
54 // the local writer as a separate process: the real probe answers false for
55 // leases held by the calling process, and tests hold the writer's lease
56 // in-process.
57 var leaseHeldByForeignRuntime = agent.SessionLeaseHeldByOtherRuntime
58
59 // errSessionTakenOver is the stable refusal every mutating endpoint returns
60 // while the foreground session is mirrored to a local writer. Clients match
61 // the leading sentence to surface the read-only state.
62 const errSessionTakenOver = "session is taken over by a local Reasonix window and is read-only here; use POST /reclaim to take it back"
63
64 // mirroredSession is Serve's bookkeeping for a session whose lease a local
65 // runtime now holds. Serve answers reads from the transcript file and mirrors
66 // the writer's frames to subscribers, but must not mutate the session.
67 type mirroredSession struct {
68 path string
69 mirrorID string
70 handoffID string
71 returnHandoffID string
72 sourceWriterID string
73 targetWriterID string
74 phase mirrorPhase
75 since time.Time
76 lastContact time.Time
77 reclaimRequested bool
78 reclaimMode handoffMode
79 }
80
81 type mirrorPhase string
82
83 const (
84 mirrorPhasePending mirrorPhase = "pending"
85 mirrorPhaseExternal mirrorPhase = "external"
86 mirrorPhaseReclaimRequested mirrorPhase = "reclaim_requested"
87 mirrorPhaseRecovering mirrorPhase = "recovering"
88 )
89
90 type mirrorGrant struct {
91 SessionPath string `json:"sessionPath"`
92 MirrorID string `json:"mirrorId"`
93 HandoffID string `json:"handoffId,omitempty"`
94 ReturnHandoffID string `json:"returnHandoffId"`
95 SourceWriterID string `json:"sourceWriterId"`
96 TargetWriterID string `json:"targetWriterId"`
97 Status string `json:"status"`
98 }
99
100 func newMirrorGeneration() (string, error) {
101 var raw [24]byte
102 if _, err := rand.Read(raw[:]); err != nil {
103 return "", err
104 }
105 return base64.RawURLEncoding.EncodeToString(raw[:]), nil
106 }
107
108 func newMirroredSession(path, sourceWriterID, targetWriterID string, phase mirrorPhase) (mirroredSession, error) {
109 mirrorID, err := newMirrorGeneration()
110 if err != nil {
111 return mirroredSession{}, err
112 }
113 handoffID, err := newMirrorGeneration()
114 if err != nil {
115 return mirroredSession{}, err
116 }
117 returnHandoffID, err := newMirrorGeneration()
118 if err != nil {
119 return mirroredSession{}, err
120 }
121 now := time.Now()
122 return mirroredSession{
123 path: path, mirrorID: mirrorID, handoffID: handoffID,
124 returnHandoffID: returnHandoffID, sourceWriterID: sourceWriterID,
125 targetWriterID: targetWriterID, phase: phase, since: now, lastContact: now,
126 }, nil
127 }
128
129 func (m mirroredSession) grant(status string) mirrorGrant {
130 return mirrorGrant{
131 SessionPath: m.path, MirrorID: m.mirrorID, HandoffID: m.handoffID,
132 ReturnHandoffID: m.returnHandoffID, SourceWriterID: m.sourceWriterID,
133 TargetWriterID: m.targetWriterID, Status: status,
134 }
135 }
136
137 // mirrorKey normalizes a session reference for the mirror registry. It is the
138 // broadcaster's route rule: final-format identity routes key verbatim, legacy
139 // transcript paths keep the canonical-path form the registry has always used,
140 // so a mirror entry and the frames emitted about it always agree on the key.
141 func mirrorKey(path string) string {
142 return sessionRouteKey(path)
143 }
144
145 func (s *Server) markMirrored(m mirroredSession) {
146 path := mirrorKey(m.path)
147 if path == "" {
148 return
149 }
150 m.path = path
151 s.mirrorMu.Lock()
152 if s.mirrored == nil {
153 s.mirrored = map[string]mirroredSession{}
154 }
155 s.mirrored[path] = m
156 s.mirrorMu.Unlock()
157 }
158
159 func (s *Server) clearMirrored(path, mirrorID string) (mirroredSession, bool) {
160 path = mirrorKey(path)
161 s.mirrorMu.Lock()
162 m, ok := s.mirrored[path]
163 if !ok || (mirrorID != "" && m.mirrorID != mirrorID) {
164 s.mirrorMu.Unlock()
165 return mirroredSession{}, false
166 }
167 delete(s.mirrored, path)
168 s.mirrorMu.Unlock()
169 return m, true
170 }
171
172 func (s *Server) mirroredEntry(path string) (mirroredSession, bool) {
173 path = mirrorKey(path)
174 s.mirrorMu.Lock()
175 defer s.mirrorMu.Unlock()
176 m, ok := s.mirrored[path]
177 return m, ok
178 }
179
180 func (s *Server) sessionMirrored(path string) bool {
181 _, ok := s.mirroredEntry(path)
182 return ok
183 }
184
185 // foregroundMirroredLocked reports whether the current foreground session has
186 // been handed to a local writer. Callers hold bindMu.
187 func (s *Server) foregroundMirroredLocked() bool {
188 cur := s.ctl()
189 if cur == nil {
190 return false
191 }
192 if path := cur.SessionPath(); path != "" {
193 return s.sessionMirrored(path)
194 }
195 // Exclusive identities have no live path; the foreground is mirrored when
196 // its bound session ref matches a mirrored identity route.
197 if concrete, ok := cur.(*control.Controller); ok {
198 if ref, bound := concrete.SessionRef(); bound {
199 return s.sessionMirrored(remoteSessionIDQueryPrefix + ref.SessionID)
200 }
201 }
202 return false
203 }
204
205 func (s *Server) touchMirrored(path, mirrorID string, phase mirrorPhase) (mirroredSession, bool) {
206 s.mirrorMu.Lock()
207 key := mirrorKey(path)
208 m, ok := s.mirrored[key]
209 if ok && m.mirrorID == mirrorID {
210 m.lastContact = time.Now()
211 if phase != "" {
212 m.phase = phase
213 }
214 s.mirrored[key] = m
215 }
216 s.mirrorMu.Unlock()
217 return m, ok && m.mirrorID == mirrorID
218 }
219
220 // rejectMirroredForegroundLocked answers 409 for foreground mutations while
221 // the session is mirrored. Returns true when the response was written.
222 // Callers hold bindMu.
223 func (s *Server) rejectMirroredForegroundLocked(w http.ResponseWriter) bool {
224 if !s.foregroundMirroredLocked() {
225 return false
226 }
227 http.Error(w, errSessionTakenOver, http.StatusConflict)
228 return true
229 }
230
231 // snapshotForeground persists the foreground session before a switch, unless a
232 // local writer owns it — a save attempt there fails closed (no write
233 // authority) and the conflict path could fork a recovery branch into a file
234 // the writer now owns. Callers hold bindMu.
235 func (s *Server) snapshotForeground(cur control.SessionAPI) {
236 if s.foregroundMirroredLocked() {
237 return
238 }
239 if err := cur.Snapshot(); err != nil {
240 slog.Warn("serve: snapshot before switch", "err", err)
241 }
242 }
243
244 type ownershipView struct {
245 SessionPath string `json:"sessionPath"`
246 Holder string `json:"holder"` // serve | external | other | free
247 RemoteAttached bool `json:"remoteAttached"`
248 Running bool `json:"running"`
249 Mirrored bool `json:"mirrored"`
250 ReclaimRequested bool `json:"reclaimRequested"`
251 TakenOver bool `json:"takenOver"`
252 HolderPID int `json:"holderPid,omitempty"`
253 HolderHost string `json:"holderHost,omitempty"`
254 }
255
256 // isSessionIDRoute reports whether a client-supplied session reference names a
257 // final-format identity instead of a legacy transcript path.
258 func isSessionIDRoute(raw string) bool {
259 return strings.HasPrefix(strings.TrimSpace(raw), remoteSessionIDQueryPrefix)
260 }
261
262 // resolveSessionIdentity validates a final-format identity route against this
263 // serve's session service and resolves its on-disk directory. The directory
264 // backs the writer-lock occupancy probe; opening the session is never needed
265 // to answer "who holds it".
266 func (s *Server) resolveSessionIdentity(raw string) (session.SessionRef, string, error) {
267 id, ok := strings.CutPrefix(strings.TrimSpace(raw), remoteSessionIDQueryPrefix)
268 if !ok || id == "" {
269 return session.SessionRef{}, "", errors.New("invalid session identity")
270 }
271 concrete, ok := s.ctl().(*control.Controller)
272 if !ok {
273 return session.SessionRef{}, "", errors.New("session identity protocol is unavailable")
274 }
275 service := concrete.SessionService()
276 if service == nil {
277 return session.SessionRef{}, "", errors.New("session service is unavailable")
278 }
279 ref := session.SessionRef{HostID: service.HostID(), SessionID: id}
280 dir, err := service.SessionDir(context.Background(), ref)
281 if err != nil {
282 return session.SessionRef{}, "", fmt.Errorf("unknown session: %w", err)
283 }
284 return ref, dir, nil
285 }
286
287 // ownershipIdentity answers the takeover probe for a final-format identity:
288 // the mirror registry first (external), then the foreground binding (serve),
289 // then the raw writer lock (other vs free).
290 func (s *Server) ownershipIdentity(w http.ResponseWriter, raw string) {
291 route := strings.TrimSpace(raw)
292 ref, dir, err := s.resolveSessionIdentity(route)
293 if err != nil {
294 http.Error(w, err.Error(), http.StatusBadRequest)
295 return
296 }
297 view := ownershipView{SessionPath: route, RemoteAttached: s.bc.Subscribers() > 0}
298 if m, ok := s.mirroredEntry(route); ok {
299 view.Holder = "external"
300 view.Mirrored = true
301 view.TakenOver = true
302 view.ReclaimRequested = m.reclaimRequested
303 s.appendServeIdentity(&view)
304 writeJSON(w, view)
305 return
306 }
307 if cur, ok := s.ctl().(*control.Controller); ok {
308 if current, bound := cur.SessionRef(); bound && current == ref {
309 view.Holder = "serve"
310 view.Running = controllerHasActiveRuntimeWork(cur)
311 s.appendServeIdentity(&view)
312 writeJSON(w, view)
313 return
314 }
315 }
316 if d := s.detachedIdentityHolder(ref); d != nil {
317 view.Holder = "serve"
318 view.Running = controllerHasActiveRuntimeWork(d.ctrl)
319 s.appendServeIdentity(&view)
320 writeJSON(w, view)
321 return
322 }
323 if session.ProbeWriterHeld(dir) {
324 view.Holder = "other"
325 } else {
326 view.Holder = "free"
327 }
328 writeJSON(w, view)
329 }
330
331 // detachedIdentityHolder returns the background session bound to ref, if any.
332 // A detached legacy controller keeps its transcript-path registry key after
333 // upgrading to an identity mid-turn, so the registry must be scanned by bound
334 // identity rather than looked up by path. Only detachedMu is taken; callers
335 // may or may not hold bindMu.
336 func (s *Server) detachedIdentityHolder(ref session.SessionRef) *detachedSession {
337 s.detachedMu.Lock()
338 defer s.detachedMu.Unlock()
339 for _, d := range s.detached {
340 if controllerBoundToIdentity(d.ctrl, ref.SessionID) {
341 return d
342 }
343 }
344 return nil
345 }
346
347 // ownership reports who currently writes a session, whether a remote SSE
348 // client is attached, and whether a turn is running — the inputs a local
349 // takeover prompt needs. remoteAttached counts every SSE subscriber; Serve
350 // cannot distinguish the desktop pump from a browser tab, so it is an
351 // over-approximation of "the remote side is watching".
352 func (s *Server) ownership(w http.ResponseWriter, r *http.Request) {
353 raw := r.URL.Query().Get("session")
354 if isSessionIDRoute(raw) {
355 s.ownershipIdentity(w, raw)
356 return
357 }
358 realPath, err := s.resolveSessionPath(raw)
359 if err != nil {
360 http.Error(w, err.Error(), http.StatusBadRequest)
361 return
362 }
363 view := ownershipView{SessionPath: agent.CanonicalSessionPath(realPath), RemoteAttached: s.bc.Subscribers() > 0}
364 if m, ok := s.mirroredEntry(realPath); ok {
365 view.Holder = "external"
366 view.Mirrored = true
367 view.TakenOver = true
368 view.ReclaimRequested = m.reclaimRequested
369 s.appendServeIdentity(&view)
370 writeJSON(w, view)
371 return
372 }
373 cur := s.ctl()
374 foreground := cur != nil && agent.CanonicalSessionPath(cur.SessionPath()) == agent.CanonicalSessionPath(realPath)
375 if foreground {
376 view.Holder = "serve"
377 view.Running = controllerHasActiveRuntimeWork(cur)
378 s.appendServeIdentity(&view)
379 writeJSON(w, view)
380 return
381 }
382 if s.detachedBusy(realPath) {
383 view.Holder = "serve"
384 view.Running = s.detachedHasActiveWork(realPath)
385 s.appendServeIdentity(&view)
386 writeJSON(w, view)
387 return
388 }
389 if leaseHeldByForeignRuntime(realPath) {
390 view.Holder = "other"
391 }
392 if view.Holder == "" {
393 view.Holder = "free"
394 }
395 writeJSON(w, view)
396 }
397
398 func (s *Server) appendServeIdentity(view *ownershipView) {
399 host, _ := os.Hostname()
400 view.HolderPID = os.Getpid()
401 view.HolderHost = strings.TrimSpace(host)
402 }
403
404 func (s *Server) detachedHasActiveWork(path string) bool {
405 path = agent.CanonicalSessionPath(path)
406 s.detachedMu.Lock()
407 defer s.detachedMu.Unlock()
408 d := s.detached[path]
409 return d != nil && controllerHasActiveRuntimeWork(d.ctrl)
410 }
411
412 type externalFramesRequest struct {
413 SessionPath string `json:"sessionPath"`
414 MirrorID string `json:"mirrorId"`
415 Frames []eventwire.Event `json:"frames"`
416 }
417
418 type externalFramesResponse struct {
419 ReclaimRequested bool `json:"reclaimRequested"`
420 ReclaimMode handoffMode `json:"reclaimMode,omitempty"`
421 ReturnHandoffID string `json:"returnHandoffId,omitempty"`
422 SourceWriterID string `json:"sourceWriterId,omitempty"`
423 }
424
425 // externalFrames mirrors the local writer's frames to every subscriber. An
426 // empty frame list is a heartbeat: the response tells the writer when the
427 // remote side asked for the session back, so an idle writer learns about a
428 // reclaim without pushing anything.
429 func (s *Server) externalFrames(w http.ResponseWriter, r *http.Request) {
430 var body externalFramesRequest
431 if err := decodeTakeoverJSON(w, r, &body); err != nil || strings.TrimSpace(body.SessionPath) == "" || strings.TrimSpace(body.MirrorID) == "" {
432 if err == nil {
433 http.Error(w, "missing sessionPath or mirrorId", http.StatusBadRequest)
434 }
435 return
436 }
437 if len(body.Frames) > externalFramesMaxCount {
438 http.Error(w, "too many frames", http.StatusRequestEntityTooLarge)
439 return
440 }
441 // Final-format identity routes key the registry verbatim; legacy references
442 // still validate as transcript paths inside the session dir.
443 if isSessionIDRoute(body.SessionPath) {
444 if _, _, err := s.resolveSessionIdentity(body.SessionPath); err != nil {
445 http.Error(w, err.Error(), http.StatusBadRequest)
446 return
447 }
448 } else {
449 realPath, err := s.resolveSessionPath(body.SessionPath)
450 if err != nil {
451 http.Error(w, err.Error(), http.StatusBadRequest)
452 return
453 }
454 body.SessionPath = agent.CanonicalSessionPath(realPath)
455 }
456 canonical := mirrorKey(body.SessionPath)
457 mirrorID := strings.TrimSpace(body.MirrorID)
458 // Validate, publish and advance contact under one mirror generation lock:
459 // re-adopt rotates the token under the same lock, so a stale request cannot
460 // pass validation and still emit frames before the post-check notices it.
461 s.mirrorMu.Lock()
462 m, ok := s.mirrored[canonical]
463 if !ok || m.mirrorID != mirrorID {
464 s.mirrorMu.Unlock()
465 http.Error(w, "session is not mirrored by this serve process", http.StatusConflict)
466 return
467 }
468 m.lastContact = time.Now()
469 m.phase = mirrorPhaseExternal
470 s.mirrored[canonical] = m
471 for i := range body.Frames {
472 frame := body.Frames[i]
473 frame.SessionPath = canonical
474 s.bc.EmitWire(frame)
475 }
476 s.mirrorMu.Unlock()
477 writeJSON(w, externalFramesResponse{
478 ReclaimRequested: m.reclaimRequested, ReclaimMode: m.reclaimMode,
479 ReturnHandoffID: m.returnHandoffID, SourceWriterID: m.sourceWriterID,
480 })
481 }
482
483 func decodeTakeoverJSON(w http.ResponseWriter, r *http.Request, dst any) error {
484 decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, externalFramesMaxBody))
485 if err := decoder.Decode(dst); err != nil {
486 var maxErr *http.MaxBytesError
487 if errors.As(err, &maxErr) {
488 http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
489 } else {
490 http.Error(w, "invalid request body", http.StatusBadRequest)
491 }
492 return err
493 }
494 return nil
495 }
496
497 // identityStatusView renders the status payload for a final-format identity
498 // selected via ?session-id:...: when another runtime owns the writer, nothing
499 // can run here and the surface must render read-only. dir backs the writer
500 // occupancy probe for holders that never adopted.
501 func (s *Server) identityStatusView(ref session.SessionRef, dir string) map[string]any {
502 sess := map[string]any{
503 "label": s.ctl().Label(),
504 "running": false,
505 "plan": false,
506 "autoApproveTools": false,
507 "bypass": false,
508 "toolApprovalMode": control.ToolApprovalReadOnly,
509 "cwd": s.ctl().SessionDir(),
510 "pendingPrompt": false,
511 "backgroundJobs": 0,
512 "cancelRequested": false,
513 "cancellable": false,
514 "takenOver": true,
515 "hostId": ref.HostID,
516 "sessionId": ref.SessionID,
517 }
518 if m, ok := s.mirroredEntry(remoteSessionIDQueryPrefix + ref.SessionID); ok {
519 sess["reclaimRequested"] = m.reclaimRequested
520 } else if !session.ProbeWriterHeld(dir) {
521 // The probe says the writer is already free; report it reclaimable so
522 // the surface can re-attach instead of showing a stale read-only badge.
523 sess["takenOver"] = false
524 }
525 return sess
526 }
527
528 // statusIdentityOverride answers the /status session-id route when this serve
529 // does not authoritatively run the identity: mirrored or foreign-held
530 // identities get the read-only takeover view. Identities bound to the
531 // foreground (or with a free writer) return false so the caller falls through
532 // to the authoritative controller snapshot or re-attaches.
533 func (s *Server) statusIdentityOverride(w http.ResponseWriter, raw string) bool {
534 route := strings.TrimSpace(raw)
535 ref, dir, err := s.resolveSessionIdentity(route)
536 if err != nil {
537 http.Error(w, err.Error(), http.StatusBadRequest)
538 return true
539 }
540 if s.serveHoldsIdentity(ref) {
541 return false
542 }
543 if _, mirrored := s.mirroredEntry(route); mirrored {
544 writeJSON(w, s.identityStatusView(ref, dir))
545 s.maybeAutoReclaimMirrored(route)
546 return true
547 }
548 if session.ProbeWriterHeld(dir) {
549 writeJSON(w, s.identityStatusView(ref, dir))
550 return true
551 }
552 // Neither run here nor written anywhere: a spectator pinned by a writer
553 // that has since exited. Answer this route with takenOver=false; a
554 // foreign-route snapshot is discarded while pinned and the banner sticks.
555 view := s.identityStatusView(ref, dir)
556 view["takenOver"] = false
557 writeJSON(w, view)
558 return true
559 }
560
561 // identityColdHistory reads a final-format identity's committed messages
562 // without a controller binding. The writer may live in another process; the
563 // durable event log is read through the session service's read-only path,
564 // which never takes the writer lease.
565 func (s *Server) identityColdHistory(raw string) ([]provider.Message, bool) {
566 ref, _, err := s.resolveSessionIdentity(raw)
567 if err != nil {
568 return nil, false
569 }
570 concrete, ok := s.ctl().(*control.Controller)
571 if !ok {
572 return nil, false
573 }
574 query := concrete.SessionService()
575 if query == nil {
576 return nil, false
577 }
578 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
579 defer cancel()
580 msgs, err := query.Query().History(ctx, ref)
581 if err != nil {
582 return nil, false
583 }
584 return msgs, true
585 }
586
587 // adopt registers a session the local runtime already owns as mirrored, so
588 // the remote side can watch it read-only and reclaim it. This is the local
589 // desktop's announcement when it opens a session directly (no takeover — there
590 // was nothing to hand off): Serve must know about the writer to mediate
591 // reclaim and mirror the frames. Sessions Serve itself holds are refused —
592 // those go through /handoff instead.
593 func (s *Server) adopt(w http.ResponseWriter, r *http.Request) {
594 var body struct {
595 SessionPath string `json:"sessionPath"`
596 WriterID string `json:"writerId"`
597 }
598 if err := decodeTakeoverJSON(w, r, &body); err != nil || strings.TrimSpace(body.SessionPath) == "" || strings.TrimSpace(body.WriterID) == "" {
599 if err == nil {
600 http.Error(w, "missing sessionPath or writerId", http.StatusBadRequest)
601 }
602 return
603 }
604 s.bindMu.Lock()
605 defer s.bindMu.Unlock()
606 if isSessionIDRoute(body.SessionPath) {
607 // Final-format identities prove ownership by the writer lock itself;
608 // there is no lease sidecar to inspect. The claim is accepted only when
609 // some local runtime actually holds the writer.
610 ref, dir, idErr := s.resolveSessionIdentity(body.SessionPath)
611 if idErr != nil {
612 http.Error(w, idErr.Error(), resolveSessionPathStatus(idErr))
613 return
614 }
615 if s.serveHoldsIdentity(ref) {
616 http.Error(w, "session is held by this serve; use POST /handoff to take it over", http.StatusConflict)
617 return
618 }
619 if !session.ProbeWriterHeld(dir) {
620 http.Error(w, "session is not held by the claimed writer", http.StatusConflict)
621 return
622 }
623 writerID := strings.TrimSpace(body.WriterID)
624 if existing, ok := s.mirroredEntry(body.SessionPath); ok && existing.targetWriterID != writerID {
625 http.Error(w, "session is mirrored by another writer", http.StatusConflict)
626 return
627 }
628 m, err := newMirroredSession(mirrorKey(body.SessionPath), agent.SessionWriterID(), writerID, mirrorPhaseExternal)
629 if err != nil {
630 http.Error(w, "create mirror generation", http.StatusInternalServerError)
631 return
632 }
633 m.handoffID = ""
634 s.markMirrored(m)
635 slog.Info("serve: final-format session adopted by local runtime", "session", m.path)
636 s.bc.Emit(event.Event{
637 Kind: event.Notice,
638 Level: event.LevelWarn,
639 Code: event.NoticeCodeSessionTakenOver,
640 Text: "This session was taken over by a local Reasonix window and is read-only here.",
641 Detail: "A Reasonix window on this machine opened this session; it keeps streaming here. Use \"take back\" to reclaim it.",
642 SessionPath: m.path,
643 })
644 writeJSON(w, m.grant("adopted"))
645 return
646 }
647 realPath, err := s.resolveSessionPath(body.SessionPath)
648 if err != nil {
649 http.Error(w, err.Error(), resolveSessionPathStatus(err))
650 return
651 }
652 if s.serveHoldsSession(realPath) {
653 http.Error(w, "session is held by this serve; use POST /handoff to take it over", http.StatusConflict)
654 return
655 }
656 info, held, inspectErr := agent.InspectSessionLease(realPath)
657 if inspectErr != nil || !held || info == nil || info.WriterID != strings.TrimSpace(body.WriterID) {
658 http.Error(w, "session is not held by the claimed writer", http.StatusConflict)
659 return
660 }
661 if existing, ok := s.mirroredEntry(realPath); ok && existing.targetWriterID != info.WriterID {
662 http.Error(w, "session is mirrored by another writer", http.StatusConflict)
663 return
664 }
665 m, err := newMirroredSession(agent.CanonicalSessionPath(realPath), agent.SessionWriterID(), info.WriterID, mirrorPhaseExternal)
666 if err != nil {
667 http.Error(w, "create mirror generation", http.StatusInternalServerError)
668 return
669 }
670 m.handoffID = ""
671 s.markMirrored(m)
672 slog.Info("serve: session adopted by local runtime", "session", agent.CanonicalSessionPath(realPath))
673 s.bc.Emit(event.Event{
674 Kind: event.Notice,
675 Level: event.LevelWarn,
676 Code: event.NoticeCodeSessionTakenOver,
677 Text: "This session was taken over by a local Reasonix window and is read-only here.",
678 Detail: "A Reasonix window on this machine opened this session; it keeps streaming here. Use \"take back\" to reclaim it.",
679 SessionPath: agent.CanonicalSessionPath(realPath),
680 })
681 writeJSON(w, m.grant("adopted"))
682 }
683
684 // mirroredReadView reports whether session is mirrored, and if so builds the
685 // file-backed read view for read-only endpoints that select a specific
686 // session.
687 func (s *Server) mirroredReadView(session string) (string, []provider.Message, bool) {
688 realPath, err := s.resolveSessionPath(session)
689 if err != nil || !s.sessionMirrored(realPath) {
690 return "", nil, false
691 }
692 msgs, ok := s.mirroredHistory(realPath)
693 if !ok {
694 return "", nil, false
695 }
696 return agent.CanonicalSessionPath(realPath), msgs, true
697 }
698
699 // externalReadView serves the file-backed read view for any session a local
700 // runtime owns — mirrored via /adopt or /handoff, or merely held by another
701 // process on this machine. A spectator client (remote tab) needs the local
702 // writer's transcript, not Serve's foreground.
703 func (s *Server) externalReadView(session string) (string, []provider.Message, bool) {
704 if s.sessionMirrored(session) {
705 return s.mirroredReadView(session)
706 }
707 realPath, err := s.resolveSessionPath(session)
708 if err != nil || !leaseHeldByForeignRuntime(realPath) {
709 return "", nil, false
710 }
711 msgs, ok := s.mirroredHistory(realPath)
712 if !ok {
713 return "", nil, false
714 }
715 return agent.CanonicalSessionPath(realPath), msgs, true
716 }
717
718 // statusViewForPath renders the per-session status payload for the ?session=
719 // selector. Local-owned sessions (mirrored or foreign-held) report takenOver;
720 // Serve-owned sessions report takenOver=false so a spectator client clears its
721 // read-only pin after reclaim or when the session returns to the foreground.
722 func (s *Server) statusViewForPath(path string, held bool) map[string]any {
723 if held {
724 return s.externalStatusView(path)
725 }
726 running := false
727 cur := s.ctl()
728 if cur != nil && agent.CanonicalSessionPath(cur.SessionPath()) == agent.CanonicalSessionPath(path) {
729 running = controllerHasActiveRuntimeWork(cur)
730 }
731 return map[string]any{
732 "label": s.ctl().Label(),
733 "running": running,
734 "plan": false,
735 "autoApproveTools": false,
736 "bypass": false,
737 "toolApprovalMode": control.ToolApprovalWorkspaceWrite,
738 "cwd": s.ctl().SessionDir(),
739 "pendingPrompt": false,
740 "backgroundJobs": 0,
741 "takenOver": false,
742 "sessionName": strings.TrimSuffix(filepath.Base(path), ".jsonl"),
743 "sessionPath": agent.CanonicalSessionPath(path),
744 }
745 }
746
747 // externalStatusView renders the status payload for a session a local runtime
748 // owns (mirrored or foreign-held): nothing here can run, ownership is external,
749 // and the surface must render read-only.
750 func (s *Server) externalStatusView(path string) map[string]any {
751 if _, ok := s.mirroredEntry(path); ok {
752 return s.mirrorStatusView(path)
753 }
754 sess := map[string]any{
755 "label": s.ctl().Label(),
756 "running": false,
757 "plan": false,
758 "autoApproveTools": false,
759 "bypass": false,
760 "toolApprovalMode": control.ToolApprovalReadOnly,
761 "cwd": s.ctl().SessionDir(),
762 "pendingPrompt": false,
763 "backgroundJobs": 0,
764 "takenOver": true,
765 "sessionName": strings.TrimSuffix(filepath.Base(path), ".jsonl"),
766 "sessionPath": agent.CanonicalSessionPath(path),
767 }
768 return sess
769 }
770
771 // mirrorStatusView renders the status payload for a mirrored session selected
772 // via ?session=: nothing can run here, ownership is external, and the surface
773 // must render read-only.
774 func (s *Server) mirrorStatusView(path string) map[string]any {
775 m, ok := s.mirroredEntry(path)
776 sess := map[string]any{
777 "label": s.ctl().Label(),
778 "running": false,
779 "plan": false,
780 "autoApproveTools": false,
781 "bypass": false,
782 "toolApprovalMode": control.ToolApprovalReadOnly,
783 "cwd": s.ctl().SessionDir(),
784 "pendingPrompt": false,
785 "backgroundJobs": 0,
786 "takenOver": true,
787 "sessionName": strings.TrimSuffix(filepath.Base(path), ".jsonl"),
788 "sessionPath": agent.CanonicalSessionPath(path),
789 }
790 if ok {
791 sess["reclaimRequested"] = m.reclaimRequested
792 }
793 return sess
794 }
795
796 // mirrorEnd is the local writer's farewell: it has closed its tab and dropped
797 // the lease, so the remote side can speak again without an explicit reclaim
798 // round-trip. A writer that still holds the lease is told to release first —
799 // ending the mirror under a live writer would leave the foreground writable
800 // in name only (its write authority is gone) and render stale history.
801 func (s *Server) mirrorEnd(w http.ResponseWriter, r *http.Request) {
802 var body struct {
803 SessionPath string `json:"sessionPath"`
804 MirrorID string `json:"mirrorId"`
805 }
806 if err := decodeTakeoverJSON(w, r, &body); err != nil || strings.TrimSpace(body.SessionPath) == "" || strings.TrimSpace(body.MirrorID) == "" {
807 if err == nil {
808 http.Error(w, "missing sessionPath or mirrorId", http.StatusBadRequest)
809 }
810 return
811 }
812 if isSessionIDRoute(body.SessionPath) {
813 s.mirrorEndIdentity(w, r, body.SessionPath, strings.TrimSpace(body.MirrorID))
814 return
815 }
816 realPath, err := s.resolveSessionPath(body.SessionPath)
817 if err != nil {
818 http.Error(w, err.Error(), resolveSessionPathStatus(err))
819 return
820 }
821 m, ok := s.mirroredEntry(realPath)
822 if !ok || m.mirrorID != strings.TrimSpace(body.MirrorID) {
823 http.Error(w, "mirror generation changed", http.StatusConflict)
824 return
825 }
826 if leaseHeldByForeignRuntime(realPath) {
827 http.Error(w, "local writer still holds the session; release it before ending the mirror", http.StatusConflict)
828 return
829 }
830 s.bindMu.Lock()
831 defer s.bindMu.Unlock()
832 current, ok := s.mirroredEntry(realPath)
833 if !ok || current.mirrorID != m.mirrorID {
834 http.Error(w, "mirror generation changed", http.StatusConflict)
835 return
836 }
837 s.reclaimMirroredLocked(w, realPath, current)
838 }
839
840 // The desktop sends mirror-end at tab close and releases its runtime in the
841 // same teardown with no ordering guarantee, so the writer lock normally drops
842 // within milliseconds of the farewell. mirrorEndReleaseWait bounds how long the
843 // farewell waits for that drop so the remote side is re-owned at once instead
844 // of sitting read-only until the 30 s stale auto-reclaim notices; a writer that
845 // keeps the lock past the bound is accepted and left to that fallback.
846 var (
847 mirrorEndReleaseWait = 2 * time.Second
848 mirrorEndReleasePoll = 25 * time.Millisecond
849 // mirrorEndProbeHookForTest runs after each probe that still sees the
850 // writer lock held, so tests can release the writer at a chosen point.
851 mirrorEndProbeHookForTest func(attempt int)
852 )
853
854 // awaitIdentityWriterRelease reports whether the identity's writer lock dropped
855 // within mirrorEndReleaseWait.
856 func awaitIdentityWriterRelease(dir string) bool {
857 deadline := time.Now().Add(mirrorEndReleaseWait)
858 for attempt := 0; session.ProbeWriterHeld(dir); attempt++ {
859 if mirrorEndProbeHookForTest != nil {
860 mirrorEndProbeHookForTest(attempt)
861 }
862 if time.Now().After(deadline) {
863 return false
864 }
865 time.Sleep(mirrorEndReleasePoll)
866 }
867 return true
868 }
869
870 // mirrorEndIdentity handles the local writer's farewell for a final-format
871 // identity. The farewell is sent before the live writer releases its runtime,
872 // so a still-held writer lock is expected, not an error: wait briefly for the
873 // drop and re-own the identity as the legacy farewell does; a writer that
874 // outlives the wait is accepted and the stale auto-reclaim (or process exit)
875 // finishes the return later.
876 func (s *Server) mirrorEndIdentity(w http.ResponseWriter, r *http.Request, route, mirrorID string) {
877 ref, dir, err := s.resolveSessionIdentity(route)
878 if err != nil {
879 http.Error(w, err.Error(), resolveSessionPathStatus(err))
880 return
881 }
882 m, ok := s.mirroredEntry(route)
883 if !ok || m.mirrorID != mirrorID {
884 http.Error(w, "mirror generation changed", http.StatusConflict)
885 return
886 }
887 // Wait outside bindMu: blocking every other command for the whole bound
888 // would freeze the serve for a writer that is merely slow to exit.
889 if !awaitIdentityWriterRelease(dir) {
890 w.WriteHeader(http.StatusNoContent)
891 return
892 }
893 s.bindMu.Lock()
894 defer s.bindMu.Unlock()
895 current, ok := s.mirroredEntry(route)
896 if !ok || current.mirrorID != m.mirrorID {
897 http.Error(w, "mirror generation changed", http.StatusConflict)
898 return
899 }
900 s.reclaimIdentityLocked(w, r.Context(), route, ref, current)
901 }
902
903 type statusRecorder struct {
904 header http.Header
905 status int
906 }
907
908 func (w *statusRecorder) Header() http.Header { return w.header }
909 func (w *statusRecorder) Write(p []byte) (int, error) {
910 if w.status == 0 {
911 w.status = http.StatusOK
912 }
913 return len(p), nil
914 }
915 func (w *statusRecorder) WriteHeader(status int) { w.status = status }
916
917 // mirroredHistory reads the transcript file for a mirrored session so
918 // hydrating and reconciling clients see the local writer's turns, not Serve's
919 // frozen in-memory copy. Returns false when the file cannot be read; callers
920 // fall back to the stale in-memory history.
921 func (s *Server) mirroredHistory(realPath string) ([]provider.Message, bool) {
922 loaded, err := agent.LoadSession(realPath)
923 if err != nil || loaded == nil {
924 return nil, false
925 }
926 return loaded.Messages, true
927 }
928
928 lines GO