Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions internal/cli/exit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package main
import (
"context"
_ "embed"
"errors"
"fmt"
"os"

Expand Down Expand Up @@ -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)
}
Expand Down
Loading