| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | "reasonix/internal/control" |
| 10 | ) |
| 11 | |
| 12 | // handoffSessionLease acquires path and publishes it on the tab without |
| 13 | // releasing the previous lease. Recovery callbacks use the returned lease to |
| 14 | // retire the old path only after the current authority-guarded save returns. |
| 15 | func (t *WorkspaceTab) handoffSessionLease(path string) (*agent.SessionLease, error) { |
| 16 | if t == nil || t.ReadOnly { |
| 17 | return nil, nil |
| 18 | } |
| 19 | legacyPath, ok, err := legacySessionPathForFileAccess(path) |
| 20 | if err != nil { |
| 21 | return nil, err |
| 22 | } |
| 23 | if !ok { |
| 24 | return nil, nil |
| 25 | } |
| 26 | key := sessionRuntimeKey(string(legacyPath)) |
| 27 | t.sessionLeaseMu.Lock() |
| 28 | if t.sessionLease != nil && sessionRuntimeKey(t.sessionLease.Path()) == key { |
| 29 | t.storeSessionLeaseRuntimeKey(key) |
| 30 | t.sessionLeaseMu.Unlock() |
| 31 | return nil, nil |
| 32 | } |
| 33 | lease, err := agent.TryAcquireSessionLease(string(legacyPath)) |
| 34 | if err != nil { |
| 35 | t.sessionLeaseMu.Unlock() |
| 36 | return nil, err |
| 37 | } |
| 38 | if hook := sessionLeaseAcquireHookForTest; hook != nil { |
| 39 | hook() |
| 40 | } |
| 41 | old := t.sessionLease |
| 42 | t.sessionLease = lease |
| 43 | t.storeSessionLeaseRuntimeKey(key) |
| 44 | t.sessionLeaseMu.Unlock() |
| 45 | return old, nil |
| 46 | } |
| 47 | |
| 48 | func (t *WorkspaceTab) swapSessionLease(lease *agent.SessionLease) *agent.SessionLease { |
| 49 | if t == nil { |
| 50 | return lease |
| 51 | } |
| 52 | t.sessionLeaseMu.Lock() |
| 53 | old := t.sessionLease |
| 54 | t.sessionLease = lease |
| 55 | key := "" |
| 56 | if lease != nil { |
| 57 | key = sessionRuntimeKey(lease.Path()) |
| 58 | } |
| 59 | t.storeSessionLeaseRuntimeKey(key) |
| 60 | t.sessionLeaseMu.Unlock() |
| 61 | return old |
| 62 | } |
| 63 | |
| 64 | func (a *App) handoffTabRecoveryLease(tab *WorkspaceTab, recoveryPath string) error { |
| 65 | if tab == nil || tab.ReadOnly { |
| 66 | return nil |
| 67 | } |
| 68 | transition, err := a.reserveSessionRuntimePath(tab, recoveryPath) |
| 69 | if err != nil { |
| 70 | return fmt.Errorf("acquire recovery session lease: %w", userFacingSessionLeaseError("", err)) |
| 71 | } |
| 72 | oldLease, err := tab.handoffSessionLease(recoveryPath) |
| 73 | if err != nil { |
| 74 | a.rollbackSessionRuntimePath(transition) |
| 75 | slog.Warn("desktop: acquire recovery session lease", "path", recoveryPath, "err", err) |
| 76 | reason := "lease_unavailable" |
| 77 | if errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 78 | reason = "lease_held" |
| 79 | } |
| 80 | _ = agent.UpdateBranchMeta(recoveryPath, false, func(meta *agent.BranchMeta) error { |
| 81 | meta.VersionKind = agent.VersionRecovery |
| 82 | meta.VersionState = agent.VersionPending |
| 83 | return nil |
| 84 | }) |
| 85 | a.emitRuntimeEvent("session:recovery-failed", sessionRecoveryFailedEvent{ |
| 86 | Reason: reason, ConversationID: tab.TopicID, TopicID: tab.TopicID, |
| 87 | RecoveryPath: recoveryPath, |
| 88 | WorkspaceRoot: tab.WorkspaceRoot, |
| 89 | CanContinue: false, RecoveryPending: true, |
| 90 | }) |
| 91 | return fmt.Errorf("acquire recovery session lease: %w", userFacingSessionLeaseError("", err)) |
| 92 | } |
| 93 | if err := bindTabWriteAuthority(tab, tab.Ctrl); err != nil { |
| 94 | newLease := tab.swapSessionLease(oldLease) |
| 95 | _ = bindTabWriteAuthority(tab, tab.Ctrl) |
| 96 | a.rollbackSessionRuntimePath(transition) |
| 97 | if newLease != nil { |
| 98 | newLease.Release() |
| 99 | } |
| 100 | return fmt.Errorf("bind recovery session authority: %w", err) |
| 101 | } |
| 102 | a.commitSessionRuntimePath(transition) |
| 103 | if oldLease != nil { |
| 104 | go oldLease.Release() |
| 105 | } |
| 106 | return nil |
| 107 | } |
| 108 | |
| 109 | // handleTabSessionTransition moves a tab's lease and binds the unpublished |
| 110 | // target Session before its controller switches paths. The source controller |
| 111 | // remains fully usable when any acquisition or bind step fails. |
| 112 | func (a *App) handleTabSessionTransition(tab *WorkspaceTab) func(control.SessionTransitionInfo) error { |
| 113 | return func(info control.SessionTransitionInfo) error { |
| 114 | if tab == nil || tab.ReadOnly { |
| 115 | return nil |
| 116 | } |
| 117 | if info.Reason == "fork" || info.Reason == "branch" { |
| 118 | if err := copyPinnedContextState(info.OriginalPath, info.TargetPath); err != nil { |
| 119 | return fmt.Errorf("copy pinned context to %s: %w", info.Reason, err) |
| 120 | } |
| 121 | } |
| 122 | pinnedState, err := loadPinnedContextState(info.TargetPath) |
| 123 | if err != nil { |
| 124 | return fmt.Errorf("load target pinned context: %w", err) |
| 125 | } |
| 126 | transition, err := a.reserveSessionRuntimePath(tab, info.TargetPath) |
| 127 | if err != nil { |
| 128 | return fmt.Errorf("acquire target session lease: %w", userFacingSessionLeaseError("", err)) |
| 129 | } |
| 130 | oldLease, err := tab.handoffSessionLease(info.TargetPath) |
| 131 | if err != nil { |
| 132 | a.rollbackSessionRuntimePath(transition) |
| 133 | return fmt.Errorf("acquire target session lease: %w", userFacingSessionLeaseError("", err)) |
| 134 | } |
| 135 | tab.sessionLeaseMu.Lock() |
| 136 | lease := tab.sessionLease |
| 137 | tab.sessionLeaseMu.Unlock() |
| 138 | if err := info.BindWriteAuthority(lease); err != nil { |
| 139 | newLease := tab.swapSessionLease(oldLease) |
| 140 | a.rollbackSessionRuntimePath(transition) |
| 141 | if newLease != nil { |
| 142 | newLease.Release() |
| 143 | } |
| 144 | return fmt.Errorf("bind target session authority: %w", err) |
| 145 | } |
| 146 | a.mu.Lock() |
| 147 | if tab.removed || !a.runtimeOwnerLiveLocked(transition.runtime) || !a.commitSessionRuntimePathLocked(transition) { |
| 148 | a.mu.Unlock() |
| 149 | newLease := tab.swapSessionLease(oldLease) |
| 150 | a.rollbackSessionRuntimePath(transition) |
| 151 | if newLease != nil { |
| 152 | newLease.Release() |
| 153 | } |
| 154 | return fmt.Errorf("bind target session: tab runtime changed; retry") |
| 155 | } |
| 156 | tab.SessionPath = canonicalTabSessionPath(info.TargetPath) |
| 157 | if a.tabs[tab.ID] == tab { |
| 158 | a.saveTabsLocked() |
| 159 | } |
| 160 | a.mu.Unlock() |
| 161 | if oldLease != nil { |
| 162 | go oldLease.Release() |
| 163 | } |
| 164 | info.OnCommit(func() { |
| 165 | tab.setPinnedFiles(pinnedState.Files) |
| 166 | a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: tab.ID, Meta: a.MetaForTab(tab.ID)}) |
| 167 | }) |
| 168 | a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(info.TargetPath)) |
| 169 | return nil |
| 170 | } |
| 171 | } |
| 172 |