Skip to content

fix(coding-agent): run resume budget check before session teardown - #1473

Open
Tinycute00 wants to merge 2 commits into
code-yeongyu:mainfrom
Tinycute00:fix/resume-budget-preflight
Open

fix(coding-agent): run resume budget check before session teardown#1473
Tinycute00 wants to merge 2 commits into
code-yeongyu:mainfrom
Tinycute00:fix/resume-budget-preflight

Conversation

@Tinycute00

@Tinycute00 Tinycute00 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Interactive /resume of a session whose restored transcript exceeds the current model's context budget silently killed the whole process (exit 1, no visible error). This fixes it in two layers.

Root cause

AgentSessionRuntime.switchSession() (packages/coding-agent/src/core/agent-session-runtime.ts) ran teardownCurrent("resume", ...) — which aborts the session, emits session_shutdown, calls dispose() and invalidates the extension runnerbefore the resume admission check. That admission check (assertModelUsableModelUsabilityBudgetError) only fires later, inside createAgentSession (sdk.ts), i.e. after teardown. So a resume that was always going to be rejected first destroyed the live session the user was still in. Any subsequent input then crashed with This extension ctx is stale after session replacement or reload.

On top of that, InteractiveMode.handleResumeSession() (packages/coding-agent/src/modes/interactive/interactive-mode.ts) routed every non-MissingSessionCwdError to handleFatalRuntimeError → process.exit(1). The error text was appended to the chat container, but the TUI was torn down before it could repaint, so the user saw a silent drop to the shell.

Repro (kimi-coding/k3-256k, contextWindow 262144, maxTokens 131072): a target session at ~148011 live tokens requires ~323030 tokens → ModelUsabilityBudgetError. Before: silent exit 1. With a 1M-context model the same resume succeeds.

Fix

Part A — agent-session-runtime.ts. switchSession now calls a new assertSessionAdmissible(sessionManager) before teardownCurrent. It rebuilds the target session context, sums live-context tokens via estimateTokens, and runs this.session.assertModelUsable(model, liveContextTokens, { includeSpeculationLead: false, admission: "resume" }) against the current model — mirroring the check createAgentSession performs after teardown. Running it pre-teardown makes a rejected resume a clean no-op: the live session is never disposed and its extension runner stays active. No extension hook exists between teardownCurrent and createRuntime, so this ordering must live in the core runtime.

Part B — interactive-mode.ts. handleResumeSession's catch block now handles ModelUsabilityBudgetError before the handleFatalRuntimeError fallback: it shows the message via showError and returns { cancelled: true }. An over-budget resume is an expected, recoverable outcome, not a fatal fault — the interactive session stays alive with a visible explanation instead of process.exit(1).

Why not an extension

The teardown/invalidate ordering is core AgentSessionRuntime state-machine behavior with no extension hook between teardownCurrent and createRuntime; the process-exit decision is core interactive-mode control flow in the caught branch. Both changed files are upstream-tracked, so each has a changes.md entry (src/core/changes.md, src/modes/interactive/changes.md) covering what changed, why, why an extension could not handle it, and expected merge-conflict zones (all LOW).

Verification evidence

  • Before: rejected resume disposes the live session; extension runner invalidated; next input throws the stale-context error; interactive mode process.exit(1) with no visible message.
  • After: rejected resume throws ModelUsabilityBudgetError without touching the live session (extensionRunner.isActive === true, same session object/file, next prompt runs); interactive mode shows the error and cancels; a large-context model still resumes fine.

Test plan

  • New regression test (TDD): packages/coding-agent/test/suite/agent-session-runtime.test.ts → "rejects an over-budget resume without invalidating the live session". It seeds a target session whose transcript far exceeds the model's context window, asserts switchSession rejects with ModelUsabilityBudgetError, and asserts the live session is untouched (extensionRunner.isActive === true, same object/file, next prompt resolves). Confirmed it fails without Part A (expected false to be true on isActive) and passes with it.
  • bun run --cwd packages/coding-agent test -- suite/agent-session-runtime.test.ts suite/model-usability-budget.test.ts24 passed.
  • bun run check (biome + pinned-deps/ts-imports/shrinkwrap/install-lock/claude-sdk-platform-lock + tsc --noEmit + browser-smoke) → pass (exit 0, 0 TS errors). Also enforced by the pre-commit hook on commit.

