返回 DeepSeek-Reasonix
session_heads.go
根目录 / desktop / session_heads.go
1 package main
2
3 import (
4 "errors"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/control"
11 "reasonix/internal/sessioncatalog"
12 )
13
14 // sessionHeadLineageState is the lineage view state for a schema-2 session:
15 // its versions are heads of one log, never separate files.
16 const sessionHeadLineageState = "heads"
17
18 // sessionHeadQuietPeriod is how long a covered head must have been idle before
19 // cleanup retires it; the writer that forked it seconds ago may still be on it.
20 var sessionHeadQuietPeriod = time.Minute
21
22 // tabMetaAfterHeadSwitch publishes a tab whose controller replaced the
23 // transcript under the same session path. The generation bump makes the
24 // frontend rehydrate instead of patching the surface it showed before.
25 func (a *App) tabMetaAfterHeadSwitch(tab *WorkspaceTab) TabMeta {
26 if tab == nil {
27 return TabMeta{}
28 }
29 a.mu.Lock()
30 tab.SessionGeneration++
31 if tab.sink != nil {
32 tab.sink.setSessionGeneration(tab.SessionGeneration)
33 }
34 meta := a.tabMeta(tab, a.activeTabID == tab.ID)
35 a.mu.Unlock()
36 a.invalidatePromptHistoryCache()
37 if meta.SessionPath != "" {
38 a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(meta.SessionPath))
39 }
40 return meta
41 }
42
43 func (a *App) tabForSessionPath(path string) (*WorkspaceTab, control.SessionAPI) {
44 key := sessionRuntimeKey(path)
45 if key == "" {
46 return nil, nil
47 }
48 a.mu.RLock()
49 defer a.mu.RUnlock()
50 for _, tab := range a.runtimeTabsLocked() {
51 if sessionRuntimeKey(tab.currentSessionPath()) == key {
52 return tab, tab.Ctrl
53 }
54 }
55 return nil, nil
56 }
57
58 func topicRecordForLineage(topic sessioncatalog.TopicRecord, selectedPath string) (sessioncatalog.SessionRecord, bool) {
59 want := selectedPath
60 if sessioncatalog.PathIdentityKey(want) == "" {
61 want = topic.RepresentativePath
62 }
63 for _, record := range topic.Sessions {
64 if sameRecoveryLineagePath(record.Path, want) {
65 return record, true
66 }
67 }
68 return sessioncatalog.SessionRecord{}, false
69 }
70
71 // sessionHeadLineage answers the versions view from the log itself when the
72 // selected session is schema 2; a schema-1 session reports no heads.
73 func (a *App) sessionHeadLineage(topic sessioncatalog.TopicRecord, selectedPath string) (RecoveryLineageView, bool) {
74 record, ok := topicRecordForLineage(topic, selectedPath)
75 if !ok {
76 return RecoveryLineageView{}, false
77 }
78 heads, err := agent.ListSessionHeads(record.Path)
79 if err != nil || len(heads) == 0 {
80 return RecoveryLineageView{}, false
81 }
82 return a.headLineageView(record, heads), true
83 }
84
85 func (a *App) headLineageView(record sessioncatalog.SessionRecord, heads []agent.SessionHead) RecoveryLineageView {
86 out := RecoveryLineageView{GroupID: agent.BranchID(record.Path), State: sessionHeadLineageState, Members: []RecoveryLineageMember{}}
87 _, overlays := a.catalogRuntimeOverlays()
88 overlay := overlays[sessionRuntimeKey(record.Path)]
89 title := record.CustomTitle
90 if meta, ok, err := agent.LoadBranchMeta(record.Path); err == nil && ok {
91 title = meta.CustomTitle
92 }
93 for _, head := range heads {
94 if head.Retired {
95 continue
96 }
97 note := head.Name
98 if head.ID == agent.SessionMainHead && note == "" {
99 note = title
100 }
101 out.Members = append(out.Members, RecoveryLineageMember{
102 Path: record.Path, HeadID: head.ID, HeadKind: head.Kind, HeadName: head.Name, Selected: head.Selected,
103 VersionKind: "head", VersionState: string(agent.VersionActive), ParentVersionID: head.ParentHead,
104 Role: sessioncatalog.RecoveryRoleNormal, Canonical: head.Selected, Turns: head.Turns,
105 Open: head.Selected && overlay.open, Running: head.Selected && overlay.running,
106 VersionNote: note, Preview: head.Preview,
107 CreatedAt: unixMilliOrZero(head.CreatedAt), LastActivityAt: unixMilliOrZero(head.LastActivity),
108 })
109 out.BranchCount++
110 if head.Covered {
111 out.CleanupEligible++
112 }
113 }
114 return out
115 }
116
117 // mergeHeadLineage folds the heads of a log into the file lineage of the same
118 // conversation. The log's own file row gives way to its heads; the selected
119 // head is canonical only when that row was, or when no file lineage exists.
120 func mergeHeadLineage(file, heads RecoveryLineageView) RecoveryLineageView {
121 if len(heads.Members) == 0 {
122 return file
123 }
124 logPath := heads.Members[0].Path
125 out := heads
126 selectedIsCanonical := len(file.Members) == 0
127 for _, member := range file.Members {
128 if sameRecoveryLineagePath(member.Path, logPath) {
129 selectedIsCanonical = member.Canonical
130 continue
131 }
132 out.Members = append(out.Members, member)
133 }
134 if !selectedIsCanonical {
135 for i := range out.Members[:len(heads.Members)] {
136 out.Members[i].Canonical = false
137 }
138 }
139 out.BranchCount = len(out.Members)
140 out.Unresolved = file.Unresolved
141 out.CleanupEligible += file.CleanupEligible
142 if len(file.Members) > 0 {
143 out.GroupID, out.State = file.GroupID, file.State
144 }
145 return out
146 }
147
148 func unixMilliOrZero(at time.Time) int64 {
149 if at.IsZero() {
150 return 0
151 }
152 return at.UnixMilli()
153 }
154
155 // chooseSessionHead makes one head the conversation's current version. An
156 // open controller switches in place so its tab keeps identity and path; a
157 // closed session gets a select marker and lands on the head when reopened.
158 func (a *App) chooseSessionHead(req RecoveryPreferenceRequest) error {
159 path, headID := strings.TrimSpace(req.Path), strings.TrimSpace(req.HeadID)
160 if _, _, err := a.sessionDirForPath(path); err != nil {
161 return errors.New("session version is unavailable")
162 }
163 if tab, ctrl := a.tabForSessionPath(path); ctrl != nil {
164 if a.tabIsReadOnly(tab) {
165 return readOnlyChannelErr()
166 }
167 if _, err := ctrl.SwitchBranch(headID); err != nil {
168 return err
169 }
170 a.tabMetaAfterHeadSwitch(tab)
171 } else if err := agent.SelectSessionHead(path, headID); err != nil {
172 return friendlySessionFileError(err)
173 }
174 dir := filepath.Dir(path)
175 if catalog := a.sessionCatalog.Load(); catalog != nil {
176 target := sessioncatalog.DirectoryTarget{Path: dir, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot}
177 if err := catalog.ReconcileDirectory(a.bootContext(), target); err != nil {
178 return errors.New("the version choice was saved but the session catalog could not refresh")
179 }
180 }
181 a.emitProjectTreeChangedForSessionDirs(dir)
182 return nil
183 }
184
185 // cleanTopicHeads routes cleanup to the heads of a schema-2 log when the
186 // topic's representative session is one; ok is false for file lineages.
187 func (a *App) cleanTopicHeads(req RecoveryCleanupRequest, topic sessioncatalog.TopicRecord) (RecoveryCleanupResult, bool) {
188 record, ok := topicRecordForLineage(topic, "")
189 if !ok {
190 return RecoveryCleanupResult{}, false
191 }
192 heads, err := agent.ListSessionHeads(record.Path)
193 if err != nil || len(heads) == 0 {
194 return RecoveryCleanupResult{}, false
195 }
196 return a.cleanSessionHeads(req, record.Path, heads), true
197 }
198
199 // cleanSessionHeads retires covered heads: versions whose whole chain is
200 // already part of the current one. Diverged heads are never touched, and a
201 // head active within the quiet period is reported busy rather than retired.
202 func (a *App) cleanSessionHeads(req RecoveryCleanupRequest, path string, heads []agent.SessionHead) RecoveryCleanupResult {
203 result := RecoveryCleanupResult{DryRun: !req.Apply, Items: []RecoveryCleanupItem{}}
204 if req.Apply {
205 a.sessionRemovalMu.Lock()
206 defer a.sessionRemovalMu.Unlock()
207 }
208 for _, head := range heads {
209 if head.Retired || head.Selected || !head.Covered {
210 continue
211 }
212 result.Eligible++
213 item := RecoveryCleanupItem{Path: path, HeadID: head.ID, Status: "eligible"}
214 if req.Apply {
215 item.Status, item.Error = retireCoveredHead(path, head, &result)
216 }
217 result.Items = append(result.Items, item)
218 }
219 if result.Moved > 0 {
220 a.emitProjectTreeChangedForSessionDirs(filepath.Dir(path))
221 }
222 return result
223 }
224
225 func retireCoveredHead(path string, head agent.SessionHead, result *RecoveryCleanupResult) (string, string) {
226 if time.Since(head.LastActivity) < sessionHeadQuietPeriod {
227 result.Busy++
228 return "busy", ""
229 }
230 if err := agent.RetireSessionHead(path, head.ID); err != nil {
231 result.Kept++
232 return "kept", "session version changed and was kept"
233 }
234 result.Moved++
235 return "retired", ""
236 }
237
238 // RenameSessionHead names one head of a schema-2 session. The main head has
239 // no name of its own: its note is the session title, so it renames the session.
240 func (a *App) RenameSessionHead(path, headID, name string) error {
241 headID = strings.TrimSpace(headID)
242 if headID == "" || headID == agent.SessionMainHead {
243 return a.RenameSession(path, name)
244 }
245 if _, _, err := a.sessionDirForPath(path); err != nil {
246 return errors.New("session version is unavailable")
247 }
248 if err := agent.RenameSessionHead(path, headID, strings.TrimSpace(name)); err != nil {
249 return friendlySessionFileError(err)
250 }
251 a.emitProjectTreeChangedForSessionDirs(filepath.Dir(path))
252 return nil
253 }
254
254 lines GO