@litmers/cursorflow-orchestrator 0.1.6 → 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/src/cli/run.ts CHANGED
@@ -99,6 +99,7 @@ async function run(args: string[]): Promise<void> {
99
99
  executor: options.executor || config.executor,
100
100
  pollInterval: config.pollInterval * 1000,
101
101
  runDir: path.join(logsDir, 'runs', `run-${Date.now()}`),
102
+ maxConcurrentLanes: config.maxConcurrentLanes,
102
103
  });
103
104
  } catch (error: any) {
104
105
  // Re-throw to be handled by the main entry point
@@ -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
- name: path.basename(f, '.json'),
92
- path: path.join(tasksDir, f),
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
- return { lane: lane.name, status: '(no state)', task: '-' };
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
- // Spawn all lanes
153
- const running: { lane: string; child: ChildProcess; logPath: string }[] = [];
154
-
155
- for (const lane of lanes) {
156
- const { child, logPath } = spawnLane({
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
- // Wait for all lanes
173
- const exitCodes: Record<string, number> = {};
174
-
175
- for (const r of running) {
176
- exitCodes[r.lane] = await waitChild(r.child);
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);
@@ -6,7 +6,7 @@
6
6
 
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { execSync, spawnSync } from 'child_process';
9
+ import { execSync, spawn, spawnSync } from 'child_process';
10
10
 
11
11
  import * as git from '../utils/git';
12
12
  import * as logger from '../utils/logger';
@@ -107,12 +107,16 @@ function parseJsonFromStdout(stdout: string): any {
107
107
  return null;
108
108
  }
109
109
 
110
- export function cursorAgentSend({ workspaceDir, chatId, prompt, model }: {
110
+ /**
111
+ * Execute cursor-agent command with streaming and better error handling
112
+ */
113
+ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, signalDir }: {
111
114
  workspaceDir: string;
112
115
  chatId: string;
113
116
  prompt: string;
114
117
  model?: string;
115
- }): AgentSendResult {
118
+ signalDir?: string;
119
+ }): Promise<AgentSendResult> {
116
120
  const args = [
117
121
  '--print',
118
122
  '--output-format', 'json',
@@ -124,74 +128,100 @@ export function cursorAgentSend({ workspaceDir, chatId, prompt, model }: {
124
128
 
125
129
  logger.info('Executing cursor-agent...');
126
130
 
127
- const res = spawnSync('cursor-agent', args, {
128
- encoding: 'utf8',
129
- stdio: 'pipe',
130
- timeout: 300000, // 5 minute timeout for LLM response
131
- });
132
-
133
- // Check for timeout
134
- if (res.error) {
135
- if ((res.error as any).code === 'ETIMEDOUT') {
136
- return {
131
+ return new Promise((resolve) => {
132
+ const child = spawn('cursor-agent', args, {
133
+ stdio: ['pipe', 'pipe', 'pipe'], // Enable stdin piping
134
+ env: process.env,
135
+ });
136
+
137
+ let fullStdout = '';
138
+ let fullStderr = '';
139
+
140
+ // Watch for "intervention.txt" signal file if any
141
+ const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
142
+ let interventionWatcher: fs.FSWatcher | null = null;
143
+
144
+ if (interventionPath && fs.existsSync(path.dirname(interventionPath))) {
145
+ interventionWatcher = fs.watch(path.dirname(interventionPath), (event, filename) => {
146
+ if (filename === 'intervention.txt' && fs.existsSync(interventionPath)) {
147
+ try {
148
+ const message = fs.readFileSync(interventionPath, 'utf8').trim();
149
+ if (message) {
150
+ logger.info(`Injecting intervention: ${message}`);
151
+ child.stdin.write(message + '\n');
152
+ fs.unlinkSync(interventionPath); // Clear it
153
+ }
154
+ } catch (e) {
155
+ logger.warn('Failed to read intervention file');
156
+ }
157
+ }
158
+ });
159
+ }
160
+
161
+ child.stdout.on('data', (data) => {
162
+ const str = data.toString();
163
+ fullStdout += str;
164
+ // Also pipe to our own stdout so it goes to terminal.log
165
+ process.stdout.write(data);
166
+ });
167
+
168
+ child.stderr.on('data', (data) => {
169
+ fullStderr += data.toString();
170
+ // Pipe to our own stderr so it goes to terminal.log
171
+ process.stderr.write(data);
172
+ });
173
+
174
+ const timeout = setTimeout(() => {
175
+ child.kill();
176
+ resolve({
137
177
  ok: false,
138
178
  exitCode: -1,
139
179
  error: 'cursor-agent timed out after 5 minutes. The LLM request may be taking too long or there may be network issues.',
140
- };
141
- }
142
-
143
- return {
144
- ok: false,
145
- exitCode: -1,
146
- error: `cursor-agent error: ${res.error.message}`,
147
- };
148
- }
149
-
150
- const json = parseJsonFromStdout(res.stdout);
151
-
152
- if (res.status !== 0 || !json || json.type !== 'result') {
153
- let errorMsg = res.stderr?.trim() || res.stdout?.trim() || `exit=${res.status}`;
154
-
155
- // Check for authentication errors
156
- if (errorMsg.includes('not authenticated') ||
157
- errorMsg.includes('login') ||
158
- errorMsg.includes('auth')) {
159
- errorMsg = 'Authentication error. Please:\n' +
160
- ' 1. Open Cursor IDE\n' +
161
- ' 2. Sign in to your account\n' +
162
- ' 3. Verify AI features are working\n' +
163
- ' 4. Try again\n\n' +
164
- `Details: ${errorMsg}`;
165
- }
166
-
167
- // Check for rate limit errors
168
- if (errorMsg.includes('rate limit') || errorMsg.includes('quota')) {
169
- errorMsg = 'API rate limit or quota exceeded. Please:\n' +
170
- ' 1. Check your Cursor subscription\n' +
171
- ' 2. Wait a few minutes and try again\n\n' +
172
- `Details: ${errorMsg}`;
173
- }
174
-
175
- // Check for model errors
176
- if (errorMsg.includes('model')) {
177
- errorMsg = `Model error (requested: ${model || 'default'}). ` +
178
- 'Please check if the model is available in your Cursor subscription.\n\n' +
179
- `Details: ${errorMsg}`;
180
- }
181
-
182
- return {
183
- ok: false,
184
- exitCode: res.status ?? -1,
185
- error: errorMsg,
186
- };
187
- }
188
-
189
- return {
190
- ok: !json.is_error,
191
- exitCode: res.status ?? 0,
192
- sessionId: json.session_id || chatId,
193
- resultText: json.result || '',
194
- };
180
+ });
181
+ }, 300000);
182
+
183
+ child.on('close', (code) => {
184
+ clearTimeout(timeout);
185
+ if (interventionWatcher) interventionWatcher.close();
186
+
187
+ const json = parseJsonFromStdout(fullStdout);
188
+
189
+ if (code !== 0 || !json || json.type !== 'result') {
190
+ let errorMsg = fullStderr.trim() || fullStdout.trim() || `exit=${code}`;
191
+
192
+ // Check for common errors
193
+ if (errorMsg.includes('not authenticated') || errorMsg.includes('login') || errorMsg.includes('auth')) {
194
+ errorMsg = 'Authentication error. Please sign in to Cursor IDE.';
195
+ } else if (errorMsg.includes('rate limit') || errorMsg.includes('quota')) {
196
+ errorMsg = 'API rate limit or quota exceeded.';
197
+ } else if (errorMsg.includes('model')) {
198
+ errorMsg = `Model error (requested: ${model || 'default'}). Check your subscription.`;
199
+ }
200
+
201
+ resolve({
202
+ ok: false,
203
+ exitCode: code ?? -1,
204
+ error: errorMsg,
205
+ });
206
+ } else {
207
+ resolve({
208
+ ok: !json.is_error,
209
+ exitCode: code ?? 0,
210
+ sessionId: json.session_id || chatId,
211
+ resultText: json.result || '',
212
+ });
213
+ }
214
+ });
215
+
216
+ child.on('error', (err) => {
217
+ clearTimeout(timeout);
218
+ resolve({
219
+ ok: false,
220
+ exitCode: -1,
221
+ error: `Failed to start cursor-agent: ${err.message}`,
222
+ });
223
+ });
224
+ });
195
225
  }
196
226
 
197
227
  /**
@@ -326,11 +356,12 @@ export async function runTask({
326
356
  }));
327
357
 
328
358
  logger.info('Sending prompt to agent...');
329
- const r1 = cursorAgentSend({
359
+ const r1 = await cursorAgentSend({
330
360
  workspaceDir: worktreeDir,
331
361
  chatId,
332
362
  prompt: prompt1,
333
363
  model,
364
+ signalDir: runDir
334
365
  });
335
366
 
336
367
  appendLog(convoPath, createConversationEntry('assistant', r1.resultText || r1.error || 'No response', {
@@ -426,7 +457,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
426
457
 
427
458
  // Create worktree only if starting fresh
428
459
  if (startIndex === 0 || !fs.existsSync(worktreeDir)) {
429
- git.createWorktree(worktreeDir, pipelineBranch, {
460
+ git.createWorktree(worktreeDir, pipelineBranch, {
430
461
  baseBranch: config.baseBranch || 'main',
431
462
  cwd: repoRoot,
432
463
  });
@@ -450,15 +481,61 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
450
481
  error: null,
451
482
  dependencyRequest: null,
452
483
  tasksFile, // Store tasks file for resume
484
+ dependsOn: config.dependsOn || [],
453
485
  };
454
486
  } else {
455
487
  state.status = 'running';
456
488
  state.error = null;
457
489
  state.dependencyRequest = null;
490
+ state.dependsOn = config.dependsOn || [];
458
491
  }
459
492
 
460
493
  saveState(statePath, state);
461
494
 
495
+ // Merge dependencies if any
496
+ if (startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
497
+ logger.section('🔗 Merging Dependencies');
498
+
499
+ // The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
500
+ const lanesRoot = path.dirname(runDir);
501
+
502
+ for (const depName of config.dependsOn) {
503
+ const depRunDir = path.join(lanesRoot, depName);
504
+ const depStatePath = path.join(depRunDir, 'state.json');
505
+
506
+ if (!fs.existsSync(depStatePath)) {
507
+ logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
508
+ continue;
509
+ }
510
+
511
+ try {
512
+ const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
513
+ if (depState.status !== 'completed') {
514
+ logger.warn(`Dependency ${depName} is in status ${depState.status}, merge might be incomplete`);
515
+ }
516
+
517
+ if (depState.pipelineBranch) {
518
+ logger.info(`Merging dependency branch: ${depState.pipelineBranch} (${depName})`);
519
+
520
+ // Fetch first to ensure we have the branch
521
+ git.runGit(['fetch', 'origin', depState.pipelineBranch], { cwd: worktreeDir, silent: true });
522
+
523
+ // Merge
524
+ git.merge(depState.pipelineBranch, {
525
+ cwd: worktreeDir,
526
+ noFf: true,
527
+ message: `chore: merge dependency ${depName} (${depState.pipelineBranch})`
528
+ });
529
+ }
530
+ } catch (e) {
531
+ logger.error(`Failed to merge dependency ${depName}: ${e}`);
532
+ }
533
+ }
534
+
535
+ // Push the merged state
536
+ git.push(pipelineBranch, { cwd: worktreeDir });
537
+ }
538
+
462
539
  // Run tasks
463
540
  const results: TaskExecutionResult[] = [];
464
541
 
@@ -40,6 +40,7 @@ export interface Task {
40
40
 
41
41
  export interface RunnerConfig {
42
42
  tasks: Task[];
43
+ dependsOn?: string[];
43
44
  pipelineBranch?: string;
44
45
  branchPrefix?: string;
45
46
  worktreeRoot?: string;
@@ -109,6 +110,7 @@ export interface LaneState {
109
110
  dependencyRequest: DependencyRequestPlan | null;
110
111
  updatedAt?: number;
111
112
  tasksFile?: string; // Original tasks file path
113
+ dependsOn?: string[];
112
114
  }
113
115
 
114
116
  export interface ConversationEntry {