diff --git a/CHANGELOG.md b/CHANGELOG.md index 744becac..b0668f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Repo: https://github.com/openclaw/acpx - Flows: coalesce heartbeat writes while storage is busy so slow filesystems do not accumulate overlapping writes and stall running steps. - Flows: keep the host alive when a shell action closes stdin before consuming its input. Thanks @SebTardif. - ACP/terminal: handle child stdout and stderr errors without terminating the host, so wait and release can finish. Thanks @SebTardif. +- ACP/launch: preserve process-spawn `ENOENT` as additive `AGENT_SPAWN_ENOENT` detail and include qualified remediation while keeping the broad runtime code and other spawn failures unchanged. Fixes #510. Thanks @anyech. ## 2026.8.28 (v0.13.2) diff --git a/docs/ACPX_ERROR_STRATEGY.md b/docs/ACPX_ERROR_STRATEGY.md index 9b7f6bdf..4cd9c792 100644 --- a/docs/ACPX_ERROR_STRATEGY.md +++ b/docs/ACPX_ERROR_STRATEGY.md @@ -78,6 +78,13 @@ Auth-required policy: - use `detailCode=AUTH_REQUIRED` for deterministic machine handling - include raw ACP payload in `acp` when available (for example `acp.code=-32000`) +Agent-spawn policy: + +- keep top-level `code` as `RUNTIME` for compatibility +- use `detailCode=AGENT_SPAWN_ENOENT` when process creation fails with `ENOENT` +- describe the missing launch path without assuming it is always the command binary; the executable, interpreter, or working directory may be absent +- leave non-`ENOENT` spawn failures on the generic runtime path + ## Queue detail codes (initial set) - `QUEUE_OWNER_CLOSED` diff --git a/src/errors.ts b/src/errors.ts index ce3c4fdb..6224fbf2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -44,8 +44,18 @@ export class AgentSpawnError extends AcpxOperationalError { readonly agentCommand: string; constructor(agentCommand: string, cause?: unknown) { - super(`Failed to spawn agent command: ${agentCommand}`, { + const spawnCode = cause instanceof Error ? (cause as NodeJS.ErrnoException).code : undefined; + const spawnEnoent = spawnCode === "ENOENT"; + const message = spawnEnoent + ? `Failed to spawn agent command: ${agentCommand}. The agent process could not start because a required executable, interpreter, working directory, or other launch path was not found. Check the command, effective PATH, and working directory, or verify the custom agent's configured argv.` + : `Failed to spawn agent command: ${agentCommand}`; + super(message, { cause: cause instanceof Error ? cause : undefined, + ...(spawnEnoent + ? { + detailCode: "AGENT_SPAWN_ENOENT", + } + : {}), }); this.agentCommand = agentCommand; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 16092748..e34e4734 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -345,6 +345,53 @@ test("CLI resolves unknown raw agent commands after newer global flags", async ( }); }); +test( + "CLI reports actionable text and JSON detail for a missing raw agent command", + { skip: process.platform === "win32" }, + async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + const missingAgent = path.join(homeDir, "missing-raw-agent"); + + const text = await runCli(["--cwd", cwd, missingAgent, "exec", "ping"], homeDir); + assert.equal(text.code, 1); + assert.match(text.stderr, /required executable, interpreter, working directory/i); + assert.match(text.stderr, /effective PATH/i); + assert.match(text.stderr, /configured argv/i); + + const json = await runCli( + ["--cwd", cwd, "--format", "json", missingAgent, "exec", "ping"], + homeDir, + ); + assert.equal(json.code, 1); + const error = parseSingleAcpErrorLine(json.stdout); + assert.equal(error.data?.acpxCode, "RUNTIME"); + assert.equal(error.data?.detailCode, "AGENT_SPAWN_ENOENT"); + assert.equal(error.data?.origin, "cli"); + + const missingCwd = await runCli( + [ + "--cwd", + path.join(homeDir, "missing-workspace"), + "--format", + "json", + "--agent", + process.execPath, + "exec", + "ping", + ], + homeDir, + ); + assert.equal(missingCwd.code, 1); + const missingCwdError = parseSingleAcpErrorLine(missingCwd.stdout); + assert.equal(missingCwdError.data?.acpxCode, "RUNTIME"); + assert.equal(missingCwdError.data?.detailCode, "AGENT_SPAWN_ENOENT"); + assert.match(missingCwdError.message ?? "", /working directory/i); + }); + }, +); + test("global passthrough flags are present in help output", async () => { await withTempHome(async (homeDir) => { const result = await runCli(["--help"], homeDir); diff --git a/test/error-normalization.test.ts b/test/error-normalization.test.ts index 14b364ae..053f0fdd 100644 --- a/test/error-normalization.test.ts +++ b/test/error-normalization.test.ts @@ -7,11 +7,42 @@ import { isAcpResourceNotFoundError, } from "../src/acp/error-normalization.js"; import { + AgentSpawnError, PermissionPromptUnavailableError, QueueConnectionError, AuthPolicyError, } from "../src/errors.js"; +test("normalizeOutputError preserves spawn ENOENT as an additive detail", () => { + const cause = Object.assign(new Error("spawn custom-agent ENOENT"), { + code: "ENOENT", + }); + const normalized = normalizeOutputError(new AgentSpawnError("custom-agent", cause), { + origin: "cli", + }); + + assert.equal(normalized.code, "RUNTIME"); + assert.equal(normalized.detailCode, "AGENT_SPAWN_ENOENT"); + assert.equal(normalized.origin, "cli"); + assert.match(normalized.message, /^Failed to spawn agent command: custom-agent\./); + assert.match(normalized.message, /required executable, interpreter, working directory/i); + assert.match(normalized.message, /effective PATH/i); + assert.match(normalized.message, /configured argv/i); +}); + +test("normalizeOutputError keeps non-ENOENT spawn failures generic", () => { + const cause = Object.assign(new Error("spawn custom-agent EACCES"), { + code: "EACCES", + }); + const normalized = normalizeOutputError(new AgentSpawnError("custom-agent", cause), { + origin: "cli", + }); + + assert.equal(normalized.code, "RUNTIME"); + assert.equal(normalized.detailCode, undefined); + assert.equal(normalized.message, "Failed to spawn agent command: custom-agent"); +}); + test("normalizeOutputError maps permission prompt unavailable errors", () => { const normalized = normalizeOutputError(new PermissionPromptUnavailableError(), { origin: "runtime",