@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 +12 -0
- package/README.ru.md +12 -0
- package/README.zh.md +12 -0
- package/lib/api.js +63 -0
- package/lib/client.js +79 -0
- package/lib/git-diff.js +226 -0
- package/lib/scheduler-execution.js +39 -0
- package/lib/store.js +4 -0
- package/lib/swarm.js +303 -0
- package/lib/task-create.js +2 -0
- package/lib/task-patch.js +1 -0
- package/lib/task-transfer.js +2 -0
- package/package.json +1 -1
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' }
|
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,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
|
+
}
|
package/lib/task-create.js
CHANGED
|
@@ -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
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
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",
|