fix(acp): retry a turn abandoned after a throttled compaction - #8215
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Real dropped-turn harm, classified fail-safe from structured payload, bounded retry, additive ABC contract mirroring existing property hops — sound and proportionate. The one inherent fragility — the transient markers mirror KAS's current error vocabulary, so an upstream rewording silently degrades — fails toward the pre-PR give-up behavior with the numeric [DESIGN-REVIEWED] 179b60b |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks are done. Composing the review. First-Principles-Verdict: CONCERNS Every item traces to the reported dropped-turn defect, but the classifier ships a third "is this transient?" vocabulary beside two the codebase already declares single-source. What this change shipsIntent: stop a throttled KAS summarization from silently eating the user's message — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 179b60b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe single candidate concerns the asymmetry that
The remaining changed code (verdict forwarding through the provider ABC with a safe No findings. [OPUS-REVIEWED] 179b60b Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
8cd2afa to
0588e6e
Compare
Legitimate, and rule-backed rather than stylistic: the shape is exactly what blocking
Pinned by |
0588e6e to
ec3c02e
Compare
Legitimate, and it caught the PR contradicting its own stated rationale: the body argued against matching prose because it would "fire on a digit that happened to land in a summary", and then the classifier scanned every string leaf — Taken exactly as recommended: the string scan now skips any key not in Two tests pin both directions: |
Both halves are correct, and they get different answers. The description claim was wrong, and that is fixed here. "Why it matters" now reads "On the dashboard, every throttled summarization…", and a new Scope paragraph names The two surfaces are deferred, deliberately. Each has its own requeue mechanics — Slack and messaging dispatch do not share the dashboard's Tracked in #8256 ( |
ec3c02e to
f2d405d
Compare
Verified and taken in full. The grep held — one consumer, and a truncated duplicate of what each arming site had already logged whole. Worth naming why it was there: the ABC declaration came from Opus's H14 finding, which was about the verdict; I widened the contract with the reason string alongside it and never went back to ask whether that half had a reader. Removed at all six sites: the ABC property ( Nothing was lost. The chat row still shows the sentence, via the |
The observation is accurate. I am keeping them, on the asymmetry rather than on the evidence. A summarization call that times out is the same class of fault as one that is throttled — the work is fine, the call did not land, and the next attempt clears it. Classifying it permanent costs a dropped user turn, which is precisely the defect this PR exists to fix. Keeping the marker costs at worst two bounded, billed retries on a permanent fault that happens to be labelled "timeout" in a reason-bearing key — and a context overflow is not labelled that way, so that path is thin. The prose-collision worry that would otherwise justify caution here is also already closed, as of On the related Watch — the third transient vocabulary — you are right and I cannot close it here: |
KAS runs summarization as a separate billed model call on the session's own
model. When that call is throttled the frame it reports is
`summarization_failed`, Crew ends the turn with STOP_REASON_COMPACTION_FAILED,
and the dashboard drops the user's message on a row reading
"Compaction failed: error" -- no cause, and nothing in gateway.log to grep,
because the KAS branch logged nothing at all.
Three defects, one path:
- The reason was read one level deep, so a flat placeholder ("error") won while
the real cause sat under `cause`. The walker now ranks every named reason in
the frame, prefers the sentence written for the user, and rejects placeholder
words so an uninformative one falls through to the raw shape instead of
becoming the whole notice.
- The KAS summarization branch had no log line, so a failure left the chat row
as its only record. It now logs the whole frame at WARNING, like the kiro-cli
twin.
- The abandoned turn was dropped unconditionally. That is right for a
conversation that overflows the window -- replaying it repeats the overflow --
and wrong for a throttled or 5xx'd summarization call, which the next attempt
clears. The reason is now classified from the payload and a transient one
re-queues the message once (budget 2), guarded on `not _turn_emitted` so a
verbatim replay can never repeat a side effect.
Retrying the compaction itself on `fallback_model` is deliberately NOT part of
this: #8159 declines Crew-driven compaction on KAS entirely, so there is no
Crew-side compaction call to retry there. Re-running the turn is the recovery
that path can actually offer.
Tests: reason ranking, placeholder rejection, the SCREAMING_SNAKE spelling of
the reason enum, 429/5xx/bool status handling, the overflow case staying
permanent, and the three requeue outcomes (transient retries, budget bounds it,
an emitted turn is never replayed).
f2d405d to
179b60b
Compare
Legitimate, and the inconsistency was mine and local: the frame carries Taken exactly as recommended: |
Legitimate, and the sharper of the two framings this hazard has had. Opus raised a neighbouring candidate last round and dropped it for lacking a groundable trigger; naming the ordering consequence is what makes it concrete — the requeue inserts at index 0, so a correction the user typed while the turn was failing would run after the message it was meant to replace, and a message they stopped would come back at all. Taken as the four-part guard you specified, reusing the promise-only continuation's own precondition set rather than inventing a parallel one: The generation comparison is the load-bearing one: a stop that completed during the turn leaves Two tests, both mutation-verified (each fails with the guard deleted):
|
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
A dashboard turn on the KAS backend ends with a chat row reading, in full:
and the user's message is gone. Nothing further happens, nothing is queued, and
gateway.loghas no compaction line at all to explain it — the reason on the rowis the only record, and the reason is the word "error".
The actual cause is in the backend's own log, not ours:
KAS runs summarization as a separate billed model call on the session's own
model, so when that model is throttled the summarization call is throttled with
it. It reports
summarization_failed, Crew arms the post-failure budget, thebackend never answers the prompt, and the turn ends with
STOP_REASON_COMPACTION_FAILED— which the dashboard treated as permanent.Why it matters
On the dashboard, every throttled summarization silently drops a turn that would
have succeeded on the next attempt, and tells the user nothing they can act on. The throttle is
transient and common: six occurrences appeared in a single ~20-minute local log
window, five of them hitting ordinary turns as well. On a long session the
message lost this way can be the one carrying an hour of context.
The blank reason is the second half of the cost. With no reason on the row and no
log line server-side, there is nothing to grep and nothing to correlate — the
failure is unreportable, which is why it went unexplained rather than unnoticed.
What changed (motivation → approach → change)
Three defects on one path, each fixed at its own layer.
1. The reason was read one level deep.
compaction_failure_detailcheckedfour flat keys on
statusand then onparams. KAS puts a placeholder in theflat position and the real cause under
cause, so the placeholder won and becamethe whole notice.
The reader is now a ranked walk over the frame: it collects every named reason at
any depth, prefers the sentence written for the user
(
userFacingSessionErrorMessage) over the machine enum, and rejects placeholderwords (
error,failed,unknown, …) so an uninformative one falls through tothe raw shape instead of becoming the notice. Separators are folded before
matching, because the same fault arrives as prose in one field
("temporarily unavailable") and as a
SCREAMING_SNAKEenum in another(
MODEL_TEMPORARILY_UNAVAILABLE).2. The KAS summarization branch logged nothing.
AcpClient's kiro-cli twinlogs the whole notification at WARNING; the KAS branch in
session_handle.pyhadno logger call, which is why the chat row was the only evidence. It now logs the
full frame, so the next occurrence is debuggable from our own logs — including
which field the reason arrived in.
3. The abandoned turn was dropped unconditionally. That is correct for a
conversation that overflows the window: replaying it repeats the same overflow,
which is what the original no-retry comment reasoned about. It is wrong for a
throttled or 5xx'd summarization call, which has nothing wrong with it.
The reason is now classified from the structured payload
(
compaction_failure_is_transient) rather than the rendered notice — the notice istruncated, redacted, and sometimes only a raw
repr, so matching its prose wouldboth miss real throttles and fire on a digit that happened to land in a summary.
An HTTP status is compared as a number under its own key, never as a substring,
and
boolis excluded because it is anintsubclass. The string scan is scopedto the same reason-bearing keys the reason reader ranks: the frame also carries
backend-echoed, conversation-derived text (
conversationSummaryrides in the verypayload the KAS branch passes whole), so scanning every string leaf would let a
summary that merely mentions "timeout" upgrade a permanent overflow to transient.
A control decision must not be reachable from content the model wrote. A transient verdict
re-queues the abandoned message once (budget 2, its own budget rather than a share
of
_acp_pipe_death_retries), guarded onnot _turn_emittedso a verbatim replaycan never repeat a side effect. An emitted turn, and every permanent reason, keep
the existing give-up behaviour.
The verdict is set where the frame arrives (
AcpClient,AcpSessionHandle) andread where the turn ends (the dashboard's
AcpProvider), so it is forwardedthrough both wrapper hops —
AcpProvider→AcpSessionProvider→ the livehandle — mirroring the existing
exit_code/last_prompt_statsproperties, anddeclared on the
LLMProviderABC with a safeFalsedefault so an adapter thatreports no verdict gives up the turn exactly as it did before. The boolean is
the whole of the new contract: the reason text is not forwarded, because the
chat row gets it from the compaction-status event title and the server log from
the WARNING each arming site emits. That
hop is load-bearing rather than cosmetic: without it the read falls to its default
and the retry is unreachable on exactly the KAS path it was written for.
Deliberately not included: retrying the compaction itself on
fallback_model. #8159 declines Crew-driven compaction on KAS entirely (KAScannot serve
/compact, and dispatching one stranded the turn semaphore for thefull 300s budget), so on that backend there is no Crew-side compaction call to
retry. Re-running the turn is the recovery this path can actually offer, and
KAS's own remedy — try another model — now reaches the user because fix 1 puts the
sentence carrying it on the row.
Scope: the dashboard only.
slack/handler.py:3694andmessaging/dispatch.py:654still return on this stop reason and still drop theturn. Both predate this PR and neither is a regression from it; each surface has
its own requeue mechanics, so porting the branch verbatim is not the right shape.
The classifier and the provider contract added here are surface-agnostic, so each
one needs only to read the verdict and re-queue — tracked in #8256.
Tests
test/test_acp_client.pytest_rejects_a_placeholder_reason— a named reason of"error"does not become the notice; the raw shape is surfaced instead. This is the reported symptom.test_prefers_the_user_facing_sentence_over_the_machine_reason— on the nested pair KAS actually sends, the sentence wins overcause.reason.TestCompactionFailureIsTransient(9) — throttle by error name; theSCREAMING_SNAKEspelling of the reason enum; a nested 5xx; 429; a 4xx staying permanent; an overflowing conversation staying permanent;httpStatusCode: Truenot read as a status code; aconversationSummarymentioning "timeout" not flipping a permanent overflow; and a genuine throttle beside that summary still matching, so the scoping costs no real case.TestCompactionVerdictReachesTheConsumer(5) — the ABC declares the verdict (and deliberately not the reason text), each wrapper hop forwards it, a client that never set it reads as permanent rather than raising, and a truthy stand-in is not accepted as a verdict.test_the_reason_and_the_verdict_read_the_same_frame— both readers run off one walker, so the displayed reason and the retry decision cannot be derived from different views of one frame.test/test_chat_runner_coverage.pytest_a_throttled_compaction_requeues_the_abandoned_message— the transient verdict re-queues and dispatches a follow-up turn, without touching the pipe-death budget or claiming a lost connection.test_the_throttle_requeue_is_bounded— a spent budget buys no further attempt and adds no notice.test_a_transient_failure_after_output_is_not_replayed— an emitted turn is never replayed, even for a retryable reason.test_compaction_failure_neither_retries_nor_claims_a_lost_link— docstring narrowed to the permanent case it actually pins; behaviour unchanged.All new tests are mutation-verified: 14 fail against pristine
src/, and the fourforwarding tests fail with either property removed.
Manual verification
N/A — unit coverage sufficient. The trigger is an upstream model throttle, which
cannot be induced on demand; the payload shapes under test are taken verbatim from
the
[SummarizationNode] Summarization failedframes in the log quoted above.Local gates on the rebased head: black, isort, flake8, mypy (1281 files), and the
12 ratchet gates all green; 1853 tests green across the ACP client, session
handle, session provider, provider, KAS backend, chat runner, dashboard chat and
connection-recovery suites.
Related Issues
No linked issue: diagnosed from a live session's own logs rather than from a
filed report.
Pattern harvest
Rule candidate: review-prompt
Pattern: a value read with
getattr(wrapper, field, default)where the field isonly ever set on an inner object — the default silently makes the branch
unreachable, so the feature is dead code that no test using a mock will catch.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)