| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "reasonix/internal/agent" |
| 7 | "reasonix/internal/boot" |
| 8 | "reasonix/internal/control" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // Runtime settings belong to the runtime owner, including a detached owner. |
| 13 | // Foreground actions still resolve their tab through tabByID/beginTabTurn. |
| 14 | func (a *App) ownsRuntimeTabLocked(tab *WorkspaceTab) bool { |
| 15 | if tab == nil { |
| 16 | return false |
| 17 | } |
| 18 | if a.tabs[tab.ID] == tab { |
| 19 | return true |
| 20 | } |
| 21 | for _, detached := range a.detachedSessions { |
| 22 | if detached == tab { |
| 23 | return true |
| 24 | } |
| 25 | } |
| 26 | return false |
| 27 | } |
| 28 | |
| 29 | func setTabStartupError(tab *WorkspaceTab, err error) bool { |
| 30 | if tab == nil { |
| 31 | return false |
| 32 | } |
| 33 | tab.StartupErr = userFacingSessionLeaseError("", err).Error() |
| 34 | tab.StartupErrLeaseHeld = errors.Is(err, agent.ErrSessionLeaseHeld) |
| 35 | tab.modelApplication.startupRetry = errors.Is(err, errNoDesktopChatModel) || errors.Is(err, boot.ErrUnknownModel) || errors.Is(err, errModelSettingsSuperseded) |
| 36 | return tab.StartupErrLeaseHeld |
| 37 | } |
| 38 | |
| 39 | func clearTabStartupError(tab *WorkspaceTab) { |
| 40 | if tab == nil { |
| 41 | return |
| 42 | } |
| 43 | tab.StartupErr = "" |
| 44 | tab.StartupErrLeaseHeld = false |
| 45 | tab.modelApplication.startupRetry = false |
| 46 | } |
| 47 | |
| 48 | func (a *App) recordTabStartupFailure(tab *WorkspaceTab, buildGeneration uint64, wailsCtx context.Context, err error) { |
| 49 | a.mu.Lock() |
| 50 | if a.tabBuildSupersededLocked(tab, buildGeneration) { |
| 51 | a.mu.Unlock() |
| 52 | return |
| 53 | } |
| 54 | leaseHeld, save := a.markTabStartupFailureLocked(tab, err, keepStartupRestore) |
| 55 | tab.releaseSessionLease() |
| 56 | a.mu.Unlock() |
| 57 | a.writeTabsSaveRequest(save) |
| 58 | if leaseHeld { |
| 59 | a.scheduleDeferredStartupBuild(tab.ID) |
| 60 | tabID := tab.ID |
| 61 | // The deferred loop retries every 2s and re-enters this path. Only the |
| 62 | // first transition to lease_blocked needs the explicit meta push — a |
| 63 | // repeated push would re-fetch the same list and churn the frontend. |
| 64 | a.mu.RLock() |
| 65 | rt := a.runtimeForTabLocked(tab) |
| 66 | alreadyBlocked := rt != nil && rt.Phase == sessionRuntimeLeaseBlocked && rt.Issue != nil && rt.Issue.Code == "session_lease_held" |
| 67 | a.mu.RUnlock() |
| 68 | if alreadyBlocked { |
| 69 | a.emitReady(wailsCtx, tab.ID) |
| 70 | return |
| 71 | } |
| 72 | // Failed startup emits no agent events. Publish the lease-blocked meta |
| 73 | // explicitly so the frontend can offer takeover. |
| 74 | a.goSafe("tab-meta-push-lease", func() { |
| 75 | if a.tabs[tabID] == nil { |
| 76 | return |
| 77 | } |
| 78 | a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: tabID, Meta: a.MetaForTab(tabID)}) |
| 79 | }) |
| 80 | } |
| 81 | a.emitReady(wailsCtx, tab.ID) |
| 82 | } |
| 83 | |
| 84 | func (a *App) rejectStaleStartupModelSettings(tab *WorkspaceTab, ctrl control.SessionAPI, generation uint64, ctx context.Context, abandon func()) bool { |
| 85 | stale, err := modelSettingsNeedApply(ctrl) |
| 86 | if err == nil && !stale { |
| 87 | return false |
| 88 | } |
| 89 | abandon() |
| 90 | if err == nil { |
| 91 | err = errModelSettingsSuperseded |
| 92 | } |
| 93 | a.recordTabStartupFailure(tab, generation, ctx, err) |
| 94 | if stale { |
| 95 | a.scheduleDeferredStartupBuild(tab.ID) |
| 96 | } |
| 97 | return true |
| 98 | } |
| 99 | |
| 100 | func (a *App) finishStartupPublication(tab *WorkspaceTab, ctrl control.SessionAPI, wailsCtx context.Context) { |
| 101 | // A directly-opened session announces itself to a resident serve so the |
| 102 | // remote side can watch it read-only and reclaim it (see |
| 103 | // adoptSessionFromLocalServe). First-open path of the takeover flow. |
| 104 | if path := strings.TrimSpace(tab.currentSessionPath()); path != "" && !tab.ReadOnly { |
| 105 | a.attachTakeoverMirror(tab.ID, path) |
| 106 | go a.adoptSessionFromLocalServe(tab.ID, path) |
| 107 | } |
| 108 | recoverPendingTurnProjections(tab, ctrl) |
| 109 | a.emitReady(wailsCtx, tab.ID) |
| 110 | if inbox, ok := ctrl.(interface{ NotifyInboxRuntimeReady() }); ok { |
| 111 | go inbox.NotifyInboxRuntimeReady() |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | type tabStartupState struct { |
| 116 | err string |
| 117 | leaseHeld bool |
| 118 | ready bool |
| 119 | modelApplication tabModelApplicationState |
| 120 | } |
| 121 | |
| 122 | func (t *WorkspaceTab) startupState() tabStartupState { |
| 123 | return tabStartupState{t.StartupErr, t.StartupErrLeaseHeld, t.Ready, t.modelApplication} |
| 124 | } |
| 125 | |
| 126 | func (t *WorkspaceTab) restoreStartupState(state tabStartupState) { |
| 127 | t.StartupErr = state.err |
| 128 | t.StartupErrLeaseHeld = state.leaseHeld |
| 129 | t.Ready = state.ready |
| 130 | t.modelApplication = state.modelApplication |
| 131 | } |
| 132 |