Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/entire/cli/hooks_git_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ func newHooksGitPrepareCommitMsgCmd() *cobra.Command {
if g.skipUnsupportedCheckpointPolicy() {
return nil
}
// When an ACTIVE agent session lives in another git common dir,
// adopt it into this repo before trailer insertion (#1439).
// Skip the same cases PrepareCommitMsg skips (merge/squash/amend
// and rebase/cherry-pick/revert): adopting there would retire a
// live session with no trailer written.
if shouldTryAutoAdoptOnPrepareCommitMsg(g.ctx, source) {
tryAutoAdoptCrossCommonDirSession(g.ctx)
}
hookErr := g.strategy.PrepareCommitMsg(g.ctx, commitMsgFile, source)
g.logCompleted(hookErr)

Expand Down
98 changes: 98 additions & 0 deletions cmd/entire/cli/internal/flock/flock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package flock

import (
"context"
"path/filepath"
"testing"
"time"
)

func TestAcquireContext_Uncontended(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "test.lock")
release, err := AcquireContext(context.Background(), path)
if err != nil {
t.Fatalf("AcquireContext() error = %v, want nil", err)
}
release()
}

func TestAcquireContext_DeadlineExceededWhenContended(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "test.lock")
holderRelease, err := Acquire(path)
if err != nil {
t.Fatalf("Acquire() error = %v, want nil", err)
}
defer holderRelease()

ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

start := time.Now()
_, err = AcquireContext(ctx, path)
elapsed := time.Since(start)
if err == nil {
t.Fatal("AcquireContext() error = nil, want context deadline exceeded")
}
if err != context.DeadlineExceeded { //nolint:errorlint // AcquireContext returns ctx.Err() directly, not wrapped
t.Fatalf("AcquireContext() error = %v, want context.DeadlineExceeded", err)
}
if elapsed > time.Second {
t.Fatalf("AcquireContext() took %v, want bounded by ctx deadline", elapsed)
}
}

func TestAcquireContext_CanceledWhenContended(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "test.lock")
holderRelease, err := Acquire(path)
if err != nil {
t.Fatalf("Acquire() error = %v, want nil", err)
}
defer holderRelease()

ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(20*time.Millisecond, cancel)

_, err = AcquireContext(ctx, path)
if err != context.Canceled { //nolint:errorlint // AcquireContext returns ctx.Err() directly, not wrapped
t.Fatalf("AcquireContext() error = %v, want context.Canceled", err)
}
}

func TestAcquireContext_SucceedsAfterContendedLockReleases(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "test.lock")
holderRelease, err := Acquire(path)
if err != nil {
t.Fatalf("Acquire() error = %v, want nil", err)
}
time.AfterFunc(30*time.Millisecond, holderRelease)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

release, err := AcquireContext(ctx, path)
if err != nil {
t.Fatalf("AcquireContext() error = %v, want nil once contended lock releases", err)
}
release()
}

func TestAcquireContext_AlreadyDoneContext(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "test.lock")
ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := AcquireContext(ctx, path)
if err != context.Canceled { //nolint:errorlint // AcquireContext returns ctx.Err() directly, not wrapped
t.Fatalf("AcquireContext() error = %v, want context.Canceled", err)
}
}
47 changes: 47 additions & 0 deletions cmd/entire/cli/internal/flock/flock_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,27 @@
package flock

import (
"context"
"errors"
"fmt"
"os"
"syscall"
"time"
)

// acquirePollInterval is how often AcquireContext retries a non-blocking
// flock attempt while waiting for a contended lock to free up.
const acquirePollInterval = 10 * time.Millisecond

