Skip to content

Commit ab84fa4

Browse files
authored
Merge pull request #427 from Lexus2016/sync/upstream-2026-06-21-b
sync: upstream daily catch-up 2026-06-21b (9 commits → 0 behind)
2 parents aa0b498 + 8789fdd commit ab84fa4

13 files changed

Lines changed: 1253 additions & 123 deletions

File tree

apps/desktop/src/components/model-visibility-dialog.tsx

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ import {
1414
$visibleModels,
1515
collapseModelFamilies,
1616
effectiveVisibleKeys,
17-
emptyProviderSentinelKey,
18-
isProviderSentinel,
1917
modelVisibilityKey,
20-
setVisibleModels
18+
setVisibleModels,
19+
toggleModelVisibility
2120
} from '@/store/model-visibility'
2221
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
2322

@@ -61,25 +60,7 @@ export function ModelVisibilityDialog({
6160
const visible = effectiveVisibleKeys(stored, providers)
6261

6362
const toggle = (provider: ModelOptionProvider, model: string) => {
64-
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
65-
const key = modelVisibilityKey(provider.slug, model)
66-
const sentinel = emptyProviderSentinelKey(provider.slug)
67-
68-
if (next.has(key)) {
69-
next.delete(key)
70-
71-
// Check if this was the last real model for this provider.
72-
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))
73-
74-
if (!remainingForProvider) {
75-
next.add(sentinel)
76-
}
77-
} else {
78-
next.delete(sentinel)
79-
next.add(key)
80-
}
81-
82-
setVisibleModels(next)
63+
setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model))
8364
}
8465

8566
const q = search.trim().toLowerCase()

apps/desktop/src/store/model-visibility.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import type { ModelOptionProvider } from '@/types/hermes'
44

55
import {
66
collapseModelFamilies,
7+
defaultVisibleKeys,
78
effectiveVisibleKeys,
89
emptyProviderSentinelKey,
910
isProviderSentinel,
10-
modelVisibilityKey
11+
modelVisibilityKey,
12+
resolveVisibleKeys,
13+
toggleModelVisibility
1114
} from './model-visibility'
1215

