@goodandready/dsh-cron 0.3.13 → 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 CHANGED
@@ -90,6 +90,18 @@ graph TD
90
90
 
91
91
  ## ✨ Features & Capabilities
92
92
 
93
+ ### 🐝 Swarm Delegation & Parallel Multi-Agent Execution (#350)
94
+ - **Controlled Worker Pool**: Configure `concurrencyLimit` (1..10, default 3) to execute independent subtasks in parallel without overloading system resources.
95
+ - **Fault-Tolerant Decomposition**: Subtask failures are isolated; the swarm aggregates all successful results and flags partial errors gracefully.
96
+ - **Automated Synthesis**: Aggregates worker outputs into a structured Markdown digest or delegates synthesis to a designated synthesizer model.
97
+ - **Swarm Metrics & History**: Run history records total, succeeded, failed counts, and duration alongside detailed per-worker execution logs.
98
+
99
+ ### 🔍 Git Diff & Interactive Patch Review (#351)
100
+ - **Automatic Diff Capture**: Tasks running in Git worktrees capture modified files, stat summary, and unified diffs (up to 256 KiB) automatically.
101
+ - **1-Click Discard**: Revert uncommitted working directory changes safely with a single click (`git reset --hard HEAD && git clean -fd`).
102
+ - **1-Click Apply**: Re-apply previously captured diffs directly to the repository (`git apply --whitespace=fix`).
103
+ - **Interactive Run Preview**: Color-coded diff inspector embedded directly into the execution history drawer.
104
+
93
105
  ### 🔀 Decision Nodes & Conditional IF/ELSE Branching (#352)
