@litmers/cursorflow-orchestrator 0.1.18 → 0.1.26

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.
Files changed (234) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +25 -7
  3. package/commands/cursorflow-clean.md +19 -0
  4. package/commands/cursorflow-runs.md +59 -0
  5. package/commands/cursorflow-stop.md +55 -0
  6. package/dist/cli/clean.js +178 -6
  7. package/dist/cli/clean.js.map +1 -1
  8. package/dist/cli/index.js +12 -1
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/init.js +8 -7
  11. package/dist/cli/init.js.map +1 -1
  12. package/dist/cli/logs.js +126 -77
  13. package/dist/cli/logs.js.map +1 -1
  14. package/dist/cli/monitor.d.ts +7 -0
  15. package/dist/cli/monitor.js +1021 -202
  16. package/dist/cli/monitor.js.map +1 -1
  17. package/dist/cli/prepare.js +39 -21
  18. package/dist/cli/prepare.js.map +1 -1
  19. package/dist/cli/resume.js +268 -163
  20. package/dist/cli/resume.js.map +1 -1
  21. package/dist/cli/run.js +11 -5
  22. package/dist/cli/run.js.map +1 -1
  23. package/dist/cli/runs.d.ts +5 -0
  24. package/dist/cli/runs.js +214 -0
  25. package/dist/cli/runs.js.map +1 -0
  26. package/dist/cli/setup-commands.js +0 -0
  27. package/dist/cli/signal.js +8 -8
  28. package/dist/cli/signal.js.map +1 -1
  29. package/dist/cli/stop.d.ts +5 -0
  30. package/dist/cli/stop.js +215 -0
  31. package/dist/cli/stop.js.map +1 -0
  32. package/dist/cli/tasks.d.ts +10 -0
  33. package/dist/cli/tasks.js +165 -0
  34. package/dist/cli/tasks.js.map +1 -0
  35. package/dist/core/auto-recovery.d.ts +212 -0
  36. package/dist/core/auto-recovery.js +737 -0
  37. package/dist/core/auto-recovery.js.map +1 -0
  38. package/dist/core/failure-policy.d.ts +156 -0
  39. package/dist/core/failure-policy.js +488 -0
  40. package/dist/core/failure-policy.js.map +1 -0
  41. package/dist/core/orchestrator.d.ts +16 -2
  42. package/dist/core/orchestrator.js +439 -105
  43. package/dist/core/orchestrator.js.map +1 -1
  44. package/dist/core/reviewer.d.ts +2 -0
  45. package/dist/core/reviewer.js +2 -0
  46. package/dist/core/reviewer.js.map +1 -1
  47. package/dist/core/runner.d.ts +33 -10
  48. package/dist/core/runner.js +374 -164
  49. package/dist/core/runner.js.map +1 -1
  50. package/dist/services/logging/buffer.d.ts +67 -0
  51. package/dist/services/logging/buffer.js +309 -0
  52. package/dist/services/logging/buffer.js.map +1 -0
  53. package/dist/services/logging/console.d.ts +89 -0
  54. package/dist/services/logging/console.js +169 -0
  55. package/dist/services/logging/console.js.map +1 -0
  56. package/dist/services/logging/file-writer.d.ts +71 -0
  57. package/dist/services/logging/file-writer.js +516 -0
  58. package/dist/services/logging/file-writer.js.map +1 -0
  59. package/dist/services/logging/formatter.d.ts +39 -0
  60. package/dist/services/logging/formatter.js +227 -0
  61. package/dist/services/logging/formatter.js.map +1 -0
  62. package/dist/services/logging/index.d.ts +11 -0
  63. package/dist/services/logging/index.js +30 -0
  64. package/dist/services/logging/index.js.map +1 -0
  65. package/dist/services/logging/parser.d.ts +31 -0
  66. package/dist/services/logging/parser.js +222 -0
  67. package/dist/services/logging/parser.js.map +1 -0
  68. package/dist/services/process/index.d.ts +59 -0
  69. package/dist/services/process/index.js +257 -0
  70. package/dist/services/process/index.js.map +1 -0
  71. package/dist/types/agent.d.ts +20 -0
  72. package/dist/types/agent.js +6 -0
  73. package/dist/types/agent.js.map +1 -0
  74. package/dist/types/config.d.ts +65 -0
  75. package/dist/types/config.js +6 -0
  76. package/dist/types/config.js.map +1 -0
  77. package/dist/types/events.d.ts +125 -0
  78. package/dist/types/events.js +6 -0
  79. package/dist/types/events.js.map +1 -0
  80. package/dist/types/index.d.ts +12 -0
  81. package/dist/types/index.js +37 -0
  82. package/dist/types/index.js.map +1 -0
  83. package/dist/types/lane.d.ts +43 -0
  84. package/dist/types/lane.js +6 -0
  85. package/dist/types/lane.js.map +1 -0
  86. package/dist/types/logging.d.ts +71 -0
  87. package/dist/types/logging.js +16 -0
  88. package/dist/types/logging.js.map +1 -0
  89. package/dist/types/review.d.ts +17 -0
  90. package/dist/types/review.js +6 -0
  91. package/dist/types/review.js.map +1 -0
  92. package/dist/types/run.d.ts +32 -0
  93. package/dist/types/run.js +6 -0
  94. package/dist/types/run.js.map +1 -0
  95. package/dist/types/task.d.ts +71 -0
  96. package/dist/types/task.js +6 -0
  97. package/dist/types/task.js.map +1 -0
  98. package/dist/ui/components.d.ts +134 -0
  99. package/dist/ui/components.js +389 -0
  100. package/dist/ui/components.js.map +1 -0
  101. package/dist/ui/log-viewer.d.ts +49 -0
  102. package/dist/ui/log-viewer.js +449 -0
  103. package/dist/ui/log-viewer.js.map +1 -0
  104. package/dist/utils/checkpoint.d.ts +87 -0
  105. package/dist/utils/checkpoint.js +317 -0
  106. package/dist/utils/checkpoint.js.map +1 -0
  107. package/dist/utils/config.d.ts +4 -0
  108. package/dist/utils/config.js +18 -8
  109. package/dist/utils/config.js.map +1 -1
  110. package/dist/utils/cursor-agent.js.map +1 -1
  111. package/dist/utils/dependency.d.ts +74 -0
  112. package/dist/utils/dependency.js +420 -0
  113. package/dist/utils/dependency.js.map +1 -0
  114. package/dist/utils/doctor.js +17 -11
  115. package/dist/utils/doctor.js.map +1 -1
  116. package/dist/utils/enhanced-logger.d.ts +10 -33
  117. package/dist/utils/enhanced-logger.js +108 -20
  118. package/dist/utils/enhanced-logger.js.map +1 -1
  119. package/dist/utils/git.d.ts +121 -0
  120. package/dist/utils/git.js +484 -11
  121. package/dist/utils/git.js.map +1 -1
  122. package/dist/utils/health.d.ts +91 -0
  123. package/dist/utils/health.js +556 -0
  124. package/dist/utils/health.js.map +1 -0
  125. package/dist/utils/lock.d.ts +95 -0
  126. package/dist/utils/lock.js +332 -0
  127. package/dist/utils/lock.js.map +1 -0
  128. package/dist/utils/log-buffer.d.ts +17 -0
  129. package/dist/utils/log-buffer.js +14 -0
  130. package/dist/utils/log-buffer.js.map +1 -0
  131. package/dist/utils/log-constants.d.ts +23 -0
  132. package/dist/utils/log-constants.js +28 -0
  133. package/dist/utils/log-constants.js.map +1 -0
  134. package/dist/utils/log-formatter.d.ts +25 -0
  135. package/dist/utils/log-formatter.js +237 -0
  136. package/dist/utils/log-formatter.js.map +1 -0
  137. package/dist/utils/log-service.d.ts +19 -0
  138. package/dist/utils/log-service.js +47 -0
  139. package/dist/utils/log-service.js.map +1 -0
  140. package/dist/utils/logger.d.ts +46 -27
  141. package/dist/utils/logger.js +82 -60
  142. package/dist/utils/logger.js.map +1 -1
  143. package/dist/utils/path.d.ts +19 -0
  144. package/dist/utils/path.js +77 -0
  145. package/dist/utils/path.js.map +1 -0
  146. package/dist/utils/process-manager.d.ts +21 -0
  147. package/dist/utils/process-manager.js +138 -0
  148. package/dist/utils/process-manager.js.map +1 -0
  149. package/dist/utils/retry.d.ts +121 -0
  150. package/dist/utils/retry.js +374 -0
  151. package/dist/utils/retry.js.map +1 -0
  152. package/dist/utils/run-service.d.ts +88 -0
  153. package/dist/utils/run-service.js +412 -0
  154. package/dist/utils/run-service.js.map +1 -0
  155. package/dist/utils/state.d.ts +62 -3
  156. package/dist/utils/state.js +317 -11
  157. package/dist/utils/state.js.map +1 -1
  158. package/dist/utils/task-service.d.ts +82 -0
  159. package/dist/utils/task-service.js +348 -0
  160. package/dist/utils/task-service.js.map +1 -0
  161. package/dist/utils/template.d.ts +14 -0
  162. package/dist/utils/template.js +122 -0
  163. package/dist/utils/template.js.map +1 -0
  164. package/dist/utils/types.d.ts +2 -271
  165. package/dist/utils/types.js +16 -0
  166. package/dist/utils/types.js.map +1 -1
  167. package/package.json +38 -23
  168. package/scripts/ai-security-check.js +0 -1
  169. package/scripts/local-security-gate.sh +0 -0
  170. package/scripts/monitor-lanes.sh +94 -0
  171. package/scripts/patches/test-cursor-agent.js +0 -1
  172. package/scripts/release.sh +0 -0
  173. package/scripts/setup-security.sh +0 -0
  174. package/scripts/stream-logs.sh +72 -0
  175. package/scripts/verify-and-fix.sh +0 -0
  176. package/src/cli/clean.ts +187 -6
  177. package/src/cli/index.ts +12 -1
  178. package/src/cli/init.ts +8 -7
  179. package/src/cli/logs.ts +124 -77
  180. package/src/cli/monitor.ts +1815 -898
  181. package/src/cli/prepare.ts +41 -21
  182. package/src/cli/resume.ts +753 -626
  183. package/src/cli/run.ts +12 -5
  184. package/src/cli/runs.ts +212 -0
  185. package/src/cli/setup-commands.ts +0 -0
  186. package/src/cli/signal.ts +8 -7
  187. package/src/cli/stop.ts +209 -0
  188. package/src/cli/tasks.ts +154 -0
  189. package/src/core/auto-recovery.ts +909 -0
  190. package/src/core/failure-policy.ts +592 -0
  191. package/src/core/orchestrator.ts +1131 -704
  192. package/src/core/reviewer.ts +4 -0
  193. package/src/core/runner.ts +444 -180
  194. package/src/services/logging/buffer.ts +326 -0
  195. package/src/services/logging/console.ts +193 -0
  196. package/src/services/logging/file-writer.ts +526 -0
  197. package/src/services/logging/formatter.ts +268 -0
  198. package/src/services/logging/index.ts +16 -0
  199. package/src/services/logging/parser.ts +232 -0
  200. package/src/services/process/index.ts +261 -0
  201. package/src/types/agent.ts +24 -0
  202. package/src/types/config.ts +79 -0
  203. package/src/types/events.ts +156 -0
  204. package/src/types/index.ts +29 -0
  205. package/src/types/lane.ts +56 -0
  206. package/src/types/logging.ts +96 -0
  207. package/src/types/review.ts +20 -0
  208. package/src/types/run.ts +37 -0
  209. package/src/types/task.ts +79 -0
  210. package/src/ui/components.ts +430 -0
  211. package/src/ui/log-viewer.ts +485 -0
  212. package/src/utils/checkpoint.ts +374 -0
  213. package/src/utils/config.ts +18 -8
  214. package/src/utils/cursor-agent.ts +1 -1
  215. package/src/utils/dependency.ts +482 -0
  216. package/src/utils/doctor.ts +18 -11
  217. package/src/utils/enhanced-logger.ts +122 -60
  218. package/src/utils/git.ts +517 -11
  219. package/src/utils/health.ts +596 -0
  220. package/src/utils/lock.ts +346 -0
  221. package/src/utils/log-buffer.ts +28 -0
  222. package/src/utils/log-constants.ts +26 -0
  223. package/src/utils/log-formatter.ts +245 -0
  224. package/src/utils/log-service.ts +49 -0
  225. package/src/utils/logger.ts +100 -51
  226. package/src/utils/path.ts +45 -0
  227. package/src/utils/process-manager.ts +100 -0
  228. package/src/utils/retry.ts +413 -0
  229. package/src/utils/run-service.ts +433 -0
  230. package/src/utils/state.ts +385 -11
  231. package/src/utils/task-service.ts +370 -0
  232. package/src/utils/template.ts +92 -0
  233. package/src/utils/types.ts +2 -314
  234. package/templates/basic.json +21 -0
