返回 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 if opts.Owner == nil {
88 opts.Owner = old.RuntimeOwner()
89 }
90 if service, runtime, ok := old.SessionBinding(); ok {
91 opts.SessionService = service
92 opts.SessionRuntime = runtime
93 opts.SessionHostID = runtime.Ref().HostID
94 }
95 // Capture migratable state before building: every accessor returns a
96 // copy, so a slow build cannot observe a half-appended turn.
97 m := runtimeMigration{
98 prevPath: old.SessionPath(),
99 carried: old.History(),
100 authorizations: old.SessionAuthorizations(),
101 toolApprovalMode: old.ToolApprovalMode(),
102 planMode: old.PlanMode(),
103 goal: old.Goal(),
104 goalRunning: old.GoalStatus() == control.GoalStatusRunning,
105 }
106 // Reuse the previous Controller's session-private temporary directory so
107 // model/settings hot rebuilds do not wipe temporary files mid-session.
108 if opts.SessionTemp == nil {
109 opts.SessionTemp = old.SessionTemp()
110 }
111 if opts.PersistentShell == nil {
112 opts.PersistentShell = old.PersistentShell()
113 }
114
115 home := config.ReasonixHomeDir()
116 // fromGraph must be the PREVIOUS generation's graph when available.
117 // Building "current disk" for both from and to collapses every plan to no-op.
118 var fromGraph *extension.DependencyGraph
119 if previous != nil && previous.Plan != nil && previous.Plan.Graph != nil {
120 fromGraph = previous.Plan.Graph
121 } else if g, err := buildRuntimeGraph(home, nil); err == nil {
122 fromGraph = g
123 }
124 opts.Graph = fromGraph
125
126 // Prefer subgraph-classified rebuild when previous assembly is available.
127 if previous != nil && !opts.ForceFullRebuild {
128 if res, handled, err := tryRebuildSubgraph(ctx, old, previous, opts, m); handled {
129 return res, err
130 }
131 }
132
133 // Freeze the old path-derived event producer before a full replacement can
134 // import it. Failure restores the old producer; successful publication
135 // transfers ownership to the replacement for every host frontend.
136 restoreLegacyEvents, err := old.SuspendLegacyEventStoreForImport(ctx)
137 if err != nil {
138 return nil, fmt.Errorf("boot: suspend legacy session events: %w", err)
139 }
140 replacementPublished := false
141 defer func() {
142 if !replacementPublished {
143 restoreLegacyEvents()
144 }
145 }()
146
147 extension.DefaultLifecycleMetrics.FullRebuilds.Add(1)
148 opts.deferPublish = true
149 res, err := BuildRuntime(ctx, opts)
150 if err != nil {
151 // Activation failure: new generation never published; old keeps serving.
152 return nil, err
153 }
154
155 var toGraph *extension.DependencyGraph
156 if g, err := buildRuntimeGraph(home, nil); err == nil {
157 toGraph = g
158 }
159 var previousSnapshot *extension.RuntimeSnapshot
160 if previous != nil {
161 previousSnapshot = previous.Snapshot
162 }
163 attachPlanAndStatus(res, fromGraph, toGraph, opts.Generation, previousSnapshot)
164
165 if err := migrateRuntimeState(res.Controller, old, m, opts.SessionCreateOptions); err != nil {
166 // Fail-atomic: release the replacement; old keeps serving.
167 // Activation never reached Active publish.
168 if res.Snapshot != nil {
169 res.Owner.Gate.BeginDrain(res.Snapshot.Generation())
170 }
171 res.Controller.ReleaseResources()
172 if res.Runtime != nil {
173 _ = res.Runtime.Close()
174 }
175 return nil, err
176 }
177 if prevGen := old.RuntimeGeneration(); prevGen != 0 && (res.Snapshot == nil || prevGen != res.Snapshot.Generation()) {
178 registerControllerDrainCancel(res.Owner, prevGen, old)
179 if host := old.Host(); host != nil {
180 h := host
181 res.Owner.Gate.RegisterDrainCancel(prevGen, func() { h.CancelInFlightMCP() })
182 }
183 }
184 // Publish new generation only after Active + state migration. Then drain
185 // Removed/Reloaded clients still held by the previous Manager.
186 publishBuildResult(res)
187 replacementPublished = true
188 if opts.Extensions != nil && res.Plan != nil {
189 opts.Extensions.DrainPlan(res.Plan)
190 }
191 // SessionEnd is not fired on ordinary rebuild.
192 return res, nil
193 }
194
195 // runtimeMigration carries the captured old-controller state into
196 // migrateRuntimeState.
197 type runtimeMigration struct {
198 prevPath string
199 carried []provider.Message
200 authorizations control.SessionAuthorizations
201 toolApprovalMode string
202 planMode bool
203 goal string
204 goalRunning bool
205 }
206
207 // migrateRuntimeState applies the captured state to the freshly built
208 // controller. Every step today is an infallible public control call; the
209 // error return is the fail-atomic seam for steps that gain failure modes.
210 func migrateRuntimeState(ctrl, old *control.Controller, m runtimeMigration, createOptions session.CreateOptions) error {
211 carried := spliceFreshSystemPrompt(m.carried, ctrl.History())
212 if ctrl.UsesExclusiveSession() {
213 if _, _, ok := ctrl.SessionBinding(); ok {
214 if err := ctrl.AdoptRebuiltModelContext(carried); err != nil {
215 return err
216 }
217 } else if m.prevPath != "" {
218 path := agent.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
219 if _, err := ctrl.ContinueLegacySessionForRebuildWithOptions(context.Background(), path, "", createOptions); err != nil {
220 return err
221 }
222 if err := ctrl.AdoptRebuiltModelContext(carried); err != nil {
223 return err
224 }
225 } else {
226 // A compatibility rebuild can start from an in-memory controller
227 // with no persistent identity. Preserve that state without minting
228 // a new logical session (which would rotate session-private temp).
229 ctrl.AdoptHistory(carried, "")
230 }
231 } else {
232 path := agent.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
233 ctrl.AdoptHistory(carried, path)
234 }
235
236 // Re-apply session axes a rebuild must not reset.
237 ctrl.SetToolApprovalMode(m.toolApprovalMode)
238 ctrl.SetPlanMode(m.planMode)
239 if m.goalRunning && strings.TrimSpace(m.goal) != "" && strings.TrimSpace(ctrl.Goal()) == "" {
240 ctrl.SetGoal(m.goal)
241 }
242 if m.prevPath == "" {
243 // No persisted recovery sidecar; carry the live checkpoint.
244 ctrl.CarryRecoveryFrom(old)
245 }
246
247 if err := ctrl.InheritLifecycleFrom(old); err != nil {
248 return fmt.Errorf("inherit controller lifecycle: %w", err)
249 }
250 ctrl.RestoreSessionAuthorizations(m.authorizations)
251 return nil
252 }
253
254 // spliceFreshSystemPrompt replaces the carried conversation's system message
255 // with the fresh build's, so the resumed session speaks the rebuilt profile
256 // contract. A carried conversation without a system message gets the fresh
257 // one prepended; a fresh build without one leaves the conversation untouched.
258 func spliceFreshSystemPrompt(carried, fresh []provider.Message) []provider.Message {
259 var system *provider.Message
260 for i := range fresh {
261 if fresh[i].Role == provider.RoleSystem {
262 system = &fresh[i]
263 break
264 }
265 }
266 if system == nil {
267 return carried
268 }
269 out := append([]provider.Message(nil), carried...)
270 for i := range out {
271 if out[i].Role == provider.RoleSystem {
272 out[i] = *system
273 return out
274 }
275 }
276 return append([]provider.Message{*system}, out...)
277 }
278
278 lines GO