@litmers/cursorflow-orchestrator 0.1.6 → 0.1.9

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,80 @@ function parseJsonFromStdout(stdout: string): any {
107
107
  return null;
108
108
  }
109
109
 
110
- export function cursorAgentSend({ workspaceDir, chatId, prompt, model }: {
110
+ /** Default timeout: 5 minutes */
111
+ const DEFAULT_TIMEOUT_MS = 300000;
112
+
113
+ /** Heartbeat interval: 30 seconds */
114
+ const HEARTBEAT_INTERVAL_MS = 30000;
115
+
116
+ /**
117
+ * Validate task configuration
118
+ * @throws Error if validation fails
119
+ */
120
+ export function validateTaskConfig(config: RunnerConfig): void {
121
+ if (!config.tasks || !Array.isArray(config.tasks)) {
122
+ throw new Error('Invalid config: "tasks" must be an array');
123
+ }
124
+
125
+ if (config.tasks.length === 0) {
126
+ throw new Error('Invalid config: "tasks" array is empty');
127
+ }
128
+
129
+ for (let i = 0; i < config.tasks.length; i++) {
130
+ const task = config.tasks[i];
131
+ const taskNum = i + 1;
132
+
133
+ if (!task) {
134
+ throw new Error(`Invalid config: Task ${taskNum} is null or undefined`);
135
+ }
136
+
137
+ if (!task.name || typeof task.name !== 'string') {
138
+ throw new Error(
139
+ `Invalid config: Task ${taskNum} missing required "name" field.\n` +
140
+ ` Found: ${JSON.stringify(task, null, 2).substring(0, 200)}...\n` +
141
+ ` Expected: { "name": "task-name", "prompt": "..." }`
142
+ );
143
+ }
144
+
145
+ if (!task.prompt || typeof task.prompt !== 'string') {
146
+ throw new Error(
147
+ `Invalid config: Task "${task.name}" (${taskNum}) missing required "prompt" field`
148
+ );
149
+ }
150
+
151
+ // Validate task name format (no spaces, special chars that could break branch names)
152
+ if (!/^[a-zA-Z0-9_-]+$/.test(task.name)) {
153
+ throw new Error(
154
+ `Invalid config: Task name "${task.name}" contains invalid characters.\n` +
155
+ ` Task names must only contain: letters, numbers, underscore (_), hyphen (-)`
156
+ );
157
+ }
158
+ }
159
+
160
+ // Validate timeout if provided
161
+ if (config.timeout !== undefined) {
162
+ if (typeof config.timeout !== 'number' || config.timeout <= 0) {
163
+ throw new Error(
164
+ `Invalid config: "timeout" must be a positive number (milliseconds).\n` +
165
+ ` Found: ${config.timeout}`
166
+ );
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Execute cursor-agent command with streaming and better error handling
173
+ */
174
+ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention }: {
111
175
  workspaceDir: string;
112
176
  chatId: string;
113
177
  prompt: string;
114
178
  model?: string;
115
- }): AgentSendResult {
179
+ signalDir?: string;
180
+ timeout?: number;
181
+ /** Enable stdin piping for intervention feature (may cause buffering issues on some systems) */
182
+ enableIntervention?: boolean;
183
+ }): Promise<AgentSendResult> {
116
184
  const args = [
117
185
  '--print',
118
186
  '--output-format', 'json',
@@ -122,76 +190,166 @@ export function cursorAgentSend({ workspaceDir, chatId, prompt, model }: {
122
190
  prompt,
123
191
  ];
124
192
 
125
- logger.info('Executing cursor-agent...');
193
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
194
+ logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
126
195
 
127
- const res = spawnSync('cursor-agent', args, {
128
- encoding: 'utf8',
129
- stdio: 'pipe',
130
- timeout: 300000, // 5 minute timeout for LLM response
131
- });
196
+ // Determine stdio mode based on intervention setting
197
+ // When intervention is enabled, we pipe stdin for message injection
198
+ // When disabled (default), we ignore stdin to avoid buffering issues
199
+ const stdinMode = enableIntervention ? 'pipe' : 'ignore';
132
200
 
133
- // Check for timeout
134
- if (res.error) {
135
- if ((res.error as any).code === 'ETIMEDOUT') {
136
- return {
137
- ok: false,
138
- exitCode: -1,
139
- 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
- };
201
+ if (enableIntervention) {
202
+ logger.info('Intervention mode enabled (stdin piped)');
148
203
  }
149
204
 
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}`;
205
+ return new Promise((resolve) => {
206
+ // Build environment, preserving user's NODE_OPTIONS but disabling problematic flags
207
+ const childEnv = { ...process.env };
154
208
 
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}`;
209
+ // Only filter out specific problematic NODE_OPTIONS, don't clear entirely
210
+ if (childEnv.NODE_OPTIONS) {
211
+ // Remove flags that might interfere with cursor-agent
212
+ const filtered = childEnv.NODE_OPTIONS
213
+ .split(' ')
214
+ .filter(opt => !opt.includes('--inspect') && !opt.includes('--debug'))
215
+ .join(' ');
216
+ childEnv.NODE_OPTIONS = filtered;
165
217
  }
166
218
 
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
- }
219
+ // Disable Python buffering in case cursor-agent uses Python
220
+ childEnv.PYTHONUNBUFFERED = '1';
174
221
 
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}`;
222
+ const child = spawn('cursor-agent', args, {
223
+ stdio: [stdinMode, 'pipe', 'pipe'],
224
+ env: childEnv,
225
+ });
226
+
227
+ // Save PID to state if possible
228
+ if (child.pid && signalDir) {
229
+ try {
230
+ const statePath = path.join(signalDir, 'state.json');
231
+ if (fs.existsSync(statePath)) {
232
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
233
+ state.pid = child.pid;
234
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
235
+ }
236
+ } catch (e) {
237
+ // Best effort
238
+ }
180
239
  }
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
- };
240
+
241
+ let fullStdout = '';
242
+ let fullStderr = '';
243
+
244
+ // Heartbeat logging to show progress
245
+ let lastHeartbeat = Date.now();
246
+ let bytesReceived = 0;
247
+ const heartbeatInterval = setInterval(() => {
248
+ const elapsed = Math.round((Date.now() - lastHeartbeat) / 1000);
249
+ const totalElapsed = Math.round((Date.now() - startTime) / 1000);
250
+ logger.info(`⏱ Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
251
+ }, HEARTBEAT_INTERVAL_MS);
252
+ const startTime = Date.now();
253
+
254
+ // Watch for "intervention.txt" signal file if any
255
+ const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
256
+ let interventionWatcher: fs.FSWatcher | null = null;
257
+
258
+ if (interventionPath && fs.existsSync(path.dirname(interventionPath))) {
259
+ interventionWatcher = fs.watch(path.dirname(interventionPath), (event, filename) => {
260
+ if (filename === 'intervention.txt' && fs.existsSync(interventionPath)) {
261
+ try {
262
+ const message = fs.readFileSync(interventionPath, 'utf8').trim();
263
+ if (message) {
264
+ if (enableIntervention && child.stdin) {
265
+ logger.info(`Injecting intervention: ${message}`);
266
+ child.stdin.write(message + '\n');
267
+ } else {
268
+ logger.warn(`Intervention requested but stdin not available: ${message}`);
269
+ logger.warn('To enable intervention, set enableIntervention: true in config');
270
+ }
271
+ fs.unlinkSync(interventionPath); // Clear it
272
+ }
273
+ } catch (e) {
274
+ logger.warn('Failed to read intervention file');
275
+ }
276
+ }
277
+ });
278
+ }
279
+
280
+ if (child.stdout) {
281
+ child.stdout.on('data', (data) => {
282
+ const str = data.toString();
283
+ fullStdout += str;
284
+ bytesReceived += data.length;
285
+ // Also pipe to our own stdout so it goes to terminal.log
286
+ process.stdout.write(data);
287
+ });
288
+ }
289
+
290
+ if (child.stderr) {
291
+ child.stderr.on('data', (data) => {
292
+ fullStderr += data.toString();
293
+ // Pipe to our own stderr so it goes to terminal.log
294
+ process.stderr.write(data);
295
+ });
296
+ }
297
+
298
+ const timeoutHandle = setTimeout(() => {
299
+ clearInterval(heartbeatInterval);
300
+ child.kill();
301
+ const timeoutSec = Math.round(timeoutMs / 1000);
302
+ resolve({
303
+ ok: false,
304
+ exitCode: -1,
305
+ error: `cursor-agent timed out after ${timeoutSec} seconds. The LLM request may be taking too long or there may be network issues.`,
306
+ });
307
+ }, timeoutMs);
308
+
309
+ child.on('close', (code) => {
310
+ clearTimeout(timeoutHandle);
311
+ clearInterval(heartbeatInterval);
312
+ if (interventionWatcher) interventionWatcher.close();
313
+
314
+ const json = parseJsonFromStdout(fullStdout);
315
+
316
+ if (code !== 0 || !json || json.type !== 'result') {
317
+ let errorMsg = fullStderr.trim() || fullStdout.trim() || `exit=${code}`;
318
+
319
+ // Check for common errors
320
+ if (errorMsg.includes('not authenticated') || errorMsg.includes('login') || errorMsg.includes('auth')) {
321
+ errorMsg = 'Authentication error. Please sign in to Cursor IDE.';
322
+ } else if (errorMsg.includes('rate limit') || errorMsg.includes('quota')) {
323
+ errorMsg = 'API rate limit or quota exceeded.';
324
+ } else if (errorMsg.includes('model')) {
325
+ errorMsg = `Model error (requested: ${model || 'default'}). Check your subscription.`;
326
+ }
327
+
328
+ resolve({
329
+ ok: false,
330
+ exitCode: code ?? -1,
331
+ error: errorMsg,
332
+ });
333
+ } else {
334
+ resolve({
335
+ ok: !json.is_error,
336
+ exitCode: code ?? 0,
337
+ sessionId: json.session_id || chatId,
338
+ resultText: json.result || '',
339
+ });
340
+ }
341
+ });
342
+
343
+ child.on('error', (err) => {
344
+ clearTimeout(timeoutHandle);
345
+ clearInterval(heartbeatInterval);
346
+ resolve({
347
+ ok: false,
348
+ exitCode: -1,
349
+ error: `Failed to start cursor-agent: ${err.message}`,
350
+ });
351
+ });
352
+ });
195
353
  }
