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