@@ -1,21 +1,30 @@
1
1
  /**
2
2
  * Core Runner - Execute tasks sequentially in a lane
3
3
  *
4
- * Adapted from sequential-agent-runner.js
4
+ * Features:
5
+ * - Enhanced retry with circuit breaker
6
+ * - Checkpoint system for recovery
7
+ * - State validation and repair
8
+ * - Improved dependency management
5
9
  */
6
10
 
7
11
  import * as fs from 'fs';
8
12
  import * as path from 'path';
9
- import { execSync, spawn, spawnSync } from 'child_process';
13
+ import { spawn, spawnSync } from 'child_process';
10
14
 
11
15
  import * as git from '../utils/git';
12
16
  import * as logger from '../utils/logger';
13
17
  import { ensureCursorAgent, checkCursorAuth, printAuthHelp } from '../utils/cursor-agent';
14
- import { saveState, appendLog, createConversationEntry } from '../utils/state';
18
+ import { saveState, appendLog, createConversationEntry, loadState, validateLaneState, repairLaneState, stateNeedsRecovery } from '../utils/state';
15
19
  import { events } from '../utils/events';
16
20
  import { loadConfig } from '../utils/config';
17
21
  import { registerWebhooks } from '../utils/webhook';
18
22
  import { runReviewLoop } from './reviewer';
