dsh-tree-task-flow 0.3.12 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,12 +27,12 @@
27
27
  规则只有一条,**只看表层,与计划树无关**:
28
28
 
29
29
  ```
30
- 从「上一次 tree_task_done 之后的第一个 tree_task_create」之后
30
+ 从「上一次 tree_task_done 之后的第一条 tree_task_create 调用」之后
31
31
  → 到本次 tree_task_done 之前,这一段收走
32
32
  ```
33
33
 
34
- 起点是上一次 `tree_task_done` 之后的**第一个** `tree_task_create`;
35
- 会话里还没有过 `tree_task_done` 时,就是表层第一个 `tree_task_create`;
34
+ 起点是上一次 `tree_task_done` 之后的**第一条** `tree_task_create`;
35
+ 会话里还没有过 `tree_task_done` 时,就是表层第一条 `tree_task_create`;
36
36
  区间里一次 `tree_task_create` 都没有时,回退到上一次 `tree_task_done` 之后。
37
37
  **`tree_task_plan` 不是边界**——它的调用与返回跟普通过程一样,随这一段一起收走。
38
38
  起点那次 `create` 的调用与返回留在上下文里,本次 `tree_task_done` 的调用与返回
@@ -40,11 +40,17 @@
40
40
  折叠只收它们**之间**的过程,换成一条计数通告:
41
41
 
42
42
  ```
43
- [tree_task_create 调用] [返回:计划已建立 + 整棵树]
43
+ [tree_task_create 调用] [返回:计划已建立 + 这个目标的树]
44
44
  [tree_task消息:隐藏了 12 条过程上下文。读取:… 写入:…]
45
45
  [tree_task_done 调用] [返回:接下来需要进行步骤:「二」(s-4)。]
46
46
  ```
47
47
 
48
+ > **`tree_task_create` 与 `tree_task_plan` 不能合并成一个工具。**
49
+ > 起点取的是"上一次 `done` 之后最早的**开工**那一次",而 `plan` 每补一次步骤就调一次;
50
+ > 并成一个工具之后,每次补步骤都会把起点往后推,`done` 与它之间那段过程就永远收不走。
51
+ > 靠"实参里有没有 `parentId`"去区分,也只是把认错起点的机会交给模型。
52
+ > 代价是两个工具的实参形状有点像,所以 `plan` 的说明里专门写了一句"建新目标不归它管"。
53
+
48
54
  两条硬约束:
49
55
 
50
56
  - 折叠区间里**夹着你的消息就不折**——往前折丢了需求,往后折丢了回应,
@@ -101,17 +107,39 @@ tree_task消息:接下来需要进行步骤:「步骤二」(s-4)。
101
107
 
102
108
  ## 六个工具
103
109
 
110
+ **写树分两个工具,不是可以合并的重复。** `tree_task_create` 与 `tree_task_done` 是折叠的
111
+ 两个边界:`create` 是"开工"那一下,`done` 是"收工"那一下。`create` 只在新建目标时调一次,
112
+ `plan` 却在任务中途反复调用——并成一个工具,折叠起点就只能靠实参去猜了(见上一节)。
113
+
104
114
  | 工具 | 用途 |
105
115
  |---|---|
106
- | `tree_task_status` | 查看整棵树、各节点状态与已提交的结果,以及现在该推进哪个节点 |
107
- | `tree_task_create` | 新增一个目标:这个目标 + 它下面的若干任务。可多次调用,多个目标并存,不替换已有计划 |
108
- | `tree_task_plan` | 给一个节点追加子节点:给目标追加任务,给任务追加子任务 |
116
+ | `tree_task_status` | 查看计划树:默认只展开**还没结束**的目标,已完成的目标只留目标那一行;填 `id` 就返回那个目标(或该节点所在目标)的完整树 |
117
+ | `tree_task_create` | 新建一个目标:这个目标 + 它下面的若干任务。可多次调用,多个目标并存,不替换已有计划。**它也是折叠的起点标记** |
118
+ | `tree_task_plan` | 给一个节点追加子节点:父节点是目标加的是任务,父节点是任务加的是子任务。**建新目标不归它管** |
109
119
  | `tree_task_done` | 完成一个节点并提交它的结果,**`result` 必填**;返回的就是"下一步干什么",这次调用连同返回留在上下文里,被收走的只是它前面的执行过程 |
