返回 DeepSeek-Reasonix
reload.go
根目录 / internal / boot / reload.go
1 package boot
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7
8 "reasonix/internal/agent"
9 "reasonix/internal/config"
10 "reasonix/internal/control"
11 "reasonix/internal/extension"
12 "reasonix/internal/provider"
13 "reasonix/internal/session"
14 )
15
16 // RebuildFrom is Rebuild using previous BuildResult for incremental sidecars
17 // and subgraph-classified assembly (no-op / interceptor-only / UI-only, …).
18 func RebuildFrom(ctx context.Context, previous *BuildResult, opts Options) (*BuildResult, error) {
19 if previous == nil || previous.Controller == nil {
20 return nil, fmt.Errorf("boot: RebuildFrom requires the BuildResult being replaced")
21 }
22 if previous.Extensions != nil {
23 opts.Extensions = previous.Extensions
24 }
25 if previous.Plan != nil && previous.Plan.Graph != nil {
26 opts.Graph = previous.Plan.Graph
27 }
28 if previous.Snapshot != nil {
29 opts.Generation = previous.Snapshot.Generation()
30 opts.PreviousSnapshot = previous.Snapshot
31 }
32 if previous.Dispatcher != nil {
33 opts.PreviousDispatcher = previous.Dispatcher
34 }
35 if previous.Owner != nil {
36 opts.Owner = previous.Owner
37 }
38 return rebuildWithPrevious(ctx, previous.Controller, previous, opts)
39 }
40
41 // Rebuild builds a replacement runtime for old, migrating session state.
42 // On any failure the partially built runtime is closed and old keeps working.
43 //
44 // The caller passes the SAME SharedHost in opts.SharedHost that the old build
45 // used (when it used one), so the replacement reuses running MCP processes
46 // instead of respawning them per rebuild.
47 //
48 // Migrated state (all via public control APIs, mirroring the desktop settings
49 // rebuild and the CLI/ACP model switch):
50 // - conversation history: old.History() resumes on the SAME session file
51 // (agent.ContinueSessionPath), with the freshly composed system message
52 // spliced over the outgoing one so the next turn speaks the rebuilt
53 // profile contract;
54 // - Goal and recovery sidecars: restored by the Resume inside AdoptHistory
55 // whenever the session path persisted; when old never pinned a path (no
56 // sidecar could exist), a running Goal is seeded from old's in-memory
57 // state and the live recovery checkpoint is carried across;
58 // - tool approval mode (Ask/Auto/Yolo) and the plan-mode flag — carried
59 // faithfully, including the inconsistent plan+goal combination a legacy
60 // session could hold, because Rebuild reproduces old's state rather than
61 // re-interpreting it;
62 // - same-session authorizations: "Allow for this session" grants and
63 // Plan-mode read-only command trust (RestoreSessionAuthorizations);
64 // - lifecycle markers (turn counter, started-once) via
65 // InheritLifecycleFrom.
66 //
67 // Left to the frontend (Rebuild deliberately does not do these):
68 // - atomically activating the replacement with
69 // control.ActivateControllerReplacement while swapping its controller
70 // pointer, then closing old AFTER the successful swap — old's controller
71 // and the old BuildResult.Runtime set stay the caller's to release
72 // (CloseIfGeneration guards against closing a newer runtime's resources);
73 // - re-installing the interactive approval gate (EnableInteractiveApproval)
74 // and re-binding approval/ask channels to the new controller;
75 // - persisting the migrated transcript (Controller.Snapshot) when the swap
76 // must be durable before it is published (ACP does this after migrating,
77 // before publishing; desktop persists after the swap);
78 // - session-lease coordination across the rebuild (desktop).
79 func Rebuild(ctx context.Context, old *control.Controller, opts Options) (*BuildResult, error) {
80 return rebuildWithPrevious(ctx, old, nil, opts)
81 }
82
83 func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous *BuildResult, opts Options) (*BuildResult, error) {
84 if old == nil {
85 return nil, fmt.Errorf("boot: Rebuild requires the controller being replaced")
86 }
87 scope, finishBackground, abortBackground, err := control.ReserveBackgroundReplacement(old)
88 if err != nil {
89 return nil, err
90 }
91 opts.BackgroundScope = scope
92 defer abortBackground()
93 if opts.Owner == nil {
94 opts.Owner = old.RuntimeOwner()
95 }
96 opts.inheritSessionBinding(old)
97 // Capture migratable state before building: every accessor returns a
98 // copy, so a slow build cannot observe a half-appended turn.
99 m := runtimeMigration{
100 prevPath: old.SessionPath(),
101 carried: old.History(),
102 authorizations: old.SessionAuthorizations(),
103 toolApprovalMode: old.ToolApprovalMode(),
104 planMode: old.PlanMode(),
105 goal: old.Goal(),
106 goalRunning: old.GoalStatus() == control.GoalStatusRunning,
107 }
108 // Reuse the previous Controller's session-private temporary directory so
109 // model/settings hot rebuilds do not wipe temporary files mid-session.
110 if opts.SessionTemp == nil {
111 opts.SessionTemp = old.SessionTemp()
112 }
113 if opts.PersistentShell == nil {
114 opts.PersistentShell = old.PersistentShell()
115 }
116 if opts.WorkspaceRepo.Dir == "" {
117 opts.WorkspaceRepo = old.WorkspaceRepo()
118 }
119
120 home := config.ReasonixHomeDir()
121 // fromGraph must be the PREVIOUS generation's graph when available.
122 // Building "current disk" for both from and to collapses every plan to no-op.
123 var fromGraph *extension.DependencyGraph
124 if previous != nil && previous.Plan != nil && previous.Plan.Graph != nil {
125 fromGraph = previous.Plan.Graph
126 } else if g, err := buildRuntimeGraph(home, nil); err == nil {
127 fromGraph = g
128 }
129 opts.Graph = fromGraph
130
131 // Prefer subgraph-classified rebuild when previous assembly is available.
132 if previous != nil && !opts.ForceFullRebuild {
133 if res, handled, err := tryRebuildSubgraph(ctx, old, previous, opts, m); handled {
134 return res, err
135 }
136 }
137
138 // Freeze the old path-derived event producer before a full replacement can
139 // import it. Failure restores the old producer; successful publication
140 // transfers ownership to the replacement for every host frontend.
141 restoreLegacyEvents, err := old.SuspendLegacyEventStoreForImport(ctx)
142 if err != nil {
143 return nil, fmt.Errorf("boot: suspend legacy session events: %w", err)
144 }
145 replacementPublished := false
146 defer func() {
147 if !replacementPublished {
148 restoreLegacyEvents()
149 }
150 }()
151
152 extension.DefaultLifecycleMetrics.FullRebuilds.Add(1)
153 opts.deferPublish = true
154 res, err := BuildRuntime(ctx, opts)
155 if err != nil {
156 // Activation failure: new generation never published; old keeps serving.
157 return nil, err
158 }
159
160 var toGraph *extension.DependencyGraph
161 if g, err := buildRuntimeGraph(home, nil); err == nil {
162 toGraph = g
163 }
164 var previousSnapshot *extension.RuntimeSnapshot
165 if previous != nil {
166 previousSnapshot = previous.Snapshot
167 }
168 attachPlanAndStatus(res, fromGraph, toGraph, opts.Generation, previousSnapshot)
169
170 if err := migrateRuntimeState(res.Controller, old, m, opts.SessionCreateOptions); err != nil {
171 // Fail-atomic: release the replacement; old keeps serving.
172 // Activation never reached Active publish.
173 if res.Snapshot != nil {
174 res.Owner.Gate.BeginDrain(res.Snapshot.Generation())
175 }
176 res.Controller.ReleaseResources()
177 if res.Runtime != nil {
178 _ = res.Runtime.Close()
179 }
180 return nil, err
181 }
182 if prevGen := old.RuntimeGeneration(); prevGen != 0 && (res.Snapshot == nil || prevGen != res.Snapshot.Generation()) {
183 registerControllerDrainCancel(res.Owner, prevGen, old)
184 if host := old.Host(); host != nil {
185 h := host
186 res.Owner.Gate.RegisterDrainCancel(prevGen, func() { h.CancelInFlightMCP() })
187 }
188 }
189 // SessionEnd is not fired on ordinary rebuild.
190 if err := finishBackground(res.Controller); err != nil {
191 res.Controller.ReleaseResources()
192 restoreLegacyEvents()
193 return nil, err
194 }
195 // A host can still reject the prepared candidate. Its extension generation
196 // must not retire the outgoing runtime before the final ownership transfer.
197 stageModelRuntimePublication(res, opts)
198 replacementPublished = true // candidate now owns commit/rollback responsibility
199 res.Controller.StageReplacementRollback(restoreLegacyEvents)
200 return res, nil
201 }
202
203 // runtimeMigration carries the captured old-controller state into
204 // migrateRuntimeState.
205 type runtimeMigration struct {
206 prevPath string
207 carried []provider.Message
208 authorizations control.SessionAuthorizations
209 toolApprovalMode string
210 planMode bool
211 goal string
212 goalRunning bool
213 }
214
215 // migrateRuntimeState applies the captured state to the freshly built
216 // controller. Every step today is an infallible public control call; the
217 // error return is the fail-atomic seam for steps that gain failure modes.
218 func migrateRuntimeState(ctrl, old *control.Controller, m runtimeMigration, createOptions session.CreateOptions) error {
219 carried := spliceFreshSystemPrompt(m.carried, ctrl.History())
220 if ctrl.UsesExclusiveSession() {
221 if _, _, ok := ctrl.SessionBinding(); ok {
222 if err := ctrl.AdoptRebuiltModelContext(carried); err != nil {
223 return err
224 }
225 } else if m.prevPath != "" {
226 path := agent.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
227 loaded, err := agent.LoadSession(path)
228 if err != nil {
229 return err
230 }
231 if err := ctrl.ResumeNativeSession(loaded.CloneWithMessages(carried), path); err != nil {
232 return err
233 }
234 } else {
235 // A compatibility rebuild can start from an in-memory controller
236 // with no persistent identity. Preserve that state without minting
237 // a new logical session (which would rotate session-private temp).
238 ctrl.AdoptHistory(carried, "")
239 }
240 } else {
241 path := agent.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
242 if ctrl.NativeLegacySession() {
243 if err := ctrl.AdoptNativeRebuiltContext(old, carried, path); err != nil {
244 return err
245 }
246 } else {
247 ctrl.AdoptHistory(carried, path)
248 }
249 }
250
251 // Rebuilds preserve the live frontend mode, including a transient TUI
252 // downgrade inside a session that once stored a remote Serve preset.
253 ctrl.SetToolApprovalMode(m.toolApprovalMode)
254 ctrl.SetPlanMode(m.planMode)
255 if m.goalRunning && strings.TrimSpace(m.goal) != "" && strings.TrimSpace(ctrl.Goal()) == "" {
256 ctrl.SetGoal(m.goal)
257 }
258 if m.prevPath == "" {
259 // No persisted recovery sidecar; carry the live checkpoint.
260 ctrl.CarryRecoveryFrom(old)
261 }
262
263 if err := ctrl.InheritLifecycleFrom(old); err != nil {
264 return fmt.Errorf("inherit controller lifecycle: %w", err)
265 }
266 ctrl.RestoreSessionAuthorizations(m.authorizations)
267 return nil
268 }
269
270 // spliceFreshSystemPrompt replaces the carried conversation's system message
271 // with the fresh build's, so the resumed session speaks the rebuilt profile
272 // contract. A carried conversation without a system message gets the fresh
273 // one prepended; a fresh build without one leaves the conversation untouched.
274 func spliceFreshSystemPrompt(carried, fresh []provider.Message) []provider.Message {
275 var system *provider.Message
276 for i := range fresh {
277 if fresh[i].Role == provider.RoleSystem {
278 system = &fresh[i]
279 break
280 }
281 }
282 if system == nil {
283 return carried
284 }
285 out := append([]provider.Message(nil), carried...)
286 for i := range out {
287 if out[i].Role == provider.RoleSystem {
288 out[i] = *system
289 return out
290 }
291 }
292 return append([]provider.Message{*system}, out...)
293 }
294
294 lines GO