Skip to content
Troubleshoot Common Failures

How to Troubleshoot Common Failures

Each section below covers one failure: the log line you see, why it happens, and what to do. For the full error catalog with every error kind and retry formula, see the error reference.

Agent won’t start

level=ERROR msg="worker run failed, non-retryable, releasing claim" error="agent: agent_not_found: agent command \"claude\" not found: exec: \"claude\": executable file not found in $PATH"

The agent binary isn’t installed or isn’t on PATH.

  1. Check whether the binary exists:

    which claude
  2. If it’s installed under a different name or path, set agent.command:

    agent:
      kind: claude-code
      command: /usr/local/bin/claude-code
  3. For SSH workers, the binary must exist on every remote host. Exit code 127 in logs means the remote host is missing it:

    ssh build01.internal "which claude && echo ok"
  4. Confirm the fix: sortie validate ./WORKFLOW.md

Agent crashes on authentication

level=WARN msg="worker run failed, scheduling retry" error="agent turn 1: agent: port_exit: exit code 1" next_attempt=1 delay_ms=10000

Workers start and immediately crash. The actual cause (a missing ANTHROPIC_API_KEY) lives inside the agent subprocess, not in Sortie’s error output. This is the most common deployment failure.

  1. Verify the variable is set:

    echo "${ANTHROPIC_API_KEY:-(unset)}"
  2. For AWS Bedrock or Google Vertex AI, verify all required variables are set. See environment variables reference for the full list.

  3. Read the agent stderr warnings immediately above the error. When a session or a turn fails, Sortie re-emits what the agent wrote to its standard error at WARN, so the runtime’s own auth message is already in the default log. --log-level debug adds every stderr line as it is read, including from turns that did not fail.

  4. For SSH workers, check what the launch carried. A remote agent gets the build host’s environment, plus its agent kind’s credential variables and whatever worker.ssh_pass_env names, read from Sortie’s own environment. A name Sortie cannot supply is reported at startup:

    level=WARN msg="ssh_pass_env variable is not set or empty in the orchestrator environment" variable=ANTHROPIC_API_KEY

A remote agent authenticates as the wrong account

The session starts and turns run, but the work lands under an identity nobody expected: a pull request opened by the tracker’s service account, or a Copilot session billed to the wrong seat.

A variable Sortie carries overrides the value or login the remote host holds under that name. The usual cause is one variable serving two purposes. tracker.api_key: $GITHUB_TOKEN puts GITHUB_TOKEN in Sortie’s environment for the tracker; copilot-cli declares GITHUB_TOKEN as one of its credential variables; every remote session then signs in with the tracker’s token instead of the host’s own copilot auth login.

  1. Check which names your agent kind carries by default in the environment reference.

  2. Stop Sortie sending the one that collides:

    extensions:
      worker:
        ssh_hosts:
          - "build01.internal"
        ssh_disallow_pass_env:
          - GITHUB_TOKEN
  3. Save the file. The worker block reloads without a restart, and the next dispatch runs under whatever identity the host itself holds.

A variable your own ssh_config lists under SendEnv is forwarded by the SSH client itself, out of Sortie’s environment, and ssh_disallow_pass_env does not stop it. Remove the SendEnv entry if that is the path it took.

A remote launch fails before the agent runs

level=WARN msg="agent stderr" line="sortie: dd is required on the remote host to receive environment variables"

Sortie delivers environment variables to a remote agent on the SSH session’s standard input, and the remote shell reads them with dd. A host without dd on PATH fails the launch, and Sortie retries it, so the same line repeats until the utility is there.

Every remote opencode launch carries variables whatever your configuration says, because the adapter sends settings of its own. Other kinds carry them whenever their credential variables are set in Sortie’s environment or worker.ssh_pass_env names something.

  1. Check each host:

    ssh build01.internal "command -v dd && echo ok"
  2. Install it where it is missing. It ships with coreutils and with busybox, so most base images have it; distroless and scratch-based images often do not.

Agent exits without producing output