110
120
  | `tree_task_update` | 修改一个节点的标题或说明 |
111
121
  | `tree_task_drop` | 丢弃一个节点;丢弃目标会连同它下面的任务与子任务一起走,丢弃任务带上它的子任务 |
112
122
 
113
123
  层级固定三级,子任务是最低一级,不能再往下拆。
114
124
 
125
+ **返回的三条规矩:**
126
+
127
+ - 写树类工具只返回**本次动到的那个目标**的树,不再每次倾倒整棵树;
128
+ - 树用**规范的 Markdown 嵌套任务清单**渲染(`- [ ]` 开头、每层两个空格缩进),
129
+ 复制出去能正常预览,不会被当成代码块;
130
+ - **结果全文给出,一个字都不截**。
131
+
132
+ ```markdown
133
+ - [x] **目标** `g-mv50h48v1` 安卓端播放
134
+ - **结果**:手机连上 PC 并把声音收下来了。
135
+ 验收:模拟器上跑通一次端到端。
136
+ - [x] **任务** `t-mv50h48v2` 工程骨架
137
+ - **结果**:交付物 android\settings.gradle;`cd android; powershell -File tools\build.ps1` 冷构建成功。
138
+ - [ ] **子任务** `s-mv50h48v5` 写 build.gradle ← 当前
139
+ - [ ] **目标** `g-mv50h48v6` 安卓端来源 ← 当前
140
+ - [ ] **任务** `t-mv50h48v7` 采集层
141
+ ```
142
+
115
143
  ### 三层怎么分
116
144
 
117
145
  | 层 | 是什么 | 怎么算分对了 |
@@ -217,11 +245,49 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
217
245
  | 字段 | 默认 | 说明 |
218
246
  |---|---|---|
219
247
  | `enabled` | `true` | 总开关;**装上即启用**,写 `false` 关掉 |
220
- | `autoContinue` | `false` | 模型停下时是否自动续行 |
248
+ | `autoContinue` | `true` | 模型停下时是否自动续行(见下节) |
221
249
  | `maxAutoRounds` | `5` | 连续自动续行的轮次上限 |
250
+ | `interruptRecovery` | `true` | 意外中断后是否自动拉起(见下节) |
251
+ | `interruptDelaysMs` | `null` | 网络类中断的拉起时刻,`null` = 1/5/10/30 分钟 |
222
252
  | `promptOrder` | `1800` | 工具说明段落的排序 |
223
253
  | `rootDir` | `null` | 状态目录,默认 `$DSH_HOME/dsh-task-tree` |
224
254
 
