@wjj865-code/dsh-orchestrator 0.3.4 → 0.4.0
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/LICENSE +21 -0
- package/README.md +23 -8
- package/client.js +6 -2
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/card.js +2 -2
- package/src/deps.js +133 -0
- package/src/host-route.js +5 -1
- package/src/ledger.js +50 -10
- package/src/queue.js +20 -2
- package/src/state.js +2 -1
- package/src/tools.js +154 -13
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wjj865-code
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ scoped 包默认是私有,**必须带 `--access public`** 才会公开;开
|
|
|
99
99
|
@wjj865-code/dsh-orchestrator
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
也可以安装指定版本:`@wjj865-code/dsh-orchestrator@0.
|
|
102
|
+
也可以安装指定版本:`@wjj865-code/dsh-orchestrator@0.4.0`。
|
|
103
103
|
2. 装完彻底退出 DSH 再启动。
|
|
104
104
|
3. 以后升级:`npm version patch` → `npm publish` → 在 DSH 插件页用 registry 包的更新入口升级(DSH 的 plugin manager 对 registry 包支持 `listVersions` / `updateBundle`),不再需要卸了重装 GitHub 版。
|
|
105
105
|
|
|
@@ -134,7 +134,7 @@ scoped 包默认是私有,**必须带 `--access public`** 才会公开;开
|
|
|
134
134
|
| `spawnProvider` | `spawn` | subagent provider(本地 fresh 子会话用 `spawn`) |
|
|
135
135
|
| `staleMinutes` | `30` | `task_list` 里超过这么多分钟无动静就标 `⚠` |
|
|
136
136
|
| `stallRounds` | `3` | 预留:阶段 3 的连续无进展升级阈值,阶段 1 只校验并保存 |
|
|
137
|
-
| `maxQueue` | `50` |
|
|
137
|
+
| `maxQueue` | `50` | 队列安全阀:`pending` + `waiting_dep` 到这个数就拒绝新建(不落库) |
|
|
138
138
|
| `autoResumeQueue` | `true` | 重启后把排队中的任务自动重新入队并派发;关掉则只恢复队列不自动跑 |
|
|
139
139
|
| `maxActive` | — | **已移除**:并发上限改由平台 `ACTIVATION_LIMIT_REACHED` + 插件队列自适应,旧配置里写了也会被忽略 |
|
|
140
140
|
| `mergeWindowMs` | `5000` | 完成通知合并窗口 |
|
|
@@ -151,6 +151,20 @@ scoped 包默认是私有,**必须带 `--access public`** 才会公开;开
|
|
|
151
151
|
- 「协调者会话尚未就绪(`agent.session` 为空)」按可重试处理:任务留在队列,等主会话就绪后重试,不标 failed。
|
|
152
152
|
- 排队中的任务被 `task_stop` 会直接取消并移出队列;`task_message` 对排队任务返回可读提示(还没有子会话可续派)。
|
|
153
153
|
|
|
154
|
+
## 依赖、分组、写域与优先级(P1)
|
|
155
|
+
|
|
156
|
+
- `task_create` 新增可选参数:
|
|
157
|
+
- `dependsOn`:前置任务编号数组(最多 20 个),必须存在且不能是 `failed/cancelled/orphaned`。
|
|
158
|
+
- `group`:分组名,`task_list({ group })` 可看该组进度;面板行显示分组。
|
|
159
|
+
- `writeScopes`:写域(目录/文件路径),与活跃任务重叠时返回冲突提示,仅 advisory,不阻断。
|
|
160
|
+
- `priority`:`-10..10`,高的先派发;同优先级按创建时间 FIFO。
|
|
161
|
+
- 前置未完成时任务落 `waiting_dep`(不派发、不占并发位);每次 `queue.pump` 先做依赖晋升:
|
|
162
|
+
- 前置全部 `done` → `pending` 并入队;派发时 brief 追加 `【前置任务】` 摘要和产物路径。
|
|
163
|
+
- 前置 `failed/cancelled/orphaned` 或不存在 → 下游自动 `failed`,错误写明哪个前置未完成。
|
|
164
|
+
- 只允许依赖已存在的任务,创建时不接受“未来的 id”,因此不会出现环;依赖失败后先 `task_retry` 前置,再重派下游。
|
|
165
|
+
- `maxQueue` 口径包含 `waiting_dep`,避免等待图无限膨胀。
|
|
166
|
+
- 面板/快照行新增 `dependsOn`、`groupId`、`writeScopes`、`priority`;`waiting_dep` 显示「等前置」。
|
|
167
|
+
|
|
154
168
|
## 只读子任务(显式 `readOnly`)
|
|
155
169
|
|
|
156
170
|
- `task_create` 可选 `readOnly: true`(默认 false);**只认显式 true**,字符串/偏真值不算,不按标题关键词猜。协调者自己判断任务性质(分析/审查/评估)后显式传。用法:
|
|
@@ -235,6 +249,7 @@ scoped 包默认是私有,**必须带 `--access public`** 才会公开;开
|
|
|
235
249
|
17. **记忆闭环**:`memory_note` 写一条 → `history_search` 能查到 → 新建相关任务时 brief 带 `【历史经验】`;`task_report done` 后再 `history_search` 能查到该任务摘要。
|
|
236
250
|
18. **项目上下文**:`project_context` 对项目目录返回 README/package/顶层结构/最近任务;不出现 `.env` 内容;不存在的 target 给可读错误。
|
|
237
251
|
19. **重派与快照**:人为让任务 `failed` 后 `task_retry` 生成新任务、旧任务保留;`snapshots/tasks-YYYY-MM-DD.sqlite` 可打开且含任务;重启同一天不重复覆盖。
|
|
252
|
+
20. **依赖/分组/优先级**:A 运行中建 `dependsOn:[A]` 的 B → B 是 `waiting_dep` 不派发;A `done` 后 B 自动派发且 brief 带 `【前置任务】`;A 失败时 B 自动失败。高 `priority` 任务先派发;同 `group` 可在 `task_list({group})` 看进度;`writeScopes` 重叠只提示不阻断。
|
|
238
253
|
|
|
239
254
|
|
|
240
255
|
## 工具
|
|
@@ -243,12 +258,12 @@ scoped 包默认是私有,**必须带 `--access public`** 才会公开;开
|
|
|
243
258
|
|
|
244
259
|
| 工具 | 作用 |
|
|
245
260
|
| --- | --- |
|
|
246
|
-
| `task_create` | 建台账 + 渲染 brief +
|
|
247
|
-
| `task_list` |
|
|
248
|
-
| `task_status` | 单任务详情 + 最近 3 条消息 + 子会话打开方式 |
|
|
249
|
-
| `task_message` | 续派消息(不新建任务),失败重试 3 次后转 `waiting_user
|
|
250
|
-
| `task_stop` | 中断当前一轮并标 `cancelled
|
|
251
|
-
| `task_retry` | 重派 `failed`/`orphaned` 任务:用原 brief 新建一次尝试(新任务号,`retry_of`
|
|
261
|
+
| `task_create` | 建台账 + 渲染 brief + 入队派发;支持 `dependsOn`(前置)、`group`(分组)、`writeScopes`(写域冲突提示)、`priority`(-10..10);缺验收标准/队列满(`maxQueue`)会拒绝;并发满自动排队,不报错;`readOnly:true` 时子会话沙箱只读 |
|
|
262
|
+
| `task_list` | 默认只列活跃任务(含 `waiting_dep`);带标题、目标摘要、最近一条消息、排队/缺前置/陈旧标记;`group` 过滤并看组进度;`active:false` 看全量 |
|
|
263
|
+
| `task_status` | 单任务详情 + 依赖/写域/分组/优先级 + 最近 3 条消息 + 子会话打开方式 |
|
|
264
|
+
| `task_message` | 续派消息(不新建任务),失败重试 3 次后转 `waiting_user`;排队/等前置任务返回提示 |
|
|
265
|
+
| `task_stop` | 中断当前一轮并标 `cancelled`;排队中或 `waiting_dep` 的任务直接取消 |
|
|
266
|
+
| `task_retry` | 重派 `failed`/`orphaned` 任务:用原 brief 新建一次尝试(新任务号,`retry_of` 留审计);保留依赖/分组/写域/优先级 |
|
|
252
267
|
| `history_search` | 查跨项目记忆、历史任务、平台历史会话;给新任务补上下文 |
|
|
253
268
|
| `project_context` | 读项目背景白名单文件、顶层结构、最近任务与相关记忆;只读 |
|
|
254
269
|
| `memory_note` | 记一条跨任务有效的决策/偏好/约束,供之后检索与 brief 注入 |
|
package/client.js
CHANGED
|
@@ -18,7 +18,7 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
const STATUS_LABEL = {
|
|
21
|
-
pending: '待派发', running: '进行中', waiting_user: '等你确认', waiting_auth: '要授权',
|
|
21
|
+
waiting_dep: '等前置', pending: '待派发', running: '进行中', waiting_user: '等你确认', waiting_auth: '要授权',
|
|
22
22
|
done: '已完成', failed: '失败', cancelled: '已取消', orphaned: '会话丢失',
|
|
23
23
|
}
|
|
24
24
|
const TONE_COLOR = {
|
|
@@ -186,7 +186,7 @@ window.__ModuleLoader__.load({
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
const matchFilter = task => filter === 'all' ? true
|
|
189
|
-
: filter === 'active' ? (task.status === 'pending' || task.status === 'running')
|
|
189
|
+
: filter === 'active' ? (task.status === 'waiting_dep' || task.status === 'pending' || task.status === 'running')
|
|
190
190
|
: filter === 'waiting' ? (task.status === 'waiting_user' || task.status === 'waiting_auth')
|
|
191
191
|
: filter === 'done' ? task.status === 'done'
|
|
192
192
|
: filter === 'failed' ? task.status === 'failed'
|
|
@@ -269,6 +269,10 @@ window.__ModuleLoader__.load({
|
|
|
269
269
|
task.targetDir !== null && task.targetDir !== undefined
|
|
270
270
|
? h('span', { style: styles.meta, title: task.targetDir }, shortPath(task.targetDir))
|
|
271
271
|
: null,
|
|
272
|
+
task.groupId ? h('span', { style: styles.meta, title: '分组' }, `组 ${task.groupId}`) : null,
|
|
273
|
+
task.dependsOn && task.dependsOn.length > 0
|
|
274
|
+
? h('span', { style: styles.meta, title: task.dependsOn.join('、') }, `依赖 ${task.dependsOn.length}`)
|
|
275
|
+
: null,
|
|
272
276
|
task.queued ? h('span', { style: styles.meta }, '排队中') : null,
|
|
273
277
|
task.stale ? h('span', { style: styles.meta }, '⚠') : null,
|
|
274
278
|
h('span', { style: styles.meta }, STATUS_LABEL[task.status] || task.status),
|
package/index.js
CHANGED
|
@@ -25,6 +25,7 @@ const COORDINATOR_POLICY = `你是 dsh-orchestrator 的协调者。硬性规则
|
|
|
25
25
|
- 工具表里可能仍列出这些团队工具(agent-scope 自注册,预设层删不掉),看到也不要调用;执行层会直接拒绝并返回原因。
|
|
26
26
|
- 查看进度只用 task_list / task_status。
|
|
27
27
|
- 派任务前可用 history_search 查跨项目记忆与历史任务,用 project_context 读项目背景;长期有效的用户决策/偏好用 memory_note 记下来。
|
|
28
|
+
- 有先后关系时用 task_create 的 dependsOn;同一需求用 group 归组;会改同一批文件时用 writeScopes 标写域;紧急任务用 priority(-10..10)。
|
|
28
29
|
- 任何关于任务数量、重复、冲突、状态的断言,必须先调用 task_list / task_status 拿到工具返回再说话;没有工具输出佐证就不许下结论,不得凭记忆或推断。
|
|
29
30
|
- 子任务完成或需要你决定时会主动通知你;收到通知再行动。
|
|
30
31
|
- 本回合没有别的事就立即结束回合;通知会开启新回合。
|
|
@@ -150,6 +151,7 @@ export async function apply(ctx, rawConfig = {}) {
|
|
|
150
151
|
// 协调者可能比插件晚加载:队列在等它,加载后立刻补跑,并对一次子会话目录。
|
|
151
152
|
queue?.pump()
|
|
152
153
|
reconcileOrphans({ db, dispatcher, coordinatorSessionId: id, isLive: isChildLive, logger, onWarn: warnings })
|
|
154
|
+
.then(() => queue?.pump())
|
|
153
155
|
.catch(error => warnings(`dsh-orchestrator: 重启对账失败:${error?.message ?? error}`))
|
|
154
156
|
}
|
|
155
157
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wjj865-code/dsh-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Coordinator + task sessions for DeepSeek Harness: durable task ledger, cross-project memory, project context, progress reports, interrupt-controlled notifications, snapshots, and a browser task panel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/card.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// 卡片只描述数据,不关心渲染框架;阶段 2 的客户端直接复用它。
|
|
2
2
|
const STATUS_LABEL = {
|
|
3
|
-
pending: '待派发', running: '进行中', waiting_user: '等你确认', waiting_auth: '要授权',
|
|
3
|
+
waiting_dep: '等前置', pending: '待派发', running: '进行中', waiting_user: '等你确认', waiting_auth: '要授权',
|
|
4
4
|
done: '已完成', failed: '失败', cancelled: '已取消', orphaned: '会话丢失',
|
|
5
5
|
}
|
|
6
6
|
const STATUS_TONE = {
|
|
7
|
-
pending: 'muted', running: 'info', waiting_user: 'warn', waiting_auth: 'warn',
|
|
7
|
+
waiting_dep: 'muted', pending: 'muted', running: 'info', waiting_user: 'warn', waiting_auth: 'warn',
|
|
8
8
|
done: 'good', failed: 'bad', cancelled: 'muted', orphaned: 'bad',
|
|
9
9
|
}
|
|
10
10
|
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 任务依赖:只允许依赖已存在的任务;创建时把未就绪的任务放进 waiting_dep,
|
|
2
|
+
// 依赖完成后再晋升为 pending 入队;依赖失败/丢失则向下游传播失败。
|
|
3
|
+
import { getTask, listTasks, recentMessages, transition } from './ledger.js'
|
|
4
|
+
import { taskLabelWithId } from './task-label.js'
|
|
5
|
+
|
|
6
|
+
const STATUS_LABEL = {
|
|
7
|
+
waiting_dep: '等前置', pending: '待派发', running: '进行中', waiting_user: '等你确认',
|
|
8
|
+
waiting_auth: '要授权', done: '已完成', failed: '失败', cancelled: '已取消', orphaned: '会话丢失',
|
|
9
|
+
}
|
|
10
|
+
const FAILED_DEP = new Set(['failed', 'cancelled', 'orphaned'])
|
|
11
|
+
const MAX_DEPENDS = 20
|
|
12
|
+
|
|
13
|
+
const labelOf = status => STATUS_LABEL[status] ?? status
|
|
14
|
+
const short = (value, max = 100) => {
|
|
15
|
+
const text = String(value ?? '').split('\n')[0].trim()
|
|
16
|
+
return text.length > max ? `${text.slice(0, max)}…` : text
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 校验 dependsOn:去重、最多 20 条、必须存在,且不能依赖已经失败/取消/丢失的任务。
|
|
21
|
+
* @returns {{ok: true, ids: string[]} | {ok: false, error: string}}
|
|
22
|
+
*/
|
|
23
|
+
export function validateDepends(db, values) {
|
|
24
|
+
if (values === undefined || values === null) return { ok: true, ids: [] }
|
|
25
|
+
if (!Array.isArray(values)) return { ok: false, error: 'dependsOn 必须是任务编号数组' }
|
|
26
|
+
const ids = []
|
|
27
|
+
for (const value of values) {
|
|
28
|
+
if (typeof value !== 'string') return { ok: false, error: 'dependsOn 里每一项都必须是任务编号字符串' }
|
|
29
|
+
const id = value.trim()
|
|
30
|
+
if (id === '' || ids.includes(id)) continue
|
|
31
|
+
ids.push(id)
|
|
32
|
+
}
|
|
33
|
+
if (ids.length > MAX_DEPENDS) return { ok: false, error: `dependsOn 最多 ${MAX_DEPENDS} 个任务` }
|
|
34
|
+
for (const id of ids) {
|
|
35
|
+
const task = getTask(db, id)
|
|
36
|
+
if (task === undefined) return { ok: false, error: `前置任务不存在:${id}` }
|
|
37
|
+
if (FAILED_DEP.has(task.status)) {
|
|
38
|
+
return { ok: false, error: `前置任务「${taskLabelWithId(task)}」当前是${labelOf(task.status)},不能作为依赖;先 task_retry 或新建一个可用的前置任务` }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, ids }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 依赖状态:missing(不存在)/ failed(终态失败)/ ready(全部 done)。 */
|
|
45
|
+
export function dependencyState(db, task) {
|
|
46
|
+
const ids = Array.isArray(task?.depends_on) ? task.depends_on : []
|
|
47
|
+
const deps = []
|
|
48
|
+
const missing = []
|
|
49
|
+
const failed = []
|
|
50
|
+
for (const id of ids) {
|
|
51
|
+
const dep = getTask(db, id)
|
|
52
|
+
if (dep === undefined) {
|
|
53
|
+
missing.push({ id })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
deps.push(dep)
|
|
57
|
+
if (FAILED_DEP.has(dep.status)) failed.push(dep)
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
deps,
|
|
61
|
+
missing,
|
|
62
|
+
failed,
|
|
63
|
+
ready: ids.length === 0 || (missing.length === 0 && deps.every(dep => dep.status === 'done')),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把 waiting_dep 任务推进到 pending(依赖全 done)或 failed(依赖失败/丢失);
|
|
69
|
+
* 循环到稳定,才能把失败沿依赖链传播下去。返回晋升/失败的任务 id。
|
|
70
|
+
*/
|
|
71
|
+
export function promoteDependents(db, { enqueue = () => {}, log = () => {} } = {}) {
|
|
72
|
+
const promoted = []
|
|
73
|
+
const failed = []
|
|
74
|
+
for (let pass = 0; pass < 20; pass += 1) {
|
|
75
|
+
const waiting = listTasks(db).filter(task => task.status === 'waiting_dep')
|
|
76
|
+
if (waiting.length === 0) break
|
|
77
|
+
let changed = false
|
|
78
|
+
for (const task of waiting) {
|
|
79
|
+
const state = dependencyState(db, task)
|
|
80
|
+
if (state.missing.length > 0) {
|
|
81
|
+
const reason = `前置任务不存在:${state.missing.map(item => item.id).join('、')}`
|
|
82
|
+
transition(db, task.id, 'failed', { error: reason })
|
|
83
|
+
failed.push(task.id)
|
|
84
|
+
changed = true
|
|
85
|
+
log('task.dep.failed', { taskId: task.id, detail: { reason } })
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (state.failed.length > 0) {
|
|
89
|
+
const names = state.failed.map(dep => `「${taskLabelWithId(dep)}」(${labelOf(dep.status)})`).join('、')
|
|
90
|
+
const reason = `前置任务未完成:${names}`
|
|
91
|
+
transition(db, task.id, 'failed', { error: reason })
|
|
92
|
+
failed.push(task.id)
|
|
93
|
+
changed = true
|
|
94
|
+
log('task.dep.failed', { taskId: task.id, detail: { reason } })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (!state.ready) continue
|
|
98
|
+
transition(db, task.id, 'pending')
|
|
99
|
+
enqueue(task.id)
|
|
100
|
+
promoted.push(task.id)
|
|
101
|
+
changed = true
|
|
102
|
+
log('task.dep.ready', { taskId: task.id, detail: { dependsOn: task.depends_on } })
|
|
103
|
+
}
|
|
104
|
+
if (!changed) break
|
|
105
|
+
}
|
|
106
|
+
return { promoted, failed }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 已就绪依赖的摘要块,派发时追加到 brief 后面(创建时依赖可能还没完成)。 */
|
|
110
|
+
export function upstreamBrief(db, dependsOn) {
|
|
111
|
+
const ids = Array.isArray(dependsOn) ? dependsOn : []
|
|
112
|
+
if (ids.length === 0) return ''
|
|
113
|
+
const lines = ['【前置任务】']
|
|
114
|
+
for (const id of ids) {
|
|
115
|
+
const dep = getTask(db, id)
|
|
116
|
+
if (dep === undefined) {
|
|
117
|
+
lines.push(`- ${id}:不存在`)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const report = recentMessages(db, id, 5).find(row => row.kind === 'report')
|
|
121
|
+
const summary = report === undefined
|
|
122
|
+
? (dep.status === 'done' ? dep.goal : `(当前${labelOf(dep.status)})`)
|
|
123
|
+
: String(report.rendered).replace(/^\[[^\]]+\]\s*/, '')
|
|
124
|
+
lines.push(`- 任务「${dep.title}」${dep.status === 'done' ? '已完成' : `当前${labelOf(dep.status)}`}:${short(summary)}`)
|
|
125
|
+
const artifacts = Array.isArray(dep.artifacts) ? dep.artifacts : []
|
|
126
|
+
if (artifacts.length > 0) {
|
|
127
|
+
lines.push(` 产物:${artifacts.slice(0, 8).map(item => item.path).join('、')}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let block = lines.join('\n')
|
|
131
|
+
if (block.length > 1600) block = `${block.slice(0, 1599)}…`
|
|
132
|
+
return block
|
|
133
|
+
}
|
package/src/host-route.js
CHANGED
|
@@ -5,7 +5,7 @@ import { listTasks, listHistoryTasks } from './ledger.js'
|
|
|
5
5
|
import { taskCard } from './card.js'
|
|
6
6
|
|
|
7
7
|
const ENDPOINTS = new Set(['snapshot'])
|
|
8
|
-
const ACTIVE = new Set(['pending', 'running', 'waiting_user', 'waiting_auth'])
|
|
8
|
+
const ACTIVE = new Set(['waiting_dep', 'pending', 'running', 'waiting_user', 'waiting_auth'])
|
|
9
9
|
|
|
10
10
|
const json = (value, status) => new Response(JSON.stringify(value), {
|
|
11
11
|
status,
|
|
@@ -35,6 +35,10 @@ export function createHostRoute({ db, coordinatorStatus, openMain, staleMinutes
|
|
|
35
35
|
targetDir: task.target_dir ?? null,
|
|
36
36
|
readOnly: task.read_only === true,
|
|
37
37
|
retryOf: task.retry_of ?? null,
|
|
38
|
+
groupId: task.group_id ?? null,
|
|
39
|
+
dependsOn: Array.isArray(task.depends_on) ? task.depends_on : [],
|
|
40
|
+
writeScopes: Array.isArray(task.write_scopes) ? task.write_scopes : [],
|
|
41
|
+
priority: task.priority ?? 0,
|
|
38
42
|
artifacts: Array.isArray(task.artifacts) ? task.artifacts : [],
|
|
39
43
|
queued: task.status === 'pending' && task.queued_at !== null && task.queued_at !== undefined,
|
|
40
44
|
stale: ACTIVE.has(task.status) && at - task.last_event_at > staleMinutes * 60_000,
|
package/src/ledger.js
CHANGED
|
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
11
11
|
status TEXT NOT NULL, session_id TEXT, target TEXT, target_dir TEXT, artifacts_json TEXT, coordinator_session TEXT NOT NULL,
|
|
12
12
|
attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
13
13
|
last_event_at INTEGER NOT NULL, error TEXT, queued_at INTEGER, read_only INTEGER NOT NULL DEFAULT 0,
|
|
14
|
-
retry_of TEXT
|
|
14
|
+
retry_of TEXT, depends_json TEXT, group_id TEXT, write_scopes_json TEXT, priority INTEGER NOT NULL DEFAULT 0
|
|
15
15
|
);
|
|
16
16
|
CREATE TABLE IF NOT EXISTS messages (
|
|
17
17
|
task_id TEXT NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL,
|
|
@@ -49,6 +49,10 @@ export async function openLedger(stateDir) {
|
|
|
49
49
|
if (!taskColumns.includes('artifacts_json')) db.exec('ALTER TABLE tasks ADD COLUMN artifacts_json TEXT')
|
|
50
50
|
if (!taskColumns.includes('read_only')) db.exec('ALTER TABLE tasks ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0')
|
|
51
51
|
if (!taskColumns.includes('retry_of')) db.exec('ALTER TABLE tasks ADD COLUMN retry_of TEXT')
|
|
52
|
+
if (!taskColumns.includes('depends_json')) db.exec('ALTER TABLE tasks ADD COLUMN depends_json TEXT')
|
|
53
|
+
if (!taskColumns.includes('group_id')) db.exec('ALTER TABLE tasks ADD COLUMN group_id TEXT')
|
|
54
|
+
if (!taskColumns.includes('write_scopes_json')) db.exec('ALTER TABLE tasks ADD COLUMN write_scopes_json TEXT')
|
|
55
|
+
if (!taskColumns.includes('priority')) db.exec('ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0')
|
|
52
56
|
return db
|
|
53
57
|
} catch (error) {
|
|
54
58
|
try { db?.close() } catch { /* 打不开的库不需要再抛关闭错误 */ }
|
|
@@ -74,19 +78,52 @@ const parseArtifacts = value => {
|
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
const parseStringArray = value => {
|
|
82
|
+
if (value === null || value === undefined) return []
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(value)
|
|
85
|
+
return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string' && item !== '') : []
|
|
86
|
+
} catch {
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
const taskOf = row => row === undefined ? undefined : ({
|
|
78
92
|
...row,
|
|
79
93
|
read_only: row.read_only === 1 || row.read_only === true,
|
|
80
94
|
brief: JSON.parse(row.brief_json),
|
|
81
95
|
artifacts: parseArtifacts(row.artifacts_json),
|
|
96
|
+
depends_on: parseStringArray(row.depends_json),
|
|
97
|
+
write_scopes: parseStringArray(row.write_scopes_json),
|
|
98
|
+
priority: Number.isFinite(row.priority) ? row.priority : 0,
|
|
82
99
|
})
|
|
83
100
|
|
|
84
|
-
export function createTask(db, {
|
|
101
|
+
export function createTask(db, {
|
|
102
|
+
brief,
|
|
103
|
+
coordinatorSession,
|
|
104
|
+
targetDir,
|
|
105
|
+
readOnly = false,
|
|
106
|
+
retryOf = null,
|
|
107
|
+
dependsOn = [],
|
|
108
|
+
groupId = null,
|
|
109
|
+
writeScopes = [],
|
|
110
|
+
priority = 0,
|
|
111
|
+
status = 'pending',
|
|
112
|
+
} = {}) {
|
|
85
113
|
const now = Date.now()
|
|
86
114
|
const id = `t-${randomUUID().slice(0, 8)}`
|
|
87
|
-
db.prepare(`INSERT INTO tasks
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
db.prepare(`INSERT INTO tasks
|
|
116
|
+
(id, title, goal, brief_json, status, coordinator_session, target_dir, read_only, retry_of,
|
|
117
|
+
depends_json, group_id, write_scopes_json, priority, created_at, updated_at, last_event_at)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
119
|
+
.run(
|
|
120
|
+
id, String(brief.title), String(brief.goal), JSON.stringify(brief), status,
|
|
121
|
+
String(coordinatorSession ?? ''), targetDir ?? null, readOnly === true ? 1 : 0, retryOf ?? null,
|
|
122
|
+
JSON.stringify(Array.isArray(dependsOn) ? dependsOn : []), groupId ?? null,
|
|
123
|
+
JSON.stringify(Array.isArray(writeScopes) ? writeScopes : []),
|
|
124
|
+
Number.isFinite(priority) ? Math.trunc(priority) : 0,
|
|
125
|
+
now, now, now,
|
|
126
|
+
)
|
|
90
127
|
return getTask(db, id)
|
|
91
128
|
}
|
|
92
129
|
|
|
@@ -96,7 +133,7 @@ export function getTask(db, id) {
|
|
|
96
133
|
|
|
97
134
|
export function listTasks(db, { active = false } = {}) {
|
|
98
135
|
const sql = active
|
|
99
|
-
? "SELECT * FROM tasks WHERE status IN ('pending','running','waiting_user','waiting_auth') ORDER BY created_at DESC, rowid DESC"
|
|
136
|
+
? "SELECT * FROM tasks WHERE status IN ('waiting_dep','pending','running','waiting_user','waiting_auth') ORDER BY created_at DESC, rowid DESC"
|
|
100
137
|
: 'SELECT * FROM tasks ORDER BY created_at DESC, rowid DESC'
|
|
101
138
|
return db.prepare(sql).all().map(taskOf)
|
|
102
139
|
}
|
|
@@ -104,7 +141,7 @@ export function listTasks(db, { active = false } = {}) {
|
|
|
104
141
|
/** 终态历史:按创建时间倒序取最多 limit 条(面板快照用,避免把活跃任务挤掉)。 */
|
|
105
142
|
export function listHistoryTasks(db, limit = 200) {
|
|
106
143
|
return db.prepare(`SELECT * FROM tasks
|
|
107
|
-
WHERE status NOT IN ('pending','running','waiting_user','waiting_auth')
|
|
144
|
+
WHERE status NOT IN ('waiting_dep','pending','running','waiting_user','waiting_auth')
|
|
108
145
|
ORDER BY created_at DESC, rowid DESC LIMIT ?`)
|
|
109
146
|
.all(Math.max(0, Math.floor(limit))).map(taskOf)
|
|
110
147
|
}
|
|
@@ -120,14 +157,17 @@ export function clearQueued(db, id) {
|
|
|
120
157
|
return getTask(db, id)
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
/** 待派发队列:pending
|
|
160
|
+
/** 待派发队列:pending 且还没有子会话,按优先级降序、创建顺序升序。 */
|
|
124
161
|
export function listPendingQueue(db) {
|
|
125
|
-
return db.prepare(
|
|
162
|
+
return db.prepare(`SELECT * FROM tasks
|
|
163
|
+
WHERE status = 'pending' AND session_id IS NULL
|
|
164
|
+
ORDER BY priority DESC, created_at ASC, rowid ASC`)
|
|
126
165
|
.all().map(taskOf)
|
|
127
166
|
}
|
|
128
167
|
|
|
168
|
+
/** 队列安全阀口径:等待依赖的任务也算占位,避免等待中的图无限膨胀。 */
|
|
129
169
|
export function countPending(db) {
|
|
130
|
-
return db.prepare("SELECT count(*) AS n FROM tasks WHERE status
|
|
170
|
+
return db.prepare("SELECT count(*) AS n FROM tasks WHERE status IN ('pending','waiting_dep')").get().n
|
|
131
171
|
}
|
|
132
172
|
|
|
133
173
|
/** 任务的渲染文本(派发时用),取第一条 brief 消息。 */
|
package/src/queue.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
briefText, clearQueued, countPending, getTask, listPendingQueue, listTasks, markQueued, transition,
|
|
5
5
|
} from './ledger.js'
|
|
6
|
+
import { promoteDependents, upstreamBrief } from './deps.js'
|
|
6
7
|
import { taskLabel } from './task-label.js'
|
|
7
8
|
|
|
8
9
|
export function createQueue({ db, dispatcher, config = {}, logger, notifier, retryDelaysMs = [2000, 5000, 10000, 30000] }) {
|
|
@@ -22,7 +23,16 @@ export function createQueue({ db, dispatcher, config = {}, logger, notifier, ret
|
|
|
22
23
|
return index === -1 ? 0 : index + 1
|
|
23
24
|
}
|
|
24
25
|
const enqueue = id => {
|
|
25
|
-
if (
|
|
26
|
+
if (ids.includes(id)) return
|
|
27
|
+
ids.push(id)
|
|
28
|
+
// 优先级高的先派;同优先级按创建时间 FIFO(JS sort 稳定,等值时保留入队顺序)。
|
|
29
|
+
ids.sort((left, right) => {
|
|
30
|
+
const a = getTask(db, left)
|
|
31
|
+
const b = getTask(db, right)
|
|
32
|
+
const priorityDelta = (b?.priority ?? 0) - (a?.priority ?? 0)
|
|
33
|
+
if (priorityDelta !== 0) return priorityDelta
|
|
34
|
+
return (a?.created_at ?? 0) - (b?.created_at ?? 0)
|
|
35
|
+
})
|
|
26
36
|
}
|
|
27
37
|
const remove = id => {
|
|
28
38
|
const index = ids.indexOf(id)
|
|
@@ -40,6 +50,10 @@ export function createQueue({ db, dispatcher, config = {}, logger, notifier, ret
|
|
|
40
50
|
|
|
41
51
|
async function pump() {
|
|
42
52
|
if (stopped) return
|
|
53
|
+
// 先把依赖已完成的 waiting_dep 晋升为 pending;晋升动作只会发生在等待中的任务上。
|
|
54
|
+
try { promoteDependents(db, { enqueue, log }) } catch (error) {
|
|
55
|
+
log('task.dep.promote-failed', { detail: { error: error?.message ?? String(error) } })
|
|
56
|
+
}
|
|
43
57
|
// 单锁:已有 pump 在跑时,新调用等同一个 promise,避免并发 create 各自提前返回导致漏派。
|
|
44
58
|
if (pumpPromise !== null) return pumpPromise
|
|
45
59
|
pumpPromise = runPump().finally(() => { pumpPromise = null })
|
|
@@ -56,7 +70,9 @@ export function createQueue({ db, dispatcher, config = {}, logger, notifier, ret
|
|
|
56
70
|
ids.shift()
|
|
57
71
|
continue
|
|
58
72
|
}
|
|
59
|
-
const rendered = briefText(db, id) ?? `【任务】${task.title}\n目标:${task.goal}
|
|
73
|
+
const rendered = [briefText(db, id) ?? `【任务】${task.title}\n目标:${task.goal}`, upstreamBrief(db, task.depends_on)]
|
|
74
|
+
.filter(text => typeof text === 'string' && text !== '')
|
|
75
|
+
.join('\n')
|
|
60
76
|
let member
|
|
61
77
|
try {
|
|
62
78
|
member = await dispatcher.spawn({
|
|
@@ -111,6 +127,8 @@ export function createQueue({ db, dispatcher, config = {}, logger, notifier, ret
|
|
|
111
127
|
summary: message,
|
|
112
128
|
system: true,
|
|
113
129
|
})
|
|
130
|
+
// 失败沿依赖链传播:下游 waiting_dep 立即失败,不再假装在等。
|
|
131
|
+
try { promoteDependents(db, { enqueue, log }) } catch { /* 传播失败只影响下游,不打断队列 */ }
|
|
114
132
|
continue
|
|
115
133
|
}
|
|
116
134
|
|
package/src/state.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// 任务状态机。只有子会话的回报能推进 running 之后的状态;
|
|
2
2
|
// 协调者只能创建、续派、停止、重派。
|
|
3
|
-
export const STATUSES = ['pending', 'running', 'waiting_user', 'waiting_auth', 'done', 'failed', 'cancelled', 'orphaned']
|
|
3
|
+
export const STATUSES = ['waiting_dep', 'pending', 'running', 'waiting_user', 'waiting_auth', 'done', 'failed', 'cancelled', 'orphaned']
|
|
4
4
|
|
|
5
5
|
const TRANSITIONS = {
|
|
6
|
+
waiting_dep: ['pending', 'failed', 'cancelled'],
|
|
6
7
|
pending: ['running', 'failed', 'cancelled'],
|
|
7
8
|
running: ['waiting_user', 'waiting_auth', 'done', 'failed', 'cancelled', 'orphaned'],
|
|
8
9
|
waiting_user: ['running', 'cancelled', 'failed', 'orphaned'],
|
package/src/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { validateBrief, renderBrief, validateTargetDir } from './brief.js'
|
|
|
5
5
|
import { inferWorkspaceTarget } from './workspace-match.js'
|
|
6
6
|
import { taskLabel, taskLabelWithId } from './task-label.js'
|
|
7
7
|
import { canTransition } from './state.js'
|
|
8
|
+
import { dependencyState, promoteDependents, validateDepends } from './deps.js'
|
|
8
9
|
import {
|
|
9
10
|
GLOBAL_PROJECT, addMemory, buildMemoryBlock, memoryTokens, projectKeyOf, remember, searchMemories,
|
|
10
11
|
} from './memory.js'
|
|
@@ -25,10 +26,10 @@ const REPORT_STATUS = ['progress', 'done', 'blocked', 'needs_auth']
|
|
|
25
26
|
const MAX_ARTIFACTS = 20
|
|
26
27
|
const MAX_ARTIFACT_LABEL = 160
|
|
27
28
|
const STATUS_LABEL = {
|
|
28
|
-
pending: '待派发', running: '进行中', waiting_user: '等你确认', waiting_auth: '要授权',
|
|
29
|
+
waiting_dep: '等前置', pending: '待派发', running: '进行中', waiting_user: '等你确认', waiting_auth: '要授权',
|
|
29
30
|
done: '已完成', failed: '失败', cancelled: '已取消', orphaned: '会话丢失',
|
|
30
31
|
}
|
|
31
|
-
const ACTIVE_STATUS = new Set(['pending', 'running', 'waiting_user', 'waiting_auth'])
|
|
32
|
+
const ACTIVE_STATUS = new Set(['waiting_dep', 'pending', 'running', 'waiting_user', 'waiting_auth'])
|
|
32
33
|
const WAITING_STATUS = new Set(['waiting_user', 'waiting_auth'])
|
|
33
34
|
const RESUME_STATUS = new Set(['waiting_user', 'waiting_auth', 'failed'])
|
|
34
35
|
const TERMINAL_STATUS = new Set(['done', 'cancelled', 'orphaned'])
|
|
@@ -45,6 +46,10 @@ function taskLine(db, task, { staleMinutes = 30 } = {}) {
|
|
|
45
46
|
`- ${taskLabelWithId(task)}`, task.read_only ? `${labelOf(task.status)}·只读` : labelOf(task.status), `子会话 ${task.target ?? '-'}`,
|
|
46
47
|
`目标:${short(task.goal)}`,
|
|
47
48
|
]
|
|
49
|
+
if (task.status === 'waiting_dep') {
|
|
50
|
+
parts.push(`等前置 ${task.depends_on.length} 个${task.priority ? `|P${task.priority}` : ''}`)
|
|
51
|
+
}
|
|
52
|
+
if (task.group_id) parts.push(`组 ${task.group_id}`)
|
|
48
53
|
const latest = recentMessages(db, task.id, 1)[0]
|
|
49
54
|
parts.push(`最近:${latest === undefined ? '-' : short(latest.rendered)}`)
|
|
50
55
|
if (task.status === 'pending' && task.session_id === null) {
|
|
@@ -85,7 +90,47 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
85
90
|
return undefined
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
//
|
|
93
|
+
// 分组 / 写域 / 优先级:都只做轻量归一,非法值忽略。
|
|
94
|
+
const normalizeGroup = value => {
|
|
95
|
+
if (typeof value !== 'string') return null
|
|
96
|
+
const text = value.trim()
|
|
97
|
+
return text === '' ? null : text.slice(0, 80)
|
|
98
|
+
}
|
|
99
|
+
const normalizeScopes = value => {
|
|
100
|
+
if (!Array.isArray(value)) return []
|
|
101
|
+
const out = []
|
|
102
|
+
for (const item of value) {
|
|
103
|
+
if (typeof item !== 'string') continue
|
|
104
|
+
const text = item.trim().replace(/\\/g, '/').replace(/\/+$/, '')
|
|
105
|
+
if (text === '' || out.includes(text)) continue
|
|
106
|
+
out.push(text.slice(0, 200))
|
|
107
|
+
if (out.length >= 20) break
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
const normalizePriority = value => {
|
|
112
|
+
if (!Number.isFinite(value)) return 0
|
|
113
|
+
return Math.max(-10, Math.min(10, Math.trunc(value)))
|
|
114
|
+
}
|
|
115
|
+
const sameScope = (left, right) => left === right
|
|
116
|
+
|| left.startsWith(`${right}/`)
|
|
117
|
+
|| right.startsWith(`${left}/`)
|
|
118
|
+
const writeScopeConflicts = (scopes, excludeId) => {
|
|
119
|
+
if (scopes.length === 0) return []
|
|
120
|
+
const conflicts = []
|
|
121
|
+
for (const task of listTasks(db, { active: true })) {
|
|
122
|
+
if (task.id === excludeId) continue
|
|
123
|
+
const other = Array.isArray(task.write_scopes) ? task.write_scopes : []
|
|
124
|
+
const overlap = scopes.find(scope => other.some(candidate => sameScope(scope, candidate)))
|
|
125
|
+
if (overlap !== undefined) conflicts.push({ task, scope: overlap })
|
|
126
|
+
if (conflicts.length >= 5) break
|
|
127
|
+
}
|
|
128
|
+
return conflicts
|
|
129
|
+
}
|
|
130
|
+
const conflictsText = conflicts => conflicts.length === 0 ? ''
|
|
131
|
+
: `写域与活跃任务冲突:${conflicts.map(({ task, scope }) => `${taskLabelWithId(task)}(${scope})`).join('、')};建议拆分或串行。`
|
|
132
|
+
|
|
133
|
+
|
|
89
134
|
const baseDirOf = task => {
|
|
90
135
|
if (typeof task?.target_dir === 'string' && task.target_dir !== '') return task.target_dir
|
|
91
136
|
let fallback
|
|
@@ -276,6 +321,10 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
276
321
|
user_verbatim: str('用户原话,可空'),
|
|
277
322
|
target: str('目标目录:可空;给了必须是已存在的绝对路径,子会话在该目录运行'),
|
|
278
323
|
readOnly: { type: 'boolean', description: '只读任务:子会话沙箱 read-only(读正常,写文件/写 shell 被拒);默认 false,只在明确需要时传 true' },
|
|
324
|
+
dependsOn: strList('前置任务编号数组;依赖全部完成后才派发;任一前置失败则本任务失败'),
|
|
325
|
+
group: str('可选分组名,如“登录改造”;面板按组汇总进度'),
|
|
326
|
+
writeScopes: strList('可选写域(目录/文件路径);与活跃任务重叠时给冲突提示(advisory)'),
|
|
327
|
+
priority: { type: 'number', description: '优先级,-10..10,默认 0;高的先派发' },
|
|
279
328
|
}), async (args, exec) => {
|
|
280
329
|
const denied = requireMain(exec)
|
|
281
330
|
if (denied !== undefined) return denied
|
|
@@ -321,6 +370,17 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
321
370
|
// 去重、队列容量、创建必须在同一个同步段里,中间不能再 await,否则并发调用会一起通过检查。
|
|
322
371
|
const title = String(args.title).trim()
|
|
323
372
|
const goal = String(args.goal).trim()
|
|
373
|
+
const depends = validateDepends(db, args.dependsOn)
|
|
374
|
+
if (depends.ok === false) {
|
|
375
|
+
log('task.create.rejected', { agentId, detail: { reason: 'depends', message: depends.error, callId } })
|
|
376
|
+
return `依赖不合法:${depends.error}。`
|
|
377
|
+
}
|
|
378
|
+
const groupId = normalizeGroup(args.group)
|
|
379
|
+
const writeScopes = normalizeScopes(args.writeScopes)
|
|
380
|
+
const priority = normalizePriority(args.priority)
|
|
381
|
+
const depState = dependencyState(db, { depends_on: depends.ids })
|
|
382
|
+
const initialStatus = depState.ready ? 'pending' : 'waiting_dep'
|
|
383
|
+
const conflicts = writeScopeConflicts(writeScopes)
|
|
324
384
|
const duplicate = listTasks(db, { active: true })
|
|
325
385
|
.find(task => task.title.trim() === title && task.goal.trim() === goal)
|
|
326
386
|
if (duplicate !== undefined) {
|
|
@@ -339,8 +399,33 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
339
399
|
})
|
|
340
400
|
return `待派发队列已满(${maxQueue} 个),先 task_stop 或等队列消化后再派。`
|
|
341
401
|
}
|
|
342
|
-
const task = createTask(db, {
|
|
343
|
-
|
|
402
|
+
const task = createTask(db, {
|
|
403
|
+
brief,
|
|
404
|
+
coordinatorSession: current.id ?? coordinatorSession,
|
|
405
|
+
targetDir: effectiveTarget,
|
|
406
|
+
readOnly,
|
|
407
|
+
dependsOn: depends.ids,
|
|
408
|
+
groupId,
|
|
409
|
+
writeScopes,
|
|
410
|
+
priority,
|
|
411
|
+
status: initialStatus,
|
|
412
|
+
})
|
|
413
|
+
log('task.create.created', {
|
|
414
|
+
taskId: task.id,
|
|
415
|
+
agentId,
|
|
416
|
+
sessionId: current.id,
|
|
417
|
+
detail: {
|
|
418
|
+
title,
|
|
419
|
+
callId,
|
|
420
|
+
targetDir: effectiveTarget ?? null,
|
|
421
|
+
readOnly,
|
|
422
|
+
dependsOn: depends.ids,
|
|
423
|
+
group: groupId,
|
|
424
|
+
writeScopes,
|
|
425
|
+
priority,
|
|
426
|
+
status: initialStatus,
|
|
427
|
+
},
|
|
428
|
+
})
|
|
344
429
|
let memoryBlock = ''
|
|
345
430
|
if (config?.memoryInject !== false) {
|
|
346
431
|
try {
|
|
@@ -353,6 +438,12 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
353
438
|
}
|
|
354
439
|
const rendered = `【任务编号】${task.id}\n${renderBrief(brief)}${memoryBlock === '' ? '' : `\n${memoryBlock}`}`
|
|
355
440
|
addMessage(db, task.id, { kind: 'brief', rendered })
|
|
441
|
+
const conflictNote = conflictsText(conflicts)
|
|
442
|
+
const depNames = depends.ids.map(id => {
|
|
443
|
+
const dep = getTask(db, id)
|
|
444
|
+
return dep === undefined ? id : `「${dep.title}」(${labelOf(dep.status)})`
|
|
445
|
+
}).join('、')
|
|
446
|
+
const dependencyNote = depends.ids.length === 0 ? '' : `前置任务:${depNames}`
|
|
356
447
|
// 配置的协调者会话失效时直接失败:挂进队列只会永远派不出去。
|
|
357
448
|
if (current.live === false) {
|
|
358
449
|
const message = invalidHint() === '' ? '协调者会话已失效' : invalidHint()
|
|
@@ -360,10 +451,15 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
360
451
|
transition(db, task.id, 'failed', { error: message })
|
|
361
452
|
throw new Error(message)
|
|
362
453
|
}
|
|
454
|
+
if (initialStatus === 'waiting_dep') {
|
|
455
|
+
const notes = [dependencyNote, conflictNote].filter(Boolean).join('\n')
|
|
456
|
+
log('task.dep.waiting', { taskId: task.id, detail: { dependsOn: depends.ids, callId } })
|
|
457
|
+
return `任务「${taskLabelWithId(task)}」已创建,等待前置任务完成。${notes === '' ? '' : `\n${notes}`}`
|
|
458
|
+
}
|
|
363
459
|
const outcome = await queue.enqueueAndPump(task.id)
|
|
364
460
|
const resultTask = outcome.task
|
|
365
461
|
const inferredNote = inferred === undefined ? '' : `(目标目录按工作区推断:${inferred.path})`
|
|
366
|
-
const notes = [invalidHint(), inferredNote].filter(Boolean).join('\n')
|
|
462
|
+
const notes = [invalidHint(), inferredNote, conflictNote, dependencyNote].filter(Boolean).join('\n')
|
|
367
463
|
if (resultTask?.status === 'failed') throw new Error(resultTask.error ?? '子会话创建失败')
|
|
368
464
|
if (resultTask?.status === 'running') {
|
|
369
465
|
return `任务「${taskLabelWithId(task)}」已派发,子会话 ${resultTask.target}。${notes === '' ? '' : `\n${notes}`}`
|
|
@@ -372,14 +468,23 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
372
468
|
return `任务「${taskLabelWithId(task)}」已创建,前面还有 ${ahead} 个任务在排队,轮到时自动派发。${notes === '' ? '' : `\n${notes}`}`
|
|
373
469
|
}),
|
|
374
470
|
|
|
375
|
-
wrap('task_list', '列出任务。默认只列活跃任务(pending/running/waiting),active=false
|
|
471
|
+
wrap('task_list', '列出任务。默认只列活跃任务(waiting_dep/pending/running/waiting),active=false 时连同历史一起列出;可按 group 过滤并看组进度。', objectSchema({
|
|
376
472
|
active: { type: 'boolean', description: '是否只看活跃任务,默认 true' },
|
|
473
|
+
group: str('可选:只看这个分组的任务'),
|
|
377
474
|
}), async (args, exec) => {
|
|
378
475
|
const denied = requireMain(exec)
|
|
379
476
|
if (denied !== undefined) return denied
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
477
|
+
const all = listTasks(db, { active: args.active !== false })
|
|
478
|
+
const group = typeof args.group === 'string' ? args.group.trim() : ''
|
|
479
|
+
const tasks = group === '' ? all : all.filter(task => task.group_id === group)
|
|
480
|
+
if (tasks.length === 0) {
|
|
481
|
+
if (group !== '') return `组「${group}」里没有任务。`
|
|
482
|
+
return args.active === false ? '台账里还没有任何任务。' : '没有活跃任务。'
|
|
483
|
+
}
|
|
484
|
+
const header = group === ''
|
|
485
|
+
? ''
|
|
486
|
+
: `组「${group}」:${tasks.filter(task => task.status === 'done').length}/${tasks.length} 完成\n`
|
|
487
|
+
return header + tasks.map(task => taskLine(db, task, { staleMinutes })).join('\n')
|
|
383
488
|
}),
|
|
384
489
|
|
|
385
490
|
wrap('task_status', '查看一个任务的详情与最近消息。', objectSchema({ task_id: str('任务编号,如 t-1a2b3c4d') }), async (args, exec) => {
|
|
@@ -393,12 +498,27 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
393
498
|
`任务「${taskLabelWithId(task)}」`,
|
|
394
499
|
`状态:${labelOf(task.status)}`,
|
|
395
500
|
...(task.read_only ? ['权限:只读(读正常,写文件/写 shell 被拒绝)'] : []),
|
|
501
|
+
...(task.group_id ? [`分组:${task.group_id}`] : []),
|
|
502
|
+
...(task.priority !== 0 ? [`优先级:${task.priority}`] : []),
|
|
396
503
|
`子会话:${task.target ?? '-'}`,
|
|
504
|
+
...(task.status === 'waiting_dep'
|
|
505
|
+
? [`等待前置:${task.depends_on.map(id => {
|
|
506
|
+
const dep = getTask(db, id)
|
|
507
|
+
return dep === undefined ? id : `${id}(${labelOf(dep.status)})`
|
|
508
|
+
}).join('、')}`]
|
|
509
|
+
: []),
|
|
397
510
|
...(task.status === 'pending'
|
|
398
511
|
? [`排队:${task.queued_at === null || task.queued_at === undefined ? '待派发(刚创建)' : '排队中(等待并发槽位)'}`]
|
|
399
512
|
: []),
|
|
400
513
|
`目标:${task.goal}`,
|
|
401
514
|
]
|
|
515
|
+
if (task.depends_on.length > 0 && task.status !== 'waiting_dep') {
|
|
516
|
+
lines.push(`前置任务:${task.depends_on.map(id => {
|
|
517
|
+
const dep = getTask(db, id)
|
|
518
|
+
return dep === undefined ? id : `「${dep.title}」(${labelOf(dep.status)})`
|
|
519
|
+
}).join('、')}`)
|
|
520
|
+
}
|
|
521
|
+
if (task.write_scopes.length > 0) lines.push(`写域:${task.write_scopes.join('、')}`)
|
|
402
522
|
if (task.retry_of) lines.push(`重派自:${task.retry_of}`)
|
|
403
523
|
if (task.error !== null && task.error !== undefined) lines.push(`原因:${task.error}`)
|
|
404
524
|
if (config?.backend !== 'team' && task.session_id !== null && task.session_id !== undefined) {
|
|
@@ -418,6 +538,9 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
418
538
|
const task = getTask(db, args.task_id ?? '')
|
|
419
539
|
if (task === undefined) return `没有任务 ${args.task_id ?? ''}。`
|
|
420
540
|
if (TERMINAL_STATUS.has(task.status)) return `任务「${labelFor(task)}」已结束(${labelOf(task.status)}),不能续派;需要新工作请 task_create。`
|
|
541
|
+
if (task.status === 'waiting_dep') {
|
|
542
|
+
return `任务「${labelFor(task)}」还在等待前置任务(${task.depends_on.join('、')}),依赖完成后会派发,暂不能续派;要取消用 task_stop。`
|
|
543
|
+
}
|
|
421
544
|
if (task.status === 'pending' && (task.target === null || task.target === undefined)) {
|
|
422
545
|
return task.queued_at === null || task.queued_at === undefined
|
|
423
546
|
? `任务「${labelFor(task)}」还没有子会话,先停止再重建。`
|
|
@@ -462,11 +585,14 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
462
585
|
const task = getTask(db, args.task_id ?? '')
|
|
463
586
|
if (task === undefined) return `没有任务 ${args.task_id ?? ''}。`
|
|
464
587
|
if (TERMINAL_STATUS.has(task.status)) return `任务「${labelFor(task)}」已结束(${labelOf(task.status)}),无需停止。`
|
|
465
|
-
if (task.status === 'pending') {
|
|
588
|
+
if (task.status === 'pending' || task.status === 'waiting_dep') {
|
|
466
589
|
queue.remove(task.id)
|
|
467
590
|
transition(db, task.id, 'cancelled')
|
|
468
591
|
releaseTaskSession(task)
|
|
469
|
-
|
|
592
|
+
queue.pump()
|
|
593
|
+
return task.status === 'waiting_dep'
|
|
594
|
+
? `任务「${labelFor(task)}」已取消(还在等待前置任务)。`
|
|
595
|
+
: `任务「${labelFor(task)}」已取消(还在排队,没有派发出去)。`
|
|
470
596
|
}
|
|
471
597
|
if (task.target !== null && task.target !== undefined) {
|
|
472
598
|
// 停止失败也要把任务标掉:用户要的是「停」,不是让 stop 抛错卡住。
|
|
@@ -500,14 +626,26 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
500
626
|
return `已存在活跃任务「${taskLabelWithId(duplicate)}」(${labelOf(duplicate.status)}),未重复重派。`
|
|
501
627
|
}
|
|
502
628
|
const current = status()
|
|
629
|
+
const depends = validateDepends(db, task.depends_on)
|
|
630
|
+
if (depends.ok === false) return `不能重派:${depends.error}。`
|
|
631
|
+
const depState = dependencyState(db, { depends_on: depends.ids })
|
|
503
632
|
const retried = createTask(db, {
|
|
504
633
|
brief: task.brief,
|
|
505
634
|
coordinatorSession: current.id ?? coordinatorSession,
|
|
506
635
|
targetDir: task.target_dir,
|
|
507
636
|
readOnly: task.read_only === true,
|
|
508
637
|
retryOf: task.id,
|
|
638
|
+
dependsOn: depends.ids,
|
|
639
|
+
groupId: task.group_id,
|
|
640
|
+
writeScopes: task.write_scopes,
|
|
641
|
+
priority: task.priority,
|
|
642
|
+
status: depState.ready ? 'pending' : 'waiting_dep',
|
|
643
|
+
})
|
|
644
|
+
log('task.retry', {
|
|
645
|
+
taskId: retried.id,
|
|
646
|
+
sessionId: current.id,
|
|
647
|
+
detail: { retryOf: task.id, dependsOn: depends.ids, status: retried.status, callId: exec?.callId },
|
|
509
648
|
})
|
|
510
|
-
log('task.retry', { taskId: retried.id, sessionId: current.id, detail: { retryOf: task.id, callId: exec?.callId } })
|
|
511
649
|
addMessage(db, retried.id, { kind: 'brief', rendered: `【任务编号】${retried.id}\n${renderBrief(task.brief)}` })
|
|
512
650
|
if (current.live === false) {
|
|
513
651
|
const message = invalidHint() === '' ? '协调者会话已失效' : invalidHint()
|
|
@@ -515,6 +653,9 @@ export function createTools({ db, dispatcher, queue, notifier, config, coordinat
|
|
|
515
653
|
transition(db, retried.id, 'failed', { error: message })
|
|
516
654
|
throw new Error(message)
|
|
517
655
|
}
|
|
656
|
+
if (retried.status === 'waiting_dep') {
|
|
657
|
+
return `任务「${labelFor(task)}」已重派为「${taskLabelWithId(retried)}」,等待前置任务完成后派发。`
|
|
658
|
+
}
|
|
518
659
|
const outcome = await queue.enqueueAndPump(retried.id)
|
|
519
660
|
const resultTask = outcome.task
|
|
520
661
|
if (resultTask?.status === 'failed') throw new Error(resultTask.error ?? '子会话创建失败')
|