Skip to content

Fix stuck model switches from false end-failure in session takeover - #1308

Open
nordicnode wants to merge 1 commit into
CodebuffAI:mainfrom
nordicnode:fix/1298-model-locked-takeover
Open

Fix stuck model switches from false end-failure in session takeover#1308
nordicnode wants to merge 1 commit into
CodebuffAI:mainfrom
nordicnode:fix/1298-model-locked-takeover

Conversation

@nordicnode

@nordicnode nordicnode commented Sep 9, 2026

Copy link
Copy Markdown

Problem & Context

#1298: picking a model while the server still holds a session row can strand the user for the full hour. In the model_locked deliberate-pick takeover (cli/src/hooks/use-freebuff-session.ts), the held row was released only when it was status === 'active' and matched the lock's model. Two real shapes fail that guard with no DELETE ever attempted:

  • ended rows still inside the server's grace window (with an instance id): the stale crashed-CLI rows this branch exists to clear, and the shape holdsLiveFreebuffSlot already treats as slot-holding;
  • rows that vanished between the lock response and the follow-up GET.

Either one made the client report "ending it failed, run /end-session", a thing it never tried, and the user waited out the hour.

Changes Made

  • Pure planner planModelLockedSwitch in cli/src/utils/freebuff-session-api.ts maps the takeover GET row to release (active on the locked model, or ended-within-grace, where the DELETE replays the refund receipt via the instance id), retry (no row left), or explain (unattributable; a different model's row is never deleted).
  • The takeover branch acts on the planner. retry re-POSTs the pick exactly once: the explicit-pick marker is consumed at branch head, so a second model_locked takes the existing silent-revert path.
  • The "ending it failed" message now fires only when a read or delete was genuinely attempted. /end-session, the withdrawn-model fallback flip, and server contracts are unchanged.

Verification

  • tsc --noEmit -p cli exits 0.
  • Repo-pinned bun x bun@1.3.14 run test in cli: 3035 pass / 18 fail on this head vs 3029 pass / 18 fail at base 529f7c01c. The failing set is identical pre-existing reds; the delta is the six added planner tests in freebuff-session-api.test.ts.
  • Added lines are prettier-clean; the touched files' pre-existing violations were left unformatted on purpose.

This fixes the client-side stuck state and the false report in #1298; the server's row lifecycle itself is outside this repo. Related to #1298.

The model_locked takeover only released rows that were active on the
locked model, so ended-within-grace rows (the stale rows the branch
exists for) and already-released rows produced an explanation claiming
the end had failed when no DELETE was ever attempted, locking the user
out of their pick for the session hour (CodebuffAI#1298).

Route the follow-up GET through a pure planner that separates release
(live row attributable to the lock) from retry (row already gone,
re-POST the consumed pick once) from explain (real read/delete failure
or an unattributable row). Grace-window DELETE replays the refund
receipt idempotently via the instance id.
@codebuff-team

Copy link
Copy Markdown
Contributor

Nice work. Pulling the takeover decision out into planModelLockedSwitch in freebuff-session-api.ts is the right move — it turns an untestable inline conditional in use-freebuff-session.ts into a pure function with six focused unit tests covering the actual failure modes (ended-but-in-grace, ended-past-grace, swept row, mismatched model, unreadable row). That's exactly the kind of change that's easy to port by hand into a private tree, which is what this mirror needs.

The logic itself holds up: treating ended rows with an instanceId as still holding a slot (matching what holdsLiveFreebuffSlot already does elsewhere) closes a real gap, and refusing to delete a row for a different model preserves the existing safety property. The one-shot retry via the consumed explicit-pick marker is a sensible way to avoid a loop when the lock races and releases itself before the follow-up GET lands.

One gap: there's no test exercising the hook branch itself — the lockRaced retry path, the one-retry-then-revert behavior, and the message-only-on-explain claim from the PR description are all asserted by reasoning about the diff rather than by a test against use-freebuff-session.ts. Given how async and store-coupled that file is, I understand why you stopped at the planner boundary, but a maintainer will likely want at least a smoke test on the hook's model_locked branch (mocking callFreebuffSession) before merging, since that's where the retry/no-loop guarantee actually lives, not in the pure function.

Good scope discipline otherwise — no touches to forbidden paths, no drive-by reformatting of the pre-existing prettier violations you called out.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree labels Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants