skimpyclaw 0.3.5 → 0.3.8
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 +14 -6
- package/dist/__tests__/api.test.js +1 -19
- package/dist/__tests__/channels.test.js +1 -1
- package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
- package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
- package/dist/__tests__/code-agents-sandbox.test.js +163 -0
- package/dist/__tests__/context-manager.test.d.ts +1 -0
- package/dist/__tests__/context-manager.test.js +236 -0
- package/dist/__tests__/package-manager-detection.test.js +5 -5
- package/dist/__tests__/setup.test.js +10 -7
- package/dist/__tests__/skills.test.js +2 -2
- package/dist/__tests__/structured-context.test.d.ts +1 -0
- package/dist/__tests__/structured-context.test.js +100 -0
- package/dist/__tests__/tools.test.js +65 -3
- package/dist/agent.js +4 -5
- package/dist/api.js +10 -85
- package/dist/audit.js +5 -51
- package/dist/channels/telegram/handlers.js +2 -60
- package/dist/channels/telegram/index.js +0 -7
- package/dist/channels.js +1 -1
- package/dist/cli.js +186 -17
- package/dist/code-agents/executor.d.ts +9 -4
- package/dist/code-agents/executor.js +187 -13
- package/dist/code-agents/index.d.ts +1 -1
- package/dist/code-agents/index.js +23 -21
- package/dist/code-agents/orchestrator.d.ts +8 -2
- package/dist/code-agents/orchestrator.js +297 -27
- package/dist/code-agents/structured-context.d.ts +7 -0
- package/dist/code-agents/structured-context.js +54 -0
- package/dist/code-agents/types.d.ts +2 -0
- package/dist/code-agents/utils.js +12 -2
- package/dist/code-agents/worktree.d.ts +40 -0
- package/dist/code-agents/worktree.js +215 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -3
- package/dist/cron.js +18 -4
- package/dist/dashboard/assets/index-BoTHPby4.js +65 -0
- package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
- package/dist/dashboard/index.html +2 -2
- package/dist/discord.js +4 -40
- package/dist/exec-approval.js +1 -1
- package/dist/file-lock.js +1 -1
- package/dist/gateway.js +3 -10
- package/dist/providers/anthropic.js +9 -5
- package/dist/providers/codex.js +10 -6
- package/dist/providers/context-manager.d.ts +22 -0
- package/dist/providers/context-manager.js +100 -0
- package/dist/providers/openai.js +9 -5
- package/dist/providers/types.d.ts +1 -0
- package/dist/security.js +9 -0
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +156 -34
- package/dist/skills.js +9 -2
- package/dist/subagent.js +33 -2
- package/dist/tools/bash-tool.js +8 -0
- package/dist/tools/browser-tool.js +3 -2
- package/dist/tools/definitions.d.ts +0 -27
- package/dist/tools/definitions.js +0 -18
- package/dist/tools/execute-context.d.ts +4 -4
- package/dist/tools/file-tools.d.ts +1 -1
- package/dist/tools/file-tools.js +1 -1
- package/dist/tools.d.ts +5 -5
- package/dist/tools.js +87 -98
- package/dist/types.d.ts +14 -22
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +30 -46
- package/dist/utils.d.ts +18 -0
- package/dist/utils.js +71 -0
- package/dist/voice.js +9 -7
- package/package.json +1 -1
- package/dist/dashboard/assets/index-UVAjSXCG.js +0 -107
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// Code Agent Orchestrator - Team coordination logic
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
2
5
|
import { getNextCodeAgentId, storeCodeAgentTask, writeCodeAgentTask, getCodeAgent } from './registry.js';
|
|
3
6
|
import { runCodeAgentBackground, runValidation } from './executor.js';
|
|
4
7
|
import { notifyCodeAgentResult } from './utils.js';
|
|
8
|
+
import { parseAgentOutput, formatStructuredContext } from './structured-context.js';
|
|
5
9
|
import { runAgentTurn } from '../agent.js';
|
|
6
10
|
import { startTrace, addEvent, endTrace } from '../audit.js';
|
|
11
|
+
import { toErrorMessage } from '../utils.js';
|
|
12
|
+
import { isGitRepo, commitPendingChanges, createWorktree, mergeWorktree, removeWorktree, cleanupAllWorktrees, } from './worktree.js';
|
|
7
13
|
/**
|
|
8
14
|
* Compute execution waves from dependency info.
|
|
9
15
|
* Returns an array of waves, where each wave is an array of subtask indices that can run in parallel.
|
|
@@ -44,14 +50,62 @@ export function computeWaves(subtasks) {
|
|
|
44
50
|
}
|
|
45
51
|
return waves;
|
|
46
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Gather lightweight codebase context to improve task decomposition.
|
|
55
|
+
* Returns a short summary of the project structure (file tree, package.json scripts).
|
|
56
|
+
* Capped at ~2000 chars to keep the decomposition prompt small.
|
|
57
|
+
*/
|
|
58
|
+
export function gatherCodebaseContext(workdir) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
// Package.json scripts
|
|
61
|
+
try {
|
|
62
|
+
const pkgPath = join(workdir, 'package.json');
|
|
63
|
+
if (existsSync(pkgPath)) {
|
|
64
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
65
|
+
if (pkg.scripts) {
|
|
66
|
+
const scriptNames = Object.keys(pkg.scripts).slice(0, 15).join(', ');
|
|
67
|
+
parts.push(`Scripts: ${scriptNames}`);
|
|
68
|
+
}
|
|
69
|
+
if (pkg.dependencies || pkg.devDependencies) {
|
|
70
|
+
const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).slice(0, 20).join(', ');
|
|
71
|
+
parts.push(`Key deps: ${deps}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { /* ignore */ }
|
|
76
|
+
// Source file tree (top-level structure)
|
|
77
|
+
try {
|
|
78
|
+
const tree = execSync('find . -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | grep -v node_modules | grep -v dist | grep -v .test. | sort | head -60', { cwd: workdir, timeout: 5000, encoding: 'utf-8' }).trim();
|
|
79
|
+
if (tree)
|
|
80
|
+
parts.push(`Source files:\n${tree}`);
|
|
81
|
+
}
|
|
82
|
+
catch { /* ignore */ }
|
|
83
|
+
const context = parts.join('\n\n');
|
|
84
|
+
return context.slice(0, 2000);
|
|
85
|
+
}
|
|
47
86
|
/**
|
|
48
87
|
* Use a quick model call to decompose a complex task into N subtasks with optional dependency info.
|
|
49
88
|
* Falls back to numbered subtask splitting on parse error.
|
|
50
89
|
* Falls back to all-independent if dependency info is missing or invalid.
|
|
51
90
|
*/
|
|
52
|
-
export async function decomposeTask(task, teamSize, config) {
|
|
91
|
+
export async function decomposeTask(task, teamSize, config, workdir) {
|
|
53
92
|
try {
|
|
54
|
-
|
|
93
|
+
// Gather codebase context for smarter decomposition
|
|
94
|
+
const codebaseContext = workdir ? gatherCodebaseContext(workdir) : '';
|
|
95
|
+
const contextBlock = codebaseContext
|
|
96
|
+
? `\n\nProject structure:\n${codebaseContext}\n`
|
|
97
|
+
: '';
|
|
98
|
+
const prompt = `You are a task decomposition expert. Split the following coding task into exactly ${teamSize} independent or dependent subtasks that can be assigned to separate coding agents.
|
|
99
|
+
|
|
100
|
+
Rules:
|
|
101
|
+
- Each subtask should be self-contained with clear file scope
|
|
102
|
+
- Each parallel agent gets its own git worktree (branch), so file overlap is OK but be aware changes are merged after
|
|
103
|
+
- Use dependsOn to order subtasks that must run sequentially (e.g. create interface before implementation)
|
|
104
|
+
- Be specific: mention exact files, functions, and expected changes
|
|
105
|
+
- Return JSON only: {"subtasks":[{"description":"...","dependsOn":[]},...]}
|
|
106
|
+
- Use 0-based indices for dependsOn
|
|
107
|
+
${contextBlock}
|
|
108
|
+
Task: ${task}`;
|
|
55
109
|
const result = await runAgentTurn('main', prompt, config);
|
|
56
110
|
const match = result.match(/\{[\s\S]*"subtasks"[\s\S]*\}/);
|
|
57
111
|
if (match) {
|
|
@@ -96,15 +150,36 @@ export async function decomposeTask(task, teamSize, config) {
|
|
|
96
150
|
/**
|
|
97
151
|
* Use a quick model call to synthesize results from multiple subtask completions.
|
|
98
152
|
*/
|
|
99
|
-
export async function synthesizeResults(originalTask, results, config) {
|
|
153
|
+
export async function synthesizeResults(originalTask, results, config, workdir) {
|
|
100
154
|
try {
|
|
101
|
-
const resultSummary = results.map((r, i) =>
|
|
155
|
+
const resultSummary = results.map((r, i) => {
|
|
156
|
+
const context = r.output
|
|
157
|
+
? formatStructuredContext(parseAgentOutput(r.output))
|
|
158
|
+
: '';
|
|
159
|
+
return `### Subtask ${i + 1}: ${r.subtask}\nStatus: ${r.status}\n${context}${r.error ? `\nError: ${r.error}` : ''}`;
|
|
160
|
+
}).join('\n\n');
|
|
161
|
+
// Include actual file changes from git for accuracy
|
|
162
|
+
let diffBlock = '';
|
|
163
|
+
if (workdir) {
|
|
164
|
+
try {
|
|
165
|
+
const diffStat = execSync('git diff --stat HEAD 2>/dev/null || git diff --stat 2>/dev/null', {
|
|
166
|
+
cwd: workdir,
|
|
167
|
+
timeout: 5000,
|
|
168
|
+
encoding: 'utf-8',
|
|
169
|
+
}).trim();
|
|
170
|
+
if (diffStat)
|
|
171
|
+
diffBlock = `\n\nActual file changes (git diff --stat):\n${diffStat.slice(0, 2000)}`;
|
|
172
|
+
}
|
|
173
|
+
catch { /* not a git repo or no changes */ }
|
|
174
|
+
}
|
|
175
|
+
const succeeded = results.filter(r => r.status === 'completed').length;
|
|
176
|
+
const failed = results.filter(r => r.status !== 'completed').length;
|
|
102
177
|
const prompt = `You are a results synthesizer. Summarize the results of a multi-agent coding task.
|
|
103
178
|
|
|
104
179
|
Original task: ${originalTask}
|
|
105
180
|
|
|
106
|
-
Results from each agent:
|
|
107
|
-
${resultSummary}
|
|
181
|
+
Results from each agent (${succeeded} succeeded, ${failed} failed):
|
|
182
|
+
${resultSummary}${diffBlock}
|
|
108
183
|
|
|
109
184
|
Provide a concise markdown summary of what was accomplished, what succeeded, and what failed (if anything). Be specific about files changed and outcomes.`;
|
|
110
185
|
return await runAgentTurn('main', prompt, config);
|
|
@@ -131,9 +206,15 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
131
206
|
durationMs: 0,
|
|
132
207
|
detail: { teamSize, workdir, agent, model, validate },
|
|
133
208
|
});
|
|
134
|
-
const
|
|
135
|
-
const
|
|
209
|
+
const configTeamTimeout = context?.fullConfig?.codeAgents?.teamTimeoutMinutes ?? 60;
|
|
210
|
+
const timeoutMinutes = Math.min(configTeamTimeout, 120);
|
|
211
|
+
// Reserve budget for overhead (decompose, synthesize, validation) and distribute rest across waves
|
|
212
|
+
const overheadMinutes = 5;
|
|
213
|
+
const availableForChildren = Math.max(timeoutMinutes - overheadMinutes, timeoutMinutes * 0.7);
|
|
136
214
|
const CANCELLED_MESSAGE = 'Cancelled by user';
|
|
215
|
+
// Worktree state — declared outside try so catch can clean up
|
|
216
|
+
const _useWorktrees = isGitRepo(workdir);
|
|
217
|
+
const _activeWorktrees = new Map();
|
|
137
218
|
try {
|
|
138
219
|
if (getCodeAgent(parentId)?.status === 'cancelled')
|
|
139
220
|
throw new Error(CANCELLED_MESSAGE);
|
|
@@ -143,8 +224,10 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
143
224
|
const fullConfig = context?.fullConfig;
|
|
144
225
|
if (!fullConfig)
|
|
145
226
|
throw new Error('No config available for task decomposition');
|
|
146
|
-
const subtasks = await decomposeTask(task, teamSize, fullConfig);
|
|
227
|
+
const subtasks = await decomposeTask(task, teamSize, fullConfig, workdir);
|
|
147
228
|
const waves = computeWaves(subtasks);
|
|
229
|
+
// Distribute timeout across waves (not team size) for better budgeting
|
|
230
|
+
const perChildTimeout = Math.max(5, Math.floor(availableForChildren / waves.length));
|
|
148
231
|
addEvent(traceId, {
|
|
149
232
|
type: 'decompose',
|
|
150
233
|
summary: `Decomposed into ${subtasks.length} subtasks in ${waves.length} wave(s)`,
|
|
@@ -181,6 +264,20 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
181
264
|
}
|
|
182
265
|
parentTask.childTaskIds = childIds;
|
|
183
266
|
writeCodeAgentTask(parentTask);
|
|
267
|
+
// Helper: get git diff summary for context passing between waves
|
|
268
|
+
function getGitDiffSummary() {
|
|
269
|
+
try {
|
|
270
|
+
const diff = execSync('git diff --stat HEAD 2>/dev/null || git diff --stat 2>/dev/null', {
|
|
271
|
+
cwd: workdir,
|
|
272
|
+
timeout: 5000,
|
|
273
|
+
encoding: 'utf-8',
|
|
274
|
+
}).trim();
|
|
275
|
+
return diff ? diff.slice(0, 1500) : '';
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return '';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
184
281
|
// Helper: build task prompt with predecessor context for dependent subtasks
|
|
185
282
|
function buildChildPrompt(subtaskIdx) {
|
|
186
283
|
const sub = subtasks[subtaskIdx];
|
|
@@ -190,12 +287,34 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
190
287
|
for (const depIdx of sub.dependsOn) {
|
|
191
288
|
const depChild = getCodeAgent(childIdByIndex[depIdx]);
|
|
192
289
|
if (depChild && depChild.outputPreview) {
|
|
193
|
-
|
|
290
|
+
// Use the full outputPreview (up to 5000 chars) not just the 500-char summary
|
|
291
|
+
const structured = formatStructuredContext(parseAgentOutput(depChild.outputPreview));
|
|
292
|
+
contextParts.push(`- Task "${subtasks[depIdx].description}" [${depChild.status}]:\n${structured}`);
|
|
194
293
|
}
|
|
195
294
|
}
|
|
196
|
-
|
|
295
|
+
// Include git diff to show what predecessor waves actually changed on disk
|
|
296
|
+
const diffSummary = getGitDiffSummary();
|
|
297
|
+
const diffBlock = diffSummary ? `\nFiles changed so far:\n${diffSummary}\n` : '';
|
|
298
|
+
if (contextParts.length === 0 && !diffBlock)
|
|
197
299
|
return sub.description;
|
|
198
|
-
return `Context from completed prerequisite tasks:\n${contextParts.join('\n')}\
|
|
300
|
+
return `Context from completed prerequisite tasks:\n${contextParts.join('\n')}${diffBlock}\nYour task: ${sub.description}`;
|
|
301
|
+
}
|
|
302
|
+
// Worktree isolation: parallel agents in the same wave get their own worktree
|
|
303
|
+
// so they can't overwrite each other's files. After the wave, branches are
|
|
304
|
+
// merged back sequentially. Falls back to shared workdir if not a git repo.
|
|
305
|
+
const useWorktrees = _useWorktrees;
|
|
306
|
+
const activeWorktrees = _activeWorktrees;
|
|
307
|
+
if (useWorktrees) {
|
|
308
|
+
// Clean up any stale worktrees from previous crashed runs
|
|
309
|
+
cleanupAllWorktrees(workdir);
|
|
310
|
+
addEvent(traceId, {
|
|
311
|
+
type: 'worktree',
|
|
312
|
+
summary: 'Git worktree isolation enabled',
|
|
313
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
console.warn('[code-team] Not a git repo — agents will share workdir (risk of file conflicts)');
|
|
199
318
|
}
|
|
200
319
|
// Phase 3: Execute waves sequentially, tasks within each wave in parallel
|
|
201
320
|
const POLL_INTERVAL = 3000;
|
|
@@ -210,29 +329,55 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
210
329
|
summary: `Starting wave ${waveIdx + 1}/${totalWaves} (${waveIndices.length} tasks)`,
|
|
211
330
|
durationMs: Date.now() - startedAt.getTime(),
|
|
212
331
|
});
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const prompt = buildChildPrompt(subtaskIdx);
|
|
218
|
-
child.status = 'running';
|
|
219
|
-
child.task = prompt;
|
|
220
|
-
child.startedAt = new Date().toISOString();
|
|
221
|
-
writeCodeAgentTask(child);
|
|
222
|
-
runCodeAgentBackground(childId, agent, prompt, workdir, false, // children don't validate individually
|
|
223
|
-
{ task: prompt, model, timeout_minutes: perChildTimeout }, new Date(), { skipNotification: true, defaultTimeoutMinutes: perChildTimeout, maxTimeoutMinutes: perChildTimeout }).catch((err) => {
|
|
332
|
+
// Helper: spawn a single child task in the given workdir
|
|
333
|
+
function spawnChild(childId, prompt, childWorkdir) {
|
|
334
|
+
runCodeAgentBackground(childId, agent, prompt, childWorkdir, false, // per-child validation is handled by the orchestrator below
|
|
335
|
+
{ task: prompt, model, timeout_minutes: perChildTimeout }, new Date(), { skipNotification: true, defaultTimeoutMinutes: perChildTimeout, maxTimeoutMinutes: perChildTimeout, validationCommands: fullConfig?.codeAgents?.validationCommands }).catch((err) => {
|
|
224
336
|
const child = getCodeAgent(childId);
|
|
225
337
|
if (child && child.status === 'running') {
|
|
226
338
|
Object.assign(child, {
|
|
227
339
|
status: 'failed',
|
|
228
340
|
endedAt: new Date().toISOString(),
|
|
229
|
-
error:
|
|
341
|
+
error: toErrorMessage(err),
|
|
230
342
|
});
|
|
231
343
|
writeCodeAgentTask(child);
|
|
232
344
|
}
|
|
233
345
|
console.error(`[code-team] Child ${childId} background error:`, err);
|
|
234
346
|
});
|
|
235
347
|
}
|
|
348
|
+
// Create worktrees for parallel tasks (waves with >1 child)
|
|
349
|
+
const useWorktreeForWave = useWorktrees && waveIndices.length > 1;
|
|
350
|
+
if (useWorktreeForWave) {
|
|
351
|
+
// Commit any pending changes so worktrees branch from a clean state
|
|
352
|
+
commitPendingChanges(workdir, `[skimpyclaw] pre-wave-${waveIdx + 1}`);
|
|
353
|
+
parentTask.liveOutput = `Phase: Creating worktrees for wave ${waveIdx + 1}...`;
|
|
354
|
+
writeCodeAgentTask(parentTask);
|
|
355
|
+
}
|
|
356
|
+
// Spawn all tasks in this wave
|
|
357
|
+
for (const subtaskIdx of waveIndices) {
|
|
358
|
+
const childId = childIdByIndex[subtaskIdx];
|
|
359
|
+
const child = getCodeAgent(childId);
|
|
360
|
+
const prompt = buildChildPrompt(subtaskIdx);
|
|
361
|
+
// Determine workdir for this child
|
|
362
|
+
let childWorkdir = workdir;
|
|
363
|
+
if (useWorktreeForWave) {
|
|
364
|
+
try {
|
|
365
|
+
const wt = createWorktree(workdir, childId);
|
|
366
|
+
activeWorktrees.set(childId, wt);
|
|
367
|
+
childWorkdir = wt.path;
|
|
368
|
+
console.log(`[code-team] Created worktree for ${childId}: ${wt.path} (branch ${wt.branch})`);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
console.error(`[code-team] Failed to create worktree for ${childId}, using shared workdir:`, err);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
child.status = 'running';
|
|
375
|
+
child.task = prompt;
|
|
376
|
+
child.workdir = childWorkdir;
|
|
377
|
+
child.startedAt = new Date().toISOString();
|
|
378
|
+
writeCodeAgentTask(child);
|
|
379
|
+
spawnChild(childId, prompt, childWorkdir);
|
|
380
|
+
}
|
|
236
381
|
// Poll until all tasks in this wave complete
|
|
237
382
|
const waveChildIds = waveIndices.map(i => childIdByIndex[i]);
|
|
238
383
|
// Check cancellation after spawning
|
|
@@ -303,6 +448,117 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
303
448
|
break;
|
|
304
449
|
}
|
|
305
450
|
}
|
|
451
|
+
// Per-wave validation: run build after each wave to catch breakage early
|
|
452
|
+
if (validate && Date.now() - startedAt.getTime() < totalTimeoutMs) {
|
|
453
|
+
const waveCompleted = waveChildIds.every(id => getCodeAgent(id)?.status === 'completed');
|
|
454
|
+
if (waveCompleted) {
|
|
455
|
+
parentTask.liveOutput = `Phase: Validating wave ${waveIdx + 1}/${totalWaves}...`;
|
|
456
|
+
writeCodeAgentTask(parentTask);
|
|
457
|
+
const { passed, output: valOutput } = await runValidation(workdir, fullConfig?.codeAgents?.validationCommands);
|
|
458
|
+
if (!passed) {
|
|
459
|
+
addEvent(traceId, {
|
|
460
|
+
type: 'wave_validation',
|
|
461
|
+
summary: `Wave ${waveIdx + 1} validation failed — retrying failed children`,
|
|
462
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
463
|
+
});
|
|
464
|
+
// Retry each child in this wave once with the validation error context
|
|
465
|
+
const retryChildIds = [];
|
|
466
|
+
for (const subtaskIdx of waveIndices) {
|
|
467
|
+
const childId = childIdByIndex[subtaskIdx];
|
|
468
|
+
const child = getCodeAgent(childId);
|
|
469
|
+
if (child.retryCount)
|
|
470
|
+
continue; // already retried
|
|
471
|
+
child.retryCount = 1;
|
|
472
|
+
child.status = 'running';
|
|
473
|
+
child.validationOutput = valOutput.slice(0, 4000);
|
|
474
|
+
child.startedAt = new Date().toISOString();
|
|
475
|
+
writeCodeAgentTask(child);
|
|
476
|
+
const retryPrompt = `Fix build/test errors. Your original task: ${subtasks[subtaskIdx].description}\n\nValidation errors:\n${valOutput.slice(0, 4000)}`;
|
|
477
|
+
child.task = retryPrompt;
|
|
478
|
+
writeCodeAgentTask(child);
|
|
479
|
+
spawnChild(childId, retryPrompt, workdir);
|
|
480
|
+
retryChildIds.push(childId);
|
|
481
|
+
}
|
|
482
|
+
// Poll until retries complete
|
|
483
|
+
if (retryChildIds.length > 0) {
|
|
484
|
+
while (true) {
|
|
485
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
|
486
|
+
if (getCodeAgent(parentId)?.status === 'cancelled')
|
|
487
|
+
throw new Error(CANCELLED_MESSAGE);
|
|
488
|
+
const retryDone = retryChildIds.every(id => {
|
|
489
|
+
const c = getCodeAgent(id);
|
|
490
|
+
return c.status !== 'running' && c.status !== 'validating';
|
|
491
|
+
});
|
|
492
|
+
if (retryDone)
|
|
493
|
+
break;
|
|
494
|
+
if (Date.now() - startedAt.getTime() > totalTimeoutMs)
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
addEvent(traceId, {
|
|
498
|
+
type: 'wave_retry_complete',
|
|
499
|
+
summary: `Wave ${waveIdx + 1} retry complete`,
|
|
500
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
addEvent(traceId, {
|
|
506
|
+
type: 'wave_validation',
|
|
507
|
+
summary: `Wave ${waveIdx + 1} validation passed`,
|
|
508
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Merge worktree branches back into main branch sequentially
|
|
514
|
+
if (useWorktreeForWave && Date.now() - startedAt.getTime() < totalTimeoutMs) {
|
|
515
|
+
parentTask.liveOutput = `Phase: Merging wave ${waveIdx + 1} results...`;
|
|
516
|
+
writeCodeAgentTask(parentTask);
|
|
517
|
+
const mergeErrors = [];
|
|
518
|
+
for (const subtaskIdx of waveIndices) {
|
|
519
|
+
const childId = childIdByIndex[subtaskIdx];
|
|
520
|
+
const wt = activeWorktrees.get(childId);
|
|
521
|
+
if (!wt)
|
|
522
|
+
continue;
|
|
523
|
+
const child = getCodeAgent(childId);
|
|
524
|
+
if (!child || child.status !== 'completed') {
|
|
525
|
+
// Don't merge failed/timed-out children
|
|
526
|
+
console.log(`[code-team] Skipping merge for ${childId} (status: ${child?.status})`);
|
|
527
|
+
removeWorktree(workdir, childId, wt.branch);
|
|
528
|
+
activeWorktrees.delete(childId);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
// Commit any uncommitted changes in the worktree before merging
|
|
532
|
+
commitPendingChanges(wt.path, `[skimpyclaw] ${childId}: ${subtasks[subtaskIdx].description.slice(0, 60)}`);
|
|
533
|
+
const { merged, conflict } = mergeWorktree(workdir, wt.branch, childId);
|
|
534
|
+
if (merged) {
|
|
535
|
+
console.log(`[code-team] Merged ${childId} (branch ${wt.branch}) successfully`);
|
|
536
|
+
addEvent(traceId, {
|
|
537
|
+
type: 'merge',
|
|
538
|
+
summary: `Merged ${childId} successfully`,
|
|
539
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
const conflictMsg = `Merge conflict for ${childId}: ${conflict}`;
|
|
544
|
+
console.error(`[code-team] ${conflictMsg}`);
|
|
545
|
+
mergeErrors.push(conflictMsg);
|
|
546
|
+
addEvent(traceId, {
|
|
547
|
+
type: 'merge_conflict',
|
|
548
|
+
summary: conflictMsg.slice(0, 200),
|
|
549
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// Clean up worktree regardless of merge result
|
|
553
|
+
removeWorktree(workdir, childId, wt.branch);
|
|
554
|
+
activeWorktrees.delete(childId);
|
|
555
|
+
}
|
|
556
|
+
if (mergeErrors.length > 0) {
|
|
557
|
+
const errorMsg = `Merge conflicts in wave ${waveIdx + 1}:\n${mergeErrors.join('\n')}`;
|
|
558
|
+
parentTask.error = (parentTask.error ? parentTask.error + '\n' : '') + errorMsg;
|
|
559
|
+
writeCodeAgentTask(parentTask);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
306
562
|
// If we timed out, don't start more waves
|
|
307
563
|
if (Date.now() - startedAt.getTime() > totalTimeoutMs)
|
|
308
564
|
break;
|
|
@@ -326,14 +582,14 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
326
582
|
summary: `Synthesizing ${childResults.length} results`,
|
|
327
583
|
durationMs: Date.now() - startedAt.getTime(),
|
|
328
584
|
});
|
|
329
|
-
const synthesis = await synthesizeResults(task, childResults, fullConfig);
|
|
585
|
+
const synthesis = await synthesizeResults(task, childResults, fullConfig, workdir);
|
|
330
586
|
parentTask.synthesisResult = synthesis;
|
|
331
587
|
// Phase 5: Validation (once, on the combined result)
|
|
332
588
|
if (validate) {
|
|
333
589
|
parentTask.liveOutput = 'Phase: Validating...';
|
|
334
590
|
parentTask.status = 'validating';
|
|
335
591
|
writeCodeAgentTask(parentTask);
|
|
336
|
-
const { passed, output } = await runValidation(workdir);
|
|
592
|
+
const { passed, output } = await runValidation(workdir, fullConfig?.codeAgents?.validationCommands);
|
|
337
593
|
const endedAt = new Date();
|
|
338
594
|
const duration = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
|
|
339
595
|
if (!passed) {
|
|
@@ -382,7 +638,21 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
|
|
|
382
638
|
await notifyCodeAgentResult(parentTask, getCodeAgent);
|
|
383
639
|
}
|
|
384
640
|
catch (err) {
|
|
385
|
-
|
|
641
|
+
// Clean up any remaining worktrees
|
|
642
|
+
for (const [childId, wt] of _activeWorktrees) {
|
|
643
|
+
try {
|
|
644
|
+
removeWorktree(workdir, childId, wt.branch);
|
|
645
|
+
}
|
|
646
|
+
catch { /* best effort */ }
|
|
647
|
+
}
|
|
648
|
+
_activeWorktrees.clear();
|
|
649
|
+
if (_useWorktrees) {
|
|
650
|
+
try {
|
|
651
|
+
cleanupAllWorktrees(workdir);
|
|
652
|
+
}
|
|
653
|
+
catch { /* best effort */ }
|
|
654
|
+
}
|
|
655
|
+
const errMsg = toErrorMessage(err);
|
|
386
656
|
addEvent(traceId, { type: 'error', summary: errMsg.slice(0, 200), durationMs: Date.now() - startedAt.getTime() });
|
|
387
657
|
await endTrace(traceId, 'error');
|
|
388
658
|
Object.assign(parentTask, {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface StructuredAgentOutput {
|
|
2
|
+
summary: string;
|
|
3
|
+
files: string[];
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function parseAgentOutput(raw: string): StructuredAgentOutput;
|
|
7
|
+
export declare function formatStructuredContext(parsed: StructuredAgentOutput): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Structured context extraction for team agent inter-communication.
|
|
2
|
+
// Replaces raw string truncation with a token-efficient structured summary
|
|
3
|
+
// that preserves the most useful signal: what files changed and what went wrong.
|
|
4
|
+
// Matches backtick-quoted paths like `src/foo.ts`
|
|
5
|
+
const BACKTICK_PATH_RE = /`([^`\n]{3,200})`/g;
|
|
6
|
+
// Matches absolute paths like /Users/katre/Sites/skimpyclaw/src/foo.ts
|
|
7
|
+
const ABS_PATH_RE = /(?:^|\s)(\/[^\s,;'"()\n]{3,300})/gm;
|
|
8
|
+
// Matches error-like lines
|
|
9
|
+
const ERROR_LINE_RE = /^.{0,20}(?:error|failed|failure|exception)[^\n]{0,200}$/gim;
|
|
10
|
+
export function parseAgentOutput(raw) {
|
|
11
|
+
const files = new Set();
|
|
12
|
+
// Extract backtick-quoted paths (e.g. `src/agent.ts`, `/abs/path.ts`)
|
|
13
|
+
BACKTICK_PATH_RE.lastIndex = 0;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = BACKTICK_PATH_RE.exec(raw)) !== null) {
|
|
16
|
+
const p = m[1].trim();
|
|
17
|
+
// Must look like a file path: contains a dot and no spaces
|
|
18
|
+
if (p.includes('.') && !p.includes(' ')) {
|
|
19
|
+
files.add(p.replace(/[.,;]$/, ''));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Extract absolute paths
|
|
23
|
+
ABS_PATH_RE.lastIndex = 0;
|
|
24
|
+
while ((m = ABS_PATH_RE.exec(raw)) !== null) {
|
|
25
|
+
const p = m[1].trim().replace(/[.,;]$/, '');
|
|
26
|
+
if (p.includes('.')) {
|
|
27
|
+
files.add(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Extract error lines
|
|
31
|
+
const errors = [];
|
|
32
|
+
ERROR_LINE_RE.lastIndex = 0;
|
|
33
|
+
while ((m = ERROR_LINE_RE.exec(raw)) !== null) {
|
|
34
|
+
const err = m[0].trim().slice(0, 200);
|
|
35
|
+
if (errors.length < 5 && !errors.includes(err)) {
|
|
36
|
+
errors.push(err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
summary: raw.slice(0, 500),
|
|
41
|
+
files: [...files].slice(0, 15),
|
|
42
|
+
errors,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function formatStructuredContext(parsed) {
|
|
46
|
+
const parts = [`Summary: ${parsed.summary}`];
|
|
47
|
+
if (parsed.files.length > 0) {
|
|
48
|
+
parts.push(`Files: ${parsed.files.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
if (parsed.errors.length > 0) {
|
|
51
|
+
parts.push(`Errors: ${parsed.errors.join(' | ')}`);
|
|
52
|
+
}
|
|
53
|
+
return parts.join('\n');
|
|
54
|
+
}
|
|
@@ -45,6 +45,8 @@ export interface CodeAgentBackgroundOptions {
|
|
|
45
45
|
maxTimeoutMinutes?: number;
|
|
46
46
|
/** Skip sending notification on completion (parent handles it) */
|
|
47
47
|
skipNotification?: boolean;
|
|
48
|
+
/** Per-project validation command overrides from config */
|
|
49
|
+
validationCommands?: Record<string, string>;
|
|
48
50
|
/** Sandbox configuration — when enabled, run CLI inside container */
|
|
49
51
|
sandboxConfig?: SandboxConfig;
|
|
50
52
|
/** Paths to mount into the sandbox container */
|
|
@@ -201,11 +201,21 @@ export async function notifyCodeAgentResult(task, getChildTask) {
|
|
|
201
201
|
// Team coordinator gets a structured notification
|
|
202
202
|
if (task.agent === 'team-coordinator') {
|
|
203
203
|
message = buildTeamNotification(task, getChildTask);
|
|
204
|
-
await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch(() => {
|
|
204
|
+
const sent = await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch((err) => {
|
|
205
|
+
console.error(`[code-agent] Failed to send team notification for ${task.id}:`, err);
|
|
206
|
+
return false;
|
|
207
|
+
});
|
|
208
|
+
if (!sent)
|
|
209
|
+
console.warn(`[code-agent] Team notification not delivered for ${task.id} (no active channel or target)`);
|
|
205
210
|
return;
|
|
206
211
|
}
|
|
207
212
|
message = buildSoloNotification(task);
|
|
208
|
-
await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch(() => {
|
|
213
|
+
const sent = await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch((err) => {
|
|
214
|
+
console.error(`[code-agent] Failed to send notification for ${task.id}:`, err);
|
|
215
|
+
return false;
|
|
216
|
+
});
|
|
217
|
+
if (!sent)
|
|
218
|
+
console.warn(`[code-agent] Notification not delivered for ${task.id} (no active channel or target)`);
|
|
209
219
|
}
|
|
210
220
|
/** Check workdir against allowed paths. */
|
|
211
221
|
export function resolveWorkdir(rawWorkdir, projects, skimpyclawRoot) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface WorktreeInfo {
|
|
2
|
+
/** Absolute path to the worktree directory */
|
|
3
|
+
path: string;
|
|
4
|
+
/** Branch name created for this worktree */
|
|
5
|
+
branch: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Check if a directory is inside a git repo.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isGitRepo(workdir: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Get the git repo root directory.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getGitRoot(workdir: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Stage and commit all current changes so worktrees branch from a clean state.
|
|
17
|
+
* Returns the commit hash, or null if nothing to commit.
|
|
18
|
+
*/
|
|
19
|
+
export declare function commitPendingChanges(workdir: string, message: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Create a git worktree for a child agent.
|
|
22
|
+
* Creates a new branch and worktree directory under .skimpyclaw-worktrees/.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createWorktree(workdir: string, childId: string): WorktreeInfo;
|
|
25
|
+
/**
|
|
26
|
+
* Merge a child's worktree branch back into the current branch.
|
|
27
|
+
* Returns { merged: true } on success, { merged: false, conflict: string } on conflict.
|
|
28
|
+
*/
|
|
29
|
+
export declare function mergeWorktree(workdir: string, branch: string, childId: string): {
|
|
30
|
+
merged: boolean;
|
|
31
|
+
conflict?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Remove a worktree and its branch.
|
|
35
|
+
*/
|
|
36
|
+
export declare function removeWorktree(workdir: string, childId: string, branch: string): void;
|
|
37
|
+
/**
|
|
38
|
+
* Clean up all skimpyclaw worktrees in a repo (e.g. on crash recovery).
|
|
39
|
+
*/
|
|
40
|
+
export declare function cleanupAllWorktrees(workdir: string): void;
|