@litmers/cursorflow-orchestrator 0.1.5 → 0.1.8
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 +15 -6
- package/README.md +33 -2
- package/commands/cursorflow-doctor.md +24 -0
- package/commands/cursorflow-signal.md +19 -0
- package/dist/cli/doctor.d.ts +15 -0
- package/dist/cli/doctor.js +139 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/monitor.d.ts +1 -1
- package/dist/cli/monitor.js +640 -145
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/resume.d.ts +1 -1
- package/dist/cli/resume.js +80 -10
- package/dist/cli/resume.js.map +1 -1
- package/dist/cli/run.js +60 -5
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/setup-commands.d.ts +4 -0
- package/dist/cli/setup-commands.js +16 -0
- package/dist/cli/setup-commands.js.map +1 -1
- package/dist/cli/signal.d.ts +7 -0
- package/dist/cli/signal.js +99 -0
- package/dist/cli/signal.js.map +1 -0
- package/dist/core/orchestrator.d.ts +4 -2
- package/dist/core/orchestrator.js +92 -23
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +182 -88
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/doctor.d.ts +63 -0
- package/dist/utils/doctor.js +280 -0
- package/dist/utils/doctor.js.map +1 -0
- package/dist/utils/types.d.ts +3 -0
- package/package.json +1 -1
- package/src/cli/doctor.ts +127 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/monitor.ts +693 -185
- package/src/cli/resume.ts +94 -12
- package/src/cli/run.ts +63 -7
- package/src/cli/setup-commands.ts +19 -0
- package/src/cli/signal.ts +89 -0
- package/src/core/orchestrator.ts +102 -27
- package/src/core/runner.ts +203 -99
- package/src/utils/doctor.ts +312 -0
- package/src/utils/types.ts +3 -0
package/src/cli/resume.ts
CHANGED
|
@@ -1,37 +1,119 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CursorFlow resume command
|
|
2
|
+
* CursorFlow resume command
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
5
8
|
import * as logger from '../utils/logger';
|
|
9
|
+
import { loadConfig, getLogsDir } from '../utils/config';
|
|
10
|
+
import { loadState } from '../utils/state';
|
|
11
|
+
import { LaneState } from '../utils/types';
|
|
6
12
|
|
|
7
13
|
interface ResumeOptions {
|
|
8
|
-
lane
|
|
14
|
+
lane: string | null;
|
|
9
15
|
runDir: string | null;
|
|
10
16
|
clean: boolean;
|
|
11
17
|
restart: boolean;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function parseArgs(args: string[]): ResumeOptions {
|
|
21
|
+
const runDirIdx = args.indexOf('--run-dir');
|
|
22
|
+
|
|
15
23
|
return {
|
|
16
|
-
lane: args
|
|
17
|
-
runDir: null,
|
|
24
|
+
lane: args.find(a => !a.startsWith('--')) || null,
|
|
25
|
+
runDir: runDirIdx >= 0 ? args[runDirIdx + 1] || null : null,
|
|
18
26
|
clean: args.includes('--clean'),
|
|
19
27
|
restart: args.includes('--restart'),
|
|
20
28
|
};
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Find the latest run directory
|
|
33
|
+
*/
|
|
34
|
+
function findLatestRunDir(logsDir: string): string | null {
|
|
35
|
+
const runsDir = path.join(logsDir, 'runs');
|
|
36
|
+
if (!fs.existsSync(runsDir)) return null;
|
|
25
37
|
|
|
38
|
+
const runs = fs.readdirSync(runsDir)
|
|
39
|
+
.filter(d => d.startsWith('run-'))
|
|
40
|
+
.sort()
|
|
41
|
+
.reverse();
|
|
42
|
+
|
|
43
|
+
return runs.length > 0 ? path.join(runsDir, runs[0]!) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function resume(args: string[]): Promise<void> {
|
|
26
47
|
const options = parseArgs(args);
|
|
48
|
+
const config = loadConfig();
|
|
49
|
+
const logsDir = getLogsDir(config);
|
|
50
|
+
|
|
51
|
+
if (!options.lane) {
|
|
52
|
+
throw new Error('Lane name required (e.g., cursorflow resume lane-1)');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let runDir = options.runDir;
|
|
56
|
+
if (!runDir) {
|
|
57
|
+
runDir = findLatestRunDir(logsDir);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!runDir || !fs.existsSync(runDir)) {
|
|
61
|
+
throw new Error(`Run directory not found: ${runDir || 'latest'}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const laneDir = path.join(runDir, 'lanes', options.lane);
|
|
65
|
+
const statePath = path.join(laneDir, 'state.json');
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(statePath)) {
|
|
68
|
+
throw new Error(`Lane state not found at ${statePath}. Is the lane name correct?`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const state = loadState<LaneState>(statePath);
|
|
72
|
+
if (!state) {
|
|
73
|
+
throw new Error(`Failed to load state from ${statePath}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!state.tasksFile || !fs.existsSync(state.tasksFile)) {
|
|
77
|
+
throw new Error(`Original tasks file not found: ${state.tasksFile}. Resume impossible without task definition.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
logger.section(`🔁 Resuming Lane: ${options.lane}`);
|
|
81
|
+
logger.info(`Run: ${path.basename(runDir)}`);
|
|
82
|
+
logger.info(`Tasks: ${state.tasksFile}`);
|
|
83
|
+
logger.info(`Starting from task index: ${options.restart ? 0 : state.currentTaskIndex}`);
|
|
84
|
+
|
|
85
|
+
const runnerPath = require.resolve('../core/runner');
|
|
86
|
+
const runnerArgs = [
|
|
87
|
+
runnerPath,
|
|
88
|
+
state.tasksFile,
|
|
89
|
+
'--run-dir', laneDir,
|
|
90
|
+
'--start-index', options.restart ? '0' : String(state.currentTaskIndex),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
logger.info(`Spawning runner process...`);
|
|
27
94
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
95
|
+
const child = spawn('node', runnerArgs, {
|
|
96
|
+
stdio: 'inherit',
|
|
97
|
+
env: process.env,
|
|
98
|
+
});
|
|
32
99
|
|
|
33
|
-
|
|
34
|
-
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
child.on('exit', (code) => {
|
|
102
|
+
if (code === 0) {
|
|
103
|
+
logger.success(`Lane ${options.lane} completed successfully`);
|
|
104
|
+
resolve();
|
|
105
|
+
} else if (code === 2) {
|
|
106
|
+
logger.warn(`Lane ${options.lane} blocked on dependency change`);
|
|
107
|
+
resolve();
|
|
108
|
+
} else {
|
|
109
|
+
reject(new Error(`Lane ${options.lane} failed with exit code ${code}`));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
child.on('error', (error) => {
|
|
114
|
+
reject(new Error(`Failed to start runner: ${error.message}`));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
35
117
|
}
|
|
36
118
|
|
|
37
119
|
export = resume;
|
package/src/cli/run.ts
CHANGED
|
@@ -6,12 +6,15 @@ import * as path from 'path';
|
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as logger from '../utils/logger';
|
|
8
8
|
import { orchestrate } from '../core/orchestrator';
|
|
9
|
-
import { loadConfig } from '../utils/config';
|
|
9
|
+
import { getLogsDir, loadConfig } from '../utils/config';
|
|
10
|
+
import { runDoctor } from '../utils/doctor';
|
|
11
|
+
import { areCommandsInstalled, setupCommands } from './setup-commands';
|
|
10
12
|
|
|
11
13
|
interface RunOptions {
|
|
12
14
|
tasksDir?: string;
|
|
13
15
|
dryRun: boolean;
|
|
14
16
|
executor: string | null;
|
|
17
|
+
skipDoctor: boolean;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
function parseArgs(args: string[]): RunOptions {
|
|
@@ -22,28 +25,81 @@ function parseArgs(args: string[]): RunOptions {
|
|
|
22
25
|
tasksDir,
|
|
23
26
|
dryRun: args.includes('--dry-run'),
|
|
24
27
|
executor: executorIdx >= 0 ? args[executorIdx + 1] || null : null,
|
|
28
|
+
skipDoctor: args.includes('--skip-doctor') || args.includes('--no-doctor'),
|
|
25
29
|
};
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
async function run(args: string[]): Promise<void> {
|
|
29
33
|
const options = parseArgs(args);
|
|
30
34
|
|
|
35
|
+
// Auto-setup Cursor commands if missing or outdated
|
|
36
|
+
if (!areCommandsInstalled()) {
|
|
37
|
+
logger.info('Installing missing or outdated Cursor IDE commands...');
|
|
38
|
+
try {
|
|
39
|
+
setupCommands({ silent: true });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Non-blocking
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
if (!options.tasksDir) {
|
|
32
46
|
console.log('\nUsage: cursorflow run <tasks-dir> [options]');
|
|
33
47
|
throw new Error('Tasks directory required');
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
if (!fs.existsSync(options.tasksDir)) {
|
|
37
|
-
throw new Error(`Tasks directory not found: ${options.tasksDir}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
50
|
const config = loadConfig();
|
|
51
|
+
const logsDir = getLogsDir(config);
|
|
52
|
+
|
|
53
|
+
// Resolve tasks dir:
|
|
54
|
+
// - Prefer the exact path if it exists relative to cwd
|
|
55
|
+
// - Otherwise, fall back to projectRoot-relative path for better ergonomics
|
|
56
|
+
const tasksDir =
|
|
57
|
+
path.isAbsolute(options.tasksDir)
|
|
58
|
+
? options.tasksDir
|
|
59
|
+
: (fs.existsSync(options.tasksDir)
|
|
60
|
+
? path.resolve(process.cwd(), options.tasksDir)
|
|
61
|
+
: path.join(config.projectRoot, options.tasksDir));
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(tasksDir)) {
|
|
64
|
+
throw new Error(`Tasks directory not found: ${tasksDir}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Preflight checks (doctor)
|
|
68
|
+
if (!options.skipDoctor) {
|
|
69
|
+
const report = runDoctor({
|
|
70
|
+
cwd: process.cwd(),
|
|
71
|
+
tasksDir,
|
|
72
|
+
executor: options.executor || config.executor,
|
|
73
|
+
includeCursorAgentChecks: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!report.ok) {
|
|
77
|
+
logger.section('🛑 Pre-flight check failed');
|
|
78
|
+
for (const issue of report.issues) {
|
|
79
|
+
const header = `${issue.title} (${issue.id})`;
|
|
80
|
+
if (issue.severity === 'error') {
|
|
81
|
+
logger.error(header, '❌');
|
|
82
|
+
} else {
|
|
83
|
+
logger.warn(header, '⚠️');
|
|
84
|
+
}
|
|
85
|
+
console.log(` ${issue.message}`);
|
|
86
|
+
if (issue.details) console.log(` Details: ${issue.details}`);
|
|
87
|
+
if (issue.fixes?.length) {
|
|
88
|
+
console.log(' Fix:');
|
|
89
|
+
for (const fix of issue.fixes) console.log(` - ${fix}`);
|
|
90
|
+
}
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
throw new Error('Pre-flight checks failed. Run `cursorflow doctor` for details.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
41
96
|
|
|
42
97
|
try {
|
|
43
|
-
await orchestrate(
|
|
98
|
+
await orchestrate(tasksDir, {
|
|
44
99
|
executor: options.executor || config.executor,
|
|
45
100
|
pollInterval: config.pollInterval * 1000,
|
|
46
|
-
runDir: path.join(
|
|
101
|
+
runDir: path.join(logsDir, 'runs', `run-${Date.now()}`),
|
|
102
|
+
maxConcurrentLanes: config.maxConcurrentLanes,
|
|
47
103
|
});
|
|
48
104
|
} catch (error: any) {
|
|
49
105
|
// Re-throw to be handled by the main entry point
|
|
@@ -183,6 +183,25 @@ export function uninstallCommands(options: SetupOptions = {}): { removed: number
|
|
|
183
183
|
return { removed };
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Check if commands are already installed
|
|
188
|
+
*/
|
|
189
|
+
export function areCommandsInstalled(): boolean {
|
|
190
|
+
const projectRoot = findProjectRoot();
|
|
191
|
+
const targetDir = path.join(projectRoot, '.cursor', 'commands', 'cursorflow');
|
|
192
|
+
const sourceDir = getCommandsSourceDir();
|
|
193
|
+
|
|
194
|
+
if (!fs.existsSync(targetDir) || !fs.existsSync(sourceDir)) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const sourceFiles = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
|
|
199
|
+
const targetFiles = fs.readdirSync(targetDir).filter(f => f.endsWith('.md'));
|
|
200
|
+
|
|
201
|
+
// Basic check: do we have all the files from source in target?
|
|
202
|
+
return sourceFiles.every(f => targetFiles.includes(f));
|
|
203
|
+
}
|
|
204
|
+
|
|
186
205
|
async function main(args: string[]): Promise<any> {
|
|
187
206
|
const options = parseArgs(args);
|
|
188
207
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorFlow signal command
|
|
3
|
+
*
|
|
4
|
+
* Send a direct message to a running lane
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as logger from '../utils/logger';
|
|
10
|
+
import { loadConfig, getLogsDir } from '../utils/config';
|
|
11
|
+
import { appendLog, createConversationEntry } from '../utils/state';
|
|
12
|
+
|
|
13
|
+
interface SignalOptions {
|
|
14
|
+
lane: string | null;
|
|
15
|
+
message: string | null;
|
|
16
|
+
runDir: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(args: string[]): SignalOptions {
|
|
20
|
+
const runDirIdx = args.indexOf('--run-dir');
|
|
21
|
+
|
|
22
|
+
// First non-option is lane, second (or rest joined) is message
|
|
23
|
+
const nonOptions = args.filter(a => !a.startsWith('--'));
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
lane: nonOptions[0] || null,
|
|
27
|
+
message: nonOptions.slice(1).join(' ') || null,
|
|
28
|
+
runDir: runDirIdx >= 0 ? args[runDirIdx + 1] || null : null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findLatestRunDir(logsDir: string): string | null {
|
|
33
|
+
const runsDir = path.join(logsDir, 'runs');
|
|
34
|
+
if (!fs.existsSync(runsDir)) return null;
|
|
35
|
+
|
|
36
|
+
const runs = fs.readdirSync(runsDir)
|
|
37
|
+
.filter(d => d.startsWith('run-'))
|
|
38
|
+
.sort()
|
|
39
|
+
.reverse();
|
|
40
|
+
|
|
41
|
+
return runs.length > 0 ? path.join(runsDir, runs[0]!) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function signal(args: string[]): Promise<void> {
|
|
45
|
+
const options = parseArgs(args);
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
const logsDir = getLogsDir(config);
|
|
48
|
+
|
|
49
|
+
if (!options.lane) {
|
|
50
|
+
throw new Error('Lane name required: cursorflow signal <lane> "<message>"');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!options.message) {
|
|
54
|
+
throw new Error('Message required: cursorflow signal <lane> "<message>"');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let runDir = options.runDir;
|
|
58
|
+
if (!runDir) {
|
|
59
|
+
runDir = findLatestRunDir(logsDir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!runDir || !fs.existsSync(runDir)) {
|
|
63
|
+
throw new Error(`Run directory not found: ${runDir || 'latest'}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const convoPath = path.join(runDir, 'lanes', options.lane, 'conversation.jsonl');
|
|
67
|
+
|
|
68
|
+
if (!fs.existsSync(convoPath)) {
|
|
69
|
+
throw new Error(`Conversation log not found at ${convoPath}. Is the lane running?`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
logger.info(`Sending signal to lane: ${options.lane}`);
|
|
73
|
+
logger.info(`Message: "${options.message}"`);
|
|
74
|
+
|
|
75
|
+
// Append as a "commander" role message
|
|
76
|
+
// Note: We cast to 'system' or similar if 'commander' isn't in the enum,
|
|
77
|
+
// but let's use 'reviewer' or 'system' which agents usually respect,
|
|
78
|
+
// or update the type definition.
|
|
79
|
+
const entry = createConversationEntry('system', `[COMMANDER INTERVENTION]\n${options.message}`, {
|
|
80
|
+
task: 'DIRECT_SIGNAL'
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
appendLog(convoPath, entry);
|
|
84
|
+
|
|
85
|
+
logger.success('Signal sent successfully. The agent will see this message in its next turn or via file monitoring.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export = signal;
|
|
89
|
+
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -10,11 +10,12 @@ 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 } from '../utils/types';
|
|
13
|
+
import { LaneState, RunnerConfig } from '../utils/types';
|
|
14
14
|
|
|
15
15
|
export interface LaneInfo {
|
|
16
16
|
name: string;
|
|
17
17
|
path: string;
|
|
18
|
+
dependsOn: string[];
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface SpawnLaneResult {
|
|
@@ -76,7 +77,7 @@ export function waitChild(proc: ChildProcess): Promise<number> {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* List lane task files in directory
|
|
80
|
+
* List lane task files in directory and load their configs for dependencies
|
|
80
81
|
*/
|
|
81
82
|
export function listLaneFiles(tasksDir: string): LaneInfo[] {
|
|
82
83
|
if (!fs.existsSync(tasksDir)) {
|
|
@@ -87,10 +88,24 @@ export function listLaneFiles(tasksDir: string): LaneInfo[] {
|
|
|
87
88
|
return files
|
|
88
89
|
.filter(f => f.endsWith('.json'))
|
|
89
90
|
.sort()
|
|
90
|
-
.map(f =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
.map(f => {
|
|
92
|
+
const filePath = path.join(tasksDir, f);
|
|
93
|
+
const name = path.basename(f, '.json');
|
|
94
|
+
let dependsOn: string[] = [];
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const config = JSON.parse(fs.readFileSync(filePath, 'utf8')) as RunnerConfig;
|
|
98
|
+
dependsOn = config.dependsOn || [];
|
|
99
|
+
} catch (e) {
|
|
100
|
+
logger.warn(`Failed to parse config for lane ${name}: ${e}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
name,
|
|
105
|
+
path: filePath,
|
|
106
|
+
dependsOn,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
/**
|
|
@@ -105,7 +120,8 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
|
|
|
105
120
|
const state = loadState<LaneState>(statePath);
|
|
106
121
|
|
|
107
122
|
if (!state) {
|
|
108
|
-
|
|
123
|
+
const isWaiting = lane.dependsOn.length > 0;
|
|
124
|
+
return { lane: lane.name, status: isWaiting ? 'waiting' : 'pending', task: '-' };
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
const idx = (state.currentTaskIndex || 0) + 1;
|
|
@@ -123,12 +139,13 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
|
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
/**
|
|
126
|
-
* Run orchestration
|
|
142
|
+
* Run orchestration with dependency management
|
|
127
143
|
*/
|
|
128
144
|
export async function orchestrate(tasksDir: string, options: {
|
|
129
145
|
runDir?: string;
|
|
130
146
|
executor?: string;
|
|
131
147
|
pollInterval?: number;
|
|
148
|
+
maxConcurrentLanes?: number;
|
|
132
149
|
} = {}): Promise<{ lanes: LaneInfo[]; exitCodes: Record<string, number>; runRoot: string }> {
|
|
133
150
|
const lanes = listLaneFiles(tasksDir);
|
|
134
151
|
|
|
@@ -142,6 +159,7 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
142
159
|
const laneRunDirs: Record<string, string> = {};
|
|
143
160
|
for (const lane of lanes) {
|
|
144
161
|
laneRunDirs[lane.name] = path.join(runRoot, 'lanes', lane.name);
|
|
162
|
+
fs.mkdirSync(laneRunDirs[lane.name], { recursive: true });
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
logger.section('🧭 Starting Orchestration');
|
|
@@ -149,31 +167,88 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
149
167
|
logger.info(`Run directory: ${runRoot}`);
|
|
150
168
|
logger.info(`Lanes: ${lanes.length}`);
|
|
151
169
|
|
|
152
|
-
|
|
153
|
-
const running: {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
laneName: lane.name,
|
|
158
|
-
tasksFile: lane.path,
|
|
159
|
-
laneRunDir: laneRunDirs[lane.name]!,
|
|
160
|
-
executor: options.executor || 'cursor-agent',
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
running.push({ lane: lane.name, child, logPath });
|
|
164
|
-
logger.info(`Lane started: ${lane.name}`);
|
|
165
|
-
}
|
|
170
|
+
const maxConcurrent = options.maxConcurrentLanes || 10;
|
|
171
|
+
const running: Map<string, { child: ChildProcess; logPath: string }> = new Map();
|
|
172
|
+
const exitCodes: Record<string, number> = {};
|
|
173
|
+
const completedLanes = new Set<string>();
|
|
174
|
+
const failedLanes = new Set<string>();
|
|
166
175
|
|
|
167
176
|
// Monitor lanes
|
|
168
177
|
const monitorInterval = setInterval(() => {
|
|
169
178
|
printLaneStatus(lanes, laneRunDirs);
|
|
170
179
|
}, options.pollInterval || 60000);
|
|
171
180
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
while (completedLanes.size + failedLanes.size < lanes.length) {
|
|
182
|
+
// 1. Identify lanes ready to start
|
|
183
|
+
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)) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check dependencies
|
|
190
|
+
for (const dep of lane.dependsOn) {
|
|
191
|
+
if (failedLanes.has(dep)) {
|
|
192
|
+
// If a dependency failed, this lane fails too
|
|
193
|
+
logger.error(`Lane ${lane.name} failed because dependency ${dep} failed`);
|
|
194
|
+
failedLanes.add(lane.name);
|
|
195
|
+
exitCodes[lane.name] = 1;
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (!completedLanes.has(dep)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// 2. Spawn ready lanes up to maxConcurrent
|
|
206
|
+
for (const lane of readyToStart) {
|
|
207
|
+
if (running.size >= maxConcurrent) break;
|
|
208
|
+
|
|
209
|
+
logger.info(`Lane started: ${lane.name}`);
|
|
210
|
+
const spawnResult = spawnLane({
|
|
211
|
+
laneName: lane.name,
|
|
212
|
+
tasksFile: lane.path,
|
|
213
|
+
laneRunDir: laneRunDirs[lane.name]!,
|
|
214
|
+
executor: options.executor || 'cursor-agent',
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
running.set(lane.name, spawnResult);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. Wait for any running lane to finish
|
|
221
|
+
if (running.size > 0) {
|
|
222
|
+
// We need to wait for at least one to finish
|
|
223
|
+
const promises = Array.from(running.entries()).map(async ([name, { child }]) => {
|
|
224
|
+
const code = await waitChild(child);
|
|
225
|
+
return { name, code };
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const finished = await Promise.race(promises);
|
|
229
|
+
|
|
230
|
+
running.delete(finished.name);
|
|
231
|
+
exitCodes[finished.name] = finished.code;
|
|
232
|
+
|
|
233
|
+
if (finished.code === 0 || finished.code === 2) {
|
|
234
|
+
completedLanes.add(finished.name);
|
|
235
|
+
} else {
|
|
236
|
+
failedLanes.add(finished.name);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
printLaneStatus(lanes, laneRunDirs);
|
|
240
|
+
} else {
|
|
241
|
+
// Nothing running and nothing ready (but not all finished)
|
|
242
|
+
// This could happen if there's a circular dependency or some logic error
|
|
243
|
+
if (readyToStart.length === 0 && completedLanes.size + failedLanes.size < lanes.length) {
|
|
244
|
+
const remaining = lanes.filter(l => !completedLanes.has(l.name) && !failedLanes.has(l.name));
|
|
245
|
+
logger.error(`Deadlock detected! Remaining lanes cannot start: ${remaining.map(l => l.name).join(', ')}`);
|
|
246
|
+
for (const l of remaining) {
|
|
247
|
+
failedLanes.add(l.name);
|
|
248
|
+
exitCodes[l.name] = 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
177
252
|
}
|
|
178
253
|
|
|
179
254
|
clearInterval(monitorInterval);
|