Help us improve
Share bugs, ideas, or general feedback.
From agent-flow
Headless dispatcher for CI/cron that reads issue queries from Automation Config, classifies bugs and features, and dispatches fix-bugs / implement-feature skills sequentially with lock-file protection.
npx claudepluginhub asysta-act/agent-flowHow this skill is triggered — by the user, by Claude, or both
Slash command
/agent-flow:autopilot [--dry-run][--dry-run]This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Headless dispatcher skill for unattended cron / batch / CI invocation. Reads `### Issue Tracker`, `### Feature Workflow` (optional) and `### Autopilot` (optional) from `## Automation Config`, classifies issues into bugs and features, enforces a portable `mkdir`-based lock, and dispatches `agent-flow:fix-bugs` or `agent-flow:implement-feature` per issue sequentially via the Skill tool.
Runs an automated bug-fix pipeline on a single ticket or batch, with checkpoint resume, step dispatch, and dry-run support.
Orchestrates multi-phase projects by dispatching isolated phase-agents for planning, execution, verification, integration, and review. Tracks state and chains artifacts; use after approving specs with 2+ phases.
Share bugs, ideas, or general feedback.
Headless dispatcher skill for unattended cron / batch / CI invocation. Reads ### Issue Tracker, ### Feature Workflow (optional) and ### Autopilot (optional) from ## Automation Config, classifies issues into bugs and features, enforces a portable mkdir-based lock, and dispatches agent-flow:fix-bugs or agent-flow:implement-feature per issue sequentially via the Skill tool.
Invoke typically as:
claude -p "Run /agent-flow:autopilot" --dangerously-skip-permissions
or, for safe inspection:
claude -p "Run /agent-flow:autopilot --dry-run" --dangerously-skip-permissions
Reference: docs/guides/autopilot.md for operator onboarding, exit-code matrix, and cron guidance.
fix-bugs, implement-feature)..agent-flow/autopilot.lock/ guards ONE host / filesystem. Multi-host deployments MUST coordinate via DISJOINT Bug query / Feature query filters per host, or run Autopilot from exactly one host. The plugin emits an INFO line with the hostname on every successful lock acquisition (see Step 3) but does NOT automatically detect cross-host contention. See docs/guides/autopilot.md#single-host-operation.pipeline-started / step-completed / pipeline-completed events per-issue.Dry run: true means no lock, no state.json, no webhook, no dispatch.Follow ../../core/config-reader.md for parsing ## Automation Config from CLAUDE.md.
This skill consumes the following sections:
### Issue Tracker (required): Type, Bug query, State transitions, On start set. The Bug query row is the required authoritative input; Feature query lives in ### Feature Workflow.### Feature Workflow (optional): Feature query, On start set. Absent section triggers a [WARN] and bug-only mode.### Autopilot (optional): 7 keys below. Absent section = all keys use defaults. Autopilot still runs using Bug query from ### Issue Tracker.### Autopilot — 7 config keysThe Autopilot section uses a | Key | Value | table. The 7 keys are (name is EXACT — case and whitespace significant when matching rows):
| Key | Type | Default | Semantics |
|---|---|---|---|
Max issues per run | integer ≥ 1 | 1 | Total cap on issues dispatched per invocation (bugs + features combined). Default of 1 is a safety cap for first use. |
Lock timeout | integer (minutes) | 120 | Age threshold after which an existing lock directory is considered stale and auto-recovered. |
Log file | path | .agent-flow/autopilot.log | Append-only run log. Each invocation appends a timestamped summary line. Separate from the lock directory. |
Bug limit | integer ≥ 0 | 0 | Per-type cap on bug dispatches. 0 = no per-type cap (only Max issues per run applies). |
Feature limit | integer ≥ 0 | 0 | Per-type cap on feature dispatches. 0 = no per-type cap (only Max issues per run applies). |
On error | enum: skip | stop | skip | Per-issue error policy: On error: skip = log [WARN] and continue with next issue; On error: stop = abort the whole run on the first per-issue error. |
Dry run | boolean | false | true = full short-circuit (no lock, no state, no webhook, no dispatch). |
Query keys are NOT in ### Autopilot: Bug query is read from ### Issue Tracker (required existing key). Feature query is read from ### Feature Workflow (optional existing section — absent triggers [WARN] and bug-only mode). Autopilot references them; it does not own them.
When the ### Autopilot section is absent, Autopilot MAY still run with all 7 keys at their defaults. Operators who do NOT wish to use Autopilot should simply not invoke /agent-flow:autopilot.
The skill executes the following steps IN ORDER. Any STOP or exit terminates Autopilot immediately without proceeding to later steps. Lock release is governed by the trap registered in Step 2.
## Automation Config via ../../core/config-reader.md.## Automation Config heading is missing → print to stderr and exit 1:
[autopilot][ERROR] Missing Automation Config section in CLAUDE.md. See docs/guides/autopilot.md.
### Issue Tracker.Bug query is absent → print to stderr and exit 1:
[autopilot][ERROR] Bug query is required but not found in ### Issue Tracker. Add Bug query to your Issue Tracker config.
Bug query is resolvable from ### Issue Tracker.Bug query. This is a defensive re-check (Step 3 above should have already caught the absence).../../core/mcp-preflight.md. Ping the tracker MCP matching ### Issue Tracker.Type. On failure, print to stderr and exit 3:
[STOP] MCP unreachable — {error}
No lock is acquired on MCP failure. No state.json is written. This is a clean, side-effect-free fail.If config Dry run: true:
[DRY RUN] Autopilot dry-run mode — full short-circuit. No lock, no state, no webhook, no dispatch.
[autopilot][INFO] Dry run mode — short-circuit. No lock, no state, no webhook, no dispatch.
[autopilot][INFO] Would process: Bug query={Bug query from Issue Tracker}, Feature query={Feature query from Feature Workflow or "<absent>"}, Max issues per run={Max issues per run}, Bug limit={Bug limit}, Feature limit={Feature limit}
Then exit 0 immediately. NO lock acquisition, NO state.json write, NO webhook fire, NO child skill dispatch. Dry-run is a fully idempotent inspection mode safe for concurrent cron.
The lock is a DIRECTORY .agent-flow/autopilot.lock/ (NOT a file) — mkdir is atomic on POSIX and NTFS. A JSON file owner.json inside the directory records {pid, hostname, acquired_at}. The trap that releases the lock on EXIT is installed ONLY AFTER a successful mkdir (avoids the trap-race where an early-failing process would nuke a lock it never acquired). The trap verifies pid == $$ before rm -rf (refuses to delete another process's lock).
Stale detection: if an existing lock has acquired_at older than Lock timeout minutes (default 120), the lock is considered stale and recovery re-acquires it exactly once. Stale-arithmetic primary path uses awk mktime; on BusyBox < 1.30 (Alpine 3.9 and earlier) awk mktime is not available — the fallback uses a filesystem mtime check via find -mmin +121.
Copy-pasteable reference (see also design.md §4.8):
# --- Portable ISO-8601 → epoch (pure bash/awk, no GNU-date -d, no BSD-date -j -f) ---
iso_to_epoch() {
# Input: 2026-04-17T14:30:00Z → epoch seconds on stdout; empty on unparseable.
local ts="$1"
[ -z "$ts" ] && { echo ""; return 1; }
local Y=${ts:0:4} M=${ts:5:2} D=${ts:8:2} h=${ts:11:2} m=${ts:14:2} s=${ts:17:2}
case "$Y$M$D$h$m$s" in
*[!0-9]*|"") echo ""; return 1 ;;
esac
# awk mktime: gawk + BusyBox ≥ 1.30 + macOS awk support it.
awk -v Y="$Y" -v M="$M" -v D="$D" -v h="$h" -v m="$m" -v s="$s" \
'BEGIN { print mktime(Y" "M" "D" "h" "m" "s" UTC") }'
}
# --- Lock acquisition ---
# The literal lock directory path is .agent-flow/autopilot.lock/ (created by mkdir .agent-flow/autopilot.lock below).
# We resolve it to an absolute path here so the trap is CWD-change-safe.
LOCK_DIR="$(pwd)/.agent-flow/autopilot.lock" # ABSOLUTE path — CWD-change-safe
OWNER_PID=$$
OWNER_HOST=$(hostname)
OWNER_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
LOCK_TIMEOUT=${LOCK_TIMEOUT:-120} # minutes
LOCK_TIMEOUT_WITH_BUFFER=$((LOCK_TIMEOUT + 5)) # +5min NFS/CIFS skew buffer
write_owner_json() {
printf '{"pid":%s,"hostname":"%s","acquired_at":"%s"}\n' \
"$OWNER_PID" "$OWNER_HOST" "$OWNER_TIME" \
> "$LOCK_DIR/owner.json"
}
install_trap() {
# Verify ownership BEFORE rm -rf. Uses absolute LOCK_DIR (CWD-safe).
# Install this function ONLY AFTER a successful mkdir of $LOCK_DIR.
trap '
if [ -f "'"$LOCK_DIR"'/owner.json" ]; then
own_pid=$(grep -o "\"pid\"[[:space:]]*:[[:space:]]*[0-9]*" "'"$LOCK_DIR"'/owner.json" 2>/dev/null | grep -o "[0-9]*$")
if [ "$own_pid" = "'"$OWNER_PID"'" ]; then
rm -rf "'"$LOCK_DIR"'"
fi
fi
' EXIT
}
log_single_host_info() {
# AUTOPILOT-R13: always emit INFO on lock acquire.
echo "[autopilot][INFO] Running on host ${OWNER_HOST}. If another host is also running Autopilot against the same tracker, it MUST use a disjoint bug/feature query. See docs/guides/autopilot.md#single-host-operation." >&2
}
mkdir -p "$(dirname "$LOCK_DIR")" 2>/dev/null
# Step 1: try to acquire (atomic mkdir)
if mkdir "$LOCK_DIR" 2>/dev/null; then
write_owner_json
install_trap # trap registered AFTER successful acquisition ONLY
log_single_host_info
else
# Lock exists — check staleness
if [ ! -f "$LOCK_DIR/owner.json" ]; then
# Empty/malformed lock — treat as stale and recover
rm -rf "$LOCK_DIR"
if mkdir "$LOCK_DIR" 2>/dev/null; then
write_owner_json
install_trap
log_single_host_info
else
echo "[autopilot][ERROR] Another Autopilot run in progress (malformed lock recovery failed)" >&2
exit 2
fi
else
acquired_at=$(grep -o '"acquired_at":"[^"]*"' "$LOCK_DIR/owner.json" | cut -d'"' -f4)
if [ -z "$acquired_at" ]; then
# Defensive: empty or partial write → treat as stale
rm -rf "$LOCK_DIR"
if mkdir "$LOCK_DIR" 2>/dev/null; then
write_owner_json
install_trap
log_single_host_info
else
echo "[autopilot][ERROR] Another Autopilot run in progress (defensive-parse recovery failed)" >&2
exit 2
fi
else
acquired_epoch=$(iso_to_epoch "$acquired_at")
if [ -z "$acquired_epoch" ]; then
# BusyBox fallback: awk mktime unavailable → filesystem mtime check.
if find "$LOCK_DIR/owner.json" -mmin +121 -print 2>/dev/null | grep -q .; then
rm -rf "$LOCK_DIR"
if mkdir "$LOCK_DIR" 2>/dev/null; then
write_owner_json
install_trap
log_single_host_info
else
echo "[autopilot][ERROR] Another Autopilot run in progress (BusyBox-fallback recovery race)" >&2
exit 2
fi
else
echo "[autopilot][ERROR] Another Autopilot run in progress (awk mktime unavailable; mtime age < 121min)" >&2
exit 2
fi
else
now_epoch=$(date -u +%s)
age_min=$(( (now_epoch - acquired_epoch) / 60 ))
if [ "$age_min" -gt "$LOCK_TIMEOUT_WITH_BUFFER" ]; then
# Stale — recover
rm -rf "$LOCK_DIR"
if mkdir "$LOCK_DIR" 2>/dev/null; then
write_owner_json
install_trap
log_single_host_info
else
echo "[autopilot][ERROR] Another Autopilot run in progress (stale recovery race)" >&2
exit 2
fi
else
own_pid_peek=$(grep -o '"pid"[[:space:]]*:[[:space:]]*[0-9]*' "$LOCK_DIR/owner.json" | grep -o '[0-9]*$')
own_host_peek=$(grep -o '"hostname":"[^"]*"' "$LOCK_DIR/owner.json" | cut -d'"' -f4)
echo "[autopilot][ERROR] Another Autopilot run in progress (pid=${own_pid_peek}, host=${own_host_peek}, since=${acquired_at})." >&2
exit 2
fi
fi
fi
fi
fi
Invariants (operators MUST preserve if customizing):
LOCK_DIR is resolved to an ABSOLUTE path before the trap is installed (CWD-change-safe).trap ... EXIT is installed ONLY AFTER a successful mkdir or successful stale re-acquire — never on the fast-fail path.owner.json.pid matches $$ before rm -rf — refuses to nuke another process's lock.-d, no BSD-date -j -f, no Python 3 dependency. Portable to Linux, Windows Git Bash, macOS.owner.json triggers defensive stale-recovery instead of hard failure.$LOCK_DIR. Cross-host awareness is an INFO log line only (Step 3), never a persistent hint file.On every successful lock acquisition (including stale recovery), always log to stderr:
[autopilot][INFO] Running on host {hostname}. If another host is also running Autopilot against the same tracker, it MUST use a disjoint bug/feature query. See docs/guides/autopilot.md#single-host-operation.
This is an INFORMATIONAL line only. It does NOT detect cross-host contention. The authoritative multi-host mitigation is operator-side DISJOINT-QUERY configuration (see guide). The INFO line aids log correlation only.
Bug query — required. Source: ### Issue Tracker.Bug query. If absent, Step 0 would have already exited 1; this is a defensive re-check.Feature query (optional). Source: ### Feature Workflow.Feature query.### Feature Workflow section is ABSENT from Automation Config:
[autopilot][WARN] Feature Workflow section absent — running in bug-only mode.
Continue in bug-only mode. DO NOT block. DO NOT exit.### Feature Workflow exists but Feature query is empty AND Feature limit > 0:
[autopilot][WARN] Feature limit={N} configured but no Feature query — treating as bug-only
Continue in bug-only mode.### Issue Tracker.Type (default youtrack; supported: youtrack, github, jira, linear, gitea, redmine).Pause timeout from ### Pause Limits (optional section; default 30 days). Parse and validate via parse_pause_timeout() (defined below); store result as PAUSE_TIMEOUT_SECONDS for use in Step 6.parse_pause_timeout() — POSIX-portable Pause timeout parserValidates the operator-supplied Pause timeout config value. Minimum 1 hour (3600 s), maximum 365 days (31536000 s). On invalid input: graceful fallback to default — log [WARN] Invalid Pause timeout '{value}'; using default 30 days and return default 2592000 s. The pipeline MUST NOT abort on invalid input; the fallback-to-default behavior ensures continued operation.
parse_pause_timeout() {
local raw="$1"
local n unit unit_lower seconds
# Strip surrounding whitespace.
raw="$(printf '%s' "$raw" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"
# Match `<N> hours` or `<N> days` (positive integer N); case-insensitive unit.
if [[ "$raw" =~ ^([0-9]+)[[:space:]]+([Hh][Oo][Uu][Rr][Ss]?|[Dd][Aa][Yy][Ss]?)$ ]]; then
n="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
# Downcase the unit token for case-insensitive matching (handles "30 Days", "2 Hours", etc.)
unit_lower=$(printf '%s' "$unit" | tr '[:upper:]' '[:lower:]')
case "$unit_lower" in
hour|hours) seconds=$(( n * 3600 )) ;;
day|days) seconds=$(( n * 86400 )) ;;
esac
# Range check: min 1 hour (3600s), max 365 days (31536000s).
if [ "$seconds" -ge 3600 ] && [ "$seconds" -le 31536000 ]; then
printf '%s\n' "$seconds"
return 0
fi
fi
# Invalid input — log warning and fall back to default 30 days (2592000s).
echo "[WARN] Invalid Pause timeout '${raw}'; using default 30 days" >&2
printf '%s\n' "2592000"
}
Bug query through the tracker MCP. Apply the Bug limit per-type cap (default 0 = no per-type cap). Apply the Max issues per run total cap (default 1) as a hard ceiling across all issue types.Feature query through the tracker MCP (skip if bug-only mode). Apply the Feature limit per-type cap (default 0 = no per-type cap). Apply remaining headroom from Max issues per run as ceiling.Bug query results → classify as bug.Feature query results → classify as feature.bug (bug wins on overlap per roadmap rule; prevents double-dispatch).For each classified issue in turn, SEQUENTIALLY (one at a time):
status == "paused":state_file=".agent-flow/${ISSUE_ID}/state.json"
if [ -f "$state_file" ]; then
current_status=$(jq -r '.status // empty' "$state_file" 2>/dev/null)
if [ "$current_status" = "paused" ]; then
asked_at=$(jq -r '.clarification.asked_at // empty' "$state_file" 2>/dev/null)
# Cross-platform ISO-8601 → epoch conversion (uses python3 fallback for BSD/macOS).
# `date -d` is GNU-only; BSD/macOS `date -d` returns 0, causing premature auto-abort on first scan.
# Strategy: try python3 first (universally available on modern systems), fall back gracefully.
_iso_to_epoch_crossplatform() {
local _ts="$1"
local epoch=""
if command -v python3 >/dev/null 2>&1; then
epoch=$(python3 -c "import datetime, sys; print(int(datetime.datetime.fromisoformat(sys.argv[1].replace('Z', '+00:00')).timestamp()))" "$_ts" 2>/dev/null)
else
# Last resort: GNU date -d (Linux only; returns empty on BSD/macOS — caller handles empty).
epoch=$(date -d "$_ts" +%s 2>/dev/null)
fi
# Warn when conversion fails on a non-empty input (e.g., BusyBox Alpine minimal host
# with neither python3 nor GNU date). Pause-timeout auto-abort is silently disabled otherwise.
if [ -z "$epoch" ] && [ -n "$_ts" ]; then
echo "[WARN] Pause timeout calc failed: neither python3 nor GNU date available. Pause-timeout auto-abort disabled on this host. Install python3 to enable." >&2
fi
printf '%s\n' "$epoch"
}
asked_epoch=$(_iso_to_epoch_crossplatform "$asked_at")
now_epoch=$(date +%s)
if [ -n "$asked_epoch" ] && [ "$asked_epoch" -gt 0 ] 2>/dev/null; then
pause_age_seconds=$(( now_epoch - asked_epoch ))
else
pause_age_seconds=0
fi
pause_timeout_seconds=$(parse_pause_timeout "${PAUSE_TIMEOUT:-30 days}") # default 30 days
if [ "$pause_age_seconds" -gt "$pause_timeout_seconds" ]; then
# Timeout elapsed — promote to aborted_by_system
jq '.status = "aborted_by_system" | .abort_reason = "clarification_timeout"' "$state_file" > "$state_file.tmp" && mv "$state_file.tmp" "$state_file"
echo "[INFO] ${ISSUE_ID}: clarification timeout exceeded — transitioned to aborted_by_system"
else
echo "[INFO] Skipping ${ISSUE_ID}: awaiting clarification"
fi
continue # skip this issue; do NOT re-dispatch
fi
fi
status == "paused" AND elapsed time since clarification.asked_at > PAUSE_TIMEOUT_SECONDS: transition state to aborted_by_system with abort_reason: "clarification_timeout" and log the timeout line, then continue (skip dispatch).status == "paused" AND elapsed time ≤ PAUSE_TIMEOUT_SECONDS: log [INFO] Skipping {ISSUE_ID}: awaiting clarification and continue (skip dispatch).pipeline-completed MUST NOT fire on pause — this skip path never invokes child skills, so no pipeline-completed event is emitted.disable-model-invocation: true in their frontmatter, which blocks the Skill tool dispatch path; plain-text headless invocation via claude -p is the only reliable workaround):# Ensure per-issue artifact directory exists
mkdir -p ".agent-flow/${ISSUE_ID}"
# Select target skill per classification ("bug" | "feature")
if [ "$classification" = "bug" ]; then
TARGET_SKILL="/agent-flow:fix-bugs"
elif [ "$classification" = "feature" ]; then
TARGET_SKILL="/agent-flow:implement-feature"
fi
# Dispatch as isolated child claude session (plain-text bypass of disable-model-invocation).
# Child session writes state.json to .agent-flow/${ISSUE_ID}/state.json per standard pipeline.
claude -p "Run ${TARGET_SKILL} ${ISSUE_ID}" \
--dangerously-skip-permissions \
> ".agent-flow/${ISSUE_ID}/dispatch-stdout.log" \
2> ".agent-flow/${ISSUE_ID}/dispatch-stderr.log"
child_exit=$?
Rationale: per-issue child-session isolation also provides crash containment (a crashed child cannot poison the parent autopilot session) and mirrors the cron-invocation pattern exactly. Token cost: ~2-5k per child-session startup — acceptable given the isolation benefits. Re-evaluate restoring Skill-tool dispatch if Anthropic ships a selective-invocation whitelist primitive.
child_exit and the child's state.json:child_state_file=".agent-flow/${ISSUE_ID}/state.json"
if [ "$child_exit" -eq 0 ] && [ -f "$child_state_file" ]; then
child_status=$(jq -r '.status // "unknown"' "$child_state_file" 2>/dev/null)
case "$child_status" in
completed) outcome="success" ;;
blocked) outcome="block" ;;
paused) outcome="paused" ;; # NEEDS_CLARIFICATION — not an error; Step 1a handles on next run
*) outcome="error" ;; # unexpected status or jq read failure
esac
else
outcome="error"
fi
Classification:
success — child_exit == 0 AND state.json.status == "completed".block — child_exit == 0 AND state.json.status == "blocked" (block comment already posted by child).paused — child_exit == 0 AND state.json.status == "paused" (NEEDS_CLARIFICATION; next autopilot run's Step 1a will enforce Pause Limits and either skip or auto-abort).error — child_exit != 0, OR state.json missing, OR unexpected status value.error:
On error: stop → log [autopilot][ERROR] Dispatch returned error for {ISSUE-ID}. On error=stop — breaking dispatch loop., DO NOT dispatch remaining issues, proceed to Step 7 (the EXIT trap will release the lock; exit code non-zero).On error: skip (default) → log [autopilot][WARN] Dispatch returned error for {ISSUE-ID}: {error message}. Continuing with next issue., proceed to the next issue.block → log [autopilot][INFO] Issue {ISSUE-ID} blocked by child skill. Continuing with next issue., proceed to the next issue. A per-issue block is not a per-issue error.
5a. If outcome is paused → log [autopilot][INFO] Issue {ISSUE-ID} paused awaiting clarification. Continuing with next issue., proceed to the next issue. A per-issue paused is not a per-issue error; Step 1a on the next autopilot run will enforce Pause Limits.success → log [autopilot][INFO] Issue {ISSUE-ID} completed ({pipeline}). Duration={D}s., proceed to the next issue.Important: Autopilot itself does NOT fire webhooks per issue. The child fix-bugs / implement-feature skills fire their own pipeline-started / step-completed / pipeline-completed events (see ../../core/post-publish-hook.md Section 4). Autopilot is a pure dispatcher at the observability layer.
After all issues are processed (or after an On error: stop break):
Emit a markdown summary table to stdout:
## Autopilot summary
| Issue ID | Type | Outcome | Duration | Tokens |
|----------|---------|----------|----------|---------|
| PROJ-42 | bug | success | 412s | 198,200 |
| PROJ-43 | bug | block | 58s | 31,400 |
| PROJ-57 | feature | success | 903s | 401,500 |
| ... | ... | ... | ... | ... |
**Totals:** {N_bugs} bugs, {N_features} features, {N_success} success, {N_block} blocked, {N_error} errored. Wall-clock: {total_duration}s. Tokens (measured when available): {total_tokens}.
Tokens column is read from the per-issue state.json.pipeline.total_tokens after each child dispatch completes. If absent (child exited without writing a completed pipeline accumulator), the column reads —.
Append the run summary to $LOG_FILE (the Log file config key, default .agent-flow/autopilot.log). One line per Autopilot invocation in the format:
{ISO8601}|{run_id}|{issues_processed}|{n_success}|{n_block}|{n_error}|{total_tokens}|{total_duration_ms}
Where run_id is the Autopilot invocation ID (autopilot-{YYYYMMDDTHHMMSSZ}), issues_processed is the count of issues dispatched, and total_tokens is the sum of per-issue state.json.pipeline.total_tokens (or 0 when not available). On write failure: log [autopilot][WARN] Log file not writable: {error} and continue — log failure never blocks the exit path.
Lock release is AUTOMATIC via the trap registered in Step 2 (EXIT handler). Operators MUST NOT manually rm -rf the lock directory from inside this skill.
Exit codes:
0 — all issues dispatched (some may have block or recoverable error outcomes; On error: skip).1 — preflight config validation failed (Step 0 — missing Bug query in Issue Tracker).2 — lock held by another run (fresh or stale-recovery failure).3 — MCP unreachable at Step 0 (no lock was acquired).On error: stop.| Exit | Meaning | Lock acquired? | State written? |
|---|---|---|---|
| 0 | All issues dispatched, or Dry run, or empty queue | Yes (released) / No (dry-run) | Per-issue by child skills (or no if dry-run) |
| 1 | Preflight failure (missing Bug query in Issue Tracker) | No | No |
| 2 | Lock held by another run | No | No |
| 3 | MCP ping failed — tracker unreachable | No | No |
| >0 (other) | Dispatch loop broke due to On error: stop | Yes (released) | Per-issue up to the erroring dispatch |
Operators configuring cron SHOULD capture exit codes: cron MAILTO + set -o pipefail + appending || echo "[autopilot] exit=$?" >> /var/log/autopilot.log is the recommended minimum harvest pattern.
Multi-host deployments against the SAME tracker are NOT coordinated by Autopilot's process-local lock. The plugin mitigation is OPERATOR-SIDE DISJOINT-QUERY configuration:
Bug query / Feature query filters, for example:
Bug query: State: Open and assignee: bot-host-aBug query: State: Open and assignee: bot-host-bAutopilot emits [autopilot][INFO] Running on host {hostname} on every successful lock acquisition (Step 3). Log aggregators can correlate per-host autopilot activity; however, the plugin does NOT automatically detect cross-host contention. See docs/guides/autopilot.md#single-host-operation for onboarding and troubleshooting.
Tracker-level distributed lock is NOT_IN_SCOPE for the current release.
Multi-host coordination via disjoint queries is the supported pattern. Distributed lock (e.g., flock advisory lock, external coordinator like etcd/redis/consul) is deferred pending operator demand and a portability test matrix (local FS + NFS + SMB + S3FUSE tiers). Half-implemented locks are MORE dangerous than disjoint queries — they create silent duplicate-execution failure modes.
$ claude -p "Run /agent-flow:autopilot --dry-run" --dangerously-skip-permissions
[DRY RUN] Autopilot dry-run mode — full short-circuit. No lock, no state, no webhook, no dispatch.
[autopilot][INFO] Dry run mode — short-circuit. No lock, no state, no webhook, no dispatch.
[autopilot][INFO] Would process: Bug query=State: Open and type: Bug, Feature query=State: Open and type: Feature, Max issues per run=1, Bug limit=0, Feature limit=0
Dry-run is safe to schedule in parallel with a live Autopilot run because it touches NO shared state.
[autopilot][ERROR] Another Autopilot run in progress → check .agent-flow/autopilot.lock/owner.json for the owning PID and host. If the owning process is gone but the lock is less than the effective stale threshold (the configured Lock timeout value plus a 5-minute NFS/CIFS clock-skew buffer; default: 125 min on primary path, 121 min on BusyBox fallback), wait for stale auto-recovery or manually rm -rf .agent-flow/autopilot.lock/ (only after verifying no live process).[STOP] MCP unreachable → run /agent-flow:check-setup to diagnose tracker MCP configuration. Autopilot does NOT retry MCP pings; next cron cycle will re-attempt.[autopilot][WARN] Feature Workflow section absent → expected for bug-only projects; no action needed.[autopilot][WARN] Feature limit=N configured but no Feature query → either remove Feature limit from ### Autopilot or add Feature query to ### Feature Workflow.--dangerously-skip-permissions blast radius: The canonical Autopilot invocation passes --dangerously-skip-permissions to claude -p. This flag disables Claude's interactive permission prompts for file writes, tool dispatch, and bash command execution, granting Autopilot (and all child skills it dispatches) unrestricted permission for the entire run.
Containment guidance for operators:
Bug query and Feature query — issue content (title, description, comments) is fed to opus-powered fixer agents that then run bash commands and write files. A poisoned issue in the tracker can influence agent behavior under --dangerously-skip-permissions.SSRF defenses for the Webhook URL config key (e.g., blocking file:///gopher:// schemes) are deferred to a future release. See docs/reference/config.md Notifications section for current operator-trust guidance.
pid == $$ before rm -rf.### Feature Workflow section — always emit [WARN] before falling back to bug-only mode.[autopilot][INFO], [autopilot][WARN], [autopilot][ERROR], [STOP] (for MCP unreachable at Step 0).docs/guides/autopilot.md#single-host-operation in the cross-host INFO line (Step 3) and in error recovery guidance.