返回 DeepSeek-Reasonix
chat_tui_reclaim.go
根目录 / internal / cli / chat_tui_reclaim.go
1 package cli
2
3 import (
4 "context"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8
9 "reasonix/internal/control"
10 "reasonix/internal/session"
11 )
12
13 // tuiSessionReclaimedMsg tells the live TUI that the takeover mirror has
14 // yielded. It is deliberately separate from tuiShutdownMsg: reclaiming one
15 // session must not terminate the process that may still host other sessions.
16 type tuiSessionReclaimedMsg struct{}
17
18 // reclaimState is the chatTUI substate a remote take-back owns: the three
19 // flags are set when the mirror yields and cleared together when the TUI
20 // adopts another session, so they never disagree about who writes.
21 type reclaimState struct {
22 // sessionReclaimed means the remote side owns the previously active
23 // session. The TUI stays alive, but input is restricted to leaving it.
24 sessionReclaimed bool
25 // reclaimedTarget retains the session that was just yielded after its
26 // controller binding is released, so the picker can select another row.
27 reclaimedTarget cliResumeTarget
28 // shutdownAfterReclaim records a signal or watchdog exit request that
29 // arrived mid-reclaim; it is honored once the reclaim callback lands
30 // instead of racing the handoff.
31 shutdownAfterReclaim bool
32 }
33
34 const sessionReclaimedNotice = "this session was taken back by the remote side; /resume switches to another session, /takeover takes it back, /quit exits"
35
36 // sessionDetached reports whether the remote side owns the session this TUI
37 // last wrote: either the reclaim callback has landed or the mirror manager has
38 // already returned it. A detached TUI must not snapshot or lease-follow the
39 // session it no longer writes.
40 func (m *chatTUI) sessionDetached() bool {
41 return m.sessionReclaimed || m.takeover != nil && m.takeover.Returned()
42 }
43
44 // completeSessionReclaim applies the manager's yield to the model, then honors
45 // an exit request that arrived while the handoff was still in flight.
46 func (m chatTUI) completeSessionReclaim() (tea.Model, tea.Cmd) {
47 m.handleSessionReclaimed()
48 if m.shutdownAfterReclaim {
49 m.shutdownAfterReclaim = false
50 return m.shutdownAndQuit(tuiShutdownMsg{})
51 }
52 return m, nil
53 }
54
55 func (m *chatTUI) handleSessionReclaimed() {
56 if m == nil || m.sessionReclaimed {
57 return
58 }
59 m.rememberReclaimedTarget()
60 if current, ok := m.ctrl.(*control.Controller); ok {
61 m.releaseCanonicalRuntime(current)
62 }
63 m.sessionReclaimed = true
64 // A picker opened before the reclaim listed the yielded session as
65 // switchable; its rows and default selection are stale now.
66 m.resumePick = nil
67 m.resetComposerInput()
68 // Keep the conversation rendered and the process alive: the notice explains
69 // the read-only state and the input gate accepts only switch/takeover/exit,
70 // so the user picks the next step instead of landing in the chooser.
71 m.notice(sessionReclaimedNotice)
72 }
73
74 // rememberReclaimedTarget records where "/takeover takes it back" must go.
75 // The yielded mirror's own key decides: a legacy path lease is re-taken
76 // through the serve's path handoff even though the exclusive engine imports
77 // the transcript under an identity (so SessionRef is bound for both kinds), a
78 // canonical route through the identity handoff. Without a yielded mirror the
79 // bound identity, then the legacy path, stand in.
80 func (m *chatTUI) rememberReclaimedTarget() {
81 if m == nil || m.ctrl == nil {
82 return
83 }
84 yielded := m.takeover.yieldedBinding()
85 if yielded != nil && !yielded.canonical {
86 m.reclaimedTarget = cliResumeTarget{path: yielded.path}
87 return
88 }
89 if identity, ok := m.ctrl.(control.IdentityLifecycle); ok {
90 if yielded != nil {
91 if id, ok := cliCanonicalRouteID(yielded.path); ok {
92 if service := identity.SessionService(); service != nil {
93 m.reclaimedTarget = cliResumeTarget{ref: session.SessionRef{HostID: service.HostID(), SessionID: id}}
94 return
95 }
96 }
97 }
98 if ref, bound := identity.SessionRef(); bound {
99 m.reclaimedTarget = cliResumeTarget{ref: ref}
100 return
101 }
102 }
103 if path := strings.TrimSpace(m.ctrl.SessionPath()); path != "" {
104 m.reclaimedTarget = cliResumeTarget{path: path}
105 }
106 }
107
108 func (m *chatTUI) releaseCanonicalRuntime(current *control.Controller) {
109 if current == nil {
110 return
111 }
112 service, runtime, exclusive := current.SessionBinding()
113 if !exclusive || service == nil || runtime == nil {
114 return
115 }
116 ref := runtime.Ref()
117 release := func(candidate *control.Controller) {
118 if candidate == nil {
119 return
120 }
121 candidateService, candidateRuntime, candidateExclusive := candidate.SessionBinding()
122 if candidateExclusive && candidateService == service && candidateRuntime == runtime {
123 if err := candidate.ReleaseSessionRuntimeBinding(); err != nil {
124 m.notice("session reclaim: release runtime binding: " + err.Error())
125 }
126 }
127 }
128
129 // Rebuilt controllers can retain a client binding until process teardown.
130 // Drop every binding for this exact runtime before asking the service to
131 // close it, otherwise its writer lock would remain held by an old model.
132 release(current)
133 for _, old := range m.oldControllers {
134 if candidate, ok := old.(*control.Controller); ok {
135 release(candidate)
136 }
137 }
138 if err := service.Close(context.Background(), ref); err != nil {
139 m.notice("session reclaim: release writer: " + err.Error())
140 }
141 }
142
143 func (m *chatTUI) resumeAfterReclaim() {
144 if m == nil {
145 return
146 }
147 m.sessionReclaimed = false
148 m.reclaimedTarget = cliResumeTarget{}
149 if m.takeover != nil {
150 m.takeover.ResumeAfterYield()
151 }
152 }
153
154 // reclaimBlocksInput reports whether a composer line must be refused because
155 // the remote side owns this session. A refusal clears the composer and
156 // explains itself, so the caller only has to finalize the frame.
157 func (m *chatTUI) reclaimBlocksInput(line string) bool {
158 if !m.sessionDetached() || reclaimInputAllowed(line) {
159 return false
160 }
161 m.resetComposerInput()
162 m.notice(sessionReclaimedNotice)
163 return true
164 }
165
166 // slashInputBlockedNotice returns the refusal for a slash command typed while
167 // the remote side is taking this session back or already owns it; the empty
168 // string means the command may run.
169 func (m *chatTUI) slashInputBlockedNotice(typedCmd string) string {
170 switch {
171 case m.sessionDetached() && !reclaimInputAllowed(typedCmd):
172 return sessionReclaimedNotice
173 case m.takeover != nil && m.takeover.Reclaiming() && typedCmd != "/quit" && typedCmd != "/exit":
174 return "the remote side is taking this session back; new input is disabled"
175 }
176 return ""
177 }
178
179 func reclaimInputAllowed(line string) bool {
180 command := strings.TrimSpace(strings.SplitN(line, " ", 2)[0])
181 switch command {
182 case "/resume", "/takeover", "/quit", "/exit":
183 return true
184 default:
185 return false
186 }
187 }
188
188 lines GO