-
Notifications
You must be signed in to change notification settings - Fork 335
Improve support for sub-agents on same branch #1212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| //go:build unix | ||
|
|
||
| package checkpoint | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "syscall" | ||
| ) | ||
|
|
||
| // acquireShadowFlock takes an exclusive POSIX advisory lock on path. The | ||
| // returned release closes the file (which drops the flock). Callers must call | ||
| // release exactly once. The lock file persists between runs — flock state is | ||
| // held by the file descriptor, not the inode on disk. | ||
| // | ||
| // Mirrors strategy.acquireStateFileLock; duplicated rather than imported to | ||
| // avoid pulling the strategy package into checkpoint. | ||
| func acquireShadowFlock(path string) (release func(), err error) { | ||
| f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // path built from validated branch name | ||
| if err != nil { | ||
| return nil, fmt.Errorf("open shadow lock: %w", err) | ||
| } | ||
| if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { //nolint:gosec // file descriptors are non-negative; standard Go pattern for syscall.Flock | ||
| _ = f.Close() | ||
| return nil, fmt.Errorf("flock shadow lock: %w", err) | ||
| } | ||
| return func() { _ = f.Close() }, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| //go:build windows | ||
|
|
||
| package checkpoint | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "golang.org/x/sys/windows" | ||
| ) | ||
|
|
||
| // acquireShadowFlock takes an exclusive lock on path via Windows LockFileEx. | ||
| // The returned release unlocks and closes the file. Callers must call release | ||
| // exactly once. | ||
| // | ||
| // Mirrors strategy.acquireStateFileLock; duplicated rather than imported to | ||
| // avoid pulling the strategy package into checkpoint. | ||
| func acquireShadowFlock(path string) (release func(), err error) { | ||
| f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // path built from validated branch name | ||
| if err != nil { | ||
| return nil, fmt.Errorf("open shadow lock: %w", err) | ||
| } | ||
| overlapped := new(windows.Overlapped) | ||
| if err := windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped); err != nil { | ||
| _ = f.Close() | ||
| return nil, fmt.Errorf("lock shadow lock: %w", err) | ||
| } | ||
| return func() { | ||
| _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped) | ||
| _ = f.Close() | ||
| }, nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| package checkpoint | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "math/rand/v2" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/entireio/cli/cmd/entire/cli/session" | ||
|
|
||
| "github.com/go-git/go-git/v6/plumbing" | ||
| ) | ||
|
|
||
| // shadowBranchLockPath returns the per-shadow-branch flock file path. Lock | ||
| // files live in <git-common-dir>/entire-shadow-locks/ so they don't pollute | ||
| // the session-state directory. Branch names are slash-escaped because some | ||
| // filesystems disallow nested directory names that mirror the shadow branch | ||
| // hierarchy (entire/<hash>). | ||
| func shadowBranchLockPath(ctx context.Context, branchName string) (string, error) { | ||
| commonDir, err := session.GetGitCommonDir(ctx) | ||
| if err != nil { | ||
| return "", fmt.Errorf("get git common dir: %w", err) | ||
| } | ||
| lockDir := filepath.Join(commonDir, "entire-shadow-locks") | ||
| if err := os.MkdirAll(lockDir, 0o750); err != nil { | ||
| return "", fmt.Errorf("create shadow lock directory: %w", err) | ||
| } | ||
| safe := strings.ReplaceAll(branchName, "/", "_") | ||
| return filepath.Join(lockDir, safe+".lock"), nil | ||
| } | ||
|
|
||
| // withShadowBranchFlock acquires the per-shadow-branch flock, runs fn, and | ||
| // releases the flock. Serializes all WriteTemporary callers that target the | ||
| // same shadow branch — across goroutines AND across processes — so the CAS | ||
| // in casUpdateShadowBranchRef only sees external writers as contention. | ||
| func withShadowBranchFlock(ctx context.Context, branchName string, fn func() error) error { | ||
| path, err := shadowBranchLockPath(ctx, branchName) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| release, err := acquireShadowFlock(path) | ||
| if err != nil { | ||
| return fmt.Errorf("acquire shadow flock %s: %w", branchName, err) | ||
| } | ||
| defer release() | ||
| return fn() | ||
| } | ||
|
|
||
| // ErrShadowRefBusy is returned by casUpdateShadowBranchRef when the ref has | ||
| // moved since the caller read it. Callers retry with a fresh parent. | ||
| var ErrShadowRefBusy = errors.New("shadow branch ref moved (CAS mismatch)") | ||
|
|
||
| // shadowRefMaxRetries bounds the WriteTemporary retry loop so a pathologically | ||
| // hot shadow branch can't hang a hook indefinitely. 16 is well above the | ||
| // number of concurrent sessions we've ever observed; the wins-required-to- | ||
| // finish distribution is bounded by N (number of concurrent writers). | ||
| const shadowRefMaxRetries = 16 | ||
|
|
||
| // shadowRefMaxJitter is the upper bound for randomized backoff between CAS | ||
| // retries. Random jitter avoids thundering-herd retry patterns when many | ||
| // sessions hit the same shadow branch simultaneously. | ||
| const shadowRefMaxJitter = 8 * time.Millisecond | ||
|
|
||
| // zeroOID is the all-zeros object id that git accepts as the <oldvalue> | ||
| // argument to `git update-ref` to mean "must not exist". | ||
| const zeroOID = "0000000000000000000000000000000000000000" | ||
|
|
||
| // casUpdateShadowBranchRef atomically updates a shadow branch ref via | ||
| // `git update-ref <ref> <new> <old>`. Pass plumbing.ZeroHash as expectedHash | ||
| // to require the ref to NOT exist (first-checkpoint case). | ||
| // | ||
| // Returns ErrShadowRefBusy when git reports the ref moved since expectedHash | ||
| // was observed; callers retry with a fresh parent. Any other failure is | ||
| // returned wrapped. | ||
| // | ||
| // Why shell out: git's ref-locking is the canonical cross-process atomic | ||
| // CAS — go-git's CheckAndSetReference doesn't interoperate with native git's | ||
| // .lock files, and shadow branches can be touched concurrently by separate | ||
| // `entire` hook processes. | ||
| func casUpdateShadowBranchRef(ctx context.Context, branchName string, newHash, expectedHash plumbing.Hash) error { | ||
| refName := "refs/heads/" + branchName | ||
|
|
||
| oldValue := zeroOID | ||
| if expectedHash != plumbing.ZeroHash { | ||
| oldValue = expectedHash.String() | ||
| } | ||
|
|
||
| cmd := exec.CommandContext(ctx, "git", "update-ref", refName, newHash.String(), oldValue) | ||
|
pjbgf marked this conversation as resolved.
Outdated
|
||
| output, err := cmd.CombinedOutput() | ||
|
pjbgf marked this conversation as resolved.
|
||
| if err == nil { | ||
| return nil | ||
| } | ||
|
|
||
| out := string(output) | ||
| // Git's CAS-failure messages: "cannot lock ref ..." (covers both | ||
| // "is at X but expected Y" and "reference already exists" for the | ||
| // zero-OID case). Other failures propagate. | ||
| if strings.Contains(out, "cannot lock ref") || strings.Contains(out, "but expected") { | ||
| return ErrShadowRefBusy | ||
| } | ||
| return fmt.Errorf("git update-ref %s: %s: %w", refName, strings.TrimSpace(out), err) | ||
|
pjbgf marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // shadowRefBackoff sleeps for a small random jitter before the next CAS | ||
| // retry. After several retries the jitter doubles to slow the thundering | ||
| // herd further. Respects context cancellation. | ||
| func shadowRefBackoff(ctx context.Context, attempt int) error { | ||
| d := time.Duration(rand.Int64N(int64(shadowRefMaxJitter))) //nolint:gosec // jitter, not security-sensitive | ||
| if attempt > 4 { | ||
| d *= 2 | ||
| } | ||
| if d <= 0 { | ||
| d = time.Millisecond | ||
| } | ||
| select { | ||
| case <-time.After(d): | ||
| return nil | ||
| case <-ctx.Done(): | ||
| return ctx.Err() //nolint:wrapcheck // canonical context cancellation | ||
| } | ||
|
pjbgf marked this conversation as resolved.
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.