level=WARN msg="agent exited without producing output, treating as failure"
level=WARN msg="worker run failed, scheduling retry" error="agent turn 1: agent: turn_failed: agent exited without producing output: no message from the agent and no tool call" next_attempt=1 delay_ms=10000

The agent subprocess exited with code 0 without reporting a turn outcome, and the adapter found no evidence the model produced anything. Evidence is a message from the agent or a tool call, read from that turn’s own stream, and the error line names the signals the adapter looked for after a colon. Claude Code, Copilot CLI, and OpenCode look for both, so their line ends no message from the agent and no tool call. Kiro reads a plain transcript that reports no tool activity, so it looks for a message only and its line ends no message from the agent. Sortie treats every one of these as turn_failed and retries with exponential backoff. Common causes:

  1. MCP config parsing failure. The agent failed to parse --additional-mcp-config or --mcp-config and exited silently. Check the WARN-level log lines immediately above the error. Sortie emits the agent’s stderr content, which contains the parse error. On codex and opencode a bad MCP configuration fails differently: those adapters read it themselves before the agent starts, so the session ends with a response_error naming the file rather than a silent exit.

  2. Missing or invalid model configuration. The agent started but the configured model was unavailable, causing an immediate exit before any LLM work.

  3. Rate limiting during initialization. The agent hit an API rate limit before producing any output.

Run with --log-level debug to see the full subprocess stderr. Fix the root cause (correct the config path, set the right API key, wait for rate limits to clear) and Sortie’s exponential backoff retries will succeed automatically.

Copilot CLI stops without finishing the task

level=WARN msg="copilot turn ended without a task-completion report" autopilot_continuations_observed=50 max_autopilot_continues=50
level=WARN msg="worker run failed, scheduling retry" error="agent turn 1: agent: turn_incomplete: agent stopped without reporting the task complete: raise copilot-cli.max_autopilot_continues if the turn needs more steps" next_attempt=1 delay_ms=10000

Copilot CLI exited cleanly, but the turn stopped before it produced a session.task_complete report: the --max-autopilot-continues ceiling was reached mid-task. Only the Copilot CLI adapter can report this; every other agent adapter still has no way to detect a turn cut off partway through.

  1. Raise copilot-cli.max_autopilot_continues (default 50) if the task genuinely needs more autopilot steps per turn. See agent.max_turns vs. copilot-cli.max_autopilot_continues for how this budget relates to agent.max_turns.

  2. Let the retry run. turn_incomplete is retried on the same exponential backoff as other transient turn failures, and the retry resumes the same Copilot session rather than starting over, so raising the ceiling is often enough on its own.

  3. Tell it apart from a stall or a genuine failure. A stall reports turn_cancelled; an agent that reported its own failure, or exited with nothing to show for the turn, reports turn_failed. turn_incomplete means specifically that the turn’s result event arrived and the exit was clean, but no session.task_complete report ever did.

A turn runs long and gets cut off

level=WARN msg="turn timeout exceeded" issue_id="PROJ-42" issue_identifier="PROJ-42" session_id="session-abc-001" turn_timeout_ms=1800000 turn_number=2
level=WARN msg="worker run failed, scheduling retry" issue_id="PROJ-42" issue_identifier="PROJ-42" session_id="session-abc-001" error="agent turn 2: agent: turn_timeout: turn exceeded the configured 1800000 ms bound; the adapter's own report follows: context deadline exceeded" next_attempt=1 delay_ms=10000

The turn ran longer than agent.turn_timeout_ms. The attempt fails and is retried on the usual exponential backoff; it is not abandoned.

  1. Tell it apart from a stall. A turn timeout reports turn_timeout; a stall reports turn_cancelled and fires on silence rather than duration, regardless of how long the turn has been running. See the error reference for both error kinds.

  2. Set a larger value if the task is genuinely long-running. See how to configure retry behavior for the tradeoffs between a longer turn timeout and the stall-detection ratio.

A run stops because it needs a person

