Commit d91d9409 authored by AI-甘富林's avatar AI-甘富林

feat(desktop): harden bundled runtime startup and workspace launch state

- Extract workspace-startup.ts with buildChatSummary, shouldRetryBootstrapWarmup,
  isTransientLocalGatewayError and toStartupErrorMessage for unified startup state
- Converge index.ts and ipc.ts to use runtimeManager.getGatewayConnection() as
  truth source for bundled-runtime mode, preventing fallback to persisted 18789
- Add WINDOWS_POWERSHELL_PATH / WINDOWS_TASKKILL_PATH absolute paths in
  runtime-manager to fix Git Bash path hijacking on Windows
- Buffer last 50 stderr lines and surface name-conflict / port-in-use hints in
  lastError via buildStderrHint()
- Add smoke:workspace-startup script and workspace-startup-smoke test
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent e47643bd
This diff is collapsed.
This diff is collapsed.
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { RuntimeModePreference } from "@qjclaw/shared-types";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
...@@ -46,7 +47,17 @@ export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGat ...@@ -46,7 +47,17 @@ export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGat
} }
} }
export function resolveEffectiveGatewayUrl(configuredUrl: string, discoveredUrl?: string): string { export function shouldUseLocalOpenClawGateway(
isPackaged: boolean,
runtimeMode: RuntimeModePreference
): boolean {
return !isPackaged || runtimeMode === "external-gateway";
}
export function resolveEffectiveGatewayUrl(
configuredUrl: string,
discoveredUrl?: string
): string {
if (!discoveredUrl) { if (!discoveredUrl) {
return configuredUrl; return configuredUrl;
} }
...@@ -58,6 +69,21 @@ export function resolveEffectiveGatewayUrl(configuredUrl: string, discoveredUrl? ...@@ -58,6 +69,21 @@ export function resolveEffectiveGatewayUrl(configuredUrl: string, discoveredUrl?
return configuredUrl; return configuredUrl;
} }
export function resolveEffectiveGatewayToken(
configuredToken?: string,
discoveredToken?: string
): string | undefined {
if (configuredToken && configuredToken.trim()) {
return configuredToken;
}
if (discoveredToken && discoveredToken.trim()) {
return discoveredToken;
}
return undefined;
}
function buildGatewayUrl(config: OpenClawGatewayConfigShape): string { function buildGatewayUrl(config: OpenClawGatewayConfigShape): string {
const port = typeof config.port === "number" ? config.port : 18789; const port = typeof config.port === "number" ? config.port : 18789;
const host = resolveHost(config); const host = resolveHost(config);
......
import type {
AppConfig,
GatewayStatus,
RuntimeCloudStatus,
RuntimeStatus,
WorkspaceSummary
} from "@qjclaw/shared-types";
export function isTransientLocalGatewayError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway closed during connect (1006)")
|| normalized.includes("gateway closed during connect (1005)")
|| normalized.includes("gateway closed during connect (1000)")
|| normalized.includes("econnrefused")
|| normalized.includes("failed to connect to ws://127.0.0.1")
|| normalized.includes("failed to connect to ws://localhost")
|| normalized.includes("gateway readiness")
|| normalized.includes("gateway became ready")
|| normalized.includes("gateway closed during readiness probe")
|| normalized.includes("gateway closed before readiness probe completed")
|| normalized.includes("bundled runtime exited before gateway became ready");
}
function isGatewayPolicyViolationError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway connection closed (1008)")
|| normalized.includes("gateway closed during connect (1008)")
|| normalized.includes("policy violation");
}
function isBundledRuntimeNameConflictError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway name/hostname conflict detected")
|| normalized.includes("name conflict")
|| normalized.includes("hostname conflict")
|| normalized.includes("bonjour");
}
export function toStartupErrorMessage(message: string | undefined, fallback: string): string {
if (isTransientLocalGatewayError(message)) {
return "本地助手暂时没有准备好,请重新准备。";
}
if (isBundledRuntimeNameConflictError(message)) {
return "内置运行时启动失败:检测到本机已有 OpenClaw 实例导致网关名称冲突,请先退出本地 OpenClaw 后重试。";
}
if (isGatewayPolicyViolationError(message)) {
return "检测到本机已有 OpenClaw 网关正在运行,但安装包未能切换到内置运行时,请先退出本地 OpenClaw 后重试。";
}
return message ?? fallback;
}
export function shouldRetryManagedRuntimeStartup(config: AppConfig, status: RuntimeStatus): boolean {
if (config.runtimeMode === "external-gateway" || status.processState !== "error") {
return false;
}
const bundledRuntimeSelected = status.selectedMode === "bundled-runtime" || status.activeMode === "bundled-runtime";
if (!bundledRuntimeSelected) {
return false;
}
return isTransientLocalGatewayError(status.lastError ?? status.message);
}
export function shouldRetryBootstrapWarmup(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
gatewayStatus: GatewayStatus | null;
isPackaged: boolean;
}): boolean {
if (!input.isPackaged || input.config.runtimeMode === "external-gateway") {
return false;
}
const bundledRuntimeSelected = input.runtimeStatus.selectedMode === "bundled-runtime"
|| input.runtimeStatus.activeMode === "bundled-runtime";
if (!bundledRuntimeSelected) {
return false;
}
const runtimeError = input.runtimeStatus.lastError ?? input.runtimeStatus.message;
if (input.runtimeStatus.processState === "error" && isTransientLocalGatewayError(runtimeError)) {
return true;
}
const gatewayError = input.gatewayStatus?.lastError ?? input.gatewayStatus?.message;
return input.gatewayStatus?.state === "error" && isTransientLocalGatewayError(gatewayError);
}
export function buildChatSummary(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
runtimeCloudStatus: RuntimeCloudStatus;
gatewayStatus: GatewayStatus | null;
warmupInFlight: boolean;
isPackaged: boolean;
}): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
const {
config,
runtimeStatus,
runtimeCloudStatus,
gatewayStatus,
warmupInFlight,
isPackaged
} = input;
if (!config.apiKeyConfigured) {
const setupMessage = config.setupMode === "direct-provider"
? "请先完成厂商与 API Key 配置。"
: "请先绑定员工密钥。";
return {
chatReady: false,
chatLaunchState: "unbound",
chatStatusMessage: setupMessage,
startupPhase: "idle",
startupMessage: setupMessage
};
}
if (config.setupMode === "employee-key" && runtimeCloudStatus.state === "error") {
const runtimeCloudError = runtimeCloudStatus.lastError ?? "员工配置同步失败,请检查密钥或网络连接。";
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeCloudError,
startupPhase: "error",
startupMessage: runtimeCloudError
};
}
const packagedBundledRuntime = isPackaged && config.runtimeMode !== "external-gateway";
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
if (
warmupInFlight
&& packagedBundledRuntime
&& runtimeStatus.processState === "error"
&& isTransientLocalGatewayError(runtimeError)
) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在重新唤起本地助手,请稍候。",
startupPhase: "starting-runtime",
startupMessage: "正在重新唤起本地助手,请稍候。"
};
}
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
if (
warmupInFlight
&& packagedBundledRuntime
&& gatewayStatus?.state === "error"
&& isTransientLocalGatewayError(gatewayError)
) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在重新连接聊天服务,请稍候。",
startupPhase: "connecting-gateway",
startupMessage: "正在重新连接聊天服务,请稍候。"
};
}
if (runtimeStatus.processState === "error") {
const runtimeErrorMessage = toStartupErrorMessage(runtimeError, "本地助手启动失败,请稍后重试。");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeErrorMessage,
startupPhase: "error",
startupMessage: runtimeErrorMessage
};
}
if (gatewayStatus?.state === "error") {
const gatewayErrorMessage = toStartupErrorMessage(gatewayError, "聊天服务连接失败,请稍后重试。");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: gatewayErrorMessage,
startupPhase: "error",
startupMessage: gatewayErrorMessage
};
}
const runtimeCanServeChat = runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
if (runtimeCanServeChat && gatewayStatus?.state === "connected") {
return {
chatReady: true,
chatLaunchState: "ready",
chatStatusMessage: "聊天服务已就绪。",
startupPhase: "ready",
startupMessage: "聊天服务已就绪。"
};
}
if (config.setupMode === "employee-key" && (runtimeCloudStatus.state === "loading" || runtimeCloudStatus.state === "unconfigured")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在同步员工配置,请稍候。",
startupPhase: "syncing-config",
startupMessage: "正在同步员工配置,请稍候。"
};
}
if (runtimeStatus.processState === "starting" || (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState !== "running")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: runtimeStatus.message || "正在唤起本地助手,请稍候。",
startupPhase: "starting-runtime",
startupMessage: runtimeStatus.message || "正在唤起本地助手,请稍候。"
};
}
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: gatewayStatus?.message ?? "正在连接聊天服务,请稍候。",
startupPhase: "connecting-gateway",
startupMessage: gatewayStatus?.message ?? "正在连接聊天服务,请稍候。"
};
}
...@@ -18,4 +18,5 @@ ...@@ -18,4 +18,5 @@
- `project-bundle-freshness-smoke.ps1` compiles the targeted `project-bundle-freshness-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that the same bundle URL plus unchanged `configVersion` still re-syncs when remote `ETag` / `Last-Modified` freshness metadata changes; `pnpm smoke:bundle-freshness` - `project-bundle-freshness-smoke.ps1` compiles the targeted `project-bundle-freshness-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that the same bundle URL plus unchanged `configVersion` still re-syncs when remote `ETag` / `Last-Modified` freshness metadata changes; `pnpm smoke:bundle-freshness`
- `project-bundle-replacement-smoke.ps1` compiles the targeted `project-bundle-replacement-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies same-project replacement, shared `skills/` and `cron/` ownership cleanup, rollback on an injected post-commit failure, and successful recovery on the next sync; `pnpm smoke:bundle-replacement` - `project-bundle-replacement-smoke.ps1` compiles the targeted `project-bundle-replacement-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies same-project replacement, shared `skills/` and `cron/` ownership cleanup, rollback on an injected post-commit failure, and successful recovery on the next sync; `pnpm smoke:bundle-replacement`
- `project-bundle-churn-smoke.ps1` compiles the targeted `project-bundle-churn-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies multi-project churn with stable survivors, same-project replacement, project removal, project addition, active-project fallback, and session survival inside unaffected projects; `pnpm smoke:bundle-churn` - `project-bundle-churn-smoke.ps1` compiles the targeted `project-bundle-churn-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies multi-project churn with stable survivors, same-project replacement, project removal, project addition, active-project fallback, and session survival inside unaffected projects; `pnpm smoke:bundle-churn`
- `workspace-startup-smoke.ps1` compiles the targeted `workspace-startup-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies packaged startup error classification plus local OpenClaw isolation policy; `pnpm smoke:workspace-startup`
- `project-isolation-smoke.ps1` runs the main project-isolation regression gate end to end, including workspace-entry, default-chat, cloud-bundle Electron lifecycle coverage, project-context refresh, empty-project inventory, bundle reconcile, bundle freshness, bundle replacement, and multi-project churn; `pnpm smoke:project-isolation` - `project-isolation-smoke.ps1` runs the main project-isolation regression gate end to end, including workspace-entry, default-chat, cloud-bundle Electron lifecycle coverage, project-context refresh, empty-project inventory, bundle reconcile, bundle freshness, bundle replacement, and multi-project churn; `pnpm smoke:project-isolation`
param(
[string]$ResultPath
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$desktopAppRoot = Join-Path $repoRoot 'apps\desktop'
$sourcePath = Join-Path $repoRoot 'build\scripts\workspace-startup-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\workspace-startup-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\workspace-startup-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($ResultPath) { $ResultPath } else { Join-Path $tempRoot 'result.json' }
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
if (-not (Test-Path $sourcePath)) {
throw "Workspace startup smoke source was not found: $sourcePath"
}
if (Test-Path $compileRoot) {
Remove-Item $compileRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $compileRoot -Force | Out-Null
$compileArgs = @(
'pnpm',
'--dir', $desktopAppRoot,
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling workspace startup smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Workspace startup smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running workspace startup smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Workspace startup smoke did not produce a result file: $resolvedResultPath"
}
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AppConfig, GatewayStatus, RuntimeCloudStatus, RuntimeStatus } from "../../packages/shared-types/src/index.js";
import {
resolveEffectiveGatewayToken,
resolveEffectiveGatewayUrl,
shouldUseLocalOpenClawGateway
} from "../../apps/desktop/src/main/services/openclaw-local-config.js";
import {
buildChatSummary,
isTransientLocalGatewayError,
shouldRetryBootstrapWarmup
} from "../../apps/desktop/src/main/workspace-startup.js";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
setupMode: "employee-key",
provider: "openai",
baseUrl: "https://api.example.com",
apiKeyConfigured: true,
gatewayTokenConfigured: true,
authTokenConfigured: true,
defaultModel: "gpt-test",
workspacePath: "D:/qjclaw/.tmp/workspace",
gatewayUrl: "ws://127.0.0.1:18789",
cloudApiBaseUrl: "https://cloud.example.com",
runtimeCloudApiBaseUrl: "https://cloud.example.com",
runtimeMode: "bundled-runtime",
...overrides
};
}
function createRuntimeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
return {
requestedMode: "bundled-runtime",
selectedMode: "bundled-runtime",
activeMode: "bundled-runtime",
payloadState: "ready",
processState: "running",
runtimeDir: "D:/runtime",
nodeExecutable: "D:/runtime/node/node.exe",
openClawEntry: "D:/runtime/openclaw/index.js",
defaultConfigPath: "D:/runtime/config/openclaw.json",
pythonExecutable: "D:/runtime/python/python.exe",
pythonManifestPath: "D:/runtime/python/python-manifest.json",
pythonReady: true,
pythonVersion: "3.11.0",
installedPythonPackages: [],
pythonMissingModules: [],
runtimeDataDir: "D:/runtime-data",
runtimeStateDir: "D:/runtime-data/state",
runtimeLogsDir: "D:/runtime-data/logs",
logFilePath: "D:/runtime-data/logs/runtime-manager.log",
gatewayUrl: "ws://127.0.0.1:18889",
gatewayTokenConfigured: true,
pid: 1234,
startedAt: new Date().toISOString(),
stoppedAt: undefined,
lastExitCode: undefined,
lastError: undefined,
message: "Managed bundled runtime process is running and Gateway is ready.",
modeReason: "Bundled runtime is selected.",
detectedFiles: [],
missingFiles: [],
checkedAt: new Date().toISOString(),
...overrides
};
}
function createRuntimeCloudStatus(overrides: Partial<RuntimeCloudStatus> = {}): RuntimeCloudStatus {
return {
state: "ready",
baseUrl: "https://cloud.example.com",
apiKeyConfigured: true,
lastFetchedAt: new Date().toISOString(),
lastError: undefined,
config: undefined,
...overrides
};
}
function createGatewayStatus(overrides: Partial<GatewayStatus> = {}): GatewayStatus {
return {
state: "connected",
url: "ws://127.0.0.1:18889",
host: "127.0.0.1",
port: 18889,
version: "test",
transport: "websocket",
lastConnectedAt: new Date().toISOString(),
lastError: undefined,
availableMethods: [],
message: "Gateway connected.",
...overrides
};
}
async function main(): Promise<void> {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "workspace-startup-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
await rm(tempRoot, { recursive: true, force: true });
await mkdir(tempRoot, { recursive: true });
const transientCodes = [
"Gateway closed during connect (1006).",
"Gateway closed during connect (1005).",
"Gateway closed during connect (1000).",
"connect ECONNREFUSED 127.0.0.1:18889"
];
for (const message of transientCodes) {
assert(isTransientLocalGatewayError(message), `Expected transient startup classification for: ${message}`);
}
const config = createConfig();
const runtimeStatus = createRuntimeStatus({
processState: "error",
lastError: "Gateway closed during connect (1006)."
});
const gatewayStatus = createGatewayStatus({
state: "error",
lastError: "Gateway closed during connect (1006).",
message: "Gateway closed during connect (1006)."
});
const startupSummary = buildChatSummary({
config,
runtimeStatus,
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus,
warmupInFlight: true,
isPackaged: true
});
assert(startupSummary.chatLaunchState === "starting", "Transient packaged startup failures should remain in starting state.");
assert(startupSummary.startupPhase === "starting-runtime", "Transient runtime failures should map to starting-runtime.");
const gatewayOnlySummary = buildChatSummary({
config,
runtimeStatus: createRuntimeStatus(),
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus,
warmupInFlight: true,
isPackaged: true
});
assert(gatewayOnlySummary.chatLaunchState === "starting", "Transient gateway failures should remain in starting state during packaged warmup.");
assert(gatewayOnlySummary.startupPhase === "connecting-gateway", "Transient gateway failures should map to connecting-gateway.");
assert(shouldRetryBootstrapWarmup({
config,
runtimeStatus,
gatewayStatus,
isPackaged: true
}), "Packaged bundled-runtime bootstrap should retry transient startup failures.");
assert(!shouldUseLocalOpenClawGateway(true, "bundled-runtime"), "Packaged bundled-runtime mode should ignore local OpenClaw.");
assert(shouldUseLocalOpenClawGateway(true, "external-gateway"), "Packaged external-gateway mode should allow local OpenClaw.");
assert(shouldUseLocalOpenClawGateway(false, "bundled-runtime"), "Dev mode should allow local OpenClaw.");
const ignoredGatewayUrl = resolveEffectiveGatewayUrl("ws://127.0.0.1:18789", undefined);
assert(ignoredGatewayUrl === "ws://127.0.0.1:18789", "Configured gateway URL should remain unchanged when no local override is used.");
const discoveredGatewayUrl = resolveEffectiveGatewayUrl("ws://127.0.0.1:18789", "ws://127.0.0.1:29999");
assert(discoveredGatewayUrl === "ws://127.0.0.1:29999", "Default gateway URL should yield to the discovered local OpenClaw URL when allowed.");
const resolvedGatewayToken = resolveEffectiveGatewayToken("", "local-token");
assert(resolvedGatewayToken === "local-token", "Local OpenClaw token should be used only when no configured token exists.");
const summary = {
ok: true,
transientCodes,
startupSummary,
gatewayOnlySummary,
shouldRetryBootstrap: true,
localOpenClawPolicy: {
packagedBundledRuntime: shouldUseLocalOpenClawGateway(true, "bundled-runtime"),
packagedExternalGateway: shouldUseLocalOpenClawGateway(true, "external-gateway"),
devBundledRuntime: shouldUseLocalOpenClawGateway(false, "bundled-runtime")
},
resolvedGatewayUrl: discoveredGatewayUrl,
resolvedGatewayToken
};
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
}
main().catch(async (error) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "workspace-startup-smoke", "result.json"));
const failure = {
ok: false,
error: error instanceof Error ? error.stack ?? error.message : String(error)
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(failure, null, 2), "utf8");
console.error(failure.error);
process.exitCode = 1;
});
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
"smoke:bundle-freshness": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-freshness-smoke.ps1", "smoke:bundle-freshness": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-freshness-smoke.ps1",
"smoke:bundle-replacement": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-replacement-smoke.ps1", "smoke:bundle-replacement": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-replacement-smoke.ps1",
"smoke:bundle-churn": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-churn-smoke.ps1", "smoke:bundle-churn": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-churn-smoke.ps1",
"smoke:workspace-startup": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-startup-smoke.ps1",
"smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime" "smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime"
}, },
"pnpm": { "pnpm": {
......
...@@ -182,6 +182,21 @@ function escapePowerShellSingleQuoted(value: string): string { ...@@ -182,6 +182,21 @@ function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''"); return value.replace(/'/g, "''");
} }
function resolveWindowsSystemExecutable(relativePath: string, fallback: string): string {
if (process.platform !== "win32") {
return fallback;
}
const systemRoot = process.env.SYSTEMROOT ?? process.env.WINDIR ?? "C:\\Windows";
return path.join(systemRoot, "System32", relativePath);
}
const WINDOWS_POWERSHELL_PATH = resolveWindowsSystemExecutable(
path.join("WindowsPowerShell", "v1.0", "powershell.exe"),
"powershell.exe"
);
const WINDOWS_TASKKILL_PATH = resolveWindowsSystemExecutable("taskkill.exe", "taskkill");
async function execPythonInlineScript(pythonExecutable: string, inlineScript: string): Promise<string> { async function execPythonInlineScript(pythonExecutable: string, inlineScript: string): Promise<string> {
try { try {
const { stdout } = await execFileAsync(pythonExecutable, ["-c", inlineScript]); const { stdout } = await execFileAsync(pythonExecutable, ["-c", inlineScript]);
...@@ -200,7 +215,7 @@ async function execPythonInlineScript(pythonExecutable: string, inlineScript: st ...@@ -200,7 +215,7 @@ async function execPythonInlineScript(pythonExecutable: string, inlineScript: st
"'@", "'@",
"& '" + escapePowerShellSingleQuoted(pythonExecutable) + "' -c $script" "& '" + escapePowerShellSingleQuoted(pythonExecutable) + "' -c $script"
].join("\n"); ].join("\n");
const { stdout } = await execFileAsync("powershell.exe", [ const { stdout } = await execFileAsync(WINDOWS_POWERSHELL_PATH, [
"-NoLogo", "-NoLogo",
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
...@@ -536,6 +551,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -536,6 +551,7 @@ export class RuntimeManager extends EventEmitter {
private lastStartedAt?: string; private lastStartedAt?: string;
private lastExitCode?: number | null; private lastExitCode?: number | null;
private lastError?: string; private lastError?: string;
private lastStderrLines: string[] = [];
private startPromise?: Promise<RuntimeStatus>; private startPromise?: Promise<RuntimeStatus>;
constructor(options: RuntimeManagerOptions) { constructor(options: RuntimeManagerOptions) {
super(); super();
...@@ -851,7 +867,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -851,7 +867,7 @@ export class RuntimeManager extends EventEmitter {
this.appendLog("warn", "Bundled runtime direct spawn was blocked with EPERM; retrying via PowerShell wrapper."); this.appendLog("warn", "Bundled runtime direct spawn was blocked with EPERM; retrying via PowerShell wrapper.");
const wrapperScript = this.buildWindowsChildWrapperScript(paths, childArgs, childStdoutLogPath, childStderrLogPath, childEnv); const wrapperScript = this.buildWindowsChildWrapperScript(paths, childArgs, childStdoutLogPath, childStderrLogPath, childEnv);
try { try {
child = spawn("powershell.exe", [ child = spawn(WINDOWS_POWERSHELL_PATH, [
"-NoLogo", "-NoLogo",
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
...@@ -875,10 +891,18 @@ export class RuntimeManager extends EventEmitter { ...@@ -875,10 +891,18 @@ export class RuntimeManager extends EventEmitter {
} }
this.child = child; this.child = child;
this.lastStderrLines = [];
child.stdout?.on("data", (chunk: Buffer) => { child.stdout?.on("data", (chunk: Buffer) => {
this.appendChunk("info", chunk, childStdoutLogPath); this.appendChunk("info", chunk, childStdoutLogPath);
}); });
child.stderr?.on("data", (chunk: Buffer) => this.appendChunk("warn", chunk, childStderrLogPath)); child.stderr?.on("data", (chunk: Buffer) => {
this.appendChunk("warn", chunk, childStderrLogPath);
const lines = chunk.toString().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
this.lastStderrLines.push(...lines);
if (this.lastStderrLines.length > 50) {
this.lastStderrLines = this.lastStderrLines.slice(-50);
}
});
child.once("error", (error) => { child.once("error", (error) => {
this.lastError = `Bundled runtime failed to start: ${error.message}`; this.lastError = `Bundled runtime failed to start: ${error.message}`;
this.lastStoppedAt = new Date().toISOString(); this.lastStoppedAt = new Date().toISOString();
...@@ -894,7 +918,8 @@ export class RuntimeManager extends EventEmitter { ...@@ -894,7 +918,8 @@ export class RuntimeManager extends EventEmitter {
this.child = undefined; this.child = undefined;
this.managedChildPid = undefined; this.managedChildPid = undefined;
if (!wasStopping && code !== 0) { if (!wasStopping && code !== 0) {
this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}.`; const stderrHint = this.buildStderrHint();
this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${stderrHint ? `: ${stderrHint}` : ""}.`;
this.appendLog("error", this.lastError); this.appendLog("error", this.lastError);
this.refreshStatus("error"); this.refreshStatus("error");
return; return;
...@@ -943,7 +968,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -943,7 +968,7 @@ export class RuntimeManager extends EventEmitter {
const failures: string[] = []; const failures: string[] = [];
for (const pid of pids) { for (const pid of pids) {
try { try {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]); await execFileAsync(WINDOWS_TASKKILL_PATH, ["/PID", String(pid), "/T", "/F"]);
} catch (error) { } catch (error) {
failures.push(error instanceof Error ? error.message : String(error)); failures.push(error instanceof Error ? error.message : String(error));
} }
...@@ -1193,6 +1218,18 @@ export class RuntimeManager extends EventEmitter { ...@@ -1193,6 +1218,18 @@ export class RuntimeManager extends EventEmitter {
}; };
} }
private buildStderrHint(): string | undefined {
const recent = this.lastStderrLines.join(" ").toLowerCase();
if (recent.includes("name conflict") || recent.includes("hostname conflict") || recent.includes("bonjour")) {
return "gateway name/hostname conflict detected (another OpenClaw instance is running)";
}
if (recent.includes("eaddrinuse") || recent.includes("address already in use")) {
return "port already in use";
}
const lastLine = this.lastStderrLines[this.lastStderrLines.length - 1];
return lastLine ?? undefined;
}
private inferProcessState(): RuntimeProcessState { private inferProcessState(): RuntimeProcessState {
if (this.runtimeStatus.processState === "stopping" && this.child) { if (this.runtimeStatus.processState === "stopping" && this.child) {
return "stopping"; return "stopping";
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment