Skip to content

Add model tiers with cross-model start-time failover - #1048

Draft
ferrislucas wants to merge 31 commits into
mainfrom
circus-chief/c17f-checkout-feature-description-b
Draft

Add model tiers with cross-model start-time failover#1048
ferrislucas wants to merge 31 commits into
mainfrom
circus-chief/c17f-checkout-feature-description-b

Conversation

@ferrislucas

@ferrislucas ferrislucas commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a Model Tiers system that lets users group provider/model pairs into an ordered failover chain, so sessions can automatically fall back to the next available model when a primary model fails to start
  • Adds a new Model Tiers settings tab (`/settings/tiers`) with full CRUD UI for creating, editing, and ordering tier members
  • Wires tier resolution into session execution so `tier::` sentinel refs are resolved at start time with cross-provider failover support
  • Narrowed start-time tier failover trigger set to only relevant error conditions
  • Fixed logging to capture the real agentType of the failing tier member
  • Fixed flaky ECONNRESET on rejected uploads by draining request body
  • Added comprehensive E2E test coverage for Model Tiers feature

Test plan

  • Navigate to Settings → Model Tiers and create a tier with multiple models
  • Create a session using a tier ref and verify it starts on the primary model
  • Unit tests: ModelTierRepository, modelTiers API, sessionExecution failover logic, frontend tiers store
  • E2E: settings tab count updated to 5 (was 4); all other settings E2E tests continue to pass
  • Full Playwright E2E suite passes (1174 tests, exit code 0)

🤖 Generated with Claude Code

ferrislucas and others added 5 commits July 3, 2026 19:26
Implements the plan in model-tiers-plan.md: users can define named,
ordered lists of models across providers ("tiers") and bind sessions,
templates, kanban lanes, project defaults, and the summary model to a
tier instead of a concrete model. When a session bound to a tier
starts, it tries the first healthy member and transparently fails
over to the next member on a service/token-limit error, crossing
providers if needed — before the first assistant message only.

- shared: tier ref helpers (isTierRef/parseTierRef/buildTierRef), Zod
  contracts, WS_MESSAGE_TYPES.TIER_FAILOVER, tier cooldown constants.
- server: model_tiers/model_tier_members tables + ModelTierRepository,
  REST API (/api/tiers), tierResolutionService (cooldown Map + member
  resolution), and the failover loop wired into the session start path
  (sessionTierFailover.js) via a tier-aware shouldRescheduleOnError.
  Extracted continueSessionCore into sessionContinuation.js to keep
  sessionExecution.js under the line-count lint limit.
- web: tiers Pinia store + API client, ModelSelector tier support
  (all 11 consumers inherit it), and a new Settings → Model Tiers tab
  for CRUD + reordering.
- sessions gain resolved_model/resolved_provider_id snapshot columns
  so they remember which concrete model they actually ran on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update E2E test to expect 5 tabs after Model Tiers tab was added to
SettingsView. Add missing modelTiers contract test file for the shared
package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes eight high-priority bugs from the model tiers remediation plan:

Fix 1 — sessionContinuation: resolve tier ref to concrete model before
  building agent env so continue turns never pass a raw `tier::<id>`
  sentinel to the agent SDK. Uses resolvedModel snapshot; falls back to
  live tier lookup if snapshot is absent.

Fix 2 — WebSocket: add `onTierFailover` event handler to
  useSessionSubscription composable and display a toast in
  SessionChatContent when a failover occurs.

Fix 3 — UI: ModelSelector shows a "Tier: <name>" chip badge when the
  session is bound to a tier; ConversationTab's activeModelDisplayName
  resolves tier refs to "Resolved Model (Tier: Name)".

Fix 4 — agentCallLogger: add _logFailoverEvent() to write tier failover
  events to the agent call log (visible in Settings → Logs).

Fix 5 — sessionTierFailover: after runSessionWithTierFailover, check
  `session.status === 'scheduled'` before writing the resolvedModel
  snapshot so auto-rescheduled sessions don't snapshot the failed member.

Fix 7 — sessionTierFailover: import DEFAULT_MAX_FAILOVER_ATTEMPTS from
  @circuschief/shared and cap the failover loop at
  Math.min(members.length, DEFAULT_MAX_FAILOVER_ATTEMPTS).

Fix 8 — ModelTiersView: prevent adding duplicate (providerId, modelId)
  pairs to a tier in the add-member dialog.

Fix 9 — summaryModelResolver: gracefully fall back to the default
  summary model instead of throwing when a tier-bound summary model has
  all members in cooldown.

