返回 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/control"
10 "reasonix/internal/provider"
11 )
12
13 // Rebuild builds a replacement runtime for old, migrating session state.
14 // On any failure the partially built runtime is closed and old keeps working.
15 //
16 // The caller passes the SAME SharedHost in opts.SharedHost that the old build
17 // used (when it used one), so the replacement reuses running MCP processes
18 // instead of respawning them per rebuild.
19 //
20 // Migrated state (all via public control APIs, mirroring the desktop settings
21 // rebuild and the CLI/ACP model switch):
22 // - conversation history: old.History() resumes on the SAME session file
23 // (agent.ContinueSessionPath), with the freshly composed system message
24 // spliced over the outgoing one so the next turn speaks the rebuilt
25 // profile contract;
26 // - Goal and recovery sidecars: restored by the Resume inside AdoptHistory
27 // whenever the session path persisted; when old never pinned a path (no
28 // sidecar could exist), a running Goal is seeded from old's in-memory
29 // state and the live recovery checkpoint is carried across;
30 // - tool approval mode (Ask/Auto/Yolo) and the plan-mode flag — carried
31 // faithfully, including the inconsistent plan+goal combination a legacy
32 // session could hold, because Rebuild reproduces old's state rather than
33 // re-interpreting it;
34 // - same-session authorizations: "Allow for this session" grants and
35 // Plan-mode read-only command trust (RestoreSessionAuthorizations);
36 // - lifecycle markers (turn counter, started-once) via
37 // InheritLifecycleFrom.
38 //
39 // Left to the frontend (Rebuild deliberately does not do these):
40 // - swapping its controller pointer and closing old AFTER a successful
41 // swap — old's controller and the old BuildResult.Runtime set stay the
42 // caller's to release (CloseIfGeneration guards against closing a newer
43 // runtime's resources);
44 // - re-installing the interactive approval gate (EnableInteractiveApproval)
45 // and re-binding approval/ask channels to the new controller;
46 // - persisting the migrated transcript (Controller.Snapshot) when the swap
47 // must be durable before it is published (ACP does this after migrating,
48 // before publishing; desktop persists after the swap);
49 // - session-lease coordination across the rebuild (desktop).
50 func Rebuild(ctx context.Context, old *control.Controller, opts Options) (*BuildResult, error) {
51 if old == nil {
52 return nil, fmt.Errorf("boot: Rebuild requires the controller being replaced")
53 }
54 // Capture migratable state before building: every accessor returns a
55 // copy, so a slow build cannot observe a half-appended turn.
56 m := runtimeMigration{
57 prevPath: old.SessionPath(),
58 carried: old.History(),
59 authorizations: old.SessionAuthorizations(),
60 toolApprovalMode: old.ToolApprovalMode(),
61 planMode: old.PlanMode(),
62 goal: old.Goal(),
63 goalRunning: old.GoalStatus() == control.GoalStatusRunning,
64 }
65 // Reuse the previous Controller's session-private temporary directory so
66 // model/settings hot rebuilds do not wipe temporary files mid-session.
67 if opts.SessionTemp == nil {
68 opts.SessionTemp = old.SessionTemp()
69 }
70 res, err := BuildRuntime(ctx, opts)
71 if err != nil {
72 return nil, err
73 }
74 if err := migrateRuntimeState(res.Controller, old, m); err != nil {
75 // Fail-atomic: nothing was published, so release the replacement
76 // without firing SessionEnd (the session logically continues on old)
77 // and close its runtime set — empty in stage 3a — so stage-5
78 // resources can never leak through this path. old is never closed
79 // here; its runtime set stays the caller's to release after a
80 // successful swap.
81 res.Controller.ReleaseResources()
82 if res.Runtime != nil {
83 _ = res.Runtime.Close()
84 }
85 return nil, err
86 }
87 return res, nil
88 }
89
90 // runtimeMigration carries the captured old-controller state into
91 // migrateRuntimeState.
92 type runtimeMigration struct {
93 prevPath string
94 carried []provider.Message
95 authorizations control.SessionAuthorizations
96 toolApprovalMode string
97 planMode bool
98 goal string
99 goalRunning bool
100 }
101
102 // migrateRuntimeState applies the captured state to the freshly built
103 // controller. Every step today is an infallible public control call; the
104 // error return is the fail-atomic seam for steps that gain failure modes
105 // (for example persisting the migrated transcript), so Rebuild's cleanup
106 // path is real rather than assumed.
107 func migrateRuntimeState(ctrl, old *control.Controller, m runtimeMigration) error {
108 carried := spliceFreshSystemPrompt(m.carried, ctrl.History())
109 path := agent.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
110 ctrl.AdoptHistory(carried, path)
111
112 // Re-apply the session axes a rebuild must not reset (mirrors the ACP
113 // session-config switch). The Goal sidecar restored by the Resume above
114 // is authoritative; the in-memory Goal is seeded only when nothing was
115 // restored (the outgoing controller never pinned a session path).
116 ctrl.SetToolApprovalMode(m.toolApprovalMode)
117 ctrl.SetPlanMode(m.planMode)
118 if m.goalRunning && strings.TrimSpace(m.goal) != "" && strings.TrimSpace(ctrl.Goal()) == "" {
119 ctrl.SetGoal(m.goal)
120 }
121 if m.prevPath == "" {
122 // No persisted recovery sidecar could have been restored, so carry
123 // the live in-memory checkpoint across the boundary.
124 ctrl.CarryRecoveryFrom(old)
125 }
126
127 // Same-session lifecycle and grants: the replacement keeps the turn
128 // counter / started-once flag and every "Allow for this session" and
129 // Plan-mode read-only trust grant the user already made.
130 ctrl.InheritLifecycleFrom(old)
131 ctrl.RestoreSessionAuthorizations(m.authorizations)
132 return nil
133 }
134
135 // spliceFreshSystemPrompt replaces the carried conversation's system message
136 // with the fresh build's, so the resumed session speaks the rebuilt profile
137 // contract. A carried conversation without a system message gets the fresh
138 // one prepended; a fresh build without one leaves the conversation untouched.
139 func spliceFreshSystemPrompt(carried, fresh []provider.Message) []provider.Message {
140 var system *provider.Message
141 for i := range fresh {
142 if fresh[i].Role == provider.RoleSystem {
143 system = &fresh[i]
144 break
145 }
146 }
147 if system == nil {
148 return carried
149 }
150 out := append([]provider.Message(nil), carried...)
151 for i := range out {
152 if out[i].Role == provider.RoleSystem {
153 out[i] = *system
154 return out
155 }
156 }
157 return append([]provider.Message{*system}, out...)
158 }
159
159 lines GO