返回 DeepSeek-Reasonix
topic_archive_runtime.go
根目录 / desktop / topic_archive_runtime.go
1 package main
2
3 import (
4 "errors"
5 "log/slog"
6 "time"
7
8 "reasonix/internal/agent"
9 )
10
11 func (a *App) captureTopicRuntimeBindings(topicID string) []removedSessionRuntime {
12 a.mu.RLock()
13 defer a.mu.RUnlock()
14 var captured []removedSessionRuntime
15 for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
16 for _, tab := range tabs {
17 if tab == nil || tab.TopicID != topicID {
18 continue
19 }
20 item := removedRuntimeFromTab(tab, tabRuntimeSessionDir(tab), canonicalTabSessionPath(tab.currentSessionPath()))
21 item.failedStartup = a.suppressTabStartupRestoreLocked(tab)
22 captured = append(captured, item)
23 }
24 }
25 return captured
26 }
27
28 // snapshotTopicRuntimeBindings keeps bindings usable until every writable
29 // controller snapshots. The caller owns runtime admission, blocking new turns.
30 func (a *App) snapshotTopicRuntimeBindings(captured []removedSessionRuntime) error {
31 for _, item := range captured {
32 if item.ctrl == nil || item.readOnly {
33 continue
34 }
35 if item.ctrl.Running() {
36 return errTopicHasActiveWork
37 }
38 if historicalPreview(item.ctrl) {
39 continue
40 }
41 if err := item.ctrl.Snapshot(); err != nil {
42 if item.failedStartup && failedStartupSnapshotError(err) {
43 slog.Warn("desktop: skipping unavailable failed runtime snapshot before removing topic")
44 continue
45 }
46 if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
47 return err
48 }
49 kind, _ := agent.SnapshotConflictKind(err)
50 slog.Warn("desktop: skipping stale runtime snapshot before removing topic", "conflict_kind", kind)
51 }
52 }
53 return nil
54 }
55
56 func (a *App) topicRuntimeBindingsMatchLocked(tabs map[string]*WorkspaceTab, topicID string, expected map[*WorkspaceTab]removedSessionRuntime) (int, bool) {
57 matched := 0
58 for _, tab := range tabs {
59 if tab == nil || tab.TopicID != topicID {
60 continue
61 }
62 item, ok := expected[tab]
63 if !ok || tab.Ctrl != item.ctrl || tabRuntimeSessionDir(tab) != item.sessionDir ||
64 canonicalTabSessionPath(tab.currentSessionPath()) != item.sessionPath ||
65 a.suppressTabStartupRestoreLocked(tab) != item.failedStartup {
66 return 0, false
67 }
68 matched++
69 }
70 return matched, true
71 }
72
73 // removeTopicRuntimeBindingsIfUnchanged commits only when the captured runtime
74 // generation still matches, so stale completion cannot remove a rebound tab.
75 func (a *App) removeTopicRuntimeBindingsIfUnchanged(topicID string, captured []removedSessionRuntime) (fallbackRuntimeTarget, bool) {
76 expected := make(map[*WorkspaceTab]removedSessionRuntime, len(captured))
77 for _, item := range captured {
78 expected[item.tab] = item
79 }
80 a.mu.Lock()
81 visible, visibleOK := a.topicRuntimeBindingsMatchLocked(a.tabs, topicID, expected)
82 detached, detachedOK := a.topicRuntimeBindingsMatchLocked(a.detachedSessions, topicID, expected)
83 if !visibleOK || !detachedOK || visible+detached != len(captured) {
84 a.mu.Unlock()
85 return fallbackRuntimeTarget{}, false
86 }
87 var fallback fallbackRuntimeTarget
88 fallbackSet := false
89 for id, tab := range a.tabs {
90 if _, ok := expected[tab]; !ok {
91 continue
92 }
93 if !fallbackSet {
94 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
95 fallbackSet = true
96 }
97 a.markTabRemovedLocked(tab)
98 stopTabAutosave(tab)
99 delete(a.tabs, id)
100 a.removeTabOrderLocked(id)
101 if a.activeTabID == id {
102 a.activeTabID = ""
103 }
104 }
105 for key, tab := range a.detachedSessions {
106 if _, ok := expected[tab]; !ok {
107 continue
108 }
109 if !fallbackSet {
110 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
111 fallbackSet = true
112 }
113 a.markTabRemovedLocked(tab)
114 stopTabAutosave(tab)
115 delete(a.detachedSessions, key)
116 }
117 if a.activeTabID == "" && len(a.tabOrder) > 0 {
118 a.activeTabID = a.tabOrder[0]
119 }
120 fallback.needs = len(captured) > 0 && len(a.tabs) == 0
121 dir, entries, activeID, version := a.saveTabsCollectLocked()
122 a.mu.Unlock()
123 a.saveTabsWrite(dir, entries, activeID, version)
124 return fallback, true
125 }
126
127 func (a *App) finalizeRemovedTopicRuntimes(removed []removedSessionRuntime) {
128 if len(removed) > 0 {
129 a.lifecycleCheckpoint("before-topic-runtime-cleanup")
130 }
131 for _, item := range removed {
132 if item.sink != nil {
133 item.sink.clearContext()
134 }
135 if item.ctrl == nil || item.readOnly {
136 continue
137 }
138 item.ctrl.SetSessionPath("")
139 a.quiesceTabAutosave(item.tab)
140 }
141 }
142
143 // Stop admission without joining the saver under App/title/index locks.
144 func stopTabAutosave(tab *WorkspaceTab) {
145 tab.saveMu.Lock()
146 tab.closing = true
147 tab.saveMu.Unlock()
148 }
149
150 func (a *App) tryLockRuntimeMutation(operation string) (func(), bool) {
151 if hook := a.runtimeMutationBeforeLockHook; hook != nil {
152 hook(operation)
153 }
154 if !a.runtimeRebuildMu.TryLock() {
155 return nil, false
156 }
157 if !a.runtimeAdmissionMu.TryLock() {
158 a.runtimeRebuildMu.Unlock()
159 return nil, false
160 }
161 return func() {
162 a.runtimeAdmissionMu.Unlock()
163 a.runtimeRebuildMu.Unlock()
164 }, true
165 }
166
167 const runtimeMutationUserWait = 1500 * time.Millisecond
168
169 // tryLockRuntimeMutationBounded serves user-initiated archives. Background
170 // owners hold these locks briefly but often, so one TryLock can lose every
171 // time; polling without blocking keeps the lock order free of new waits.
172 func (a *App) tryLockRuntimeMutationBounded(operation string) (func(), bool) {
173 if hook := a.runtimeMutationBeforeLockHook; hook != nil {
174 hook(operation)
175 }
176 deadline := time.Now().Add(runtimeMutationUserWait)
177 for wait := 5 * time.Millisecond; ; wait = min(2*wait, 200*time.Millisecond) {
178 if a.runtimeRebuildMu.TryLock() {
179 if a.runtimeAdmissionMu.TryLock() {
180 return func() {
181 a.runtimeAdmissionMu.Unlock()
182 a.runtimeRebuildMu.Unlock()
183 }, true
184 }
185 a.runtimeRebuildMu.Unlock()
186 }
187 if !time.Now().Add(wait).Before(deadline) {
188 return nil, false
189 }
190 time.Sleep(wait)
191 }
192 }
193
193 lines GO