Tests: new sessionContinuation.test.js (4 tests, Fix 1); extended
  sessionTierFailover.test.js (Fixes 5, 7); extended
  summaryModelResolver.test.js (Fix 9); extended useWebSocket.test.js
  (Fix 2); updated test mocks in SessionChatContent/Overlay tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract focused helpers from larger functions to clarify responsibilities:
- Split resolveConcreteContinueModel out of buildContinueModelAndEnv
- Extract emitTierFailoverEvent from handleTierMemberFailure
- Drop unused timestamp param/locals from failover logging paths
- Consolidate duplicate imports in summaryModelResolver test and ConversationTab

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: z.ai <noreply@z.ai>
@ferrislucas
ferrislucas marked this pull request as draft July 4, 2026 18:18
ferrislucas and others added 2 commits July 4, 2026 14:42
Fix 1 (blocker): Make buildModelAndProvider tier-aware so
continueSessionWithExistingMessage never forwards a raw tier:: sentinel
to the agent. Resolves to stored resolvedModel snapshot or falls back to
live resolveActiveModel lookup on the reschedule-retry path.

Fix 2: Make cooldown successor-aware — markUnhealthy is only called when
hasNextHealthyMember is true, preventing a dead-end where the terminal
member is cooled down and blocks both new starts and reschedule-retries.

Fix 3: Preserve resolvedModel when a successful run is proactively
rescheduled — snapshots the member that ran (has assistant messages)
regardless of subsequent scheduled status.

Fix 4: Introduce matchesStartFailoverEligibleError — a tighter matcher
for start-time tier failover that avoids spurious cross-provider failover
on non-quota errors (e.g. "Unexpected token in JSON"). Both
handleTierMemberFailure and isTierFailoverEligibleError now use it.
matchesTokenLimitError retains its intentional breadth for reschedule decisions.

Fix 5: Confirm and test that tierFailover log entries are isolated in
their own call_type bucket — they do not inflate runSession call_count
or token sums in getSessionStats/getGlobalStats.

Fix 6: Add doc comment to cooldown map documenting per-process scope
and restart-clears-cooldown behavior with a note for future multi-process support.

All new fixes ship with regression tests. Server suite: 4804 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Multer stops reading the request stream when it rejects an upload, so
responding immediately can reset the socket mid-upload instead of
closing cleanly. Drain the remaining body before sending the error
response to make the rejection path deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ferrislucas
ferrislucas marked this pull request as ready for review July 6, 2026 01:59
ferrislucas and others added 21 commits July 5, 2026 22:17
matchesStartFailoverEligibleError treated prompt-too-long conditions
(context length / context window / max_tokens) as failover-eligible.
These aren't outages or quota exhaustion, so failing over to another
provider won't fix an oversized prompt and can mask a real prompt-size
bug by silently bouncing across providers. Removed those patterns from
the tighter start-failover matcher; matchesTokenLimitError keeps its
intentional breadth for the separate auto-reschedule decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_logFailoverEvent always wrote agentType: 'claude-code' and success: false.
Failing over *from* a Codex/Gemini member logged the wrong agent type, and
success:false mapped the row to status 'error' (AgentCallLogRepository.complete),
putting a benign system event in the error bucket.

- agentCallLogger._logFailoverEvent now accepts an explicit agentType
  (falling back to 'claude-code') and completes with success: true — a
  failover that successfully advances is not a call failure. Stats are
  grouped by call_type, so tierFailover stays isolated from runSession
  cost/failure rollups either way.
- sessionTierFailover.emitTierFailoverEvent derives the source member's
  agentType via resolveAgentTypeFromModel(member.modelId) and passes it
  through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tests/e2e/model-tiers.spec.ts covering the deterministic surfaces
per the amended DoD (option A): Model Tiers settings tab render, tier
CRUD (create/rename/add-member/delete), member reorder persistence, and
ModelSelector's tier::<id> sentinel (mutually exclusive with a concrete
model). The failover *notice* toast is exercised by injecting a
TIER_FAILOVER message via page.routeWebSocket rather than provoking a
real provider outage — the cassette harness can't express a start-time
provider error deterministically. Actual failover *behavior* stays
covered by the server integration suite (sessionTierFailover.test.js).

While wiring the CRUD test, found ModelTiersView.vue calling
uiStore.showSuccess()/showError(), which don't exist on the ui store
(it exposes success()/error()/warning()/info()) — every create/update/
delete threw after the API call succeeded, leaving the modal stuck open
with a bogus "g.showSuccess is not a function" error. Fixed to use the
real store methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts to make PR #1048 ready for review:
- migrations/index.js: keep both appended migration blocks (model
  tiers from this branch + normalize-stale-claude-model-ids from main)
