qwen-code/docs/design/daemon-session-model.md
jinye 2172721405
feat(cli): restore each daemon session onto its last selected model (#9687)
* feat(cli): restore each daemon session onto its last selected model

Idle detach currently rebuilds Config from settings.model.name, so session A picks up whatever model session B last switched to.

Fixes #9686

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address session-model persistence review findings

- reader: always select the last assistant record into the restore read
  set so the legacy lastAssistantModel fallback still fires when a
  trailing chat_compression candidate excludes it from the resume read
- recorder: assign currentSessionModel before the awaited write so a
  rewind landing in the pending-write window re-anchors the new binding
  instead of the stale one
- reader/recorder: reject non-string session_model payload fields
  instead of crashing the restore path on malformed transcripts
- protocol doc: describe the session_model append as best-effort, not
  an unconditional consequence of a successful switch
- cli: import RUNTIME_SNAPSHOT_PREFIX/stripRuntimeSnapshotPrefix from
  core instead of duplicating the prefix algorithm locally
- tests: pin the isRuntime/baseUrl payload dimension, the prefix and
  route-mismatch false arms, the neither-field fallback, and regression
  coverage for the two fixes above

* fix(cli): keep daemon session-model restore from failing load

Pre-auth restore skipped the last-assistant fallback, and a recorded qwen-oauth binding could hard-fail load when cached credentials were gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): roll session-model auth retry back onto the settings route

Same-id baseUrl restores and runtime-only settings models were skipping or breaking the fallback, which made load fail on the recorded credential set.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep empty daemon sessions from creating a transcript

Recording the session model on newSession wrote a jsonl file before any user content, so close/delete/child-death left the id occupied and listing still showed the empty session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allowlist restored session-model routes against the registry

JSONL baseUrl is only a registry selector, so unknown hosts are dropped before switchModel. Restore also keeps the last valid session_model payload instead of falling through a torn trailing record.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): retry session-model auth after same-id snapshot restore

The retry gate ignored runtime-snapshot identity, so restoring an implicit
registry route off a same-id snapshot looked unchanged and skipped rollback.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 16:33:36 +00:00

4 KiB

Daemon session model persistence

Status

Implementation companion to keeping a daemon session on the model it was created or last switched to, across detach / idle reap / daemon restart.

Problem

Each ACP session has its own in-memory Config, but Session.setModel (and ACP /model) also write settings.model.name. Switching away from an idle session typically detaches and closes it. The next load/resume builds a new Config from current settings, so session A picks up model B.

Assistant JSONL records store model per turn, but restore does not apply it.

Goals

  • Daemon load/resume of an existing session restores that session's model.
  • New sessions still inherit the last persisted model.name default.
  • TUI and CLI --resume do not switch models from this record.
  • Resume stays read-only (no JSONL append).

Non-goals

  • Restoring model in TUI / CLI --resume.
  • Changing approval-mode persistence.
  • Stopping model.name updates for new-session defaults.
  • Rebinding idle live sessions after workspaceReload.

Record format

Append-only system / session_model JSONL records, last-wins, same pattern as session_source.

interface SessionModelRecordPayload {
  modelId: string;
  authType: string;
  baseUrl?: string;
  isRuntime?: boolean;
}

modelId is the canonical id after switchModel (no ACP route id, no $runtime| prefix). Runtime selections store the underlying id with isRuntime: true.

Write sites (daemon user intent only)

All writes go through ChatRecordingService.recordSessionModel (best-effort, identical payload is a no-op), except rewind: rewindRecording re-appends the in-memory binding after the rewind record so last-wins on the active branch still matches Config.

  1. Session.setModel after a successful switch (including persistDefault: false).
  2. ACP /model <id> via switchMainModel when executionMode === 'acp'.
  3. rewindRecording, which re-anchors the live binding (not a user switch).

acpAgent.newSession must not write. Empty daemon sessions have no transcript file; listing, DELETE, and child death all depend on that. A new session already inherits settings.model.name. Load/resume of a session that never switched models uses the last assistant model, else the current settings default. A session that has user records but no assistant record and was never switched therefore has no binding; that residual window is accepted so empty sessions stay file-less.

loadSession / resumeSession must not write. workspaceReload must not write.

Implicit registry records omit baseUrl and isRuntime. Restore must still switchModel when the cold Config currently holds a same-id runtime snapshot, so the session leaves the snapshot endpoint instead of no-op'ing.

Restore (ACP cold start only)

Live attach/resume skips restore. Cold loadSession / resumeSession:

  1. newSessionConfig still constructs Config from current settings.
  2. Before ensureAuthenticated, apply the last valid session_model payload, else the last assistant model (same auth from modelsConfig.getCurrentAuthType() when content-generator auth is not yet populated), else keep settings. A recorded baseUrl is a registry route selector, not an arbitrary endpoint: it is honored only when it matches a configured registry route for that auth type and model, otherwise the implicit registry route is used. authType must be a known AuthType.
  3. switchModel failure is non-fatal. A recorded runtime-snapshot binding whose live snapshot is gone still switches the bare id when a registry route exists; that can be a different endpoint than the recorded binding. Restore continues on the settings default only when no route resolves. If the restored auth then fails ensureAuthenticated, load/resume reverts to the settings model and retries authentication once.

Surfaces

JSONL is shared, so core must accept the subtype. Replay already skips ordinary system records. Only ACP applies switchModel on restore.