23
+ import { safeJoin } from '../utils/path';
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';
19
28
  import {
20
29
  RunnerConfig,
21
30
  Task,
@@ -24,7 +33,7 @@ import {
24
33
  DependencyPolicy,
25
34
  DependencyRequestPlan,
26
35
  LaneState
27
- } from '../utils/types';
36
+ } from '../types';
28
37
 
29
38
  /**
30
39
  * Execute cursor-agent command with timeout and better error handling
@@ -173,19 +182,18 @@ export function validateTaskConfig(config: RunnerConfig): void {
173
182
  }
174
183
 
175
184
  /**
176
- * Execute cursor-agent command with streaming and better error handling
185
+ * Internal: Execute cursor-agent command with streaming
177
186
  */
178
- export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention, outputFormat }: {
187
+ async function cursorAgentSendRaw({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention, outputFormat, taskName }: {
179
188
  workspaceDir: string;
180
189
  chatId: string;
181
190
  prompt: string;
182
191
  model?: string;
183
192
  signalDir?: string;
184
193
  timeout?: number;
185
- /** Enable stdin piping for intervention feature (may cause buffering issues on some systems) */
186
194
  enableIntervention?: boolean;
187
- /** Output format for cursor-agent (default: 'stream-json') */
188
195
  outputFormat?: 'stream-json' | 'json' | 'plain';
196
+ taskName?: string;
189
197
  }): Promise<AgentSendResult> {
190
198
  // Use stream-json format for structured output with tool calls and results
191
199
  const format = outputFormat || 'stream-json';
@@ -201,24 +209,15 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
201
209
  ];
202
210
 
203
211
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
204
- logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
205
212
 
206
213
  // Determine stdio mode based on intervention setting
207
- // When intervention is enabled, we pipe stdin for message injection
208
- // When disabled (default), we ignore stdin to avoid buffering issues
209
214
  const stdinMode = enableIntervention ? 'pipe' : 'ignore';
210
215
 
211
- if (enableIntervention) {
212
- logger.info('Intervention mode enabled (stdin piped)');
213
- }
214
-
215
216
  return new Promise((resolve) => {
216
217
  // Build environment, preserving user's NODE_OPTIONS but disabling problematic flags
217
218
  const childEnv = { ...process.env };
218
219
 
219
- // Only filter out specific problematic NODE_OPTIONS, don't clear entirely
220
220
  if (childEnv.NODE_OPTIONS) {
221
- // Remove flags that might interfere with cursor-agent
222
221
  const filtered = childEnv.NODE_OPTIONS
223
222
  .split(' ')
224
223
  .filter(opt => !opt.includes('--inspect') && !opt.includes('--debug'))
@@ -226,7 +225,6 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
226
225
  childEnv.NODE_OPTIONS = filtered;
227
226
  }
228
227
 
229
- // Disable Python buffering in case cursor-agent uses Python
230
228
  childEnv.PYTHONUNBUFFERED = '1';
231
229
 
232
230
  const child = spawn('cursor-agent', args, {
@@ -234,18 +232,15 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
234
232
  env: childEnv,
235
233
  });
236
234
 
237
- logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
238
-
239
- // Save PID to state if possible (avoid TOCTOU by reading directly)
235
+ // Save PID to state if possible
240
236
  if (child.pid && signalDir) {
241
237
  try {
242
- const statePath = path.join(signalDir, 'state.json');
243
- // Read directly without existence check to avoid race condition
238
+ const statePath = safeJoin(signalDir, 'state.json');
244
239
  const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
245
240
  state.pid = child.pid;
246
241
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
247
242
  } catch {
248
- // Best effort - file may not exist yet
243
+ // Best effort
249
244
  }
250
245
  }
251
246
 
@@ -253,24 +248,23 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
253
248
  let fullStderr = '';
254
249
  let timeoutHandle: NodeJS.Timeout;
255
250
 
256
- // Heartbeat logging to show progress
251
+ // Heartbeat logging
257
252
  let lastHeartbeat = Date.now();
258
253
  let bytesReceived = 0;
254
+ const startTime = Date.now();
259
255
  const heartbeatInterval = setInterval(() => {
260
- const elapsed = Math.round((Date.now() - lastHeartbeat) / 1000);
261
256
  const totalElapsed = Math.round((Date.now() - startTime) / 1000);
262
- logger.info(`⏱ Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
257
+ // Output without timestamp - orchestrator will add it
258
+ console.log(`⏱ Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
263
259
  }, HEARTBEAT_INTERVAL_MS);
264
- const startTime = Date.now();
265
260
 
266
- // Watch for "intervention.txt" or "timeout.txt" signal files
261
+ // Signal watchers (intervention, timeout)
267
262
  const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
268
263
  const timeoutPath = signalDir ? path.join(signalDir, 'timeout.txt') : null;
269
264
  let signalWatcher: fs.FSWatcher | null = null;
270
265
 
271
266
  if (signalDir && fs.existsSync(signalDir)) {
272
267
  signalWatcher = fs.watch(signalDir, (event, filename) => {
273
- // Handle intervention
274
268
  if (filename === 'intervention.txt' && interventionPath && fs.existsSync(interventionPath)) {
275
269
  try {
276
270
  const message = fs.readFileSync(interventionPath, 'utf8').trim();
@@ -278,59 +272,48 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
278
272
  if (enableIntervention && child.stdin) {
279
273
  logger.info(`Injecting intervention: ${message}`);
280
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
+ }
281
284
  } else {
282
285
  logger.warn(`Intervention requested but stdin not available: ${message}`);
283
- logger.warn('To enable intervention, set enableIntervention: true in config');
284
286
  }
285
- fs.unlinkSync(interventionPath); // Clear it
287
+ fs.unlinkSync(interventionPath);
286
288
  }
287
- } catch (e) {
288
- logger.warn('Failed to read intervention file');
289
- }
289
+ } catch {}
290
290
  }
291
291
 
292
- // Handle dynamic timeout update
293
292
  if (filename === 'timeout.txt' && timeoutPath && fs.existsSync(timeoutPath)) {
294
293
  try {
295
294
  const newTimeoutStr = fs.readFileSync(timeoutPath, 'utf8').trim();
296
295
  const newTimeoutMs = parseInt(newTimeoutStr);
297
-
298
296
  if (!isNaN(newTimeoutMs) && newTimeoutMs > 0) {
299
297
  logger.info(`⏱ Dynamic timeout update: ${Math.round(newTimeoutMs / 1000)}s`);
300
-
301
- // Clear old timeout
302
298
  if (timeoutHandle) clearTimeout(timeoutHandle);
303
-
304
- // Set new timeout based on total elapsed time
305
299
  const elapsed = Date.now() - startTime;
306
300
  const remaining = Math.max(1000, newTimeoutMs - elapsed);
307
-
308
301
  timeoutHandle = setTimeout(() => {
309
302
  clearInterval(heartbeatInterval);
310
303
  child.kill();
311
- const totalSec = Math.round(newTimeoutMs / 1000);
312
- resolve({
313
- ok: false,
314
- exitCode: -1,
315
- error: `cursor-agent timed out after updated limit of ${totalSec} seconds.`,
316
- });
304
+ resolve({ ok: false, exitCode: -1, error: `cursor-agent timed out after updated limit.` });
317
305
  }, remaining);
318
-
319
- fs.unlinkSync(timeoutPath); // Clear it
306
+ fs.unlinkSync(timeoutPath);
320
307
  }
321
- } catch (e) {
322
- logger.warn('Failed to read timeout update file');
323
- }
308
+ } catch {}
324
309
  }
325
310
  });
326
311
  }
