@goodandready/dsh-cron 0.3.12 → 0.3.14
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 +24 -0
- package/README.ru.md +24 -0
- package/README.zh.md +24 -0
- package/lib/api.js +180 -5
- package/lib/branching.js +341 -0
- package/lib/client.js +949 -758
- package/lib/dag.js +88 -18
- package/lib/doctor.js +431 -0
- package/lib/git-diff.js +226 -0
- package/lib/scheduler-execution.js +98 -6
- package/lib/store.js +4 -0
- package/lib/swarm.js +303 -0
- package/lib/task-create.js +11 -0
- package/lib/task-patch.js +19 -1
- package/lib/task-transfer.js +9 -0
- package/package.json +1 -1
package/lib/git-diff.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Diff and Patch Review Engine (#351)
|
|
3
|
+
*
|
|
4
|
+
* Provides safe collection, parsing, inspection, and manipulation of Git diffs
|
|
5
|
+
* generated during task execution (e.g. scripts modifying workspace or worktree).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
export const MAX_DIFF_SIZE = 256 * 1024; // 256 KiB safety ceiling
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks if a given directory is inside a git working tree.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} cwd
|
|
22
|
+
* @returns {Promise<boolean>}
|
|
23
|
+
*/
|
|
24
|
+
export async function isGitRepository(cwd) {
|
|
25
|
+
if (!cwd) return false;
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
28
|
+
cwd,
|
|
29
|
+
timeout: 3000,
|
|
30
|
+
});
|
|
31
|
+
return stdout.trim() === 'true';
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parses git status --porcelain output into structured file items.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} porcelainOutput
|
|
41
|
+
* @returns {Array<Object>}
|
|
42
|
+
*/
|
|
43
|
+
export function parsePorcelainStatus(porcelainOutput) {
|
|
44
|
+
if (!porcelainOutput || typeof porcelainOutput !== 'string') return [];
|
|
45
|
+
const lines = porcelainOutput.split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
|
46
|
+
const files = [];
|
|
47
|
+
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (line.length < 3) continue;
|
|
50
|
+
const x = line[0];
|
|
51
|
+
const y = line[1];
|
|
52
|
+
const filePath = line.slice(3).trim();
|
|
53
|
+
|
|
54
|
+
let status = 'modified';
|
|
55
|
+
if (x === 'A' || y === 'A' || (x === '?' && y === '?')) status = 'added';
|
|
56
|
+
else if (x === 'D' || y === 'D') status = 'deleted';
|
|
57
|
+
else if (x === 'R' || y === 'R') status = 'renamed';
|
|
58
|
+
|
|
59
|
+
files.push({
|
|
60
|
+
path: filePath,
|
|
61
|
+
rawCode: `${x}${y}`.trim(),
|
|
62
|
+
status,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parses git diff --stat output into structured totals.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} statOutput
|
|
73
|
+
* @returns {{ filesCount: number, insertions: number, deletions: number }}
|
|
74
|
+
*/
|
|
75
|
+
export function parseGitStatSummary(statOutput) {
|
|
76
|
+
if (!statOutput || typeof statOutput !== 'string') {
|
|
77
|
+
return { filesCount: 0, insertions: 0, deletions: 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const match = statOutput.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/i);
|
|
81
|
+
if (!match) {
|
|
82
|
+
return { filesCount: 0, insertions: 0, deletions: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
filesCount: Number(match[1] || 0),
|
|
87
|
+
insertions: Number(match[2] || 0),
|
|
88
|
+
deletions: Number(match[3] || 0),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Captures the current Git diff in the specified directory.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} cwd Target repository directory
|
|
96
|
+
* @param {Object} [options]
|
|
97
|
+
* @param {number} [options.maxSize=262144] Maximum diff size before truncation
|
|
98
|
+
* @returns {Promise<Object>} { hasChanges: boolean, stat: string, diff: string, files: Array, summary: Object, truncated: boolean }
|
|
99
|
+
*/
|
|
100
|
+
export async function captureGitDiff(cwd, { maxSize = MAX_DIFF_SIZE } = {}) {
|
|
101
|
+
if (!cwd) {
|
|
102
|
+
return { hasChanges: false, error: 'No working directory specified' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isRepo = await isGitRepository(cwd);
|
|
106
|
+
if (!isRepo) {
|
|
107
|
+
return { hasChanges: false, isRepo: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// 1. Check if there are any changes
|
|
112
|
+
const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain'], { cwd, timeout: 5000 });
|
|
113
|
+
const cleanStatus = statusOut.trim();
|
|
114
|
+
if (!cleanStatus) {
|
|
115
|
+
return { hasChanges: false, stat: '', diff: '', files: [], summary: { filesCount: 0, insertions: 0, deletions: 0 } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const files = parsePorcelainStatus(cleanStatus);
|
|
119
|
+
|
|
120
|
+
// 2. Fetch diff stat
|
|
121
|
+
let stat = '';
|
|
122
|
+
try {
|
|
123
|
+
const { stdout: statOut } = await execFileAsync('git', ['diff', '--stat', 'HEAD'], { cwd, timeout: 5000 });
|
|
124
|
+
stat = statOut.trim();
|
|
125
|
+
} catch {
|
|
126
|
+
// Fallback without HEAD (e.g. unborn branch)
|
|
127
|
+
try {
|
|
128
|
+
const { stdout: statOut } = await execFileAsync('git', ['diff', '--stat'], { cwd, timeout: 5000 });
|
|
129
|
+
stat = statOut.trim();
|
|
130
|
+
} catch {
|
|
131
|
+
stat = `${files.length} file(s) changed`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const summary = parseGitStatSummary(stat);
|
|
136
|
+
if (summary.filesCount === 0 && files.length > 0) {
|
|
137
|
+
summary.filesCount = files.length;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. Fetch full unified diff
|
|
141
|
+
let rawDiff = '';
|
|
142
|
+
try {
|
|
143
|
+
const { stdout: diffOut } = await execFileAsync('git', ['diff', 'HEAD'], { cwd, timeout: 8000, maxBuffer: 10 * 1024 * 1024 });
|
|
144
|
+
rawDiff = diffOut;
|
|
145
|
+
} catch {
|
|
146
|
+
try {
|
|
147
|
+
const { stdout: diffOut } = await execFileAsync('git', ['diff'], { cwd, timeout: 8000, maxBuffer: 10 * 1024 * 1024 });
|
|
148
|
+
rawDiff = diffOut;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
rawDiff = `[Diff unavailable: ${err.message}]`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let truncated = false;
|
|
155
|
+
let diff = rawDiff;
|
|
156
|
+
if (Buffer.byteLength(diff, 'utf8') > maxSize) {
|
|
157
|
+
diff = diff.slice(0, maxSize) + '\n\n[... Diff truncated: exceeds 256 KiB limit ...]';
|
|
158
|
+
truncated = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
hasChanges: true,
|
|
163
|
+
isRepo: true,
|
|
164
|
+
stat,
|
|
165
|
+
diff,
|
|
166
|
+
files,
|
|
167
|
+
summary,
|
|
168
|
+
truncated,
|
|
169
|
+
};
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return {
|
|
172
|
+
hasChanges: false,
|
|
173
|
+
error: err.message || String(err),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Discards all uncommitted changes (tracked and untracked) in the target git directory.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} cwd Target repository directory
|
|
182
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
183
|
+
*/
|
|
184
|
+
export async function discardGitChanges(cwd) {
|
|
185
|
+
if (!cwd) return { ok: false, error: 'No working directory specified' };
|
|
186
|
+
const isRepo = await isGitRepository(cwd);
|
|
187
|
+
if (!isRepo) return { ok: false, error: 'Not a git repository' };
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Reset tracked modifications
|
|
191
|
+
await execFileAsync('git', ['reset', '--hard', 'HEAD'], { cwd, timeout: 10000 });
|
|
192
|
+
// Clean untracked files and directories
|
|
193
|
+
await execFileAsync('git', ['clean', '-fd'], { cwd, timeout: 10000 });
|
|
194
|
+
return { ok: true, discarded: true };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { ok: false, error: err.message || String(err) };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Applies a git patch string to the target repository directory.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} cwd Target repository directory
|
|
204
|
+
* @param {string} patchContent Unified diff / patch text
|
|
205
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
206
|
+
*/
|
|
207
|
+
export async function applyGitDiff(cwd, patchContent) {
|
|
208
|
+
if (!cwd || !patchContent) return { ok: false, error: 'Missing directory or patch content' };
|
|
209
|
+
const isRepo = await isGitRepository(cwd);
|
|
210
|
+
if (!isRepo) return { ok: false, error: 'Not a git repository' };
|
|
211
|
+
|
|
212
|
+
const tmpPath = path.join(os.tmpdir(), `dsh-cron-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.diff`);
|
|
213
|
+
try {
|
|
214
|
+
await fs.writeFile(tmpPath, patchContent, 'utf8');
|
|
215
|
+
await execFileAsync('git', ['apply', '--whitespace=fix', tmpPath], { cwd, timeout: 10000 });
|
|
216
|
+
return { ok: true, applied: true };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { ok: false, error: `Failed to apply git diff: ${err.message || err}` };
|
|
219
|
+
} finally {
|
|
220
|
+
try {
|
|
221
|
+
await fs.unlink(tmpPath);
|
|
222
|
+
} catch (_e) {
|
|
223
|
+
/* ignore temp file cleanup error */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { executeSwarmTask } from './swarm.js';
|
|
2
|
+
import { captureGitDiff } from './git-diff.js';
|
|
3
|
+
import { resolveBranchTargets } from './branching.js';
|
|
4
|
+
import { shouldAutoHeal, applyDoctorFix } from './doctor.js';
|
|
1
5
|
import { recordPromptRunOutcome } from './prompt-evolver.js';
|
|
2
6
|
import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
|
|
3
7
|
import { approvalManager } from './approval.js';
|
|
@@ -137,6 +141,25 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
|
|
|
137
141
|
model: task.model || '',
|
|
138
142
|
fallback: false,
|
|
139
143
|
};
|
|
144
|
+
if (task.type === 'swarm') {
|
|
145
|
+
try {
|
|
146
|
+
const swarmRes = await executeSwarmTask(task, {
|
|
147
|
+
executeFn: (subtask) => scheduler.executeFn(subtask, { signal, ...options }),
|
|
148
|
+
askModel: scheduler.askModel,
|
|
149
|
+
signal,
|
|
150
|
+
logger,
|
|
151
|
+
});
|
|
152
|
+
result.output = swarmRes.output || '';
|
|
153
|
+
result.status = swarmRes.status;
|
|
154
|
+
result.subtaskRuns = swarmRes.subtaskRuns || [];
|
|
155
|
+
result.swarmStats = swarmRes.stats;
|
|
156
|
+
return result;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
result.error = err.message || String(err);
|
|
159
|
+
result.status = 'error';
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
140
163
|
if (typeof scheduler.executeFn !== 'function') return result;
|
|
141
164
|
const executableTask = resolveTaskForExecution(task, options);
|
|
142
165
|
try {
|
|
@@ -441,14 +464,42 @@ export async function applySilentRuleToRun(task, runInfo, { askModel, readSettin
|
|
|
441
464
|
/**
|
|
442
465
|
* Run the model-assisted steps for a finished run.
|
|
443
466
|
*/
|
|
444
|
-
export async function applyModelAssists(task, runInfo, options) {
|
|
467
|
+
export async function applyModelAssists(task, runInfo, options = {}) {
|
|
445
468
|
const inspection = await inspectFailureRun(task, runInfo, options);
|
|
446
469
|
if (inspection.inspected) {
|
|
447
470
|
runInfo.diagnosis = inspection.diagnosis;
|
|
448
471
|
runInfo.suggestion = inspection.suggestion;
|
|
449
472
|
runInfo.confidence = inspection.confidence;
|
|
473
|
+
runInfo.doctorReport = inspection.doctorReport || {
|
|
474
|
+
category: inspection.category || 'runtime_error',
|
|
475
|
+
cause: inspection.diagnosis,
|
|
476
|
+
suggestedFix: inspection.suggestion,
|
|
477
|
+
confidence: inspection.confidence,
|
|
478
|
+
diff: inspection.diff || '',
|
|
479
|
+
timestamp: Date.now(),
|
|
480
|
+
applied: false,
|
|
481
|
+
autoHealed: false,
|
|
482
|
+
};
|
|
450
483
|
logger.info(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
|
|
451
|
-
|
|
484
|
+
|
|
485
|
+
// Safe Auto-Heal Check (#353)
|
|
486
|
+
if (shouldAutoHeal(task, runInfo.doctorReport)) {
|
|
487
|
+
try {
|
|
488
|
+
const healResult = await applyDoctorFix(task, runInfo.doctorReport, {
|
|
489
|
+
store: options.store,
|
|
490
|
+
user: 'scheduler:auto-heal',
|
|
491
|
+
isAutoHeal: true,
|
|
492
|
+
});
|
|
493
|
+
if (healResult.ok) {
|
|
494
|
+
logger.info(`[dsh-cron] task "${task.title}" auto-healed successfully with doctor fix!`);
|
|
495
|
+
runInfo.autoHealed = true;
|
|
496
|
+
if (runInfo.doctorReport) runInfo.doctorReport.autoHealed = true;
|
|
497
|
+
}
|
|
498
|
+
} catch (autoHealErr) {
|
|
499
|
+
logger.warn(`[dsh-cron] failed to auto-heal task "${task.title}":`, autoHealErr.message);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} else if (inspection.error && (task.inspectOnFailure || task.autoDiagnose || task.autoHeal)) {
|
|
452
503
|
logger.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
|
|
453
504
|
}
|
|
454
505
|
|
|
@@ -742,6 +793,24 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
742
793
|
},
|
|
743
794
|
];
|
|
744
795
|
|
|
796
|
+
if (outcome.subtaskRuns) {
|
|
797
|
+
runInfo.subtaskRuns = outcome.subtaskRuns;
|
|
798
|
+
runInfo.swarmStats = outcome.swarmStats;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Capture git diff if task runs in a worktree or custom git cwd (#351)
|
|
802
|
+
const diffDir = task.worktree ? (runInfo.worktreePath || task.cwd) : task.cwd;
|
|
803
|
+
if (diffDir) {
|
|
804
|
+
try {
|
|
805
|
+
const diffResult = await captureGitDiff(diffDir);
|
|
806
|
+
if (diffResult && diffResult.hasChanges) {
|
|
807
|
+
runInfo.gitDiff = diffResult;
|
|
808
|
+
}
|
|
809
|
+
} catch {
|
|
810
|
+
// Non-fatal if diff capture fails
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
745
814
|
runInfo.stages = stages;
|
|
746
815
|
runInfo.delivery = deliveryResult;
|
|
747
816
|
runInfo.cost = {
|
|
@@ -769,8 +838,14 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
769
838
|
runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
|
|
770
839
|
}
|
|
771
840
|
|
|
772
|
-
// Chain Checkpoints (#319, #328)
|
|
773
|
-
const
|
|
841
|
+
// Chain Checkpoints & Decision Nodes (#319, #328, #352)
|
|
842
|
+
const branchResult = resolveBranchTargets(task, outcome, runInfo);
|
|
843
|
+
if (branchResult && branchResult.decision) {
|
|
844
|
+
runInfo.branchDecision = branchResult.decision;
|
|
845
|
+
}
|
|
846
|
+
const chainedTargets = branchResult
|
|
847
|
+
? branchResult.targets
|
|
848
|
+
: parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
|
|
774
849
|
let hasDependents = false;
|
|
775
850
|
if (scheduler.store?.tasks) {
|
|
776
851
|
for (const t of Object.values(scheduler.store.tasks)) {
|
|
@@ -888,10 +963,27 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
888
963
|
}
|
|
889
964
|
}
|
|
890
965
|
|
|
891
|
-
// 2. Task Chaining (#137, #328 Fan-Out / Fan-In)
|
|
892
|
-
const targets =
|
|
966
|
+
// 2. Task Chaining & Decision Nodes (#137, #328, #352 Fan-Out / Fan-In)
|
|
967
|
+
const targets = branchResult
|
|
968
|
+
? branchResult.targets
|
|
969
|
+
: parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
|
|
893
970
|
const currentDepth = options.chainDepth || 0;
|
|
894
971
|
|
|
972
|
+
if (branchResult && branchResult.decision) {
|
|
973
|
+
logger.info(`[dsh-cron] decision node in "${task.title}": routed to ${targets.join(', ') || 'none'} (${branchResult.decision.isElse ? 'fallback else' : (branchResult.decision.matched ? `rule #${branchResult.decision.matchedIndex + 1}` : 'unmatched')})`);
|
|
974
|
+
if (chainRunId && scheduler.store.chainCheckpointManager && Array.isArray(task.branchRules)) {
|
|
975
|
+
for (const rule of task.branchRules) {
|
|
976
|
+
if (rule && rule.targetTask && !targets.includes(rule.targetTask)) {
|
|
977
|
+
scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, rule.targetTask, {
|
|
978
|
+
status: 'skipped',
|
|
979
|
+
skippedReason: 'condition not met',
|
|
980
|
+
durationMs: 0,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
895
987
|
if (currentDepth < 4) {
|
|
896
988
|
// 2.1 Trigger explicit targets (Fan-Out)
|
|
897
989
|
for (const targetId of targets) {
|
package/lib/store.js
CHANGED
|
@@ -776,6 +776,10 @@ export class TaskStore {
|
|
|
776
776
|
selfHealing: runInfo.selfHealing || null,
|
|
777
777
|
autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
|
|
778
778
|
confidence: runInfo.confidence || '',
|
|
779
|
+
doctorReport: runInfo.doctorReport || null,
|
|
780
|
+
subtaskRuns: Array.isArray(runInfo.subtaskRuns) ? runInfo.subtaskRuns : null,
|
|
781
|
+
swarmStats: runInfo.swarmStats || null,
|
|
782
|
+
gitDiff: runInfo.gitDiff || null,
|
|
779
783
|
},
|
|
780
784
|
...prevRuns,
|
|
781
785
|
];
|
package/lib/swarm.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Delegation Engine (#350)
|
|
3
|
+
*
|
|
4
|
+
* Provides parallel multi-agent task execution and swarm decomposition:
|
|
5
|
+
* - Dynamic or static subtask orchestration
|
|
6
|
+
* - Controlled concurrency worker pool (concurrencyLimit)
|
|
7
|
+
* - Fault-tolerant execution (single worker failure does not crash the swarm)
|
|
8
|
+
* - Result aggregation and synthesis
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseJsonAnswer } from './llm-ask.js';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SWARM_CONCURRENCY = 3;
|
|
14
|
+
export const MAX_SWARM_CONCURRENCY = 10;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sanitizes and normalizes swarm configuration.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} [swarmConfig]
|
|
20
|
+
* @returns {Object}
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizeSwarmConfig(swarmConfig) {
|
|
23
|
+
if (!swarmConfig || typeof swarmConfig !== 'object') {
|
|
24
|
+
return {
|
|
25
|
+
concurrencyLimit: DEFAULT_SWARM_CONCURRENCY,
|
|
26
|
+
subtasks: [],
|
|
27
|
+
orchestrator: null,
|
|
28
|
+
synthesizer: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const concurrencyRaw = Number(swarmConfig.concurrencyLimit || swarmConfig.concurrency || DEFAULT_SWARM_CONCURRENCY);
|
|
33
|
+
const concurrencyLimit = Math.min(Math.max(1, Number.isNaN(concurrencyRaw) ? DEFAULT_SWARM_CONCURRENCY : concurrencyRaw), MAX_SWARM_CONCURRENCY);
|
|
34
|
+
|
|
35
|
+
let subtasks = [];
|
|
36
|
+
if (Array.isArray(swarmConfig.subtasks)) {
|
|
37
|
+
subtasks = swarmConfig.subtasks.map((st, idx) => ({
|
|
38
|
+
id: String(st.id || `subtask_${idx + 1}`).trim(),
|
|
39
|
+
title: String(st.title || st.id || `Worker ${idx + 1}`).trim(),
|
|
40
|
+
prompt: String(st.prompt || '').trim(),
|
|
41
|
+
model: st.model ? String(st.model).trim() : undefined,
|
|
42
|
+
type: st.type ? String(st.type).trim() : 'llm',
|
|
43
|
+
cwd: st.cwd ? String(st.cwd).trim() : undefined,
|
|
44
|
+
env: st.env && typeof st.env === 'object' ? st.env : undefined,
|
|
45
|
+
})).filter((st) => Boolean(st.prompt));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let orchestrator = null;
|
|
49
|
+
if (swarmConfig.orchestrator && typeof swarmConfig.orchestrator === 'object') {
|
|
50
|
+
orchestrator = {
|
|
51
|
+
prompt: swarmConfig.orchestrator.prompt ? String(swarmConfig.orchestrator.prompt).trim() : '',
|
|
52
|
+
model: swarmConfig.orchestrator.model ? String(swarmConfig.orchestrator.model).trim() : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let synthesizer = null;
|
|
57
|
+
if (swarmConfig.synthesizer && typeof swarmConfig.synthesizer === 'object') {
|
|
58
|
+
synthesizer = {
|
|
59
|
+
prompt: swarmConfig.synthesizer.prompt ? String(swarmConfig.synthesizer.prompt).trim() : '',
|
|
60
|
+
model: swarmConfig.synthesizer.model ? String(swarmConfig.synthesizer.model).trim() : undefined,
|
|
61
|
+
mode: ['summary', 'concat', 'json'].includes(swarmConfig.synthesizer.mode) ? swarmConfig.synthesizer.mode : 'summary',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
concurrencyLimit,
|
|
67
|
+
subtasks,
|
|
68
|
+
orchestrator,
|
|
69
|
+
synthesizer,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds the orchestrator system prompt for dynamic task decomposition.
|
|
75
|
+
*/
|
|
76
|
+
const ORCHESTRATOR_SYSTEM_PROMPT = [
|
|
77
|
+
'You are a Swarm Task Orchestrator.',
|
|
78
|
+
'Your role is to decompose the user task into 2 to 8 independent parallel subtasks.',
|
|
79
|
+
'Respond strictly with a JSON object in this format:',
|
|
80
|
+
'{"subtasks": [{"id": "unique_id", "title": "short title", "prompt": "precise instructions for worker"}]}',
|
|
81
|
+
'Do not include markdown code fences outside JSON. Return only valid JSON.',
|
|
82
|
+
].join(' ');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Executes a list of subtasks using a concurrency-limited pool.
|
|
86
|
+
*
|
|
87
|
+
* @param {Array<Object>} subtasks
|
|
88
|
+
* @param {Function} workerExecutor async (subtask) => { output, error, exitCode }
|
|
89
|
+
* @param {Object} [options]
|
|
90
|
+
* @param {number} [options.concurrencyLimit=3]
|
|
91
|
+
* @param {AbortSignal} [options.signal]
|
|
92
|
+
* @returns {Promise<Array<Object>>} subtaskRuns
|
|
93
|
+
*/
|
|
94
|
+
export async function executeWorkerPool(subtasks, workerExecutor, { concurrencyLimit = DEFAULT_SWARM_CONCURRENCY, signal } = {}) {
|
|
95
|
+
if (!Array.isArray(subtasks) || subtasks.length === 0) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const results = new Array(subtasks.length);
|
|
100
|
+
let currentIndex = 0;
|
|
101
|
+
|
|
102
|
+
async function worker() {
|
|
103
|
+
while (currentIndex < subtasks.length) {
|
|
104
|
+
if (signal?.aborted) {
|
|
105
|
+
throw new Error('Swarm execution aborted');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const idx = currentIndex++;
|
|
109
|
+
const st = subtasks[idx];
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const res = await workerExecutor(st);
|
|
114
|
+
const durationMs = Date.now() - start;
|
|
115
|
+
results[idx] = {
|
|
116
|
+
id: st.id,
|
|
117
|
+
title: st.title || st.id,
|
|
118
|
+
status: (res && res.error) ? 'error' : 'success',
|
|
119
|
+
output: String((res && (res.output || res.result || res.stdout)) || ''),
|
|
120
|
+
error: res && res.error ? String(res.error) : null,
|
|
121
|
+
durationMs,
|
|
122
|
+
};
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const durationMs = Date.now() - start;
|
|
125
|
+
results[idx] = {
|
|
126
|
+
id: st.id,
|
|
127
|
+
title: st.title || st.id,
|
|
128
|
+
status: 'error',
|
|
129
|
+
output: '',
|
|
130
|
+
error: err?.message || String(err),
|
|
131
|
+
durationMs,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const limit = Math.max(1, Math.min(concurrencyLimit, subtasks.length));
|
|
138
|
+
const workers = Array.from({ length: limit }, () => worker());
|
|
139
|
+
await Promise.all(workers);
|
|
140
|
+
|
|
141
|
+
return results;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Synthesizes the results of all subtask runs into a final unified report.
|
|
146
|
+
*
|
|
147
|
+
* @param {Object} params
|
|
148
|
+
* @param {Object} params.task
|
|
149
|
+
* @param {Array<Object>} params.subtaskRuns
|
|
150
|
+
* @param {Function} [params.askModel]
|
|
151
|
+
* @returns {Promise<string>}
|
|
152
|
+
*/
|
|
153
|
+
export async function synthesizeSwarmResults({ task, subtaskRuns, askModel }) {
|
|
154
|
+
const synthesizer = task.swarm?.synthesizer;
|
|
155
|
+
|
|
156
|
+
// If a model and prompt are configured, ask LLM to synthesize
|
|
157
|
+
if (synthesizer?.prompt && typeof askModel === 'function') {
|
|
158
|
+
try {
|
|
159
|
+
const summaryContext = subtaskRuns.map((r, i) => [
|
|
160
|
+
`### Worker ${i + 1}: ${r.title} [Status: ${r.status}]`,
|
|
161
|
+
r.error ? `Error: ${r.error}` : '',
|
|
162
|
+
r.output ? `Output:\n${r.output}` : '(no output)',
|
|
163
|
+
].filter(Boolean).join('\n')).join('\n\n---\n\n');
|
|
164
|
+
|
|
165
|
+
const prompt = [
|
|
166
|
+
synthesizer.prompt,
|
|
167
|
+
'',
|
|
168
|
+
'## Worker Subtask Outputs:',
|
|
169
|
+
summaryContext,
|
|
170
|
+
].join('\n');
|
|
171
|
+
|
|
172
|
+
const res = await askModel({
|
|
173
|
+
task,
|
|
174
|
+
prompt,
|
|
175
|
+
system: 'You synthesize multi-agent swarm execution results into a comprehensive, cohesive summary report.',
|
|
176
|
+
preferredModel: synthesizer.model || task.model,
|
|
177
|
+
maxTokens: 2000,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (res && res.ok && res.text) {
|
|
181
|
+
return res.text.trim();
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// Fallback to structured joining on failure
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Default clean markdown synthesis
|
|
189
|
+
const header = `## 🐝 Swarm Execution Report: ${task.title || task.id}\n`;
|
|
190
|
+
const successfulCount = subtaskRuns.filter((r) => r.status === 'success').length;
|
|
191
|
+
const statsLine = `**Summary**: ${successfulCount} / ${subtaskRuns.length} subtasks completed successfully.\n\n`;
|
|
192
|
+
|
|
193
|
+
const body = subtaskRuns.map((r) => {
|
|
194
|
+
const icon = r.status === 'success' ? '✅' : '❌';
|
|
195
|
+
const lines = [
|
|
196
|
+
`### ${icon} ${r.title} (${r.durationMs}ms)`,
|
|
197
|
+
r.error ? `> ⚠️ **Error**: ${r.error}` : '',
|
|
198
|
+
r.output ? r.output.trim() : '*(No output produced)*',
|
|
199
|
+
].filter(Boolean);
|
|
200
|
+
return lines.join('\n\n');
|
|
201
|
+
}).join('\n\n---\n\n');
|
|
202
|
+
|
|
203
|
+
return header + statsLine + body;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Main entry point for executing a swarm task.
|
|
208
|
+
*
|
|
209
|
+
* @param {Object} task
|
|
210
|
+
* @param {Object} options
|
|
211
|
+
* @param {Function} options.executeFn Worker execution handler: async (subtask) => result
|
|
212
|
+
* @param {Function} [options.askModel] Model querying function for dynamic decomposition/synthesis
|
|
213
|
+
* @param {AbortSignal} [options.signal]
|
|
214
|
+
* @param {Object} [options.logger]
|
|
215
|
+
* @returns {Promise<Object>} Execution result { ok, status, output, subtaskRuns, stats }
|
|
216
|
+
*/
|
|
217
|
+
export async function executeSwarmTask(task, { executeFn, askModel, signal, logger } = {}) {
|
|
218
|
+
const swarmConfig = sanitizeSwarmConfig(task.swarm);
|
|
219
|
+
let subtasks = [...swarmConfig.subtasks];
|
|
220
|
+
|
|
221
|
+
// 1. Dynamic decomposition via orchestrator prompt if subtasks not explicitly listed
|
|
222
|
+
if (subtasks.length === 0 && swarmConfig.orchestrator?.prompt && typeof askModel === 'function') {
|
|
223
|
+
try {
|
|
224
|
+
const decompRes = await askModel({
|
|
225
|
+
task,
|
|
226
|
+
prompt: swarmConfig.orchestrator.prompt,
|
|
227
|
+
system: ORCHESTRATOR_SYSTEM_PROMPT,
|
|
228
|
+
preferredModel: swarmConfig.orchestrator.model || task.model,
|
|
229
|
+
maxTokens: 1000,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (decompRes && decompRes.ok && decompRes.text) {
|
|
233
|
+
const parsed = parseJsonAnswer(decompRes.text);
|
|
234
|
+
if (parsed && Array.isArray(parsed.subtasks)) {
|
|
235
|
+
subtasks = parsed.subtasks.map((st, i) => ({
|
|
236
|
+
id: String(st.id || `subtask_${i + 1}`).trim(),
|
|
237
|
+
title: String(st.title || st.id || `Worker ${i + 1}`).trim(),
|
|
238
|
+
prompt: String(st.prompt || '').trim(),
|
|
239
|
+
type: st.type || 'llm',
|
|
240
|
+
model: st.model || task.model,
|
|
241
|
+
})).filter((st) => Boolean(st.prompt));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (logger) logger.warn(`[dsh-cron] Swarm dynamic decomposition failed: ${err.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Fallback: if still no subtasks, construct single task from prompt
|
|
250
|
+
if (subtasks.length === 0) {
|
|
251
|
+
subtasks = [{
|
|
252
|
+
id: 'worker_1',
|
|
253
|
+
title: task.title || 'Swarm Primary Worker',
|
|
254
|
+
prompt: task.prompt || '',
|
|
255
|
+
type: 'llm',
|
|
256
|
+
model: task.model,
|
|
257
|
+
}];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const startAll = Date.now();
|
|
261
|
+
|
|
262
|
+
// 2. Execute workers concurrently
|
|
263
|
+
const defaultWorkerExecutor = async (st) => {
|
|
264
|
+
if (typeof executeFn === 'function') {
|
|
265
|
+
return executeFn({
|
|
266
|
+
...task,
|
|
267
|
+
id: `${task.id}__${st.id}`,
|
|
268
|
+
title: `${task.title || task.id} -> ${st.title}`,
|
|
269
|
+
prompt: st.prompt,
|
|
270
|
+
type: st.type || task.type,
|
|
271
|
+
model: st.model || task.model,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return { output: `Worker ${st.title} completed`, status: 'success' };
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const subtaskRuns = await executeWorkerPool(subtasks, defaultWorkerExecutor, {
|
|
278
|
+
concurrencyLimit: swarmConfig.concurrencyLimit,
|
|
279
|
+
signal,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const totalDurationMs = Date.now() - startAll;
|
|
283
|
+
const completedCount = subtaskRuns.filter((r) => r.status === 'success').length;
|
|
284
|
+
const failedCount = subtaskRuns.filter((r) => r.status === 'error').length;
|
|
285
|
+
|
|
286
|
+
// 3. Synthesize final report
|
|
287
|
+
const output = await synthesizeSwarmResults({ task, subtaskRuns, askModel });
|
|
288
|
+
|
|
289
|
+
const status = failedCount === 0 ? 'success' : (completedCount > 0 ? 'warning' : 'error');
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
ok: status !== 'error',
|
|
293
|
+
status,
|
|
294
|
+
output,
|
|
295
|
+
subtaskRuns,
|
|
296
|
+
stats: {
|
|
297
|
+
total: subtasks.length,
|
|
298
|
+
completed: completedCount,
|
|
299
|
+
failed: failedCount,
|
|
300
|
+
totalDurationMs,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|