@goodandready/dsh-cron 0.3.13 → 0.3.15
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 +13 -1
- package/README.ru.md +13 -1
- package/README.zh.md +13 -1
- package/lib/api.js +63 -0
- package/lib/client.js +79 -0
- package/lib/git-diff.js +226 -0
- package/lib/index.js +1 -1
- package/lib/runner-worktree.js +35 -5
- 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.
|
|
@@ -193,7 +205,7 @@ Every task picks its own runtime; non-LLM runtimes need no model and consume no
|
|
|
193
205
|
* **SSH** (`ssh`) — execute a command on a remote host through a `dsh-remote-workspace` profile (`sshProfileId`) or standalone host/key fields.
|
|
194
206
|
* **Docker** (`docker`) — run the command in a container image (`dockerImage`).
|
|
195
207
|
* **Environment variables** — a per-task `env` map (KEY VALUE per line in the UI) applied to external runtimes; secrets do not belong here.
|
|
196
|
-
* **Workspaces and worktrees** — bind a task to a harness workspace (`workspaceId`)
|
|
208
|
+
* **Workspaces and worktrees** — bind a task to a harness workspace (`workspaceId`, resolved through host `workspaceRegistry`) or path, and run code-modifying agent tasks in an isolated git worktree (`worktree`, `keepWorktree`).
|
|
197
209
|
|
|
198
210
|
### 7. Cost Control: Fallback Model & Burn Guard
|
|
199
211
|
* **Fallback Model** — A task can run on the cheap model by default and still finish on the strong one: set `fallbackModel` (and optionally `fallbackProvider`) and a failed run — `error` or `timeout` — is retried **once** on that model before the ordinary retry backoff applies. History records which model produced the result and whether the fallback was used, usage and cost of both attempts are summed, and the `{model}` template variable renders the model that finished the run. Only agent-mediated tasks (`llm`, `skill`, `workflow`) can use a fallback.
|
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` запускается, если ни одно из правил не совпало.
|
|
@@ -193,7 +205,7 @@ cron({
|
|
|
193
205
|
* **SSH** (`ssh`) — выполнение команды на удалённом хосте через профиль `dsh-remote-workspace` (`sshProfileId`) или отдельные поля host/key.
|
|
194
206
|
* **Docker** (`docker`) — выполнение команды в контейнере образа (`dockerImage`).
|
|
195
207
|
* **Переменные окружения** — карта `env` на задачу (в UI — строки KEY VALUE) для внешних рантаймов; секретам здесь не место.
|
|
196
|
-
* **Workspace и worktree** — привязка задачи к workspace харнесса (`workspaceId
|
|
208
|
+
* **Workspace и worktree** — привязка задачи к workspace харнесса (`workspaceId`, разрешаемому через службу `workspaceRegistry`) или пути, и запуск в изолированном git worktree (`worktree`, `keepWorktree`).
|
|
197
209
|
|
|
198
210
|
### 7. Экономия: fallback-модель и защита бюджета (Burn Guard)
|
|
199
211
|
* **Fallback-модель** — задача может идти на дешёвой модели по умолчанию и всё же завершиться на сильной: задайте `fallbackModel` (и при необходимости `fallbackProvider`), и сбойный запуск (`error` или `timeout`) один раз повторится на этой модели, прежде чем включится обычный retry с задержкой. В истории видно, какая модель произвела результат и был ли использован fallback; расход и стоимость обеих попыток суммируются; переменная шаблона `{model}` подставляет модель, завершившую запуск. Fallback доступен только агентским типам (`llm`, `skill`, `workflow`).
|
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`。
|
|
@@ -193,7 +205,7 @@ cron({
|
|
|
193
205
|
* **SSH**(`ssh`)—— 通过 `dsh-remote-workspace` 配置(`sshProfileId`)或独立 host/key 字段在远程主机执行命令。
|
|
194
206
|
* **Docker**(`docker`)—— 在镜像容器(`dockerImage`)中执行命令。
|
|
195
207
|
* **环境变量** —— 按任务的 `env` 映射(界面中每行 KEY VALUE)应用于外部运行时;请勿在此存放密钥。
|
|
196
|
-
* **工作区与 worktree** —— 将任务绑定到 Harness 工作区(`workspaceId
|
|
208
|
+
* **工作区与 worktree** —— 将任务绑定到 Harness 工作区(`workspaceId`,通过宿主 `workspaceRegistry` 服务解析)或直接路径;对会修改代码的智能体任务,可在隔离的 git worktree 中运行(`worktree`、`keepWorktree`)。
|
|
197
209
|
|
|
198
210
|
### 7. 成本控制:回退模型与支出保护(Burn Guard)
|
|
199
211
|
* **回退模型** —— 任务可以默认使用便宜模型,失败时改用更强模型完成:设置 `fallbackModel`(可选 `fallbackProvider`),失败(`error` 或 `timeout`)的运行会在该模型上重试一次,之后才进入常规重试退避。历史记录会标明最终产出结果的模型以及是否使用了回退,两次尝试的用量与成本都会累计,模板变量 `{model}` 渲染完成运行的模型。回退仅适用于智能体类型(`llm`、`skill`、`workflow`)。
|
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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -95,7 +95,7 @@ export {
|
|
|
95
95
|
};
|
|
96
96
|
|
|
97
97
|
export const name = '@goodandready/dsh-cron';
|
|
98
|
-
export const inject = ['tools', 'webServer', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
|
|
98
|
+
export const inject = ['tools', 'webServer', 'llm', 'agents', 'agentDefaultModel', 'credentials', 'workspaceRegistry'];
|
|
99
99
|
|
|
100
100
|
import { executeCreateTask } from './task-create.js';
|
|
101
101
|
import {
|
package/lib/runner-worktree.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { logger } from './logger.js';
|
|
2
2
|
import { bestEffort } from './best-effort.js';
|
|
3
3
|
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Resolve the working directory for a task: explicit cwd, a bound
|
|
6
8
|
* workspace id resolved through the harness, or the process cwd (#31).
|
|
@@ -8,17 +10,45 @@ import { bestEffort } from './best-effort.js';
|
|
|
8
10
|
export function resolveCwd(task, ctx = null) {
|
|
9
11
|
if (task.cwd) return String(task.cwd);
|
|
10
12
|
if (task.workspaceId) {
|
|
11
|
-
|
|
13
|
+
const rawId = String(task.workspaceId).trim();
|
|
14
|
+
if (ctx) {
|
|
12
15
|
const resolved = bestEffort('resolve-workspace-path', () => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
// Issue #363 / GH #12: host workspace service is 'workspaceRegistry' in DeepSeek Harness
|
|
17
|
+
const registry =
|
|
18
|
+
(typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : null) ||
|
|
19
|
+
ctx.workspaceRegistry ||
|
|
20
|
+
(typeof ctx.get === 'function' ? ctx.get('workspaces') : null) ||
|
|
21
|
+
ctx.workspaces;
|
|
22
|
+
|
|
23
|
+
if (registry) {
|
|
24
|
+
// 1. Direct get(id) lookup on WorkspaceRegistry
|
|
25
|
+
if (typeof registry.get === 'function') {
|
|
26
|
+
const ws = registry.get(rawId);
|
|
27
|
+
if (ws && ws.path) return String(ws.path);
|
|
28
|
+
}
|
|
29
|
+
// 2. List lookup if ID, title, or Path was provided ("Workspace id or path")
|
|
30
|
+
if (typeof registry.list === 'function') {
|
|
31
|
+
const list = registry.list();
|
|
32
|
+
if (Array.isArray(list)) {
|
|
33
|
+
const matched = list.find((w) => w && (w.id === rawId || w.path === rawId || w.title === rawId));
|
|
34
|
+
if (matched && matched.path) return String(matched.path);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
17
37
|
}
|
|
18
38
|
return null;
|
|
19
39
|
});
|
|
20
40
|
if (resolved) return resolved;
|
|
21
41
|
}
|
|
42
|
+
|
|
43
|
+
// 3. Fallback: if task.workspaceId directly points to an existing directory path on disk
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(rawId) && fs.statSync(rawId).isDirectory()) {
|
|
46
|
+
return rawId;
|
|
47
|
+
}
|
|
48
|
+
} catch (_fsErr) {
|
|
49
|
+
// ignore filesystem stat error
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
// Issue #231: fail-closed if workspaceId was specified but cannot be resolved
|
|
23
53
|
throw new Error(`Workspace "${task.workspaceId}" requested by task could not be resolved`);
|
|
24
54
|
}
|
|
@@ -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.15",
|
|
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",
|