196
354
 
197
355
  /**
@@ -326,11 +484,14 @@ export async function runTask({
326
484
  }));
327
485
 
328
486
  logger.info('Sending prompt to agent...');
329
- const r1 = cursorAgentSend({
487
+ const r1 = await cursorAgentSend({
330
488
  workspaceDir: worktreeDir,
331
489
  chatId,
332
490
  prompt: prompt1,
333
491
  model,
492
+ signalDir: runDir,
493
+ timeout: config.timeout,
494
+ enableIntervention: config.enableIntervention,
334
495
  });
335
496
 
336
497
  appendLog(convoPath, createConversationEntry('assistant', r1.resultText || r1.error || 'No response', {
@@ -374,6 +535,17 @@ export async function runTask({
374
535
  export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number } = {}): Promise<TaskExecutionResult[]> {
375
536
  const startIndex = options.startIndex || 0;
376
537
 
538
+ // Validate configuration before starting
539
+ logger.info('Validating task configuration...');
540
+ try {
541
+ validateTaskConfig(config);
542
+ logger.success('✓ Configuration valid');
543
+ } catch (validationError: any) {
544
+ logger.error('❌ Configuration validation failed');
545
+ logger.error(` ${validationError.message}`);
546
+ throw validationError;
547
+ }
548
+
377
549
  // Ensure cursor-agent is installed
378
550
  ensureCursorAgent();
379
551
 
@@ -426,7 +598,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
426
598
 
427
599
  // Create worktree only if starting fresh
428
600
  if (startIndex === 0 || !fs.existsSync(worktreeDir)) {
429
- git.createWorktree(worktreeDir, pipelineBranch, {
601
+ git.createWorktree(worktreeDir, pipelineBranch, {
430
602
  baseBranch: config.baseBranch || 'main',
431
603
  cwd: repoRoot,
432
604
  });
@@ -450,15 +622,61 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
450
622
  error: null,
451
623
  dependencyRequest: null,
452
624
  tasksFile, // Store tasks file for resume
625
+ dependsOn: config.dependsOn || [],
453
626
  };
454
627
  } else {
455
628
  state.status = 'running';
456
629
  state.error = null;
457
630
  state.dependencyRequest = null;
631
+ state.dependsOn = config.dependsOn || [];
458
632
  }
459
633
 
460
634
  saveState(statePath, state);
461
635
 
636
+ // Merge dependencies if any
637
+ if (startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
638
+ logger.section('🔗 Merging Dependencies');
639
+
640
+ // The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
641
+ const lanesRoot = path.dirname(runDir);
642
+
643
+ for (const depName of config.dependsOn) {
644
+ const depRunDir = path.join(lanesRoot, depName);
645
+ const depStatePath = path.join(depRunDir, 'state.json');
646
+
647
+ if (!fs.existsSync(depStatePath)) {
648
+ logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
649
+ continue;
650
+ }
651
+
652
+ try {
653
+ const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
654
+ if (depState.status !== 'completed') {
655
+ logger.warn(`Dependency ${depName} is in status ${depState.status}, merge might be incomplete`);
656
+ }
657
+
658
+ if (depState.pipelineBranch) {
659
+ logger.info(`Merging dependency branch: ${depState.pipelineBranch} (${depName})`);
660
+
661
+ // Fetch first to ensure we have the branch
662
+ git.runGit(['fetch', 'origin', depState.pipelineBranch], { cwd: worktreeDir, silent: true });
663
+
664
+ // Merge
665
+ git.merge(depState.pipelineBranch, {
666
+ cwd: worktreeDir,
667
+ noFf: true,
668
+ message: `chore: merge dependency ${depName} (${depState.pipelineBranch})`
669
+ });
670
+ }
671
+ } catch (e) {
672
+ logger.error(`Failed to merge dependency ${depName}: ${e}`);
673
+ }
674
+ }
675
+
676
+ // Push the merged state
677
+ git.push(pipelineBranch, { cwd: worktreeDir });
678
+ }
679
+
462
680
  // Run tasks
463
681
  const results: TaskExecutionResult[] = [];
464
682
 
@@ -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;
@@ -49,6 +50,14 @@ export interface RunnerConfig {
49
50
  reviewModel?: string;
50
51
  maxReviewIterations?: number;
51
52
  acceptanceCriteria?: string[];
53
+ /** Task execution timeout in milliseconds. Default: 300000 (5 minutes) */
54
+ timeout?: number;
55
+ /**
56
+ * Enable intervention feature (stdin piping for message injection).
57
+ * Warning: May cause stdout buffering issues on some systems.
58
+ * Default: false
59
+ */
60
+ enableIntervention?: boolean;
52
61
  }
53
62
 
54
63
  export interface DependencyRequestPlan {
@@ -109,6 +118,8 @@ export interface LaneState {
109
118
  dependencyRequest: DependencyRequestPlan | null;
110
119
  updatedAt?: number;
111
120
  tasksFile?: string; // Original tasks file path
121
+ dependsOn?: string[];
122
+ pid?: number;
112
123
  }
113
124
 
114
125
  export interface ConversationEntry {