返回 DeepSeek-Reasonix
lease.go
1 // Package workspacelease serializes Delivery writers that target the same
2 // workspace. Readers never acquire a lease. Write-tool holds are released when
3 // the tool returns, with bounded retention for background jobs.
4 package workspacelease
5
6 import (
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "os"
13 "path/filepath"
14 "runtime"
15 "slices"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20
21 "reasonix/internal/filelock"
22 "reasonix/internal/pathidentity"
23 )
24
25 const backgroundGrace = 30 * time.Second
26
27 // WaitNotice is called once when an acquisition cannot complete immediately.
28 // It must return quickly and must not call back into Owner.
29 type WaitNotice func()
30
31 type ownerActivity struct {
32 activeRuns int
33 background int
34 }
35
36 type systemHold struct {
37 refs int
38 scope string
39 keys []string
40 slots []string
41 release func()
42 }
43
44 type sharedSystemHold struct {
45 refs int
46 release func()
47 }
48
49 type ownerLease struct {
50 acquiring bool
51 waiting bool
52 targetScope string
53 targetLabel string
54 targetKeys []string
55 acquireDone chan struct{}
56 changed chan struct{}
57 holds map[uint64]*systemHold
58 shared map[string]*sharedSystemHold
59 nextID uint64
60 legacy []func()
61 epoch uint64
62 graceTimer *time.Timer
63 }
64
65 // Owner is one Delivery session's re-entrant workspace lease. One Owner may be
66 // shared by the root agent and its subagents; different sessions use different
67 // Owners even when they share a workspace.
68 type Owner struct {
69 lockPath string
70 lockDir string
71 canonical string
72 compatibility string
73 rootPath string
74 onWait WaitNotice
75 graceAfter time.Duration
76
77 mu sync.Mutex
78 activity ownerActivity
79 lease ownerLease
80 }
81
82 // State is a sanitized process-local snapshot used by Desktop. WaitingKeys are
83 // internal canonical identities; they are never copied into the desktop payload.
84 type State struct {
85 Acquired bool
86 Waiting bool
87 Scope string
88 Label string
89 HeldScope string
90 HeldLabel string
91 HeldKeys []string
92 WaitingKeys []string
93 }
94
95 // State returns the current acquisition state without performing lease I/O.
96 func (o *Owner) State() State {
97 if o == nil {
98 return State{}
99 }
100 o.mu.Lock()
101 defer o.mu.Unlock()
102 heldScope, heldLabel := o.holdScopeLocked()
103 heldKeys := o.heldKeysLocked()
104 state := State{
105 Acquired: len(o.lease.holds) > 0,
106 Waiting: o.lease.waiting,
107 Scope: heldScope,
108 Label: heldLabel,
109 HeldScope: heldScope,
110 HeldLabel: heldLabel,
111 HeldKeys: heldKeys,
112 }
113 if o.lease.waiting {
114 state.Scope = o.lease.targetScope
115 state.Label = o.lease.targetLabel
116 state.WaitingKeys = append([]string(nil), o.lease.targetKeys...)
117 }
118 return state
119 }
120
121 func (o *Owner) holdScopeLocked() (string, string) {
122 keys := map[string]string{}
123 for _, hold := range o.lease.holds {
124 if hold.scope == "workspace" {
125 return "workspace", ""
126 }
127 for _, key := range hold.keys {
128 keys[key] = filepath.Base(key)
129 }
130 }
131 switch len(keys) {
132 case 0:
133 return "", ""
134 case 1:
135 for _, label := range keys {
136 return "file", label
137 }
138 default:
139 return "files", fmt.Sprintf("%d files", len(keys))
140 }
141 return "", ""
142 }
143
144 // New returns a Delivery-session lease owner for workspaceRoot. lockDir is
145 // shared by Reasonix processes and remains outside the user's workspace.
146 func New(workspaceRoot, lockDir string, onWait WaitNotice) (*Owner, error) {
147 canonical, compatibility, err := workspaceIdentities(workspaceRoot)
148 if err != nil {
149 return nil, err
150 }
151 lockDir = strings.TrimSpace(lockDir)
152 if lockDir == "" {
153 return nil, errors.New("workspace lease directory is unavailable")
154 }
155 if err := os.MkdirAll(lockDir, 0o700); err != nil {
156 return nil, fmt.Errorf("create workspace lease directory: %w", err)
157 }
158 lockPath := workspaceLockPath(lockDir, compatibility)
159 return &Owner{
160 lockPath: lockPath, canonical: canonical, compatibility: compatibility,
161 rootPath: workspaceRoot,
162 lockDir: lockDir,
163 onWait: onWait, graceAfter: backgroundGrace,
164 lease: ownerLease{
165 changed: make(chan struct{}), holds: map[uint64]*systemHold{},
166 shared: map[string]*sharedSystemHold{},
167 },
168 }, nil
169 }
170
171 // HeldKeys returns the actual lock-domain identities currently held. An
172 // exclusive hold reports the workspace root; path holds report only files.
173 func (o *Owner) HeldKeys() []string {
174 if o == nil {
175 return nil
176 }
177 o.mu.Lock()
178 defer o.mu.Unlock()
179 return o.heldKeysLocked()
180 }
181
182 func (o *Owner) heldKeysLocked() []string {
183 seen := map[string]bool{}
184 for _, hold := range o.lease.holds {
185 for _, key := range hold.keys {
186 seen[key] = true
187 }
188 }
189 out := make([]string, 0, len(seen))
190 for key := range seen {
191 out = append(out, key)
192 }
193 sort.Strings(out)
194 return out
195 }
196
197 // CanonicalWorkspace returns the stable identity used to key a workspace.
198 func CanonicalWorkspace(root string) (string, error) {
199 canonical, _, err := workspaceIdentities(root)
200 return canonical, err
201 }
202
203 func workspaceIdentities(root string) (canonical, compatibility string, err error) {
204 root = strings.TrimSpace(root)
205 if root == "" {
206 return "", "", errors.New("workspace root is empty")
207 }
208 baseDir := ""
209 if !filepath.IsAbs(root) {
210 baseDir, err = os.Getwd()
211 if err != nil {
212 return "", "", fmt.Errorf("resolve workspace root base: %w", err)
213 }
214 }
215 identity, err := pathidentity.Resolve(root, pathidentity.Options{BaseDir: baseDir, FollowLeaf: true})
216 if err != nil {
217 return "", "", fmt.Errorf("resolve workspace root: %w", err)
218 }
219 gitRoot := nearestGitWorktreeRoot(identity.PhysicalPath)
220 identity, err = pathidentity.Resolve(gitRoot, pathidentity.Options{FollowLeaf: true})
221 if err != nil {
222 return "", "", fmt.Errorf("resolve workspace lease root: %w", err)
223 }
224 compatibility = compatibilityIdentityPath(identity.PhysicalPath)
225 return identity.Key, compatibility, nil
226 }
227
228 func nearestGitWorktreeRoot(path string) string {
229 start := path
230 if info, err := os.Stat(path); err == nil && !info.IsDir() {
231 start = filepath.Dir(path)
232 }
233 for current := start; ; current = filepath.Dir(current) {
234 if _, err := os.Lstat(filepath.Join(current, ".git")); err == nil {
235 return current
236 }
237 parent := filepath.Dir(current)
238 if parent == current {
239 return path
240 }
241 }
242 }
243
244 // BeginRun registers an agent run without taking a writer lease.
245 func (o *Owner) BeginRun() {
246 if o == nil {
247 return
248 }
249 o.mu.Lock()
250 o.activity.activeRuns++
251 o.cancelGraceLocked()
252 o.mu.Unlock()
253 }
254
255 // EndRun drops leaked tool references after the final participating run.
256 func (o *Owner) EndRun() {
257 if o == nil {
258 return
259 }
260 o.mu.Lock()
261 if o.activity.activeRuns > 0 {
262 o.activity.activeRuns--
263 }
264 if o.activity.activeRuns == 0 {
265 for _, hold := range o.lease.holds {
266 hold.refs = 0
267 }
268 o.lease.legacy = nil
269 }
270 releases := o.collectInactiveLocked()
271 o.mu.Unlock()
272 runReleases(releases)
273 }
274
275 // AcquireWrite acquires a legacy workspace hold released by ReleaseWrite or
276 // EndRun. New call sites should prefer HoldWrite.
277 func (o *Owner) AcquireWrite(ctx context.Context) error {
278 release, err := o.HoldWrite(ctx)
279 if err == nil && o != nil {
280 o.mu.Lock()
281 o.lease.legacy = append(o.lease.legacy, release)
282 o.mu.Unlock()
283 }
284 return err
285 }
286
287 // HoldWrite acquires an exclusive workspace hold. If this Owner already has
288 // path holds, it waits for them to finish instead of dropping their protection.
289 func (o *Owner) HoldWrite(ctx context.Context) (func(), error) {
290 if o == nil {
291 return func() {}, nil
292 }
293 if ctx == nil {
294 ctx = context.Background()
295 }
296 for {
297 o.mu.Lock()
298 if id, hold := o.exclusiveHoldLocked(); hold != nil {
299 hold.refs++
300 o.cancelGraceLocked()
301 o.mu.Unlock()
302 return o.releaseHoldFunc(id), nil
303 }
304 if o.lease.acquiring {
305 done := o.lease.acquireDone
306 o.mu.Unlock()
307 if err := waitForSignal(ctx, done); err != nil {
308 return func() {}, err
309 }
310 continue
311 }
312 o.beginAcquisitionLocked("workspace", "", []string{o.canonical})
313 for o.hasPathHoldsLocked() {
314 if o.activity.background > 0 {
315 o.armGraceLocked()
316 }
317 changed := o.lease.changed
318 o.mu.Unlock()
319 if err := waitForSignal(ctx, changed); err != nil {
320 o.mu.Lock()
321 o.finishAcquisitionLocked()
322 o.mu.Unlock()
323 return func() {}, err
324 }
325 o.mu.Lock()
326 }
327 o.mu.Unlock()
328
329 notified := false
330 snapshots, err := snapshotWorkspaceRoots([]string{o.rootPath})
331 var release func()
332 if err == nil {
333 release, err = o.acquireWorkspace(ctx, filelock.ModeExclusive, &notified)
334 }
335 if err == nil {
336 err = o.revalidateRoot(snapshots[0])
337 if err != nil {
338 release()
339 }
340 }
341 o.mu.Lock()
342 var id uint64
343 if err == nil {
344 id = o.addHoldLocked(&systemHold{
345 refs: 1, scope: "workspace", keys: []string{o.canonical}, release: release,
346 })
347 }
348 o.finishAcquisitionLocked()
349 releases := o.collectInactiveLocked()
350 o.mu.Unlock()
351 runReleases(releases)
352 if err != nil {
353 return func() {}, err
354 }
355 return o.releaseHoldFunc(id), nil
356 }
357 }
358
359 func (o *Owner) revalidateRoot(snapshot workspaceRootSnapshot) error {
360 canonical, _, err := workspaceIdentities(snapshot.path)
361 if err != nil {
362 return fmt.Errorf("revalidate workspace root: %w", err)
363 }
364 currentInfo, statErr := os.Stat(snapshot.path)
365 currentExists := statErr == nil
366 if statErr != nil && !os.IsNotExist(statErr) {
367 return fmt.Errorf("revalidate workspace root: %w", statErr)
368 }
369 if canonical != snapshot.key || currentExists != snapshot.exists || (currentExists && !os.SameFile(snapshot.info, currentInfo)) {
370 return errors.New("workspace root identity changed while waiting")
371 }
372 return nil
373 }
374
375 // ReleaseWrite releases the most recent legacy AcquireWrite/AcquireWriteForPath.
376 func (o *Owner) ReleaseWrite() {
377 if o == nil {
378 return
379 }
380 o.mu.Lock()
381 if len(o.lease.legacy) == 0 {
382 o.mu.Unlock()
383 return
384 }
385 last := len(o.lease.legacy) - 1
386 release := o.lease.legacy[last]
387 o.lease.legacy = o.lease.legacy[:last]
388 o.mu.Unlock()
389 release()
390 }
391
392 func (o *Owner) exclusiveHoldLocked() (uint64, *systemHold) {
393 for id, hold := range o.lease.holds {
394 if hold.scope == "workspace" {
395 return id, hold
396 }
397 }
398 return 0, nil
399 }
400
401 func (o *Owner) hasPathHoldsLocked() bool {
402 for _, hold := range o.lease.holds {
403 if hold.scope != "workspace" {
404 return true
405 }
406 }
407 return false
408 }
409
410 func (o *Owner) addHoldLocked(hold *systemHold) uint64 {
411 o.lease.nextID++
412 o.lease.holds[o.lease.nextID] = hold
413 o.lease.epoch++
414 o.cancelGraceLocked()
415 o.signalChangedLocked()
416 return o.lease.nextID
417 }
418
419 func (o *Owner) releaseHoldFunc(id uint64) func() {
420 var once sync.Once
421 return func() {
422 once.Do(func() {
423 o.mu.Lock()
424 if hold := o.lease.holds[id]; hold != nil && hold.refs > 0 {
425 hold.refs--
426 }
427 releases := o.collectInactiveLocked()
428 o.signalChangedLocked()
429 o.mu.Unlock()
430 runReleases(releases)
431 })
432 }
433 }
434
435 // RetainUntil keeps completed tool holds alive for a background job.
436 func (o *Owner) RetainUntil(done <-chan struct{}) {
437 if o == nil || done == nil {
438 return
439 }
440 o.mu.Lock()
441 if len(o.lease.holds) == 0 {
442 o.mu.Unlock()
443 return
444 }
445 o.activity.background++
446 o.mu.Unlock()
447 go func() {
448 <-done
449 o.mu.Lock()
450 if o.activity.background > 0 {
451 o.activity.background--
452 }
453 releases := o.collectInactiveLocked()
454 o.mu.Unlock()
455 runReleases(releases)
456 }()
457 }
458
459 func (o *Owner) collectInactiveLocked() []func() {
460 var inactive bool
461 for _, hold := range o.lease.holds {
462 if hold.refs == 0 {
463 inactive = true
464 break
465 }
466 }
467 if !inactive {
468 return nil
469 }
470 if o.activity.background > 0 {
471 o.armGraceLocked()
472 return nil
473 }
474 return o.takeInactiveLocked()
475 }
476
477 func (o *Owner) takeInactiveLocked() []func() {
478 o.cancelGraceLocked()
479 var releases []func()
480 for id, hold := range o.lease.holds {
481 if hold.refs != 0 {
482 continue
483 }
484 delete(o.lease.holds, id)
485 if hold.release != nil {
486 releases = append(releases, hold.release)
487 }
488 }
489 if len(releases) > 0 {
490 o.signalChangedLocked()
491 }
492 return releases
493 }
494
495 func (o *Owner) armGraceLocked() {
496 if o.graceAfter <= 0 || o.lease.graceTimer != nil {
497 return
498 }
499 epoch := o.lease.epoch
500 o.lease.graceTimer = time.AfterFunc(o.graceAfter, func() {
501 o.mu.Lock()
502 if o.lease.graceTimer == nil || o.lease.epoch != epoch {
503 o.mu.Unlock()
504 return
505 }
506 releases := o.takeInactiveLocked()
507 o.mu.Unlock()
508 runReleases(releases)
509 })
510 }
511
512 func (o *Owner) cancelGraceLocked() {
513 if o.lease.graceTimer != nil {
514 o.lease.graceTimer.Stop()
515 o.lease.graceTimer = nil
516 }
517 }
518
519 func (o *Owner) beginAcquisitionLocked(scope, label string, keys []string) {
520 o.lease.acquiring = true
521 o.lease.acquireDone = make(chan struct{})
522 o.lease.targetScope = scope
523 o.lease.targetLabel = label
524 o.lease.targetKeys = append([]string(nil), keys...)
525 o.signalChangedLocked()
526 }
527
528 func (o *Owner) finishAcquisitionLocked() {
529 o.lease.acquiring = false
530 o.lease.waiting = false
531 o.lease.targetScope = ""
532 o.lease.targetLabel = ""
533 o.lease.targetKeys = nil
534 if o.lease.acquireDone != nil {
535 close(o.lease.acquireDone)
536 o.lease.acquireDone = nil
537 }
538 o.signalChangedLocked()
539 }
540
541 func (o *Owner) signalChangedLocked() {
542 if o.lease.changed != nil {
543 close(o.lease.changed)
544 }
545 o.lease.changed = make(chan struct{})
546 }
547
548 func (o *Owner) markWaiting() bool {
549 o.mu.Lock()
550 first := !o.lease.waiting
551 o.lease.waiting = true
552 o.signalChangedLocked()
553 o.mu.Unlock()
554 return first
555 }
556
557 func (o *Owner) acquireWorkspace(ctx context.Context, mode filelock.Mode, notified *bool) (func(), error) {
558 roots := append(ancestorDirectories(o.canonical), ancestorDirectories(o.compatibility)...)
559 compatRelease, err := o.acquireCompatibilityRoots(ctx, roots, mode, notified)
560 if err != nil {
561 return nil, err
562 }
563 if mode != filelock.ModeExclusive {
564 return compatRelease, nil
565 }
566 treeRelease, err := o.acquireQueuedMode(ctx, o.treeLockPath(o.canonical), filelock.ModeExclusive, notified)
567 if err != nil {
568 compatRelease()
569 return nil, err
570 }
571 return func() { runReleases([]func(){compatRelease, treeRelease}) }, nil
572 }
573
574 // acquireCompatibilityRoots keeps the original per-workspace lock protocol in
575 // the hierarchy. Previous Reasonix versions only know these exact lock files,
576 // so descendants take their ancestor locks shared while a whole-workspace
577 // writer takes its own root exclusively.
578 func (o *Owner) acquireCompatibilityRoots(
579 ctx context.Context,
580 roots []string,
581 rootMode filelock.Mode,
582 notified *bool,
583 ) (func(), error) {
584 roots = orderedWorkspaceRoots(roots)
585 releases := make([]func(), 0, len(roots))
586 for _, root := range roots {
587 mode := filelock.ModeShared
588 if normalizeIdentityPath(root) == o.canonical {
589 mode = rootMode
590 }
591 release, err := o.acquireQueuedMode(ctx, workspaceLockPath(o.lockDir, root), mode, notified)
592 if err != nil {
593 runReleases(releases)
594 return nil, err
595 }
596 releases = append(releases, release)
597 }
598 return func() { runReleases(releases) }, nil
599 }
600
601 func (o *Owner) acquireQueuedMode(ctx context.Context, lockPath string, mode filelock.Mode, notified *bool) (func(), error) {
602 if mode == filelock.ModeShared {
603 return o.acquireSharedDomain(ctx, lockPath, notified)
604 }
605 return o.acquireQueuedModeRaw(ctx, lockPath, mode, notified)
606 }
607
608 func (o *Owner) acquireSharedDomain(ctx context.Context, lockPath string, notified *bool) (func(), error) {
609 lockPath = compatibilityIdentityPath(lockPath)
610 o.mu.Lock()
611 // Re-enter before the writer-priority queue: its writer waits for this Owner's
612 // shared hold, so queueing the same Owner would deadlock. Other Owners still
613 // queue because they have no entry here.
614 if hold := o.lease.shared[lockPath]; hold != nil {
615 hold.refs++
616 o.mu.Unlock()
617 return o.releaseSharedDomainFunc(lockPath, hold), nil
618 }
619 o.mu.Unlock()
620
621 release, err := o.acquireQueuedModeRaw(ctx, lockPath, filelock.ModeShared, notified)
622 if err != nil {
623 return nil, err
624 }
625 hold := &sharedSystemHold{refs: 1, release: release}
626 o.mu.Lock()
627 o.lease.shared[lockPath] = hold
628 o.mu.Unlock()
629 return o.releaseSharedDomainFunc(lockPath, hold), nil
630 }
631
632 func (o *Owner) releaseSharedDomainFunc(lockPath string, target *sharedSystemHold) func() {
633 var once sync.Once
634 return func() {
635 once.Do(func() {
636 var release func()
637 o.mu.Lock()
638 if hold := o.lease.shared[lockPath]; hold == target {
639 hold.refs--
640 if hold.refs == 0 {
641 delete(o.lease.shared, lockPath)
642 release = hold.release
643 }
644 }
645 o.mu.Unlock()
646 if release != nil {
647 release()
648 }
649 })
650 }
651 }
652
653 func (o *Owner) acquireQueuedModeRaw(ctx context.Context, lockPath string, mode filelock.Mode, notified *bool) (func(), error) {
654 queueRelease, err := o.acquireMode(ctx, lockPath+".queue", filelock.ModeExclusive, notified)
655 if err != nil {
656 return nil, err
657 }
658 release, err := o.acquireMode(ctx, lockPath, mode, notified)
659 queueRelease()
660 return release, err
661 }
662
663 func workspaceLockPath(lockDir, canonical string) string {
664 canonical = compatibilityIdentityPath(canonical)
665 sum := sha256.Sum256([]byte(canonical))
666 return filepath.Join(lockDir, hex.EncodeToString(sum[:])+".lock")
667 }
668
669 func ancestorDirectories(path string) []string {
670 path = compatibilityIdentityPath(path)
671 reversed := []string{path}
672 for current := path; ; {
673 parent := compatibilityIdentityPath(filepath.Dir(current))
674 if parent == current {
675 break
676 }
677 reversed = append(reversed, parent)
678 current = parent
679 }
680 out := make([]string, len(reversed))
681 for i := range reversed {
682 out[len(reversed)-1-i] = reversed[i]
683 }
684 return out
685 }
686
687 func orderedWorkspaceRoots(roots []string) []string {
688 seen := make(map[string]bool, len(roots))
689 out := make([]string, 0, len(roots))
690 for _, root := range roots {
691 root = compatibilityIdentityPath(root)
692 if root == "" || seen[root] {
693 continue
694 }
695 seen[root] = true
696 out = append(out, root)
697 }
698 sort.Slice(out, func(i, j int) bool {
699 leftDepth := len(ancestorDirectories(out[i]))
700 rightDepth := len(ancestorDirectories(out[j]))
701 if leftDepth == rightDepth {
702 return out[i] < out[j]
703 }
704 return leftDepth < rightDepth
705 })
706 return out
707 }
708
709 // compatibilityIdentityPath preserves the exact path bytes used by the
710 // previous workspace-lock protocol. New path and hierarchy stripes use
711 // normalizeIdentityPath instead, so macOS aliases still share those domains.
712 func compatibilityIdentityPath(path string) string {
713 path = filepath.Clean(path)
714 if runtime.GOOS == "windows" {
715 path = strings.ToLower(filepath.ToSlash(path))
716 }
717 return path
718 }
719
720 func normalizeIdentityPath(path string) string {
721 identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: true})
722 if err != nil {
723 return ""
724 }
725 return identity.Key
726 }
727
728 func lockIdentityKey(path string) (string, error) {
729 identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: false})
730 if err != nil {
731 return "", err
732 }
733 return identity.Key, nil
734 }
735
736 func (o *Owner) acquireMode(ctx context.Context, path string, mode filelock.Mode, notified *bool) (func(), error) {
737 localKey, err := lockIdentityKey(path)
738 if err != nil {
739 return nil, fmt.Errorf("resolve workspace lock identity: %w", err)
740 }
741 release, err := filelock.TryAcquireModeWithKey(path, localKey, mode)
742 if err == nil {
743 return release, nil
744 }
745 if !errors.Is(err, filelock.ErrHeld) {
746 return nil, fmt.Errorf("acquire workspace write lease: %w", err)
747 }
748 if o.markWaiting() && !*notified {
749 *notified = true
750 if o.onWait != nil {
751 o.onWait()
752 }
753 }
754 release, err = filelock.AcquireModeWithKey(ctx, path, localKey, mode)
755 if err != nil {
756 return nil, fmt.Errorf("acquire workspace write lease: %w", err)
757 }
758 return release, nil
759 }
760
761 func waitForSignal(ctx context.Context, signal <-chan struct{}) error {
762 select {
763 case <-signal:
764 return nil
765 case <-ctx.Done():
766 return ctx.Err()
767 }
768 }
769
770 func runReleases(releases []func()) {
771 for _, release := range slices.Backward(releases) {
772 release()
773 }
774 }
775
775 lines GO