@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 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.3.4`。
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` | 待派发队列安全阀:`pending` 任务到这个数就拒绝新建(不落库) |
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 + 入队派发子会话;缺验收标准/队列满(`maxQueue`)会拒绝;并发满自动排队,不报错;`readOnly:true` 时子会话沙箱只读 |
247
- | `task_list` | 默认只列活跃任务;带标题、目标摘要、最近一条消息、排队/陈旧标记;`active:false` 看全量 |
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.4",
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, { brief, coordinatorSession, targetDir, readOnly = false, retryOf = null }) {
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 (id, title, goal, brief_json, status, coordinator_session, target_dir, read_only, retry_of, created_at, updated_at, last_event_at)
88
- VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`)
89
- .run(id, String(brief.title), String(brief.goal), JSON.stringify(brief), String(coordinatorSession ?? ''), targetDir ?? null, readOnly === true ? 1 : 0, retryOf ?? null, now, now, now)
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("SELECT * FROM tasks WHERE status = 'pending' AND session_id IS NULL ORDER BY created_at ASC, rowid ASC")
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 = 'pending'").get().n
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 (!ids.includes(id)) ids.push(id)
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
- // 无 target 时相对产物按主会话目录解析(defaultCwd 可以是 getter,主会话重建后跟新目录)。
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, { brief, coordinatorSession: current.id ?? coordinatorSession, targetDir: effectiveTarget, readOnly })
343
- log('task.create.created', { taskId: task.id, agentId, sessionId: current.id, detail: { title, callId, targetDir: effectiveTarget ?? null, readOnly } })
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 时连同历史一起列出。', objectSchema({
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 tasks = listTasks(db, { active: args.active !== false })
381
- if (tasks.length === 0) return args.active === false ? '台账里还没有任何任务。' : '没有活跃任务。'
382
- return tasks.map(task => taskLine(db, task, { staleMinutes })).join('\n')
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
- return `任务「${labelFor(task)}」已取消(还在排队,没有派发出去)。`
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 ?? '子会话创建失败')