detached 962dca9

fix extensions missing executable diagnostics

commit
962dca9f894b45e2a7f45454bd81a30f2c450363
author
Zeno Jiricek · · 2026-09-09 22:27 UTC
committer
Zeno Jiricek · · 2026-09-09 23:42 UTC
parent
5bef29b
signature
unsigned
4 files · +67 -12
M packages/agent/extensions/manager.go +7 -3
@@ -229,7 +229,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
229229 go func(extDir string) {
230230 defer wg.Done()
231231 if err := m.loadOne(ctx, extDir); err != nil {
232- errCh <- fmt.Errorf("%s: %w", extDir, err)
232+ errCh <- err
233233 }
234234 }(j.dir)
235235 }
@@ -506,7 +506,7 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
506506 go func(extDir string) {
507507 defer wg.Done()
508508 if err := m.loadOne(ctx, extDir); err != nil {
509- errCh <- fmt.Errorf("%s: %w", extDir, err)
509+ errCh <- err
510510 }
511511 }(abs)
512512 }
@@ -727,7 +727,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
727727 return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
728728 }
729729 if err := cmd.Start(); err != nil {
730- return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err)
730+ language := ""
731+ if ext.Manifest.Language != "" {
732+ language = fmt.Sprintf(" (declared language: %q)", ext.Manifest.Language)
733+ }
734+ return fmt.Errorf("Extension %s failed to start.\n\n exec: %q%s\n error: %w\n extension directory: %s\n stderr log: %s", ext.Manifest.Name, ext.Manifest.Exec, language, err, ext.Dir, logPath)
731735 }
732736 started = true
733737 ext.cmd = cmd
@@ -229,7 +229,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
229 go func(extDir string) {229 go func(extDir string) {
230 defer wg.Done()230 defer wg.Done()
231 if err := m.loadOne(ctx, extDir); err != nil {231 if err := m.loadOne(ctx, extDir); err != nil {
232- errCh <- fmt.Errorf("%s: %w", extDir, err)232+ errCh <- err
233 }233 }
234 }(j.dir)234 }(j.dir)
235 }235 }
@@ -506,7 +506,7 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
506 go func(extDir string) {506 go func(extDir string) {
507 defer wg.Done()507 defer wg.Done()
508 if err := m.loadOne(ctx, extDir); err != nil {508 if err := m.loadOne(ctx, extDir); err != nil {
509- errCh <- fmt.Errorf("%s: %w", extDir, err)509+ errCh <- err
510 }510 }
511 }(abs)511 }(abs)
512 }512 }
@@ -727,7 +727,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
727 return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)727 return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
728 }728 }
729 if err := cmd.Start(); err != nil {729 if err := cmd.Start(); err != nil {
730- return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err)730+ language := ""
731+ if ext.Manifest.Language != "" {
732+ language = fmt.Sprintf(" (declared language: %q)", ext.Manifest.Language)
733+ }
734+ return fmt.Errorf("Extension %s failed to start.\n\n exec: %q%s\n error: %w\n extension directory: %s\n stderr log: %s", ext.Manifest.Name, ext.Manifest.Exec, language, err, ext.Dir, logPath)
731 }735 }
732 started = true736 started = true
733 ext.cmd = cmd737 ext.cmd = cmd
M packages/agent/extensions/manager_test.go +31 -0
@@ -160,6 +160,37 @@ func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing
160160 }
161161 }
162162
163+func TestDiscoverReportsMissingRuntimeWhenExtensionCannotStart(t *testing.T) {
164+ tmp := t.TempDir()
165+ extDir := filepath.Join(tmp, "extensions", "missing-runtime")
166+ if err := os.MkdirAll(extDir, 0o755); err != nil {
167+ t.Fatal(err)
168+ }
169+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"missing-runtime","exec":"python3","language":"python"}`), 0o644); err != nil {
170+ t.Fatal(err)
171+ }
172+
173+ // Isolate PATH so this tests a missing language runtime rather than
174+ // relying on a made-up executable name that could theoretically exist.
175+ t.Setenv("PATH", filepath.Join(tmp, "empty-bin"))
176+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
177+ errs := mgr.Discover(context.Background())
178+ if len(errs) != 1 {
179+ t.Fatalf("discover errors = %v, want one", errs)
180+ }
181+ got := errs[0].Error()
182+ for _, want := range []string{
183+ `Extension missing-runtime failed to start.`,
184+ `exec: "python3"`,
185+ `declared language: "python"`,
186+ "executable file not found",
187+ } {
188+ if !strings.Contains(got, want) {
189+ t.Fatalf("error = %q, want it to contain %q", got, want)
190+ }
191+ }
192+}
193+
163194 func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
164195 if runtime.GOOS == "windows" {
165196 t.Skip("mock extension uses Unix signals; skip on windows")
@@ -160,6 +160,37 @@ func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing
160 }160 }
161 }161 }
162 162
163+func TestDiscoverReportsMissingRuntimeWhenExtensionCannotStart(t *testing.T) {
164+ tmp := t.TempDir()
165+ extDir := filepath.Join(tmp, "extensions", "missing-runtime")
166+ if err := os.MkdirAll(extDir, 0o755); err != nil {
167+ t.Fatal(err)
168+ }
169+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"missing-runtime","exec":"python3","language":"python"}`), 0o644); err != nil {
170+ t.Fatal(err)
171+ }
172+
173+ // Isolate PATH so this tests a missing language runtime rather than
174+ // relying on a made-up executable name that could theoretically exist.
175+ t.Setenv("PATH", filepath.Join(tmp, "empty-bin"))
176+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
177+ errs := mgr.Discover(context.Background())
178+ if len(errs) != 1 {
179+ t.Fatalf("discover errors = %v, want one", errs)
180+ }
181+ got := errs[0].Error()
182+ for _, want := range []string{
183+ `Extension missing-runtime failed to start.`,
184+ `exec: "python3"`,
185+ `declared language: "python"`,
186+ "executable file not found",
187+ } {
188+ if !strings.Contains(got, want) {
189+ t.Fatalf("error = %q, want it to contain %q", got, want)
190+ }
191+ }
192+}
193+
163 func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {194 func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
164 if runtime.GOOS == "windows" {195 if runtime.GOOS == "windows" {
165 t.Skip("mock extension uses Unix signals; skip on windows")196 t.Skip("mock extension uses Unix signals; skip on windows")
M packages/agent/modes/reload_ext.go +14 -9
@@ -55,16 +55,21 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
5555 }
5656 var out []string
5757 for _, msg := range errors {
58- wrapped := tui.WrapANSILine(msg, wrapWidth)
59- if len(wrapped) == 0 {
60- wrapped = []string{""}
61- }
62- for idx, line := range wrapped {
63- prefix := marker
64- if idx > 0 {
65- prefix = indent
58+ // Split explicit newlines before wrapping. Passing a multiline string
59+ // to WrapANSILine would leave the terminal to interpret the embedded
60+ // newline, which can misalign the continuation text.
61+ for lineIndex, paragraph := range strings.Split(msg, "\n") {
62+ wrapped := tui.WrapANSILine(paragraph, wrapWidth)
63+ if len(wrapped) == 0 {
64+ wrapped = []string{""}
65+ }
66+ for wrapIndex, line := range wrapped {
67+ prefix := indent
68+ if lineIndex == 0 && wrapIndex == 0 {
69+ prefix = marker
70+ }
71+ out = append(out, theme.FG256(theme.Error, prefix+line))
6672 }
67- out = append(out, theme.FG256(theme.Error, prefix+line))
6873 }
6974 }
7075 return out
@@ -55,16 +55,21 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
55 }55 }
56 var out []string56 var out []string
57 for _, msg := range errors {57 for _, msg := range errors {
58- wrapped := tui.WrapANSILine(msg, wrapWidth)58+ // Split explicit newlines before wrapping. Passing a multiline string
59- if len(wrapped) == 0 {59+ // to WrapANSILine would leave the terminal to interpret the embedded
60- wrapped = []string{""}60+ // newline, which can misalign the continuation text.
61- }61+ for lineIndex, paragraph := range strings.Split(msg, "\n") {
62- for idx, line := range wrapped {62+ wrapped := tui.WrapANSILine(paragraph, wrapWidth)
63- prefix := marker63+ if len(wrapped) == 0 {
64- if idx > 0 {64+ wrapped = []string{""}
65- prefix = indent65+ }
66+ for wrapIndex, line := range wrapped {
67+ prefix := indent
68+ if lineIndex == 0 && wrapIndex == 0 {
69+ prefix = marker
70+ }
71+ out = append(out, theme.FG256(theme.Error, prefix+line))
66 }72 }
67- out = append(out, theme.FG256(theme.Error, prefix+line))
68 }73 }
69 }74 }
70 return out75 return out
M packages/agent/modes/reload_ext_test.go +15 -0
@@ -35,6 +35,21 @@ func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {
3535 }
3636 }
3737
38+func TestRenderReloadErrorsPreservesMultilineAlignment(t *testing.T) {
39+ lines := renderReloadErrors(tui.Theme{Error: 1}, []string{"failed to start\nextension directory: /tmp/ext\nstderr log: /tmp/ext.log"}, 80)
40+ if len(lines) != 3 {
41+ t.Fatalf("rendered %d lines, want 3: %q", len(lines), lines)
42+ }
43+ if !strings.Contains(lines[0], "✖ failed to start") {
44+ t.Fatalf("first line = %q, want error marker", lines[0])
45+ }
46+ for _, line := range lines[1:] {
47+ if !strings.Contains(line, " ") {
48+ t.Fatalf("continuation line = %q, want indentation", line)
49+ }
50+ }
51+}
52+
3853 func TestFormatReloadStatusSuccess(t *testing.T) {
3954 msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3})
4055 if failed {
@@ -35,6 +35,21 @@ func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {
35 }35 }
36 }36 }
37 37
38+func TestRenderReloadErrorsPreservesMultilineAlignment(t *testing.T) {
39+ lines := renderReloadErrors(tui.Theme{Error: 1}, []string{"failed to start\nextension directory: /tmp/ext\nstderr log: /tmp/ext.log"}, 80)
40+ if len(lines) != 3 {
41+ t.Fatalf("rendered %d lines, want 3: %q", len(lines), lines)
42+ }
43+ if !strings.Contains(lines[0], "✖ failed to start") {
44+ t.Fatalf("first line = %q, want error marker", lines[0])
45+ }
46+ for _, line := range lines[1:] {
47+ if !strings.Contains(line, " ") {
48+ t.Fatalf("continuation line = %q, want indentation", line)
49+ }
50+ }
51+}
52+
38 func TestFormatReloadStatusSuccess(t *testing.T) {53 func TestFormatReloadStatusSuccess(t *testing.T) {
39 msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3})54 msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3})
40 if failed {55 if failed {

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