feat: 增强工作树管理功能,支持恢复现有工作树并优化执行命令的参数提示

This commit is contained in:
catlog22
2026-01-07 16:58:30 +08:00
parent 42fbc1936d
commit e9fb7be85f
2 changed files with 147 additions and 180 deletions

View File

@@ -26,12 +26,19 @@ Minimal orchestrator that dispatches **solution IDs** to executors. Each executo
/issue:execute --queue QUE-xxx # Execute specific queue
/issue:execute --worktree # Use git worktrees for parallel isolation
/issue:execute --worktree --queue QUE-xxx
/issue:execute --worktree /path/to/existing/worktree # Resume in existing worktree
```
**Parallelism**: Determined automatically by task dependency DAG (no manual control)
**Executor & Dry-run**: Selected via interactive prompt (AskUserQuestion)
**Worktree**: Creates isolated git worktrees for each parallel executor
**Worktree Options**:
- `--worktree` - Create a new worktree with timestamp-based name
- `--worktree <existing-path>` - Resume in an existing worktree (for recovery/continuation)
**Resume**: Use `git worktree list` to find existing worktrees from interrupted executions
## Execution Flow
```
@@ -159,10 +166,14 @@ if (useWorktree) {
Bash('git worktree prune');
}
// Parse existing worktree path from args if provided
// Example: --worktree /path/to/existing/worktree
const existingWorktree = args.worktree && typeof args.worktree === 'string' ? args.worktree : null;
// Launch ALL solutions in batch in parallel (DAG guarantees no conflicts)
const executions = batch.map(solutionId => {
updateTodo(solutionId, 'in_progress');
return dispatchExecutor(solutionId, executor, useWorktree);
return dispatchExecutor(solutionId, executor, useWorktree, existingWorktree);
});
await Promise.all(executions);
@@ -172,25 +183,47 @@ batch.forEach(id => updateTodo(id, 'completed'));
### Executor Dispatch
```javascript
function dispatchExecutor(solutionId, executorType, useWorktree = false) {
function dispatchExecutor(solutionId, executorType, useWorktree = false, existingWorktree = null) {
// Worktree setup commands (if enabled) - using absolute paths
// Supports both creating new worktrees and resuming in existing ones
const worktreeSetup = useWorktree ? `
### Step 0: Setup Isolated Worktree
\`\`\`bash
# Use absolute paths to avoid issues when running from subdirectories
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_BASE="\${REPO_ROOT}/.ccw/worktrees"
WORKTREE_NAME="exec-${solutionId}-$(date +%H%M%S)"
WORKTREE_PATH="\${WORKTREE_BASE}/\${WORKTREE_NAME}"
# Ensure worktree base exists
mkdir -p "\${WORKTREE_BASE}"
# Check if existing worktree path was provided
EXISTING_WORKTREE="${existingWorktree || ''}"
# Prune stale worktrees
git worktree prune
if [[ -n "\${EXISTING_WORKTREE}" && -d "\${EXISTING_WORKTREE}" ]]; then
# Resume mode: Use existing worktree
WORKTREE_PATH="\${EXISTING_WORKTREE}"
WORKTREE_NAME=$(basename "\${WORKTREE_PATH}")
# Create worktree
git worktree add "\${WORKTREE_PATH}" -b "\${WORKTREE_NAME}"
# Verify it's a valid git worktree
if ! git -C "\${WORKTREE_PATH}" rev-parse --is-inside-work-tree &>/dev/null; then
echo "Error: \${EXISTING_WORKTREE} is not a valid git worktree"
exit 1
fi
echo "Resuming in existing worktree: \${WORKTREE_PATH}"
else
# Create mode: New worktree with timestamp
WORKTREE_NAME="exec-${solutionId}-$(date +%H%M%S)"
WORKTREE_PATH="\${WORKTREE_BASE}/\${WORKTREE_NAME}"
# Ensure worktree base exists
mkdir -p "\${WORKTREE_BASE}"
# Prune stale worktrees
git worktree prune
# Create worktree
git worktree add "\${WORKTREE_PATH}" -b "\${WORKTREE_NAME}"
echo "Created new worktree: \${WORKTREE_PATH}"
fi
# Setup cleanup trap for graceful failure handling
cleanup_worktree() {

View File

@@ -486,6 +486,16 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: CodexLens Index List - Get all indexed projects with details
// Initialize watchers on first request (restore from config)
if (!watchersInitialized) {
watchersInitialized = true;
// Run async initialization without blocking the request
initializeWatchers(broadcastToClients).catch(err => {
console.error('[CodexLens] Failed to initialize watchers:', err);
});
}
if (pathname === '/api/codexlens/indexes') {
try {
// Check if CodexLens is installed first (without auto-installing)
@@ -1963,18 +1973,59 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
// ============================================================
// API: Get File Watcher Status
// API: Get File Watcher Status
// Supports ?path=<path> query parameter for specific watcher
// Returns all watchers if no path specified
if (pathname === '/api/codexlens/watch/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
running: watcherStats.running,
root_path: watcherStats.root_path,
events_processed: watcherStats.events_processed,
start_time: watcherStats.start_time?.toISOString() || null,
uptime_seconds: watcherStats.start_time
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
: 0
}));
const queryPath = url.searchParams.get('path');
if (queryPath) {
// Return status for specific path
const normalizedPath = normalizePath(queryPath);
const watcher = activeWatchers.get(normalizedPath);
if (watcher) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
running: watcher.stats.running,
root_path: watcher.stats.root_path,
events_processed: watcher.stats.events_processed,
start_time: watcher.stats.start_time?.toISOString() || null,
uptime_seconds: watcher.stats.start_time
? Math.floor((Date.now() - watcher.stats.start_time.getTime()) / 1000)
: 0
}));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
running: false,
root_path: '',
events_processed: 0,
start_time: null,
uptime_seconds: 0
}));
}
} else {
// Return all watchers
const watchers = Array.from(activeWatchers.entries()).map(([path, watcher]) => ({
root_path: watcher.stats.root_path,
running: watcher.stats.running,
events_processed: watcher.stats.events_processed,
start_time: watcher.stats.start_time?.toISOString() || null,
uptime_seconds: watcher.stats.start_time
? Math.floor((Date.now() - watcher.stats.start_time.getTime()) / 1000)
: 0
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
watchers,
count: watchers.length
}));
}
return true;
}
@@ -1983,195 +2034,77 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
handlePostRequest(req, res, async (body) => {
const { path: watchPath, debounce_ms = 1000 } = body;
const targetPath = watchPath || initialPath;
const normalizedPath = normalizePath(targetPath);
if (watcherStats.running) {
return { success: false, error: 'Watcher already running', status: 400 };
// Check if watcher already running for this path
if (activeWatchers.has(normalizedPath)) {
return { success: false, error: 'Watcher already running for this path', status: 400 };
}
try {
const { spawn } = await import('child_process');
const { join } = await import('path');
const { existsSync, statSync } = await import('fs');
// Validate path exists and is a directory
if (!existsSync(targetPath)) {
return { success: false, error: `Path does not exist: ${targetPath}`, status: 400 };
}
const pathStat = statSync(targetPath);
if (!pathStat.isDirectory()) {
return { success: false, error: `Path is not a directory: ${targetPath}`, status: 400 };
// Start watcher process using new architecture
const result = await startWatcherProcess(targetPath, debounce_ms, broadcastToClients);
if (!result.success) {
return { success: false, error: result.error, status: 400 };
}
// Get the codexlens CLI path
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
return { success: false, error: 'CodexLens not installed', status: 400 };
}
// Verify directory is indexed before starting watcher
try {
const statusResult = await executeCodexLens(['projects', 'list', '--json']);
if (statusResult.success && statusResult.stdout) {
const parsed = extractJSON(statusResult.stdout);
const projects = parsed.result || parsed || [];
const normalizedTarget = targetPath.toLowerCase().replace(/\\/g, '/');
const isIndexed = Array.isArray(projects) && projects.some((p: { source_root: string }) =>
p.source_root && p.source_root.toLowerCase().replace(/\\/g, '/') === normalizedTarget
);
if (!isIndexed) {
return {
success: false,
error: `Directory is not indexed: ${targetPath}. Run 'codexlens init' first.`,
status: 400
};
}
}
} catch (err) {
console.warn('[CodexLens] Could not verify index status:', err);
// Continue anyway - watcher will fail with proper error if not indexed
}
// Spawn watch process using Python (no shell: true for security)
// CodexLens is a Python package, must run via python -m codexlens
const pythonPath = getVenvPythonPath();
const args = ['-m', 'codexlens', 'watch', targetPath, '--debounce', String(debounce_ms)];
watcherProcess = spawn(pythonPath, args, {
cwd: targetPath,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env }
});
watcherStats = {
running: true,
root_path: targetPath,
events_processed: 0,
start_time: new Date()
// Persist to config file
const config = readWatcherConfig();
config[normalizedPath] = {
enabled: true,
debounce_ms
};
// Capture stderr for error messages (capped at 4KB to prevent memory leak)
const MAX_STDERR_SIZE = 4096;
let stderrBuffer = '';
if (watcherProcess.stderr) {
watcherProcess.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
// Cap buffer size to prevent memory leak in long-running watchers
if (stderrBuffer.length > MAX_STDERR_SIZE) {
stderrBuffer = stderrBuffer.slice(-MAX_STDERR_SIZE);
}
});
}
// Handle process output for event counting
if (watcherProcess.stdout) {
watcherProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
// Count processed events from output
const matches = output.match(/Processed \d+ events?/g);
if (matches) {
watcherStats.events_processed += matches.length;
}
});
}
// Handle spawn errors (e.g., ENOENT)
watcherProcess.on('error', (err: Error) => {
console.error(`[CodexLens] Watcher spawn error: ${err.message}`);
watcherStats.running = false;
watcherProcess = null;
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false, error: `Spawn error: ${err.message}` }
});
});
// Handle process exit
watcherProcess.on('exit', (code: number) => {
watcherStats.running = false;
watcherProcess = null;
console.log(`[CodexLens] Watcher exited with code ${code}`);
// Broadcast error if exited with non-zero code
if (code !== 0) {
const errorMsg = stderrBuffer.trim() || `Exited with code ${code}`;
// Use stripAnsiCodes helper for consistent ANSI cleanup
const cleanError = stripAnsiCodes(errorMsg);
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false, error: cleanError }
});
} else {
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false }
});
}
});
// Broadcast watcher started
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: true, path: targetPath }
});
writeWatcherConfig(config);
return {
success: true,
message: 'Watcher started',
message: 'Watcher started and persisted to config',
path: targetPath,
pid: watcherProcess.pid
pid: result.pid
};
} catch (err) {
} catch (err: any) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: Stop File Watcher
if (pathname === '/api/codexlens/watch/stop' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
if (!watcherStats.running || !watcherProcess) {
return { success: false, error: 'Watcher not running', status: 400 };
handlePostRequest(req, res, async (body) => {
const { path: watchPath } = body;
const targetPath = watchPath || initialPath;
const normalizedPath = normalizePath(targetPath);
// Check if watcher is running for this path
if (!activeWatchers.has(normalizedPath)) {
return { success: false, error: 'Watcher not running for this path', status: 400 };
}
try {
// Send SIGTERM to gracefully stop the watcher
watcherProcess.kill('SIGTERM');
// Wait a moment for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 500));
// Force kill if still running
if (watcherProcess && !watcherProcess.killed) {
watcherProcess.kill('SIGKILL');
// Stop watcher process using new architecture
const result = await stopWatcherProcess(targetPath, broadcastToClients);
if (!result.success) {
return { success: false, error: result.error, status: 400 };
}
const finalStats = {
events_processed: watcherStats.events_processed,
uptime_seconds: watcherStats.start_time
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
: 0
};
watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null
};
watcherProcess = null;
// Broadcast watcher stopped
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false }
});
// Update config file - disable watcher
const config = readWatcherConfig();
if (config[normalizedPath]) {
config[normalizedPath].enabled = false;
writeWatcherConfig(config);
}
return {
success: true,
message: 'Watcher stopped',
...finalStats
events_processed: result.stats?.events_processed || 0,
uptime_seconds: result.stats?.uptime_seconds || 0
};
} catch (err) {
} catch (err: any) {
return { success: false, error: err.message, status: 500 };
}
});
@@ -2179,6 +2112,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
}
// ============================================================
// SPLADE ENDPOINTS
// ============================================================