255
+ ## 意外中断自动拉起
256
+
257
+ 上一节 `autoContinue` 管的是"模型自己正常停下"。这一节管的是**这一轮没能正常
258
+ 结束**:网络断了、模型 API 报错、或者工具执行到一半进程没了。这两种停法插件原先不管,
259
+ 会话就一直躺在那里。
260
+
261
+ 判据全部取自 DSH 自己写下的数据,不看文本:
262
+
263
+ | 中断 | 怎么认 | 什么时候拉起 |
264
+ |---|---|---|
265
+ | 网络 / API | `turn/end` 的原因是 `error`,错误码是 `TRANSPORT`、`TIMEOUT`、`SERVER`、`RATE_LIMIT`、`EMPTY_RESPONSE` | 中断后 **1 分钟、5 分钟、10 分钟、30 分钟**各一次 |
266
+ | 模型输出损坏 | `turn/end` 的原因是 `error`,错误码是 `MALFORMED_RESPONSE`(模型给出的工具入参不是合法 JSON) | **立即**一次 |
267
+ | 工具执行 | 这一轮出现过 `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED` 的合成工具结果(意思是"这次调用没留下结果") | **立即**一次 |
268
+
269
+ 后两类虽然都"立即一次",处置并不一样——差别在于**有没有东西要勘察、有没有东西可收**:
270
+
271
+ - **工具执行类**:那次调用确实发生过,只是结果没落盘。拉起消息会明确说"它到底做没做
272
+ 没人知道,先确认那一步的实际影响再决定要不要重做";第二次仍然失败时,把那条拉起消息、
273
+ 发起该调用的助手消息、以及它的合成错误结果一起**收走**(和折叠同一套机制,界面上仍可
274
+ 展开),再拉一次。
275
+ - **模型输出损坏类**:DSH 是在解析响应流时就判了死刑——**整条响应被作废,一个工具都没
276
+ 执行,连 `tool/call` 都没落盘**,没有任何副作用要勘察。拉起消息只说"原样重做那一步,
277
+ 把引号括号写全,必要时一次只调一个工具";第二次仍然失败时**什么都不收**(没东西可收),
278
+ 直接再拉一次。
279
+
280
+ 两类都是第三次同类中断就停手,链上记 `exhausted`。
281
+
282
+ 几条边界:
283
+
284
+ - **余额不足(`QUOTA`)不拉。** 等再久也不会自己好。
285
+ - **你按下的停止,永远不拉。** 和自动续行的第 1 条闸门一致。
286
+ - **普通工具报错不拉。** 比如"文件没找到""edit 没匹配上""网址被拦"——这些模型看到就能
287
+ 自己改道,本来也没中断这一轮。
288
+ - 只在**这个会话的计划树还没走完**时才动手。等待时刻写在状态目录的 `revive/` 下,
289
+ 重启 `dsh web` 不会丢。
290
+
225
291
  ## 暂停与继续
226
292
 
227
293
  界面上的暂停按钮(或 `/task-tree pause`)是**真暂停**,不是"关掉自动续行":
@@ -249,22 +315,32 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
249
315
  暂停期间人按界面上的停止(中断本轮)也不会卡住:闸门盯着本轮的取消信号,
250
316
  信号一 abort 就立刻放行。
251
317
 
252
- ## 自动续行的三道闸门
318
+ ## 自动续行
253
319
 
254
- `autoContinue` 默认关闭,开着也只是**兜底**:`tree_task_done` 的返回文本里已经带了
255
- "请继续执行下一个子任务",模型通常会在同一轮里接着做。这里兜的是模型仍然停下来的情况。
320
+ **模型自己停下、计划树却没跑完,插件就推一条"接下来要做 X"把它唤醒。** 这只是**兜底**:
321
+ `tree_task_done` 的返回文本里已经带了"请继续执行下一个子任务",模型通常会在同一轮里接着做。
256
322
 
257
- 开启后仍有三道闸门,缺一不可:
323
+ `autoContinue` **默认开启**。一条总规矩:**插件还没有配置界面,功能就不能默认关**——
324
+ 默认关等于这个功能不存在,没人会去翻 YAML 把它打开。等哪天真做了配置界面,再交给用户去配。
258
325
 
259
- 1. **默认关**——`autoContinue: false` 时不注册续行逻辑。
260
- 2. **中断即停**——用户按下停止(`agent/turn-stopping` 且 `signal.aborted`),
261
- 这个会话就被标记为不再自动续行,直到真人再发一条消息。
262
- 3. **轮次上限**——连续续行达到 `maxAutoRounds` 就停;只有真人发的消息才重置配额。
326
+ 开着也不会脱缰,有四道闸门:
263
327
 
