| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/http" |
| 6 | |
| 7 | "reasonix/internal/agent" |
| 8 | ) |
| 9 | |
| 10 | var takeoverBuildLocalSpectatorCandidateForTest func(*App, *WorkspaceTab, tabRuntimeSnapshot, string, *agent.Session) (*sessionRebindCandidate, error) |
| 11 | |
| 12 | type takeoverTabState uint8 |
| 13 | |
| 14 | const ( |
| 15 | takeoverTabUnavailable takeoverTabState = iota |
| 16 | takeoverTabStartupBlocked |
| 17 | takeoverTabLocalSpectator |
| 18 | ) |
| 19 | |
| 20 | func (a *App) takeoverTabState(tab *WorkspaceTab) takeoverTabState { |
| 21 | if a == nil || tab == nil { |
| 22 | return takeoverTabUnavailable |
| 23 | } |
| 24 | a.mu.RLock() |
| 25 | defer a.mu.RUnlock() |
| 26 | return a.takeoverTabStateLocked(tab) |
| 27 | } |
| 28 | |
| 29 | func (a *App) takeoverTabStateAt(tab *WorkspaceTab, epoch, path string) takeoverTabState { |
| 30 | if a == nil || tab == nil { |
| 31 | return takeoverTabUnavailable |
| 32 | } |
| 33 | a.mu.RLock() |
| 34 | defer a.mu.RUnlock() |
| 35 | if a.runtimeEpochForTabLocked(tab) != epoch || sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(path) { |
| 36 | return takeoverTabUnavailable |
| 37 | } |
| 38 | return a.takeoverTabStateLocked(tab) |
| 39 | } |
| 40 | |
| 41 | func (a *App) takeoverTabStateLocked(tab *WorkspaceTab) takeoverTabState { |
| 42 | if a.tabs[tab.ID] != tab || tab.removed { |
| 43 | return takeoverTabUnavailable |
| 44 | } |
| 45 | if tab.Ctrl == nil && tab.StartupErrLeaseHeld { |
| 46 | return takeoverTabStartupBlocked |
| 47 | } |
| 48 | if tab.Ctrl != nil && tab.ReadOnly && tab.Takeover.Spectator { |
| 49 | return takeoverTabLocalSpectator |
| 50 | } |
| 51 | return takeoverTabUnavailable |
| 52 | } |
| 53 | |
| 54 | func (a *App) markLocalTakeoverSpectator(tab *WorkspaceTab) { |
| 55 | if a == nil || tab == nil { |
| 56 | return |
| 57 | } |
| 58 | marked := false |
| 59 | a.mu.Lock() |
| 60 | if a.tabs[tab.ID] == tab && !tab.removed { |
| 61 | tab.ReadOnly = true |
| 62 | tab.Takeover.Spectator = true |
| 63 | a.saveTabsLocked() |
| 64 | marked = true |
| 65 | } |
| 66 | a.mu.Unlock() |
| 67 | if marked { |
| 68 | a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: tab.ID, Meta: a.MetaForTab(tab.ID)}) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func (a *App) failLocalSpectatorTakeover( |
| 73 | key string, |
| 74 | lease *agent.SessionLease, |
| 75 | record takeoverServeRecord, |
| 76 | client *http.Client, |
| 77 | grant takeoverGrant, |
| 78 | cause error, |
| 79 | ) error { |
| 80 | if lease == nil { |
| 81 | return cause |
| 82 | } |
| 83 | if mirror := a.takeoverMirrorForKey(key); mirror != nil { |
| 84 | mirror.returnLeaseAfterFailedTakeover(lease) |
| 85 | return cause |
| 86 | } |
| 87 | if err := lease.ReleaseForHandoff(grant.SourceWriterID, grant.ReturnHandoffID); err != nil { |
| 88 | lease.Release() |
| 89 | return fmt.Errorf("%w (return reclaimed lease: %w)", cause, err) |
| 90 | } |
| 91 | a.endFailedTakeover(record, client, grant) |
| 92 | return cause |
| 93 | } |
| 94 | |
| 95 | // promoteLocalTakeoverSpectator completes the A -> B -> A handoff. The old |
| 96 | // read-only controller stays published until a freshly loaded replacement is |
| 97 | // fully built and authorized, so any failure leaves a usable spectator rather |
| 98 | // than a half-promoted writer. |
| 99 | // |
| 100 | // The caller holds runtimeRebuildMu and has already received grant from Serve. |
| 101 | func (a *App) promoteLocalTakeoverSpectator( |
| 102 | tab *WorkspaceTab, |
| 103 | path, sourceEpoch string, |
| 104 | record takeoverServeRecord, |
| 105 | client *http.Client, |
| 106 | grant takeoverGrant, |
| 107 | ) error { |
| 108 | a.runtimeAdmissionMu.Lock() |
| 109 | defer a.runtimeAdmissionMu.Unlock() |
| 110 | tab.turnStartMu.Lock() |
| 111 | defer tab.turnStartMu.Unlock() |
| 112 | |
| 113 | a.mu.RLock() |
| 114 | valid := a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl != nil && tab.ReadOnly && tab.Takeover.Spectator && |
| 115 | a.runtimeEpochForTabLocked(tab) == sourceEpoch && |
| 116 | sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(path) |
| 117 | source := snapshotTabRuntimeLocked(tab) |
| 118 | a.mu.RUnlock() |
| 119 | if !valid || tab.sessionLeaseRuntimeKey() != "" || controllerHasActiveRuntimeWork(source.ctrl) { |
| 120 | a.endFailedTakeover(record, client, grant) |
| 121 | return fmt.Errorf("tab changed while reclaiming the session; retry") |
| 122 | } |
| 123 | |
| 124 | lease, err := agent.TryAcquireSessionLeaseWithHandoff(path, grant.SourceWriterID, grant.HandoffID) |
| 125 | if err != nil { |
| 126 | a.endFailedTakeover(record, client, grant) |
| 127 | return userFacingSessionLeaseError("", err) |
| 128 | } |
| 129 | key := sessionRuntimeKey(path) |
| 130 | a.registerTakeoverMirror(key, tab.ID, path, record, client, grant) |
| 131 | |
| 132 | // Reload only after targeted acquisition: Serve may have appended the last |
| 133 | // remote turn immediately before publishing the handoff reservation. |
| 134 | loaded, err := loadResumableSession(path) |
| 135 | if err != nil { |
| 136 | return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("reload reclaimed session: %w", err)) |
| 137 | } |
| 138 | var candidate *sessionRebindCandidate |
| 139 | if build := takeoverBuildLocalSpectatorCandidateForTest; build != nil { |
| 140 | candidate, err = build(a, tab, source, path, loaded) |
| 141 | } else { |
| 142 | candidate, err = a.buildSessionRebindCandidate(tab, source, path, loaded, loadTabSessionProfile(path), false) |
| 143 | } |
| 144 | if err != nil { |
| 145 | return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("rebuild reclaimed session: %w", err)) |
| 146 | } |
| 147 | committed := false |
| 148 | defer func() { |
| 149 | if !committed { |
| 150 | candidate.close() |
| 151 | } |
| 152 | }() |
| 153 | desiredRuntime := source.normalizedRuntime() |
| 154 | configureControllerRuntime(candidate.ctrl, source.ctrl, desiredRuntime) |
| 155 | restoredRuntime, err := normalizeRestoredControllerRuntime(candidate.ctrl, desiredRuntime) |
| 156 | if err != nil { |
| 157 | return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("restore reclaimed runtime: %w", err)) |
| 158 | } |
| 159 | candidate.runtime = restoredRuntime |
| 160 | |
| 161 | a.mu.Lock() |
| 162 | valid = a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == source.ctrl && tab.ReadOnly && tab.Takeover.Spectator && |
| 163 | a.runtimeEpochForTabLocked(tab) == sourceEpoch && |
| 164 | sessionRuntimeKey(tab.currentSessionPath()) == key |
| 165 | if !valid || tab.sessionLeaseRuntimeKey() != "" { |
| 166 | a.mu.Unlock() |
| 167 | return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("tab changed while reclaiming the session; retry")) |
| 168 | } |
| 169 | if err := bindCandidateWriteAuthority(candidate.ctrl, lease); err != nil { |
| 170 | a.mu.Unlock() |
| 171 | return a.failLocalSpectatorTakeover(key, lease, record, client, grant, fmt.Errorf("bind reclaimed session authority: %w", err)) |
| 172 | } |
| 173 | oldCtrl, oldSink := tab.Ctrl, tab.sink |
| 174 | tab.adoptSessionLease(lease) |
| 175 | tab.Ctrl = candidate.ctrl |
| 176 | tab.sink = candidate.sink |
| 177 | tab.SessionPath = path |
| 178 | tab.model = candidate.model |
| 179 | tab.Label = candidate.ctrl.Label() |
| 180 | applyNormalizedRuntimeToTabLocked(tab, candidate.runtime) |
| 181 | tab.Takeover.Spectator = false |
| 182 | tab.Ready = true |
| 183 | clearTabStartupError(tab) |
| 184 | tab.ActivityStatus = "" |
| 185 | tab.replaceTelemetry(candidate.telemetry, key) |
| 186 | if tab.sink != nil { |
| 187 | tab.sink.setBinding(tab.ID, a, tab.SessionGeneration) |
| 188 | tab.sink.setContext(a.ctx) |
| 189 | } |
| 190 | a.supersedeTabBuildLocked(tab) |
| 191 | newEpoch := a.advanceSessionRuntimeEpochLocked(tab) |
| 192 | a.saveTabsLocked() |
| 193 | candidate.ctrl = nil |
| 194 | candidate.sink = nil |
| 195 | committed = true |
| 196 | a.mu.Unlock() |
| 197 | |
| 198 | // Reopen the terminal/input capability gate only after the replacement and |
| 199 | // its write authority are visible as one committed runtime. |
| 200 | a.setTabReadOnly(tab.ID, false) |
| 201 | a.attachTakeoverMirror(tab.ID, path) |
| 202 | if oldSink != nil { |
| 203 | oldSink.setBinding("", nil) |
| 204 | oldSink.clearContext() |
| 205 | } |
| 206 | if oldCtrl != nil { |
| 207 | oldCtrl.Close() |
| 208 | } |
| 209 | a.persistTabSessionPath(tab, path) |
| 210 | a.notifyTabRuntimeRebuiltAtEpoch(tab, newEpoch) |
| 211 | a.emitReady(a.ctx, tab.ID) |
| 212 | return nil |
| 213 | } |
| 214 |