diff --git a/internal/app/daemon/app_headless.go b/internal/app/daemon/app_headless.go index 136d9e05..c6cc9e86 100644 --- a/internal/app/daemon/app_headless.go +++ b/internal/app/daemon/app_headless.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "os" "strings" "time" @@ -189,7 +190,12 @@ func (a *App) startManagedHeadless(command control.DaemonCommand) []eventcontrac firstNonEmpty(command.WorkspaceKey, command.ThreadCWD), err, ) - return a.handleManagedHeadlessLaunchFailure(command, err, now) + // When the launch fails because the workspace directory no longer + // exists, the OS reports an opaque "directory name is invalid" error. + // Replace it with an explanatory message so the Feishu client learns + // the real cause instead of a generic failure. + launchErr := explainMissingWorkspaceLaunchError(command, workDir, err) + return a.handleManagedHeadlessLaunchFailure(command, launchErr, now) } a.managedHeadlessRuntime.Processes[command.InstanceID] = &headlessruntime.Process{ @@ -214,6 +220,30 @@ func (a *App) startManagedHeadless(command control.DaemonCommand) []eventcontrac return a.service.HandleHeadlessLaunchStarted(command.SurfaceSessionID, command.InstanceID, pid) } +// explainMissingWorkspaceLaunchError upgrades an opaque launch failure into an +// explanatory one when the cause is that the workspace directory no longer +// exists on disk (e.g. it was moved or deleted after the thread was created). +// On any other failure it returns the original error untouched. +func explainMissingWorkspaceLaunchError(command control.DaemonCommand, workDir string, err error) error { + workDir = strings.TrimSpace(workDir) + if workDir == "" { + return err + } + if info, statErr := os.Stat(workDir); statErr == nil && info.IsDir() { + return err + } + return agentproto.ErrorInfo{ + Code: "headless_workspace_dir_missing", + Layer: "daemon", + Stage: "headless_start", + Operation: "start_headless", + Message: fmt.Sprintf("工作区目录已不存在:%s,可能已被移动或删除。请重新选择目录,或重新接入该工作区。", workDir), + Details: err.Error(), + SurfaceSessionID: command.SurfaceSessionID, + ThreadID: command.ThreadID, + } +} + func (a *App) handleManagedHeadlessLaunchFailure(command control.DaemonCommand, err error, now time.Time) []eventcontract.Event { events := a.service.HandleHeadlessLaunchFailed(command.SurfaceSessionID, command.InstanceID, err) if !command.AutoRestore { diff --git a/internal/app/daemon/app_headless_workspace_missing_test.go b/internal/app/daemon/app_headless_workspace_missing_test.go new file mode 100644 index 00000000..d811e59a --- /dev/null +++ b/internal/app/daemon/app_headless_workspace_missing_test.go @@ -0,0 +1,57 @@ +package daemon + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" + "github.com/kxn/codex-remote-feishu/internal/core/control" +) + +func TestExplainMissingWorkspaceLaunchErrorReplacesOpaqueError(t *testing.T) { + t.Parallel() + + missingDir := filepath.Join(t.TempDir(), "deleted-workspace") + rawErr := errors.New(`fork/exec D:\Research\codex-remote-feishu\codex-remote.exe: The directory name is invalid.`) + + got := explainMissingWorkspaceLaunchError(control.DaemonCommand{ + SurfaceSessionID: "surface-1", + ThreadID: "thread-1", + }, missingDir, rawErr) + + var info agentproto.ErrorInfo + if !errors.As(got, &info) { + t.Fatalf("expected ErrorInfo, got %T: %v", got, got) + } + if info.Code != "headless_workspace_dir_missing" { + t.Fatalf("unexpected code: %q", info.Code) + } + if !strings.Contains(info.Message, missingDir) || !strings.Contains(info.Message, "工作区目录已不存在") { + t.Fatalf("message does not explain the missing directory: %q", info.Message) + } + if !strings.Contains(info.Details, "directory name is invalid") { + t.Fatalf("expected raw error preserved in details, got %q", info.Details) + } +} + +func TestExplainMissingWorkspaceLaunchErrorKeepsErrorWhenDirExists(t *testing.T) { + t.Parallel() + + existingDir := t.TempDir() + rawErr := errors.New("some other launch failure") + + got := explainMissingWorkspaceLaunchError(control.DaemonCommand{ + SurfaceSessionID: "surface-1", + }, existingDir, rawErr) + + if got != rawErr { + t.Fatalf("expected original error to be preserved when workspace exists, got %v", got) + } + + // An empty workdir is not a missing-directory case either. + if got := explainMissingWorkspaceLaunchError(control.DaemonCommand{}, "", rawErr); got != rawErr { + t.Fatalf("expected original error for empty workDir, got %v", got) + } +}