create-claude-workspace 1.1.152 → 2.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/README.md +33 -1
- package/dist/index.js +29 -56
- package/dist/scheduler/agents/health-checker.mjs +98 -0
- package/dist/scheduler/agents/health-checker.spec.js +143 -0
- package/dist/scheduler/agents/orchestrator.mjs +149 -0
- package/dist/scheduler/agents/orchestrator.spec.js +87 -0
- package/dist/scheduler/agents/prompt-builder.mjs +204 -0
- package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
- package/dist/scheduler/agents/worker-pool.mjs +137 -0
- package/dist/scheduler/agents/worker-pool.spec.js +45 -0
- package/dist/scheduler/git/ci-watcher.mjs +93 -0
- package/dist/scheduler/git/ci-watcher.spec.js +35 -0
- package/dist/scheduler/git/manager.mjs +228 -0
- package/dist/scheduler/git/manager.spec.js +198 -0
- package/dist/scheduler/git/release.mjs +117 -0
- package/dist/scheduler/git/release.spec.js +175 -0
- package/dist/scheduler/index.mjs +309 -0
- package/dist/scheduler/index.spec.js +72 -0
- package/dist/scheduler/integration.spec.js +289 -0
- package/dist/scheduler/loop.mjs +435 -0
- package/dist/scheduler/loop.spec.js +139 -0
- package/dist/scheduler/state/session.mjs +14 -0
- package/dist/scheduler/state/session.spec.js +36 -0
- package/dist/scheduler/state/state.mjs +102 -0
- package/dist/scheduler/state/state.spec.js +175 -0
- package/dist/scheduler/tasks/inbox.mjs +98 -0
- package/dist/scheduler/tasks/inbox.spec.js +168 -0
- package/dist/scheduler/tasks/parser.mjs +228 -0
- package/dist/scheduler/tasks/parser.spec.js +303 -0
- package/dist/scheduler/tasks/queue.mjs +152 -0
- package/dist/scheduler/tasks/queue.spec.js +223 -0
- package/dist/scheduler/types.mjs +20 -0
- package/dist/scheduler/util/memory.mjs +126 -0
- package/dist/scheduler/util/memory.spec.js +165 -0
- package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
- package/package.json +3 -4
- package/dist/scripts/autonomous.mjs +0 -493
- package/dist/scripts/autonomous.spec.js +0 -46
- package/dist/scripts/docker-run.mjs +0 -462
- package/dist/scripts/integration.spec.js +0 -108
- package/dist/scripts/lib/formatter.mjs +0 -309
- package/dist/scripts/lib/formatter.spec.js +0 -262
- package/dist/scripts/lib/state.mjs +0 -44
- package/dist/scripts/lib/state.spec.js +0 -59
- package/dist/template/.claude/docker/.dockerignore +0 -8
- package/dist/template/.claude/docker/Dockerfile +0 -54
- package/dist/template/.claude/docker/docker-compose.yml +0 -22
- package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
- /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
- /package/dist/{scripts/lib → scheduler/ui}/tui.mjs +0 -0
- /package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.spec.js +0 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// ─── Main orchestration loop: pipeline state machine ───
|
|
2
|
+
// Picks tasks, assigns to workers, advances through plan→implement→test→review→commit→merge.
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { parseTodoMd, updateTaskCheckbox } from './tasks/parser.mjs';
|
|
6
|
+
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
|
|
7
|
+
import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
|
|
8
|
+
import { recordSession, getSession, clearSession } from './state/session.mjs';
|
|
9
|
+
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree } from './git/manager.mjs';
|
|
10
|
+
import { scanAgents } from './agents/health-checker.mjs';
|
|
11
|
+
import { watchPipeline, detectCIPlatform } from './git/ci-watcher.mjs';
|
|
12
|
+
import { createRelease } from './git/release.mjs';
|
|
13
|
+
import { processInbox, addTaskMessageToTask } from './tasks/inbox.mjs';
|
|
14
|
+
import { buildPlanPrompt, buildImplementPrompt, buildTestPrompt, buildReviewPrompt, buildReworkPrompt, } from './agents/prompt-builder.mjs';
|
|
15
|
+
const MAX_REVIEW_CYCLES = 5;
|
|
16
|
+
const MAX_BUILD_FIXES = 3;
|
|
17
|
+
const MAX_CI_FIXES = 3;
|
|
18
|
+
/**
|
|
19
|
+
* Run one iteration of the scheduler loop.
|
|
20
|
+
* Returns true if work was done, false if idle.
|
|
21
|
+
*/
|
|
22
|
+
export async function runIteration(deps) {
|
|
23
|
+
const { pool, orchestrator, state, opts, logger } = deps;
|
|
24
|
+
const projectDir = opts.projectDir;
|
|
25
|
+
// Rotate log if needed
|
|
26
|
+
rotateLog(projectDir);
|
|
27
|
+
// Process inbox — immediate, non-blocking
|
|
28
|
+
const inboxMessages = processInbox(projectDir);
|
|
29
|
+
for (const msg of inboxMessages) {
|
|
30
|
+
if (msg.type === 'add-task') {
|
|
31
|
+
const addMsg = msg;
|
|
32
|
+
const nextId = `inbox-${Date.now()}`;
|
|
33
|
+
const newTask = addTaskMessageToTask(addMsg, state.currentPhase, nextId);
|
|
34
|
+
logger.info(`[inbox] New task: ${newTask.title}`);
|
|
35
|
+
appendEvent(projectDir, createEvent('task_started', { taskId: nextId, detail: `inbox: ${newTask.title}` }));
|
|
36
|
+
// Task will be picked up when we load tasks below
|
|
37
|
+
}
|
|
38
|
+
else if (msg.type === 'message') {
|
|
39
|
+
const freeMsg = msg;
|
|
40
|
+
logger.info(`[inbox] User message: ${freeMsg.text}`);
|
|
41
|
+
// Forward to orchestrator for interpretation
|
|
42
|
+
try {
|
|
43
|
+
const slot = pool.idleSlot();
|
|
44
|
+
if (slot) {
|
|
45
|
+
await orchestrator.consult(`User sent this message during autonomous development:\n\n"${freeMsg.text}"\n\nAcknowledge and adjust your approach if needed. Respond briefly.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { /* best-effort */ }
|
|
49
|
+
}
|
|
50
|
+
else if (msg.type === 'stop') {
|
|
51
|
+
logger.info('[inbox] Stop requested by user');
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
else if (msg.type === 'pause') {
|
|
55
|
+
logger.info('[inbox] Pause requested — waiting for resume');
|
|
56
|
+
// Caller (scheduler.mts) handles pause state
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Load tasks
|
|
60
|
+
const todoPath = resolve(projectDir, '.claude/scheduler/tasks.json');
|
|
61
|
+
const todoMdPath = resolve(projectDir, 'TODO.md');
|
|
62
|
+
let tasks;
|
|
63
|
+
if (existsSync(todoPath)) {
|
|
64
|
+
tasks = loadTasksJson(todoPath);
|
|
65
|
+
}
|
|
66
|
+
else if (existsSync(todoMdPath)) {
|
|
67
|
+
// Fallback: parse TODO.md
|
|
68
|
+
const content = readFileSync(todoMdPath, 'utf-8');
|
|
69
|
+
tasks = parseTodoMd(content);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
logger.info('No tasks found (no tasks.json or TODO.md)');
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// Check project completion
|
|
76
|
+
if (isProjectComplete(tasks)) {
|
|
77
|
+
logger.info('All tasks complete.');
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// Scan agents
|
|
81
|
+
const agents = scanAgents(projectDir);
|
|
82
|
+
// Build dependency graph
|
|
83
|
+
const graph = buildGraph(tasks);
|
|
84
|
+
const cycle = graph.findCycle();
|
|
85
|
+
if (cycle) {
|
|
86
|
+
logger.error(`Dependency cycle detected: ${cycle.join(' → ')}`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
// Get runnable tasks
|
|
90
|
+
const runnable = graph.runnable();
|
|
91
|
+
if (runnable.length === 0) {
|
|
92
|
+
logger.info('No runnable tasks (all blocked by dependencies)');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
// Check for phase transition
|
|
96
|
+
const currentPhase = state.currentPhase;
|
|
97
|
+
if (isPhaseComplete(tasks, currentPhase)) {
|
|
98
|
+
const next = getNextPhase(tasks);
|
|
99
|
+
if (next !== null && next !== currentPhase) {
|
|
100
|
+
logger.info(`Phase ${currentPhase} complete → advancing to Phase ${next}`);
|
|
101
|
+
state.currentPhase = next;
|
|
102
|
+
// Release for completed phase
|
|
103
|
+
const phaseTasks = tasks.filter(t => t.phase === currentPhase && t.status === 'done');
|
|
104
|
+
if (phaseTasks.length > 0) {
|
|
105
|
+
const release = createRelease(projectDir, phaseTasks, currentPhase);
|
|
106
|
+
if (release) {
|
|
107
|
+
logger.info(`Release ${release.version} created for Phase ${currentPhase}`);
|
|
108
|
+
appendEvent(projectDir, createEvent('release', { detail: release.version }));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
appendEvent(projectDir, createEvent('phase_transition', { detail: `Phase ${currentPhase} → ${next}` }));
|
|
112
|
+
writeState(projectDir, state);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Get parallel batches
|
|
116
|
+
const batches = getParallelBatches(runnable);
|
|
117
|
+
if (batches.length === 0)
|
|
118
|
+
return false;
|
|
119
|
+
const batch = batches[0];
|
|
120
|
+
const availableSlots = opts.concurrency - pool.activeCount();
|
|
121
|
+
const tasksToRun = batch.slice(0, availableSlots);
|
|
122
|
+
const queued = batch.length - tasksToRun.length;
|
|
123
|
+
if (tasksToRun.length === 0) {
|
|
124
|
+
logger.info(`All ${opts.concurrency} workers busy. ${batch.length} tasks queued.`);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (queued > 0) {
|
|
128
|
+
logger.info(`Starting ${tasksToRun.length} tasks, ${queued} queued (waiting for workers)`);
|
|
129
|
+
}
|
|
130
|
+
// Process each task through the pipeline
|
|
131
|
+
let workDone = false;
|
|
132
|
+
for (const task of tasksToRun) {
|
|
133
|
+
const slot = pool.idleSlot();
|
|
134
|
+
if (!slot)
|
|
135
|
+
break;
|
|
136
|
+
try {
|
|
137
|
+
const success = await runTaskPipeline(task, slot.id, agents, deps);
|
|
138
|
+
workDone = true;
|
|
139
|
+
if (success) {
|
|
140
|
+
task.status = 'done';
|
|
141
|
+
state.completedTasks.push(task.id);
|
|
142
|
+
updateTodoFile(projectDir, task, 'done');
|
|
143
|
+
appendEvent(projectDir, createEvent('task_completed', { taskId: task.id }));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
task.status = 'skipped';
|
|
147
|
+
state.skippedTasks.push(task.id);
|
|
148
|
+
updateTodoFile(projectDir, task, 'skipped');
|
|
149
|
+
appendEvent(projectDir, createEvent('task_skipped', { taskId: task.id }));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
logger.error(`Task ${task.id} failed: ${err.message}`);
|
|
154
|
+
task.status = 'skipped';
|
|
155
|
+
state.skippedTasks.push(task.id);
|
|
156
|
+
updateTodoFile(projectDir, task, 'skipped');
|
|
157
|
+
appendEvent(projectDir, createEvent('error', { taskId: task.id, detail: err.message }));
|
|
158
|
+
}
|
|
159
|
+
writeState(projectDir, state);
|
|
160
|
+
}
|
|
161
|
+
state.iteration++;
|
|
162
|
+
writeState(projectDir, state);
|
|
163
|
+
return workDone;
|
|
164
|
+
}
|
|
165
|
+
// ─── Pipeline execution ───
|
|
166
|
+
async function runTaskPipeline(task, workerId, agents, deps) {
|
|
167
|
+
const { pool, orchestrator, state, opts, logger } = deps;
|
|
168
|
+
const projectDir = opts.projectDir;
|
|
169
|
+
// Create worktree
|
|
170
|
+
const slug = taskToSlug(task);
|
|
171
|
+
const worktreePath = createWorktree(projectDir, slug);
|
|
172
|
+
logger.info(`[${task.id}] Worktree created: ${slug}`);
|
|
173
|
+
// Initialize pipeline state
|
|
174
|
+
const pipeline = {
|
|
175
|
+
taskId: task.id,
|
|
176
|
+
workerId,
|
|
177
|
+
worktreePath,
|
|
178
|
+
step: 'plan',
|
|
179
|
+
architectPlan: null,
|
|
180
|
+
apiContract: null,
|
|
181
|
+
reviewFindings: null,
|
|
182
|
+
testingSection: null,
|
|
183
|
+
reviewCycles: 0,
|
|
184
|
+
ciFixes: 0,
|
|
185
|
+
buildFixes: 0,
|
|
186
|
+
assignedAgent: null,
|
|
187
|
+
};
|
|
188
|
+
state.pipelines[task.id] = pipeline;
|
|
189
|
+
try {
|
|
190
|
+
// Route task to agent
|
|
191
|
+
const routing = await orchestrator.routeTask(task, 'plan', agents);
|
|
192
|
+
pipeline.assignedAgent = routing.agent;
|
|
193
|
+
if (routing.create) {
|
|
194
|
+
// Auto-create new agent
|
|
195
|
+
await createAgentFile(projectDir, routing.create);
|
|
196
|
+
pipeline.assignedAgent = routing.create.name;
|
|
197
|
+
appendEvent(projectDir, createEvent('agent_created', { agentType: routing.create.name }));
|
|
198
|
+
}
|
|
199
|
+
// STEP 1: Plan
|
|
200
|
+
pipeline.step = 'plan';
|
|
201
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
|
|
202
|
+
const planResult = await spawnAgent(pool, workerId, {
|
|
203
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
204
|
+
cwd: worktreePath,
|
|
205
|
+
prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
|
|
206
|
+
model: getAgentModel(pipeline.assignedAgent, agents),
|
|
207
|
+
}, state, task.id, logger);
|
|
208
|
+
if (!planResult.success) {
|
|
209
|
+
logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
pipeline.architectPlan = planResult.output;
|
|
213
|
+
pipeline.testingSection = extractSection(planResult.output, 'TESTING');
|
|
214
|
+
// Check for split recommendation
|
|
215
|
+
if (task.complexity === 'L' && planResult.output.includes('SPLIT RECOMMENDATION') && !planResult.output.includes('No split needed')) {
|
|
216
|
+
logger.info(`[${task.id}] L-task split recommended — deferring to decomposition`);
|
|
217
|
+
// TODO: implement decomposition flow
|
|
218
|
+
}
|
|
219
|
+
// STEP 2: Implement
|
|
220
|
+
pipeline.step = 'implement';
|
|
221
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'implement' }));
|
|
222
|
+
const implResult = await spawnAgent(pool, workerId, {
|
|
223
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
224
|
+
cwd: worktreePath,
|
|
225
|
+
prompt: buildImplementPrompt({
|
|
226
|
+
task, worktreePath, projectDir,
|
|
227
|
+
architectPlan: pipeline.architectPlan,
|
|
228
|
+
apiContract: pipeline.apiContract ?? undefined,
|
|
229
|
+
}),
|
|
230
|
+
model: getAgentModel(pipeline.assignedAgent, agents),
|
|
231
|
+
}, state, task.id, logger);
|
|
232
|
+
if (!implResult.success) {
|
|
233
|
+
logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
// STEP 3: Test
|
|
237
|
+
pipeline.step = 'test';
|
|
238
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
|
|
239
|
+
const changedFiles = getChangedFiles(worktreePath);
|
|
240
|
+
const testRouting = await orchestrator.routeTask(task, 'test', agents);
|
|
241
|
+
const testResult = await spawnAgent(pool, workerId, {
|
|
242
|
+
agent: testRouting.agent ?? undefined,
|
|
243
|
+
cwd: worktreePath,
|
|
244
|
+
prompt: buildTestPrompt({
|
|
245
|
+
task, worktreePath, projectDir, changedFiles,
|
|
246
|
+
testingSection: pipeline.testingSection ?? undefined,
|
|
247
|
+
}),
|
|
248
|
+
model: getAgentModel(testRouting.agent, agents),
|
|
249
|
+
}, state, task.id, logger);
|
|
250
|
+
if (!testResult.success) {
|
|
251
|
+
pipeline.buildFixes++;
|
|
252
|
+
if (pipeline.buildFixes >= MAX_BUILD_FIXES) {
|
|
253
|
+
logger.error(`[${task.id}] Tests failed ${MAX_BUILD_FIXES} times — skipping`);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
// Try to fix via orchestrator
|
|
257
|
+
const decision = await orchestrator.handleFailure(task.title, 'test', testResult.error ?? 'Tests failed', pipeline.buildFixes);
|
|
258
|
+
if (decision.action === 'skip')
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
// STEP 4: Review
|
|
262
|
+
pipeline.step = 'review';
|
|
263
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'review' }));
|
|
264
|
+
const reviewRouting = await orchestrator.routeTask(task, 'review', agents);
|
|
265
|
+
let reviewPassed = false;
|
|
266
|
+
while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
|
|
267
|
+
pipeline.reviewCycles++;
|
|
268
|
+
const reviewResult = await spawnAgent(pool, workerId, {
|
|
269
|
+
agent: reviewRouting.agent ?? undefined,
|
|
270
|
+
cwd: worktreePath,
|
|
271
|
+
prompt: buildReviewPrompt({
|
|
272
|
+
task, worktreePath, projectDir,
|
|
273
|
+
changedFiles: getChangedFiles(worktreePath),
|
|
274
|
+
testingSection: pipeline.testingSection ?? undefined,
|
|
275
|
+
}),
|
|
276
|
+
model: getAgentModel(reviewRouting.agent, agents),
|
|
277
|
+
}, state, task.id, logger);
|
|
278
|
+
if (reviewResult.output.includes('**PASS**') || reviewResult.output.includes('PASS')) {
|
|
279
|
+
reviewPassed = true;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
pipeline.reviewFindings = reviewResult.output;
|
|
283
|
+
if (pipeline.reviewCycles >= MAX_REVIEW_CYCLES) {
|
|
284
|
+
logger.warn(`[${task.id}] Review limit (${MAX_REVIEW_CYCLES}) reached — proceeding anyway`);
|
|
285
|
+
reviewPassed = true;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// Rework
|
|
289
|
+
pipeline.step = 'rework';
|
|
290
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
|
|
291
|
+
await spawnAgent(pool, workerId, {
|
|
292
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
293
|
+
cwd: worktreePath,
|
|
294
|
+
prompt: buildReworkPrompt({
|
|
295
|
+
task, worktreePath, projectDir,
|
|
296
|
+
reviewFindings: pipeline.reviewFindings,
|
|
297
|
+
}),
|
|
298
|
+
model: getAgentModel(pipeline.assignedAgent, agents),
|
|
299
|
+
}, state, task.id, logger);
|
|
300
|
+
pipeline.step = 're-review';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// STEP 5: Commit
|
|
305
|
+
pipeline.step = 'commit';
|
|
306
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'commit' }));
|
|
307
|
+
const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
|
|
308
|
+
const sha = commitInWorktree(worktreePath, commitMsg);
|
|
309
|
+
if (!sha) {
|
|
310
|
+
logger.warn(`[${task.id}] Nothing to commit`);
|
|
311
|
+
}
|
|
312
|
+
// STEP 6: Push + CI watch
|
|
313
|
+
const ciPlatform = detectCIPlatform(projectDir);
|
|
314
|
+
if (ciPlatform !== 'none') {
|
|
315
|
+
const pushed = pushWorktree(worktreePath);
|
|
316
|
+
if (pushed) {
|
|
317
|
+
pipeline.step = 'ci-watch';
|
|
318
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'ci-watch' }));
|
|
319
|
+
const ciResult = await watchPipeline(slug, ciPlatform, projectDir, logger);
|
|
320
|
+
appendEvent(projectDir, createEvent('ci_status', { taskId: task.id, detail: ciResult.status }));
|
|
321
|
+
if (ciResult.status === 'failed' && pipeline.ciFixes < MAX_CI_FIXES) {
|
|
322
|
+
logger.warn(`[${task.id}] CI failed — fix attempt ${pipeline.ciFixes + 1}/${MAX_CI_FIXES}`);
|
|
323
|
+
pipeline.ciFixes++;
|
|
324
|
+
// Could spawn agent to fix CI, for now log and continue
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// STEP 7: Merge
|
|
329
|
+
pipeline.step = 'merge';
|
|
330
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'merge' }));
|
|
331
|
+
// Sync main before merge
|
|
332
|
+
syncMain(projectDir);
|
|
333
|
+
const mergeResult = mergeToMain(projectDir, slug);
|
|
334
|
+
if (mergeResult.success) {
|
|
335
|
+
logger.info(`[${task.id}] Merged to main (${mergeResult.sha?.slice(0, 7)})`);
|
|
336
|
+
appendEvent(projectDir, createEvent('merge_completed', { taskId: task.id, detail: mergeResult.sha ?? '' }));
|
|
337
|
+
}
|
|
338
|
+
else if (mergeResult.conflict) {
|
|
339
|
+
logger.warn(`[${task.id}] Merge conflict — consulting orchestrator`);
|
|
340
|
+
const decision = await orchestrator.handleMergeConflict(task.title, []);
|
|
341
|
+
if (decision.action === 'skip')
|
|
342
|
+
return false;
|
|
343
|
+
// For rebase: would need to rebase in worktree and retry
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
logger.error(`[${task.id}] Merge failed: ${mergeResult.error}`);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
// Cleanup
|
|
350
|
+
cleanupWorktree(projectDir, worktreePath, slug);
|
|
351
|
+
pipeline.step = 'done';
|
|
352
|
+
clearSession(state, task.id);
|
|
353
|
+
delete state.pipelines[task.id];
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
logger.error(`[${task.id}] Pipeline error: ${err.message}`);
|
|
358
|
+
delete state.pipelines[task.id];
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// ─── Helpers ───
|
|
363
|
+
async function spawnAgent(pool, slotId, opts, state, taskId, logger) {
|
|
364
|
+
// Check for existing session (resume on crash)
|
|
365
|
+
const existingSession = getSession(state, taskId);
|
|
366
|
+
const result = await pool.spawn(slotId, {
|
|
367
|
+
...opts,
|
|
368
|
+
resume: existingSession ?? undefined,
|
|
369
|
+
});
|
|
370
|
+
// Record session for crash recovery
|
|
371
|
+
if (result.sessionId) {
|
|
372
|
+
recordSession(state, taskId, result.sessionId);
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
function taskToSlug(task) {
|
|
377
|
+
const sanitized = task.title
|
|
378
|
+
.toLowerCase()
|
|
379
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
380
|
+
.replace(/^-|-$/g, '')
|
|
381
|
+
.slice(0, 40);
|
|
382
|
+
return `feat/${task.id}-${sanitized}`;
|
|
383
|
+
}
|
|
384
|
+
function getAgentModel(agentName, agents) {
|
|
385
|
+
if (!agentName)
|
|
386
|
+
return undefined;
|
|
387
|
+
const agent = agents.find(a => a.name === agentName);
|
|
388
|
+
return agent?.model;
|
|
389
|
+
}
|
|
390
|
+
function extractSection(output, section) {
|
|
391
|
+
const pattern = new RegExp(`### ${section}\\s*\\n([\\s\\S]*?)(?=\\n### |$)`, 'i');
|
|
392
|
+
const match = output.match(pattern);
|
|
393
|
+
return match ? match[1].trim() : null;
|
|
394
|
+
}
|
|
395
|
+
function loadTasksJson(path) {
|
|
396
|
+
const content = readFileSync(path, 'utf-8');
|
|
397
|
+
const data = JSON.parse(content);
|
|
398
|
+
if (data.phases) {
|
|
399
|
+
// Structured format
|
|
400
|
+
return data.phases.flatMap((phase) => (phase.tasks ?? []).map((t) => ({
|
|
401
|
+
...t,
|
|
402
|
+
phase: phase.id ?? 0,
|
|
403
|
+
dependsOn: t.dependsOn ?? [],
|
|
404
|
+
issueMarker: t.issueMarker ?? null,
|
|
405
|
+
kitUpgrade: t.kitUpgrade ?? false,
|
|
406
|
+
lineNumber: t.lineNumber ?? 0,
|
|
407
|
+
changelog: t.changelog ?? 'added',
|
|
408
|
+
})));
|
|
409
|
+
}
|
|
410
|
+
// Flat array format
|
|
411
|
+
return data;
|
|
412
|
+
}
|
|
413
|
+
function updateTodoFile(projectDir, task, status) {
|
|
414
|
+
const todoMdPath = resolve(projectDir, 'TODO.md');
|
|
415
|
+
if (task.lineNumber > 0 && existsSync(todoMdPath)) {
|
|
416
|
+
const content = readFileSync(todoMdPath, 'utf-8');
|
|
417
|
+
const updated = updateTaskCheckbox(content, task.lineNumber, status);
|
|
418
|
+
writeFileSync(todoMdPath, updated, 'utf-8');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function createAgentFile(projectDir, agent) {
|
|
422
|
+
const agentsDir = resolve(projectDir, '.claude', 'agents');
|
|
423
|
+
if (!existsSync(agentsDir))
|
|
424
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
425
|
+
const content = `---
|
|
426
|
+
name: ${agent.name}
|
|
427
|
+
description: ${agent.description}
|
|
428
|
+
model: ${agent.model}
|
|
429
|
+
steps: ${agent.steps.join(', ')}
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
${agent.prompt}
|
|
433
|
+
`;
|
|
434
|
+
writeFileSync(resolve(agentsDir, `${agent.name}.md`), content, 'utf-8');
|
|
435
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { emptyState } from './state/state.mjs';
|
|
3
|
+
// ─── Unit tests for helper functions ───
|
|
4
|
+
// The scheduler-loop module's main function (runIteration) requires full
|
|
5
|
+
// SDK + git infrastructure. Integration tests cover that.
|
|
6
|
+
// Here we test the pure logic helpers.
|
|
7
|
+
// We test the helper functions by importing them.
|
|
8
|
+
// Note: Since helpers are not exported, we test via the public interface
|
|
9
|
+
// or test the patterns used.
|
|
10
|
+
describe('scheduler-loop module structure', () => {
|
|
11
|
+
it('exports runIteration function', async () => {
|
|
12
|
+
const mod = await import('./loop.mjs');
|
|
13
|
+
expect(typeof mod.runIteration).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
// ─── Test taskToSlug pattern ───
|
|
17
|
+
describe('task slug generation', () => {
|
|
18
|
+
it('generates valid git branch names', () => {
|
|
19
|
+
// Test the pattern used by taskToSlug
|
|
20
|
+
const title = 'Create auth API endpoint';
|
|
21
|
+
const sanitized = title
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-|-$/g, '')
|
|
25
|
+
.slice(0, 40);
|
|
26
|
+
expect(sanitized).toBe('create-auth-api-endpoint');
|
|
27
|
+
expect(sanitized).not.toMatch(/[^a-z0-9-]/);
|
|
28
|
+
});
|
|
29
|
+
it('truncates long titles', () => {
|
|
30
|
+
const title = 'This is a very long task title that should be truncated for git branch names';
|
|
31
|
+
const sanitized = title
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
34
|
+
.replace(/^-|-$/g, '')
|
|
35
|
+
.slice(0, 40);
|
|
36
|
+
expect(sanitized.length).toBeLessThanOrEqual(40);
|
|
37
|
+
});
|
|
38
|
+
it('handles special characters', () => {
|
|
39
|
+
const title = 'Fix @scope/package (v2.0) — breaking!';
|
|
40
|
+
const sanitized = title
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-|-$/g, '')
|
|
44
|
+
.slice(0, 40);
|
|
45
|
+
expect(sanitized).toBe('fix-scope-package-v2-0-breaking');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
// ─── Test extractSection pattern ───
|
|
49
|
+
describe('section extraction', () => {
|
|
50
|
+
it('extracts a section from structured output', () => {
|
|
51
|
+
const output = `### EXISTING CODE
|
|
52
|
+
Some existing code info
|
|
53
|
+
|
|
54
|
+
### TESTING
|
|
55
|
+
Test the auth endpoint with mocked DB.
|
|
56
|
+
Skip E2E for this task.
|
|
57
|
+
|
|
58
|
+
### PITFALLS
|
|
59
|
+
Watch for race conditions.`;
|
|
60
|
+
const pattern = /### TESTING\s*\n([\s\S]*?)(?=\n### |$)/i;
|
|
61
|
+
const match = output.match(pattern);
|
|
62
|
+
expect(match).not.toBeNull();
|
|
63
|
+
expect(match[1].trim()).toBe('Test the auth endpoint with mocked DB.\nSkip E2E for this task.');
|
|
64
|
+
});
|
|
65
|
+
it('returns null when section not found', () => {
|
|
66
|
+
const output = '### OTHER\nSome content';
|
|
67
|
+
const pattern = /### TESTING\s*\n([\s\S]*?)(?=\n### |$)/i;
|
|
68
|
+
expect(output.match(pattern)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// ─── Test loadTasksJson pattern ───
|
|
72
|
+
describe('tasks.json loading', () => {
|
|
73
|
+
it('handles flat array format', () => {
|
|
74
|
+
const data = [
|
|
75
|
+
{ id: 'p0-1', title: 'Task 1', phase: 0, type: 'backend', complexity: 'S', status: 'todo' },
|
|
76
|
+
];
|
|
77
|
+
expect(data[0].id).toBe('p0-1');
|
|
78
|
+
});
|
|
79
|
+
it('handles structured phases format', () => {
|
|
80
|
+
const data = {
|
|
81
|
+
phases: [
|
|
82
|
+
{
|
|
83
|
+
id: 0,
|
|
84
|
+
name: 'Foundation',
|
|
85
|
+
tasks: [
|
|
86
|
+
{ id: 'p0-1', title: 'Init', type: 'fullstack', complexity: 'S', status: 'todo' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
const tasks = data.phases.flatMap(phase => (phase.tasks ?? []).map(t => ({ ...t, phase: phase.id })));
|
|
92
|
+
expect(tasks).toHaveLength(1);
|
|
93
|
+
expect(tasks[0].phase).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ─── State management ───
|
|
97
|
+
describe('pipeline state tracking', () => {
|
|
98
|
+
it('initializes pipeline with correct defaults', () => {
|
|
99
|
+
const state = emptyState(2);
|
|
100
|
+
state.pipelines['p1-1'] = {
|
|
101
|
+
taskId: 'p1-1',
|
|
102
|
+
workerId: 0,
|
|
103
|
+
worktreePath: '/tmp/wt',
|
|
104
|
+
step: 'plan',
|
|
105
|
+
architectPlan: null,
|
|
106
|
+
apiContract: null,
|
|
107
|
+
reviewFindings: null,
|
|
108
|
+
testingSection: null,
|
|
109
|
+
reviewCycles: 0,
|
|
110
|
+
ciFixes: 0,
|
|
111
|
+
buildFixes: 0,
|
|
112
|
+
assignedAgent: null,
|
|
113
|
+
};
|
|
114
|
+
expect(state.pipelines['p1-1'].step).toBe('plan');
|
|
115
|
+
expect(state.pipelines['p1-1'].reviewCycles).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
it('tracks review cycles', () => {
|
|
118
|
+
const pipeline = {
|
|
119
|
+
taskId: 'p1-1',
|
|
120
|
+
workerId: 0,
|
|
121
|
+
worktreePath: '/tmp/wt',
|
|
122
|
+
step: 'review',
|
|
123
|
+
architectPlan: null,
|
|
124
|
+
apiContract: null,
|
|
125
|
+
reviewFindings: null,
|
|
126
|
+
testingSection: null,
|
|
127
|
+
reviewCycles: 0,
|
|
128
|
+
ciFixes: 0,
|
|
129
|
+
buildFixes: 0,
|
|
130
|
+
assignedAgent: null,
|
|
131
|
+
};
|
|
132
|
+
pipeline.reviewCycles++;
|
|
133
|
+
expect(pipeline.reviewCycles).toBe(1);
|
|
134
|
+
pipeline.reviewCycles++;
|
|
135
|
+
expect(pipeline.reviewCycles).toBe(2);
|
|
136
|
+
// Max is 5
|
|
137
|
+
expect(pipeline.reviewCycles < 5).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ─── Session ID tracking per task for --resume on crash/restart ───
|
|
2
|
+
// Thin wrapper over SchedulerState.sessionMap.
|
|
3
|
+
export function recordSession(state, taskId, sessionId) {
|
|
4
|
+
state.sessionMap[taskId] = sessionId;
|
|
5
|
+
}
|
|
6
|
+
export function getSession(state, taskId) {
|
|
7
|
+
return state.sessionMap[taskId] ?? null;
|
|
8
|
+
}
|
|
9
|
+
export function clearSession(state, taskId) {
|
|
10
|
+
delete state.sessionMap[taskId];
|
|
11
|
+
}
|
|
12
|
+
export function clearAllSessions(state) {
|
|
13
|
+
state.sessionMap = {};
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { recordSession, getSession, clearSession, clearAllSessions } from './session.mjs';
|
|
3
|
+
import { emptyState } from './state.mjs';
|
|
4
|
+
describe('session-manager', () => {
|
|
5
|
+
it('records and retrieves a session', () => {
|
|
6
|
+
const state = emptyState(1);
|
|
7
|
+
recordSession(state, 'p1-1', 'session-abc');
|
|
8
|
+
expect(getSession(state, 'p1-1')).toBe('session-abc');
|
|
9
|
+
});
|
|
10
|
+
it('returns null for unknown task', () => {
|
|
11
|
+
const state = emptyState(1);
|
|
12
|
+
expect(getSession(state, 'p1-99')).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it('clears a specific session', () => {
|
|
15
|
+
const state = emptyState(1);
|
|
16
|
+
recordSession(state, 'p1-1', 'session-abc');
|
|
17
|
+
recordSession(state, 'p1-2', 'session-def');
|
|
18
|
+
clearSession(state, 'p1-1');
|
|
19
|
+
expect(getSession(state, 'p1-1')).toBeNull();
|
|
20
|
+
expect(getSession(state, 'p1-2')).toBe('session-def');
|
|
21
|
+
});
|
|
22
|
+
it('overwrites existing session', () => {
|
|
23
|
+
const state = emptyState(1);
|
|
24
|
+
recordSession(state, 'p1-1', 'session-old');
|
|
25
|
+
recordSession(state, 'p1-1', 'session-new');
|
|
26
|
+
expect(getSession(state, 'p1-1')).toBe('session-new');
|
|
27
|
+
});
|
|
28
|
+
it('clears all sessions', () => {
|
|
29
|
+
const state = emptyState(1);
|
|
30
|
+
recordSession(state, 'p1-1', 'a');
|
|
31
|
+
recordSession(state, 'p1-2', 'b');
|
|
32
|
+
clearAllSessions(state);
|
|
33
|
+
expect(getSession(state, 'p1-1')).toBeNull();
|
|
34
|
+
expect(getSession(state, 'p1-2')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|