From 29fd88ad1c36d545e68ff46e9bb37d72715e5b1f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:13:50 -0700 Subject: [PATCH] fix(skills): throttle focus rescans and correct self-blocked placement copy Every window focus re-read and re-hashed all installed packages, and the nudge and panel each forced their own trailing rescan for one event; a 15s cooldown plus a shared invalidation latch keep one bounded scan per event while install-change events stay immediate. Bundle artifacts are now loaded once per run instead of re-parsed on every scan. A read-only or otherwise unsupported outdated placement now explains that it blocks itself instead of blaming a phantom sibling placement; the supported topology set moved to shared so eligibility and copy cannot drift. --- src/main/skills/skill-bundle-artifacts.ts | 19 +++++++- .../skills/skill-freshness-eligibility.ts | 13 ++--- .../skills/SkillFreshnessPanel.test.tsx | 14 ++++++ .../components/skills/SkillFreshnessPanel.tsx | 23 ++++++--- .../src/hooks/useSkillFreshness.test.tsx | 47 +++++++++++++++++++ src/renderer/src/hooks/useSkillFreshness.ts | 32 ++++++++++--- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/skill-freshness.ts | 7 +++ 12 files changed, 139 insertions(+), 23 deletions(-) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts index 024ea4f4257..d2a1eca9ff9 100644 --- a/src/main/skills/skill-bundle-artifacts.ts +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -30,9 +30,26 @@ function assertSupportedSchema( } } -export async function loadSkillBundleArtifacts( +const artifactsByResourceRoot = new Map>() + +// Why: the artifacts ship with the binary and never change within a run, while +// focus-triggered rescans would otherwise re-read and re-parse them every time. +export function loadSkillBundleArtifacts( resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources') ): Promise { + const cached = artifactsByResourceRoot.get(resourceRoot) + if (cached) { + return cached + } + const loading = readSkillBundleArtifacts(resourceRoot) + artifactsByResourceRoot.set(resourceRoot, loading) + loading.catch(() => { + artifactsByResourceRoot.delete(resourceRoot) + }) + return loading +} + +async function readSkillBundleArtifacts(resourceRoot: string): Promise { const bundleRoot = join(resourceRoot, 'skills') const [manifest, registry, releaseMapping] = await Promise.all([ readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse), diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts index 705d745c2a6..a2a6a1c3778 100644 --- a/src/main/skills/skill-freshness-eligibility.ts +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -1,13 +1,8 @@ -import type { - SkillFreshnessInstallation, - SkillInstallationTopology +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation } from '../../shared/skill-freshness' -const SUPPORTED_GLOBAL_TOPOLOGIES = new Set([ - 'canonical-copy', - 'provider-alias' -]) - export function eligibleSkillUpdateNames( installations: readonly SkillFreshnessInstallation[] ): string[] { @@ -24,7 +19,7 @@ export function eligibleSkillUpdateNames( const everyPlacementIsSafe = entries.every( (entry) => (entry.status === 'current' || entry.status === 'outdated') && - SUPPORTED_GLOBAL_TOPOLOGIES.has(entry.topology) && + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && Boolean(entry.resolvedPath && entry.physicalIdentity) ) if (hasOutdated && everyPlacementIsSafe) { diff --git a/src/renderer/src/components/skills/SkillFreshnessPanel.test.tsx b/src/renderer/src/components/skills/SkillFreshnessPanel.test.tsx index e1880bc4bef..f91b562a5a0 100644 --- a/src/renderer/src/components/skills/SkillFreshnessPanel.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessPanel.test.tsx @@ -191,4 +191,18 @@ describe('SkillFreshnessPanel', () => { expect(rendered.textContent).toContain('Repository scope') expect(rendered.textContent).not.toContain('Review update command') }) + + it('explains a self-blocked outdated placement without blaming a sibling', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + const rendered = await renderPanel() + + expect(rendered.textContent).toContain('this placement cannot be updated safely in place') + expect(rendered.textContent).not.toContain('another placement of this name') + expect(rendered.textContent).toContain('Read only') + }) }) diff --git a/src/renderer/src/components/skills/SkillFreshnessPanel.tsx b/src/renderer/src/components/skills/SkillFreshnessPanel.tsx index 35806556d4b..79e7bf3db0b 100644 --- a/src/renderer/src/components/skills/SkillFreshnessPanel.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessPanel.tsx @@ -5,7 +5,10 @@ import type { SkillFreshnessStatus, SkillInstallationTopology } from '../../../../shared/skill-freshness' -import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness' +import { + buildTargetedSkillUpdateCommand, + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES +} from '../../../../shared/skill-freshness' import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' @@ -68,15 +71,23 @@ function statusDescription( 'Exactly matches the version bundled with this Orca build.' ) case 'outdated': - return eligibleNames.has(installation.name) + if (eligibleNames.has(installation.name)) { + return translate( + 'auto.components.skills.SkillFreshnessPanel.outdatedDescription', + 'Exactly matches an older official Orca snapshot.' + ) + } + // Why: the block can come from this row's own unsupported placement or from + // a sibling placement of the same name; blaming a phantom sibling misleads. + return SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) ? translate( - 'auto.components.skills.SkillFreshnessPanel.outdatedDescription', - 'Exactly matches an older official Orca snapshot.' - ) - : translate( 'auto.components.skills.SkillFreshnessPanel.outdatedBlockedDescription', 'An older official copy was found, but another placement of this name prevents a safe global update.' ) + : translate( + 'auto.components.skills.SkillFreshnessPanel.outdatedUnsupportedPlacementDescription', + 'An older official copy was found, but this placement cannot be updated safely in place.' + ) case 'newer-known': return translate( 'auto.components.skills.SkillFreshnessPanel.newerKnownDescription', diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx index d4e45219bdb..2e087487667 100644 --- a/src/renderer/src/hooks/useSkillFreshness.test.tsx +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -71,4 +71,51 @@ describe('useSkillFreshness', () => { await act(async () => second.resolve(inventory(2))) expect(state?.inventory?.scannedAt).toBe(2) }) + + it('skips focus rescans inside the cooldown but honors install-change events', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('coalesces multiple consumers into one rescan per invalidation event', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts index 77125952ce0..b09b815eb9c 100644 --- a/src/renderer/src/hooks/useSkillFreshness.ts +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -3,10 +3,15 @@ import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' import { useMountedRef } from './useMountedRef' const INSTALLED_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' +// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes +// every installed package; a just-completed scan stays authoritative briefly. +const FOCUS_RESCAN_COOLDOWN_MS = 15_000 let cachedInventory: SkillFreshnessInventory | null = null let pendingInventory: Promise | null = null let invalidationRevision = 0 let completedRevision = -1 +let lastCompletedScanAt = 0 +let sharedInvalidationPending = false async function loadInventory(force: boolean): Promise { if (force) { @@ -24,6 +29,7 @@ async function loadInventory(force: boolean): Promise { .then((inventory) => { cachedInventory = inventory completedRevision = Math.max(completedRevision, requestRevision) + lastCompletedScanAt = Date.now() return inventory }) .finally(() => { @@ -77,15 +83,27 @@ export function useSkillFreshness(): SkillFreshnessState { }, [refresh]) useEffect(() => { - const invalidate = (): void => { + const invalidate = (respectCooldown: boolean): void => { + if (respectCooldown && Date.now() - lastCompletedScanAt < FOCUS_RESCAN_COOLDOWN_MS) { + return + } + // Why: the nudge and the panel both listen for the same events; one forced + // rescan per event refreshes every consumer through the shared request. + const alreadyInvalidated = sharedInvalidationPending + sharedInvalidationPending = true + queueMicrotask(() => { + sharedInvalidationPending = false + }) cachedInventory = null - void refresh(true) + void refresh(!alreadyInvalidated) } - window.addEventListener('focus', invalidate) - window.addEventListener(INSTALLED_SKILLS_CHANGED_EVENT, invalidate) + const onFocus = (): void => invalidate(true) + const onInstalledSkillsChanged = (): void => invalidate(false) + window.addEventListener('focus', onFocus) + window.addEventListener(INSTALLED_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) return () => { - window.removeEventListener('focus', invalidate) - window.removeEventListener(INSTALLED_SKILLS_CHANGED_EVENT, invalidate) + window.removeEventListener('focus', onFocus) + window.removeEventListener(INSTALLED_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) } }, [refresh]) @@ -98,5 +116,7 @@ export const _skillFreshnessCacheForTests = { pendingInventory = null invalidationRevision = 0 completedRevision = -1 + lastCompletedScanAt = 0 + sharedInvalidationPending = false } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a8846e9b0a5..61cf42d7f8e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3618,6 +3618,7 @@ "currentDescription": "Exactly matches the version bundled with this Orca build.", "outdatedDescription": "Exactly matches an older official Orca snapshot.", "outdatedBlockedDescription": "An older official copy was found, but another placement of this name prevents a safe global update.", + "outdatedUnsupportedPlacementDescription": "An older official copy was found, but this placement cannot be updated safely in place.", "newerKnownDescription": "Matches a known revision newer than this Orca build. No update is offered.", "unrecognizedDescription": "May be edited or from another source. Orca will not update it.", "inaccessibleDescription": "Orca could not inspect this placement. No update is offered.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fd723c6494a..1cb0627e72a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3618,6 +3618,7 @@ "currentDescription": "Coincide exactamente con la versión incluida en esta compilación de Orca.", "outdatedDescription": "Coincide exactamente con una instantánea oficial anterior de Orca.", "outdatedBlockedDescription": "Se encontró una copia oficial anterior, pero otra ubicación con este nombre impide una actualización global segura.", + "outdatedUnsupportedPlacementDescription": "Se encontró una copia oficial anterior, pero esta ubicación no se puede actualizar de forma segura.", "newerKnownDescription": "Coincide con una revisión conocida más reciente que esta compilación de Orca. No se ofrece ninguna actualización.", "unrecognizedDescription": "Puede estar editada o proceder de otra fuente. Orca no la actualizará.", "inaccessibleDescription": "Orca no pudo inspeccionar esta ubicación. No se ofrece ninguna actualización.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9b08ced6049..b10b9213efc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3618,6 +3618,7 @@ "currentDescription": "この Orca ビルドに同梱されたバージョンと完全に一致します。", "outdatedDescription": "以前の公式 Orca スナップショットと完全に一致します。", "outdatedBlockedDescription": "以前の公式コピーが見つかりましたが、同名の別の配置があるため安全なグローバル更新はできません。", + "outdatedUnsupportedPlacementDescription": "以前の公式コピーが見つかりましたが、この配置はその場で安全に更新できません。", "newerKnownDescription": "この Orca ビルドより新しい既知のリビジョンと一致します。更新は提供されません。", "unrecognizedDescription": "編集済みか、別の提供元の可能性があります。Orca は更新しません。", "inaccessibleDescription": "Orca はこの配置を検査できませんでした。更新は提供されません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1eb531a2bc3..31be1213769 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3618,6 +3618,7 @@ "currentDescription": "이 Orca 빌드에 포함된 버전과 정확히 일치합니다.", "outdatedDescription": "이전 공식 Orca 스냅샷과 정확히 일치합니다.", "outdatedBlockedDescription": "이전 공식 복사본을 찾았지만 같은 이름의 다른 위치 때문에 안전한 전역 업데이트를 제공할 수 없습니다.", + "outdatedUnsupportedPlacementDescription": "이전 공식 복사본을 찾았지만 이 위치는 제자리에서 안전하게 업데이트할 수 없습니다.", "newerKnownDescription": "이 Orca 빌드보다 최신인 알려진 리비전과 일치합니다. 업데이트는 제공되지 않습니다.", "unrecognizedDescription": "편집되었거나 다른 출처에서 왔을 수 있습니다. Orca는 업데이트하지 않습니다.", "inaccessibleDescription": "Orca가 이 위치를 검사할 수 없습니다. 업데이트는 제공되지 않습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index eb6fc4f3a0c..2c8c40b4a14 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3618,6 +3618,7 @@ "currentDescription": "与此 Orca 构建中捆绑的版本完全一致。", "outdatedDescription": "与早期的官方 Orca 快照完全一致。", "outdatedBlockedDescription": "找到了早期官方副本,但同名的另一个位置阻止了安全的全局更新。", + "outdatedUnsupportedPlacementDescription": "找到了早期官方副本,但此位置无法安全地就地更新。", "newerKnownDescription": "与比此 Orca 构建更新的已知修订版一致。不提供更新。", "unrecognizedDescription": "可能已被编辑或来自其他来源。Orca 不会更新它。", "inaccessibleDescription": "Orca 无法检查此位置。不提供更新。", @@ -11427,7 +11428,7 @@ "d5edd81755": "重命名自", "6e459867ad": "会话本地连续性状态。 Git 不再将此文件报告为未合并。", "9c2901ef8a": "下一次冲突", - "41d9af2e7a": "上一个冲突", + "41d9af2e7a": "上一个冲突", "55d61a0ccd": "冲突 ·", "da539359b6": "对于此冲突,没有可编辑的工作树文件。" }, diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 706ce2a8f2c..4ebfa8b887f 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -56,6 +56,13 @@ export type SkillInstallationTopology = | 'repo-scope' | 'plugin-cache' +// Why: eligibility and the explanation copy must agree on which placements the +// validated npx rail can converge; a drifted copy would blame a phantom sibling. +export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet = new Set([ + 'canonical-copy', + 'provider-alias' +]) + export type SkillFreshnessInstallation = { id: string name: string