264
- 队列里还有排队输入时也不抢,让用户先说话。
328
+ 1. **中断即停**——你按下停止,落盘的 `turn/end` 会记成 `reason.kind = "aborted"`,
329
+ 插件从那里认,把这个会话标记为不再自动续行(落盘在 `held/` 下,跨重启有效),
330
+ 直到真人再发一条消息。
331
+ > **这里不能用 `agent/turn-stopping` 的 `signal.aborted` 当判据。** 那个事件只在
332
+ > "这一轮本来就要正常收尾"时才发(`dsh-agent-loop` 里唯一一处 dispatch 前面刚做过
333
+ > `throwIfAborted()`);你按停止会让信号 abort、从别处抛出,catch 里直接记下
334
+ > `turn/end` 就重新抛出,那条 dispatch 走不到——`signal.aborted` 在那里几乎永远是
335
+ > `false`,拿它当闸门等于没闸门。
336
+ 2. **轮次上限**——连续续行达到 `maxAutoRounds`(默认 5)就停;只有真人发的消息才重置配额。
337
+ 3. **不抢排队输入**——队列里还有你排着的话时,让用户先说。
338
+ 4. **暂停压住它**——`/task-tree pause` 之后,即使模型自己停了下来,兜底也不会把它拉起来;
339
+ `/task-tree resume` 再放开。
265
340
 
266
- 另外,**暂停会一并压住它**:`/task-tree pause` 之后,即使模型自己停了下来,
267
- 兜底也不会把它重新拉起来;`/task-tree resume` 再放开。
341
+ > **`agent/status` 只在状态变化时发。** 重启 `dsh web` 之后,一个本来就停在 `idle` 上的
342
+ > 会话不会产生这个事件(新建的 agent 一开始就是 idle),所以它**不会**被自动唤醒——
343
+ > 要它动,你得在那个会话里先说一句话。从下一次模型自己停下开始,续行就接手了。
268
344
 
269
345
  ## 状态文件
270
346
 
@@ -274,6 +350,8 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
274
350
  |---|---|
275
351
  | `plans/<sessionId>.json` | 计划树(每个会话一棵) |
276
352
  | `surfaces/<sessionId>.json` | 三层的进度游标 `{ key, cursor }`(只给界面看,不参与折叠) |
353
+ | `held/<sessionId>.json` | 暂停 / 停止状态(跨重启保留) |
354
+ | `revive/<sessionId>.json` | 意外中断的拉起时刻表(跨重启保留) |
277
355
 
278
356
  > 目录名沿用插件改名前的标识,为的是不让既有的计划文件失联;插件名与它无关。
279
357
 
@@ -289,7 +367,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
289
367
  空 `result` 会被拒绝。所以要写清"产出了什么",写成能独立看懂的样子——
290
368
  过程会丢,别把过程写进去。
291
369
 
292
- 2. **折叠只看表层。** 起点是上一次 `done` 之后的第一个 `create`;
370
+ 2. **折叠只看表层。** 起点是上一次 `done` 之后的第一条 `tree_task_create`;
293
371
  `tree_task_plan` 不是边界,它的调用与返回跟过程一样会被收走;区间里没有过程时
294
372
  什么都不注入,夹着你的消息时整段不折——那一段原样留着(往前折丢需求、
295
373
  往后折丢回应),代价是上下文不收缩。
@@ -1,6 +1,7 @@
1
1
  # 这个文件只是示例,插件不需要它——**安装即启用**,装完就能用。
2
2
  #
3
- # 想改默认值时,把下面这段抄进 profile 自己的 cordis.patch.yml
3
+ # **默认值一律是"开"。** 插件还没有配置界面,功能默认关等于这个功能不存在。
4
+ # 想改成默认以外的东西时,把下面这段抄进 profile 自己的 cordis.patch.yml
4
5
  # (通常是 $DSH_HOME/profiles/<名字>/cordis.patch.yml):
5
6
  #
6
7
  # - id: task-tree
@@ -15,11 +16,18 @@
15
16
  # 总开关。默认 true(装上即启用);写 false 就一句话关掉,
