Skip to content

Commit f318d9d

Browse files
authored
Surface setup failures in CLI when sandbox connection polling completes (#485)
1 parent 47b4024 commit f318d9d

File tree

7 files changed

+264
-2
lines changed

7 files changed

+264
-2
lines changed

cmd/rwx/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ func classifyError(err error) string {
104104
return "ambiguous_task_key"
105105
case errors.Is(err, internalerrors.ErrNetworkTransient):
106106
return "network_transient_error"
107+
case errors.Is(err, internalerrors.ErrSandboxSetupFailure):
108+
return "sandbox_setup_failure"
107109
default:
108110
return "unknown"
109111
}

internal/api/sandbox_connection_info.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ type SandboxConnectionInfo struct {
66
PublicHostKey string `json:"public_host_key"`
77
PrivateUserKey string `json:"private_user_key"`
88
Polling PollingResult `json:"polling"`
9+
FailureReason string `json:"failure_reason,omitempty"`
910
}
1011

1112
type SandboxConnectionInfoError struct {

internal/cli/service_sandbox.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,7 @@ func (s Service) waitForSandboxReadyWithToken(runID, scopedToken string, jsonMod
15101510
// Check once before showing spinner - sandbox may already be ready
15111511
connInfo, err := s.APIClient.GetSandboxConnectionInfo(runID, scopedToken)
15121512
if err != nil {
1513+
s.printSandboxRunPrompt(runID)
15131514
return nil, errors.Wrap(err, "unable to get sandbox connection info")
15141515
}
15151516

@@ -1518,7 +1519,7 @@ func (s Service) waitForSandboxReadyWithToken(runID, scopedToken string, jsonMod
15181519
}
15191520

15201521
if connInfo.Polling.Completed {
1521-
return nil, fmt.Errorf("Sandbox run '%s' completed before becoming ready", runID)
1522+
return nil, s.sandboxCompletedError(runID, connInfo)
15221523
}
15231524

15241525
// Sandbox not ready yet - start spinner and poll
@@ -1538,6 +1539,7 @@ func (s Service) waitForSandboxReadyWithToken(runID, scopedToken string, jsonMod
15381539

15391540
connInfo, err = s.APIClient.GetSandboxConnectionInfo(runID, scopedToken)
15401541
if err != nil {
1542+
s.printSandboxRunPrompt(runID)
15411543
return nil, errors.Wrap(err, "unable to get sandbox connection info")
15421544
}
15431545

@@ -1546,11 +1548,36 @@ func (s Service) waitForSandboxReadyWithToken(runID, scopedToken string, jsonMod
15461548
}
15471549

15481550
if connInfo.Polling.Completed {
1549-
return nil, fmt.Errorf("Sandbox run '%s' completed before becoming ready", runID)
1551+
return nil, s.sandboxCompletedError(runID, connInfo)
15501552
}
15511553
}
15521554
}
15531555

1556+
// sandboxCompletedError prints run failure output to stderr and returns an appropriate error.
1557+
// The prompt fetch is best-effort and silently skipped if unavailable.
1558+
func (s Service) sandboxCompletedError(runID string, connInfo api.SandboxConnectionInfo) error {
1559+
s.printSandboxRunPrompt(runID)
1560+
1561+
switch connInfo.FailureReason {
1562+
case "timed_out":
1563+
return errors.WrapSentinel(fmt.Errorf("Sandbox run '%s' timed out before becoming ready", runID), errors.ErrSandboxSetupFailure)
1564+
case "cancelled":
1565+
return errors.WrapSentinel(fmt.Errorf("Sandbox run '%s' was cancelled before becoming ready", runID), errors.ErrSandboxSetupFailure)
1566+
case "failed":
1567+
return errors.WrapSentinel(fmt.Errorf("Sandbox run '%s' failed before becoming ready", runID), errors.ErrSandboxSetupFailure)
1568+
default:
1569+
return errors.WrapSentinel(fmt.Errorf("Sandbox run '%s' completed before becoming ready", runID), errors.ErrSandboxSetupFailure)
1570+
}
1571+
}
1572+
1573+
// printSandboxRunPrompt fetches and prints the run prompt to stderr.
1574+
// Best-effort: silently skipped if the prompt is unavailable or the run is still in progress.
1575+
func (s Service) printSandboxRunPrompt(runID string) {
1576+
if prompt, err := s.APIClient.GetRunPrompt(runID); err == nil && prompt != "" {
1577+
fmt.Fprintf(s.Stderr, "\n%s", prompt)
1578+
}
1579+
}
1580+
15541581
func (s Service) connectSSH(connInfo *api.SandboxConnectionInfo) error {
15551582
privateUserKey, err := ssh.ParsePrivateKey([]byte(connInfo.PrivateUserKey))
15561583
if err != nil {

internal/cli/service_sandbox_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,202 @@ func TestService_ExecSandbox(t *testing.T) {
644644
require.Contains(t, err.Error(), "completed before becoming ready")
645645
})
646646

647+
t.Run("prints run failure output to stderr on polling completion", func(t *testing.T) {
648+
setup := setupTest(t)
649+
650+
runID := "run-setup-failed"
651+
652+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
653+
return api.SandboxConnectionInfo{
654+
Sandboxable: false,
655+
Polling: api.PollingResult{Completed: true},
656+
}, nil
657+
}
658+
659+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
660+
require.Equal(t, runID, id)
661+
return "# Failed task:\n\n- setup\n", nil
662+
}
663+
664+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
665+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
666+
Command: []string{"echo", "hello"},
667+
RunID: runID,
668+
Json: true,
669+
})
670+
671+
require.Error(t, err)
672+
require.Contains(t, err.Error(), "completed before becoming ready")
673+
require.Contains(t, setup.mockStderr.String(), "Failed task")
674+
})
675+
676+
t.Run("prints run failure output to stderr when polling loop completes", func(t *testing.T) {
677+
setup := setupTest(t)
678+
679+
runID := "run-polling-failed"
680+
calls := atomic.Int32{}
681+
backoff := 0
682+
683+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
684+
if calls.Add(1) == 1 {
685+
return api.SandboxConnectionInfo{
686+
Sandboxable: false,
687+
Polling: api.PollingResult{Completed: false, BackoffMs: &backoff},
688+
}, nil
689+
}
690+
return api.SandboxConnectionInfo{
691+
Sandboxable: false,
692+
Polling: api.PollingResult{Completed: true},
693+
}, nil
694+
}
695+
696+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
697+
require.Equal(t, runID, id)
698+
return "# Failed task:\n\n- setup\n", nil
699+
}
700+
701+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
702+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
703+
Command: []string{"echo", "hello"},
704+
RunID: runID,
705+
Json: true,
706+
})
707+
708+
require.Error(t, err)
709+
require.Contains(t, err.Error(), "completed before becoming ready")
710+
require.Contains(t, setup.mockStderr.String(), "Failed task")
711+
})
712+
713+
t.Run("gracefully degrades when GetRunPrompt fails on polling completion", func(t *testing.T) {
714+
setup := setupTest(t)
715+
716+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
717+
return api.SandboxConnectionInfo{
718+
Sandboxable: false,
719+
Polling: api.PollingResult{Completed: true},
720+
}, nil
721+
}
722+
723+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
724+
return "", errors.New("server unavailable")
725+
}
726+
727+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
728+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
729+
Command: []string{"echo", "hello"},
730+
RunID: "run-no-prompt",
731+
Json: true,
732+
})
733+
734+
require.Error(t, err)
735+
require.Contains(t, err.Error(), "completed before becoming ready")
736+
require.Empty(t, setup.mockStderr.String())
737+
})
738+
739+
t.Run("prints run failure output to stderr when GetSandboxConnectionInfo returns an error", func(t *testing.T) {
740+
setup := setupTest(t)
741+
742+
runID := "run-conn-error"
743+
744+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
745+
return api.SandboxConnectionInfo{}, errors.New("This run or task is no longer available for sandbox")
746+
}
747+
748+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
749+
require.Equal(t, runID, id)
750+
return "# Failed task:\n\n- preflight\n", nil
751+
}
752+
753+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
754+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
755+
Command: []string{"echo", "hello"},
756+
RunID: runID,
757+
Json: true,
758+
})
759+
760+
require.Error(t, err)
761+
require.Contains(t, err.Error(), "unable to get sandbox connection info")
762+
require.Contains(t, setup.mockStderr.String(), "Failed task")
763+
})
764+
765+
t.Run("uses timed_out FailureReason for natural error message", func(t *testing.T) {
766+
setup := setupTest(t)
767+
768+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
769+
return api.SandboxConnectionInfo{
770+
Sandboxable: false,
771+
Polling: api.PollingResult{Completed: true},
772+
FailureReason: "timed_out",
773+
}, nil
774+
}
775+
776+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
777+
return "", nil
778+
}
779+
780+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
781+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
782+
Command: []string{"echo", "hello"},
783+
RunID: "run-timed-out",
784+
Json: true,
785+
})
786+
787+
require.Error(t, err)
788+
require.Contains(t, err.Error(), "timed out before becoming ready")
789+
})
790+
791+
t.Run("uses cancelled FailureReason for natural error message", func(t *testing.T) {
792+
setup := setupTest(t)
793+
794+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
795+
return api.SandboxConnectionInfo{
796+
Sandboxable: false,
797+
Polling: api.PollingResult{Completed: true},
798+
FailureReason: "cancelled",
799+
}, nil
800+
}
801+
802+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
803+
return "", nil
804+
}
805+
806+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
807+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
808+
Command: []string{"echo", "hello"},
809+
RunID: "run-cancelled",
810+
Json: true,
811+
})
812+
813+
require.Error(t, err)
814+
require.Contains(t, err.Error(), "was cancelled before becoming ready")
815+
})
816+
817+
t.Run("uses failed FailureReason for natural error message", func(t *testing.T) {
818+
setup := setupTest(t)
819+
820+
setup.mockAPI.MockGetSandboxConnectionInfo = func(id, token string) (api.SandboxConnectionInfo, error) {
821+
return api.SandboxConnectionInfo{
822+
Sandboxable: false,
823+
Polling: api.PollingResult{Completed: true},
824+
FailureReason: "failed",
825+
}, nil
826+
}
827+
828+
setup.mockAPI.MockGetRunPrompt = func(id string) (string, error) {
829+
return "", nil
830+
}
831+
832+
_, err := setup.service.ExecSandbox(cli.ExecSandboxConfig{
833+
ConfigFile: setup.absConfig(".rwx/sandbox.yml"),
834+
Command: []string{"echo", "hello"},
835+
RunID: "run-failed",
836+
Json: true,
837+
})
838+
839+
require.Error(t, err)
840+
require.Contains(t, err.Error(), "failed before becoming ready")
841+
})
842+
647843
t.Run("shows firewall hint when SSH connection times out", func(t *testing.T) {
648844
setup := setupTest(t)
649845

internal/errors/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ var (
6464
ErrGone = errors.New("gone")
6565
ErrRetry = errors.New("retry")
6666
ErrSandboxNoGitDir = errors.New("no .git directory found in sandbox. Set 'preserve-git-dir: true' on your git/clone task")
67+
ErrSandboxSetupFailure = errors.New("sandbox setup failure")
6768
ErrSSH = errors.New("ssh error")
6869
ErrPatch = errors.New("patch error")
6970
ErrTimeout = errors.New("timeout")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
tasks:
2+
- key: preflight
3+
run: |
4+
echo "intentional-setup-failure"
5+
exit 1
6+
7+
- key: sandbox
8+
use: preflight
9+
run: rwx-sandbox
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env bash
2+
# Verifies that setup failure output is surfaced in stderr when a sandbox run
3+
# fails before reaching the sandbox task.
4+
set -euo pipefail
5+
6+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7+
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
8+
RWX_CLI="${REPO_ROOT}/rwx"
9+
10+
stderr_output=$("${RWX_CLI}" sandbox exec \
11+
"${SCRIPT_DIR}/definitions/sandbox-setup-failure.yml" \
12+
-- echo hello 2>&1 >/dev/null) || true
13+
14+
if ! echo "$stderr_output" | grep -q "Failed task"; then
15+
echo "ERROR: Expected setup failure prompt in stderr but did not find it"
16+
echo "stderr was: $stderr_output"
17+
exit 1
18+
fi
19+
20+
if ! echo "$stderr_output" | grep -q "preflight"; then
21+
echo "ERROR: Expected failing task name in stderr but did not find it"
22+
echo "stderr was: $stderr_output"
23+
exit 1
24+
fi
25+
26+
echo "OK: setup failure prompt was surfaced in stderr"

0 commit comments

Comments
 (0)