fix(gateway): reduce ClawX context merge waits; bundle memory-core chokidar (#930)

This commit is contained in:
paisley
2026-04-28 19:06:31 +08:00
committed by GitHub
parent 9d3a59dc0e
commit 5c97044865
6 changed files with 166 additions and 38 deletions

View File

@@ -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 });

View File

@@ -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<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
async function ensureDir(dir: string): Promise<void> {
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<string[]> {
async function resolveAllWorkspaceDirs(): Promise<WorkspaceDir[]> {
const openclawDir = join(homedir(), '.openclaw');
const dirs = new Set<string>();
const dirs = new Map<string, WorkspaceDir>();
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<string[]> {
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<string[]> {
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<string[]> {
// 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<string[]> {
*/
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
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<void> {
*/
export async function removeChatFirstBootstrapFiles(): Promise<void> {
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<void> {
// ── 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<number> {
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<MergeResult> {
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<number> {
}
}
return skipped;
return { missing, retryableMissing };
}
const RETRY_INTERVAL_MS = 2000;
const MAX_RETRIES = 15;
const MAX_RETRIES = 5;
let ensureClawXContextPromise: Promise<void> | null = null;
let ensureClawXContextWaitsForAll = false;
/**
* Ensure ClawX context snippets are merged into the openclaw workspace
* bootstrap files.
*/
export async function ensureClawXContext(): Promise<void> {
let skipped = await mergeClawXContextOnce();
if (skipped === 0) {
export async function ensureClawXContext(options: EnsureClawXContextOptions = {}): Promise<void> {
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<void> {
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();
}

View File

@@ -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",

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -225,6 +225,7 @@ const BUNDLED_EXTENSION_RUNTIME_DEP_PLUGIN_IDS = [
'bonjour',
'browser',
'discord',
'memory-core',
'qqbot',
'telegram',
];

View File

@@ -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');
});
});