16
17
  # 关掉后插件不注册工具、不加提示词、不折叠上下文、不续行,一个字节都不碰会话。
17
18
  enabled: true
18
- # 模型停下时是否自动续行。默认 false —— 开着意味着 agent 一空下来就被再推一把,
19
- # 会出现「按下停止却停不下来」,所以它从不默认打开。
20
- autoContinue: false
19
+ # 模型停下时是否自动续行。默认 true —— 一条总规矩:插件还没有配置界面,
20
+ # 功能就不能默认关(默认关等于这个功能不存在)。怕它停不下来不必靠这里:
21
+ # 你按下停止会落盘 held/stopped,续行自己就被拦住,真人再发一条消息才解除。
22
+ autoContinue: true
21
23
  # 连续自动续行的轮次上限,达到就停;只有真人发言才重置配额。
22
24
  maxAutoRounds: 5
25
+ # 意外中断后是否自动拉起(见 README「意外中断自动拉起」)。默认 true:
26
+ # 它只在「这一轮不是正常结束」时动手,正常停下不受影响。
27
+ interruptRecovery: true
28
+ # 网络/API 类中断的拉起时刻,单位毫秒,默认 [1分钟, 5分钟, 10分钟, 30分钟];
29
+ # null 表示用内置默认值。工具执行中断不看这张表,它永远立即拉一次。
30
+ interruptDelaysMs: null
23
31
  # 工具说明段落的排序。1800 落在内置的 TOOL_PTY(1700) 与 TOOL_WEB_SEARCH(2000)
24
32
  # 之间的空档,不与任何内置段落撞号。
25
33
  promptOrder: 1800
package/cordis.patch.yml CHANGED
@@ -11,11 +11,15 @@
11
11
  # ⚠️ 同 id 的 config 是**整体替换**,不是逐字段深合并——覆盖时要把需要的
12
12
  # 字段一次写全。可覆盖的字段见 DEFAULT_CONFIG:
13
13
  #
14
- # enabled 总开关 默认 true(写 false 关掉)
15
- # autoContinue 是否允许自动续行 默认 false(开着会让人停不下来)
16
- # maxAutoRounds 连续自动续行轮次上限 默认 5
17
- # promptOrder 工具说明段顺序 默认 1800
18
- # rootDir 插件状态目录 默认 $DSH_HOME/dsh-task-tree
14
+ # enabled 总开关 默认 true(写 false 关掉)
15
+ # autoContinue 是否允许自动续行 默认 true
16
+ # maxAutoRounds 连续自动续行轮次上限 默认 5
17
+ # interruptRecovery 意外中断是否自动拉起 默认 true
18
+ # interruptDelaysMs 网络类中断拉起时刻 默认 null(= 1/5/10/30 分钟)
19
+ # promptOrder 工具说明段顺序 默认 1800
20
+ # rootDir 插件状态目录 默认 $DSH_HOME/dsh-task-tree
21
+ #
22
+ # 默认值一律是"开"。插件还没有配置界面,功能默认关等于这个功能不存在。
19
23
  - insert:
20
24
  - id: task-tree
21
25
  name: 'dsh-tree-task-flow'
@@ -43,12 +43,43 @@ export function registerAutoContinue(ctx, { store, cfg }) {
43
43
  return stopped.has(sessionId) || store?.readHeld?.(sessionId)?.stopped === true;
44
44
  }
45
45
 
