Safely auto-update managed Orca skills

This commit is contained in:
brennanb2025
2026-06-24 02:54:03 -07:00
parent efe8111091
commit 5ab31df5d7
50 changed files with 2084 additions and 851 deletions
+26 -17
View File
@@ -27,7 +27,7 @@ Automatic managed-skill updates are enabled by default. Users can turn them off
Initial support is limited to Orca-owned feature skills:
- `linear-tickets`
- `orca-linear`
- `orchestration`
- `computer-use`
- `orca-cli`
@@ -52,7 +52,7 @@ The automatic-updates setting does not create a new trigger. It only controls wh
| Skill | Trigger |
| --- | --- |
| `linear-tickets` | User starts a new worktree from a Linear task, or launches/continues an agent workflow attached to a Linear issue where agents are expected to read or update the issue. |
| `orca-linear` | User starts a new worktree from a Linear task, or launches/continues an agent workflow attached to a Linear issue where agents are expected to read or update the issue. |
| `orchestration` | User starts an orchestration workflow, such as `/orchestration`, handoff, child-agent coordination, or an explicit orchestration enable/use action. |
| `computer-use` | User starts a Computer Use workflow, or an agent first attempts to invoke computer-use for the active task. |
| `orca-cli` | A workflow that depends on the Orca CLI skill starts, such as Browser Use, mobile emulator agent workflows, or another feature that explicitly needs Orca CLI affordances. |
@@ -63,15 +63,19 @@ The Agents settings pane exposes the default-on preference as "Allow verified Or
## Modal Copy Requirements
When the fallback modal is shown because an agent attempted a skill, the modal must say that.
When the fallback modal appears from a feature trigger, the modal must name the feature context
and workspace. Do not claim that an agent attempted the skill unless the triggering surface can
prove that; runtime and workflow entry points can also be initiated directly by the user.
Examples:
- "An agent just tried to use Orca orchestration. Orca needs to update the orchestration skill before agents can coordinate reliably."
- "An agent tried to use Orca's CLI skill for this workflow. Orca can update it now, or you can run the command manually."
- "This Linear task workflow needs the Linear agent skill. Orca could not update it automatically."
- "Orca Orchestration was used in octopus. Update the orchestration skill to enable agents to coordinate reliably."
- "The Orca CLI skill is needed in octopus. Update the CLI skill to enable this workflow to continue reliably."
- "A worktree was started from a Linear task in octopus. Install the Linear agent skill to enable agents to read and update Linear issues."
The modal should show the exact command Orca would run or wanted the user to run.
When a safe command is available, the modal should show the exact command Orca would run or
wanted the user to run. When no safe command is available, it should explain why Orca cannot
update automatically and offer a re-check after the user fixes the install/runtime state.
## Safety Model
@@ -92,7 +96,7 @@ Auto-update is eligible only when all checks pass:
9. The active project runtime is not repair-required.
10. The coordinator has not recorded a recent failed attempt for the same skill/runtime/scope.
For the first version, auto-update should be limited to tracked global installs. Project-scoped installs should use the same trigger and return a non-emitted fallback until project-scope behavior and a safe manual command are proven.
For the first version, auto-update should be limited to tracked global installs. Project-scoped installs should use the same trigger and show a manual-review fallback until project-scope behavior and a safe explicit update command are proven.
## Scope Resolution
@@ -189,13 +193,13 @@ Optional future invalidation:
| Risk | Mitigation |
| --- | --- |
| Wrong scope gets updated | Never use cwd inference; require discovery scope plus matching lockfile; use explicit `--global` or `--project`. |
| User has both global and project installs | Treat as ambiguous unless the active workflow has a single proven scope; return non-emitted fallback until there is an actionable command. |
| User has custom/local/hand-copied skill | Require lock metadata; return fallback if missing or legacy and only emit UI when there is a safe manual command. |
| User has both global and project installs | Treat as ambiguous unless the active workflow has a single proven scope; show a manual-review fallback instead of guessing. |
| User has custom/local/hand-copied skill | Require lock metadata; show a manual-review fallback if metadata is missing, malformed, or not Orca-managed. |
| Copy-mode installs are recreated as symlinks by upstream CLI | Avoid auto-update unless install metadata is trusted; call this out only when the fallback/manual path is actionable. |
| Private repo or auth-gated source needs credentials | Do not auto-update when source/auth cannot be proven non-interactive; return non-emitted fallback unless Orca has a safe manual command. |
| WSL distro unavailable or project runtime repair-required | Return fallback state without opening a setup modal until WSL-specific guidance or commands are actionable. |
| Private repo or auth-gated source needs credentials | Do not auto-update when source/auth cannot be proven non-interactive; show a manual-review fallback rather than running a command. |
| WSL distro unavailable or project runtime repair-required | Do not run host update commands; show runtime-context fallback guidance and allow re-check after repair. |
| SSH/remote filesystem update writes somewhere unexpected | Do not auto-update remote/SSH skills in v1. |
| Modal appears unexpectedly because an agent triggered it | Modal copy must name the triggering context: "An agent just tried to..." |
| Modal appears unexpectedly because a workflow triggered it | Modal copy must name the feature context and workspace without guessing whether an agent or user initiated it. |
| 50 agents cause 50 checks | Central coordinator with in-flight dedupe, success cache, and failure cooldown. |
| User turns automatic updates off | Keep the same triggers, but return the manual update modal for stale global installs without cooling that disabled-path modal away. |
| Update command hangs or npm/network is slow | Add timeout, cancellation on app shutdown where possible, and emit a modal only when a manual command is available. |
@@ -209,10 +213,11 @@ Optional future invalidation:
2. Renderer calls the coordinator with skill name and workflow context.
3. Coordinator returns one of:
- `ready`: installed and recently checked.
- `updated`: reserved for a verified background update path once the CLI contract ships.
- `updated`: Orca verified and ran the single-skill global update command successfully.
- `fallback`: return reason and optional command.
4. Workflow continues for `ready` and `updated`.
5. For `fallback`, show the contextual modal only when the fallback includes a manual command; otherwise avoid an interrupting dead-end prompt.
5. For `fallback`, show the contextual modal for actionable setup/manual-review states and keep
cooldown/unsupported internal states silent.
## Implementation Steps
@@ -238,7 +243,7 @@ Optional future invalidation:
## Tests
- Global tracked skill update eligibility without running blind `npx`.
- Global tracked skill update eligibility and verified single-skill `npx` execution.
- Project tracked skill falls back in v1.
- Same skill in global and project scope falls back as ambiguous.
- Symlinked visible global install remains global after `realpath`.
@@ -253,6 +258,10 @@ Optional future invalidation:
## Recommended First Version
Ship the coordinator and intent-boundary triggers, but do not run background `npx` until the verified CLI/ref/hash contract is available. Tracked global Orca-managed installs are the only future auto-update target for this version line. Everything else uses the same trigger and returns fallback state; only fallback state with a safe manual command opens a contextual modal.
Ship the coordinator and intent-boundary triggers with automatic updates for tracked global
Orca-managed installs only. The coordinator runs the explicit single-skill global update command
after discovery and lockfile validation, then verifies the post-update install before reporting
`updated`. Everything else uses the same trigger and returns fallback state; actionable fallback
states open a contextual modal.
This covers the common setup path from Orca's own install buttons while protecting custom, project, remote, and ambiguous installs.
+6 -1
View File
@@ -36,7 +36,8 @@ import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { getManagedSkillUpdateCoordinator } from './skills/managed-skill-updates'
import { abortManagedSkillUpdateProcesses } from './skills/managed-skill-updates'
import { getManagedSkillUpdateCoordinator } from './skills/managed-skill-update-coordinator-registry'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
@@ -737,6 +738,9 @@ function openMainWindow(): BrowserWindow {
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
onBeforeRelaunch: async () => {
isQuitting = true
// Why: app:relaunch exits directly and can skip will-quit, so cancel
// managed skill updater subprocesses before scheduling that exit.
abortManagedSkillUpdateProcesses()
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
}
}
@@ -1741,6 +1745,7 @@ app.on('before-quit', () => {
// async work and let Electron exit.
let daemonDisconnectDone = false
app.on('will-quit', (e) => {
abortManagedSkillUpdateProcesses()
// Why: before-quit can still be aborted by renderer beforeunload; wait until
// the committed quit path before removing the Windows notification icon.
destroySystemTray()
+46 -3
View File
@@ -65,7 +65,7 @@ vi.mock('./runtime-environment-request-connections', () => ({
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('../skills/managed-skill-updates', () => ({
vi.mock('../skills/managed-skill-update-coordinator-registry', () => ({
getManagedSkillUpdateCoordinator: () => ({
ensureManagedReady: ensureManagedReadyMock
})
@@ -400,7 +400,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
})
it('nudges managed skill readiness after remote runtime feature RPCs without opening setup', async () => {
it('nudges managed skill readiness after remote runtime feature RPCs and emits fallback setup', async () => {
registerRuntimeEnvironmentHandlers({} as never)
ensureManagedReadyMock.mockResolvedValue({
status: 'fallback',
@@ -442,7 +442,50 @@ describe('registerRuntimeEnvironmentHandlers', () => {
remoteRuntime: true
})
})
expect(sendManagedSkillFallbackMock).not.toHaveBeenCalled()
expect(sendManagedSkillFallbackMock).toHaveBeenCalledWith({
status: 'fallback',
skillName: 'orchestration',
context: 'agent-orchestration',
runtime: 'host',
scope: 'missing',
reason: 'remote-runtime'
})
})
it('nudges orca-cli readiness after remote emulator runtime RPCs', async () => {
registerRuntimeEnvironmentHandlers({} as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'rpc-emulator',
ok: true,
result: { tapped: true },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const call = handler<
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
await expect(
call(null, {
selector: 'desk',
method: 'emulator.tap',
params: { x: 10, y: 20 }
})
).resolves.toMatchObject({ ok: true })
await vi.waitFor(() => {
expect(ensureManagedReadyMock).toHaveBeenCalledWith({
skillName: 'orca-cli',
context: 'agent-orca-cli',
remoteRuntime: true
})
})
})
it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Store } from '../persistence'
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
import type { RuntimeManagedSkillNudgeHandler } from '../runtime/rpc/managed-skill-nudge'
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-updates'
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-update-coordinator-registry'
import { sendManagedSkillFallback, sendManagedSkillUpdated } from './skills'
export function createRuntimeManagedSkillNudge(store: Store): RuntimeManagedSkillNudgeHandler {
+16 -16
View File
@@ -150,14 +150,14 @@ describe('registerSkillsHandlers', () => {
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('registers managed ensure and returns repair-required fallback without emitting setup', async () => {
it('registers managed ensure and emits repair-required fallback setup', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const result = await handler(
{ sender: { send } },
{
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: {
projectRuntime: {
@@ -177,9 +177,9 @@ describe('registerSkillsHandlers', () => {
expect(result).toMatchObject({
status: 'fallback',
reason: 'repair-required-runtime',
skillName: 'linear-tickets'
skillName: 'orca-linear'
})
expect(send).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
@@ -203,7 +203,7 @@ describe('registerSkillsHandlers', () => {
expect(send).not.toHaveBeenCalled()
})
it('does not emit managed fallback events for remote runtime results', async () => {
it('emits managed fallback events for remote runtime results', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const result = await handler(
@@ -219,16 +219,16 @@ describe('registerSkillsHandlers', () => {
status: 'fallback',
reason: 'remote-runtime'
})
expect(send).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
})
it('does not emit managed fallback events for WSL runtime results', async () => {
it('emits managed fallback events for WSL runtime results', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const result = await handler(
{ sender: { send } },
{
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
}
@@ -238,24 +238,24 @@ describe('registerSkillsHandlers', () => {
status: 'fallback',
reason: 'wsl-runtime'
})
expect(send).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
})
it('does not emit managed fallback events without a manual command', async () => {
it('emits managed fallback events for unsafe installs without a manual command', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
discoverSkillsMock.mockResolvedValue({
skills: [
{
id: 'repo-linear-tickets',
name: 'linear-tickets',
id: 'repo-orca-linear',
name: 'orca-linear',
description: null,
providers: ['agent-skills'],
sourceKind: 'repo',
sourceLabel: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/linear-tickets',
skillFilePath: '/workspace/current/.agents/skills/linear-tickets/SKILL.md',
directoryPath: '/workspace/current/.agents/skills/orca-linear',
skillFilePath: '/workspace/current/.agents/skills/orca-linear/SKILL.md',
installed: true,
fileCount: 1,
updatedAt: 1
@@ -267,7 +267,7 @@ describe('registerSkillsHandlers', () => {
const result = await handler(
{ sender: { send } },
{
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
}
@@ -277,6 +277,6 @@ describe('registerSkillsHandlers', () => {
status: 'fallback',
reason: 'project-install'
})
expect(send).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
})
})
+8 -1
View File
@@ -9,7 +9,11 @@ import type {
} from '../../shared/skills'
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-updates'
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-update-coordinator-registry'
import {
publishManagedSkillFallback,
publishManagedSkillUpdated
} from '../skills/managed-skill-events'
type SkillDiscoveryRuntimeTarget =
| { runtime: 'host' }
@@ -70,6 +74,7 @@ export function registerSkillsHandlers(store: Store): void {
): Promise<ManagedAgentSkillEnsureResult> => {
const result = await getManagedSkillUpdateCoordinator(store).ensureManagedReady(request)
if (shouldEmitManagedAgentSkillFallback(result)) {
publishManagedSkillFallback(result)
event.sender.send('skills:managedFallback', result)
}
if (result.status === 'updated') {
@@ -84,6 +89,7 @@ export function sendManagedSkillFallback(result: ManagedAgentSkillEnsureResult):
if (!shouldEmitManagedAgentSkillFallback(result)) {
return
}
publishManagedSkillFallback(result)
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('skills:managedFallback', result)
}
@@ -93,6 +99,7 @@ export function sendManagedSkillUpdated(result: ManagedAgentSkillEnsureResult):
if (result.status !== 'updated') {
return
}
publishManagedSkillUpdated(result)
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('skills:managedUpdated', result)
}
@@ -120,6 +120,11 @@ const METHODS = [
params: z.object({}),
handler: () => ({ id: 'msg-1' })
}),
defineMethod({
name: 'emulator.tap',
params: z.object({}),
handler: () => ({ tapped: true })
}),
defineMethod({
name: 'browser.fail',
params: z.object({}),
@@ -137,10 +142,12 @@ describe('RpcDispatcher feature interactions', () => {
await dispatcher.dispatch(makeRequest('browser.click'))
await dispatcher.dispatch(makeRequest('computer.click'))
await dispatcher.dispatch(makeRequest('orchestration.send'))
await dispatcher.dispatch(makeRequest('emulator.tap'))
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-orchestration')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('mobile-emulator-agent-use')
})
it('nudges managed skills for runtime feature use without blocking responses', async () => {
@@ -180,6 +187,20 @@ describe('RpcDispatcher feature interactions', () => {
})
})
it('nudges orca-cli when mobile emulator runtime methods are used', async () => {
const runtime = makeRuntime()
const nudgeManagedSkill = vi.fn()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS, nudgeManagedSkill })
await dispatcher.dispatch(makeRequest('emulator.tap'))
await Promise.resolve()
expect(nudgeManagedSkill).toHaveBeenCalledWith({
skillName: 'orca-cli',
context: 'agent-orca-cli'
})
})
it('marks runtime managed-skill nudges remote without passing host discovery targets', async () => {
const runtime = makeRuntime()
const nudgeManagedSkill = vi.fn()
@@ -273,6 +294,7 @@ describe('RpcDispatcher feature interactions', () => {
makeRequest('browser.screencast', browserPaneUiParams),
() => {}
)
await dispatcher.dispatch(makeRequest('emulator.tap', browserPaneUiParams))
await dispatcher.dispatch(makeRequest('browser.click'))
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
+1 -1
View File
@@ -30,7 +30,7 @@ export function getManagedSkillNudgeForFeatureInteraction(
if (id === 'computer-use') {
return { skillName: COMPUTER_USE_SKILL_NAME, context: 'agent-computer-use' }
}
if (id === 'agent-browser-use') {
if (id === 'agent-browser-use' || id === 'mobile-emulator-agent-use') {
return { skillName: ORCA_CLI_SKILL_NAME, context: 'agent-orca-cli' }
}
return null
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest, RpcResponse } from '../core'
import { SKILL_METHODS } from './skills'
import {
publishManagedSkillFallback,
publishManagedSkillUpdated
} from '../../../skills/managed-skill-events'
import type { ManagedAgentSkillFallback, ManagedAgentSkillUpdated } from '../../../../shared/skills'
function makeRequest(method: string): RpcRequest {
return {
id: 'req-1',
authToken: 'token',
method
}
}
function makeRuntime(): {
runtime: {
getRuntimeId: () => string
listRepos: () => unknown[]
registerSubscriptionCleanup: (
subscriptionId: string,
cleanup: () => void,
connectionId?: string
) => void
cleanupSubscription: (subscriptionId: string) => void
}
cleanups: Map<string, () => void>
} {
const cleanups = new Map<string, () => void>()
return {
cleanups,
runtime: {
getRuntimeId: () => 'runtime-1',
listRepos: () => [],
registerSubscriptionCleanup: vi.fn((subscriptionId, cleanup) => {
cleanups.set(subscriptionId, cleanup)
}),
cleanupSubscription: (subscriptionId) => {
const cleanup = cleanups.get(subscriptionId)
if (!cleanup) {
return
}
cleanups.delete(subscriptionId)
cleanup()
}
}
}
}
function parseResponse(raw: string): RpcResponse {
return JSON.parse(raw) as RpcResponse
}
describe('skills runtime RPC methods', () => {
it('streams managed skill events from the main-process event bus', async () => {
const { runtime, cleanups } = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: SKILL_METHODS })
const responses: RpcResponse[] = []
const dispatch = dispatcher.dispatchStreaming(
makeRequest('skills.managedEvents'),
(response) => {
responses.push(parseResponse(response))
},
{ connectionId: 'conn-1' }
)
await vi.waitFor(() => {
expect(responses[0]).toMatchObject({
ok: true,
streaming: true,
result: { type: 'ready', subscriptionId: expect.stringContaining('conn-1') }
})
})
const fallbackEvent = {
status: 'fallback',
skillName: 'orca-linear',
context: 'linear-worktree',
runtime: 'host',
scope: 'missing',
reason: 'missing-install',
uiKey: 'host::orca-linear:linear-worktree',
message: 'Install orca-linear.',
request: { skillName: 'orca-linear', context: 'linear-worktree' }
} satisfies ManagedAgentSkillFallback
const updatedEvent = {
status: 'updated',
skillName: 'orchestration',
context: 'agent-orchestration',
runtime: 'host',
scope: 'global'
} satisfies ManagedAgentSkillUpdated
publishManagedSkillFallback(fallbackEvent)
publishManagedSkillUpdated(updatedEvent)
expect(responses).toEqual(
expect.arrayContaining([
expect.objectContaining({
ok: true,
streaming: true,
result: { type: 'fallback', event: fallbackEvent }
}),
expect.objectContaining({
ok: true,
streaming: true,
result: { type: 'updated', event: updatedEvent }
})
])
)
const ready = responses[0]
const subscriptionId =
ready.ok && typeof ready.result === 'object' && ready.result
? (ready.result as { subscriptionId?: string }).subscriptionId
: undefined
expect(subscriptionId).toBeTruthy()
runtime.cleanupSubscription(subscriptionId!)
await dispatch
expect(cleanups.size).toBe(0)
expect(responses.at(-1)).toMatchObject({
ok: true,
streaming: true,
result: { type: 'end' }
})
})
})
+30 -2
View File
@@ -1,10 +1,38 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { discoverSkills } from '../../../skills/discovery'
import { onManagedSkillEvent } from '../../../skills/managed-skill-events'
export const SKILL_METHODS: RpcMethod[] = [
let managedSkillEventsSubscriptionSeq = 0
export const SKILL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'skills.discover',
params: null,
handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() })
}),
defineStreamingMethod({
name: 'skills.managedEvents',
params: null,
handler: async (_params, { runtime, connectionId }, emit) => {
await new Promise<void>((resolve) => {
const unsubscribe = onManagedSkillEvent((event) => {
emit(event)
})
const seq = ++managedSkillEventsSubscriptionSeq
const subscriptionId = `skills-managed-events-${connectionId ?? 'inproc'}-${seq}`
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
unsubscribe()
emit({ type: 'end' })
resolve()
},
connectionId
)
emit({ type: 'ready', subscriptionId })
})
}
})
]
@@ -1,5 +1,8 @@
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
import {
isBrowserPaneUiRuntimeRpcParams,
isUiOwnedRuntimeRpcParams
} from '../../../shared/runtime-rpc-feature-interaction-source'
export function getRuntimeFeatureInteractionId(
method: string,
@@ -21,9 +24,12 @@ export function getRuntimeFeatureInteractionId(
if (method.startsWith('browser.') && !method.startsWith('browser.profile')) {
return 'agent-browser-use'
}
if (method.startsWith('emulator.')) {
if (method.startsWith('emulator.') && isUiOwnedRuntimeRpcParams(rawParams)) {
return null
}
if (method.startsWith('emulator.')) {
return 'mobile-emulator-agent-use'
}
if (method === 'computer.permissions') {
return 'computer-use-setup'
}
@@ -8,6 +8,7 @@ import type {
ManagedAgentSkillScope
} from '../../shared/skills'
import { getManagedSkillFallbackMessage } from './managed-skill-fallback-message'
import { buildManagedSkillManualCommand } from './managed-skill-update-contract'
export function buildManagedSkillReadyResult(
request: ManagedAgentSkillEnsureRequest
@@ -21,6 +22,31 @@ export function buildManagedSkillReadyResult(
}
}
export function buildManagedSkillUpdatedResult(
request: ManagedAgentSkillEnsureRequest
): ManagedAgentSkillEnsureResult {
return {
status: 'updated',
skillName: request.skillName,
context: request.context,
runtime: 'host',
scope: 'global'
}
}
export function buildManagedSkillGlobalUpdateFallback(
request: ManagedAgentSkillEnsureRequest,
reason: Extract<ManagedAgentSkillFallbackReason, 'update-failed' | 'update-timeout'>
): ManagedAgentSkillFallback {
return buildManagedSkillFallback({
request,
reason,
runtime: 'host',
scope: 'global',
manualCommand: buildManagedSkillManualCommand('update', request.skillName)
})
}
export function buildManagedSkillFallback(args: {
request: ManagedAgentSkillEnsureRequest
reason: ManagedAgentSkillFallbackReason
+39
View File
@@ -0,0 +1,39 @@
import type {
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallback,
ManagedAgentSkillUpdated
} from '../../shared/skills'
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
export type ManagedSkillEvent =
| { type: 'fallback'; event: ManagedAgentSkillFallback }
| { type: 'updated'; event: ManagedAgentSkillUpdated }
const managedSkillEventListeners = new Set<(event: ManagedSkillEvent) => void>()
export function onManagedSkillEvent(listener: (event: ManagedSkillEvent) => void): () => void {
managedSkillEventListeners.add(listener)
return () => {
managedSkillEventListeners.delete(listener)
}
}
export function publishManagedSkillFallback(result: ManagedAgentSkillEnsureResult): void {
if (!shouldEmitManagedAgentSkillFallback(result)) {
return
}
publishManagedSkillEvent({ type: 'fallback', event: result })
}
export function publishManagedSkillUpdated(result: ManagedAgentSkillEnsureResult): void {
if (result.status !== 'updated') {
return
}
publishManagedSkillEvent({ type: 'updated', event: result })
}
function publishManagedSkillEvent(event: ManagedSkillEvent): void {
for (const listener of managedSkillEventListeners) {
listener(event)
}
}
@@ -1,10 +1,12 @@
import type {
DiscoveredSkill,
ManagedAgentSkillManualCommand,
ManagedAgentSkillName,
ManagedAgentSkillScope,
SkillDiscoveryResult
} from '../../shared/skills'
import { selectManagedSkillDiscoveryCandidates } from './managed-skill-discovery-selection'
import { normalizePathForManagedSkillKey } from './managed-skill-update-cache-key'
import { buildManagedSkillManualCommand } from './managed-skill-update-contract'
type ManagedSkillGlobalCandidate = SkillDiscoveryResult['skills'][number]
@@ -90,7 +92,7 @@ export function selectSingleGlobalManagedSkillCandidate(args: {
}
const candidate = homeCandidates[0]
if (candidate.directoryIsSymlink || candidate.skillFileIsSymlink) {
if (hasSymlinkedSkillPath(candidate)) {
return {
status: 'fallback',
fallback: { reason: 'symlinked-global-install', scope: 'global' }
@@ -98,3 +100,19 @@ export function selectSingleGlobalManagedSkillCandidate(args: {
}
return { status: 'candidate', candidate }
}
function hasSymlinkedSkillPath(candidate: DiscoveredSkill): boolean {
return (
candidate.directoryIsSymlink === true ||
candidate.skillFileIsSymlink === true ||
!isCanonicalPath(candidate.directoryPath, candidate.realDirectoryPath) ||
!isCanonicalPath(candidate.skillFilePath, candidate.realSkillFilePath)
)
}
function isCanonicalPath(path: string, realPath: string | null | undefined): boolean {
return (
typeof realPath === 'string' &&
normalizePathForManagedSkillKey(realPath) === normalizePathForManagedSkillKey(path)
)
}
@@ -0,0 +1,30 @@
import type { ManagedAgentSkillEnsureRequest, SkillDiscoveryResult } from '../../shared/skills'
import { selectSingleGlobalManagedSkillCandidate } from './managed-skill-global-candidate'
import { readManagedSkillLockEntry } from './managed-skill-lockfile'
export async function verifyManagedSkillPostUpdate(args: {
discoverHostSkills: (projectRootPath?: string | null) => Promise<SkillDiscoveryResult>
homeDir: string
readTextFile: (path: string) => Promise<string>
request: ManagedAgentSkillEnsureRequest
}): Promise<{ ok: true; lockHash: string } | { ok: false }> {
const postDiscovery = await args.discoverHostSkills(args.request.discoveryTarget?.projectRootPath)
const postCandidateDecision = selectSingleGlobalManagedSkillCandidate({
discovery: postDiscovery,
homeDir: args.homeDir,
projectRootPath: args.request.discoveryTarget?.projectRootPath,
skillName: args.request.skillName
})
if (postCandidateDecision.status === 'fallback') {
return { ok: false }
}
const postLockEntryResult = await readManagedSkillLockEntry({
homeDir: args.homeDir,
readTextFile: args.readTextFile,
skillName: args.request.skillName
})
return postLockEntryResult.ok
? { ok: true, lockHash: postLockEntryResult.entry.skillFolderHash }
: { ok: false }
}
@@ -0,0 +1,67 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { ORCHESTRATION_SKILL_NAME } from '../../shared/agent-feature-install-commands'
import type {
DiscoveredSkill,
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillName,
SkillDiscoveryResult
} from '../../shared/skills'
export const TEST_MANAGED_HOME_ROOT = join(homedir(), '.agents', 'skills')
export const orchestrationRequest: ManagedAgentSkillEnsureRequest = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
}
export function discoveredSkill(
patch: Partial<DiscoveredSkill> & Pick<DiscoveredSkill, 'name' | 'sourceKind'>
): DiscoveredSkill {
const rootPath = patch.rootPath ?? TEST_MANAGED_HOME_ROOT
const directoryPath = patch.directoryPath ?? join(rootPath, patch.name)
return {
id: patch.id ?? `${patch.sourceKind}-${patch.name}`,
name: patch.name,
description: patch.description ?? null,
providers: patch.providers ?? ['agent-skills'],
sourceKind: patch.sourceKind,
sourceLabel: patch.sourceLabel ?? patch.sourceKind,
rootPath,
directoryPath,
realDirectoryPath: patch.realDirectoryPath ?? directoryPath,
directoryIsSymlink: patch.directoryIsSymlink ?? false,
skillFilePath: patch.skillFilePath ?? join(directoryPath, 'SKILL.md'),
realSkillFilePath: patch.realSkillFilePath ?? join(directoryPath, 'SKILL.md'),
skillFileIsSymlink: patch.skillFileIsSymlink ?? false,
installed: patch.installed ?? true,
fileCount: patch.fileCount ?? 1,
updatedAt: patch.updatedAt ?? 1
}
}
export function discovery(skills: DiscoveredSkill[]): SkillDiscoveryResult {
return { skills, sources: [], scannedAt: 1 }
}
export function homeDiscovery(
skillName: ManagedAgentSkillName = ORCHESTRATION_SKILL_NAME
): SkillDiscoveryResult {
return discovery([discoveredSkill({ name: skillName, sourceKind: 'home' })])
}
export function lockfile(skillName: ManagedAgentSkillName, skillFolderHash: string): string {
return JSON.stringify({
version: 3,
skills: {
[skillName]: {
source: 'stablyai/orca',
sourceType: 'github',
sourceUrl: 'https://github.com/stablyai/orca.git',
skillPath: `skills/${skillName}/SKILL.md`,
skillFolderHash
}
}
})
}
@@ -1,9 +1,10 @@
import { isAbsolute, normalize, relative, sep } from 'node:path'
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallbackReason,
ManagedAgentSkillRuntime
} from '../../shared/skills'
import type { ExpectedManagedSkillRevision } from './managed-skill-update-contract'
export function shouldCooldownFallback(result: ManagedAgentSkillEnsureResult): boolean {
return (
@@ -16,18 +17,48 @@ export function shouldCooldownFallback(result: ManagedAgentSkillEnsureResult): b
export function makeManagedSkillSuccessCacheKey(args: {
appVersion: string
request: ManagedAgentSkillEnsureRequest
expected: ExpectedManagedSkillRevision | undefined
currentLockHash: string
}): string {
return [args.appVersion, 'host', '', 'global', args.request.skillName, args.currentLockHash].join(
':'
)
}
export function makeManagedSkillTargetFallbackCacheKey(args: {
appVersion: string
distro?: string | null
reason: ManagedAgentSkillFallbackReason
request: ManagedAgentSkillEnsureRequest
runtime: ManagedAgentSkillRuntime
}): string {
return [
args.appVersion,
'host',
'',
'global',
args.runtime,
args.distro ?? '',
'target-fallback',
normalizeManagedSkillKeyPart(args.request.context),
normalizeManagedSkillKeyPart(args.request.discoveryTarget?.projectRootPath),
args.request.skillName,
args.expected?.expectedHash ?? 'missing-expected-hash',
args.expected?.expectedSourceRef ?? 'missing-source-ref'
args.reason
].join(':')
}
export function makeManagedSkillPreDiscoveryCacheKey(args: {
appVersion: string
backgroundUpdatesEnabled: boolean
distro?: string | null
request: ManagedAgentSkillEnsureRequest
runtime: Extract<ManagedAgentSkillRuntime, 'host' | 'wsl'>
}): string {
return [
args.appVersion,
args.runtime,
args.distro ?? '',
'pre-discovery',
args.backgroundUpdatesEnabled ? 'background-updates-on' : 'background-updates-off',
normalizeManagedSkillKeyPart(args.request.context),
normalizeManagedSkillKeyPart(args.request.discoveryTarget?.projectRootPath),
args.request.skillName
].join(':')
}
@@ -2,25 +2,15 @@ import {
buildAgentFeatureSkillInstallCommand,
buildAgentFeatureSkillUpdateCommand,
COMPUTER_USE_SKILL_NAME,
LINEAR_TICKETS_SKILL_NAME,
ORCA_LINEAR_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../shared/agent-feature-install-commands'
import type { ManagedAgentSkillManualCommand, ManagedAgentSkillName } from '../../shared/skills'
export type ExpectedManagedSkillRevision = {
expectedHash: string
expectedSourceRef: string
skillsPackageVersion: string
}
export const EXPECTED_MANAGED_SKILL_REVISIONS: Partial<
Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>
> = {}
const MANAGED_SKILL_NAMES = new Set<string>([
COMPUTER_USE_SKILL_NAME,
LINEAR_TICKETS_SKILL_NAME,
ORCA_LINEAR_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
])
@@ -29,10 +19,6 @@ export function isManagedAgentSkillName(value: string): value is ManagedAgentSki
return MANAGED_SKILL_NAMES.has(value)
}
export function isImmutableSourceRef(value: string): boolean {
return /^[a-f0-9]{40}$/i.test(value)
}
export function buildManagedSkillManualCommand(
kind: ManagedAgentSkillManualCommand['kind'],
skillName: ManagedAgentSkillName
@@ -0,0 +1,24 @@
import type { Store } from '../persistence'
import { discoverSkills } from './discovery'
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
const coordinatorByStore = new WeakMap<Store, ManagedSkillUpdateCoordinator>()
export function getManagedSkillUpdateCoordinator(store: Store): ManagedSkillUpdateCoordinator {
const existing = coordinatorByStore.get(store)
if (existing) {
return existing
}
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: process.env.npm_package_version,
backgroundUpdatesEnabled: () =>
store.getSettings().managedAgentSkillBackgroundUpdatesEnabled !== false,
discoverHostSkills: (projectRootPath) =>
discoverSkills({
repos: store.getRepos(),
...(projectRootPath ? { cwd: projectRootPath } : {})
})
})
coordinatorByStore.set(store, coordinator)
return coordinator
}
@@ -0,0 +1,96 @@
import { EventEmitter } from 'node:events'
import { spawn } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_SKILL_NAME } from '../../shared/agent-feature-install-commands'
import { createManagedSkillUpdateRunner } from './managed-skill-update-runner'
vi.mock('node:child_process', () => ({
spawn: vi.fn()
}))
class FakeChildProcess extends EventEmitter {
readonly kill = vi.fn()
readonly unref = vi.fn()
constructor(readonly pid?: number) {
super()
}
}
describe('createManagedSkillUpdateRunner', () => {
afterEach(() => {
vi.useRealTimers()
vi.mocked(spawn).mockReset()
})
it('resolves timeout without waiting for the child close event', async () => {
vi.useFakeTimers()
const child = new FakeChildProcess()
vi.mocked(spawn).mockReturnValue(child as never)
const runner = createManagedSkillUpdateRunner({ timeoutMs: 50 })
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
await vi.advanceTimersByTimeAsync(50)
await expect(resultPromise).resolves.toEqual({ status: 'timeout' })
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('kills the process when the shutdown signal aborts', async () => {
const child = new FakeChildProcess()
vi.mocked(spawn).mockReturnValue(child as never)
const controller = new AbortController()
const runner = createManagedSkillUpdateRunner({ signal: controller.signal, timeoutMs: 1_000 })
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
controller.abort()
await expect(resultPromise).resolves.toEqual({ status: 'failure', error: 'aborted' })
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('spawns the single-skill global update command without a shell from a neutral cwd', async () => {
const child = new FakeChildProcess()
vi.mocked(spawn).mockReturnValue(child as never)
const runner = createManagedSkillUpdateRunner({ cwd: '/home/alice', timeoutMs: 1_000 })
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
child.emit('close', 0)
await expect(resultPromise).resolves.toEqual({ status: 'success' })
expect(spawn).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'orchestration', '--global', '--yes'],
{
cwd: '/home/alice',
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
})
it('uses taskkill for Windows process-tree cleanup on timeout', async () => {
vi.useFakeTimers()
const child = new FakeChildProcess(123)
const killer = new FakeChildProcess()
vi.mocked(spawn)
.mockReturnValueOnce(child as never)
.mockReturnValueOnce(killer as never)
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const runner = createManagedSkillUpdateRunner({ timeoutMs: 50 })
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
await vi.advanceTimersByTimeAsync(50)
await expect(resultPromise).resolves.toEqual({ status: 'timeout' })
expect(spawn).toHaveBeenNthCalledWith(
2,
'taskkill',
['/pid', '123', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(killer.unref).toHaveBeenCalledTimes(1)
platform.mockRestore()
})
})
@@ -0,0 +1,108 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { homedir } from 'node:os'
import type { ManagedAgentSkillName } from '../../shared/skills'
export type ManagedSkillUpdateRunnerResult =
| { status: 'success' }
| { status: 'failure'; exitCode?: number | null; error?: string }
| { status: 'timeout' }
export type ManagedSkillUpdateRunner = (
skillName: ManagedAgentSkillName
) => Promise<ManagedSkillUpdateRunnerResult>
const DEFAULT_UPDATE_TIMEOUT_MS = 120_000
export function buildManagedSkillUpdateCommand(skillName: ManagedAgentSkillName): {
executable: string
args: string[]
} {
return {
executable: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: ['--yes', 'skills', 'update', skillName, '--global', '--yes']
}
}
export function createManagedSkillUpdateRunner(
args: {
cwd?: string
signal?: AbortSignal
timeoutMs?: number
} = {}
): ManagedSkillUpdateRunner {
const timeoutMs = args.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS
const cwd = args.cwd ?? homedir()
return (skillName) => runManagedSkillUpdate(skillName, timeoutMs, cwd, args.signal)
}
async function runManagedSkillUpdate(
skillName: ManagedAgentSkillName,
timeoutMs: number,
cwd: string,
signal: AbortSignal | undefined
): Promise<ManagedSkillUpdateRunnerResult> {
const command = buildManagedSkillUpdateCommand(skillName)
return new Promise((resolve) => {
let settled = false
const child = spawn(command.executable, command.args, {
cwd,
shell: false,
stdio: 'ignore',
windowsHide: true
})
const killChildTree = (): void => {
killSpawnedCommandTree(child)
}
const cleanup = (): void => {
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
}
const finish = (result: ManagedSkillUpdateRunnerResult): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve(result)
}
const onAbort = (): void => {
killChildTree()
finish({ status: 'failure', error: 'aborted' })
}
const timeout = setTimeout(() => {
killChildTree()
finish({ status: 'timeout' })
}, timeoutMs)
if (signal?.aborted) {
onAbort()
return
}
signal?.addEventListener('abort', onAbort, { once: true })
child.once('error', (error) => {
finish({ status: 'failure', error: error.message })
})
child.once('close', (exitCode) => {
finish(exitCode === 0 ? { status: 'success' } : { status: 'failure', exitCode })
})
})
}
function killSpawnedCommandTree(child: ChildProcess): void {
const pid = child.pid
if (!pid || process.platform !== 'win32') {
child.kill()
return
}
try {
// Why: `npx.cmd` can leave its node/npm child alive if only cmd.exe is killed.
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.on('error', () => child.kill())
killer.unref()
} catch {
child.kill()
}
}
@@ -0,0 +1,278 @@
import { describe, expect, it, vi } from 'vitest'
import {
ORCA_LINEAR_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../shared/agent-feature-install-commands'
import type { ManagedAgentSkillEnsureRequest, ManagedAgentSkillName } from '../../shared/skills'
import {
TEST_MANAGED_HOME_ROOT,
discoveredSkill,
discovery,
homeDiscovery,
lockfile,
orchestrationRequest
} from './managed-skill-test-fixtures'
import { buildManagedSkillUpdateCommand } from './managed-skill-update-runner'
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
describe('ManagedSkillUpdateCoordinator safety cases', () => {
it('does not auto-update project, ambiguous, bundled, plugin, or symlinked installs', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const cases = [
{
skills: [
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
],
reason: 'project-install'
},
{
skills: [
discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' }),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
],
reason: 'ambiguous-install'
},
{
skills: [discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'bundled' })],
reason: 'bundled-or-plugin-install'
},
{
skills: [discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'plugin' })],
reason: 'bundled-or-plugin-install'
},
{
skills: [
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
directoryIsSymlink: true
})
],
reason: 'symlinked-global-install'
},
{
skills: [
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
directoryPath: '/home/alice/.agents/skills/orchestration',
realDirectoryPath: '/mnt/central/skills/orchestration',
skillFilePath: '/home/alice/.agents/skills/orchestration/SKILL.md',
realSkillFilePath: '/mnt/central/skills/orchestration/SKILL.md'
})
],
reason: 'symlinked-global-install'
}
] as const
for (const testCase of cases) {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () => discovery([...testCase.skills]),
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
updateRunner
})
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(result.status === 'fallback' ? result.reason : null).toBe(testCase.reason)
}
expect(updateRunner).not.toHaveBeenCalled()
})
it('does not treat Codex or Claude home skills as managed global installs', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const coordinator = new ManagedSkillUpdateCoordinator({
homeDir: () => '/home/alice',
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
providers: ['codex'],
rootPath: '/home/alice/.codex/skills',
directoryPath: '/home/alice/.codex/skills/orchestration'
})
]),
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
updateRunner
})
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('install')
expect(updateRunner).not.toHaveBeenCalled()
})
it('keeps concurrent checks for different project roots independent', async () => {
const discoverHostSkills = vi.fn(async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
)
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills,
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'hash-1'),
updateRunner: async () => ({ status: 'success' })
})
const [currentProject, otherProject] = await Promise.all([
coordinator.ensureManagedReady(orchestrationRequest),
coordinator.ensureManagedReady({
...orchestrationRequest,
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
})
])
expect(currentProject.status === 'fallback' ? currentProject.reason : null).toBe(
'ambiguous-install'
)
expect(otherProject.status).toBe('ready')
expect(discoverHostSkills).toHaveBeenCalledTimes(3)
})
it('returns missing install with an install command without auto-installing', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () => discovery([]),
updateRunner
})
const result = await coordinator.ensureManagedReady({
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
expect(result.status === 'fallback' ? result.manualCommand?.command : null).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill orca-linear --global --yes'
)
expect(updateRunner).not.toHaveBeenCalled()
})
it('dedupes concurrent missing-install checks before discovery', async () => {
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
const [first, second] = await Promise.all([
coordinator.ensureManagedReady({
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
}),
coordinator.ensureManagedReady({
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
])
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
expect(second.status === 'fallback' ? second.reason : null).toBe('missing-install')
})
it('cooldowns repeated missing-install checks and lets force bypass that cooldown', async () => {
let now = 100
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills
})
const request: ManagedAgentSkillEnsureRequest = {
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
}
const first = await coordinator.ensureManagedReady(request)
now = 200
const second = await coordinator.ensureManagedReady(request)
const forced = await coordinator.ensureManagedReady({ ...request, force: true })
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
expect(forced.status === 'fallback' ? forced.reason : null).toBe('missing-install')
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
})
it('falls back for WSL, remote, and target-required runtime cases without local commands', async () => {
const coordinator = new ManagedSkillUpdateCoordinator()
const wsl = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
const remote = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
remoteRuntime: true
})
const unknown = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration'
})
expect(wsl.status === 'fallback' ? wsl.reason : null).toBe('wsl-runtime')
expect(wsl.status === 'fallback' ? wsl.manualCommand : undefined).toBeUndefined()
expect(remote.status === 'fallback' ? remote.reason : null).toBe('remote-runtime')
expect(remote.status === 'fallback' ? remote.manualCommand : undefined).toBeUndefined()
expect(unknown.status === 'fallback' ? unknown.reason : null).toBe('target-required')
})
it('uses canonical orca-linear as the managed Linear skill and leaves legacy unsupported', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () => homeDiscovery(ORCA_LINEAR_SKILL_NAME),
readTextFile: async () => lockfile(ORCA_LINEAR_SKILL_NAME, 'hash-1'),
updateRunner: async () => ({ status: 'success' })
})
const canonical = await coordinator.ensureManagedReady({
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
const legacy = await coordinator.ensureManagedReady({
skillName: 'linear-tickets' as ManagedAgentSkillName,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
expect(canonical.status).toBe('ready')
expect(legacy.status === 'fallback' ? legacy.reason : null).toBe('unsupported-skill')
})
it('builds the exact single-skill global update command and Windows executable seam', () => {
expect(buildManagedSkillUpdateCommand(ORCHESTRATION_SKILL_NAME)).toEqual({
executable: 'npx',
args: ['--yes', 'skills', 'update', 'orchestration', '--global', '--yes']
})
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
expect(buildManagedSkillUpdateCommand(ORCHESTRATION_SKILL_NAME)).toEqual({
executable: 'npx.cmd',
args: ['--yes', 'skills', 'update', 'orchestration', '--global', '--yes']
})
platform.mockRestore()
})
})
+263 -597
View File
@@ -1,638 +1,304 @@
import { describe, expect, it, vi } from 'vitest'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
LINEAR_TICKETS_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../shared/agent-feature-install-commands'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../shared/skills'
import { ORCHESTRATION_SKILL_NAME } from '../../shared/agent-feature-install-commands'
import { homeDiscovery, lockfile, orchestrationRequest } from './managed-skill-test-fixtures'
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
const TEST_MANAGED_HOME_ROOT = join(homedir(), '.agents', 'skills')
function discoveredSkill(
patch: Partial<DiscoveredSkill> & Pick<DiscoveredSkill, 'name' | 'sourceKind'>
): DiscoveredSkill {
const rootPath = patch.rootPath ?? TEST_MANAGED_HOME_ROOT
const directoryPath = patch.directoryPath ?? join(rootPath, patch.name)
return {
id: patch.id ?? `${patch.sourceKind}-${patch.name}`,
name: patch.name,
description: patch.description ?? null,
providers: patch.providers ?? ['agent-skills'],
sourceKind: patch.sourceKind,
sourceLabel: patch.sourceLabel ?? patch.sourceKind,
rootPath,
directoryPath,
realDirectoryPath: patch.realDirectoryPath ?? directoryPath,
directoryIsSymlink: patch.directoryIsSymlink ?? false,
skillFilePath: patch.skillFilePath ?? join(directoryPath, 'SKILL.md'),
realSkillFilePath: patch.realSkillFilePath ?? join(directoryPath, 'SKILL.md'),
skillFileIsSymlink: patch.skillFileIsSymlink ?? false,
installed: patch.installed ?? true,
fileCount: patch.fileCount ?? 1,
updatedAt: patch.updatedAt ?? 1
}
}
function discovery(skills: DiscoveredSkill[]): SkillDiscoveryResult {
return { skills, sources: [], scannedAt: 1 }
}
function lockfile(skillFolderHash: string): string {
return JSON.stringify({
version: 3,
skills: {
[ORCHESTRATION_SKILL_NAME]: {
source: 'stablyai/orca',
sourceType: 'github',
sourceUrl: 'https://github.com/stablyai/orca.git',
skillPath: `skills/${ORCHESTRATION_SKILL_NAME}/SKILL.md`,
skillFolderHash
}
}
})
}
describe('ManagedSkillUpdateCoordinator', () => {
it('dedupes concurrent missing-install checks before discovery', async () => {
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
const [first, second] = await Promise.all([
coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
}),
coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
])
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
expect(first.status).toBe('fallback')
expect(second.status).toBe('fallback')
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
expect(first.status === 'fallback' ? first.manualCommand?.kind : null).toBe('install')
})
it('cooldowns repeated missing-install checks after the first fallback', async () => {
let now = 100
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills
})
const first = await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
now = 200
const second = await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
})
it('lets explicit re-checks bypass fallback cooldown', async () => {
let now = 100
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills
})
await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
now = 200
const forced = await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' },
force: true
})
expect(forced.status === 'fallback' ? forced.reason : null).toBe('missing-install')
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
})
it('cooldowns repeated remote-runtime target fallbacks', async () => {
let now = 100
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now
})
const request = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration' as const,
remoteRuntime: true
} as const
const first = await coordinator.ensureManagedReady(request)
now = 200
const second = await coordinator.ensureManagedReady(request)
expect(first.status === 'fallback' ? first.reason : null).toBe('remote-runtime')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
})
it('cooldowns repeated target-required fallbacks', async () => {
let now = 100
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now
})
const request = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration' as const
} as const
const first = await coordinator.ensureManagedReady(request)
now = 200
const second = await coordinator.ensureManagedReady(request)
expect(first.status === 'fallback' ? first.reason : null).toBe('target-required')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
})
it('falls back for project and symlinked global installs', async () => {
const projectCoordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const symlinkCoordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
directoryIsSymlink: true
})
])
})
const project = await projectCoordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
const symlink = await symlinkCoordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(project.status === 'fallback' ? project.reason : null).toBe('project-install')
expect(symlink.status === 'fallback' ? symlink.reason : null).toBe('symlinked-global-install')
})
it('ignores project installs from unrelated repo roots', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/unrelated/.agents/skills',
directoryPath: '/workspace/unrelated/.agents/skills/orchestration'
})
])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(result.status).toBe('ready')
})
it('falls back when the current project root has a project-scoped install', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('ambiguous-install')
})
it('does not emit a global install command when project scope exists but target is unknown', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('project-install')
expect(result.status === 'fallback' ? result.manualCommand : undefined).toBeUndefined()
})
it('passes the project root into host discovery', async () => {
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(discoverHostSkills).toHaveBeenCalledWith('/workspace/current')
})
it('treats installed global skills as ready when the expected hash manifest is missing', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status).toBe('ready')
})
it('does not treat Codex or Claude home skills as managed global installs', async () => {
const readTextFile = vi.fn(async () => lockfile('hash-1'))
const coordinator = new ManagedSkillUpdateCoordinator({
homeDir: () => '/home/alice',
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
providers: ['codex'],
rootPath: '/home/alice/.codex/skills',
directoryPath: '/home/alice/.codex/skills/orchestration'
})
]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('install')
expect(readTextFile).not.toHaveBeenCalled()
})
it('returns ready when a verified manifest and lock hash already match', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () => lockfile('hash-1')
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status).toBe('ready')
})
it('serves verified ready checks from the success cache without rediscovery', async () => {
const discoverHostSkills = vi.fn(async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })])
)
const readTextFile = vi.fn(async () => lockfile('hash-1'))
it('runs one verified update and returns updated when the post-update lock hash changes', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const readTextFile = vi
.fn()
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: '1.0.0',
discoverHostSkills,
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
readTextFile,
updateRunner
})
const request = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration' as const,
discoveryTarget: { runtime: 'host' as const, projectRootPath: '/workspace/current' }
} as const
const first = await coordinator.ensureManagedReady(request)
const second = await coordinator.ensureManagedReady(request)
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(first.status).toBe('ready')
expect(result.status).toBe('updated')
expect(updateRunner).toHaveBeenCalledWith(ORCHESTRATION_SKILL_NAME)
expect(updateRunner).toHaveBeenCalledTimes(1)
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
expect(readTextFile).toHaveBeenCalledTimes(2)
})
it('returns ready when the verified update succeeds without changing the lock hash', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () => homeDiscovery(),
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'same-hash'),
updateRunner: async () => ({ status: 'success' })
})
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(result.status).toBe('ready')
})
it('serves a successful post-update hash from cache without repeated subprocess or metadata work', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const readTextFile = vi
.fn()
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
.mockResolvedValue(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: '1.0.0',
discoverHostSkills,
readTextFile,
updateRunner
})
const first = await coordinator.ensureManagedReady(orchestrationRequest)
const second = await coordinator.ensureManagedReady(orchestrationRequest)
expect(first.status).toBe('updated')
expect(second.status).toBe('ready')
expect(updateRunner).toHaveBeenCalledTimes(1)
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
expect(readTextFile).toHaveBeenCalledTimes(2)
})
it('keeps repeated sequential ready checks on the pre-discovery fast path', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const readTextFile = vi
.fn()
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: '1.0.0',
discoverHostSkills,
readTextFile,
updateRunner
})
const first = await coordinator.ensureManagedReady(orchestrationRequest)
const results = await Promise.all(
Array.from({ length: 50 }, () => coordinator.ensureManagedReady(orchestrationRequest))
)
expect(first.status).toBe('updated')
expect(results.every((result) => result.status === 'ready')).toBe(true)
expect(updateRunner).toHaveBeenCalledTimes(1)
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
expect(readTextFile).toHaveBeenCalledTimes(2)
})
it('reuses a verified global lock hash across workspaces after discovery safety checks', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const readTextFile = vi
.fn()
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
.mockResolvedValue(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: '1.0.0',
discoverHostSkills,
readTextFile,
updateRunner
})
const first = await coordinator.ensureManagedReady(orchestrationRequest)
const second = await coordinator.ensureManagedReady({
...orchestrationRequest,
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
})
expect(first.status).toBe('updated')
expect(second.status).toBe('ready')
expect(updateRunner).toHaveBeenCalledTimes(1)
expect(discoverHostSkills).toHaveBeenCalledTimes(3)
expect(readTextFile).toHaveBeenCalledTimes(3)
})
it('dedupes concurrent automatic update attempts for the same trigger', async () => {
let releaseRunner: (() => void) | undefined
const updateRunner = vi.fn(
() =>
new Promise<{ status: 'success' }>((resolve) => {
releaseRunner = () => resolve({ status: 'success' })
})
)
const readTextFile = vi
.fn()
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () => homeDiscovery(),
readTextFile,
updateRunner
})
const requests = Array.from({ length: 50 }, () =>
coordinator.ensureManagedReady(orchestrationRequest)
)
await vi.waitFor(() => expect(updateRunner).toHaveBeenCalledTimes(1))
if (!releaseRunner) {
throw new Error('runner was not called')
}
releaseRunner()
const results = await Promise.all(requests)
expect(results.every((result) => result.status === 'updated')).toBe(true)
expect(updateRunner).toHaveBeenCalledTimes(1)
})
it('returns update-failed with the manual update command and then cooldowns automatic retries', async () => {
let now = 100
const updateRunner = vi.fn(async () => ({ status: 'failure' as const, exitCode: 1 }))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const readTextFile = vi.fn(async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills,
readTextFile,
updateRunner
})
const first = await coordinator.ensureManagedReady(orchestrationRequest)
now = 200
const second = await coordinator.ensureManagedReady(orchestrationRequest)
const repeated = await Promise.all(
Array.from({ length: 50 }, () => coordinator.ensureManagedReady(orchestrationRequest))
)
expect(first.status === 'fallback' ? first.reason : null).toBe('update-failed')
expect(first.status === 'fallback' ? first.manualCommand?.command : null).toBe(
'npx --yes skills update orchestration --global --yes'
)
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
expect(repeated.every((result) => result.status === 'fallback')).toBe(true)
expect(
repeated.every((result) =>
result.status === 'fallback' ? result.reason === 'cooldown' : false
)
).toBe(true)
expect(updateRunner).toHaveBeenCalledTimes(1)
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
expect(readTextFile).toHaveBeenCalledTimes(1)
})
it('keeps concurrent checks for different project roots independent', async () => {
const discoverHostSkills = vi.fn(async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
)
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
const [currentProject, otherProject] = await Promise.all([
coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
}),
coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
})
])
expect(currentProject.status === 'fallback' ? currentProject.reason : null).toBe(
'ambiguous-install'
)
expect(otherProject.status).toBe('ready')
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
})
it('keys project fallback UI identity by project root', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const first = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
const second = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
})
expect(first.status === 'fallback' ? first.uiKey : null).toContain('/workspace/current')
expect(second.status === 'fallback' ? second.uiKey : null).toContain('/workspace/other')
expect(
first.status === 'fallback' && second.status === 'fallback' ? first.uiKey : null
).not.toBe(second.status === 'fallback' ? second.uiKey : null)
})
it('reads the current global lockfile version field', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () =>
JSON.stringify({
version: 3,
skills: {
[ORCHESTRATION_SKILL_NAME]: {
source: 'stablyai/orca',
sourceType: 'github',
sourceUrl: 'https://github.com/stablyai/orca.git',
skillPath: `skills/${ORCHESTRATION_SKILL_NAME}/SKILL.md`,
skillFolderHash: 'hash-1'
}
}
})
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status).toBe('ready')
})
it('returns a malformed fallback for non-object lockfile JSON', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () => 'null'
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('lockfile-malformed')
})
it('falls back without cooldown when automatic updates are off', async () => {
it('lets explicit re-checks bypass automatic update cooldown', async () => {
let now = 100
const readTextFile = vi.fn(async () => lockfile('old-hash'))
const updateRunner = vi.fn(async () => ({ status: 'failure' as const, exitCode: 1 }))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills: async () => homeDiscovery(),
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
updateRunner
})
await coordinator.ensureManagedReady(orchestrationRequest)
now = 200
const forced = await coordinator.ensureManagedReady({ ...orchestrationRequest, force: true })
expect(forced.status === 'fallback' ? forced.reason : null).toBe('update-failed')
expect(updateRunner).toHaveBeenCalledTimes(2)
})
it('returns update-timeout distinctly with the manual update command', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () => homeDiscovery(),
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
updateRunner: async () => ({ status: 'timeout' })
})
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(result.status === 'fallback' ? result.reason : null).toBe('update-timeout')
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('update')
})
it('falls back to manual update every time when automatic updates are disabled', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const readTextFile = vi.fn(async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
backgroundUpdatesEnabled: () => false,
cooldownMs: 1_000,
now: () => now,
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-2',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
discoverHostSkills,
readTextFile,
updateRunner
})
const first = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
now = 200
const second = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
const first = await coordinator.ensureManagedReady(orchestrationRequest)
const second = await coordinator.ensureManagedReady(orchestrationRequest)
const repeated = await Promise.all(
Array.from({ length: 50 }, () => coordinator.ensureManagedReady(orchestrationRequest))
)
expect(first.status === 'fallback' ? first.reason : null).toBe('background-update-disabled')
expect(first.status === 'fallback' ? first.manualCommand?.kind : null).toBe('update')
expect(second.status === 'fallback' ? second.reason : null).toBe('background-update-disabled')
expect(second.status === 'fallback' ? second.manualCommand?.kind : null).toBe('update')
expect(
repeated.every((result) =>
result.status === 'fallback' ? result.reason === 'background-update-disabled' : false
)
).toBe(true)
expect(updateRunner).not.toHaveBeenCalled()
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
expect(readTextFile).toHaveBeenCalledTimes(1)
})
it('lets explicit re-checks bypass the disabled automatic update fast path', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const discoverHostSkills = vi.fn(async () => homeDiscovery())
const readTextFile = vi.fn(async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
backgroundUpdatesEnabled: () => false,
discoverHostSkills,
readTextFile,
updateRunner
})
await coordinator.ensureManagedReady(orchestrationRequest)
const forced = await coordinator.ensureManagedReady({ ...orchestrationRequest, force: true })
expect(forced.status === 'fallback' ? forced.reason : null).toBe('background-update-disabled')
expect(updateRunner).not.toHaveBeenCalled()
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
expect(readTextFile).toHaveBeenCalledTimes(2)
})
it('shows manual update after automatic update cooldown when the setting is turned off', async () => {
let now = 100
let backgroundUpdatesEnabled = true
const readTextFile = vi.fn(async () => lockfile('old-hash'))
it('returns update-failed when post-update verification cannot prove the lock entry', async () => {
const readTextFile = vi
.fn()
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
.mockResolvedValueOnce(JSON.stringify({ version: 3, skills: {} }))
const coordinator = new ManagedSkillUpdateCoordinator({
backgroundUpdatesEnabled: () => backgroundUpdatesEnabled,
cooldownMs: 1_000,
now: () => now,
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-2',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
discoverHostSkills: async () => homeDiscovery(),
readTextFile,
updateRunner: async () => ({ status: 'success' })
})
const automaticAttempt = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
backgroundUpdatesEnabled = false
now = 200
const manualAttempt = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(automaticAttempt.status === 'fallback' ? automaticAttempt.reason : null).toBe(
'unsupported-cli-contract'
)
expect(manualAttempt.status === 'fallback' ? manualAttempt.reason : null).toBe(
'background-update-disabled'
)
expect(manualAttempt.status === 'fallback' ? manualAttempt.manualCommand?.kind : null).toBe(
'update'
)
expect(readTextFile).toHaveBeenCalledTimes(2)
expect(result.status === 'fallback' ? result.reason : null).toBe('update-failed')
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('update')
})
it('does not show the manual-update modal while automatic updates are enabled', async () => {
it('does not run the updater for missing or unmanaged lockfile cases', async () => {
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-2',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () => lockfile('old-hash')
discoverHostSkills: async () => homeDiscovery(),
readTextFile: async () => JSON.stringify({ version: 3, skills: {} }),
updateRunner
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
const result = await coordinator.ensureManagedReady(orchestrationRequest)
expect(result.status === 'fallback' ? result.reason : null).toBe('unsupported-cli-contract')
expect(result.status === 'fallback' ? result.reason : null).toBe('lock-entry-missing')
expect(result.status === 'fallback' ? result.manualCommand : undefined).toBeUndefined()
expect(updateRunner).not.toHaveBeenCalled()
})
})
+105 -116
View File
@@ -3,29 +3,32 @@ import { homedir } from 'node:os'
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillName,
SkillDiscoveryResult
} from '../../shared/skills'
import type { Store } from '../persistence'
import { discoverSkills } from './discovery'
import {
buildManagedSkillFallback,
buildManagedSkillReadyResult
buildManagedSkillGlobalUpdateFallback,
buildManagedSkillReadyResult,
buildManagedSkillUpdatedResult
} from './managed-skill-ensure-result'
import { selectSingleGlobalManagedSkillCandidate } from './managed-skill-global-candidate'
import { readManagedSkillLockEntry } from './managed-skill-lockfile'
import {
makeManagedSkillPreDiscoveryCacheKey,
makeManagedSkillSuccessCacheKey,
normalizeManagedSkillKeyPart,
makeManagedSkillTargetFallbackCacheKey,
shouldCooldownFallback
} from './managed-skill-update-cache-key'
import {
buildManagedSkillManualCommand,
EXPECTED_MANAGED_SKILL_REVISIONS,
isImmutableSourceRef,
isManagedAgentSkillName,
type ExpectedManagedSkillRevision
isManagedAgentSkillName
} from './managed-skill-update-contract'
import { verifyManagedSkillPostUpdate } from './managed-skill-post-update-verification'
import {
createManagedSkillUpdateRunner,
type ManagedSkillUpdateRunner
} from './managed-skill-update-runner'
import { resolveManagedSkillTarget, type ResolvedManagedSkillTarget } from './managed-skill-target'
type ManagedSkillCoordinatorDeps = {
@@ -36,30 +39,14 @@ type ManagedSkillCoordinatorDeps = {
homeDir?: () => string
now?: () => number
readTextFile?: (path: string) => Promise<string>
expectedRevisions?: Partial<Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>>
updateRunner?: ManagedSkillUpdateRunner
}
const DEFAULT_COOLDOWN_MS = 60_000
const defaultUpdateAbortController = new AbortController()
const coordinatorByStore = new WeakMap<Store, ManagedSkillUpdateCoordinator>()
export function getManagedSkillUpdateCoordinator(store: Store): ManagedSkillUpdateCoordinator {
const existing = coordinatorByStore.get(store)
if (existing) {
return existing
}
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: process.env.npm_package_version,
backgroundUpdatesEnabled: () =>
store.getSettings().managedAgentSkillBackgroundUpdatesEnabled !== false,
discoverHostSkills: (projectRootPath) =>
discoverSkills({
repos: store.getRepos(),
...(projectRootPath ? { cwd: projectRootPath } : {})
})
})
coordinatorByStore.set(store, coordinator)
return coordinator
export function abortManagedSkillUpdateProcesses(): void {
defaultUpdateAbortController.abort()
}
export class ManagedSkillUpdateCoordinator {
@@ -72,14 +59,17 @@ export class ManagedSkillUpdateCoordinator {
private readonly homeDir: () => string
private readonly now: () => number
private readonly readTextFile: (path: string) => Promise<string>
private readonly expectedRevisions: Partial<
Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>
>
private readonly updateRunner: ManagedSkillUpdateRunner
private readonly inFlightByPreDiscoveryKey = new Map<
string,
Promise<ManagedAgentSkillEnsureResult>
>()
private readonly disabledFallbackByPreDiscoveryKey = new Map<
string,
{ result: ManagedAgentSkillEnsureResult; until: number }
>()
private readonly cooldownUntilByKey = new Map<string, number>()
private readonly readyUntilByPreDiscoveryKey = new Map<string, number>()
private readonly successCache = new Set<string>()
constructor(deps: ManagedSkillCoordinatorDeps = {}) {
@@ -89,8 +79,10 @@ export class ManagedSkillUpdateCoordinator {
this.discoverHostSkills = deps.discoverHostSkills ?? (() => discoverSkills({ repos: [] }))
this.homeDir = deps.homeDir ?? homedir
this.now = deps.now ?? Date.now
this.readTextFile = deps.readTextFile ?? readFileUtf8
this.expectedRevisions = deps.expectedRevisions ?? EXPECTED_MANAGED_SKILL_REVISIONS
this.readTextFile = deps.readTextFile ?? ((path) => readFile(path, 'utf8'))
this.updateRunner =
deps.updateRunner ??
createManagedSkillUpdateRunner({ signal: defaultUpdateAbortController.signal })
}
ensureManagedReady(
@@ -109,16 +101,13 @@ export class ManagedSkillUpdateCoordinator {
const target = resolveManagedSkillTarget(request)
if (!target.ok) {
const targetFallbackKey = [
this.appVersion,
target.runtime,
target.distro ?? '',
'target-fallback',
normalizeManagedSkillKeyPart(request.context),
normalizeManagedSkillKeyPart(request.discoveryTarget?.projectRootPath),
request.skillName,
target.reason
].join(':')
const targetFallbackKey = makeManagedSkillTargetFallbackCacheKey({
appVersion: this.appVersion,
distro: target.distro,
reason: target.reason,
request,
runtime: target.runtime
})
const cooldownUntil = this.cooldownUntilByKey.get(targetFallbackKey)
if (!request.force && cooldownUntil && cooldownUntil > this.now()) {
return Promise.resolve(
@@ -143,28 +132,13 @@ export class ManagedSkillUpdateCoordinator {
)
}
const expected = this.expectedRevisions[request.skillName]
const successLookupKey = makeManagedSkillSuccessCacheKey({
const preDiscoveryKey = makeManagedSkillPreDiscoveryCacheKey({
appVersion: this.appVersion,
backgroundUpdatesEnabled: this.backgroundUpdatesEnabled(),
distro: target.distro,
request,
expected
runtime: target.runtime
})
if (target.runtime === 'host' && this.successCache.has(successLookupKey)) {
return Promise.resolve(buildManagedSkillReadyResult(request))
}
const preDiscoveryKey = [
this.appVersion,
target.runtime,
target.distro ?? '',
'pre-discovery',
this.backgroundUpdatesEnabled() ? 'background-updates-on' : 'background-updates-off',
normalizeManagedSkillKeyPart(request.context),
normalizeManagedSkillKeyPart(request.discoveryTarget?.projectRootPath),
request.skillName,
expected?.expectedHash ?? 'missing-expected-hash',
expected?.expectedSourceRef ?? 'missing-source-ref'
].join(':')
const existing = this.inFlightByPreDiscoveryKey.get(preDiscoveryKey)
if (existing) {
return existing
@@ -181,12 +155,29 @@ export class ManagedSkillUpdateCoordinator {
})
)
}
const readyUntil = this.readyUntilByPreDiscoveryKey.get(preDiscoveryKey)
if (!request.force && readyUntil && readyUntil > this.now()) {
return Promise.resolve(buildManagedSkillReadyResult(request))
}
const disabledFallback = this.disabledFallbackByPreDiscoveryKey.get(preDiscoveryKey)
if (!request.force && disabledFallback && disabledFallback.until > this.now()) {
return Promise.resolve(disabledFallback.result)
}
const promise = this.evaluate(request, target, expected)
const promise = this.evaluate(request, target)
.then((result) => {
if (result.status === 'ready' || result.status === 'updated') {
this.readyUntilByPreDiscoveryKey.set(preDiscoveryKey, this.now() + this.cooldownMs)
}
if (shouldCooldownFallback(result)) {
this.cooldownUntilByKey.set(preDiscoveryKey, this.now() + this.cooldownMs)
}
if (isBackgroundUpdateDisabledFallback(result)) {
this.disabledFallbackByPreDiscoveryKey.set(preDiscoveryKey, {
result,
until: this.now() + this.cooldownMs
})
}
return result
})
.finally(() => {
@@ -200,8 +191,7 @@ export class ManagedSkillUpdateCoordinator {
private async evaluate(
request: ManagedAgentSkillEnsureRequest,
target: Extract<ResolvedManagedSkillTarget, { ok: true }>,
expected: ExpectedManagedSkillRevision | undefined
target: Extract<ResolvedManagedSkillTarget, { ok: true }>
): Promise<ManagedAgentSkillEnsureResult> {
if (target.runtime === 'wsl') {
return buildManagedSkillFallback({
@@ -229,45 +219,6 @@ export class ManagedSkillUpdateCoordinator {
manualCommand: globalCandidateDecision.fallback.manualCommand
})
}
if (!expected) {
// Why: without a pinned/hash manifest, V1 cannot prove staleness or offer a
// real update path, so installed global managed skills should not prompt.
const cacheKey = makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
expected
})
this.successCache.add(cacheKey)
return buildManagedSkillReadyResult(request)
}
if (!isImmutableSourceRef(expected.expectedSourceRef)) {
return buildManagedSkillFallback({
request,
reason: 'unsupported-cli-contract',
runtime: 'host',
scope: 'global'
})
}
const cacheKey = makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
expected
})
if (this.successCache.has(cacheKey)) {
return buildManagedSkillReadyResult(request)
}
const backgroundUpdatesEnabled = this.backgroundUpdatesEnabled()
const cooldownUntil = this.cooldownUntilByKey.get(cacheKey)
if (backgroundUpdatesEnabled && !request.force && cooldownUntil && cooldownUntil > this.now()) {
return buildManagedSkillFallback({
request,
reason: 'cooldown',
runtime: 'host',
scope: 'global'
})
}
const lockEntryResult = await readManagedSkillLockEntry({
homeDir: this.homeDir(),
@@ -282,10 +233,16 @@ export class ManagedSkillUpdateCoordinator {
scope: 'global'
})
}
if (lockEntryResult.entry.skillFolderHash === expected.expectedHash) {
this.successCache.add(cacheKey)
const currentLockHash = lockEntryResult.entry.skillFolderHash
const cacheKey = makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
currentLockHash
})
if (this.successCache.has(cacheKey)) {
return buildManagedSkillReadyResult(request)
}
const backgroundUpdatesEnabled = this.backgroundUpdatesEnabled()
if (!backgroundUpdatesEnabled) {
return buildManagedSkillFallback({
request,
@@ -296,18 +253,50 @@ export class ManagedSkillUpdateCoordinator {
})
}
// Why: this tree does not ship the verified CLI hash/ref update contract
// yet, so stale installs must fall back instead of running blind `npx`.
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
return buildManagedSkillFallback({
request,
reason: 'unsupported-cli-contract',
runtime: 'host',
scope: 'global'
const cooldownUntil = this.cooldownUntilByKey.get(cacheKey)
if (!request.force && cooldownUntil && cooldownUntil > this.now()) {
return buildManagedSkillFallback({
request,
reason: 'cooldown',
runtime: 'host',
scope: 'global'
})
}
const updateResult = await this.updateRunner(request.skillName)
if (updateResult.status !== 'success') {
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
return buildManagedSkillGlobalUpdateFallback(
request,
updateResult.status === 'timeout' ? 'update-timeout' : 'update-failed'
)
}
const postUpdate = await verifyManagedSkillPostUpdate({
discoverHostSkills: this.discoverHostSkills,
homeDir: this.homeDir(),
readTextFile: this.readTextFile,
request
})
if (!postUpdate.ok) {
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
return buildManagedSkillGlobalUpdateFallback(request, 'update-failed')
}
const postLockHash = postUpdate.lockHash
this.successCache.add(
makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
currentLockHash: postLockHash
})
)
if (postLockHash === currentLockHash) {
return buildManagedSkillReadyResult(request)
}
return buildManagedSkillUpdatedResult(request)
}
}
function readFileUtf8(path: string): Promise<string> {
return readFile(path, 'utf8')
function isBackgroundUpdateDisabledFallback(result: ManagedAgentSkillEnsureResult): boolean {
return result.status === 'fallback' && result.reason === 'background-update-disabled'
}
@@ -7,31 +7,51 @@ export function useEmulatorPaneControls(worktreeId: string) {
const sendTap = useCallback(
async (x: number, y: number) => {
await callRuntimeRpc({ kind: 'local' }, 'emulator.tap', { x, y, worktree: worktreeId })
await callRuntimeRpc(
{ kind: 'local' },
'emulator.tap',
{ x, y, worktree: worktreeId },
{ suppressFeatureInteraction: true }
)
},
[worktreeId]
)
const sendButton = useCallback(
async (name: string) => {
await callRuntimeRpc({ kind: 'local' }, 'emulator.button', { name, worktree: worktreeId })
await callRuntimeRpc(
{ kind: 'local' },
'emulator.button',
{ name, worktree: worktreeId },
{ suppressFeatureInteraction: true }
)
},
[worktreeId]
)
const sendGesture = useCallback(
async (points: EmulatorGesturePoint[]) => {
await callRuntimeRpc({ kind: 'local' }, 'emulator.gesture', { points, worktree: worktreeId })
await callRuntimeRpc(
{ kind: 'local' },
'emulator.gesture',
{ points, worktree: worktreeId },
{ suppressFeatureInteraction: true }
)
},
[worktreeId]
)
const sendRotate = useCallback(async () => {
const orientation = nextRotateOrientationRef.current
await callRuntimeRpc({ kind: 'local' }, 'emulator.rotate', {
orientation,
worktree: worktreeId
})
await callRuntimeRpc(
{ kind: 'local' },
'emulator.rotate',
{
orientation,
worktree: worktreeId
},
{ suppressFeatureInteraction: true }
)
nextRotateOrientationRef.current =
orientation === 'landscape_left' ? 'portrait' : 'landscape_left'
}, [worktreeId])
@@ -63,7 +63,8 @@ export function useEmulatorPaneSession({
const list = (await callRuntimeRpc(
{ kind: 'local' },
'emulator.listSimulators',
{}
{},
{ suppressFeatureInteraction: true }
)) as SimulatorDeviceRow[]
const next = markSimulatorDeviceBooted(list, bootedTarget)
if (!mountedRef.current) {
@@ -180,11 +181,16 @@ export function useEmulatorPaneSession({
setStreamKey(null)
liveTargetRef.current = null
}
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.attach', {
device: target,
worktree: worktreeId,
focus: false
})) as { attached?: boolean; info?: EmulatorPaneSession['info'] }
const res = (await callRuntimeRpc(
{ kind: 'local' },
'emulator.attach',
{
device: target,
worktree: worktreeId,
focus: false
},
{ suppressFeatureInteraction: true }
)) as { attached?: boolean; info?: EmulatorPaneSession['info'] }
if (!mountedRef.current) {
// Why: attach can finish after the tab closes, after the earlier
// unmount shutdown already no-op'd because the session was not registered yet.
@@ -34,10 +34,15 @@ export function useEmulatorPaneShutdown({
useAppStore.getState().setTabLabel(tabId, 'Shutting down…')
}
try {
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
...(deviceTarget ? { device: deviceTarget } : {}),
worktree: worktreeId
})) as { deviceUdid?: string }
const res = (await callRuntimeRpc(
{ kind: 'local' },
'emulator.shutdown',
{
...(deviceTarget ? { device: deviceTarget } : {}),
worktree: worktreeId
},
{ suppressFeatureInteraction: true }
)) as { deviceUdid?: string }
const shutdownTarget = res?.deviceUdid || deviceTarget
window.dispatchEvent(
new CustomEvent(EMULATOR_LOCAL_SHUTDOWN_EVENT, {
@@ -103,7 +103,8 @@ export function MobileEmulatorSettingsPane({
const result = (await callRuntimeRpc(
{ kind: 'local' },
'emulator.availability',
{}
{},
{ suppressFeatureInteraction: true }
)) as EmulatorAvailability
setAvailability(result)
} catch (error) {
@@ -65,6 +65,12 @@ describe('ManagedAgentSkillSetupDialogHost copy', () => {
)
})
it('supports review copy for fallback cases without a runnable command', () => {
expect(getManagedSkillContextCopy('agent-orchestration', 'Review')).toBe(
'Orca Orchestration was used. Review the orchestration skill to enable agents to coordinate reliably.'
)
})
it('splits workspace-aware context copy so the workspace can expose its full path', () => {
expect(getManagedSkillContextWorkspaceCopy('agent-computer-use', 'Install')).toEqual({
beforeWorkspace: 'Computer Use was used in ',
@@ -128,7 +134,7 @@ describe('ManagedAgentSkillSetupDialogHost queue state', () => {
})
})
it('does not replace the active modal with a non-actionable re-check fallback', () => {
it('does not replace the active modal with a cooldown re-check fallback', () => {
const active = fallback({
context: 'agent-orchestration',
uiKey: 'host::orchestration:agent-orchestration',
@@ -141,7 +147,7 @@ describe('ManagedAgentSkillSetupDialogHost queue state', () => {
})
const deadEndFallback = fallback({
context: 'agent-orchestration',
reason: 'lockfile-malformed',
reason: 'cooldown',
uiKey: 'host::orchestration:agent-orchestration'
})
@@ -152,4 +158,29 @@ describe('ManagedAgentSkillSetupDialogHost queue state', () => {
queue: []
})
})
it('keeps manual-review safety fallbacks visible after re-check', () => {
const active = fallback({
context: 'agent-orchestration',
uiKey: 'host::orchestration:agent-orchestration',
manualCommand: {
kind: 'update',
command: 'npx skills update orchestration',
runtime: 'host',
scope: 'global'
}
})
const reviewFallback = fallback({
context: 'agent-orchestration',
reason: 'lockfile-malformed',
uiKey: 'host::orchestration:agent-orchestration'
})
expect(
replaceActiveAfterManagedAgentSkillRecheck({ active, queue: [] }, reviewFallback)
).toEqual({
active: reviewFallback,
queue: []
})
})
})
@@ -87,9 +87,11 @@ export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null {
}, [enqueueFallback])
const contextActionLabel =
active?.manualCommand?.kind === 'update'
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.update', 'Update')
: translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.install', 'Install')
active?.manualCommand?.kind === 'install'
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.install', 'Install')
: active?.manualCommand?.kind === 'update'
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.update', 'Update')
: translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.review', 'Review')
const contextCopy = active ? getManagedSkillContextCopy(active.context, contextActionLabel) : ''
const installedCommand = useMemo(
() => (active ? buildAgentFeatureSkillUpdateCommand(active.skillName) : ''),
@@ -35,7 +35,7 @@ export function getManagedSkillContextCopy(
case 'agent-orca-cli':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContext',
'The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.',
'The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.',
{ actionLabel }
)
}
@@ -86,7 +86,7 @@ export function getManagedSkillContextWorkspaceCopy(
return {
beforeWorkspace: translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContextBeforeWorkspace',
'The Orca CLI skill was used in '
'The Orca CLI skill is needed in '
),
afterWorkspace: translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContextAfterWorkspace',
@@ -95,7 +95,9 @@ export async function discoverInstalledAgentSkills(
const inFlightDiscovery = pendingDiscoveryByTarget.get(key)
if (inFlightDiscovery) {
if (!force) {
const inFlightSatisfiesForcedRefresh =
pendingDiscoverySatisfiesForcedRefreshByTarget.get(key) === true
if (!force || inFlightSatisfiesForcedRefresh) {
return inFlightDiscovery
}
try {
@@ -267,6 +267,29 @@ describe('discoverInstalledAgentSkills', () => {
await expect(forcedRefresh).resolves.toBe(freshResult)
})
it('reuses an in-flight forced refresh for the same target', async () => {
const forcedScan = deferred<SkillDiscoveryResult>()
const discover = vi.fn<() => Promise<SkillDiscoveryResult>>()
discover.mockReturnValueOnce(forcedScan.promise)
vi.stubGlobal('window', {
api: { skills: { discover } }
})
const firstForcedRefresh =
_installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true)
const secondForcedRefresh =
_installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true)
expect(discover).toHaveBeenCalledTimes(1)
const freshResult = discoveryResult([skill({ name: 'orca-cli' })])
forcedScan.resolve(freshResult)
await expect(firstForcedRefresh).resolves.toBe(freshResult)
await expect(secondForcedRefresh).resolves.toBe(freshResult)
expect(discover).toHaveBeenCalledTimes(1)
})
it('caches host and WSL discovery results separately', async () => {
const hostResult = discoveryResult([skill({ name: 'host-skill' })])
const wslResult = discoveryResult([skill({ name: 'wsl-skill' })])
+3 -2
View File
@@ -3312,20 +3312,21 @@
"terminalAria": "Agent skill setup terminal",
"update": "Update",
"install": "Install",
"review": "Review",
"recheck": "Re-check",
"dontShowAgain": "Don't show again",
"notNow": "Not now",
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"linearWorktreeContextBeforeWorkspace": "A worktree was started from a Linear task in ",
"linearWorktreeContextAfterWorkspace": ". {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
"agentOrchestrationContextBeforeWorkspace": "Orca Orchestration was used in ",
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"targetRequired": "Orca couldn't tell which runtime should use this skill.",
"unsupportedSkill": "This isn't an Orca-managed skill.",
+3 -2
View File
@@ -3312,13 +3312,14 @@
"terminalAria": "Terminal de configuración de habilidad de agente",
"update": "Actualizar",
"install": "Instalar",
"review": "Revisar",
"recheck": "Volver a comprobar",
"dontShowAgain": "No volver a mostrar",
"notNow": "Ahora no",
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"targetRequired": "Orca no pudo resolver el entorno de ejecución que debe leer esta habilidad.",
"unsupportedSkill": "Esta no es una habilidad de agente administrada por Orca.",
"repairRequiredRuntime": "El entorno de ejecución seleccionado necesita reparación antes de que Orca pueda inspeccionar sus habilidades.",
@@ -3346,7 +3347,7 @@
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
}
},
+3 -2
View File
@@ -3312,13 +3312,14 @@
"terminalAria": "エージェントスキル設定ターミナル",
"update": "更新",
"install": "インストール",
"review": "確認",
"recheck": "再チェック",
"dontShowAgain": "今後表示しない",
"notNow": "今はしない",
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"targetRequired": "Orca はこのスキルを読み取るランタイムを解決できませんでした。",
"unsupportedSkill": "これは Orca 管理のエージェントスキルではありません。",
"repairRequiredRuntime": "Orca がスキルを検査する前に、選択したランタイムの修復が必要です。",
@@ -3346,7 +3347,7 @@
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
}
},
+3 -2
View File
@@ -3312,13 +3312,14 @@
"terminalAria": "에이전트 스킬 설정 터미널",
"update": "업데이트",
"install": "설치",
"review": "검토",
"recheck": "다시 확인",
"dontShowAgain": "다시 표시하지 않기",
"notNow": "나중에",
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"targetRequired": "Orca가 이 스킬을 읽어야 하는 런타임을 확인할 수 없습니다.",
"unsupportedSkill": "Orca가 관리하는 에이전트 스킬이 아닙니다.",
"repairRequiredRuntime": "Orca가 스킬을 검사하기 전에 선택한 런타임을 복구해야 합니다.",
@@ -3346,7 +3347,7 @@
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
}
},
+3 -2
View File
@@ -3312,13 +3312,14 @@
"terminalAria": "代理技能设置终端",
"update": "更新",
"install": "安装",
"review": "审查",
"recheck": "重新检查",
"dontShowAgain": "不再显示",
"notNow": "稍后",
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
"targetRequired": "Orca 无法解析应读取此技能的运行时。",
"unsupportedSkill": "这不是 Orca 托管的代理技能。",
"repairRequiredRuntime": "所选运行时需要修复,Orca 才能检查其技能。",
@@ -3346,7 +3347,7 @@
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
}
},
@@ -85,10 +85,17 @@ describe('openMobileEmulatorTab', () => {
await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBe('sim-1')
expect(calls).toEqual(['ensure', 'attach'])
expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'emulator.attach', {
worktree: 'wt-1',
focus: false
})
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'local' },
'emulator.attach',
{
worktree: 'wt-1',
focus: false
},
{
suppressFeatureInteraction: true
}
)
expect(ensureSimulatorTab).toHaveBeenCalledWith('wt-1', {
placement: 'rightSplit',
targetGroupId: 'group-1',
@@ -163,10 +170,15 @@ describe('openMobileEmulatorTab', () => {
await expect(launched).resolves.toBe('sim-1')
expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'emulator.shutdown', {
worktree: 'wt-1',
managedOnly: true
})
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'local' },
'emulator.shutdown',
{
worktree: 'wt-1',
managedOnly: true
},
{ suppressFeatureInteraction: true }
)
expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull()
})
@@ -92,7 +92,8 @@ export async function openMobileEmulatorTab(
{
worktree: worktreeId,
focus: false
}
},
{ suppressFeatureInteraction: true }
)
if (!result.attached || !result.info) {
throw new Error('Could not start the emulator.')
@@ -25,10 +25,15 @@ function getUnifiedTabsForWorktree(worktreeId: string): SimulatorTabReference[]
}
function shutdownManagedSimulator(worktreeId: string): Promise<unknown> {
return callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
worktree: worktreeId,
managedOnly: true
})
return callRuntimeRpc(
{ kind: 'local' },
'emulator.shutdown',
{
worktree: worktreeId,
managedOnly: true
},
{ suppressFeatureInteraction: true }
)
}
export async function shutdownManagedSimulatorIfNoPane(
@@ -84,15 +84,15 @@ const mockApi = {
skills: {
ensureManagedReady: vi.fn().mockResolvedValue({
status: 'fallback',
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
runtime: 'remote',
scope: 'missing',
reason: 'remote-runtime',
uiKey: 'remote::linear-tickets:linear-worktree',
uiKey: 'remote::orca-linear:linear-worktree',
message: 'This skill is on a remote runtime, so Orca needs you to update it there.',
request: {
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
remoteRuntime: true
}
@@ -1775,7 +1775,7 @@ describe('createWorktree base status merge', () => {
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
remoteRuntime: true
})
@@ -1818,7 +1818,7 @@ describe('createWorktree base status merge', () => {
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: expect.objectContaining({
projectRootPath: '/repo/wt1'
@@ -1864,7 +1864,7 @@ describe('createWorktree base status merge', () => {
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: {
projectRootPath: 'C:\\repo\\wt1',
+2 -2
View File
@@ -32,7 +32,7 @@ import {
type RuntimeClientTarget,
RuntimeRpcCallError
} from '../../runtime/runtime-rpc-client'
import { LINEAR_TICKETS_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { ORCA_LINEAR_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review'
@@ -174,7 +174,7 @@ function nudgeLinearManagedSkillSetup(
: null
void window.api.skills
.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
...(localDiscoveryTarget ? { discoveryTarget: localDiscoveryTarget } : {}),
...(target.kind === 'environment' || remote ? { remoteRuntime: true } : {})
+351 -6
View File
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreloadApi } from '../../../preload/api-types'
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { ManagedAgentSkillFallback, ManagedAgentSkillUpdated } from '../../../shared/skills'
import type { TaskSourceContext } from '../../../shared/task-source-context'
const TEST_COMMIT_OID = '0123456789abcdef0123456789abcdef01234567'
@@ -49,6 +50,8 @@ function installBrowserGlobals(userAgent = 'Linux'): {
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
atob: (value: string) => Buffer.from(value, 'base64').toString('binary'),
btoa: (value: string) => Buffer.from(value, 'binary').toString('base64')
} as unknown as Window & typeof globalThis
@@ -1400,13 +1403,13 @@ describe('web UI preload API', () => {
)
})
it('returns remote managed skill fallback from web ensure checks without emitting setup', async () => {
it('returns and emits remote managed skill fallback from web ensure checks', async () => {
const { api } = await installApi()
const fallback = vi.fn()
const unsubscribe = api.skills.onManagedFallback(fallback)
const result = await api.skills.ensureManagedReady({
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
remoteRuntime: true
})
@@ -1414,24 +1417,366 @@ describe('web UI preload API', () => {
expect(result).toMatchObject({
status: 'fallback',
reason: 'remote-runtime',
skillName: 'linear-tickets'
skillName: 'orca-linear'
})
expect(fallback).not.toHaveBeenCalled()
expect(fallback).toHaveBeenCalledWith(result)
fallback.mockClear()
const cooldown = await api.skills.ensureManagedReady({
skillName: 'linear-tickets',
skillName: 'orca-linear',
context: 'linear-worktree',
remoteRuntime: true
})
expect(cooldown).toMatchObject({
status: 'fallback',
reason: 'cooldown',
skillName: 'linear-tickets'
skillName: 'orca-linear'
})
expect(fallback).not.toHaveBeenCalled()
unsubscribe()
})
it('dispatches paired runtime managed skill stream events to web callbacks', async () => {
const subscriptions: {
method: string
params: unknown
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void
}
}[] = []
const unsubscribeRemote = vi.fn()
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(
method: string,
params: unknown,
callbacks: { onResponse: (response: RuntimeRpcResponse<unknown>) => void }
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
subscriptions.push({ method, params, callbacks })
return Promise.resolve({ unsubscribe: unsubscribeRemote, sendBinary: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const fallback = vi.fn()
const updated = vi.fn()
const unsubscribeFallback = globals.window.api.skills.onManagedFallback(fallback)
const unsubscribeUpdated = globals.window.api.skills.onManagedUpdated(updated)
expect(subscriptions).toHaveLength(1)
expect(subscriptions[0]).toMatchObject({
method: 'skills.managedEvents',
params: undefined
})
const fallbackEvent = {
status: 'fallback',
skillName: 'orca-linear',
context: 'linear-worktree',
runtime: 'host',
scope: 'missing',
reason: 'missing-install',
uiKey: 'host::orca-linear:linear-worktree',
message: 'Install orca-linear.',
request: { skillName: 'orca-linear', context: 'linear-worktree' }
} satisfies ManagedAgentSkillFallback
const updatedEvent = {
status: 'updated',
skillName: 'orchestration',
context: 'agent-orchestration',
runtime: 'host',
scope: 'global'
} satisfies ManagedAgentSkillUpdated
subscriptions[0].callbacks.onResponse({
id: 'stream-1',
ok: true,
result: { type: 'fallback', event: fallbackEvent },
_meta: { runtimeId: 'runtime-1' }
})
subscriptions[0].callbacks.onResponse({
id: 'stream-1',
ok: true,
result: { type: 'updated', event: updatedEvent },
_meta: { runtimeId: 'runtime-1' }
})
expect(fallback).toHaveBeenCalledWith(fallbackEvent)
expect(updated).toHaveBeenCalledWith(updatedEvent)
await Promise.resolve()
unsubscribeFallback()
unsubscribeUpdated()
expect(unsubscribeRemote).toHaveBeenCalledTimes(1)
})
it('resubscribes managed skill callbacks after a same-runtime stream close', async () => {
vi.useFakeTimers()
const subscriptions: {
method: string
params: unknown
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void
onClose?: () => void
}
}[] = []
const unsubscribeRemote = vi.fn()
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(
method: string,
params: unknown,
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void
onClose?: () => void
}
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
subscriptions.push({ method, params, callbacks })
return Promise.resolve({ unsubscribe: unsubscribeRemote, sendBinary: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
await Promise.resolve()
expect(subscriptions).toHaveLength(1)
subscriptions[0].callbacks.onClose?.()
expect(unsubscribeRemote).toHaveBeenCalledTimes(1)
expect(subscriptions).toHaveLength(1)
await vi.advanceTimersByTimeAsync(5_000)
expect(subscriptions).toHaveLength(2)
expect(subscriptions[1]).toMatchObject({
method: 'skills.managedEvents',
params: undefined
})
unsubscribe()
vi.useRealTimers()
})
it('keeps direct web managed fallback behavior when runtime event subscription fails', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(): Promise<never> {
return Promise.reject(new Error('method unavailable'))
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const fallback = vi.fn()
const unsubscribe = globals.window.api.skills.onManagedFallback(fallback)
await Promise.resolve()
const result = await globals.window.api.skills.ensureManagedReady({
skillName: 'orca-linear',
context: 'linear-worktree',
remoteRuntime: true
})
expect(result).toMatchObject({
status: 'fallback',
reason: 'remote-runtime',
skillName: 'orca-linear'
})
expect(fallback).toHaveBeenCalledWith(result)
unsubscribe()
})
it('retries managed skill callbacks after a same-runtime pending subscription failure', async () => {
vi.useFakeTimers()
const subscriptions: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(
method: string,
params: unknown
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
subscriptions.push({ method, params })
if (subscriptions.length === 1) {
return Promise.reject(new Error('runtime starting'))
}
return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
await Promise.resolve()
expect(subscriptions).toEqual([{ method: 'skills.managedEvents', params: undefined }])
await vi.advanceTimersByTimeAsync(5_000)
expect(subscriptions).toEqual([
{ method: 'skills.managedEvents', params: undefined },
{ method: 'skills.managedEvents', params: undefined }
])
unsubscribe()
vi.useRealTimers()
})
it('subscribes managed skill callbacks after a web runtime is paired', async () => {
const subscriptions: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(
method: string,
params: unknown
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
subscriptions.push({ method, params })
return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
expect(subscriptions).toEqual([])
const pairingCode = Buffer.from(
JSON.stringify({
v: 2,
endpoint: 'ws://127.0.0.1:1234',
deviceToken: 'token',
publicKeyB64: 'public-key'
})
).toString('base64url')
await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Test runtime',
pairingCode: `orca://pair?code=${pairingCode}`
})
await Promise.resolve()
expect(subscriptions).toEqual([{ method: 'skills.managedEvents', params: undefined }])
unsubscribe()
})
it('resubscribes managed skill callbacks when a pending stream fails during re-pair', async () => {
const rejectFirst: ((error: Error) => void)[] = []
const subscriptions: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(
method: string,
params: unknown
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
subscriptions.push({ method, params })
if (subscriptions.length === 1) {
return new Promise((_resolve, reject) => {
rejectFirst.push(reject)
})
}
return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
expect(subscriptions).toEqual([{ method: 'skills.managedEvents', params: undefined }])
const pairingCode = Buffer.from(
JSON.stringify({
v: 2,
endpoint: 'ws://127.0.0.1:5678',
deviceToken: 'token-2',
publicKeyB64: 'public-key-2'
})
).toString('base64url')
await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Replacement runtime',
pairingCode: `orca://pair?code=${pairingCode}`
})
rejectFirst[0]?.(new Error('old runtime closed'))
await vi.waitFor(() => {
expect(subscriptions).toHaveLength(2)
})
expect(subscriptions[1]).toEqual({ method: 'skills.managedEvents', params: undefined })
unsubscribe()
})
it('does not leak a pending managed skill stream subscription during callback churn', async () => {
const resolvers: ((handle: { unsubscribe: () => void; sendBinary: () => void }) => void)[] = []
const unsubscribeRemote = [vi.fn(), vi.fn()]
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
return new Promise((resolve) => {
resolvers.push(resolve)
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const unsubscribeFallback = globals.window.api.skills.onManagedFallback(vi.fn())
expect(resolvers).toHaveLength(1)
unsubscribeFallback()
const unsubscribeUpdated = globals.window.api.skills.onManagedUpdated(vi.fn())
expect(resolvers).toHaveLength(1)
resolvers[0]?.({ unsubscribe: unsubscribeRemote[0], sendBinary: vi.fn() })
await vi.waitFor(() => {
expect(unsubscribeRemote[0]).toHaveBeenCalledTimes(1)
expect(resolvers).toHaveLength(2)
})
resolvers[1]?.({ unsubscribe: unsubscribeRemote[1], sendBinary: vi.fn() })
await Promise.resolve()
unsubscribeUpdated()
expect(unsubscribeRemote[1]).toHaveBeenCalledTimes(1)
})
it('rejects paired web computer-use status failures instead of marking the helper unavailable', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
+155 -1
View File
@@ -29,8 +29,10 @@ import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallback,
ManagedAgentSkillUpdated,
SkillDiscoveryResult
} from '../../../shared/skills'
import { shouldEmitManagedAgentSkillFallback } from '../../../shared/skills'
import {
getDefaultOnboardingState,
getDefaultSettings,
@@ -127,6 +129,7 @@ let activeClientEnvironmentId: string | null = null
let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
const activeRuntimeEnvironmentListeners = new Set<() => void>()
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -357,6 +360,12 @@ type WebKeybindingDocument = {
platforms: Partial<Record<KeybindingPlatform, KeybindingOverrides>>
}
type ManagedSkillRuntimeStreamEvent =
| { type: 'fallback'; event: ManagedAgentSkillFallback }
| { type: 'updated'; event: ManagedAgentSkillUpdated }
| { type: 'ready'; subscriptionId: string }
| { type: 'end' }
export const GITHUB_WEB_RPC_METHODS = {
repoSlug: 'github.repoSlug',
repoUpstream: 'github.repoUpstream',
@@ -1012,6 +1021,7 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
closeActiveRuntimeClients()
activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer })
saveStoredWebRuntimeEnvironment(activeEnvironment)
notifyActiveRuntimeEnvironmentChanged()
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
},
resolve: async ({ selector }) =>
@@ -2302,8 +2312,127 @@ function createComputerUsePermissionsApi(): NonNullable<
function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
const fallbackCallbacks = new Set<(event: ManagedAgentSkillFallback) => void>()
const updatedCallbacks = new Set<(event: ManagedAgentSkillUpdated) => void>()
const cooldownUntilByKey = new Map<string, number>()
let managedEventsSubscription: { unsubscribe: () => void } | null = null
let managedEventsSubscriptionPromise: Promise<void> | null = null
let managedEventsSubscriptionCancelPending = false
let managedEventsRetryTimer: number | null = null
const cooldownMs = 60_000
const managedEventsRetryMs = 5_000
const hasManagedEventCallbacks = (): boolean =>
fallbackCallbacks.size > 0 || updatedCallbacks.size > 0
const clearManagedEventRetryTimer = (): void => {
if (managedEventsRetryTimer === null) {
return
}
window.clearTimeout(managedEventsRetryTimer)
managedEventsRetryTimer = null
}
const scheduleManagedEventRetry = (): void => {
if (managedEventsRetryTimer !== null || !hasManagedEventCallbacks()) {
return
}
if (!requireActiveEnvironmentOrNull()) {
return
}
managedEventsRetryTimer = window.setTimeout(() => {
managedEventsRetryTimer = null
ensureManagedEventSubscription()
}, managedEventsRetryMs)
}
const stopManagedEventSubscription = (): void => {
const subscription = managedEventsSubscription
if (subscription) {
managedEventsSubscription = null
managedEventsSubscriptionPromise = null
managedEventsSubscriptionCancelPending = false
subscription.unsubscribe()
return
}
if (managedEventsSubscriptionPromise) {
managedEventsSubscriptionCancelPending = true
return
}
managedEventsSubscriptionCancelPending = false
}
const restartManagedEventSubscription = (): void => {
stopManagedEventSubscription()
scheduleManagedEventRetry()
}
const handleManagedStreamEvent = (event: ManagedSkillRuntimeStreamEvent): void => {
if (event.type === 'fallback') {
fallbackCallbacks.forEach((callback) => callback(event.event))
} else if (event.type === 'updated') {
updatedCallbacks.forEach((callback) => callback(event.event))
} else if (event.type === 'end') {
restartManagedEventSubscription()
}
}
const ensureManagedEventSubscription = (): void => {
if (
managedEventsSubscription ||
managedEventsSubscriptionPromise ||
!hasManagedEventCallbacks()
) {
return
}
const environment = requireActiveEnvironmentOrNull()
if (!environment) {
return
}
clearManagedEventRetryTimer()
const client = getClientForEnvironment(environment)
const subscribedEnvironmentId = environment.id
managedEventsSubscriptionCancelPending = false
managedEventsSubscriptionPromise = client
.subscribe(
'skills.managedEvents',
undefined,
{
onResponse: (response) => {
if (!response.ok) {
stopManagedEventSubscription()
return
}
handleManagedStreamEvent(response.result as ManagedSkillRuntimeStreamEvent)
},
onError: () => {
restartManagedEventSubscription()
},
onClose: () => {
restartManagedEventSubscription()
}
},
{ timeoutMs: 15_000 }
)
.then((subscription) => {
if (managedEventsSubscriptionCancelPending) {
managedEventsSubscriptionCancelPending = false
managedEventsSubscriptionPromise = null
subscription.unsubscribe()
ensureManagedEventSubscription()
return
}
managedEventsSubscription = subscription
managedEventsSubscriptionPromise = null
if (!hasManagedEventCallbacks()) {
stopManagedEventSubscription()
}
})
.catch(() => {
managedEventsSubscription = null
managedEventsSubscriptionPromise = null
managedEventsSubscriptionCancelPending = false
const active = requireActiveEnvironmentOrNull()
if (hasManagedEventCallbacks() && active && active.id !== subscribedEnvironmentId) {
ensureManagedEventSubscription()
} else {
scheduleManagedEventRetry()
}
})
}
activeRuntimeEnvironmentListeners.add(ensureManagedEventSubscription)
return {
discover: () =>
callRuntimeResult<SkillDiscoveryResult>('skills.discover', undefined, 15_000).catch(() => ({
@@ -2346,15 +2475,33 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
request
} satisfies ManagedAgentSkillFallback
cooldownUntilByKey.set(uiKey, Date.now() + cooldownMs)
if (shouldEmitManagedAgentSkillFallback(result)) {
fallbackCallbacks.forEach((callback) => callback(result))
}
return Promise.resolve(result satisfies ManagedAgentSkillEnsureResult)
},
onManagedFallback: (callback) => {
fallbackCallbacks.add(callback)
ensureManagedEventSubscription()
return () => {
fallbackCallbacks.delete(callback)
if (!hasManagedEventCallbacks()) {
clearManagedEventRetryTimer()
stopManagedEventSubscription()
}
}
},
onManagedUpdated: () => () => {}
onManagedUpdated: (callback) => {
updatedCallbacks.add(callback)
ensureManagedEventSubscription()
return () => {
updatedCallbacks.delete(callback)
if (!hasManagedEventCallbacks()) {
clearManagedEventRetryTimer()
stopManagedEventSubscription()
}
}
}
}
}
@@ -2670,6 +2817,13 @@ function disconnectActiveRuntimeEnvironment(): void {
closeActiveRuntimeClients()
clearStoredWebRuntimeEnvironment()
activeEnvironment = null
notifyActiveRuntimeEnvironmentChanged()
}
function notifyActiveRuntimeEnvironmentChanged(): void {
for (const listener of activeRuntimeEnvironmentListeners) {
listener()
}
}
function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment {
@@ -28,6 +28,7 @@ export type FeatureInteractionId =
| 'agent-orchestration-setup'
| 'agent-orchestration'
| 'mobile-emulator-agent-setup'
| 'mobile-emulator-agent-use'
| 'ai-commit-generation'
| 'ai-pr-generation'
| 'claude-account-switching'
@@ -105,6 +106,10 @@ export const FEATURE_INTERACTIONS = [
id: 'mobile-emulator-agent-setup',
interaction: 'Mobile Emulator agent CLI or skill setup opened'
},
{
id: 'mobile-emulator-agent-use',
interaction: 'mobile emulator runtime method used'
},
{
id: 'ai-commit-generation',
interaction: 'AI commit message generation enabled or used'
@@ -49,6 +49,7 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = {
'agent-orchestration-setup': 'setup',
'agent-orchestration': 'collaboration',
'mobile-emulator-agent-setup': 'setup',
'mobile-emulator-agent-use': 'agent',
'ai-commit-generation': 'source_control',
'ai-pr-generation': 'source_control',
'claude-account-switching': 'settings',
+1
View File
@@ -60,6 +60,7 @@ describe('feature interactions', () => {
'agent-orchestration-setup',
'agent-orchestration',
'mobile-emulator-agent-setup',
'mobile-emulator-agent-use',
'ai-commit-generation',
'ai-pr-generation',
'claude-account-switching',
@@ -15,6 +15,10 @@ export function withBrowserPaneUiRuntimeRpcSource(value: unknown): unknown {
}
export function isBrowserPaneUiRuntimeRpcParams(value: unknown): boolean {
return isUiOwnedRuntimeRpcParams(value)
}
export function isUiOwnedRuntimeRpcParams(value: unknown): boolean {
return (
value !== null &&
typeof value === 'object' &&
+5 -5
View File
@@ -1,7 +1,7 @@
import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime'
import type {
COMPUTER_USE_SKILL_NAME,
LINEAR_TICKETS_SKILL_NAME,
ORCA_LINEAR_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from './agent-feature-install-commands'
@@ -59,7 +59,7 @@ export type SkillFrontmatterSummary = {
export type ManagedAgentSkillName =
| typeof COMPUTER_USE_SKILL_NAME
| typeof LINEAR_TICKETS_SKILL_NAME
| typeof ORCA_LINEAR_SKILL_NAME
| typeof ORCA_CLI_SKILL_NAME
| typeof ORCHESTRATION_SKILL_NAME
@@ -154,8 +154,8 @@ export function shouldEmitManagedAgentSkillFallback(
return (
result.status === 'fallback' &&
result.reason !== 'cooldown' &&
// Why: the modal is an install/update surface; fallbacks without a command
// only create a dead-end Re-check loop.
Boolean(result.manualCommand)
// Why: these are integration/no-op states, not actionable user setup states.
result.reason !== 'target-required' &&
result.reason !== 'unsupported-skill'
)
}