返回 DeepSeek-Reasonix
session_head.go
根目录 / internal / agent / session_head.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "slices"
7 "time"
8
9 "reasonix/internal/store"
10 )
11
12 // sessionHeadState is what a Session knows about its schema-2 log position;
13 // it stays zero for schema-1 sessions. state caches the replayed graph so a
14 // save only reads the bytes appended since the last observed tail.
15 type sessionHeadState struct {
16 ref HeadRef
17 dag bool
18 headCount int
19 state *sessionDAGState
20 events []HeadEvent
21 pending []sessionDAGEntry
22 openTurn *sessionDAGTurn
23 }
24
25 // HeadEvent reports a head-level fact a save discovered; the controller turns
26 // it into a user-facing notice.
27 type HeadEvent struct {
28 Kind string
29 HeadID string
30 OtherWriter string
31 }
32
33 const (
34 // HeadEventForkedConcurrent: this writer's head diverged from another
35 // writer's appends and continued on a fresh head.
36 HeadEventForkedConcurrent = "forked_concurrent"
37 // HeadEventMultipleRecentHeads: the session opened on one of several heads
38 // that were active within recentHeadWindow; the others remain versions.
39 HeadEventMultipleRecentHeads = "multiple_recent_heads"
40 )
41
42 // recentHeadWindow bounds how old a competing head may be before opening a
43 // conversation stops mentioning it.
44 const recentHeadWindow = 24 * time.Hour
45
46 // loadHeadEvents derives the events a fresh load should surface.
47 func loadHeadEvents(st *sessionDAGState, selected string) []HeadEvent {
48 if st == nil {
49 return nil
50 }
51 h := st.heads[selected]
52 if h == nil {
53 return nil
54 }
55 for _, id := range st.liveHeads() {
56 if id == selected {
57 continue
58 }
59 if other := st.heads[id]; other != nil && h.lastActivity.Sub(other.lastActivity) < recentHeadWindow {
60 return []HeadEvent{{Kind: HeadEventMultipleRecentHeads, HeadID: selected, OtherWriter: other.writer}}
61 }
62 }
63 return nil
64 }
65
66 // Head reports the head this session was loaded from or last saved to. ok is
67 // false for sessions that still live in a schema-1 log or a bare .jsonl.
68 func (s *Session) Head() (HeadRef, bool) {
69 if s == nil {
70 return HeadRef{}, false
71 }
72 s.mu.RLock()
73 defer s.mu.RUnlock()
74 return s.head.ref, s.head.dag
75 }
76
77 // DrainHeadEvents returns and clears the head events recorded by saves.
78 func (s *Session) DrainHeadEvents() []HeadEvent {
79 if s == nil {
80 return nil
81 }
82 s.mu.Lock()
83 defer s.mu.Unlock()
84 events := s.head.events
85 s.head.events = nil
86 return events
87 }
88
89 // selectedHead picks the head a plain open lands on: the last explicit select
90 // while that head is alive, otherwise the most recently active live head, with
91 // the larger log offset breaking ties so every reader of the same bytes agrees.
92 func (st *sessionDAGState) selectedHead() string {
93 if h := st.heads[st.selected]; h != nil && !h.retired {
94 return h.id
95 }
96 pick := func(includeRetired bool) string {
97 best := ""
98 for _, id := range st.headOrder {
99 h := st.heads[id]
100 if h == nil || (h.retired && !includeRetired) {
101 continue
102 }
103 if best == "" {
104 best = id
105 continue
106 }
107 b := st.heads[best]
108 if h.lastActivity.After(b.lastActivity) || (h.lastActivity.Equal(b.lastActivity) && h.lastOffset > b.lastOffset) {
109 best = id
110 }
111 }
112 return best
113 }
114 if best := pick(false); best != "" {
115 return best
116 }
117 if best := pick(true); best != "" {
118 return best
119 }
120 return SessionMainHead
121 }
122
123 func (st *sessionDAGState) headRecord(id string, selected string) SessionHead {
124 h := st.heads[id]
125 msgs, _ := st.materialize(id)
126 preview, turns := SessionPreviewFromMessages(msgs)
127 return SessionHead{
128 ID: h.id,
129 Kind: h.kind,
130 Name: h.name,
131 ParentHead: h.parentHead,
132 ForkFrom: h.forkFrom,
133 Writer: h.writer,
134 LeafID: h.leaf,
135 CreatedAt: h.createdAt,
136 LastActivity: h.lastActivity,
137 Retired: h.retired,
138 Selected: h.id == selected,
139 MessageCount: len(msgs),
140 Turns: turns,
141 Preview: preview,
142 }
143 }
144
145 // headList lists every head in declaration order with the selected one marked
146 // and covered heads flagged: retiring one of those loses no message.
147 func (st *sessionDAGState) headList() []SessionHead {
148 selected := st.selectedHead()
149 onChain := map[string]struct{}{}
150 for _, id := range st.chainIDs(selected) {
151 onChain[id] = struct{}{}
152 }
153 out := make([]SessionHead, 0, len(st.headOrder))
154 for _, id := range st.headOrder {
155 h := st.heads[id]
156 if h == nil {
157 continue
158 }
159 rec := st.headRecord(id, selected)
160 if id != selected && !h.retired {
161 _, rec.Covered = onChain[h.leaf]
162 rec.Covered = rec.Covered || h.leaf == ""
163 }
164 out = append(out, rec)
165 }
166 return out
167 }
168
169 // liveHeads returns the ids of heads that have not been retired.
170 func (st *sessionDAGState) liveHeads() []string {
171 out := make([]string, 0, len(st.headOrder))
172 for _, id := range st.headOrder {
173 if h := st.heads[id]; h != nil && !h.retired {
174 out = append(out, id)
175 }
176 }
177 return out
178 }
179
180 // reachable returns every node id on the chain of any live head.
181 func (st *sessionDAGState) reachable() map[string]struct{} {
182 keep := map[string]struct{}{}
183 for _, id := range st.liveHeads() {
184 for _, mid := range st.chainIDs(id) {
185 keep[mid] = struct{}{}
186 }
187 }
188 return keep
189 }
190
191 // ListSessionHeads replays a schema-2 log and returns its heads. A schema-1
192 // session has no heads and returns nil, nil.
193 func ListSessionHeads(path string) ([]SessionHead, error) {
194 return listSessionHeads(context.Background(), path, defaultSessionReplayLimits, false)
195 }
196
197 // ListSessionHeadsForMigration enumerates a frozen source without the cumulative
198 // interactive replay budget. The migration owner must freeze the source first.
199 func ListSessionHeadsForMigration(ctx context.Context, path string) ([]SessionHead, error) {
200 return listSessionHeads(ctx, path, migrationSessionReplayLimits(), true)
201 }
202
203 func listSessionHeads(ctx context.Context, path string, limits sessionReplayLimits, strict bool) ([]SessionHead, error) {
204 probe, err := probeSessionEventLogWithLimits(path, limits)
205 if err != nil {
206 return nil, err
207 }
208 if !probe.dag {
209 return nil, nil
210 }
211 st, err := replaySessionDAG(ctx, store.SessionEventLog(path), limits)
212 if err != nil {
213 return nil, fmt.Errorf("list session heads: %w", err)
214 }
215 if strict && st.damaged {
216 return nil, fmt.Errorf("list session heads: incomplete legacy DAG")
217 }
218 heads := st.headList()
219 slices.SortStableFunc(heads, func(a, b SessionHead) int {
220 return a.CreatedAt.Compare(b.CreatedAt)
221 })
222 return heads, nil
223 }
224
224 lines GO