Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d548908
fix(cron): make live-adapter delivery confirmation reliable (#38922, …
iamlukethedev Jun 21, 2026
65d7c7f
fix(cron): execute job immediately on action='run'
kyssta-exe Jun 21, 2026
02a3288
Merge pull request #50018 from NousResearch/salvage/f3a-delivery-confirm
kshitijk4poor Jun 21, 2026
f1f36b3
fix(cron): repair migrated cron timezone offsets to prevent double-fire
Tranquil-Flow Jun 21, 2026
4cc28aa
fix(cron): route Telegram DM-topic cron delivery through DeliveryRout…
kshitijk4poor Jun 21, 2026
f43c616
chore(release): add devsart95 to AUTHOR_MAP
kshitijk4poor Jun 21, 2026
3051a16
Merge pull request #50023 from NousResearch/salvage/f3b-telegram-dmtopic
kshitijk4poor Jun 21, 2026
f6a504d
Merge pull request #50025 from NousResearch/salvage/cron-run-immediate
kshitijk4poor Jun 21, 2026
f57ff7a
Merge pull request #50034 from NousResearch/salvage/cron-tz-offset-re…
kshitijk4poor Jun 21, 2026
8666fd7
fix(desktop): preserve other providers' hide-all in model visibility …
Jun 16, 2026
461fcc0
test(desktop): harden model-visibility toggle + dedupe default expansion
kshitijk4poor Jun 21, 2026
8ca38d3
Merge pull request #50100 from kshitijk4poor/salvage/model-visibility…
kshitijk4poor Jun 21, 2026
472c068
fix(mcp): detect 'unknown method' phrasing in ping keepalive fallback
xxxigm Jun 21, 2026
7b9a0b3
test(mcp): cover 'unknown method' ping keepalive fallback (#50028)
xxxigm Jun 21, 2026
8789fdd
Merge upstream/main — daily catch-up 2026-06-21b (9 commits)
Lexus2016 Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 3 additions & 22 deletions apps/desktop/src/components/model-visibility-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey,
setVisibleModels
setVisibleModels,
toggleModelVisibility
} from '@/store/model-visibility'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'

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

const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
const sentinel = emptyProviderSentinelKey(provider.slug)

if (next.has(key)) {
next.delete(key)

// Check if this was the last real model for this provider.
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))

if (!remainingForProvider) {
next.add(sentinel)
}
} else {
next.delete(sentinel)
next.add(key)
}

setVisibleModels(next)
setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model))
}

const q = search.trim().toLowerCase()
Expand Down
134 changes: 133 additions & 1 deletion apps/desktop/src/store/model-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import type { ModelOptionProvider } from '@/types/hermes'

import {
collapseModelFamilies,
defaultVisibleKeys,
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey
modelVisibilityKey,
resolveVisibleKeys,
toggleModelVisibility
} from './model-visibility'

