From 62b28a2f77508af257641b16cfecf64857d5b556 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Wed, 1 Jul 2026 22:35:54 -0700 Subject: [PATCH] Defer managed skill prompts until restart --- .../reference/agent-skill-auto-update-plan.md | 90 ++++++++----- src/main/index.ts | 11 +- src/main/ipc/runtime-environments.test.ts | 46 ++----- src/main/ipc/runtime-managed-skill-nudge.ts | 11 +- src/main/ipc/skills.test.ts | 94 +++++++++++++- src/main/ipc/skills.ts | 27 ++++ src/main/persistence.test.ts | 1 + .../skills/managed-skill-restart-prompts.ts | 122 ++++++++++++++++++ src/preload/api-types.ts | 2 + src/preload/index.ts | 6 + .../ManagedAgentSkillSetupDialogHost.test.ts | 26 ++++ .../ManagedAgentSkillSetupDialogHost.tsx | 14 ++ .../src/store/slices/worktrees.test.ts | 7 +- src/renderer/src/store/slices/worktrees.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/constants.ts | 1 + src/shared/types.ts | 4 + 17 files changed, 370 insertions(+), 96 deletions(-) create mode 100644 src/main/skills/managed-skill-restart-prompts.ts diff --git a/docs/reference/agent-skill-auto-update-plan.md b/docs/reference/agent-skill-auto-update-plan.md index 6929beb1704..18f9b7de679 100644 --- a/docs/reference/agent-skill-auto-update-plan.md +++ b/docs/reference/agent-skill-auto-update-plan.md @@ -2,19 +2,23 @@ ## Summary -Orca should keep Orca-managed agent skills current when a user or agent enters a workflow that needs the skill. The update should be automatic only when Orca can prove it is updating the same tracked install the user already has. When Orca cannot prove that but has a safe manual install/update command, the same workflow trigger should show a contextual setup/update modal instead. +Orca should record when a user or agent enters a workflow that needs an Orca-managed agent skill, +then ask about missing/outdated setup after the next Orca restart. The restart-time check should +update automatically only when Orca can prove it is updating the same tracked install the user +already has. When Orca cannot prove that but has a safe manual install/update command, the restart +prompt should show a contextual setup/update modal instead. Settings must not trigger background skill updates. Settings may continue to show status and manual controls. Automatic managed-skill updates are experimental and off by default. Users can opt in from the -Agents settings pane; when disabled, Orca still uses the same workflow triggers, but stale global -managed-skill updates fall back to the contextual manual update modal instead of running in the -background. +Experimental settings pane; when disabled, Orca still records the same workflow triggers, but stale +global managed-skill updates fall back to the contextual manual update modal on the next restart +instead of running in the background. ## Goals - Avoid sending users to Settings -> CLI for routine skill maintenance. -- Keep managed Orca skills current at the moment they are actually needed. +- Keep managed Orca skills current without interrupting the workflow that revealed the need. - Avoid surprising writes to user-managed, project-managed, remote, or ambiguous skill installs. - Avoid repeated subprocess/network work when many agents attempt the same skill. - Explain any modal in the context that caused it, especially when an agent triggered the need. @@ -23,7 +27,8 @@ background. - Do not build a generic updater for every skill on the Skills page. - Do not silently update bundled, plugin, hand-copied, local-path, or user-authored skills. -- Do not run broad skill checks at app startup. +- Do not run broad skill checks at app startup; only re-check previously recorded managed-skill + workflow needs. - Do not infer update scope from Orca's current working directory. ## Managed Skills @@ -37,21 +42,26 @@ Initial support is limited to Orca-owned feature skills: ## Core UX Rule -Auto-update and ask prompts have the same trigger. +Auto-update and ask prompts have the same deferred trigger. ```text User or agent enters a workflow that needs a managed skill --> coordinator checks whether background update is safe +-> Orca records that managed skill need for the next launch +-> after Orca restarts, coordinator checks whether background update is safe -> if safe, update in the background -> if unsafe or update fails and Orca has a safe manual command, show the contextual modal -> otherwise return fallback state without opening an unactionable modal ``` -There is no separate "ask" trigger. Asking is the fallback path for the same intent boundary. +There is no separate "ask" trigger. Asking is the restart-time fallback path for the same recorded +intent boundary. -The automatic-updates setting does not create a new trigger. It only controls whether an eligible stale global install may be updated quietly. Because the setting is off by default, that same trigger shows the update modal each time an update is needed unless the user has explicitly enabled experimental automatic updates. +The automatic-updates setting does not create a new trigger. It only controls whether an eligible +stale global install may be updated quietly when the recorded request is flushed after restart. +Because the setting is off by default, that same restart-time flush shows the update modal when an +update is needed unless the user has explicitly enabled experimental automatic updates. -## Intent Boundary Triggers +## Deferred Intent Triggers | Skill | Trigger | | --- | --- | @@ -60,16 +70,17 @@ The automatic-updates setting does not create a new trigger. It only controls wh | `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. | -Settings pages, setup guide status cards, and generic skill browsing should not trigger automatic updates. They can display status and manual actions. +Settings pages, setup guide status cards, and generic skill browsing should not record deferred +automatic updates. They can display status and manual actions. -The Agents settings pane exposes the opt-in experimental preference as "Automatically update +The Experimental settings pane exposes the opt-in experimental preference as "Automatically update verified Orca skills" with copy explaining that Orca can update verified Orca-managed global -skills in the background only when a workflow needs them and the safe update path has been proven. -Leaving it off means updates are reviewed manually at the same workflow boundaries. +skills in the background only after a workflow has recorded the need and the safe update path has +been proven on restart. Leaving it off means updates are reviewed manually after restart. ## Modal Copy Requirements -When the fallback modal appears from a feature trigger, the modal must name the feature context +When the fallback modal appears after restart, 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. @@ -151,7 +162,10 @@ Responsibilities: - Refresh skill discovery after success. - Return fallback information when auto-update is unsafe or failed. -Renderer surfaces should not independently spawn update commands. They should call the coordinator and then either continue the workflow, show an actionable modal response that includes a manual command, or avoid interrupting for non-actionable fallback state. +Renderer surfaces should not independently spawn update commands. Workflow surfaces should record a +restart prompt request; the restart flush should call the coordinator and then either clear resolved +requests, show an actionable modal response that includes a manual command, or avoid interrupting +for non-actionable fallback state. ## Performance Guardrails @@ -163,15 +177,16 @@ The update path must be cheap when many agents are running. - Share one in-flight promise across all callers. - Cache success for the current app version/session. - Back off after failure. -- Prefer invalidating "needs check" after an Orca app update, then checking only at the next real workflow trigger. +- Prefer invalidating "needs check" after an Orca app update, then checking only after a recorded + workflow need is flushed on restart. Example: ```text 50 agents attempt orchestration --> first attempt asks coordinator for orchestration:host:global --> coordinator starts one update/check --> other 49 attempts join the same in-flight result or use cached success/failure +-> first attempt records orchestration for the next restart +-> repeated attempts dedupe to the same restart prompt request +-> next Orca launch flushes one update/check ``` ## Invalidation @@ -181,7 +196,8 @@ The default invalidation should be app-version based. ```text Orca app updates -> managed skill check state is invalidated --> next workflow trigger for each skill/runtime/scope checks once +-> next workflow trigger records a restart prompt request +-> next Orca launch checks each recorded skill/runtime/scope once -> success is cached for the session/app version ``` @@ -205,9 +221,9 @@ Optional future invalidation: | 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 a workflow triggered it | Modal copy must name the feature context and workspace without guessing whether an agent or user initiated it. | +| Modal appears unexpectedly because a workflow triggered it | Workflow triggers only record restart prompts; 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 leaves automatic updates off | Keep the same triggers, but return the manual update modal for stale global installs without cooling that disabled-path modal away. | +| User leaves automatic updates off | Keep the same deferred triggers, but return the manual update modal for stale global installs after restart 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. | | Upstream `skills` CLI lacks reliable dry-run status | Do not show stale status unless backed by coordinator evidence; use update command only after eligibility passes. | | Supply-chain/source surprise | Limit to Orca-managed skill names and tracked lock entries; do not update arbitrary Skills page entries. | @@ -216,13 +232,14 @@ Optional future invalidation: ## User Flow 1. User starts a workflow that needs a managed skill. -2. Renderer calls the coordinator with skill name and workflow context. -3. Coordinator returns one of: +2. Orca records the skill name and workflow context as a restart prompt request. +3. After Orca restarts, the renderer flushes recorded restart prompt requests. +4. Coordinator returns one of: - `ready`: installed and recently checked. - `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 for actionable setup/manual-review states and keep +5. For `ready` and `updated`, remove the persisted restart prompt request. +6. For `fallback`, show the contextual modal for actionable setup/manual-review states and keep cooldown/unsupported internal states silent. ## Implementation Steps @@ -238,7 +255,9 @@ Optional future invalidation: - failure cooldowns 4. Add IPC: - `skills:ensureManagedReady` -5. Wire the intent boundary triggers: + - `skills:deferManagedReadyPrompt` + - `skills:flushRestartPrompts` +5. Wire the deferred intent boundary triggers: - Linear worktree-from-task and linked Linear agent workflow. - Orchestration workflow start. - Computer Use workflow start or first runtime invocation. @@ -258,17 +277,18 @@ Optional future invalidation: - WSL runtime fallbacks do not open dead-end setup modals until WSL commands are supported. - 50 concurrent requests for one skill produce one update attempt. - Failure cooldown prevents repeated attempts. -- Default-disabled automatic updates show the manual update modal on each stale-update trigger. +- Default-disabled automatic updates show the manual update modal after restart for each recorded + stale-update trigger. - App-version invalidation re-enables a single check after update. -- Agent-triggered fallback modal includes triggering context. +- Agent-triggered fallback modal includes the recorded triggering context after restart. ## Recommended First Version -Ship the coordinator and intent-boundary triggers with modal-first behavior by default. Keep +Ship the coordinator and deferred intent-boundary triggers with restart-prompt behavior by default. Keep automatic updates available as an experimental opt-in for tracked global Orca-managed installs only. When enabled, 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. +restart-time discovery and lockfile validation, then verifies the post-update install before +reporting `updated`. Everything else uses the same deferred trigger and returns fallback state; +actionable fallback states open a contextual modal after restart. 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 a9a43eb0ca5..4d05f7f31f4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,6 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' -import { shouldEmitManagedAgentSkillFallback } from '../shared/skills' import { applyAppIcon } from './app-icon' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' @@ -28,7 +27,7 @@ import { registerCoreHandlers } from './ipc/register-core-handlers' import { initObservability, shutdownObservability } from './observability' import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' -import { sendManagedSkillFallback, sendManagedSkillUpdated } from './ipc/skills' +import { recordManagedSkillRestartPromptRequest } from './skills/managed-skill-restart-prompts' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' import { runManagedHookInstallers } from './agent-hooks/install-telemetry' import { @@ -43,7 +42,6 @@ import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' 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' @@ -1941,17 +1939,12 @@ app.whenReady().then(async () => { managedSkillDiscoveryTarget: { runtime: 'host' }, managedSkillRemoteRuntime: isServeMode, nudgeManagedSkill: async ({ skillName, context, remoteRuntime, discoveryTarget }) => { - const result = await getManagedSkillUpdateCoordinator(store!).ensureManagedReady({ + recordManagedSkillRestartPromptRequest(store!, { skillName, context, ...(remoteRuntime ? { remoteRuntime } : {}), ...(!remoteRuntime && discoveryTarget ? { discoveryTarget } : {}) }) - if (result.status === 'fallback' && shouldEmitManagedAgentSkillFallback(result)) { - sendManagedSkillFallback(result) - } else if (result.status === 'updated') { - sendManagedSkillUpdated(result) - } } }) registerMobileHandlers(runtimeRpc) diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 2bc7c1b7667..7b53039cf3e 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -21,9 +21,7 @@ const { subscribeRemoteRuntimeSharedControlRequestMock, getRemoteRuntimeSharedControlDiagnosticsMock, closeRemoteRuntimeRequestConnectionMock, - ensureManagedReadyMock, - sendManagedSkillFallbackMock, - sendManagedSkillUpdatedMock + recordManagedSkillRestartPromptRequestMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -37,9 +35,7 @@ const { subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(), getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(), closeRemoteRuntimeRequestConnectionMock: vi.fn(), - ensureManagedReadyMock: vi.fn(), - sendManagedSkillFallbackMock: vi.fn(), - sendManagedSkillUpdatedMock: vi.fn() + recordManagedSkillRestartPromptRequestMock: vi.fn() })) vi.mock('electron', () => ({ @@ -65,15 +61,8 @@ vi.mock('./runtime-environment-request-connections', () => ({ closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock })) -vi.mock('../skills/managed-skill-update-coordinator-registry', () => ({ - getManagedSkillUpdateCoordinator: () => ({ - ensureManagedReady: ensureManagedReadyMock - }) -})) - -vi.mock('./skills', () => ({ - sendManagedSkillFallback: sendManagedSkillFallbackMock, - sendManagedSkillUpdated: sendManagedSkillUpdatedMock +vi.mock('../skills/managed-skill-restart-prompts', () => ({ + recordManagedSkillRestartPromptRequest: recordManagedSkillRestartPromptRequestMock })) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' @@ -114,10 +103,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getRemoteRuntimeSharedControlDiagnosticsMock.mockReset() getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null) closeRemoteRuntimeRequestConnectionMock.mockReset() - ensureManagedReadyMock.mockReset() - ensureManagedReadyMock.mockResolvedValue({ status: 'ready' }) - sendManagedSkillFallbackMock.mockReset() - sendManagedSkillUpdatedMock.mockReset() + recordManagedSkillRestartPromptRequestMock.mockReset() }) afterEach(() => { @@ -421,16 +407,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled() }) - it('nudges managed skill readiness after remote runtime feature RPCs and emits fallback setup', async () => { + it('records managed skill restart prompts after remote runtime feature RPCs', async () => { registerRuntimeEnvironmentHandlers({} as never) - ensureManagedReadyMock.mockResolvedValue({ - status: 'fallback', - skillName: 'orchestration', - context: 'agent-orchestration', - runtime: 'host', - scope: 'missing', - reason: 'remote-runtime' - }) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'rpc-orchestration', ok: true, @@ -457,20 +435,12 @@ describe('registerRuntimeEnvironmentHandlers', () => { ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => { - expect(ensureManagedReadyMock).toHaveBeenCalledWith({ + expect(recordManagedSkillRestartPromptRequestMock).toHaveBeenCalledWith(expect.anything(), { skillName: 'orchestration', context: 'agent-orchestration', remoteRuntime: true }) }) - 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 () => { @@ -501,7 +471,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => { - expect(ensureManagedReadyMock).toHaveBeenCalledWith({ + expect(recordManagedSkillRestartPromptRequestMock).toHaveBeenCalledWith(expect.anything(), { skillName: 'orca-cli', context: 'agent-orca-cli', remoteRuntime: true diff --git a/src/main/ipc/runtime-managed-skill-nudge.ts b/src/main/ipc/runtime-managed-skill-nudge.ts index 23257982ae0..fa4d84ee52e 100644 --- a/src/main/ipc/runtime-managed-skill-nudge.ts +++ b/src/main/ipc/runtime-managed-skill-nudge.ts @@ -1,21 +1,14 @@ 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-update-coordinator-registry' -import { sendManagedSkillFallback, sendManagedSkillUpdated } from './skills' +import { recordManagedSkillRestartPromptRequest } from '../skills/managed-skill-restart-prompts' export function createRuntimeManagedSkillNudge(store: Store): RuntimeManagedSkillNudgeHandler { return async ({ skillName, context, remoteRuntime, discoveryTarget }) => { - const result = await getManagedSkillUpdateCoordinator(store).ensureManagedReady({ + recordManagedSkillRestartPromptRequest(store, { skillName, context, ...(remoteRuntime ? { remoteRuntime } : {}), ...(!remoteRuntime && discoveryTarget ? { discoveryTarget } : {}) }) - if (shouldEmitManagedAgentSkillFallback(result)) { - sendManagedSkillFallback(result) - } else if (result.status === 'updated') { - sendManagedSkillUpdated(result) - } } } diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 9e82f375c75..1334f776109 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( () => ({ @@ -33,9 +34,14 @@ import { registerSkillsHandlers } from './skills' describe('registerSkillsHandlers', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }] + let settings = getDefaultSettings('C:\\Users\\alice') const store = { getRepos: vi.fn(() => repos), - getSettings: vi.fn(() => getDefaultSettings('C:\\Users\\alice')) + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) } beforeEach(() => { @@ -43,8 +49,10 @@ describe('registerSkillsHandlers', () => { discoverSkillsMock.mockReset() getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() + settings = getDefaultSettings('C:\\Users\\alice') store.getRepos.mockClear() store.getSettings.mockClear() + store.updateSettings.mockClear() discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { @@ -82,6 +90,28 @@ describe('registerSkillsHandlers', () => { ) => Promise } + function getDeferHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:deferManagedReadyPrompt' + ) + if (!call) { + throw new Error('skills:deferManagedReadyPrompt handler was not registered') + } + return call[1] as (_event: unknown, request: unknown) => Promise + } + + function getFlushHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:flushRestartPrompts' + ) + if (!call) { + throw new Error('skills:flushRestartPrompts handler was not registered') + } + return call[1] as (event: { sender: { send: ReturnType } }) => Promise + } + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { const handler = getDiscoverHandler() @@ -191,6 +221,68 @@ describe('registerSkillsHandlers', () => { expect(discoverSkillsMock).not.toHaveBeenCalled() }) + it('defers managed setup prompts into settings without emitting immediately', async () => { + const handler = getDeferHandler() + + await handler(null, { + skillName: 'orca-linear', + context: 'linear-worktree', + discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' } + }) + + expect(store.updateSettings).toHaveBeenCalledWith({ + managedAgentSkillRestartPromptRequests: [ + { + skillName: 'orca-linear', + context: 'linear-worktree', + discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' } + } + ] + }) + }) + + it('flushes restart prompts through managed ensure and emits actionable fallbacks', async () => { + const handler = getFlushHandler() + const send = vi.fn() + settings = { + ...settings, + managedAgentSkillRestartPromptRequests: [ + { + skillName: 'computer-use', + context: 'agent-computer-use', + remoteRuntime: true + } + ] + } + + const results = await handler({ sender: { send } }) + + expect(results).toMatchObject([ + { + status: 'fallback', + skillName: 'computer-use', + context: 'agent-computer-use', + reason: 'remote-runtime' + } + ]) + expect(send).toHaveBeenCalledWith( + 'skills:managedFallback', + expect.objectContaining({ + status: 'fallback', + skillName: 'computer-use', + context: 'agent-computer-use', + reason: 'remote-runtime' + }) + ) + expect(settings.managedAgentSkillRestartPromptRequests).toEqual([ + { + skillName: 'computer-use', + context: 'agent-computer-use', + remoteRuntime: true + } + ]) + }) + it('does not emit managed fallback events for cooldown results', async () => { const handler = getEnsureHandler() const send = vi.fn() diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index dcf7e147304..b9d1d928d8e 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -14,6 +14,10 @@ import { publishManagedSkillFallback, publishManagedSkillUpdated } from '../skills/managed-skill-events' +import { + flushManagedSkillRestartPromptRequests, + recordManagedSkillRestartPromptRequest +} from '../skills/managed-skill-restart-prompts' type SkillDiscoveryRuntimeTarget = | { runtime: 'host' } @@ -84,6 +88,29 @@ export function registerSkillsHandlers(store: Store): void { return result } ) + + ipcMain.handle( + 'skills:deferManagedReadyPrompt', + (_event, request: ManagedAgentSkillEnsureRequest): void => { + recordManagedSkillRestartPromptRequest(store, request) + } + ) + + ipcMain.handle( + 'skills:flushRestartPrompts', + async (event): Promise => { + const results = await flushManagedSkillRestartPromptRequests(store) + for (const result of results) { + if (shouldEmitManagedAgentSkillFallback(result)) { + publishManagedSkillFallback(result) + event.sender.send('skills:managedFallback', result) + } else if (result.status === 'updated') { + sendManagedSkillUpdated(result) + } + } + return results + } + ) } export function sendManagedSkillFallback(result: ManagedAgentSkillEnsureResult): void { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 054449a7493..0ebb225bbea 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -506,6 +506,7 @@ describe('Store', () => { expect(settings.experimentalTerminalAttention).toBe(false) expect(settings.experimentalNewWorktreeCardStyle).toBe(true) expect(settings.managedAgentSkillSetupPromptsEnabled).toBe(true) + expect(settings.managedAgentSkillRestartPromptRequests).toEqual([]) expect(settings.managedAgentSkillBackgroundUpdatesEnabled).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) diff --git a/src/main/skills/managed-skill-restart-prompts.ts b/src/main/skills/managed-skill-restart-prompts.ts new file mode 100644 index 00000000000..7691ffc05ef --- /dev/null +++ b/src/main/skills/managed-skill-restart-prompts.ts @@ -0,0 +1,122 @@ +import type { Store } from '../persistence' +import type { + ManagedAgentSkillContext, + ManagedAgentSkillEnsureRequest, + ManagedAgentSkillEnsureResult, + SkillDiscoveryTarget +} from '../../shared/skills' +import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills' +import { getManagedSkillUpdateCoordinator } from './managed-skill-update-coordinator-registry' +import { isManagedAgentSkillName } from './managed-skill-update-contract' + +const MAX_RESTART_PROMPT_REQUESTS = 20 +const MANAGED_SKILL_CONTEXTS = new Set([ + 'linear-worktree', + 'agent-orchestration', + 'agent-computer-use', + 'agent-orca-cli' +]) + +export function recordManagedSkillRestartPromptRequest( + store: Store, + request: ManagedAgentSkillEnsureRequest +): void { + const normalizedRequest = normalizeManagedSkillRestartPromptRequest(request) + if (!normalizedRequest) { + return + } + const current = normalizeManagedSkillRestartPromptRequests( + store.getSettings().managedAgentSkillRestartPromptRequests + ) + const byKey = new Map( + current.map((entry) => [makeManagedSkillRestartPromptRequestKey(entry), entry]) + ) + byKey.set(makeManagedSkillRestartPromptRequestKey(normalizedRequest), normalizedRequest) + const next = Array.from(byKey.values()).slice(-MAX_RESTART_PROMPT_REQUESTS) + if (JSON.stringify(next) === JSON.stringify(current)) { + return + } + store.updateSettings({ managedAgentSkillRestartPromptRequests: next }) +} + +export async function flushManagedSkillRestartPromptRequests( + store: Store +): Promise { + const current = normalizeManagedSkillRestartPromptRequests( + store.getSettings().managedAgentSkillRestartPromptRequests + ) + const results: ManagedAgentSkillEnsureResult[] = [] + const retained: ManagedAgentSkillEnsureRequest[] = [] + const coordinator = getManagedSkillUpdateCoordinator(store) + + for (const request of current) { + const result = await coordinator.ensureManagedReady({ ...request, force: true }) + results.push(result) + if (shouldEmitManagedAgentSkillFallback(result)) { + retained.push(request) + } + } + + if (JSON.stringify(retained) !== JSON.stringify(current)) { + store.updateSettings({ managedAgentSkillRestartPromptRequests: retained }) + } + return results +} + +export function normalizeManagedSkillRestartPromptRequests( + value: unknown +): ManagedAgentSkillEnsureRequest[] { + if (!Array.isArray(value)) { + return [] + } + const byKey = new Map() + for (const entry of value) { + const request = normalizeManagedSkillRestartPromptRequest(entry) + if (!request) { + continue + } + byKey.set(makeManagedSkillRestartPromptRequestKey(request), request) + } + return Array.from(byKey.values()).slice(-MAX_RESTART_PROMPT_REQUESTS) +} + +function normalizeManagedSkillRestartPromptRequest( + value: unknown +): ManagedAgentSkillEnsureRequest | null { + if ( + !isRecord(value) || + typeof value.skillName !== 'string' || + !isManagedAgentSkillName(value.skillName) + ) { + return null + } + if (typeof value.context !== 'string' || !MANAGED_SKILL_CONTEXTS.has(value.context)) { + return null + } + const request: ManagedAgentSkillEnsureRequest = { + skillName: value.skillName, + context: value.context as ManagedAgentSkillContext + } + if (value.remoteRuntime === true) { + request.remoteRuntime = true + } + if (isRecord(value.discoveryTarget)) { + request.discoveryTarget = value.discoveryTarget as SkillDiscoveryTarget + } + return request +} + +function makeManagedSkillRestartPromptRequestKey( + request: ManagedAgentSkillEnsureRequest +): string { + return JSON.stringify({ + skillName: request.skillName, + context: request.context, + remoteRuntime: request.remoteRuntime === true, + discoveryTarget: request.discoveryTarget ?? null + }) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 21023d0a6aa..56d7c538538 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2033,6 +2033,8 @@ export type PreloadApi = { ensureManagedReady: ( request: ManagedAgentSkillEnsureRequest ) => Promise + deferManagedReadyPrompt: (request: ManagedAgentSkillEnsureRequest) => Promise + flushRestartPrompts: () => Promise onManagedFallback: (callback: (event: ManagedAgentSkillFallback) => void) => () => void onManagedUpdated: (callback: (event: ManagedAgentSkillUpdated) => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8eca0595a30..9012503dbf2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2015,6 +2015,12 @@ const api = { ): Promise => ipcRenderer.invoke('skills:ensureManagedReady', request), + deferManagedReadyPrompt: (request: ManagedAgentSkillEnsureRequest): Promise => + ipcRenderer.invoke('skills:deferManagedReadyPrompt', request), + + flushRestartPrompts: (): Promise => + ipcRenderer.invoke('skills:flushRestartPrompts'), + onManagedFallback: (callback: (event: ManagedAgentSkillFallback) => void): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts index 7a8227d56c1..1671d7fcd13 100644 --- a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts +++ b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.test.ts @@ -260,6 +260,9 @@ describe('ManagedAgentSkillSetupDialogHost rendering', () => { let fallbackListener: ((event: ManagedAgentSkillFallback) => void) | null = null window.api = { skills: { + ensureManagedReady: vi.fn(), + deferManagedReadyPrompt: vi.fn(), + flushRestartPrompts: vi.fn().mockResolvedValue([]), onManagedFallback: vi.fn((listener) => { fallbackListener = listener return vi.fn() @@ -321,6 +324,29 @@ describe('ManagedAgentSkillSetupDialogHost rendering', () => { root.unmount() }) + it('flushes deferred restart prompts once after settings hydrate', async () => { + const { root } = await renderHost() + + await vi.waitFor(() => { + expect(window.api.skills.flushRestartPrompts).toHaveBeenCalledOnce() + }) + + root.unmount() + }) + + it('does not flush deferred restart prompts when prompts are disabled at startup', async () => { + storeMocks.state.settings = { + ...getDefaultSettings('/tmp'), + managedAgentSkillSetupPromptsEnabled: false + } + const { root } = await renderHost() + + await Promise.resolve() + + expect(window.api.skills.flushRestartPrompts).not.toHaveBeenCalled() + root.unmount() + }) + it('keeps the modal open when Don’t show again cannot persist', async () => { storeMocks.state.updateSettings.mockResolvedValue(undefined) const { root, emitFallback } = await renderHost() diff --git a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx index ed1f6be2596..ca2a89e212b 100644 --- a/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx +++ b/src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx @@ -45,7 +45,9 @@ export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null { }) const queuedKeysRef = useRef(new Set()) const snoozedKeysRef = useRef(new Set()) + const flushedRestartPromptsRef = useRef(false) const [rechecking, setRechecking] = useState(false) + const settings = useAppStore((state) => state.settings) const setupPromptsEnabled = useAppStore( (state) => state.settings?.managedAgentSkillSetupPromptsEnabled !== false ) @@ -105,6 +107,18 @@ export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null { } }, [enqueueFallback]) + useEffect(() => { + if (!settings || flushedRestartPromptsRef.current) { + return + } + flushedRestartPromptsRef.current = true + if (!setupPromptsEnabled) { + return + } + // Why: workflow-triggered setup nudges are persisted and surfaced only after restart. + void window.api.skills.flushRestartPrompts().catch(() => {}) + }, [settings, setupPromptsEnabled]) + const contextActionLabel = active?.manualCommand?.kind === 'install' ? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.install', 'Install') diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 677933c696c..5071797aded 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -84,6 +84,7 @@ const mockApi = { get: vi.fn(() => ({ platform: 'win32' })) }, skills: { + deferManagedReadyPrompt: vi.fn().mockResolvedValue(undefined), ensureManagedReady: vi.fn().mockResolvedValue({ status: 'fallback', skillName: 'orca-linear', @@ -2865,7 +2866,7 @@ describe('createWorktree base status merge', () => { 'ENG-123' ) - expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({ + expect(mockApi.skills.deferManagedReadyPrompt).toHaveBeenCalledWith({ skillName: 'orca-linear', context: 'linear-worktree', remoteRuntime: true @@ -2908,7 +2909,7 @@ describe('createWorktree base status merge', () => { 'ENG-123' ) - expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({ + expect(mockApi.skills.deferManagedReadyPrompt).toHaveBeenCalledWith({ skillName: 'orca-linear', context: 'linear-worktree', discoveryTarget: expect.objectContaining({ @@ -2954,7 +2955,7 @@ describe('createWorktree base status merge', () => { 'ENG-123' ) - expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({ + expect(mockApi.skills.deferManagedReadyPrompt).toHaveBeenCalledWith({ skillName: 'orca-linear', context: 'linear-worktree', discoveryTarget: { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index abbe02e33b0..25c7440026d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -180,7 +180,7 @@ function nudgeLinearManagedSkillSetup( : { runtime: 'host' as const, projectRootPath } : null void window.api.skills - .ensureManagedReady({ + .deferManagedReadyPrompt({ skillName: ORCA_LINEAR_SKILL_NAME, context: 'linear-worktree', ...(localDiscoveryTarget ? { discoveryTarget: localDiscoveryTarget } : {}), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6dd18deb6e4..50e24bb5444 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2606,6 +2606,8 @@ function createSkillsApi(): NonNullable['skills']> { } return Promise.resolve(result satisfies ManagedAgentSkillEnsureResult) }, + deferManagedReadyPrompt: () => Promise.resolve(), + flushRestartPrompts: () => Promise.resolve([]), onManagedFallback: (callback) => { fallbackCallbacks.add(callback) ensureManagedEventSubscription() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7cfb46cdb72..17dc18e4f60 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -325,6 +325,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { agentYoloDefaultsMigrated: true, agentStatusHooksEnabled: true, managedAgentSkillSetupPromptsEnabled: true, + managedAgentSkillRestartPromptRequests: [], // Why: keep silent skill mutation opt-in while the verified updater is experimental. managedAgentSkillBackgroundUpdatesEnabled: false, tabAutoGenerateTitle: false, diff --git a/src/shared/types.ts b/src/shared/types.ts index 05329a66bd3..b98f5ebe92c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,7 @@ import type { GlobalWindowsRuntimeDefault, LocalWindowsRuntimePreference } from './project-execution-runtime' +import type { ManagedAgentSkillEnsureRequest } from './skills' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -2762,6 +2763,9 @@ export type GlobalSettings = { /** Why: users can silence managed-skill setup prompts from the modal, but * the prompt should be easy to restore from normal Agents settings. */ managedAgentSkillSetupPromptsEnabled?: boolean + /** Why: workflow-triggered skill setup nudges should surface after restart, + * not while the user is entering the workflow that caused the check. */ + managedAgentSkillRestartPromptRequests?: ManagedAgentSkillEnsureRequest[] /** Why: verified background updates mutate user skill files, so this remains * opt-in while experimental; off makes updates manual. */ managedAgentSkillBackgroundUpdatesEnabled?: boolean