ralphctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/ralphctl +13 -0
- package/package.json +92 -0
- package/schemas/config.schema.json +20 -0
- package/schemas/ideate-output.schema.json +22 -0
- package/schemas/projects.schema.json +53 -0
- package/schemas/requirements-output.schema.json +24 -0
- package/schemas/sprint.schema.json +109 -0
- package/schemas/task-import.schema.json +49 -0
- package/schemas/tasks.schema.json +72 -0
- package/src/ai/executor.ts +973 -0
- package/src/ai/lifecycle.ts +45 -0
- package/src/ai/parser.ts +40 -0
- package/src/ai/permissions.ts +207 -0
- package/src/ai/process-manager.ts +248 -0
- package/src/ai/prompts/ideate-auto.md +144 -0
- package/src/ai/prompts/ideate.md +165 -0
- package/src/ai/prompts/index.ts +89 -0
- package/src/ai/prompts/plan-auto.md +131 -0
- package/src/ai/prompts/plan-common.md +157 -0
- package/src/ai/prompts/plan-interactive.md +190 -0
- package/src/ai/prompts/task-execution.md +159 -0
- package/src/ai/prompts/ticket-refine.md +230 -0
- package/src/ai/rate-limiter.ts +89 -0
- package/src/ai/runner.ts +478 -0
- package/src/ai/session.ts +319 -0
- package/src/ai/task-context.ts +270 -0
- package/src/cli-metadata.ts +7 -0
- package/src/cli.ts +65 -0
- package/src/commands/completion/index.ts +33 -0
- package/src/commands/config/config.ts +58 -0
- package/src/commands/config/index.ts +33 -0
- package/src/commands/dashboard/dashboard.ts +5 -0
- package/src/commands/dashboard/index.ts +6 -0
- package/src/commands/doctor/doctor.ts +271 -0
- package/src/commands/doctor/index.ts +25 -0
- package/src/commands/progress/index.ts +25 -0
- package/src/commands/progress/log.ts +64 -0
- package/src/commands/progress/show.ts +14 -0
- package/src/commands/project/add.ts +336 -0
- package/src/commands/project/index.ts +104 -0
- package/src/commands/project/list.ts +31 -0
- package/src/commands/project/remove.ts +43 -0
- package/src/commands/project/repo.ts +118 -0
- package/src/commands/project/show.ts +49 -0
- package/src/commands/sprint/close.ts +180 -0
- package/src/commands/sprint/context.ts +109 -0
- package/src/commands/sprint/create.ts +60 -0
- package/src/commands/sprint/current.ts +75 -0
- package/src/commands/sprint/delete.ts +72 -0
- package/src/commands/sprint/health.ts +229 -0
- package/src/commands/sprint/ideate.ts +496 -0
- package/src/commands/sprint/index.ts +226 -0
- package/src/commands/sprint/list.ts +86 -0
- package/src/commands/sprint/plan-utils.ts +207 -0
- package/src/commands/sprint/plan.ts +549 -0
- package/src/commands/sprint/refine.ts +359 -0
- package/src/commands/sprint/requirements.ts +58 -0
- package/src/commands/sprint/show.ts +140 -0
- package/src/commands/sprint/start.ts +119 -0
- package/src/commands/sprint/switch.ts +20 -0
- package/src/commands/task/add.ts +316 -0
- package/src/commands/task/import.ts +150 -0
- package/src/commands/task/index.ts +123 -0
- package/src/commands/task/list.ts +145 -0
- package/src/commands/task/next.ts +45 -0
- package/src/commands/task/remove.ts +47 -0
- package/src/commands/task/reorder.ts +45 -0
- package/src/commands/task/show.ts +111 -0
- package/src/commands/task/status.ts +99 -0
- package/src/commands/ticket/add.ts +265 -0
- package/src/commands/ticket/edit.ts +166 -0
- package/src/commands/ticket/index.ts +114 -0
- package/src/commands/ticket/list.ts +128 -0
- package/src/commands/ticket/refine-utils.ts +89 -0
- package/src/commands/ticket/refine.ts +268 -0
- package/src/commands/ticket/remove.ts +48 -0
- package/src/commands/ticket/show.ts +74 -0
- package/src/completion/handle.ts +30 -0
- package/src/completion/resolver.ts +241 -0
- package/src/interactive/dashboard.ts +268 -0
- package/src/interactive/escapable.ts +81 -0
- package/src/interactive/file-browser.ts +153 -0
- package/src/interactive/index.ts +429 -0
- package/src/interactive/menu.ts +403 -0
- package/src/interactive/selectors.ts +273 -0
- package/src/interactive/wizard.ts +221 -0
- package/src/providers/claude.ts +53 -0
- package/src/providers/copilot.ts +86 -0
- package/src/providers/index.ts +43 -0
- package/src/providers/types.ts +85 -0
- package/src/schemas/index.ts +130 -0
- package/src/store/config.ts +74 -0
- package/src/store/progress.ts +230 -0
- package/src/store/project.ts +276 -0
- package/src/store/sprint.ts +229 -0
- package/src/store/task.ts +443 -0
- package/src/store/ticket.ts +178 -0
- package/src/theme/index.ts +215 -0
- package/src/theme/ui.ts +872 -0
- package/src/utils/detect-scripts.ts +247 -0
- package/src/utils/editor-input.ts +41 -0
- package/src/utils/editor.ts +37 -0
- package/src/utils/exit-codes.ts +27 -0
- package/src/utils/file-lock.ts +135 -0
- package/src/utils/git.ts +185 -0
- package/src/utils/ids.ts +37 -0
- package/src/utils/issue-fetch.ts +244 -0
- package/src/utils/json-extract.ts +62 -0
- package/src/utils/multiline.ts +61 -0
- package/src/utils/path-selector.ts +236 -0
- package/src/utils/paths.ts +108 -0
- package/src/utils/provider.ts +34 -0
- package/src/utils/requirements-export.ts +63 -0
- package/src/utils/storage.ts +107 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { readFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { highlight, info, muted, success, warning } from '@src/theme/index.ts';
|
|
4
|
+
import { ProcessManager } from '@src/ai/process-manager.ts';
|
|
5
|
+
import {
|
|
6
|
+
getNextTask,
|
|
7
|
+
getReadyTasks,
|
|
8
|
+
getRemainingTasks,
|
|
9
|
+
getTasks,
|
|
10
|
+
isTaskBlocked,
|
|
11
|
+
updateTask,
|
|
12
|
+
updateTaskStatus,
|
|
13
|
+
} from '@src/store/task.ts';
|
|
14
|
+
import { getProgress, logProgress, summarizeProgressForContext } from '@src/store/progress.ts';
|
|
15
|
+
import { getProgressFilePath, getSprintDir } from '@src/utils/paths.ts';
|
|
16
|
+
import { buildTaskExecutionPrompt } from '@src/ai/prompts/index.ts';
|
|
17
|
+
import type { Task } from '@src/schemas/index.ts';
|
|
18
|
+
import { createSpinner, formatTaskStatus } from '@src/theme/ui.ts';
|
|
19
|
+
import { type ExecutionResult, parseExecutionResult } from '@src/ai/parser.ts';
|
|
20
|
+
import type { SpawnResult } from '@src/ai/session.ts';
|
|
21
|
+
import { SpawnError, spawnInteractive, spawnWithRetry } from '@src/ai/session.ts';
|
|
22
|
+
import { RateLimitCoordinator } from '@src/ai/rate-limiter.ts';
|
|
23
|
+
import { EXIT_ALL_BLOCKED, EXIT_ERROR, EXIT_NO_TASKS, EXIT_SUCCESS } from '@src/utils/exit-codes.ts';
|
|
24
|
+
import { getSprint } from '@src/store/sprint.ts';
|
|
25
|
+
import {
|
|
26
|
+
buildFullTaskContext,
|
|
27
|
+
formatTask,
|
|
28
|
+
getContextFileName,
|
|
29
|
+
getEffectiveCheckScript,
|
|
30
|
+
getProjectForTask,
|
|
31
|
+
getRecentGitHistory,
|
|
32
|
+
runPermissionCheck,
|
|
33
|
+
type CheckResults,
|
|
34
|
+
type CheckStatus,
|
|
35
|
+
type TaskContext,
|
|
36
|
+
writeTaskContextFile,
|
|
37
|
+
} from '@src/ai/task-context.ts';
|
|
38
|
+
import { runLifecycleHook } from '@src/ai/lifecycle.ts';
|
|
39
|
+
import { type ProviderAdapter } from '@src/providers/types.ts';
|
|
40
|
+
import { getActiveProvider } from '@src/providers/index.ts';
|
|
41
|
+
import { verifySprintBranch } from '@src/ai/runner.ts';
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// TYPES
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
export interface ExecutorOptions {
|
|
48
|
+
/** Step through tasks with approval between each */
|
|
49
|
+
step: boolean;
|
|
50
|
+
/** Limit number of tasks to execute */
|
|
51
|
+
count: number | null;
|
|
52
|
+
/** Interactive AI session (collaborate with provider) */
|
|
53
|
+
session: boolean;
|
|
54
|
+
/** Skip auto-commit after task completion */
|
|
55
|
+
noCommit: boolean;
|
|
56
|
+
/** Max parallel tasks (undefined = auto based on unique repos) */
|
|
57
|
+
concurrency?: number;
|
|
58
|
+
/** Max rate-limit retries per task */
|
|
59
|
+
maxRetries?: number;
|
|
60
|
+
/** Stop launching new tasks on first failure */
|
|
61
|
+
failFast?: boolean;
|
|
62
|
+
/** Skip precondition checks (e.g., unplanned tickets) */
|
|
63
|
+
force?: boolean;
|
|
64
|
+
/** Force re-run of check scripts even if they already ran this sprint */
|
|
65
|
+
refreshCheck?: boolean;
|
|
66
|
+
/** Auto-generate sprint branch (ralphctl/<sprint-id>) */
|
|
67
|
+
branch?: boolean;
|
|
68
|
+
/** Custom branch name for sprint execution */
|
|
69
|
+
branchName?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reason why execution stopped */
|
|
73
|
+
export type StopReason =
|
|
74
|
+
| 'all_completed' // All tasks done
|
|
75
|
+
| 'count_reached' // Reached task count limit
|
|
76
|
+
| 'task_blocked' // A task could not be completed
|
|
77
|
+
| 'user_paused' // User chose not to continue in interactive mode
|
|
78
|
+
| 'no_tasks' // No tasks available
|
|
79
|
+
| 'all_blocked'; // All remaining tasks blocked by dependencies
|
|
80
|
+
|
|
81
|
+
export interface ExecutionSummary {
|
|
82
|
+
/** Number of tasks completed in this run */
|
|
83
|
+
completed: number;
|
|
84
|
+
/** Number of remaining tasks */
|
|
85
|
+
remaining: number;
|
|
86
|
+
/** Why execution stopped */
|
|
87
|
+
stopReason: StopReason;
|
|
88
|
+
/** Task that caused pause (if stopReason is task_blocked) */
|
|
89
|
+
blockedTask: Task | null;
|
|
90
|
+
/** Reason for block (if any) */
|
|
91
|
+
blockedReason: string | null;
|
|
92
|
+
/** Exit code for CLI */
|
|
93
|
+
exitCode: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// TASK EXECUTION
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/** Extended result that includes session ID for resume capability */
|
|
101
|
+
interface TaskExecutionResult extends ExecutionResult {
|
|
102
|
+
sessionId: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function executeTask(
|
|
106
|
+
ctx: TaskContext,
|
|
107
|
+
options: ExecutorOptions,
|
|
108
|
+
sprintId: string,
|
|
109
|
+
resumeSessionId?: string,
|
|
110
|
+
provider?: ProviderAdapter,
|
|
111
|
+
checkStatus?: CheckStatus
|
|
112
|
+
): Promise<TaskExecutionResult> {
|
|
113
|
+
const p = provider ?? (await getActiveProvider());
|
|
114
|
+
const label = p.displayName;
|
|
115
|
+
const projectPath = ctx.task.projectPath;
|
|
116
|
+
const sprintDir = getSprintDir(sprintId);
|
|
117
|
+
|
|
118
|
+
if (options.session) {
|
|
119
|
+
const contextFileName = getContextFileName(sprintId, ctx.task.id);
|
|
120
|
+
const gitHistory = getRecentGitHistory(projectPath, 20);
|
|
121
|
+
const checkScript = getEffectiveCheckScript(ctx.project, projectPath);
|
|
122
|
+
const allProgress = await getProgress(sprintId);
|
|
123
|
+
const progressSummary = summarizeProgressForContext(allProgress, projectPath, 3);
|
|
124
|
+
const fullTaskContent = buildFullTaskContext(ctx, progressSummary || null, gitHistory, checkScript, checkStatus);
|
|
125
|
+
const progressFilePath = getProgressFilePath(sprintId);
|
|
126
|
+
const instructions = buildTaskExecutionPrompt(progressFilePath, options.noCommit, contextFileName);
|
|
127
|
+
const contextFile = await writeTaskContextFile(projectPath, fullTaskContent, instructions, sprintId, ctx.task.id);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const result = spawnInteractive(
|
|
131
|
+
`Read ${contextFileName} and follow the instructions`,
|
|
132
|
+
{
|
|
133
|
+
cwd: projectPath,
|
|
134
|
+
args: ['--add-dir', sprintDir],
|
|
135
|
+
},
|
|
136
|
+
p
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (result.error) {
|
|
140
|
+
return { success: false, output: '', blockedReason: result.error, sessionId: null };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (result.code === 0) {
|
|
144
|
+
return { success: true, output: '', verified: true, sessionId: null };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
output: '',
|
|
149
|
+
blockedReason: `${label} exited with code ${String(result.code)}`,
|
|
150
|
+
sessionId: null,
|
|
151
|
+
};
|
|
152
|
+
} finally {
|
|
153
|
+
try {
|
|
154
|
+
await unlink(contextFile);
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore cleanup errors
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Headless mode
|
|
162
|
+
let spawnResult: SpawnResult;
|
|
163
|
+
|
|
164
|
+
if (resumeSessionId) {
|
|
165
|
+
// Resume a previous session — send a short continuation prompt
|
|
166
|
+
const spinner = createSpinner(`Resuming ${label} session for: ${ctx.task.name}`).start();
|
|
167
|
+
|
|
168
|
+
// Register spinner cleanup with ProcessManager
|
|
169
|
+
const manager = ProcessManager.getInstance();
|
|
170
|
+
const deregister = manager.registerCleanup(() => {
|
|
171
|
+
spinner.stop();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
spawnResult = await spawnWithRetry(
|
|
176
|
+
{
|
|
177
|
+
cwd: projectPath,
|
|
178
|
+
args: ['--add-dir', sprintDir],
|
|
179
|
+
prompt: 'Continue where you left off. Complete the task and signal completion.',
|
|
180
|
+
resumeSessionId,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
maxRetries: options.maxRetries,
|
|
184
|
+
onRetry: (attempt, delayMs) => {
|
|
185
|
+
spinner.text = `Rate limited, retrying in ${String(Math.round(delayMs / 1000))}s (attempt ${String(attempt)})...`;
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
p
|
|
189
|
+
);
|
|
190
|
+
spinner.succeed(`${label} completed: ${ctx.task.name}`);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
spinner.fail(`${label} failed: ${ctx.task.name}`);
|
|
193
|
+
throw err;
|
|
194
|
+
} finally {
|
|
195
|
+
deregister(); // Clean up callback registration
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Fresh session — build full context
|
|
199
|
+
const contextFileName = getContextFileName(sprintId, ctx.task.id);
|
|
200
|
+
const gitHistory = getRecentGitHistory(projectPath, 20);
|
|
201
|
+
const checkScript = getEffectiveCheckScript(ctx.project, projectPath);
|
|
202
|
+
const allProgress = await getProgress(sprintId);
|
|
203
|
+
const progressSummary = summarizeProgressForContext(allProgress, projectPath, 3);
|
|
204
|
+
const fullTaskContent = buildFullTaskContext(ctx, progressSummary || null, gitHistory, checkScript, checkStatus);
|
|
205
|
+
const progressFilePath = getProgressFilePath(sprintId);
|
|
206
|
+
const instructions = buildTaskExecutionPrompt(progressFilePath, options.noCommit, contextFileName);
|
|
207
|
+
const contextFile = await writeTaskContextFile(projectPath, fullTaskContent, instructions, sprintId, ctx.task.id);
|
|
208
|
+
|
|
209
|
+
const spinner = createSpinner(`${label} is working on: ${ctx.task.name}`).start();
|
|
210
|
+
|
|
211
|
+
// Register spinner cleanup with ProcessManager
|
|
212
|
+
const manager = ProcessManager.getInstance();
|
|
213
|
+
const deregister = manager.registerCleanup(() => {
|
|
214
|
+
spinner.stop();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const contextContent = await readFile(contextFile, 'utf-8');
|
|
219
|
+
spawnResult = await spawnWithRetry(
|
|
220
|
+
{
|
|
221
|
+
cwd: projectPath,
|
|
222
|
+
args: ['--add-dir', sprintDir],
|
|
223
|
+
prompt: contextContent,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
maxRetries: options.maxRetries,
|
|
227
|
+
onRetry: (attempt, delayMs) => {
|
|
228
|
+
spinner.text = `Rate limited, retrying in ${String(Math.round(delayMs / 1000))}s (attempt ${String(attempt)})...`;
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
p
|
|
232
|
+
);
|
|
233
|
+
spinner.succeed(`${label} completed: ${ctx.task.name}`);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
spinner.fail(`${label} failed: ${ctx.task.name}`);
|
|
236
|
+
throw err;
|
|
237
|
+
} finally {
|
|
238
|
+
deregister(); // Clean up callback registration
|
|
239
|
+
try {
|
|
240
|
+
await unlink(contextFile);
|
|
241
|
+
} catch {
|
|
242
|
+
// Ignore cleanup errors
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const parsed = parseExecutionResult(spawnResult.stdout);
|
|
248
|
+
return { ...parsed, sessionId: spawnResult.sessionId };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// SEQUENTIAL EXECUTION LOOP
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Check if all remaining tasks are blocked by dependencies.
|
|
257
|
+
*/
|
|
258
|
+
async function areAllRemainingBlocked(sprintId: string): Promise<boolean> {
|
|
259
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
260
|
+
if (remaining.length === 0) return false;
|
|
261
|
+
|
|
262
|
+
for (const task of remaining) {
|
|
263
|
+
if (task.status === 'in_progress') return false;
|
|
264
|
+
const blocked = await isTaskBlocked(task.id, sprintId);
|
|
265
|
+
if (!blocked) return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Sequential execution loop - executes tasks one at a time.
|
|
272
|
+
* Used for session mode, step mode, or --concurrency 1.
|
|
273
|
+
*/
|
|
274
|
+
export async function executeTaskLoop(
|
|
275
|
+
sprintId: string,
|
|
276
|
+
options: ExecutorOptions,
|
|
277
|
+
checkResults?: CheckResults
|
|
278
|
+
): Promise<ExecutionSummary> {
|
|
279
|
+
// Install signal handlers eagerly so Ctrl+C works before the first child spawns
|
|
280
|
+
ProcessManager.getInstance().ensureHandlers();
|
|
281
|
+
|
|
282
|
+
// Resolve provider once for the entire loop
|
|
283
|
+
const provider = await getActiveProvider();
|
|
284
|
+
const label = provider.displayName;
|
|
285
|
+
|
|
286
|
+
const sprint = await getSprint(sprintId);
|
|
287
|
+
let completedCount = 0;
|
|
288
|
+
const targetCount = options.count ?? Infinity;
|
|
289
|
+
|
|
290
|
+
// Check for resumability - find in_progress task
|
|
291
|
+
const firstTask = await getNextTask(sprintId);
|
|
292
|
+
if (firstTask?.status === 'in_progress') {
|
|
293
|
+
console.log(warning(`\nResuming from: ${firstTask.id} - ${firstTask.name}`));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Main implementation loop
|
|
297
|
+
while (completedCount < targetCount) {
|
|
298
|
+
// Break immediately if shutdown is in progress (Ctrl+C)
|
|
299
|
+
const manager = ProcessManager.getInstance();
|
|
300
|
+
if (manager.isShuttingDown()) {
|
|
301
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
302
|
+
return {
|
|
303
|
+
completed: completedCount,
|
|
304
|
+
remaining: remaining.length,
|
|
305
|
+
stopReason: 'task_blocked',
|
|
306
|
+
blockedTask: null,
|
|
307
|
+
blockedReason: 'Interrupted by user',
|
|
308
|
+
exitCode: EXIT_ERROR,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const task = await getNextTask(sprintId);
|
|
313
|
+
|
|
314
|
+
if (!task) {
|
|
315
|
+
// Check if all remaining tasks are blocked
|
|
316
|
+
if (await areAllRemainingBlocked(sprintId)) {
|
|
317
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
318
|
+
return {
|
|
319
|
+
completed: completedCount,
|
|
320
|
+
remaining: remaining.length,
|
|
321
|
+
stopReason: 'all_blocked',
|
|
322
|
+
blockedTask: null,
|
|
323
|
+
blockedReason: 'All remaining tasks are blocked by dependencies',
|
|
324
|
+
exitCode: EXIT_ALL_BLOCKED,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Truly no tasks
|
|
329
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
330
|
+
if (remaining.length === 0 && completedCount === 0) {
|
|
331
|
+
return {
|
|
332
|
+
completed: 0,
|
|
333
|
+
remaining: 0,
|
|
334
|
+
stopReason: 'no_tasks',
|
|
335
|
+
blockedTask: null,
|
|
336
|
+
blockedReason: null,
|
|
337
|
+
exitCode: EXIT_NO_TASKS,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log(success('\nAll tasks completed!'));
|
|
342
|
+
return {
|
|
343
|
+
completed: completedCount,
|
|
344
|
+
remaining: 0,
|
|
345
|
+
stopReason: 'all_completed',
|
|
346
|
+
blockedTask: null,
|
|
347
|
+
blockedReason: null,
|
|
348
|
+
exitCode: EXIT_SUCCESS,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(info(`\n--- Task ${String(task.order)}: ${task.name} ---`));
|
|
353
|
+
console.log(info('ID: ') + task.id);
|
|
354
|
+
console.log(info('Project: ') + task.projectPath);
|
|
355
|
+
console.log(info('Status: ') + formatTaskStatus(task.status));
|
|
356
|
+
|
|
357
|
+
// Mark as in_progress if not already
|
|
358
|
+
if (task.status !== 'in_progress') {
|
|
359
|
+
await updateTaskStatus(task.id, 'in_progress', sprintId);
|
|
360
|
+
console.log(muted('Status updated to: in_progress'));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Get project for the task (if available)
|
|
364
|
+
const project = await getProjectForTask(task, sprint);
|
|
365
|
+
|
|
366
|
+
// Build context for AI provider
|
|
367
|
+
const ctx: TaskContext = { sprint, task, project };
|
|
368
|
+
const taskPrompt = formatTask(ctx);
|
|
369
|
+
|
|
370
|
+
// Run permission check (only on first task of the loop)
|
|
371
|
+
if (completedCount === 0) {
|
|
372
|
+
runPermissionCheck(ctx, options.noCommit, provider.name);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Branch verification (if sprint has a branch set)
|
|
376
|
+
if (sprint.branch) {
|
|
377
|
+
if (!verifySprintBranch(task.projectPath, sprint.branch)) {
|
|
378
|
+
console.log(warning(`\nBranch verification failed: expected '${sprint.branch}' in ${task.projectPath}`));
|
|
379
|
+
console.log(muted(`Task ${task.id} remains in_progress.`));
|
|
380
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
381
|
+
return {
|
|
382
|
+
completed: completedCount,
|
|
383
|
+
remaining: remaining.length,
|
|
384
|
+
stopReason: 'task_blocked',
|
|
385
|
+
blockedTask: task,
|
|
386
|
+
blockedReason: `Repository ${task.projectPath} is not on expected branch '${sprint.branch}'`,
|
|
387
|
+
exitCode: EXIT_ERROR,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (options.session) {
|
|
393
|
+
console.log(highlight(`\n[Task Context for ${label}]`));
|
|
394
|
+
console.log(muted('─'.repeat(50)));
|
|
395
|
+
console.log(taskPrompt);
|
|
396
|
+
console.log(muted('─'.repeat(50)));
|
|
397
|
+
console.log(muted(`\nStarting ${label} in ${task.projectPath} (session)...\n`));
|
|
398
|
+
} else {
|
|
399
|
+
console.log(muted(`Starting ${label} in ${task.projectPath} (headless)...`));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Execute task with AI provider
|
|
403
|
+
const result = await executeTask(ctx, options, sprintId, undefined, provider, checkResults?.get(task.projectPath));
|
|
404
|
+
|
|
405
|
+
if (!result.success) {
|
|
406
|
+
console.log(warning('\nTask not completed.'));
|
|
407
|
+
if (result.blockedReason) {
|
|
408
|
+
console.log(warning(`Reason: ${result.blockedReason}`));
|
|
409
|
+
}
|
|
410
|
+
console.log(muted('\nExecution paused. Task remains in_progress.'));
|
|
411
|
+
console.log(muted(`Resume with: ralphctl sprint start ${sprintId}\n`));
|
|
412
|
+
|
|
413
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
414
|
+
return {
|
|
415
|
+
completed: completedCount,
|
|
416
|
+
remaining: remaining.length,
|
|
417
|
+
stopReason: 'task_blocked',
|
|
418
|
+
blockedTask: task,
|
|
419
|
+
blockedReason: result.blockedReason ?? 'Unknown reason',
|
|
420
|
+
exitCode: EXIT_ERROR,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Store verification result if available
|
|
425
|
+
if (result.verified) {
|
|
426
|
+
await updateTask(
|
|
427
|
+
task.id,
|
|
428
|
+
{
|
|
429
|
+
verified: true,
|
|
430
|
+
verificationOutput: result.verificationOutput,
|
|
431
|
+
},
|
|
432
|
+
sprintId
|
|
433
|
+
);
|
|
434
|
+
console.log(success('Verification: passed'));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Post-task check hook — run checkScript as a gate before marking done
|
|
438
|
+
const checkScript = getEffectiveCheckScript(project, task.projectPath);
|
|
439
|
+
if (checkScript) {
|
|
440
|
+
console.log(muted(`Running post-task check: ${checkScript}`));
|
|
441
|
+
const hookResult = runLifecycleHook(task.projectPath, checkScript, 'taskComplete');
|
|
442
|
+
if (!hookResult.passed) {
|
|
443
|
+
console.log(warning(`\nPost-task check failed for: ${task.name}`));
|
|
444
|
+
console.log(muted('Task remains in_progress. Execution paused.'));
|
|
445
|
+
console.log(muted(`Resume with: ralphctl sprint start ${sprintId}\n`));
|
|
446
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
447
|
+
return {
|
|
448
|
+
completed: completedCount,
|
|
449
|
+
remaining: remaining.length,
|
|
450
|
+
stopReason: 'task_blocked',
|
|
451
|
+
blockedTask: task,
|
|
452
|
+
blockedReason: `Post-task check failed: ${hookResult.output.slice(0, 500)}`,
|
|
453
|
+
exitCode: EXIT_ERROR,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
console.log(success('Post-task check: passed'));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Update task status: in_progress → done
|
|
460
|
+
await updateTaskStatus(task.id, 'done', sprintId);
|
|
461
|
+
console.log(success('Status updated to: done'));
|
|
462
|
+
|
|
463
|
+
// Log automatic progress
|
|
464
|
+
await logProgress(
|
|
465
|
+
`Completed task: ${task.id} - ${task.name}\n\n` +
|
|
466
|
+
(task.description ? `Description: ${task.description}\n` : '') +
|
|
467
|
+
(task.steps.length > 0 ? `Steps:\n${task.steps.map((s, i) => ` ${String(i + 1)}. ${s}`).join('\n')}` : ''),
|
|
468
|
+
{ sprintId, projectPath: task.projectPath }
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
completedCount++;
|
|
472
|
+
|
|
473
|
+
// Interactive mode: confirm before continuing
|
|
474
|
+
if (options.step && completedCount < targetCount) {
|
|
475
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
476
|
+
if (remaining.length > 0) {
|
|
477
|
+
console.log(info(`\n${String(remaining.length)} task(s) remaining.`));
|
|
478
|
+
const continueLoop = await confirm({
|
|
479
|
+
message: 'Continue to next task?',
|
|
480
|
+
default: true,
|
|
481
|
+
});
|
|
482
|
+
if (!continueLoop) {
|
|
483
|
+
console.log(muted('\nExecution paused.'));
|
|
484
|
+
console.log(muted(`Resume with: ralphctl sprint start ${sprintId}\n`));
|
|
485
|
+
return {
|
|
486
|
+
completed: completedCount,
|
|
487
|
+
remaining: remaining.length,
|
|
488
|
+
stopReason: 'user_paused',
|
|
489
|
+
blockedTask: null,
|
|
490
|
+
blockedReason: null,
|
|
491
|
+
exitCode: EXIT_SUCCESS,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Reached count limit
|
|
499
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
500
|
+
return {
|
|
501
|
+
completed: completedCount,
|
|
502
|
+
remaining: remaining.length,
|
|
503
|
+
stopReason: remaining.length === 0 ? 'all_completed' : 'count_reached',
|
|
504
|
+
blockedTask: null,
|
|
505
|
+
blockedReason: null,
|
|
506
|
+
exitCode: EXIT_SUCCESS,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ============================================================================
|
|
511
|
+
// PARALLEL EXECUTION LOOP
|
|
512
|
+
// ============================================================================
|
|
513
|
+
|
|
514
|
+
interface ParallelTaskResult {
|
|
515
|
+
task: Task;
|
|
516
|
+
result: TaskExecutionResult | null;
|
|
517
|
+
error: Error | null;
|
|
518
|
+
/** Whether this failure is a rate limit (should retry, not count as failure) */
|
|
519
|
+
isRateLimited: boolean;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Pick tasks to launch: one per unique projectPath, respecting concurrency limit.
|
|
524
|
+
* Excludes repos that already have an in-flight task.
|
|
525
|
+
*/
|
|
526
|
+
function pickTasksToLaunch(
|
|
527
|
+
readyTasks: Task[],
|
|
528
|
+
inFlightPaths: Set<string>,
|
|
529
|
+
concurrencyLimit: number,
|
|
530
|
+
currentInFlight: number
|
|
531
|
+
): Task[] {
|
|
532
|
+
const available = readyTasks.filter((t) => !inFlightPaths.has(t.projectPath));
|
|
533
|
+
|
|
534
|
+
// Deduplicate by projectPath — pick the first (lowest order) task per repo
|
|
535
|
+
const byPath = new Map<string, Task>();
|
|
536
|
+
for (const task of available) {
|
|
537
|
+
if (!byPath.has(task.projectPath)) {
|
|
538
|
+
byPath.set(task.projectPath, task);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const candidates = [...byPath.values()];
|
|
543
|
+
const slotsAvailable = concurrencyLimit - currentInFlight;
|
|
544
|
+
return candidates.slice(0, Math.max(0, slotsAvailable));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Parallel execution loop - runs tasks concurrently across different repos.
|
|
549
|
+
* At most one task per projectPath runs at a time to avoid git conflicts.
|
|
550
|
+
*/
|
|
551
|
+
export async function executeTaskLoopParallel(
|
|
552
|
+
sprintId: string,
|
|
553
|
+
options: ExecutorOptions,
|
|
554
|
+
checkResults?: CheckResults
|
|
555
|
+
): Promise<ExecutionSummary> {
|
|
556
|
+
// Install signal handlers eagerly so Ctrl+C works before the first child spawns
|
|
557
|
+
ProcessManager.getInstance().ensureHandlers();
|
|
558
|
+
|
|
559
|
+
// Resolve provider once for the entire loop
|
|
560
|
+
const provider = await getActiveProvider();
|
|
561
|
+
const label = provider.displayName;
|
|
562
|
+
|
|
563
|
+
const sprint = await getSprint(sprintId);
|
|
564
|
+
let completedCount = 0;
|
|
565
|
+
const targetCount = options.count ?? Infinity;
|
|
566
|
+
const failFast = options.failFast ?? true;
|
|
567
|
+
let hasFailed = false;
|
|
568
|
+
let firstBlockedTask: Task | null = null;
|
|
569
|
+
let firstBlockedReason: string | null = null;
|
|
570
|
+
|
|
571
|
+
// Determine concurrency limit (hard cap prevents resource exhaustion)
|
|
572
|
+
const MAX_CONCURRENCY = 10;
|
|
573
|
+
const allTasks = await getTasks(sprintId);
|
|
574
|
+
const uniqueRepoPaths = new Set(allTasks.map((t) => t.projectPath));
|
|
575
|
+
const concurrencyLimit = Math.min(options.concurrency ?? uniqueRepoPaths.size, MAX_CONCURRENCY);
|
|
576
|
+
|
|
577
|
+
console.log(muted(`Parallel mode: up to ${String(concurrencyLimit)} concurrent task(s)`));
|
|
578
|
+
|
|
579
|
+
// Set up rate limit coordinator
|
|
580
|
+
const coordinator = new RateLimitCoordinator({
|
|
581
|
+
onPause: (delayMs) => {
|
|
582
|
+
console.log(warning(`\nRate limited. Pausing new launches for ${String(Math.round(delayMs / 1000))}s...`));
|
|
583
|
+
},
|
|
584
|
+
onResume: () => {
|
|
585
|
+
console.log(success('Rate limit cooldown ended. Resuming launches.'));
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Track in-flight tasks and session IDs for resume
|
|
590
|
+
const inFlightPaths = new Set<string>();
|
|
591
|
+
const running = new Map<string, Promise<ParallelTaskResult>>();
|
|
592
|
+
const taskSessionIds = new Map<string, string>(); // taskId → AI session ID
|
|
593
|
+
const branchRetries = new Map<string, number>(); // taskId → branch verification attempts
|
|
594
|
+
const MAX_BRANCH_RETRIES = 3;
|
|
595
|
+
let permissionCheckDone = false;
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
// Check for resumable in_progress tasks
|
|
599
|
+
const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress');
|
|
600
|
+
if (inProgressTasks.length > 0) {
|
|
601
|
+
console.log(warning(`\nResuming ${String(inProgressTasks.length)} in-progress task(s):`));
|
|
602
|
+
for (const t of inProgressTasks) {
|
|
603
|
+
console.log(warning(` - ${t.id}: ${t.name}`));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
while (completedCount < targetCount) {
|
|
608
|
+
// Break immediately if shutdown is in progress (Ctrl+C)
|
|
609
|
+
const manager = ProcessManager.getInstance();
|
|
610
|
+
if (manager.isShuttingDown()) {
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Wait if rate limited before checking for new tasks
|
|
615
|
+
await coordinator.waitIfPaused();
|
|
616
|
+
|
|
617
|
+
// Get current task state from disk
|
|
618
|
+
const readyTasks = await getReadyTasks(sprintId);
|
|
619
|
+
|
|
620
|
+
// Also check for in_progress tasks (resumable)
|
|
621
|
+
const currentTasks = await getTasks(sprintId);
|
|
622
|
+
const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && !running.has(t.id));
|
|
623
|
+
|
|
624
|
+
// Combine: resume in_progress first, then ready tasks
|
|
625
|
+
const launchCandidates = [...inProgress, ...readyTasks.filter((t) => !inProgress.some((ip) => ip.id === t.id))];
|
|
626
|
+
|
|
627
|
+
if (launchCandidates.length === 0 && running.size === 0) {
|
|
628
|
+
// Nothing to run and nothing in flight
|
|
629
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
630
|
+
if (remaining.length === 0) {
|
|
631
|
+
if (completedCount === 0) {
|
|
632
|
+
return {
|
|
633
|
+
completed: 0,
|
|
634
|
+
remaining: 0,
|
|
635
|
+
stopReason: 'no_tasks',
|
|
636
|
+
blockedTask: null,
|
|
637
|
+
blockedReason: null,
|
|
638
|
+
exitCode: EXIT_NO_TASKS,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
console.log(success('\nAll tasks completed!'));
|
|
642
|
+
return {
|
|
643
|
+
completed: completedCount,
|
|
644
|
+
remaining: 0,
|
|
645
|
+
stopReason: 'all_completed',
|
|
646
|
+
blockedTask: null,
|
|
647
|
+
blockedReason: null,
|
|
648
|
+
exitCode: EXIT_SUCCESS,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Tasks exist but none are launchable — all blocked
|
|
653
|
+
return {
|
|
654
|
+
completed: completedCount,
|
|
655
|
+
remaining: remaining.length,
|
|
656
|
+
stopReason: hasFailed ? 'task_blocked' : 'all_blocked',
|
|
657
|
+
blockedTask: firstBlockedTask,
|
|
658
|
+
blockedReason: firstBlockedReason ?? 'All remaining tasks are blocked by dependencies',
|
|
659
|
+
exitCode: hasFailed ? EXIT_ERROR : EXIT_ALL_BLOCKED,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Pick tasks to launch (if we should)
|
|
664
|
+
if (!hasFailed || !failFast) {
|
|
665
|
+
const toStart = pickTasksToLaunch(launchCandidates, inFlightPaths, concurrencyLimit, running.size);
|
|
666
|
+
|
|
667
|
+
for (const task of toStart) {
|
|
668
|
+
if (completedCount + running.size >= targetCount) break;
|
|
669
|
+
|
|
670
|
+
// Cache project lookup — reused for permission check and execution
|
|
671
|
+
const project = await getProjectForTask(task, sprint);
|
|
672
|
+
|
|
673
|
+
// Run permission check once (before any task starts)
|
|
674
|
+
if (!permissionCheckDone) {
|
|
675
|
+
const ctx: TaskContext = { sprint, task, project };
|
|
676
|
+
runPermissionCheck(ctx, options.noCommit, provider.name);
|
|
677
|
+
permissionCheckDone = true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Branch verification (if sprint has a branch set)
|
|
681
|
+
if (sprint.branch) {
|
|
682
|
+
if (!verifySprintBranch(task.projectPath, sprint.branch)) {
|
|
683
|
+
const attempt = (branchRetries.get(task.id) ?? 0) + 1;
|
|
684
|
+
branchRetries.set(task.id, attempt);
|
|
685
|
+
|
|
686
|
+
if (attempt < MAX_BRANCH_RETRIES) {
|
|
687
|
+
// Transient failure — re-enqueue for retry (similar to rate-limited tasks)
|
|
688
|
+
console.log(
|
|
689
|
+
warning(
|
|
690
|
+
`\n Branch verification failed (attempt ${String(attempt)}/${String(MAX_BRANCH_RETRIES)}): expected '${sprint.branch}' in ${task.projectPath}`
|
|
691
|
+
)
|
|
692
|
+
);
|
|
693
|
+
console.log(muted(` Task ${task.id} will retry on next loop iteration.`));
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Exhausted retries — treat as a real failure
|
|
698
|
+
console.log(
|
|
699
|
+
warning(
|
|
700
|
+
`\n Branch verification failed after ${String(MAX_BRANCH_RETRIES)} attempts: expected '${sprint.branch}' in ${task.projectPath}`
|
|
701
|
+
)
|
|
702
|
+
);
|
|
703
|
+
console.log(muted(` Task ${task.id} not started — wrong branch.`));
|
|
704
|
+
hasFailed = true;
|
|
705
|
+
if (!firstBlockedTask) {
|
|
706
|
+
firstBlockedTask = task;
|
|
707
|
+
firstBlockedReason = `Repository ${task.projectPath} is not on expected branch '${sprint.branch}'`;
|
|
708
|
+
}
|
|
709
|
+
if (failFast) {
|
|
710
|
+
console.log(muted('Fail-fast: waiting for running tasks to finish...'));
|
|
711
|
+
}
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Mark as in_progress only after pre-flight passes
|
|
717
|
+
if (task.status !== 'in_progress') {
|
|
718
|
+
await updateTaskStatus(task.id, 'in_progress', sprintId);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Check if we have a session ID to resume from (rate-limit recovery)
|
|
722
|
+
const resumeId = taskSessionIds.get(task.id);
|
|
723
|
+
const action = resumeId ? 'Resuming' : 'Starting';
|
|
724
|
+
|
|
725
|
+
console.log(info(`\n--- ${action} task ${String(task.order)}: ${task.name} ---`));
|
|
726
|
+
console.log(info('ID: ') + task.id);
|
|
727
|
+
console.log(info('Project: ') + task.projectPath);
|
|
728
|
+
if (resumeId) {
|
|
729
|
+
console.log(muted(`Resuming ${label} session ${resumeId.slice(0, 8)}...`));
|
|
730
|
+
} else {
|
|
731
|
+
console.log(muted(`Starting ${label} in ${task.projectPath} (headless)...`));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
inFlightPaths.add(task.projectPath);
|
|
735
|
+
|
|
736
|
+
const taskPromise = (async (): Promise<ParallelTaskResult> => {
|
|
737
|
+
try {
|
|
738
|
+
const ctx: TaskContext = { sprint, task, project };
|
|
739
|
+
const result = await executeTask(
|
|
740
|
+
ctx,
|
|
741
|
+
options,
|
|
742
|
+
sprintId,
|
|
743
|
+
resumeId,
|
|
744
|
+
provider,
|
|
745
|
+
checkResults?.get(task.projectPath)
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
// Store session ID for potential future resume
|
|
749
|
+
if (result.sessionId) {
|
|
750
|
+
taskSessionIds.set(task.id, result.sessionId);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { task, result, error: null, isRateLimited: false };
|
|
754
|
+
} catch (err) {
|
|
755
|
+
if (err instanceof SpawnError && err.rateLimited) {
|
|
756
|
+
// Store session ID from error for resume after cooldown
|
|
757
|
+
if (err.sessionId) {
|
|
758
|
+
taskSessionIds.set(task.id, err.sessionId);
|
|
759
|
+
}
|
|
760
|
+
const delay = err.retryAfterMs ?? 60_000;
|
|
761
|
+
coordinator.pause(delay);
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
task,
|
|
765
|
+
result: null,
|
|
766
|
+
error: err,
|
|
767
|
+
isRateLimited: true,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
task,
|
|
773
|
+
result: null,
|
|
774
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
775
|
+
isRateLimited: false,
|
|
776
|
+
};
|
|
777
|
+
} finally {
|
|
778
|
+
inFlightPaths.delete(task.projectPath);
|
|
779
|
+
}
|
|
780
|
+
})();
|
|
781
|
+
|
|
782
|
+
running.set(task.id, taskPromise);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Wait for any task to complete
|
|
787
|
+
if (running.size === 0) {
|
|
788
|
+
// Check if any tasks are pending branch retry before giving up
|
|
789
|
+
const hasPendingBranchRetry = [...branchRetries.entries()].some(([, count]) => count < MAX_BRANCH_RETRIES);
|
|
790
|
+
if (hasPendingBranchRetry) {
|
|
791
|
+
// Brief delay before retrying to avoid tight-looping
|
|
792
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
// Nothing launched, nothing running, no retries pending — stop
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Wait for first task to complete, then check rate limit state and launch next batch
|
|
800
|
+
const settled = await Promise.race([...running.values()]);
|
|
801
|
+
running.delete(settled.task.id);
|
|
802
|
+
|
|
803
|
+
// Process the result
|
|
804
|
+
if (settled.error) {
|
|
805
|
+
if (settled.isRateLimited) {
|
|
806
|
+
// Rate limit — not a real failure, will be re-queued after cooldown
|
|
807
|
+
const sessionId = taskSessionIds.get(settled.task.id);
|
|
808
|
+
console.log(warning(`\nRate limited: ${settled.task.name}`));
|
|
809
|
+
if (sessionId) {
|
|
810
|
+
console.log(muted(`Session saved for resume: ${sessionId.slice(0, 8)}...`));
|
|
811
|
+
}
|
|
812
|
+
console.log(muted('Will retry after cooldown.'));
|
|
813
|
+
// Don't set hasFailed — this task will be re-launched on next loop iteration
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Real error
|
|
818
|
+
console.log(warning(`\nTask failed: ${settled.task.name}`));
|
|
819
|
+
console.log(warning(`Error: ${settled.error.message}`));
|
|
820
|
+
console.log(muted(`Task ${settled.task.id} remains in_progress for resumption.`));
|
|
821
|
+
|
|
822
|
+
hasFailed = true;
|
|
823
|
+
if (!firstBlockedTask) {
|
|
824
|
+
firstBlockedTask = settled.task;
|
|
825
|
+
firstBlockedReason = settled.error.message;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (failFast) {
|
|
829
|
+
console.log(muted('Fail-fast: waiting for running tasks to finish...'));
|
|
830
|
+
}
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (settled.result && !settled.result.success) {
|
|
835
|
+
console.log(warning(`\nTask not completed: ${settled.task.name}`));
|
|
836
|
+
if (settled.result.blockedReason) {
|
|
837
|
+
console.log(warning(`Reason: ${settled.result.blockedReason}`));
|
|
838
|
+
}
|
|
839
|
+
console.log(muted(`Task ${settled.task.id} remains in_progress.`));
|
|
840
|
+
|
|
841
|
+
hasFailed = true;
|
|
842
|
+
if (!firstBlockedTask) {
|
|
843
|
+
firstBlockedTask = settled.task;
|
|
844
|
+
firstBlockedReason = settled.result.blockedReason ?? 'Unknown reason';
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (failFast) {
|
|
848
|
+
console.log(muted('Fail-fast: waiting for running tasks to finish...'));
|
|
849
|
+
}
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Task completed successfully
|
|
854
|
+
if (settled.result) {
|
|
855
|
+
// Store verification result
|
|
856
|
+
if (settled.result.verified) {
|
|
857
|
+
await updateTask(
|
|
858
|
+
settled.task.id,
|
|
859
|
+
{
|
|
860
|
+
verified: true,
|
|
861
|
+
verificationOutput: settled.result.verificationOutput,
|
|
862
|
+
},
|
|
863
|
+
sprintId
|
|
864
|
+
);
|
|
865
|
+
console.log(success(`Verification passed: ${settled.task.name}`));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Post-task check hook
|
|
869
|
+
const taskProject = await getProjectForTask(settled.task, sprint);
|
|
870
|
+
const taskCheckScript = getEffectiveCheckScript(taskProject, settled.task.projectPath);
|
|
871
|
+
if (taskCheckScript) {
|
|
872
|
+
const hookResult = runLifecycleHook(settled.task.projectPath, taskCheckScript, 'taskComplete');
|
|
873
|
+
if (!hookResult.passed) {
|
|
874
|
+
console.log(warning(`\nPost-task check failed for: ${settled.task.name}`));
|
|
875
|
+
console.log(muted(`Task ${settled.task.id} remains in_progress.`));
|
|
876
|
+
hasFailed = true;
|
|
877
|
+
if (!firstBlockedTask) {
|
|
878
|
+
firstBlockedTask = settled.task;
|
|
879
|
+
firstBlockedReason = `Post-task check failed: ${hookResult.output.slice(0, 500)}`;
|
|
880
|
+
}
|
|
881
|
+
if (failFast) {
|
|
882
|
+
console.log(muted('Fail-fast: waiting for running tasks to finish...'));
|
|
883
|
+
}
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
console.log(success(`Post-task check passed: ${settled.task.name}`));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Mark done
|
|
890
|
+
await updateTaskStatus(settled.task.id, 'done', sprintId);
|
|
891
|
+
console.log(success(`Completed: ${settled.task.name}`));
|
|
892
|
+
|
|
893
|
+
// Clean up session tracking
|
|
894
|
+
taskSessionIds.delete(settled.task.id);
|
|
895
|
+
|
|
896
|
+
// Log progress
|
|
897
|
+
await logProgress(
|
|
898
|
+
`Completed task: ${settled.task.id} - ${settled.task.name}\n\n` +
|
|
899
|
+
(settled.task.description ? `Description: ${settled.task.description}\n` : '') +
|
|
900
|
+
(settled.task.steps.length > 0
|
|
901
|
+
? `Steps:\n${settled.task.steps.map((s, i) => ` ${String(i + 1)}. ${s}`).join('\n')}`
|
|
902
|
+
: ''),
|
|
903
|
+
{ sprintId, projectPath: settled.task.projectPath }
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
completedCount++;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Wait for any remaining in-flight tasks
|
|
911
|
+
if (running.size > 0) {
|
|
912
|
+
console.log(muted(`\nWaiting for ${String(running.size)} remaining task(s)...`));
|
|
913
|
+
const remaining = await Promise.allSettled([...running.values()]);
|
|
914
|
+
for (const r of remaining) {
|
|
915
|
+
if (r.status === 'fulfilled' && r.value.result?.success) {
|
|
916
|
+
if (r.value.result.verified) {
|
|
917
|
+
await updateTask(
|
|
918
|
+
r.value.task.id,
|
|
919
|
+
{ verified: true, verificationOutput: r.value.result.verificationOutput },
|
|
920
|
+
sprintId
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
// Post-task check hook
|
|
924
|
+
const drainProject = await getProjectForTask(r.value.task, sprint);
|
|
925
|
+
const drainCheckScript = getEffectiveCheckScript(drainProject, r.value.task.projectPath);
|
|
926
|
+
if (drainCheckScript) {
|
|
927
|
+
const hookResult = runLifecycleHook(r.value.task.projectPath, drainCheckScript, 'taskComplete');
|
|
928
|
+
if (!hookResult.passed) {
|
|
929
|
+
console.log(warning(`Post-task check failed for: ${r.value.task.name}`));
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
await updateTaskStatus(r.value.task.id, 'done', sprintId);
|
|
934
|
+
console.log(success(`Completed: ${r.value.task.name}`));
|
|
935
|
+
await logProgress(`Completed task: ${r.value.task.id} - ${r.value.task.name}`, {
|
|
936
|
+
sprintId,
|
|
937
|
+
projectPath: r.value.task.projectPath,
|
|
938
|
+
});
|
|
939
|
+
completedCount++;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
} finally {
|
|
944
|
+
coordinator.dispose();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const remainingTasks = await getRemainingTasks(sprintId);
|
|
948
|
+
|
|
949
|
+
if (hasFailed) {
|
|
950
|
+
return {
|
|
951
|
+
completed: completedCount,
|
|
952
|
+
remaining: remainingTasks.length,
|
|
953
|
+
stopReason: 'task_blocked',
|
|
954
|
+
blockedTask: firstBlockedTask,
|
|
955
|
+
blockedReason: firstBlockedReason,
|
|
956
|
+
exitCode: EXIT_ERROR,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
completed: completedCount,
|
|
962
|
+
remaining: remainingTasks.length,
|
|
963
|
+
stopReason: remainingTasks.length === 0 ? 'all_completed' : 'count_reached',
|
|
964
|
+
blockedTask: null,
|
|
965
|
+
blockedReason: null,
|
|
966
|
+
exitCode: EXIT_SUCCESS,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Re-export for backward compatibility
|
|
971
|
+
export { formatTask as formatTaskContext } from '@src/ai/task-context.ts';
|
|
972
|
+
// Re-export TaskContext type for consumers
|
|
973
|
+
export type { TaskContext } from '@src/ai/task-context.ts';
|