Notes

  • Same bug shape exists upstream in badlogic/pi-mono (the ordering lives in the shared AgentSessionRuntime); this PR targets code-yeongyu/senpi per the fork's contribution rules.
  • Diff is minimal: the two source changes + one regression test + two changes.md entries. No drive-by edits.

Summary by cubic

Fixes /resume of an over-budget session so it no longer destroys the live session or exits the process. The model-budget admission check now runs before session teardown and the switch lifecycle event, against the model the resume will actually restore, and interactive mode shows the error and cancels instead of calling process.exit(1).

  • A rejected resume is a true no-op: the live session and its extension runner stay intact, and no session_before_switch event fires.
  • The check resolves the destination session's stored model, so a resume the active model would fit but the restored model cannot is still rejected up front.
  • The cwd-override retry gets the same recoverable handling, and the rejection keeps its typed identity across the shared-host RPC boundary.
  • Adds regression tests for the runtime, interactive mode, and RPC client.

Written for commit 0b85a15. Summary will update on new commits.

Review in cubic

Resuming a session whose restored transcript exceeds the current model's
context budget threw ModelUsabilityBudgetError only from createAgentSession,
which runs after switchSession has already torn down the live session and
invalidated its extension runner. A resume that was always going to be
rejected therefore destroyed the session the user was still in, and the next
input crashed with the stale-extension-context error. In interactive mode the
error was routed to handleFatalRuntimeError -> process.exit(1), so the user
was silently dropped to the shell.

