mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
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.
This commit is contained in:
@@ -30,9 +30,26 @@ function assertSupportedSchema(
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSkillBundleArtifacts(
|
||||
const artifactsByResourceRoot = new Map<string, Promise<SkillBundleArtifacts>>()
|
||||
|
||||
// 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<SkillBundleArtifacts> {
|
||||
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<SkillBundleArtifacts> {
|
||||
const bundleRoot = join(resourceRoot, 'skills')
|
||||
const [manifest, registry, releaseMapping] = await Promise.all([
|
||||
readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse),
|
||||
|
||||
@@ -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<SkillInstallationTopology>([
|
||||
'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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<SkillFreshnessInventory>()
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () => root?.render(<Probe />))
|
||||
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<SkillFreshnessInventory>()
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValue(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<>
|
||||
<Probe />
|
||||
<Probe />
|
||||
</>
|
||||
)
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<SkillFreshnessInventory> | null = null
|
||||
let invalidationRevision = 0
|
||||
let completedRevision = -1
|
||||
let lastCompletedScanAt = 0
|
||||
let sharedInvalidationPending = false
|
||||
|
||||
async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
|
||||
if (force) {
|
||||
@@ -24,6 +29,7 @@ async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3618,6 +3618,7 @@
|
||||
"currentDescription": "この Orca ビルドに同梱されたバージョンと完全に一致します。",
|
||||
"outdatedDescription": "以前の公式 Orca スナップショットと完全に一致します。",
|
||||
"outdatedBlockedDescription": "以前の公式コピーが見つかりましたが、同名の別の配置があるため安全なグローバル更新はできません。",
|
||||
"outdatedUnsupportedPlacementDescription": "以前の公式コピーが見つかりましたが、この配置はその場で安全に更新できません。",
|
||||
"newerKnownDescription": "この Orca ビルドより新しい既知のリビジョンと一致します。更新は提供されません。",
|
||||
"unrecognizedDescription": "編集済みか、別の提供元の可能性があります。Orca は更新しません。",
|
||||
"inaccessibleDescription": "Orca はこの配置を検査できませんでした。更新は提供されません。",
|
||||
|
||||
@@ -3618,6 +3618,7 @@
|
||||
"currentDescription": "이 Orca 빌드에 포함된 버전과 정확히 일치합니다.",
|
||||
"outdatedDescription": "이전 공식 Orca 스냅샷과 정확히 일치합니다.",
|
||||
"outdatedBlockedDescription": "이전 공식 복사본을 찾았지만 같은 이름의 다른 위치 때문에 안전한 전역 업데이트를 제공할 수 없습니다.",
|
||||
"outdatedUnsupportedPlacementDescription": "이전 공식 복사본을 찾았지만 이 위치는 제자리에서 안전하게 업데이트할 수 없습니다.",
|
||||
"newerKnownDescription": "이 Orca 빌드보다 최신인 알려진 리비전과 일치합니다. 업데이트는 제공되지 않습니다.",
|
||||
"unrecognizedDescription": "편집되었거나 다른 출처에서 왔을 수 있습니다. Orca는 업데이트하지 않습니다.",
|
||||
"inaccessibleDescription": "Orca가 이 위치를 검사할 수 없습니다. 업데이트는 제공되지 않습니다.",
|
||||
|
||||
@@ -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": "对于此冲突,没有可编辑的工作树文件。"
|
||||
},
|
||||
|
||||
@@ -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<SkillInstallationTopology> = new Set([
|
||||
'canonical-copy',
|
||||
'provider-alias'
|
||||
])
|
||||
|
||||
export type SkillFreshnessInstallation = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user