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

fix(desktop): recover gateway chat startup after employee-key bind

parent fa1a14fe
import type {
AppConfig,
GatewayStatus,
RuntimeCloudFetchAction,
RuntimeStatus
} from "@qjclaw/shared-types";
import { isTransientLocalGatewayError } from "./workspace-startup.js";
export interface ChatGatewayRecoveryCoordinator {
getConfig: () => Promise<AppConfig>;
runtimeStatus: () => Promise<RuntimeStatus>;
gatewayStatus: () => Promise<GatewayStatus | null>;
startManagedRuntime: (
reason: string,
options?: {
action?: RuntimeCloudFetchAction;
restart?: boolean;
config?: AppConfig;
inputToken?: string;
}
) => Promise<RuntimeStatus>;
shouldRefreshGatewayClient: (config?: AppConfig, inputToken?: string) => Promise<boolean>;
reconfigureGatewayClient: (config?: AppConfig, inputToken?: string) => Promise<void>;
connectGatewayClientWithRetry: () => Promise<void>;
}
function isManagedRuntimeMode(config: AppConfig): boolean {
return config.runtimeMode !== "external-gateway";
}
function isGatewayConnected(status: GatewayStatus | null): boolean {
return status?.state === "connected";
}
function toRuntimeNotReadyError(status: RuntimeStatus): Error {
return new Error(status.lastError ?? status.message ?? "Local assistant is not ready.");
}
function toGatewayNotReadyError(status: GatewayStatus | null): Error {
return new Error(status?.lastError ?? status?.message ?? "Gateway is not connected.");
}
export function isRecoverableGatewayChatError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway websocket is not open")
|| isTransientLocalGatewayError(message);
}
export async function ensureGatewayReadyForChat(
coordinator: ChatGatewayRecoveryCoordinator,
reason: string,
inputToken?: string
): Promise<void> {
const config = await coordinator.getConfig();
let runtimeStatus = await coordinator.runtimeStatus();
const managedRuntimeMode = isManagedRuntimeMode(config);
if (managedRuntimeMode && runtimeStatus.processState !== "running") {
runtimeStatus = await coordinator.startManagedRuntime(reason, {
action: "init",
config,
inputToken
});
}
const shouldRefreshGatewayClient = await coordinator.shouldRefreshGatewayClient(config, inputToken);
if (shouldRefreshGatewayClient) {
await coordinator.reconfigureGatewayClient(config, inputToken);
}
let gatewayStatus = await coordinator.gatewayStatus();
if (!isGatewayConnected(gatewayStatus) || shouldRefreshGatewayClient) {
await coordinator.connectGatewayClientWithRetry();
gatewayStatus = await coordinator.gatewayStatus();
}
runtimeStatus = await coordinator.runtimeStatus();
if (managedRuntimeMode && runtimeStatus.processState !== "running") {
throw toRuntimeNotReadyError(runtimeStatus);
}
if (!isGatewayConnected(gatewayStatus)) {
throw toGatewayNotReadyError(gatewayStatus);
}
}
export async function recoverGatewayForChat(
coordinator: ChatGatewayRecoveryCoordinator,
_reason: string,
inputToken?: string
): Promise<void> {
const config = await coordinator.getConfig();
await coordinator.reconfigureGatewayClient(config, inputToken);
await coordinator.connectGatewayClientWithRetry();
const runtimeStatus = await coordinator.runtimeStatus();
if (isManagedRuntimeMode(config) && runtimeStatus.processState !== "running") {
throw toRuntimeNotReadyError(runtimeStatus);
}
const gatewayStatus = await coordinator.gatewayStatus();
if (!isGatewayConnected(gatewayStatus)) {
throw toGatewayNotReadyError(gatewayStatus);
}
}
export async function runGatewayChatRequestWithRecovery<T>(
coordinator: ChatGatewayRecoveryCoordinator,
input: {
reason: string;
inputToken?: string;
execute: () => Promise<T>;
}
): Promise<T> {
await ensureGatewayReadyForChat(coordinator, input.reason, input.inputToken);
try {
return await input.execute();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isRecoverableGatewayChatError(message)) {
throw error;
}
await recoverGatewayForChat(coordinator, `${input.reason}-recover`, input.inputToken);
return input.execute();
}
}
...@@ -51,6 +51,9 @@ import { ...@@ -51,6 +51,9 @@ import {
refreshProjectContextAfterExecution, refreshProjectContextAfterExecution,
shouldRefreshProjectContextAfterExecution shouldRefreshProjectContextAfterExecution
} from "./services/project-context-lifecycle.js"; } from "./services/project-context-lifecycle.js";
import {
runGatewayChatRequestWithRecovery
} from "./chat-gateway-recovery.js";
import { import {
buildChatSummary, buildChatSummary,
shouldRetryBootstrapWarmup, shouldRetryBootstrapWarmup,
...@@ -399,6 +402,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -399,6 +402,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return status; return status;
}; };
const chatGatewayRecoveryCoordinator = {
getConfig: () => getEffectiveConfig(),
runtimeStatus: () => runtimeManager.status(),
gatewayStatus: () => gatewayClient.status().catch(() => null),
startManagedRuntime,
shouldRefreshGatewayClient,
reconfigureGatewayClient,
connectGatewayClientWithRetry
};
let workspaceWarmupTail: Promise<void> = Promise.resolve(); let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false; let workspaceWarmupInFlight = false;
let bootstrapRecoveryAttempts = 0; let bootstrapRecoveryAttempts = 0;
...@@ -780,7 +793,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -780,7 +793,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
executionPolicy: preparedExecution.executionPolicy executionPolicy: preparedExecution.executionPolicy
}; };
} }
const result = await gatewayClient.sendPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt); const result = await runGatewayChatRequestWithRecovery(chatGatewayRecoveryCoordinator, {
reason: "chat-send",
execute: () => gatewayClient.sendPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt)
});
await projectStore.appendSessionMessage(result.sessionId, result.reply); await projectStore.appendSessionMessage(result.sessionId, result.reply);
await projectStore.updateSessionLastActive(result.sessionId).catch(() => undefined); await projectStore.updateSessionLastActive(result.sessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId);
...@@ -970,7 +986,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -970,7 +986,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
executionPolicy: executionPolicy ?? undefined executionPolicy: executionPolicy ?? undefined
}; };
} }
const stream = await gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, { const stream = await runGatewayChatRequestWithRecovery(chatGatewayRecoveryCoordinator, {
reason: "chat-stream",
execute: () => gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => { onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({ queueOrSend({
type: "started", type: "started",
...@@ -1033,6 +1051,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1033,6 +1051,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
queueProjectContextRefresh(); queueProjectContextRefresh();
} }
})
}); });
ready = true; ready = true;
flushQueuedEvents({ flushQueuedEvents({
...@@ -1098,6 +1117,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1098,6 +1117,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await runtimeManager.setRequestedMode(config.runtimeMode); await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) { if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await reconfigureGatewayClient(config, input.gatewayToken);
void queueWorkspaceWarmup("config-save", { void queueWorkspaceWarmup("config-save", {
action: "init", action: "init",
config, config,
...@@ -1224,6 +1244,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1224,6 +1244,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await runtimeManager.setRequestedMode(config.runtimeMode); await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) { if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await reconfigureGatewayClient(config, input.gatewayToken);
void queueWorkspaceWarmup("config-save", { void queueWorkspaceWarmup("config-save", {
action: "init", action: "init",
config, config,
......
...@@ -12,7 +12,8 @@ export function isTransientLocalGatewayError(message?: string): boolean { ...@@ -12,7 +12,8 @@ export function isTransientLocalGatewayError(message?: string): boolean {
} }
const normalized = message.toLowerCase(); const normalized = message.toLowerCase();
return normalized.includes("gateway closed during connect (1006)") return normalized.includes("gateway websocket is not open")
|| normalized.includes("gateway closed during connect (1006)")
|| normalized.includes("gateway closed during connect (1005)") || normalized.includes("gateway closed during connect (1005)")
|| normalized.includes("gateway closed during connect (1000)") || normalized.includes("gateway closed during connect (1000)")
|| normalized.includes("econnrefused") || normalized.includes("econnrefused")
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
- `apps/ui` emits its production bundle into `apps/desktop/dist/renderer` - `apps/ui` emits its production bundle into `apps/desktop/dist/renderer`
- `apps/desktop` packages the final EXE - `apps/desktop` packages the final EXE
- `vendor/openclaw-runtime` is reserved for the pinned runtime payload - `vendor/openclaw-runtime` is reserved for the pinned runtime payload
- `installer-smoke.ps1` performs a real silent NSIS install into `.tmp`, launches the installed app in smoke mode, and validates packaged paths plus diagnostics output - `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; `pnpm smoke:installer`
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions - `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation - `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
- `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache` - `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache`
...@@ -12,6 +12,8 @@ ...@@ -12,6 +12,8 @@
- `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle` - `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat` - `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe` - `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
- `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall QianjiangClaw.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change`
- `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall QianjiangClaw.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue`
- `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh` - `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh`
- `project-empty-inventory-smoke.ps1` compiles the targeted `project-empty-inventory-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that an empty project inventory stays empty, session listing returns `[]`, session creation is blocked with the pending-cloud message, and the first synced bundle-backed project becomes active cleanly; `pnpm smoke:empty-project-inventory` - `project-empty-inventory-smoke.ps1` compiles the targeted `project-empty-inventory-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that an empty project inventory stays empty, session listing returns `[]`, session creation is blocked with the pending-cloud message, and the first synced bundle-backed project becomes active cleanly; `pnpm smoke:empty-project-inventory`
- `project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile` - `project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile`
...@@ -19,4 +21,5 @@ ...@@ -19,4 +21,5 @@
- `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` - `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`
- `chat-gateway-recovery-smoke.ps1` compiles the targeted `chat-gateway-recovery-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies send-time gateway readiness enforcement plus single-shot reconnect/retry for `Gateway websocket is not open.`; `pnpm smoke:chat-gateway-recovery`
- `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\chat-gateway-recovery-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\chat-gateway-recovery-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\chat-gateway-recovery-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 "Chat gateway recovery 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 chat-gateway-recovery smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Chat gateway recovery smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running chat-gateway-recovery smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Chat gateway recovery 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, RuntimeStatus } from "../../packages/shared-types/src/index.js";
import {
ensureGatewayReadyForChat,
isRecoverableGatewayChatError,
runGatewayChatRequestWithRecovery,
type ChatGatewayRecoveryCoordinator
} from "../../apps/desktop/src/main/chat-gateway-recovery.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 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
};
}
function createCoordinatorFixture(input: {
config?: AppConfig;
runtimeStatus?: RuntimeStatus;
gatewayStatus?: GatewayStatus | null;
shouldRefreshGatewayClient?: boolean[];
onStartManagedRuntime?: () => RuntimeStatus;
}): ChatGatewayRecoveryCoordinator & {
counters: {
startManagedRuntime: number;
reconfigureGatewayClient: number;
connectGatewayClientWithRetry: number;
shouldRefreshGatewayClient: number;
};
} {
const config = input.config ?? createConfig();
let runtimeStatus = input.runtimeStatus ?? createRuntimeStatus();
let gatewayStatus = input.gatewayStatus ?? createGatewayStatus();
const refreshDecisions = [...(input.shouldRefreshGatewayClient ?? [false])];
const counters = {
startManagedRuntime: 0,
reconfigureGatewayClient: 0,
connectGatewayClientWithRetry: 0,
shouldRefreshGatewayClient: 0
};
return {
counters,
getConfig: async () => config,
runtimeStatus: async () => runtimeStatus,
gatewayStatus: async () => gatewayStatus,
startManagedRuntime: async () => {
counters.startManagedRuntime += 1;
runtimeStatus = input.onStartManagedRuntime?.() ?? createRuntimeStatus();
return runtimeStatus;
},
shouldRefreshGatewayClient: async () => {
counters.shouldRefreshGatewayClient += 1;
return refreshDecisions.shift() ?? false;
},
reconfigureGatewayClient: async () => {
counters.reconfigureGatewayClient += 1;
gatewayStatus = createGatewayStatus({
state: "disconnected",
message: "Gateway configuration updated."
});
},
connectGatewayClientWithRetry: async () => {
counters.connectGatewayClientWithRetry += 1;
gatewayStatus = createGatewayStatus();
}
};
}
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", "chat-gateway-recovery-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
await rm(tempRoot, { recursive: true, force: true });
await mkdir(tempRoot, { recursive: true });
assert(isRecoverableGatewayChatError("Gateway websocket is not open."), "Expected websocket-not-open to be recoverable.");
assert(isRecoverableGatewayChatError("Gateway closed during connect (1006)."), "Expected 1006 to be recoverable.");
assert(!isRecoverableGatewayChatError("Policy violation"), "Policy violation must not be treated as recoverable chat transport noise.");
const readinessCoordinator = createCoordinatorFixture({
runtimeStatus: createRuntimeStatus({
processState: "starting",
message: "Managed bundled runtime is starting."
}),
gatewayStatus: createGatewayStatus({
state: "disconnected",
message: "Gateway disconnected."
}),
shouldRefreshGatewayClient: [true]
});
await ensureGatewayReadyForChat(readinessCoordinator, "chat-send");
assert(readinessCoordinator.counters.startManagedRuntime === 1, "Bundled runtime chat preparation should start the managed runtime when it is not running.");
assert(readinessCoordinator.counters.reconfigureGatewayClient === 1, "Bundled runtime chat preparation should reconfigure the gateway client when refresh is required.");
assert(readinessCoordinator.counters.connectGatewayClientWithRetry === 1, "Bundled runtime chat preparation should reconnect the gateway client when disconnected.");
const retryCoordinator = createCoordinatorFixture({
shouldRefreshGatewayClient: [false]
});
let executeCount = 0;
const retryResult = await runGatewayChatRequestWithRecovery(retryCoordinator, {
reason: "chat-stream",
execute: async () => {
executeCount += 1;
if (executeCount === 1) {
throw new Error("Gateway websocket is not open.");
}
return {
requestId: "request-1",
sessionId: "session-1"
};
}
});
assert(executeCount === 2, "Recoverable chat failures should retry the gateway request exactly once.");
assert(retryCoordinator.counters.reconfigureGatewayClient === 1, "Recoverable chat failures should force a gateway reconfigure before retry.");
assert(retryCoordinator.counters.connectGatewayClientWithRetry === 1, "Recoverable chat failures should reconnect the gateway before retry.");
const failureCoordinator = createCoordinatorFixture({
shouldRefreshGatewayClient: [false]
});
let unrecoverableError = "";
try {
await runGatewayChatRequestWithRecovery(failureCoordinator, {
reason: "chat-send",
execute: async () => {
throw new Error("Policy violation");
}
});
} catch (error) {
unrecoverableError = error instanceof Error ? error.message : String(error);
}
assert(unrecoverableError === "Policy violation", "Unrecoverable gateway errors should surface unchanged.");
assert(failureCoordinator.counters.reconfigureGatewayClient === 0, "Unrecoverable gateway errors must not trigger forced reconnect.");
assert(failureCoordinator.counters.connectGatewayClientWithRetry === 0, "Unrecoverable gateway errors must not trigger forced reconnect.");
const summary = {
ok: true,
readinessCounters: readinessCoordinator.counters,
retryCounters: retryCoordinator.counters,
retryResult,
executeCount,
unrecoverableError
};
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", "chat-gateway-recovery-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;
});
...@@ -113,6 +113,7 @@ async function main(): Promise<void> { ...@@ -113,6 +113,7 @@ async function main(): Promise<void> {
await mkdir(tempRoot, { recursive: true }); await mkdir(tempRoot, { recursive: true });
const transientCodes = [ const transientCodes = [
"Gateway websocket is not open.",
"Gateway closed during connect (1006).", "Gateway closed during connect (1006).",
"Gateway closed during connect (1005).", "Gateway closed during connect (1005).",
"Gateway closed during connect (1000).", "Gateway closed during connect (1000).",
......
...@@ -28,7 +28,10 @@ ...@@ -28,7 +28,10 @@
"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: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:chat-gateway-recovery": "powershell -ExecutionPolicy Bypass -File build/scripts/chat-gateway-recovery-smoke.ps1",
"smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime",
"smoke:installer:path-change": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-path-change-smoke.ps1",
"smoke:installer:target-residue": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-target-residue-smoke.ps1"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
......
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