AgentSessionRuntime.switchSession now runs assertSessionAdmissible before
teardownCurrent, mirroring the post-teardown admission check in createAgentSession
against the current model. A rejected resume becomes a clean no-op that leaves
the live session intact. InteractiveMode.handleResumeSession now catches
ModelUsabilityBudgetError, shows it via showError, and returns cancelled instead
of exiting the process.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T00:33:49.001890Z 99e7984 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99e7984681

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +282 to +285
const model = this.session.model;
if (!model) return;
const liveContextTokens = existingSession.messages.reduce((total, message) => total + estimateTokens(message), 0);
this.session.assertModelUsable(model, liveContextTokens, { includeSpeculationLead: false, admission: "resume" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check admission against the model that resume will restore

When the destination session stores a different model from the active session, this preflight evaluates this.session.model (and the active session's prompt/tool budget), while createAgentSession later restores existingSession.model using the destination services before performing the authoritative check. If the active model has a larger window than the restored model, this check passes, teardown still invalidates the live session, and the post-teardown check throws—the destructive failure this change intends to prevent; the inverse combination incorrectly rejects a valid resume. Resolve the destination runtime inputs before performing the preflight.

AGENTS.md reference: packages/coding-agent/src/core/AGENTS.md:L37-L40

Useful? React with 👍 / 👎.

Comment on lines +7580 to +7583
if (error instanceof ModelUsabilityBudgetError) {
this.showError(`Failed to resume session: ${error.message}`);
return { cancelled: true };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch budget failures from the cwd-override retry

When the selected session's recorded cwd is missing, the first switch enters the MissingSessionCwdError branch and the user supplies a replacement cwd, but any ModelUsabilityBudgetError thrown by the second switchSession call occurs from inside this catch block and cannot be handled by the sibling branch below. The session selector invokes this async callback through a void callback, so an over-budget moved session still produces an unhandled rejection instead of the visible recoverable error promised here; apply the budget-error handling to the override attempt as well.

Useful? React with 👍 / 👎.

Comment on lines +7580 to +7583
if (error instanceof ModelUsabilityBudgetError) {
this.showError(`Failed to resume session: ${error.message}`);
return { cancelled: true };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve budget error identity across the shared-host RPC

When experimental.sharedHost is enabled, switchSession runs through RpcClient, whose getData reconstructs all non-missing-cwd failures as plain Error objects. Consequently a host-side ModelUsabilityBudgetError never satisfies this instanceof check and falls through to handleFatalRuntimeError, so the TUI still exits for exactly the recoverable resume failure this branch is intended to handle. Carry a typed RPC error code and reconstruct or otherwise classify the budget error on the client.

Useful? React with 👍 / 👎.

const previousSessionFile = this.session.sessionFile;
const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride);
assertSessionCwdExists(sessionManager, this.cwd);
await this.assertSessionAdmissible(sessionManager);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run admission before firing the switch lifecycle event

On an over-budget resume, session_before_switch is emitted before this new check, so handlers can irreversibly tear down live-session state even though the switch is then rejected and the current session remains active. This already affects the bundled btw extension: its handler aborts the active side query and removes its widget, after which assertSessionAdmissible throws and there is no shutdown/start sequence or rollback. Perform the non-mutating admission check before emitting the pre-switch event so a rejected resume is actually a no-op.

AGENTS.md reference: packages/coding-agent/AGENTS.md:L62-L64

Useful? React with 👍 / 👎.

…ore the switch event

Resolve the model a resume will actually restore (the destination session's
stored model via resolveStoredModelReference when present and authorized, else
the live model) and run the admission check against it, so a resume that the
active model would fit but the restored model cannot is rejected before the
live session is torn down. Move the non-mutating preflight (SessionManager.open,
cwd existence, admission) ahead of the session_before_switch emit so a rejected
resume is a true no-op and never lets handlers mutate live-session state.

Give the cwd-override retry in handleResumeSession the same recoverable budget
handling via a shared cancelResumeWithBudgetError helper instead of letting a
second-attempt budget error escape as an unhandled rejection.

Preserve the budget rejection's typed identity across the shared-host RPC
boundary: connection-handler emits a model_usability_budget error code with the
projection, and rpc-client reconstructs ModelUsabilityBudgetError so the TUI's
instanceof check still holds client-side.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Tinycute00

Copy link
Copy Markdown
Contributor Author

Addressed all four Codex findings plus the Changelog gate on this branch (commit 0b85a15f2):

  • P1 (check the restored model): assertSessionAdmissible now resolves the model the resume will actually restore via the new resolveResumeModelresolveStoredModelReference(existingSession.model, this.session.modelRuntime) when the destination carries an authorized stored model, else the active model — and runs the budget check against that, so a resume the active model would fit but the restored model cannot is rejected before teardown.
  • P2 (cwd-override retry budget catch): the MissingSessionCwdError override attempt in handleResumeSession is now wrapped in its own try/catch; a ModelUsabilityBudgetError from the second switchSession gets the same showError + { cancelled: true } treatment via the shared cancelResumeWithBudgetError helper instead of escaping as an unhandled rejection.
  • P2 (RPC error identity): connection-handler now emits a typed model_usability_budget error code carrying the projection, and rpc-client.getData reconstructs ModelUsabilityBudgetError from that code (not a message substring), so the client-side instanceof check holds under experimental.sharedHost and the TUI no longer exits.
  • P2 (admission before the switch event): the non-mutating preflight (SessionManager.open, assertSessionCwdExists, assertSessionAdmissible) now runs before emitBeforeSwitch("resume", ...), so a rejected resume is a true no-op and no handler (e.g. the btw side-query abort / widget removal) mutates live-session state.
  • Changelog gate: added a ### Fixed entry under ## [Unreleased] in packages/coding-agent/CHANGELOG.md and updated the src/core, src/modes/interactive, and src/modes/rpc changes.md trackers; the gate now passes (3 production paths covered).

Tests: extended test/suite/agent-session-runtime.test.ts (restored-model preflight + pre-emit ordering as a no-op), added test/rpc-client-budget-error.test.ts (client-side typed-error reconstruction) and test/interactive-mode-resume-budget.test.ts (first-attempt and cwd-override budget catch). All pass; bun run check is green.

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.

1 participant