detached 3ef5f9b

fix(subagents): resolve child zot executable through PATH

commit
3ef5f9b79fb873e420e3e2c08b4f73d987b8880b
author
brice · · 2026-08-05 05:15 UTC
parent
3464d10
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +98 -2
M packages/agent/subagents/runner.go +58 -2
@@ -164,6 +164,54 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
164164 return args
165165 }
166166
167+// resolveSubagentExecutable finds a runnable zot binary without assuming a
168+// particular installation directory. A parent process can outlive the file
169+// it was started from (for example after a reinstall), so the current
170+// executable and argv[0] are tried first and PATH is used as the fallback.
171+// Keeping the lookup on PATH is important: it covers GOBIN, user-local bin
172+// directories, package-manager locations, and system installs without
173+// hard-coding any of them.
174+func resolveSubagentExecutable(self, argv0 string, lookPath func(string) (string, error)) (string, error) {
175+ if lookPath == nil {
176+ lookPath = exec.LookPath
177+ }
178+
179+ candidates := make([]string, 0, 5)
180+ addCandidate := func(candidate string) {
181+ if candidate == "" || candidate == "." {
182+ return
183+ }
184+ for _, existing := range candidates {
185+ if existing == candidate {
186+ return
187+ }
188+ }
189+ candidates = append(candidates, candidate)
190+ }
191+ addCandidate(self)
192+ addCandidate(argv0)
193+ if self != "" {
194+ addCandidate(filepath.Base(self))
195+ }
196+ if argv0 != "" {
197+ addCandidate(filepath.Base(argv0))
198+ }
199+ addCandidate("zot")
200+
201+ var lookupErrors []string
202+ for _, candidate := range candidates {
203+ resolved, err := lookPath(candidate)
204+ if err == nil {
205+ return resolved, nil
206+ }
207+ lookupErrors = append(lookupErrors, fmt.Sprintf("%q: %v", candidate, err))
208+ }
209+ if len(lookupErrors) == 0 {
210+ return "", fmt.Errorf("locate zot executable: no executable candidates")
211+ }
212+ return "", fmt.Errorf("locate zot executable: %s", strings.Join(lookupErrors, "; "))
213+}
214+
167215 func (r *execRunner) Run(ctx context.Context, sink Sink) error {
168216 // SessionPath resolution order:
169217 // 1. explicit r.SessionPath set by the test / caller
@@ -204,9 +252,17 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
204252
205253 args := r.Command
206254 if len(args) == 0 {
207- exe, err := os.Executable()
255+ self, selfErr := os.Executable()
256+ argv0 := ""
257+ if len(os.Args) > 0 {
258+ argv0 = os.Args[0]
259+ }
260+ exe, err := resolveSubagentExecutable(self, argv0, exec.LookPath)
208261 if err != nil {
209- return fmt.Errorf("locate self: %w", err)
262+ if selfErr != nil {
263+ return fmt.Errorf("locate zot executable (os.Executable: %v): %w", selfErr, err)
264+ }
265+ return err
210266 }
211267 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
212268 }
@@ -164,6 +164,54 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
164 return args164 return args
165 }165 }
166 166
167+// resolveSubagentExecutable finds a runnable zot binary without assuming a
168+// particular installation directory. A parent process can outlive the file
169+// it was started from (for example after a reinstall), so the current
170+// executable and argv[0] are tried first and PATH is used as the fallback.
171+// Keeping the lookup on PATH is important: it covers GOBIN, user-local bin
172+// directories, package-manager locations, and system installs without
173+// hard-coding any of them.
174+func resolveSubagentExecutable(self, argv0 string, lookPath func(string) (string, error)) (string, error) {
175+ if lookPath == nil {
176+ lookPath = exec.LookPath
177+ }
178+
179+ candidates := make([]string, 0, 5)
180+ addCandidate := func(candidate string) {
181+ if candidate == "" || candidate == "." {
182+ return
183+ }
184+ for _, existing := range candidates {
185+ if existing == candidate {
186+ return
187+ }
188+ }
189+ candidates = append(candidates, candidate)
190+ }
191+ addCandidate(self)
192+ addCandidate(argv0)
193+ if self != "" {
194+ addCandidate(filepath.Base(self))
195+ }
196+ if argv0 != "" {
197+ addCandidate(filepath.Base(argv0))
198+ }
199+ addCandidate("zot")
200+
201+ var lookupErrors []string
202+ for _, candidate := range candidates {
203+ resolved, err := lookPath(candidate)
204+ if err == nil {
205+ return resolved, nil
206+ }
207+ lookupErrors = append(lookupErrors, fmt.Sprintf("%q: %v", candidate, err))
208+ }
209+ if len(lookupErrors) == 0 {
210+ return "", fmt.Errorf("locate zot executable: no executable candidates")
211+ }
212+ return "", fmt.Errorf("locate zot executable: %s", strings.Join(lookupErrors, "; "))
213+}
214+
167 func (r *execRunner) Run(ctx context.Context, sink Sink) error {215 func (r *execRunner) Run(ctx context.Context, sink Sink) error {
168 // SessionPath resolution order:216 // SessionPath resolution order:
169 // 1. explicit r.SessionPath set by the test / caller217 // 1. explicit r.SessionPath set by the test / caller
@@ -204,9 +252,17 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
204 252
205 args := r.Command253 args := r.Command
206 if len(args) == 0 {254 if len(args) == 0 {
207- exe, err := os.Executable()255+ self, selfErr := os.Executable()
256+ argv0 := ""
257+ if len(os.Args) > 0 {
258+ argv0 = os.Args[0]
259+ }
260+ exe, err := resolveSubagentExecutable(self, argv0, exec.LookPath)
208 if err != nil {261 if err != nil {
209- return fmt.Errorf("locate self: %w", err)262+ if selfErr != nil {
263+ return fmt.Errorf("locate zot executable (os.Executable: %v): %w", selfErr, err)
264+ }
265+ return err
210 }266 }
211 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)267 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
212 }268 }
M packages/agent/subagents/runner_test.go +40 -0
@@ -194,6 +194,46 @@ func TestSubagentWorkerArgs(t *testing.T) {
194194 }
195195 }
196196
197+func TestResolveSubagentExecutablePrefersCurrentExecutable(t *testing.T) {
198+ var calls []string
199+ got, err := resolveSubagentExecutable("/current/bin/zot", "/old/bin/zot", func(candidate string) (string, error) {
200+ calls = append(calls, candidate)
201+ if candidate == "/current/bin/zot" {
202+ return candidate, nil
203+ }
204+ return "", os.ErrNotExist
205+ })
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ if got != "/current/bin/zot" {
210+ t.Fatalf("executable = %q, want current executable", got)
211+ }
212+ if len(calls) != 1 || calls[0] != "/current/bin/zot" {
213+ t.Fatalf("lookup calls = %v, want only the current executable", calls)
214+ }
215+}
216+
217+func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) {
218+ var calls []string
219+ got, err := resolveSubagentExecutable("/removed/.local/bin/zot", "/removed/.local/bin/zot", func(candidate string) (string, error) {
220+ calls = append(calls, candidate)
221+ if candidate == "zot" {
222+ return "/active/go/bin/zot", nil
223+ }
224+ return "", os.ErrNotExist
225+ })
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ if got != "/active/go/bin/zot" {
230+ t.Fatalf("executable = %q, want PATH fallback", got)
231+ }
232+ if len(calls) != 2 || calls[0] != "/removed/.local/bin/zot" || calls[1] != "zot" {
233+ t.Fatalf("lookup calls = %v, want stale path followed by PATH lookup", calls)
234+ }
235+}
236+
197237 func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
198238 args := subagentWorkerArgs(subagentWorkerArgsOpts{
199239 Exe: "/zot",
@@ -194,6 +194,46 @@ func TestSubagentWorkerArgs(t *testing.T) {
194 }194 }
195 }195 }
196 196
197+func TestResolveSubagentExecutablePrefersCurrentExecutable(t *testing.T) {
198+ var calls []string
199+ got, err := resolveSubagentExecutable("/current/bin/zot", "/old/bin/zot", func(candidate string) (string, error) {
200+ calls = append(calls, candidate)
201+ if candidate == "/current/bin/zot" {
202+ return candidate, nil
203+ }
204+ return "", os.ErrNotExist
205+ })
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ if got != "/current/bin/zot" {
210+ t.Fatalf("executable = %q, want current executable", got)
211+ }
212+ if len(calls) != 1 || calls[0] != "/current/bin/zot" {
213+ t.Fatalf("lookup calls = %v, want only the current executable", calls)
214+ }
215+}
216+
217+func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) {
218+ var calls []string
219+ got, err := resolveSubagentExecutable("/removed/.local/bin/zot", "/removed/.local/bin/zot", func(candidate string) (string, error) {
220+ calls = append(calls, candidate)
221+ if candidate == "zot" {
222+ return "/active/go/bin/zot", nil
223+ }
224+ return "", os.ErrNotExist
225+ })
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ if got != "/active/go/bin/zot" {
230+ t.Fatalf("executable = %q, want PATH fallback", got)
231+ }
232+ if len(calls) != 2 || calls[0] != "/removed/.local/bin/zot" || calls[1] != "zot" {
233+ t.Fatalf("lookup calls = %v, want stale path followed by PATH lookup", calls)
234+ }
235+}
236+
197 func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {237 func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
198 args := subagentWorkerArgs(subagentWorkerArgsOpts{238 args := subagentWorkerArgs(subagentWorkerArgsOpts{
199 Exe: "/zot",239 Exe: "/zot",

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 476

↑↓ moveenter openesc close