level=WARN msg="agent asked for a decision only a person can make, ending the attempt"
level=ERROR msg="worker run failed, non-retryable, releasing claim" error="agent turn 2: agent: turn_input_required: agent asked for a decision only a person can make: an answer to a question"

The agent asked for something no unattended run can supply: an answer to a question, or a permission the runtime gave Sortie no way to refuse and still continue. Sortie refuses rather than consenting on your behalf, and ends the attempt rather than waiting. The claim is released, no retry is scheduled, and the run is recorded with status needs_person rather than failed.

  1. Read the message tail, not just the error kind. The text after turn_input_required: names what the agent asked for. an answer to a question and wider filesystem or network access are the two tails in use today.

  2. Do not reconfigure the agent for non-interactive mode. It already is. Every runtime is launched in a mode that cannot ask interactively, and a pass-through setting that would undo that is refused before the run starts. This ending happens on the paths that survive that launch posture.

  3. Give the agent what it lacked, outside the run. If it wanted wider access, widen the sandbox: for Codex, codex.thread_sandbox and codex.turn_sandbox_policy. If it asked a question, the answer belongs in the issue or in the prompt template, so the next dispatch does not need to ask. See the Codex adapter reference for which requests end an attempt and which ones the agent can work around.

  4. Narrow the task if neither applies. An issue whose resolution genuinely needs a human decision is not work an unattended agent can finish, and repeated needs_person runs on the same issue are the signal to take it out of the dispatch set.

A session is stopped in flight by the token budget

level=WARN msg="run stopped by token ceiling" issue_id="PROJ-42" issue_identifier="PROJ-42" session_id="session-abc-002" reason=token_budget used_tokens=1503417 budget_tokens=1500000 issue_tokens_completed=1481200 session_tokens=22217 sum_source=confirmed_read ceiling_setting=agent.max_tokens unmeasured_sessions=0

The issue’s cumulative token spend reached agent.max_tokens while a session was running, so Sortie cancelled the worker rather than let the session run to the end over budget. The attempt is recorded with status budget_stopped, the claim is released, and no retry is scheduled. At the next poll tick the issue enters the budget-exhausted set, and that is what posts the comment naming the ceiling on the tracker.

  1. Tell it apart from a stall or a reconciliation kill. Those cancel the same worker context, so the agent reports the same turn_cancelled error either way. The recorded status is what separates them: only the token ceiling records budget_stopped. See the worker exit kinds table.

  2. Read session_tokens against issue_tokens_completed before changing anything. session_tokens is what the cancelled session had spent; issue_tokens_completed is what the issue’s earlier sessions had already banked. When the second figure sits just under the budget on its own, the session had almost no room from the start, and raising the ceiling buys the issue another attempt rather than a longer one. When session_tokens carries most of the total, one session is spending the whole budget and agent.max_turns or the adapter’s own per-turn cap is the tighter lever.

  3. Raise the ceiling only if the work is worth it. agent.max_tokens reloads from WORKFLOW.md without a restart, and the new value reaches the sessions already running from the next poll tick. See how to control agent costs for choosing a figure.

Issue keeps re-running and never advances

level=WARN msg="handoff withheld by evidence policy" issue_id="PROJ-42" issue_identifier="PROJ-42" policy="observed" verdict="absence of work observed" reason="workspace commit and working tree match the run baseline" turns_completed=2 consecutive_absences=1

