Skip to content

fix(fallback): converge main-chat and workflow model fallback - #2201

Merged
flora131 merged 13 commits into
mainfrom
fix/2170-model-fallback-convergence
Aug 5, 2026
Merged

fix(fallback): converge main-chat and workflow model fallback#2201
flora131 merged 13 commits into
mainfrom
fix/2170-model-fallback-convergence

Conversation

@flora131

@flora131 flora131 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR converges main-chat and workflow fallback behavior so both paths classify failures the same way, retry and advance candidates on the same rules, and keep fallback model switches scoped to the failing turn.

Changes

  • Share the model-failure classifier and retry policy across coding-agent, workflows, and subagents so rejected credentials, unavailable or incompatible models, quota failures, and transport failures make the same fallback decision everywhere.
  • Scope main-chat fallback model switches to the failing turn, restore the user-selected model on the next turn boundary, and keep explicit /model choices from being overwritten by that restore.
  • Retry thrown workflow candidate failures on the same candidate under settings.retry before advancing through fallbackModels, while preserving the admitted stage prompt on continue paths.
  • Prefer openai-codex token invalidation over nested abort diagnostics so a dead Codex credential advances to the next candidate instead of being treated as a cancellation.
  • Rebase the branch onto current origin/main and keep the package changelog entries under ## [Unreleased].

Notes

  • Local gates passed before push: npm run check, npm run test:unit, and npm run test --workspace=@bastani/atomic.

Closes #2170


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Greptile Summary

This change unifies model fallback behavior across main chat, workflow execution, and subagents. It distinguishes terminal model failures from transient failures, retries transient provider errors on the current model within the configured limit, and advances immediately to an available fallback for authentication and incompatible-request errors. Main chat restores the user-selected model before the next independent request.

Focused execution contradicted the tested failure hypotheses: authentication and incompatible-request failures advanced directly without retrying the failed model; transient failures retried before fallback; the selected main-chat model was restored at the next request boundary; and workflow fallback continued after retry-eligible errors. The focused suite passed 61 tests, with follow-up main-chat and workflow coverage also passing.

Confidence Score: 5/5

The change is safe to merge; no blocking failure remains in the validated fallback and retry behavior.

Focused execution confirmed direct fallback for terminal authentication and request-compatibility failures, bounded same-model recovery for transient provider failures, restoration of the selected main-chat model, and continued workflow recovery.

T-Rex T-Rex Logs

What T-Rex did

  • Compared the focused workflow retry behavior before and after this change.
  • Ran the changed classifier, main-chat, and workflow fallback/retry coverage; 61 tests passed.
  • Compared the parent revision workflow test results before and after the change, noting that before-change tests retried auth/request-incompatible failures (32/32 passed) and after-change tests advanced immediately for terminal same-model failures while preserving transient retries and fallback walks (61/61 passed).
  • Confirmed that no temporary tests or scripts were required because existing narrow Vitest coverage exercised all requested paths.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (6): Last reviewed commit: "chore: re-trigger CI for 7500aa72b (Test..." | Re-trigger Greptile

@mintlify