// Acquire takes an exclusive advisory lock on path, creating the file if
// needed. The returned release closes the file, which drops the flock.
// Callers must invoke release exactly once. The lock file persists between
// runs — flock state is held by the file descriptor, not by the inode on
// disk — so the lockfile contents are immaterial.
//
// Acquire blocks indefinitely on a contended lock and cannot be interrupted
// by context cancellation. Callers that need a bounded wait must use
// AcquireContext instead.
func Acquire(path string) (release func(), err error) {
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation
if err != nil {
Expand All @@ -28,3 +39,39 @@ func Acquire(path string) (release func(), err error) {
}
return func() { _ = f.Close() }, nil
}

// AcquireContext behaves like Acquire, except the wait for a contended lock
// is bounded by ctx: it repeatedly attempts a non-blocking flock
// (LOCK_EX|LOCK_NB) and polls at acquirePollInterval until either the lock
// is obtained or ctx is done, in which case it returns ctx.Err(). Use this
// instead of Acquire whenever the caller has a deadline that must bound
// lock acquisition — the blocking syscall.Flock(LOCK_EX) used by Acquire
// ignores context cancellation entirely.
func AcquireContext(ctx context.Context, path string) (release func(), err error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // canonical context cancellation
}
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation
if err != nil {
return nil, fmt.Errorf("open flock: %w", err)
}

ticker := time.NewTicker(acquirePollInterval)
defer ticker.Stop()
for {
flockErr := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) //nolint:gosec // file descriptors are non-negative; standard Go pattern for syscall.Flock
if flockErr == nil {
return func() { _ = f.Close() }, nil
}
if !errors.Is(flockErr, syscall.EWOULDBLOCK) && !errors.Is(flockErr, syscall.EAGAIN) {
_ = f.Close()
return nil, fmt.Errorf("flock: %w", flockErr)
}
select {
case <-ctx.Done():
_ = f.Close()
return nil, ctx.Err() //nolint:wrapcheck // canonical context cancellation
case <-ticker.C:
}
}
}
51 changes: 51 additions & 0 deletions cmd/entire/cli/internal/flock/flock_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,26 @@
package flock

import (
"context"
"errors"
"fmt"
"os"
"time"

"golang.org/x/sys/windows"
)

// acquirePollInterval is how often AcquireContext retries a non-blocking
// lock attempt while waiting for a contended lock to free up.
const acquirePollInterval = 10 * time.Millisecond

// Acquire takes an exclusive lock on path via Windows LockFileEx. The
// returned release unlocks and closes the file. Callers must invoke release
// exactly once.
//
// Acquire blocks indefinitely on a contended lock and cannot be interrupted
// by context cancellation. Callers that need a bounded wait must use
// AcquireContext instead.
func Acquire(path string) (release func(), err error) {
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation
if err != nil {
Expand All @@ -27,3 +38,43 @@ func Acquire(path string) (release func(), err error) {
_ = f.Close()
}, nil
}

// AcquireContext behaves like Acquire, except the wait for a contended lock
// is bounded by ctx: it repeatedly attempts a non-blocking LockFileEx
// (LOCKFILE_EXCLUSIVE_LOCK|LOCKFILE_FAIL_IMMEDIATELY) and polls at
// acquirePollInterval until either the lock is obtained or ctx is done, in
// which case it returns ctx.Err(). Use this instead of Acquire whenever the
// caller has a deadline that must bound lock acquisition.
func AcquireContext(ctx context.Context, path string) (release func(), err error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // canonical context cancellation
}
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation
if err != nil {
return nil, fmt.Errorf("open flock: %w", err)
}

ticker := time.NewTicker(acquirePollInterval)
defer ticker.Stop()
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
for {
overlapped := new(windows.Overlapped)
lockErr := windows.LockFileEx(windows.Handle(f.Fd()), flags, 0, 1, 0, overlapped)
if lockErr == nil {
return func() {
_ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped)
_ = f.Close()
}, nil
}
if !errors.Is(lockErr, windows.ERROR_LOCK_VIOLATION) && !errors.Is(lockErr, windows.ERROR_IO_PENDING) {
_ = f.Close()
return nil, fmt.Errorf("lock flock: %w", lockErr)
}
select {
case <-ctx.Done():
_ = f.Close()
return nil, ctx.Err() //nolint:wrapcheck // canonical context cancellation
case <-ticker.C:
}
}
}
22 changes: 12 additions & 10 deletions cmd/entire/cli/proclive/proclive.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,18 @@ const maxAncestorDepth = 12
// (node, bun, python) are deliberately absent — for several agents the runtime
// IS the long-lived agent, so treating it as transient would skip the real owner.
var transientNames = map[string]bool{
"entire": true,
"sh": true,
"bash": true,
"zsh": true,
"dash": true,
"fish": true,
"ash": true,
"ksh": true,
"env": true,
"go": true,
"entire": true,
"sh": true,
"bash": true,
"zsh": true,
"dash": true,
"fish": true,
"ash": true,
"ksh": true,
"env": true,
"go": true,
"git": true, // prepare-commit-msg / commit hooks run under git
"git.exe": true,
}

func isTransient(name string) bool {
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/proclive/proclive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestCheck_HostMismatchIsUnknown(t *testing.T) {

func TestIsTransient(t *testing.T) {
t.Parallel()
for _, name := range []string{"entire", "sh", "bash", "ZSH", " dash ", "Fish", "go"} {
for _, name := range []string{"entire", "sh", "bash", "ZSH", " dash ", "Fish", "go", "git", "Git.exe"} {
if !isTransient(name) {
t.Errorf("isTransient(%q) = false, want true", name)
}
Expand Down
Loading
Loading