- ModelSelector.test.js: keep both describe blocks (tier support +
  orphaned/unknown model id); re-close the tier describe block

ModelSelector.vue auto-merged cleanly (unknown-model badge from main
is independent of the tier optgroup from this branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: z.ai <noreply@z.ai>
…embers

Built-in providers use fixed non-UUID ids (e.g. anthropic-default), so
the uuid() constraint on TierMember.providerId was incorrectly rejecting
valid requests. Relaxed to min(1) throughout the contract and updated
tests accordingly.

Also added Issue 3 fix to getTierMembersResolved: filter out members whose
model was deleted from an otherwise-present provider (model_tier_members
has no FK on model_id, so orphaned members must be filtered at query time).
Updated unit and E2E tests to register model ids so they are not treated
as orphans by this new filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the remaining gaps between the model-tiers FRD and the implementation
after PR #1048:

- Fix 1: sessionProvider's resolve*FromModel helpers now accept an explicit
  providerId hint to disambiguate a modelId shared by two providers.
- Fix 2: unify tier-ref resolution for continuation paths behind a new
  tierResolutionService.resolveTierRefForContinue helper, shared by
  sessionContinuation.buildContinueModelAndEnv and
  sessionManager.buildModelAndProvider. Switching from one bound tier to a
  different one now resolves the new tier live instead of reusing the old
  tier's snapshot, and an explicit concrete-model override always clears the
  stored resolvedModel/resolvedProviderId snapshot.
- Fix 3: refresh/backfill the active concrete snapshot whenever a session's
  tier binding changes or a legacy row is missing one.
- Fix 4: thread the exact tier member's providerId through the start-time
  failover path (attemptRunWithModel, reconcileAgentTypeForRun,
  resolveInitialSessionModelEnv, and the failover log's agentType) so
  cross-provider members sharing a modelId resolve correctly.
- Fix 5: add tierResolutionService.findNextHealthyTierMember — a single
  ordered, attempt-cap-aware scan shared by the cooldown decision and the
  failover notice/log so they can't disagree about which member is next.
- Fix 6: degrade safely when a new/scheduled session starts on a tier ref
  that no longer resolves (deleted or emptied) — fall back to the session's
  last concrete snapshot, or the server default, with a visible notice and
  log entry, instead of failing the session outright.
- Fix 7: allow summary settings to store a tier ref (summaryProviderId must
  be null) in the settings API validation, matching what
  summaryModelResolver and the web form already supported.
- Fix 8: keep the ModelSelector tier chip visible (and mark it stale) when
  the bound tier has been deleted or emptied, instead of silently falling
  back to a concrete default with no visible trace of the original binding.

Covered by new unit/integration tests across sessionProvider, sessionAgentGuard,
tierResolutionService, sessionTierFailover, sessionContinuation, sessionManager,
settings, and ModelSelector, plus the existing model-tiers/settings E2E specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…model tiers

Adds write-time validation of `tier::<id>` sentinel values across all session
and kanban write paths, ensuring broken tier bindings are never persisted.
Extends sessionAgentGuard to resolve the active tier member before performing
cross-kind drift checks, and clears stale providerId when a tier ref is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ckout-feature-description-b

Co-authored-by: z.ai <noreply@z.ai>

# Conflicts:
#	packages/web/src/components/ModelSelector.vue
…E reliability

Separates the archive and unarchive flows into distinct emitted events
(archive vs unarchive) so each handler is unambiguous regardless of
session state. Also updates model references from gpt-5.5 to gpt-5.6-sol
and hardens E2E tests with explicit element waits and expect.poll instead
of fixed timeouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the correctness gaps found in review against the Model Tiers PRD:
- Reject/degrade a summary tier binding that resolves to an unsupported
  provider kind (e.g. Google) at both write-time (settings API) and
  resolve-time (summaryModelResolver), so it can never silently mis-route
  to the Anthropic client.
- Unify the failover suppression check and the failover advance check on
  the same cap-aware resolver so they can't disagree for tiers with more
  than DEFAULT_MAX_FAILOVER_ATTEMPTS members, guaranteeing an exhausted
  tier always reaches terminal error handling instead of a silent hang.
- Tighten the 'billing' start-failover trigger to specific phrases so it
  no longer fires on unrelated text that merely mentions billing.
- Rename the create-time-only resolveAgentTypeFromModel in
  db/session-helpers.js to resolveInitialAgentTypeFromModel to
  disambiguate it from the provider-aware run-time resolver in
  sessionProvider.js.
Add a README features bullet with settings/edit-tier screenshots, a
technical Model Tiers section in docs/development.md, and update
CLAUDE.md's architecture notes to reflect the new tables, services,
and views introduced by the feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a scripted spawn-outcome seam (e2eSpawnOutcomes.js) and captured
agent event helpers (e2eSpawnEvents.js) so E2E tests can simulate
provider quota/rate-limit/outage/auth failures and assert cross-model
failover routing, plus a new model-tiers-failover.spec.ts suite that
exercises this against tierResolutionService and sessionExecution.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…into circus-chief/c17f-checkout-feature-description-b

Co-authored-by: Codex <noreply@openai.com>

# Conflicts:
#	packages/web/src/components/ModelSelector.test.js
#	packages/web/src/components/ModelSelector.vue
…into circus-chief/c17f-checkout-feature-description-b
…into circus-chief/c17f-checkout-feature-description-b
…into circus-chief/c17f-checkout-feature-description-b
…into circus-chief/c17f-checkout-feature-description-b
Resolves conflicts between the structured lane-run/workflow-session work on
main and the model-tiers work on this branch:

- sessionExecution.js: kept this branch's extraction of continueSessionCore
  into sessionContinuation.js and ported main's `interactive` flag onto the
  extracted copy. Kept main's workflow imports (workflowSessionService,
  drainLaneEntryTrigger) alongside the re-export.
- kanbanTriggers.js: kept this branch's tier-aware
  deriveAgentTypeForModelOrTier and resolveCommitAttributionOverrideForModel,
  combined with main's attachRootSession and its move of parentSessionId into
  sessions.create() (required by the new parent immutability trigger).
- kanban.js: kept both branches' imports.
- schemaBaseline.test.js: combined both column sets in physical order —
  main's workflow columns from schema.sql, then this branch's
  migration-added resolved_model / resolved_provider_id.

Merge-induced lint fixes:
- Extracted finishWorkflowTurn() and shouldRethrowForTierFailover() out of
  _executeSession, which exceeded max-statements/complexity once both
  branches' additions landed in it.
- Extracted resolveInheritedLaneRunId() into session-helpers.js, which put
  SessionRepository.js back under max-lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Minor fixes across 5 E2E spec files (archive-worktree-cleanup,
file-attachments, kanban-lane-run-structured, model-tiers,
session-navigation) to align with current app behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ckout-feature-description-b

Co-authored-by: Codex <noreply@openai.com>

# Conflicts:
#	packages/server/src/db/SessionRepository.js
@ferrislucas
ferrislucas marked this pull request as draft August 4, 2026 02:28
ferrislucas and others added 3 commits August 11, 2026 21:30
Co-authored-by: Codex <noreply@openai.com>
…ckout-feature-description-b

Co-authored-by: Codex <noreply@openai.com>

# Conflicts:
#	packages/server/src/api/kanban.test.js
#	packages/server/src/db/SessionRepository.js
#	packages/server/src/services/queryParamBuilder.js
#	packages/server/src/services/sessionExecution.js
#	packages/server/src/services/sessionProvider.test.js
#	packages/shared/src/index.js
#	packages/web/src/components/SessionChatContent.test.js
#	packages/web/src/components/SessionChatContent.vue
#	packages/web/src/components/SessionChatOverlay.test.js
#	packages/web/src/composables/useSessionSubscription.js
The origin/main merge (da92d4b) resolved several conflicted files by
keeping only the branch side, silently dropping code main had added.
That surfaced as 21 failing tests and 4 lint errors.

- SessionRepository: restore claimScheduled() (and its claimScheduledRow
  import) so scheduled-session claiming works again — this fixes the
  run-scheduled-now API and repository claim tests.
- queryParamBuilder: restore the conversationId param, interaction
  callbacks, and askUserQuestion toolConfig on the Claude path, and
  thread conversationId from the standard start and tier-failover start
  paths so the callbacks bind to the right conversation.
- sessionExecution/sessionContinuation/sessionTierFailover: restore the
  structured start-result contract ({started, sessionId, reason}). Both
  start paths now propagate _executeSession's rejection result and
  otherwise return startedSessionExecution(). A rejected dispatch inside
  the tier loop is surfaced verbatim rather than snapshotted as a
  healthy member or failed over from.
- sessionExecution: extract beginSessionStart() and
  _runTierBoundSession() out of runSessionCore, which was over the
  complexity and max-statements budgets after the merge.
- kanban.test.js: restore main's Idempotency-Key and cross-project
  ownership suites alongside the branch's tier-ref lane tests.
- kanbanService.test.js: mock resolveCommitAttributionOverrideForModel,
  which the model-tiers git-setup path now calls before dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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