diff --git a/.agents/skills/uloop-launch/SKILL.md b/.agents/skills/uloop-launch/SKILL.md index c86f117b60..3530881aad 100644 --- a/.agents/skills/uloop-launch/SKILL.md +++ b/.agents/skills/uloop-launch/SKILL.md @@ -49,12 +49,19 @@ uloop launch --quit - Prints detected Unity version - Prints project path -- If Unity is already running, focuses the existing window -- If launching, prints when it is waiting for Unity CLI Loop server readiness -- If launching, waits until Unity finishes startup and the CLI can connect to the project -- When launch readiness completes, returns JSON with: - - `Success`: whether launch readiness completed +- If Unity is already running, focuses the existing window and verifies tool readiness +- If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness +- If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project +- Successful launch, restart, existing-process, and quit paths return JSON with: + - `Success`: whether the command completed - `Ready`: whether Unity CLI Loop is ready for commands - `ServerReady`: whether the Unity CLI Loop server accepted requests - `ProjectIpcReady`: whether the project IPC path accepted tool requests + - `AlreadyRunning`: whether an existing Unity process was reused + - `Launched`: whether this command launched a Unity process + - `Restarted`: whether this command stopped an existing process and launched a new one + - `Quit`: whether this command stopped Unity without launching a new process + - `PreviousProcessId`: process ID stopped by restart or quit, when available + - `CurrentProcessId`: current Unity process ID, when available + - `ProjectRoot`: resolved project root - `Message`: readiness summary diff --git a/.claude/skills/uloop-launch/SKILL.md b/.claude/skills/uloop-launch/SKILL.md index c86f117b60..3530881aad 100644 --- a/.claude/skills/uloop-launch/SKILL.md +++ b/.claude/skills/uloop-launch/SKILL.md @@ -49,12 +49,19 @@ uloop launch --quit - Prints detected Unity version - Prints project path -- If Unity is already running, focuses the existing window -- If launching, prints when it is waiting for Unity CLI Loop server readiness -- If launching, waits until Unity finishes startup and the CLI can connect to the project -- When launch readiness completes, returns JSON with: - - `Success`: whether launch readiness completed +- If Unity is already running, focuses the existing window and verifies tool readiness +- If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness +- If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project +- Successful launch, restart, existing-process, and quit paths return JSON with: + - `Success`: whether the command completed - `Ready`: whether Unity CLI Loop is ready for commands - `ServerReady`: whether the Unity CLI Loop server accepted requests - `ProjectIpcReady`: whether the project IPC path accepted tool requests + - `AlreadyRunning`: whether an existing Unity process was reused + - `Launched`: whether this command launched a Unity process + - `Restarted`: whether this command stopped an existing process and launched a new one + - `Quit`: whether this command stopped Unity without launching a new process + - `PreviousProcessId`: process ID stopped by restart or quit, when available + - `CurrentProcessId`: current Unity process ID, when available + - `ProjectRoot`: resolved project root - `Message`: readiness summary diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index c86f117b60..3530881aad 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -49,12 +49,19 @@ uloop launch --quit - Prints detected Unity version - Prints project path -- If Unity is already running, focuses the existing window -- If launching, prints when it is waiting for Unity CLI Loop server readiness -- If launching, waits until Unity finishes startup and the CLI can connect to the project -- When launch readiness completes, returns JSON with: - - `Success`: whether launch readiness completed +- If Unity is already running, focuses the existing window and verifies tool readiness +- If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness +- If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project +- Successful launch, restart, existing-process, and quit paths return JSON with: + - `Success`: whether the command completed - `Ready`: whether Unity CLI Loop is ready for commands - `ServerReady`: whether the Unity CLI Loop server accepted requests - `ProjectIpcReady`: whether the project IPC path accepted tool requests + - `AlreadyRunning`: whether an existing Unity process was reused + - `Launched`: whether this command launched a Unity process + - `Restarted`: whether this command stopped an existing process and launched a new one + - `Quit`: whether this command stopped Unity without launching a new process + - `PreviousProcessId`: process ID stopped by restart or quit, when available + - `CurrentProcessId`: current Unity process ID, when available + - `ProjectRoot`: resolved project root - `Message`: readiness summary diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index cd519d4e88..299d862e6f 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.27"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.28"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/cli/contract.json b/cli/contract.json index eb610a641d..d1b2bf8d03 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.27" + "cliVersion": "3.0.0-beta.28" } diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index 4c948d4baf..9f25193af9 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -29,6 +29,7 @@ const ( var ( findRunningUnityProcessForLaunch = findRunningUnityProcess focusUnityProcessForLaunch = focusUnityProcess + killUnityProcessForLaunch = killUnityProcess resolveUnityExecutablePathForLaunch = resolveUnityExecutablePath waitForUnityLockfileForLaunch = waitForUnityLockfile waitForToolReadinessForLaunch = waitForToolReadiness @@ -203,22 +204,27 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std if runningProcess != nil { if !options.restart && !options.quit { logLaunchExistingFocus(ctx, projectRoot, runningProcess.pid) - writeFormat(stdout, "Unity is already running for %s (PID: %d)\n", projectRoot, runningProcess.pid) - return 0 + spinner := newLaunchSpinner(stdout, stderr) + defer spinner.Stop() + writeLaunchReadinessWait(stdout, spinner) + if err := waitForToolReadinessForLaunch(ctx, projectRoot); err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) + return 1 + } + spinner.Stop() + return writeExistingLaunchReadyResponse(stdout, stderr, projectRoot, runningProcess.pid) } - if err := killUnityProcess(runningProcess.pid); err != nil { + if err := killUnityProcessForLaunch(runningProcess.pid); err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } if options.quit { - writeFormat(stdout, "Unity process stopped (PID: %d)\n", runningProcess.pid) - return 0 + return writeLaunchQuitResponse(stdout, stderr, projectRoot, &runningProcess.pid, launchStoppedMessage) } } if options.quit { - writeLine(stdout, "No Unity process is running for this project.") - return 0 + return writeLaunchQuitResponse(stdout, stderr, projectRoot, nil, launchNoProcessMessage) } removedStaleTemp, err := cleanStaleUnityTemp(projectRoot) @@ -258,6 +264,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } + currentPid := command.Process.Pid if err := command.Process.Release(); err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 @@ -272,7 +279,11 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std return 1 } spinner.Stop() - return writeLaunchReadyResponse(stdout, stderr, projectRoot) + var previousPid *int + if runningProcess != nil { + previousPid = &runningProcess.pid + } + return writeLaunchedReadyResponse(stdout, stderr, projectRoot, previousPid, currentPid) } func newUnityLaunchCommand(unityPath string, launchArgs []string) *exec.Cmd { diff --git a/cli/internal/cli/launch_ready.go b/cli/internal/cli/launch_ready.go index 00af31cc57..fdd935b530 100644 --- a/cli/internal/cli/launch_ready.go +++ b/cli/internal/cli/launch_ready.go @@ -6,16 +6,26 @@ import ( ) const ( - launchReadinessMessage = "Waiting for Unity CLI Loop server readiness..." - launchReadyMessage = "Unity CLI Loop is ready." + launchReadinessMessage = "Waiting for Unity CLI Loop server readiness..." + launchReadyMessage = "Unity CLI Loop is ready." + launchAlreadyRunningReadyMessage = "Unity is already running and ready." + launchStoppedMessage = "Unity process stopped." + launchNoProcessMessage = "No Unity process is running for this project." ) type launchReadyResponse struct { - Success bool `json:"Success"` - Ready bool `json:"Ready"` - ServerReady bool `json:"ServerReady"` - ProjectIpcReady bool `json:"ProjectIpcReady"` - Message string `json:"Message"` + Success bool `json:"Success"` + Ready bool `json:"Ready"` + ServerReady bool `json:"ServerReady"` + ProjectIpcReady bool `json:"ProjectIpcReady"` + AlreadyRunning bool `json:"AlreadyRunning"` + Launched bool `json:"Launched"` + Restarted bool `json:"Restarted"` + Quit bool `json:"Quit"` + PreviousProcessId *int `json:"PreviousProcessId,omitempty"` + CurrentProcessId *int `json:"CurrentProcessId,omitempty"` + ProjectRoot string `json:"ProjectRoot"` + Message string `json:"Message"` } func writeLaunchReadinessWait(stdout io.Writer, spinner *terminalSpinner) { @@ -25,17 +35,60 @@ func writeLaunchReadinessWait(stdout io.Writer, spinner *terminalSpinner) { } } -func writeLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string) int { - response := launchReadyResponse{ - Success: true, - Ready: true, - ServerReady: true, - ProjectIpcReady: true, - Message: launchReadyMessage, - } +func writeExistingLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + ServerReady: true, + ProjectIpcReady: true, + AlreadyRunning: true, + CurrentProcessId: ¤tPid, + ProjectRoot: projectRoot, + Message: launchAlreadyRunningReadyMessage, + }) +} + +func writeLaunchedReadyResponse( + stdout io.Writer, + stderr io.Writer, + projectRoot string, + previousPid *int, + currentPid int, +) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + ServerReady: true, + ProjectIpcReady: true, + Launched: true, + Restarted: previousPid != nil, + PreviousProcessId: previousPid, + CurrentProcessId: ¤tPid, + ProjectRoot: projectRoot, + Message: launchReadyMessage, + }) +} + +func writeLaunchQuitResponse( + stdout io.Writer, + stderr io.Writer, + projectRoot string, + previousPid *int, + message string, +) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Quit: true, + PreviousProcessId: previousPid, + ProjectRoot: projectRoot, + Message: message, + }) +} + +func writeLaunchResponse(stdout io.Writer, stderr io.Writer, response launchReadyResponse) int { payload, err := json.Marshal(response) if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) + writeClassifiedError(stderr, err, errorContext{projectRoot: response.ProjectRoot, command: launchCommandName}) return 1 } writeJSON(stdout, payload) diff --git a/cli/internal/cli/launch_test.go b/cli/internal/cli/launch_test.go index b0b0c44e80..b40329da57 100644 --- a/cli/internal/cli/launch_test.go +++ b/cli/internal/cli/launch_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -167,21 +168,146 @@ func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) { } } +func TestRunLaunchWritesStructuredResponseForExistingUnityProcess(t *testing.T) { + // Verifies launch reports machine-readable readiness when Unity was already running. + originalFinder := findRunningUnityProcessForLaunch + originalFocus := focusUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch + readinessChecked := false + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 111}, nil + } + focusUnityProcessForLaunch = func(context.Context, int) error { + return nil + } + waitForToolReadinessForLaunch = func(context.Context, string) error { + readinessChecked = true + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + focusUnityProcessForLaunch = originalFocus + waitForToolReadinessForLaunch = originalReadinessWait + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch( + context.Background(), + launchOptions{projectPath: projectRoot}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) + } + if !readinessChecked { + t.Fatal("launch should verify tool readiness before reporting an existing Unity process as ready") + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.ServerReady || !response.ProjectIpcReady { + t.Fatalf("ready flags mismatch: %#v", response) + } + if !response.AlreadyRunning || response.Launched || response.Restarted { + t.Fatalf("process state flags mismatch: %#v", response) + } + if response.CurrentProcessId == nil || *response.CurrentProcessId != 111 { + t.Fatalf("current process id mismatch: %#v", response.CurrentProcessId) + } + if response.PreviousProcessId != nil { + t.Fatalf("existing launch should not report a previous process: %#v", response.PreviousProcessId) + } +} + +func TestRunLaunchRestartWritesProcessTransitionResponse(t *testing.T) { + // Verifies restart reports both the stopped process and the newly launched process. + originalFinder := findRunningUnityProcessForLaunch + originalKiller := killUnityProcessForLaunch + originalResolver := resolveUnityExecutablePathForLaunch + originalLockfileWait := waitForUnityLockfileForLaunch + originalReadinessWait := waitForToolReadinessForLaunch + killedPid := 0 + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 222}, nil + } + killUnityProcessForLaunch = func(pid int) error { + killedPid = pid + return nil + } + resolveUnityExecutablePathForLaunch = func(string) (string, error) { + return "/usr/bin/true", nil + } + waitForUnityLockfileForLaunch = func(context.Context, string, time.Duration, time.Duration) error { + return nil + } + waitForToolReadinessForLaunch = func(context.Context, string) error { + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + killUnityProcessForLaunch = originalKiller + resolveUnityExecutablePathForLaunch = originalResolver + waitForUnityLockfileForLaunch = originalLockfileWait + waitForToolReadinessForLaunch = originalReadinessWait + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch( + context.Background(), + launchOptions{projectPath: projectRoot, restart: true}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) + } + if killedPid != 222 { + t.Fatalf("restart killed pid mismatch: %d", killedPid) + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.ServerReady || !response.ProjectIpcReady { + t.Fatalf("ready flags mismatch: %#v", response) + } + if !response.Launched || !response.Restarted || response.AlreadyRunning { + t.Fatalf("process state flags mismatch: %#v", response) + } + if response.PreviousProcessId == nil || *response.PreviousProcessId != 222 { + t.Fatalf("previous process id mismatch: %#v", response.PreviousProcessId) + } + if response.CurrentProcessId == nil || *response.CurrentProcessId <= 0 { + t.Fatalf("current process id mismatch: %#v", response.CurrentProcessId) + } +} + // Verifies launch logs when it focuses an already-running Unity process. func TestRunLaunchWritesExistingFocusSuccessVibeLog(t *testing.T) { enableCliVibeLog(t) originalFinder := findRunningUnityProcessForLaunch originalFocus := focusUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { return &unityProcess{pid: 111}, nil } focusUnityProcessForLaunch = func(context.Context, int) error { return nil } + waitForToolReadinessForLaunch = func(context.Context, string) error { + return nil + } t.Cleanup(func() { findRunningUnityProcessForLaunch = originalFinder focusUnityProcessForLaunch = originalFocus + waitForToolReadinessForLaunch = originalReadinessWait }) projectRoot := createLaunchTestProject(t) @@ -218,15 +344,20 @@ func TestRunLaunchWritesExistingFocusFailureVibeLog(t *testing.T) { originalFinder := findRunningUnityProcessForLaunch originalFocus := focusUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { return &unityProcess{pid: 222}, nil } focusUnityProcessForLaunch = func(context.Context, int) error { return fmt.Errorf("activation denied") } + waitForToolReadinessForLaunch = func(context.Context, string) error { + return nil + } t.Cleanup(func() { findRunningUnityProcessForLaunch = originalFinder focusUnityProcessForLaunch = originalFocus + waitForToolReadinessForLaunch = originalReadinessWait }) projectRoot := createLaunchTestProject(t) @@ -351,3 +482,17 @@ func createLaunchTestProject(t *testing.T) string { } return projectRoot } + +func decodeLaunchResponseFromOutput(t *testing.T, output string) launchReadyResponse { + t.Helper() + + jsonStart := strings.LastIndex(output, "{") + if jsonStart < 0 { + t.Fatalf("launch output did not contain a JSON object:\n%s", output) + } + var response launchReadyResponse + if err := json.Unmarshal([]byte(output[jsonStart:]), &response); err != nil { + t.Fatalf("failed to decode launch JSON: %v\n%s", err, output) + } + return response +} diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 1575fafc3d..90a4c143c3 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.27", + "version": "3.0.0-beta.28", "tools": [ { "name": "compile",