mintlify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bastani 🟢 Ready View Preview Aug 5, 2026, 1:57 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Comment thread packages/coding-agent/src/core/model-fallback-failures.ts Fixed
Comment thread packages/coding-agent/src/core/model-fallback-failures.ts Fixed
Comment thread packages/workflows/src/runs/foreground/stage-runner-controller.ts Outdated
Comment thread packages/workflows/src/runs/foreground/stage-runner-controller.ts Outdated
Comment thread packages/workflows/src/runs/foreground/stage-runner-controller.ts
flora131 added a commit that referenced this pull request Aug 5, 2026
… walk (#2170)

Prompt delivery and session creation authorized same-model retries with the
broad fallback-eligibility predicate, so a 401/400/model-unavailable failure
burned the whole settings.retry budget on a candidate that had already
definitively rejected the request before advancing. Both call sites now use
the shared isRetryableSameModelFailure() classifier, keeping the broad
predicate for fallback advancement and the unresolved-context-overflow
exclusion. Addresses Greptile review on #2201.

Assistant-model: Claude Fable 5
flora131 added 13 commits August 4, 2026 21:19
Main chat and workflow stages resolved fallback candidates through the same
helper but disagreed on when a chain advances, how long a candidate is retried
first, and how long a switch lasts. Converge them in three directions.

Share the failure classifier. The single implementation now lives in
packages/coding-agent/src/core/model-fallback-failures.ts; the workflows and
subagents modules re-export it. Main chat splits the one predicate in two:
_isRetryableError answers "request this model again", _isFallbackableError
answers "spend the next candidate". Auth, model-unavailable, and
request-incompatible failures now advance the chain, and openai-codex token
invalidation is terminal for the current model while remaining fallbackable.

Scope a main-chat switch to the failing turn. A fallback records the
user-selected origin model and reasoning level, and restores them at the next
turn boundary — before the next idle prompt, before queued follow-ups, and
after a compaction continuation settles. Restores are recorded in model
history and in the model_fallback_* lifecycle. An explicit /model, model
cycle, or thinking-level choice cancels the pending restore so the user's
selection wins.

Give the workflow candidate walk retry-then-advance timing. Thrown auth,
model-resolution, and transport failures now get a bounded same-model retry
with exponential backoff from settings.retry before handleCandidateFailure
advances, and retry.enabled: false keeps immediate advancement. The retry
honours pause/resume and abort, and keeps the admitted stage prompt when it
resumes the existing turn with _runAgentContinue(), which the agent rejects on
a transcript that does not end in the prompt being resumed.

Context overflow keeps compaction first. Only once compaction is disabled,
fails, or reports the overflow unresolved does the chain advance, so a
compactable first overflow still costs no candidate but an unrecoverable one
can reach a larger-context model.

The retry decision and its backoff curve now live once, in
packages/coding-agent/src/core/retry-policy.ts, used by both the main-chat
retry loop and the workflow prompt and session-creation retries.

Refs: #2170
Assistant-model: Claude Opus 5
Captures the fallback seams, file references, contracts, and risks that guided
the convergence work, alongside the state of the inherited implementation it
was handed.

Refs: #2170
Assistant-model: Claude Opus 5
…ail (#2170)

restoreSessionMessages() retains non-error assistant messages, so an attempt
that streamed a completed assistant before throwing left an assistant tail.
pi-agent-core's Agent.continue() rejects that with "Cannot continue from
message role: assistant", which turned a recoverable retry into a hard stop.

Gate the continuation path on the contract continue() actually states: the
restored transcript must be non-empty and must not end in an assistant. When
the tail is ineligible the retry drops the retained prompt and re-prompts
instead, so the input is re-sent exactly once.

Cover the four admitted orderings on a real-AgentSession probe — tool-result
tail, non-error assistant tail, pause during backoff, and abort during backoff
— and add a main-chat case that drives the production compaction path from a
failed overflow compaction through to a real fallback switch, rather than
injecting the unresolved-overflow flag.

Mark the research ticket superseded and correct the claims that no longer
describe the repository.

Refs: #2170
Assistant-model: Claude Opus 5
…2170)

setThinkingLevel() cleared the fallback scope on every call, before the
isChanging check. That cancelled the pending primary-model restore both when a
user changed reasoning level during a fallback turn and when the registry
refresh re-applied the current level unchanged, leaving the session stranded on
the fallback model. The contract's only stated exception is an explicit /model
choice.

Drop the clear. When the level actually changes during an open fallback scope,
carry it into the scope so the restore returns the primary model without
overwriting the reasoning level the user picked — the same rule the model
already follows. setModel() and both model cycles still clear the scope, so an
explicit /model still cancels the restore.

Also add ResourceExhausted to the shared retryable pattern list. Upstream pi-ai
retries it, but the shared classifier did not, so a transient gRPC provider
error stopped a session with no fallback chain instead of spending its
same-model retry budget. It goes in the one shared list rather than a
main-chat-only pattern, and the shared conformance corpus covers it so both
companion packages assert the same answer.

Refs: #2170
Assistant-model: Claude Opus 5
…2170)

Four defects in the stage fallback path, all reproduced before fixing.

createInitialSession() created only candidates[0] and handed failures to a
retry helper that rethrew after exhausting that one candidate. A session
created before any prompt — an eager stage call, a control attach, or
ctx.__ensureSession() — therefore failed the stage with its configured
fallbacks untried, and ensureSession() cached the rejected promise so every
later caller replayed the same failure. Add a creation-only version of the
prompt candidate walk, and clear the cached promise by identity after a
terminal rejection.

The explicit-candidate branch of promptWithFallback() never consumed
pendingCreationResumeMessage, so a paused eager creation resumed with a
replacement objective sent the stale original instead. Consume and clear it
before tryResumeCurrentSession() and before the loop.

canContinueFromTranscript() checked the raw tail role, but pi-agent-core
requires the converted tail to be user or toolResult. Atomic's converter drops
a custom, bash-execution, or branch-summary message that is excluded from
context or empty, exposing the assistant beneath it. Evaluate the same
convertToLlm() result Atomic sends.

