Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/src/agent/acp-npx-startup-policy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -99,7 +107,7 @@ export async function runNpxStartupWithRecovery<T>(
});
}

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);
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/src/agent/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions apps/cli/src/agent/npx-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({});
Expand Down
8 changes: 7 additions & 1 deletion apps/electron/src/main/services/cli-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "检查中",
Expand All @@ -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 后再试。",
Expand All @@ -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": "已验证",
Expand All @@ -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",
Expand All @@ -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": "尚未选择",
Expand Down
Loading
Loading