From 5c97044865b7044e337dbafea8aefad74c2eeebe Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:06:31 +0800 Subject: [PATCH] fix(gateway): reduce ClawX context merge waits; bundle memory-core chokidar (#930) --- electron/api/routes/agents.ts | 2 +- electron/utils/openclaw-workspace.ts | 141 ++++++++++++++++++++------- package.json | 1 + pnpm-lock.yaml | 3 + scripts/bundle-openclaw.mjs | 1 + tests/unit/strip-first-run.test.ts | 56 +++++++++++ 6 files changed, 166 insertions(+), 38 deletions(-) diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts index e98c3b3..d6266e3 100644 --- a/electron/api/routes/agents.ts +++ b/electron/api/routes/agents.ts @@ -131,7 +131,7 @@ export async function handleAgentRoutes( scheduleGatewayReload(ctx, 'create-agent'); // Ensure newly provisioned workspaces get ClawX context merge/cleanup // even when gateway status events do not fire (e.g. in-process reload). - void ensureClawXContext().catch((err) => { + void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => { console.warn('[agents] Failed to ensure ClawX context after agent creation:', err); }); sendJson(res, 200, { success: true, ...snapshot }); diff --git a/electron/utils/openclaw-workspace.ts b/electron/utils/openclaw-workspace.ts index c6ec018..3123392 100644 --- a/electron/utils/openclaw-workspace.ts +++ b/electron/utils/openclaw-workspace.ts @@ -4,9 +4,9 @@ * All file I/O is async (fs/promises) to avoid blocking the Electron * main thread. */ -import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises'; +import { access, readFile, writeFile, readdir, unlink } from 'fs/promises'; import { constants } from 'fs'; -import { join } from 'path'; +import { join, resolve, sep } from 'path'; import { homedir } from 'os'; import { logger } from './logger'; import { getResourcesDir } from './paths'; @@ -20,10 +20,10 @@ async function fileExists(p: string): Promise { try { await access(p, constants.F_OK); return true; } catch { return false; } } -async function ensureDir(dir: string): Promise { - if (!(await fileExists(dir))) { - await mkdir(dir, { recursive: true }); - } +function isCurrentOpenClawPath(p: string): boolean { + const openclawDir = resolve(join(homedir(), '.openclaw')); + const workspaceDir = resolve(p); + return workspaceDir === openclawDir || workspaceDir.startsWith(openclawDir + sep); } // ── Pure helpers (no I/O) ──────────────────────────────────────── @@ -105,14 +105,29 @@ export function stripFirstRunSection(content: string): string { // ── Workspace directory resolution ─────────────────────────────── +type WorkspaceDir = { + dir: string; + /** + * Only the default workspace is expected to be seeded during Gateway startup. + * Other agent workspaces may remain empty until that agent is actually used, + * so missing bootstrap files there should not keep a startup retry loop alive. + */ + waitForGatewaySeed: boolean; +}; + /** - * Collect all unique workspace directories from the openclaw config: - * the defaults workspace, each agent's workspace, and any workspace-* - * directories that already exist under ~/.openclaw/. + * Collect all unique workspace directories from the openclaw config. */ -async function resolveAllWorkspaceDirs(): Promise { +async function resolveAllWorkspaceDirs(): Promise { const openclawDir = join(homedir(), '.openclaw'); - const dirs = new Set(); + const dirs = new Map(); + const addDir = (dir: string, waitForGatewaySeed: boolean) => { + const existing = dirs.get(dir); + dirs.set(dir, { + dir, + waitForGatewaySeed: waitForGatewaySeed || existing?.waitForGatewaySeed === true, + }); + }; const configPath = join(openclawDir, 'openclaw.json'); try { @@ -120,8 +135,10 @@ async function resolveAllWorkspaceDirs(): Promise { const config = JSON.parse(await readFile(configPath, 'utf-8')); const defaultWs = config?.agents?.defaults?.workspace; + let hasDefaultWorkspace = false; if (typeof defaultWs === 'string' && defaultWs.trim()) { - dirs.add(defaultWs.replace(/^~/, homedir())); + addDir(defaultWs.replace(/^~/, homedir()), true); + hasDefaultWorkspace = true; } const agents = config?.agents?.list; @@ -129,7 +146,9 @@ async function resolveAllWorkspaceDirs(): Promise { for (const agent of agents) { const ws = agent?.workspace; if (typeof ws === 'string' && ws.trim()) { - dirs.add(ws.replace(/^~/, homedir())); + const isMainDefault = + agent?.default === true || (agent?.id === 'main' && !hasDefaultWorkspace); + addDir(ws.replace(/^~/, homedir()), isMainDefault); } } } @@ -145,10 +164,10 @@ async function resolveAllWorkspaceDirs(): Promise { // explicitly declared in openclaw.json should be seeded. if (dirs.size === 0) { - dirs.add(join(openclawDir, 'workspace')); + addDir(join(openclawDir, 'workspace'), true); } - return [...dirs]; + return [...dirs.values()]; } // ── Bootstrap file repair ──────────────────────────────────────── @@ -159,7 +178,7 @@ async function resolveAllWorkspaceDirs(): Promise { */ export async function repairClawXOnlyBootstrapFiles(): Promise { const workspaceDirs = await resolveAllWorkspaceDirs(); - for (const workspaceDir of workspaceDirs) { + for (const { dir: workspaceDir } of workspaceDirs) { if (!(await fileExists(workspaceDir))) continue; let entries: string[]; @@ -202,7 +221,7 @@ export async function repairClawXOnlyBootstrapFiles(): Promise { */ export async function removeChatFirstBootstrapFiles(): Promise { const workspaceDirs = await resolveAllWorkspaceDirs(); - for (const workspaceDir of workspaceDirs) { + for (const { dir: workspaceDir } of workspaceDirs) { const bootstrapPath = join(workspaceDir, 'BOOTSTRAP.md'); if (!(await fileExists(bootstrapPath))) continue; @@ -218,37 +237,64 @@ export async function removeChatFirstBootstrapFiles(): Promise { // ── Context merging ────────────────────────────────────────────── /** - * Merge ClawX context snippets into workspace bootstrap files that - * already exist on disk. Returns the number of target files that were - * skipped because they don't exist yet. + * Merge ClawX context snippets into workspace bootstrap files that already + * exist on disk. Missing files are only retryable for startup-owned workspaces. */ -async function mergeClawXContextOnce(): Promise { +type MergeResult = { + missing: number; + retryableMissing: number; +}; + +type EnsureClawXContextOptions = { + /** + * Startup should only wait for the default workspace. Explicit provisioning + * flows can opt in so a freshly-created agent workspace gets patched after + * the Gateway seeds it. + */ + waitForAllConfiguredWorkspaces?: boolean; +}; + +async function mergeClawXContextOnce(options: EnsureClawXContextOptions = {}): Promise { const contextDir = join(getResourcesDir(), 'context'); if (!(await fileExists(contextDir))) { logger.debug('ClawX context directory not found, skipping context merge'); - return 0; + return { missing: 0, retryableMissing: 0 }; } let files: string[]; try { files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md')); } catch { - return 0; + return { missing: 0, retryableMissing: 0 }; } const workspaceDirs = await resolveAllWorkspaceDirs(); - let skipped = 0; + let missing = 0; + let retryableMissing = 0; - for (const workspaceDir of workspaceDirs) { - await ensureDir(workspaceDir); + for (const { dir: workspaceDir, waitForGatewaySeed } of workspaceDirs) { + const workspaceExists = await fileExists(workspaceDir); + const shouldWaitForSeed = + (waitForGatewaySeed || options.waitForAllConfiguredWorkspaces === true) + && (workspaceExists || isCurrentOpenClawPath(workspaceDir)); + + if (!workspaceExists) { + if (shouldWaitForSeed) { + retryableMissing += files.length; + } + missing += files.length; + continue; + } for (const file of files) { const targetName = file.replace('.clawx.md', '.md'); const targetPath = join(workspaceDir, targetName); if (!(await fileExists(targetPath))) { - logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`); - skipped++; + missing++; + if (shouldWaitForSeed) { + retryableMissing++; + } continue; } @@ -275,34 +321,55 @@ async function mergeClawXContextOnce(): Promise { } } - return skipped; + return { missing, retryableMissing }; } const RETRY_INTERVAL_MS = 2000; -const MAX_RETRIES = 15; +const MAX_RETRIES = 5; +let ensureClawXContextPromise: Promise | null = null; +let ensureClawXContextWaitsForAll = false; /** * Ensure ClawX context snippets are merged into the openclaw workspace * bootstrap files. */ -export async function ensureClawXContext(): Promise { - let skipped = await mergeClawXContextOnce(); - if (skipped === 0) { +export async function ensureClawXContext(options: EnsureClawXContextOptions = {}): Promise { + if (ensureClawXContextPromise) { + if (options.waitForAllConfiguredWorkspaces && !ensureClawXContextWaitsForAll) { + return ensureClawXContextPromise.then(() => ensureClawXContext(options)); + } + return ensureClawXContextPromise; + } + + ensureClawXContextWaitsForAll = options.waitForAllConfiguredWorkspaces === true; + ensureClawXContextPromise = runEnsureClawXContext(options).finally(() => { + ensureClawXContextPromise = null; + ensureClawXContextWaitsForAll = false; + }); + return ensureClawXContextPromise; +} + +async function runEnsureClawXContext(options: EnsureClawXContextOptions): Promise { + let result = await mergeClawXContextOnce(options); + if (result.retryableMissing === 0) { await removeChatFirstBootstrapFiles(); + if (result.missing > 0) { + logger.debug(`ClawX context merge skipped ${result.missing} non-ready file(s)`); + } return; } for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS)); - skipped = await mergeClawXContextOnce(); - if (skipped === 0) { + result = await mergeClawXContextOnce(options); + if (result.retryableMissing === 0) { await removeChatFirstBootstrapFiles(); logger.info(`ClawX context merge completed after ${attempt} retry(ies)`); return; } - logger.debug(`ClawX context merge: ${skipped} file(s) still missing (retry ${attempt}/${MAX_RETRIES})`); + logger.debug(`ClawX context merge: ${result.retryableMissing} startup file(s) still missing (retry ${attempt}/${MAX_RETRIES})`); } - logger.warn(`ClawX context merge: ${skipped} file(s) still missing after ${MAX_RETRIES} retries`); + logger.warn(`ClawX context merge: ${result.retryableMissing} startup file(s) still missing after ${MAX_RETRIES} retries`); await removeChatFirstBootstrapFiles(); } diff --git a/package.json b/package.json index bf346f2..5086c95 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ }, "dependencies": { "@sinclair/typebox": "^0.34.48", + "chokidar": "^5.0.0", "clawhub": "^0.5.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303207d..b337c79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.48 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 clawhub: specifier: ^0.5.0 version: 0.5.0 diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index 123da3f..95c805f 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -225,6 +225,7 @@ const BUNDLED_EXTENSION_RUNTIME_DEP_PLUGIN_IDS = [ 'bonjour', 'browser', 'discord', + 'memory-core', 'qqbot', 'telegram', ]; diff --git a/tests/unit/strip-first-run.test.ts b/tests/unit/strip-first-run.test.ts index fb93568..9e58bc0 100644 --- a/tests/unit/strip-first-run.test.ts +++ b/tests/unit/strip-first-run.test.ts @@ -19,6 +19,7 @@ vi.mock('os', async () => { }); import { + ensureClawXContext, mergeClawXSection, removeChatFirstBootstrapFiles, stripFirstRunSection, @@ -203,3 +204,58 @@ describe('removeChatFirstBootstrapFiles', () => { await expect(access(join(agentWorkspace, 'BOOTSTRAP.md'))).rejects.toThrow(); }); }); + +describe('ensureClawXContext', () => { + it('does not wait for missing files in non-default agent workspaces', async () => { + const openclawDir = join(testHome, '.openclaw'); + const defaultWorkspace = join(openclawDir, 'workspace-main'); + const agentWorkspace = join(openclawDir, 'workspace-agent'); + await mkdir(defaultWorkspace, { recursive: true }); + await mkdir(agentWorkspace, { recursive: true }); + await writeFile(join(defaultWorkspace, 'AGENTS.md'), '# AGENTS.md\n\nExisting agents.\n', 'utf-8'); + await writeFile(join(defaultWorkspace, 'TOOLS.md'), '# TOOLS.md\n\nExisting tools.\n', 'utf-8'); + await writeFile( + join(openclawDir, 'openclaw.json'), + JSON.stringify({ + agents: { + defaults: { workspace: defaultWorkspace }, + list: [{ id: 'agent', workspace: agentWorkspace }], + }, + }), + 'utf-8', + ); + + const result = await Promise.race([ + ensureClawXContext().then(() => 'done'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 200)), + ]); + + expect(result).toBe('done'); + await expect(readFile(join(defaultWorkspace, 'AGENTS.md'), 'utf-8')).resolves.toContain('## ClawX Environment'); + await expect(readFile(join(defaultWorkspace, 'TOOLS.md'), 'utf-8')).resolves.toContain('## ClawX Tool Notes'); + await expect(access(join(agentWorkspace, 'AGENTS.md'))).rejects.toThrow(); + await expect(access(join(agentWorkspace, 'TOOLS.md'))).rejects.toThrow(); + }); + + it('does not wait for missing external default workspaces', async () => { + const openclawDir = join(testHome, '.openclaw'); + const externalWorkspace = join(testHome, '..', `external-missing-${Date.now()}`); + await mkdir(openclawDir, { recursive: true }); + await writeFile( + join(openclawDir, 'openclaw.json'), + JSON.stringify({ + agents: { + defaults: { workspace: externalWorkspace }, + }, + }), + 'utf-8', + ); + + const result = await Promise.race([ + ensureClawXContext().then(() => 'done'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 200)), + ]); + + expect(result).toBe('done'); + }); +});