返回 DeepSeek-Reasonix
save_dag_shutdown.go
根目录 / internal / agent / save_dag_shutdown.go
1 package agent
2
3 import (
4 "context"
5 "crypto/sha256"
6 "errors"
7 "fmt"
8 "os"
9 "strings"
10 "time"
11
12 "reasonix/internal/provider"
13 "reasonix/internal/store"
14 )
15
16 // AppendForShutdownWithoutLock persists the unsaved tail of a schema-2
17 // session after the cross-process save lock stayed held for the whole
18 // bounded wait at shutdown. The entries are appended without that lock, on
19 // a fresh concurrent head so no locked writer can be extending the same
20 // chain, and the derived cache files are left to the next locked save.
21 // handled is false for a schema-1 log, whose shutdown path is still a
22 // recovery copy.
23 func (s *Session) AppendForShutdownWithoutLock(path string, rewrite bool) (handled bool, err error) {
24 if strings.TrimSpace(path) == "" {
25 return false, fmt.Errorf("empty session path")
26 }
27 unlock := lockSessionSavePath(path)
28 defer unlock()
29 probe, err := probeSessionEventLog(path)
30 if err != nil || !probe.dag {
31 return false, err
32 }
33 mode := sessionSaveSnapshot
34 if rewrite {
35 mode = sessionSaveRewrite
36 }
37 return true, s.appendUnlockedLocked(path, mode)
38 }
39
40 func (s *Session) appendUnlockedLocked(path string, mode sessionSaveMode) error {
41 ctx := context.Background()
42 now := time.Now().UTC()
43 msgs, version, rewriteVersion := s.snapshotWithVersion()
44 digest, _, err := digestAndSizeSessionMessages(msgs)
45 if err != nil {
46 return err
47 }
48 st, err := s.dagStateWithoutLock(ctx, path)
49 if err != nil {
50 return err
51 }
52 s.ensureMessageIDsForSave(msgs)
53 plan, err := s.planDAGWrite(path, st, msgs, mode, now)
54 if err != nil {
55 var conflict *SessionSnapshotConflictError
56 if errors.As(err, &conflict) {
57 // Disk is ahead and this session added nothing: the next open reads it.
58 return nil
59 }
60 return err
61 }
62 pending := s.takePendingMarkers()
63 entries := shutdownHeadBatch(plan, pending, now)
64 baseRevision, _, revErr := sessionContentRevision(path)
65 if revErr != nil {
66 baseRevision = 0
67 }
68 if len(entries) == 0 {
69 s.adoptDAGPosition(st, plan)
70 s.markCheckpointPersisted(path, digest, version, baseRevision, rewriteVersion, msgs, true)
71 return nil
72 }
73 tail := st.lastGoodEnd
74 if _, err := appendSessionDAGEntriesUnlocked(path, entries); err != nil {
75 s.requeuePendingMarkers(pending)
76 return err
77 }
78 st.damaged = false
79 if err := st.replayFrom(ctx, tail, defaultSessionReplayLimits); err != nil {
80 return err
81 }
82 if st.damaged || st.heads[plan.head] == nil {
83 return fmt.Errorf("session log %s: entries appended without the lock did not replay", path)
84 }
85 plan.applyIDRenames(s)
86 s.adoptDAGPosition(st, plan)
87 s.markCheckpointPersisted(path, digest, version, baseRevision, rewriteVersion, msgs, true)
88 return nil
89 }
90
91 // dagStateWithoutLock replays the log for an unlocked append. A torn tail is
92 // left alone: repairing it needs the lock, and the append terminates it
93 // instead so its own entries start on a fresh line.
94 func (s *Session) dagStateWithoutLock(ctx context.Context, path string) (*sessionDAGState, error) {
95 logPath := store.SessionEventLog(path)
96 s.mu.RLock()
97 cached := s.head.state
98 s.mu.RUnlock()
99 header, ok, err := readSessionDAGHeader(path)
100 if err != nil {
101 return nil, err
102 }
103 if cached != nil && ok && cached.path == logPath && header.generation == cached.generation {
104 if info, err := os.Stat(logPath); err == nil && info.Size() >= cached.lastGoodEnd {
105 cached.damaged = false
106 if err := cached.replayFrom(ctx, cached.lastGoodEnd, defaultSessionReplayLimits); err == nil {
107 return cached, nil
108 }
109 }
110 }
111 return replaySessionDAG(ctx, logPath, defaultSessionReplayLimits)
112 }
113
114 // shutdownHeadBatch turns a save plan into an unlocked batch. Message entries
115 // (and an owned rewind, which becomes the fork point) move onto a fresh
116 // concurrent head, so a locked writer continuing the old head can never
117 // interleave with them. A turn_end whose turn_begin is already on the old
118 // head stays there to close it; a batch of overlays and markers alone needs
119 // no new head. plan is updated to describe the batch actually written.
120 func shutdownHeadBatch(plan *dagWritePlan, pending []sessionDAGEntry, now time.Time) []sessionDAGEntry {
121 from, moveHead := "", false
122 for _, e := range plan.entries {
123 switch e.Type {
124 case sessionDAGTypeMessage:
125 if !moveHead {
126 from = e.Parent
127 }
128 moveHead = true
129 case sessionDAGTypeRewind:
130 from, moveHead = e.To, true
131 }
132 }
133 if plan.forked || !moveHead {
134 for i := range pending {
135 pending[i].Head = plan.head
136 }
137 return append(plan.entries, pending...)
138 }
139 oldHead, newHead := plan.head, NewHeadID()
140 out := make([]sessionDAGEntry, 0, len(plan.entries)+len(pending)+1)
141 out = append(out, sessionDAGEntry{Type: sessionDAGTypeFork, Head: oldHead, NewHead: newHead, From: from, Kind: HeadKindConcurrent, At: now})
142 for _, e := range plan.entries {
143 if e.Type == sessionDAGTypeRewind {
144 continue
145 }
146 e.Head = newHead
147 out = append(out, e)
148 }
149 begun := map[string]bool{}
150 for _, e := range pending {
151 if e.Type == sessionDAGTypeTurnBegin {
152 begun[e.Turn] = true
153 }
154 }
155 for _, e := range pending {
156 e.Head = newHead
157 if e.Type == sessionDAGTypeTurnEnd && !begun[e.Turn] {
158 e.Head = oldHead
159 }
160 out = append(out, e)
161 }
162 plan.head, plan.forked, plan.rewound, plan.pureAppend = newHead, true, false, false
163 return out
164 }
165
166 // republishDAGDerivedIfPending refreshes the derived files a previous
167 // unlocked or checkpoint save skipped, once a locked save finds nothing new
168 // to append.
169 func (s *Session) republishDAGDerivedIfPending(ctx context.Context, path string, st *sessionDAGState, plan *dagWritePlan, msgs []provider.Message, digest [sha256.Size]byte, revision int64) {
170 if !s.persistState(path).projectionPending {
171 return
172 }
173 selected := st.selectedHead()
174 displayCurrent := selected == plan.head && writeDAGCheckpointCache(path, plan, msgs, revision)
175 s.publishDAGDerived(ctx, path, st, plan, msgs, digest, revision, selected, displayCurrent, false)
176 }
177
178 // DerivedFilesPending reports whether the last save of path left its derived
179 // files (listing sidecar, display cache, head index) to a later locked save,
180 // as an unlocked shutdown append or a tool checkpoint does.
181 func (s *Session) DerivedFilesPending(path string) bool {
182 return s.persistState(path).projectionPending
183 }
184
184 lines GO