1316
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
@@ -96,4 +99,133 @@ describe('model visibility', () => {
9699
expect(isProviderSentinel('openai::')).toBe(true)
97100
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
98101
})
102+
103+
it('resolveVisibleKeys preserves sentinels that effectiveVisibleKeys strips', () => {
104+
const stored = new Set([emptyProviderSentinelKey('nous')])
105+
const providers = [provider('nous', ['hermes-x', 'hermes-y']), provider('ollama', ['qwen3:latest'])]
106+
107+
const resolved = resolveVisibleKeys(stored, providers)
108+
expect(resolved.has(emptyProviderSentinelKey('nous'))).toBe(true)
109+
expect(resolved.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
110+
// Un-customized providers still expand to their defaults.
111+
expect(resolved.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
112+
113+
// Display variant drops the sentinel.
114+
expect(effectiveVisibleKeys(stored, providers).has(emptyProviderSentinelKey('nous'))).toBe(false)
115+
})
116+
})
117+
118+
describe('toggleModelVisibility', () => {
119+
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]
120+
121+
// Drive the handler the way the dialog does: feed each result back in as the
122+
// next `stored`, so the persisted set is what the next toggle starts from.
123+
const apply = (stored: Set<string> | null, slug: string, model: string) =>
124+
toggleModelVisibility(stored, providers, slug, model)
125+
126+
it('records a hide-all sentinel when the last model of a provider is toggled off', () => {
127+
let stored: Set<string> | null = null
128+
stored = apply(stored, 'openai', 'gpt-a')
129+
stored = apply(stored, 'openai', 'gpt-b')
130+
131+
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(true)
132+
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
133+
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
134+
})
135+
136+
it('keeps a hidden provider hidden when a different provider is toggled (regression for #43485)', () => {
137+
// Hide ALL of nous — its sentinel is now stored.
138+
let stored: Set<string> | null = null
139+
stored = apply(stored, 'nous', 'hermes-x')
140+
stored = apply(stored, 'nous', 'hermes-y')
141+
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
142+
143+
// Toggle a model in another provider. nous must NOT snap back on.
144+
stored = apply(stored, 'openai', 'gpt-a')
145+
146+
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
147+
const visible = effectiveVisibleKeys(stored, providers)
148+
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
149+
expect(visible.has(modelVisibilityKey('nous', 'hermes-y'))).toBe(false)
150+
})
151+
152+
it('clears only the toggled provider sentinel when a model is re-enabled', () => {
153+
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai'), emptyProviderSentinelKey('nous')])
154+
155+
stored = apply(stored, 'openai', 'gpt-a')
156+
157+
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(false)
158+
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
159+
const visible = effectiveVisibleKeys(stored, providers)
160+
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
161+
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
162+
})
163+
164+
it('re-enabling one model of a hidden-all provider restores ONLY that model, not the curated defaults', () => {
165+
// openai hidden-all, nous untouched.
166+
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai')])
167+
168+
stored = apply(stored, 'openai', 'gpt-a')
169+
170+
const visible = effectiveVisibleKeys(stored, providers)
171+
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
172+
// gpt-b is NOT restored — "you hid everything, you get back only what you re-enable".
173+
expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
174+
})
175+
176+
it('re-hiding the last re-enabled model re-adds the sentinel (full round-trip)', () => {
177+
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai')])
178+
179+
// Re-enable gpt-a (clears sentinel, set = {gpt-a}), then toggle it back off.
180+
stored = apply(stored, 'openai', 'gpt-a')
181+
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(false)
182+
stored = apply(stored, 'openai', 'gpt-a')
183+
184+
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(true)
185+
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
186+
})
187+
188+
it('toggling from an empty (non-null) stored set adds the model without expanding defaults', () => {
189+
// Empty-but-not-null = "everything hidden". resolveVisibleKeys short-circuits to {}.
190+
const stored = new Set<string>()
191+
192+
const next = apply(stored, 'openai', 'gpt-a')
193+
194+
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
195+
// No curated defaults were expanded for any provider.
196+
expect(next.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
197+
expect(next.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
198+
})
199+
200+
it('toggling off one default model from null stored keeps the rest of the curated defaults', () => {
201+
// null = "never customized": resolveVisibleKeys expands all defaults first.
202+
const next = apply(null, 'openai', 'gpt-a')
203+
204+
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
205+
expect(next.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(true)
206+
expect(next.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(true)
207+
// Other models remain, so no sentinel.
208+
expect(next.has(emptyProviderSentinelKey('openai'))).toBe(false)
209+
})
210+
211+
it('tolerates a provider with zero models (defensive — dialog filters these out)', () => {
212+
const ps = [provider('empty', []), provider('openai', ['gpt-a'])]
213+
const next = toggleModelVisibility(new Set([modelVisibilityKey('openai', 'gpt-a')]), ps, 'empty', 'ghost')
214+
215+
// No crash; the phantom key is recorded but no defaults are invented.
216+
expect([...next].some(k => k.startsWith('empty::') && !isProviderSentinel(k))).toBe(true)
217+
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
218+
})
219+
})
220+
221+
describe('resolveVisibleKeys', () => {
222+
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]
223+
224+
it('returns the curated defaults verbatim for null stored', () => {
225+
expect(resolveVisibleKeys(null, providers)).toEqual(defaultVisibleKeys(providers))
226+
})
227+
228+
it('returns an empty set for an empty (non-null) stored set', () => {
229+
expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([])
230+
})
99231
})

apps/desktop/src/store/model-visibility.ts

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,29 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S
106106
const keys = new Set<string>()
107107

108108
for (const provider of providers) {
109-
const families = collapseModelFamilies(provider.models ?? [])
110-
111-
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
112-
keys.add(modelVisibilityKey(provider.slug, family.id))
113-
}
109+
expandProviderDefaults(provider, keys)
114110
}
115111

116112
return keys
117113
}
118114

