@litmers/cursorflow-orchestrator 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +83 -2
- package/commands/cursorflow-clean.md +20 -6
- package/commands/cursorflow-prepare.md +1 -1
- package/commands/cursorflow-resume.md +127 -6
- package/commands/cursorflow-run.md +2 -2
- package/commands/cursorflow-signal.md +11 -4
- package/dist/cli/clean.js +164 -12
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +6 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/logs.d.ts +8 -0
- package/dist/cli/logs.js +759 -0
- package/dist/cli/logs.js.map +1 -0
- package/dist/cli/monitor.js +113 -30
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/prepare.js +1 -1
- package/dist/cli/resume.js +367 -18
- package/dist/cli/resume.js.map +1 -1
- package/dist/cli/run.js +9 -0
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/signal.js +34 -20
- package/dist/cli/signal.js.map +1 -1
- package/dist/core/orchestrator.d.ts +13 -1
- package/dist/core/orchestrator.js +396 -35
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/reviewer.d.ts +2 -0
- package/dist/core/reviewer.js +24 -2
- package/dist/core/reviewer.js.map +1 -1
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +266 -61
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/config.js +38 -1
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/enhanced-logger.d.ts +210 -0
- package/dist/utils/enhanced-logger.js +1030 -0
- package/dist/utils/enhanced-logger.js.map +1 -0
- package/dist/utils/events.d.ts +59 -0
- package/dist/utils/events.js +37 -0
- package/dist/utils/events.js.map +1 -0
- package/dist/utils/git.d.ts +11 -0
- package/dist/utils/git.js +40 -0
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +4 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/types.d.ts +132 -1
- package/dist/utils/webhook.d.ts +5 -0
- package/dist/utils/webhook.js +109 -0
- package/dist/utils/webhook.js.map +1 -0
- package/examples/README.md +1 -1
- package/package.json +2 -1
- package/scripts/patches/test-cursor-agent.js +1 -1
- package/scripts/simple-logging-test.sh +97 -0
- package/scripts/test-real-cursor-lifecycle.sh +289 -0
- package/scripts/test-real-logging.sh +289 -0
- package/scripts/test-streaming-multi-task.sh +247 -0
- package/src/cli/clean.ts +170 -13
- package/src/cli/index.ts +4 -1
- package/src/cli/logs.ts +863 -0
- package/src/cli/monitor.ts +123 -30
- package/src/cli/prepare.ts +1 -1
- package/src/cli/resume.ts +463 -22
- package/src/cli/run.ts +10 -0
- package/src/cli/signal.ts +43 -27
- package/src/core/orchestrator.ts +458 -36
- package/src/core/reviewer.ts +40 -4
- package/src/core/runner.ts +293 -60
- package/src/utils/config.ts +41 -1
- package/src/utils/enhanced-logger.ts +1166 -0
- package/src/utils/events.ts +117 -0
- package/src/utils/git.ts +40 -0
- package/src/utils/logger.ts +4 -1
- package/src/utils/types.ts +160 -1
- package/src/utils/webhook.ts +85 -0
package/src/core/orchestrator.ts
CHANGED
|
@@ -10,31 +10,56 @@ import { spawn, ChildProcess } from 'child_process';
|
|
|
10
10
|
|
|
11
11
|
import * as logger from '../utils/logger';
|
|
12
12
|
import { loadState } from '../utils/state';
|
|
13
|
-
import { LaneState, RunnerConfig } from '../utils/types';
|
|
13
|
+
import { LaneState, RunnerConfig, WebhookConfig, DependencyRequestPlan, EnhancedLogConfig } from '../utils/types';
|
|
14
|
+
import { events } from '../utils/events';
|
|
15
|
+
import { registerWebhooks } from '../utils/webhook';
|
|
16
|
+
import { loadConfig, getLogsDir } from '../utils/config';
|
|
17
|
+
import * as git from '../utils/git';
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
|
+
import {
|
|
20
|
+
EnhancedLogManager,
|
|
21
|
+
createLogManager,
|
|
22
|
+
DEFAULT_LOG_CONFIG,
|
|
23
|
+
ParsedMessage,
|
|
24
|
+
stripAnsi
|
|
25
|
+
} from '../utils/enhanced-logger';
|
|
14
26
|
|
|
15
27
|
export interface LaneInfo {
|
|
16
28
|
name: string;
|
|
17
29
|
path: string;
|
|
18
30
|
dependsOn: string[];
|
|
31
|
+
startIndex?: number; // Current task index to resume from
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export interface SpawnLaneResult {
|
|
22
35
|
child: ChildProcess;
|
|
23
36
|
logPath: string;
|
|
37
|
+
logManager?: EnhancedLogManager;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
41
|
* Spawn a lane process
|
|
28
42
|
*/
|
|
29
|
-
export function spawnLane({
|
|
43
|
+
export function spawnLane({
|
|
44
|
+
laneName,
|
|
45
|
+
tasksFile,
|
|
46
|
+
laneRunDir,
|
|
47
|
+
executor,
|
|
48
|
+
startIndex = 0,
|
|
49
|
+
pipelineBranch,
|
|
50
|
+
enhancedLogConfig,
|
|
51
|
+
noGit = false,
|
|
52
|
+
}: {
|
|
30
53
|
laneName: string;
|
|
31
54
|
tasksFile: string;
|
|
32
55
|
laneRunDir: string;
|
|
33
56
|
executor: string;
|
|
57
|
+
startIndex?: number;
|
|
58
|
+
pipelineBranch?: string;
|
|
59
|
+
enhancedLogConfig?: Partial<EnhancedLogConfig>;
|
|
60
|
+
noGit?: boolean;
|
|
34
61
|
}): SpawnLaneResult {
|
|
35
62
|
fs.mkdirSync(laneRunDir, { recursive: true});
|
|
36
|
-
const logPath = path.join(laneRunDir, 'terminal.log');
|
|
37
|
-
const logFd = fs.openSync(logPath, 'a');
|
|
38
63
|
|
|
39
64
|
// Use extension-less resolve to handle both .ts (dev) and .js (dist)
|
|
40
65
|
const runnerPath = require.resolve('./runner');
|
|
@@ -44,21 +69,194 @@ export function spawnLane({ tasksFile, laneRunDir, executor }: {
|
|
|
44
69
|
tasksFile,
|
|
45
70
|
'--run-dir', laneRunDir,
|
|
46
71
|
'--executor', executor,
|
|
72
|
+
'--start-index', startIndex.toString(),
|
|
47
73
|
];
|
|
74
|
+
|
|
75
|
+
if (pipelineBranch) {
|
|
76
|
+
args.push('--pipeline-branch', pipelineBranch);
|
|
77
|
+
}
|
|
48
78
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
detached: false,
|
|
53
|
-
});
|
|
79
|
+
if (noGit) {
|
|
80
|
+
args.push('--no-git');
|
|
81
|
+
}
|
|
54
82
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
// Create enhanced log manager if enabled
|
|
84
|
+
const logConfig = { ...DEFAULT_LOG_CONFIG, ...enhancedLogConfig };
|
|
85
|
+
let logManager: EnhancedLogManager | undefined;
|
|
86
|
+
let logPath: string;
|
|
87
|
+
let child: ChildProcess;
|
|
88
|
+
|
|
89
|
+
// Build environment for child process
|
|
90
|
+
const childEnv = {
|
|
91
|
+
...process.env,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (logConfig.enabled) {
|
|
95
|
+
// Create callback for clean console output
|
|
96
|
+
const onParsedMessage = (msg: ParsedMessage) => {
|
|
97
|
+
// Print a clean, colored version of the message to the console
|
|
98
|
+
const ts = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
|
99
|
+
const laneLabel = `[${laneName}]`.padEnd(12);
|
|
100
|
+
|
|
101
|
+
let prefix = '';
|
|
102
|
+
let content = msg.content;
|
|
103
|
+
|
|
104
|
+
switch (msg.type) {
|
|
105
|
+
case 'user':
|
|
106
|
+
prefix = `${logger.COLORS.cyan}🧑 USER${logger.COLORS.reset}`;
|
|
107
|
+
// No truncation for user prompt to ensure full command visibility
|
|
108
|
+
content = content.replace(/\n/g, ' ');
|
|
109
|
+
break;
|
|
110
|
+
case 'assistant':
|
|
111
|
+
prefix = `${logger.COLORS.green}🤖 ASST${logger.COLORS.reset}`;
|
|
112
|
+
break;
|
|
113
|
+
case 'tool':
|
|
114
|
+
prefix = `${logger.COLORS.yellow}🔧 TOOL${logger.COLORS.reset}`;
|
|
115
|
+
// Simplify tool call: [Tool: read_file] {"target_file":"..."} -> read_file(target_file: ...)
|
|
116
|
+
const toolMatch = content.match(/\[Tool: ([^\]]+)\] (.*)/);
|
|
117
|
+
if (toolMatch) {
|
|
118
|
+
const [, name, args] = toolMatch;
|
|
119
|
+
try {
|
|
120
|
+
const parsedArgs = JSON.parse(args!);
|
|
121
|
+
let argStr = '';
|
|
122
|
+
|
|
123
|
+
if (name === 'read_file' && parsedArgs.target_file) {
|
|
124
|
+
argStr = parsedArgs.target_file;
|
|
125
|
+
} else if (name === 'run_terminal_cmd' && parsedArgs.command) {
|
|
126
|
+
argStr = parsedArgs.command;
|
|
127
|
+
} else if (name === 'write' && parsedArgs.file_path) {
|
|
128
|
+
argStr = parsedArgs.file_path;
|
|
129
|
+
} else if (name === 'search_replace' && parsedArgs.file_path) {
|
|
130
|
+
argStr = parsedArgs.file_path;
|
|
131
|
+
} else {
|
|
132
|
+
// Generic summary for other tools
|
|
133
|
+
const keys = Object.keys(parsedArgs);
|
|
134
|
+
if (keys.length > 0) {
|
|
135
|
+
argStr = String(parsedArgs[keys[0]]).substring(0, 50);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}(${argStr})`;
|
|
139
|
+
} catch {
|
|
140
|
+
content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}: ${args}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case 'tool_result':
|
|
145
|
+
prefix = `${logger.COLORS.gray}📄 RESL${logger.COLORS.reset}`;
|
|
146
|
+
// Simplify tool result: [Tool Result: read_file] ... -> read_file OK
|
|
147
|
+
const resMatch = content.match(/\[Tool Result: ([^\]]+)\]/);
|
|
148
|
+
content = resMatch ? `${resMatch[1]} OK` : 'result';
|
|
149
|
+
break;
|
|
150
|
+
case 'result':
|
|
151
|
+
prefix = `${logger.COLORS.green}✅ DONE${logger.COLORS.reset}`;
|
|
152
|
+
break;
|
|
153
|
+
case 'system':
|
|
154
|
+
prefix = `${logger.COLORS.gray}⚙️ SYS${logger.COLORS.reset}`;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (prefix) {
|
|
159
|
+
const lines = content.split('\n');
|
|
160
|
+
const tsPrefix = `${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneLabel}${logger.COLORS.reset}`;
|
|
161
|
+
|
|
162
|
+
if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'result') {
|
|
163
|
+
const header = `${prefix} ┌${'─'.repeat(60)}`;
|
|
164
|
+
process.stdout.write(`${tsPrefix} ${header}\n`);
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
process.stdout.write(`${tsPrefix} ${' '.repeat(stripAnsi(prefix).length)} │ ${line}\n`);
|
|
167
|
+
}
|
|
168
|
+
process.stdout.write(`${tsPrefix} ${' '.repeat(stripAnsi(prefix).length)} └${'─'.repeat(60)}\n`);
|
|
169
|
+
} else {
|
|
170
|
+
process.stdout.write(`${tsPrefix} ${prefix} ${content}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
logManager = createLogManager(laneRunDir, laneName, logConfig, onParsedMessage);
|
|
176
|
+
logPath = logManager.getLogPaths().clean;
|
|
177
|
+
|
|
178
|
+
// Spawn with pipe for enhanced logging
|
|
179
|
+
child = spawn('node', args, {
|
|
180
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
env: childEnv,
|
|
182
|
+
detached: false,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Buffer for non-JSON lines
|
|
186
|
+
let lineBuffer = '';
|
|
187
|
+
|
|
188
|
+
// Pipe stdout and stderr through enhanced logger
|
|
189
|
+
if (child.stdout) {
|
|
190
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
191
|
+
logManager!.writeStdout(data);
|
|
192
|
+
|
|
193
|
+
// Filter out JSON lines from console output to keep it clean
|
|
194
|
+
const str = data.toString();
|
|
195
|
+
lineBuffer += str;
|
|
196
|
+
const lines = lineBuffer.split('\n');
|
|
197
|
+
lineBuffer = lines.pop() || '';
|
|
198
|
+
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
const trimmed = line.trim();
|
|
201
|
+
// Only print if NOT a noisy line
|
|
202
|
+
if (trimmed &&
|
|
203
|
+
!trimmed.startsWith('{') &&
|
|
204
|
+
!trimmed.startsWith('[') &&
|
|
205
|
+
!trimmed.includes('{"type"') &&
|
|
206
|
+
!trimmed.includes('Heartbeat:')) {
|
|
207
|
+
process.stdout.write(`${logger.COLORS.gray}[${new Date().toLocaleTimeString('en-US', { hour12: false })}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneName.padEnd(10)}${logger.COLORS.reset} ${line}\n`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (child.stderr) {
|
|
214
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
215
|
+
logManager!.writeStderr(data);
|
|
216
|
+
const str = data.toString();
|
|
217
|
+
const lines = str.split('\n');
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
const trimmed = line.trim();
|
|
220
|
+
if (trimmed) {
|
|
221
|
+
// Check if it's a real error or just git/status output on stderr
|
|
222
|
+
const isStatus = trimmed.startsWith('Preparing worktree') ||
|
|
223
|
+
trimmed.startsWith('Switched to a new branch') ||
|
|
224
|
+
trimmed.startsWith('HEAD is now at') ||
|
|
225
|
+
trimmed.includes('actual output');
|
|
226
|
+
|
|
227
|
+
if (isStatus) {
|
|
228
|
+
process.stdout.write(`${logger.COLORS.gray}[${new Date().toLocaleTimeString('en-US', { hour12: false })}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneName.padEnd(10)}${logger.COLORS.reset} ${trimmed}\n`);
|
|
229
|
+
} else {
|
|
230
|
+
process.stderr.write(`${logger.COLORS.red}[${laneName}] ERROR: ${trimmed}${logger.COLORS.reset}\n`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Close log manager when process exits
|
|
238
|
+
child.on('exit', () => {
|
|
239
|
+
logManager?.close();
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
// Fallback to simple file logging
|
|
243
|
+
logPath = path.join(laneRunDir, 'terminal.log');
|
|
244
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
245
|
+
|
|
246
|
+
child = spawn('node', args, {
|
|
247
|
+
stdio: ['ignore', logFd, logFd],
|
|
248
|
+
env: childEnv,
|
|
249
|
+
detached: false,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
fs.closeSync(logFd);
|
|
254
|
+
} catch {
|
|
255
|
+
// Ignore
|
|
256
|
+
}
|
|
59
257
|
}
|
|
60
258
|
|
|
61
|
-
return { child, logPath };
|
|
259
|
+
return { child, logPath, logManager };
|
|
62
260
|
}
|
|
63
261
|
|
|
64
262
|
/**
|
|
@@ -138,6 +336,108 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
|
|
|
138
336
|
}
|
|
139
337
|
}
|
|
140
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Resolve dependencies for all blocked lanes and sync with all active lanes
|
|
341
|
+
*/
|
|
342
|
+
async function resolveAllDependencies(
|
|
343
|
+
blockedLanes: Map<string, DependencyRequestPlan>,
|
|
344
|
+
allLanes: LaneInfo[],
|
|
345
|
+
laneRunDirs: Record<string, string>,
|
|
346
|
+
pipelineBranch: string,
|
|
347
|
+
runRoot: string
|
|
348
|
+
) {
|
|
349
|
+
// 1. Collect all unique changes and commands from blocked lanes
|
|
350
|
+
const allChanges: string[] = [];
|
|
351
|
+
const allCommands: string[] = [];
|
|
352
|
+
|
|
353
|
+
for (const [, plan] of blockedLanes) {
|
|
354
|
+
if (plan.changes) allChanges.push(...plan.changes);
|
|
355
|
+
if (plan.commands) allCommands.push(...plan.commands);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const uniqueChanges = Array.from(new Set(allChanges));
|
|
359
|
+
const uniqueCommands = Array.from(new Set(allCommands));
|
|
360
|
+
|
|
361
|
+
if (uniqueCommands.length === 0) return;
|
|
362
|
+
|
|
363
|
+
// 2. Setup a temporary worktree for resolution if needed, or use the first available one
|
|
364
|
+
const firstLaneName = Array.from(blockedLanes.keys())[0]!;
|
|
365
|
+
const statePath = path.join(laneRunDirs[firstLaneName]!, 'state.json');
|
|
366
|
+
const state = loadState<LaneState>(statePath);
|
|
367
|
+
const worktreeDir = state?.worktreeDir || path.join(runRoot, 'resolution-worktree');
|
|
368
|
+
|
|
369
|
+
if (!fs.existsSync(worktreeDir)) {
|
|
370
|
+
logger.info(`Creating resolution worktree at ${worktreeDir}`);
|
|
371
|
+
git.createWorktree(worktreeDir, pipelineBranch, { baseBranch: 'main' });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. Resolve on pipeline branch
|
|
375
|
+
logger.info(`Resolving dependencies on ${pipelineBranch}`);
|
|
376
|
+
git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
|
|
377
|
+
|
|
378
|
+
for (const cmd of uniqueCommands) {
|
|
379
|
+
logger.info(`Running: ${cmd}`);
|
|
380
|
+
try {
|
|
381
|
+
execSync(cmd, { cwd: worktreeDir, stdio: 'inherit' });
|
|
382
|
+
} catch (e: any) {
|
|
383
|
+
throw new Error(`Command failed: ${cmd}. ${e.message}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
git.runGit(['add', '.'], { cwd: worktreeDir });
|
|
389
|
+
git.runGit(['commit', '-m', `chore: auto-resolve dependencies\n\n${uniqueChanges.join('\n')}`], { cwd: worktreeDir });
|
|
390
|
+
|
|
391
|
+
// Log changed files
|
|
392
|
+
const stats = git.getLastOperationStats(worktreeDir);
|
|
393
|
+
if (stats) {
|
|
394
|
+
logger.info('Changed files:\n' + stats);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
git.push(pipelineBranch, { cwd: worktreeDir });
|
|
398
|
+
} catch (e) { /* ignore if nothing to commit */ }
|
|
399
|
+
|
|
400
|
+
// 4. Sync ALL active lanes (blocked + pending + running)
|
|
401
|
+
// Since we only call this when running.size === 0, "active" means not completed/failed
|
|
402
|
+
for (const lane of allLanes) {
|
|
403
|
+
const laneDir = laneRunDirs[lane.name];
|
|
404
|
+
if (!laneDir) continue;
|
|
405
|
+
|
|
406
|
+
const laneState = loadState<LaneState>(path.join(laneDir, 'state.json'));
|
|
407
|
+
if (!laneState || laneState.status === 'completed' || laneState.status === 'failed') continue;
|
|
408
|
+
|
|
409
|
+
// Merge pipelineBranch into the lane's current task branch
|
|
410
|
+
const currentIdx = laneState.currentTaskIndex;
|
|
411
|
+
const taskConfig = JSON.parse(fs.readFileSync(lane.path, 'utf8')) as RunnerConfig;
|
|
412
|
+
const task = taskConfig.tasks[currentIdx];
|
|
413
|
+
|
|
414
|
+
if (task) {
|
|
415
|
+
const taskBranch = `${pipelineBranch}--${String(currentIdx + 1).padStart(2, '0')}-${task.name}`;
|
|
416
|
+
logger.info(`Syncing lane ${lane.name} branch ${taskBranch}`);
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
// If task branch doesn't exist yet, it will be created from pipelineBranch when the lane starts
|
|
420
|
+
if (git.branchExists(taskBranch, { cwd: worktreeDir })) {
|
|
421
|
+
git.runGit(['checkout', taskBranch], { cwd: worktreeDir });
|
|
422
|
+
git.runGit(['merge', pipelineBranch, '--no-edit'], { cwd: worktreeDir });
|
|
423
|
+
|
|
424
|
+
// Log changed files
|
|
425
|
+
const stats = git.getLastOperationStats(worktreeDir);
|
|
426
|
+
if (stats) {
|
|
427
|
+
logger.info(`Sync results for ${lane.name}:\n` + stats);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
git.push(taskBranch, { cwd: worktreeDir });
|
|
431
|
+
}
|
|
432
|
+
} catch (e: any) {
|
|
433
|
+
logger.warn(`Failed to sync branch ${taskBranch}: ${e.message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
|
|
439
|
+
}
|
|
440
|
+
|
|
141
441
|
/**
|
|
142
442
|
* Run orchestration with dependency management
|
|
143
443
|
*/
|
|
@@ -146,6 +446,10 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
146
446
|
executor?: string;
|
|
147
447
|
pollInterval?: number;
|
|
148
448
|
maxConcurrentLanes?: number;
|
|
449
|
+
webhooks?: WebhookConfig[];
|
|
450
|
+
autoResolveDependencies?: boolean;
|
|
451
|
+
enhancedLogging?: Partial<EnhancedLogConfig>;
|
|
452
|
+
noGit?: boolean;
|
|
149
453
|
} = {}): Promise<{ lanes: LaneInfo[]; exitCodes: Record<string, number>; runRoot: string }> {
|
|
150
454
|
const lanes = listLaneFiles(tasksDir);
|
|
151
455
|
|
|
@@ -153,8 +457,42 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
153
457
|
throw new Error(`No lane task files found in ${tasksDir}`);
|
|
154
458
|
}
|
|
155
459
|
|
|
156
|
-
const
|
|
460
|
+
const config = loadConfig();
|
|
461
|
+
const logsDir = getLogsDir(config);
|
|
462
|
+
const runId = `run-${Date.now()}`;
|
|
463
|
+
// Use absolute path for runRoot to avoid issues with subfolders
|
|
464
|
+
const runRoot = options.runDir
|
|
465
|
+
? (path.isAbsolute(options.runDir) ? options.runDir : path.resolve(process.cwd(), options.runDir))
|
|
466
|
+
: path.join(logsDir, 'runs', runId);
|
|
467
|
+
|
|
157
468
|
fs.mkdirSync(runRoot, { recursive: true });
|
|
469
|
+
|
|
470
|
+
const pipelineBranch = `cursorflow/run-${Date.now().toString(36)}`;
|
|
471
|
+
|
|
472
|
+
// Initialize event system
|
|
473
|
+
events.setRunId(runId);
|
|
474
|
+
if (options.webhooks) {
|
|
475
|
+
registerWebhooks(options.webhooks);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
events.emit('orchestration.started', {
|
|
479
|
+
runId,
|
|
480
|
+
tasksDir,
|
|
481
|
+
laneCount: lanes.length,
|
|
482
|
+
runRoot,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const maxConcurrent = options.maxConcurrentLanes || 10;
|
|
486
|
+
const running: Map<string, { child: ChildProcess; logPath: string }> = new Map();
|
|
487
|
+
const exitCodes: Record<string, number> = {};
|
|
488
|
+
const completedLanes = new Set<string>();
|
|
489
|
+
const failedLanes = new Set<string>();
|
|
490
|
+
const blockedLanes: Map<string, DependencyRequestPlan> = new Map();
|
|
491
|
+
|
|
492
|
+
// Track start index for each lane (initially 0)
|
|
493
|
+
for (const lane of lanes) {
|
|
494
|
+
lane.startIndex = 0;
|
|
495
|
+
}
|
|
158
496
|
|
|
159
497
|
const laneRunDirs: Record<string, string> = {};
|
|
160
498
|
for (const lane of lanes) {
|
|
@@ -166,35 +504,54 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
166
504
|
logger.info(`Tasks directory: ${tasksDir}`);
|
|
167
505
|
logger.info(`Run directory: ${runRoot}`);
|
|
168
506
|
logger.info(`Lanes: ${lanes.length}`);
|
|
507
|
+
|
|
508
|
+
// Display dependency graph
|
|
509
|
+
logger.info('\n📊 Dependency Graph:');
|
|
510
|
+
for (const lane of lanes) {
|
|
511
|
+
const deps = lane.dependsOn.length > 0 ? ` [depends on: ${lane.dependsOn.join(', ')}]` : '';
|
|
512
|
+
console.log(` ${logger.COLORS.cyan}${lane.name}${logger.COLORS.reset}${deps}`);
|
|
513
|
+
|
|
514
|
+
// Simple tree-like visualization for deep dependencies
|
|
515
|
+
if (lane.dependsOn.length > 0) {
|
|
516
|
+
for (const dep of lane.dependsOn) {
|
|
517
|
+
console.log(` └─ ${dep}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
console.log('');
|
|
522
|
+
|
|
523
|
+
// Disable auto-resolve when noGit mode is enabled
|
|
524
|
+
const autoResolve = !options.noGit && options.autoResolveDependencies !== false;
|
|
169
525
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const completedLanes = new Set<string>();
|
|
174
|
-
const failedLanes = new Set<string>();
|
|
526
|
+
if (options.noGit) {
|
|
527
|
+
logger.info('🚫 Git operations disabled (--no-git mode)');
|
|
528
|
+
}
|
|
175
529
|
|
|
176
530
|
// Monitor lanes
|
|
177
531
|
const monitorInterval = setInterval(() => {
|
|
178
532
|
printLaneStatus(lanes, laneRunDirs);
|
|
179
533
|
}, options.pollInterval || 60000);
|
|
180
534
|
|
|
181
|
-
while (completedLanes.size + failedLanes.size < lanes.length) {
|
|
535
|
+
while (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length || (blockedLanes.size > 0 && running.size === 0)) {
|
|
182
536
|
// 1. Identify lanes ready to start
|
|
183
537
|
const readyToStart = lanes.filter(lane => {
|
|
184
|
-
// Not already running or completed
|
|
185
|
-
if (running.has(lane.name) || completedLanes.has(lane.name) || failedLanes.has(lane.name)) {
|
|
538
|
+
// Not already running or completed or failed or blocked
|
|
539
|
+
if (running.has(lane.name) || completedLanes.has(lane.name) || failedLanes.has(lane.name) || blockedLanes.has(lane.name)) {
|
|
186
540
|
return false;
|
|
187
541
|
}
|
|
188
542
|
|
|
189
543
|
// Check dependencies
|
|
190
544
|
for (const dep of lane.dependsOn) {
|
|
191
545
|
if (failedLanes.has(dep)) {
|
|
192
|
-
|
|
193
|
-
logger.error(`Lane ${lane.name} failed because dependency ${dep} failed`);
|
|
546
|
+
logger.error(`Lane ${lane.name} will not start because dependency ${dep} failed`);
|
|
194
547
|
failedLanes.add(lane.name);
|
|
195
548
|
exitCodes[lane.name] = 1;
|
|
196
549
|
return false;
|
|
197
550
|
}
|
|
551
|
+
if (blockedLanes.has(dep)) {
|
|
552
|
+
// If a dependency is blocked, wait
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
198
555
|
if (!completedLanes.has(dep)) {
|
|
199
556
|
return false;
|
|
200
557
|
}
|
|
@@ -206,20 +563,28 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
206
563
|
for (const lane of readyToStart) {
|
|
207
564
|
if (running.size >= maxConcurrent) break;
|
|
208
565
|
|
|
209
|
-
logger.info(`Lane started: ${lane.name}`);
|
|
566
|
+
logger.info(`Lane started: ${lane.name}${lane.startIndex ? ` (resuming from ${lane.startIndex})` : ''}`);
|
|
210
567
|
const spawnResult = spawnLane({
|
|
211
568
|
laneName: lane.name,
|
|
212
569
|
tasksFile: lane.path,
|
|
213
570
|
laneRunDir: laneRunDirs[lane.name]!,
|
|
214
571
|
executor: options.executor || 'cursor-agent',
|
|
572
|
+
startIndex: lane.startIndex,
|
|
573
|
+
pipelineBranch,
|
|
574
|
+
enhancedLogConfig: options.enhancedLogging,
|
|
575
|
+
noGit: options.noGit,
|
|
215
576
|
});
|
|
216
577
|
|
|
217
578
|
running.set(lane.name, spawnResult);
|
|
579
|
+
events.emit('lane.started', {
|
|
580
|
+
laneName: lane.name,
|
|
581
|
+
pid: spawnResult.child.pid,
|
|
582
|
+
logPath: spawnResult.logPath,
|
|
583
|
+
});
|
|
218
584
|
}
|
|
219
585
|
|
|
220
586
|
// 3. Wait for any running lane to finish
|
|
221
587
|
if (running.size > 0) {
|
|
222
|
-
// We need to wait for at least one to finish
|
|
223
588
|
const promises = Array.from(running.entries()).map(async ([name, { child }]) => {
|
|
224
589
|
const code = await waitChild(child);
|
|
225
590
|
return { name, code };
|
|
@@ -230,23 +595,72 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
230
595
|
running.delete(finished.name);
|
|
231
596
|
exitCodes[finished.name] = finished.code;
|
|
232
597
|
|
|
233
|
-
if (finished.code === 0
|
|
598
|
+
if (finished.code === 0) {
|
|
234
599
|
completedLanes.add(finished.name);
|
|
600
|
+
events.emit('lane.completed', {
|
|
601
|
+
laneName: finished.name,
|
|
602
|
+
exitCode: finished.code,
|
|
603
|
+
});
|
|
604
|
+
} else if (finished.code === 2) {
|
|
605
|
+
// Blocked by dependency
|
|
606
|
+
const statePath = path.join(laneRunDirs[finished.name]!, 'state.json');
|
|
607
|
+
const state = loadState<LaneState>(statePath);
|
|
608
|
+
|
|
609
|
+
if (state && state.dependencyRequest) {
|
|
610
|
+
blockedLanes.set(finished.name, state.dependencyRequest);
|
|
611
|
+
const lane = lanes.find(l => l.name === finished.name);
|
|
612
|
+
if (lane) {
|
|
613
|
+
lane.startIndex = Math.max(0, state.currentTaskIndex - 1); // Task was blocked, retry it
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
events.emit('lane.blocked', {
|
|
617
|
+
laneName: finished.name,
|
|
618
|
+
dependencyRequest: state.dependencyRequest,
|
|
619
|
+
});
|
|
620
|
+
logger.warn(`Lane ${finished.name} is blocked on dependency change request`);
|
|
621
|
+
} else {
|
|
622
|
+
failedLanes.add(finished.name);
|
|
623
|
+
logger.error(`Lane ${finished.name} exited with code 2 but no dependency request found`);
|
|
624
|
+
}
|
|
235
625
|
} else {
|
|
236
626
|
failedLanes.add(finished.name);
|
|
627
|
+
events.emit('lane.failed', {
|
|
628
|
+
laneName: finished.name,
|
|
629
|
+
exitCode: finished.code,
|
|
630
|
+
error: 'Process exited with non-zero code',
|
|
631
|
+
});
|
|
237
632
|
}
|
|
238
633
|
|
|
239
634
|
printLaneStatus(lanes, laneRunDirs);
|
|
240
635
|
} else {
|
|
241
|
-
// Nothing running
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
636
|
+
// Nothing running. Are we blocked?
|
|
637
|
+
if (blockedLanes.size > 0 && autoResolve) {
|
|
638
|
+
logger.section('🛠 Auto-Resolving Dependencies');
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
await resolveAllDependencies(blockedLanes, lanes, laneRunDirs, pipelineBranch, runRoot);
|
|
642
|
+
|
|
643
|
+
// Clear blocked status
|
|
644
|
+
blockedLanes.clear();
|
|
645
|
+
logger.success('Dependencies resolved and synced across all active lanes. Resuming...');
|
|
646
|
+
} catch (error: any) {
|
|
647
|
+
logger.error(`Auto-resolution failed: ${error.message}`);
|
|
648
|
+
// Move blocked to failed
|
|
649
|
+
for (const name of blockedLanes.keys()) {
|
|
650
|
+
failedLanes.add(name);
|
|
651
|
+
}
|
|
652
|
+
blockedLanes.clear();
|
|
653
|
+
}
|
|
654
|
+
} else if (readyToStart.length === 0 && completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length) {
|
|
655
|
+
const remaining = lanes.filter(l => !completedLanes.has(l.name) && !failedLanes.has(l.name) && !blockedLanes.has(l.name));
|
|
245
656
|
logger.error(`Deadlock detected! Remaining lanes cannot start: ${remaining.map(l => l.name).join(', ')}`);
|
|
246
657
|
for (const l of remaining) {
|
|
247
658
|
failedLanes.add(l.name);
|
|
248
659
|
exitCodes[l.name] = 1;
|
|
249
660
|
}
|
|
661
|
+
} else {
|
|
662
|
+
// All finished
|
|
663
|
+
break;
|
|
250
664
|
}
|
|
251
665
|
}
|
|
252
666
|
}
|
|
@@ -262,17 +676,25 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
262
676
|
process.exit(1);
|
|
263
677
|
}
|
|
264
678
|
|
|
265
|
-
// Check for blocked lanes
|
|
266
|
-
const blocked =
|
|
267
|
-
.filter(([, code]) => code === 2)
|
|
268
|
-
.map(([lane]) => lane);
|
|
679
|
+
// Check for blocked lanes (if autoResolve was false)
|
|
680
|
+
const blocked = Array.from(blockedLanes.keys());
|
|
269
681
|
|
|
270
682
|
if (blocked.length > 0) {
|
|
271
683
|
logger.warn(`Lanes blocked on dependency: ${blocked.join(', ')}`);
|
|
272
684
|
logger.info('Handle dependency changes manually and resume lanes');
|
|
685
|
+
events.emit('orchestration.failed', {
|
|
686
|
+
error: 'Some lanes blocked on dependency change requests',
|
|
687
|
+
blockedLanes: blocked,
|
|
688
|
+
});
|
|
273
689
|
process.exit(2);
|
|
274
690
|
}
|
|
275
691
|
|
|
276
692
|
logger.success('All lanes completed successfully!');
|
|
693
|
+
events.emit('orchestration.completed', {
|
|
694
|
+
runId,
|
|
695
|
+
laneCount: lanes.length,
|
|
696
|
+
completedCount: completedLanes.size,
|
|
697
|
+
failedCount: failedLanes.size,
|
|
698
|
+
});
|
|
277
699
|
return { lanes, exitCodes, runRoot };
|
|
278
700
|
}
|