From afb6115ad7dd9f803dff9eabab272d7b933f3b26 Mon Sep 17 00:00:00 2001 From: imskull Date: Sat, 6 Jun 2026 16:49:09 +0800 Subject: [PATCH] fix(headless): explain missing workspace dir on switch failure When switching to a workspace whose directory was moved or deleted, the managed headless launch reaches exec and the OS rejects it with an opaque "The directory name is invalid." error, which surfaces to the Feishu client as a generic switch failure with no explanation. Map that launch failure to an explanatory ErrorInfo (headless_workspace_dir_missing) when the workspace directory no longer exists, telling the user the directory is gone and to reselect/re-import it, while preserving the raw OS error in Details. The success path is untouched. --- internal/app/daemon/app_headless.go | 32 ++++++++++- .../app_headless_workspace_missing_test.go | 57 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 internal/app/daemon/app_headless_workspace_missing_test.go 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) + } +}