327
312
 
328
313
  if (child.stdout) {
329
314
  child.stdout.on('data', (data) => {
330
- const str = data.toString();
331
- fullStdout += str;
315
+ fullStdout += data.toString();
332
316
  bytesReceived += data.length;
333
- // Also pipe to our own stdout so it goes to terminal.log
334
317
  process.stdout.write(data);
335
318
  });
336
319
  }
@@ -338,7 +321,6 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
338
321
  if (child.stderr) {
339
322
  child.stderr.on('data', (data) => {
340
323
  fullStderr += data.toString();
341
- // Pipe to our own stderr so it goes to terminal.log
342
324
  process.stderr.write(data);
343
325
  });
344
326
  }
@@ -346,11 +328,10 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
346
328
  timeoutHandle = setTimeout(() => {
347
329
  clearInterval(heartbeatInterval);
348
330
  child.kill();
349
- const timeoutSec = Math.round(timeoutMs / 1000);
350
331
  resolve({
351
332
  ok: false,
352
333
  exitCode: -1,
353
- error: `cursor-agent timed out after ${timeoutSec} seconds. The LLM request may be taking too long or there may be network issues.`,
334
+ error: `cursor-agent timed out after ${Math.round(timeoutMs / 1000)} seconds.`,
354
335
  });
355
336
  }, timeoutMs);
