From 9a7f2d2f1666c6f3105fac0d093ca263052115be Mon Sep 17 00:00:00 2001 From: "Doink (OpenClaw)" Date: Sat, 7 Mar 2026 16:21:58 -0800 Subject: [PATCH] feat: callback metadata, lifecycle webhooks, spawn ENOENT retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #104: Add --callback-session and --callback-agent CLI flags to launch. Stored in session.meta as openclaw_callback_session_key / openclaw_callback_agent_id. Propagated through daemon launch, multi-adapter orchestration, and surfaced in list/status --json output. - #108: Native lifecycle webhook emission from daemon. Reads webhook_url and webhook_secret from config (~/.agentctl/config.json) or env vars (AGENTCTL_WEBHOOK_URL, AGENTCTL_WEBHOOK_SECRET). On running→stopped transitions POSTs {hook_type, session_id, cwd, adapter, duration_seconds, exit_status, summary, meta, timestamp} with HMAC-SHA256 signature. Fire-and-forget across all detection paths (session.stop, reconcile, PID liveness cleanup). - #120: Spawn ENOENT retry. All adapter launch() methods now use spawnWithRetry() which on ENOENT waits 500ms, calls clearBinaryCache(), re-resolves the binary path, and retries once. Fixes #104, Fixes #108, Fixes #120 Co-Authored-By: Claude Opus 4.6 --- src/adapters/claude-code.ts | 9 +- src/adapters/codex.ts | 8 +- src/adapters/opencode.ts | 8 +- src/adapters/pi-rust.ts | 8 +- src/adapters/pi.ts | 9 +- src/cli.ts | 20 ++++ src/core/types.ts | 4 + src/daemon/server.ts | 48 ++++++++- src/daemon/webhook.test.ts | 166 +++++++++++++++++++++++++++++ src/daemon/webhook.ts | 102 ++++++++++++++++++ src/launch-orchestrator.ts | 14 +++ src/utils/config.ts | 2 + src/utils/spawn-with-retry.test.ts | 91 ++++++++++++++++ src/utils/spawn-with-retry.ts | 77 +++++++++++++ 14 files changed, 532 insertions(+), 34 deletions(-) create mode 100644 src/daemon/webhook.test.ts create mode 100644 src/daemon/webhook.ts create mode 100644 src/utils/spawn-with-retry.test.ts create mode 100644 src/utils/spawn-with-retry.ts diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index 5f1ceae..d0d6ee3 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -24,6 +24,7 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -480,19 +481,13 @@ export class ClaudeCodeAdapter implements AgentAdapter { const logFd = await fs.open(logPath, "w"); // Capture stderr to the same log file for debugging launch failures - const claudePath = await resolveBinaryPath("claude"); - const child = spawn(claudePath, args, { + const child = await spawnWithRetry("claude", args, { cwd, env, stdio: [promptFd ? promptFd.fd : "ignore", logFd.fd, logFd.fd], detached: true, }); - // Handle spawn errors (e.g. ENOENT) gracefully instead of crashing the daemon - child.on("error", (err) => { - console.error(`[claude-code] spawn error: ${err.message}`); - }); - // Fully detach: child runs in its own process group. // When the wrapper gets SIGTERM, the child keeps running. child.unref(); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index d713848..166ef8a 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -23,6 +23,7 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -280,18 +281,13 @@ export class CodexAdapter implements AgentAdapter { const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`); const logFd = await fs.open(logPath, "w"); - const codexPath = await resolveBinaryPath("codex"); - const child = spawn(codexPath, args, { + const child = await spawnWithRetry("codex", args, { cwd, env, stdio: [promptFd ? promptFd.fd : "ignore", logFd.fd, "ignore"], detached: true, }); - child.on("error", (err) => { - console.error(`[codex] spawn error: ${err.message}`); - }); - child.unref(); const pid = child.pid; diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 1094653..e5dab0b 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -23,6 +23,7 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -372,18 +373,13 @@ export class OpenCodeAdapter implements AgentAdapter { const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`); const logFd = await fs.open(logPath, "w"); - const opencodePath = await resolveBinaryPath("opencode"); - const child = spawn(opencodePath, args, { + const child = await spawnWithRetry("opencode", args, { cwd, env, stdio: [promptFd ? promptFd.fd : "ignore", logFd.fd, logFd.fd], detached: true, }); - child.on("error", (err) => { - console.error(`[opencode] spawn error: ${err.message}`); - }); - child.unref(); const pid = child.pid; diff --git a/src/adapters/pi-rust.ts b/src/adapters/pi-rust.ts index e70d0ae..6c9ab92 100644 --- a/src/adapters/pi-rust.ts +++ b/src/adapters/pi-rust.ts @@ -23,6 +23,7 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -378,18 +379,13 @@ export class PiRustAdapter implements AgentAdapter { const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`); const logFd = await fs.open(logPath, "w"); - const piRustPath = await resolveBinaryPath("pi-rust"); - const child = spawn(piRustPath, args, { + const child = await spawnWithRetry("pi-rust", args, { cwd, env, stdio: [promptFd ? promptFd.fd : "ignore", logFd.fd, "ignore"], detached: true, }); - child.on("error", (err) => { - console.error(`[pi-rust] spawn error: ${err.message}`); - }); - child.unref(); const pid = child.pid; diff --git a/src/adapters/pi.ts b/src/adapters/pi.ts index 142a1d4..00b6ef9 100644 --- a/src/adapters/pi.ts +++ b/src/adapters/pi.ts @@ -23,6 +23,7 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -356,19 +357,13 @@ export class PiAdapter implements AgentAdapter { const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`); const logFd = await fs.open(logPath, "w"); - const piPath = await resolveBinaryPath("pi"); - const child = spawn(piPath, args, { + const child = await spawnWithRetry("pi", args, { cwd, env, stdio: [promptFd ? promptFd.fd : "ignore", logFd.fd, logFd.fd], detached: true, }); - child.on("error", (err) => { - console.error(`[pi] spawn error: ${err.message}`); - }); - - // Fully detach: child runs in its own process group. child.unref(); const pid = child.pid; diff --git a/src/cli.ts b/src/cli.ts index 437ca45..0f969ad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -567,6 +567,8 @@ program .option("--matrix ", "YAML matrix file for advanced sweep launch") .option("--on-create