fix(subagents): stabilize cross-platform persistence tests
- commit
- f319fb4249ba1b4b1e8a0bb297eaa629b5ca630c
- author
- brice · · 2026-08-05 04:38 UTC
- parent
- 0958647
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +58 -9
M packages/agent/subagents/persist_test.go +3 -0
| @@ -645,6 +645,9 @@ func TestStopMustFinishBeforeResumeOrRemove(t *testing.T) { | |||
| 645 | 645 | } | |
| 646 | 646 | ||
| 647 | 647 | func TestResumeFencesLiveReloadedWorker(t *testing.T) { | |
| 648 | + | if runtime.GOOS == "windows" { | |
| 649 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |
| 650 | + | } | |
| 648 | 651 | root := t.TempDir() | |
| 649 | 652 | first := New(Config{ | |
| 650 | 653 | Root: root, RepoRoot: root, | |
| @@ -645,6 +645,9 @@ func TestStopMustFinishBeforeResumeOrRemove(t *testing.T) { | |||||
| 645 | } | 645 | } | ||
| 646 | 646 | ||||
| 647 | func TestResumeFencesLiveReloadedWorker(t *testing.T) { | 647 | func TestResumeFencesLiveReloadedWorker(t *testing.T) { | ||
| 648 | + | if runtime.GOOS == "windows" { | |||
| 649 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 650 | + | } | |||
| 648 | root := t.TempDir() | 651 | root := t.TempDir() | ||
| 649 | first := New(Config{ | 652 | first := New(Config{ | ||
| 650 | Root: root, RepoRoot: root, | 653 | Root: root, RepoRoot: root, | ||
M packages/agent/subagents/result_test.go +2 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "runtime" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "unicode/utf8" | |
| @@ -145,7 +146,7 @@ func TestTurnResultReferencesAndPersistence(t *testing.T) { | |||
| 145 | 146 | if err != nil { | |
| 146 | 147 | t.Fatal(err) | |
| 147 | 148 | } | |
| 148 | - | if info.Mode().Perm() != 0o600 { | |
| 149 | + | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { | |
| 149 | 150 | t.Fatalf("result permissions = %o, want 600", info.Mode().Perm()) | |
| 150 | 151 | } | |
| 151 | 152 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "runtime" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "testing" | 11 | "testing" | ||
| 11 | "unicode/utf8" | 12 | "unicode/utf8" | ||
| @@ -145,7 +146,7 @@ func TestTurnResultReferencesAndPersistence(t *testing.T) { | |||||
| 145 | if err != nil { | 146 | if err != nil { | ||
| 146 | t.Fatal(err) | 147 | t.Fatal(err) | ||
| 147 | } | 148 | } | ||
| 148 | - | if info.Mode().Perm() != 0o600 { | 149 | + | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { |
| 149 | t.Fatalf("result permissions = %o, want 600", info.Mode().Perm()) | 150 | t.Fatalf("result permissions = %o, want 600", info.Mode().Perm()) | ||
| 150 | } | 151 | } | ||
| 151 | } | 152 | } | ||
M packages/agent/subagents/scheduler.go +33 -7
| @@ -94,22 +94,48 @@ func pathWithin(path, root string) bool { | |||
| 94 | 94 | } | |
| 95 | 95 | pathAbs = filepath.Clean(pathAbs) | |
| 96 | 96 | rootAbs = filepath.Clean(rootAbs) | |
| 97 | - | if pathAbs == rootAbs { | |
| 98 | - | return true | |
| 97 | + | rel, err := filepath.Rel(rootAbs, pathAbs) | |
| 98 | + | if err != nil { | |
| 99 | + | return false | |
| 99 | 100 | } | |
| 100 | - | prefix := rootAbs + string(filepath.Separator) | |
| 101 | - | return strings.HasPrefix(pathAbs, prefix) | |
| 101 | + | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | // containmentPath returns a canonical path even when the final path does not | |
| 105 | + | // exist yet. EvalSymlinks cannot resolve a missing event or session file, so | |
| 106 | + | // resolve the nearest existing ancestor and append the missing components. | |
| 107 | + | // Without this, a symlinked temporary directory can make a valid child path | |
| 108 | + | // look outside its state directory on macOS and Windows. | |
| 104 | 109 | func containmentPath(path string) (string, error) { | |
| 105 | 110 | absolute, err := filepath.Abs(path) | |
| 106 | 111 | if err != nil { | |
| 107 | 112 | return "", err | |
| 108 | 113 | } | |
| 109 | - | if evaluated, evalErr := filepath.EvalSymlinks(absolute); evalErr == nil { | |
| 110 | - | return evaluated, nil | |
| 114 | + | absolute = filepath.Clean(absolute) | |
| 115 | + | ||
| 116 | + | current := absolute | |
| 117 | + | var missing []string | |
| 118 | + | for { | |
| 119 | + | if _, statErr := os.Lstat(current); statErr == nil { | |
| 120 | + | evaluated, evalErr := filepath.EvalSymlinks(current) | |
| 121 | + | if evalErr != nil { | |
| 122 | + | return "", evalErr | |
| 123 | + | } | |
| 124 | + | for i := len(missing) - 1; i >= 0; i-- { | |
| 125 | + | evaluated = filepath.Join(evaluated, missing[i]) | |
| 126 | + | } | |
| 127 | + | return filepath.Clean(evaluated), nil | |
| 128 | + | } else if !os.IsNotExist(statErr) { | |
| 129 | + | return "", statErr | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | parent := filepath.Dir(current) | |
| 133 | + | if parent == current { | |
| 134 | + | return absolute, nil | |
| 135 | + | } | |
| 136 | + | missing = append(missing, filepath.Base(current)) | |
| 137 | + | current = parent | |
| 111 | 138 | } | |
| 112 | - | return filepath.Clean(absolute), nil | |
| 113 | 139 | } | |
| 114 | 140 | ||
| 115 | 141 | func (f *Supervisor) schedule() { | |
| @@ -94,22 +94,48 @@ func pathWithin(path, root string) bool { | |||||
| 94 | } | 94 | } | ||
| 95 | pathAbs = filepath.Clean(pathAbs) | 95 | pathAbs = filepath.Clean(pathAbs) | ||
| 96 | rootAbs = filepath.Clean(rootAbs) | 96 | rootAbs = filepath.Clean(rootAbs) | ||
| 97 | - | if pathAbs == rootAbs { | 97 | + | rel, err := filepath.Rel(rootAbs, pathAbs) |
| 98 | - | return true | 98 | + | if err != nil { |
| 99 | + | return false | |||
| 99 | } | 100 | } | ||
| 100 | - | prefix := rootAbs + string(filepath.Separator) | 101 | + | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) |
| 101 | - | return strings.HasPrefix(pathAbs, prefix) | |||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | + | // containmentPath returns a canonical path even when the final path does not | |||
| 105 | + | // exist yet. EvalSymlinks cannot resolve a missing event or session file, so | |||
| 106 | + | // resolve the nearest existing ancestor and append the missing components. | |||
| 107 | + | // Without this, a symlinked temporary directory can make a valid child path | |||
| 108 | + | // look outside its state directory on macOS and Windows. | |||
| 104 | func containmentPath(path string) (string, error) { | 109 | func containmentPath(path string) (string, error) { | ||
| 105 | absolute, err := filepath.Abs(path) | 110 | absolute, err := filepath.Abs(path) | ||
| 106 | if err != nil { | 111 | if err != nil { | ||
| 107 | return "", err | 112 | return "", err | ||
| 108 | } | 113 | } | ||
| 109 | - | if evaluated, evalErr := filepath.EvalSymlinks(absolute); evalErr == nil { | 114 | + | absolute = filepath.Clean(absolute) |
| 110 | - | return evaluated, nil | 115 | + | |
| 116 | + | current := absolute | |||
| 117 | + | var missing []string | |||
| 118 | + | for { | |||
| 119 | + | if _, statErr := os.Lstat(current); statErr == nil { | |||
| 120 | + | evaluated, evalErr := filepath.EvalSymlinks(current) | |||
| 121 | + | if evalErr != nil { | |||
| 122 | + | return "", evalErr | |||
| 123 | + | } | |||
| 124 | + | for i := len(missing) - 1; i >= 0; i-- { | |||
| 125 | + | evaluated = filepath.Join(evaluated, missing[i]) | |||
| 126 | + | } | |||
| 127 | + | return filepath.Clean(evaluated), nil | |||
| 128 | + | } else if !os.IsNotExist(statErr) { | |||
| 129 | + | return "", statErr | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | parent := filepath.Dir(current) | |||
| 133 | + | if parent == current { | |||
| 134 | + | return absolute, nil | |||
| 135 | + | } | |||
| 136 | + | missing = append(missing, filepath.Base(current)) | |||
| 137 | + | current = parent | |||
| 111 | } | 138 | } | ||
| 112 | - | return filepath.Clean(absolute), nil | |||
| 113 | } | 139 | } | ||
| 114 | 140 | ||||
| 115 | func (f *Supervisor) schedule() { | 141 | func (f *Supervisor) schedule() { | ||
M packages/agent/subagents/scheduler_test.go +16 -0
| @@ -10,6 +10,22 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | func TestPathWithinResolvesMissingDescendantThroughSymlink(t *testing.T) { | |
| 14 | + | realRoot := t.TempDir() | |
| 15 | + | aliasParent := filepath.Join(t.TempDir(), "alias") | |
| 16 | + | if err := os.Symlink(realRoot, aliasParent); err != nil { | |
| 17 | + | t.Skipf("directory symlinks unavailable: %v", err) | |
| 18 | + | } | |
| 19 | + | stateDir := filepath.Join(aliasParent, "state") | |
| 20 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | missingFile := filepath.Join(stateDir, "events.jsonl") | |
| 24 | + | if !pathWithin(missingFile, stateDir) { | |
| 25 | + | t.Fatalf("pathWithin(%q, %q) = false; want true", missingFile, stateDir) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 13 | 29 | func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) { | |
| 14 | 30 | root := t.TempDir() | |
| 15 | 31 | started := make(chan string, 3) | |
| @@ -10,6 +10,22 @@ import ( | |||||
| 10 | "time" | 10 | "time" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | func TestPathWithinResolvesMissingDescendantThroughSymlink(t *testing.T) { | |||
| 14 | + | realRoot := t.TempDir() | |||
| 15 | + | aliasParent := filepath.Join(t.TempDir(), "alias") | |||
| 16 | + | if err := os.Symlink(realRoot, aliasParent); err != nil { | |||
| 17 | + | t.Skipf("directory symlinks unavailable: %v", err) | |||
| 18 | + | } | |||
| 19 | + | stateDir := filepath.Join(aliasParent, "state") | |||
| 20 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | missingFile := filepath.Join(stateDir, "events.jsonl") | |||
| 24 | + | if !pathWithin(missingFile, stateDir) { | |||
| 25 | + | t.Fatalf("pathWithin(%q, %q) = false; want true", missingFile, stateDir) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 13 | func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) { | 29 | func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) { | ||
| 14 | root := t.TempDir() | 30 | root := t.TempDir() | ||
| 15 | started := make(chan string, 3) | 31 | started := make(chan string, 3) | ||
M packages/agent/subagents/supervisor_test.go +4 -1
| @@ -142,6 +142,9 @@ func TestStopCancelsRunningAgent(t *testing.T) { | |||
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) { | |
| 145 | + | if runtime.GOOS == "windows" { | |
| 146 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |
| 147 | + | } | |
| 145 | 148 | const grace = 250 * time.Millisecond | |
| 146 | 149 | started := make(chan struct{}) | |
| 147 | 150 | canceled := make(chan struct{}) | |
| @@ -209,7 +212,7 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) | |||
| 209 | 212 | if runtime.GOOS == "windows" { | |
| 210 | 213 | t.Skip("subagent inbox transport uses Unix-domain sockets") | |
| 211 | 214 | } | |
| 212 | - | path := filepath.Join(t.TempDir(), "agent.sock") | |
| 215 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") | |
| 213 | 216 | listener, err := Listen(path) | |
| 214 | 217 | if err != nil { | |
| 215 | 218 | t.Fatal(err) | |
| @@ -142,6 +142,9 @@ func TestStopCancelsRunningAgent(t *testing.T) { | |||||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) { | 144 | func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) { | ||
| 145 | + | if runtime.GOOS == "windows" { | |||
| 146 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 147 | + | } | |||
| 145 | const grace = 250 * time.Millisecond | 148 | const grace = 250 * time.Millisecond | ||
| 146 | started := make(chan struct{}) | 149 | started := make(chan struct{}) | ||
| 147 | canceled := make(chan struct{}) | 150 | canceled := make(chan struct{}) | ||
| @@ -209,7 +212,7 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) | |||||
| 209 | if runtime.GOOS == "windows" { | 212 | if runtime.GOOS == "windows" { | ||
| 210 | t.Skip("subagent inbox transport uses Unix-domain sockets") | 213 | t.Skip("subagent inbox transport uses Unix-domain sockets") | ||
| 211 | } | 214 | } | ||
| 212 | - | path := filepath.Join(t.TempDir(), "agent.sock") | 215 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") |
| 213 | listener, err := Listen(path) | 216 | listener, err := Listen(path) | ||
| 214 | if err != nil { | 217 | if err != nil { | ||
| 215 | t.Fatal(err) | 218 | t.Fatal(err) | ||