const provider = (slug: string, models: string[]): ModelOptionProvider => ({
Expand Down Expand Up @@ -96,4 +99,133 @@ describe('model visibility', () => {
expect(isProviderSentinel('openai::')).toBe(true)
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
})

it('resolveVisibleKeys preserves sentinels that effectiveVisibleKeys strips', () => {
const stored = new Set([emptyProviderSentinelKey('nous')])
const providers = [provider('nous', ['hermes-x', 'hermes-y']), provider('ollama', ['qwen3:latest'])]

const resolved = resolveVisibleKeys(stored, providers)
expect(resolved.has(emptyProviderSentinelKey('nous'))).toBe(true)
expect(resolved.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
// Un-customized providers still expand to their defaults.
expect(resolved.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)

// Display variant drops the sentinel.
expect(effectiveVisibleKeys(stored, providers).has(emptyProviderSentinelKey('nous'))).toBe(false)
})
})

describe('toggleModelVisibility', () => {
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]

// Drive the handler the way the dialog does: feed each result back in as the
// next `stored`, so the persisted set is what the next toggle starts from.
const apply = (stored: Set<string> | null, slug: string, model: string) =>
toggleModelVisibility(stored, providers, slug, model)

it('records a hide-all sentinel when the last model of a provider is toggled off', () => {
let stored: Set<string> | null = null
stored = apply(stored, 'openai', 'gpt-a')
stored = apply(stored, 'openai', 'gpt-b')

expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(true)
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
})

it('keeps a hidden provider hidden when a different provider is toggled (regression for #43485)', () => {
// Hide ALL of nous — its sentinel is now stored.
let stored: Set<string> | null = null
stored = apply(stored, 'nous', 'hermes-x')
stored = apply(stored, 'nous', 'hermes-y')
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)

// Toggle a model in another provider. nous must NOT snap back on.
stored = apply(stored, 'openai', 'gpt-a')

expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
const visible = effectiveVisibleKeys(stored, providers)
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
expect(visible.has(modelVisibilityKey('nous', 'hermes-y'))).toBe(false)
})

it('clears only the toggled provider sentinel when a model is re-enabled', () => {
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai'), emptyProviderSentinelKey('nous')])

stored = apply(stored, 'openai', 'gpt-a')

expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(false)
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
const visible = effectiveVisibleKeys(stored, providers)
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
})

it('re-enabling one model of a hidden-all provider restores ONLY that model, not the curated defaults', () => {
// openai hidden-all, nous untouched.
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai')])

stored = apply(stored, 'openai', 'gpt-a')

const visible = effectiveVisibleKeys(stored, providers)
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
// gpt-b is NOT restored — "you hid everything, you get back only what you re-enable".
expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
})

it('re-hiding the last re-enabled model re-adds the sentinel (full round-trip)', () => {
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai')])

// Re-enable gpt-a (clears sentinel, set = {gpt-a}), then toggle it back off.
stored = apply(stored, 'openai', 'gpt-a')
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(false)
stored = apply(stored, 'openai', 'gpt-a')

expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(true)
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
})

it('toggling from an empty (non-null) stored set adds the model without expanding defaults', () => {
// Empty-but-not-null = "everything hidden". resolveVisibleKeys short-circuits to {}.
const stored = new Set<string>()

const next = apply(stored, 'openai', 'gpt-a')

expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
// No curated defaults were expanded for any provider.
expect(next.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
expect(next.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
})

it('toggling off one default model from null stored keeps the rest of the curated defaults', () => {
// null = "never customized": resolveVisibleKeys expands all defaults first.
const next = apply(null, 'openai', 'gpt-a')

expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
expect(next.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(true)
expect(next.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(true)
// Other models remain, so no sentinel.
expect(next.has(emptyProviderSentinelKey('openai'))).toBe(false)
})

it('tolerates a provider with zero models (defensive — dialog filters these out)', () => {
const ps = [provider('empty', []), provider('openai', ['gpt-a'])]
const next = toggleModelVisibility(new Set([modelVisibilityKey('openai', 'gpt-a')]), ps, 'empty', 'ghost')

// No crash; the phantom key is recorded but no defaults are invented.
expect([...next].some(k => k.startsWith('empty::') && !isProviderSentinel(k))).toBe(true)
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
})
})

describe('resolveVisibleKeys', () => {
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]

it('returns the curated defaults verbatim for null stored', () => {
expect(resolveVisibleKeys(null, providers)).toEqual(defaultVisibleKeys(providers))
})

it('returns an empty set for an empty (non-null) stored set', () => {
expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([])
})
})
84 changes: 71 additions & 13 deletions apps/desktop/src/store/model-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,19 +106,29 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S
const keys = new Set<string>()

for (const provider of providers) {
const families = collapseModelFamilies(provider.models ?? [])

for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
keys.add(modelVisibilityKey(provider.slug, family.id))
}
expandProviderDefaults(provider, keys)
}

return keys
}

/** Resolve which keys are currently visible: the user's explicit set when
* configured, otherwise the curated default for the given providers. */
export function effectiveVisibleKeys(
/** Add a provider's curated default model keys (top-N collapsed families) to
* `target`. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the
* expansion rule lives in exactly one place. */
function expandProviderDefaults(provider: ModelOptionProvider, target: Set<string>): void {
const families = collapseModelFamilies(provider.models ?? [])

for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
target.add(modelVisibilityKey(provider.slug, family.id))
}
}

/** Resolve the canonical working set: the user's stored keys plus the curated
* default expansion for any provider they haven't customized. Hide-all
* sentinels are PRESERVED here — this is the set the toggle handler mutates and
* persists, so dropping a sentinel would silently re-enable a provider the user
* emptied. Use `effectiveVisibleKeys` for display (sentinels stripped). */
export function resolveVisibleKeys(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[]
): Set<string> {
Expand All @@ -134,22 +144,31 @@ export function effectiveVisibleKeys(

for (const provider of providers) {
const providerPrefix = `${provider.slug}::`

const hasStoredProvider = [...stored].some(
key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
)

const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))

if (hasStoredProvider || hasSentinel) {
continue
}

const families = collapseModelFamilies(provider.models ?? [])

for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
next.add(modelVisibilityKey(provider.slug, family.id))
}
expandProviderDefaults(provider, next)
}

return next
}

