返回 DeepSeek-Reasonix
runtime_rebuild.go
根目录 / internal / cli / runtime_rebuild.go
1 package cli
2
3 import (
4 "context"
5 "fmt"
6
7 tea "charm.land/bubbletea/v2"
8
9 "reasonix/internal/boot"
10 "reasonix/internal/control"
11 "reasonix/internal/i18n"
12 )
13
14 // runtimeRebuilder builds the /reload replacement controller through
15 // boot.Rebuild with the session's current model/profile/effort. cli.go
16 // supplies it because that is where the boot.Options (and the SharedHost, when
17 // one exists) live; the TUI owns only the queue/swap/close sequencing.
18 type runtimeRebuilder func(ctx context.Context, spec controllerBuildSpec, old *control.Controller) (*boot.BuildResult, error)
19
20 // reloadDisposition is the /reload decision, extracted so the queue semantics
21 // are unit-testable without a running tea program.
22 type reloadDisposition int
23
24 const (
25 // reloadUnavailable means this session has no rebuild seam (headless or
26 // test surface): /reload can only report unavailable.
27 reloadUnavailable reloadDisposition = iota
28 // reloadNow means the TUI is idle: rebuild immediately.
29 reloadNow
30 // reloadQueued means a turn or a runtime switch is in flight: coalesce
31 // exactly one reload and run it from the idle drain.
32 reloadQueued
33 )
34
35 func (m *chatTUI) reloadDisposition() reloadDisposition {
36 if m == nil || m.ctrl == nil || m.rebuildRuntime == nil {
37 return reloadUnavailable
38 }
39 if m.modelSwitchPending || m.runtimeSwitchBusy() {
40 return reloadQueued
41 }
42 return reloadNow
43 }
44
45 // runReloadCommand handles "/reload": rebuild the agent runtime (tools,
46 // skills, commands, hooks, MCP servers, providers) in place, keeping this
47 // session. A turn or rebuild in flight queues exactly one reload; the
48 // TurnDone drain runs it once the TUI is idle.
49 func (m *chatTUI) runReloadCommand() tea.Cmd {
50 if m == nil || m.ctrl == nil {
51 return nil
52 }
53 switch m.reloadDisposition() {
54 case reloadUnavailable:
55 m.notice(i18n.M.RuntimeRefreshUnavailable)
56 case reloadQueued:
57 // Coalesce: repeated /reload while busy keeps a single queued reload.
58 if !m.pendingReload {
59 m.pendingReload = true
60 m.notice(i18n.M.RuntimeReloadQueued)
61 }
62 case reloadNow:
63 return m.scheduleRuntimeReload()
64 }
65 return nil
66 }
67
68 // drainQueuedRuntimeReload runs a /reload queued while the TUI was busy. It is
69 // called from the TurnDone drain and after a runtime switch settles; the busy
70 // re-check keeps the reload queued when background work outlives the turn.
71 func (m *chatTUI) drainQueuedRuntimeReload() tea.Cmd {
72 if m == nil || !m.pendingReload {
73 return nil
74 }
75 if m.reloadDisposition() != reloadNow {
76 return nil
77 }
78 return m.scheduleRuntimeReload()
79 }
80
81 // scheduleRuntimeReload rebuilds the current controller through boot.Rebuild
82 // (same model/profile/effort; fresh extension discovery and migrated session
83 // state) and swaps it in only after the replacement is fully built. The swap
84 // and the old controller's retirement land in the shared modelSwitchMsg
85 // handler, exactly like a model switch: on failure the old controller keeps
86 // serving; on success the old one is closed at exit (closing it inside the
87 // TUI corrupts bubbletea's raw mode — see oldControllers).
88 func (m *chatTUI) scheduleRuntimeReload() tea.Cmd {
89 if m == nil || m.ctrl == nil || m.rebuildRuntime == nil {
90 return nil
91 }
92 oldCtrl, ok := m.ctrl.(*control.Controller)
93 if !ok {
94 // The real TUI always drives a *control.Controller; a SessionAPI test
95 // double has no boot.Options to rebuild from.
96 m.notice(i18n.M.RuntimeRefreshUnavailable)
97 return nil
98 }
99 m.pendingReload = false
100 if err := m.ctrl.Snapshot(); err != nil {
101 m.notice("reload: snapshot failed: " + err.Error())
102 }
103 // Move the lease to the controller's current file before the replacement
104 // binds it for writing (mirrors scheduleCurrentControllerRebuild). Capture
105 // the path after Snapshot: a conflict retarget can move it.
106 resumePath := m.ctrl.SessionPath()
107 if err := m.rebindSessionLease(resumePath); err != nil {
108 m.notice("reload: " + sessionLeaseHeldNotice(err))
109 return nil
110 }
111
112 rebuild := m.rebuildRuntime
113 spec := controllerBuildSpec{
114 ModelRef: m.modelRef,
115 RuntimeProfile: m.runtimeProfile,
116 // EffortOverride stays nil: the captured build options in cli.go
117 // already track the session's current effort.
118 }
119 m.modelSwitchPending = true
120 m.pendingModelSwitch = func() tea.Msg {
121 res, err := rebuild(context.Background(), spec, oldCtrl)
122 if err != nil {
123 return modelSwitchMsg{ref: spec.ModelRef, failurePrefix: "reload", err: err}
124 }
125 c := res.Controller
126 notice := i18n.M.RuntimeReloaded
127 if res.Snapshot != nil {
128 notice = fmt.Sprintf(i18n.M.RuntimeReloadedGenerationFmt, res.Snapshot.Generation())
129 }
130 // res.Runtime is the stage-3a (always empty) runtime set; when stage 5
131 // binds sidecar processes it must retire alongside the controller via
132 // oldControllers.
133 return modelSwitchMsg{
134 ref: spec.ModelRef,
135 ctrl: c,
136 oldCtrl: oldCtrl,
137 label: c.Label(),
138 commands: c.Commands(),
139 skills: c.SlashSkills(),
140 host: c.Host(),
141 successNotice: notice,
142 }
143 }
144 return m.pendingModelSwitch
145 }
146
147 // runtimeSettingChangeReady guards settings that need a controller rebuild.
148 // A nil controller is a non-interactive/test surface where persistence can
149 // still proceed without an in-session refresh.
150 func (m *chatTUI) runtimeSettingChangeReady() bool {
151 if m == nil || m.ctrl == nil {
152 return true
153 }
154 if m.buildController == nil {
155 m.notice(i18n.M.RuntimeRefreshUnavailable)
156 return false
157 }
158 if m.runtimeSwitchBusy() {
159 m.notice(i18n.M.RuntimeRefreshBusy)
160 return false
161 }
162 if m.modelSwitchPending {
163 m.notice(i18n.M.RuntimeSwitchPending)
164 return false
165 }
166 return true
167 }
168
169 // scheduleCurrentControllerRebuild refreshes configuration-backed runtime state
170 // without changing the active model/profile. The old controller remains usable
171 // until a fully initialized replacement is ready.
172 func (m *chatTUI) scheduleCurrentControllerRebuild(reason, successNotice string) tea.Cmd {
173 if m == nil || m.ctrl == nil {
174 return nil
175 }
176 if m.buildController == nil {
177 m.notice(i18n.M.RuntimeRefreshUnavailable)
178 return nil
179 }
180 if err := m.ctrl.Snapshot(); err != nil {
181 m.notice(reason + ": snapshot failed: " + err.Error())
182 }
183 carried := m.ctrl.History()
184 resumePath := m.ctrl.SessionPath()
185 if err := m.rebindSessionLease(resumePath); err != nil {
186 m.notice(reason + ": " + sessionLeaseHeldNotice(err))
187 return nil
188 }
189
190 oldCtrl := m.ctrl
191 build := m.buildController
192 ref := m.modelRef
193 profile := m.runtimeProfile
194 m.modelSwitchPending = true
195 m.pendingModelSwitch = func() tea.Msg {
196 c, err := build(controllerBuildSpec{
197 ModelRef: ref,
198 RuntimeProfile: profile,
199 ToolApprovalMode: oldCtrl.ToolApprovalMode(),
200 PlanMode: oldCtrl.PlanMode(),
201 }, carried, resumePath, oldCtrl)
202 if err != nil {
203 return modelSwitchMsg{ref: ref, failurePrefix: reason, err: err}
204 }
205 return modelSwitchMsg{
206 ref: ref,
207 ctrl: c,
208 oldCtrl: oldCtrl,
209 label: c.Label(),
210 commands: c.Commands(),
211 skills: c.SlashSkills(),
212 host: c.Host(),
213 successNotice: successNotice,
214 }
215 }
216 return m.pendingModelSwitch
217 }
218
218 lines GO