Sortie’s default tracker.handoff_evidence policy withholds the handoff transition when a run leaves the workspace exactly as it found it. The issue stays in its active tracker state, and Sortie retries it on exponential backoff rather than failing silently or moving it forward on the strength of an exit code alone.

  1. Check what the agent actually did. A withheld run names its verdict as the run’s failure reason in run history. If the agent reported success but changed nothing in the workspace, that is exactly the case this policy exists to catch, unless the requested outcome had genuinely already held, which is the next case.

  2. A run that finds nothing to change should say so. If the issue’s outcome already held before the agent started, the agent can write no-change-needed to .sortie/status instead of leaving the workspace silently unchanged. Sortie appends this instruction to every first-turn prompt automatically, so no prompt template change is needed. A declaration that survives self-review (where enabled) is never withheld and does not count toward the ceiling in item 3 below. It records the run as succeeded and moves the issue to tracker.no_change_state if one is configured. See state machine reference: declaring that nothing needed changing. A run that changes nothing and declares nothing keeps the withheld outcome described above.

  3. A dispatch whose only product is a tracker write is a known false positive. If your agent’s entire job is calling tracker_api to transition the issue itself, set tracker.handoff_evidence: off. See workflow configuration.

  4. Repeated absences park the issue. After a bounded number of consecutive withheld runs, Sortie stops retrying and applies an escalation label instead of looping forever. See park issues stuck in a loop of empty runs.

  5. Not every workspace can be measured. A workspace that is not a Git work tree changes nothing under the default policy. Only strict withholds there, and it withholds every transition. See the state machine reference.

Tracker returns 401 or 403

level=ERROR msg="failed to fetch candidate issues" error="tracker: tracker_auth_error: HTTP 401: Unauthorized"

The API token is wrong, expired, or lacks required permissions. Sortie does not stop polling on this error. It logs it and retries on the next poll interval, so you will see it repeat until you fix the credential.

  1. Verify the environment variable resolves to a non-empty value:

    echo "${SORTIE_JIRA_API_KEY:-(unset)}"
  2. Test the token directly:

    curl -s -u "you@company.com:your-api-token" \
      "https://yourcompany.atlassian.net/rest/api/3/myself" | head -5

    That is the pairing Sortie sends for a key in email:token form. A Data Center personal access token carries no colon and goes out as a bearer credential instead.

  3. If you use handoff_state, in_progress_state, or tracker.comments, the token needs to be able to write to issues, not only read them.

Sortie won’t start: endpoint is rejected

level=ERROR msg="failed to construct tracker adapter" error="tracker: tracker_payload_error: gitea: endpoint \"https://gitea.example.com:abc\" is not a valid absolute http(s) url"

endpoint failed to parse as an absolute http or https URL carrying a hostname. This is a construction-time failure on every adapter that reads an endpoint (GitHub, Gitea, GitLab, Linear). Sortie refuses to start rather than letting a bad value reach the HTTP client and fail later as a network error. The same check runs offline through sortie validate, except for a Gitea CI or SCM endpoint set through a top-level gitea: override, which validate does not inspect.

Three shapes commonly trigger this:

  • A port with no host, such as http://:8080. Give the hostname: http://gitea.internal:8080.
  • An unbracketed IPv6 address, such as http://fd00::1:3000, the exact form an address prints as from ip addr. Add brackets around the address: http://[fd00::1]:3000.
  • A query string or fragment appended to the base URL, such as https://gitlab.example.com?insecure=1. Remove it; the adapter appends its own API path and has nowhere to put one.

If the endpoint carries a username or password, the error message masks it before printing, so a credential never appears in the log.

Template render fails

level=ERROR msg="template render error in WORKFLOW.md (line 24): can't evaluate field titel in type map[string]any"

Sortie runs templates in strict mode. Unknown variables are hard errors. Three common causes:

  • Typo in a field name. Check the name against the variable table. The error message names the exact field and line.

  • Unguarded nil field. .issue.parent is nil when no parent exists. Wrap it: {{ if .issue.parent }}{{ .issue.parent.identifier }}{{ end }}

  • Dot rebinding inside range. Inside {{ range .issue.labels }}, . is the current element. Use {{ $.issue.identifier }} to reach the root.

Run sortie validate ./WORKFLOW.md after every template edit to catch these before runtime.

Workspace won’t create

level=ERROR msg="workspace create: permission denied: /opt/sortie_workspaces/PROJ-42"

Three variants:

  • Permission denied. The process user can’t write to workspace.root. Fix permissions or change the root to a writable path like ~/sortie-workspaces.

  • Containment violation (path escapes root). An issue identifier produced a path outside the workspace root (a security boundary). Investigate the identifiers in your tracker.

  • Disk full. Check with df -h /opt/sortie_workspaces.

