返回 DeepSeek-Reasonix
project_tree_runtime.go
根目录 / desktop / project_tree_runtime.go
1 package main
2
3 import (
4 "context"
5 "log/slog"
6 "maps"
7 "sort"
8 "strings"
9 "sync"
10 "time"
11
12 "reasonix/internal/control"
13 "reasonix/internal/session"
14 )
15
16 type projectTreeRuntimeState struct {
17 // activityAt records the last activity-status event per tab ID. The TTL
18 // watchdog reaps a live status that sees no terminating event; the state
19 // lives here (not on WorkspaceTab) to keep tabs.go within budget.
20 activityMu sync.Mutex
21 activityAt map[string]time.Time
22 }
23
24 // noteActivityStatus refreshes a tab's activity timestamp on every status
25 // event, even an unchanged one: the TTL watchdog measures silence, not how
26 // long a status has been displayed, so a long but active turn is never reaped.
27 func (s *projectTreeRuntimeState) noteActivityStatus(a *App, tabID string) {
28 s.setActivityAt(tabID, time.Now())
29 // Runtime snapshots, not silence, determine whether work remains active.
30 }
31
32 func (s *projectTreeRuntimeState) setActivityAt(tabID string, at time.Time) {
33 s.activityMu.Lock()
34 defer s.activityMu.Unlock()
35 if s.activityAt == nil {
36 s.activityAt = map[string]time.Time{}
37 }
38 s.activityAt[tabID] = at
39 }
40
41 func (s *projectTreeRuntimeState) activityAtSnapshot() map[string]time.Time {
42 s.activityMu.Lock()
43 defer s.activityMu.Unlock()
44 out := make(map[string]time.Time, len(s.activityAt))
45 maps.Copy(out, s.activityAt)
46 return out
47 }
48
49 func syncRuntimeWorkspaceRootSpelling(tab *WorkspaceTab, projects []desktopProject) bool {
50 if tab == nil || tab.Scope != "project" {
51 return false
52 }
53 i := projectIndexByRoot(projects, tab.WorkspaceRoot)
54 if i < 0 || tab.WorkspaceRoot == projects[i].Root {
55 return false
56 }
57 tab.WorkspaceRoot = projects[i].Root
58 return true
59 }
60
61 // catalogRuntimeSnapshots copies runtime identity under App.mu, then lets all
62 // controller calls happen after the app lock is released. Controllers own
63 // their own locks and must never become part of the App.mu lock order.
64 func (a *App) catalogRuntimeSnapshots() []catalogRuntimeSnapshot {
65 if a == nil {
66 return []catalogRuntimeSnapshot{}
67 }
68 a.mu.RLock()
69 snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
70 collect := func(tab *WorkspaceTab, open bool) {
71 if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
72 return
73 }
74 snapshots = append(snapshots, catalogRuntimeSnapshot{
75 tabID: tab.ID,
76 scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID,
77 sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle,
78 topicTitleSource: tab.topicTitleSource, ctrl: tab.Ctrl, open: open,
79 })
80 }
81 for _, tab := range a.tabs {
82 collect(tab, true)
83 }
84 for _, tab := range a.detachedSessions {
85 collect(tab, false)
86 }
87 a.mu.RUnlock()
88 return snapshots
89 }
90
91 // GetProjectTreeRuntimeSnapshot returns the complete in-memory runtime
92 // projection. The frontend subscribes first and then calls this method; the
93 // independent revision makes either arrival order deterministic.
94 func (a *App) GetProjectTreeRuntimeSnapshot() ProjectTreeRuntimeSnapshot {
95 if a == nil {
96 return ProjectTreeRuntimeSnapshot{Topics: []ProjectRuntimeTopic{}}
97 }
98 snapshot := a.GetRuntimeStateSnapshot()
99 return ProjectTreeRuntimeSnapshot{Revision: snapshot.Revision, Topics: snapshot.Topics}
100 }
101
102 func cloneRuntimeTopics(topics []ProjectRuntimeTopic) []ProjectRuntimeTopic {
103 var cloneNode func(ProjectNode) ProjectNode
104 cloneNode = func(node ProjectNode) ProjectNode {
105 next := node
106 next.Children = make([]ProjectNode, len(node.Children))
107 for i, child := range node.Children {
108 next.Children[i] = cloneNode(child)
109 }
110 if node.Remote != nil {
111 remote := *node.Remote
112 next.Remote = &remote
113 }
114 return next
115 }
116 result := make([]ProjectRuntimeTopic, len(topics))
117 for i, topic := range topics {
118 result[i] = topic
119 result[i].Node = cloneNode(topic.Node)
120 }
121 return result
122 }
123
124 func (a *App) projectTreeRuntimeTopics(snapshots []catalogRuntimeSnapshot) []ProjectRuntimeTopic {
125 bySession := map[string]ProjectRuntimeTopic{}
126 state, _ := a.workspaceRegistry().Load(a.bootContext())
127 for _, snapshot := range snapshots {
128 scope, root := normalizeDesktopTopicScope(snapshot.scope, snapshot.workspaceRoot)
129 snapshot.scope, snapshot.workspaceRoot = scope, root
130 if snapshot.sessionPath == "" && snapshot.ctrl != nil {
131 snapshot.sessionPath = snapshot.ctrl.SessionPath()
132 }
133 nodes, _ := a.runtimeProjectTopicNodes(scope, root, []catalogRuntimeSnapshot{snapshot}, false)
134 if len(nodes) == 0 {
135 continue
136 }
137 node := nodes[0]
138 node.TabID = snapshot.tabID
139 path := strings.TrimSpace(snapshot.sessionPath)
140 if id, ok := parseSessionRoute(path); ok {
141 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
142 node.Session, node.SessionPath, node.Key = &ref, sessionRoute(id), "canonical_"+id
143 } else if identity, ok := snapshot.ctrl.(control.IdentityLifecycle); ok {
144 if ref, bound := identity.SessionRef(); bound && ref.SessionID != "" {
145 node.Session, node.SessionPath, node.Key = &ref, sessionRoute(ref.SessionID), "canonical_"+ref.SessionID
146 }
147 }
148 if node.Session == nil && path != "" {
149 node.SessionPath, node.Key = path, projectSessionNodeKey(scope, path)
150 }
151 if node.Session != nil {
152 node.IdentityAliases = sourceAliases(state, desktopWorkspaceOwnerID(state, scope, root), node.Session.SessionID)
153 node.LifecycleGeneration = state.SessionStates[node.Session.SessionID].Generation
154 if snapshot.tabID != "" {
155 node.IdentityAliases = append(node.IdentityAliases, "tab\x00local\x00"+snapshot.tabID)
156 }
157 } else if path == "" && snapshot.tabID != "" {
158 node.Key = "tab_" + snapshot.tabID
159 }
160 key := scope + "\x00" + root + "\x00" + node.Key
161 bySession[key] = ProjectRuntimeTopic{Scope: scope, WorkspaceRoot: root, Node: node}
162 }
163 keys := make([]string, 0, len(bySession))
164 for key := range bySession {
165 keys = append(keys, key)
166 }
167 sort.Strings(keys)
168 topics := make([]ProjectRuntimeTopic, 0, len(keys))
169 for _, key := range keys {
170 topics = append(topics, bySession[key])
171 }
172 return topics
173 }
174
175 func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, appCtx context.Context) bool {
176 attached := a.attachExistingSessionRuntimeCore(tab, path, appCtx)
177 if attached {
178 a.emitProjectTreeRuntimeChangedWithLegacy()
179 }
180 return attached
181 }
182
183 func (a *App) closeTab(tabID string, allowDetach bool) error {
184 err := a.closeTabRuntime(tabID, allowDetach)
185 if err == nil {
186 a.emitProjectTreeRuntimeChangedWithLegacy()
187 }
188 return err
189 }
190
191 func (a *App) emitProjectTreeChangedEvent() {
192 if a.projectTreeChangedHook != nil {
193 a.projectTreeChangedHook()
194 return
195 }
196 a.emitProjectTreeRuntimeChanged()
197 a.emitRuntimeEvent("project-tree:changed")
198 }
199
200 func (a *App) emitProjectTreeRuntimeChanged() {
201 if a == nil {
202 return
203 }
204 snapshot := a.GetRuntimeStateSnapshot()
205 a.emitRuntimeEvent("project-tree:runtime-changed", ProjectTreeRuntimeSnapshot{Revision: snapshot.Revision, Topics: snapshot.Topics})
206 a.emitRuntimeEvent("runtime-state:changed", snapshot)
207 }
208
209 // The tagged legacy event keeps the previous frontend usable for one release.
210 func (a *App) emitProjectTreeRuntimeChangedWithLegacy() {
211 a.emitProjectTreeRuntimeChanged()
212 a.emitRuntimeEvent("project-tree:changed", map[string]string{"reason": "runtime"})
213 }
214
215 func (a *App) emitRuntimeEvent(name string, payload ...any) {
216 if a != nil && a.ctx != nil {
217 a.runtimeEvents.Emit(a.ctx, name, payload...)
218 }
219 }
220
221 const (
222 // topicActivityStatusTTL bounds how long a live spinner status may go
223 // without any turn event before it is treated as orphaned. A flat TTL is
224 // used instead of a per-session P99: turn durations are not tracked
225 // per session in this package, and simplicity wins (#8528/#8555/#8859).
226 topicActivityStatusTTL = 10 * time.Minute
227 )
228
229 // liveTopicActivityStatus reports statuses that must be terminated by a
230 // TurnDone event; if that event is lost the spinner would run forever.
231 // waiting_confirmation is excluded: it waits on the user, not on turn events.
232 func liveTopicActivityStatus(status string) bool {
233 switch status {
234 case topicStatusThinking, topicStatusStreaming:
235 return true
236 }
237 return false
238 }
239
240 func (a *App) reapStaleTopicActivityStatus(now time.Time) {
241 activityAt := a.projectTreeRuntime.activityAtSnapshot()
242 a.mu.Lock()
243 var reaped []string
244 for _, tab := range a.runtimeTabsLocked() {
245 if tab == nil || !liveTopicActivityStatus(tab.ActivityStatus) {
246 continue
247 }
248 if at := activityAt[tab.ID]; at.IsZero() || now.Sub(at) < topicActivityStatusTTL {
249 continue
250 }
251 reaped = append(reaped, tab.ID+":"+tab.ActivityStatus)
252 tab.ActivityStatus = ""
253 }
254 a.mu.Unlock()
255 if len(reaped) == 0 {
256 return
257 }
258 slog.Warn("desktop: cleared stale topic activity status (no turn event within TTL)",
259 "tabs", reaped, "ttl", topicActivityStatusTTL.String())
260 a.emitProjectTreeRuntimeChangedWithLegacy()
261 }
262
263 // reconcileTabActivityStatus clears a live spinner status the session's
264 // controller does not corroborate — a TurnDone missed while the session was
265 // detached would otherwise spin forever after reopen. The controller query
266 // takes the controller's own lock, so it runs outside App.mu (the same lock
267 // order rule as catalogRuntimeSnapshots).
268 func (a *App) reconcileTabActivityStatus(tab *WorkspaceTab) bool {
269 if a == nil || tab == nil {
270 return false
271 }
272 a.mu.RLock()
273 status := tab.ActivityStatus
274 ctrl := tab.Ctrl
275 a.mu.RUnlock()
276 if ctrl == nil || !liveTopicActivityStatus(status) || ctrl.Running() {
277 return false
278 }
279 a.mu.Lock()
280 defer a.mu.Unlock()
281 if tab.ActivityStatus != status {
282 // A new turn (or its TurnDone) landed while the locks were dropped.
283 return false
284 }
285 tab.ActivityStatus = ""
286 slog.Warn("desktop: cleared stale topic activity status on session open", "tab", tab.ID, "status", status)
287 return true
288 }
289
290 // setTabActivityStatus records the project-tree status for a tab's in-flight
291 // turn and notes the event time for the TTL watchdog.
292 func (a *App) setTabActivityStatus(tabID, status string) bool {
293 a.mu.Lock()
294 defer a.mu.Unlock()
295 tab := a.tabByEventSinkIDLocked(tabID)
296 if tab == nil {
297 return false
298 }
299 a.projectTreeRuntime.noteActivityStatus(a, tab.ID)
300 status = normalizeTopicStatus(status)
301 if tab.ActivityStatus == status {
302 return false
303 }
304 tab.ActivityStatus = status
305 return true
306 }
307
307 lines GO