From 5ab31df5d71f0bd305f7a7ea195bd9ce40b0d8ff Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Wed, 24 Jun 2026 02:54:03 -0700 Subject: [PATCH] Safely auto-update managed Orca skills --- .../reference/agent-skill-auto-update-plan.md | 43 +- src/main/index.ts | 7 +- src/main/ipc/runtime-environments.test.ts | 49 +- src/main/ipc/runtime-managed-skill-nudge.ts | 2 +- src/main/ipc/skills.test.ts | 32 +- src/main/ipc/skills.ts | 9 +- .../dispatcher-feature-interactions.test.ts | 22 + src/main/runtime/rpc/managed-skill-nudge.ts | 2 +- src/main/runtime/rpc/methods/skills.test.ts | 132 +++ src/main/runtime/rpc/methods/skills.ts | 32 +- .../rpc/runtime-feature-interaction-id.ts | 10 +- .../skills/managed-skill-ensure-result.ts | 26 + src/main/skills/managed-skill-events.ts | 39 + .../skills/managed-skill-global-candidate.ts | 20 +- .../managed-skill-post-update-verification.ts | 30 + .../skills/managed-skill-test-fixtures.ts | 67 ++ .../skills/managed-skill-update-cache-key.ts | 47 +- .../skills/managed-skill-update-contract.ts | 18 +- ...naged-skill-update-coordinator-registry.ts | 24 + .../managed-skill-update-runner.test.ts | 96 ++ .../skills/managed-skill-update-runner.ts | 108 +++ .../managed-skill-updates-safety.test.ts | 278 ++++++ src/main/skills/managed-skill-updates.test.ts | 860 ++++++------------ src/main/skills/managed-skill-updates.ts | 221 +++-- .../use-emulator-pane-controls.ts | 34 +- .../use-emulator-pane-session.ts | 18 +- .../use-emulator-pane-shutdown.ts | 13 +- .../settings/MobileEmulatorSettingsPane.tsx | 3 +- .../ManagedAgentSkillSetupDialogHost.test.ts | 35 +- .../ManagedAgentSkillSetupDialogHost.tsx | 8 +- .../skills/managed-agent-skill-dialog-copy.ts | 4 +- .../hooks/installed-agent-skill-discovery.ts | 4 +- .../src/hooks/useInstalledAgentSkills.test.ts | 23 + src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- .../src/lib/open-mobile-emulator-tab.test.ts | 28 +- .../src/lib/open-mobile-emulator-tab.ts | 3 +- .../lib/simulator-pane-shutdown-scheduler.ts | 13 +- .../src/store/slices/worktrees.test.ts | 12 +- src/renderer/src/store/slices/worktrees.ts | 4 +- src/renderer/src/web/web-preload-api.test.ts | 357 +++++++- src/renderer/src/web/web-preload-api.ts | 156 +++- src/shared/feature-interaction-catalog.ts | 5 + src/shared/feature-interaction-categories.ts | 1 + src/shared/feature-interactions.test.ts | 1 + .../runtime-rpc-feature-interaction-source.ts | 4 + src/shared/skills.ts | 10 +- 50 files changed, 2084 insertions(+), 851 deletions(-) create mode 100644 src/main/runtime/rpc/methods/skills.test.ts create mode 100644 src/main/skills/managed-skill-events.ts create mode 100644 src/main/skills/managed-skill-post-update-verification.ts create mode 100644 src/main/skills/managed-skill-test-fixtures.ts create mode 100644 src/main/skills/managed-skill-update-coordinator-registry.ts create mode 100644 src/main/skills/managed-skill-update-runner.test.ts create mode 100644 src/main/skills/managed-skill-update-runner.ts create mode 100644 src/main/skills/managed-skill-updates-safety.test.ts diff --git a/docs/reference/agent-skill-auto-update-plan.md b/docs/reference/agent-skill-auto-update-plan.md index 27f7f1203b6..6d8f04d4b1b 100644 --- a/docs/reference/agent-skill-auto-update-plan.md +++ b/docs/reference/agent-skill-auto-update-plan.md @@ -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. diff --git a/src/main/index.ts b/src/main/index.ts index 3a7b81a9d8d..1ef592f4d2d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 13f3243a647..ebbb8fd92c2 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -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 () => { diff --git a/src/main/ipc/runtime-managed-skill-nudge.ts b/src/main/ipc/runtime-managed-skill-nudge.ts index 71fadc4616b..23257982ae0 100644 --- a/src/main/ipc/runtime-managed-skill-nudge.ts +++ b/src/main/ipc/runtime-managed-skill-nudge.ts @@ -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 { diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index fc3c8f7cb32..7e350ab9fb7 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -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) }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index d0bd2550d38..a798a1b9091 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -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 => { 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) } diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index 75571df748b..fb4c28e9de6 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -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) diff --git a/src/main/runtime/rpc/managed-skill-nudge.ts b/src/main/runtime/rpc/managed-skill-nudge.ts index bb0881f804c..d05f411a253 100644 --- a/src/main/runtime/rpc/managed-skill-nudge.ts +++ b/src/main/runtime/rpc/managed-skill-nudge.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts new file mode 100644 index 00000000000..ecc9191a46f --- /dev/null +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -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 void> +} { + const cleanups = new Map 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' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 0c157779068..c9e653389ad 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -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((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 }) + }) + } }) ] diff --git a/src/main/runtime/rpc/runtime-feature-interaction-id.ts b/src/main/runtime/rpc/runtime-feature-interaction-id.ts index 0c0a44cbecf..93b69d191c3 100644 --- a/src/main/runtime/rpc/runtime-feature-interaction-id.ts +++ b/src/main/runtime/rpc/runtime-feature-interaction-id.ts @@ -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' } diff --git a/src/main/skills/managed-skill-ensure-result.ts b/src/main/skills/managed-skill-ensure-result.ts index 2bde9b562c9..3a7dee747e5 100644 --- a/src/main/skills/managed-skill-ensure-result.ts +++ b/src/main/skills/managed-skill-ensure-result.ts @@ -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 +): ManagedAgentSkillFallback { + return buildManagedSkillFallback({ + request, + reason, + runtime: 'host', + scope: 'global', + manualCommand: buildManagedSkillManualCommand('update', request.skillName) + }) +} + export function buildManagedSkillFallback(args: { request: ManagedAgentSkillEnsureRequest reason: ManagedAgentSkillFallbackReason diff --git a/src/main/skills/managed-skill-events.ts b/src/main/skills/managed-skill-events.ts new file mode 100644 index 00000000000..3a544a86170 --- /dev/null +++ b/src/main/skills/managed-skill-events.ts @@ -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) + } +} diff --git a/src/main/skills/managed-skill-global-candidate.ts b/src/main/skills/managed-skill-global-candidate.ts index bce66084514..60808bdf2e3 100644 --- a/src/main/skills/managed-skill-global-candidate.ts +++ b/src/main/skills/managed-skill-global-candidate.ts @@ -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) + ) +} diff --git a/src/main/skills/managed-skill-post-update-verification.ts b/src/main/skills/managed-skill-post-update-verification.ts new file mode 100644 index 00000000000..ebcb2165c0f --- /dev/null +++ b/src/main/skills/managed-skill-post-update-verification.ts @@ -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 + homeDir: string + readTextFile: (path: string) => Promise + 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 } +} diff --git a/src/main/skills/managed-skill-test-fixtures.ts b/src/main/skills/managed-skill-test-fixtures.ts new file mode 100644 index 00000000000..995ccb77917 --- /dev/null +++ b/src/main/skills/managed-skill-test-fixtures.ts @@ -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 & Pick +): 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 + } + } + }) +} diff --git a/src/main/skills/managed-skill-update-cache-key.ts b/src/main/skills/managed-skill-update-cache-key.ts index 1d8e2a7b5ec..cc0d9e19f03 100644 --- a/src/main/skills/managed-skill-update-cache-key.ts +++ b/src/main/skills/managed-skill-update-cache-key.ts @@ -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 +}): 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(':') } diff --git a/src/main/skills/managed-skill-update-contract.ts b/src/main/skills/managed-skill-update-contract.ts index febf826dd16..1ae914e9d57 100644 --- a/src/main/skills/managed-skill-update-contract.ts +++ b/src/main/skills/managed-skill-update-contract.ts @@ -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 -> = {} - const MANAGED_SKILL_NAMES = new Set([ 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 diff --git a/src/main/skills/managed-skill-update-coordinator-registry.ts b/src/main/skills/managed-skill-update-coordinator-registry.ts new file mode 100644 index 00000000000..de86decc69d --- /dev/null +++ b/src/main/skills/managed-skill-update-coordinator-registry.ts @@ -0,0 +1,24 @@ +import type { Store } from '../persistence' +import { discoverSkills } from './discovery' +import { ManagedSkillUpdateCoordinator } from './managed-skill-updates' + +const coordinatorByStore = new WeakMap() + +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 +} diff --git a/src/main/skills/managed-skill-update-runner.test.ts b/src/main/skills/managed-skill-update-runner.test.ts new file mode 100644 index 00000000000..896b75cd06c --- /dev/null +++ b/src/main/skills/managed-skill-update-runner.test.ts @@ -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() + }) +}) diff --git a/src/main/skills/managed-skill-update-runner.ts b/src/main/skills/managed-skill-update-runner.ts new file mode 100644 index 00000000000..0394a1919d1 --- /dev/null +++ b/src/main/skills/managed-skill-update-runner.ts @@ -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 + +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 { + 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() + } +} diff --git a/src/main/skills/managed-skill-updates-safety.test.ts b/src/main/skills/managed-skill-updates-safety.test.ts new file mode 100644 index 00000000000..efa7ae8e30a --- /dev/null +++ b/src/main/skills/managed-skill-updates-safety.test.ts @@ -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() + }) +}) diff --git a/src/main/skills/managed-skill-updates.test.ts b/src/main/skills/managed-skill-updates.test.ts index b324d180536..c68e8265e6b 100644 --- a/src/main/skills/managed-skill-updates.test.ts +++ b/src/main/skills/managed-skill-updates.test.ts @@ -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 & Pick -): 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() }) }) diff --git a/src/main/skills/managed-skill-updates.ts b/src/main/skills/managed-skill-updates.ts index 0c2ddfc4829..d460bc08f37 100644 --- a/src/main/skills/managed-skill-updates.ts +++ b/src/main/skills/managed-skill-updates.ts @@ -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 - expectedRevisions?: Partial> + updateRunner?: ManagedSkillUpdateRunner } const DEFAULT_COOLDOWN_MS = 60_000 +const defaultUpdateAbortController = new AbortController() -const coordinatorByStore = new WeakMap() - -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 - private readonly expectedRevisions: Partial< - Record - > + private readonly updateRunner: ManagedSkillUpdateRunner private readonly inFlightByPreDiscoveryKey = new Map< string, Promise >() + private readonly disabledFallbackByPreDiscoveryKey = new Map< + string, + { result: ManagedAgentSkillEnsureResult; until: number } + >() private readonly cooldownUntilByKey = new Map() + private readonly readyUntilByPreDiscoveryKey = new Map() private readonly successCache = new Set() 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, - expected: ExpectedManagedSkillRevision | undefined + target: Extract ): Promise { 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 { - return readFile(path, 'utf8') +function isBackgroundUpdateDisabledFallback(result: ManagedAgentSkillEnsureResult): boolean { + return result.status === 'fallback' && result.reason === 'background-update-disabled' } diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts index c760c4f5152..01dff8e0a1d 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts @@ -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]) diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts index 5201daafeea..5ab09c2d7fc 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts @@ -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. diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts index 01d5c7a6c9c..46ebc0f3f6d 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts @@ -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, { diff --git a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx index c8dc0221acb..c56731ad041 100644 --- a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx @@ -103,7 +103,8 @@ export function MobileEmulatorSettingsPane({ const result = (await callRuntimeRpc( { kind: 'local' }, 'emulator.availability', - {} + {}, + { suppressFeatureInteraction: true } )) as EmulatorAvailability setAvailability(result) } catch (error) { diff --git a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts index a786c070b35..7114968c247 100644 --- a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts +++ b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts @@ -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: [] + }) + }) }) diff --git a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx index 5c767bb1bc5..8027e5c80f4 100644 --- a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx +++ b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx @@ -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) : ''), diff --git a/src/renderer/src/components/skills/managed-agent-skill-dialog-copy.ts b/src/renderer/src/components/skills/managed-agent-skill-dialog-copy.ts index 8e84120cbfc..8f535877b6d 100644 --- a/src/renderer/src/components/skills/managed-agent-skill-dialog-copy.ts +++ b/src/renderer/src/components/skills/managed-agent-skill-dialog-copy.ts @@ -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', diff --git a/src/renderer/src/hooks/installed-agent-skill-discovery.ts b/src/renderer/src/hooks/installed-agent-skill-discovery.ts index fd240767f97..7b3e3567749 100644 --- a/src/renderer/src/hooks/installed-agent-skill-discovery.ts +++ b/src/renderer/src/hooks/installed-agent-skill-discovery.ts @@ -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 { diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts index b2ce0acdb5e..d7b11734a48 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -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() + const discover = vi.fn<() => Promise>() + 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' })]) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f7b436676bb..b0e606cc92c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d686c1262ae..3e707fb6d3e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f461251fc21..fdba10363ac 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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." } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2b9d6860a3b..c198ab21fc2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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." } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4ad1597209b..d9fc86d5e50 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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." } }, diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts index 6afb783d26f..23252aedd9c 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts @@ -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() }) diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.ts b/src/renderer/src/lib/open-mobile-emulator-tab.ts index 06147d2d5da..fa3dc725031 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.ts @@ -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.') diff --git a/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts b/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts index 722f322d80a..8af6c3eface 100644 --- a/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts +++ b/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts @@ -25,10 +25,15 @@ function getUnifiedTabsForWorktree(worktreeId: string): SimulatorTabReference[] } function shutdownManagedSimulator(worktreeId: string): Promise { - 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( diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 770bcd65909..7843444effd 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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', diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 565b66e0557..270c942ee8e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -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 } : {}) diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 7cf115147e3..f0037f7f739 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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) => void + } + }[] = [] + const unsubscribeRemote = vi.fn() + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + subscribe( + method: string, + params: unknown, + callbacks: { onResponse: (response: RuntimeRpcResponse) => 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) => void + onClose?: () => void + } + }[] = [] + const unsubscribeRemote = vi.fn() + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + subscribe( + method: string, + params: unknown, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => 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 { + 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 { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c9ffb86ed77..172c6550764 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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 { return new Promise((resolve, reject) => { @@ -357,6 +360,12 @@ type WebKeybindingDocument = { platforms: Partial> } +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['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['skills']> { const fallbackCallbacks = new Set<(event: ManagedAgentSkillFallback) => void>() + const updatedCallbacks = new Set<(event: ManagedAgentSkillUpdated) => void>() const cooldownUntilByKey = new Map() + let managedEventsSubscription: { unsubscribe: () => void } | null = null + let managedEventsSubscriptionPromise: Promise | 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('skills.discover', undefined, 15_000).catch(() => ({ @@ -2346,15 +2475,33 @@ function createSkillsApi(): NonNullable['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 { diff --git a/src/shared/feature-interaction-catalog.ts b/src/shared/feature-interaction-catalog.ts index 2acfe7952b2..d2791ce63e2 100644 --- a/src/shared/feature-interaction-catalog.ts +++ b/src/shared/feature-interaction-catalog.ts @@ -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' diff --git a/src/shared/feature-interaction-categories.ts b/src/shared/feature-interaction-categories.ts index a231a35a17a..5ac7b19fb61 100644 --- a/src/shared/feature-interaction-categories.ts +++ b/src/shared/feature-interaction-categories.ts @@ -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', diff --git a/src/shared/feature-interactions.test.ts b/src/shared/feature-interactions.test.ts index 83ff79a1351..36cff5569da 100644 --- a/src/shared/feature-interactions.test.ts +++ b/src/shared/feature-interactions.test.ts @@ -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', diff --git a/src/shared/runtime-rpc-feature-interaction-source.ts b/src/shared/runtime-rpc-feature-interaction-source.ts index ce481338885..ba8cc4c2386 100644 --- a/src/shared/runtime-rpc-feature-interaction-source.ts +++ b/src/shared/runtime-rpc-feature-interaction-source.ts @@ -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' && diff --git a/src/shared/skills.ts b/src/shared/skills.ts index 6d1e305cc7d..78cccf68da5 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -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' ) }