Hook script fails

level=WARN msg="after_create hook failed, rolling back workspace" issue_id=abc123 issue_identifier=PROJ-42 workspace=/opt/sortie_workspaces/PROJ-42 error="hook run: exit_code=128: exit status 128" hook_output="Cloning into '.'...\nfatal: Could not read from remote repository."

A hook exited non-zero. after_create and before_run failures are fatal for the attempt; after_run and before_remove are logged but ignored. For a fatal hook the orchestrator follows up with a worker run failed, scheduling retry line carrying the same error.

  1. Read the hook_output attribute on the WARN record. It holds the hook’s combined stdout and stderr at every log level; no debug flag is needed. The value keeps the last 8 KiB of output and starts with a truncation marker when earlier output was dropped. A hook that printed nothing produces no hook_output attribute. Output of successful hooks appears only at --log-level debug, on hook completed records.

  2. Test the hook manually:

    mkdir /tmp/test-ws && cd /tmp/test-ws
    git clone --depth 1 git@github.com:acme/backend.git .

    Common causes: SSH key not forwarded, wrong repo URL, missing dependencies. Hooks run with a restricted environment that strips variables like GIT_SSH_COMMAND; see the environment reference.

  3. For timeout errors, increase hooks.timeout_ms in WORKFLOW.md.

Issues not being dispatched

level=INFO msg="tick completed" candidates=0 dispatched=0 ... running=0 retrying=0 ...

(the tick completed line carries more fields than shown here; see How to monitor Sortie with logs for the full field list)

Sortie is polling but finds nothing to dispatch.

  1. State names must match exactly. Verify tracker.active_states matches your tracker (case-sensitive). "To Do" and "to do" are different states.

  2. Use dry-run to see what Sortie would dispatch:

    sortie --dry-run ./WORKFLOW.md

    Each candidate gets a would_dispatch or skip_reason field in the log.

  3. Concurrency cap reached. If running equals agent.max_concurrent_agents, new issues wait. Increase the cap or wait for running agents to finish.

  4. Query filter too narrow. A typo in tracker.query_filter returns zero results. Use --dry-run --log-level debug to see the full query.

  5. A blocker hasn’t cleared, or its list couldn’t be read. An issue held for this reason carries a skip_reason in dry-run output: blocked_by means a listed blocker hasn’t reached a terminal state yet. blockers_unresolved and blockers_not_read mean Sortie couldn’t read the blocker list this poll (a failed read, or the per-poll read budget was already spent on other candidates) and will retry on a later poll. This applies to GitHub and Gitea, which read dependencies separately from the candidate list. See candidate eligibility for the full gate, and sortie_candidate_holds_total on the Prometheus metrics reference to watch this over time instead of one poll at a time.

  6. A per-issue budget ceiling was reached. An issue held by agent.max_sessions or agent.max_tokens stays in its active tracker state and is skipped on every poll. Check GET /api/v1/{identifier} for status: "budget_exhausted", the dashboard’s Budget blocked table, or grep your logs for blocking re-dispatch. Sortie also posts one comment on the issue naming the ceiling that stopped it, and that comment counts the issue’s sessions the token ceiling stopped in flight, if any. See how to control agent costs.

Sortie won’t start at all

dispatch preflight failed: tracker.kind is required

Sortie validates the config at startup and reports all failures at once. Run sortie validate ./WORKFLOW.md to see every problem, including advisory warnings for typos in YAML keys and type mismatches that would silently fall back to defaults at runtime. The most common missing fields:

FieldRequired by
tracker.kindAlways
tracker.projectJira adapter
tracker.api_keyJira adapter (after $VAR expansion)
active_states or terminal_statesAt least one non-empty

If $VAR references aren’t resolving, verify the variables are exported in the shell that runs Sortie:

env | grep SORTIE

See the workflow configuration reference for every field, default, and constraint.

Was this page helpful?