94
106
  - **Conditional Routing**: Tasks can define `branchRules` evaluating execution outcomes (`exitCode`, `output`, `output.json.<path>`, `status`) with operators (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
95
107
  - **Fallback Else**: Optional `elseTask` triggers when no branch condition matches.
package/README.ru.md CHANGED
@@ -90,6 +90,18 @@ graph TD
90
90
 
91
91
  ## ✨ Возможности
92
92
 
93
+ ### 🐝 Роевая декомпозиция задач и мультиагентный запуск (Swarm Delegation) (#350)
94
+ - **Управляемый пул воркеров**: Настройка `concurrencyLimit` (1..10, по умолчанию 3) для параллельного выполнения независимых подзадач без перегрузки системы.
95
+ - **Отказоустойчивая декомпозиция**: Ошибки отдельных воркеров изолированы; рой объединяет успешные результаты и корректно фиксирует частичные сбои.
96
+ - **Автоматический синтез**: Сведение результатов всех воркеров в структурированный Markdown-дайджест или через выделенную модель-синтезатор.
97
+ - **Метрики и история роя**: Сохранение количества успешных/сбойных задач, времени выполнения и детальных логов каждого воркера.
98
+
99
+ ### 🔍 Просмотр Git Diff и интерактивное подтверждение патчей (#351)
100
+ - **Автоматический захват диффов**: Задачи в Git-каталогах автоматически сохраняют список файлов, сводку изменений и унифицированный diff (до 256 КиБ).
101
+ - **1-Click Discard**: Безопасный откат незакоммиченных изменений в один клик (`git reset --hard HEAD && git clean -fd`).
102
+ - **1-Click Apply**: Повторное наложение сохранённого патча на рабочий каталог (`git apply --whitespace=fix`).
103
+ - **Интерактивный просмотр**: Цветовая подсветка строк diff в карточке истории запусков.
104
+
93
105
  ### 🔀 Решающие узлы и условные развилки IF/ELSE (#352)
94
106
  - **Маршрутизация по условию**: Задачи могут задавать `branchRules` для проверки результата (`exitCode`, `output`, `output.json.<path>`, `status`) через операторы (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
95
107
  - **Ветка Else**: Резервная задача `elseTask` запускается, если ни одно из правил не совпало.
package/README.zh.md CHANGED
@@ -90,6 +90,18 @@ graph TD
90
90
 
91
91
  ## ✨ 功能与能力
92
92
 
93
+ ### 🐝 群蚁协同委托与多智能体并行执行 (Swarm Delegation) (#350)
94
+ - **受控工作池**:配置并发限制 `concurrencyLimit`(1..10,默认 3),在不耗尽系统资源的前提下并行处理独立子任务。
95
+ - **高容错分解**:单个子任务异常完全隔离,群体自动聚合所有成功输出,并在存在异常时清晰上报。
96
+ - **智能结果汇编**:支持将所有工作节点的执行输出聚合成结构化 Markdown 报告或交由综合模型提炼总结。
97
+ - **群体指标与历史**:运行历史完整记录成功/失败统计、耗时及每个子节点的执行快照与日志。
98
+
99
+ ### 🔍 Git Diff 变更查看与交互式补丁审查 (#351)
100
+ - **自动 Diff 捕获**:在 Git 工作区执行的任务会自动捕获变更文件列表、状态统计及完整 Unified Diff(最高 256 KiB)。
101
+ - **一键丢弃 (Discard)**:一键清空工作区未提交改动,安全重置仓库状态(`git reset --hard HEAD && git clean -fd`)。
102
+ - **一键应用 (Apply)**:将保存的 Diff 补丁直接重新注入并应用到工作区(`git apply --whitespace=fix`)。
103
+ - **交互式审阅面板**:在运行详情抽屉中直接查看带有色彩区分的高亮 Diff 变更对比。
104
+
93
105
  ### 🔀 决策节点与条件分支 IF/ELSE (#352)
94
106
  - **条件路由**:任务可配置 `branchRules`(评估 `exitCode`、`output`、`output.json.<path>` 等字段),支持 `equals`、`notequals`、`contains`、`gt`、`lt`、`regex` 等操作符。
95
107
  - **回退分支**:若无匹配规则,自动执行 `elseTask`。
package/lib/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { captureGitDiff, discardGitChanges, applyGitDiff } from './git-diff.js';
1
2
  import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from './doctor.js';
2
3
  import { handleTaskWebhookRoute } from './api-task-webhook.js';
3
4
  import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
@@ -346,6 +347,24 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, sub
346
347
  sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
347
348
  return;
348
349
  }
350
+ if (action === 'git-diff') {
351
+ const task = store.get(id);
352
+ if (!task) {
353
+ sendJson(res, 404, NOT_FOUND);
354
+ return;
355
+ }
356
+ const runs = store.getHistory(id, 10);
357
+ const runId = url.searchParams.get('runId') || subAction;
358
+ const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
359
+ const diffData = targetRun?.gitDiff || null;
360
+ sendJson(res, 200, {
361
+ ok: true,
362
+ taskId: id,
363
+ runId: targetRun?.id || null,
364
+ gitDiff: diffData,
365
+ });
366
+ return;
367
+ }
349
368
  if (action === 'doctor') {
350
369
  const task = store.get(id);
351
370
  if (!task) {
@@ -399,6 +418,50 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, sub
399
418
 
400
419
  /** Task actions that are plain POSTs. Returns true when one matched. */
401
420
  async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
421
+ if (action === 'git-diff' && (subAction === 'discard' || subAction === 'revert')) {
422
+ const task = store.get(id);
423
+ if (!task) {
424
+ sendJson(res, 404, NOT_FOUND);
425
+ return true;
426
+ }
427
+ const targetDir = task.cwd;
428
+ if (!targetDir) {
429
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
430
+ return true;
431
+ }
432
+ const result = await discardGitChanges(targetDir);
433
+ sendJson(res, result.ok ? 200 : 500, result);
434
+ return true;
435
+ }
436
+
437
+ if (action === 'git-diff' && subAction === 'apply') {
438
+ const task = store.get(id);
439
+ if (!task) {
440
+ sendJson(res, 404, NOT_FOUND);
441
+ return true;
442
+ }
443
+ const body = await readBody(req, res);
444
+ const bodyData = (body && body.body) || {};
445
+ let patch = bodyData.diff || bodyData.patch;
446
+ if (!patch) {
447
+ const runs = store.getHistory(id, 5);
448
+ const targetRun = runs.find((r) => r.gitDiff?.diff);
449
+ patch = targetRun?.gitDiff?.diff;
450
+ }
451
+ if (!patch) {
452
+ sendJson(res, 400, { ok: false, error: 'No diff patch provided or found in run history' });
453
+ return true;
454
+ }
455
+ const targetDir = task.cwd;
456
+ if (!targetDir) {
457
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
458
+ return true;
459
+ }
460
+ const result = await applyGitDiff(targetDir, patch);
461
+ sendJson(res, result.ok ? 200 : 500, result);
462
+ return true;
463
+ }
464
+
402
465
  if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
403
466
  const task = store.get(id);
404
467
  if (!task) {
package/lib/client.js CHANGED
@@ -3557,6 +3557,85 @@ onClick: () => openEditModal(selectedTaskMeta, { prompt: run.suggestion })
3557
3557
  }, T_KEY(t, 'history.applySuggestion'))
3558
3558
  )
3559
3559
  ),
