@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.
@@ -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
- } else if (inspection.error && task.inspectOnFailure) {
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 chainedTargets = parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
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 = parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
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
+ }