| name | autopilot |
| description | Headless dispatcher that reads Bug query / Feature query from Automation Config, classifies issues, and dispatches existing skills (fix-bugs / implement-feature). Lock-file protected. For cron / batch / CI invocation. |
| allowed-tools | mcp__*, Bash, Read, Write, Edit, Grep, Glob, Skill, Task |
| disable-model-invocation | true |
| argument-hint | [--dry-run] |
Autopilot
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.
Scope & Boundaries
- Dispatcher only — this skill never itself modifies code, writes PRs, or runs tests. Those responsibilities belong to the child skills (
fix-bugs, implement-feature).
- Process-local lock —
.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.
- Sequential dispatch — issues are dispatched one at a time. No per-issue parallelism at the Autopilot layer.
- Top-level observability only — Autopilot itself fires NO per-iteration webhooks. Child skills fire their own
pipeline-started / step-completed / pipeline-completed events per-issue.
- Dry-run is a full short-circuit —
Dry run: true means no lock, no state.json, no webhook, no dispatch.
Configuration
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 keys
The 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.
Process
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.
Step 0: Preflight — config validation + MCP ping
- Parse
## Automation Config via ../../core/config-reader.md.
- If
## 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.
- If
### 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.
- Validate
Bug query is resolvable from ### Issue Tracker.Bug query. This is a defensive re-check (Step 3 above should have already caught the absence).
- MCP pre-flight ping: follow
../../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.
Step 1: Dry-run short-circuit check
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.
Step 2: Acquire lock (mkdir-based, portable bash)
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):
iso_to_epoch() {
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 -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_DIR="$(pwd)/.agent-flow/autopilot.lock"
OWNER_PID=$$
OWNER_HOST=$(hostname)
OWNER_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
LOCK_TIMEOUT=${LOCK_TIMEOUT:-120}
LOCK_TIMEOUT_WITH_BUFFER=$((LOCK_TIMEOUT + 5))
write_owner_json() {
printf '{"pid":%s,"hostname":"%s","acquired_at":"%s"}\n' \
"$OWNER_PID" "$OWNER_HOST" "$OWNER_TIME" \
> "$LOCK_DIR/owner.json"
}
install_trap() {
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() {
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
if mkdir "$LOCK_DIR" 2>/dev/null; then
write_owner_json
install_trap
log_single_host_info
else
if [ ! -f "$LOCK_DIR/owner.json" ]; 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 (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
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
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
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.
- The trap verifies
owner.json.pid matches $$ before rm -rf — refuses to nuke another process's lock.
- ISO-8601 parsing uses pure bash + awk; no GNU-date
-d, no BSD-date -j -f, no Python 3 dependency. Portable to Linux, Windows Git Bash, macOS.
- Empty or malformed
owner.json triggers defensive stale-recovery instead of hard failure.
- Stale threshold carries a +5 minute buffer to absorb NFS/CIFS clock skew.
- No sidecar files are written outside
$LOCK_DIR. Cross-host awareness is an INFO log line only (Step 3), never a persistent hint file.
Step 3: Cross-host INFO (AUTOPILOT-R13)
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.
Step 4: Read Bug + Feature queries
- Read
Bug query — required. Source: ### Issue Tracker.Bug query. If absent, Step 0 would have already exited 1; this is a defensive re-check.
- Read
Feature query (optional). Source: ### Feature Workflow.Feature query.
- If
### 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.
- If
### 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.
- Determine the tracker MCP prefix from
### Issue Tracker.Type (default youtrack; supported: youtrack, github, jira, linear, gitea, redmine).
- Read
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 parser
Validates 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
raw="$(printf '%s' "$raw" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"
if [[ "$raw" =~ ^([0-9]+)[[:space:]]+([Hh][Oo][Uu][Rr][Ss]?|[Dd][Aa][Yy][Ss]?)$ ]]; then
n="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
unit_lower=$(printf '%s' "$unit" | tr '[:upper:]' '[:lower:]')
case "$unit_lower" in
hour|hours) seconds=$(( n * 3600 )) ;;
day|days) seconds=$(( n * 86400 )) ;;
esac
if [ "$seconds" -ge 3600 ] && [ "$seconds" -le 31536000 ]; then
printf '%s\n' "$seconds"
return 0
fi
fi
echo "[WARN] Invalid Pause timeout '${raw}'; using default 30 days" >&2
printf '%s\n' "2592000"
}
Step 5: Two-query classification loop
- Fetch BUGS first via
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.
- Fetch FEATURES via
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.
- Build the classification list. For each issue returned:
- If the issue appears ONLY in
Bug query results → classify as bug.
- If the issue appears ONLY in
Feature query results → classify as feature.
- If the issue appears in BOTH queries → classify as
bug (bug wins on overlap per roadmap rule; prevents double-dispatch).
- Produce an ordered dispatch list: bugs first (in tracker-returned order), then features (in tracker-returned order), all de-duplicated.
Step 6: Per-issue dispatch
For each classified issue in turn, SEQUENTIALLY (one at a time):
- Record per-issue start time (for the summary table in Step 7).
1a. Pause-state detection: Before dispatch, check whether the issue has an existing state.json with
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)
_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
epoch=$(date -d "$_ts" +%s 2>/dev/null)
fi
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}")
if [ "$pause_age_seconds" -gt "$pause_timeout_seconds" ]; then
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
fi
fi
- If
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).
- If
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.
- Dispatch via Bash subprocess (resolves upstream Claude Code #26251 — pipeline skills have
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):
mkdir -p ".agent-flow/${ISSUE_ID}"
if [ "$classification" = "bug" ]; then
TARGET_SKILL="/agent-flow:fix-bugs"
elif [ "$classification" = "feature" ]; then
TARGET_SKILL="/agent-flow:implement-feature"
fi
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.
- Capture per-issue outcome from
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" ;;
*) outcome="error" ;;
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.
- If outcome is
error:
- If
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).
- If
On error: skip (default) → log [autopilot][WARN] Dispatch returned error for {ISSUE-ID}: {error message}. Continuing with next issue., proceed to the next issue.
- If outcome is
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.
- If outcome is
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.
Step 7: Final summary
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).
- non-zero (other) — dispatch loop broke early due to
On error: stop.
Exit Code Matrix
| 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.
Cross-Host Operation
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:
- Preferred: run Autopilot from exactly ONE cron host.
- If unavoidable: configure per-host DISJOINT
Bug query / Feature query filters, for example:
- Host A:
Bug query: State: Open and assignee: bot-host-a
- Host B:
Bug query: State: Open and assignee: bot-host-b
Autopilot 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.
Dry-Run Example
$ 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.
Troubleshooting
[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.
Security Considerations
--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:
- Run Autopilot as a dedicated low-privilege OS user with filesystem access scoped to the project directory.
- Consider wrapping the invocation in a container or chroot to limit blast radius to the project tree.
- Audit
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.
- Restrict network egress from the Autopilot host if the tracker is internal; this limits exfiltration risk from compromised issue content.
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.
Rules
- NEVER modify code directly — Autopilot is a dispatcher only.
- NEVER fire webhooks at the Autopilot layer — child skills own their own lifecycle events.
- NEVER acquire the lock in dry-run mode.
- NEVER write state.json at the Autopilot layer — per-issue state is owned by child skills.
- NEVER remove another process's lock directory — the trap verifies
pid == $$ before rm -rf.
- NEVER silently ignore a missing
### Feature Workflow section — always emit [WARN] before falling back to bug-only mode.
- ALWAYS use the EXACT log prefixes:
[autopilot][INFO], [autopilot][WARN], [autopilot][ERROR], [STOP] (for MCP unreachable at Step 0).
- ALWAYS reference
docs/guides/autopilot.md#single-host-operation in the cross-host INFO line (Step 3) and in error recovery guidance.