46
+ /**
47
+ * 用户按下停止:**从落盘的 `turn/end` 认,不能从 `agent/turn-stopping` 认。**
48
+ *
49
+ * `agent/turn-stopping` 只在"这一轮本来就要正常收尾"时才发——`dsh-agent-loop` 里
50
+ * 唯一一处 dispatch 在 `turn()` 的 try 里,紧挨着前面那句 `signal.throwIfAborted()`。
51
+ * 用户按停止会让信号 abort、从别处抛出,catch 里直接记下
52
+ * `turn/end reason.kind = "aborted"` 并重新抛出,那条 dispatch 根本走不到。
53
+ * 也就是说 `payload.signal.aborted` 在那里几乎永远是 `false`——拿它当闸门等于没闸门:
54
+ * 会话被停掉之后 `agent/status` 一转 `idle`,续行立刻又把它拉起来。
55
+ *
56
+ * 可靠的事实是**落盘的 `turn/end`**:中止一定会在 `finally` 里被记成
57
+ * `reason: { kind: "aborted", reason: { kind: "user" } }`(cause 为 `user` 就是人按的,
58
+ * 另有 `parent` / `hook` / `disposed`)。而且它发生在驱动循环 `setPhase(idle)`
59
+ * 之前——`agent/status` 是之后才发的,所以这个标记一定赶得上那次判断。
60
+ *
61
+ * 中止就是中止:不管是谁按的,插件都不该自作主张把这一轮重新拉起来。
62
+ */
63
+ ctx.on("session/event", (session, event) => {
64
+ if (event?.type !== "turn/end") return;
65
+ if (event.data?.reason?.kind !== "aborted") return;
66
+ const sessionId = session?.id;
67
+ if (sessionId === undefined) return;
68
+ stopped.add(sessionId);
69
+ // 落盘:重启后条目上仍看得到「继续」,也仍然压得住自动续行。
70
+ store?.patchHeld?.(sessionId, { stopped: true });
71
+ });
72
+
73
+ /**
74
+ * 次要路径,留着兜底:如果中止恰好发生在 `agent/turn-stopping` 的 dispatch 过程中
75
+ * (信号在那一刻才变 abort),上面那条 `turn/end` 判据同样会兜住它;这里只是让
76
+ * 标记更早一点落下。**不要把它当成主判据**,原因见上。
77
+ */
46
78
  ctx.on("agent/turn-stopping", (payload) => {
47
79
  const sessionId = payload?.agent?.session?.id;
48
80
  if (sessionId === undefined) return;
49
81
  if (payload?.signal?.aborted === true) {
50
82
  stopped.add(sessionId);
51
- // 落盘:重启后条目上仍看得到「继续」,也仍然压得住自动续行。
52
83
  store?.patchHeld?.(sessionId, { stopped: true });
53
84
  }
54
85
  });