3560
+ run.subtaskRuns && Array.isArray(run.subtaskRuns) && run.subtaskRuns.length > 0 && h('div', {
3561
+ style: { marginTop: '8px', padding: '8px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: '6px', fontSize: '12px' }
3562
+ },
3563
+ h('div', { style: { fontWeight: '600', marginBottom: '4px' } }, '🐝 ' + T_KEY(t, 'history.swarmBadge') + ' (' + run.subtaskRuns.filter(r => r.status === 'success').length + '/' + run.subtaskRuns.length + ')'),
3564
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
3565
+ run.subtaskRuns.map((st, sidx) => h('div', {
3566
+ key: sidx,
3567
+ style: { display: 'flex', justifyContent: 'space-between', padding: '2px 6px', background: 'var(--dsw-alias-bg-layer-3)', borderRadius: '4px' }
3568
+ },
3569
+ h('span', null, (st.status === 'success' ? '✅ ' : '❌ ') + st.title),
3570
+ h('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '11px' } }, (st.durationMs || 0) + 'ms')
3571
+ ))
3572
+ )
3573
+ ),
3574
+ run.gitDiff && run.gitDiff.hasChanges && h('div', {
3575
+ className: 'dsh-cron-status-banner',
3576
+ style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' }
3577
+ },
3578
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3579
+ h('strong', null, '📝 ' + T_KEY(t, 'history.gitDiff')),
3580
+ h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, run.gitDiff.stat || '')
3581
+ ),
3582
+ run.gitDiff.diff && h('div', {
3583
+ style: {
3584
+ background: 'var(--dsw-alias-bg-layer-2)',
3585
+ border: '1px solid var(--dsw-alias-border-l2)',
3586
+ borderRadius: '4px',
3587
+ padding: '6px',
3588
+ fontFamily: 'monospace',
3589
+ fontSize: '11px',
3590
+ maxHeight: '140px',
3591
+ overflowY: 'auto',
3592
+ whiteSpace: 'pre'
3593
+ }
3594
+ }, run.gitDiff.diff),
3595
+ h('div', { style: { display: 'flex', gap: '8px', marginTop: '4px' } },
3596
+ h('button', {
3597
+ type: 'button',
3598
+ className: 'dsh-cron-btn-secondary',
3599
+ style: { padding: '2px 8px', fontSize: '12px' },
3600
+ onClick: async () => {
3601
+ if (!confirm('Discard all uncommitted changes in task directory?')) return;
3602
+ const c = typeof AbortController !== 'undefined' ? new AbortController() : null;
3603
+ const tId = c ? setTimeout(() => c.abort(), 15000) : null;
3604
+ try {
3605
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(formId) + '/git-diff/discard', {
3606
+ method: 'POST',
3607
+ signal: c ? c.signal : undefined
3608
+ });
3609
+ const d = await res.json();
3610
+ if (d.ok) { alert('Working directory reset successfully.'); if (formId) loadTaskHistory(formId); }
3611
+ else alert(d.error || 'Failed to discard changes');
3612
+ } catch (e) { alert(e.message); }
3613
+ finally { if (tId) clearTimeout(tId); }
3614
+ }
3615
+ }, '🗑️ ' + T_KEY(t, 'history.discardDiff')),
3616
+ h('button', {
3617
+ type: 'button',
3618
+ className: 'dsh-cron-btn-primary',
3619
+ style: { padding: '2px 8px', fontSize: '12px' },
3620
+ onClick: async () => {
3621
+ const c = typeof AbortController !== 'undefined' ? new AbortController() : null;
3622
+ const tId = c ? setTimeout(() => c.abort(), 15000) : null;
3623
+ try {
3624
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(formId) + '/git-diff/apply', {
3625
+ method: 'POST',
3626
+ headers: { 'Content-Type': 'application/json' },
3627
+ signal: c ? c.signal : undefined,
3628
+ body: JSON.stringify({ diff: run.gitDiff.diff })
3629
+ });
3630
+ const d = await res.json();
3631
+ if (d.ok) { alert('Patch applied successfully.'); if (formId) loadTaskHistory(formId); }
3632
+ else alert(d.error || 'Failed to apply patch');
3633
+ } catch (e) { alert(e.message); }
3634
+ finally { if (tId) clearTimeout(tId); }
3635
+ }
3636
+ }, '✅ ' + T_KEY(t, 'history.applyDiff'))
3637
+ )
3638
+ ),
3560
3639
  run.silentSkip && h('div', {
3561
3640
  className: 'dsh-cron-hint',
3562
3641
  style: { display: 'flex', gap: '6px', alignItems: 'center' }
@@ -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,5 @@
1
+ import { executeSwarmTask } from './swarm.js';
2
+ import { captureGitDiff } from './git-diff.js';
1
3
  import { resolveBranchTargets } from './branching.js';
2
4
  import { shouldAutoHeal, applyDoctorFix } from './doctor.js';
3
5
  import { recordPromptRunOutcome } from './prompt-evolver.js';
@@ -139,6 +141,25 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
139
141
  model: task.model || '',
140
142
  fallback: false,
141
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
+ }
142
163
  if (typeof scheduler.executeFn !== 'function') return result;
143
164
  const executableTask = resolveTaskForExecution(task, options);
144
165
  try {
@@ -772,6 +793,24 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
772
793
  },
773
794
  ];