An already-unresolved context overflow was fed to the shared retry policy as an
ordinary retryable failure, so it burned the whole settings.retry budget on a
model whose compaction had already failed. Gate same-candidate retry on
!isUnresolvedContextOverflowFailure while leaving the shared classifier and the
wrapper untouched, so the candidate walk still advances.

Refs: #2170
Assistant-model: Claude Opus 5
_trySwitchToFallbackModel() skipped a candidate only when it matched the failed
model AND its reasoning level, so openai-codex/gpt-5.5:high failing with an
invalidated OAuth token fell back to openai-codex/gpt-5.5:low — the same dead
credential, with model_fallback_start reporting an identical from and to.

The boundary is not "auth"; it is the distinction the shared classifier already
draws. A failure that may spend a candidate but cannot be repaired by another
request to the same model — rejected credential, unavailable model, request the
model cannot serve — is unaffected by reasoning level, so every reasoning
variant of that model is out for the rest of the turn.

Track those models in a turn-scoped blocked list and skip matching candidates
before the fallback scope opens, so a skipped variant emits no event and starts
no lifecycle. The list clears with the existing fallback-attempt state on a new
user turn, a successful turn, and scope close. Transient rate-limit and
transport failures stay same-model retryable and keep their reasoning variants.

The pre-existing same-provider reasoning test used the fixture's default
"Not Found" message, which is fallbackable but not same-model retryable, so it
was asserting the defect. It now uses a real transient failure and covers the
behavior that must survive.

Refs: #2170
Assistant-model: Claude Opus 5
Three races in the stage runner, each reproduced before fixing.

disposeCurrentSession() cleared the shared creation promise unconditionally,
including while a candidate walk was still creating the next session. A
concurrent ctx.__ensureSession(), or a first ctx.prompt() whose
explicit-candidate branch bypassed ensureSession() entirely, could then start a
second walk: duplicate provider work, with the losing session left live and
undisposed. Mark the promise a walk owns, clear it on dispose only when it is
not that promise, and let the explicit-candidate prompt join a creation already
in flight.

createSession() was awaited with no pause observer, so a controlled pause that
both started and finished while the adapter was in flight was invisible;
completing a resume clears the pause request, so a post-await currentResume()
check sees nothing and the stale objective is prompted. Register an observer
before creating and latch any replacement objective onto the resume itself. The
observer set is separate from the thrown-retry states and the creation promise
is returned untouched, so abort semantics, backoff timing, and attached-stream
observability are all unchanged — an earlier attempt that added one microtask
hop broke the executor pause and abort suites, which is what caught it.

_runAgentContinue() was called directly, so the abort a controlled pause causes
was classified as a terminal model failure and the stage became unrecoverable
by resume. Apply the prompt's pause rules to the continuation: settle the pause,
await its delivery settlement, then re-prompt with the replacement objective
instead of spending a fallback candidate.

