From 27c2d9712235f3539d7efa6fd42310d44accaa43 Mon Sep 17 00:00:00 2001 From: Selim Ataballyev Date: Fri, 19 Jun 2026 19:59:09 +0500 Subject: [PATCH] Add doctor check for timeout/gtimeout availability --- src/commands/doctor-checks.ts | 30 ++++++++++++ src/commands/doctor.ts | 2 + tests/commands/doctor-checks.test.ts | 70 ++++++++++++++++++++++++++++ tests/commands/doctor.test.ts | 3 +- 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/commands/doctor-checks.ts b/src/commands/doctor-checks.ts index be3c95d..3384e95 100644 --- a/src/commands/doctor-checks.ts +++ b/src/commands/doctor-checks.ts @@ -76,6 +76,36 @@ export async function checkJq(projectDir: string): Promise { }; } +export async function checkTimeout(projectDir: string): Promise { + const label = "timeout command available"; + // The Ralph loop wraps every agent call in `timeout`/`gtimeout` + // (see .ralph/lib/timeout_utils.sh). On macOS that means `gtimeout` from + // GNU coreutils; on Linux it is the built-in `timeout`. Without it the loop + // exits on its first iteration, so doctor must verify it the same way the + // loop resolves it: via `command -v` inside bash. + const probe = + process.platform === "darwin" + ? "command -v gtimeout || command -v timeout" + : "command -v timeout"; + const result = await runBashCommand(probe, { cwd: projectDir }); + const available = result.exitCode === 0; + const resolved = result.stdout.trim().split("/").pop(); + return { + label, + passed: available, + detail: available + ? resolved || undefined + : "timeout/gtimeout not found in bash PATH", + hint: available + ? undefined + : process.platform === "win32" + ? "Install coreutils (provides timeout), e.g. via MSYS2: pacman -S coreutils" + : process.platform === "darwin" + ? "Install GNU coreutils (provides gtimeout): brew install coreutils" + : "Install coreutils (provides timeout): sudo apt-get install coreutils", + }; +} + export async function checkGitRepo(projectDir: string): Promise { const label = "git repository with commits"; const gitDir = await runBashCommand("git rev-parse --git-dir", { cwd: projectDir }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index b258eaa..fec34c8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { checkBash, checkGitRepo, checkJq, + checkTimeout, checkConfig, checkBmadDir, checkRalphLoop, @@ -119,6 +120,7 @@ const CORE_CHECKS: CheckDefinition[] = [ { id: "bash-available", run: checkBash }, { id: "git-repo", run: checkGitRepo }, { id: "jq-available", run: checkJq }, + { id: "timeout-available", run: checkTimeout }, { id: "config-valid", run: checkConfig }, { id: "bmad-dir", run: checkBmadDir }, { id: "ralph-loop", run: checkRalphLoop }, diff --git a/tests/commands/doctor-checks.test.ts b/tests/commands/doctor-checks.test.ts index 271a6ce..814aa75 100644 --- a/tests/commands/doctor-checks.test.ts +++ b/tests/commands/doctor-checks.test.ts @@ -44,6 +44,7 @@ import { checkNodeVersion, checkBash, checkJq, + checkTimeout, checkGitRepo, checkBmadDir, checkRalphLoop, @@ -221,6 +222,75 @@ describe("checkJq", () => { }); }); +describe("checkTimeout", () => { + it("uses the label 'timeout command available'", async () => { + mockedRunBashCommand.mockResolvedValue({ + exitCode: 0, + stdout: "/usr/bin/timeout\n", + stderr: "", + }); + + const result = await checkTimeout("/projects/webapp"); + + expect(result.label).toBe("timeout command available"); + }); + + it("passes and reports the resolved binary when available", async () => { + mockedRunBashCommand.mockResolvedValue({ + exitCode: 0, + stdout: "/usr/bin/timeout\n", + stderr: "", + }); + + const result = await checkTimeout("/projects/webapp"); + + expect(result.passed).toBe(true); + expect(result.detail).toBe("timeout"); + expect(result.hint).toBeUndefined(); + }); + + it("probes for gtimeout first on macOS", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + + try { + mockedRunBashCommand.mockResolvedValue({ + exitCode: 0, + stdout: "/opt/homebrew/bin/gtimeout\n", + stderr: "", + }); + + const result = await checkTimeout("/projects/webapp"); + + expect(mockedRunBashCommand).toHaveBeenCalledWith( + "command -v gtimeout || command -v timeout", + { cwd: "/projects/webapp" } + ); + expect(result.passed).toBe(true); + expect(result.detail).toBe("gtimeout"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + + it("fails with a coreutils hint when no timeout binary is found on macOS", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + + try { + mockedRunBashCommand.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "" }); + + const result = await checkTimeout("/projects/webapp"); + + expect(result.passed).toBe(false); + expect(result.detail).toBe("timeout/gtimeout not found in bash PATH"); + expect(result.hint).toContain("brew install coreutils"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); +}); + describe("checkDir", () => { it("passes when the path is a directory", async () => { mockStat.mockResolvedValue({ isDirectory: () => true } as ReturnType< diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 0b0c7e4..489e693 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -1030,7 +1030,7 @@ describe("doctor command", { timeout: 15000 }, () => { const registry = buildCheckRegistry(claudeCodePlatform); expect(Array.isArray(registry)).toBe(true); - expect(registry.length).toBe(18); + expect(registry.length).toBe(19); // All checks should have required properties for (const check of registry) { @@ -1052,6 +1052,7 @@ describe("doctor command", { timeout: 15000 }, () => { "bash-available", "git-repo", "jq-available", + "timeout-available", "config-valid", "bmad-dir", "ralph-loop",