| 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 |