feat(cron): surface model drift impact in Desktop
This commit is contained in:
@@ -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 } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user