- Custom exceptions in
acp/transport_errors.py(re-exported byacp/client.py) for ACP protocol/prompt errors, inacp/session_handle_runtime_contract.py(re-exported byacp/session_handle.py) for runtime/transport errors, and inacp/runtime_session_setup.pyandacp/runtime_session_create.py(re-exported byacp/runtime.py) for runtime binding and session-start errors - Error strings at CLI boundaries (never expose tracebacks to users)
- Graceful degradation — partial output returned on timeout
When automatic posting carries a driver-accepted review snapshot, the driver restores that snapshot to the durable result after adopting delivery metadata and before confirming the draft. A failed confirmation preserves the accepted findings for manual retry and does not advance the posted-key ledger. A failed restoration write returns a per-item posting failure with a redacted error; the batch continues and generates its report from accepted snapshots. It does not claim that the durable record is safe for retry.
Two independent families. AcpError covers protocol and prompt-level failures;
AcpRuntimeError covers the process and request transport underneath it.
AcpError (base, acp/transport_errors.py) — carries `transient`, the retry verdict
├── AcpTimeoutError — prompt timed out, has partial_output
├── AcpPermissionNeeded — tool approval required
├── AcpProcessDied — kiro-cli exited unexpectedly
│ └── AcpRegistrationRateLimited — the death's stderr shows a throttled
│ dynamic registration (HTTP 429); transient, so the
│ retry ladders recover it instead of surfacing a
│ terminal generic death. Classified only while the
│ session has produced no text and run no tool, so
│ the verdict can never license a replay that
│ repeats side effects. An ambiguous-delivery death
│ (a stdin stall with the child alive) is never
│ this subclass: it stays a non-transient
│ AcpProcessDied with ambiguous_delivery set
├── AcpAuthRequired — kiro-cli not authenticated; non-retryable
├── AcpSandboxInitFailed — an OS sandbox refused to initialize; non-retryable
├── AcpDelegateLaunchFailed — the kiro-cli-chat delegate failed to launch (a stale
│ or version-skewed install); non-retryable, and not an
│ AcpProcessDied, so the chat shows the reinstall
│ message instead of a respawn loop
├── AcpKasExtractBroken — kiro-cli's unpacked KAS engine (kas/<version>) is
│ incomplete and could not be moved aside, or the fresh
│ copy failed the same way; non-retryable, not an
│ AcpProcessDied, and the message names the directory
│ and the mv command
├── AcpKasEngineRefused -- kiro-cli refused its v3 (KAS) engine on this host
│ ("V3 is currently not supported on this system",
│ acp/kas_engine_refusal.py); non-retryable, and the
│ runtime that met it records KAS unservable, in memory
│ and in the data home, so new sessions take the Kiro
│ CLI launch
├── AcpToolGateUnroutable — tool calls would bypass the PreToolUse gate;
│ non-retryable, wraps acp_tool_gate.ToolGateUnroutable
├── PiGateExtensionTampered — a shipped gate extension (Pi or DeepSeek) failed its digest check
├── AcpConversationBindingMismatch — preserved thinking is bound to a different
│ conversation prefix; non-transient, so retrying the
│ same native conversation is forbidden and the
│ dashboard may perform its bounded discard-and-replay
│ recovery
├── AcpModelUnavailable — requested model not entitled; non-retryable
└── AcpPromptBusy — a prompt is already in flight on this session
AcpRuntimeError (base, acp/session_handle_runtime_contract.py)
├── AcpRuntimeDead — the underlying process has died
├── AcpRequestTimeout — a request's response missed its budget
│ ├── AcpSessionStartTimeout — `session/new` timed out while a collector owns
│ │ the possible late result (acp/runtime_session_create.py)
│ └── AcpRuntimeOverloaded — `initialize` went unanswered while the agents
│ slice was throttled; transient=False, because
│ the remedy is freeing agent memory, not a retry
└── AcpWorkspaceBindingError — descriptor-bound runtime cannot serve another cwd
└── AcpToolSurfaceBindingError — a shared runtime cannot safely serve the
requested tool surface (acp/runtime_session_setup.py)
AcpToolGateUnroutable is a distinct type rather than a transport error because
the condition is a configuration fact: a respawn re-reads the same answer and
refuses again while consuming a reconnect budget meant for transport faults. The
same argument makes AcpAuthRequired and AcpModelUnavailable distinct — each
one is invalid on its own terms, so the retry ladder must be skipped rather than
walked. AcpRequestTimeout subclasses its base so existing
except AcpRuntimeError handlers keep catching it.
A retry DROPPED after its backoff hands back everything it counted before it. A spent one-shot silently disarms the next real recovery; a counted attempt shortens the next ladder and inflates its backoff seed, both for a retry that never reached the provider. A no-requeue exit ENDS the turn, so it owes the same per-turn budget refresh the landed and terminal arms do -- the whole ladder, not a decrement, because an arm that already spent attempts 1..2 would otherwise stay permanently short. The pending "retrying..." card is corrected by APPENDING a give-up row rather than retracted, so the affordance to continue comes back instead of the row simply disappearing.
| Boundary | Strategy |
|---|---|
| ACP → CLI | Catch AcpError, print user-friendly message, sys.exit(1) |
| Sandbox refusal → CLI and boot logs | SandboxCeilingUnsealable and SandboxUnavailableError sit outside the AcpError hierarchy, and each one's own text names the host's remedy. kirocrew chat (cli_chat._run_chat) prints that text as one line through safe_terminal_line and exits 1; the built-in adoption STAGE pass logs a SandboxUnavailableError as one warning carrying it. Neither prints a stack trace |
| JSON-RPC read | Non-JSON lines silently skipped (kiro-cli debug output) |
| Config load | Invalid JSON → log warning, return defaults |
| Integer field in a dashboard JSON body | Read through dashboard/body_numbers.body_int, which raises ValueError for 1e999/-1e999 (infinite floats after json.loads) where int() raises OverflowError. The handler's own except (TypeError, ValueError) then gives the infinite value the same 400 or fallback any other malformed value gets there, never a 500. A site does not add OverflowError to its own except clause instead |
Skill index (list_skills) |
One global SKILL.md that is not UTF-8 or cannot be opened → one warning naming the file, that row dropped, every other row listed. Never a failed listing: the index feeds every chat turn and GET /api/skills. Rationale: memory-skills-hooks |
| Process spawn | Backend-specific executable resolver, including trusted-path checks where required; clear error if missing |
A host that does not answer (kirocrew kas login, the KAS engine manifest fetch) |
The message names the host and leads with the cause in plain words. asset_downloader.network_failure_cause reads it from the exception types (urllib's URLError.reason, aiohttp's os_error), never their text: connection refused, the host name could not be resolved or timed out. Any other failure keeps the library's own text, which is then the only clue there is, and so does an aiohttp exception with no host (a connect timeout names its host only in its text) |
Notes git subprocess (md_notebook/git_ops.py) |
A non-zero exit reports git's last 3 stderr lines PLUS the first transport-caused line when one sits outside that tail. git ends every fetch/push failure with the same access-rights boilerplate and prints an SSH diagnosis first, so a tail alone reports a permission problem the operator does not have (a host reachable only via ~/.ssh/config never resolved at all). The extra line widens what the message reports; it does not decide whether the command failed. |
Auto-Improvement git step (auto_improvement/backend/clone_setup.py) |
A failed clone, branch list, checkout or fetch reports git's last 2 stderr lines after dropping blank lines and git's access-rights advisory (Please make sure you have the correct access rights / and the repository exists.), redacted and then bounded. The advisory follows every transport or auth fatal, so keeping it reports a repository problem in place of the Permission denied (publickey). line above it. |
App Store install clone, pinned fetch and pull (apps/registry_pipeline/checkout.py) |
A credential-free (catalog or index-tier registry) fetch of an ssh remote whose output holds ssh's own ssh: Could not resolve hostname line returns git_clone_failed_strict_ssh_route with a fixed sentence: the fetch ran in the strict sandbox, which hides ~/.ssh, so neither ~/.ssh/config nor the ssh-agent applied, and a host reachable only through an ssh config route is installed from the operator's own clone with kirocrew app install <dir>. It carries no proxy hint, because ssh never reads HTTPS_PROXY; an owner-credentialed clone and an HTTPS remote keep the network hint. |
| asyncio loop callback | A Windows Proactor reset repeated by its connection_lost close callback is warning-only; task-level connection resets and other exceptions remain ERRORs with crash breadcrumbs |
consolidate_apply validates the merge before any catalog write, backup,
candidate clearing, or audit append. The worker's text must parse as a ruleset;
it is then redacted, and the redacted text must still parse and must contain no
repeated pattern_id(title, scope) — uniqueness is judged on the text that
would actually be stored, because redaction can erase the only difference
between two titles. Any refusal returns ok: false; duplicate refusal also
returns duplicate_ids and the colliding duplicate_titles, and names those
titles in error, so the caller can merge each repeated title and scope into one
rule before retrying. The existing rules and staged candidates remain intact.
Different scopes may carry the same title. This guard does not identify semantic
near-duplicates or choose between conflicting guidance.
Dashboard JSON errors include a stable lower-snake code alongside advisory
error text, preserving the route's HTTP status. Redact untrusted text fields
before putting them in a transparent response dictionary. A computed status is
compliant when that dictionary carries an explicit code; an uncoded or opaque
body remains debt in test/test_error_code_contract.py. That guard also checks
literal code values on computed-status responses and refuses dictionary spreads
that could replace the code.
ErrorNotice's optional Ask the agent action resolves the structured report,
stages its prompt in the error hand-off FIFO, then navigates to /chat through
the imperative navigator installed by App. The installed navigator carries the
same useMayLeaveForNavigation answer used by shell links. sendErrorToChat
asks that answer before it writes the FIFO or notifies a mounted chat subscriber;
a veto therefore leaves the current page, its draft, and the hand-off queue
unchanged. With no registered page guard the answer remains true, preserving
the existing hand-off. The root error boundary's explicit hard-navigation mode
bypasses the live React tree: it stages, then leaves through hardNavigate,
a full page load of /chat unless the window installed a hard-exit sink
(installHardExit).
An app pop-out window (/popout/app/<route>) must never load the dashboard
itself, so it installs that sink (AppPopoutHardExit in
pages/AppPopoutFrame.tsx): the sink drains the staged prompt and hands /chat
to the main window through the pop-out channel. The shell installs
hardNavigate as that window's soft navigator too, with no leave guard, since
the pop-out's own page is never left. So every Ask the agent there, soft or
hard, reaches the main window's chat and leaves the popped-out app mounted with
its state; the main window asks its own leave guard before it navigates.
The ask happens exactly once per click. A surface that already has a leave gate
in scope (the notification sheet's crash fallback, through AskAgentButton's
gate built on useGuardedLeave) asks the page through that gate; the hand-off
it then runs passes leaveGranted to sendErrorToChat, which skips the
installed navigator's own ask. Both reads are the same
useMayLeaveForNavigation channel, and a page guard that confirms a draft away
keeps the draft dirty until the page unmounts, so a second ask was a second live
confirm — one whose "keep my draft" cancelled a hand-off the first ask had
already accepted. An ungated caller still asks through the navigator.
A failed run is a hand-off source too, not only a failed request (#7403). An
expanded failure or timeout row in a scheduled job's history (LogEntry)
renders AskAgentButton with a report built by utils/cronRunReport.prompt.ts:
job name and id, run id, trigger, start time, the row's summary, and the TAIL of
the run's trace (scrubbed by redactSecrets before the cut, capped at
MAX_TRACE_TAIL), so the agent receives the reason a run failed, which a run
reports at the end. The job and run ids let the agent read more history itself.
cancelled and success rows offer nothing. The hand-off goes to the ordinary
chat with no per-job "debug agent" setting: the agent that opens is the one the
user would otherwise paste the log into, and a history row holds no draft.
acp/transport_errors.py (re-exported by acp/client.py) rewrites raw JSON-RPC
backend errors into actionable user text (_format_acp_error) and decides
retry-eligibility (_is_transient_raw_error).
Both key off the SAME module-level _RE_* patterns so wording and retry verdict
never drift.
When a curated branch words the message (user_worded=True), that wording
replaces the provider's text: a named 5xx, an HTTP 50x and a "please try again"
hint all read "transient error (HTTP 5xx)", and a dispatch failure reads the
connection card. _raise_acp_error
therefore keeps the frame itself on AcpError.raw_detail (on the early
AcpPromptBusy raise too) through
raw_error_detail (acp/error_frames.py): code, message and data rendered as one line of compact
ASCII JSON (ensure_ascii, so neither a newline nor a Unicode line break such as
U+2028 in provider text can forge a log line). Every string in the frame
is scrubbed by redact_exfiltration_urls then redact_credentials in its own
spelling BEFORE the escaping, because escaping puts a backslash before each
quote and a quoted "aws_session_token": "..." inside a provider string would
no longer match; the rendered whole is scrubbed again, which catches a
key/value pair in a structured data. Only then is it cut to
RAW_ERROR_DETAIL_MAX_CHARS (2000) with the dropped length named. The dashboard
turn logs it at WARNING as ACP error frame in slot <key>: <frame>, on a line of
its own beside ACP error in slot ..., which the session-health scanner parses
and which keeps its shape. The scanner ignores a stall signature that follows
the raw-frame label, so a frame echoing one cannot mark a slot stalled. A fallback message already carries the provider's
text, so its raw_detail stays empty and no second line is written. Pinned by
test/test_acp_error_raw_frame_log.py.
Notable terminal (non-retryable) classes:
- Context window overflow: the provider's exact "The context window
overflowed" rejection becomes an ordinary
AcpErrorwithtransient=False,structural_terminal=True, andcontext_overflow=True._raise_acp_errorconstructs it through the commonAcpErrorpath and applies all three facts in the existing data-field-only structural tag block; an echo in the JSON-RPCmessagecannot classify an unrelated failure. It is terminal on the same native session: replaying the same startup envelope cannot make it smaller. A surface may replace the session or runtime only when no model text or tool side effect was observed; subagents use one shared-to-dedicated retry. - Malformed request: a structural rejection (backend "Improperly formed
request"). Classified TERMINAL: the identical payload cannot succeed on
retry, so the message states the request was malformed and points at a repair
affordance (
/compactto shrink and repair the conversation, or starting a new conversation) rather than suggesting a retry. The reset affordance is PROSE, not a command: this formatter does not know which surface renders the string, and the reset command differs per surface (/newon Telegram and Discord, a new tab on the dashboard), so naming one spelling hands every other surface's user a command that does nothing. A command may be named here only if every surface UNDERSTANDS it:/compactqualifies because it reaches the backend through the prompt transport everywhere, even on Slack, which also offers!compactas its own alias. The same rule governs the sibling prompt-busy branch, which for the same reason now names no command at all. - Lost backend session: when
acp_error_is_session_not_foundmatches, the turn resets the session binding and queues ONESYNTHETIC_RECOVERY_KINDretry with a reconnect notice, armed asReplayFamily.SESSION_NOT_FOUND. A Stop that already resolved suppresses it; the drain and consume seams veto it on a later Stop, a queued follow-up or steer, or a rebind (with a cancel notice), refunding the one-shot. A second loss on the same turn ends with the give-up text. The predicate matches a second raw answer with the same fresh-process remedy: a frame whosedatanameserrorKind: cloud_credential_error—_raise_acp_errorreads it structurally, tags theAcpErrorcredential_reload, and pins it non-transient, because the process's cached credentials cannot be re-loaded and a retry on THAT process only draws the failure again. It rides the reset-and-retry-once path unchanged, under the same one-retry budget and tool-activity gates, and is deliberately notauth_required: that tag offers the Kiro sign-in card, which signs in the wrong identity for a harness that loads its own credentials. The dashboard and channel surfaces word the retry for credentials (CREDENTIAL_RELOAD_RETRY_NOTICE), and when the fresh process fails too (the helper itself is broken: an expired SSO login, a revoked role) the give-up line carries the provider's own message (credential_give_up_text) instead of "start a new chat", which would fail the same way. That message is redacted insidecredential_give_up_textthroughredact_via_context, the egress path that finds link and credential spans on the original text and rewrites it once (so neither pass hides a value from the other), because the dashboard row and the channel post that show it do not redact on their own, and the frame's trailingdatarepr is dropped from it. - Preserved-thinking binding mismatch:
AcpConversationBindingMismatch(non-transient) means the stored native conversation carries thinking bound to another conversation prefix. A dashboard outer turn with no reply, thought or tool call yet, no attached sub-agent and no Stop, steer, queued follow-up or rebind discards the native resume SID once and queues ONESYNTHETIC_RECOVERY_KINDreplay of the turn, armed asReplayFamily.THINKING_BINDING. The one-shot is the poisoned-conversation one shared with the image-history recovery; the ledger's drain and consume seams veto the replay (refunding it) exactly as they do that family's. A refused rebuild falls through to the terminal error. - Unsupported image history: Kiro's
IMAGE_FORMAT_UNSUPPORTED/ImageValidationErroris terminal and structural. The exception also carries the narrowerimage_format_unsupportedtag. A current attachment is left in place with remove-or-re-encode guidance; a dashboard turn with no new attachments may discard the native resume SID once and retry from Kiro Crew's bounded text transcript, which excludes native binary image blocks. "No new attachment" is read as TWO facts, because empty dashboard attachment lists do not prove the turn shipped no image: a channel turn (and a dashboard turn that types a path) carries its image as a bare path inside the message text, whichbuild_prompt_blocksinlines as a CURRENT-turn image block. So the recovery additionally requires that the raw message match none ofimage_refs._PATH_RE— the builder's own scanner on the builder's own haystack — and otherwise falls through to the terminal guidance rather than clearing a healthy conversation and re-inlining the same bytes. The queued recovery turn is gated at DISPATCH, not only at enqueue: the conversation discard and the pending-reset consume are awaited between the two, and a soft Stop in that window preserves the queue while_stoppingsnaps back to idle. The slot therefore records the recovery's queue id plus the slot- and session-scoped stop generations at enqueue, and the queue drain drops the entry (refunding the shared one-shot) when either counter moved, when user input queued behind it, or when the slot was rebound to another session — the same rule (RecoveryReplays.revalidate,dashboard/recovery_replays.py) the model-access and refusal replays carry, re-checked at the turn's consume seam. One requeue is exempt, decided at the requeue: a verbatim requeue of a sub-agent completion the model never consumed is a result the parent is still owed, so it is queued again as the completion it is (SUBAGENT_COMPLETION_KIND), with no recovery record, and runs ahead of a newer user message instead of being suppressed or cancelled by a soft Stop. A hard kill discards it, and runner-written text queued for the completion (a continuation, a retry prompt) is never exempt. - Oversized request: kiro-cli's own refusal,
This message is too large to send, and it contains no text that can be shortened. Remove or reduce the attached content and try again.It is emitted when the context overflowed and the pending message is irreducible (image blocks have no truncated form), and kiro-cli neither compacts on the way to saying so nor appends the failed message to the native history, so the conversation is byte-identical before and after the failure and the identical payload is refused identically on every retry. Classified TERMINAL and taggedstructural_terminallike the two rejections above — a size verdict rather than a shape verdict, but equally deterministic, and the tag is what stops a self-prompting loop from re-sending the same attachment every cycle. Matched against the providerdatafield only, where kiro-cli's ACP server places an agent-loop error (messageis the-32603boilerplate). The terminal verdict is stated explicitly in the classifier because the sentence ends in "try again", which a retry-hint pattern must not read as a momentary blip. No curated copy: the provider's sentence already names the remedy, so the unknown-shape path shows it verbatim, and it does not carryimage_format_unsupported— the conversation-discard recovery above is for a rejected image, not for a request that is merely too big. - Usage limit and model not entitled: allowance spent, or the plan lacks
the model; also terminal, with guidance to switch model or tier.
_RE_USAGE_LIMITcovers two vocabularies, because a Claude seat (claude-agent-acp) does not go through Bedrock and words a spent allowance in Anthropic's terms, not AWS's: the window-named limits (monthly/daily/weekly, plus the rollingsessionlimit and its5-hour/five-hourspelling), and the named exceptions (MonthlyLimitError,FreeTierLimitExceeded). The window must be one of those NAMED spellings, not any word before "limit", so a genuine "hit your rate limit" stays a retryable throttle. The formatter quotes the provider's own sentence verbatim (_provider_detail), so the reset time Anthropic appends ("· resets 5:50pm", "It resets Monday at 9am") reaches the user unparaphrased. A barerate_limit_error/ HTTP 429 WITHOUT that limit wording is deliberately NOT a usage limit: a short-term rate limit clears on its own, so it stays a retryable throttle — it is the named-window wording, not the 429, that marks a spent allowance a retry cannot recover.
The auth family has exactly ONE transient member, credential propagation.
Bedrock refuses a freshly minted credential with "The security token included in
the request is invalid" — usually wrapped in UnrecognizedClientException and
carrying a 403 — until IAM has propagated it, and the identical credential is
accepted seconds later, so the existing backoff ladder absorbs it.
is_credential_propagation_delay is the single predicate, read ahead of
_RE_AUTH and the session-expiry branch in BOTH the classifier and the
formatter, ahead of llm_helpers._is_transient_acp_error's
accessdenied/unrecognizedclient exclusion short-circuit (a
_TRANSIENT_MARKERS entry alone is unreachable, because the exclusion sits
above the markers), and inside is_auth_failure_output so acp/runtime_liveness.py's
stderr latch does not convert it to the explicitly non-retryable
AcpAuthRequired and skip the ladder entirely. It is scoped to the "is invalid"
WORDING, never to the status: a bare 401/403, an ... is expired token, a
combined "invalid or expired", and an invalid bearer token all stay terminal.
The predicate lives in credential_errors.py, not acp/client.py, so consumers
on the application side of the agent-SDK boundary share one verdict without a
fresh ACP-layer import edge.
That wording is shared with a permanently invalid access key
(UnrecognizedClientException or InvalidClientTokenId for a key that was
deleted, rotated, or mistyped), so a never-valid credential is also classified
transient. The retry budget bounds that misclassification — three retries, ~15 s
— and the formatted message closes with the terminal "refresh your AWS
credentials" guidance rather than asserting the propagation diagnosis as fact.
The engine's model-list refusal is checked before the connection and 5xx
families. KAS answers session/prompt with -32000 "Kiro could not load the
available models. Please try again." and data.errorType
ModelRegistryUnavailableError when every fetch of the model list failed for a
reason other than a rejected or denied credential. The frame keeps no HTTP status
and none of the service's answer, and its cause may persist (a sign-in that sends
no profile ARN got HTTP 400 ValidationException on every call, #26109), so it is
never worded as an HTTP 5xx: acp/model_registry_errors.py compares
data.errorType exactly (a data sent as JSON text is parsed first; the name in
message or other prose matches nothing) and words it as a model list that could
not load, with the sign-in as the fix when it repeats. Only the wording changes:
the frame's "Please try again" still gives it the retryable verdict and the
http_5xx classifier kind, which match the engine's own SERVER_ERROR, bounded by
the ladder.
Retry hints are the other wording-only signal, and they are provider-scoped:
_RE_5XX_HINT carries one alternative per backend spelling ("please try again"
for Kiro/Bedrock, "try your request again" for the claude-agent-acp seam's
generic upstream 500, whose frame has no named exception and no HTTP status, so
the hint is its only transient marker). Onboarding a backend means auditing that
alternation. The status and named-token patterns carry provider spellings too:
_RE_5XX_STATUS reads a status after HTTP, status or the claude-agent-acp
seam's API Error: prefix, so Anthropic's overload answer (API Error: 529 {...overloaded_error...}) is a retryable http_5xx, and _RE_5XX_NAMED's
overloaded error token covers Claude Code's summary once its own retries are
spent (API Error: Repeated 529 Overloaded errors). A bare 529 without a prefix
stays prose, and the adapter's API Error: NNN Input is too long size verdict is
checked ahead of the 5xx family, so it stays terminal. The kiro-cli mid-stream envelope ("Encountered an error in the
response stream: …") is deliberately NOT a hint — matching it would make the
branch a catch-all that discards the real cause.
The throttle branch is retryable on every backend, but its wording is
positive-identity per AGENTS.md "Harness parity": only kiro-cli reaches the
model through Bedrock (ACP_BACKEND_KIRO, the empty-string id), so an inline
backend == ACP_BACKEND_KIRO check names Bedrock for it alone and "The model
provider" for every other harness — a Claude seat, Codex, goose and the rest
never touch AWS, so naming Bedrock sent their users chasing throttling that does
not exist. The verb stays "throttling" in both forms so
llm_helpers._TRANSIENT_MARKERS ("throttl") still classifies the formatted
message as transient; the retry verdict is unchanged, only the subject noun moves.
A network-path drop is the one transient class that waits by time rather
than by count. AcpError.connection_failure is set from the raw frame when the
verdict is transient and classify_provider_error names the frame
PROVIDER_ERROR_CONNECTION: connection wording (_RE_CONNECTION) or a
connector dispatch failure, with no throttle, usage limit, auth or
session-expiry rejection, or model-availability answer outranking it. The
formatter checks the same connector patterns before the 5xx family, so a
dispatch failure shows the "Could not reach the model backend" card rather than
the generic 5xx prose; the verdict still travels as a tag so the retry layer
never reads it back from the message. On an
interactive dashboard turn
(chat_runner's pre-stream transient branch) a connection failure keeps
re-prompting the same live session past TRANSIENT_RETRIES until
CONNECTION_RECOVERY_WINDOW_SECS (300 s) have passed since the ladder's first
retry, on the same backoff curve capped at CONNECTION_RETRY_MAX_DELAY (30 s),
and shows one standing "Connection unstable — retrying…" notice for the extra
attempts rather than one per attempt. Stop, a landed turn and an exhausted
window end it exactly as they end the fixed ladder. The dashboard settles that
standing notice once the window closes (resolveGatewayNotice in
website/src/pages/chat/gatewayNotice.ts): it reads "Connection restored"
when an answer follows it, and the muted past-tense "Connection interrupted"
when the give-up row or any other error comes first, so it never stays
"retrying…" above the red "please try again." row. A new user turn decides
neither: a message typed during the backoff does not end the replay, so the
notice stays pending. Another re-queued recovery row (kind: transient_retry,
such as a dead process's "Connection lost — retrying…") is read as recovery
still running, so the scan continues past it. Provider answers keep the
fixed count: a provider that answers with errors is not helped by a longer wait.
A dead process is read by llm_helpers.acp_error_is_pipe_death: the client's
own "process exited" / "not running" wording on an AcpError that carries no
JSON-RPC code. An error frame is never a death, because the process that sent
it is alive. The claude adapter answers a failed Claude Code start with
-32603 and "Claude Code process exited with code 1"; a fresh process repeats
it, so the turn shows that error card instead of re-queuing behind
"Connection lost — retrying…" until "Session stuck".
test_chat_runner_turn_error_arms.py pins both.
codex-acp reports a turn's terminal provider error to a client without JetBrains
AIR's sessionFailure capability as an agent_message_chunk (the error message
plus a blank line) and then answers the prompt with end_turn
(CodexEventHandler.createErrorEvent, every release read from 1.11.0 to
2.1.2-preview.5). Crew does not declare AIR: that one declaration also switches
the adapter's tool-call, plan and permission frames to AIR shapes. Members of
ACP_BACKENDS_UNATTRIBUTED_TERMINAL_ERROR are handled in
AcpSessionHandle._run_turn instead:
- A text chunk with no
messageIdis adapter-authored. Model text always names its item. The adapter's fixed notices (Warning:,Config warning:,*Context compacted) and every chunk of a prompt the adapter parses as a slash command are delivered as they arrive. - Any other such chunk is HELD, together with a steer-consumed report behind it.
Content, a permission request or any other terminal after it releases
everything held, in order and unchanged. A co-tenant's fanned-out frame
(
runtime_global) on a shared process passes through without releasing it. - An
end_turnright behind held chunks, each a whole chunk ending in a blank line, makes the turn FAIL. The chunks are never delivered as text, duplicates collapse to one message, and the turn raises through_raise_acp_errorwith the provider message (unwrapped from codex's{"type":"error","status":N,...}envelope, status kept as(HTTP N)). Wording, the transient verdict (a 5xx is transient, a 4xx is not), and the model-substitution and sign-in tags therefore come from the same classifier as every other harness. Withholding the steer report lets the caller queue that steer again. - A JSON-RPC error that follows held chunks (codex's
usageLimitExceededwrites the text and fails the prompt) is raised alone, so the failure is reported once.
The selected model and effort are untouched, and no recovery is added: whether the failed turn is retried is decided downstream from the raised error, exactly as for any other harness.
A refusal is a turn the model DECLINED, not a turn that failed: the request
reached the model and the answer is "no". It is deterministic — the same prompt
hits the same filter — so it is never retried, and the useful thing to show is
the reason. Harnesses report that reason unevenly, so acp/types.RefusalInfo
is the one shape every harness is folded onto (category,
explanation, recommended_model), each field left EMPTY when the provider
did not say — never guessed.
- Kiro (kiro-cli, KAS) — the service's content filter emits a
_kiro.dev/metadataframe withstopReason: CONTENT_FILTEREDand arefusalobject, streams the canned explanation ("The selected model cannot continue this conversation…") as ordinary assistant text, then ends the turn with a plainend_turn(or a bare-32603).acp/_dispatch.parse_refusalreads the frame (members ofACP_BACKENDS_STRUCTURED_REFUSALonly) ontoAcpPromptStats.refusal;AcpPromptStats.terminal_refusalrewrites the terminal's stop reason toSTOP_REASON_REFUSALand attaches the payload asAcpEvent.refusal. The explanation is redacted at the parser. - claude-agent-acp, codex-acp — only Anthropic's bare
stopReason: "refusal"reaches the client;terminal_refusalpasses it through with no payload, and the dashboard's refusal branch (keyed on the stop reason) renders the bare card. - Dashboard —
chat_turn.recovery.refusal_card_textrenders one card fromRefusalInfo: the lead line, then one line per non-empty field. Because the Kiro explanation streams as text, the card is emitted from BOTH the answered and the text-less branch of the turn epilogue.