774
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
+
775
814
  runInfo.stages = stages;
776
815
  runInfo.delivery = deliveryResult;
777
816
  runInfo.cost = {
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
+ }
@@ -1,3 +1,4 @@
1
+ import { sanitizeSwarmConfig } from './swarm.js';
1
2
  import { sanitizeBranchRules } from './branching.js';
2
3
  import { sanitizePromptHistory } from './prompt-evolver.js';
3
4
  import { sanitizeAdaptiveConfig } from './adaptive.js';
@@ -57,6 +58,7 @@ export function executeCreateTask(store, scheduler, args) {
57
58
  onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
58
59
  onFailure: args.onFailure ? String(args.onFailure).trim() : '',
59
60
  branchRules: args.branchRules !== undefined ? sanitizeBranchRules(args.branchRules) : undefined,
61
+ swarm: args.swarm !== undefined ? sanitizeSwarmConfig(args.swarm) : undefined,
60
62
  elseTask: args.elseTask ? String(args.elseTask).trim() : undefined,
61
63
  autoHeal: args.autoHeal !== undefined ? Boolean(args.autoHeal) : undefined,
62
64
  selfHealing: args.selfHealing && typeof args.selfHealing === 'object' ? {
package/lib/task-patch.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeSwarmConfig } from './swarm.js';
1
2
  import { sanitizeBranchRules } from './branching.js';
2
3
  import { trackPromptEdit, sanitizePromptHistory } from './prompt-evolver.js';
3
4
  import { sanitizeAdaptiveConfig } from './adaptive.js';
@@ -96,6 +96,8 @@ export const PATCHABLE_TASK_FIELDS = [
96
96
  'revisions',
97
97
  'previousPrompt',
98
98
  'previousScript',
99
+ 'swarm',
100
+ 'gitDiff',
99
101
  ];
100
102
 
101
103
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",