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