@litmers/cursorflow-orchestrator 0.1.31 → 0.1.34
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/README.md +144 -52
- package/commands/cursorflow-add.md +159 -0
- package/commands/cursorflow-monitor.md +23 -2
- package/commands/cursorflow-new.md +87 -0
- package/dist/cli/add.d.ts +7 -0
- package/dist/cli/add.js +377 -0
- package/dist/cli/add.js.map +1 -0
- package/dist/cli/clean.js +1 -0
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/config.d.ts +7 -0
- package/dist/cli/config.js +181 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/index.js +34 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/logs.js +7 -33
- package/dist/cli/logs.js.map +1 -1
- package/dist/cli/monitor.js +51 -62
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/new.d.ts +7 -0
- package/dist/cli/new.js +232 -0
- package/dist/cli/new.js.map +1 -0
- package/dist/cli/prepare.js +95 -193
- package/dist/cli/prepare.js.map +1 -1
- package/dist/cli/resume.js +11 -47
- package/dist/cli/resume.js.map +1 -1
- package/dist/cli/run.js +27 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/tasks.js +1 -2
- package/dist/cli/tasks.js.map +1 -1
- package/dist/core/failure-policy.d.ts +9 -0
- package/dist/core/failure-policy.js +9 -0
- package/dist/core/failure-policy.js.map +1 -1
- package/dist/core/orchestrator.d.ts +20 -6
- package/dist/core/orchestrator.js +213 -333
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/runner/agent.d.ts +27 -0
- package/dist/core/runner/agent.js +294 -0
- package/dist/core/runner/agent.js.map +1 -0
- package/dist/core/runner/index.d.ts +5 -0
- package/dist/core/runner/index.js +22 -0
- package/dist/core/runner/index.js.map +1 -0
- package/dist/core/runner/pipeline.d.ts +9 -0
- package/dist/core/runner/pipeline.js +539 -0
- package/dist/core/runner/pipeline.js.map +1 -0
- package/dist/core/runner/prompt.d.ts +25 -0
- package/dist/core/runner/prompt.js +175 -0
- package/dist/core/runner/prompt.js.map +1 -0
- package/dist/core/runner/task.d.ts +26 -0
- package/dist/core/runner/task.js +283 -0
- package/dist/core/runner/task.js.map +1 -0
- package/dist/core/runner/utils.d.ts +37 -0
- package/dist/core/runner/utils.js +161 -0
- package/dist/core/runner/utils.js.map +1 -0
- package/dist/core/runner.d.ts +2 -96
- package/dist/core/runner.js +11 -1136
- package/dist/core/runner.js.map +1 -1
- package/dist/core/stall-detection.d.ts +326 -0
- package/dist/core/stall-detection.js +781 -0
- package/dist/core/stall-detection.js.map +1 -0
- package/dist/types/config.d.ts +6 -6
- package/dist/types/flow.d.ts +84 -0
- package/dist/types/flow.js +10 -0
- package/dist/types/flow.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +3 -3
- package/dist/types/index.js.map +1 -1
- package/dist/types/lane.d.ts +0 -2
- package/dist/types/logging.d.ts +5 -1
- package/dist/types/task.d.ts +7 -11
- package/dist/utils/config.js +7 -15
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/dependency.d.ts +36 -1
- package/dist/utils/dependency.js +256 -1
- package/dist/utils/dependency.js.map +1 -1
- package/dist/utils/enhanced-logger.d.ts +45 -82
- package/dist/utils/enhanced-logger.js +238 -844
- package/dist/utils/enhanced-logger.js.map +1 -1
- package/dist/utils/git.d.ts +29 -0
- package/dist/utils/git.js +115 -5
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/state.js +0 -2
- package/dist/utils/state.js.map +1 -1
- package/dist/utils/task-service.d.ts +2 -2
- package/dist/utils/task-service.js +40 -31
- package/dist/utils/task-service.js.map +1 -1
- package/package.json +4 -3
- package/src/cli/add.ts +397 -0
- package/src/cli/clean.ts +1 -0
- package/src/cli/config.ts +177 -0
- package/src/cli/index.ts +36 -32
- package/src/cli/logs.ts +7 -31
- package/src/cli/monitor.ts +55 -71
- package/src/cli/new.ts +235 -0
- package/src/cli/prepare.ts +98 -205
- package/src/cli/resume.ts +13 -56
- package/src/cli/run.ts +311 -306
- package/src/cli/tasks.ts +1 -2
- package/src/core/failure-policy.ts +9 -0
- package/src/core/orchestrator.ts +277 -378
- package/src/core/runner/agent.ts +314 -0
- package/src/core/runner/index.ts +6 -0
- package/src/core/runner/pipeline.ts +567 -0
- package/src/core/runner/prompt.ts +174 -0
- package/src/core/runner/task.ts +320 -0
- package/src/core/runner/utils.ts +142 -0
- package/src/core/runner.ts +8 -1347
- package/src/core/stall-detection.ts +936 -0
- package/src/types/config.ts +6 -6
- package/src/types/flow.ts +91 -0
- package/src/types/index.ts +15 -3
- package/src/types/lane.ts +0 -2
- package/src/types/logging.ts +5 -1
- package/src/types/task.ts +7 -11
- package/src/utils/config.ts +8 -16
- package/src/utils/dependency.ts +311 -2
- package/src/utils/enhanced-logger.ts +263 -927
- package/src/utils/git.ts +145 -5
- package/src/utils/state.ts +0 -2
- package/src/utils/task-service.ts +48 -40
- package/commands/cursorflow-review.md +0 -56
- package/commands/cursorflow-runs.md +0 -59
- package/dist/cli/runs.d.ts +0 -5
- package/dist/cli/runs.js +0 -214
- package/dist/cli/runs.js.map +0 -1
- package/dist/core/reviewer.d.ts +0 -66
- package/dist/core/reviewer.js +0 -265
- package/dist/core/reviewer.js.map +0 -1
- package/src/cli/runs.ts +0 -212
- package/src/core/reviewer.ts +0 -285
package/src/core/runner.ts
CHANGED
|
@@ -1,1362 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core Runner - Execute tasks sequentially in a lane
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - Enhanced retry with circuit breaker
|
|
6
|
-
* - Checkpoint system for recovery
|
|
7
|
-
* - State validation and repair
|
|
8
|
-
* - Improved dependency management
|
|
4
|
+
* This file is now a wrapper around modular components in ./runner/
|
|
9
5
|
*/
|
|
10
6
|
|
|
11
7
|
import * as fs from 'fs';
|
|
12
8
|
import * as path from 'path';
|
|
13
|
-
import { spawn, spawnSync } from 'child_process';
|
|
14
9
|
|
|
15
|
-
import * as git from '../utils/git';
|
|
16
10
|
import * as logger from '../utils/logger';
|
|
17
|
-
import { ensureCursorAgent, checkCursorAuth, printAuthHelp } from '../utils/cursor-agent';
|
|
18
|
-
import { saveState, appendLog, createConversationEntry, loadState, validateLaneState, repairLaneState, stateNeedsRecovery } from '../utils/state';
|
|
19
|
-
import { events } from '../utils/events';
|
|
20
11
|
import { loadConfig } from '../utils/config';
|
|
21
12
|
import { registerWebhooks } from '../utils/webhook';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { analyzeFailure, RecoveryAction, logFailure, withRetry } from './failure-policy';
|
|
25
|
-
import { createCheckpoint, getLatestCheckpoint, restoreFromCheckpoint } from '../utils/checkpoint';
|
|
26
|
-
import { waitForTaskDependencies as waitForDeps, DependencyWaitOptions } from '../utils/dependency';
|
|
27
|
-
import { preflightCheck, printPreflightReport } from '../utils/health';
|
|
28
|
-
import {
|
|
29
|
-
RunnerConfig,
|
|
30
|
-
Task,
|
|
31
|
-
TaskExecutionResult,
|
|
32
|
-
AgentSendResult,
|
|
33
|
-
DependencyPolicy,
|
|
34
|
-
DependencyRequestPlan,
|
|
35
|
-
LaneState
|
|
36
|
-
} from '../types';
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Execute cursor-agent command with timeout and better error handling
|
|
40
|
-
*/
|
|
41
|
-
export function cursorAgentCreateChat(): string {
|
|
42
|
-
try {
|
|
43
|
-
const res = spawnSync('cursor-agent', ['create-chat'], {
|
|
44
|
-
encoding: 'utf8',
|
|
45
|
-
stdio: 'pipe',
|
|
46
|
-
timeout: 30000, // 30 second timeout
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
if (res.error || res.status !== 0) {
|
|
50
|
-
throw res.error || new Error(res.stderr || 'Failed to create chat');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const out = res.stdout;
|
|
54
|
-
const lines = out.split('\n').filter(Boolean);
|
|
55
|
-
const chatId = lines[lines.length - 1] || null;
|
|
56
|
-
|
|
57
|
-
if (!chatId) {
|
|
58
|
-
throw new Error('Failed to get chat ID from cursor-agent');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
logger.info(`Created chat session: ${chatId}`);
|
|
62
|
-
return chatId;
|
|
63
|
-
} catch (error: any) {
|
|
64
|
-
// Check for common errors
|
|
65
|
-
if (error.message.includes('ENOENT')) {
|
|
66
|
-
throw new Error('cursor-agent CLI not found. Install with: npm install -g @cursor/agent');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (error.message.includes('ETIMEDOUT') || error.killed) {
|
|
70
|
-
throw new Error('cursor-agent timed out. Check your internet connection and Cursor authentication.');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (error.stderr) {
|
|
74
|
-
const stderr = error.stderr.toString();
|
|
75
|
-
|
|
76
|
-
// Check for authentication errors
|
|
77
|
-
if (stderr.includes('not authenticated') ||
|
|
78
|
-
stderr.includes('login') ||
|
|
79
|
-
stderr.includes('auth')) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
'Cursor authentication failed. Please:\n' +
|
|
82
|
-
' 1. Open Cursor IDE\n' +
|
|
83
|
-
' 2. Sign in to your account\n' +
|
|
84
|
-
' 3. Verify you can use AI features\n' +
|
|
85
|
-
' 4. Try running cursorflow again\n\n' +
|
|
86
|
-
`Original error: ${stderr.trim()}`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Check for API key errors
|
|
91
|
-
if (stderr.includes('api key') || stderr.includes('API_KEY')) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
'Cursor API key error. Please check your Cursor account and subscription.\n' +
|
|
94
|
-
`Error: ${stderr.trim()}`
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
throw new Error(`cursor-agent error: ${stderr.trim()}`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
throw new Error(`Failed to create chat: ${error.message}`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function parseJsonFromStdout(stdout: string): any {
|
|
106
|
-
const text = String(stdout || '').trim();
|
|
107
|
-
if (!text) return null;
|
|
108
|
-
const lines = text.split('\n').filter(Boolean);
|
|
109
|
-
|
|
110
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
111
|
-
const line = lines[i]?.trim();
|
|
112
|
-
if (line?.startsWith('{') && line?.endsWith('}')) {
|
|
113
|
-
try {
|
|
114
|
-
return JSON.parse(line);
|
|
115
|
-
} catch {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Default timeout: 10 minutes */
|
|
124
|
-
const DEFAULT_TIMEOUT_MS = 600000;
|
|
125
|
-
|
|
126
|
-
/** Heartbeat interval: 30 seconds */
|
|
127
|
-
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Validate task configuration
|
|
131
|
-
* @throws Error if validation fails
|
|
132
|
-
*/
|
|
133
|
-
export function validateTaskConfig(config: RunnerConfig): void {
|
|
134
|
-
if (!config.tasks || !Array.isArray(config.tasks)) {
|
|
135
|
-
throw new Error('Invalid config: "tasks" must be an array');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (config.tasks.length === 0) {
|
|
139
|
-
throw new Error('Invalid config: "tasks" array is empty');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
for (let i = 0; i < config.tasks.length; i++) {
|
|
143
|
-
const task = config.tasks[i];
|
|
144
|
-
const taskNum = i + 1;
|
|
145
|
-
|
|
146
|
-
if (!task) {
|
|
147
|
-
throw new Error(`Invalid config: Task ${taskNum} is null or undefined`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (!task.name || typeof task.name !== 'string') {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`Invalid config: Task ${taskNum} missing required "name" field.\n` +
|
|
153
|
-
` Found: ${JSON.stringify(task, null, 2).substring(0, 200)}...\n` +
|
|
154
|
-
` Expected: { "name": "task-name", "prompt": "..." }`
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (!task.prompt || typeof task.prompt !== 'string') {
|
|
159
|
-
throw new Error(
|
|
160
|
-
`Invalid config: Task "${task.name}" (${taskNum}) missing required "prompt" field`
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Validate task name format (no spaces, special chars that could break branch names)
|
|
165
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(task.name)) {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`Invalid config: Task name "${task.name}" contains invalid characters.\n` +
|
|
168
|
-
` Task names must only contain: letters, numbers, underscore (_), hyphen (-)`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Validate timeout if provided
|
|
174
|
-
if (config.timeout !== undefined) {
|
|
175
|
-
if (typeof config.timeout !== 'number' || config.timeout <= 0) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
`Invalid config: "timeout" must be a positive number (milliseconds).\n` +
|
|
178
|
-
` Found: ${config.timeout}`
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Internal: Execute cursor-agent command with streaming
|
|
186
|
-
*/
|
|
187
|
-
async function cursorAgentSendRaw({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention, outputFormat, taskName }: {
|
|
188
|
-
workspaceDir: string;
|
|
189
|
-
chatId: string;
|
|
190
|
-
prompt: string;
|
|
191
|
-
model?: string;
|
|
192
|
-
signalDir?: string;
|
|
193
|
-
timeout?: number;
|
|
194
|
-
enableIntervention?: boolean;
|
|
195
|
-
outputFormat?: 'stream-json' | 'json' | 'plain';
|
|
196
|
-
taskName?: string;
|
|
197
|
-
}): Promise<AgentSendResult> {
|
|
198
|
-
// Use stream-json format for structured output with tool calls and results
|
|
199
|
-
const format = outputFormat || 'stream-json';
|
|
200
|
-
const args = [
|
|
201
|
-
'--print',
|
|
202
|
-
'--force',
|
|
203
|
-
'--approve-mcps',
|
|
204
|
-
'--output-format', format,
|
|
205
|
-
'--workspace', workspaceDir,
|
|
206
|
-
...(model ? ['--model', model] : []),
|
|
207
|
-
'--resume', chatId,
|
|
208
|
-
prompt,
|
|
209
|
-
];
|
|
210
|
-
|
|
211
|
-
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
212
|
-
|
|
213
|
-
// Determine stdio mode based on intervention setting
|
|
214
|
-
const stdinMode = enableIntervention ? 'pipe' : 'ignore';
|
|
215
|
-
|
|
216
|
-
return new Promise((resolve) => {
|
|
217
|
-
// Build environment, preserving user's NODE_OPTIONS but disabling problematic flags
|
|
218
|
-
const childEnv = { ...process.env };
|
|
219
|
-
|
|
220
|
-
if (childEnv.NODE_OPTIONS) {
|
|
221
|
-
const filtered = childEnv.NODE_OPTIONS
|
|
222
|
-
.split(' ')
|
|
223
|
-
.filter(opt => !opt.includes('--inspect') && !opt.includes('--debug'))
|
|
224
|
-
.join(' ');
|
|
225
|
-
childEnv.NODE_OPTIONS = filtered;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
childEnv.PYTHONUNBUFFERED = '1';
|
|
229
|
-
|
|
230
|
-
const child = spawn('cursor-agent', args, {
|
|
231
|
-
stdio: [stdinMode, 'pipe', 'pipe'],
|
|
232
|
-
env: childEnv,
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// Save PID to state if possible
|
|
236
|
-
if (child.pid && signalDir) {
|
|
237
|
-
try {
|
|
238
|
-
const statePath = safeJoin(signalDir, 'state.json');
|
|
239
|
-
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
240
|
-
state.pid = child.pid;
|
|
241
|
-
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
242
|
-
} catch {
|
|
243
|
-
// Best effort
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
let fullStdout = '';
|
|
248
|
-
let fullStderr = '';
|
|
249
|
-
let timeoutHandle: NodeJS.Timeout;
|
|
250
|
-
|
|
251
|
-
// Heartbeat logging
|
|
252
|
-
let lastHeartbeat = Date.now();
|
|
253
|
-
let bytesReceived = 0;
|
|
254
|
-
const startTime = Date.now();
|
|
255
|
-
const heartbeatInterval = setInterval(() => {
|
|
256
|
-
const totalElapsed = Math.round((Date.now() - startTime) / 1000);
|
|
257
|
-
// Output without timestamp - orchestrator will add it
|
|
258
|
-
console.log(`⏱ Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
|
|
259
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
260
|
-
|
|
261
|
-
// Signal watchers (intervention, timeout)
|
|
262
|
-
const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
|
|
263
|
-
const timeoutPath = signalDir ? path.join(signalDir, 'timeout.txt') : null;
|
|
264
|
-
let signalWatcher: fs.FSWatcher | null = null;
|
|
265
|
-
|
|
266
|
-
if (signalDir && fs.existsSync(signalDir)) {
|
|
267
|
-
signalWatcher = fs.watch(signalDir, (event, filename) => {
|
|
268
|
-
if (filename === 'intervention.txt' && interventionPath && fs.existsSync(interventionPath)) {
|
|
269
|
-
try {
|
|
270
|
-
const message = fs.readFileSync(interventionPath, 'utf8').trim();
|
|
271
|
-
if (message) {
|
|
272
|
-
if (enableIntervention && child.stdin) {
|
|
273
|
-
logger.info(`Injecting intervention: ${message}`);
|
|
274
|
-
child.stdin.write(message + '\n');
|
|
275
|
-
|
|
276
|
-
// Log to conversation history for visibility in monitor/logs
|
|
277
|
-
if (signalDir) {
|
|
278
|
-
const convoPath = path.join(signalDir, 'conversation.jsonl');
|
|
279
|
-
appendLog(convoPath, createConversationEntry('intervention', `[HUMAN INTERVENTION]: ${message}`, {
|
|
280
|
-
task: taskName || 'AGENT_TURN',
|
|
281
|
-
model: 'manual'
|
|
282
|
-
}));
|
|
283
|
-
}
|
|
284
|
-
} else {
|
|
285
|
-
logger.warn(`Intervention requested but stdin not available: ${message}`);
|
|
286
|
-
}
|
|
287
|
-
fs.unlinkSync(interventionPath);
|
|
288
|
-
}
|
|
289
|
-
} catch {}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (filename === 'timeout.txt' && timeoutPath && fs.existsSync(timeoutPath)) {
|
|
293
|
-
try {
|
|
294
|
-
const newTimeoutStr = fs.readFileSync(timeoutPath, 'utf8').trim();
|
|
295
|
-
const newTimeoutMs = parseInt(newTimeoutStr);
|
|
296
|
-
if (!isNaN(newTimeoutMs) && newTimeoutMs > 0) {
|
|
297
|
-
logger.info(`⏱ Dynamic timeout update: ${Math.round(newTimeoutMs / 1000)}s`);
|
|
298
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
299
|
-
const elapsed = Date.now() - startTime;
|
|
300
|
-
const remaining = Math.max(1000, newTimeoutMs - elapsed);
|
|
301
|
-
timeoutHandle = setTimeout(() => {
|
|
302
|
-
clearInterval(heartbeatInterval);
|
|
303
|
-
child.kill();
|
|
304
|
-
resolve({ ok: false, exitCode: -1, error: `cursor-agent timed out after updated limit.` });
|
|
305
|
-
}, remaining);
|
|
306
|
-
fs.unlinkSync(timeoutPath);
|
|
307
|
-
}
|
|
308
|
-
} catch {}
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (child.stdout) {
|
|
314
|
-
child.stdout.on('data', (data) => {
|
|
315
|
-
fullStdout += data.toString();
|
|
316
|
-
bytesReceived += data.length;
|
|
317
|
-
process.stdout.write(data);
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (child.stderr) {
|
|
322
|
-
child.stderr.on('data', (data) => {
|
|
323
|
-
fullStderr += data.toString();
|
|
324
|
-
process.stderr.write(data);
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
timeoutHandle = setTimeout(() => {
|
|
329
|
-
clearInterval(heartbeatInterval);
|
|
330
|
-
child.kill();
|
|
331
|
-
resolve({
|
|
332
|
-
ok: false,
|
|
333
|
-
exitCode: -1,
|
|
334
|
-
error: `cursor-agent timed out after ${Math.round(timeoutMs / 1000)} seconds.`,
|
|
335
|
-
});
|
|
336
|
-
}, timeoutMs);
|
|
337
|
-
|
|
338
|
-
child.on('close', (code) => {
|
|
339
|
-
clearTimeout(timeoutHandle);
|
|
340
|
-
clearInterval(heartbeatInterval);
|
|
341
|
-
if (signalWatcher) signalWatcher.close();
|
|
342
|
-
|
|
343
|
-
const json = parseJsonFromStdout(fullStdout);
|
|
344
|
-
|
|
345
|
-
if (code !== 0 || !json || json.type !== 'result') {
|
|
346
|
-
let errorMsg = fullStderr.trim() || fullStdout.trim() || `exit=${code}`;
|
|
347
|
-
resolve({ ok: false, exitCode: code ?? -1, error: errorMsg });
|
|
348
|
-
} else {
|
|
349
|
-
resolve({
|
|
350
|
-
ok: !json.is_error,
|
|
351
|
-
exitCode: code ?? 0,
|
|
352
|
-
sessionId: json.session_id || chatId,
|
|
353
|
-
resultText: json.result || '',
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
child.on('error', (err) => {
|
|
359
|
-
clearTimeout(timeoutHandle);
|
|
360
|
-
clearInterval(heartbeatInterval);
|
|
361
|
-
resolve({ ok: false, exitCode: -1, error: `Failed to start cursor-agent: ${err.message}` });
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Execute cursor-agent command with retries for transient errors
|
|
368
|
-
*/
|
|
369
|
-
export async function cursorAgentSend(options: {
|
|
370
|
-
workspaceDir: string;
|
|
371
|
-
chatId: string;
|
|
372
|
-
prompt: string;
|
|
373
|
-
model?: string;
|
|
374
|
-
signalDir?: string;
|
|
375
|
-
timeout?: number;
|
|
376
|
-
enableIntervention?: boolean;
|
|
377
|
-
outputFormat?: 'stream-json' | 'json' | 'plain';
|
|
378
|
-
taskName?: string;
|
|
379
|
-
}): Promise<AgentSendResult> {
|
|
380
|
-
const laneName = options.signalDir ? path.basename(path.dirname(options.signalDir)) : 'agent';
|
|
381
|
-
|
|
382
|
-
return withRetry(
|
|
383
|
-
laneName,
|
|
384
|
-
() => cursorAgentSendRaw(options),
|
|
385
|
-
(res) => ({ ok: res.ok, error: res.error }),
|
|
386
|
-
{ maxRetries: 3 }
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Extract dependency change request from agent response
|
|
392
|
-
*/
|
|
393
|
-
export function extractDependencyRequest(text: string): { required: boolean; plan?: DependencyRequestPlan; raw: string } {
|
|
394
|
-
const t = String(text || '');
|
|
395
|
-
const marker = 'DEPENDENCY_CHANGE_REQUIRED';
|
|
396
|
-
|
|
397
|
-
if (!t.includes(marker)) {
|
|
398
|
-
return { required: false, raw: t };
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const after = t.split(marker).slice(1).join(marker);
|
|
402
|
-
const match = after.match(/\{[\s\S]*?\}/);
|
|
403
|
-
|
|
404
|
-
if (match) {
|
|
405
|
-
try {
|
|
406
|
-
return {
|
|
407
|
-
required: true,
|
|
408
|
-
plan: JSON.parse(match[0]!) as DependencyRequestPlan,
|
|
409
|
-
raw: t,
|
|
410
|
-
};
|
|
411
|
-
} catch {
|
|
412
|
-
return { required: true, raw: t };
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return { required: true, raw: t };
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Inter-task state file name
|
|
421
|
-
*/
|
|
422
|
-
const LANE_STATE_FILE = '_cursorflow/lane-state.json';
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Dependency request file name - agent writes here when dependency changes are needed
|
|
426
|
-
*/
|
|
427
|
-
const DEPENDENCY_REQUEST_FILE = '_cursorflow/dependency-request.json';
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Read dependency request from file if it exists
|
|
431
|
-
*/
|
|
432
|
-
export function readDependencyRequestFile(worktreeDir: string): { required: boolean; plan?: DependencyRequestPlan } {
|
|
433
|
-
const filePath = safeJoin(worktreeDir, DEPENDENCY_REQUEST_FILE);
|
|
434
|
-
|
|
435
|
-
if (!fs.existsSync(filePath)) {
|
|
436
|
-
return { required: false };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
try {
|
|
440
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
441
|
-
const plan = JSON.parse(content) as DependencyRequestPlan;
|
|
442
|
-
|
|
443
|
-
// Validate required fields
|
|
444
|
-
if (plan.reason && Array.isArray(plan.commands) && plan.commands.length > 0) {
|
|
445
|
-
logger.info(`📦 Dependency request file detected: ${filePath}`);
|
|
446
|
-
return { required: true, plan };
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
logger.warn(`Invalid dependency request file format: ${filePath}`);
|
|
450
|
-
return { required: false };
|
|
451
|
-
} catch (e) {
|
|
452
|
-
logger.warn(`Failed to parse dependency request file: ${e}`);
|
|
453
|
-
return { required: false };
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Clear dependency request file after processing
|
|
459
|
-
*/
|
|
460
|
-
export function clearDependencyRequestFile(worktreeDir: string): void {
|
|
461
|
-
const filePath = safeJoin(worktreeDir, DEPENDENCY_REQUEST_FILE);
|
|
462
|
-
|
|
463
|
-
if (fs.existsSync(filePath)) {
|
|
464
|
-
try {
|
|
465
|
-
fs.unlinkSync(filePath);
|
|
466
|
-
logger.info(`🗑️ Cleared dependency request file: ${filePath}`);
|
|
467
|
-
} catch (e) {
|
|
468
|
-
logger.warn(`Failed to clear dependency request file: ${e}`);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Wrap prompt with dependency policy instructions (legacy, used by tests)
|
|
475
|
-
*/
|
|
476
|
-
export function wrapPromptForDependencyPolicy(prompt: string, policy: DependencyPolicy): string {
|
|
477
|
-
if (policy.allowDependencyChange && !policy.lockfileReadOnly) {
|
|
478
|
-
return prompt;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
let wrapped = `### 📦 Dependency Policy\n`;
|
|
482
|
-
wrapped += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
|
|
483
|
-
wrapped += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n\n`;
|
|
484
|
-
wrapped += prompt;
|
|
485
|
-
|
|
486
|
-
return wrapped;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Wrap prompt with global context, dependency policy, and worktree instructions
|
|
491
|
-
*/
|
|
492
|
-
export function wrapPrompt(
|
|
493
|
-
prompt: string,
|
|
494
|
-
config: RunnerConfig,
|
|
495
|
-
options: {
|
|
496
|
-
noGit?: boolean;
|
|
497
|
-
isWorktree?: boolean;
|
|
498
|
-
previousState?: string | null;
|
|
499
|
-
} = {}
|
|
500
|
-
): string {
|
|
501
|
-
const { noGit = false, isWorktree = true, previousState = null } = options;
|
|
502
|
-
|
|
503
|
-
// 1. PREFIX: Environment & Worktree context
|
|
504
|
-
let wrapped = `### 🛠 Environment & Context\n`;
|
|
505
|
-
wrapped += `- **Workspace**: 당신은 독립된 **Git 워크트리** (프로젝트 루트)에서 작업 중입니다.\n`;
|
|
506
|
-
wrapped += `- **Path Rule**: 모든 파일 참조 및 터미널 명령어는 **현재 디렉토리(./)**를 기준으로 하세요.\n`;
|
|
507
|
-
|
|
508
|
-
if (isWorktree) {
|
|
509
|
-
wrapped += `- **File Availability**: Git 추적 파일만 존재합니다. (node_modules, .env 등은 기본적으로 없음)\n`;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// 2. Previous Task State (if available)
|
|
513
|
-
if (previousState) {
|
|
514
|
-
wrapped += `\n### 💡 Previous Task State\n`;
|
|
515
|
-
wrapped += `이전 태스크에서 전달된 상태 정보입니다:\n`;
|
|
516
|
-
wrapped += `\`\`\`json\n${previousState}\n\`\`\`\n`;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// 3. Dependency Policy (Integrated)
|
|
520
|
-
const policy = config.dependencyPolicy;
|
|
521
|
-
wrapped += `\n### 📦 Dependency Policy\n`;
|
|
522
|
-
wrapped += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
|
|
523
|
-
wrapped += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n`;
|
|
524
|
-
|
|
525
|
-
if (noGit) {
|
|
526
|
-
wrapped += `- NO_GIT_MODE: Git 명령어를 사용하지 마세요. 파일 수정만 가능합니다.\n`;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
wrapped += `\n**📦 Dependency Change Rules:**\n`;
|
|
530
|
-
wrapped += `1. 코드를 수정하기 전, 의존성 변경이 필요한지 **먼저** 판단하세요.\n`;
|
|
531
|
-
wrapped += `2. 의존성 변경이 필요하다면:\n`;
|
|
532
|
-
wrapped += ` - **다른 파일을 절대 수정하지 마세요.**\n`;
|
|
533
|
-
wrapped += ` - 아래 JSON을 \`./${DEPENDENCY_REQUEST_FILE}\` 파일에 저장하세요:\n`;
|
|
534
|
-
wrapped += ` \`\`\`json\n`;
|
|
535
|
-
wrapped += ` {\n`;
|
|
536
|
-
wrapped += ` "reason": "왜 이 의존성이 필요한지 설명",\n`;
|
|
537
|
-
wrapped += ` "changes": ["add lodash@^4.17.21", "remove unused-pkg"],\n`;
|
|
538
|
-
wrapped += ` "commands": ["pnpm add lodash@^4.17.21", "pnpm remove unused-pkg"],\n`;
|
|
539
|
-
wrapped += ` "notes": "추가 참고사항 (선택)" \n`;
|
|
540
|
-
wrapped += ` }\n`;
|
|
541
|
-
wrapped += ` \`\`\`\n`;
|
|
542
|
-
wrapped += ` - 파일 저장 후 **즉시 작업을 종료**하세요. 오케스트레이터가 처리합니다.\n`;
|
|
543
|
-
wrapped += `3. 의존성 변경이 불필요하면 바로 본 작업을 진행하세요.\n`;
|
|
544
|
-
|
|
545
|
-
wrapped += `\n---\n\n${prompt}\n\n---\n`;
|
|
546
|
-
|
|
547
|
-
// 4. SUFFIX: Task Completion & Git Requirements
|
|
548
|
-
wrapped += `\n### 📝 Task Completion Requirements\n`;
|
|
549
|
-
wrapped += `**반드시 다음 순서로 작업을 마무리하세요:**\n\n`;
|
|
550
|
-
|
|
551
|
-
if (!noGit) {
|
|
552
|
-
wrapped += `1. **Git Commit & Push** (필수!):\n`;
|
|
553
|
-
wrapped += ` \`\`\`bash\n`;
|
|
554
|
-
wrapped += ` git add -A\n`;
|
|
555
|
-
wrapped += ` git commit -m "feat: <작업 내용 요약>"\n`;
|
|
556
|
-
wrapped += ` git push origin HEAD\n`;
|
|
557
|
-
wrapped += ` \`\`\`\n`;
|
|
558
|
-
wrapped += ` ⚠️ 커밋과 푸시 없이 작업을 종료하면 변경사항이 손실됩니다!\n\n`;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
wrapped += `2. **State Passing**: 다음 태스크로 전달할 정보가 있다면 \`./${LANE_STATE_FILE}\`에 JSON으로 저장하세요.\n\n`;
|
|
562
|
-
wrapped += `3. **Summary**: 작업 완료 후 다음을 요약해 주세요:\n`;
|
|
563
|
-
wrapped += ` - 생성/수정된 파일 목록\n`;
|
|
564
|
-
wrapped += ` - 주요 변경 사항\n`;
|
|
565
|
-
wrapped += ` - 커밋 해시 (git log --oneline -1)\n\n`;
|
|
566
|
-
wrapped += `4. 지시된 문서(docs/...)를 찾을 수 없다면 즉시 보고하세요.\n`;
|
|
567
|
-
|
|
568
|
-
return wrapped;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Apply file permissions based on dependency policy
|
|
573
|
-
*/
|
|
574
|
-
export function applyDependencyFilePermissions(worktreeDir: string, policy: DependencyPolicy): void {
|
|
575
|
-
const targets: string[] = [];
|
|
576
|
-
|
|
577
|
-
if (!policy.allowDependencyChange) {
|
|
578
|
-
targets.push('package.json');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (policy.lockfileReadOnly) {
|
|
582
|
-
targets.push('pnpm-lock.yaml', 'package-lock.json', 'yarn.lock');
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
for (const file of targets) {
|
|
586
|
-
const filePath = safeJoin(worktreeDir, file);
|
|
587
|
-
if (!fs.existsSync(filePath)) continue;
|
|
588
|
-
|
|
589
|
-
try {
|
|
590
|
-
const stats = fs.statSync(filePath);
|
|
591
|
-
const mode = stats.mode & 0o777;
|
|
592
|
-
fs.chmodSync(filePath, mode & ~0o222); // Remove write bits
|
|
593
|
-
} catch {
|
|
594
|
-
// Best effort
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Wait for task-level dependencies to be completed by other lanes
|
|
601
|
-
* Now uses the enhanced dependency module with timeout support
|
|
602
|
-
*/
|
|
603
|
-
export async function waitForTaskDependencies(
|
|
604
|
-
deps: string[],
|
|
605
|
-
runDir: string,
|
|
606
|
-
options: DependencyWaitOptions = {}
|
|
607
|
-
): Promise<void> {
|
|
608
|
-
if (!deps || deps.length === 0) return;
|
|
609
|
-
|
|
610
|
-
const lanesRoot = path.dirname(runDir);
|
|
611
|
-
|
|
612
|
-
const result = await waitForDeps(deps, lanesRoot, {
|
|
613
|
-
timeoutMs: options.timeoutMs || 30 * 60 * 1000, // 30 minutes default
|
|
614
|
-
pollIntervalMs: options.pollIntervalMs || 5000,
|
|
615
|
-
onTimeout: options.onTimeout || 'fail',
|
|
616
|
-
onProgress: (pending, completed) => {
|
|
617
|
-
if (completed.length > 0) {
|
|
618
|
-
logger.info(`Dependencies progress: ${completed.length}/${deps.length} completed`);
|
|
619
|
-
}
|
|
620
|
-
},
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
if (!result.success) {
|
|
624
|
-
if (result.timedOut) {
|
|
625
|
-
throw new Error(`Dependency wait timed out after ${Math.round(result.elapsedMs / 1000)}s. Pending: ${result.failedDependencies.join(', ')}`);
|
|
626
|
-
}
|
|
627
|
-
throw new Error(`Dependencies failed: ${result.failedDependencies.join(', ')}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Merge branches from dependency lanes with safe merge
|
|
633
|
-
*/
|
|
634
|
-
export async function mergeDependencyBranches(deps: string[], runDir: string, worktreeDir: string): Promise<void> {
|
|
635
|
-
if (!deps || deps.length === 0) return;
|
|
636
|
-
|
|
637
|
-
const lanesRoot = path.dirname(runDir);
|
|
638
|
-
const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
|
|
639
|
-
|
|
640
|
-
for (const laneName of lanesToMerge) {
|
|
641
|
-
const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
|
|
642
|
-
if (!fs.existsSync(depStatePath)) continue;
|
|
643
|
-
|
|
644
|
-
try {
|
|
645
|
-
const state = loadState<LaneState>(depStatePath);
|
|
646
|
-
if (!state?.pipelineBranch) continue;
|
|
647
|
-
|
|
648
|
-
logger.info(`Merging branch from ${laneName}: ${state.pipelineBranch}`);
|
|
649
|
-
|
|
650
|
-
// Ensure we have the latest
|
|
651
|
-
git.runGit(['fetch', 'origin', state.pipelineBranch], { cwd: worktreeDir, silent: true });
|
|
652
|
-
|
|
653
|
-
// Use safe merge with conflict detection
|
|
654
|
-
const mergeResult = git.safeMerge(state.pipelineBranch, {
|
|
655
|
-
cwd: worktreeDir,
|
|
656
|
-
noFf: true,
|
|
657
|
-
message: `chore: merge task dependency from ${laneName}`,
|
|
658
|
-
abortOnConflict: true,
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
if (!mergeResult.success) {
|
|
662
|
-
if (mergeResult.conflict) {
|
|
663
|
-
logger.error(`Merge conflict with ${laneName}: ${mergeResult.conflictingFiles.join(', ')}`);
|
|
664
|
-
throw new Error(`Merge conflict: ${mergeResult.conflictingFiles.join(', ')}`);
|
|
665
|
-
}
|
|
666
|
-
throw new Error(mergeResult.error || 'Merge failed');
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
logger.success(`✓ Merged ${laneName}`);
|
|
670
|
-
} catch (e) {
|
|
671
|
-
logger.error(`Failed to merge branch from ${laneName}: ${e}`);
|
|
672
|
-
throw e;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Run a single task
|
|
679
|
-
*/
|
|
680
|
-
export async function runTask({
|
|
681
|
-
task,
|
|
682
|
-
config,
|
|
683
|
-
index,
|
|
684
|
-
worktreeDir,
|
|
685
|
-
pipelineBranch,
|
|
686
|
-
taskBranch,
|
|
687
|
-
chatId,
|
|
688
|
-
runDir,
|
|
689
|
-
noGit = false,
|
|
690
|
-
}: {
|
|
691
|
-
task: Task;
|
|
692
|
-
config: RunnerConfig;
|
|
693
|
-
index: number;
|
|
694
|
-
worktreeDir: string;
|
|
695
|
-
pipelineBranch: string;
|
|
696
|
-
taskBranch: string;
|
|
697
|
-
chatId: string;
|
|
698
|
-
runDir: string;
|
|
699
|
-
noGit?: boolean;
|
|
700
|
-
}): Promise<TaskExecutionResult> {
|
|
701
|
-
const model = task.model || config.model || 'sonnet-4.5';
|
|
702
|
-
const timeout = task.timeout || config.timeout;
|
|
703
|
-
const convoPath = safeJoin(runDir, 'conversation.jsonl');
|
|
704
|
-
|
|
705
|
-
logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
|
|
706
|
-
logger.info(`Model: ${model}`);
|
|
707
|
-
if (noGit) {
|
|
708
|
-
logger.info('🚫 noGit mode: skipping branch operations');
|
|
709
|
-
} else {
|
|
710
|
-
logger.info(`Branch: ${taskBranch}`);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
events.emit('task.started', {
|
|
714
|
-
taskName: task.name,
|
|
715
|
-
taskBranch,
|
|
716
|
-
index,
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
// Checkout task branch (skip in noGit mode)
|
|
720
|
-
if (!noGit) {
|
|
721
|
-
git.runGit(['checkout', '-B', taskBranch], { cwd: worktreeDir });
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Apply dependency permissions
|
|
725
|
-
applyDependencyFilePermissions(worktreeDir, config.dependencyPolicy);
|
|
726
|
-
|
|
727
|
-
// Read previous task state if available
|
|
728
|
-
let previousState: string | null = null;
|
|
729
|
-
const stateFilePath = safeJoin(worktreeDir, LANE_STATE_FILE);
|
|
730
|
-
if (fs.existsSync(stateFilePath)) {
|
|
731
|
-
try {
|
|
732
|
-
previousState = fs.readFileSync(stateFilePath, 'utf8');
|
|
733
|
-
logger.info('Loaded previous task state from _cursorflow/lane-state.json');
|
|
734
|
-
} catch (e) {
|
|
735
|
-
logger.warn(`Failed to read inter-task state: ${e}`);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// Wrap prompt with context, previous state, and completion instructions
|
|
740
|
-
const wrappedPrompt = wrapPrompt(task.prompt, config, {
|
|
741
|
-
noGit,
|
|
742
|
-
isWorktree: !noGit,
|
|
743
|
-
previousState
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
// Log ONLY the original prompt to keep logs clean
|
|
747
|
-
appendLog(convoPath, createConversationEntry('user', task.prompt, {
|
|
748
|
-
task: task.name,
|
|
749
|
-
model,
|
|
750
|
-
}));
|
|
751
|
-
|
|
752
|
-
logger.info('Sending prompt to agent...');
|
|
753
|
-
const startTime = Date.now();
|
|
754
|
-
events.emit('agent.prompt_sent', {
|
|
755
|
-
taskName: task.name,
|
|
756
|
-
model,
|
|
757
|
-
promptLength: wrappedPrompt.length,
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
const r1 = await cursorAgentSend({
|
|
761
|
-
workspaceDir: worktreeDir,
|
|
762
|
-
chatId,
|
|
763
|
-
prompt: wrappedPrompt,
|
|
764
|
-
model,
|
|
765
|
-
signalDir: runDir,
|
|
766
|
-
timeout,
|
|
767
|
-
enableIntervention: config.enableIntervention,
|
|
768
|
-
outputFormat: config.agentOutputFormat,
|
|
769
|
-
taskName: task.name,
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
const duration = Date.now() - startTime;
|
|
773
|
-
events.emit('agent.response_received', {
|
|
774
|
-
taskName: task.name,
|
|
775
|
-
ok: r1.ok,
|
|
776
|
-
duration,
|
|
777
|
-
responseLength: r1.resultText?.length || 0,
|
|
778
|
-
error: r1.error,
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
appendLog(convoPath, createConversationEntry('assistant', r1.resultText || r1.error || 'No response', {
|
|
782
|
-
task: task.name,
|
|
783
|
-
model,
|
|
784
|
-
}));
|
|
785
|
-
|
|
786
|
-
if (!r1.ok) {
|
|
787
|
-
events.emit('task.failed', {
|
|
788
|
-
taskName: task.name,
|
|
789
|
-
taskBranch,
|
|
790
|
-
error: r1.error,
|
|
791
|
-
});
|
|
792
|
-
return {
|
|
793
|
-
taskName: task.name,
|
|
794
|
-
taskBranch,
|
|
795
|
-
status: 'ERROR',
|
|
796
|
-
error: r1.error,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Check for dependency request (file-based takes priority, then text-based)
|
|
801
|
-
const fileDepReq = readDependencyRequestFile(worktreeDir);
|
|
802
|
-
const textDepReq = extractDependencyRequest(r1.resultText || '');
|
|
803
|
-
|
|
804
|
-
// Determine which request to use (file-based is preferred as it's more structured)
|
|
805
|
-
const depReq = fileDepReq.required ? fileDepReq : textDepReq;
|
|
806
|
-
|
|
807
|
-
if (depReq.required) {
|
|
808
|
-
logger.info(`📦 Dependency change requested: ${depReq.plan?.reason || 'No reason provided'}`);
|
|
809
|
-
|
|
810
|
-
if (depReq.plan) {
|
|
811
|
-
logger.info(` Commands: ${depReq.plan.commands.join(', ')}`);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
if (!config.dependencyPolicy.allowDependencyChange) {
|
|
815
|
-
// Clear the file so it doesn't persist after resolution
|
|
816
|
-
clearDependencyRequestFile(worktreeDir);
|
|
817
|
-
|
|
818
|
-
return {
|
|
819
|
-
taskName: task.name,
|
|
820
|
-
taskBranch,
|
|
821
|
-
status: 'BLOCKED_DEPENDENCY',
|
|
822
|
-
dependencyRequest: depReq.plan || null,
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// Push task branch (skip in noGit mode)
|
|
828
|
-
if (!noGit) {
|
|
829
|
-
git.push(taskBranch, { cwd: worktreeDir, setUpstream: true });
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Automatic Review
|
|
833
|
-
const reviewEnabled = config.reviewAllTasks || task.acceptanceCriteria?.length || config.enableReview;
|
|
834
|
-
|
|
835
|
-
if (reviewEnabled) {
|
|
836
|
-
logger.section(`🔍 Reviewing Task: ${task.name}`);
|
|
837
|
-
const reviewResult = await runReviewLoop({
|
|
838
|
-
taskResult: {
|
|
839
|
-
taskName: task.name,
|
|
840
|
-
taskBranch: taskBranch,
|
|
841
|
-
acceptanceCriteria: task.acceptanceCriteria,
|
|
842
|
-
},
|
|
843
|
-
worktreeDir,
|
|
844
|
-
runDir,
|
|
845
|
-
config,
|
|
846
|
-
workChatId: chatId,
|
|
847
|
-
model, // Use the same model as requested
|
|
848
|
-
cursorAgentSend,
|
|
849
|
-
cursorAgentCreateChat,
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
if (!reviewResult.approved) {
|
|
853
|
-
logger.error(`❌ Task review failed after ${reviewResult.iterations} iterations`);
|
|
854
|
-
return {
|
|
855
|
-
taskName: task.name,
|
|
856
|
-
taskBranch,
|
|
857
|
-
status: 'ERROR',
|
|
858
|
-
error: reviewResult.error || 'Task failed to pass review criteria',
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
events.emit('task.completed', {
|
|
864
|
-
taskName: task.name,
|
|
865
|
-
taskBranch,
|
|
866
|
-
status: 'FINISHED',
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
return {
|
|
870
|
-
taskName: task.name,
|
|
871
|
-
taskBranch,
|
|
872
|
-
status: 'FINISHED',
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* Run all tasks in sequence
|
|
878
|
-
*/
|
|
879
|
-
export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number; noGit?: boolean; skipPreflight?: boolean } = {}): Promise<TaskExecutionResult[]> {
|
|
880
|
-
const startIndex = options.startIndex || 0;
|
|
881
|
-
const noGit = options.noGit || config.noGit || false;
|
|
882
|
-
|
|
883
|
-
if (noGit) {
|
|
884
|
-
logger.info('🚫 Running in noGit mode - Git operations will be skipped');
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// Validate configuration before starting
|
|
888
|
-
logger.info('Validating task configuration...');
|
|
889
|
-
try {
|
|
890
|
-
validateTaskConfig(config);
|
|
891
|
-
logger.success('✓ Configuration valid');
|
|
892
|
-
} catch (validationError: any) {
|
|
893
|
-
logger.error('❌ Configuration validation failed');
|
|
894
|
-
logger.error(` ${validationError.message}`);
|
|
895
|
-
throw validationError;
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Run preflight checks (can be skipped for resume)
|
|
899
|
-
if (!options.skipPreflight && startIndex === 0) {
|
|
900
|
-
logger.info('Running preflight checks...');
|
|
901
|
-
const preflight = await preflightCheck({
|
|
902
|
-
requireRemote: !noGit,
|
|
903
|
-
requireAuth: true,
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
if (!preflight.canProceed) {
|
|
907
|
-
printPreflightReport(preflight);
|
|
908
|
-
throw new Error('Preflight check failed. Please fix the blockers above.');
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
if (preflight.warnings.length > 0) {
|
|
912
|
-
for (const warning of preflight.warnings) {
|
|
913
|
-
logger.warn(`⚠️ ${warning}`);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
logger.success('✓ Preflight checks passed');
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// Warn if baseBranch is set in config (it will be ignored)
|
|
921
|
-
if (config.baseBranch) {
|
|
922
|
-
logger.warn(`⚠️ config.baseBranch="${config.baseBranch}" will be ignored. Using current branch instead.`);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// Ensure cursor-agent is installed
|
|
926
|
-
ensureCursorAgent();
|
|
927
|
-
|
|
928
|
-
// Check authentication before starting
|
|
929
|
-
logger.info('Checking Cursor authentication...');
|
|
930
|
-
const authStatus = checkCursorAuth();
|
|
931
|
-
|
|
932
|
-
if (!authStatus.authenticated) {
|
|
933
|
-
logger.error('❌ Cursor authentication failed');
|
|
934
|
-
logger.error(` ${authStatus.message}`);
|
|
935
|
-
|
|
936
|
-
if (authStatus.details) {
|
|
937
|
-
logger.error(` Details: ${authStatus.details}`);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
if (authStatus.help) {
|
|
941
|
-
logger.error(` ${authStatus.help}`);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
console.log('');
|
|
945
|
-
printAuthHelp();
|
|
946
|
-
|
|
947
|
-
throw new Error('Cursor authentication required. Please authenticate and try again.');
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
logger.success('✓ Cursor authentication OK');
|
|
951
|
-
|
|
952
|
-
// In noGit mode, we don't need repoRoot - use current directory
|
|
953
|
-
const repoRoot = noGit ? process.cwd() : git.getMainRepoRoot();
|
|
954
|
-
|
|
955
|
-
// ALWAYS use current branch as base - ignore config.baseBranch
|
|
956
|
-
// This ensures dependency structure is maintained in the worktree
|
|
957
|
-
const currentBranch = noGit ? 'main' : git.getCurrentBranch(repoRoot);
|
|
958
|
-
logger.info(`📍 Base branch: ${currentBranch} (current branch)`);
|
|
959
|
-
|
|
960
|
-
// Load existing state if resuming
|
|
961
|
-
const statePath = safeJoin(runDir, 'state.json');
|
|
962
|
-
let state: LaneState | null = null;
|
|
963
|
-
|
|
964
|
-
if (fs.existsSync(statePath)) {
|
|
965
|
-
// Check if state needs recovery
|
|
966
|
-
if (stateNeedsRecovery(statePath)) {
|
|
967
|
-
logger.warn('State file indicates incomplete previous run. Attempting recovery...');
|
|
968
|
-
const repairedState = repairLaneState(statePath);
|
|
969
|
-
if (repairedState) {
|
|
970
|
-
state = repairedState;
|
|
971
|
-
logger.success('✓ State recovered');
|
|
972
|
-
} else {
|
|
973
|
-
logger.warn('Could not recover state. Starting fresh.');
|
|
974
|
-
}
|
|
975
|
-
} else {
|
|
976
|
-
state = loadState<LaneState>(statePath);
|
|
977
|
-
|
|
978
|
-
// Validate loaded state
|
|
979
|
-
if (state) {
|
|
980
|
-
const validation = validateLaneState(statePath, {
|
|
981
|
-
checkWorktree: !noGit,
|
|
982
|
-
checkBranch: !noGit,
|
|
983
|
-
autoRepair: true,
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
if (!validation.valid) {
|
|
987
|
-
logger.warn(`State validation issues: ${validation.issues.join(', ')}`);
|
|
988
|
-
if (validation.repaired) {
|
|
989
|
-
logger.info('State was auto-repaired');
|
|
990
|
-
state = validation.repairedState || state;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
|
998
|
-
const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
|
|
999
|
-
|
|
1000
|
-
// In noGit mode, use a simple local directory instead of worktree
|
|
1001
|
-
// Flatten the path by replacing slashes with hyphens to avoid race conditions in parent directory creation
|
|
1002
|
-
const worktreeDir = state?.worktreeDir || config.worktreeDir || (noGit
|
|
1003
|
-
? safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
|
|
1004
|
-
: safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/worktrees', pipelineBranch.replace(/\//g, '-')));
|
|
1005
|
-
|
|
1006
|
-
if (startIndex === 0) {
|
|
1007
|
-
logger.section('🚀 Starting Pipeline');
|
|
1008
|
-
} else {
|
|
1009
|
-
logger.section(`🔁 Resuming Pipeline from task ${startIndex + 1}`);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
logger.info(`Pipeline Branch: ${pipelineBranch}`);
|
|
1013
|
-
logger.info(`Worktree: ${worktreeDir}`);
|
|
1014
|
-
logger.info(`Tasks: ${config.tasks.length}`);
|
|
1015
|
-
|
|
1016
|
-
// Create worktree only if starting fresh and worktree doesn't exist
|
|
1017
|
-
if (!fs.existsSync(worktreeDir)) {
|
|
1018
|
-
if (noGit) {
|
|
1019
|
-
// In noGit mode, just create the directory
|
|
1020
|
-
logger.info(`Creating work directory: ${worktreeDir}`);
|
|
1021
|
-
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
1022
|
-
} else {
|
|
1023
|
-
// Use a simple retry mechanism for Git worktree creation to handle potential race conditions
|
|
1024
|
-
let retries = 3;
|
|
1025
|
-
let lastError: Error | null = null;
|
|
1026
|
-
|
|
1027
|
-
while (retries > 0) {
|
|
1028
|
-
try {
|
|
1029
|
-
// Ensure parent directory exists before calling git worktree
|
|
1030
|
-
const worktreeParent = path.dirname(worktreeDir);
|
|
1031
|
-
if (!fs.existsSync(worktreeParent)) {
|
|
1032
|
-
fs.mkdirSync(worktreeParent, { recursive: true });
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// Always use the current branch (already captured at start) as the base branch
|
|
1036
|
-
git.createWorktree(worktreeDir, pipelineBranch, {
|
|
1037
|
-
baseBranch: currentBranch,
|
|
1038
|
-
cwd: repoRoot,
|
|
1039
|
-
});
|
|
1040
|
-
break; // Success
|
|
1041
|
-
} catch (e: any) {
|
|
1042
|
-
lastError = e;
|
|
1043
|
-
retries--;
|
|
1044
|
-
if (retries > 0) {
|
|
1045
|
-
const delay = Math.floor(Math.random() * 1000) + 500;
|
|
1046
|
-
logger.warn(`Worktree creation failed, retrying in ${delay}ms... (${retries} retries left)`);
|
|
1047
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (retries === 0 && lastError) {
|
|
1053
|
-
throw new Error(`Failed to create Git worktree after retries: ${lastError.message}`);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
} else if (!noGit) {
|
|
1057
|
-
// If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
|
|
1058
|
-
logger.info(`Reusing existing worktree: ${worktreeDir}`);
|
|
1059
|
-
try {
|
|
1060
|
-
git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
|
|
1061
|
-
} catch (e) {
|
|
1062
|
-
// If checkout fails, maybe the worktree is in a weird state.
|
|
1063
|
-
// For now, just log it. In a more robust impl, we might want to repair it.
|
|
1064
|
-
logger.warn(`Failed to checkout branch ${pipelineBranch} in existing worktree: ${e}`);
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// Create chat
|
|
1069
|
-
logger.info('Creating chat session...');
|
|
1070
|
-
const chatId = cursorAgentCreateChat();
|
|
1071
|
-
|
|
1072
|
-
// Initialize state if not loaded
|
|
1073
|
-
if (!state) {
|
|
1074
|
-
state = {
|
|
1075
|
-
status: 'running',
|
|
1076
|
-
pipelineBranch,
|
|
1077
|
-
worktreeDir,
|
|
1078
|
-
totalTasks: config.tasks.length,
|
|
1079
|
-
currentTaskIndex: 0,
|
|
1080
|
-
label: pipelineBranch,
|
|
1081
|
-
startTime: Date.now(),
|
|
1082
|
-
endTime: null,
|
|
1083
|
-
error: null,
|
|
1084
|
-
dependencyRequest: null,
|
|
1085
|
-
tasksFile, // Store tasks file for resume
|
|
1086
|
-
dependsOn: config.dependsOn || [],
|
|
1087
|
-
completedTasks: [],
|
|
1088
|
-
};
|
|
1089
|
-
} else {
|
|
1090
|
-
state.status = 'running';
|
|
1091
|
-
state.error = null;
|
|
1092
|
-
state.dependencyRequest = null;
|
|
1093
|
-
state.pipelineBranch = pipelineBranch;
|
|
1094
|
-
state.worktreeDir = worktreeDir;
|
|
1095
|
-
state.label = state.label || pipelineBranch;
|
|
1096
|
-
state.dependsOn = config.dependsOn || [];
|
|
1097
|
-
state.completedTasks = state.completedTasks || [];
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
saveState(statePath, state);
|
|
1101
|
-
|
|
1102
|
-
// Merge dependencies if any (skip in noGit mode)
|
|
1103
|
-
if (!noGit && startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
|
|
1104
|
-
logger.section('🔗 Merging Dependencies');
|
|
1105
|
-
|
|
1106
|
-
// The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
|
|
1107
|
-
const lanesRoot = path.dirname(runDir);
|
|
1108
|
-
|
|
1109
|
-
for (const depName of config.dependsOn) {
|
|
1110
|
-
const depRunDir = path.join(lanesRoot, depName); // nosemgrep
|
|
1111
|
-
const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
|
|
1112
|
-
|
|
1113
|
-
if (!fs.existsSync(depStatePath)) {
|
|
1114
|
-
logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
|
|
1115
|
-
continue;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
try {
|
|
1119
|
-
const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
|
|
1120
|
-
if (depState.status !== 'completed') {
|
|
1121
|
-
logger.warn(`Dependency ${depName} is in status ${depState.status}, merge might be incomplete`);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
if (depState.pipelineBranch) {
|
|
1125
|
-
logger.info(`Merging dependency branch: ${depState.pipelineBranch} (${depName})`);
|
|
1126
|
-
|
|
1127
|
-
// Fetch first to ensure we have the branch
|
|
1128
|
-
git.runGit(['fetch', 'origin', depState.pipelineBranch], { cwd: worktreeDir, silent: true });
|
|
1129
|
-
|
|
1130
|
-
// Merge
|
|
1131
|
-
git.merge(depState.pipelineBranch, {
|
|
1132
|
-
cwd: worktreeDir,
|
|
1133
|
-
noFf: true,
|
|
1134
|
-
message: `chore: merge dependency ${depName} (${depState.pipelineBranch})`
|
|
1135
|
-
});
|
|
1136
|
-
|
|
1137
|
-
// Log changed files
|
|
1138
|
-
const stats = git.getLastOperationStats(worktreeDir);
|
|
1139
|
-
if (stats) {
|
|
1140
|
-
logger.info('Changed files:\n' + stats);
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
} catch (e) {
|
|
1144
|
-
logger.error(`Failed to merge dependency ${depName}: ${e}`);
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Push the merged state
|
|
1149
|
-
git.push(pipelineBranch, { cwd: worktreeDir });
|
|
1150
|
-
} else if (noGit && startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
|
|
1151
|
-
logger.info('⚠️ Dependencies specified but Git is disabled - copying files instead of merging');
|
|
1152
|
-
|
|
1153
|
-
// The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
|
|
1154
|
-
const lanesRoot = path.dirname(runDir);
|
|
1155
|
-
|
|
1156
|
-
for (const depName of config.dependsOn) {
|
|
1157
|
-
const depRunDir = safeJoin(lanesRoot, depName);
|
|
1158
|
-
const depStatePath = safeJoin(depRunDir, 'state.json');
|
|
1159
|
-
|
|
1160
|
-
if (!fs.existsSync(depStatePath)) {
|
|
1161
|
-
continue;
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
try {
|
|
1165
|
-
const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
|
|
1166
|
-
if (depState.worktreeDir && fs.existsSync(depState.worktreeDir)) {
|
|
1167
|
-
logger.info(`Copying files from dependency ${depName}: ${depState.worktreeDir} → ${worktreeDir}`);
|
|
1168
|
-
|
|
1169
|
-
// Use a simple recursive copy (excluding Git and internal dirs)
|
|
1170
|
-
const copyFiles = (src: string, dest: string) => {
|
|
1171
|
-
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
1172
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
1173
|
-
|
|
1174
|
-
for (const entry of entries) {
|
|
1175
|
-
if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
|
|
1176
|
-
|
|
1177
|
-
const srcPath = safeJoin(src, entry.name);
|
|
1178
|
-
const destPath = safeJoin(dest, entry.name);
|
|
1179
|
-
|
|
1180
|
-
if (entry.isDirectory()) {
|
|
1181
|
-
copyFiles(srcPath, destPath);
|
|
1182
|
-
} else {
|
|
1183
|
-
fs.copyFileSync(srcPath, destPath);
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
};
|
|
1187
|
-
|
|
1188
|
-
copyFiles(depState.worktreeDir, worktreeDir);
|
|
1189
|
-
}
|
|
1190
|
-
} catch (e) {
|
|
1191
|
-
logger.error(`Failed to copy dependency ${depName}: ${e}`);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
// Run tasks
|
|
1197
|
-
const results: TaskExecutionResult[] = [];
|
|
1198
|
-
const laneName = state.label || path.basename(runDir);
|
|
1199
|
-
|
|
1200
|
-
for (let i = startIndex; i < config.tasks.length; i++) {
|
|
1201
|
-
const task = config.tasks[i]!;
|
|
1202
|
-
const taskBranch = `${pipelineBranch}--${String(i + 1).padStart(2, '0')}-${task.name}`;
|
|
1203
|
-
|
|
1204
|
-
// Create checkpoint before each task
|
|
1205
|
-
try {
|
|
1206
|
-
await createCheckpoint(laneName, runDir, noGit ? null : worktreeDir, {
|
|
1207
|
-
description: `Before task ${i + 1}: ${task.name}`,
|
|
1208
|
-
maxCheckpoints: 5,
|
|
1209
|
-
});
|
|
1210
|
-
} catch (e: any) {
|
|
1211
|
-
logger.warn(`Failed to create checkpoint: ${e.message}`);
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// Handle task-level dependencies
|
|
1215
|
-
if (task.dependsOn && task.dependsOn.length > 0) {
|
|
1216
|
-
state.status = 'waiting';
|
|
1217
|
-
state.waitingFor = task.dependsOn;
|
|
1218
|
-
saveState(statePath, state);
|
|
1219
|
-
|
|
1220
|
-
try {
|
|
1221
|
-
// Use enhanced dependency wait with timeout
|
|
1222
|
-
await waitForTaskDependencies(task.dependsOn, runDir, {
|
|
1223
|
-
timeoutMs: config.timeout || 30 * 60 * 1000,
|
|
1224
|
-
onTimeout: 'fail',
|
|
1225
|
-
});
|
|
1226
|
-
|
|
1227
|
-
if (!noGit) {
|
|
1228
|
-
await mergeDependencyBranches(task.dependsOn, runDir, worktreeDir);
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
state.status = 'running';
|
|
1232
|
-
state.waitingFor = [];
|
|
1233
|
-
saveState(statePath, state);
|
|
1234
|
-
} catch (e: any) {
|
|
1235
|
-
state.status = 'failed';
|
|
1236
|
-
state.waitingFor = [];
|
|
1237
|
-
state.error = e.message;
|
|
1238
|
-
saveState(statePath, state);
|
|
1239
|
-
logger.error(`Task dependency wait/merge failed: ${e.message}`);
|
|
1240
|
-
|
|
1241
|
-
// Try to restore from checkpoint
|
|
1242
|
-
const latestCheckpoint = getLatestCheckpoint(runDir);
|
|
1243
|
-
if (latestCheckpoint) {
|
|
1244
|
-
logger.info(`💾 Checkpoint available: ${latestCheckpoint.id}`);
|
|
1245
|
-
logger.info(` Resume with: cursorflow resume --checkpoint ${latestCheckpoint.id}`);
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
process.exit(1);
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
const result = await runTask({
|
|
1253
|
-
task,
|
|
1254
|
-
config,
|
|
1255
|
-
index: i,
|
|
1256
|
-
worktreeDir,
|
|
1257
|
-
pipelineBranch,
|
|
1258
|
-
taskBranch,
|
|
1259
|
-
chatId,
|
|
1260
|
-
runDir,
|
|
1261
|
-
noGit,
|
|
1262
|
-
});
|
|
1263
|
-
|
|
1264
|
-
results.push(result);
|
|
1265
|
-
|
|
1266
|
-
// Update state
|
|
1267
|
-
state.currentTaskIndex = i + 1;
|
|
1268
|
-
state.completedTasks = state.completedTasks || [];
|
|
1269
|
-
if (!state.completedTasks.includes(task.name)) {
|
|
1270
|
-
state.completedTasks.push(task.name);
|
|
1271
|
-
}
|
|
1272
|
-
saveState(statePath, state);
|
|
1273
|
-
|
|
1274
|
-
// Handle blocked or error
|
|
1275
|
-
if (result.status === 'BLOCKED_DEPENDENCY') {
|
|
1276
|
-
state.status = 'failed';
|
|
1277
|
-
state.dependencyRequest = result.dependencyRequest || null;
|
|
1278
|
-
saveState(statePath, state);
|
|
1279
|
-
|
|
1280
|
-
if (result.dependencyRequest) {
|
|
1281
|
-
events.emit('lane.dependency_requested', {
|
|
1282
|
-
laneName: state.label,
|
|
1283
|
-
dependencyRequest: result.dependencyRequest,
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
logger.warn('Task blocked on dependency change');
|
|
1288
|
-
process.exit(2);
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
if (result.status !== 'FINISHED') {
|
|
1292
|
-
state.status = 'failed';
|
|
1293
|
-
state.error = result.error || 'Unknown error';
|
|
1294
|
-
saveState(statePath, state);
|
|
1295
|
-
logger.error(`Task failed: ${result.error}`);
|
|
1296
|
-
process.exit(1);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// Merge into pipeline (skip in noGit mode)
|
|
1300
|
-
if (!noGit) {
|
|
1301
|
-
logger.info(`Merging ${taskBranch} → ${pipelineBranch}`);
|
|
1302
|
-
git.merge(taskBranch, { cwd: worktreeDir, noFf: true });
|
|
13
|
+
import { events } from '../utils/events';
|
|
14
|
+
import { RunnerConfig } from '../types';
|
|
1303
15
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
if (stats) {
|
|
1307
|
-
logger.info('Changed files:\n' + stats);
|
|
1308
|
-
}
|
|
16
|
+
// Re-export everything from modular components
|
|
17
|
+
export * from './runner/index';
|
|
1309
18
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
logger.info(`✓ Task ${task.name} completed (noGit mode - no branch operations)`);
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
// Complete
|
|
1317
|
-
state.status = 'completed';
|
|
1318
|
-
state.endTime = Date.now();
|
|
1319
|
-
saveState(statePath, state);
|
|
1320
|
-
|
|
1321
|
-
// Log final file summary
|
|
1322
|
-
if (noGit) {
|
|
1323
|
-
const getFileSummary = (dir: string): { files: number; dirs: number } => {
|
|
1324
|
-
let stats = { files: 0, dirs: 0 };
|
|
1325
|
-
if (!fs.existsSync(dir)) return stats;
|
|
1326
|
-
|
|
1327
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1328
|
-
for (const entry of entries) {
|
|
1329
|
-
if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
|
|
1330
|
-
|
|
1331
|
-
if (entry.isDirectory()) {
|
|
1332
|
-
stats.dirs++;
|
|
1333
|
-
const sub = getFileSummary(safeJoin(dir, entry.name));
|
|
1334
|
-
stats.files += sub.files;
|
|
1335
|
-
stats.dirs += sub.dirs;
|
|
1336
|
-
} else {
|
|
1337
|
-
stats.files++;
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
return stats;
|
|
1341
|
-
};
|
|
1342
|
-
|
|
1343
|
-
const summary = getFileSummary(worktreeDir);
|
|
1344
|
-
logger.info(`Final Workspace Summary (noGit): ${summary.files} files, ${summary.dirs} directories created/modified`);
|
|
1345
|
-
} else {
|
|
1346
|
-
try {
|
|
1347
|
-
// Always use current branch for comparison (already captured at start)
|
|
1348
|
-
const stats = git.runGit(['diff', '--stat', currentBranch, pipelineBranch], { cwd: repoRoot, silent: true });
|
|
1349
|
-
if (stats) {
|
|
1350
|
-
logger.info('Final Workspace Summary (Git):\n' + stats);
|
|
1351
|
-
}
|
|
1352
|
-
} catch (e) {
|
|
1353
|
-
// Ignore
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
logger.success('All tasks completed!');
|
|
1358
|
-
return results;
|
|
1359
|
-
}
|
|
19
|
+
// Import necessary parts for the CLI entry point
|
|
20
|
+
import { runTasks } from './runner/pipeline';
|
|
1360
21
|
|
|
1361
22
|
/**
|
|
1362
23
|
* CLI entry point
|
|
@@ -1426,7 +87,7 @@ if (require.main === module) {
|
|
|
1426
87
|
};
|
|
1427
88
|
|
|
1428
89
|
// Add agent output format default
|
|
1429
|
-
config.agentOutputFormat = config.agentOutputFormat || globalConfig?.agentOutputFormat || '
|
|
90
|
+
config.agentOutputFormat = config.agentOutputFormat || globalConfig?.agentOutputFormat || 'json';
|
|
1430
91
|
|
|
1431
92
|
// Run tasks
|
|
1432
93
|
runTasks(tasksFile, config, runDir, { startIndex, noGit })
|