diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6652587..24ace50 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -596,7 +596,7 @@ func execFork(ctx context.Context, src session.Source, model string, stdin io.Re cmd.Stdin = stdin cmd.Stdout = stdout cmd.Stderr = stderr - return cmd.Run() + return launchError(cmd.Run()) } type intoRunner func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error @@ -608,7 +608,35 @@ func execInto(ctx context.Context, name string, args []string, stdin io.Reader, cmd.Stdin = stdin cmd.Stdout = stdout cmd.Stderr = stderr - return cmd.Run() + return launchError(cmd.Run()) +} + +// ExitError is a launched agent's own exit status on its way out to main, +// which exits with the same number. +// +// A fork hands the terminal to another program, so that program's failure is +// not catchup's: it has already printed whatever it wanted to say, and the +// only thing left to carry is the code a caller's script reads. Reporting it +// as a catchup error instead — the "exit status 3" line this replaces, on top +// of a flattened exit 1 — both hid which program failed and told a wrapper +// that catchup had. +type ExitError struct{ Code int } + +func (e *ExitError) Error() string { return fmt.Sprintf("exit status %d", e.Code) } + +// launchError converts the result of running a launched agent. Only a clean +// non-zero exit becomes an ExitError; a failure to start it at all — a binary +// that is not on PATH, an argv the OS refuses — is catchup's own error and +// keeps its message. A signal leaves no code to pass on, so it stays a plain +// failure. +func launchError(err error) error { + var exit *exec.ExitError + if errors.As(err, &exit) { + if code := exit.ExitCode(); code > 0 { + return &ExitError{Code: code} + } + } + return err } // forkInto is the cross-agent half of fork: it cannot transplant one agent's diff --git a/internal/cli/exit_test.go b/internal/cli/exit_test.go new file mode 100644 index 0000000..08e5f00 --- /dev/null +++ b/internal/cli/exit_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "errors" + "os/exec" + "runtime" + "strconv" + "testing" +) + +// exitCmd is a command that exits with code and nothing else — the shortest +// way to get a genuine *exec.ExitError from the OS rather than a hand-built +// stand-in that could not prove the conversion works. +func exitCmd(code int) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/c", "exit", strconv.Itoa(code)) + } + return exec.Command("sh", "-c", "exit "+strconv.Itoa(code)) +} + +func TestLaunchErrorCarriesTheAgentsExitCode(t *testing.T) { + var exit *ExitError + got := launchError(exitCmd(3).Run()) + if !errors.As(got, &exit) { + t.Fatalf("a launched agent's non-zero exit must survive as an ExitError, got %v", got) + } + if exit.Code != 3 { + t.Errorf("want the agent's own code 3, got %d", exit.Code) + } +} + +func TestLaunchErrorLeavesCatchupsOwnFailuresAlone(t *testing.T) { + // Failing to start the agent at all is catchup's error to report: there + // is no exit status, because nothing ran. + notFound := exec.Command("catchup-no-such-agent-binary").Run() + if notFound == nil { + t.Fatal("expected the missing binary to fail") + } + if got := launchError(notFound); !errors.Is(got, notFound) { + t.Errorf("a failure to launch must keep its own message, got %v", got) + } + // A clean exit is not an error, and must not become one. + if got := launchError(exitCmd(0).Run()); got != nil { + t.Errorf("want nil for a successful agent, got %v", got) + } +} diff --git a/main.go b/main.go index 5558285..6349269 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ package main import ( "context" _ "embed" + "errors" "fmt" "os" @@ -40,6 +41,14 @@ func main() { skillDirs := session.ResolveSkillDirs(roots, home) if err := cli.Run(ctx, os.Args[1:], roots, current, skillDirs, skillMD, version, cwd, os.Stdin, os.Stdout, os.Stderr); err != nil { + // A fork replaces this terminal's occupant with another agent, so + // that agent's exit status is the run's answer and is passed through + // unannounced: it has already reported itself, and a wrapper reading + // the code needs the number it actually returned. + var exit *cli.ExitError + if errors.As(err, &exit) { + os.Exit(exit.Code) + } fmt.Fprintln(os.Stderr, "catchup:", err) os.Exit(1) }