mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: 增强工作树管理功能,支持恢复现有工作树并优化执行命令的参数提示
This commit is contained in:
@@ -26,12 +26,19 @@ Minimal orchestrator that dispatches **solution IDs** to executors. Each executo
|
|||||||
/issue:execute --queue QUE-xxx # Execute specific queue
|
/issue:execute --queue QUE-xxx # Execute specific queue
|
||||||
/issue:execute --worktree # Use git worktrees for parallel isolation
|
/issue:execute --worktree # Use git worktrees for parallel isolation
|
||||||
/issue:execute --worktree --queue QUE-xxx
|
/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)
|
**Parallelism**: Determined automatically by task dependency DAG (no manual control)
|
||||||
**Executor & Dry-run**: Selected via interactive prompt (AskUserQuestion)
|
**Executor & Dry-run**: Selected via interactive prompt (AskUserQuestion)
|
||||||
**Worktree**: Creates isolated git worktrees for each parallel executor
|
**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
|
## Execution Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -159,10 +166,14 @@ if (useWorktree) {
|
|||||||
Bash('git worktree prune');
|
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)
|
// Launch ALL solutions in batch in parallel (DAG guarantees no conflicts)
|
||||||
const executions = batch.map(solutionId => {
|
const executions = batch.map(solutionId => {
|
||||||
updateTodo(solutionId, 'in_progress');
|
updateTodo(solutionId, 'in_progress');
|
||||||
return dispatchExecutor(solutionId, executor, useWorktree);
|
return dispatchExecutor(solutionId, executor, useWorktree, existingWorktree);
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(executions);
|
await Promise.all(executions);
|
||||||
@@ -172,25 +183,47 @@ batch.forEach(id => updateTodo(id, 'completed'));
|
|||||||
### Executor Dispatch
|
### Executor Dispatch
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
function dispatchExecutor(solutionId, executorType, useWorktree = false) {
|
function dispatchExecutor(solutionId, executorType, useWorktree = false, existingWorktree = null) {
|
||||||
// Worktree setup commands (if enabled) - using absolute paths
|
// Worktree setup commands (if enabled) - using absolute paths
|
||||||
|
// Supports both creating new worktrees and resuming in existing ones
|
||||||
const worktreeSetup = useWorktree ? `
|
const worktreeSetup = useWorktree ? `
|
||||||
### Step 0: Setup Isolated Worktree
|
### Step 0: Setup Isolated Worktree
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# Use absolute paths to avoid issues when running from subdirectories
|
# Use absolute paths to avoid issues when running from subdirectories
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
WORKTREE_BASE="\${REPO_ROOT}/.ccw/worktrees"
|
WORKTREE_BASE="\${REPO_ROOT}/.ccw/worktrees"
|
||||||
WORKTREE_NAME="exec-${solutionId}-$(date +%H%M%S)"
|
|
||||||
WORKTREE_PATH="\${WORKTREE_BASE}/\${WORKTREE_NAME}"
|
|
||||||
|
|
||||||
# Ensure worktree base exists
|
# Check if existing worktree path was provided
|
||||||
mkdir -p "\${WORKTREE_BASE}"
|
EXISTING_WORKTREE="${existingWorktree || ''}"
|
||||||
|
|
||||||
# Prune stale worktrees
|
if [[ -n "\${EXISTING_WORKTREE}" && -d "\${EXISTING_WORKTREE}" ]]; then
|
||||||
git worktree prune
|
# Resume mode: Use existing worktree
|
||||||
|
WORKTREE_PATH="\${EXISTING_WORKTREE}"
|
||||||
|
WORKTREE_NAME=$(basename "\${WORKTREE_PATH}")
|
||||||
|
|
||||||
# Create worktree
|
# Verify it's a valid git worktree
|
||||||
git worktree add "\${WORKTREE_PATH}" -b "\${WORKTREE_NAME}"
|
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
|
# Setup cleanup trap for graceful failure handling
|
||||||
cleanup_worktree() {
|
cleanup_worktree() {
|
||||||
|
|||||||
@@ -486,6 +486,16 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
|||||||
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
||||||
|
|
||||||
// API: CodexLens Index List - Get all indexed projects with details
|
// 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') {
|
if (pathname === '/api/codexlens/indexes') {
|
||||||
try {
|
try {
|
||||||
// Check if CodexLens is installed first (without auto-installing)
|
// 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
|
||||||
|
// 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') {
|
if (pathname === '/api/codexlens/watch/status') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
const queryPath = url.searchParams.get('path');
|
||||||
res.end(JSON.stringify({
|
|
||||||
success: true,
|
if (queryPath) {
|
||||||
running: watcherStats.running,
|
// Return status for specific path
|
||||||
root_path: watcherStats.root_path,
|
const normalizedPath = normalizePath(queryPath);
|
||||||
events_processed: watcherStats.events_processed,
|
const watcher = activeWatchers.get(normalizedPath);
|
||||||
start_time: watcherStats.start_time?.toISOString() || null,
|
|
||||||
uptime_seconds: watcherStats.start_time
|
if (watcher) {
|
||||||
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
: 0
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1983,195 +2034,77 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
|||||||
handlePostRequest(req, res, async (body) => {
|
handlePostRequest(req, res, async (body) => {
|
||||||
const { path: watchPath, debounce_ms = 1000 } = body;
|
const { path: watchPath, debounce_ms = 1000 } = body;
|
||||||
const targetPath = watchPath || initialPath;
|
const targetPath = watchPath || initialPath;
|
||||||
|
const normalizedPath = normalizePath(targetPath);
|
||||||
|
|
||||||
if (watcherStats.running) {
|
// Check if watcher already running for this path
|
||||||
return { success: false, error: 'Watcher already running', status: 400 };
|
if (activeWatchers.has(normalizedPath)) {
|
||||||
|
return { success: false, error: 'Watcher already running for this path', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { spawn } = await import('child_process');
|
// Start watcher process using new architecture
|
||||||
const { join } = await import('path');
|
const result = await startWatcherProcess(targetPath, debounce_ms, broadcastToClients);
|
||||||
const { existsSync, statSync } = await import('fs');
|
|
||||||
|
|
||||||
// Validate path exists and is a directory
|
if (!result.success) {
|
||||||
if (!existsSync(targetPath)) {
|
return { success: false, error: result.error, status: 400 };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the codexlens CLI path
|
// Persist to config file
|
||||||
const venvStatus = await checkVenvStatus();
|
const config = readWatcherConfig();
|
||||||
if (!venvStatus.ready) {
|
config[normalizedPath] = {
|
||||||
return { success: false, error: 'CodexLens not installed', status: 400 };
|
enabled: true,
|
||||||
}
|
debounce_ms
|
||||||
|
|
||||||
// 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()
|
|
||||||
};
|
};
|
||||||
|
writeWatcherConfig(config);
|
||||||
// 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 }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Watcher started',
|
message: 'Watcher started and persisted to config',
|
||||||
path: targetPath,
|
path: targetPath,
|
||||||
pid: watcherProcess.pid
|
pid: result.pid
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
return { success: false, error: err.message, status: 500 };
|
return { success: false, error: err.message, status: 500 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// API: Stop File Watcher
|
// API: Stop File Watcher
|
||||||
if (pathname === '/api/codexlens/watch/stop' && req.method === 'POST') {
|
if (pathname === '/api/codexlens/watch/stop' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async () => {
|
handlePostRequest(req, res, async (body) => {
|
||||||
if (!watcherStats.running || !watcherProcess) {
|
const { path: watchPath } = body;
|
||||||
return { success: false, error: 'Watcher not running', status: 400 };
|
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 {
|
try {
|
||||||
// Send SIGTERM to gracefully stop the watcher
|
// Stop watcher process using new architecture
|
||||||
watcherProcess.kill('SIGTERM');
|
const result = await stopWatcherProcess(targetPath, broadcastToClients);
|
||||||
|
|
||||||
// Wait a moment for graceful shutdown
|
if (!result.success) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
return { success: false, error: result.error, status: 400 };
|
||||||
|
|
||||||
// Force kill if still running
|
|
||||||
if (watcherProcess && !watcherProcess.killed) {
|
|
||||||
watcherProcess.kill('SIGKILL');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalStats = {
|
// Update config file - disable watcher
|
||||||
events_processed: watcherStats.events_processed,
|
const config = readWatcherConfig();
|
||||||
uptime_seconds: watcherStats.start_time
|
if (config[normalizedPath]) {
|
||||||
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
|
config[normalizedPath].enabled = false;
|
||||||
: 0
|
writeWatcherConfig(config);
|
||||||
};
|
}
|
||||||
|
|
||||||
watcherStats = {
|
|
||||||
running: false,
|
|
||||||
root_path: '',
|
|
||||||
events_processed: 0,
|
|
||||||
start_time: null
|
|
||||||
};
|
|
||||||
watcherProcess = null;
|
|
||||||
|
|
||||||
// Broadcast watcher stopped
|
|
||||||
broadcastToClients({
|
|
||||||
type: 'CODEXLENS_WATCHER_STATUS',
|
|
||||||
payload: { running: false }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Watcher stopped',
|
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 };
|
return { success: false, error: err.message, status: 500 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2179,6 +2112,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SPLADE ENDPOINTS
|
// SPLADE ENDPOINTS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user