detached f319fb4

fix(subagents): stabilize cross-platform persistence tests

commit
f319fb4249ba1b4b1e8a0bb297eaa629b5ca630c
author
brice · · 2026-08-05 04:38 UTC
parent
0958647
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
645645 }
646646
647647 func TestResumeFencesLiveReloadedWorker(t *testing.T) {
648+ if runtime.GOOS == "windows" {
649+ t.Skip("subagent inbox transport uses Unix-domain sockets")
650+ }
648651 root := t.TempDir()
649652 first := New(Config{
650653 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 (
66 "errors"
77 "os"
88 "path/filepath"
9+ "runtime"
910 "strings"
1011 "testing"
1112 "unicode/utf8"
@@ -145,7 +146,7 @@ func TestTurnResultReferencesAndPersistence(t *testing.T) {
145146 if err != nil {
146147 t.Fatal(err)
147148 }
148- if info.Mode().Perm() != 0o600 {
149+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
149150 t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())
150151 }
151152 }
@@ -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 {
9494 }
9595 pathAbs = filepath.Clean(pathAbs)
9696 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
99100 }
100- prefix := rootAbs + string(filepath.Separator)
101- return strings.HasPrefix(pathAbs, prefix)
101+ return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
102102 }
103103
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.
104109 func containmentPath(path string) (string, error) {
105110 absolute, err := filepath.Abs(path)
106111 if err != nil {
107112 return "", err
108113 }
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
111138 }
112- return filepath.Clean(absolute), nil
113139 }
114140
115141 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 true98+ 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 "", err112 return "", err
108 }113 }
109- if evaluated, evalErr := filepath.EvalSymlinks(absolute); evalErr == nil {114+ absolute = filepath.Clean(absolute)
110- return evaluated, nil115+
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 (
1010 "time"
1111 )
1212
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+
1329 func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) {
1430 root := t.TempDir()
1531 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) {
142142 }
143143
144144 func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) {
145+ if runtime.GOOS == "windows" {
146+ t.Skip("subagent inbox transport uses Unix-domain sockets")
147+ }
145148 const grace = 250 * time.Millisecond
146149 started := make(chan struct{})
147150 canceled := make(chan struct{})
@@ -209,7 +212,7 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T)
209212 if runtime.GOOS == "windows" {
210213 t.Skip("subagent inbox transport uses Unix-domain sockets")
211214 }
212- path := filepath.Join(t.TempDir(), "agent.sock")
215+ path := filepath.Join(shortSocketDir(t), "agent.sock")
213216 listener, err := Listen(path)
214217 if err != nil {
215218 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.Millisecond148 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close