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