Add benchmark results for fast3 and fast4, implement KeepAliveLspBridge, and add tests for staged strategies

- Added new benchmark result files: compare_2026-02-09_score_fast3.json and compare_2026-02-09_score_fast4.json.
- Implemented KeepAliveLspBridge to maintain a persistent LSP connection across multiple queries, improving performance.
- Created unit tests for staged clustering strategies in test_staged_stage3_fast_strategies.py, ensuring correct behavior of score and dir_rr strategies.
This commit is contained in:
catlog22
2026-02-09 20:45:29 +08:00
parent c62d26183b
commit 4344e79e68
64 changed files with 6154 additions and 123 deletions

View File

@@ -0,0 +1,110 @@
import path from 'path';
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh';
export type CliSessionResumeStrategy = 'nativeResume' | 'promptConcat';
export interface CliSessionExecuteCommandInput {
projectRoot: string;
shellKind: CliSessionShellKind;
tool: string;
prompt: string;
mode?: 'analysis' | 'write' | 'auto';
model?: string;
workingDir?: string;
category?: 'user' | 'internal' | 'insight';
resumeStrategy?: CliSessionResumeStrategy;
prevExecutionId?: string;
executionId: string;
}
export interface CliSessionExecuteCommandOutput {
command: string;
}
function toPosixPath(p: string): string {
return p.replace(/\\/g, '/');
}
function toWslPath(winPath: string): string {
const normalized = winPath.replace(/\\/g, '/').replace(/\/+/g, '/');
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
if (!driveMatch) return normalized;
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
}
function escapeArg(value: string): string {
// Minimal quoting that works in pwsh + bash.
// We intentionally avoid escaping with platform-specific rules; values are expected to be simple (paths/tool/model).
if (!value) return '""';
if (/[\s"]/g.test(value)) {
return `"${value.replaceAll('"', '\\"')}"`;
}
return value;
}
export function buildCliSessionExecuteCommand(input: CliSessionExecuteCommandInput): CliSessionExecuteCommandOutput {
const {
projectRoot,
shellKind,
tool,
prompt,
mode = 'analysis',
model,
workingDir,
category = 'user',
resumeStrategy = 'nativeResume',
prevExecutionId,
executionId
} = input;
const nodeExe = shellKind === 'wsl-bash' ? 'node.exe' : 'node';
const ccwScriptWin = path.join(projectRoot, 'ccw', 'bin', 'ccw.js');
const ccwScriptPosix = toPosixPath(ccwScriptWin);
const ccwScriptWsl = toWslPath(ccwScriptPosix);
// In WSL we prefer running the Windows Node (`node.exe`) for compatibility
// (no dependency on Node being installed inside the Linux distro). However,
// Windows executables do not reliably understand `/mnt/*` paths, so we convert
// to Windows paths at runtime via `wslpath -w`.
const wslPreambleParts: string[] = [];
if (shellKind === 'wsl-bash') {
wslPreambleParts.push(`CCW_WIN=$(wslpath -w ${escapeArg(ccwScriptWsl)})`);
if (workingDir) {
const wdWsl = toWslPath(toPosixPath(workingDir));
wslPreambleParts.push(`WD_WIN=$(wslpath -w ${escapeArg(wdWsl)})`);
}
}
const wslPreamble = wslPreambleParts.length > 0 ? `${wslPreambleParts.join('; ')}; ` : '';
const cdArg =
workingDir
? shellKind === 'wsl-bash'
? ' --cd "$WD_WIN"'
: ` --cd ${escapeArg(toPosixPath(workingDir))}`
: '';
const modelArg = model ? ` --model ${escapeArg(model)}` : '';
const resumeArg = prevExecutionId ? ` --resume ${escapeArg(prevExecutionId)}` : '';
const noNativeArg = resumeStrategy === 'promptConcat' ? ' --no-native' : '';
// Pipe prompt through stdin so multi-line works without shell-dependent quoting.
// Base64 avoids escaping issues; decode is performed by node itself.
const promptB64 = Buffer.from(prompt, 'utf8').toString('base64');
const decodeCmd = `${nodeExe} -e "process.stdout.write(Buffer.from('${promptB64}','base64'))"`;
const ccwTarget = shellKind === 'wsl-bash' ? '"$CCW_WIN"' : escapeArg(ccwScriptPosix);
const ccwCmd =
`${nodeExe} ${ccwTarget} cli` +
` --tool ${escapeArg(tool)}` +
` --mode ${escapeArg(mode)}` +
`${modelArg}` +
`${cdArg}` +
` --category ${escapeArg(category)}` +
` --stream` +
` --id ${escapeArg(executionId)}` +
`${resumeArg}` +
`${noNativeArg}`;
return { command: `${wslPreamble}${decodeCmd} | ${ccwCmd}` };
}

View File

@@ -0,0 +1,380 @@
import { existsSync } from 'fs';
import os from 'os';
import path from 'path';
import { randomBytes } from 'crypto';
import { spawnSync } from 'child_process';
import * as nodePty from 'node-pty';
import { EventEmitter } from 'events';
import { broadcastToClients } from '../websocket.js';
import {
buildCliSessionExecuteCommand,
type CliSessionShellKind,
type CliSessionResumeStrategy
} from './cli-session-command-builder.js';
import { getCliSessionPolicy } from './cli-session-policy.js';
export interface CliSession {
sessionKey: string;
shellKind: CliSessionShellKind;
workingDir: string;
tool?: string;
model?: string;
resumeKey?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateCliSessionOptions {
workingDir: string;
cols?: number;
rows?: number;
preferredShell?: 'bash' | 'pwsh';
tool?: string;
model?: string;
resumeKey?: string;
}
export interface ExecuteInCliSessionOptions {
tool: string;
prompt: string;
mode?: 'analysis' | 'write' | 'auto';
model?: string;
workingDir?: string;
category?: 'user' | 'internal' | 'insight';
resumeKey?: string;
resumeStrategy?: CliSessionResumeStrategy;
}
export interface CliSessionOutputEvent {
sessionKey: string;
data: string;
timestamp: string;
}
interface CliSessionInternal extends CliSession {
pty: nodePty.IPty;
buffer: string[];
bufferBytes: number;
lastActivityAt: number;
}
function nowIso(): string {
return new Date().toISOString();
}
function createSessionKey(): string {
const suffix = randomBytes(4).toString('hex');
return `cli-session-${Date.now()}-${suffix}`;
}
function normalizeWorkingDir(workingDir: string): string {
return path.resolve(workingDir);
}
function findGitBashExe(): string | null {
const candidates = [
'C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe',
'C:\\\\Program Files\\\\Git\\\\usr\\\\bin\\\\bash.exe',
'C:\\\\Program Files (x86)\\\\Git\\\\bin\\\\bash.exe',
'C:\\\\Program Files (x86)\\\\Git\\\\usr\\\\bin\\\\bash.exe'
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
try {
const where = spawnSync('where', ['bash'], { encoding: 'utf8', windowsHide: true });
if (where.status === 0) {
const lines = (where.stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const gitBash = lines.find(l => /\\Git\\.*\\bash\.exe$/i.test(l));
return gitBash || (lines[0] || null);
}
} catch {
// ignore
}
return null;
}
function isWslAvailable(): boolean {
try {
const probe = spawnSync('wsl.exe', ['-e', 'bash', '-lc', 'echo ok'], {
encoding: 'utf8',
windowsHide: true,
timeout: 1500
});
return probe.status === 0;
} catch {
return false;
}
}
function pickShell(preferred: 'bash' | 'pwsh'): { shellKind: CliSessionShellKind; file: string; args: string[] } {
if (os.platform() === 'win32') {
if (preferred === 'bash') {
if (isWslAvailable()) {
return { shellKind: 'wsl-bash', file: 'wsl.exe', args: ['-e', 'bash', '-l', '-i'] };
}
const gitBash = findGitBashExe();
if (gitBash) {
return { shellKind: 'git-bash', file: gitBash, args: ['-l', '-i'] };
}
}
// Fallback: PowerShell (pwsh preferred, windows powershell as final)
const pwsh = spawnSync('where', ['pwsh'], { encoding: 'utf8', windowsHide: true });
if (pwsh.status === 0) {
return { shellKind: 'pwsh', file: 'pwsh', args: ['-NoLogo'] };
}
return { shellKind: 'pwsh', file: 'powershell', args: ['-NoLogo'] };
}
// Non-Windows: keep it simple (bash-first)
if (preferred === 'pwsh') {
return { shellKind: 'pwsh', file: 'pwsh', args: ['-NoLogo'] };
}
return { shellKind: 'git-bash', file: 'bash', args: ['-l', '-i'] };
}
function toWslPath(winPath: string): string {
const normalized = winPath.replace(/\\/g, '/');
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
if (!driveMatch) return normalized;
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
}
export class CliSessionManager {
private sessions = new Map<string, CliSessionInternal>();
private resumeKeyLastExecution = new Map<string, string>();
private projectRoot: string;
private emitter = new EventEmitter();
private maxBufferBytes: number;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.maxBufferBytes = getCliSessionPolicy().maxBufferBytes;
}
listSessions(): CliSession[] {
return Array.from(this.sessions.values()).map(({ pty: _pty, buffer: _buffer, bufferBytes: _bytes, ...rest }) => rest);
}
getSession(sessionKey: string): CliSession | null {
const session = this.sessions.get(sessionKey);
if (!session) return null;
const { pty: _pty, buffer: _buffer, bufferBytes: _bytes, ...rest } = session;
return rest;
}
getBuffer(sessionKey: string): string {
const session = this.sessions.get(sessionKey);
if (!session) return '';
return session.buffer.join('');
}
createSession(options: CreateCliSessionOptions): CliSession {
const workingDir = normalizeWorkingDir(options.workingDir);
const preferredShell = options.preferredShell ?? 'bash';
const { shellKind, file, args } = pickShell(preferredShell);
const sessionKey = createSessionKey();
const createdAt = nowIso();
const pty = nodePty.spawn(file, args, {
name: 'xterm-256color',
cols: options.cols ?? 120,
rows: options.rows ?? 30,
cwd: workingDir,
env: process.env as Record<string, string>
});
const session: CliSessionInternal = {
sessionKey,
shellKind,
workingDir,
tool: options.tool,
model: options.model,
resumeKey: options.resumeKey,
createdAt,
updatedAt: createdAt,
pty,
buffer: [],
bufferBytes: 0,
lastActivityAt: Date.now(),
};
pty.onData((data) => {
this.appendToBuffer(sessionKey, data);
const now = Date.now();
const s = this.sessions.get(sessionKey);
if (s) {
s.updatedAt = nowIso();
s.lastActivityAt = now;
}
this.emitter.emit('output', {
sessionKey,
data,
timestamp: nowIso(),
} satisfies CliSessionOutputEvent);
broadcastToClients({
type: 'CLI_SESSION_OUTPUT',
payload: {
sessionKey,
data,
timestamp: nowIso()
} satisfies CliSessionOutputEvent
});
});
pty.onExit(({ exitCode, signal }) => {
this.sessions.delete(sessionKey);
broadcastToClients({
type: 'CLI_SESSION_CLOSED',
payload: {
sessionKey,
exitCode,
signal,
timestamp: nowIso()
}
});
});
this.sessions.set(sessionKey, session);
// WSL often ignores Windows cwd; best-effort cd to mounted path.
if (shellKind === 'wsl-bash') {
const wslCwd = toWslPath(workingDir.replace(/\\/g, '/'));
this.sendText(sessionKey, `cd ${wslCwd}`, true);
}
broadcastToClients({
type: 'CLI_SESSION_CREATED',
payload: { session: this.getSession(sessionKey), timestamp: nowIso() }
});
return this.getSession(sessionKey)!;
}
sendText(sessionKey: string, text: string, appendNewline: boolean): void {
const session = this.sessions.get(sessionKey);
if (!session) {
throw new Error(`Session not found: ${sessionKey}`);
}
session.updatedAt = nowIso();
session.lastActivityAt = Date.now();
session.pty.write(text);
if (appendNewline) {
session.pty.write('\r');
}
}
resize(sessionKey: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionKey);
if (!session) {
throw new Error(`Session not found: ${sessionKey}`);
}
session.updatedAt = nowIso();
session.lastActivityAt = Date.now();
session.pty.resize(cols, rows);
}
close(sessionKey: string): void {
const session = this.sessions.get(sessionKey);
if (!session) return;
session.updatedAt = nowIso();
session.lastActivityAt = Date.now();
try {
session.pty.kill();
} finally {
this.sessions.delete(sessionKey);
broadcastToClients({ type: 'CLI_SESSION_CLOSED', payload: { sessionKey, timestamp: nowIso() } });
}
}
execute(sessionKey: string, options: ExecuteInCliSessionOptions): { executionId: string; command: string } {
const session = this.sessions.get(sessionKey);
if (!session) {
throw new Error(`Session not found: ${sessionKey}`);
}
session.updatedAt = nowIso();
session.lastActivityAt = Date.now();
const resumeKey = options.resumeKey ?? session.resumeKey;
const resumeMapKey = resumeKey ? `${options.tool}:${resumeKey}` : null;
const prevExecutionId = resumeMapKey ? this.resumeKeyLastExecution.get(resumeMapKey) : undefined;
const executionId = resumeKey
? `${resumeKey}-${Date.now()}`
: `exec-${Date.now()}-${randomBytes(3).toString('hex')}`;
const { command } = buildCliSessionExecuteCommand({
projectRoot: this.projectRoot,
shellKind: session.shellKind,
tool: options.tool,
prompt: options.prompt,
mode: options.mode,
model: options.model,
workingDir: options.workingDir ?? session.workingDir,
category: options.category,
resumeStrategy: options.resumeStrategy,
prevExecutionId,
executionId
});
// Best-effort: preemptively update mapping so subsequent queue items can chain.
if (resumeMapKey) {
this.resumeKeyLastExecution.set(resumeMapKey, executionId);
}
this.sendText(sessionKey, command, true);
broadcastToClients({
type: 'CLI_SESSION_EXECUTE',
payload: { sessionKey, executionId, command, timestamp: nowIso() }
});
return { executionId, command };
}
private appendToBuffer(sessionKey: string, chunk: string): void {
const session = this.sessions.get(sessionKey);
if (!session) return;
session.buffer.push(chunk);
session.bufferBytes += Buffer.byteLength(chunk, 'utf8');
while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 0) {
const removed = session.buffer.shift();
if (removed) session.bufferBytes -= Buffer.byteLength(removed, 'utf8');
}
}
onOutput(listener: (event: CliSessionOutputEvent) => void): () => void {
const handler = (event: CliSessionOutputEvent) => listener(event);
this.emitter.on('output', handler);
return () => this.emitter.off('output', handler);
}
closeIdleSessions(idleTimeoutMs: number): number {
if (idleTimeoutMs <= 0) return 0;
const now = Date.now();
let closed = 0;
for (const s of this.sessions.values()) {
if (now - s.lastActivityAt >= idleTimeoutMs) {
this.close(s.sessionKey);
closed += 1;
}
}
return closed;
}
}
const managersByRoot = new Map<string, CliSessionManager>();
export function getCliSessionManager(projectRoot: string = process.cwd()): CliSessionManager {
const resolved = path.resolve(projectRoot);
const existing = managersByRoot.get(resolved);
if (existing) return existing;
const created = new CliSessionManager(resolved);
managersByRoot.set(resolved, created);
return created;
}

View File

@@ -0,0 +1,55 @@
import os from 'os';
export interface CliSessionPolicy {
allowedTools: string[];
maxSessions: number;
idleTimeoutMs: number;
allowWorkingDirOutsideProject: boolean;
maxBufferBytes: number;
rateLimit: {
createPerMinute: number;
executePerMinute: number;
sendBytesPerMinute: number;
resizePerMinute: number;
};
}
function parseIntEnv(name: string, fallback: number): number {
const raw = (process.env[name] ?? '').trim();
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
function parseBoolEnv(name: string, fallback: boolean): boolean {
const raw = (process.env[name] ?? '').trim().toLowerCase();
if (!raw) return fallback;
if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y') return true;
if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'n') return false;
return fallback;
}
export function getCliSessionPolicy(): CliSessionPolicy {
const defaultAllowedTools = ['claude', 'codex', 'gemini', 'qwen', 'opencode'];
const allowedToolsRaw = (process.env.CCW_CLI_SESSIONS_ALLOWED_TOOLS ?? '').trim();
const allowedTools = allowedToolsRaw
? allowedToolsRaw.split(',').map((t) => t.trim()).filter(Boolean)
: defaultAllowedTools;
const maxSessionsDefault = os.platform() === 'win32' ? 6 : 8;
return {
allowedTools,
maxSessions: parseIntEnv('CCW_CLI_SESSIONS_MAX', maxSessionsDefault),
idleTimeoutMs: parseIntEnv('CCW_CLI_SESSIONS_IDLE_TIMEOUT_MS', 30 * 60_000),
allowWorkingDirOutsideProject: parseBoolEnv('CCW_CLI_SESSIONS_ALLOW_OUTSIDE_PROJECT', false),
maxBufferBytes: parseIntEnv('CCW_CLI_SESSIONS_MAX_BUFFER_BYTES', 2 * 1024 * 1024),
rateLimit: {
createPerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_CREATE_PER_MIN', 12),
executePerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_EXECUTE_PER_MIN', 60),
sendBytesPerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_SEND_BYTES_PER_MIN', 256 * 1024),
resizePerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_RESIZE_PER_MIN', 120),
},
};
}

View File

@@ -19,6 +19,7 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { broadcastToClients } from '../websocket.js';
import { executeCliTool } from '../../tools/cli-executor-core.js';
import { getCliSessionManager } from './cli-session-manager.js';
import type {
Flow,
FlowNode,
@@ -244,6 +245,44 @@ export class NodeRunner {
const mode = this.determineCliMode(data.mode);
try {
// Optional: route execution to a PTY session (tmux-like send)
if (data.delivery === 'sendToSession') {
const targetSessionKey = data.targetSessionKey;
if (!targetSessionKey) {
return {
success: false,
error: 'delivery=sendToSession requires targetSessionKey'
};
}
const manager = getCliSessionManager(process.cwd());
const routed = manager.execute(targetSessionKey, {
tool,
prompt: instruction,
mode,
workingDir: this.context.workingDir,
resumeKey: data.resumeKey,
resumeStrategy: data.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
});
const outputKey = data.outputName || `${node.id}_output`;
this.context.variables[outputKey] = {
delivery: 'sendToSession',
sessionKey: targetSessionKey,
executionId: routed.executionId,
command: routed.command
};
this.context.variables[`${node.id}_executionId`] = routed.executionId;
this.context.variables[`${node.id}_command`] = routed.command;
this.context.variables[`${node.id}_success`] = true;
return {
success: true,
output: routed.command,
exitCode: 0
};
}
// Execute via CLI tool
const result = await executeCliTool({
tool,

View File

@@ -0,0 +1,49 @@
export interface RateLimitResult {
ok: boolean;
remaining: number;
resetAt: number;
}
interface BucketState {
tokens: number;
resetAt: number;
}
/**
* Simple fixed-window token bucket (in-memory).
* Good enough for local dashboard usage; not suitable for multi-process deployments.
*/
export class RateLimiter {
private buckets = new Map<string, BucketState>();
private limit: number;
private windowMs: number;
constructor(opts: { limit: number; windowMs: number }) {
this.limit = Math.max(0, opts.limit);
this.windowMs = Math.max(1, opts.windowMs);
}
consume(key: string, cost: number = 1): RateLimitResult {
const now = Date.now();
const safeCost = Math.max(0, Math.floor(cost));
const existing = this.buckets.get(key);
if (!existing || now >= existing.resetAt) {
const resetAt = now + this.windowMs;
const nextTokens = this.limit - safeCost;
const ok = nextTokens >= 0;
const tokens = ok ? nextTokens : this.limit;
this.buckets.set(key, { tokens, resetAt });
return { ok, remaining: Math.max(0, ok ? tokens : 0), resetAt };
}
const nextTokens = existing.tokens - safeCost;
if (nextTokens < 0) {
return { ok: false, remaining: Math.max(0, existing.tokens), resetAt: existing.resetAt };
}
existing.tokens = nextTokens;
return { ok: true, remaining: nextTokens, resetAt: existing.resetAt };
}
}