119-
/** Resolve which keys are currently visible: the user's explicit set when
120-
* configured, otherwise the curated default for the given providers. */
121-
export function effectiveVisibleKeys(
115+
/** Add a provider's curated default model keys (top-N collapsed families) to
116+
* `target`. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the
117+
* expansion rule lives in exactly one place. */
118+
function expandProviderDefaults(provider: ModelOptionProvider, target: Set<string>): void {
119+
const families = collapseModelFamilies(provider.models ?? [])
120+
121+
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
122+
target.add(modelVisibilityKey(provider.slug, family.id))
123+
}
124+
}
125+
126+
/** Resolve the canonical working set: the user's stored keys plus the curated
127+
* default expansion for any provider they haven't customized. Hide-all
128+
* sentinels are PRESERVED here — this is the set the toggle handler mutates and
129+
* persists, so dropping a sentinel would silently re-enable a provider the user
130+
* emptied. Use `effectiveVisibleKeys` for display (sentinels stripped). */
131+
export function resolveVisibleKeys(
122132
stored: Set<string> | null,
123133
providers: readonly ModelOptionProvider[]
124134
): Set<string> {
@@ -134,22 +144,31 @@ export function effectiveVisibleKeys(
134144

135145
for (const provider of providers) {
136146
const providerPrefix = `${provider.slug}::`
147+
137148
const hasStoredProvider = [...stored].some(
138149
key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
139150
)
151+
140152
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
141153

142154
if (hasStoredProvider || hasSentinel) {
143155
continue
144156
}
145157

146-
const families = collapseModelFamilies(provider.models ?? [])
147-
148-
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
149-
next.add(modelVisibilityKey(provider.slug, family.id))
150-
}
158+
expandProviderDefaults(provider, next)
151159
}
152160

161+
return next
162+
}
163+
164+
/** Resolve which keys are currently visible for DISPLAY: the resolved working
165+
* set with bookkeeping sentinels stripped (they are not real models). */
166+
export function effectiveVisibleKeys(
167+
stored: Set<string> | null,
168+
providers: readonly ModelOptionProvider[]
169+
): Set<string> {
170+
const next = resolveVisibleKeys(stored, providers)
171+
153172
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
154173
for (const key of [...next]) {
155174
if (isProviderSentinel(key)) {
@@ -159,3 +178,42 @@ export function effectiveVisibleKeys(
159178

160179
return next
161180
}
181+
182+
/** Compute the next persisted visibility set when one model row is toggled.
183+
* Seeds from `resolveVisibleKeys` (NOT `effectiveVisibleKeys`) so other
184+
* providers' hide-all sentinels survive the persist. When the last visible
185+
* model of a provider is toggled off, a sentinel records the explicit
186+
* hide-all; re-enabling a model clears THAT provider's sentinel (only). */
187+
export function toggleModelVisibility(
188+
stored: Set<string> | null,
189+
providers: readonly ModelOptionProvider[],
190+
providerSlug: string,
191+
model: string
192+
): Set<string> {
193+
// `resolveVisibleKeys` always returns a fresh Set, so we can mutate it directly.
194+
const next = resolveVisibleKeys(stored, providers)
195+
const key = modelVisibilityKey(providerSlug, model)
196+
const sentinel = emptyProviderSentinelKey(providerSlug)
197+
198+
if (next.has(key)) {
199+
next.delete(key)
200+
201+
// Check if this was the last real model for this provider.
202+
const remainingForProvider = [...next].some(
203+
k => k.startsWith(`${providerSlug}::`) && !isProviderSentinel(k)
204+
)
205+
206+
if (!remainingForProvider) {
207+
next.add(sentinel)
208+
}
209+
} else {
210+
// Re-enabling promotes a previously hidden-all provider to an explicit
211+
// set of exactly the one re-enabled model — the curated defaults are NOT
212+
// restored. Intentional: "you hid everything, you get back only what you
213+
// re-enable." (Locked in by the sentinel-clear-on-re-enable test.)
214+
next.delete(sentinel)
215+
next.add(key)
216+
}
217+
218+
return next
219+
}

cron/jobs.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,31 @@ def _ensure_aware(dt: datetime) -> datetime:
409409
return dt.astimezone(target_tz)
410410

411411

412+
def _timezone_offset_mismatch(stored: datetime, current: datetime) -> bool:
413+
"""Return True when a stored aware timestamp uses a different UTC offset.
414+
415+
Naive stored timestamps return False: they carry no offset to compare, and
416+
are normalized by ``_ensure_aware`` instead — they intentionally never take
417+
the offset-repair path.
418+
"""
419+
if stored.tzinfo is None or current.tzinfo is None:
420+
return False
421+
return stored.utcoffset() != current.utcoffset()
422+
423+
424+
def _stored_wall_clock_is_future(stored: datetime, current: datetime) -> bool:
425+
"""Return True when the stored local wall-clock time has not arrived yet.
426+
427+
Cron schedules express local wall-clock intent. If Hermes/system local time
428+
changes after next_run_at was persisted, an old offset can make a future
429+
wall-clock run look due at the converted absolute time (for example
430+
21:00+10 becomes 13:00+02). Comparing naive wall-clock values lets us
431+
distinguish that migration case from a genuinely missed run whose scheduled
432+
wall time has already passed.
433+
"""
434+
return stored.replace(tzinfo=None) > current.replace(tzinfo=None)
435+
436+
412437
def _recoverable_oneshot_run_at(
413438
schedule: Dict[str, Any],
414439
now: datetime,
@@ -1352,10 +1377,50 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:
13521377
needs_save = True
13531378
break
13541379

1355-
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
1380+
raw_next_run_dt = datetime.fromisoformat(next_run)
1381+
schedule = job.get("schedule", {})
1382+
kind = schedule.get("kind")
1383+
1384+
next_run_dt = _ensure_aware(raw_next_run_dt)
1385+
# Migration repair: a cron job persists next_run_at as an absolute
1386+
# instant, but the cron expr describes local wall-clock intent. If the
1387+
# configured/system timezone changed after persistence, the stored
1388+
# instant's offset no longer matches now's, and its converted time can
1389+
# look due hours early (21:00+10 -> 13:00+02). When the stored *wall
1390+
# clock* is still in the future, recompute from the schedule so we fire
1391+
# at the intended local time instead of early-then-again.
1392+
#
1393+
# TRADE-OFF: this cannot distinguish a config/host TZ migration from a
1394+
# legitimate DST offset change. A DST boundary that satisfies all four
1395+
# conditions will recompute (and thus SKIP the pending occurrence, no
1396+
# catch-up) rather than fire it. Accepted: in the pure-migration case
1397+
# the recompute lands on the same wall-clock time later the same period,
1398+
# and DST-boundary collisions with a still-future stored wall clock are
1399+
# rare relative to the double-fire bug this prevents (#28934).
1400+
if (
1401+
kind == "cron"
1402+
and next_run_dt <= now
1403+
and _timezone_offset_mismatch(raw_next_run_dt, now)
1404+
and _stored_wall_clock_is_future(raw_next_run_dt, now)
1405+
):
1406+
new_next = compute_next_run(schedule, now.isoformat())
1407+
if new_next:
1408+
logger.info(
1409+
"Job '%s' next_run_at offset changed (%s -> %s). "
1410+
"Recomputing cron run to preserve local wall-clock intent: %s",
1411+
job.get("name", job["id"]),
1412+
raw_next_run_dt.utcoffset(),
1413+
now.utcoffset(),
1414+
new_next,
1415+
)
1416+
for rj in raw_jobs:
1417+
if rj["id"] == job["id"]:
1418+
rj["next_run_at"] = new_next
1419+
needs_save = True
1420+
break
1421+
continue
1422+
13561423
if next_run_dt <= now:
1357-
schedule = job.get("schedule", {})
1358-
kind = schedule.get("kind")
13591424

13601425
# For recurring jobs, check if the scheduled time is stale
13611426
# (gateway was down and missed the window). Fast-forward to

0 commit comments

Comments
 (0)