package/lib/commands.js CHANGED
@@ -161,7 +161,7 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
161
161
  if (enabled !== true) {
162
162
  lines.push("", DISABLED_NOTE);
163
163
  } else if (plan === null) {
164
- lines.push("", "本会话还没有计划。用 tree_task_create 建立目标与任务。");
164
+ lines.push("", "本会话还没有计划。用 tree_task_plan 建立目标与任务。");
165
165
  } else {
166
166
  // 检查点优先于"当前节点":那一刻要推进的不是某个子任务,
167
167
  // 而是"这一层到底做完了没有"这个判断。
package/lib/compaction.js CHANGED
@@ -67,8 +67,8 @@ const NOTICE_TEXT =
67
67
  "tree_task消息:上下文刚被压缩过,计划树没有被压缩。用 tree_task_status 取回当前进度、" +
68
68
  "各节点已提交的 result,以及下一步该推进哪个节点。";
69
69
 
70
- /** 界面上的一行摘要。 */
71
- const NOTICE_LINE = "树形任务流 · 上下文被压缩,计划树仍在";
70
+ /** 界面上的一行摘要(**不含统一前缀**——前缀由 `pluginSource` 加)。 */
71
+ const NOTICE_LINE = "上下文被压缩,计划树仍在";
72
72
 
73
73
  /** 提示消息的编号,保证同一毫秒内连发两条也不撞 id。 */
74
74
  let noticeSeq = 0;
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@ import { registerAutoContinue } from "./auto-continue.js";
21
21
  import { registerCommands } from "./commands.js";
22
22
  import { registerCompactionNotice } from "./compaction.js";
23
23
  import { registerHttp } from "./http.js";
24
+ import { registerInterruptRecovery } from "./interrupt.js";
24
25
  import { registerPause } from "./pause.js";
25
26
  import { DEFAULT_ORDER, registerPromptSection } from "./prompt.js";
26
27
  import { registerRegion } from "./region.js";
@@ -54,13 +55,36 @@ export const DEFAULT_CONFIG = {
54
55
  /**
55
56
  * 是否允许在模型停下时自动续行。
56
57
  *
57
- * ★ **默认关闭**。开着它意味着:用户按下停止、agent 回到 idle,
58
- * 插件立刻又把它拉起来——会话就停不下来了。任务自动执行必须是**受控**的,
59
- * 要开也得由用户显式打开。
58
+ * ★ **默认开启**。一条总规矩:**插件还没有配置界面,功能就不能默认关**——
59
+ * 默认关等于这个功能不存在,没人会去翻 YAML 把它打开。等哪天真做了配置界面,
60
+ * 再交给用户去配。
61
+ *
62
+ * 曾经担心"开着就停不下来"(用户按下停止、agent 回到 idle,插件立刻又把它拉起来)。
63
+ * **那个担心已经有闸门挡住了**:按停止会写 `held/<sessionId>.json` 的
64
+ * `stopped: true`(见 `auto-continue.js` 的 `agent/turn-stopping`),
65
+ * `agent/status` 一回 idle 就先看这个标记,是就一步都不动;真人再发一条消息才解除。
66
+ * 再加上 `maxAutoRounds` 的轮次上限,兜底不会变成脱缰。
60
67
  */
61
- autoContinue: false,
68
+ autoContinue: true,
62
69
  /** 连续自动续行的轮次上限,达到就停。 */
63
70
  maxAutoRounds: 5,
71
+ /**
72
+ * 意外中断的自动拉起。
73
+ *
74
+ * ★ **默认开启**。它和 `autoContinue` 管的是两件事:`autoContinue` 是"模型自己
75
+ * 正常收尾了、但计划没做完",本项是"模型根本没机会说完就断了"——网络断了、
76
+ * 模型 API 报错、或者进程在工具执行中途没了。后者不加干预就永远停在那儿。
77
+ *
78
+ * 用户按停止(`aborted`)永远不拉起;余额不足(`QUOTA`)也不拉(等再久也不会好)。
79
+ */
80
+ interruptRecovery: true,
81
+ /**
82
+ * 网络类中断的拉起时刻表,单位毫秒,相对中断那一刻。
83
+ *
84
+ * 默认 `[1 分钟, 5 分钟, 10 分钟, 30 分钟]`——四个**累计时刻**,不是递增间隔。
85
+ * 一条链只跑一遍;跑完还失败就停手,等真人发消息再允许重开。
86
+ */
87
+ interruptDelaysMs: null,
64
88
  /** 工具说明段落的排序。 */
65
89
  promptOrder: DEFAULT_ORDER,
66
90
  /** 状态目录;null 表示 $DSH_HOME/dsh-task-tree。 */
@@ -115,6 +139,13 @@ export function apply(ctx, config) {
115
139
  // 下一次模型请求还没发出"那个岔口。它同时压住上面的自动续行兜底。
116
140
  const pause = registerPause(ctx, { store, autoContinue });
117
141
 
142
+ // 意外中断的拉起:网络类按 1/5/10/30 分钟各一次,工具类(结果未知的合成错误)
143
+ // 立刻一次,再失败就收走那一次尝试再来一次。它与 autoContinue 并存:
144
+ // 那边管"模型正常收尾但计划没做完",这边管"模型没机会说完就断了"。
145
+ if (cfg.interruptRecovery === true) {
146
+ registerInterruptRecovery(ctx, { store, cfg, pause });
147
+ }
148
+
118
149
  // /task-tree 命令:查看与管理任务树,也是"暂停 / 继续"的开关。
119
150
  registerCommands(ctx, { store, cfg, pause, enabled: true });
120
151