Refs: #2170
Assistant-model: Claude Opus 5
… walk (#2170)

Prompt delivery and session creation authorized same-model retries with the
broad fallback-eligibility predicate, so a 401/400/model-unavailable failure
burned the whole settings.retry budget on a candidate that had already
definitively rejected the request before advancing. Both call sites now use
the shared isRetryableSameModelFailure() classifier, keeping the broad
predicate for fallback advancement and the unresolved-context-overflow
exclusion. Addresses Greptile review on #2201.

Assistant-model: Claude Fable 5
@flora131
flora131 force-pushed the fix/2170-model-fallback-convergence branch from c3175a4 to 71728a5 Compare August 5, 2026 04:25
@flora131
flora131 merged commit 37d1531 into main Aug 5, 2026
18 checks passed
@flora131
flora131 deleted the fix/2170-model-fallback-convergence branch August 5, 2026 04:58
flora131 added a commit that referenced this pull request Aug 5, 2026
…ane (#2205)

* docs(specs): spec for Codex-aligned in-process subagent runner with Rust control plane (#2188)

Full Codex alignment resolved with the requester: zero OS processes (async
runner deleted, survival traded for cold-identity reload), turn-scoped
execution guards, LRU residency with transparent cold reload, persistent
canonical child identities, 100ms interrupt grace, no kill-capable timers.
Control plane implemented in Rust (crates/atomic-natives, NAPI-RS). Atomic
keeps per-agent tool allowlists, depth<=5, model fallback, and its
request/response tool surface. Clean break: env bridge, watchdog, exit
codes, and the file-claim delivery pipeline are deleted. Fixes #2191 via
unified background continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(natives): add Rust in-process subagent control plane (#2188)

* fix(natives): emit consumable string union types

* feat(subagents): add in-process runner doors

* refactor(subagents): route foreground attempts in process

* docs(subagents): note in-process foreground semantics

* docs(natives): note consumable status unions

* docs(subagents): record in-process slices report

* docs: track issue 2188 validation flake

* fix(subagents): route termination to live in-process attempts

* feat(subagents): drive foreground candidates, structured output, and detach in process

Adds an explicit test-session seam to the in-process runner so the foreground
path can be exercised without an OS child, moves pre-admission model-candidate
filtering and cwd validation onto the in-process door, runs the structured-output
corrective-retry loop inside the runner, and routes intercom detach through
continue_in_background instead of the process-era placeholder.

* style(subagents): sort in-process run-sync imports

* fix(subagents): persist the child input artifact and surface failed progress errors

The in-process foreground path stopped writing _input.md, which spec 5.5 keeps as
user-facing artifact naming, and never populated AgentProgress.error on a failed
attempt. Rewrites the acceptance and structured-output suites against the typed
status contract instead of a spawned fake CLI.

* test(subagents): migrate intercom detach tests in-process

* docs(research): add issue 2188 TS migration blueprint

* feat(subagents): add in-process background continuation seam

* feat(subagents): run async single children in process

Replaces the detached-runner single path with the in-process
continuation seam. async-execution-single.ts is deleted per spec
section 10 and its behavior moves to runs/inprocess/background-single.ts,
which admits the child, continues it in the background, and returns the
canonical child path immediately.

Refs #2188

* chore(gitignore): ignore subagent test scratch dir

* feat(subagents): collapse async chain and parallel onto the foreground executor (#2188)

async: true is now a don't-wait request over the same in-process foreground
executor. runAsyncPath admits the child, settles the call with a typed
continued status and canonical child path, then runs the work on the existing
runChainPath / runParallelPath un-awaited. Chain substitution, dynamic fanout,
worktrees, structured output, skills, progress files, fail-fast, and per-step
model candidates stay on the one code path that already implements and tests
them, rather than a second serialized runner.

This removes the last two OS spawn sites, satisfying spec G1 (zero OS child
processes): async-execution-common.ts spawnRunner and the detached runner in
subagent-runner-streaming.ts.

Deletes per spec section 10 "Modules removed": async-execution-{chain,common,types}.ts
and the async-execution.ts barrel, the 12-file subagent-runner*.ts family,
async-event-journal.ts, and top-level-async.ts. Their exclusively-process-era
tests (subagents-async-config, subagents-async-event-journal) are removed with
their subjects; tests covering kept behavior are re-pointed at the in-process
path with typed-status assertions.

* refactor(subagents): delete the process-era spawn and watchdog modules (#2188)

With the last OS spawn site gone, these spec section 10 "Modules removed"
entries have no importers left:

- runs/shared/attempt-watchdog.ts   - the idle/wall watchdog; zero importers
- runs/shared/final-drain.ts        - the stdout drain grace
- runs/shared/pi-spawn.ts           - CLI-child spawn resolution
- runs/shared/spawn-env.ts          - the env bridge builder
- shared/post-exit-stdio-guard.ts   - process-only; sole importer was the watchdog

subagents-final-drain.test.ts and subagents-pi-spawn.test.ts are removed with
their subjects. interactive-engine-env-scrub.test.ts keeps its three
scrubInteractiveEngineEnv tests, which cover kept coding-agent behavior; only
the two buildSubagentSpawnEnv cases are removed with spawn-env.ts.

* docs(subagents): record the in-process async break in the changelog (#2188)

* feat(subagents): resolve child mode policy at admission

* docs(subagents): record typed child policy move

* chore: drop stray agent report from the repository

* feat(subagents): move child prompt behavior into session construction

* feat(subagents): route nested controls in process

* feat(mcp): consume typed child direct-tool policy

* feat(intercom): bind child identity at client connect

* feat(workflows): pass typed stage policy without env isolation

* refactor(natives): modularize subagent control domains

* refactor(subagents): move nested runtime support behind in-process boundary

* refactor(subagents): remove legacy prompt runtime adapter

* feat(subagents): route management actions through control registry

* feat(subagents): route cold resume through control plane

* fix(subagents): preserve in-process child results

* fix(subagents): persist async dispatch results

* fix(natives): await child termination grace and preserve causes

* refactor(subagents): delete process-era result pipeline

* refactor(subagents): back async jobs by in-process state

* test(mcp): remove process-era env setup

* test(subagents): retain extension lifecycle coverage

* fix(subagents): route list action through control registry

* test(subagents): guard in-process clean break and document async ownership

* docs(subagents): remove legacy bridge references

* docs(mcp): record direct-tool bridge removal

* test(ci): include untracked clean-break paths in guard

* refactor(subagents): make typed status the only outcome discriminator (#2188)

Removes exitCode from SingleResult/ModelAttempt/AsyncStatus, deletes the numeric synthesis on the in-process path, and migrates every TUI, intercom, notification, chain, and workflow-graph consumer onto the typed status union. deliverChildResult now distinguishes artifacts-disabled from an unresolved artifacts directory and throws for the latter. Native terminateChildAttempt calls are awaited.

* test(ci): guard removed nested process modules

* test(ci): scan unreleased clean-break contracts

* docs(subagents): record typed result status contract

* fix(subagents): restore agent listing and trim async status text

* fix(subagents): retry capacity and normalize child ids

* fix(subagents): show live progress and ctrl+o hint for parallel and chain runs

The in-process runner only surfaced onUpdate at terminal moments, so a
foreground parallel/chain run rendered a bare header with no rows, no
activity, and no 'Press ctrl+o for live detail' hint while children ran.

- runner: publish throttled AgentProgress from session events (tool
  start/end, assistant message_end, agent_start) via ChildSpec.onProgress
- run-sync: forward progress as live tool-result updates with a synthetic
  running result, feeding the existing parallel/chain merge paths
- renderers: stop bailing to text when a multi run has progress but no
  results yet; span rows across progress/totalSteps; render running rows
  with activity and the live-detail hint; add a group-level hint fallback
- test: regression coverage for progress-only parallel/chain rendering

Verified live against the built CLI under tmux (captures in
atomic-e2e-evidence-2188: tour-06/07/08).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(subagents): align child model fallback with the shared SDK session fallback

In-process children now pass their pre-filtered candidate ladder to
createAgentSession's fallbackModels, so child fallback uses the exact
classification, same-model retry, and candidate-advancement behavior main
chat and workflow stages converged on in #2170/#2201, instead of a
subagents-local single-candidate path. Results report the effective model,
attemptedModels (from model_fallback events), and pre-spawn skipped
attempts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subagents): survive stale extension ctx in deferred job hydration

hydrateActiveJobsDeferred captured the extension ctx into a timer; after
session replacement or reload the ctx.cwd getter throws, crashing the host
(caught by the installed-package Node smoke test). Capture cwd while the
ctx is live and never touch ctx from the timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(natives): track subagent control exports in the binding contract

The Rust in-process subagent control plane added five NAPI exports
(AdmissionRefusalKind, AgentStatus, NapiSubagentControl, SubagentControl,
TerminationCause) that the export contract test never learned about, so
the coding-agent suite failed on both linux-x64 and windows-x64.

Add the five exports to EXPECTED_NATIVE_EXPORTS. The assertion stays an
exact whole-surface equality check.

* ci(test): build native bindings for the root suites

The bundled subagent extension now reaches the Rust control plane in
crates/atomic-natives through a static import, so the extension throws at
import time when no binding is present. The suites job carried no Rust
toolchain, so on a clean Windows checkout 21 unit suites died during
collection and 6 more tests failed on the empty tool list that followed.

Build the binding before the unit and integration steps, as agent-suite
already does. Measured at 42s Linux and 86s Windows, both inside the
existing 8/12 minute caps.

The topology contract asserted that suites needed no Rust. Replace that
stale claim with the ordering assertions the other native-consuming jobs
carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): add missing statement separator in Windows child-process probe

The PowerShell one-liner in subagents-zero-process joined the $parent
assignment and Get-CimInstance without a ';', producing a parser error
(Unexpected token 'Get-CimInstance') and a nonzero probe exit code on
the Windows suites job.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
sina85 added a commit to sina85/atomic that referenced this pull request Aug 6, 2026
Bring node-card model/thinking display + durable thinkingLevel persistence up
to date with upstream 0.9.13-alpha.1. Only CHANGELOG conflicted; the
stage-runner thinkingLevel additions auto-merged cleanly against upstream's
model-fallback convergence (bastani-inc#2201).
MarkAronov added a commit to MarkAronov/atomic that referenced this pull request Aug 7, 2026
…-summaries

Two conflicts, both in packages/coding-agent.

`agent-session-events.ts`: the session-summary launch landed on the same line
as the context-overflow fallback block and the turn-scoped model restore added
by bastani-inc#2201. Both sides are kept, with the launch last, for two reasons. A
successful `_trySwitchToFallbackModel()` returns above it, so a turn that
continues on another model is not summarized mid-flight. And
`_restoreFallbackModel()` has already run by then, which matters because the
launch reads `this.model` synchronously before it parks on `waitForIdle()`;
launching any earlier would send the summary request on the fallback model
rather than the one the user selected.

`CHANGELOG.md`: both sides appended to `[Unreleased]`. Kept both, `### Added`
first per the section order in AGENTS.md, with upstream's text and its new
`0.9.13-alpha.1` section unchanged.
MarkAronov added a commit to MarkAronov/atomic that referenced this pull request Aug 7, 2026
… one request

Every turn schedules a summary launch, and the previous turn's can still be in
flight when the next one wakes. The newcomer aborted its predecessor and issued
its own request, so both spent a provider call and only the second could
persist. The ordering that hid this was incidental: bastani-inc#2201 added awaits to the
turn path, and the wasted request became reliable rather than rare.

Publish the in-flight request as `_sessionSummaryRun`, carrying the
`throughId` it describes. A launch that wakes to find a run covering the same
conversation state now awaits it instead of replacing it; a launch describing a
newer state still supersedes, exactly as before.

Once a run is published, ownership of that slot rather than the token is what
licenses a write. A joiner claims the token on its way in, so a token check
after the request would have the joiner invalidate the very run it is waiting
for. The token still guards the parked phase, where a launch holds no
AbortController and nothing else can reach it. `abortSessionSummary()` clears
the run as well, so a provider that ignores its signal still fails the
ownership check, and a later launch cannot join a cancelled run.

The deferred behind that promise is hand-rolled rather than
`Promise.withResolvers`. coding-agent is the one compiled package here and its
lib target predates ES2024, so the shipped sources cannot use it even though
the raw-TypeScript packages do. Only `test:integration`, which compiles the
package the way the build does, catches that; the root `tsc --noEmit` runs
against a newer lib and passes.

Guard the launch itself with `typeof this._maybeGenerateSessionSummary ===
"function"`, matching every other optional method in that block. The
main-chat fallback suites drive `_processAgentEvent` on a synthetic session
object, so an unguarded call threw and took
`a compactable context overflow does not spend a fallback candidate` with it.

Two tests budgeted no response for the turn-2 launch, which reaches the
provider before disposal or the next prompt lands and is cancelled mid-request.
That request is spent either way and cannot be recalled, so both now budget it.
No assertion changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 12, 2026
…cker (#2155)

* feat(coding-agent): persist per-session resume summaries

Add a `session_summary` session entry recording a generated one-line summary
alongside the id of the last user/assistant message it describes. The resume
picker reads it during its existing single pass over the session file and
treats it as fresh only while that id is still the newest conversation
message, so a summary retires the moment the conversation moves on. A later
`branch_summary` retires it too, since the branch it described was abandoned.

The anchor is the last message id rather than the leaf id. Appends move the
leaf -- including the summary's own append, and every model or thinking-level
change -- so a leaf-based check would never match and would regenerate on
every idle.

Summary usage counts toward session usage totals and the footer, matching
branch summaries. It is deliberately excluded from cache-continuity stats,
where compaction and branch summaries reset the chain: a summary request is a
separate call and does not break the main prompt prefix.

Generation and display follow in later commits.

Refs: #1033

* feat(coding-agent): generate resume summaries when the agent goes idle

Add a one-line session summarizer that runs after `agent_end` and persists a
`session_summary` entry for the resume picker. It reuses the existing
summarization path -- entry selection under a token budget, transcript
serialization, credential resolution, stream function and retry policy -- with
a new one-line prompt, no reasoning request, and a hard clamp on the stored
line so a chatty model cannot break picker rendering.

Generation is fire-and-forget and silent by design: nothing awaits it, failures
and aborts return without surfacing anything, and retries carry no lifecycle
callbacks, since the picker already falls back to the session name or first
message.

It declines to run when disabled, in print or json mode, while streaming or
compacting, without a model, on workflow-stage sessions, on very short
sessions, or when the last conversation message has not moved since the stored
summary -- which is the main cost control.

Concurrency is handled by a monotonic token plus a session-scoped
AbortController. After the request returns, the run re-reads both the token and
the last conversation message id and discards its own result if either moved,
so a slow call cannot persist a summary the conversation has already outrun.
The controller is cleared only by the run that still owns it.

Adds `sessionSummary.enabled` (default true) as a kill switch.

Cancellation wiring from the prompt and shutdown paths, and seeding the
in-memory anchor on resume, follow in a later commit.

Refs: #1033

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(coding-agent): show session summaries in the resume picker

Surface the generated summary in `/resume` and `atomic -r`. The selector now
displays `summary ?? name ?? firstMessage`, so a fresh summary takes the row
and today's display becomes the fallback. Staleness needs no handling here: a
missing or outdated summary is simply absent from `SessionInfo`, so the chain
falls through on its own. Session id, message count, age, and cwd stay on the
row, and search matches against the summary as well.

Carry the field across the interactive-engine boundary too -- row type,
protocol parser, and the row-to-SessionInfo mapper -- otherwise summaries would
be silently dropped for engine-hosted pickers. The workflows package mirrors
the row type by hand rather than importing it, so it gets the field on both
sides; nothing enforces that mirror, and omitting either half would drop
summaries from `/workflow resume` with no type error.

Refs: #1033

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(coding-agent): cancel, reseed and document session summaries

Wire up the two lifecycle gaps and cover the feature with tests.

Cancellation: `abortSessionSummary()` now runs when the next prompt starts and
during session disposal. A background summary can no longer outlive the
conversation it describes or hold the process open at shutdown.

Resume: the "nothing new to summarize" check falls back to the persisted
`summarizedThroughId` when the in-memory anchor is empty, so the first idle
after resuming a session no longer regenerates a summary that is already
current.

Tests cover the listing rules (fresh, stale after a newer message, retired by a
later branch summary, unaffected by tool results, absent when never generated)
and the generation rules (anchored to the newest conversation message, no
regeneration while the conversation has not moved, disabled by setting, skipped
in non-interactive modes, and no persistence once the conversation has outrun
the request).

Documents the entry type and its staleness rule in the session format, the
picker behaviour and its fallbacks in the sessions guide, and the new setting.

Refs: #1033

* fix(coding-agent): stop a failed session summary from rejecting

Credential resolution throws outright when no API key is configured, which is
an ordinary state for a session that never prompts. Because the summary runs as
`void this._maybeGenerateSessionSummary()`, that throw escaped as an unhandled
rejection and could take the process down mid-run; the workflow tool-node quit
integration test hit it through a real CLI child and timed out.

Background work now swallows every failure -- credential resolution, generation,
and persistence alike -- which is what fire-and-forget has to mean here. The
returned `error`/`aborted` cases were already silent; thrown ones were not.

Refs: #1033

* fix(coding-agent): cancel the session summary at the right point in prompt()

Cancellation ran at the top of `prompt()`, ahead of the workflow-delivery
authorization boundary and the slash-command path, both of which must observe an
untouched session. It now runs once real user input is admitted, which is also
when a summary of the previous turn actually becomes stale.

Two prompt tests drive `prompt.call()` against hand-built stub sessions, so a
new method call on `this` surfaced there as `abortSessionSummary is not a
function` rather than as an assertion failure. Their fixtures gain the method.

Refs: #1033

* fix(coding-agent): apply one retirement rule to summaries, and honour abort

Two P1 findings from the automated review, both verified by running code.

A retired summary suppressed its own replacement. The resume fallback read the
latest persisted `session_summary` as its freshness anchor without the
later-`branch_summary` retirement rule the picker applies, so a session whose
summary was retired by a branch would match the anchor, skip generation on every
idle, and show fallback text in `/resume` indefinitely. `getLatestSessionSummary`
is now retirement-aware and the picker calls it too, instead of tracking the same
rule inline -- one lookup, both sides, which is what the split had broken.

An aborted request could still persist. `abortSessionSummary()` cancels the
signal without bumping the ordering token, so a provider that ignores the signal
and returns an ordinary result would pass the token and anchor checks and write a
summary that was explicitly cancelled. The signal is now checked directly before
persisting.

Adds listing coverage for a summary generated *after* a branch summary, which
must survive: retirement is positional, not permanent.

Refs: #1033

* fix(coding-agent): cancel an in-flight session summary on tree navigation

Third P1 from the automated review. Moving the leaf invalidates a summary still
being generated, but the anchor check could not see it: a `branch_summary` is
not a conversation message, and navigating to an existing assistant message
leaves the last conversation message id unchanged. A request that returned after
the move therefore passed the token and anchor checks and was persisted against
a branch that was no longer active.

`navigateTree` now cancels the in-flight summary the same way `prompt()` does,
which the signal check added for the previous finding turns into a silent
discard -- including for a provider that ignores cancellation and returns an
ordinary result.

The regression test holds the summary request open with a faux response factory,
moves the leaf past the last assistant with a `session_info` entry, then
navigates back to that message, so the anchor genuinely survives the move.
Verified to fail without the fix.

Refs: #1033

* fix(coding-agent): generate session summaries at a real idle boundary

`agent_end` fires while the agent still reports `isStreaming`, and that flag
survives the entire microtask queue, clearing only on a later macrotask. The
summary launch read the flag immediately and returned, with nothing to retry
it, so generation only ever happened when `_checkCompaction` on the preceding
line happened to cross a macrotask boundary. It does under the unit harness and
does not in the real TUI, so the suite stayed green while the feature produced
nothing.

Wait for `agent.waitForIdle()` instead, and claim the supersession token before
the wait so a later turn abandons a launch that is still parked.

Deferring to idle widens the window in which a launch exists with no
AbortController, which `abortSessionSummary()` cannot reach, so disposal is now
tracked as terminal state. `_disposed` is checked on entry, after the wait,
before auth, before the provider call, and before persisting.
`abortSessionSummary()` bumps the token as well as aborting, and `dispose()` is
idempotent.

Reported by @flora131 from a real tmux TUI run against the PR head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(coding-agent): collapse overlapping session-summary launches into one request

Every turn schedules a summary launch, and the previous turn's can still be in
flight when the next one wakes. The newcomer aborted its predecessor and issued
its own request, so both spent a provider call and only the second could
persist. The ordering that hid this was incidental: #2201 added awaits to the
turn path, and the wasted request became reliable rather than rare.

Publish the in-flight request as `_sessionSummaryRun`, carrying the
`throughId` it describes. A launch that wakes to find a run covering the same
conversation state now awaits it instead of replacing it; a launch describing a
newer state still supersedes, exactly as before.

Once a run is published, ownership of that slot rather than the token is what
licenses a write. A joiner claims the token on its way in, so a token check
after the request would have the joiner invalidate the very run it is waiting
for. The token still guards the parked phase, where a launch holds no
AbortController and nothing else can reach it. `abortSessionSummary()` clears
the run as well, so a provider that ignores its signal still fails the
ownership check, and a later launch cannot join a cancelled run.

The deferred behind that promise is hand-rolled rather than
`Promise.withResolvers`. coding-agent is the one compiled package here and its
lib target predates ES2024, so the shipped sources cannot use it even though
the raw-TypeScript packages do. Only `test:integration`, which compiles the
package the way the build does, catches that; the root `tsc --noEmit` runs
against a newer lib and passes.

Guard the launch itself with `typeof this._maybeGenerateSessionSummary ===
"function"`, matching every other optional method in that block. The
main-chat fallback suites drive `_processAgentEvent` on a synthetic session
object, so an unguarded call threw and took
`a compactable context overflow does not spend a fallback candidate` with it.

Two tests budgeted no response for the turn-2 launch, which reaches the
provider before disposal or the next prompt lands and is cancelled mid-request.
That request is spent either way and cannot be recalled, so both now budget it.
No assertion changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): keep the cycle-fallback request count to the prompt turn

sessionSummary generation issues a background provider request once the
agent goes idle, which the fake server in this test counts alongside the
cycled prompt's turn, so requests reached 2.

Turn summaries off in the fixture settings. The counter exists to prove
the fallback path does not fire a second turn for one prompt, and that
claim is only readable when the prompt is the sole caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(coding-agent): render session summaries in a dedicated picker column

- keep the session name/first message as the row identity; the generated
  summary renders beside it and never displaces it
- show a "No summary available." placeholder when a summary is missing,
  stale, or failed; omit the column entirely on narrow terminals
- gate the disposal test on request start so turn 2s launch provably
  spends a response before dispose(), fixing the linux CI failure where
  the pending-response count depended on scheduler timing
- update docs/sessions.md and the changelog wording to match

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(coding-agent): regenerate session summary after branch retirement

abortSessionSummary() now drops the in-memory anchor cache. The cache is
not retirement-aware: after branchWithSummary() retired the stored
summary without moving the last conversation message id, a cached anchor
still matching that id skipped regeneration and left the picker on
fallback text until the next real turn. The persisted lookup already
handles retirement, so the cache defers to it after any cancellation.

Addresses the one substantiated Greptile P1 on #2155.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(coding-agent): restore the released 0.9.13-alpha.2 changelog section

The branch's merge of main dropped the immutable [0.9.13-alpha.2]
section and left its entries duplicated under [Unreleased], which
the changelog immutability test rejects against the release tag.
CHANGELOG.md is now origin/main's content plus only this PR's own
entry under [Unreleased].

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger CI

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Norin Lavaee <nlavaee@umich.edu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Converge main-chat and workflow model fallback: share the failure classifier, scope the switch to the failing turn, and retry before advancing

2 participants