diff --git a/AGENTS.md b/AGENTS.md index 57c6b4fbe..a498f8f72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,14 @@ and Web/mobile app sources. `MachineMeta.protocolCapabilities`; never infer support from the CLI release version. Missing capabilities mean legacy/unsupported. Advertised set and version checks share one binding in `packages/shared/src/machine-protocol-capabilities.ts` so a key never travels without its version. +- The machine owns the deadline for an ACP startup round-trip and answers with its own + failure reason. A client timeout over the same request is a backstop for a daemon that + died without replying, so it derives from + `packages/shared/src/acp-startup-budget.ts` and stays strictly above the machine's worst + case; never set a second, smaller client deadline that expires work the machine is still doing. + That worst case is the whole recovery policy, not one attempt: a cold `npx` initialize + timeout makes the machine purge and retry, and every retry gets the full per-attempt + timeouts again, so the attempt count lives in the same shared binding as the timeouts. - Managed runtime downloads default to the public R2-backed channel owned by `packages/platform/src/runtime-artifacts.ts`; local and cloud assembly must use that same constant. `LODY_RUNTIME_BASE_URL` is only an explicit mirror override. diff --git a/apps/cli/src/agent/acp-npx-startup-policy.ts b/apps/cli/src/agent/acp-npx-startup-policy.ts index f2595d3b6..f1dd33e11 100644 --- a/apps/cli/src/agent/acp-npx-startup-policy.ts +++ b/apps/cli/src/agent/acp-npx-startup-policy.ts @@ -1,3 +1,4 @@ +import { ACP_COLD_NPX_INIT_TIMEOUT_MS, ACP_NPX_STARTUP_MAX_ATTEMPTS } from '@lody/shared'; import type { AcpStartupTimeoutOptions } from './agent-client'; import { AcpTimeoutError } from './agent-client'; import type { Logger } from '@/utils/logger'; @@ -14,7 +15,14 @@ import { type NpxCacheIo, } from './npx-cache'; -export const COLD_NPX_INIT_TIMEOUT_MS = 300_000; +/** + * Re-exported so the client-side backstop in `@lody/shared/acp-startup-budget` + * and this startup path cannot drift into two different worst cases. The + * backstop has to cover every attempt this policy may make, not just one, so + * the attempt count is part of the same shared binding as the timeout. + */ +export const COLD_NPX_INIT_TIMEOUT_MS = ACP_COLD_NPX_INIT_TIMEOUT_MS; +export const DEFAULT_NPX_STARTUP_MAX_ATTEMPTS = ACP_NPX_STARTUP_MAX_ATTEMPTS; export type NpxStartupAttemptInput = { attempt: number; @@ -99,7 +107,7 @@ export async function runNpxStartupWithRecovery( }); } - const maxAttempts = options.maxAttempts ?? 3; + const maxAttempts = options.maxAttempts ?? DEFAULT_NPX_STARTUP_MAX_ATTEMPTS; const coldInitTimeoutMs = options.coldInitTimeoutMs ?? COLD_NPX_INIT_TIMEOUT_MS; const npxCacheRoot = getConfiguredNpxCacheRoot(options.env); const roots = options.npxCacheRoots ?? (npxCacheRoot ? [npxCacheRoot] : undefined); diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 8aa975aa4..8611912ed 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -36,6 +36,8 @@ import { buildAskUserQuestionElicitationResponse, formatMcpResolutionProblem, getServerNow, + ACP_INIT_TIMEOUT_MS as DEFAULT_ACP_INIT_TIMEOUT_MS, + ACP_NEW_SESSION_TIMEOUT_MS as DEFAULT_ACP_NEW_SESSION_TIMEOUT_MS, } from '@lody/shared'; import { getLocalControlSocketPath } from '@lody/shared/node/local-ipc'; import { getLodyMcpHttpEndpoint } from '@/mcp/lody-mcp-http-server'; @@ -1718,7 +1720,10 @@ export class AgentClient implements acp.Client { // connection.initialize() internally spawns the CLI process and waits for it to respond. // Missing dependencies or local runtime issues can hang this operation indefinitely. // Apply a hard timeout so startup fails fast. - const ACP_INIT_TIMEOUT_MS = Math.max(0, timeoutOptions.initTimeoutMs ?? 120_000); // 2 minutes default + const ACP_INIT_TIMEOUT_MS = Math.max( + 0, + timeoutOptions.initTimeoutMs ?? DEFAULT_ACP_INIT_TIMEOUT_MS + ); let initResponse: acp.InitializeResponse; try { @@ -2067,7 +2072,10 @@ export class AgentClient implements acp.Client { // 2. Start the internal query system which spawns another subprocess // 3. Call query.supportedModels() and query.supportedCommands() // Any of these can hang due to runtime/environment issues. Apply a hard timeout. - const ACP_NEW_SESSION_TIMEOUT_MS = Math.max(0, timeoutOptions.newSessionTimeoutMs ?? 120_000); // 2 minutes default + const ACP_NEW_SESSION_TIMEOUT_MS = Math.max( + 0, + timeoutOptions.newSessionTimeoutMs ?? DEFAULT_ACP_NEW_SESSION_TIMEOUT_MS + ); try { sessionResponse = await withTimeout( diff --git a/apps/cli/src/agent/npx-cache.test.ts b/apps/cli/src/agent/npx-cache.test.ts index fcb00c38d..f58c14164 100644 --- a/apps/cli/src/agent/npx-cache.test.ts +++ b/apps/cli/src/agent/npx-cache.test.ts @@ -2,9 +2,16 @@ import { homedir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS, + ACP_INIT_TIMEOUT_MS, + ACP_NEW_SESSION_TIMEOUT_MS, + ACP_NPX_STARTUP_MAX_ATTEMPTS, +} from '@lody/shared'; import { AcpTimeoutError } from './agent-client'; import { COLD_NPX_INIT_TIMEOUT_MS, + DEFAULT_NPX_STARTUP_MAX_ATTEMPTS, runNpxStartupWithRecovery, type NpxStartupAttemptInput, } from './acp-npx-startup-policy'; @@ -509,6 +516,49 @@ describe('runNpxStartupWithRecovery', () => { expect(new Set(io.removed)).toEqual(new Set([npxRoot, cacache])); }); + it('keeps a whole cold-timeout retry run inside the client backstop', async () => { + // The client budget is derived from this loop, so the loop is what has to + // prove the budget: every attempt it is allowed to make, at the timeouts it + // hands each attempt, must still fit under the backstop. Otherwise the + // client reports a timeout while the machine is mid-recovery. + const attempts: NpxStartupAttemptInput[] = []; + const cache = getLodyNpmCacheDir(); + const npxRoot = join(cache, '_npx'); + const io = makeIo({ dirs: { [npxRoot]: [], [join(cache, '_cacache')]: [] } }); + + await expect( + runNpxStartupWithRecovery({ + command: 'npx', + args: npxArgs(), + env: { npm_config_cache: cache }, + logger, + logPrefix: '[test]', + npxCacheIo: io, + npxCacheRoots: [npxRoot], + getStderrTail: () => '', + attempt: async (input) => { + attempts.push(input); + throw new AcpTimeoutError( + 'connection.initialize', + input.startupTimeouts?.initTimeoutMs ?? ACP_INIT_TIMEOUT_MS, + `session-${input.attempt}` + ); + }, + }) + ).rejects.toBeInstanceOf(AcpTimeoutError); + + expect(DEFAULT_NPX_STARTUP_MAX_ATTEMPTS).toBe(ACP_NPX_STARTUP_MAX_ATTEMPTS); + expect(attempts).toHaveLength(ACP_NPX_STARTUP_MAX_ATTEMPTS); + const worstCaseMs = attempts.reduce( + (total, input) => + total + + (input.startupTimeouts?.initTimeoutMs ?? ACP_INIT_TIMEOUT_MS) + + (input.startupTimeouts?.newSessionTimeoutMs ?? ACP_NEW_SESSION_TIMEOUT_MS), + 0 + ); + expect(worstCaseMs).toBeLessThan(ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS); + }); + it('refreshes stale npm metadata online once for a missing exact version', async () => { const attempts: NpxStartupAttemptInput[] = []; const io = makeIo({}); diff --git a/apps/electron/src/main/services/cli-service.ts b/apps/electron/src/main/services/cli-service.ts index 2e12242f8..a3d37c349 100644 --- a/apps/electron/src/main/services/cli-service.ts +++ b/apps/electron/src/main/services/cli-service.ts @@ -33,6 +33,7 @@ import { makeLocalProbeClientAuto } from '@lody/shared/node/local-ipc' import { getLodyDataDir } from '@lody/shared/node/installation-profile' +import { ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS } from '@lody/shared/acp-startup-budget' import type { LocalProjectControlRequest, LocalProjectControlResponse, @@ -72,7 +73,12 @@ const CLI_ELECTRON_SESSION_TOKEN_ENV = 'LODY_ELECTRON_SESSION_TOKEN' // See apps/cli/src/commands/start.ts:ELECTRON_SESSION_USER_ID_ENV for rationale. const CLI_ELECTRON_SESSION_USER_ID_ENV = 'LODY_ELECTRON_SESSION_USER_ID' const LOCAL_SESSION_CONTROL_TIMEOUT_MS = 10_000 -const LOCAL_SESSION_CONTROL_ACP_REFRESH_TIMEOUT_MS = 120_000 +// The machine owns this deadline and answers with its own failure reason. This +// is only a backstop for a daemon that died without replying, so it is derived +// from the machine's worst case rather than set to a second, smaller number: +// a 120s socket timeout here expired requests the CLI was still working on +// (a cold `npx` init alone may run 300s) and reported them as our timeout. +const LOCAL_SESSION_CONTROL_ACP_REFRESH_TIMEOUT_MS = ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS // Downloading + unpacking a registry agent binary can take minutes on a slow // link, so the local-control request must outlive the default before falling // back to Streams RPC. diff --git a/locales/en.json b/locales/en.json index d239dbfc1..11f18ca74 100644 --- a/locales/en.json +++ b/locales/en.json @@ -991,6 +991,8 @@ "onboarding.projects.skip": "Skip for now", "onboarding.projects.waitingLocalAgent": "Waiting for the local agent to connect…", "onboarding.projects.title": "Pick a project to start with", + "onboarding.providers.activityRuntimeFailed": "Runtime failed", + "onboarding.providers.activitySlow": "Taking longer", "onboarding.providers.addAnother": "Add another Agent", "onboarding.providers.addFirst": "Add your first Agent", "onboarding.providers.activityChecking": "Checking", @@ -1000,6 +1002,7 @@ "onboarding.providers.activityStarting": "Starting", "onboarding.providers.activityVerifying": "Verifying", "onboarding.providers.description": "Add an Agent now. Lody will continue setup and let you know if anything needs your attention.", + "onboarding.providers.failedAction": "Failed", "onboarding.providers.failureReasonA11y": "Failed: {{reason}}", "onboarding.providers.failureReasonTitle": "Why it failed", "onboarding.providers.localAgentUnreachable": "Could not reach the local agent. Please restart Lody and try again.", @@ -1010,6 +1013,11 @@ "onboarding.providers.showLess": "Show less", "onboarding.providers.showMore": "+{{count}} more", "onboarding.providers.skip": "Skip for now", + "onboarding.providers.slowWaitDetail": "Agent setup is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.", + "onboarding.providers.slowWaitCopied": "Setup details copied", + "onboarding.providers.slowWaitCopy": "Copy setup details", + "onboarding.providers.slowWaitCopyFailed": "Could not copy setup details", + "onboarding.providers.slowWaitTitle": "This is taking longer than usual", "onboarding.providers.statusFailed": "Failed", "onboarding.providers.statusNeedsAuth": "Sign in", "onboarding.providers.statusPassed": "Verified", @@ -1025,6 +1033,7 @@ "onboarding.preview.agentVerifying": "Verifying the agent runtime…", "onboarding.preview.conversationStarting": "Starting your first task…", "onboarding.preview.projectImporting": "Adding project…", + "onboarding.providers.workingSeconds": "{{seconds}}s", "onboarding.shell.stepCounter": "Step {{current}} of {{total}}", "onboarding.summary.description": "You can add Agents and projects later from Settings.", "onboarding.summary.agent": "Agent", @@ -1034,7 +1043,9 @@ "onboarding.summary.agentRetryHint": "Agent setup can be retried here.", "onboarding.summary.failedDescription": "Your Agent could not finish setup. Retry here or enter Lody and finish later.", "onboarding.summary.failedTitle": "Agent setup needs attention", - "onboarding.summary.failureCode": "Failure code: {{code}}", + "onboarding.summary.failure.runtimeInstallFailed": "Lody could not download the Agent runtime. Check your connection and try again.", + "onboarding.summary.failure.runtimeUnavailable": "This Agent is not available on the selected machine. Update Lody or choose another machine in Settings.", + "onboarding.summary.failure.verificationFailed": "Lody could not verify this Agent. Check its sign-in or credentials and try again.", "onboarding.summary.open": "Open Lody", "onboarding.summary.notConfigured": "Not configured", "onboarding.summary.notSelected": "Not selected", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8c0e272a0..45964e0d1 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -991,6 +991,8 @@ "onboarding.projects.skip": "暂时跳过", "onboarding.projects.waitingLocalAgent": "正在等待本机 Agent 连接…", "onboarding.projects.title": "选择一个项目开始", + "onboarding.providers.activityRuntimeFailed": "运行时失败", + "onboarding.providers.activitySlow": "耗时偏长", "onboarding.providers.addAnother": "再添加一个 Agent", "onboarding.providers.addFirst": "添加第一个 Agent", "onboarding.providers.activityChecking": "检查中", @@ -1000,6 +1002,7 @@ "onboarding.providers.activityStarting": "启动中", "onboarding.providers.activityVerifying": "校验中", "onboarding.providers.description": "先添加一个 Agent。Lody 会继续完成配置,并在需要你处理时提醒你。", + "onboarding.providers.failedAction": "失败", "onboarding.providers.failureReasonA11y": "失败:{{reason}}", "onboarding.providers.failureReasonTitle": "失败原因", "onboarding.providers.localAgentUnreachable": "无法连接本机 Agent。请重启 Lody 后再试。", @@ -1010,6 +1013,11 @@ "onboarding.providers.showLess": "收起", "onboarding.providers.showMore": "更多 +{{count}}", "onboarding.providers.skip": "稍后再配置", + "onboarding.providers.slowWaitDetail": "Agent 配置仍在进行。首次运行可能需要下载并解包 Agent。你可以继续下一步,Lody 会在后台完成。", + "onboarding.providers.slowWaitCopied": "已复制配置详情", + "onboarding.providers.slowWaitCopy": "复制配置详情", + "onboarding.providers.slowWaitCopyFailed": "复制配置详情失败", + "onboarding.providers.slowWaitTitle": "这次等待比平时更久", "onboarding.providers.statusFailed": "失败", "onboarding.providers.statusNeedsAuth": "需要登录", "onboarding.providers.statusPassed": "已验证", @@ -1025,6 +1033,7 @@ "onboarding.preview.agentVerifying": "正在验证 Agent runtime…", "onboarding.preview.conversationStarting": "正在启动你的第一个任务…", "onboarding.preview.projectImporting": "正在添加项目…", + "onboarding.providers.workingSeconds": "{{seconds}} 秒", "onboarding.shell.stepCounter": "第 {{current}} / {{total}} 步", "onboarding.summary.description": "之后可以在设置中添加 Agent 和项目。", "onboarding.summary.agent": "Agent", @@ -1034,7 +1043,9 @@ "onboarding.summary.agentRetryHint": "你可以在这里重试 Agent 配置。", "onboarding.summary.failedDescription": "Agent 未能完成配置。你可以在这里重试,也可以先进入 Lody 稍后处理。", "onboarding.summary.failedTitle": "Agent 配置需要处理", - "onboarding.summary.failureCode": "失败代码:{{code}}", + "onboarding.summary.failure.runtimeInstallFailed": "Lody 无法下载 Agent 运行环境。请检查网络连接后重试。", + "onboarding.summary.failure.runtimeUnavailable": "所选机器无法使用这个 Agent。请更新 Lody,或前往设置选择其他机器。", + "onboarding.summary.failure.verificationFailed": "Lody 无法验证这个 Agent。请检查登录状态或凭据后重试。", "onboarding.summary.open": "打开 Lody", "onboarding.summary.notConfigured": "尚未配置", "onboarding.summary.notSelected": "尚未选择", diff --git a/packages/components/src/components/onboarding/AGENTS.md b/packages/components/src/components/onboarding/AGENTS.md index 60425074b..766497ba9 100644 --- a/packages/components/src/components/onboarding/AGENTS.md +++ b/packages/components/src/components/onboarding/AGENTS.md @@ -7,14 +7,18 @@ - User-facing onboarding copy calls the execution choice an Agent. Reserve Provider for Settings configuration management and internal `AgentConfig`/`ProviderSetup` state. - Completion stays in the existing router and navigates to the created session when one exists. Reload recovery must target the normal product root after completion. - Desktop onboarding owns the app theme for its whole route lifetime: enter and reload in `light`, and restore the persisted source to `system` only after completion succeeds or the route unmounts. +- Managed built-in runtimes prefetch CONCURRENTLY, starting when onboarding mounts and running through the intro ceremony. They are independent artifacts keyed by agent type and installs are deduped per machine and agent, so serial prefetch only made the felt wait the sum of every download rather than the longest. A selected provider moves to the front of the launch order; it never restarts or waits behind work already in flight. This stays off the renderer's critical path because `handleMachineAcpBinaryProgress` fans out only to live listeners and otherwise records a snapshot: background prefetch must not subscribe to per-percent progress, or a concurrent download becomes a render storm over the ceremony animation. - `ceremony/intro-sequence.tsx` owns the four-beat illustrated intro. Keep its approved assets and direction in `intro-illustration-direction.md`; setup screens must not replace it with a generic welcome card. - Setup screens use the real `TourStill` product composition. Its Browser beat includes the production Visual Annotation surfaces; do not replace the tour with a hand-built mock. - `TourApp` reuses production components against fixture state, so it must remain inside `TourCloudBoundary`. The boundary owns fixture identity, workspace, authentication, and cloud operations; no tour child may observe or call the outer app's cloud adapter. - The tour's runtime is a stand-in on BOTH planes: `TourCloudBoundary` for cloud operations and `createTourRepo` for `runtime.repo`. The reused components read as well as write — the composer opens the workspace catalog and machine Flock documents — so every tour document must open, read empty, and report its first remote sync as done, and writes on either plane must reject rather than silently succeed. Supply the missing plane; do not fork a repo-free copy of a product component to avoid it. -- Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot, and late results must not commit after edit, delete, replacement, or unmount. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button, and indeterminate work uses its neutral label. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. +- Provider rows keep the last durable result separate from request-scoped activity, and runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot. AgentConfig tests and ProviderSetup rows share the compact progress-button treatment; determinate progress fills it. Do not add a second activity row. A failed result keeps its reason inspectable from the badge until success or a config mutation clears it. A status that already failed (`error`, `unsupported-platform`, `incompatible-host`) resolves to `runtime-failed` and wears the failure tone, never back to `checking-runtime`: the response owns the durable reason, but in-flight activity must not present a known failure as work in progress. +- Wait escalation is one binding, `providerWaitEscalation`: `normal` names the stage, `measured` adds seconds, `exceptional` stops calling it ordinary. It changes what the UI CLAIMS, never inventing a denominator. The clock is REQUEST-scoped, so exceptional copy speaks of the whole setup, never the current stage — that would pin the number on work a second old. `buildProviderWaitReport` is that tier's pasteable block: unlocalized, phase ids, pointer-independent. +- Leaving the step detaches probes, never aborts: the machine drops a refresh once its last consumer leaves, so unmount-abort made "continue" mean "give up on this agent". No late result may commit after edit, delete, replace, or unmount, but only the first three cancel (`invalidate`); `detachAll` only blocks the commit — which is what lets copy promise background work. - The first-task primary action never strands onboarding behind run prerequisites or Session persistence. It requests product navigation immediately; when that navigation succeeds and a runnable prompt exists, Session creation and dispatch continue only as best-effort background work and never navigate or delay entry into the product. Failed navigation creates no Session and remains retryable. An empty task becomes Enter Lody rather than a disabled final action. - Workspace is required product context when the platform exposes multi-workspace onboarding, so it has no Skip or Enter Lody action. A failed workspace-list read keeps the investigation detail and a real platform-backed Retry action. Mutations use bounded waits: after a stale write the UI releases its attempt lock so the user may retry or go back, while late results never navigate or clear a newer attempt. A slow slug availability query is still pending, not failed: explain the slow network, keep Create disabled until the server answers, and let the user restart the check without treating elapsed time as validation. Query errors stay inside the slug field with their detail and Retry. A slug-less existing workspace is repaired inline because its Settings route is unreachable until the slug exists. - Every onboarding error path writes its underlying error or failure detail to `console.error` even when the UI also shows an inline message or toast. Recoverable user actions must become retryable again after failure; never leave an error with only explanatory copy when the same operation can be attempted safely. - Desktop onboarding analytics use the shared flow/step/operation/completion event contract and one anonymous session-scoped `flow_id`. Capture is hard-gated by the platform `telemetry` capability, so the OSS local composition emits nothing. Properties may contain fixed state, action, failure-code, attempt, count, and duration values; never send names, slugs, repository paths, prompts, raw error messages, or other user-authored text. - First Task keeps an exact published `AgentConfig` selection and may switch only among published configs on the selected project's machine. It never falls back to another config when that selection disappears. Skipping First Task completes onboarding without creating a Session, first turn, or dispatch request. - Skipping provider setup completes onboarding as an exploration path. Its summary must not claim the product is ready to run; it sends the user into Lody and points later agent setup to Settings. This is distinct from the pending-setup summary, which uses a compact status table and says setup is still progressing without guessing whether the current work is download, installation, verification, authentication, or machine availability. The summary reads the live ProviderSetupTask: a durable `failed` status shows as setup failed (never as still progressing), and a deleted task shows as not configured — unless the machine has already replaced it with a published AgentConfig under the same id, which reads as ready. +- Provider setup failure codes are internal diagnostics. Onboarding maps them to specific, actionable user copy and never prints the raw enum in the UI. diff --git a/packages/components/src/components/onboarding/index.ts b/packages/components/src/components/onboarding/index.ts index e0a327a1b..c653bf078 100644 --- a/packages/components/src/components/onboarding/index.ts +++ b/packages/components/src/components/onboarding/index.ts @@ -18,6 +18,10 @@ export { } from './screens/providers-screen'; export type { DesktopOnboardingProviderSelection } from '@/atoms/onboarding'; export type { ProviderTestActivity } from './provider-test-state'; +export { + PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS, + PROVIDER_WAIT_MEASURED_AFTER_SECONDS, +} from './provider-test-state'; export { ProjectsScreen, ProjectsScreenView, diff --git a/packages/components/src/components/onboarding/onboarding-loading.tsx b/packages/components/src/components/onboarding/onboarding-loading.tsx index b020d86bd..d23ef3196 100644 --- a/packages/components/src/components/onboarding/onboarding-loading.tsx +++ b/packages/components/src/components/onboarding/onboarding-loading.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { motion } from 'framer-motion'; +import { motion, useReducedMotion } from 'framer-motion'; import type { CliRuntimeStartupStage, ElectronCliPhase, ElectronCliState } from '@lody/shared'; import { cn } from '@/lib/utils'; import { getIpcServices, onIpcEvent, sendIpc } from '@/lib/electron-ipc-client'; @@ -29,6 +29,7 @@ export function OnboardingLoadingView({ bypassed = false, }: OnboardingLoadingViewProps) { const { t } = useTranslation(); + const shouldReduceMotion = useReducedMotion(); const reachedIndex = useMemo(() => { if (phase === 'running') return STARTUP_STAGES.length - 1; @@ -94,6 +95,7 @@ export function OnboardingLoadingView({ {STARTUP_STAGES.map((s, i) => { const reached = i <= reachedIndex; const active = i === reachedIndex && phase !== 'running'; + const stalledProbe = bypassed && active && i < STARTUP_STAGES.length - 1; return (
  • - {stageLabel(s)} diff --git a/packages/components/src/components/onboarding/provider-test-state.ts b/packages/components/src/components/onboarding/provider-test-state.ts index 1d799e752..3f4be6de4 100644 --- a/packages/components/src/components/onboarding/provider-test-state.ts +++ b/packages/components/src/components/onboarding/provider-test-state.ts @@ -1,16 +1,25 @@ import type { AgentConfigId, MachineAcpBinaryProgressMessage } from '@lody/shared'; +import type { AgentReadiness } from '@/components/shared/agent-readiness-mark'; + export type ProviderTestActivityPhase = | 'checking-runtime' | 'downloading-runtime' | 'verifying-runtime' | 'extracting-runtime' | 'installing-runtime' - | 'probing-provider'; + | 'probing-provider' + | 'runtime-failed'; export type ProviderTestActivity = { phase: ProviderTestActivityPhase; percent?: number; + /** + * When the owning request started, so a stage with no denominator can still + * show elapsed time. A wait you can measure is bounded; an unbounded one is + * the thing that has no floor. + */ + startedAtMs?: number; }; export function providerTestActivityFromProgress( @@ -38,15 +47,111 @@ export function providerTestActivityFromProgress( case 'unsupported-platform': case 'incompatible-host': case 'error': - // The final refresh response owns the durable error. Until it arrives, - // keep the activity honest without briefly presenting a second result. - return { phase: 'checking-runtime' }; + // The final refresh response still owns the durable error and its reason; + // this phase only keeps the in-flight activity honest until it arrives. + // Reporting a runtime that already failed as 'checking-runtime' is how a + // known failure gets presented as work still in progress. + return { phase: 'runtime-failed' }; } const unreachableStatus: never = progress.status; throw new Error(`Unknown provider runtime progress status: ${String(unreachableStatus)}`); } +export type AgentRuntimeReadiness = { + readiness: AgentReadiness; + /** Only ever set while downloading, the one stage with a real denominator. */ + percent: number | null; +}; + +/** + * Turns the machine's ephemeral runtime progress into the readiness a mark can + * wear. Background prefetch fills those snapshots before the user reaches this + * step, so the logo wall reads as an inventory filling in rather than as a + * queue of pending work. + * + * A failed runtime reads as `cold`, not as a failure: the mark carries no + * failure vocabulary, and the row's own badge owns the reason. + */ +export function agentRuntimeReadinessFromProgress( + progress: MachineAcpBinaryProgressMessage | null | undefined +): AgentRuntimeReadiness { + if (!progress) return { readiness: 'cold', percent: null }; + switch (progress.status) { + case 'installed': + return { readiness: 'ready', percent: null }; + case 'downloading': + return { + readiness: 'arriving', + percent: + typeof progress.percent === 'number' + ? Math.min(100, Math.max(0, progress.percent)) + : null, + }; + case 'checking': + case 'not-installed': + case 'verifying': + case 'extracting': + case 'publishing': + return { readiness: 'arriving', percent: null }; + case 'unsupported-platform': + case 'incompatible-host': + case 'error': + return { readiness: 'cold', percent: null }; + } + + const unreachableStatus: never = progress.status; + throw new Error(`Unknown provider runtime progress status: ${String(unreachableStatus)}`); +} + +/** + * Readiness for a row whose test/setup request is in flight. The request owns + * the mark while it runs, so a determinate download fills the ring and every + * denominator-free stage — including the ACP handshake — orbits instead. + */ +export function agentRuntimeReadinessFromActivity( + activity: ProviderTestActivity | undefined +): AgentRuntimeReadiness | null { + if (!activity) return null; + if (activity.phase === 'runtime-failed') return { readiness: 'cold', percent: null }; + if (activity.phase === 'downloading-runtime') { + return { + readiness: 'arriving', + percent: + typeof activity.percent === 'number' + ? Math.min(100, Math.max(0, activity.percent)) + : null, + }; + } + return { readiness: 'arriving', percent: null }; +} + +/** + * How a wait presents once it stops being routine. + * + * - `normal` — name the stage and nothing else. A counter under a few seconds + * only teaches the user to watch a number that was never going to matter. + * - `measured` — show elapsed time. The wait has run long enough that the user + * wants it bounded, and a number they can watch is what bounds it. + * - `exceptional` — say so. Past here a stage name over a rising number reads + * as the UI insisting everything is fine; the honest move is to admit the + * wait has left the normal range. Not by inventing progress — by changing + * what the interface is willing to claim. + */ +export type ProviderWaitEscalation = 'normal' | 'measured' | 'exceptional'; + +/** Elapsed seconds at which a wait starts showing its number. */ +export const PROVIDER_WAIT_MEASURED_AFTER_SECONDS = 10; + +/** Elapsed seconds at which a wait stops presenting itself as ordinary. */ +export const PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS = 60; + +export function providerWaitEscalation(elapsedSeconds: number): ProviderWaitEscalation { + if (elapsedSeconds >= PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS) return 'exceptional'; + if (elapsedSeconds >= PROVIDER_WAIT_MEASURED_AFTER_SECONDS) return 'measured'; + return 'normal'; +} + export type ProviderTestRun = { id: number; signal: AbortSignal; @@ -57,6 +162,11 @@ export type ProviderTestRunRegistry = ReturnType = [ + ['Agent', `${input.agentName} (${input.cliType}/${input.agentType})`], + ['Stage', input.phase], + // The counter is request-scoped, so the report says so rather than letting + // the number read as time spent in the stage above it. + ['Setup elapsed', `${Math.max(0, Math.floor(input.elapsedSeconds))}s`], + [ + 'Download', + typeof input.percent === 'number' && Number.isFinite(input.percent) + ? `${Math.min(100, Math.max(0, Math.round(input.percent)))}%` + : undefined, + ], + ['Runtime', environment.runtime], + ['OS', environment.os], + ['App version', environment.appVersion], + ['Build', environment.build], + ['Language', environment.language], + ['Online', formatOnline(environment.online)], + ['Time', environment.timestamp], + ]; + + const body = fields + .filter((entry): entry is [string, string] => Boolean(entry[1])) + .map(([label, value]) => `${label}: ${value}`) + .join('\n'); + + return `${REPORT_TITLE}\n\n${body}`; +} diff --git a/packages/components/src/components/onboarding/screens/providers-screen.tsx b/packages/components/src/components/onboarding/screens/providers-screen.tsx index 09c4b1439..cf652bd0e 100644 --- a/packages/components/src/components/onboarding/screens/providers-screen.tsx +++ b/packages/components/src/components/onboarding/screens/providers-screen.tsx @@ -2,10 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue, useSetAtom } from 'jotai'; import { motion, AnimatePresence } from 'framer-motion'; -import { CheckCircle2, ChevronDown, ChevronUp, Loader2, Plus, Trash2, XCircle } from 'lucide-react'; +import { + CheckCircle2, + ChevronDown, + ChevronUp, + Copy, + Loader2, + Plus, + Trash2, + XCircle, +} from 'lucide-react'; import { REGISTRY_ACP_AGENTS, getBuiltinAgentByAgentType, + isManagedBuiltinAgentType, type AgentBrandId, type BuiltinAgentType, type ManagedBuiltinAgentType, @@ -51,6 +61,7 @@ import { resyncMachineFlockRows } from '@/hooks/use-machine-flock-rows'; import { useMachineAcpBinaryActions } from '@/hooks/use-machine-acp-binary-actions'; import { useProviderSetupRuntimeProgress } from '@/hooks/use-provider-setup-runtime-progress'; import { AgentIcon } from '@/components/icons/agent-icon'; +import { AgentReadinessMark } from '@/components/shared/agent-readiness-mark'; import { REGISTRY_AGENT_ICON_SVGS } from '@/components/icons/registry-agent-icons'; import { AgentConfigDialog, @@ -72,11 +83,19 @@ import { resolveInitialOnboardingProviderStatus, type OnboardingProviderStatus, } from '../provider-status'; +import { collectErrorBoundaryEnvironment } from '@/lib/error-boundary-report'; +import { writeTextToClipboard } from '@/lib/clipboard'; +import { buildProviderWaitReport } from '../provider-wait-report'; import { + agentRuntimeReadinessFromActivity, createProviderTestRunRegistry, providerTestActivityFromProgress, + providerWaitEscalation, + type AgentRuntimeReadiness, type ProviderTestActivity, + type ProviderWaitEscalation, } from '../provider-test-state'; +import { useBuiltinRuntimeReadiness } from '../use-builtin-runtime-readiness'; import { useOnboardingAnalytics } from '../onboarding-analytics'; export type ProviderTestStatus = OnboardingProviderStatus | 'needs-auth'; @@ -183,6 +202,12 @@ export interface ProvidersScreenViewProps { testActivities?: Record; /** Latest failed probe detail, kept available after its toast disappears. */ failureReasons?: Record; + /** + * Readiness of the managed built-in runtimes the background prefetch warms. + * Passed in rather than read from a runtime atom so this half stays + * presentational and story-renderable. + */ + runtimeReadiness?: Partial>; selectedProviderId?: string | null; /** True when the local machine record has not yet arrived. */ noLocalMachine: boolean; @@ -215,6 +240,7 @@ export function ProvidersScreenView({ testStatuses, testActivities = {}, failureReasons = {}, + runtimeReadiness = {}, selectedProviderId, noLocalMachine, localMachineId = null, @@ -332,8 +358,14 @@ export function ProvidersScreenView({ {configs.map((config) => { const status: ProviderTestStatus = testStatuses[config.id] ?? 'untested'; const activity = testActivities[config.id]; - const activityPercent = getProviderTestActivityPercent(activity); const selected = config.id === resolvedSelectedProviderId; + // 'needs-auth' stays ready on purpose: that agent arrived, it + // is waiting on the user, and the row's panel says so. + const rowReadiness: AgentRuntimeReadiness = + agentRuntimeReadinessFromActivity(activity) ?? + (status === 'failed' + ? { readiness: 'cold', percent: null } + : { readiness: 'ready', percent: null }); return ( (onSelect ? onSelect(config) : onEdit(config))} > -
    - -
    + {/* The mark carries the work, so the row needs no second + activity element. A published config reads as ready + unless a request is running on it or it failed: + dimming an agent the user already has, because we + have not probed it, is the anxiety this replaces. */} +
    {config.name}
    @@ -397,14 +434,7 @@ export function ProvidersScreenView({ {t('common.edit', 'Edit')} {activity ? ( - + ) : status !== 'needs-auth' ? (
    ); } +/** + * The glyph inside a showcase chip. + * + * Only the managed built-in runtimes the background prefetch warms get the + * readiness treatment, and they light up from monochrome to full brand colour + * as each one lands. The rest keep the wall's resting monochrome look, because + * nothing is being prepared for them and a dimmed mark would imply otherwise. + */ +function ShowcaseChipMark({ + agent, + runtimeReadiness, +}: { + agent: ShowcaseAgent; + runtimeReadiness: Partial>; +}) { + const warmed = + agent.pick.kind === 'builtin' && isManagedBuiltinAgentType(agent.pick.agentType) + ? runtimeReadiness[agent.pick.agentType] + : undefined; + + if (warmed) { + return ( + + ); + } + + return ( + + + + ); +} + /** * "Logo wall" of supported ACP agents. Communicates that Lody runs far more * than the two built-ins; clicking a brand opens the create dialog pre-selected @@ -491,9 +571,11 @@ export function ProvidersScreenView({ function AgentShowcase({ disabled, onPick, + runtimeReadiness, }: { disabled: boolean; onPick: (pick: ShowcasePick) => void; + runtimeReadiness: Partial>; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -527,14 +609,7 @@ function AgentShowcase({ 'disabled:pointer-events-none disabled:opacity-50' )} > - - - + {agent.label} ))} @@ -623,6 +698,7 @@ export function ProvidersScreen({ [allSetups, localMachineId] ); useProviderSetupRuntimeProgress(runtime, workspaceId, localSetups); + const runtimeReadiness = useBuiltinRuntimeReadiness(localMachineId); const [dialogMode, setDialogMode] = useState(null); const dialogOpen = dialogMode !== null; @@ -671,9 +747,13 @@ export function ProvidersScreen({ [clearTestActivity] ); + // Detach, never abort: leaving this step stops the screen from committing a + // result it can no longer show, but the machine keeps working. The refresh + // writes durable capabilities, so a recovery that outlives the step still + // finishes the job the user asked for. useEffect( () => () => { - testRunsRef.current.invalidateAll(); + testRunsRef.current.detachAll(); }, [] ); @@ -852,7 +932,7 @@ export function ProvidersScreen({ }); setTestActivities((prev) => ({ ...prev, - [config.id]: { phase: 'checking-runtime' }, + [config.id]: { phase: 'checking-runtime', startedAtMs: Date.now() }, })); void (async () => { try { @@ -864,7 +944,14 @@ export function ProvidersScreen({ if (!testRunsRef.current.isCurrent(config.id, run)) return; setTestActivities((prev) => ({ ...prev, - [config.id]: providerTestActivityFromProgress(progress), + [config.id]: { + ...providerTestActivityFromProgress(progress), + // Elapsed time belongs to the request, not to the stage it + // happens to be in, so it survives every phase change. + ...(prev[config.id]?.startedAtMs !== undefined + ? { startedAtMs: prev[config.id]?.startedAtMs } + : {}), + }, })); }, }); @@ -1107,6 +1194,7 @@ export function ProvidersScreen({ testStatuses={testStatuses} testActivities={testActivities} failureReasons={failureReasons} + runtimeReadiness={runtimeReadiness} selectedProviderId={selectedProviderId} noLocalMachine={!localMachine} localMachineId={localMachineId} @@ -1215,6 +1303,126 @@ export function ProvidersScreen({ ); } +/** Seconds since `startedAtMs`, ticking only while one is supplied. */ +function useElapsedSeconds(startedAtMs: number | null): number { + const [elapsedSeconds, setElapsedSeconds] = useState(0); + useEffect(() => { + if (startedAtMs === null) { + setElapsedSeconds(0); + return undefined; + } + const tick = (): void => + setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))); + tick(); + const intervalId = window.setInterval(tick, 1000); + return () => window.clearInterval(intervalId); + }, [startedAtMs]); + return elapsedSeconds; +} + +/** + * The escalation tier a row's wait has reached, and the seconds behind it. + * + * The badge and the action both read this, so the tone the row takes and the + * number it shows can never disagree about which tier the wait is in. Only a + * denominator-free stage escalates: a download already answers "how much + * longer" with a percentage, and a runtime that has failed is not waiting. + */ +function useProviderWaitEscalation(activity: ProviderTestActivity | undefined): { + escalation: ProviderWaitEscalation; + elapsedSeconds: number; +} { + const percent = getProviderTestActivityPercent(activity); + const measurable = activity !== undefined && percent === null && activity.phase !== 'runtime-failed'; + const elapsedSeconds = useElapsedSeconds(measurable ? (activity.startedAtMs ?? null) : null); + return { escalation: providerWaitEscalation(elapsedSeconds), elapsedSeconds }; +} + +/** + * The in-flight action for a row. + * + * A download has a denominator, so the button fills and reads as a percentage. + * Every other stage — the ACP handshake above all — has none, and inventing one + * would be a lie. Once the wait is `measured` it reports elapsed time instead, + * which is what turns an open-ended wait into a wait the user can measure. The + * badge beside it names the stage, so the two together read as "Starting · 14s" + * — and, once the wait is `exceptional`, as "Taking longer · 74s". + */ +function ProviderActivityAction({ + activity, + config, +}: { + activity: ProviderTestActivity; + config: AgentConfigMeta; +}) { + const { t } = useTranslation(); + const percent = getProviderTestActivityPercent(activity); + const runtimeFailed = activity.phase === 'runtime-failed'; + const { escalation, elapsedSeconds } = useProviderWaitEscalation(activity); + const label = (() => { + if (percent !== null) return `${percent}%`; + // Never label an already-failed runtime as ongoing work while the durable + // reason is still in flight. + if (runtimeFailed) return t('onboarding.providers.failedAction', 'Failed'); + if (escalation !== 'normal') { + return t('onboarding.providers.workingSeconds', '{{seconds}}s', { + seconds: elapsedSeconds, + }); + } + return t('onboarding.providers.workingAction', 'Working'); + })(); + + const handleCopyReport = useCallback(() => { + const report = buildProviderWaitReport({ + agentName: config.name, + cliType: config.cliType, + agentType: config.agentType, + phase: activity.phase, + elapsedSeconds, + percent, + environment: collectErrorBoundaryEnvironment(), + }); + void writeTextToClipboard(report).then((ok) => { + if (ok) { + toast.success(t('onboarding.providers.slowWaitCopied', 'Setup details copied')); + return; + } + // Copying can be blocked (insecure context, no gesture). Say so rather + // than leaving the user believing they have something to paste. + console.error('[onboarding] Could not copy provider setup details to the clipboard'); + toast.error( + t('onboarding.providers.slowWaitCopyFailed', 'Could not copy setup details'), + { description: report } + ); + }); + }, [activity.phase, config, elapsedSeconds, percent, t]); + + // The escalation's real ask is "tell someone". A wait this long ends in the + // chat, and the three things we would have to ask for there — which stage, + // how long, which build — are the three things the user cannot see. So the + // exceptional tier hands them one block to paste. It is also the tier's only + // pointer-independent affordance: the tooltip beside it is hover-only. + const copyable = !runtimeFailed && escalation === 'exceptional'; + + return ( + <> + + {copyable ? ( + + ) : null} + + ); +} + function getProviderTestActivityPercent(activity?: ProviderTestActivity): number | null { return activity?.phase === 'downloading-runtime' && typeof activity.percent === 'number' ? Math.min(100, Math.max(0, Math.round(activity.percent))) @@ -1231,8 +1439,9 @@ function ProviderStatusBadge({ failureReason?: string; }) { const { t } = useTranslation(); + const { escalation } = useProviderWaitEscalation(activity); if (activity) { - const label = (() => { + const stageLabel = (() => { switch (activity.phase) { case 'checking-runtime': return t('onboarding.providers.activityChecking', 'Checking'); @@ -1246,19 +1455,63 @@ function ProviderStatusBadge({ return t('onboarding.providers.activityInstalling', 'Installing'); case 'probing-provider': return t('onboarding.providers.activityStarting', 'Starting'); + case 'runtime-failed': + return t('onboarding.providers.activityRuntimeFailed', 'Runtime failed'); } const unreachablePhase: never = activity.phase; throw new Error(`Unknown provider test activity phase: ${String(unreachablePhase)}`); })(); - return ( + // A runtime that already reported a failure must not keep wearing the + // in-progress tone; the final response still owns the durable reason. + const runtimeFailed = activity.phase === 'runtime-failed'; + // Second escalation. The row stops naming the stage as if this were a + // normal run and says what is actually true — the wait left the usual + // range — in the amber this screen already uses for "needs your + // attention, but nothing has failed". No new progress is invented, and + // the elapsed counter beside it keeps the wait measurable. + const exceptional = !runtimeFailed && escalation === 'exceptional'; + // Request-scoped, exactly like the counter beside it. The badge no longer + // names a stage here on purpose: the timer measures the whole setup, so a + // sentence about the current stage would attach the elapsed number to work + // that may have started a second ago. The stage still travels — in the + // copyable report, where it is diagnostic data rather than a claim. + const slowDetail = t( + 'onboarding.providers.slowWaitDetail', + 'Agent setup is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.' + ); + const badge = ( - {label} + {exceptional ? t('onboarding.providers.activitySlow', 'Taking longer') : stageLabel} ); + if (!exceptional) return badge; + return ( + + + {badge} + +
    + {t('onboarding.providers.slowWaitTitle', 'This is taking longer than usual')} +
    +
    {slowDetail}
    +
    +
    +
    + ); } if (status === 'passed') { return ( diff --git a/packages/components/src/components/onboarding/screens/summary-screen.tsx b/packages/components/src/components/onboarding/screens/summary-screen.tsx index 7cf9b6eca..2bc531287 100644 --- a/packages/components/src/components/onboarding/screens/summary-screen.tsx +++ b/packages/components/src/components/onboarding/screens/summary-screen.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Check, Clock3, Loader2, Minus, RotateCcw, XCircle } from 'lucide-react'; +import type { ProviderSetupFailureCode } from '@lody/shared'; import { Table, TableBody, TableCell, TableRow } from '@/ui/table'; import { Button } from '@/ui/button'; import { OnboardingBackButton, OnboardingNextButton, OnboardingShell } from '../onboarding-shell'; @@ -21,7 +22,7 @@ export function SummaryScreen({ }: { agentState: OnboardingSummaryAgentState; agentName?: string; - agentFailureCode?: string; + agentFailureCode?: ProviderSetupFailureCode; projectName?: string; onBack: () => void; onComplete: () => void; @@ -101,14 +102,11 @@ export function SummaryScreen({ {agentState === 'failed' && onRetryAgent ? (
    -

    {t('onboarding.summary.agentRetryHint', 'Agent setup can be retried here.')}

    - {agentFailureCode ? ( -

    - {t('onboarding.summary.failureCode', 'Failure code: {{code}}', { - code: agentFailureCode, - })} -

    - ) : null} +

    + {agentFailureCode + ? agentFailureMessage(t, agentFailureCode) + : t('onboarding.summary.agentRetryHint', 'Agent setup can be retried here.')} +

    {retryError ? (

    {retryError}

    ) : null} @@ -163,6 +161,31 @@ export function SummaryScreen({ ); } +function agentFailureMessage( + t: ReturnType['t'], + failureCode: ProviderSetupFailureCode +): string { + switch (failureCode) { + case 'runtime-unavailable': + return t( + 'onboarding.summary.failure.runtimeUnavailable', + 'This Agent is not available on the selected machine. Update Lody or choose another machine in Settings.' + ); + case 'runtime-install-failed': + return t( + 'onboarding.summary.failure.runtimeInstallFailed', + 'Lody could not download the Agent runtime. Check your connection and try again.' + ); + case 'verification-failed': + return t( + 'onboarding.summary.failure.verificationFailed', + 'Lody could not verify this Agent. Check its sign-in or credentials and try again.' + ); + default: + return failureCode satisfies never; + } +} + function SummaryRow({ label, value, diff --git a/packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts b/packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts new file mode 100644 index 000000000..4745613f6 --- /dev/null +++ b/packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import type { ManagedBuiltinAgentType, MachineId } from '@lody/shared'; + +import { activeWorkspaceRuntimeAtom } from '@/atoms/runtime'; +import { useMachineAcpBinaryProgress } from '@/hooks/use-machine-acp-binary-progress'; +import { BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES } from './use-onboarding-builtin-runtime-prefetch'; +import { agentRuntimeReadinessFromProgress, type AgentRuntimeReadiness } from './provider-test-state'; + +export type BuiltinRuntimeReadinessMap = Partial< + Record +>; + +/** + * Readiness of the runtimes the background prefetch warms, for the surfaces that + * light a mark up as each one lands. + * + * Reading it here rather than during the ceremony is deliberate: the runtime + * records a progress snapshot whether or not anyone listens, so subscribing on + * this screen picks up whatever already happened without a per-percent render + * ever landing on the intro animation. The hook list is fixed-length because + * the warmed set is a module constant. + */ +export function useBuiltinRuntimeReadiness(machineId: MachineId | null): BuiltinRuntimeReadinessMap { + const runtime = useAtomValue(activeWorkspaceRuntimeAtom); + const [kimi, codex, claude] = BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES; + const kimiProgress = useMachineAcpBinaryProgress(runtime, machineId, kimi); + const codexProgress = useMachineAcpBinaryProgress(runtime, machineId, codex); + const claudeProgress = useMachineAcpBinaryProgress(runtime, machineId, claude); + + return useMemo( + () => ({ + [kimi]: agentRuntimeReadinessFromProgress(kimiProgress), + [codex]: agentRuntimeReadinessFromProgress(codexProgress), + [claude]: agentRuntimeReadinessFromProgress(claudeProgress), + }), + [claude, claudeProgress, codex, codexProgress, kimi, kimiProgress] + ); +} diff --git a/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts b/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts index c9200dfba..ea1047919 100644 --- a/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts +++ b/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts @@ -6,7 +6,12 @@ import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { localCliStartingAtom, localMachineIdAtom } from '@/atoms/local-probe'; import { useMachineAcpBinaryActions } from '@/hooks/use-machine-acp-binary-actions'; -const BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES = [ +/** + * The managed runtimes warmed in the background. Exported because the provider + * step reads readiness for exactly this set: a mark may only light up for an + * agent something is actually preparing. + */ +export const BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES = [ 'kimi', 'codex', 'claude', @@ -29,8 +34,7 @@ type PrefetchErrorHandler = (agentType: ManagedBuiltinAgentType, error: unknown) type PrefetchScopeState = { readonly completed: Set; - pending: ManagedBuiltinAgentType[]; - running: ManagedBuiltinAgentType | null; + readonly running: Set; owner: symbol | null; task: PrefetchTask | null; onError: PrefetchErrorHandler | null; @@ -47,8 +51,7 @@ class OnboardingBuiltinRuntimePrefetchScheduler { ): { dispose: () => void } { const state = this.scopes.get(scopeKey) ?? { completed: new Set(), - pending: [], - running: null, + running: new Set(), owner: null, task: null, onError: null, @@ -58,10 +61,7 @@ class OnboardingBuiltinRuntimePrefetchScheduler { state.owner = owner; state.task = task; state.onError = onError ?? null; - state.pending = order.filter( - (agentType) => agentType !== state.running && !state.completed.has(agentType) - ); - this.runNext(scopeKey, state); + this.launchPending(scopeKey, state, order); return { dispose: () => { @@ -69,7 +69,6 @@ class OnboardingBuiltinRuntimePrefetchScheduler { state.owner = null; state.task = null; state.onError = null; - state.pending = []; }, }; } @@ -78,26 +77,35 @@ class OnboardingBuiltinRuntimePrefetchScheduler { this.scopes.clear(); } - private runNext(scopeKey: string, state: PrefetchScopeState): void { - if (state.running || !state.owner || !state.task) return; - const agentType = state.pending.shift(); - if (!agentType) return; + /** + * Every runtime that is not already running or finished starts now. `order` + * still decides which request reaches the wire first, so a selected provider + * gets the connection ahead of the rest without waiting behind it. + */ + private launchPending( + scopeKey: string, + state: PrefetchScopeState, + order: readonly ManagedBuiltinAgentType[] + ): void { + if (!state.owner || !state.task) return; const task = state.task; const onError = state.onError; - state.running = agentType; - void Promise.resolve() - .then(() => task(agentType)) - .then(() => { - state.completed.add(agentType); - }) - .catch((error) => { - onError?.(agentType, error); - }) - .finally(() => { - if (this.scopes.get(scopeKey) !== state) return; - state.running = null; - this.runNext(scopeKey, state); - }); + for (const agentType of order) { + if (state.running.has(agentType) || state.completed.has(agentType)) continue; + state.running.add(agentType); + void Promise.resolve() + .then(() => task(agentType)) + .then(() => { + state.completed.add(agentType); + }) + .catch((error) => { + onError?.(agentType, error); + }) + .finally(() => { + if (this.scopes.get(scopeKey) !== state) return; + state.running.delete(agentType); + }); + } } } @@ -113,9 +121,13 @@ export const __onboardingBuiltinRuntimePrefetchForTests = { /** * Onboarding should not wait for a user to reach or interact with the provider - * step before managed built-in runtimes begin downloading. Background work is - * serial across effect restarts. Selecting a provider reorders work that has - * not started; the current download is allowed to finish first. + * step before managed built-in runtimes begin downloading. The runtimes are + * downloaded CONCURRENTLY: the wait a user actually feels is wall-clock, and + * running them one at a time made it the sum of three downloads instead of the + * longest one. They are independent artifacts keyed by agent type, and + * `useMachineAcpBinaryActions` already dedupes installs per machine and agent, + * so nothing is duplicated by starting them together. Selecting a provider only + * moves it to the front of the launch order; work already in flight continues. */ export function useOnboardingBuiltinRuntimePrefetch( preferredAgentType: ManagedBuiltinAgentType | null diff --git a/packages/components/src/components/shared/agent-readiness-mark.tsx b/packages/components/src/components/shared/agent-readiness-mark.tsx new file mode 100644 index 000000000..11a622a34 --- /dev/null +++ b/packages/components/src/components/shared/agent-readiness-mark.tsx @@ -0,0 +1,137 @@ +import type { AgentBrandId, AgentConfigCliType } from '@lody/shared'; + +import { AgentIcon } from '@/components/icons/agent-icon'; +import { cn } from '@/lib/utils'; + +/** + * How far along an agent is toward being usable, expressed on the agent's own + * mark rather than as a separate status word beside it. + * + * - `cold` — nothing has been prepared yet: monochrome and dim. + * - `arriving` — being prepared: still monochrome, and the mark carries a ring. + * - `ready` — full brand contrast, no ring. A ready agent says nothing at all, + * because a badge confirming success only advertises that failure exists. + */ +export type AgentReadiness = 'cold' | 'arriving' | 'ready'; + +const RING_RADIUS = 45; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; +/** Visible sweep of the indeterminate arc, as a fraction of the ring. */ +const ORBIT_ARC_FRACTION = 0.22; + +const SIZES = { + sm: { box: 'h-7 w-7', icon: 'h-3 w-3', stroke: 8 }, + md: { box: 'h-10 w-10', icon: 'h-4.5 w-4.5', stroke: 7 }, + lg: { box: 'h-14 w-14', icon: 'h-6 w-6', stroke: 6 }, +} as const; + +export type AgentReadinessMarkProps = { + cliType: AgentConfigCliType; + agentType: string; + brandId?: AgentBrandId; + env?: Record; + readiness: AgentReadiness; + /** + * Determinate progress, 0-100. Supplied only while `arriving`, and only when + * the work actually has a denominator — a download does, an ACP handshake + * does not. `null` keeps the ring on its indeterminate orbit instead of + * inventing a number. + */ + percent?: number | null; + size?: keyof typeof SIZES; + className?: string; + /** Describes the mark for assistive tech; the visual carries no text. */ + ariaLabel?: string; +}; + +export function AgentReadinessMark({ + cliType, + agentType, + brandId, + env, + readiness, + percent = null, + size = 'md', + className, + ariaLabel, +}: AgentReadinessMarkProps) { + const { box, icon, stroke } = SIZES[size]; + const determinatePercent = + typeof percent === 'number' && Number.isFinite(percent) + ? Math.min(100, Math.max(0, percent)) + : null; + + return ( + + {readiness === 'arriving' ? ( + // -rotate-90 puts 0% at twelve o'clock; the orbit rotation composes on + // the inner group so both transforms stay independent. + + ) : null} + + + ); +} diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 216da9e33..66c8fd364 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -64,6 +64,7 @@ import { type LodyPresenceStateMap, type LoroStreamsTokenProviderEvent, type SyncReason, + ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS, } from '@lody/shared'; import { LocalLoroTransportAdapter } from '@lody/shared/local-loro-transport'; import type { TaskId, WorkspaceId } from '@lody/shared'; @@ -1828,7 +1829,10 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise; + +export default meta; +type Story = StoryObj; + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    + {label} + {children} +
    + ); +} + +/** + * The whole vocabulary in one frame: saturation carries readiness, and the ring + * carries the wait — filling when there is a denominator, orbiting when there + * is not. + */ +export const Vocabulary: Story = { + args: { cliType: 'builtin', agentType: 'codex', readiness: 'ready' }, + render: () => ( +
    + + + + + + + + + + + + + + + +
    + ), +}; + +/** The logo wall lighting up: an inventory filling in, not a queue of waits. */ +export const InventoryFillingIn: Story = { + args: { cliType: 'builtin', agentType: 'kimi', readiness: 'ready' }, + render: () => ( +
    + + + +
    + ), +}; + +export const Sizes: Story = { + args: { cliType: 'builtin', agentType: 'claude', readiness: 'arriving', percent: 40 }, + render: () => ( +
    + + + +
    + ), +}; diff --git a/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx b/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx index 524d65b37..1905dc1aa 100644 --- a/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx +++ b/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx @@ -195,3 +195,17 @@ export const ProviderSkip: Story = {}; export const ProviderPendingSetup: Story = { render: () => , }; + +export const ProviderSetupFailed: Story = { + render: () => ( + undefined} + /> + ), +}; diff --git a/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx b/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx index 7bd288685..30357478a 100644 --- a/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx +++ b/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx @@ -4,6 +4,8 @@ import { fn } from 'storybook/test'; import type { AgentConfigId, AgentConfigMeta, MachineId } from '@lody/shared'; import { OnboardingBackdrop, + PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS, + PROVIDER_WAIT_MEASURED_AFTER_SECONDS, ProvidersScreenView, type ProviderTestActivity, type ProviderTestStatus, @@ -226,6 +228,35 @@ export const DownloadingRuntime: Story = { }, }; +/** + * The three escalation tiers side by side. A wait that just started names its + * stage; one past the measured threshold adds the seconds it has taken; one + * past the exceptional threshold stops calling itself ordinary, says so, and + * offers the pasteable report — without inventing any progress it does not + * have. Kimi's row is the case that forced the copy to be request-scoped: the + * setup has run a full minute, but the handshake it is in may have started a + * second ago, so nothing on that row may claim the stage took the time. + */ +export const WaitEscalation: Story = { + args: { + configs: [claudeConfig, codexConfig, kimiConfig], + testStatuses: {}, + testActivities: { + [claudeConfig.id]: { phase: 'probing-provider', startedAtMs: Date.now() }, + [codexConfig.id]: { + phase: 'probing-provider', + startedAtMs: Date.now() - PROVIDER_WAIT_MEASURED_AFTER_SECONDS * 1000, + }, + [kimiConfig.id]: { + phase: 'probing-provider', + startedAtMs: Date.now() - PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS * 1000, + }, + }, + selectedProviderId: kimiConfig.id, + noLocalMachine: false, + }, +}; + export const RecheckingVerified: Story = { args: { configs: [claudeConfig, codexConfig], diff --git a/packages/components/src/tailwind/index.css b/packages/components/src/tailwind/index.css index a32f60aa9..b89f352b7 100644 --- a/packages/components/src/tailwind/index.css +++ b/packages/components/src/tailwind/index.css @@ -1891,6 +1891,27 @@ pre, } } +/* An agent runtime whose wait has no denominator (the ACP handshake) gets the + same ring as a download, on an orbit instead of a fill. Transform-only and + CSS-driven, so React stays idle while it spins. */ +@keyframes agent-readiness-orbit { + to { + transform: rotate(360deg); + } +} + +.agent-readiness-orbit { + animation: agent-readiness-orbit 1.4s linear infinite; + transform-origin: 50% 50%; + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .agent-readiness-orbit { + animation: none; + } +} + /* Agent activity stays on compositor-friendly transform/opacity animations. The highlighted label is a pseudo-element so React remains idle while the agent is working. */ diff --git a/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts b/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts index b98b31d65..a534d4426 100644 --- a/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts +++ b/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts @@ -3,6 +3,44 @@ import type { ManagedBuiltinAgentType } from '@lody/shared'; import { __onboardingBuiltinRuntimePrefetchForTests as prefetch } from '../src/components/onboarding/use-onboarding-builtin-runtime-prefetch'; +type Deferred = { promise: Promise; resolve: () => void }; + +function createDeferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +/** + * The scheduler records completion in `.then`/`.finally` hops, so the test has + * to let those microtasks run. This is a fixed number of microtask turns, not a + * timer or a wall-clock wait, so it cannot race. + */ +async function drainMicrotasks(): Promise { + for (let turn = 0; turn < 8; turn += 1) await Promise.resolve(); +} + +/** Records start order and reports each start through its own explicit signal. */ +function createRecorder(hold?: Deferred) { + const starts: ManagedBuiltinAgentType[] = []; + const started = new Map(); + const signalFor = (agentType: ManagedBuiltinAgentType): Deferred => { + const existing = started.get(agentType); + if (existing) return existing; + const deferred = createDeferred(); + started.set(agentType, deferred); + return deferred; + }; + const run = async (agentType: ManagedBuiltinAgentType): Promise => { + starts.push(agentType); + signalFor(agentType).resolve(); + if (hold) await hold.promise; + }; + return { starts, run, waitForStart: (agentType: ManagedBuiltinAgentType) => signalFor(agentType).promise }; +} + describe('onboarding builtin runtime prefetch scheduling', () => { beforeEach(() => { prefetch.reset(); @@ -14,46 +52,52 @@ describe('onboarding builtin runtime prefetch scheduling', () => { expect(prefetch.resolvePrefetchOrder('claude')).toEqual(['claude', 'kimi', 'codex']); }); - it('finishes the running task before starting the newly preferred runtime', async () => { - const createDeferred = () => { - let resolve!: () => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; - }; - const kimiStarted = createDeferred(); - const kimiFinished = createDeferred(); - const codexStarted = createDeferred(); - const starts: ManagedBuiltinAgentType[] = []; - const runTask = async (agentType: ManagedBuiltinAgentType): Promise => { - starts.push(agentType); - if (agentType === 'kimi') { - kimiStarted.resolve(); - await kimiFinished.promise; - } - if (agentType === 'codex') { - codexStarted.resolve(); - } - }; - - const initial = prefetch.schedule( - 'workspace:machine', - prefetch.resolvePrefetchOrder(null), - runTask - ); - await kimiStarted.promise; + it('starts every runtime concurrently instead of queueing behind the first', async () => { + const hold = createDeferred(); + const recorder = createRecorder(hold); + + prefetch.schedule('workspace:machine', prefetch.resolvePrefetchOrder(null), recorder.run); + + // No task has settled, so a serial scheduler would never start the other + // two and this await would never resolve. + await Promise.all([ + recorder.waitForStart('kimi'), + recorder.waitForStart('codex'), + recorder.waitForStart('claude'), + ]); + expect(recorder.starts).toEqual(['kimi', 'codex', 'claude']); + + hold.resolve(); + }); + + it('launches the preferred runtime first without restarting work in flight', async () => { + const hold = createDeferred(); + const recorder = createRecorder(hold); + + const initial = prefetch.schedule('workspace:machine', ['kimi'], recorder.run); + await recorder.waitForStart('kimi'); initial.dispose(); - prefetch.schedule( - 'workspace:machine', - prefetch.resolvePrefetchOrder('codex'), - runTask - ); - expect(starts).toEqual(['kimi']); - - kimiFinished.resolve(); - await codexStarted.promise; - expect(starts.slice(0, 2)).toEqual(['kimi', 'codex']); + prefetch.schedule('workspace:machine', prefetch.resolvePrefetchOrder('codex'), recorder.run); + await Promise.all([recorder.waitForStart('codex'), recorder.waitForStart('claude')]); + + // 'codex' leads the new order, and the in-flight 'kimi' is not started twice. + expect(recorder.starts).toEqual(['kimi', 'codex', 'claude']); + + hold.resolve(); + }); + + it('does not restart a runtime that already finished', async () => { + const recorder = createRecorder(); + + const initial = prefetch.schedule('workspace:machine', ['kimi'], recorder.run); + await recorder.waitForStart('kimi'); + await drainMicrotasks(); + + initial.dispose(); + prefetch.schedule('workspace:machine', prefetch.resolvePrefetchOrder(null), recorder.run); + await Promise.all([recorder.waitForStart('codex'), recorder.waitForStart('claude')]); + + expect(recorder.starts).toEqual(['kimi', 'codex', 'claude']); }); }); diff --git a/packages/components/tests/onboarding-flow.test.tsx b/packages/components/tests/onboarding-flow.test.tsx index 395b67ff5..12e4c7626 100644 --- a/packages/components/tests/onboarding-flow.test.tsx +++ b/packages/components/tests/onboarding-flow.test.tsx @@ -54,6 +54,7 @@ import { OnboardingOverlay, resolveDesktopOnboardingPhase, } from '../src/components/onboarding/onboarding-overlay'; +import { OnboardingLoadingView } from '../src/components/onboarding/onboarding-loading'; import { getDesktopOnboardingSteps } from '../src/components/onboarding/onboarding-steps'; import { ProjectsScreen, @@ -157,6 +158,14 @@ describe('desktop onboarding flow', () => { expect(mocks.getCliState).not.toHaveBeenCalled(); }); + it('marks the stalled startup step as a spring probe after the bypass', async () => { + await act(async () => { + root?.render(); + }); + + expect(container.querySelector('[data-onboarding-stalled-probe]')).not.toBeNull(); + }); + it('derives steps and repairs stale phases from platform capabilities', () => { expect(getDesktopOnboardingSteps({ cloudAccount: false, multiWorkspace: false })).toEqual([ 'ceremony', @@ -449,7 +458,10 @@ describe('desktop onboarding flow', () => { ); }); - expect(container.textContent).toContain('Failure code: runtime-install-failed'); + expect(container.textContent).toContain( + 'Lody could not download the Agent runtime. Check your connection and try again.' + ); + expect(container.textContent).not.toContain('runtime-install-failed'); await act(async () => { findButton(container, 'Retry').dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); diff --git a/packages/components/tests/provider-test-state.test.ts b/packages/components/tests/provider-test-state.test.ts index 9613a48fe..f8cab94c3 100644 --- a/packages/components/tests/provider-test-state.test.ts +++ b/packages/components/tests/provider-test-state.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest'; import type { AgentConfigId, MachineAcpBinaryProgressMessage, MachineId } from '@lody/shared'; import { + agentRuntimeReadinessFromActivity, + agentRuntimeReadinessFromProgress, createProviderTestRunRegistry, providerTestActivityFromProgress, + providerWaitEscalation, + PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS, + PROVIDER_WAIT_MEASURED_AFTER_SECONDS, } from '../src/components/onboarding/provider-test-state'; const configId = 'provider-1' as AgentConfigId; @@ -41,6 +46,14 @@ describe('providerTestActivityFromProgress', () => { phase: 'probing-provider', }); }); + + it('does not present a runtime that already failed as still checking', () => { + for (const status of ['error', 'unsupported-platform', 'incompatible-host'] as const) { + expect(providerTestActivityFromProgress(progress(status))).toEqual({ + phase: 'runtime-failed', + }); + } + }); }); describe('createProviderTestRunRegistry', () => { @@ -65,4 +78,110 @@ describe('createProviderTestRunRegistry', () => { expect(registry.finish(configId, completed)).toBe(true); expect(registry.finish(configId, completed)).toBe(false); }); + + it('detaches without aborting, so leaving the step does not cancel the work', () => { + // Leaving the provider step must stop the screen committing a result it can + // no longer show, and nothing more. The machine drops a refresh once its + // last consumer leaves, so aborting here would make "continue" quietly mean + // "abandon this agent" — the opposite of what the UI offers at that moment. + const registry = createProviderTestRunRegistry(); + const detached = registry.start(configId); + + registry.detachAll(); + + expect(detached.signal.aborted).toBe(false); + expect(registry.isCurrent(configId, detached)).toBe(false); + expect(registry.finish(configId, detached)).toBe(false); + }); +}); + +describe('agentRuntimeReadinessFromProgress', () => { + it('lights the mark up only once the runtime has landed', () => { + expect(agentRuntimeReadinessFromProgress(progress('installed'))).toEqual({ + readiness: 'ready', + percent: null, + }); + expect(agentRuntimeReadinessFromProgress(null)).toEqual({ + readiness: 'cold', + percent: null, + }); + }); + + it('only carries a denominator while downloading', () => { + expect(agentRuntimeReadinessFromProgress(progress('downloading', 62.5))).toEqual({ + readiness: 'arriving', + percent: 62.5, + }); + expect(agentRuntimeReadinessFromProgress(progress('downloading'))).toEqual({ + readiness: 'arriving', + percent: null, + }); + for (const status of ['checking', 'verifying', 'extracting', 'publishing'] as const) { + expect(agentRuntimeReadinessFromProgress(progress(status))).toEqual({ + readiness: 'arriving', + percent: null, + }); + } + }); + + it('reads a failed runtime as cold, leaving the reason to the row badge', () => { + for (const status of ['error', 'unsupported-platform', 'incompatible-host'] as const) { + expect(agentRuntimeReadinessFromProgress(progress(status))).toEqual({ + readiness: 'cold', + percent: null, + }); + } + }); +}); + +describe('agentRuntimeReadinessFromActivity', () => { + it('leaves the mark to its durable state when nothing is in flight', () => { + expect(agentRuntimeReadinessFromActivity(undefined)).toBeNull(); + }); + + it('fills for a download and orbits for every denominator-free stage', () => { + expect( + agentRuntimeReadinessFromActivity({ phase: 'downloading-runtime', percent: 140 }) + ).toEqual({ readiness: 'arriving', percent: 100 }); + // The ACP handshake is the case that matters: no percentage exists, so none + // may be invented. + expect(agentRuntimeReadinessFromActivity({ phase: 'probing-provider' })).toEqual({ + readiness: 'arriving', + percent: null, + }); + }); + + it('stops presenting an already-failed runtime as arriving', () => { + expect(agentRuntimeReadinessFromActivity({ phase: 'runtime-failed' })).toEqual({ + readiness: 'cold', + percent: null, + }); + }); +}); + +describe('providerWaitEscalation', () => { + it('says only the stage name while a wait is still ordinary', () => { + expect(providerWaitEscalation(0)).toBe('normal'); + expect(providerWaitEscalation(PROVIDER_WAIT_MEASURED_AFTER_SECONDS - 1)).toBe('normal'); + }); + + it('starts measuring once the wait is long enough to want bounded', () => { + expect(providerWaitEscalation(PROVIDER_WAIT_MEASURED_AFTER_SECONDS)).toBe('measured'); + expect(providerWaitEscalation(PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS - 1)).toBe('measured'); + }); + + it('admits the wait left the normal range instead of holding the stage name', () => { + // The second escalation is the whole point of having tiers: past here a + // stage name over a rising number is the interface insisting nothing is + // wrong, so the tone has to change rather than the number growing alone. + expect(providerWaitEscalation(PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS)).toBe('exceptional'); + expect(providerWaitEscalation(600)).toBe('exceptional'); + }); + + it('escalates in order, so a tier can never be skipped or reversed', () => { + expect(PROVIDER_WAIT_MEASURED_AFTER_SECONDS).toBeGreaterThan(0); + expect(PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS).toBeGreaterThan( + PROVIDER_WAIT_MEASURED_AFTER_SECONDS + ); + }); }); diff --git a/packages/components/tests/provider-wait-report.test.ts b/packages/components/tests/provider-wait-report.test.ts new file mode 100644 index 000000000..d52872cd6 --- /dev/null +++ b/packages/components/tests/provider-wait-report.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProviderWaitReport } from '../src/components/onboarding/provider-wait-report'; + +const base = { + agentName: 'Claude Code', + cliType: 'builtin' as const, + agentType: 'claude', + phase: 'probing-provider' as const, + elapsedSeconds: 74, + environment: { + runtime: 'electron', + os: 'darwin', + appVersion: '0.90.0', + build: 'abc1234', + language: 'en-US', + online: true, + timestamp: '2026-09-06T09:00:00.000Z', + }, +}; + +describe('buildProviderWaitReport', () => { + it('carries the three things the user cannot see: stage, elapsed, and build', () => { + expect(buildProviderWaitReport(base)).toBe( + [ + 'Lody: agent setup is taking longer than usual', + '', + 'Agent: Claude Code (builtin/claude)', + 'Stage: probing-provider', + 'Setup elapsed: 74s', + 'Runtime: electron', + 'OS: darwin', + 'App version: 0.90.0', + 'Build: abc1234', + 'Language: en-US', + 'Online: yes', + 'Time: 2026-09-06T09:00:00.000Z', + ].join('\n') + ); + }); + + it('labels the counter as setup elapsed, not as time in the stage', () => { + // The timer is request-scoped, so a download that just handed off to the + // ACP handshake reports 74s of SETUP against a stage one second old. The + // label is what keeps that from reading as a claim about the stage. + const report = buildProviderWaitReport(base); + expect(report).toContain('Setup elapsed: 74s'); + expect(report).not.toContain('Stage elapsed'); + }); + + it('reports a download percentage only when one exists', () => { + expect( + buildProviderWaitReport({ + ...base, + phase: 'downloading-runtime', + percent: 63.6, + }) + ).toContain('Download: 64%'); + expect(buildProviderWaitReport({ ...base, percent: null })).not.toContain('Download:'); + expect(buildProviderWaitReport({ ...base, percent: Number.NaN })).not.toContain('Download:'); + }); + + it('drops environment fields the host could not supply', () => { + const report = buildProviderWaitReport({ ...base, environment: { runtime: 'web' } }); + expect(report).toContain('Runtime: web'); + expect(report).not.toContain('OS:'); + expect(report).not.toContain('Online:'); + }); + + it('is not localized, so a stage id stays greppable whatever the user reads', () => { + // Whoever answers in the chat reads this, not the user. A translated stage + // name is a stage name we cannot search for. + expect(buildProviderWaitReport({ ...base, phase: 'extracting-runtime' })).toContain( + 'Stage: extracting-runtime' + ); + }); + + it('never reports a negative or fractional elapsed', () => { + expect(buildProviderWaitReport({ ...base, elapsedSeconds: -5 })).toContain( + 'Setup elapsed: 0s' + ); + expect(buildProviderWaitReport({ ...base, elapsedSeconds: 12.9 })).toContain( + 'Setup elapsed: 12s' + ); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index a4fb26c81..a76d332d4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,10 @@ "types": "./src/index.ts", "import": "./src/index.ts" }, + "./acp-startup-budget": { + "types": "./src/acp-startup-budget.ts", + "import": "./src/acp-startup-budget.ts" + }, "./visual-annotation-inspector": { "types": "./src/visual-annotation-inspector.ts", "import": "./src/visual-annotation-inspector.ts" diff --git a/packages/shared/src/acp-startup-budget.ts b/packages/shared/src/acp-startup-budget.ts new file mode 100644 index 000000000..12cbb37ab --- /dev/null +++ b/packages/shared/src/acp-startup-budget.ts @@ -0,0 +1,79 @@ +/** + * How long the MACHINE may take to answer one ACP startup round-trip. + * + * A client that also runs its own deadline over the same request needs these + * numbers, or the two clocks disagree about who owns the truth. That is not + * hypothetical: the Electron local transport used a 120s socket timeout while + * the machine could still be inside a 300s cold `npx` init, so the client + * reported a timeout for a request the machine was still working on and would + * have answered — including its real failure reason — a few minutes later. + * + * The machine owns the deadline. A client budget derived from these constants + * is a backstop for a daemon that died without replying, never a competing + * deadline, so it must stay strictly larger than the machine's worst case. + */ + +/** Hard timeout on ACP `initialize` for an already-installed runtime. */ +export const ACP_INIT_TIMEOUT_MS = 120_000; + +/** + * `initialize` for a registry agent distributed through `npx`, whose first run + * may still be resolving and unpacking the package tree. + */ +export const ACP_COLD_NPX_INIT_TIMEOUT_MS = 300_000; + +/** Hard timeout on ACP `session/new`, which starts the agent's own subprocesses. */ +export const ACP_NEW_SESSION_TIMEOUT_MS = 120_000; + +/** + * Attempts `runNpxStartupWithRecovery` may spend on one startup request. + * + * A cold `initialize` timeout, npm cache corruption, stale package metadata, or + * a broken install each purge and retry, and every retry starts a fresh + * process that gets the full per-attempt timeouts again. + */ +export const ACP_NPX_STARTUP_MAX_ATTEMPTS = 3; + +/** + * Worst-case machine time for ONE startup attempt: the slowest `initialize` + * followed by `session/new`. + * + * Runtime download is deliberately excluded. It streams progress frames, so an + * inactivity-based transport timeout is continuously reset while it runs and a + * slow download cannot expire the request. + */ +export const ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS = + ACP_COLD_NPX_INIT_TIMEOUT_MS + ACP_NEW_SESSION_TIMEOUT_MS; + +/** + * Terminating the failed child (a 3s SIGTERM grace) and purging the npx/npm + * cache directories before the next attempt. Not covered by either ACP timeout, + * and the retry loop emits no progress frame across it, so it is silence the + * client budget has to absorb like any other. + */ +const ACP_STARTUP_ATTEMPT_CLEANUP_MS = 10_000; + +/** + * Worst-case machine time for a capability refresh: every attempt the recovery + * policy is allowed to make, plus the cleanup between them. + * + * Budgeting a single attempt is what makes a client backstop lie. The machine + * treats a cold `initialize` timeout as a reason to purge and try again, so a + * one-attempt budget expires while the machine is still executing its intended + * recovery — and the user is told "timed out" instead of the reason the machine + * would have reported on attempt three. + */ +export const ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS = + ACP_NPX_STARTUP_MAX_ATTEMPTS * ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS + + (ACP_NPX_STARTUP_MAX_ATTEMPTS - 1) * ACP_STARTUP_ATTEMPT_CLEANUP_MS; + +/** Headroom for process spawn, teardown, and transport overhead. */ +const CLIENT_BACKSTOP_MARGIN_MS = 30_000; + +/** + * Client-side backstop for one `machine/acp-capabilities-refresh`. Larger than + * {@link ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS} on purpose: reaching it + * means the machine never replied at all. + */ +export const ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS = + ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS + CLIENT_BACKSTOP_MARGIN_MS; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f0a5edc80..6cf56f0bc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -23,6 +23,7 @@ export * from './ai'; export * from './message-text-spans'; export * from './deepseek-harness'; export * from './acp-run-config'; +export * from './acp-startup-budget'; export * from './image-file-types'; export * from './custom-acp-command'; export * from './session-image'; diff --git a/packages/shared/tests/acp-startup-budget.test.ts b/packages/shared/tests/acp-startup-budget.test.ts new file mode 100644 index 000000000..e746ee841 --- /dev/null +++ b/packages/shared/tests/acp-startup-budget.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { + ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS, + ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS, + ACP_COLD_NPX_INIT_TIMEOUT_MS, + ACP_INIT_TIMEOUT_MS, + ACP_NEW_SESSION_TIMEOUT_MS, + ACP_NPX_STARTUP_MAX_ATTEMPTS, + ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS, +} from '../src/acp-startup-budget'; + +describe('acp startup budget', () => { + it('covers the slowest initialize followed by session/new in one attempt', () => { + expect(ACP_COLD_NPX_INIT_TIMEOUT_MS).toBeGreaterThanOrEqual(ACP_INIT_TIMEOUT_MS); + expect(ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS).toBe( + ACP_COLD_NPX_INIT_TIMEOUT_MS + ACP_NEW_SESSION_TIMEOUT_MS + ); + }); + + it('covers every attempt the npx recovery policy may make, not just the first', () => { + // A cold `initialize` timeout is a reason for the machine to purge and try + // again, and each retry gets the full per-attempt timeouts. Budgeting one + // attempt lets the client expire during attempt two or three — while the + // machine is still executing the recovery it intended — and report a + // client timeout instead of the machine's own final reason. + expect(ACP_NPX_STARTUP_MAX_ATTEMPTS).toBeGreaterThan(1); + expect(ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS).toBeGreaterThan( + ACP_NPX_STARTUP_MAX_ATTEMPTS * ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS + ); + }); + + it('keeps the client backstop strictly above the machine budget', () => { + // A client deadline at or below the machine budget expires requests the + // machine is still working on, and reports a transport timeout instead of + // the machine's own failure reason. + expect(ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS).toBeGreaterThan( + ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS + ); + }); +});