356
337
 
@@ -363,21 +344,7 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
363
344
 
364
345
  if (code !== 0 || !json || json.type !== 'result') {
365
346
  let errorMsg = fullStderr.trim() || fullStdout.trim() || `exit=${code}`;
366
-
367
- // Check for common errors
368
- if (errorMsg.includes('not authenticated') || errorMsg.includes('login') || errorMsg.includes('auth')) {
369
- errorMsg = 'Authentication error. Please sign in to Cursor IDE.';
370
- } else if (errorMsg.includes('rate limit') || errorMsg.includes('quota')) {
371
- errorMsg = 'API rate limit or quota exceeded.';
372
- } else if (errorMsg.includes('model')) {
373
- errorMsg = `Model error (requested: ${model || 'default'}). Check your subscription.`;
374
- }
375
-
376
- resolve({
377
- ok: false,
378
- exitCode: code ?? -1,
379
- error: errorMsg,
380
- });
347
+ resolve({ ok: false, exitCode: code ?? -1, error: errorMsg });
381
348
  } else {
382
349
  resolve({
383
350
  ok: !json.is_error,
@@ -391,15 +358,35 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
391
358
  child.on('error', (err) => {
392
359
  clearTimeout(timeoutHandle);
393
360
  clearInterval(heartbeatInterval);
394
- resolve({
395
- ok: false,
396
- exitCode: -1,
397
- error: `Failed to start cursor-agent: ${err.message}`,
398
- });
361
+ resolve({ ok: false, exitCode: -1, error: `Failed to start cursor-agent: ${err.message}` });
399
362
  });
400
363
  });
401
364
  }
402
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
+
403
390
  /**
404
391
  * Extract dependency change request from agent response
405
392
  */
@@ -430,33 +417,155 @@ export function extractDependencyRequest(text: string): { required: boolean; pla
430
417
  }
431
418
 
432
419
  /**
433
- * Wrap prompt with dependency policy
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
434
426
  */
435
- export function wrapPromptForDependencyPolicy(prompt: string, policy: DependencyPolicy, options: { noGit?: boolean } = {}): string {
436
- const { noGit = false } = options;
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
+ }
437
438
 
438
- if (policy.allowDependencyChange && !policy.lockfileReadOnly && !noGit) {
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) {
439
478
  return prompt;
440
479
  }
441
480
 
442
- let rules = '# Dependency Policy (MUST FOLLOW)\n\nYou are running in a restricted lane.\n\n';
481
+ let wrapped = `### 📦 Dependency Policy\n`;
482
+ wrapped += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
483
+ wrapped += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n\n`;
484
+ wrapped += prompt;
443
485
 
444
- rules += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
445
- rules += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n`;
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`;
446
524
 
447
525
  if (noGit) {
448
- rules += '- NO_GIT_MODE: Git is disabled. DO NOT run any git commands (commit, push, etc.). Just edit files.\n';
526
+ wrapped += `- NO_GIT_MODE: Git 명령어를 사용하지 마세요. 파일 수정만 가능합니다.\n`;
449
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`;
450
550
 
