magic-agent 1.0.49 → 1.0.108

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.
Files changed (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2168 -143
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -0,0 +1,401 @@
1
+ // Package team - 团队模式:主引擎做计划,子引擎分头执行,主引擎汇总。
2
+ //
3
+ // ── 为什么不新造一个引擎 ──
4
+ // 团队模式是**会话层的一种运行模式**,不是一个后端。同一个会话里仍然只有一个
5
+ // session_id、一条事件流、一个 result 收尾;变的只是「谁在干活」:主引擎(当前
6
+ // `-e`)负责拆解与汇总,子任务各起各的进程、各用各的模型、各在自己的目录里跑。
7
+ //
8
+ // ── 上下文策略:**不共享** ──
9
+ // 子任务不接续主会话,也不互相接续(用户 2026-10-21 定稿)。代价是每个子任务都要
10
+ // 自带全部上下文;收益是可控、可并行、可单独重跑。所以:
11
+ //
12
+ // PLAN.md 全队共享的**唯一事实源**,由 core 写一次、之后只读(0444)
13
+ // TASK-<id>.md 每个子任务的详细任务书(输入 + 约束 + 交付物 + 验收),只读
14
+ // <task_id>/ 子引擎自己的工作目录(进程 cwd),互不可见
15
+ // OUT-<id>.md 子任务正文,core 收尾时抄写(不信任子引擎自报)
16
+ // ARTS-<id>.json 该任务的产物清单,core 从事件里归拢
17
+ //
18
+ // 写权是**机制保证**的,不是约定:子引擎只拿到自己工作目录的写权,PLAN/TASK 是只读
19
+ // 文件,OUT/ARTS 由 core 写。于是「谁改了计划」这种问题不存在。
20
+ //
21
+ // ── 子任务 = 普通会话(2026-10-22 用户定稿) ──
22
+ // 每个子任务在会话列表里就是**一条普通会话**:各自有登记记录与日志(由调用方通过
23
+ // Options.OpenSubSession 提供),靠父指针挂在主会话下显示,点进去能看到它完整的
24
+ // 思考 / 正文 / 工具过程,也能单独停止。子任务之间仍不共享上下文。
25
+ //
26
+ // 主会话里只出现**一张任务列表卡**(agent.KindTasks 快照):一行一个子任务,
27
+ // 带状态(pending / running / done / failed / skipped)与对应的子会话 id。
28
+ // 因此子任务的内部事件**不再转发**到主会话 —— 它们属于子会话自己的日志。
29
+ package team
30
+
31
+ import (
32
+ "context"
33
+ "fmt"
34
+ "strconv"
35
+ "strings"
36
+ "sync"
37
+ "time"
38
+
39
+ "github.com/darren/magic-agent/internal/agent"
40
+ )
41
+
42
+ // Kinds 支持的子任务类别(顺序即 --help / 报错文案的顺序)。
43
+ //
44
+ // 类别是**派生值**:任务的唯一作者字段是 role(见 role.go),类别由角色的 Kind 决定,
45
+ // 再据此选引擎。类别回答的是「这类活该派给谁」,不是「必须产出什么文件」——
46
+ // 分派依据是**引擎擅长什么**(例如带视频 skill 的引擎接 video 类)。
47
+ //
48
+ // text 文档、分析、结论、文案
49
+ // code 复杂代码改动(多文件 / 要构建或测试 / 有明确改动面),交给擅长写代码的引擎
50
+ // image 画面类产出(位图 / 矢量),交给擅长画图的引擎
51
+ // video 影像类产出,交给擅长做视频的引擎(如带视频 skill 的)
52
+ // game 可运行的游戏工程(复合:代码 + 素材,由子引擎自己再拆)
53
+ //
54
+ // code 只收「复杂到值得单开一个执行者」的代码活;简单代码由主引擎在汇总里直接给出,
55
+ // 不拆子任务(规则写在 plannerSystemPrompt 里)。
56
+ var Kinds = []string{KindText, KindCode, KindImage, KindVideo, KindGame}
57
+
58
+ // 类别常量。
59
+ const (
60
+ KindText = "text"
61
+ KindCode = "code"
62
+ KindImage = "image"
63
+ KindVideo = "video"
64
+ KindGame = "game"
65
+
66
+ // KindPlan 计划阶段(planner)的路由键。
67
+ //
68
+ // 它是**阶段**,不是子任务类别,所以刻意不放进 Kinds:任务的类别由角色派生,
69
+ // 永远不会是 plan(NormalizeKind 也不认它),否则会拆出一个叫「计划」的执行子任务。
70
+ // 它只用于「谁来拆任务」——见 RouteKinds / PlanRoute。
71
+ KindPlan = "plan"
72
+ )
73
+
74
+ // RouteKinds 可配置路由的全部键(`--team-route` / 客户端 routes 的合法类别)。
75
+ //
76
+ // = 计划阶段 + 五类子任务。与 Kinds 分开是上面的原因(plan 不能当子任务);
77
+ // 顺序即报错文案的顺序。
78
+ var RouteKinds = []string{KindPlan, KindText, KindCode, KindImage, KindVideo, KindGame}
79
+
80
+ // Route 某一类子任务该交给哪个引擎 / 模型。空引擎 = 跟随主引擎(主引擎为空时才真的没有)。
81
+ type Route struct {
82
+ Engine string
83
+ Model string
84
+ }
85
+
86
+ // Options 一次团队调用的全部输入。
87
+ type Options struct {
88
+ // Engine 主引擎(计划 + 汇总都用它)。必填。
89
+ Engine agent.Engine
90
+ // Model 主引擎的模型(空 = 引擎默认)。
91
+ Model string
92
+ // Prompt 用户原始诉求。
93
+ Prompt string
94
+ // SystemPrompt 主引擎的系统提示词(计划与汇总都注入)。
95
+ SystemPrompt string
96
+ // Reasoning 思考档位(原样透传子任务,空 = 不传)。
97
+ Reasoning string
98
+ // SessionID 主引擎的会话续接 id(空 = 新会话)。
99
+ //
100
+ // ⚠️ 只有**主引擎**(计划 + 汇总)会拿到它,子任务永远是空 —— 这正是用户
101
+ // 2026-10-21 定稿的「不共享上下文」:主引擎与用户保持对话连续,子任务各自从零开始。
102
+ SessionID string
103
+
104
+ // Routes 路由键 → 引擎/模型。键取 RouteKinds(plan + 五类子任务),
105
+ // 缺项跟随主引擎(见 RouteFor / PlanRoute)。
106
+ Routes map[string]Route
107
+ // Parallel 并发度(<=0 取 defaultParallel)。
108
+ Parallel int
109
+ // Split 拆解粒度:coarse(最多 3 个)| standard(6,默认)| fine(10)。空 = standard。
110
+ Split string
111
+ // SplitSimpleCode 简单代码也单独拆给 coder(默认 false:简单代码由主引擎直接给出)。
112
+ SplitSimpleCode bool
113
+ // Rules 角色规则覆盖(键 role:<name>,值「这类任务适合什么」)。
114
+ //
115
+ // 只改计划阶段的角色表描述(用户配置:「描述好每一类适合的规则」),
116
+ // 不改执行者的系统提示词。
117
+ Rules map[string]string
118
+ // TaskTimeout 单个子任务的超时(<=0 按总超时推算,见 taskTimeout)。
119
+ TaskTimeout time.Duration
120
+ // Timeout 整轮预算(<=0 不限)。
121
+ Timeout time.Duration
122
+
123
+ // Workspace 用户选的工作空间(空 = 默认空间)。
124
+ Workspace string
125
+ // RunID 本次运行 id(由会话登记表分配,决定运行目录名)。
126
+ RunID string
127
+ // BaseDir 覆盖运行目录的父目录(空 = 按 chooseRunDir 推断)。
128
+ BaseDir string
129
+
130
+ // Permission 子任务的权限上限(默认 accept-edits,见 DefaultTaskPermission)。
131
+ Permission agent.PermissionTier
132
+ // PermissionOptions 与 Permission 配套的四档选项。
133
+ PermissionOptions agent.PermissionOptions
134
+ // Tools 子任务的工具模式(默认 ToolsOn —— 子任务要能读写文件才有意义)。
135
+ Tools agent.ToolsMode
136
+
137
+ // PlanOnly 只出计划与任务书,不执行(--team-plan-only)。
138
+ PlanOnly bool
139
+ // Adaptive 第一轮执行完后,按失败与交接单 open 项再规划一轮补充任务
140
+ // (--team-adaptive,CLI 默认开;Go 零值关,测试与程序化调用默认不受影响)。
141
+ //
142
+ // 只补一轮、最多 adaptiveMaxTasks 个;追加失败不会打断整轮。
143
+ Adaptive bool
144
+ // Continue 主引擎的「续接最近一次会话」(--continue;与 SessionID 互斥,id 优先)。
145
+ Continue bool
146
+
147
+ // OnEvent 事件出口(由 CLI 层接到 eventSink)。并发安全由调用方保证。
148
+ OnEvent func(agent.StreamEvent)
149
+
150
+ // OpenSubSession 为一个子任务开一条**普通会话**(登记 + 独立日志)。
151
+ //
152
+ // 由 CLI 层实现(team 包不依赖 internal/session 与日志实现,见 subsession.go)。
153
+ // 返回 nil = 这条子任务不单独登记:过程事件不转发给任何人,只在任务列表里留一行。
154
+ // runDir 是本次运行的根目录,engine / model 是实际解析出来的路由结果。
155
+ OpenSubSession func(t Task, runDir, engine, model string) SubSession
156
+ }
157
+
158
+ // DefaultTaskPermission 子任务的默认权限上限。
159
+ //
160
+ // 刻意**低于**主会话的默认档(full):子任务是自己拆出来的一段活,让它带
161
+ // 「完全访问」等于把一次授权扩成 N 份。要放开就显式传 --team-permission。
162
+ const DefaultTaskPermission = agent.PermissionAcceptEdits
163
+
164
+ const defaultParallel = 4
165
+
166
+ // TaskResult 一个子任务的结局。
167
+ type TaskResult struct {
168
+ ID string
169
+ // Kind 任务类别(由 Role 派生,见 Task.EffectiveKind)。
170
+ Kind string
171
+ // Role 执行角色(规范名;任务必填,归一化后一定非空)。
172
+ Role string
173
+ Title string
174
+ Engine string
175
+ Model string
176
+ State string // done | failed | skipped
177
+ Reason string
178
+ Text string
179
+ // SessionID 子引擎自己为这个子任务开的会话 id(可能为空)。
180
+ //
181
+ // 它**不是**这条会话的锚点(主会话的 id 在 Result.SessionID),而是绑定在主会话
182
+ // 下面的一条子会话:只用于登记 / 留档,事件流里刻意抹掉(见 execute.go)。
183
+ SessionID string
184
+ // OutputPath OUT-<id>.md 的绝对路径(失败时也写,内容是失败原因)。
185
+ OutputPath string
186
+ // Arts 该任务产出的产物绝对路径(从事件里归拢,已去重)。
187
+ Arts []string
188
+ // Missing 任务书里声明、但跑完没找到的交付物文件名。
189
+ Missing []string
190
+ // Handoff 子任务正文末尾的交接单(解析不出为 nil,下游回落到正文截断)。
191
+ Handoff *Handoff
192
+ // Checks 验收命令的执行结果(只在任务跑完时执行)。
193
+ Checks []CheckResult
194
+ // Attempts 实际尝试次数(1 = 一次通过;>1 = 验收未过重试过)。
195
+ Attempts int
196
+ }
197
+
198
+ // Result 一次团队调用的结果。
199
+ type Result struct {
200
+ // Text 最终正文(reducer 产出;reducer 失败时是兜底清单)。
201
+ Text string
202
+ // RunDir 运行目录(PLAN.md 所在)。
203
+ RunDir string
204
+ PlanPath string
205
+ Tasks []TaskResult
206
+ // SessionID 主引擎的会话 id(reducer 那一轮)。
207
+ SessionID string
208
+ Latency time.Duration
209
+ }
210
+
211
+ // Run 执行一次团队调用。
212
+ //
213
+ // 失败语义(写死,别改成别的):
214
+ // - 计划生成失败 / 解析失败 / 依赖成环 → **降级单任务团队**,明说一行,不打死整轮;
215
+ // - 某个子任务失败 → 按 required 决定「继续」或「整轮失败」;
216
+ // - reducer 失败 → 回落「产物清单 + 各任务正文首段」,仍以正常结果收尾。
217
+ //
218
+ // 这三种降级都不会让事件流少一个终态 —— 调用方的「每轮以 result 或 error 收尾」
219
+ // 不变量照旧成立。
220
+ func Run(ctx context.Context, opts Options) (Result, error) {
221
+ if opts.Engine == nil {
222
+ return Result{}, fmt.Errorf("team: 主引擎为空")
223
+ }
224
+ if strings.TrimSpace(opts.Prompt) == "" {
225
+ return Result{}, fmt.Errorf("team: 提示词为空")
226
+ }
227
+ started := time.Now()
228
+ if opts.Parallel <= 0 {
229
+ opts.Parallel = defaultParallel
230
+ }
231
+ if opts.Permission == "" {
232
+ opts.Permission = DefaultTaskPermission
233
+ }
234
+ if opts.Tools == nil {
235
+ opts.Tools = agent.ToolsOn
236
+ }
237
+ if opts.OnEvent == nil {
238
+ opts.OnEvent = func(agent.StreamEvent) {}
239
+ }
240
+
241
+ emit := opts.OnEvent
242
+ emit(agent.StreamEvent{Kind: agent.KindNotice, Text: "团队模式:正在生成计划…"})
243
+
244
+ /* 计划阶段:主引擎**关工具**跑一次。
245
+ 关工具不是为了省事 —— 计划必须是可解析的结构化输出,开着工具时模型会把
246
+ 中间过程(读文件 / 跑命令)混进正文,解析成功率会掉下来。 */
247
+ plan, planSession, planErr := planWithHeartbeat(ctx, opts, emit)
248
+ degraded := ""
249
+ if planErr != nil {
250
+ degraded = "计划生成失败,已降级为单任务:" + planErr.Error()
251
+ plan = fallbackPlan(opts.Prompt)
252
+ }
253
+ if err := plan.Validate(); err != nil {
254
+ degraded = "计划不可用,已降级为单任务:" + err.Error()
255
+ plan = fallbackPlan(opts.Prompt)
256
+ }
257
+ if degraded != "" {
258
+ emit(agent.StreamEvent{Kind: agent.KindNotice, Text: degraded})
259
+ }
260
+
261
+ // 运行目录与计划文档(唯一事实源)。
262
+ runDir, err := chooseRunDir(opts)
263
+ if err != nil {
264
+ return Result{}, err
265
+ }
266
+ res := Result{RunDir: runDir, PlanPath: PlanPath(runDir)}
267
+ if err := WritePlanDoc(runDir, plan, opts); err != nil {
268
+ return Result{}, fmt.Errorf("team: 写计划文档失败:%w", err)
269
+ }
270
+ emitPlanEvent(emit, res.PlanPath, plan, opts.PlanOnly)
271
+
272
+ // 任务书(每个子任务一份)。写在 `PlanOnly` 判断**之前**:--team-plan-only 的用途
273
+ // 就是「先确认方案」—— 用户要看的是「真要跑起来,每个子任务会收到什么」,
274
+ // 只给一份总览等于看不到最关键的那一层(帮助文案也一直是这么写的)。
275
+ // 写不出就整轮失败 —— 没有任务书等于子任务没有输入。
276
+ for _, t := range plan.Tasks {
277
+ if err := WriteTaskDoc(runDir, plan, t); err != nil {
278
+ return res, fmt.Errorf("team: 写任务书 %s 失败:%w", t.ID, err)
279
+ }
280
+ if err := EnsureTaskDir(runDir, t.ID); err != nil {
281
+ return res, fmt.Errorf("team: 建工作目录 %s 失败:%w", t.ID, err)
282
+ }
283
+ }
284
+
285
+ // 计划一确定就把**全部**子任务登记进任务列表卡(含还在等上游的)。
286
+ //
287
+ // 为什么提前登记:execute 按依赖分层,有 deps 的子任务要等上一层跑完才轮到它 ——
288
+ // 期间界面上连「有这个任务」都看不到。
289
+ board := newBoard(emit)
290
+ for _, t := range plan.Tasks {
291
+ board.add(t, RouteFor(t, opts))
292
+ }
293
+ board.announce()
294
+
295
+ if opts.PlanOnly {
296
+ res.Text = "# 计划已生成\n\n" + res.PlanPath + "\n\n" + PlanMarkdown(plan) +
297
+ "\n\n(任务书已就绪:" + TaskDocPath(runDir, plan.Tasks[0].ID) + " 等 " +
298
+ strconv.Itoa(len(plan.Tasks)) + " 份;去掉了 --team-plan-only 就按这些任务书执行)"
299
+ res.SessionID = planSession
300
+ emit(agent.StreamEvent{Kind: agent.KindText, Text: res.Text})
301
+ res.Latency = time.Since(started)
302
+ return res, nil
303
+ }
304
+
305
+ // 执行。
306
+ results := executeWithHeartbeat(ctx, opts, plan, runDir, board)
307
+ if opts.Adaptive {
308
+ // 再规划只补一轮:它读的是第一轮的结果,追加的任务不再触发第二轮。
309
+ plan, results = adaptiveReplan(ctx, opts, plan, results, runDir, planSession, board)
310
+ }
311
+ res.Tasks = results
312
+
313
+ // 汇总。
314
+ text, sid := reduce(ctx, opts, plan, results, planSession, emit)
315
+ res.Text = text
316
+ if sid != "" {
317
+ res.SessionID = sid
318
+ } else {
319
+ res.SessionID = planSession
320
+ }
321
+ res.Latency = time.Since(started)
322
+ return res, nil
323
+ }
324
+
325
+ // planHeartbeat 计划阶段的进度播报间隔。
326
+ const planHeartbeat = 5 * time.Second
327
+
328
+ // planWithHeartbeat 跑计划阶段,等待期间定期播报「已等 N 秒」。
329
+ //
330
+ // 计划是一次**非流式**调用(ToolsOff,要模型整段吐 JSON),实测 40 秒起步;
331
+ // 这期间一条事件都没有,客户端看起来就是「发出去了没反应」。心跳不伪造进度,
332
+ // 只证明 core 还活着 —— 这是「无响应」里最长、也最容易被当成挂死的一段。
333
+ //
334
+ // 退出顺序:close(stop) 之后 wg.Wait(),保证心跳 goroutine 彻底停了才返回,
335
+ // 否则它的 notice 可能插到「计划已生成」后面。
336
+ func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.StreamEvent)) (Plan, string, error) {
337
+ stop := make(chan struct{})
338
+ var wg sync.WaitGroup
339
+ wg.Add(1)
340
+ go func() {
341
+ defer wg.Done()
342
+ start := time.Now()
343
+ tick := time.NewTicker(planHeartbeat)
344
+ defer tick.Stop()
345
+ for {
346
+ select {
347
+ case <-stop:
348
+ return
349
+ case <-ctx.Done():
350
+ return
351
+ case <-tick.C:
352
+ emit(agent.StreamEvent{
353
+ Kind: agent.KindNotice,
354
+ Text: fmt.Sprintf("团队模式:正在生成计划…(已 %d 秒)", int(time.Since(start).Seconds())),
355
+ })
356
+ }
357
+ }
358
+ }()
359
+ plan, sid, err := makePlan(ctx, opts)
360
+ close(stop)
361
+ wg.Wait()
362
+ return plan, sid, err
363
+ }
364
+
365
+ // 执行阶段的心跳参数。
366
+ //
367
+ // 子引擎可能几十秒到十几分钟不吐一条事件:模型侧 request 超时后 claude CLI 会自己
368
+ // 重试,重试期间不产生任何 stream-json。此时任务列表里只有一行「运行中」,跟卡死
369
+ // 无法区分。心跳不伪造进度,只证明「这个子任务还活着」。
370
+ const (
371
+ taskHeartbeat = 20 * time.Second
372
+ taskQuiet = 30 * time.Second
373
+ )
374
+
375
+ // executeWithHeartbeat 跑执行阶段,并对长时间没动静的子任务定期播报。
376
+ func executeWithHeartbeat(ctx context.Context, opts Options, plan Plan, runDir string, board *Board) []TaskResult {
377
+ stop := make(chan struct{})
378
+ var wg sync.WaitGroup
379
+ wg.Add(1)
380
+ go func() {
381
+ defer wg.Done()
382
+ tick := time.NewTicker(taskHeartbeat)
383
+ defer tick.Stop()
384
+ for {
385
+ select {
386
+ case <-stop:
387
+ return
388
+ case <-ctx.Done():
389
+ return
390
+ case <-tick.C:
391
+ for _, text := range board.quietLines() {
392
+ board.notice(text)
393
+ }
394
+ }
395
+ }
396
+ }()
397
+ results := execute(ctx, opts, plan, runDir, board)
398
+ close(stop)
399
+ wg.Wait()
400
+ return results
401
+ }