feat(cron): surface model drift impact in Desktop

This commit is contained in:
Carl Taylor
2026-08-11 00:52:58 +10:00
committed by Teknium
parent 1ef0a366da
commit 654435210c
24 changed files with 1219 additions and 82 deletions
+16 -1
View File
@@ -34,7 +34,7 @@ import { playWakeSound } from '@/lib/wake-sound'
import { $billingSettingsRequest } from '@/store/billing-block'
import { $desktopBoot } from '@/store/boot'
import { requestVoiceConversationStart } from '@/store/composer'
import { setCronFocusJobId } from '@/store/cron'
import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import {
@@ -158,6 +158,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// context (the sticky toast). The shell owns `navigate`, so it consumes the
// intent counter here; the ref skips the initial mount value.
const billingSettingsSeenRef = useRef(0)
const cronReviewSeenRef = useRef(0)
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
// Stable identity for the whole callback surface (see WiringActions). Mutated
// in place each render so memoized surfaces never re-render on churn.
@@ -166,6 +167,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const billingSettingsRequest = useStore($billingSettingsRequest)
const cronReviewRequest = useStore($cronReviewRequest)
const currentCwd = useStore($currentCwd)
// eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror
@@ -180,6 +182,19 @@ export function ContribWiring({ children }: { children: ReactNode }) {
navigate(`${SETTINGS_ROUTE}?tab=billing`)
}
}, [billingSettingsRequest, navigate])
// eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror
useEffect(() => {
if (cronReviewRequest === cronReviewSeenRef.current) {
return
}
cronReviewSeenRef.current = cronReviewRequest
if (cronReviewRequest > 0) {
navigate(CRON_ROUTE)
}
}, [cronReviewRequest, navigate])
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
@@ -31,6 +31,7 @@ vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
getApiRequestProfile: () => 'default',
getMoaModels: () => getMoaModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
@@ -71,7 +72,7 @@ beforeEach(() => {
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
getMoaModels.mockResolvedValue(null)
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
setModelAssignment.mockResolvedValue({ ok: true, provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
@@ -232,6 +233,7 @@ describe('ModelSettings', () => {
]
})
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'local-ollama',
model: 'qwen3:latest',
gateway_tools: []
@@ -353,6 +355,7 @@ describe('ModelSettings', () => {
it('warns when a main switch leaves auxiliary tasks pinned to another provider', async () => {
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'openrouter',
model: 'anthropic/claude-opus-4.7',
gateway_tools: [],
@@ -27,6 +27,7 @@ import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { setMainModelAssignment } from '@/store/cron-model-impact'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding'
@@ -619,10 +620,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const result = await setModelAssignment({
const result = await setMainModelAssignment({
model: selectedModel,
provider: selectedProvider,
scope: 'main',
...(selectedProviderRow?.api_url ? { base_url: selectedProviderRow.api_url } : {})
})
+7
View File
@@ -1370,6 +1370,13 @@ export const ar = defineLocale({
},
cron: {
close: 'إغلاق',
modelImpact: {
title: 'تحتاج المهام المجدولة إلى المراجعة',
message: count => `سيتم تخطي ${count} من المهام المجدولة حتى تراجع إعدادات النموذج الخاصة بها.`,
detailMore: (names, remaining) => `${names} و${remaining} أخرى`,
review: 'مراجعة المهام المجدولة',
saveFailed: 'لم يحفظ Hermes تغيير النموذج هذا.'
},
search: 'بحث',
loading: 'جار التحميل...',
states: {
+8
View File
@@ -1666,6 +1666,14 @@ export const en: Translations = {
close: 'Close cron',
title: 'Scheduled jobs',
count: count => `${count} ${count === 1 ? 'job' : 'jobs'}`,
modelImpact: {
title: 'Scheduled jobs need review',
message: count =>
`${count} scheduled ${count === 1 ? 'job' : 'jobs'} will be skipped until you review their model settings.`,
detailMore: (names, remaining) => `${names} and ${remaining} more`,
review: 'Review scheduled jobs',
saveFailed: 'Hermes did not save that model change.'
},
search: 'Search cron jobs...',
loading: 'Loading cron jobs...',
states: {
+7
View File
@@ -1489,6 +1489,13 @@ export const ja = defineLocale({
close: 'Cron を閉じる',
title: 'スケジュール済みジョブ',
count: count => `${count} 件のジョブ`,
modelImpact: {
title: 'スケジュール済みジョブの確認が必要です',
message: count => `モデル設定を確認するまで、${count} 件のスケジュール済みジョブがスキップされます。`,
detailMore: (names, remaining) => `${names}、ほか ${remaining} 件`,
review: 'スケジュール済みジョブを確認',
saveFailed: 'Hermes はモデルの変更を保存しませんでした。'
},
search: 'Cron ジョブを検索...',
loading: 'Cron ジョブを読み込み中...',
states: {
+7
View File
@@ -1403,6 +1403,13 @@ export interface Translations {
close: string
title: string
count: (count: number) => string
modelImpact: {
title: string
message: (count: number) => string
detailMore: (names: string, remaining: number) => string
review: string
saveFailed: string
}
search: string
loading: string
states: Record<string, string>
+7
View File
@@ -1436,6 +1436,13 @@ export const zhHant = defineLocale({
close: '關閉排程',
title: '排程工作',
count: count => `${count} 個工作`,
modelImpact: {
title: '排程工作需要檢查',
message: count => `在您檢查模型設定之前,${count} 個排程工作將被略過。`,
detailMore: (names, remaining) => `${names},以及另外 ${remaining} 個`,
review: '檢查排程工作',
saveFailed: 'Hermes 未儲存該模型變更。'
},
search: '搜尋排程工作…',
loading: '正在載入排程工作…',
states: {
+7
View File
@@ -1858,6 +1858,13 @@ export const zh: Translations = {
close: '关闭定时任务',
title: '定时任务',
count: count => `${count} 个任务`,
modelImpact: {
title: '定时任务需要检查',
message: count => `在您检查模型设置之前,${count} 个定时任务将被跳过。`,
detailMore: (names, remaining) => `${names},以及另外 ${remaining} 个`,
review: '检查定时任务',
saveFailed: 'Hermes 未保存该模型更改。'
},
search: '搜索定时任务…',
loading: '正在加载定时任务…',
states: {
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { HermesConnection } from '@/global'
import { getCronModelImpactScope, syncCronModelImpactConnection } from './cron-model-impact-scope'
function connection(
baseUrl: string,
wsUrl: string,
overrides: Partial<HermesConnection> = {}
): HermesConnection {
return {
baseUrl,
isFullscreen: false,
mode: 'remote',
nativeOverlayWidth: 0,
token: 'secret-not-part-of-identity',
wsUrl,
logs: [],
windowButtonPosition: null,
...overrides
}
}
describe('cron model impact backend identity', () => {
it('survives disconnects and reminted websocket tickets but invalidates for another backend', () => {
syncCronModelImpactConnection(connection('https://one.example', 'wss://one.example?ticket=first'))
const first = getCronModelImpactScope()
syncCronModelImpactConnection(null)
expect(getCronModelImpactScope()).toEqual(first)
syncCronModelImpactConnection(connection('https://one.example', 'wss://one.example?ticket=second'))
expect(getCronModelImpactScope()).toEqual(first)
syncCronModelImpactConnection(connection('https://two.example', 'wss://two.example?ticket=third'))
expect(getCronModelImpactScope().generation).toBe(first.generation + 1)
})
it('treats reminted SSH tunnel ports as the same remote backend', () => {
const ssh = {
remoteKind: 'ssh' as const,
remoteIdentity: 'operator@remote-box',
remoteHost: 'remote-box'
}
syncCronModelImpactConnection(connection('http://127.0.0.1:41001', 'ws://127.0.0.1:41001', ssh))
const first = getCronModelImpactScope()
syncCronModelImpactConnection(connection('http://127.0.0.1:52002', 'ws://127.0.0.1:52002', ssh))
expect(getCronModelImpactScope()).toEqual(first)
})
})
@@ -0,0 +1,65 @@
import type { HermesConnection } from '@/global'
let generation = 0
let connectionIdentity = ''
const invalidationListeners = new Set<() => void>()
export interface CronModelImpactScopeSnapshot {
connection: string
generation: number
}
export function getCronModelImpactScope(): CronModelImpactScopeSnapshot {
return { connection: connectionIdentity, generation }
}
export function beginCronModelImpactAssignment(): CronModelImpactScopeSnapshot {
generation += 1
return getCronModelImpactScope()
}
export function invalidateCronModelImpactScopeState(): void {
generation += 1
invalidationListeners.forEach(listener => listener())
}
export function onCronModelImpactScopeInvalidated(listener: () => void): () => void {
invalidationListeners.add(listener)
return () => invalidationListeners.delete(listener)
}
function identityForConnection(connection: HermesConnection | null): string {
if (!connection) {
return ''
}
const backendIdentity =
connection.remoteKind === 'ssh'
? connection.remoteIdentity || connection.remoteHost || ''
: connection.remoteIdentity || connection.baseUrl
return [connection.mode ?? '', connection.remoteKind ?? '', backendIdentity, connection.profile ?? ''].join(
'\u0000'
)
}
/** Keep pending responses and action closures bound to the backend that issued
* them without storing or comparing connection secrets. */
export function syncCronModelImpactConnection(connection: HermesConnection | null): void {
// A null descriptor is an ordinary reconnect state, not evidence that the
// user selected another backend. Retain the last durable identity so the
// reconnect can prove whether the backend actually changed.
if (!connection) {
return
}
const next = identityForConnection(connection)
if (connectionIdentity && connectionIdentity !== next) {
invalidateCronModelImpactScopeState()
}
connectionIdentity = next
}
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $cronReviewRequest } from '@/store/cron'
import { $notifications, clearNotifications, dismissNotification } from '@/store/notifications'
import type { ModelAssignmentResponse } from '@/types/hermes'
const setModelAssignment = vi.fn()
const getApiRequestProfile = vi.fn<() => string | null>(() => 'default')
vi.mock('@/hermes', () => ({
setModelAssignment: (...args: unknown[]) => setModelAssignment(...args),
getApiRequestProfile: () => getApiRequestProfile()
}))
import {
CRON_MODEL_IMPACT_NOTIFICATION_ID,
invalidateCronModelImpactScope,
setMainModelAssignment
} from '@/store/cron-model-impact'
function response(
impact: ModelAssignmentResponse['cron_model_impact']
): ModelAssignmentResponse {
return {
ok: true,
scope: 'main',
provider: 'nous',
model: 'new/model',
cron_model_impact: impact
}
}
function positive(name = 'Morning summary'): ModelAssignmentResponse['cron_model_impact'] {
return {
available: true,
guard_enabled: true,
affected_count: 1,
truncated: false,
jobs: [{ id: 'job-1', name, drifted_axes: ['provider', 'model'] }]
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
beforeEach(() => {
setModelAssignment.mockReset()
getApiRequestProfile.mockReset()
getApiRequestProfile.mockReturnValue('default')
clearNotifications()
invalidateCronModelImpactScope({ clearNotification: false })
})
describe('setMainModelAssignment', () => {
it('shows one consumer warning and routes via a read-only review action', async () => {
setModelAssignment.mockResolvedValue(response(positive()))
const requestCount = $cronReviewRequest.get()
await setMainModelAssignment({ provider: 'nous', model: 'new/model' })
expect(setModelAssignment).toHaveBeenCalledWith({
scope: 'main',
provider: 'nous',
model: 'new/model'
})
const notification = $notifications.get().find(item => item.id === CRON_MODEL_IMPACT_NOTIFICATION_ID)
expect(notification?.kind).toBe('warning')
expect(notification?.title).toBe('Scheduled jobs need review')
expect(notification?.message).toContain('1 scheduled job will be skipped')
expect(notification?.detail).toContain('Morning summary')
expect(notification?.action?.label).toBe('Review scheduled jobs')
notification?.action?.onClick()
expect($cronReviewRequest.get()).toBe(requestCount + 1)
expect(setModelAssignment).toHaveBeenCalledTimes(1)
})
it('ignores malformed untrusted impact data', async () => {
setModelAssignment.mockResolvedValue(
response({
available: true,
guard_enabled: true,
affected_count: 2,
truncated: false,
jobs: [{ id: 'job-1', name: 'One', drifted_axes: ['provider'] }]
})
)
await setMainModelAssignment({ provider: 'nous', model: 'new/model' })
expect($notifications.get()).toEqual([])
})
it('keeps an existing warning for an older backend but clears it on explicit zero impact', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
expect($notifications.get()).toHaveLength(1)
setModelAssignment.mockResolvedValueOnce(response(undefined))
await setMainModelAssignment({ provider: 'nous', model: 'two' })
expect($notifications.get()).toHaveLength(1)
const retainedAction = $notifications.get()[0].action
const reviewCount = $cronReviewRequest.get()
retainedAction?.onClick()
expect($cronReviewRequest.get()).toBe(reviewCount + 1)
setModelAssignment.mockResolvedValueOnce(
response({
available: true,
guard_enabled: true,
affected_count: 0,
truncated: false,
jobs: []
})
)
await setMainModelAssignment({ provider: 'nous', model: 'three' })
expect($notifications.get()).toEqual([])
})
it('rejects non-persisted confirmation outcomes without changing impact state', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
setModelAssignment.mockResolvedValueOnce({
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse)
await expect(
setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
).rejects.toThrow('Confirm this expensive model.')
expect($notifications.get()).toHaveLength(1)
const action = $notifications.get()[0].action
const reviewCount = $cronReviewRequest.get()
action?.onClick()
expect($cronReviewRequest.get()).toBe(reviewCount + 1)
})
it('publishes only the latest same-profile assignment when responses reverse', async () => {
const first = deferred<ModelAssignmentResponse>()
const second = deferred<ModelAssignmentResponse>()
setModelAssignment.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const firstCall = setMainModelAssignment({ provider: 'nous', model: 'first' })
const secondCall = setMainModelAssignment({ provider: 'nous', model: 'second' })
second.resolve(response(positive('Second job')))
await secondCall
first.resolve(response(positive('Stale first job')))
await firstCall
const notification = $notifications.get()[0]
expect(notification.detail).toContain('Second job')
expect(notification.detail).not.toContain('Stale first job')
})
it('invalidates pending responses and action closures on profile or connection changes', async () => {
const pending = deferred<ModelAssignmentResponse>()
setModelAssignment.mockReturnValueOnce(pending.promise)
const call = setMainModelAssignment({ provider: 'nous', model: 'pending' })
invalidateCronModelImpactScope()
pending.resolve(response(positive('Stale job')))
await call
expect($notifications.get()).toEqual([])
setModelAssignment.mockResolvedValueOnce(response(positive('Current job')))
await setMainModelAssignment({ provider: 'nous', model: 'current' })
const action = $notifications.get()[0].action
const requestCount = $cronReviewRequest.get()
getApiRequestProfile.mockReturnValue('other')
invalidateCronModelImpactScope({ clearNotification: false })
action?.onClick()
expect($cronReviewRequest.get()).toBe(requestCount)
})
it('clears an obsolete warning when the drift guard is disabled', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
expect($notifications.get()).toHaveLength(1)
setModelAssignment.mockResolvedValueOnce(
response({ available: true, guard_enabled: false, affected_count: 0, truncated: false, jobs: [] })
)
await setMainModelAssignment({ provider: 'nous', model: 'two' })
expect($notifications.get()).toEqual([])
})
it('does not let a dismissed notification mutate cron configuration', async () => {
setModelAssignment.mockResolvedValue(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'new/model' })
dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID)
expect($notifications.get()).toEqual([])
expect(setModelAssignment).toHaveBeenCalledTimes(1)
})
})
+195
View File
@@ -0,0 +1,195 @@
import { getApiRequestProfile, setModelAssignment } from '@/hermes'
import { translateNow } from '@/i18n'
import { requestCronReview } from '@/store/cron'
import {
beginCronModelImpactAssignment,
getCronModelImpactScope,
invalidateCronModelImpactScopeState,
onCronModelImpactScopeInvalidated
} from '@/store/cron-model-impact-scope'
import { dismissNotification, notify } from '@/store/notifications'
import type {
CronModelDriftAxis,
CronModelImpact,
CronModelImpactJob,
ModelAssignmentRequest,
ModelAssignmentResponse
} from '@/types/hermes'
export const CRON_MODEL_IMPACT_NOTIFICATION_ID = 'cron-model-impact'
const MAX_JOBS = 50
const MAX_ID_CODE_POINTS = 256
const MAX_NAME_CODE_POINTS = 120
const ALLOWED_AXES = new Set<CronModelDriftAxis>(['provider', 'model'])
function profileIdentity(): string {
return getApiRequestProfile()?.trim() || 'default'
}
function codePointLength(value: string): number {
return [...value].length
}
function hasControlCharacters(value: string): boolean {
return /\p{C}/u.test(value)
}
function validJob(value: unknown): value is CronModelImpactJob {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const job = value as Partial<CronModelImpactJob>
if (
typeof job.id !== 'string' ||
job.id.trim() !== job.id ||
!job.id ||
codePointLength(job.id) > MAX_ID_CODE_POINTS ||
hasControlCharacters(job.id) ||
typeof job.name !== 'string' ||
job.name.trim() !== job.name ||
!job.name ||
codePointLength(job.name) > MAX_NAME_CODE_POINTS ||
hasControlCharacters(job.name) ||
!Array.isArray(job.drifted_axes) ||
job.drifted_axes.length < 1 ||
job.drifted_axes.length > 2 ||
new Set(job.drifted_axes).size !== job.drifted_axes.length ||
!job.drifted_axes.every(axis => ALLOWED_AXES.has(axis))
) {
return false
}
return true
}
export function parseCronModelImpact(value: unknown): CronModelImpact | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const impact = value as Partial<CronModelImpact>
if (
typeof impact.available !== 'boolean' ||
typeof impact.guard_enabled !== 'boolean' ||
!Number.isSafeInteger(impact.affected_count) ||
(impact.affected_count ?? -1) < 0 ||
typeof impact.truncated !== 'boolean' ||
!Array.isArray(impact.jobs) ||
impact.jobs.length > MAX_JOBS ||
!impact.jobs.every(validJob)
) {
return null
}
const count = impact.affected_count as number
const ids = impact.jobs.map(job => job.id)
if (
new Set(ids).size !== ids.length ||
(!impact.truncated && count !== impact.jobs.length) ||
(impact.truncated && (impact.jobs.length !== MAX_JOBS || count <= impact.jobs.length))
) {
return null
}
return impact as CronModelImpact
}
function currentResponseScope(profile: string, connection: string, generation: number): boolean {
const scope = getCronModelImpactScope()
return profileIdentity() === profile && scope.connection === connection && scope.generation === generation
}
function currentActionScope(profile: string, connection: string): boolean {
return profileIdentity() === profile && getCronModelImpactScope().connection === connection
}
function detailFor(impact: CronModelImpact): string {
const visible = impact.jobs.slice(0, 3).map(job => job.name)
const remaining = impact.affected_count - visible.length
return remaining > 0
? translateNow('cron.modelImpact.detailMore', visible.join(', '), remaining)
: visible.join(', ')
}
function publishImpact(
impact: CronModelImpact,
profile: string,
connection: string,
generation: number
): void {
if (!impact.available) {
return
}
if (!impact.guard_enabled || impact.affected_count === 0) {
dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID)
return
}
notify({
id: CRON_MODEL_IMPACT_NOTIFICATION_ID,
kind: 'warning',
title: translateNow('cron.modelImpact.title'),
message: translateNow('cron.modelImpact.message', impact.affected_count),
detail: detailFor(impact),
action: {
label: translateNow('cron.modelImpact.review'),
onClick: () => {
if (currentActionScope(profile, connection)) {
requestCronReview()
}
}
}
})
}
export async function setMainModelAssignment(
request: Omit<ModelAssignmentRequest, 'scope'>
): Promise<ModelAssignmentResponse> {
const { connection, generation } = beginCronModelImpactAssignment()
const profile = profileIdentity()
const result = await setModelAssignment({ ...request, scope: 'main' })
if (result.ok !== true) {
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
if (!currentResponseScope(profile, connection, generation)) {
return result
}
// Missing means an older backend. It is not evidence that an existing impact
// has gone away, so leave the current warning untouched.
if (result.cron_model_impact !== undefined) {
const impact = parseCronModelImpact(result.cron_model_impact)
if (impact) {
publishImpact(impact, profile, connection, generation)
}
}
return result
}
export function invalidateCronModelImpactScope(options: { clearNotification?: boolean } = {}): void {
if (options.clearNotification === false) {
beginCronModelImpactAssignment()
return
}
invalidateCronModelImpactScopeState()
}
// Scope changes originating outside this module (profile/backend switches)
// clear any warning that belongs to the old runtime.
onCronModelImpactScopeInvalidated(() => dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID))
+5
View File
@@ -17,3 +17,8 @@ export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => $cronJobs.
// after consumption so re-opening cron normally doesn't re-focus a stale job.
export const $cronFocusJobId = atom<null | string>(null)
export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id)
// Shell-owned one-shot intent for stores without router context. Do not set a
// focus id here: the cron overlay's first fetch may not have loaded that row.
export const $cronReviewRequest = atom(0)
export const requestCronReview = () => $cronReviewRequest.set($cronReviewRequest.get() + 1)
+58
View File
@@ -416,6 +416,64 @@ describe('OAuth onboarding', () => {
expect(recommendedIndex).toBeGreaterThan(optionsIndex)
expect(setIndex).toBeGreaterThan(recommendedIndex)
})
it('does not advance when the default model assignment is not persisted', async () => {
const model = 'openai/gpt-5.5-pro'
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/oauth/nous/submit') {
return { ok: true, status: 'approved' }
}
if (path.startsWith('/api/model/options')) {
return { providers: [{ name: 'Nous Portal', slug: 'nous', models: [model] }] }
}
if (path.startsWith('/api/model/recommended-default?')) {
return { provider: 'nous', model, free_tier: false }
}
if (path === '/api/model/set') {
return {
ok: false,
provider: 'nous',
model,
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
}
}
throw new Error(`unexpected api path: ${path}`)
})
const requestGatewayMock = vi.fn(async (method: string) => {
if (method === 'reload.env') {
return {}
}
throw new Error(`unexpected gateway method: ${method}`)
})
const requestGateway = requestGatewayMock as OnboardingContext['requestGateway']
$desktopOnboarding.set(
baseState({
flow: {
status: 'awaiting_user',
provider: provider('nous', 'Nous Portal'),
start: {
auth_url: 'https://portal.example/auth',
expires_in: 600,
flow: 'pkce',
session_id: 'portal-session'
},
code: 'fresh-code'
},
requested: true
})
)
await submitOnboardingCode(onboardingContext(requestGateway))
const state = $desktopOnboarding.get()
expect(state.flow.status).toBe('error')
expect(state.flow.status === 'error' ? state.flow.message : '').toContain('Confirm this expensive model.')
expect(requestGatewayMock).not.toHaveBeenCalledWith('setup.runtime_check', expect.anything())
})
})
describe('saveOnboardingLocalEndpoint', () => {
+8 -9
View File
@@ -7,13 +7,13 @@ import {
listOAuthProviders,
pollOAuthSession,
setEnvVar,
setModelAssignment,
startOAuthLogin,
submitOAuthCode,
validateProviderCredential
} from '@/hermes'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { setMainModelAssignment } from '@/store/cron-model-impact'
import { notify, notifyError } from '@/store/notifications'
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -318,16 +318,16 @@ async function completeWithModelConfirm(
// config provider (e.g. anthropic from a prior failed setup) cannot make
// setup.runtime_check validate the wrong backend after a fresh OAuth login.
try {
const res = await setModelAssignment({
scope: 'main',
const res = await setMainModelAssignment({
provider: defaults.providerSlug,
model: defaults.defaultModel
})
notifyGatewayTools(res.gateway_tools)
} catch {
// Persistence failed — still run the scoped runtime check below and
// show the confirm card so the user can pick something explicitly.
} catch (error) {
onFail(error instanceof Error ? error.message : 'Hermes could not save the selected model.')
return
}
}
@@ -846,7 +846,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: strin
}
try {
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
await setMainModelAssignment({ provider: 'custom', model, base_url: url, api_key: key })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)
@@ -883,8 +883,7 @@ export async function setOnboardingModel(model: string) {
setFlow({ ...flow, currentModel: model, saving: true })
try {
await setModelAssignment({
scope: 'main',
await setMainModelAssignment({
provider: flow.providerSlug,
model
})
+2
View File
@@ -11,6 +11,7 @@ import {
storedStringArray,
storedStringRecord
} from '@/lib/storage'
import { invalidateCronModelImpactScopeState } from '@/store/cron-model-impact-scope'
import { $gateway, ensureGatewayForProfile, openGatewayForProfile } from '@/store/gateway'
import { setConnection } from '@/store/session'
import { resetStarmapGraph } from '@/store/starmap'
@@ -176,6 +177,7 @@ $activeGatewayProfile.subscribe(value => {
setApiRequestProfile(key)
if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) {
invalidateCronModelImpactScopeState()
// Profile-scoped settings + the unified session list are now stale.
// Narrowed so account/marketplace/onboarding caches don't refetch on
// every profile switch.
+5 -1
View File
@@ -6,6 +6,7 @@ import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import { syncCronModelImpactConnection } from '@/store/cron-model-impact-scope'
import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T)
@@ -621,7 +622,10 @@ export const $contextSuggestions = atom<ContextSuggestion[]>([])
export const $modelPickerOpen = atom(false)
export const $sessionPickerOpen = atom(false)
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
export const setConnection = (next: Updater<HermesConnection | null>) => {
updateAtom($connection, next)
syncCronModelImpactConnection($connection.get())
}
export const setGatewayState = (next: Updater<ConnectionState>) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
+20
View File
@@ -1268,6 +1268,22 @@ export interface StaleAuxAssignment {
model: string
}
export type CronModelDriftAxis = 'model' | 'provider'
export interface CronModelImpactJob {
id: string
name: string
drifted_axes: CronModelDriftAxis[]
}
export interface CronModelImpact {
available: boolean
guard_enabled: boolean
affected_count: number
truncated: boolean
jobs: CronModelImpactJob[]
}
/** One skill-hub source (official index, GitHub, skills.sh, …) as reported by
* `GET /api/skills/hub/sources`. */
export interface SkillHubSource {
@@ -1423,6 +1439,10 @@ export interface ModelAssignmentResponse {
* switching the main provider to Nous. Empty unless provider === 'nous'
* and the user is a paid subscriber with unconfigured tools. */
gateway_tools?: string[]
/** Additive profile-local cron impact returned after a persisted main assignment. */
cron_model_impact?: CronModelImpact
confirm_message?: string
confirm_required?: boolean
model?: string
ok: boolean
provider?: string
+20 -31
View File
@@ -45,8 +45,10 @@ from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli.config import (
_expand_env_vars,
cron_model_drift_axes,
cron_model_drift_guard_enabled,
load_config,
resolve_cron_model_drift_defaults,
)
from hermes_cli.fallback_config import get_fallback_chain
from hermes_time import now as _hermes_now
@@ -3790,14 +3792,12 @@ def run_job(
# Cron-fleet default beats the global chat model: it is
# the user's explicit "cron runs on this" setting.
model = _cron_default_model
elif isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
# Mirror the CLI/oneshot resolution: prefer ``default``,
# accept a ``model`` alias, overwrite only when truthy.
_default = _model_cfg.get("default") or _model_cfg.get("model")
if _default:
model = _default
else:
# Shared with Desktop's post-save impact summary so both
# paths compare snapshots against the same global model.
_, _global_model = resolve_cron_model_drift_defaults(_cfg)
if _global_model:
model = _global_model
except Exception as e:
logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e)
@@ -4051,30 +4051,19 @@ def run_job(
# unpinned cron jobs there, so the guard is skipped for that axis.
if cron_model_drift_guard_enabled(_cfg):
_drift: list[str] = []
_provider_snapshot = (job.get("provider_snapshot") or "").strip().lower()
if (
_provider_snapshot
and not (job.get("provider") or "").strip()
and not _cron_default_provider
_current_provider = str(
primary_provider_for_drift or runtime.get("provider") or ""
).strip().lower()
_current_model = str(primary_model_for_drift or "").strip().lower()
for _axis in cron_model_drift_axes(
job,
current_provider=_current_provider,
current_model=_current_model,
config=_cfg,
):
_current_provider = str(
primary_provider_for_drift or runtime.get("provider") or ""
).strip().lower()
if _current_provider and _current_provider != _provider_snapshot:
_drift.append(
f"provider '{_provider_snapshot}' -> '{_current_provider}'"
)
_model_snapshot = (job.get("model_snapshot") or "").strip().lower()
if (
_model_snapshot
and not (job.get("model") or "").strip()
and not _cron_default_model
):
_current_model = str(primary_model_for_drift or "").strip().lower()
if _current_model and _current_model != _model_snapshot:
_drift.append(
f"model '{_model_snapshot}' -> '{_current_model}'"
)
_snapshot = str(job.get(f"{_axis}_snapshot") or "").strip().lower()
_current = _current_provider if _axis == "provider" else _current_model
_drift.append(f"{_axis} '{_snapshot}' -> '{_current}'")
if _drift:
_changes = "; ".join(_drift)
logger.warning(
+183 -37
View File
@@ -27,6 +27,7 @@ import sys
import tempfile
import threading
import time
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Set
@@ -4640,16 +4641,182 @@ def _cron_fleet_default_covers_axis(
return isinstance(value, str) and bool(value.strip())
def _load_cron_jobs_for_config_warning() -> List[Dict[str, Any]]:
"""Best-effort read of the active profile's cron jobs database.
_CRON_MODEL_IMPACT_JOB_LIMIT = 50
_CRON_MODEL_IMPACT_ID_LIMIT = 256
_CRON_MODEL_IMPACT_NAME_LIMIT = 120
Delegates to ``cron.jobs.load_jobs`` to reuse its BOM handling, corruption
repair, and context-local store resolution (tests, embedders). Falls back
to an empty list on any failure so config writes never break.
def _model_assignment_text(value: Any) -> str:
"""Return a trimmed scalar model/provider value, or empty for malformed data."""
return value.strip() if isinstance(value, str) else ""
def resolve_cron_model_drift_defaults(
config: Any,
*,
environ: Optional[Dict[str, str]] = None,
) -> Tuple[str, str]:
"""Resolve the global provider/model values cron compares against snapshots.
Mirrors the scheduler's global-model precedence: a truthy configured model
wins ``HERMES_MODEL``; the environment is only a fallback. Per-job and cron
fleet defaults are handled by the caller/classifier because they suppress a
drift axis rather than changing the global assignment.
"""
env = os.environ if environ is None else environ
provider = ""
model = _model_assignment_text(env.get("HERMES_MODEL", ""))
model_config = config.get("model") if isinstance(config, dict) else None
if isinstance(model_config, str):
configured_model = model_config.strip()
if configured_model:
model = configured_model
elif isinstance(model_config, dict):
provider = _model_assignment_text(model_config.get("provider"))
configured_model = _model_assignment_text(
model_config.get("default")
or model_config.get("model")
or model_config.get("name")
)
if configured_model:
model = configured_model
return provider, model
def cron_model_drift_axes(
job: Any,
*,
current_provider: Any = "",
current_model: Any = "",
config: Any = None,
) -> List[str]:
"""Return the unpinned axes that the fail-closed cron guard would block."""
if not isinstance(job, dict) or not cron_model_drift_guard_enabled(config):
return []
current = {
"provider": _model_assignment_text(current_provider).lower(),
"model": _model_assignment_text(current_model).lower(),
}
drifted: List[str] = []
for axis in ("provider", "model"):
if _cron_fleet_default_covers_axis(axis, config):
continue
if _model_assignment_text(job.get(axis)):
continue
snapshot = _model_assignment_text(job.get(f"{axis}_snapshot")).lower()
if snapshot and current[axis] and snapshot != current[axis]:
drifted.append(axis)
return drifted
def _valid_cron_impact_job_id(value: Any) -> str:
if not isinstance(value, str):
return ""
job_id = value.strip()
if not job_id or len(job_id) > _CRON_MODEL_IMPACT_ID_LIMIT:
return ""
if any(unicodedata.category(char).startswith("C") for char in job_id):
return ""
return job_id
def _cron_impact_job_name(value: Any, job_id: str) -> str:
if isinstance(value, str):
printable = "".join(
char for char in value if not unicodedata.category(char).startswith("C")
)
name = " ".join(printable.split())[:_CRON_MODEL_IMPACT_NAME_LIMIT].rstrip()
if name:
return name
return f"Job {job_id}"[:_CRON_MODEL_IMPACT_NAME_LIMIT].rstrip()
def _unavailable_cron_model_impact(guard_enabled: bool) -> Dict[str, Any]:
return {
"available": False,
"guard_enabled": guard_enabled,
"affected_count": 0,
"truncated": False,
"jobs": [],
}
def build_cron_model_impact(
*,
current_provider: Any = "",
current_model: Any = "",
config: Any = None,
jobs: Any = None,
) -> Dict[str, Any]:
"""Build a bounded, profile-local summary of jobs blocked by model drift.
Job-store inspection is deliberately best effort: a model assignment has
already succeeded by the time Desktop requests this summary, so an unreadable
store is represented as unavailable instead of failing or rolling back it.
"""
guard_enabled = cron_model_drift_guard_enabled(config)
if jobs is None:
try:
from cron.jobs import load_jobs
jobs = load_jobs()
except Exception:
return _unavailable_cron_model_impact(guard_enabled)
if not isinstance(jobs, list):
return _unavailable_cron_model_impact(guard_enabled)
result: Dict[str, Any] = {
"available": True,
"guard_enabled": guard_enabled,
"affected_count": 0,
"truncated": False,
"jobs": [],
}
if not guard_enabled:
return result
from cron.jobs import is_job_runnable
seen_ids: Set[str] = set()
for job in jobs:
if not isinstance(job, dict):
continue
if not is_job_runnable(job) or job.get("no_agent"):
continue
job_id = _valid_cron_impact_job_id(job.get("id"))
if not job_id or job_id in seen_ids:
continue
seen_ids.add(job_id)
axes = cron_model_drift_axes(
job,
current_provider=current_provider,
current_model=current_model,
config=config,
)
if not axes:
continue
result["affected_count"] += 1
if len(result["jobs"]) < _CRON_MODEL_IMPACT_JOB_LIMIT:
result["jobs"].append(
{
"id": job_id,
"name": _cron_impact_job_name(job.get("name"), job_id),
"drifted_axes": axes,
}
)
result["truncated"] = result["affected_count"] > len(result["jobs"])
return result
def _load_cron_jobs_for_config_warning() -> List[Dict[str, Any]]:
"""Best-effort read of the active profile's cron jobs database."""
try:
from cron.jobs import load_jobs
return load_jobs()
jobs = load_jobs()
return jobs if isinstance(jobs, list) else []
except Exception:
return []
@@ -4659,46 +4826,25 @@ def warn_unpinned_cron_jobs_after_model_config_change(
value: Any,
config: Optional[Dict[str, Any]] = None,
) -> None:
"""Warn when a global model/provider change will trip cron's drift guard.
Cron intentionally fails closed when an unpinned agent job's current global
model/provider differs from its creation-time snapshot. Surface that outcome
when the operator changes the global axis instead of letting the next tick
be the first visible signal.
"""
"""Warn when a global model/provider change will trip cron's drift guard."""
axis = _cron_model_drift_axis_for_config_key(key)
if axis is None:
return
if not cron_model_drift_guard_enabled(config):
return
# A cron-fleet default covering this axis (cron.model /
# cron.model_provider) means unpinned jobs no longer follow the global
# value at all — the drift guard will not engage, so warning here would
# be a false alarm.
if _cron_fleet_default_covers_axis(axis, config):
return
new_value = str(value or "").strip().lower()
new_value = _model_assignment_text(value)
if not new_value:
return
pinned_field = axis
snapshot_field = f"{axis}_snapshot"
affected = 0
for job in _load_cron_jobs_for_config_warning():
if not job.get("enabled", True):
continue
if job.get("no_agent"):
continue
if str(job.get(pinned_field) or "").strip():
continue
snapshot = str(job.get(snapshot_field) or "").strip().lower()
if snapshot and snapshot != new_value:
affected += 1
impact = build_cron_model_impact(
current_provider=new_value if axis == "provider" else "",
current_model=new_value if axis == "model" else "",
config=config,
jobs=_load_cron_jobs_for_config_warning(),
)
affected = impact["affected_count"]
if affected <= 0:
return
snapshot_field = f"{axis}_snapshot"
noun = "job" if affected == 1 else "jobs"
verb = "has" if affected == 1 else "have"
print(
+17
View File
@@ -58,6 +58,7 @@ if str(PROJECT_ROOT) not in sys.path:
from hermes_cli import __version__, __release_date__
from hermes_cli.config import (
build_cron_model_impact,
cfg_get,
DEFAULT_CONFIG,
OPTIONAL_ENV_VARS,
@@ -69,6 +70,7 @@ from hermes_cli.config import (
load_config,
load_env,
read_raw_config,
resolve_cron_model_drift_defaults,
save_config,
save_env_value,
remove_env_value,
@@ -6804,6 +6806,20 @@ def _apply_model_assignment_sync(
"model": str(slot_cfg.get("model", "") or ""),
})
try:
effective_config = load_config()
effective_provider, effective_model = resolve_cron_model_drift_defaults(
effective_config
)
cron_model_impact = build_cron_model_impact(
current_provider=effective_provider or provider,
current_model=effective_model or model,
config=effective_config,
)
except Exception:
_log.debug("cron model impact inspection failed", exc_info=True)
cron_model_impact = build_cron_model_impact(config=cfg, jobs={})
return {
"ok": True,
"scope": "main",
@@ -6812,6 +6828,7 @@ def _apply_model_assignment_sync(
"base_url": model_cfg.get("base_url", ""),
"gateway_tools": gateway_tools,
"stale_aux": stale_aux,
"cron_model_impact": cron_model_impact,
}
# scope == "auxiliary"
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from typing import Any
import pytest
from hermes_cli.config import (
build_cron_model_impact,
cron_model_drift_axes,
resolve_cron_model_drift_defaults,
)
def _job(**overrides: Any) -> dict[str, Any]:
job = {
"id": "job-1",
"name": "Morning summary",
"enabled": True,
"no_agent": False,
"provider_snapshot": "openrouter",
"model_snapshot": "old/model",
}
job.update(overrides)
return job
def _impact(jobs: object, **config: Any) -> dict[str, Any]:
return build_cron_model_impact(
current_provider="nous",
current_model="new/model",
config=config,
jobs=jobs,
)
def test_drift_axes_match_unpinned_guard_semantics() -> None:
assert cron_model_drift_axes(
_job(), current_provider=" NOUS ", current_model="NEW/MODEL", config={}
) == ["provider", "model"]
assert cron_model_drift_axes(
_job(provider="openrouter"),
current_provider="nous",
current_model="new/model",
config={},
) == ["model"]
assert cron_model_drift_axes(
_job(model="old/model", provider="openrouter"),
current_provider="nous",
current_model="new/model",
config={},
) == []
def test_fleet_defaults_and_literal_false_guard_suppress_only_intended_axes() -> None:
assert cron_model_drift_axes(
_job(),
current_provider="nous",
current_model="new/model",
config={"cron": {"model": "fleet/model"}},
) == ["provider"]
assert cron_model_drift_axes(
_job(),
current_provider="nous",
current_model="new/model",
config={"cron": {"model_provider": "openrouter"}},
) == ["model"]
assert cron_model_drift_axes(
_job(),
current_provider="nous",
current_model="new/model",
config={"cron": {"model_drift_guard": False}},
) == []
assert cron_model_drift_axes(
_job(),
current_provider="nous",
current_model="new/model",
config={"cron": {"model_drift_guard": "false"}},
) == ["provider", "model"]
def test_summary_filters_disabled_no_agent_and_mixed_pins() -> None:
jobs = [
_job(id="disabled", enabled=False),
_job(id="script", no_agent=True),
_job(id="paused-state", state="paused"),
_job(id="paused-marker", paused_at="2026-08-11T00:00:00Z"),
_job(id="fully-pinned", model="old/model", provider="openrouter"),
_job(id="model-pinned", model="old/model"),
_job(id="provider-pinned", provider="openrouter"),
]
impact = _impact(jobs)
assert impact == {
"available": True,
"guard_enabled": True,
"affected_count": 2,
"truncated": False,
"jobs": [
{"id": "model-pinned", "name": "Morning summary", "drifted_axes": ["provider"]},
{"id": "provider-pinned", "name": "Morning summary", "drifted_axes": ["model"]},
],
}
def test_summary_handles_legacy_and_malformed_records_without_hiding_valid_siblings() -> None:
impact = _impact(
[
None,
"bad",
{"id": ["not-a-string"]},
_job(id="job-1", name=" Morning\u0000 summary "),
_job(id="job-1", name="duplicate"),
_job(id="job-2", name=42),
_job(id="missing-snapshot", provider_snapshot=None, model_snapshot={}),
]
)
assert impact["affected_count"] == 2
assert impact["jobs"] == [
{"id": "job-1", "name": "Morning summary", "drifted_axes": ["provider", "model"]},
{"id": "job-2", "name": "Job job-2", "drifted_axes": ["provider", "model"]},
]
@pytest.mark.parametrize("jobs", [{"jobs": []}, "jobs", 7])
def test_non_list_job_collection_is_unavailable(jobs: object) -> None:
assert _impact(jobs) == {
"available": False,
"guard_enabled": True,
"affected_count": 0,
"truncated": False,
"jobs": [],
}
def test_summary_bounds_id_name_and_payload() -> None:
valid = [_job(id=f"job-{index:03}", name=f" Job {index} ") for index in range(51)]
jobs = [
_job(id="x" * 257),
_job(id="bad\u200bid"),
_job(id="edge", name="x" * 121),
*valid,
]
impact = _impact(jobs)
assert impact["affected_count"] == 52
assert len(impact["jobs"]) == 50
assert impact["truncated"] is True
assert impact["jobs"][0]["id"] == "edge"
assert impact["jobs"][0]["name"] == "x" * 120
assert impact["jobs"][-1]["id"] == "job-048"
def test_fallback_name_respects_the_desktop_code_point_limit() -> None:
job_id = "x" * 256
impact = _impact([_job(id=job_id, name=42)])
assert impact["affected_count"] == 1
assert impact["jobs"][0]["name"] == (f"Job {job_id}")[:120]
def test_guard_disabled_summary_is_available_but_empty() -> None:
assert _impact([_job()], cron={"model_drift_guard": False}) == {
"available": True,
"guard_enabled": False,
"affected_count": 0,
"truncated": False,
"jobs": [],
}
def test_effective_defaults_match_scheduler_config_over_env_precedence() -> None:
assert resolve_cron_model_drift_defaults(
{"model": {"provider": "managed", "default": "managed/model"}},
environ={"HERMES_MODEL": "env/model"},
) == ("managed", "managed/model")
assert resolve_cron_model_drift_defaults(
{"model": {}}, environ={"HERMES_MODEL": "env/model"}
) == ("", "env/model")
assert resolve_cron_model_drift_defaults(
{"model": "legacy/model"}, environ={"HERMES_MODEL": "env/model"}
) == ("", "legacy/model")
def test_loader_exception_returns_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
import cron.jobs
def fail() -> list[dict[str, Any]]:
raise RuntimeError("broken store")
monkeypatch.setattr(cron.jobs, "load_jobs", fail)
impact = build_cron_model_impact(
current_provider="nous", current_model="new/model", config={}
)
assert impact["available"] is False
assert impact["affected_count"] == 0
@@ -6,6 +6,8 @@ profile switcher can target any profile's HERMES_HOME. These tests pin:
reads/writes land in the REQUESTED profile, the dashboard's own profile
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
"""
import json
import pytest
import yaml
@@ -50,6 +52,12 @@ def _cfg(home):
return yaml.safe_load((home / "config.yaml").read_text()) or {}
def _write_jobs(home, jobs):
cron_dir = home / "cron"
cron_dir.mkdir(parents=True, exist_ok=True)
(cron_dir / "jobs.json").write_text(json.dumps(jobs), encoding="utf-8")
class TestProfileScopedConfig:
@@ -180,6 +188,113 @@ class TestProfileScopedModel:
if isinstance(default_model, dict):
assert default_model.get("default") != "test/model-1"
def test_main_assignment_reports_only_target_profile_cron_impact(
self, client, isolated_profiles
):
stale = {
"name": "Worker summary",
"enabled": True,
"no_agent": False,
"provider_snapshot": "openrouter",
"model_snapshot": "old/model",
}
_write_jobs(
isolated_profiles["worker_beta"], [{"id": "worker-job", **stale}]
)
_write_jobs(
isolated_profiles["default"],
[{"id": "default-job", **stale, "name": "Default summary"}],
)
resp = client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "new/model",
"confirm_expensive_model": True,
"profile": "worker_beta",
},
)
assert resp.status_code == 200
assert resp.json()["cron_model_impact"] == {
"available": True,
"guard_enabled": True,
"affected_count": 1,
"truncated": False,
"jobs": [
{
"id": "worker-job",
"name": "Worker summary",
"drifted_axes": ["provider", "model"],
}
],
}
def test_unavailable_impact_does_not_fail_persisted_assignment(
self, client, isolated_profiles, monkeypatch
):
import cron.jobs
monkeypatch.setattr(cron.jobs, "load_jobs", lambda: {"malformed": True})
resp = client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "new/model",
"confirm_expensive_model": True,
"profile": "worker_beta",
},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert resp.json()["cron_model_impact"]["available"] is False
assert _cfg(isolated_profiles["worker_beta"])["model"]["default"] == "new/model"
def test_auxiliary_and_confirmation_responses_have_no_impact_summary(
self, client, isolated_profiles
):
_write_jobs(
isolated_profiles["worker_beta"],
[
{
"id": "worker-job",
"enabled": True,
"provider_snapshot": "openrouter",
"model_snapshot": "old/model",
}
],
)
auxiliary = client.post(
"/api/model/set",
json={
"scope": "auxiliary",
"provider": "nous",
"model": "new/model",
"profile": "worker_beta",
},
)
confirmation = client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "openrouter",
"model": "openai/gpt-5.5-pro",
"profile": "worker_beta",
},
)
assert auxiliary.status_code == 200
assert "cron_model_impact" not in auxiliary.json()
assert confirmation.status_code == 200
assert confirmation.json()["confirm_required"] is True
assert "cron_model_impact" not in confirmation.json()