@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/CHANGELOG.md +31 -0
- package/README.md +97 -321
- package/commands/cursorflow-doctor.md +28 -0
- package/commands/cursorflow-monitor.md +59 -101
- package/commands/cursorflow-prepare.md +25 -2
- package/commands/cursorflow-resume.md +11 -0
- package/commands/cursorflow-run.md +109 -100
- package/commands/cursorflow-signal.md +85 -14
- package/dist/cli/clean.d.ts +3 -1
- package/dist/cli/clean.js +122 -8
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/index.js +20 -20
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/monitor.d.ts +1 -1
- package/dist/cli/monitor.js +678 -145
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/run.js +1 -0
- package/dist/cli/run.js.map +1 -1
- 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 +14 -2
- package/dist/core/runner.js +244 -58
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/types.d.ts +11 -0
- package/package.json +1 -1
- package/scripts/patches/test-cursor-agent.js +203 -0
- package/src/cli/clean.ts +129 -9
- package/src/cli/index.ts +20 -20
- package/src/cli/monitor.ts +732 -185
- package/src/cli/run.ts +1 -0
- package/src/core/orchestrator.ts +102 -27
- package/src/core/runner.ts +284 -66
- package/src/utils/types.ts +11 -0
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
|
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);
|
package/src/core/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
+
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
194
|
+
logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
|
|
126
195
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
//
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
'
|
|
162
|
-
|
|
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
|
-
//
|
|
168
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
package/src/utils/types.ts
CHANGED
|
@@ -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 {
|