返回 DeepSeek-Reasonix
lease.go
1 // Package workspacelease serializes Delivery writers that target the same
2 // workspace. Readers never acquire a lease. A writer keeps its lease from the
3 // first mutation until every participating agent run and background job has
4 // finished, so review and verification cannot be invalidated by another
5 // Delivery session changing the workspace mid-turn.
6 package workspacelease
7
8 import (
9 "context"
10 "crypto/sha256"
11 "encoding/hex"
12 "errors"
13 "fmt"
14 "os"
15 "path/filepath"
16 "runtime"
17 "strings"
18 "sync"
19 "time"
20 )
21
22 const retryInterval = 75 * time.Millisecond
23
24 var errHeld = errors.New("workspace write lease is held")
25
26 // WaitNotice is called once when an acquisition cannot complete immediately.
27 // It must return quickly and must not call back into Owner.
28 type WaitNotice func()
29
30 // Owner is one Delivery session's re-entrant workspace lease. One Owner may be
31 // shared by the root agent and all of its subagents. Different sessions must
32 // use different Owners, even when they share a workspace.
33 type Owner struct {
34 lockPath string
35 onWait WaitNotice
36 local *localLock
37
38 mu sync.Mutex
39 activeRuns int
40 background int
41 acquired bool
42 acquiring bool
43 waiting bool
44 acquireDone chan struct{}
45 releaseSystem func()
46 }
47
48 // State is a sanitized process-local snapshot used by Desktop to explain a
49 // workspace conflict. It deliberately contains no path, PID, or lock token.
50 type State struct {
51 Acquired bool
52 Waiting bool
53 }
54
55 // State returns the current acquisition state without performing lease I/O.
56 func (o *Owner) State() State {
57 if o == nil {
58 return State{}
59 }
60 o.mu.Lock()
61 defer o.mu.Unlock()
62 return State{Acquired: o.acquired, Waiting: o.waiting}
63 }
64
65 type localLock struct {
66 token chan struct{}
67 }
68
69 var localRegistry = struct {
70 sync.Mutex
71 locks map[string]*localLock
72 }{locks: map[string]*localLock{}}
73
74 // New returns a Delivery-session lease owner for workspaceRoot. lockDir must be
75 // shared by Reasonix processes for cross-process protection; it is kept outside
76 // the workspace so acquiring a lease never dirties user files.
77 func New(workspaceRoot, lockDir string, onWait WaitNotice) (*Owner, error) {
78 canonical, err := CanonicalWorkspace(workspaceRoot)
79 if err != nil {
80 return nil, err
81 }
82 lockDir = strings.TrimSpace(lockDir)
83 if lockDir == "" {
84 return nil, errors.New("workspace lease directory is unavailable")
85 }
86 if err := os.MkdirAll(lockDir, 0o700); err != nil {
87 return nil, fmt.Errorf("create workspace lease directory: %w", err)
88 }
89 sum := sha256.Sum256([]byte(canonical))
90 key := hex.EncodeToString(sum[:])
91
92 localRegistry.Lock()
93 local := localRegistry.locks[key]
94 if local == nil {
95 local = &localLock{token: make(chan struct{}, 1)}
96 local.token <- struct{}{}
97 localRegistry.locks[key] = local
98 }
99 localRegistry.Unlock()
100
101 return &Owner{
102 lockPath: filepath.Join(lockDir, key+".lock"),
103 onWait: onWait,
104 local: local,
105 }, nil
106 }
107
108 // CanonicalWorkspace returns the stable identity used to key a workspace. It
109 // resolves symlinks when possible and folds case on Windows, where paths are
110 // case-insensitive by default.
111 func CanonicalWorkspace(root string) (string, error) {
112 root = strings.TrimSpace(root)
113 if root == "" {
114 return "", errors.New("workspace root is empty")
115 }
116 abs, err := filepath.Abs(root)
117 if err != nil {
118 return "", fmt.Errorf("resolve workspace root: %w", err)
119 }
120 abs = filepath.Clean(abs)
121 if resolved, resolveErr := filepath.EvalSymlinks(abs); resolveErr == nil {
122 abs = filepath.Clean(resolved)
123 } else if !os.IsNotExist(resolveErr) {
124 return "", fmt.Errorf("canonicalize workspace root: %w", resolveErr)
125 }
126 abs = nearestGitWorktreeRoot(abs)
127 if runtime.GOOS == "windows" {
128 abs = strings.ToLower(filepath.ToSlash(abs))
129 }
130 return abs, nil
131 }
132
133 // nearestGitWorktreeRoot folds a repository root and any selected directory
134 // beneath it into one writer domain. It intentionally detects the .git marker
135 // through the filesystem instead of invoking Git, so the no-Git Windows path
136 // keeps the same safety guarantee. Linked worktrees each have their own .git
137 // marker and therefore remain independent writer domains.
138 func nearestGitWorktreeRoot(path string) string {
139 start := path
140 if info, err := os.Stat(path); err == nil && !info.IsDir() {
141 start = filepath.Dir(path)
142 }
143 for current := start; ; current = filepath.Dir(current) {
144 if _, err := os.Lstat(filepath.Join(current, ".git")); err == nil {
145 return current
146 }
147 parent := filepath.Dir(current)
148 if parent == current {
149 return path
150 }
151 }
152 }
153
154 // BeginRun registers an agent run that participates in this session. The call
155 // is intentionally cheap and does not acquire the write lease; read-only turns
156 // therefore remain fully concurrent.
157 func (o *Owner) BeginRun() {
158 if o == nil {
159 return
160 }
161 o.mu.Lock()
162 o.activeRuns++
163 o.mu.Unlock()
164 }
165
166 // EndRun releases the lease after the final participating run and retained
167 // background job finishes.
168 func (o *Owner) EndRun() {
169 if o == nil {
170 return
171 }
172 o.mu.Lock()
173 if o.activeRuns > 0 {
174 o.activeRuns--
175 }
176 release := o.releaseIfIdleLocked()
177 o.mu.Unlock()
178 if release != nil {
179 release()
180 }
181 }
182
183 // AcquireWrite lazily acquires this session's exclusive write lease. It is
184 // re-entrant across parallel tool calls and shared subagents.
185 func (o *Owner) AcquireWrite(ctx context.Context) error {
186 if o == nil {
187 return nil
188 }
189 if ctx == nil {
190 ctx = context.Background()
191 }
192 for {
193 o.mu.Lock()
194 if o.acquired {
195 o.mu.Unlock()
196 return nil
197 }
198 if o.acquiring {
199 done := o.acquireDone
200 o.mu.Unlock()
201 select {
202 case <-done:
203 continue
204 case <-ctx.Done():
205 return ctx.Err()
206 }
207 }
208 o.acquiring = true
209 o.acquireDone = make(chan struct{})
210 done := o.acquireDone
211 o.mu.Unlock()
212
213 release, err := o.acquire(ctx)
214 o.mu.Lock()
215 o.acquiring = false
216 o.waiting = false
217 if err == nil {
218 o.acquired = true
219 o.releaseSystem = release
220 }
221 close(done)
222 releaseIfIdle := o.releaseIfIdleLocked()
223 o.mu.Unlock()
224 if releaseIfIdle != nil {
225 releaseIfIdle()
226 }
227 return err
228 }
229 }
230
231 // RetainUntil keeps an already-acquired lease alive for a background job. It
232 // is a no-op when this session has not acquired the workspace, which preserves
233 // concurrency for background readers.
234 func (o *Owner) RetainUntil(done <-chan struct{}) {
235 if o == nil || done == nil {
236 return
237 }
238 o.mu.Lock()
239 if !o.acquired {
240 o.mu.Unlock()
241 return
242 }
243 o.background++
244 o.mu.Unlock()
245 go func() {
246 <-done
247 o.mu.Lock()
248 if o.background > 0 {
249 o.background--
250 }
251 release := o.releaseIfIdleLocked()
252 o.mu.Unlock()
253 if release != nil {
254 release()
255 }
256 }()
257 }
258
259 func (o *Owner) releaseIfIdleLocked() func() {
260 if !o.acquired || o.acquiring || o.activeRuns != 0 || o.background != 0 {
261 return nil
262 }
263 release := o.releaseSystem
264 o.acquired = false
265 o.releaseSystem = nil
266 return release
267 }
268
269 func (o *Owner) acquire(ctx context.Context) (func(), error) {
270 waited := false
271 notifyWait := func() {
272 if waited {
273 return
274 }
275 waited = true
276 o.mu.Lock()
277 o.waiting = true
278 o.mu.Unlock()
279 if o.onWait != nil {
280 o.onWait()
281 }
282 }
283
284 select {
285 case <-o.local.token:
286 case <-ctx.Done():
287 return nil, ctx.Err()
288 default:
289 notifyWait()
290 select {
291 case <-o.local.token:
292 case <-ctx.Done():
293 return nil, ctx.Err()
294 }
295 }
296
297 releaseLocal := func() { o.local.token <- struct{}{} }
298 for {
299 releaseFile, err := tryLockFile(o.lockPath)
300 if err == nil {
301 return func() {
302 releaseFile()
303 releaseLocal()
304 }, nil
305 }
306 if !errors.Is(err, errHeld) {
307 releaseLocal()
308 return nil, fmt.Errorf("acquire workspace write lease: %w", err)
309 }
310 notifyWait()
311 timer := time.NewTimer(retryInterval)
312 select {
313 case <-timer.C:
314 case <-ctx.Done():
315 if !timer.Stop() {
316 <-timer.C
317 }
318 releaseLocal()
319 return nil, ctx.Err()
320 }
321 }
322 }
323
323 lines GO