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 { | |||
| 229 | 229 | go func(extDir string) { | |
| 230 | 230 | defer wg.Done() | |
| 231 | 231 | if err := m.loadOne(ctx, extDir); err != nil { | |
| 232 | - | errCh <- fmt.Errorf("%s: %w", extDir, err) | |
| 232 | + | errCh <- err | |
| 233 | 233 | } | |
| 234 | 234 | }(j.dir) | |
| 235 | 235 | } | |
| @@ -506,7 +506,7 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error { | |||
| 506 | 506 | go func(extDir string) { | |
| 507 | 507 | defer wg.Done() | |
| 508 | 508 | if err := m.loadOne(ctx, extDir); err != nil { | |
| 509 | - | errCh <- fmt.Errorf("%s: %w", extDir, err) | |
| 509 | + | errCh <- err | |
| 510 | 510 | } | |
| 511 | 511 | }(abs) | |
| 512 | 512 | } | |
| @@ -727,7 +727,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 727 | 727 | return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err) | |
| 728 | 728 | } | |
| 729 | 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 | 736 | started = true | |
| 733 | 737 | 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 = true | 736 | started = true | ||
| 733 | ext.cmd = cmd | 737 | ext.cmd = cmd | ||
M packages/agent/extensions/manager_test.go +31 -0
| @@ -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 | 194 | func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) { | |
| 164 | 195 | if runtime.GOOS == "windows" { | |
| 165 | 196 | 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 { | |||
| 55 | 55 | } | |
| 56 | 56 | var out []string | |
| 57 | 57 | 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)) | |
| 66 | 72 | } | |
| 67 | - | out = append(out, theme.FG256(theme.Error, prefix+line)) | |
| 68 | 73 | } | |
| 69 | 74 | } | |
| 70 | 75 | return out | |
| @@ -55,16 +55,21 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string { | |||||
| 55 | } | 55 | } | ||
| 56 | var out []string | 56 | 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 := marker | 63 | + | if len(wrapped) == 0 { |
| 64 | - | if idx > 0 { | 64 | + | wrapped = []string{""} |
| 65 | - | prefix = indent | 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)) | |||
| 66 | } | 72 | } | ||
| 67 | - | out = append(out, theme.FG256(theme.Error, prefix+line)) | |||
| 68 | } | 73 | } | ||
| 69 | } | 74 | } | ||
| 70 | return out | 75 | return out | ||
M packages/agent/modes/reload_ext_test.go +15 -0
| @@ -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 | 53 | func TestFormatReloadStatusSuccess(t *testing.T) { | |
| 39 | 54 | msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3}) | |
| 40 | 55 | 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 { | ||