/** Resolve which keys are currently visible for DISPLAY: the resolved working
* set with bookkeeping sentinels stripped (they are not real models). */
export function effectiveVisibleKeys(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[]
): Set<string> {
const next = resolveVisibleKeys(stored, providers)

// Strip sentinel keys — they are bookkeeping, not real visibility entries.
for (const key of [...next]) {
if (isProviderSentinel(key)) {
Expand All @@ -159,3 +178,42 @@ export function effectiveVisibleKeys(

return next
}

/** Compute the next persisted visibility set when one model row is toggled.
* Seeds from `resolveVisibleKeys` (NOT `effectiveVisibleKeys`) so other
* providers' hide-all sentinels survive the persist. When the last visible
* model of a provider is toggled off, a sentinel records the explicit
* hide-all; re-enabling a model clears THAT provider's sentinel (only). */
export function toggleModelVisibility(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[],
providerSlug: string,
model: string
): Set<string> {
// `resolveVisibleKeys` always returns a fresh Set, so we can mutate it directly.
const next = resolveVisibleKeys(stored, providers)
const key = modelVisibilityKey(providerSlug, model)
const sentinel = emptyProviderSentinelKey(providerSlug)

if (next.has(key)) {
next.delete(key)

// Check if this was the last real model for this provider.
const remainingForProvider = [...next].some(
k => k.startsWith(`${providerSlug}::`) && !isProviderSentinel(k)
)

if (!remainingForProvider) {
next.add(sentinel)
}
} else {
// Re-enabling promotes a previously hidden-all provider to an explicit
// set of exactly the one re-enabled model — the curated defaults are NOT
// restored. Intentional: "you hid everything, you get back only what you
// re-enable." (Locked in by the sentinel-clear-on-re-enable test.)
next.delete(sentinel)
next.add(key)
}

return next
}
71 changes: 68 additions & 3 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,31 @@ def _ensure_aware(dt: datetime) -> datetime:
return dt.astimezone(target_tz)


def _timezone_offset_mismatch(stored: datetime, current: datetime) -> bool:
"""Return True when a stored aware timestamp uses a different UTC offset.

Naive stored timestamps return False: they carry no offset to compare, and
are normalized by ``_ensure_aware`` instead — they intentionally never take
the offset-repair path.
"""
if stored.tzinfo is None or current.tzinfo is None:
return False
return stored.utcoffset() != current.utcoffset()


def _stored_wall_clock_is_future(stored: datetime, current: datetime) -> bool:
"""Return True when the stored local wall-clock time has not arrived yet.

Cron schedules express local wall-clock intent. If Hermes/system local time
changes after next_run_at was persisted, an old offset can make a future
wall-clock run look due at the converted absolute time (for example
21:00+10 becomes 13:00+02). Comparing naive wall-clock values lets us
distinguish that migration case from a genuinely missed run whose scheduled
wall time has already passed.
"""
return stored.replace(tzinfo=None) > current.replace(tzinfo=None)


def _recoverable_oneshot_run_at(
schedule: Dict[str, Any],
now: datetime,
Expand Down Expand Up @@ -1352,10 +1377,50 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:
needs_save = True
break

next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
raw_next_run_dt = datetime.fromisoformat(next_run)
schedule = job.get("schedule", {})
kind = schedule.get("kind")

next_run_dt = _ensure_aware(raw_next_run_dt)
# Migration repair: a cron job persists next_run_at as an absolute
# instant, but the cron expr describes local wall-clock intent. If the
# configured/system timezone changed after persistence, the stored
# instant's offset no longer matches now's, and its converted time can
# look due hours early (21:00+10 -> 13:00+02). When the stored *wall
# clock* is still in the future, recompute from the schedule so we fire
# at the intended local time instead of early-then-again.
#
# TRADE-OFF: this cannot distinguish a config/host TZ migration from a
# legitimate DST offset change. A DST boundary that satisfies all four
# conditions will recompute (and thus SKIP the pending occurrence, no
# catch-up) rather than fire it. Accepted: in the pure-migration case
# the recompute lands on the same wall-clock time later the same period,
# and DST-boundary collisions with a still-future stored wall clock are
# rare relative to the double-fire bug this prevents (#28934).
if (
kind == "cron"
and next_run_dt <= now
and _timezone_offset_mismatch(raw_next_run_dt, now)
and _stored_wall_clock_is_future(raw_next_run_dt, now)
):
new_next = compute_next_run(schedule, now.isoformat())
if new_next:
logger.info(
"Job '%s' next_run_at offset changed (%s -> %s). "
"Recomputing cron run to preserve local wall-clock intent: %s",
job.get("name", job["id"]),
raw_next_run_dt.utcoffset(),
now.utcoffset(),
new_next,
)
for rj in raw_jobs:
if rj["id"] == job["id"]:
rj["next_run_at"] = new_next
needs_save = True
break
continue

if next_run_dt <= now:
schedule = job.get("schedule", {})
kind = schedule.get("kind")

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