451
- rules += '\nRules:\n';
452
- rules += '- BEFORE making any code changes, decide whether dependency changes are required.\n';
453
- rules += '- If dependency changes are required, DO NOT change any files. Instead reply with:\n\n';
454
- rules += 'DEPENDENCY_CHANGE_REQUIRED\n';
455
- rules += '```json\n{ "reason": "...", "changes": [...], "commands": ["pnpm add ..."], "notes": "..." }\n```\n\n';
456
- rules += 'Then STOP.\n';
457
- rules += '- If dependency changes are NOT required, proceed normally.\n';
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
+ }
458
560
 
459
- return `${rules}\n---\n\n${prompt}`;
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;
460
569
  }
461
570
 
462
571
  /**
@@ -474,7 +583,7 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
474
583
  }
475
584
 
476
585
  for (const file of targets) {
477
- const filePath = path.join(worktreeDir, file);
586
+ const filePath = safeJoin(worktreeDir, file);
478
587
  if (!fs.existsSync(filePath)) continue;
479
588
 
480
589
  try {
@@ -489,49 +598,38 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
489
598
 
490
599
  /**
491
600
  * Wait for task-level dependencies to be completed by other lanes
601
+ * Now uses the enhanced dependency module with timeout support
492
602
  */
493
- export async function waitForTaskDependencies(deps: string[], runDir: string): Promise<void> {
603
+ export async function waitForTaskDependencies(
604
+ deps: string[],
605
+ runDir: string,
606
+ options: DependencyWaitOptions = {}
607
+ ): Promise<void> {
494
608
  if (!deps || deps.length === 0) return;
495
609
 
496
610
  const lanesRoot = path.dirname(runDir);
497
- const pendingDeps = new Set(deps);
498
-
499
- logger.info(`Waiting for task dependencies: ${deps.join(', ')}`);
500
-
501
- while (pendingDeps.size > 0) {
502
- for (const dep of pendingDeps) {
503
- const [laneName, taskName] = dep.split(':');
504
- if (!laneName || !taskName) {
505
- logger.warn(`Invalid dependency format: ${dep}. Expected "lane:task"`);
506
- pendingDeps.delete(dep);
507
- continue;
508
- }
509
-
510
- const depStatePath = path.join(lanesRoot, laneName, 'state.json');
511
- if (fs.existsSync(depStatePath)) {
512
- try {
513
- const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
514
- if (state.completedTasks && state.completedTasks.includes(taskName)) {
515
- logger.info(`✓ Dependency met: ${dep}`);
516
- pendingDeps.delete(dep);
517
- } else if (state.status === 'failed') {
518
- throw new Error(`Dependency failed: ${dep} (Lane ${laneName} failed)`);
519
- }
520
- } catch (e: any) {
521
- if (e.message.includes('Dependency failed')) throw e;
522
- // Ignore parse errors, file might be being written
523
- }
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`);
524
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(', ')}`);
525
626
  }
526
-
527
- if (pendingDeps.size > 0) {
528
- await new Promise(resolve => setTimeout(resolve, 5000)); // Poll every 5 seconds
529
- }
627
+ throw new Error(`Dependencies failed: ${result.failedDependencies.join(', ')}`);
530
628
  }
531
629
  }
532
630
 
533
631
  /**
534
- * Merge branches from dependency lanes
632
+ * Merge branches from dependency lanes with safe merge
535
633
  */
536
634
  export async function mergeDependencyBranches(deps: string[], runDir: string, worktreeDir: string): Promise<void> {
537
635
  if (!deps || deps.length === 0) return;
@@ -540,25 +638,38 @@ export async function mergeDependencyBranches(deps: string[], runDir: string, wo
540
638
  const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
541
639
 
542
640
  for (const laneName of lanesToMerge) {
543
- const depStatePath = path.join(lanesRoot, laneName, 'state.json');
641
+ const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
544
642
  if (!fs.existsSync(depStatePath)) continue;
545
643
 
546
644
  try {
547
- const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
548
- if (state.pipelineBranch) {
549
- logger.info(`Merging branch from ${laneName}: ${state.pipelineBranch}`);
550
-
551
- // Ensure we have the latest
552
- git.runGit(['fetch', 'origin', state.pipelineBranch], { cwd: worktreeDir, silent: true });
553
-
554
- git.merge(state.pipelineBranch, {
555
- cwd: worktreeDir,
556
- noFf: true,
557
- message: `chore: merge task dependency from ${laneName}`
558
- });
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');
559
667
  }
668
+
669
+ logger.success(`✓ Merged ${laneName}`);
560
670
  } catch (e) {
561
671
  logger.error(`Failed to merge branch from ${laneName}: ${e}`);
672
+ throw e;
562
673
  }
563
674
  }
564
675
  }
@@ -589,7 +700,7 @@ export async function runTask({
589
700
  }): Promise<TaskExecutionResult> {
590
701
  const model = task.model || config.model || 'sonnet-4.5';
591
702
  const timeout = task.timeout || config.timeout;
592
- const convoPath = path.join(runDir, 'conversation.jsonl');
703
+ const convoPath = safeJoin(runDir, 'conversation.jsonl');
593
704
 
594
705
  logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
595
706
  logger.info(`Model: ${model}`);
@@ -613,10 +724,27 @@ export async function runTask({
613
724
  // Apply dependency permissions
614
725
  applyDependencyFilePermissions(worktreeDir, config.dependencyPolicy);
615
726
 
616
- // Run prompt
617
- const prompt1 = wrapPromptForDependencyPolicy(task.prompt, config.dependencyPolicy, { noGit });
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
+ });
618
745
 
619
- appendLog(convoPath, createConversationEntry('user', prompt1, {
746
+ // Log ONLY the original prompt to keep logs clean
747
+ appendLog(convoPath, createConversationEntry('user', task.prompt, {
620
748
  task: task.name,
621
749
  model,
622
750
  }));
@@ -626,18 +754,19 @@ export async function runTask({
626
754
  events.emit('agent.prompt_sent', {
627
755
  taskName: task.name,
628
756
  model,
629
- promptLength: prompt1.length,
757
+ promptLength: wrappedPrompt.length,
630
758
  });
631
759
 
632
760
  const r1 = await cursorAgentSend({
633
761
  workspaceDir: worktreeDir,
634
762
  chatId,
635
- prompt: prompt1,
763
+ prompt: wrappedPrompt,
636
764
  model,
637
765
  signalDir: runDir,
638
766
  timeout,
639
767
  enableIntervention: config.enableIntervention,
640
768
  outputFormat: config.agentOutputFormat,
769
+ taskName: task.name,
641
770
  });
642
771
 
643
772
  const duration = Date.now() - startTime;
@@ -668,15 +797,31 @@ export async function runTask({
668
797
  };
669
798
  }
670
799
 
671
- // Check for dependency request
672
- const depReq = extractDependencyRequest(r1.resultText || '');
673
- if (depReq.required && !config.dependencyPolicy.allowDependencyChange) {
674
- return {
675
- taskName: task.name,
676
- taskBranch,
677
- status: 'BLOCKED_DEPENDENCY',
678
- dependencyRequest: depReq.plan || null,
679
- };
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
+ }
680
825
  }
681
826
 
682
827
  // Push task branch (skip in noGit mode)
@@ -731,7 +876,7 @@ export async function runTask({
731
876
  /**
732
877
  * Run all tasks in sequence
733
878
  */
734
- export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number; noGit?: boolean } = {}): Promise<TaskExecutionResult[]> {
879
+ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number; noGit?: boolean; skipPreflight?: boolean } = {}): Promise<TaskExecutionResult[]> {
735
880
  const startIndex = options.startIndex || 0;
736
881
  const noGit = options.noGit || config.noGit || false;
737
882
 
@@ -750,6 +895,33 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
750
895
  throw validationError;
751
896
  }
752
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
+
753
925
  // Ensure cursor-agent is installed
754
926
  ensureCursorAgent();
755
927
 
@@ -778,26 +950,58 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
778
950
  logger.success('✓ Cursor authentication OK');
779
951
 
780
952
  // In noGit mode, we don't need repoRoot - use current directory
781
- const repoRoot = noGit ? process.cwd() : git.getRepoRoot();
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)`);
782
959
 
783
960
  // Load existing state if resuming
784
- const statePath = path.join(runDir, 'state.json');
961
+ const statePath = safeJoin(runDir, 'state.json');
785
962
  let state: LaneState | null = null;
786
963
 
787
964
  if (fs.existsSync(statePath)) {
788
- try {
789
- state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
790
- } catch (e) {
791
- logger.warn(`Failed to load existing state from ${statePath}: ${e}`);
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
+ }
792
994
  }
793
995
  }
794
996
 
795
997
  const randomSuffix = Math.random().toString(36).substring(2, 7);
796
998
  const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
999
+
797
1000
  // In noGit mode, use a simple local directory instead of worktree
798
- const worktreeDir = state?.worktreeDir || (noGit
799
- ? path.join(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
800
- : path.join(repoRoot, config.worktreeRoot || '_cursorflow/worktrees', pipelineBranch));
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, '-')));
801
1005
 
802
1006
  if (startIndex === 0) {
803
1007
  logger.section('🚀 Starting Pipeline');
@@ -816,10 +1020,38 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
816
1020
  logger.info(`Creating work directory: ${worktreeDir}`);
817
1021
  fs.mkdirSync(worktreeDir, { recursive: true });
818
1022
  } else {
819
- git.createWorktree(worktreeDir, pipelineBranch, {
820
- baseBranch: config.baseBranch || 'main',
821
- cwd: repoRoot,
822
- });
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
+ }
823
1055
  }
824
1056
  } else if (!noGit) {
825
1057
  // If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
@@ -858,6 +1090,9 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
858
1090
  state.status = 'running';
859
1091
  state.error = null;
860
1092
  state.dependencyRequest = null;
1093
+ state.pipelineBranch = pipelineBranch;
1094
+ state.worktreeDir = worktreeDir;
1095
+ state.label = state.label || pipelineBranch;
861
1096
  state.dependsOn = config.dependsOn || [];
862
1097
  state.completedTasks = state.completedTasks || [];
863
1098
  }
@@ -872,8 +1107,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
872
1107
  const lanesRoot = path.dirname(runDir);
873
1108
 
874
1109
  for (const depName of config.dependsOn) {
875
- const depRunDir = path.join(lanesRoot, depName);
876
- const depStatePath = path.join(depRunDir, 'state.json');
1110
+ const depRunDir = path.join(lanesRoot, depName); // nosemgrep
1111
+ const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
877
1112
 
878
1113
  if (!fs.existsSync(depStatePath)) {
879
1114
  logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
@@ -919,8 +1154,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
919
1154
  const lanesRoot = path.dirname(runDir);
920
1155
 
921
1156
  for (const depName of config.dependsOn) {
922
- const depRunDir = path.join(lanesRoot, depName);
923
- const depStatePath = path.join(depRunDir, 'state.json');
1157
+ const depRunDir = safeJoin(lanesRoot, depName);
1158
+ const depStatePath = safeJoin(depRunDir, 'state.json');
924
1159
 
925
1160
  if (!fs.existsSync(depStatePath)) {
926
1161
  continue;
@@ -939,8 +1174,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
939
1174
  for (const entry of entries) {
940
1175
  if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
941
1176
 
942
- const srcPath = path.join(src, entry.name);
943
- const destPath = path.join(dest, entry.name);
1177
+ const srcPath = safeJoin(src, entry.name);
1178
+ const destPath = safeJoin(dest, entry.name);
944
1179
 
945
1180
  if (entry.isDirectory()) {
946
1181
  copyFiles(srcPath, destPath);
@@ -960,11 +1195,22 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
960
1195
 
961
1196
  // Run tasks
962
1197
  const results: TaskExecutionResult[] = [];
1198
+ const laneName = state.label || path.basename(runDir);
963
1199
 
964
1200
  for (let i = startIndex; i < config.tasks.length; i++) {
965
1201
  const task = config.tasks[i]!;
966
1202
  const taskBranch = `${pipelineBranch}--${String(i + 1).padStart(2, '0')}-${task.name}`;
967
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
+
968
1214
  // Handle task-level dependencies
969
1215
  if (task.dependsOn && task.dependsOn.length > 0) {
970
1216
  state.status = 'waiting';
@@ -972,7 +1218,11 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
972
1218
  saveState(statePath, state);
973
1219
 
974
1220
  try {
975
- await waitForTaskDependencies(task.dependsOn, runDir);
1221
+ // Use enhanced dependency wait with timeout
1222
+ await waitForTaskDependencies(task.dependsOn, runDir, {
1223
+ timeoutMs: config.timeout || 30 * 60 * 1000,
1224
+ onTimeout: 'fail',
1225
+ });
976
1226
 
977
1227
  if (!noGit) {
978
1228
  await mergeDependencyBranches(task.dependsOn, runDir, worktreeDir);
@@ -987,6 +1237,14 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
987
1237
  state.error = e.message;
988
1238
  saveState(statePath, state);
989
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
+
990
1248
  process.exit(1);
991
1249
  }
992
1250
  }
@@ -1072,7 +1330,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
1072
1330
 
1073
1331
  if (entry.isDirectory()) {
1074
1332
  stats.dirs++;
1075
- const sub = getFileSummary(path.join(dir, entry.name));
1333
+ const sub = getFileSummary(safeJoin(dir, entry.name));
1076
1334
  stats.files += sub.files;
1077
1335
  stats.dirs += sub.dirs;
1078
1336
  } else {
@@ -1086,7 +1344,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
1086
1344
  logger.info(`Final Workspace Summary (noGit): ${summary.files} files, ${summary.dirs} directories created/modified`);
1087
1345
  } else {
1088
1346
  try {
1089
- const stats = git.runGit(['diff', '--stat', config.baseBranch || 'main', pipelineBranch], { cwd: repoRoot, silent: true });
1347
+ // Always use current branch for comparison (already captured at start)
1348
+ const stats = git.runGit(['diff', '--stat', currentBranch, pipelineBranch], { cwd: repoRoot, silent: true });
1090
1349
  if (stats) {
1091
1350
  logger.info('Final Workspace Summary (Git):\n' + stats);
1092
1351
  }
@@ -1114,11 +1373,13 @@ if (require.main === module) {
1114
1373
  const runDirIdx = args.indexOf('--run-dir');
1115
1374
  const startIdxIdx = args.indexOf('--start-index');
1116
1375
  const pipelineBranchIdx = args.indexOf('--pipeline-branch');
1376
+ const worktreeDirIdx = args.indexOf('--worktree-dir');
1117
1377
  const noGit = args.includes('--no-git');
1118
1378
 
1119
1379
  const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
1120
1380
  const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
1121
1381
  const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
1382
+ const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
1122
1383
 
1123
1384
  // Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
1124
1385
  const parts = runDir.split(path.sep);
@@ -1150,6 +1411,9 @@ if (require.main === module) {
1150
1411
  if (forcedPipelineBranch) {
1151
1412
  config.pipelineBranch = forcedPipelineBranch;
1152
1413
  }
1414
+ if (forcedWorktreeDir) {
1415
+ config.worktreeDir = forcedWorktreeDir;
1416
+ }
1153
1417
  } catch (error: any) {
1154
1418
  console.error(`Failed to load tasks file: ${error.message}`);
1155
1419
  process.exit(1);