返回 DeepSeek-Reasonix
session_write_authority.go
根目录 / internal / acp / session_write_authority.go
1 package acp
2
3 import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "path/filepath"
8 "strings"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 )
13
14 // bindACPWriteAuthority issues a generation-bound write authority when ctrl is
15 // a concrete *control.Controller. Test fakes without the method stay unbound.
16 func bindACPWriteAuthority(ctrl acpController, lease *agent.SessionLease) error {
17 c, ok := ctrl.(*control.Controller)
18 if !ok || c == nil {
19 return nil
20 }
21 return c.BindSessionWriteAuthority(lease)
22 }
23
24 func bindACPWriteAuthorityOrClose(ctrl acpController, lease *agent.SessionLease) error {
25 if err := bindACPWriteAuthority(ctrl, lease); err != nil {
26 if lease != nil {
27 lease.Release()
28 }
29 ctrl.Close()
30 return err
31 }
32 return nil
33 }
34
35 func (s *service) bindSessionPathHandlers(id string, params *SessionParams) {
36 params.OnSessionRecovered = s.sessionRecoveredHandler(id)
37 params.OnSessionTransition = s.sessionTransitionHandler(id)
38 }
39
40 func resumeACPControllerForWrite(ctrl acpController, loaded *agent.Session, path string, lease *agent.SessionLease) error {
41 ctrl.Resume(loaded, path)
42 return bindACPWriteAuthorityOrClose(ctrl, lease)
43 }
44
45 func snapshotACPController(sess *acpSession, ctrl acpController) error {
46 err := ctrl.Snapshot()
47 sess.waitForRetiredSessionLeases()
48 return err
49 }
50
51 func (s *service) prepareACPReplacementAuthority(sess *acpSession, next *control.Controller, current acpController, path, snapshotAction string) error {
52 next.SetOnSessionRecovered(s.sessionRecoveredHandlerFor(sess.id, next))
53 next.SetOnSessionTransition(s.sessionTransitionHandler(sess.id))
54 sess.mu.Lock()
55 lease := sess.lease
56 sess.mu.Unlock()
57 if lease != nil {
58 if err := bindACPWriteAuthority(next, lease); err != nil {
59 return fmt.Errorf("bind replacement session authority")
60 }
61 }
62 if path == "" {
63 return nil
64 }
65 if err := snapshotACPController(sess, next); err != nil {
66 _ = bindACPWriteAuthority(current, lease)
67 return fmt.Errorf("%s: %w", snapshotAction, err)
68 }
69 return nil
70 }
71
72 // sessionTransitionHandler binds an unpublished branch/switch Session to its
73 // target lease before the controller publishes it. ACP metadata changes only
74 // after both acquisition and authority binding succeed.
75 func (s *service) sessionTransitionHandler(id string) func(control.SessionTransitionInfo) error {
76 return func(info control.SessionTransitionInfo) error {
77 targetPath := strings.TrimSpace(info.TargetPath)
78 if targetPath == "" {
79 return nil
80 }
81 sess := s.session(id)
82 if sess == nil {
83 return fmt.Errorf("bind target session: session is unavailable")
84 }
85 lease, err := agent.TryAcquireSessionLease(targetPath)
86 if err != nil {
87 if errors.Is(err, agent.ErrSessionLeaseHeld) {
88 return fmt.Errorf("bind target session: %s; %s",
89 control.SessionInUseMessage(err), control.SessionLeaseCloseHint)
90 }
91 return fmt.Errorf("bind target session: %w", err)
92 }
93 sess.mu.Lock()
94 if sess.deleted {
95 sess.mu.Unlock()
96 lease.Release()
97 return fmt.Errorf("bind target session: session is deleted")
98 }
99 if err := info.BindWriteAuthority(lease); err != nil {
100 sess.mu.Unlock()
101 lease.Release()
102 return fmt.Errorf("bind target session authority: %w", err)
103 }
104 old := sess.lease
105 sess.lease = lease
106 sess.transcript = targetPath
107 meta := sess.metaLocked()
108 sess.mu.Unlock()
109 sess.retireSessionLease(old)
110 _ = saveACPMeta(targetPath, meta)
111 s.persistACPTranscriptRedirect(id, targetPath, meta)
112 return nil
113 }
114 }
115
116 // persistACPTranscriptRedirect keeps restart-time id lookup attached to the
117 // transcript an intentional transition or conflict recovery selected. The
118 // active transcript owns the full metadata; the id-keyed sidecar is only a
119 // single-hop redirect when the paths differ.
120 func (s *service) persistACPTranscriptRedirect(id, activePath string, meta acpSessionMeta) {
121 dir := s.sessionDir()
122 if dir == "" {
123 return
124 }
125 idPath := transcriptPath(dir, id)
126 if idPath == activePath {
127 return
128 }
129 idMeta, _, err := loadACPMeta(idPath)
130 if err != nil {
131 slog.Warn("acp: load id-keyed meta for transcript redirect", "err", err)
132 idMeta = acpSessionMeta{}
133 }
134 if idMeta.SessionID == "" {
135 idMeta.SessionID = id
136 }
137 if idMeta.Cwd == "" {
138 idMeta.Cwd = meta.Cwd
139 }
140 if idMeta.CreatedAt.IsZero() {
141 idMeta.CreatedAt = meta.CreatedAt
142 }
143 idMeta.ActiveTranscript = filepath.Base(activePath)
144 if err := saveACPMeta(idPath, idMeta); err != nil {
145 slog.Warn("acp: save transcript redirect", "err", err)
146 }
147 }
148
149 // sessionRecoveredHandler follows a conflict recovery at commit time so ACP
150 // metadata, transcript lookup, and the write lease all point at one file.
151 func (s *service) sessionRecoveredHandler(id string) func(control.SessionRecoveryInfo) error {
152 return s.sessionRecoveredHandlerFor(id, nil)
153 }
154
155 func (s *service) sessionRecoveredHandlerFor(id string, owner acpController) func(control.SessionRecoveryInfo) error {
156 return func(info control.SessionRecoveryInfo) error {
157 recoveryPath := strings.TrimSpace(info.RecoveryPath)
158 if recoveryPath == "" {
159 return nil
160 }
161 sess := s.session(id)
162 if sess == nil {
163 return nil
164 }
165 lease, err := agent.TryAcquireSessionLease(recoveryPath)
166 if err != nil {
167 if errors.Is(err, agent.ErrSessionLeaseHeld) {
168 return fmt.Errorf("bind recovery session: %s; %s",
169 control.SessionInUseMessage(err), control.SessionLeaseCloseHint)
170 }
171 return fmt.Errorf("bind recovery session: %w", err)
172 }
173 sess.mu.Lock()
174 if sess.deleted {
175 sess.mu.Unlock()
176 lease.Release()
177 return fmt.Errorf("bind recovery session: session is deleted")
178 }
179 old := sess.lease
180 ctrl := owner
181 if ctrl == nil {
182 ctrl = sess.ctrl
183 }
184 if err := bindACPWriteAuthority(ctrl, lease); err != nil {
185 if old != nil {
186 _ = bindACPWriteAuthority(ctrl, old)
187 }
188 sess.mu.Unlock()
189 lease.Release()
190 return fmt.Errorf("bind recovery session: unable to bind recovered transcript authority")
191 }
192 sess.lease = lease
193 sess.transcript = recoveryPath
194 meta := sess.metaLocked()
195 sess.mu.Unlock()
196 sess.retireSessionLease(old)
197 _ = saveACPMeta(recoveryPath, meta)
198 s.persistACPTranscriptRedirect(id, recoveryPath, meta)
199 return nil
200 }
201 }
202
202 lines GO