fix(skills): fail closed on stale freshness scans

This commit is contained in:
Brennan Benson
2026-07-15 01:12:14 -07:00
parent 408357df4d
commit 2d1ad80896
8 changed files with 300 additions and 79 deletions
@@ -4,6 +4,7 @@ import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { isDeepStrictEqual } from 'node:util'
const SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
@@ -424,15 +425,17 @@ function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
if (releasedCount < Math.max(0, committedSnapshots.length - 1)) {
throw new Error(
`Released snapshot history is incomplete for ${name}. ` +
'Fetch all release tags before regenerating skill artifacts.'
)
}
const protectedCount = Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
if (
!rebuilt ||
rebuilt.releaseRevision !== committed.releaseRevision ||
rebuilt.packageDigest !== committed.packageDigest
) {
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
@@ -111,6 +111,28 @@ describe('skill bundle manifest generator', () => {
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
}
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
}
},
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
)
).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
@@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({
toastDismiss: vi.fn(),
requestDialog: vi.fn(),
settingsLoaded: true,
inventory: null as SkillFreshnessInventory | null
inventory: null as SkillFreshnessInventory | null,
error: null as string | null
}))
function placement(
@@ -58,7 +59,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: false,
error: null,
error: mocks.error,
refresh: vi.fn()
})
}))
@@ -106,6 +107,7 @@ describe('SkillFreshnessNudge', () => {
mocks.dismissed = []
mocks.settingsLoaded = true
mocks.inventory = eligibleInventory()
mocks.error = null
mocks.updateSettings.mockReset()
mocks.updateSettings.mockResolvedValue(undefined)
mocks.toastInfo.mockReset()
@@ -162,6 +164,19 @@ describe('SkillFreshnessNudge', () => {
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('retracts a stale nudge when re-inventory fails', async () => {
await renderNudge()
const options = mocks.toastInfo.mock.calls[0]?.[1]
mocks.inventory = null
mocks.error = 'scan failed'
await rerenderNudge()
options.onDismiss()
expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('persists the exact placement/revision key once on explicit dismissal', async () => {
await renderNudge()
@@ -35,7 +35,18 @@ export function SkillFreshnessNudge(): null {
useEffect(() => {
const inventory = state.inventory
if (!settingsLoaded || !inventory) {
if (!settingsLoaded) {
return
}
if (!inventory) {
const active = activeNudgeRef.current
if (state.error && active) {
// Why: a failed re-check cannot keep advertising authority derived from
// old bytes; retract without turning the scan failure into a dismissal.
active.persistDismissal = false
activeNudgeRef.current = null
toast.dismiss(active.id)
}
return
}
const eligibleNames = new Set(inventory.eligibleUpdateNames)
@@ -152,7 +163,7 @@ export function SkillFreshnessNudge(): null {
}
)
activeNudgeRef.current = nextActive
}, [dismissed, settingsLoaded, state.inventory, updateSettings])
}, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings])
return null
}
@@ -16,6 +16,7 @@ import {
const mocks = vi.hoisted(() => ({
inventory: null as SkillFreshnessInventory | null,
loading: false,
error: null as string | null,
refresh: vi.fn(),
terminalProps: [] as {
command: string
@@ -30,7 +31,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: mocks.loading,
error: null,
error: mocks.error,
refresh: mocks.refresh
})
}))
@@ -139,6 +140,7 @@ describe('SkillFreshnessUpdateDialog', () => {
consumeSkillFreshnessUpdateDialogRequest()
mocks.inventory = eligibleInventory()
mocks.loading = false
mocks.error = null
mocks.refresh.mockReset()
mocks.notifyChanged.mockReset()
mocks.terminalProps.length = 0
@@ -210,6 +212,14 @@ describe('SkillFreshnessUpdateDialog', () => {
await act(async () => {
mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' })
})
mocks.inventory = null
mocks.loading = true
await rerender()
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
'npx skills update orca-cli --global'
)
mocks.inventory = {
schemaVersion: 1,
installations: [
@@ -218,6 +228,7 @@ describe('SkillFreshnessUpdateDialog', () => {
eligibleUpdateNames: [],
scannedAt: 2
}
mocks.loading = false
await rerender()
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
@@ -226,6 +237,30 @@ describe('SkillFreshnessUpdateDialog', () => {
)
})
it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => {
await renderDialog()
await openViaRequest()
expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull()
mocks.inventory = null
mocks.loading = true
await rerender()
expect(container?.textContent).toContain('Checking installed Orca skills')
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
})
it('shows a failed scan as an error instead of indefinite progress', async () => {
mocks.inventory = null
mocks.error = 'Could not inspect installed skills.'
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain('Could not inspect installed skills.')
expect(container?.textContent).not.toContain('Checking installed Orca skills')
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
})
it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => {
await renderDialog()
await openViaRequest()
@@ -149,7 +149,15 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
}, [])
useEffect(() => {
if (!open || state.loading || state.error) {
if (!open) {
return
}
if (state.loading || state.error || !inventory) {
// Why: a scan invalidates the authorization behind an unsubmitted draft.
// A running command keeps its PTY until exit, but stale drafts fail closed.
if (!terminalSubmittedRef.current && terminalCommand !== null) {
setTerminalCommand(null)
}
return
}
if (awaitingExitRefresh) {
@@ -226,9 +234,11 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
</DialogDescription>
</DialogHeader>
{state.error ? <p className="text-xs text-destructive">{state.error}</p> : null}
<SummaryHeadline kind={summaryKind} eligibleCount={eligibleNames.length} />
{state.error ? (
<p className="text-xs text-destructive">{state.error}</p>
) : (
<SummaryHeadline kind={summaryKind} eligibleCount={eligibleNames.length} />
)}
{terminalCommand ? (
<OnboardingInlineCommandTerminal
@@ -13,12 +13,15 @@ import {
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (cause: unknown) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((complete) => {
let reject!: (cause: unknown) => void
const promise = new Promise<T>((complete, fail) => {
resolve = complete
reject = fail
})
return { promise, resolve }
return { promise, resolve, reject }
}
function inventory(scannedAt: number): SkillFreshnessInventory {
@@ -28,9 +31,11 @@ function inventory(scannedAt: number): SkillFreshnessInventory {
let root: Root | null = null
let container: HTMLDivElement | null = null
let state: SkillFreshnessState | null = null
const states = new Map<string, SkillFreshnessState>()
function Probe(): null {
function Probe({ id = 'default' }: { id?: string }): null {
state = useSkillFreshness()
states.set(id, state)
return null
}
@@ -38,6 +43,7 @@ describe('useSkillFreshness', () => {
beforeEach(() => {
_skillFreshnessCacheForTests.reset()
state = null
states.clear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -118,4 +124,90 @@ describe('useSkillFreshness', () => {
await act(async () => second.resolve(inventory(2)))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
})
it('publishes a manual refresh to every consumer', async () => {
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(1))
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () =>
root?.render(
<>
<Probe id="one" />
<Probe id="two" />
</>
)
)
expect(states.get('one')?.inventory?.scannedAt).toBe(1)
expect(states.get('two')?.inventory?.scannedAt).toBe(1)
let refresh: Promise<void> | undefined
await act(async () => {
refresh = states.get('one')?.refresh()
await Promise.resolve()
})
expect(states.get('one')?.inventory).toBeNull()
expect(states.get('two')?.inventory).toBeNull()
await act(async () => second.resolve(inventory(2)))
await refresh
expect(states.get('one')?.inventory?.scannedAt).toBe(2)
expect(states.get('two')?.inventory?.scannedAt).toBe(2)
expect(freshnessInventory).toHaveBeenCalledTimes(2)
})
it('fails closed when an invalidation scan rejects', async () => {
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(1))
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<Probe />))
expect(state?.inventory?.scannedAt).toBe(1)
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
expect(state?.inventory).toBeNull()
expect(state?.loading).toBe(true)
await act(async () => second.reject(new Error('scan failed')))
expect(state?.inventory).toBeNull()
expect(state?.loading).toBe(false)
expect(state?.error).toBe('scan failed')
})
it('installs one event-listener pair for multiple consumers and cleans it up', async () => {
const addEventListener = vi.spyOn(window, 'addEventListener')
const removeEventListener = vi.spyOn(window, 'removeEventListener')
window.api = {
skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) }
} as never
await act(async () =>
root?.render(
<>
<Probe id="one" />
<Probe id="two" />
</>
)
)
expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
expect(
addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed')
).toHaveLength(1)
await act(async () => root?.unmount())
root = null
expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
expect(
removeEventListener.mock.calls.filter(
([name]) => name === 'orca:installed-agent-skills-changed'
)
).toHaveLength(1)
})
})
+95 -62
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
import { useMountedRef } from './useMountedRef'
// 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.
@@ -11,7 +10,34 @@ let pendingInventory: Promise<SkillFreshnessInventory> | null = null
let invalidationRevision = 0
let completedRevision = -1
let lastCompletedScanAt = 0
let sharedInvalidationPending = false
let refreshSequence = 0
type SkillFreshnessSnapshot = {
inventory: SkillFreshnessInventory | null
loading: boolean
error: string | null
}
let snapshot: SkillFreshnessSnapshot = {
inventory: null,
loading: false,
error: null
}
const subscribers = new Set<() => void>()
function publishSnapshot(next: SkillFreshnessSnapshot): void {
if (
snapshot.inventory === next.inventory &&
snapshot.loading === next.loading &&
snapshot.error === next.error
) {
return
}
snapshot = next
for (const subscriber of subscribers) {
subscriber()
}
}
async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
if (force) {
@@ -43,71 +69,77 @@ async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
}
}
export type SkillFreshnessState = {
inventory: SkillFreshnessInventory | null
loading: boolean
error: string | null
async function refreshSkillFreshness(force = true): Promise<void> {
const sequence = ++refreshSequence
// Why: eligibility is write authority for the draft command. Once invalidated,
// stale bytes must stop authorizing UI even if the replacement scan fails.
publishSnapshot({ inventory: null, loading: true, error: null })
try {
const inventory = await loadInventory(force)
if (sequence === refreshSequence) {
publishSnapshot({ inventory, loading: false, error: null })
}
} catch (cause) {
if (sequence === refreshSequence) {
publishSnapshot({
inventory: null,
loading: false,
error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.'
})
}
}
}
function onWindowFocus(): void {
if (Date.now() - lastCompletedScanAt < FOCUS_RESCAN_COOLDOWN_MS) {
return
}
void refreshSkillFreshness(true)
}
function onInstalledSkillsChanged(): void {
void refreshSkillFreshness(true)
}
function subscribe(subscriber: () => void): () => void {
subscribers.add(subscriber)
if (subscribers.size === 1) {
// Why: every consumer reads one external snapshot, so focus/install events
// install one listener and trigger one shared IPC scan regardless of UI count.
window.addEventListener('focus', onWindowFocus)
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
}
return () => {
subscribers.delete(subscriber)
if (subscribers.size === 0) {
window.removeEventListener('focus', onWindowFocus)
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
}
}
}
function getSnapshot(): SkillFreshnessSnapshot {
return snapshot
}
function ensureInventoryLoaded(): void {
if (!snapshot.inventory && !snapshot.loading) {
void refreshSkillFreshness(false)
}
}
export type SkillFreshnessState = SkillFreshnessSnapshot & {
refresh: () => Promise<void>
}
export function useSkillFreshness(): SkillFreshnessState {
const [inventory, setInventory] = useState<SkillFreshnessInventory | null>(cachedInventory)
const [loading, setLoading] = useState(cachedInventory === null)
const [error, setError] = useState<string | null>(null)
const mountedRef = useMountedRef()
const refresh = useCallback(
async (force = true): Promise<void> => {
setLoading(true)
try {
const next = await loadInventory(force)
if (mountedRef.current) {
setInventory(next)
setError(null)
}
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof Error ? cause.message : 'Could not inspect Orca skills.')
}
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
},
[mountedRef]
)
const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
useEffect(() => {
void refresh(false)
}, [refresh])
ensureInventoryLoaded()
}, [])
useEffect(() => {
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(!alreadyInvalidated)
}
const onFocus = (): void => invalidate(true)
const onInstalledSkillsChanged = (): void => invalidate(false)
window.addEventListener('focus', onFocus)
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
return () => {
window.removeEventListener('focus', onFocus)
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
}
}, [refresh])
return { inventory, loading, error, refresh: () => refresh(true) }
return { ...current, refresh: refreshSkillFreshness }
}
export const _skillFreshnessCacheForTests = {
@@ -117,6 +149,7 @@ export const _skillFreshnessCacheForTests = {
invalidationRevision = 0
completedRevision = -1
lastCompletedScanAt = 0
sharedInvalidationPending = false
refreshSequence = 0
snapshot = { inventory: null, loading: false, error: null }
}
}