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.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
// schedule.go - 执行调度:就绪即跑(Kahn 就绪队列)+ 失败级联,收在一个窄接口后面。
|
|
4
|
+
//
|
|
5
|
+
// 为什么不是「按层」:分层会把同层里无关的任务捆在一起 —— t3 只依赖 t1,却要等同层
|
|
6
|
+
// 跑得最慢的 t2 结束才能开始。任务数是个位数时差异不明显,但没有任何理由为「分层」
|
|
7
|
+
// 付这份空转,而就绪队列的代码量并不比分层多。
|
|
8
|
+
//
|
|
9
|
+
// 为什么抽接口:调度语义(谁就绪、谁被跳过、并发多少、取消怎么收)和执行语义
|
|
10
|
+
// (跑引擎、落盘、投影事件)是两件事。接口把前者钉死,将来换实现或接外部编排器时
|
|
11
|
+
// 不用动 execute。
|
|
12
|
+
|
|
13
|
+
import (
|
|
14
|
+
"context"
|
|
15
|
+
"fmt"
|
|
16
|
+
"sort"
|
|
17
|
+
"strings"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// Worker 跑一个**已就绪**的任务。upstream 是此刻已结束任务的结局快照(依赖必在其中)。
|
|
21
|
+
// 返回的 TaskResult 由调度器统一收进结果表;State 为空时按失败处理。
|
|
22
|
+
type Worker func(ctx context.Context, t Task, upstream map[string]TaskResult) TaskResult
|
|
23
|
+
|
|
24
|
+
// SkipFunc 处理一个因上游未完成(含级联)而跳过的任务:留档与收卡由调用方做。
|
|
25
|
+
type SkipFunc func(t Task, reason string)
|
|
26
|
+
|
|
27
|
+
// Scheduler 执行调度器。
|
|
28
|
+
//
|
|
29
|
+
// 契约(实现必须满足):
|
|
30
|
+
// - 每个任务恰好被 work 或 skip 处理一次,返回值按计划顺序排列;
|
|
31
|
+
// - 只有依赖全部结束的任务才会进入 work,upstream 里至少有它的全部依赖;
|
|
32
|
+
// - 并发的 work 调用不超过上限;
|
|
33
|
+
// - 计划不合法(重复 id / 缺依赖 / 成环)返回 error,不调用任何回调;
|
|
34
|
+
// - ctx 取消后不再启动新任务,剩余任务按「已取消」跳过(可级联)。
|
|
35
|
+
type Scheduler interface {
|
|
36
|
+
Run(ctx context.Context, plan Plan, work Worker, skip SkipFunc) ([]TaskResult, error)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ReadyQueue 就绪即跑的调度器:依赖一结束就入队,不等同层其他任务。
|
|
40
|
+
type ReadyQueue struct {
|
|
41
|
+
// Parallel 同时跑几个;<=0 取 defaultParallel(4)。
|
|
42
|
+
Parallel int
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var _ Scheduler = ReadyQueue{}
|
|
46
|
+
|
|
47
|
+
func (q ReadyQueue) parallel() int {
|
|
48
|
+
if q.Parallel > 0 {
|
|
49
|
+
return q.Parallel
|
|
50
|
+
}
|
|
51
|
+
return defaultParallel
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func (q ReadyQueue) Run(ctx context.Context, plan Plan, work Worker, skip SkipFunc) ([]TaskResult, error) {
|
|
55
|
+
if work == nil {
|
|
56
|
+
return nil, fmt.Errorf("调度器缺少 worker")
|
|
57
|
+
}
|
|
58
|
+
n := len(plan.Tasks)
|
|
59
|
+
if n == 0 {
|
|
60
|
+
return nil, fmt.Errorf("计划里没有任务")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 图:入度 + 反向边。id 与 deps 都先 TrimSpace —— 计划是模型产出的,
|
|
64
|
+
// " t1 " 这种写法不该变成一条不存在的依赖。
|
|
65
|
+
type node struct {
|
|
66
|
+
task Task
|
|
67
|
+
indegree int
|
|
68
|
+
dependents []string
|
|
69
|
+
}
|
|
70
|
+
nodes := make(map[string]*node, n)
|
|
71
|
+
order := make([]string, 0, n)
|
|
72
|
+
for _, t := range plan.Tasks {
|
|
73
|
+
id := strings.TrimSpace(t.ID)
|
|
74
|
+
if id == "" {
|
|
75
|
+
return nil, fmt.Errorf("有任务缺少 id")
|
|
76
|
+
}
|
|
77
|
+
if _, dup := nodes[id]; dup {
|
|
78
|
+
return nil, fmt.Errorf("任务 id 重复:%s", id)
|
|
79
|
+
}
|
|
80
|
+
nodes[id] = &node{task: t}
|
|
81
|
+
order = append(order, id)
|
|
82
|
+
}
|
|
83
|
+
for _, t := range plan.Tasks {
|
|
84
|
+
id := strings.TrimSpace(t.ID)
|
|
85
|
+
for _, raw := range t.Deps {
|
|
86
|
+
dep := strings.TrimSpace(raw)
|
|
87
|
+
up, ok := nodes[dep]
|
|
88
|
+
if !ok {
|
|
89
|
+
return nil, fmt.Errorf("任务 %s 依赖了不存在的任务 %q", id, dep)
|
|
90
|
+
}
|
|
91
|
+
nodes[id].indegree++
|
|
92
|
+
up.dependents = append(up.dependents, id)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 初始就绪集:入度 0。按 id 排序,保证同一张计划每次跑的顺序一致。
|
|
97
|
+
ready := make([]string, 0, n)
|
|
98
|
+
for _, id := range order {
|
|
99
|
+
if nodes[id].indegree == 0 {
|
|
100
|
+
ready = append(ready, id)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
sort.Strings(ready)
|
|
104
|
+
|
|
105
|
+
type completion struct {
|
|
106
|
+
id string
|
|
107
|
+
r TaskResult
|
|
108
|
+
}
|
|
109
|
+
// 容量 n:worker 永远不会因为没人收而卡住。
|
|
110
|
+
completions := make(chan completion, n)
|
|
111
|
+
results := make(map[string]TaskResult, n)
|
|
112
|
+
blocked := make(map[string]string, n) // 任务 id → 根因(它为何没能完成)
|
|
113
|
+
finished, running := 0, 0
|
|
114
|
+
|
|
115
|
+
// 释放一个任务的后续:入度减一,归零即入就绪队列。
|
|
116
|
+
release := func(id string) {
|
|
117
|
+
for _, dep := range nodes[id].dependents {
|
|
118
|
+
nodes[dep].indegree--
|
|
119
|
+
if nodes[dep].indegree == 0 {
|
|
120
|
+
ready = append(ready, dep)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
sort.Strings(ready)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 记一个跳过并级联:根因继续往下传,下游报的是「最初为什么没跑成」,
|
|
127
|
+
// 而不是一层层套娃的「上游的上游……」。
|
|
128
|
+
markSkipped := func(id, reason, root string) {
|
|
129
|
+
t := nodes[id].task
|
|
130
|
+
r := TaskResult{ID: t.ID, Kind: t.EffectiveKind(), Role: t.Role, Title: t.Title, State: TaskSkipped, Reason: reason}
|
|
131
|
+
results[id] = r
|
|
132
|
+
blocked[id] = root
|
|
133
|
+
if skip != nil {
|
|
134
|
+
skip(t, reason)
|
|
135
|
+
}
|
|
136
|
+
finished++
|
|
137
|
+
release(id)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for finished < n {
|
|
141
|
+
// ① 填满并发:有依赖结束就补位,不等「整层」。
|
|
142
|
+
for running < q.parallel() && len(ready) > 0 {
|
|
143
|
+
id := ready[0]
|
|
144
|
+
ready = ready[1:]
|
|
145
|
+
t := nodes[id].task
|
|
146
|
+
|
|
147
|
+
// 上游必需任务没完成 → 本任务不跑(并级联到它的下游)。
|
|
148
|
+
if dep, root, bad := blockedBy(t, blocked); bad {
|
|
149
|
+
markSkipped(id, "上游 "+dep+" 未完成("+root+")", root)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
// 已取消:不再起新进程,剩余任务一律按「已取消」收尾。
|
|
153
|
+
if ctx.Err() != nil {
|
|
154
|
+
markSkipped(id, "已取消", "已取消")
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
running++
|
|
159
|
+
upstream := snapshotResults(results)
|
|
160
|
+
go func(t Task, upstream map[string]TaskResult) {
|
|
161
|
+
completions <- completion{id: strings.TrimSpace(t.ID), r: work(ctx, t, upstream)}
|
|
162
|
+
}(t, upstream)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ② 填完这一轮可能刚好全部收尾(尤其是级联跳过的情形),别误判成环。
|
|
166
|
+
if finished >= n {
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
// 没有在跑的、也没有就绪的 → 剩下的全卡在环上。
|
|
170
|
+
if running == 0 {
|
|
171
|
+
if len(ready) == 0 {
|
|
172
|
+
return nil, fmt.Errorf("任务依赖成环:%s", strings.Join(pendingIDs(order, results), " → "))
|
|
173
|
+
}
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ③ 等一个结束,记结局并释放下游。
|
|
178
|
+
c := <-completions
|
|
179
|
+
running--
|
|
180
|
+
finished++
|
|
181
|
+
if strings.TrimSpace(c.r.ID) == "" {
|
|
182
|
+
c.r.ID = c.id
|
|
183
|
+
}
|
|
184
|
+
if c.r.State == "" {
|
|
185
|
+
c.r.State = TaskFailed
|
|
186
|
+
}
|
|
187
|
+
if c.r.State != TaskDone && nodes[c.id].task.IsRequired() {
|
|
188
|
+
root := strings.TrimSpace(c.r.Reason)
|
|
189
|
+
if root == "" {
|
|
190
|
+
root = "未完成"
|
|
191
|
+
}
|
|
192
|
+
blocked[c.id] = root
|
|
193
|
+
}
|
|
194
|
+
results[c.id] = c.r
|
|
195
|
+
release(c.id)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
out := make([]TaskResult, 0, n)
|
|
199
|
+
for _, id := range order {
|
|
200
|
+
if r, ok := results[id]; ok {
|
|
201
|
+
out = append(out, r)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
out = append(out, TaskResult{ID: id, State: TaskSkipped, Reason: "未执行"})
|
|
205
|
+
}
|
|
206
|
+
return out, nil
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// blockedBy 判断本任务是否被**必需的**上游阻断,返回直接上游 id 与根因。
|
|
210
|
+
//
|
|
211
|
+
// 只认 blocked 表:非必需任务失败不会进表,因此它的下游照常跑(尽力而为的语义)。
|
|
212
|
+
func blockedBy(t Task, blocked map[string]string) (dep, root string, bad bool) {
|
|
213
|
+
for _, d := range t.Deps {
|
|
214
|
+
d = strings.TrimSpace(d)
|
|
215
|
+
if r, ok := blocked[d]; ok {
|
|
216
|
+
return d, r, true
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return "", "", false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// snapshotResults 拿一份已结束结局的副本:worker 只读,主循环继续写。
|
|
223
|
+
func snapshotResults(m map[string]TaskResult) map[string]TaskResult {
|
|
224
|
+
out := make(map[string]TaskResult, len(m))
|
|
225
|
+
for k, v := range m {
|
|
226
|
+
out[k] = v
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// pendingIDs 还没有结局的任务 id(成环时报错用),排序保证输出稳定。
|
|
232
|
+
func pendingIDs(order []string, results map[string]TaskResult) []string {
|
|
233
|
+
var out []string
|
|
234
|
+
for _, id := range order {
|
|
235
|
+
if _, ok := results[id]; !ok {
|
|
236
|
+
out = append(out, id)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
sort.Strings(out)
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
// schedule_test.go - 调度语义的用例。**不碰真引擎**:Worker 就是普通函数,
|
|
4
|
+
// 因此这里能只测「谁先跑、谁被跳过、并发多少」,跟引擎可用性无关。
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"sync/atomic"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func schedPlan(tasks ...Task) Plan { return Plan{Goal: "g", Tasks: tasks} }
|
|
16
|
+
|
|
17
|
+
func schedTask(id string, deps ...string) Task {
|
|
18
|
+
return Task{ID: id, Kind: KindText, Prompt: "p", Deps: deps}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// TestReadyQueueStartsTaskAsSoonAsItsDepsFinish 钉住这次优化的核心:
|
|
22
|
+
// t3 只依赖 t2,就不该等慢任务 t1 —— 按层调度会让它一直等到 t1 结束。
|
|
23
|
+
func TestReadyQueueStartsTaskAsSoonAsItsDepsFinish(t *testing.T) {
|
|
24
|
+
plan := schedPlan(
|
|
25
|
+
schedTask("t1"), // 慢任务:卡住不返回
|
|
26
|
+
schedTask("t2"), // 快任务
|
|
27
|
+
schedTask("t3", "t2"), // 只依赖 t2
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
releaseCh := make(chan struct{})
|
|
31
|
+
var releaseOnce sync.Once
|
|
32
|
+
release := func() { releaseOnce.Do(func() { close(releaseCh) }) }
|
|
33
|
+
defer release() // 断言失败也不把 worker 永久挂住
|
|
34
|
+
|
|
35
|
+
t3ran := make(chan struct{})
|
|
36
|
+
var t1done atomic.Bool
|
|
37
|
+
|
|
38
|
+
work := func(_ context.Context, tk Task, upstream map[string]TaskResult) TaskResult {
|
|
39
|
+
switch tk.ID {
|
|
40
|
+
case "t1":
|
|
41
|
+
<-releaseCh
|
|
42
|
+
t1done.Store(true)
|
|
43
|
+
case "t2":
|
|
44
|
+
return TaskResult{ID: "t2", State: TaskDone, Text: "上游结论"}
|
|
45
|
+
case "t3":
|
|
46
|
+
if _, ok := upstream["t2"]; !ok {
|
|
47
|
+
t.Errorf("t3 的 upstream 应含 t2,实际:%v", upstream)
|
|
48
|
+
}
|
|
49
|
+
if t1done.Load() {
|
|
50
|
+
t.Error("t3 不该等 t1:它只依赖 t2(调度退化回按层了)")
|
|
51
|
+
}
|
|
52
|
+
close(t3ran)
|
|
53
|
+
}
|
|
54
|
+
return TaskResult{ID: tk.ID, State: TaskDone}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type outcome struct {
|
|
58
|
+
results []TaskResult
|
|
59
|
+
err error
|
|
60
|
+
}
|
|
61
|
+
done := make(chan outcome, 1)
|
|
62
|
+
go func() {
|
|
63
|
+
rs, err := (ReadyQueue{Parallel: 2}).Run(context.Background(), plan, work, nil)
|
|
64
|
+
done <- outcome{rs, err}
|
|
65
|
+
}()
|
|
66
|
+
|
|
67
|
+
select {
|
|
68
|
+
case <-t3ran:
|
|
69
|
+
case <-time.After(3 * time.Second):
|
|
70
|
+
release()
|
|
71
|
+
t.Fatal("t3 在 t1 结束前没有开跑:仍然是按层阻塞")
|
|
72
|
+
}
|
|
73
|
+
release()
|
|
74
|
+
|
|
75
|
+
select {
|
|
76
|
+
case o := <-done:
|
|
77
|
+
if o.err != nil {
|
|
78
|
+
t.Fatal(o.err)
|
|
79
|
+
}
|
|
80
|
+
if len(o.results) != 3 {
|
|
81
|
+
t.Fatalf("应当 3 个结局,得到 %d", len(o.results))
|
|
82
|
+
}
|
|
83
|
+
for i, want := range []string{"t1", "t2", "t3"} {
|
|
84
|
+
if o.results[i].ID != want || o.results[i].State != TaskDone {
|
|
85
|
+
t.Fatalf("第 %d 个结局应为 done 的 %s,得到 %+v", i, want, o.results[i])
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
case <-time.After(3 * time.Second):
|
|
89
|
+
t.Fatal("调度没有结束")
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// TestReadyQueueCascadesRequiredFailure 钉住级联语义与**根因传递**:
|
|
94
|
+
// t3 报的是最初的原因「超时」,而不是「上游 t2 未完成(上游 t1 未完成)」这种套娃。
|
|
95
|
+
func TestReadyQueueCascadesRequiredFailure(t *testing.T) {
|
|
96
|
+
plan := schedPlan(
|
|
97
|
+
schedTask("t1"),
|
|
98
|
+
schedTask("t2", "t1"),
|
|
99
|
+
schedTask("t3", "t2"),
|
|
100
|
+
schedTask("t4"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
var workCalls sync.Map
|
|
104
|
+
work := func(_ context.Context, tk Task, _ map[string]TaskResult) TaskResult {
|
|
105
|
+
workCalls.Store(tk.ID, true)
|
|
106
|
+
if tk.ID == "t1" {
|
|
107
|
+
return TaskResult{ID: "t1", State: TaskFailed, Reason: "超时"}
|
|
108
|
+
}
|
|
109
|
+
return TaskResult{ID: tk.ID, State: TaskDone}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var skipped []string
|
|
113
|
+
results, err := (ReadyQueue{Parallel: 4}).Run(context.Background(), plan, work,
|
|
114
|
+
func(tk Task, reason string) { skipped = append(skipped, tk.ID+":"+reason) })
|
|
115
|
+
if err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
if len(results) != 4 || results[0].ID != "t1" || results[3].ID != "t4" {
|
|
119
|
+
t.Fatalf("结局应按计划顺序返回:%+v", results)
|
|
120
|
+
}
|
|
121
|
+
if results[0].State != TaskFailed {
|
|
122
|
+
t.Fatalf("t1 应失败,得到 %s", results[0].State)
|
|
123
|
+
}
|
|
124
|
+
if _, ran := workCalls.Load("t2"); ran {
|
|
125
|
+
t.Fatal("上游必需任务失败后,t2 不该跑")
|
|
126
|
+
}
|
|
127
|
+
if _, ran := workCalls.Load("t3"); ran {
|
|
128
|
+
t.Fatal("被级联跳过的 t3 不该跑")
|
|
129
|
+
}
|
|
130
|
+
if results[1].State != TaskSkipped || !strings.Contains(results[1].Reason, "上游 t1 未完成(超时)") {
|
|
131
|
+
t.Fatalf("t2 的跳过原因不对:%+v", results[1])
|
|
132
|
+
}
|
|
133
|
+
if results[2].State != TaskSkipped || !strings.Contains(results[2].Reason, "上游 t2 未完成(超时)") {
|
|
134
|
+
t.Fatalf("t3 应继承根因「超时」:%+v", results[2])
|
|
135
|
+
}
|
|
136
|
+
if results[3].State != TaskDone {
|
|
137
|
+
t.Fatalf("无关任务 t4 应照常完成,得到 %s", results[3].State)
|
|
138
|
+
}
|
|
139
|
+
if len(skipped) != 2 {
|
|
140
|
+
t.Fatalf("应当跳过 2 个(t2/t3),得到 %v", skipped)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// TestReadyQueueNonRequiredFailureDoesNotBlockDownstream 钉住「尽力而为」语义:
|
|
145
|
+
// required=false 的任务失败不进 blocked 表,下游照常跑。
|
|
146
|
+
func TestReadyQueueNonRequiredFailureDoesNotBlockDownstream(t *testing.T) {
|
|
147
|
+
no := false
|
|
148
|
+
plan := schedPlan(
|
|
149
|
+
Task{ID: "t1", Kind: KindText, Prompt: "p", Required: &no},
|
|
150
|
+
schedTask("t2", "t1"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
work := func(_ context.Context, tk Task, _ map[string]TaskResult) TaskResult {
|
|
154
|
+
if tk.ID == "t1" {
|
|
155
|
+
return TaskResult{ID: "t1", State: TaskFailed, Reason: "没画成"}
|
|
156
|
+
}
|
|
157
|
+
return TaskResult{ID: tk.ID, State: TaskDone}
|
|
158
|
+
}
|
|
159
|
+
skipped := 0
|
|
160
|
+
results, err := (ReadyQueue{Parallel: 2}).Run(context.Background(), plan, work,
|
|
161
|
+
func(Task, string) { skipped++ })
|
|
162
|
+
if err != nil {
|
|
163
|
+
t.Fatal(err)
|
|
164
|
+
}
|
|
165
|
+
if results[0].State != TaskFailed || results[1].State != TaskDone || skipped != 0 {
|
|
166
|
+
t.Fatalf("非必需任务失败不该阻断下游:%+v(skipped=%d)", results, skipped)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// TestReadyQueueCancellationSkipsWithoutStarting 钉住取消语义:
|
|
171
|
+
// 已经取消的 ctx 下不起任何新任务,全部按「已取消」收尾且仍有终态。
|
|
172
|
+
func TestReadyQueueCancellationSkipsWithoutStarting(t *testing.T) {
|
|
173
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
174
|
+
cancel()
|
|
175
|
+
|
|
176
|
+
var started atomic.Int32
|
|
177
|
+
results, err := (ReadyQueue{Parallel: 2}).Run(ctx,
|
|
178
|
+
schedPlan(schedTask("t1"), schedTask("t2"), schedTask("t3", "t1")),
|
|
179
|
+
func(_ context.Context, tk Task, _ map[string]TaskResult) TaskResult {
|
|
180
|
+
started.Add(1)
|
|
181
|
+
return TaskResult{ID: tk.ID, State: TaskDone}
|
|
182
|
+
}, nil)
|
|
183
|
+
if err != nil {
|
|
184
|
+
t.Fatal(err)
|
|
185
|
+
}
|
|
186
|
+
if started.Load() != 0 {
|
|
187
|
+
t.Fatalf("已取消时不该启动任务,实际启动 %d 个", started.Load())
|
|
188
|
+
}
|
|
189
|
+
for _, r := range results {
|
|
190
|
+
if r.State != TaskSkipped {
|
|
191
|
+
t.Fatalf("%s 应跳过,得到 %+v", r.ID, r)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if results[0].Reason != "已取消" || results[1].Reason != "已取消" {
|
|
195
|
+
t.Fatalf("无依赖任务的原因应为「已取消」:%+v", results)
|
|
196
|
+
}
|
|
197
|
+
if !strings.Contains(results[2].Reason, "上游 t1 未完成(已取消)") {
|
|
198
|
+
t.Fatalf("下游应继承「已取消」根因:%+v", results[2])
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// TestReadyQueueRejectsInvalidPlans 钉住「非法计划不调用任何回调」。
|
|
203
|
+
func TestReadyQueueRejectsInvalidPlans(t *testing.T) {
|
|
204
|
+
cases := []struct {
|
|
205
|
+
name string
|
|
206
|
+
plan Plan
|
|
207
|
+
want string
|
|
208
|
+
}{
|
|
209
|
+
{"空计划", Plan{}, "没有任务"},
|
|
210
|
+
{"重复 id", schedPlan(schedTask("t1"), schedTask("t1")), "重复"},
|
|
211
|
+
{"缺依赖", schedPlan(schedTask("t1", "t9")), "不存在的任务"},
|
|
212
|
+
{"成环", schedPlan(schedTask("t1", "t2"), schedTask("t2", "t1")), "成环"},
|
|
213
|
+
{"自依赖", schedPlan(schedTask("t1", "t1")), "成环"},
|
|
214
|
+
}
|
|
215
|
+
for _, c := range cases {
|
|
216
|
+
t.Run(c.name, func(t *testing.T) {
|
|
217
|
+
called := false
|
|
218
|
+
_, err := (ReadyQueue{Parallel: 2}).Run(context.Background(), c.plan,
|
|
219
|
+
func(_ context.Context, tk Task, _ map[string]TaskResult) TaskResult {
|
|
220
|
+
called = true
|
|
221
|
+
return TaskResult{ID: tk.ID, State: TaskDone}
|
|
222
|
+
}, func(Task, string) { called = true })
|
|
223
|
+
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
224
|
+
t.Fatalf("应报含 %q 的错误,得到 %v", c.want, err)
|
|
225
|
+
}
|
|
226
|
+
if called {
|
|
227
|
+
t.Fatal("非法计划不该调用 worker / skip")
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// TestReadyQueueRespectsParallel 钉住并发上限:满了就不再启动,释放一个才补一个。
|
|
234
|
+
func TestReadyQueueRespectsParallel(t *testing.T) {
|
|
235
|
+
plan := schedPlan(schedTask("t1"), schedTask("t2"), schedTask("t3"), schedTask("t4"))
|
|
236
|
+
|
|
237
|
+
releaseCh := make(chan struct{})
|
|
238
|
+
var releaseOnce sync.Once
|
|
239
|
+
release := func() { releaseOnce.Do(func() { close(releaseCh) }) }
|
|
240
|
+
defer release()
|
|
241
|
+
|
|
242
|
+
started := make(chan string, 8)
|
|
243
|
+
var mu sync.Mutex
|
|
244
|
+
running, peak := 0, 0
|
|
245
|
+
|
|
246
|
+
work := func(_ context.Context, tk Task, _ map[string]TaskResult) TaskResult {
|
|
247
|
+
mu.Lock()
|
|
248
|
+
running++
|
|
249
|
+
if running > peak {
|
|
250
|
+
peak = running
|
|
251
|
+
}
|
|
252
|
+
mu.Unlock()
|
|
253
|
+
|
|
254
|
+
started <- tk.ID
|
|
255
|
+
<-releaseCh
|
|
256
|
+
|
|
257
|
+
mu.Lock()
|
|
258
|
+
running--
|
|
259
|
+
mu.Unlock()
|
|
260
|
+
return TaskResult{ID: tk.ID, State: TaskDone}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
done := make(chan error, 1)
|
|
264
|
+
go func() {
|
|
265
|
+
_, err := (ReadyQueue{Parallel: 2}).Run(context.Background(), plan, work, nil)
|
|
266
|
+
done <- err
|
|
267
|
+
}()
|
|
268
|
+
|
|
269
|
+
<-started
|
|
270
|
+
<-started
|
|
271
|
+
select {
|
|
272
|
+
case id := <-started:
|
|
273
|
+
t.Fatalf("并发上限为 2,却又启动了 %s", id)
|
|
274
|
+
case <-time.After(150 * time.Millisecond):
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
release()
|
|
278
|
+
if err := <-done; err != nil {
|
|
279
|
+
t.Fatal(err)
|
|
280
|
+
}
|
|
281
|
+
mu.Lock()
|
|
282
|
+
defer mu.Unlock()
|
|
283
|
+
if peak != 2 {
|
|
284
|
+
t.Fatalf("峰值并发应为 2,得到 %d", peak)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
// subsession.go - 子任务的「会话化」出口。
|
|
4
|
+
//
|
|
5
|
+
// 团队包不依赖 internal/session 与日志实现(那是 CLI 的职责),只通过 SubSession
|
|
6
|
+
// 把三件事委托出去:登记(可出现在会话列表、可单独停止)、过程事件写自己的日志、
|
|
7
|
+
// 收尾状态。调用方不提供(OpenSubSession 为空)时退回旧行为:只在主会话里留任务行。
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"context"
|
|
11
|
+
|
|
12
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// SubSession 一个子任务在调用方侧对应的会话。
|
|
16
|
+
type SubSession interface {
|
|
17
|
+
// RunID 这条子会话的登记 id(空 = 没登记,例如调用方没开 --save-registry)。
|
|
18
|
+
RunID() string
|
|
19
|
+
// SetSessionID 引擎一给出会话 id 就通知(通常早于事件流里出现)。
|
|
20
|
+
SetSessionID(id string)
|
|
21
|
+
// Push 子任务的一条过程事件:写它自己的会话日志,不进主会话。
|
|
22
|
+
Push(ev agent.StreamEvent)
|
|
23
|
+
// Finish 收尾。state 取 TaskDone / TaskFailed / TaskSkipped。
|
|
24
|
+
Finish(sessionID, state, reason string)
|
|
25
|
+
// Context 在子任务 ctx 上挂「子引擎 pid 登记」的钩子,返回新 ctx。
|
|
26
|
+
Context(parent context.Context) context.Context
|
|
27
|
+
}
|