LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: Tracing the Silent Failures That Break Persistent Agents

When an autonomous agent fails to restart after a system reboot, the culprit is rarely exotic. In most cases, the LaunchAgent plist is present, launchctl reports no errors, and yet the process never starts. The root cause is almost always a path mismatch—a disconnect between what the plist declares and what actually exists on disk. These failures are silent by design: launchd loads the job, evaluates it, and then quietly discards it when the executable or working directory cannot be resolved. Understanding the exact sequence of checks launchd performs, and knowing where to look for evidence of failure, is the core skill this post develops.

How launchd Resolves Paths

Before debugging, it helps to understand what launchd expects. When it processes a plist, it resolves the Program or ProgramArguments[0] key literally—no PATH expansion, no shell interpolation, no symlink resolution beyond what the filesystem provides at that moment. If the binary lives at /opt/homebrew/bin/openclaw on Apple Silicon but the plist declares /usr/local/bin/openclaw (the Intel Homebrew prefix), the job silently fails to execute.

The same applies to WorkingDirectory, StandardOutPath, and StandardErrorPath. A log path of /var/log/openclaw/agent.log will cause the job to fail at launch if the directory /var/log/openclaw/ does not exist and launchd cannot create it.

Diagnosing the Mismatch

Start with the most direct diagnostic: examine the job’s actual state after loading it.

# Load the agent explicitly and check its exit code
launchctl load ~/Library/LaunchAgents/com.moe-ops.openclaw.plist

# Query the job's state
launchctl list | grep openclaw

The output columns are PID, LastExitStatus, and Label. A missing PID with a non-zero LastExitStatus confirms the job launched and immediately died. A PID of - with an exit status of 0 and no process visible in ps means the job never attempted execution—this is the path mismatch signature.

On macOS 10.10 and later, use the domain-targeted form:

launchctl print gui/$(id -u)/com.moe-ops.openclaw

This prints the full job state including path, state, reason, and any last exit code. Look specifically for a reason of path not found or bad executable.

To verify the declared executable path independently:

# Extract the Program key from the plist
/usr/libexec/PlistBuddy -c "Print :Program" ~/Library/LaunchAgents/com.moe-ops.openclaw.plist

# Confirm the binary exists and is executable
ls -la $(/usr/libexec/PlistBuddy -c "Print :Program" ~/Library/LaunchAgents/com.moe-ops.openclaw.plist)

If that ls returns No such file or directory, you have found your mismatch.

Common Mismatch Patterns and Their Fixes

Architecture prefix mismatch. This is endemic in environments that transitioned from Intel to Apple Silicon Macs. Homebrew installs binaries under /usr/local on Intel and /opt/homebrew on Apple Silicon. Audit your plists:

grep -r "ProgramArguments\|Program" ~/Library/LaunchAgents/ | grep "/usr/local"

Cross-reference against brew --prefix to confirm the active prefix.

Virtual environment paths. Python-based agents frequently reference a virtualenv binary that no longer exists because the environment was recreated or moved:

<key>ProgramArguments</key>
<array>
    <string>/Users/agent/.venv/openclaw/bin/python</string>
    <string>-m</string>
    <string>openclaw.runner</string>
</array>

If the virtualenv was rebuilt, the interpreter path is valid but may point to a different Python version that lacks required packages. Always pin virtualenv paths to absolute locations and document the recreation procedure alongside the plist.

Missing log directories. launchd will not create intermediate directories for StandardOutPath or StandardErrorPath. A plist referencing /var/log/openclaw/agent.log requires that /var/log/openclaw/ exist before the agent loads. Add a LaunchOnlyOnce guard or a companion startup item that creates the directory first, or handle it in a wrapper script:

#!/bin/bash
mkdir -p /var/log/openclaw
exec /opt/homebrew/bin/openclaw --config /etc/openclaw/config.yaml

Username expansion failure. The tilde ~ is a shell construct. launchd does not expand it. A path of ~/bin/openclaw in a plist is interpreted literally as a path starting with ~, which does not exist. Always use absolute paths:

# Wrong
<string>~/Library/Application Support/openclaw/bin/runner</string>

# Correct
<string>/Users/agent/Library/Application Support/openclaw/bin/runner</string>

Reading the Unified Log for Silent Failures

When launchctl output is ambiguous, the unified logging system captures launchd’s internal decisions:

log show --predicate 'subsystem == "com.apple.launchd"' \
         --style compact \
         --last 5m \
         | grep -i "openclaw\|path\|exec\|failed"

This stream frequently reveals messages like posix_spawn: No such file or directory tied to a specific job label, which confirms path resolution failed at the OS level rather than inside the agent.

Validating Before Loading

Build path validation into your deployment pipeline rather than diagnosing failures post-hoc. A simple preflight check:

#!/bin/bash
PLIST="$1"
PROGRAM=$(/usr/libexec/PlistBuddy -c "Print :Program" "$PLIST" 2>/dev/null)
if [[ -z "$PROGRAM" ]]; then
    PROGRAM=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$PLIST")
fi
if [[ ! -x "$PROGRAM" ]]; then
    echo "ERROR: Executable not found or not executable: $PROGRAM"
    exit 1
fi
echo "OK: $PROGRAM"

Run this against every plist before launchctl load and catch mismatches before they become silent production failures.

References

Write a comment